menu
書いてる野郎
orebike@gmail.com
公式の最初のやつやってみる。
最終的なコードはこうなるのだが、それを 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 main
このファイルのコードがどのパッケージに所属するのかというやつ。
エントリーポイントのパッケージは main
なのでそのようにしている。
import ( "fmt" "os" tea "github.com/charmbracelet/bubbletea" )
このファイル内でどの外部パッケージを利用するか宣言。 fmt
と os
はプログラミングの基本的機能を提供する定番のやつ。
最後の github.com/charmbracelet/bubbletea
が今回使いたい bubbletea
のライブラリでそれを tea
という短縮名で使えるように書いている。
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
記述で指定の型のマップを生成している。
func (m model) Init() tea.Cmd { return nil }
Go ではパッケージに対して init
という特別な関数を作ると、それがパッケージの初期化に使われるのだが、これは大文字から始まる Init
で、さらにこいつは関数ではなくメソッドである。そして何もしない。よくわからないがほっておこう。おそらくインターフェース実装の1つなんだろう。
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 していることに何か意味がるのかもしれない。
内部を見ていく。
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 はどうでもいいらしい。 なのでここでは空っぽの構造体を詰め物にしている。
最後に return
return m, nil
1個はレシーバー自身で、2個目が tea.Cmd
というこれもインターフェースなのかな。
このレシーバーが参照ではなくコピーなのがポイントなのかもしれない。よくわからん。
これも 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 である。
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行で行うことができる。
この変数はブロック内部に閉じているので外に影響を与えない。
メソッドのレシーバーがポインタ指定ではないところがやや気になる。