Go言語 / TUI / bubbletea / HelloWorld

Go言語 / TUI / bubbletea / HelloWorld

公式の最初のやつやってみる。

最終的なコードはこうなるのだが、それを Go 初心者が読み解いていくようにする。

package main
 
import (
	"fmt"
	"os"
 
	tea "github.com/charmbracelet/bubbletea"
)
 
type model struct {
	choices  []string         // items on the to-do list
	cursor   int              // which to-do list item our cursor is pointing at
	selected map[int]struct{} // which to-do items are selected
}
 
var initialModel = model{
	// Our to-do list is just a grocery list
	choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
 
	// A map which indicates which choices are selected. We're using
	// the  map like a mathematical set. The keys refer to the indexes
	// of the `choices` slice, above.
	selected: make(map[int]struct{}),
}
 
func (m model) Init() tea.Cmd {
	// Just return `nil`, which means "no I/O right now, please."
	return nil
}
 
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
 
	// Is it a key press?
	case tea.KeyMsg:
 
		// Cool, what was the actual key pressed?
		switch msg.String() {
 
		// These keys should exit the program.
		case "ctrl+c", "q":
			return m, tea.Quit
 
		// The "up" and "k" keys move the cursor up
		case "up", "k":
			if m.cursor > 0 {
				m.cursor--
			}
 
		// The "down" and "j" keys move the cursor down
		case "down", "j":
			if m.cursor < len(m.choices)-1 {
				m.cursor++
			}
 
		// The "enter" key and the spacebar (a literal space) toggle
		// the selected state for the item that the cursor is pointing at.
		case "enter", " ":
			_, ok := m.selected[m.cursor]
			if ok {
				delete(m.selected, m.cursor)
			} else {
				m.selected[m.cursor] = struct{}{}
			}
		}
	}
 
	// Return the updated model to the Bubble Tea runtime for processing.
	// Note that we're not returning a command.
	return m, nil
}
 
func (m model) View() string {
	// The header
	s := "What should we buy at the market?\n\n"
 
	// Iterate over our choices
	for i, choice := range m.choices {
 
		// Is the cursor pointing at this choice?
		cursor := " " // no cursor
		if m.cursor == i {
			cursor = ">" // cursor!
		}
 
		// Is this choice selected?
		checked := " " // not selected
		if _, ok := m.selected[i]; ok {
			checked = "x" // selected!
		}
 
		// Render the row
		s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
	}
 
	// The footer
	s += "\nPress q to quit.\n"
 
	// Send the UI for rendering
	return s
}
 
func main() {
	p := tea.NewProgram(initialModel)
	if err := p.Start(); err != nil {
		fmt.Printf("Alas, there's been an error: %v", err)
		os.Exit(1)
	}
}

これを実行するとこうなる

What should we buy at the market?

  [ ] Buy carrots
> [x] Buy celery
  [ ] Buy kohlrabi

Press q to quit.

Package 宣言

package main

このファイルのコードがどのパッケージに所属するのかというやつ。

エントリーポイントのパッケージは main なのでそのようにしている。

import

import (
	"fmt"
	"os"
	tea "github.com/charmbracelet/bubbletea"
)

このファイル内でどの外部パッケージを利用するか宣言。 fmtos はプログラミングの基本的機能を提供する定番のやつ。 最後の github.com/charmbracelet/bubbletea が今回使いたい bubbletea のライブラリでそれを tea という短縮名で使えるように書いている。

model 構造体宣言

type model struct {
	choices  []string         // items on the to-do list
	cursor   int              // which to-do list item our cursor is pointing at
	selected map[int]struct{} // which to-do items are selected
}

struct 記述で構造体を宣言し、その構造体に対して type 記述で model という名前をつけている。

model というのは TEA の設計思想の中の model, view, update の要素の1つであり、それを象徴している構造体となる。 それは、画面の現在状態の本質を表している。

そこに3つのフィールドを定義している。フィールドは フィールド名 型 の順でズラズラ書いていけばいい

choices という名前で選択肢群を格納する string 型の スライス を定義している。スライスというのは参照型の便利配列と思っておけばいい。

cursor という名前で操作できるカーソルがどの選択肢を指しているかを int 型で定義している。

selected という名前で選択済みの選択肢を key を int 型、value を空の構造体で定義している。これはなぜこうなっているのかはよくわからないが先に進む。

構造体の値を作る

var initialModel = model{
	choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
	selected: make(map[int]struct{}),
}

↑で定義した model 構造体を使って実際に使う値を生成している。

choices にスライスで3つの文字列を設定し、selected には make 記述で指定の型のマップを生成している。

Init メソッド

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

Go ではパッケージに対して init という特別な関数を作ると、それがパッケージの初期化に使われるのだが、これは大文字から始まる Init で、さらにこいつは関数ではなくメソッドである。そして何もしない。よくわからないがほっておこう。おそらくインターフェース実装の1つなんだろう。

Update メソッド

Go言語 / Basic / メソッド

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

この Update というのは TEA の設計思想での Update を象徴しているのであろう。 そのようなシグネチャのメソッドを作らないとダメだということだ。 こういう、その形のメソッドがあるあるならそのように動くよという設計思想を「ダックタイピング」という。

Update メソッドを最初に作った model 構造体に対して作っている。この構造体は内部では m という名前で使うようにしている。 普通構造体に対するメソッドを作る場合は、構造体のポインタ型を取るのだがこの場合は実体をとっている。 つまり、コピーが作られて、オリジナルには影響が無いようなメソッドになるはずである。

内部で m の中身を読むだけなら問題は起きないのだが、どうなんだろう?

引数は msg という名前で tea.Msg 型のモノを受け取ることになっている。これは何かこのライブラリのインターフェースがそうなっているのだろう。 そして、2つの値を return する。Go の関数は複数の値を同時に return できる。今回は 1つ目に tea.Model 型の何かと2つ目に tea.CMD 型の何かを返すようになっている。

この2つはおそらくインターフェースだろう。インターフェース実装がコピーに対して行われているのはここで return していることに何か意味がるのかもしれない。

Update 内部処理 キャスト

内部を見ていく。

switch 文がいきなり登場。この switch 文は何かの分岐のためではなく「型アサーション」というキャスト処理の構文となる。

switch msg := msg.(type) {
case tea.KeyMsg:
	switch msg.String() {
	case "ctrl+c", "q":
		return m, tea.Quit
 
	case "up", "k":
		if m.cursor > 0 {
			m.cursor--
		}
 
	case "down", "j":
		if m.cursor < len(m.choices)-1 {
			m.cursor++
		}
 
	case "enter", " ":
		_, ok := m.selected[m.cursor]
		if ok {
			delete(m.selected, m.cursor)
		} else {
			m.selected[m.cursor] = struct{}{}
		}
	}
}

引数で受けている msg*tea.Msg 型のインターフェースであって、実の型ではない。メソッドというのは実の型に対して実装されていて、インターフェースではない。 Go は型付けが強い言語なので型の間での互換性がまったくない、同じインターフェースを実装してようがいまいが違う型は違う型なのである。 なのでインターフェースが実装への窓口として使えないのである。

そこで適切な使える型へキャストしてやる必要がある。これが「型アサーション (Type Assertion)」と呼ばれる処理で、その構文がこの switch になる。 上限分岐の switch 構文の形はしているがこれは特殊処理で switch ではない、キャスト処理である。 先を見ればわかるが、条件分岐を1個しかしていない

msg := msg.(type) この記述で型のキャストを行っている。ここでの構文は特別で実装へのキャストを行ってそれを型推論で新しい変数に入れている。 同じ名前の変数に入れているがスコープがわかれているので別物という扱いになっている。

そしてキャスト後の型への処理を case に書く。今なら tea.KeyMsg 型にキャストしたということになる。

msg が実の型にキャストされているので次の String() メソッドが使用可能になっている。 ここでの switch は所謂分岐処理である。case の内容を見ると、ここに文字列でキーボードの1ステップ操作が入ってくるようだ。 Go の case の特徴として合致条件を複数書くことができる。

m は内部状態を指しているので、それを操作によって書き換えていく(Update) という流れのようだ。

このような表現がある。

_, ok := m.selected[m.cursor]

model の宣言から selected はマップである。

go は複数値の return が可能で2つ目の値に正否を返すという定番の設計がある。 今回はカーソル位置をキーとしてそこに何かが存在するか否かを調べている。 存在した値の中身は重要ではなく存在していることが重要である。 なのでアンスコで受けることでその値を捨てますという宣言である。 正否の値は ok という変数で受けるのが慣例になっている。

後続の処理はトグル処理になっている。マップを単にキーの存在メモとして使っているので value はどうでもいいらしい。 なのでここでは空っぽの構造体を詰め物にしている。

Update 内部処理 return

最後に return

return m, nil

1個はレシーバー自身で、2個目が tea.Cmd というこれもインターフェースなのかな。

このレシーバーが参照ではなくコピーなのがポイントなのかもしれない。よくわからん。

View メソッド

これも model に対するメソッドの実装の一部。TEA 設計思想の「View」に当たる部分、「Model」をどう見た目として表現するかの話。 今回のアプリは TUI なのでその出力は文字列になるはずである。

func (m model) View() string {
	s := "What should we buy at the market?\n\n"
 
	for i, choice := range m.choices {
		cursor := " " // no cursor
		if m.cursor == i {
			cursor = ">" // cursor!
		}
 
		checked := " "
		if _, ok := m.selected[i]; ok {
			checked = "x"
		}
 
		s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
	}
 
	s += "\nPress q to quit.\n"
 
	return s
}

確かにシグネチャがそうなっている。

まず文字列を宣言して1行目を組み立てている。この s という変数に結果を詰め込んでいこうという考えのようだ。

次に出てくるのがこの記述で、これはあんまり深いこと考えずに Go のコレクション foreach はこう書くと覚えておけばいい。

for i, choice := range m.choices {

この range という記述で後続のコレクションがインデックス(ゼロスタート)と共に変数へ吐き出されつつループする。

カーソルの空間を確保しつつ、指すカーソルの位置の時だけ > とする。

選択済みの空間を確保しつつ、チェックされた要素の時だけ x とする。

全部総合して最後に改行を加えて1行分として追記していく。

最後にちょっと足して終了。

内部的にどうなるかしらんが、全描画だね。

main

お膳立てが終わったので最後に main である。

func main() {
	p := tea.NewProgram(initialModel)
	if err := p.Start(); err != nil {
		fmt.Printf("Alas, there's been an error: %v", err)
		os.Exit(1)
	}
}

最初に作った構造体を元に、なんか作って Start させている。 多分この p は process か何かの略だろう。

条件分岐して、エラーが出た場合は終了するようになっている。 この Go 独特の if の書き方があって、条件分岐の前に条件、もしくはブロック内部で使う値を取得する処理を書くことができ、その後判定を書くことができる。 なのでここで start させつつちゃんと start できたかどうかの判定を1行で行うことができる。

この変数はブロック内部に閉じているので外に影響を与えない。

まとめ

メソッドのレシーバーがポインタ指定ではないところがやや気になる。

golang/tui/bubbletea/helloworld.txt · 最終更新: 2021-07-20 09:59 by ore