詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

Fork me on GitHub

Bubble Tea でリッチなターミナルアプリケーションを作る #Go

近年、普段の作業をマウスでやりたくない気持ちが高まっている(デスク周りが散らかってきたせいだという説が有力です)。メールは結局ターミナルでメールを読むことにしたため問題なく過ごせているが、その他のタスクをキーボードだけでやるには、ターミナル動くアプリケーションを作れる必要がある。それもリッチなやつだ。見た目は派手な方がいい。

この記事は Kyoto.go remote #32 LT会 で発表した 入門 Bubble Tea の増補版です。

Bubble Tea とは

GitHub - charmbracelet/bubbletea: A powerful little TUI framework 🏗

Bubble Tea とは、Go でリッチなターミナルアプリケーション(TUI)を作るためのフレームワーク。Charm というプロジェクトの一部のようで、ホームページを見てもらったら分かるとおり異様にお洒落である。これはやる気が出ますね。Bubble Tea とは英語でタピオカティーのことらしい。なるほど。

README によれば、Bubble Tea は Elm Architecture に基づいたフレームワークである、とのこと。以下、Elm Architecture を解説しつつ bubbletea を使っていく。

The Elm Architecture

まず Elm という言語がある。Haskell ライクな関数型で、JavaScript にトランスパイルされるような言語だ。Elm がウェブでインタラクティブな UI を実現するために採用しているのが Elm Architecture。

この Elm Architecture は Redux の prior art として名前をあげられているので、このあたりを触ったことがあれば簡単に理解できると思う。

Elm Architecture では Model がアプリケーションの唯一のステートとなり、関数 View によって Model から UI が生成される(Elm なら HTML だし、Bubble Tea なら文字列)。ユーザなどモデル外部からの入力は Msg として表され、関数 Update で処理されて新しい Model が生成される。流れが一方向になっているので御しやすいというわけ。

tea.Model

さて、このアーキテクチャに則って、bubbletea で Go における TUI 実装を見ていく。まずは tea.Model を見てみる(bubbletea は tea という名前でパッケージを定義している)。

type Model interface {
    Init() Cmd
    Update(Msg) (Model, Cmd)
    View() string
}

Cmd はあとで見ることにして、先の説明どおりなことが分かるだろう。

  • Update() は新しい Model を返す。
  • View() は文字列を返す。これがそのままターミナルに表示される!

では React でも見るようなカウンタを作ってみよう。スペースキーを押したら数を1ずつ増やしていくようなアプリケーションにしてみる。

ソースコードは https://github.com/motemen/example-go-bubbletea/tree/main/01-counter

type model struct {
    count int
}

func (model) Init() tea.Cmd {
    return nil
}

func (m model) View() string {
    return fmt.Sprintf("count: %v", m.count)
}

どれもそのまま書き下したような形で、とくに解説もいらないだろう。アプリケーションのロジックが集中するのは Update になる。

まず書くなら、今回はこういう感じだ。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case " ":
            m.count = m.count + 1
            return m, nil // 更新したモデルを返す → View() が描画される
        }
    }

    return m, nil
}

tea.Msg の実体は interface{} で、つまり何でもいい。アプリケーションそれぞれに特有の Msg を実装することになるが、事前に定義されたものもあり、tea.KeyMsg はその一つ。ユーザからのキー入力はこれで表される。

スペースキーが押されたときに、count を 1 増やした新しい model を返している。その model に対して View() が呼ばれ、結果の文字列がユーザのターミナルに描画される、という簡単な流れだ。完成!

これでも動くのだけど、このままだとこのプログラムは終了できない。Ctrl+C でさえも bubbletea が奪ってしまうからだ。そこでこういうコードも追加する。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    ...
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        ...
        }
     ...
}

Ctrl+C されたときに、tea.Quit という値も返している。この Cmd を返すと bubbletea がプログラムを終了してくれるというわけだ。これで本当に完成。

スペースキーを連打してます

tea.Cmd

tea.Cmd が登場した。Cmd とは Elm にも登場する概念で、外界への(副作用を伴う)命令を表現している。そしてその命令が終わったら、同じく Msg を戻してくることになっている。

bubbletea においては単に tea.Msg を返す関数であり、さらに言うと goroutine の中で実行される。

type Cmd func() Msg

では先ほどのカウンタをメモリ上ではなく外に置くことにしてみよう。おあつらえむきに CountAPI というサービスがあったので、これを利用する。ウェブのアクセスカウンターみたいく、GET したらカウントを返してくれる API がある。おあつらえむきすぎる……。

ソースコードは https://github.com/motemen/example-go-bubbletea/tree/main/02-counter-api

重要なコードは以下。

type countLoadedMsg struct {
    count int
}

func (m model) hitCounter() tea.Msg {
    count, err := m.api.Hit() // ここで HTTP API を叩く……
    ...
    return countLoadedMsg{count: count} // モデルに渡すための Msg
}

countLoadedMsg という独自の Msg を定義する。API から結果が返ってきたらこれにくるんでモデルに渡してやるためのものだ。型をみれば分かるとおり、m.hitCounter が Cmd になる。

これを利用する Update は以下のようになる。

func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case " ":
            m.loading = true
            return m, m.hitCounter // ここで Cmd を返し…
        }
    case countLoadedMsg: // Cmd の結果を受けとる
        m.count = msg.count
        m.loading = false
        return m, nil
    }

    return m, nil
}

counter += 1 する代わりに Cmd を返し、結果が帰ってきたらモデルを更新するというだけ。Update の仕事はシンプルなままだ。

その他のコードも少しずつ変更する。

func (m model) Init() tea.Cmd {
    return m.hitCounter // プログラム開始時に実行したい Cmd
}

func (m model) View() string {
    if m.loading {
        return "..."
    } else {
        return fmt.Sprintf("count: %v", m.count)
    }
}

起動時にも HTTP API を叩いてカウントを取得したいので、先ほどはスルーした Init() で Cmd を返してやる。あとロード中はそれっぽい表示にしてあげたり。だんだんダイナミックな感じになってまいりました。

少し読み込みを待ってるのがおわかりでしょうか

bubbles でコンポーネントを使う

いやぜんぜんリッチな感じじゃないんだが……。そうですよね。bubbletea のいいところは bubbles というコンポーネント集があるところだ。

GitHub - charmbracelet/bubbles: TUI components for Bubble Tea 🍡

README 見てたらアガるよね! ではロード中にスピナーを回してみることにする。

bubbles のコンポーネントは bubbletea のモデルとして提供されるので、以下のように組み込んでいく。

type model struct {
    ... 
    spinner spinner.Model
}

func (m model) View() string {
    if m.loading {
        return m.spinner.View() // '-' とか '/' とか '|' とかになる
    }
    ...
}

子モデルとして spinner.Model を持たせ、ロード中は ... の代わりに回転する棒を表示させるように View を書き換えた。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.spinner, cmd = m.spinner.Update(msg) // ここで Tick を受け取っていたらスピナーの表示が変わる
    ...
}

func (m model) Init() tea.Cmd {
    return tea.Batch(
        m.hitCounter,
        m.spinner.Tick, // スピナーの回転をはじめる
    )
}

Update では、子モデルにも Msg を渡す。具体的には spinner.Tick という Msg が重要で、これが数百msごとにスピナーを回転させるための Msg になっている。

以上のソースコードは https://github.com/motemen/example-go-bubbletea/tree/main/03-counter-api-spinner

線がくるくる回っている!!!

もっとリッチに!

bubbles には他にもいろいろなコンポーネントがあるが、最後にリストを使った例をあげて終わりにする。試しに Scrapbox のページを一覧するようなコードを書いてみる。

コードの全体は https://github.com/motemen/example-go-bubbletea/tree/main/04-list-api

モデルはこんな感じだ。

type model struct {
    projectName string
    list        list.Model
}

func initModel(projectName string) model {
    m := model{
        projectName: projectName,
        list:        list.New(nil, list.NewDefaultDelegate(), 1, 1),
    }
    ...
    return m
}

list.NewDefaultDelegate() はリストの要素を描画してくれるオブジェクトを返す。とりあえずこれを使っていればいい感じになる(逆にこれを使わないと、けっこう苦労して描画することになる)。

これでリストの要素をいい感じに描画してもらうために、リストに表示したい struct は list.DefaultItem を実装する。

type scrapboxPage struct {
    Title_ string `json:"title"`
    ID     string `json:"id"`
}

// Description implements list.DefaultItem
func (p scrapboxPage) Description() string {
    return p.ID
}

// Title implements list.DefaultItem
func (p scrapboxPage) Title() string {
    return p.Title_
}

// FilterValue implements list.Item
func (p scrapboxPage) FilterValue() string {
    return p.Title_
}

API を叩いたり結果を Msg に詰める処理は省略。リストを画面いっぱいに表示するため、WindowSizeMsg を受け取って、list.SetSize() する。メソッドには Cmd を返すものとそうでないものとあるので、気にして使わないと思ったように動かないことがあるので注意。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.list, cmd = m.list.Update(msg)

    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.list.SetSize(msg.Width, msg.Height)
    ...
    }
    
    return m, cmd
}

あとはボイラープレートみたいなもんで、これで完成。簡単でしたね!

ほかにもいろいろ

ほかにも bubbletea のコンパニオンとして、ターミナルに文字列を描画したり色をいい感じに取り扱ったりするライブラリ群があるのだけど紹介しきれないのでここまで。自分も使いこなせてない部分があるし……。それではよい TUI ライフを。

The Go gopher is designed by Renee French, licensed under CC BY 3.0

はてなで一緒に働きませんか?