この記事はSLP KBIT AdventCalendar 2023 11日目の記事となることが期待されています
はじめに
こんにちは、na_11です。前回はGASでボボボーボ・ボーボボボットを作る記事を書きました。
アドベントカレンダー終盤はなんかクライマックス感出てたら嫌だな、やっぱり中盤だよね! と思ってたら初旬~中旬がポッカリ空いてしまい、気づいたら中盤で記事2つ書くことになってました。執筆スケジュールがクライマックス
はじめに(2)
世の中のGUIソフトには様々なものがあります。例えばこれ:
※VOICEVOX / 栗田まろん(Twitter(現X), A.I.VOICE, VOICEVOX)
UIがとてもナウナウな雰囲気で良いですね。
でも一体どうやって作るんだ...? 学校で得た知識では全く見当がつかないぞ...?
流石にこれではみっともないです。いくつかグレードアップしたいものですが、ナウナウなUIは作り方が全く分かりません。(なんかHTMLやCSSを使うらしいとは聞きました。マジ...?)
そんな中TUIなるものを見つけました。
ということで、今回はTUIを紹介するような記事になります。
そもそもTUIを知ったのが11月末なのであっさりとした内容です。
概要
Go言語とBubbleTeaライブラリ(Go的にはBubble Teaモジュールと言ったほうがいい?)を使用し、BubbleTeaのTUI構成チュートリアルを通してみます。
GoはC言語ライクな言語なので、未経験でもまあまあ分かると思う
TUIとは?
Text User Interface。
昔のBIOSっぽい画面と言えば分かる人もいるかも知れません。ターミナル上で文字を巧みに使って画面を構成します。
そのためリップルエフェクトのある豪華で派手なボタンをドーン!とかはできませんが、複数の入力ボックスをカーソルキーで横断だとか、リストとかはできるみたいです。
予備知識
Goがどのようにライブラリ(モジュール)を扱うかと、Bubble Teaモジュールが参考にしているElmアーキテクチャについて。
Goのモジュールの扱い方(go.mod)
たまに実行するgo mod init
やgo mod tidy
の意味。
初心者向け。展開してください
筆者のようなGo初心者向けです。メモ程度に書いておきます。
要約
- GOPATHじゃなくてgo module
- プログラムファイルを入れるディレクトリを作る。カレントディレクトリをそこに移動。
$ go mod init [適当な名前]
- プログラムを書く
$ go mod tidy
説明
Goでは、GitHub等に落ちているモジュールを自由にDLして、自分のプロジェクトへ組み込むことができます。例えば今回はBubbleTeaモジュールを使用します。
ただGoがどのようにモジュールを扱い管理するか、以下2通りの方式があるようです。
- GOPATH
- go module
GOPATHが従来の方法、現在はgo moduleが主流のようです。ググる時は「go moduleを知りたいのに気づけばGOPATHの記事を見ていた」なんてことにならないよう気をつけましょう。
go moduleではまるでpipとかのパッケージマネージャのように、ソースコードを参照して必要なモジュールのDLや削除を行ってくれます。管理するための情報は各プロジェクトごとに作成されるgo.modやgo.sumファイル内に記述されます。
何か1つソフトウェアを作るつもりで、実際にgo moduleを使ってみましょう。
適当にソフトウェアのためのディレクトリを作り、そこへカレントディレクトリを移して
$ go mod init [モジュール名]
とします。
今回はgo moduleを使ってみるだけなのでモジュール名は何でもいいです、公開もしませんし。github等に公開する場合は命名規則があるっぽいですが。
実は自分で作った公開していないソースコードもモジュールとなります。正確には、go.modと同じ&それより深い階層にいるソースコードたちは1つのモジュールとして扱われます。
公開しているかどうかや、上手い・下手、利用されることを考えているかなどは関係ありません。
ちなみに、同じディレクトリに属するソースコードたちはパッケージとして扱われ、パッケージが1コンパイル単位となります。つまり、同一パッケージに所属するソースファイル間では変数や関数が共有されます。
そして、モジュールは複数のパッケージの集合体となり得るということです。
さて、ここで色々なモジュールをimportしてプログラムを書いたとしましょう。そのモジュールはGitHubで公開されているもののため、コンパイルする時にはダウンロードしなければなりません。
$ go mod tidy
実際にプログラムを書いていないため、実行するとエラーか何か出ると思います
とすれば、自動でダウンロードを行ってくれます。
「前まで使っていたが今はimportしていないモジュール」がある場合でも、このコマンドによりgo.modがいい感じに修正されます。
ローカルにあるモジュールをimportする際は、そのモジュールの場所を指定し追加するコマンドがあるようですがググってください。
基本的にgo.modは人力でいじることは無いらしいです。そしてgo.sumはDLしたモジュールのチェックサムを保管するファイルのようです。
おわり。
Elmアーキテクチャ
ModelやUpdateの意味
展開してください
今回使用するモジュール、Bubble Teaが参考にしているアーキテクチャです。
本来はWebアプリケーション等での利用を想定しているようですが、Bubble Teaの作者さんはTUIでも参考にできると思ったようです。
公式解説ページがあるのでそこを読めばいいのですが、メモ程度にまとめると
Elmアーキテクチャは
- Model
- View
- Update
の3つの部分から成る。
Modelは、そのソフトウェアが持つ全てのデータを保管する。
Viewは、Modelが持つ情報がどう表示されるかを定義する。
Updateは、「ボタンを押した」等の変化があった時、何をするか(Modelの内容変更など)を定義する。
みたいなものでしょうか。MVCモデルに似ている気がします。
このくらいの理解で今はいいと思います。
Bubble Teaは正確・厳格にElmアーキテクチャに基づいているわけではないので(まず目的が違う)、全部の説明を血眼にして読む必要はないでしょう。
おわり。
実践
というわけでBubble Teaの公式チュートリアルを通します。チュートリアルの解説になってる...
ところどころでGoに関する情報とかを付加します。
To-Doリストを作るみたいです。
セットアップ・import
ソースコードを書くためにまず適当なディレクトリを作り、そこでコンソールを開き
$ go mod init [適当な名前]
します。
ではコードを書いていきます。私はmain.goファイルを作りました(これ以外の名前でもいいのかよくわからない)
mainパッケージだと定義し、必要なモジュールをimportします
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
VSCode等で記述している場合、この時点で赤線が出るかもしれませんが気にしないでください。
「importしてるけど使ってない」「このモジュールが見つからない」エラーです。(Goでは未使用なimportも警告ではなくエラーになります)
Model
状態やデータを保管するModelを定義します。
type model struct {
choices []string // To-Doリスト
cursor int // カーソルが指している番号
selected map[int]struct{} // 選択されたアイテム番号
}
この文章では「カーソルが指している」と「選択されている」はノットイコールなのでご注意願います。(「選択されている」はTo-Doリストにおいて「チェックが入っている」的な意味合いです。)
分かりにくくて申し訳ない...
map[int]struct{}
の部分、mapはPythonでいう辞書型です。
キーとしてintの要素番号、値としてstruct型を持ちます。
謎な形式ですが、単純に選択されていれば「選択された要素番号と空の構造体をペアとして辞書型に登録する」、選択が外されれば「選択が外された要素番号を持つペアを辞書型から削除する」だけです。
選択された要素番号を持つスライス(拡張可能な配列)で良さそうな気がしますが、複数回にわたって存在確認をするならこちらの方が早いようです。
→【Go】Sliceの中に特定の要素が存在しているかを高速に判定する
このModel構造体に、メソッドとしてInitやUpdateを追加していきます。
初期化
アプリケーションの初期状態を定義します。
ここでは初期化したモデルを返していますが、別の場所で初期化モデルを変数として宣言し、それを返すことも可能だそうです。
func initialModel() model {
return model{
// To-Doリストの内容
choices: []string{"にんじんを買う", "キャベツを買う", "ラズベリーパイを買う"},
// 選択されている要素の番号を持つマップ。
// キーとして要素番号を、値としてstruct型を持つ
selected: make(map[int]struct{}),
}
}
次にInitメソッドを定義します。
Initメソッドでは、アプリケーションを実行させるために走らせる初期コマンドを定義します。
コマンドではI/O処理等が可能ですが、今回は何もそのようなことはしないためnilを返します。
func (m model) Init() tea.Cmd {
// nilを返す。つまり「何もしないでくれ」
return nil
}
func (m model) Init() tea.Cmd
の部分は、「modelのメソッドとして、tea.Cmdを返すInitを定義する。引数は何も取らない」みたいな意味になります。
Updateメソッド
何かが起きた時の処理を記述し、更新されたModelを返します。更に処理を行うためにコマンドを返すこともできるようです。
「起きたこと」はMsg
として伝えられますが、型は任意です。キー入力だったり、タイマーだったり、サーバからのレスポンスだったりします。
通常はどの型かどうかをSwitchで判定します。
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
// キー入力か?
case tea.KeyMsg:
// 何のキーが押された?
switch msg.String() {
// 終了するべきキー
case "ctrl+c", "q":
return m, tea.Quit
// "up"や"k"でカーソルを上に
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
// "down"や"j"でカーソルを下に
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
// "Enter"や"スペース"で選択状態を切り替え
case "enter", " ":
_, ok := m.selected[m.cursor]
if ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
// 更新したモデルをBubble Teaランタイムに返し処理してもらう
// コマンドとしてnilを返す
return m, nil
}
関数定義部分、返り値の部分が(tea.Model, tea.Cmd)
となっています。
Goでは複数の値を返すことができ、まさに今2つの値を返り値としているということです。
msg := msg.(type)
では、:=
によって「msgをmsg.(type)の型・内容で宣言し初期化する」ことをしています。=
による代入とは違うので気をつけてください。
例えば、代入は何回でもできますが:=
は宣言を含むため2回するとエラーとなります。
delete()
は標準で用意されている、マップの要素を削除する関数です。
カーソルの当たっている要素が既に選択状態であればその要素番号をマップから削除し、そうでなければ要素番号をキー、空の構造体を値としてマップに追加します。
Viewメソッド
UIを描画するところです。このメソッドの中でごちゃごちゃをして、文字列を返すことで表示ができます。
returnする時点でUIの全てが文字列に格納されていなければなりません。
func (m model) View() string {
// ヘッダー
s := "What should we buy at the market?\n\n"
// アイテム数分繰り返す
for i, choice := range m.choices {
// このアイテムにカーソルが当たっているか?
cursor := " " // 当たっていない
if m.cursor == i {
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
}
全てをくっつける
main関数でそれぞれをまとめて実行させます。
tea.NewProgram
に初期化されたモデルを渡して、実行します。
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
}
ちなみに、Goではエラーを値として返すことでエラーハンドリングを行います。try-catch文はGoには存在しません。
ここではif文内でerrを定義・初期化し(ついでにTo-Doリスト部分を実行し)、errが発生したか(nilでないか)を見ています。
p.Run()
が返す2つの値のうち1つを_
で受け取っていますが、これは「ブランク変数」(場所によって空白識別子, ブランク識別子とも)と呼ばれ、「不要なので無視する」的な使い方をされます。
似たようなものはPythonでも見られますね(ただPythonは慣例として変数名が「_」な変数にいらないデータを代入しているだけみたいですが...)。
コンパイル・実行
さて、今更ですがGoはコンパイル型言語です。コンパイルをしてから実行します。
$ go mod tidy
してBubble Teaをダウンロードしておきましょう。
その後は
$ go build main.go
(main.goをコンパイルしmain.exeを生成する)
か
$ go run main.go
(main.goをコンパイルし実行するがexeは生成しない)
で終わりです。
これで上下キー(j,kキー)でカーソルを動かし、Enterやスペースでチェックを入れることができるUIが完成しました。
qやCtrl+Cで終了もできます。
最後に
CUIで動かすソフトとなれば「数字を入力:_」みたいなUIになりがちですが、TUI形式で作成すれば余計なHTMLなどに触れないまま高度なUIを制作することができそうです。
Goに限らず、Python等でもTUIライブラリはあるようなので、選択肢の1つとして頭の片隅に置いておくというのはいかがでしょうか?
(これ大規模なソフトウェアでも全ての情報を1つのモデルに閉じ込めるんでしょうかね...? 一応main関数内で複数のモデルを生成してRun()反復横跳びもできそうですけど...)