Go
golang
CUI
gocui
Go2Day 4

gocuiのコンポーネントライブラリを作った話

Go2 Advent Calendar 2018 4日目の記事です。

こんにちわ

最近GoでCUI・CLIツールを作るのにハマっています。
CUIツールを作るときにい使用しているライブラリでgocuiというのを使っています。

今日はgocuiのコンポーネントライブラリっぽいやつを作ったので、その話をすこしします。
ソースはこちらになります。

本記事を読む前に、gocuiの知識はあったほうが良いので、
こちらの記事を軽く読んでおく事をめちゃくちゃオススメします。

どういうやつ?

ターミナル上でhtmlのformっぽい入力インターフェイスを簡単に作ることができます。
ボタンやチェックボックスなどを用意してあります。

demo.gif

作った背景

以前gocuiを使用してDockerのCUIクライアントツールdocuiを作りましたが、
コンテナ作成などで必要な情報を入力するインターフェイスを自前で用意する必要がありました。

それがかなりめんどくさかったのと、
他のCUIツールを作るときに使用したいかもしれないし、調べた限りgocuiのcomponentライブラリがない、
というのもあってコンポーネントとして切り出したほうが良さそうというのがきっかけでした。

ちなみに、docuiの移植前後はこんな感じです。
左が旧バージョン、右が適用後のバージョンになります。
今更ながら、旧バージョンのUIずれているし味気ないし酷いな…

image.png

使い方

_demosにあるselectのサンプルをもとに説明していきます。

select.gif

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においてあるので、使ってみたい方は覗いてみてください。

内部処理

簡単な使い方を説明したところで、
上記のSelectcomponentの内部でどんな処理をしているかを見ていきます。

Selectの構造体は以下の様になっています。

type Select struct {
    *InputField
    options      []string // 選択するオプション一覧を保持する
    currentOpt   int // 現在選択しているオプションの配列インデックス
    isExpanded   bool // オプション一覧が開いているかどうかの判定フラグ
    ctype        ComponentType // componentのタイプ Formでcomponentの判定に使用する
    listColor    *Attributes // オプション一覧の色の定義
    listHandlers Handlers // オプション一覧を開いたときの操作の定義
}

Select自体はInputFieldcomponentを埋め込んでいて、それを拡張した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を押下すると選択したオプションを反映して、一覧を閉じる処理が必要

こういった事を考え実装する必要があります。
けっこう大変です。
いっそ違うライブラリを使ったほうが楽ではないか?と思います。

作る上で苦労したこと

主に苦労したのは

  1. componentのインターフェイスをどうするか
  2. formに各component(ボタンなど)を組み込むときの共通化の部分をどうするか

の2つです。

1.はどういうメソッドがあれば良いのか、どこまで設定値を使用者側で設定できるようにするか悩みました。
サクッと使いたい人もいれば、細かく設定(色など)したいもいるだろうけど、
ひとまずここはできるようにしておこう、あとは需要に応じてissueやプルリクで対応していけばよいかなというところで線引しています。

ではどのように線引して行ったかというと、既存のライブラリを参考しただけです。
せっかく世の中に素晴らしいライブラリがあるのに、そのUIを参考にしないのはもったいないし、
今の自分の経験値からでは出てこないようなアイディアが詰まっていることもあります。

今回componentライブラリを作成するにあたって、参考にしたのはVueのUIライブラリElementUItviewです。
特に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でサンプルを見れるので学習にも役立つと思います。

最後に

「作ったものは質を気にせずにどんどん公開していこう、不幸になる人なんていないから」
というのが実はこの記事で一番言いたいことだったりします。

大したやつじゃないと自分が思っても、どこかで誰かの役に立ったりするのが個人的にすごく嬉しいです。
この記事を読んで、自分も公開してみようかなって方いましたらぜひ公開していきましょ。