24
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

GoのCUIライブラリgocui入門

Posted at

こんにちわ

以前Dockerを直感的に操作できるCUIツールを作成した際に使用した、
CUIライブラリgocuiについての知見を共有できれば良いなと思い、記事を書いていこうと思います。

CUIツールを作る際、ライブラリ検討に役立てればと思います。

対象読者

  • CUIツールを作りたい方
  • Goの基礎を理解している方

アジェンダ

gocuiの特徴

##メリット

  • 用意されている関数と構造体は少なくシンプルなため、学習コストが低い
  • 使用しているプロジェクトが多数あるため、参考情報(ソース)が充実している

##デメリット

  • マルチバイトが未対応の様なので、日本語表示などの文字が欠けたりする
  • 入力インターフェイスのサポートが不十分なため、複数の入力項目を使うようなUIを作るのに向いていない

gocuiの基本

viewの作成

gocuiではviewは画面を指します。
こちらについては後述しますがview毎にキーバインドを設定することができます。
viewを作成する方法が3つあります。

  1. Layout(g *gocui.Gui) error関数を実装した構造体をSetManager(manager ...Manager)に渡す。
  2. func (g *gocui.Gui) error関数をSetManagerFunc(manager func (*gocui.Gui) error)に渡す。
  3. 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に関数本体が追加されてMainLoopLayoutが実行されます。
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
}

上記の流れをまとめると…

  1. termboxイベントハンドリングのスレッドを起動
  2. Escキーやマウスの設定
  3. g.flush()で画面を描写
    1. Gui.managers分のLayout()を実行する
    2. Gui.views分のview定義をもとに画面を描写する
  4. 無限ループで以下の処理を繰り返す
    1. selectでキーイベント or ユーザイベントが発生するまで処理を止める
    2. 1.の処理が完了しても、キューに溜まったキーイベント or ユーザイベントがあった場合はすべて処理
    3. 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製の素晴らしいツールがあるので、ここを見て頂ければと思います。

24
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?