Go2 Advent Calendar 2018 4日目の記事です。
こんにちわ
最近GoでCUI・CLIツールを作るのにハマっています。
CUIツールを作るときにい使用しているライブラリでgocuiというのを使っています。
今日はgocuiのコンポーネントライブラリっぽいやつを作ったので、その話をすこしします。
ソースはこちらになります。
本記事を読む前に、gocuiの知識はあったほうが良いので、
こちらの記事を軽く読んでおく事をめちゃくちゃオススメします。
どういうやつ?
ターミナル上でhtmlのformっぽい入力インターフェイスを簡単に作ることができます。
ボタンやチェックボックスなどを用意してあります。
作った背景
以前gocuiを使用してDockerのCUIクライアントツールdocuiを作りましたが、
コンテナ作成などで必要な情報を入力するインターフェイスを自前で用意する必要がありました。
それがかなりめんどくさかったのと、
他のCUIツールを作るときに使用したいかもしれないし、調べた限りgocuiのcomponentライブラリがない、
というのもあってコンポーネントとして切り出したほうが良さそうというのがきっかけでした。
ちなみに、docuiの移植前後はこんな感じです。
左が旧バージョン、右が適用後のバージョンになります。
今更ながら、旧バージョンのUIずれているし味気ないし酷いな…
使い方
_demosにあるselectのサンプルをもとに説明していきます。
func main() {
gui, err := gocui.NewGui(gocui.Output256)
if err != nil {
panic(err)
}
defer gui.Close()
if err := gui.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
panic(err)
}
component.NewSelect(gui, "Programming Language:", 0, 0, 21, 10).
AddOptions("Go", "Java", "PHP", "Python", "Ruby", "C", "C++", "C#").
Draw()
if err := gui.MainLoop(); err != nil && err != gocui.ErrQuit {
panic(err)
}
}
-
gocui.NewGui(gocui.Output256)
でgocuiのインスタンスを生成しておく。 -
component.NewSelect
でselect componentのインスタンスを生成する。 -
AddOptions
でselect一覧で選択したいオプションを追加していく。 -
Draw
でgocuiインスタンスに諸々の設定を追加していく。 -
gui.MainLoop()
を呼び出して、gocuiインスタンスに追加された設定をもとに画面描写やキーバインド処理などを実行する。
基本の流れは上記の様に、gocuiインスタンスを作成して、
それをcomponentに渡した後に、methodで各設定をしていき、Draw
で渡されたgocuiインスタンスに対して設定を追加していくという流れになっています。
gocui自体はgocuiインスタンスにviewの設定を追加した後で、MainLoopで一気に処理する動きになっていて、
今回作成したcomponentライブラリはviewの細かい設定をより簡単にできるようにラップしたものになります。
他にもサンプルコードを_demosにおいてあるので、使ってみたい方は覗いてみてください。
内部処理
簡単な使い方を説明したところで、
上記のSelect
componentの内部でどんな処理をしているかを見ていきます。
Select
の構造体は以下の様になっています。
type Select struct {
*InputField
options []string // 選択するオプション一覧を保持する
currentOpt int // 現在選択しているオプションの配列インデックス
isExpanded bool // オプション一覧が開いているかどうかの判定フラグ
ctype ComponentType // componentのタイプ Formでcomponentの判定に使用する
listColor *Attributes // オプション一覧の色の定義
listHandlers Handlers // オプション一覧を開いたときの操作の定義
}
Select
自体はInputField
componentを埋め込んでいて、それを拡張したcomponentになります。
以下がselect componentをnewするときの処理になります。
// NewSelect new select
func NewSelect(gui *gocui.Gui, label string, x, y, labelWidth, fieldWidth int) *Select {
s := &Select{
InputField: NewInputField(gui, label, x, y, labelWidth, fieldWidth), // InputFieldのインスタンスを生成
listHandlers: make(Handlers),
ctype: TypeSelect,
}
// Enterでオプション一覧を開くように、InputFieldのAddHandlerを使用して定義
s.AddHandler(gocui.KeyEnter, s.expandOpt)
// オプション一覧の色を定義
s.AddAttribute(gocui.ColorBlack, gocui.ColorWhite, gocui.ColorBlack, gocui.ColorGreen).
// AddListHandlerを利用してオプション一覧を開いたとき、jk↑↓で移動、Enterで選択できるように定義
AddListHandler('j', s.nextOpt).
AddListHandler('k', s.preOpt).
AddListHandler(gocui.KeyArrowDown, s.nextOpt).
AddListHandler(gocui.KeyArrowUp, s.preOpt).
AddListHandler(gocui.KeyEnter, s.selectOpt).
// `InputField`を埋め込んでいるので、入力できないようにする必要がある
SetEditable(false)
return s
}
Select
で苦労したのはオプション一覧をどのように表示・選択できるようにするかという点です。
gocuiの仕様上、viewを作成してviewの領域内で文字を描写する必要があります。
つまり、オプション一覧を表示する時はオプションの数だけviewを定義する必要があります。
また、どのオプションを選択しているかをわかるように、フォーカス処理や選択後の閉じる処理も必要になります。
オプション一覧を表示する処理はexpandOpt
で行っているので、その処理を見ていきます。
func (s *Select) expandOpt(g *gocui.Gui, vi *gocui.View) error {
if s.hasOpts() {
s.isExpanded = true
g.Cursor = false
x := s.field.X
w := s.field.W
y := s.field.Y
h := y + 2
for _, opt := range s.options {
// オプション一覧は下方向に展開していくので、yとh座標をインクリメントする
y++
h++
// オプションごとviewを定義していく
if v, err := g.SetView(opt, x, y, w, h); err != nil {
if err != gocui.ErrUnknownView {
panic(err)
}
v.Frame = false
v.SelFgColor = s.listColor.textColor
v.SelBgColor = s.listColor.textBgColor
v.FgColor = s.listColor.hilightColor
v.BgColor = s.listColor.hilightBgColor
// 設定したキーバインドをオプションごとに追加する
for key, handler := range s.listHandlers {
if err := g.SetKeybinding(v.Name(), key, gocui.ModNone, handler); err != nil {
panic(err)
}
}
fmt.Fprint(v, opt)
}
}
// 一覧を開いたときに選択したのオプションにフォーカスを当てる
v, _ := g.SetCurrentView(s.options[s.currentOpt])
v.Highlight = true
}
return nil
}
func (s *Select) selectOpt(g *gocui.Gui, v *gocui.View) error {
// オプション一覧が開いていれば閉じる、開いていなければ展開する
if !s.isExpanded {
s.expandOpt(g, v)
} else {
s.closeOpt(g, v)
}
return nil
}
func (s *Select) nextOpt(g *gocui.Gui, v *gocui.View) error {
maxOpt := len(s.options)
if maxOpt == 0 {
return nil
}
// 前のオプションのフォーカスを外す
v.Highlight = false
next := s.currentOpt + 1
if next >= maxOpt {
next = s.currentOpt
}
// 次のオプションにフォーカスを当てる
s.currentOpt = next
v, _ = g.SetCurrentView(s.options[next])
v.Highlight = true
return nil
}
func (s *Select) closeOpt(g *gocui.Gui, v *gocui.View) error {
s.isExpanded = false
g.Cursor = true
// オプションリストのviewとキーバインドを削除する
for _, opt := range s.options {
g.DeleteView(opt)
g.DeleteKeybindings(opt)
}
v, _ = g.SetCurrentView(s.GetLabel())
v.Clear()
// 選択したオプションを反映する
fmt.Fprint(v, s.GetSelected())
return nil
}
Select
でEnterを押下すると上記の処理が走り、オプションごとのview座標をインクリメントしながら描写します。
やり方自体はシンプルですが、コード量と処理量が多いのが難点ですね。
ざっくりまとめると、
- オプションリストをEnterで動的に生成するにはオプションごとviewを作成し、移動と選択のキーバインドを追加する処理が必要
- 移動は前のviewのフォーカスを外し、次のviewにフォーカスを当てる処理が必要
- オプション一覧でEnterを押下すると選択したオプションを反映して、一覧を閉じる処理が必要
こういった事を考え実装する必要があります。
けっこう大変です。
いっそ違うライブラリを使ったほうが楽ではないか?と思います。
作る上で苦労したこと
主に苦労したのは
- componentのインターフェイスをどうするか
- formに各component(ボタンなど)を組み込むときの共通化の部分をどうするか
の2つです。
1.はどういうメソッドがあれば良いのか、どこまで設定値を使用者側で設定できるようにするか悩みました。
サクッと使いたい人もいれば、細かく設定(色など)したいもいるだろうけど、
ひとまずここはできるようにしておこう、あとは需要に応じてissueやプルリクで対応していけばよいかなというところで線引しています。
ではどのように線引して行ったかというと、既存のライブラリを参考しただけです。
せっかく世の中に素晴らしいライブラリがあるのに、そのUIを参考にしないのはもったいないし、
今の自分の経験値からでは出てこないようなアイディアが詰まっていることもあります。
今回componentライブラリを作成するにあたって、参考にしたのはVueのUIライブラリElementUIとtviewです。
特にtviewは標準でformなどが使えるので、それを参考にgocui版を作ったようなもんです。
2.はformは各componentを内包していて、それらをDraw()
でまとめて処理しています。
まとめて処理するにはGoのインターフェイスを使います。
for _, cp := range f.components {
p := cp.GetPosition()
if p.W > f.W {
f.W = p.W
}
if p.H > f.H {
f.H = p.H
}
cp.AddHandlerOnly(gocui.KeyTab, f.NextItem)
cp.AddHandlerOnly(gocui.KeyArrowDown, f.NextItem)
cp.AddHandlerOnly(gocui.KeyArrowUp, f.PreItem)
cp.Draw()
}
ここで苦労したのは、インターフェイスに定義をどうするかということです。
どんなmethodがformで必要なのかをcomponentを作りながら定義していきました。
ここが一番難しかったです。
設計できるひと、尊敬です…
作って学んだこと
先日、初めて外国の方からプルリクをいただきました。
感動して涙で目の前が見えませんでした。
自分では大したことがないモノを作ったと思っても、
世界中で誰かが見てくれて使ってくれているかもしれないから、
今まで通り、恐れずガンガン公開していこうと改めて思いました。
それも含めて、作っていて学んだことは
- 仕様で悩むときは既存のライブラリを参考にしたほうが良い、自分にはないアイディアがそこに詰まっているから。
- 共通処理はinterfaceを定義して使うと良い、よりソースがスマートになるから。
- 質を気にせずに作ったものをどんどん公開したほうが良い、モチベの維持と勉強になるから。
です。
余談(gocuiの今後とそのかわりになるもの)
gocuiの今後ですが、作者自身があまり活動していないようで、
プルリクがマージされる気配もなさそうなので、新機能が追加されることがあまり望めないかなと思っています。
gocuiの代わりになるものをいくつかピックアップした中で一番良さそうなのがtviewでした。
gocuiと比べてtviewはまだ生まれて1年くらいのようで、
開発がそれなりに活発でformやtableなどのcomponentは標準搭載してあるのでリッチなCUIライブラリです。
tviewはhtmlの思想をいくつか取り込んでいるんだなというのがすこし使ってみた感想です。
なので、htmlをある程度しっている方であればそれほど使い方で悩むことはないんじゃないかなと思います。
興味ある方はtviewを覗いてみてください。
demosでサンプルを見れるので学習にも役立つと思います。
最後に
「作ったものは質を気にせずにどんどん公開していこう、不幸になる人なんていないから」
というのが実はこの記事で一番言いたいことだったりします。
大したやつじゃないと自分が思っても、どこかで誰かの役に立ったりするのが個人的にすごく嬉しいです。
この記事を読んで、自分も公開してみようかなって方いましたらぜひ公開していきましょ。