こんにちわ
以前Dockerを直感的に操作できるCUIツールを作成した際に使用した、
CUIライブラリgocuiについての知見を共有できれば良いなと思い、記事を書いていこうと思います。
CUIツールを作る際、ライブラリ検討に役立てればと思います。
対象読者
- CUIツールを作りたい方
- Goの基礎を理解している方
アジェンダ
gocuiの特徴
メリット
- 用意されている関数と構造体は少なくシンプルなため、学習コストが低い
- 使用しているプロジェクトが多数あるため、参考情報(ソース)が充実している
デメリット
- マルチバイトが未対応の様なので、日本語表示などの文字が欠けたりする
- 入力インターフェイスのサポートが不十分なため、複数の入力項目を使うようなUIを作るのに向いていない
gocuiの基本
viewの作成
gocuiではviewは画面を指します。
こちらについては後述しますがview毎にキーバインドを設定することができます。
viewを作成する方法が3つあります。
-
Layout(g *gocui.Gui) error
関数を実装した構造体をSetManager(manager ...Manager)
に渡す。 -
func (g *gocui.Gui) error
関数をSetManagerFunc(manager func (*gocui.Gui) error)
に渡す。 -
func (g *Gui) SetView(name string, x0, y0, x1, y1 int) (*View, error)
を使用する。
それぞれのサンプルは以下の様になります。
-
func (g *gocui.Gui) error
関数を追加するパターン
func main() {
g, err := gocui.NewGui(gocui.OutputNormal)
if err != nil {
// handle error
}
defer g.Close()
g.SetManagerFunc(layout)
if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
log.Panicln(err)
}
if err := g.MainLoop(); err != nil && err != gocui.ErrQuit {
// handle error
}
}
func layout(g *gocui.Gui) error {
maxX, maxY := g.Size()
if v, err := g.SetView("hello", maxX/2-7, maxY/2, maxX/2+7, maxY/2+2); err != nil {
if err != gocui.ErrUnknownView {
return err
}
fmt.Fprintln(v, "Hello world!")
}
return nil
}
func quit(g *gocui.Gui, v *gocui.View) error {
return gocui.ErrQuit
}
-
Layout(g *gocui.Gui) error
関数を実装するパターン
type widget struct {}
func main() {
g, err := gocui.NewGui(gocui.OutputNormal)
if err != nil {
// handle error
}
defer g.Close()
w := new(widget)
g.SetManager(w)
if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
log.Panicln(err)
}
if err := g.MainLoop(); err != nil && err != gocui.ErrQuit {
// handle error
}
}
func quit(g *gocui.Gui, v *gocui.View) error {
return gocui.ErrQuit
}
func (w *widget) Layout(g *gocui.Gui) error {
maxX, maxY := g.Size()
if v, err := g.SetView("hello", maxX/2-7, maxY/2, maxX/2+7, maxY/2+2); err != nil {
if err != gocui.ErrUnknownView {
return err
}
fmt.Fprintln(v, "Hello world!")
}
return nil
}
-
SetView(name string, x0, y0, x1, y1 int) (*View, error)
を使用するパターン
func main() {
g, err := gocui.NewGui(gocui.OutputNormal)
if err != nil {
// handle error
}
defer g.Close()
if v, err := g.SetView("hello", 2, 2, 22, 7); err != nil {
if err != gocui.ErrUnknownView {
// handle error
}
fmt.Fprintln(v, "Hello world!")
}
if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
log.Panicln(err)
}
if err := g.MainLoop(); err != nil && err != gocui.ErrQuit {
// handle error
}
}
func quit(g *gocui.Gui, v *gocui.View) error {
return gocui.ErrQuit
}
gocuiが実行されるまでの流れ
gocuiはtermbox-goベースで作られています。
termbox-goはコンソール上での描写やイベントハンドリングなどを行うことができ、
gocuiではそれをラッピングしたライブラリの様です。
gocuiを使用する上で、実行されるまで大まかの流れを把握しておく必要があるので、
上記のサンプルをもとに、gocuiが実行されるまでの流れについて説明していきます。
以前こちらの記事でも説明したのですが、
この記事では少し補足していきます。
gocuiはGui
構造体がを母体としていて、gocui.NewGui
でGuiのインスタンスを作成してから、
それに対してviewの定義などを追加していく形になっています。
Gui
構造体の定義は以下の様になっています。
type Gui struct {
tbEvents chan termbox.Event // termboxイベントハンドリング
userEvents chan userEvent // gocuiのイベントハンドリング
views []*View // Viewの定義を保持
currentView *View // フォーカス中のviewを保持
managers []Manager // Manager定義を保持
keybindings []*keybinding // キーバインド定義を保持
maxX, maxY int // コンソール画面サイズを保持
outputMode OutputMode // カラーの定義、8色か256色
// BgColor and FgColor allow to configure the background and foreground
// colors of the GUI.
BgColor, FgColor Attribute
// SelBgColor and SelFgColor allow to configure the background and
// foreground colors of the frame of the current view.
SelBgColor, SelFgColor Attribute
// If Highlight is true, Sel{Bg,Fg}Colors will be used to draw the
// frame of the current view.
Highlight bool
// If Cursor is true then the cursor is enabled.
Cursor bool
// If Mouse is true then mouse events will be enabled.
Mouse bool
// If InputEsc is true, when ESC sequence is in the buffer and it doesn't
// match any known sequence, ESC means KeyEsc.
InputEsc bool
// If ASCII is true then use ASCII instead of unicode to draw the
// interface. Using ASCII is more portable.
ASCII bool
}
viewの作成方法によって、描写されるまでの流れが若干異なります。
1と2の場合、Gui.managers
に関数本体が追加されてMainLoop
でLayout
が実行されます。
3の場合はGui.views
からview定義をもとに画面を描写します。
それぞれの関数内の処理を見ていきます。
SetView
func (g *Gui) SetView(name string, x0, y0, x1, y1 int) (*View, error) {
if x0 >= x1 || y0 >= y1 {
return nil, errors.New("invalid dimensions")
}
if name == "" {
return nil, errors.New("invalid name")
}
// すでに同名のview定義があった場合は画面サイズを更新
if v, err := g.View(name); err == nil {
v.x0 = x0
v.y0 = y0
v.x1 = x1
v.y1 = y1
v.tainted = true
return v, nil
}
// 新たなviewを定義
v := newView(name, x0, y0, x1, y1, g.outputMode)
v.BgColor, v.FgColor = g.BgColor, g.FgColor
v.SelBgColor, v.SelFgColor = g.SelBgColor, g.SelFgColor
// view定義をGui.views配列に追加
g.views = append(g.views, v)
return v, ErrUnknownView
}
SetManager
func (g *Gui) SetManager(managers ...Manager) {
// Gui.managers配列にLayoutの実装を追加
g.managers = managers
g.currentView = nil
g.views = nil
g.keybindings = nil
// termboxイベントを発生
go func() { g.tbEvents <- termbox.Event{Type: termbox.EventResize} }()
}
SetManager
の場合
- view定義とキーバインド定義がクリアされるので実装時はview定義の順番を気をつける
- 非同期でtermboxイベントを発行しているので、同期的にGui.viewsに関する何らかの処理を定義直後に行うと想定通りに動きにならないことがある
の2点を気をつけましょう。
続いて、gocuiのメイン処理を見ていきます。
func (g *Gui) MainLoop() error {
// 1. termboxイベントハンドリングスレッド起動
go func() {
for {
g.tbEvents <- termbox.PollEvent()
}
}()
// 2. Escキーやマウスの設定
inputMode := termbox.InputAlt
if g.InputEsc {
inputMode = termbox.InputEsc
}
if g.Mouse {
inputMode |= termbox.InputMouse
}
termbox.SetInputMode(inputMode)
// 3. 画面描写
if err := g.flush(); err != nil {
return err
}
// 4. 無限ループでイベントハンドリング
for {
select {
// termboxイベントハンドリング
case ev := <-g.tbEvents:
if err := g.handleEvent(&ev); err != nil {
return err
}
// gocuiイベントハンドリング
// gocui.Update()を使用することでこのイベントを発生させる事できる
case ev := <-g.userEvents:
if err := ev.f(g); err != nil {
return err
}
}
// キューに溜まったイベントを処理
if err := g.consumeevents(); err != nil {
return err
}
// 画面描写
if err := g.flush(); err != nil {
return err
}
}
}
func (g *Gui) flush() error {
termbox.Clear(termbox.Attribute(g.FgColor), termbox.Attribute(g.BgColor))
maxX, maxY := termbox.Size()
// if GUI's size has changed, we need to redraw all views
if maxX != g.maxX || maxY != g.maxY {
for _, v := range g.views {
v.tainted = true
}
}
g.maxX, g.maxY = maxX, maxY
// 3.1. Layoutを処理
for _, m := range g.managers {
if err := m.Layout(g); err != nil {
return err
}
}
// 3.2. view定義をもとに画面描写処理
for _, v := range g.views {
if v.Frame {
var fgColor, bgColor Attribute
if g.Highlight && v == g.currentView {
fgColor = g.SelFgColor
bgColor = g.SelBgColor
} else {
fgColor = g.FgColor
bgColor = g.BgColor
}
if err := g.drawFrameEdges(v, fgColor, bgColor); err != nil {
return err
}
if err := g.drawFrameCorners(v, fgColor, bgColor); err != nil {
return err
}
if v.Title != "" {
if err := g.drawTitle(v, fgColor, bgColor); err != nil {
return err
}
}
}
if err := g.draw(v); err != nil {
return err
}
}
termbox.Flush()
return nil
}
上記の流れをまとめると…
- termboxイベントハンドリングのスレッドを起動
- Escキーやマウスの設定
-
g.flush()
で画面を描写-
Gui.managers
分のLayout()
を実行する -
Gui.views
分のview定義をもとに画面を描写する
-
- 無限ループで以下の処理を繰り返す
- selectでキーイベント or ユーザイベントが発生するまで処理を止める
- 1.の処理が完了しても、キューに溜まったキーイベント or ユーザイベントがあった場合はすべて処理
- 2.の処理完了後3.の処理を実行する
になります。
キーバインドの設定
gocuiでこの画面ではこのキーの組み合わせでメニューを出したいなど、
画面での操作を定義することができます。
また特定の画面ではなく、全体で使用できるキーバインドも定義できます。
定義時に使用するgocuiの関数はSetKeybinding
になります。
引数に関しては大事なところだけ説明していきます。
// viewname 画面名を指定、空文字の場合は全体で使用可能なキーバインドになる
// key gocuiで定義されているKeyを使用するか、rune型の単一文字を使用するかを指定
// handler キー押下時に実行される処理関数を指定
func (g *Gui) SetKeybinding(viewname string, key interface{}, mod Modifier, handler func(*Gui, *View) error) error {
var kb *keybinding
k, ch, err := getKey(key)
if err != nil {
return err
}
kb = newKeybinding(viewname, k, ch, mod, handler)
g.keybindings = append(g.keybindings, kb)
return nil
}
前項目のサンプルにも書いてありますが、以下のように定義するだけです。
// アプリ全体で使用可能なキーバインドの場合
if err := g.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
log.Panicln(err)
}
// 特定画面でのキーバインドを定義
if err := g.SetKeybinding("viewname", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
log.Panicln(err)
}
// rune型の単一文字をキーバインドとして定義する場合
if err := g.SetKeybinding("viewname", 'q', gocui.ModNone, quit); err != nil {
log.Panicln(err)
}
// キー押下時に実行される処理関数
func quit(g *gocui.Gui, v *gocui.View) error {
return gocui.ErrQuit
}
一つ注意点として、キーバインド定義は配列に追加されるため、
全く同じキーバインド定義が複数あった場合、その分の処理が実行されてしまいます。
同じ処理が複数回実行され想定外の動作になってしまう可能性があるので、実装時は注意する必要があります。
※コーディングミスレベルの話なので、普通にコーディングしていれば問題ないはず。
Viewの切り替え
view間の切り替えはとても簡単で、SetCurrentView()
関数にview名を渡すだけです。
公式サンプル参照。
func toggleButton(g *gocui.Gui, v *gocui.View) error {
nextview := "butdown"
if v != nil && v.Name() == "butdown" {
nextview = "butup"
}
// フォーカスを変更
_, err := g.SetCurrentView(nextview)
return err
}
gocuiではviewを重ねる事ができ、後に定義したviewが表に表示されます。
上記のやり方ではviewのフォーカスを切り替えだけなので、重ねられたviewは表には表示されない状態になります。
viewを重ねた状態で切り替えと表示を行うには以下のようにする必要があります。
func SetCurrentPanel(g *gocui.Gui, name string) (*gocui.View, error) {
// フォーカスを変更
v, err := g.SetCurrentView(name)
if err != nil {
return nil, err
}
// 対象viewを最上面に表示
return g.SetViewOnTop(name)
}
Viewの更新
viewの更新を行うにはGui.Update(f func(*Gui) error)
を使用します。
g.Update(func(g *gocui.Gui) error {
v, err := g.View("viewname")
if err != nil {
// handle error
}
v.Clear()
fmt.Fprintln(v, "Writing from different goroutines")
return nil
})
以前作成したDockerを直感的に操作できるCUIツールでは5秒間隔で各viewのステータスを更新する仕様になっていて、その時にUpdate
関数を使用しています。
gocuiが実行されるまでの流れで説明したMainLoop
の処理を見ればわかると思いますが、
画面描写の更新タイミングはイベント発生時になります。
イベントの種類は以下の2つです。
- termboxイベント
- gocuiイベント
キーバインドでtermboxイベント発火します。
Update
でgocuiイベント発火します。
func (g *Gui) Update(f func(*Gui) error) {
go func() { g.userEvents <- userEvent{f: f} }()
}
編集モード
gocuiではviewに編集モードというのがあります。
編集モードがONの場合は、viewがエディタに変わり入力可能になります。
編集モードをONにするのはView.Editable = true
とするだけです。
if v, err := g.SetView("v1", 0, 0, maxX/2-1, maxY/2-1); err != nil {
if err != gocui.ErrUnknownView {
return err
}
v.Title = "v1 (editable)"
v.Editable = true // trueにすることでエディタモードが有効
v.Wrap = true
if _, err = setCurrentViewOnTop(g, "v1"); err != nil {
return err
}
}
// デフォルトではこちらの定義済みのエディタを使用
func simpleEditor(v *View, key Key, ch rune, mod Modifier) {
switch {
case ch != 0 && mod == 0:
v.EditWrite(ch)
case key == KeySpace:
v.EditWrite(' ')
case key == KeyBackspace || key == KeyBackspace2:
v.EditDelete(true)
case key == KeyDelete:
v.EditDelete(false)
case key == KeyInsert:
v.Overwrite = !v.Overwrite
case key == KeyEnter:
v.EditNewLine()
case key == KeyArrowDown:
v.MoveCursor(0, 1, false)
case key == KeyArrowUp:
v.MoveCursor(0, -1, false)
case key == KeyArrowLeft:
v.MoveCursor(-1, 0, false)
case key == KeyArrowRight:
v.MoveCursor(1, 0, false)
}
}
まとめ
gocuiについて理解している範囲の内容をざっくり書きましたが、
他にも文字の色を変えたりすることもできますが、
これ以上細かく説明するよりもGoDocを見て頂いたほうが早いかと思います。
冒頭にお伝えした通り、gocui自体はシンプルなのでGoDocを読んでも理解にそれほど時間はかからないかと思います。
簡単なCUIツールを作ると時はgocuiは便利なので、興味ある方はぜひ一度使ってみください。
ちなみに、gocuiを使用しているプロジェクトの一つでgitのCUIツールlazygitというのがあり、かなりクオリティが高いので実装で躓いた時にソースを参考にしてみると良いかと思います。
他にもgocui製の素晴らしいツールがあるので、ここを見て頂ければと思います。