Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

こんにちは。今日もいいターミナル日和ですね。
今回は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.")
}

スクリーンショット 2019-07-29 18.07.38.png
簡単な解説:

  • 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()
}

ダウンロード.gif

ポイントは 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")
    // 以下略
}

スクリーンショット 2019-07-29 18.05.48.png

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、よろしくお願いします。

eihigh
denagames
ユーザーファーストを掲げ、アプリ・ブラウザゲームの運営に特化したDeNAのグループ会社です
https://denagames-tokyo.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away