【Go】tviewによるTUIツール作成
はじめに
この記事は、Goの勉強を兼ねてTUIツールを作った際に、調べた内容をまとめたものとなります。
GoにはTUIライブラリはいくつかありますが、今回はtviewというライブラリを使用しました。
tviewは、ドキュメントや設定例が豊富で簡単にTUIツールが作れるため、とても便利なライブラリなのですが、日本語の解説記事があまり見当たらなかったため、今回記事にしてみました。
tviewについて
tviewはtcellを元に様々なウィジェットが実装されており、それらを組み合わせてTUIツールを作る事ができるライブラリとなります。
tviewは大きく分けて、二種類の構造体で構成されています。
- ウィジェット: 入力フォームやテーブル等を構成する構造体
- Application: 各ウィジェットを実際に描画したり等、全体を制御する構造体
tviewを用いたTUIツールの実装の流れとしては、基本的には下記となります。
- 各ウィジェットの設定
- レイアウト設定
- アプリケーションの全体設定
1. 各ウィジェットの設定
使用するウィジェットを選び、表示内容や処理内容を設定していきます。
ウィジェットの種類は下記になります。
- TextView
- Table
- TreeView
- List
- InputField
- DropDown
- Checkbox
- Button
- Form
- Modal
基本設定
各ウィジェットに準備されている関数を呼び出して、常に表示する内容やTUIツール起動時に表示する内容を設定します。
例としてTextViewというウィジェットに枠やタイトル、テキストエリアを設定する方法を記載しています。
a := tview.NewTextView()
a.SetText("textarea(a)")
b := tview.NewTextView()
b.SetText("textarea(b)")
b.SetTitle("title(b)").
SetBorder(true)
c := tview.NewTextView()
c.SetText("textarea(c)") // TextViewのテキストエリアに表示する内容を設定
c.SetTitle("title(c)"). // タイトル名を設定
SetTitleAlign(tview.AlignRight). // タイトルを右に寄せる設定
SetBorder(true) // 枠を有効化する設定
上記a, b, cのウィジェットを並べて表示した場合、下記のようになります。
実際に動作するコードはこちら
package main
import (
"github.com/rivo/tview"
)
func main() {
app := tview.NewApplication()
a := tview.NewTextView()
a.SetText("textarea(a)")
b := tview.NewTextView()
b.SetText("textarea(b)")
b.SetTitle("title(b)").
SetBorder(true)
c := tview.NewTextView()
c.SetText("textarea(c)")
c.SetTitle("title(c)").
SetTitleAlign(tview.AlignRight).
SetBorder(true)
flex := tview.NewFlex().
AddItem(a, 0, 1, false).
AddItem(b, 0, 1, false).
AddItem(c, 0, 1, false)
if err := app.SetRoot(flex, true).Run(); err != nil {
panic(err)
}
}
動的な処理の設定
TUIツールは、キー入力で表示を変更させる等、何らかのトリガーに対応してアクションを起こすような処理は必要になるかと思います。
次にそのような設定方法について説明していきます。
動的な処理を行う方法としてSetInputCapture
関数により、キー入力をトリガーとして何らかのアクションを起こす事ができます。
SetInputCapture
関数の引数capture
関数を設定する事により、キー入力の度にcapture
関数が実行されるようになります。
func (b *Box) SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) *Box
各ウィジェットにはdefaultのキー設定が準備されていますが、capture
関数はdefaultのキー設定よりも先に入力を受け取り、処理を行う事ができます。
capture
関数の引数event
に入力されたキー(tcell.EventKey
)が渡されるため、それを元に条件分岐を行います。
defaultのキー設定はcapture
関数の戻り値を元に実行されます。
-
return event
とした場合、入力キーをそのまま伝え、対応するアクションを実行 -
return nil
とした場合、入力されたキーを伝えない
条件分岐は、event.Key
とswitch
/case
を使用します。
event.Key
には、tcellライブラリのKey型が入ります。
textView := tview.NewTextView()
textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyCtrlF:
// CtrlFを押した時の処理を記述
return event // CtrlFをInputFieldのdefaultのキー設定へ伝える
case tcell.KeyRune:
switch event.Rune() {
case 'a':
// aを押した時の処理を記述
return nil // aを入力してもdefaultのキー設定へ伝えない
case 'b':
// bを押した時の処理を記述
return nil // bを入力してもdefaultのキー設定へ伝えない
}
}
return event // 上記以外のキー入力をdefaultのキーアクションへ伝える
})
各ウィジェットのdefaultのキー設定がどうなっているかは、ドキュメントに記載されています。
例としてTextViewの場合、下記のようなショートカットが準備されています。
h, left arrow: Move left.
l, right arrow: Move right.
j, down arrow: Move down.
k, up arrow: Move up.
g, home: Move to the top.
G, end: Move to the bottom.
Ctrl-F, page down: Move down by one page.
Ctrl-B, page up: Move up by one page.
defaultのキー設定を別のキーに変更したい場合は、特定のキー情報を戻り値とすれば実現できます。
inputField := tview.NewTextView()
inputField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyCtrlF:
return tcell.NewEventKey(tcell.KeyCtrlA, 0, tcell.ModNone)
// CtrlFを入力した時、CtrlAをdefaultのキー設定へ伝える
case tcell.KeyCtrlB:
return tcell.NewEventKey(tcell.KeyRune, 'b', tcell.ModNone)
// CtrlBを入力した時、bをdefaultのキー設定へ伝える
}
return event
})
また、capture
関数により、別のウィジェットを操作するという事ももちろん可能です。
例としてinputFieldにてEnter
を押すと、入力されている内容をtextViewに書き込む方法を記載しています。
textView := tview.NewTextView()
inputField := tview.NewInputField()
inputField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEnter: // Enterを入力したとき
textView.SetText(textView.GetText(true) + inputField.GetText() + "\n")
// textViewに入力されている内容と、inputFieldに入力されている内容を取得し、textViewのテキストエリアに表示する
inputField.SetText("") // inputFieldの入力欄を空にする
return nil // Enterをdefaultのキーアクションへは入力しない
}
return event // Enter以外はdefaultのキーアクションへ入力
})
上記を実行した場合、下記のようになります。
実際に動作するコードはこちら
package main
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func main() {
app := tview.NewApplication()
textView := tview.NewTextView()
textView.SetTitle("textView")
textView.SetBorder(true)
inputField := tview.NewInputField()
inputField.SetLabel("input: ")
inputField.SetTitle("inputField").
SetBorder(true)
inputField.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEnter:
textView.SetText(textView.GetText(true) + inputField.GetText() + "\n")
inputField.SetText("")
return nil
}
return event
})
flex := tview.NewFlex()
flex.SetDirection(tview.FlexRow).
AddItem(inputField, 3, 0, true).
AddItem(textView, 0, 1, false)
if err := app.SetRoot(flex, true).Run(); err != nil {
panic(err)
}
}
SetInputCapture
関数以外にも、SetMouseCapture
関数やSetChangedFunc
関数等、様々な条件をトリガーにする事ができますので、それらを使用して各ウィジェットの表示内容や処理内容を作っていく事ができます。
また、tviewリポジトリのdemosに各ウィジェットの使用例がありますので、git clone
して実際に実行してみると各ウィジェットの使い方が理解しやすいかと思います。
2. レイアウト設定
使用するウィジェットの設定が終わったら、次に各ウィジェットをどのように配置するかを設定します。
レイアウトの設定には、下記のようなレイアウト設定用のウィジェット(以降レイアウトウィジェットと記述します)が用意されています。
-
Flex
一画面に複数のウィジェットをどのように配置するかを設定できます。
Flexの表示領域の大きさに関わらず、常に固定した大きさでウィジェットを表示させたい場合や、各ウィジェットの比率を設定し、比率に応じて大きさを決定したい場合に使用します。 -
Grid
Flexと同じく、一画面に複数のウィジェットをどのように配置するかを設定できます。
表示させる領域の大きさに応じて表示させるウィジェットを変更したい場合や、座標を細かく指定してウィジェットを表示したい場合に使用します。 -
Pages
ブラウザのタブのような形でウィジェットを配置したり、レイヤーを分けてウィジェットを重ねて表示したい場合に使用します。
レイアウトウィジェットに、1で作成したウィジェットを追加していき、最終的に一つのウィジェットとしてまとめる必要があります(一つのウィジェットしか使用しない場合は、レイアウト設定は省略可能です)。
また、レイアウトウィジェットもPrimitiveインターフェースを満たす構造体のため、下記のように入れ子構造にする事が可能であり、複雑なレイアウトを作成する事ができます。
基本設定
各レイアウトウィジェットに準備されている関数を使用してどのウィジェットをどのように配置するかをAddItem
関数やAddPage
関数を使用して設定します。
func (f *Flex) AddItem(item Primitive, fixedSize, proportion int, focus bool) *Flex
func (g *Grid) AddItem(p Primitive, row, column, rowSpan, colSpan, minGridHeight, minGridWidth int, focus bool) *Grid
func (p *Pages) AddPage(name string, item Primitive, resize, visible bool) *Pages
例として、Flex, Grid, Pagesでレイアウトを設定する方法を記載しています。
flex := tview.NewFlex()
flex.SetDirection(tview.FlexRow). // 各ウィジェットを縦に配置する設定
AddItem(widget1, 3, 0, true). // widget1は常に3行固定で表示する
AddItem(widget2, 0, 1, false). // widget2は残りの領域の1/3で表示する
AddItem(widget3, 0, 2, false) // widget3は残りの領域の2/3で表示する
grid := tview.NewGrid()
grid.SetSize(5, 5, 0, 0). // 表示領域を縦横それぞれを5等分し、5x5のグリッドを作る
AddItem(widget4, 0, 0, 1, 1, 0, 0, true). //(0,0)から(1,1)にwidget4を表示する
AddItem(widget5, 1, 1, 2, 2, 0, 0, true). //(1,1)から(3,3)にwidget5を表示する
AddItem(widget6, 3, 3, 2, 2, 0, 0, true) //(3,3)から(5,5)にwidget6を表示する
page := tview.NewPages()
page.AddPage("page1", flex, true, true). // flexをpageに追加し、表示する
AddPage("page2", grid, true, false) // gridをpageに追加するが、表示させない
上記を実行した場合は、下記のようになります。
(左:初期表示 右:Pageをスイッチした時の表示)
実際に動作するコードはこちら
package main
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func main() {
app := tview.NewApplication()
widget1 := tview.NewBox()
widget1.SetTitle("widget1").
SetBorder(true)
widget2 := tview.NewBox()
widget2.SetTitle("widget2").
SetBorder(true)
widget3 := tview.NewBox()
widget3.SetTitle("widget3").
SetBorder(true)
widget4 := tview.NewBox()
widget4.SetTitle("widget4").
SetBorder(true)
widget5 := tview.NewBox()
widget5.SetTitle("widget5").
SetBorder(true)
widget6 := tview.NewBox()
widget6.SetTitle("widget6").
SetBorder(true)
flex := tview.NewFlex()
flex.SetTitle("flex").
SetBorder(true)
flex.SetDirection(tview.FlexRow).
AddItem(widget1, 3, 0, true).
AddItem(widget2, 0, 1, false).
AddItem(widget3, 0, 2, false)
grid := tview.NewGrid()
grid.SetTitle("grid").
SetBorder(true)
grid.SetSize(5, 5, 0, 0).
AddItem(widget4, 0, 0, 1, 1, 0, 0, true).
AddItem(widget5, 1, 1, 2, 2, 0, 0, true).
AddItem(widget6, 3, 3, 2, 2, 0, 0, true)
page := tview.NewPages()
page.SetTitle("page1").
SetBorder(true)
page.AddPage("page1", flex, true, true).
AddPage("page2", grid, true, false)
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyTab:
curPage, _ := page.GetFrontPage()
if curPage == "page1" {
page.SwitchToPage("page2")
page.SetTitle("page2")
} else {
page.SwitchToPage("page1")
page.SetTitle("page1")
}
return nil
}
return event
})
if err := app.SetRoot(page, true).Run(); err != nil {
panic(err)
}
}
フォーカスについて
tviewにはフォーカスという概念があり、後述するApplication.SetFocus
関数により、ウィジェットにフォーカスを当てる事ができます。
フォーカスが当たったウィジェットのみキー入力が伝えられます。
そのため、特定のウィジェットにキー入力を伝えたい場合は、そのウィジェットにフォーカスを当てる必要があります。
注意点として、フォーカスは入れ子構造のルートのウィジェットから末端のウィジェットまで順番に伝搬されていく形となります。
そのため、キー入力はルートのウィジェットのcapture
関数から順番に処理され、末端のウィジェットのcapture
関数やdefaultのキー設定へ伝えられていきます。
レイアウトウィジェットにフォーカスを当たった場合、レイアウトウィジェットに追加されているウィジェットのどれかにフォーカスが当たります。
FlexやGridにフォーカスが当たった場合、AddItem
の引数focus
がtrue
のウィジェットにフォーカスが当たります。
複数のウィジェットのfocus
がtrue
となっている場合は、先に追加したウィジェットにフォーカスが当たります。
flex := tview.NewFlex()
flex.AddItem(inputField, 3, 0, false). // 引数focusがfalseのため、フォーカスが当たらない
AddItem(textView, 0, 1, true). // フォーカスが当たるウィジェット
AddItem(textView2, 0, 2, true) // 先に追加したウィジェットの引数focusがtrueのため、フォーカスが当たらない
Pagesの場合は、一番手前に表示されているPage(一番最後に追加した引数visible
がtrue
のPage)のウィジェットにフォーカスが当たります。
page := tview.NewPages()
page.AddPage("page1", flex, true, true). // 後に追加したウィジェットの引数visibleがtrueのため、フォーカスが当たらない
AddPage("page2", flex, true, true). // フォーカスが当たるウィジェット
AddPage("page3", grid, true, false) // 引数visbleがfalseのため、フォーカスが当たらない
3. Applicationの全体設定
レイアウト設定が完了したら、Application構造体により全体的を設定を行い、最後にApplication.Run
関数を実行する事でTUIの描画を開始する事ができます。
主に下記のような設定が可能となります。
呼び出すウィジェットを設定
Application.SetRoot
関数により、どのウィジェットを呼び出すかを設定します。
これは、Application.Run
関数を実行する前にかならず実行する必要があります。
引数root
には、1,2にて一つにまとめたウィジェット(入れ子構造のルートのウィジェット)を指定します。
func (a *Application) SetRoot(root Primitive, fullscreen bool) *Application
フォーカスの設定
Application.SetFocus
関数により、どのウィジェットにフォーカスを当てるかを設定します。
Application.SetFocus
関数を呼び出さない場合は、defaultでApplication.SetRoot
関数で指定したウィジェットにフォーカスが当たります。
func (a *Application) SetFocus(p Primitive) *Application
また、各ウィジェットのcapture
関数にてApplication.SetFocus
関数を呼び出す事で、キー入力をトリガーとして、フォーカスを当てるウィジェットを変更する事も可能です。
キー入力の設定
Application.SetInputCapture
関数により、アプリケーション全体のキー入力の制御が可能となります。
設定方法は、各ウィジェットのSetInputCapture
関数と同様となります。
func (a *Application) SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) *Application
処理される順番は下記の通り、Application.SetInputCapture
関数にて設定したcapture
関数が一番先にキー入力を受け取ります。
そのため、全てのウィジェットの共通処理については、個々のウィジェットで実装するよりも、こちらで制御した方が便利かと思います。
アプリケーションの実行/停止
全ての設定が完了したら、Application.Run
関数により、TUIの描画を開始する事ができます。
また、Application.Stop
関数を呼び出すか、Ctrl-Cを入力する事で動作を停止する事ができます。
package main
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func main() {
app := tview.NewApplication()
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEnter: // Enterを入力したとき
app.Stop() // アプリケーションを停止
return nil
}
return event
})
textView := tview.NewTextView().SetBorder(true)
textView2 := tview.NewTextView().SetBorder(true)
flex := tview.NewFlex().
AddItem(textView, 0, 1, true).
AddItem(textView2, 0, 1, true)
if err := app.SetRoot(flex, true).Run(); err != nil { // flexをルートにして実行
panic(err) // 異常が起こった場合はpanicさせる
}
}
ここまで、tviewを使用してTUIツールを作成する方法を一通り説明してきましたが、tviewにはまだまだ説明できていない機能もたくさん実装されています。
詳細は、ドキュメントやGithub wikiを確認してみてもらえればと思います。
その他参考情報
ここからは、僕自身がtviewを使用してTUIツールを作る上で、ハマった点を記載します。
(全てドキュメントやGithub wikiに記載されている内容です)
複数のウィジェットを非同期に動作させる方法
例えば、下記のようにinputFieldにてキー入力がある度に重い処理を行う必要がある場合、inputFieldに連続でキー入力をしても、一つ入力する度に重い処理が完了するまで待ち時間が発生します。
app := tview.NewApplication()
textView := tview.NewTextView()
inputField := tview.NewInputField()
inputField.SetChangedFunc(func(text string) { // inputFieldの入力内容が変更される度に実行
// 何らかの重い処理を実行
textView.SetText("重い処理の結果") // 重い処理の結果をtextViewのテキストエリアに表示する
return
})
このような場合、重い処理を別のgoroutineで処理させる事により、inputFieldでスムーズにキー入力を行えるようになりますが、mainのgoroutine以外でtviewの関数を呼び出す場合、競合状態となる可能性があります。
競合を回避するには、QueueUpdate
/QueueUpdateDraw
関数でtviewの関数をラップして、mainのgoroutineと処理を同期する必要があります。
inputField.SetChangedFunc(func(text string) {
go func() {
// 何らかの重い処理を実行
- textView.SetText("重い処理の結果")
+ app.QueueUpdate(func() {
+ textView.SetText("重い処理の結果")
+ })
}()
return
})
TextViewのWrite関数について
TextViewのテキストエリアの内容を変更する方法は下記二つあります。
-
SetText
関数: テキストエリアの内容をセットする(前に表示していた内容は削除) -
Write
関数: テキストエリアの内容に追記する(前に表示していた内容は残る)
また、Write
関数によりTextViewは、io.Writerインタフェースを満たす構造体となっているため、io.Pipe
やfmt.Fprint
等により、テキストエリアに書き込む事ができます。
Write
関数は、mainのgoroutine以外から呼び出しても競合状態にはなりませんが、他のgoroutineから書き込んだ場合、書き込んだテキストがすぐに反映されない場合があります(main goroutine上の再描画のタイミングを待つ必要があります)。
それを解決するために、SetChangedFunc
関数を使用します。
func (t *TextView) SetChangedFunc(handler func()) *TextView
SetChangedFunc
関数は、引数handler
関数を設定する事によりテキストエリアの内容が変更される度にhandler
関数を実行できるようになります。
handler
関数で、Application.Draw
関数を実行する事により、Write
関数でテキストエリアに書き込む度に、再描画する事ができます。
app := tview.NewApplication()
textView := tview.NewTextView()
inputField := tview.NewInputField()
textView.SetChangedFunc(func() { // テキストエリアの内容が変更される度に
app.Draw() // 再描画を実行
})
inputField.SetChangedFunc(func(text string) {
go func() {
// 何らかの重い処理を実行
fmt.Fprintln(textView, "重い処理の結果") //textView.Write関数を使用して書き込み
}()
return
})
あとがき
最後まで読んでいただきありがとうございます。
記事の内容に不備等あれば、教えていただけますと幸いです。
また、今回説明してきた内容を元に、shellコマンドのパイプ前後の入出力情報をインタラクティブに表示するツールを作ってみました。
githubにアップロードしていますので、もし興味があれば、確認してみてもらえればと思います。
-
仕様変更を行ったissue: https://github.com/rivo/tview/issues/421 ↩︎
Discussion