🚁

【Go】tviewによるTUIツール作成

2021/01/30に公開

はじめに

この記事は、Goの勉強を兼ねてTUIツールを作った際に、調べた内容をまとめたものとなります。
GoにはTUIライブラリはいくつかありますが、今回はtviewというライブラリを使用しました。
https://github.com/rivo/tview

tviewは、ドキュメント設定例が豊富で簡単にTUIツールが作れるため、とても便利なライブラリなのですが、日本語の解説記事があまり見当たらなかったため、今回記事にしてみました。

tviewについて

tviewはtcellを元に様々なウィジェットが実装されており、それらを組み合わせてTUIツールを作る事ができるライブラリとなります。
tviewは大きく分けて、二種類の構造体で構成されています。

  • ウィジェット: 入力フォームやテーブル等を構成する構造体
  • Application: 各ウィジェットを実際に描画したり等、全体を制御する構造体

tviewを用いたTUIツールの実装の流れとしては、基本的には下記となります。

  1. 各ウィジェットの設定
  2. レイアウト設定
  3. アプリケーションの全体設定

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.Keyswitch/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の引数focustrueのウィジェットにフォーカスが当たります。
複数のウィジェットのfocustrueとなっている場合は、先に追加したウィジェットにフォーカスが当たります。

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(一番最後に追加した引数visibletrueの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.Pipefmt.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にアップロードしていますので、もし興味があれば、確認してみてもらえればと思います。
https://github.com/minefuto/tp

脚注
  1. 仕様変更を行ったissue: https://github.com/rivo/tview/issues/421 ↩︎

Discussion