Edited at

Goで世界一シンプルなCUIライブラリを作りました

こんにちは。今日もいいターミナル日和ですね。

今回はGo製の新たなCUIライブラリ goban を作ったので、その紹介と簡単な使い方を説明します。


(追記)罫線と文字幅について

日本語環境では罫線が全角幅で表示されてしまい、綺麗に出力されない問題があります。その際は、以下の一行をMainの前に挿入してください。

    runewidth.DefaultCondition = &runewidth.Condition{EastAsianWidth: false}

ただし、これは ambiguous width に分類される文字すべてを半角幅で表示してしまうため、記号などを用いたときに別の問題が発生する場合があります。注意して使用してください。

今後、CJK環境では罫線を諦めてASCIIでボックスを表示する実装を入れる予定です。


CUIライブラリについて

ターミナルで動くグラフィカルなアプリを作るためのライブラリです。著名なものに gocui, tviewなどがあります。

gobanはこれらと比較してよりシンプルに、よりGoらしく書けることを意識しています。


gobanの特徴


  • シンプルながら強力なAPI

  • ボックス描画

  • グリッドレイアウト

  • チャネル経由でイベントを処理する。(not イベントハンドラ)

  • 分離されたビューとコントローラー

  • 容易なビューの組み合わせ


Hello, World!

READMEにも記載していますが、こちらがHello Worldです。

package main

import (
"context"

"github.com/eihigh/goban"
)

func main() {
goban.Main(app, view)
}

func app(_ context.Context, es goban.Events) error {
goban.Show()
es.ReadKey()
return nil
}

func view() {
goban.Screen().Enclose("hello").Prints("Hello World!\nPress any key to exit.")
}



簡単な解説:



  • goban.Main で画面を作成し、メイン処理を呼び出します。


  • app がユーザーが記述するメインの処理です。app が終了すると、Main も終了します。


  • goban.Show() で描画を行います。(後述)


  • es.ReadKey() はキーイベントを待って、読み取る関数です。キー以外のイベントは無視されます。


  • return nil でappを終了します。その後Mainも終了し、appの返したエラーを返します。

上述したとおり、イベントはチャネル経由で流れてきます。そのため、「ふつうの」プログラムのように流れ通りに処理を書いて、適時イベントを待ったり描画を行ったりする形になります。この辺はまさにGoのパワーが活きています。


描画システム

Hello Worldでは view という一つの描画関数のみを用いています。



  • goban.Main(app, view)view を描画関数として登録しています。


  • goban.Show() で描画関数が呼び出されます。

描画関数は複数登録でき、登録した順に呼び出されます。なので、ポップアップなどは以下のように適時ビューを追加することで、上にビューを重ねることができます。

func main() {

goban.Main(app)
}

func app(_ context.Context, es goban.Events) error {
// 下敷きとなるview
v := func() {
b := goban.Screen()
b.Puts("Press any key to open popup")
b.Puts("Ctrl+C to exit")
}
goban.PushViewFunc(v)

for {
goban.Show()
es.ReadKey()
popup(es)
}
}

func popup(es goban.Events) {
// ポップアップのview
v := func() {
b := goban.NewBox(0, 0, 40, 5).Enclose("popup window")
b.Prints("Press any key to close popup")
}

// viewを上に追加する。
// ポップアップのようにモーダルビューを用いるときは、
// このように PushView と defer PopView をペアで使うのが推奨です。
goban.PushViewFunc(v)
defer goban.PopView()

goban.Show()
es.ReadKey()
}

ポイントは defer PopView で、これを用いることによって関数を脱出した際に自動的にビューを削除することができます。このように、通常状態遷移が必要な処理も、このように非常にシンプルに書くことができます。


グリッドレイアウト

gobanでは Box を用いてさまざまな描画を行います。その補助として、グリッドレイアウトを使用することができます。

var (

grid = goban.NewGrid(
" 1fr 1fr 1fr ",
"1fr header header header",
"3fr side content ",
"1fr footer footer footer",
)
)

func main() {
goban.Main(app, view)
}

func app(_ context.Context, es goban.Events) error {
goban.Show()
es.ReadKey()
return nil
}

func view() {
b := goban.Screen().Enclose("")
header := b.GridItem(grid, "header").DrawSides("", 0, 0, 0, 1)
header.Prints("Header")
// 以下略
}

NewGrid 関数にレイアウト文字列を渡して、直感的にグリッドレイアウトを作成できます。


  • 長さの単位には fr または em を使用できます。

  • 1行目には列のサイズを、2行目以降は行のサイズ+エリア名を記述します。同じエリア名を連続させることで、範囲を連結させることができます。


  • goban.Box#GridItem 関数で、グリッドに相当する範囲のボックスを取得します。

今回は文字列でグリッドを生成していますが、行と列の大きさを直接指定したり、範囲を直接指定して切り出したり(goban.Box#GridCell)することができます。


ビューモデル

ここまで、viewはすべて関数として解説してきましたが、実際には goban.View というインターフェースとして定義されています。(関数を渡すと内部でインターフェースを満たす型にキャストされています。)

このことを利用して、viewを状態を持つ型として定義することで、よりviewを強力にすることができます。

func main() {

goban.Main(app)
}

func app(_ context.Context, es goban.Events) error {
v := &menuView{
items: []string{
"foo", "bar", "baz",
},
}
goban.PushView(v)

for {
goban.Show()
switch k := es.ReadKey(); k.Key() {
case tcell.KeyUp:
v.cursor--
case tcell.KeyDown:
v.cursor++
}
}
}

// goban.Viewを実装している。
type menuView struct {
cursor int
items []string
}

func (v *menuView) View() {
b := goban.NewBox(0, 0, 50, 20).Enclose("menu")
b.Puts("↑, ↓: move cursor")
for i, item := range v.items {
if i == v.cursor {
b.Print("> ")
}
b.Puts(item)
}
}

app側で menuView を作成し、viewとして追加しています。このパターンによって、


  • カーソル位置などの状態を持ちやすくなる。

  • appとviewをより独立に書くことができる。

  • appでview modelのスコープを明確にできる( defer PopView / defer RemoveView(v) と組み合わせてください)。

などのメリットが得られます。

以上、基本的な機能の解説でした!


これから

鋭意開発中ですが、基本機能のAPIはそう変わらない予定です。

今後はより見た目を改善する機能や(中央揃えなど)、エスケープシーケンスへの対応を広げたいです。

(追記) 試行錯誤中ですが、マウスサポートも追加しました。

また、拡張性を生かして、基本APIはそのままに別パッケージとして便利なウィジェットを多数提供する予定です。たとえば、

if confirm(events, msg) { /* 確認画面でOKされたときの処理 */ }

// goban.Eventsを渡せば描画から確認完了までやってくれるのがポイント!

など。(コードの見た目が Immediate GUI っぽくなりますね)

気になった方には是非examplesを覗いていただいて、気に入れば使っていただけると嬉しいです。p-rも歓迎です! goban、よろしくお願いします。