Go言語(golang)でTUIアプリを作ろう ( その9 DialogBoxの利用 )

新たなPrimitiveとして、”Dialog"を作成してみる。


アプリケーション画面設計を行う際には、データを選択したり、条件入力を行う機能が不可欠です。一般にそのような補助入力画面は、DialogBoxという形式で実装されています。
IT用語辞典では、

ダイアログボックスとは、コンピュータの操作画面で、利用者に何らかの入力を促すために表示される矩形の領域のこと。小さなウィンドウの形で表示されることが多い。“dialog” は「対話」の意。

と定義されています。

多くのUIツールキットでは、このようなDialogBoxが標準ツールとして提供されていますが、残念ながらtviewにはないのです。
となれば、しかたがない。なんとかDialogBoxもどきを自作しないといけません。


[1] DialogBoxをどう作るか

tviewに「DialogBoxは存在しない」と述べましたが、それに近いものが実装されています。

(1) tviewのModal

それが、ModalというPrimitiveです。Modalでは、下記のような画面を作成することができます。

コードは、下記になります。

// Demo code for the Modal primitive.  
package main  
  
import (  
	"github.com/rivo/tview"  
)  
  
func main() {  
	app := tview.NewApplication()  
	modal := tview.NewModal().  
		SetText("Do you want to quit the application?").  
		AddButtons([]string{"Quit", "Cancel"}).  
		SetDoneFunc(func(buttonIndex int, buttonLabel string) {  
			if buttonLabel == "Quit" {  
				app.Stop()  
			}  
		})  
	if err := app.SetRoot(modal, false).EnableMouse(true).Run(); err != nil {  
		panic(err)  
	}  
}  

見ていただけばわかりますが、これはいわゆるMessageBoxです。Modalで画面に表示できるのは、一行のテキストと複数のButtonのみで、汎用性のない構成になってしまっています。本来であれば、ベースとなるDialogBoxをまず提供し、その一つの要素としてMessgaeBoxが作成できるよう設計すべきなのです。

(2) Modalを改造し、DialogBoxへ

tviewのソースコードを眺めながら変更方法を模索しました。
Modalでは、Frame上にFormを配置することで画面を作成していますが、このFrame上のFormFlexコンテナに変更してやれば、汎用化することができそうです。

(3) Primitiveの作成手法

それでは、この考え方に沿って汎用性のあるDialogBoxを作成していきましょう。
Tviewにおける部品は、すべてPrimitiveと呼ばれますが、その作成については、ここにドキュメントがあり、その中盤の「Writing Primitives」という部分に具体的な手法が書かれています。簡単にまとめれば、「新規作成には、Primitive interfaceを実装しないといけないが、それにはBoxをサブクラス化するとよい」ということのようです。Go言語(golang)でTUIアプリを作ろう(その3 tviewの基本構造)で示した概念図のとおりです。


[2] MyDialogの作成

それでは、実際にDialogBoxを作成していきましょう。
まず、新たにパッケージmytviewを設定して、そこにMyDialogとして作成、下記のメソッドを定義しました。

(1) Dialogの生成

func NewMyDialog(width int, height int, ratioX int, ratioY int) *MyDialog  

この関数でDialogを生成します。
widthheightは、表示するDialogのサイズ。
ratioXratioYは、表示位置を指示します。画面の中央が21で下に、3以上で上に移動します。

(2) コンテナのセット

func (m *MyDialog) SetFlex(flex *tview.Flex) *MyDialog  

表示する画面を定義したFlexコンテナをDialogにセット。

(3) コンテナから入力されたデータの取り込み

func (m *MyDialog) SetParm(label string, input string) *MyDialog  

Dialogで入力されたデータを取り込みます。この情報は、下記のSetDoneFuncのパラメータとなります。

(4) 処理ルーティンの設定

func (m *MyDialog) SetDoneFunc(handler func(buttonLabel string, input string)) *MyDialog  

プログラムで入力データを扱う関数の定義です。

(5) Dialogの描画

func (m *MyDialog) Draw(screen tcell.Screen)  

Dialogの描画ルーティン。NewMyDialogで設定されたサイズ、及び位置に画面を描画します。

→詳細は、ソース(mydialog.go)を参照してください。


[3] MyDialogの利用

Dialog上のFlexコンテナに具体的なフィールドを設定して、実際のDialogBoxとして動作するよう、mywidgetに3つの関数を追加します。

関数のパラメータですが、widthheightratioXratioYは、NewMyDialogに引き渡すもの、borderは枠線を設定するかどうかのフラグで、すべての関数共通となります。

(1) SelectBox

func MySelectBox(items []string, width int, height int, ratioX int, ratioY int, current int, border bool) *mytview.MyDialog {  

itemsは、リスト表示する文字列、currentは、初期選択対象となる項目の番号。最上位は0になります。

(2) InputBox

func MyInputDialog(app *tview.Application, search string, width int, height int, ratioX int, ratioY int, border bool) *mytview.MyDialog {  

searchは、初期表示する文字列です。Buttonは、OKCancelに決め打ちしています。

(3) MessageBox

func MyMessageBox(app *tview.Application, message string, width int, height int, ratioX int, ratioY int, border bool) *mytview.MyDialog {  

messageは、DialogBoxに表示するメッセージ。Buttonは、YesNoに決め打ちしています。

ボーダー枠の問題

上の画面を見ていただくとわかりますが、DialogBoxの枠線が連続していませんね。これについては、下記に情報があります。

これによれば、下記の環境変数を指定すると良いとのこと。

export LC_CTYPE="en_US.UTF-8"  

確かに、枠線がきれいに表示されます。これは文字列の描画に関わる環境変数のようですが、英語モードにある1バイト罫線や枠線との関係でしょうか。


[4] プログラムでの使用

それでは、実際にプログラムでDialogBoxを使用してみましょう。
今回は、List画面(list.go)に、3つのボタンを追加し、そこから各DialogBoxを呼び出しています。

	btnT := myButton("<T>").SetSelectedFunc(func() {  
		self.getTable(pages, common)  
	})  
                      :  
  
	btnS := myButton("<S>").SetSelectedFunc(func() {  
		self.getSearch(pages, common)  
	})  
                      :  
  
	btnC := myButton("<C>").SetSelectedFunc(func() {  
		self.getYesNo(pages, common, "Yes or No")  
	})  
                      :  

それぞれの処理は、下記となります。

func (self *MainList) getTable(pages *tview.Pages, common *Common) {  
	s := strings.Split("テーブルA テーブルB テーブルC テーブルE テーブルF テーブルG テーブルH テーブルI テーブルJ テーブルK テーブルL テーブルM", " ")  
	tables := MySelectBox(s, 30, 20, 2, 3, 0, true).  
		//tables := MySelectBox(s, 30, 20, 2, 3, 0, false).  
		SetDoneFunc(func(buttonLabel string, inputString string) {  
			if buttonLabel == "OK" {  
				common.reset()  
				common.tableName = inputString  
				pages.RemovePage("table")  
				self.display(common)  
			}  
		})  
  
	tables.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {  
		switch event.Key() {  
		case tcell.KeyEscape:  
			pages.RemovePage("table")  
			self.display(common)  
		}  
		return event  
	})  
	// @@@ if you like full screen dialog, replace line below.  
	pages.AddPage("table", tables, false, true)  
	//self.app.SetRoot(tables, true)  
	// @@@@  
}  
  
func (self *MainList) getSearch(pages *tview.Pages, common *Common) {  
	search := MyInputDialog(self.app, common.search, 40, 7, 2, 4, true).  
		//search := MyInputDialog(self.app, common.search, 40, 7, 2, 4, false).  
		SetDoneFunc(func(buttonLabel string, inputString string) {  
			if buttonLabel == "OK" {  
				common.search = inputString  
			} else if buttonLabel == "Cancel" {  
				common.search = ""  
			}  
			pages.RemovePage("search")  
			common.resetPaging()  
			self.display(common)  
		})  
	// @@@ if you like full screen dialog, replace line below.  
	pages.AddPage("search", search, false, true)  
	//self.app.SetRoot(search, true)  
	// @@@@  
  
}  
func (self *MainList) getYesNo(pages *tview.Pages, common *Common, msg string) {  
	yesno := MyMessageBox(self.app, msg, 30, 7, 2, 3, true).  
		//yesno := MyMessageBox(self.app, msg, 30, 7, 2, 3, false).  
		SetDoneFunc(func(buttonLabel string, inputString string) {  
			if buttonLabel == "Yes" {  
			}  
			pages.RemovePage("yesno")  
			self.display(common)  
		})  
  
	yesno.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {  
		switch event.Key() {  
		case tcell.KeyEscape:  
			pages.RemovePage("yesno")  
			self.display(common)  
		}  
		return event  
	})  
	// @@@ if you like full screen dialog, replace line below.  
	pages.AddPage("yesno", yesno, false, true)  
	//self.app.SetRoot(yesno, true)  
	// @@@@  
}  

DialogBoxではなく独立した別画面として扱いたい場合は、上のコード上の@@@で囲んである行を下記のように入れ替えてください。(MessageBoxの例)

	// @@@ if you like full screen dialog, replace line below.  
	//pages.AddPage("yesno", yesno, false, true)  
	self.app.SetRoot(yesno, true)  
	// @@@@  

[5] プログラムの構造と実行

プログラムのディレクトリ構造は下記のようになります。

cmd/  
  main.go  
tui/  
  application.go  
  common.go  
  list.go  
  mywidget.go  
mytview  
  mydialog.go  
go.mod  
go.sum  

実行は前回までと同様です。

go run cmd/main.go  

以上で、懸案の一つであったDialogBoxを実装することが出来ました。残された課題は複数行入力ですね。次回はこれに対応しましょう。


ソースコードについて

GitHubに登録しました。今回のコードは、Section09となります。