メインコンテンツまでスキップ

Golangで作るTUIツール

小倉大地
Engineer

GolangのBubble Teaというライブラリを使って、TUI1で動作するPingツールを作ってみました。

https://github.com/papu-nika/penguin

TUIはOSのインストールや設定から、さまざまなツールなどで使われており、一定目的に沿ったツールには向いていると思います。 サンプルを作りながら、Bubble Teaの使い方を紹介します。

Bubble Teaとは

CharmというCLI用のライブラリを団体?の提供しているライブラリのようです。Charmは他にも面白そうなCLI用のライブラリを提供しています。 TUIというとlessコマンドやtviewのように全画面ターミナルを使うタイプがイメージがありますが、Bubble TeaはCLIの一部分だけをTUIとして利用できます。

このまま動くサンプルコードです。
"Hello, World!"を表示して「q」か「Ctrl+c」で終了するだけの最低限の動作になります。
package main

import (
"fmt"
"os"

tea "github.com/charmbracelet/bubbletea"
)

type model struct {
}

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

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
default:
return m, nil
}
}
return m, nil
}

func (m model) View() string {
return "Hello, World!"
}

func main() {
var m model

p := tea.NewProgram(m)
if _, err := p.Run(); err != nil {
os.Exit(1)
}
}

tea.NewProgramの引数のtea.Modelは以下のようなシンプルなインターフェイスになっています。Bubble TeaのREADMEではElm Architectureに基づくフレームワークだと書かれています。MVU(Model, View, Update)だそうで、DjangoのMTVやRailsのMVCに似ているかもしれません。

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
}

〇✕ゲームサンプル

試しにoxゲームを実装します。こんな動作となります。

UpdateにはSwitch分でキーイベントを待ち受けます。数字キーが押されたら、その数字に対応するセルに〇か✕を入れます。勝敗が決まったら終了します。 つまりUpdateメソッドでユーザーの入力を受け付けて、Modelを更新し、Viewメソッドで描画します。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var err error
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
var num int
if num, err = strconv.Atoi(msg.String()); err != nil {
return m, nil
}

if err = m.setCell(num); err != nil {
return m, tea.Println("already put. please input other number.")
} else {
if winner := m.winner(); winner != nil {
return m, tea.Batch(
tea.Quit,
tea.Println(fmt.Sprintf("\n %s win!!!!!", winner.String())),
)
}
m.turn = !m.turn
return m, nil
}
default:
return m, nil
}
}
return m, nil
}
フルで動くコードはこちら
package main

import (
"errors"
"fmt"
"os"
"strconv"

tea "github.com/charmbracelet/bubbletea"
)

type MARUBATU bool

const (
MARU MARUBATU = true
BATU MARUBATU = false
)

func (m MARUBATU) String() string {
if m == MARU {
return "○"
}
return "×"
}

type model struct {
maruBatu [3][3]*MARUBATU
turn MARUBATU
}

func getXY(n int) (int, int) {
x := (n - 1) / 3
y := (n - 1) % 3
return x, y
}

func (m model) getCell(n int) *MARUBATU {
x, y := getXY(n)
return m.maruBatu[x][y]
}

func (m *model) setCell(n int) error {
x, y := getXY(n)
if m.maruBatu[x][y] == nil {
marukabatu := m.turn
m.maruBatu[x][y] = &marukabatu
return nil
}
return errors.New("already put")
}
func (m *model) createBarubatuTable() [3][3]string {
var matuBatuTable [3][3]string
for x, row := range m.maruBatu {
for y, cell := range row {
if cell == nil {
matuBatuTable[x][y] = strconv.Itoa(x*3 + y + 1)
} else {
matuBatuTable[x][y] = cell.String()
}
}
}
return matuBatuTable
}

func (m model) winner() *MARUBATU {
winPatterns := [][3]int{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9},
{1, 4, 7},
{2, 5, 8},
{3, 6, 9},
{1, 5, 9},
{3, 5, 7},
}
for _, p := range winPatterns {
if m.getCell(p[0]) != nil && m.getCell(p[1]) != nil && m.getCell(p[2]) != nil &&
*m.getCell(p[0]) == *m.getCell(p[1]) && *m.getCell(p[1]) == *m.getCell(p[2]) {
return m.getCell(p[0])
}
}
return nil
}

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

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var err error
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit

case "1", "2", "3", "4", "5", "6", "7", "8", "9":
var num int
if num, err = strconv.Atoi(msg.String()); err != nil {
return m, nil
}

if err = m.setCell(num); err != nil {
return m, tea.Println("already put. please input other number.")
} else {
if winner := m.winner(); winner != nil {
return m, tea.Batch(
tea.Quit,
tea.Println(fmt.Sprintf("\n %s win!!!!!", winner.String())),
)
}
m.turn = !m.turn
return m, nil
}

default:
return m, nil
}
}

return m, nil
}

func (m model) View() string {
var matuBatuTable [3][3]string = m.createBarubatuTable()

return fmt.Sprintf(`
turn: %s

%s | %s | %s
---+---+---
%s | %s | %s
---+---+---
%s | %s | %s

`, m.turn.String(),
matuBatuTable[0][0], matuBatuTable[0][1], matuBatuTable[0][2],
matuBatuTable[1][0], matuBatuTable[1][1], matuBatuTable[1][2],
matuBatuTable[2][0], matuBatuTable[2][1], matuBatuTable[2][2])
}

func main() {
var m model
p := tea.NewProgram(m)
if _, err := p.Run(); err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
}

かなりシンプルに実装できました。ちなみにReactのチュートリアルの三目並べを参考にしました。さすがにReactの方が書きやすいですが、Bubble Teaでもいい感じに書けたかと思います。

非同期実行

Pingの結果を継続的に更新して表示するには非同期に裏で実行しつつ、もちろんユーザ入力も待ち受け開ければなりません。 Golangですので、goroutineとchannelを使って非同期処理を実装しますが、1工夫必要になります。

Bubble Tea自体がイベントループをして、ユーザーの入力を待ち受けています。そして入力を受け付けたらUpdateを実行するような実装となっているはずです。
同じようにイベントループをもう1つ追加してあげて、外部からのイベントを待ち受ける仕組みを作ってあげればよさそうです。ここでポイントとなるのが、tea.Cmdです。tea.Cmdtea.Msgを返す関数ですが、返したtea.Msgは裏側でUpdateメソッドに渡してくれます。

ひたすらにtime.Now()を渡して、表示するだけのプログラムを作ってみます。

type model struct {
t time.Time
c chan tea.Msg
}

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

func waitCount(c chan tea.Msg) tea.Cmd {
return func() tea.Msg {
return <-c
}
}

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
}
case time.Time:
m.t = msg
return m, waitCount(m.c)
}

return m, nil
}

func (m model) View() string {
return m.t.Local().Format("2006-01-02 15:04:05.00")
}

func roopEvent(c chan tea.Msg) {
for {
c <- time.Now()
}
}
警告

ちなみに、UpdateメソッドとViewメソッドを実行するだけでは描画されません。実際のレンダリングはこのあたりで行われていそうです。

Pingツール

公開しているので、試してみてください!

go install github.com/papu-nika/penguin@latest

https://github.com/papu-nika/penguin

さいごに

私はネットワークエンジニア出身ですが、ExPingなどのツールを使っていました。もう少し手軽にできるとよさそうだなと思い、Pingツールを作ってみました。pro-bingというライブラリを使っていますがHTTPクライアントも対応しているようなので、HTTP対応してもいいかもしれません。

WeCapitalではエンジニアを募集しています!!

Footnotes

  1. https://ja.wikipedia.org/wiki/テキストユーザインタフェース