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

こんにちは。今日もいいターミナル日和ですね。
今回はGo製の新たなCUIライブラリ [goban](https://github.com/eihigh/goban) を作ったので、その紹介と簡単な使い方を説明します。

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

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

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

## CUIライブラリについて
ターミナルで動くグラフィカルなアプリを作るためのライブラリです。著名なものに [gocui](https://github.com/jroimartin/gocui), [tview](https://github.com/rivo/tview)などがあります。
gobanはこれらと比較してよりシンプルに、よりGoらしく書けることを意識しています。

## gobanの特徴

* シンプルながら強力なAPI
* ボックス描画
* グリッドレイアウト
* チャネル経由でイベントを処理する。（not イベントハンドラ）
* 分離されたビューとコントローラー
* 容易なビューの組み合わせ

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

```go
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.")
}
```

<img width="451" alt="スクリーンショット 2019-07-29 18.07.38.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348791/8720c760-8149-0983-47d2-ce42a6de4329.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()` で描画関数が呼び出されます。

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

```go
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348791/c8bfc7b9-9dff-9998-ce1f-e20b999a6cda.gif)


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

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

```go
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")
	// 以下略
}
```
<img width="682" alt="スクリーンショット 2019-07-29 18.05.48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348791/f3a437c7-6de2-5065-eecf-7cdb564d9711.png">


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

* 長さの単位には `fr` または `em` を使用できます。
* １行目には列のサイズを、２行目以降は行のサイズ＋エリア名を記述します。同じエリア名を連続させることで、範囲を連結させることができます。
* `goban.Box#GridItem` 関数で、グリッドに相当する範囲のボックスを取得します。

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

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

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

```go
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はそのままに別パッケージとして便利なウィジェットを多数提供する予定です。たとえば、

```go
if confirm(events, msg) { /* 確認画面でOKされたときの処理 */ }
// goban.Eventsを渡せば描画から確認完了までやってくれるのがポイント！
```

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

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