【Go言語】ElmArchitectureでTUIアプリが作れるbubbleteaでちょっとリッチなToDoアプリを作る

10 min read読了の目安(約9000字

注: この記事はQiitaにも投稿されています。(https://qiita.com/yuzuy/items/e6b10432118e51f8d099)

はしがき

本記事ではElm Architecture likeにTUIアプリを作成できるbubbleteaというフレームワークを使ってToDoアプリを作っていきます。

タスク/完了したタスク一覧表示、追加、編集、完了、削除を行うことができます。
イメージ画像↓
イメージ画像

Elm Architectureについて

Elm Architectureを知らない方は公式ガイド(日本語訳)この記事をざっと読んでからにしたほうが理解がしやすいかと思います。(本記事ではElm Architectureの解説は殆どしません。)

筆者はElmを少し触ったことある程度なのでElm Architectureに対する理解が甘いです。
なので何か間違いがあればコメントやTwitterで指摘していただけると幸いです。

本記事で実装する機能

なお本記事ではタスク一覧表示と、タスクの追加までの解説(ガイド)にしようかと思っています。そこからあとはただただコード書き足してくだけなので。
適当にコードだけ読んで雰囲気を感じとりたい方、他の機能の実装の仕方を詳しく知りたい方、マサカリを投げてくださる方はyuzuy/todo-cliを参照してください!

それでは早速実装を始めていきましょう!

実装

筆者の環境

バージョン
OS macOS Catalina 10.15.7
iTerm 3.3.12
Go 1.15.2
bubbletea 0.7.0
bubbles 0.7.0

注: bubbleteaについて。このアプリを実装している途中にも破壊的変更が入ったので違うバージョンのbubbleteaを使う場合でバグが発生した場合はリポジトリを参照してください。

タスクの一覧表示

この節ではタスク一覧を表示してカーソルでタスクを選択できるところまで実装します。

最初にパッケージをgo getしましょう。

// bubbletea
go get github.com/charmbracelet/bubbletea

// utility
go get github.com/charmbracelet/bubbles

Model

まずタスクの構造を定義します。

main.go
type Task struct {
    ID        int
    Name      string
    IsDone    bool
    CreatedAt time.Time
}

ミニマムな構造になっているので他にもFinishedAtとか欲しい場合は追加しましょう。

次にmodelを定義します。Elm Architectureのモデルにあたるところです。
Elm Architectureではアプリの状態をモデルで管理します。
この章で
扱う状態はタスクの一覧とカーソルの位置の2つなので、実装は以下の通りになります。

main.go
type model struct {
    cursor int
    tasks  []*Task
}

これだけではbubbleteaがモデルとして扱ってくれません。
モデルとして扱うにはmodeltea.Modelを実装させる必要があります。

tea.Modelの定義↓

// Model contains the program's state as well as it's core functions.
type Model interface {
	// Init is the first function that will be called. It returns an optional
	// initial command. To not perform an initial command return nil.
	Init() Cmd

	// Update is called when a message is received. Use it to inspect messages
	// and, in response, update the model and/or send a command.
	Update(Msg) (Model, Cmd)

	// View renders the program's UI, which is just a string. The view is
	// rendered after every Update.
	View() string
}

まずは初期化関数であるInit()から実装していきますが、このアプリでは最初に実行するべきコマンドがないので、nilを返すだけで大丈夫です。
(modelstructの初期化は別で行います。)
(本記事ではコマンドを殆ど扱わないのでコマンドについてはあまり気にしないでよいです。気になる場合はElm公式ガイド(日本語訳)を参照してみるといいでしょう。)

main.go
import (
    ...
    tea "github.com/charmbracelet/bubbletea"
)

...

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

Update

Update()ではユーザーの操作(Msg)を元にmodelの状態を変化させます。

main.go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "j":
            if m.cursor < len(m.tasks) {
                m.cursor++
            }
        case "k":
            if m.cursor > 1 {
                m.cursor--
            }
        case "q":
            return m, tea.Quit
        }
    }

    return m, nil
}

tea.KeyMsg(ユーザーのキー操作)をハンドリングして、
"j"が押されたらcursorの値を1増やす。
"k"が押されたらcursorの値を1減らす。
"q"が押されたら、tea.Quitコマンドを返してアプリを終了することを定義しています。

カーソルが無限に上がったり下がったりすると困るのでm.cursor > 1などの条件を付けています。
ちなみに、case "j", "down"case "k", "up"とすると、矢印キーでもカーソルを上下に動かすことができるようになるので、お好みでどうぞ。

View

View()ではmodelを元に描画するテキストを生成します。
stringでゴリゴリ書いていきます。

main.go
func (m model) View() string {
    s := "--YOUR TASKS--\n\n"

    for i, v := range m.tasks {
        cursor := " "
        if i == m.cursor-1 {
            cursor = ">"
        }

        timeLayout := "2006-01-02 15:04"
        s += fmt.Sprintf("%s #%d %s (%s)\n", cursor, v.ID, v.Name, v.CreatedAt.Format(timeLayout))
    }

    s += "\nPress 'q' to quit\n"

    return s
}

カーソルはインデックス番号で数えていないのでカーソルがそのタスクを指しているかは、インデックスim.cursor-1を比較して判定しています。

これでタスクを表示するのに十分な材料が揃いました!
main関数を定義してアプリを起動できるようにしましょう!

main

main関数ではmodelstructの初期化、アプリの起動を行います。

main.go
func main() {
    m := model{
        cursor: 1,
        tasks:  []*Task{
            {
                ID:        1,
                Name:      "First task!",
                CreatedAt: time.Now(),
            },
            {
                ID:        2,
                Name:      "Write an article about bubbletea",
                CreatedAt: time.Now(),
            },
        }
    }

    p := tea.NewProgram(m)
    if err := p.Start(); err != nil {
        fmt.Printf("app-name: %s", err.Error())
        os.Exit(1)
    }
}

本来ならタスクはファイル等から読み込みますが、そこまで実装すると時間がかかるので今回はハードコーディングします。
tea.NewProgram()でプログラムを生成、p.Start()で起動します。

早速go runコマンドで実行しましょう!
タスク一覧が表示され、"j","k"キーでカーソルを上下に移動させることができるはずです!

タスクの追加

さて、タスクの一覧表示ができましたが、これではToDoアプリは名乗れそうにありません。
この節ではToDoアプリの最も大事な機能の一つ、タスクの追加を実装していきます。

Model

先程まではカーソルの位置、タスク一覧を保持するだけで大丈夫でしたが、タスクの追加を実装するにあたってユーザーからの入力を受け取り、保持するフィールドを設ける必要があります。

bubbleteaでテキスト入力を実装するにはgithub.com/charmbracelet/bubbles/textinputというパッケージを利用します。

main.go
import (
    ...
    input "github.com/charmbracelet/bubbles/textinput"
)

type model struct {
    ...
    newTaskNameInput input.Model
}

これだけではタスク一覧表示モード(以下ノーマルモード)とタスク追加モード(以下追加モード)の判別ができないので、modeというフィールドも追加します。

main.go
type model struct {
    mode int
    ...
    newTaskNameInput input.Model
}

modeの識別子も定義しましょう。

main.go
const (
    normalMode = iota
    additionalMode
)

Update

早速Update()関数の変更に入っていきたいところですが、タスクを追加するにあたって、足りない要素が1つ。
このToDoアプリではタスクのidを連番で管理しようと思っているので、最新のタスクのidを保持しておく必要があります。

グローバル変数として宣言、main()で初期化、Update()でインクリメント、という風に実装します。
(書いてる途中に思いましたが、modelのフィールドとして管理するのもありかもしれません。)

Twitter@ababupdownbaさんにご指摘いただいて、やっぱりmodelのフィールドにした方がいいぽいのでそっちで実装します。

main.go
type model struct {
    ...
    latestTaskID int
}

func main() {
    ...
    // 今回はハードコーディング祭りですが、ちゃんと実装する場合はファイル等から読み込むときなどで初期値を入れましょう。
    m.latestTaskID = 2
    ...
}

さて、本題のUpdate()ですが、追加モードではノーマルモードとは違い全ての文字キーを入力として扱うことになります。

そのためmodel.Update()とは別にmodel.addingTaskUpdate()を定義して、model.mode == additionalModeであればそちらで処理するようにします。

ノーマルモードでは"a"が押されたときに追加モードに変更するように、
追加モードでは"ctrl+q"が押されたときにノーマルモードに戻るように、"enter"が押されたときにタスクが追加されるようにしましょう。

main.go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    if m.mode == additionalMode {
        return m.addingTaskUpdate(msg)
    }

    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        ...
        case "a":
            m.mode = additionalMode
        ...
    }

    return m, nil
}

func (m model) addingTaskUpdate(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+q":
            m.mode = normalMode
            m.newTaskNameInput.Reset()
            return m, nil
        case "enter":
            m.latestTaskID++
            m.tasks = append(m.tasks, &Task{
                ID  :      m.latestTaskID,
                Name:      m.newTaskNameInput.Value(),
                CreatedAt: time.Now(),
            })

            m.mode = normalMode
            m.newTaskNameInput.Reset()

            return m, nil
        }
    }

    var cmd tea.Cmd
    m.newTaskNameInput, cmd = input.Update(msg, m.newTaskNameInput)

    return m, cmd
}

m.newTaskNameInput.Value()でタイプされた値を取り出し、m.newTaskNameInput.Reset()でリセットしています。
input.Update()でキー入力をハンドリングしてm.newTaskNameInputを更新しています。

View

View()Update()と同じく処理を分ける必要があります。
ですがこれはたった6行の変更で実装できます!

main.go
func (m model) View() string {
    if m.mode == additionalMode {
        return m.addingTaskView()
    }
    ...
}

func (m model) addingTaskView() string {
    return fmt.Sprintf("Additional Mode\n\nInput a new task name\n\n%s", input.View(m.newTaskNameInput))
}

input.View()input.ModelView()用にstringへ変換してくれる変数です。

main

新たなフィールドmodenewTaskNameInputの初期化を加えましょう。

main.go
func main() {
    newTaskNameInput := input.NewModel()
    newTaskNameInput.Placeholder = "New task name..."
    newTaskNameInput.Focus()

    m := model{
        mode: normalMode,
        ...
        newTaskNameInput: newTaskNameInput,
    }
    ...
}

Placeholderは何も入力されていないときに表示する文字列です。

Focus()を呼び出すことによってそのinput.Modelがフォーカスされます。
これは複数の入力を一画面で扱うときなんかに使うらしいです。今回は2つ以上の入力をさせるわけではないのでおまじない程度に思っていただければ大丈夫だと思います。

さあ、これでタスクの追加も実装できました!
早速先程同様、go runコマンド等で実行してみましょう!

あとがき

本記事ではbubbleteaを用いたちょっとリッチなToDoアプリの作り方を解説しました。
思っていたより簡単に実装ができて、tigのような複雑なCLIツールを作ってみたかったけどはじめの一歩を踏み出せていなかった僕にとってはとても魅力的なフレームワークでした。

bubbleteaにはこれ以外にもリッチなTUIアプリケーションを作るための機能がたくさんあるのでぜひリポジトリを覗いてみてください!