Go言語(golang)でTUIアプリを作ろう ( その5 Listで一覧表示 )

tviewのListを使ってみよう。


今回は、「画面に複数行を一覧表形式で表示する」アプリケーションを作成していきます。tviewでは、Listがこの機能を担いますので、その概要について説明しましょう。


[1] Listの設定

下記のメソッドで定義します。

func NewList() *List  

(1) List標準構造

定義したListに対し、下記のAddItemメソッドで項目を追加していきます。
tviewList構造は少し変わっていて、二行構成でショートカットキーまで指定する形になっています。

func (l *List) AddItem(mainText, secondaryText string, shortcut rune, selected func()) *List  

下記は、tviewのListサンプルにある例です。

//  
// s05_00.go  
//  
package main  
import (  
	"github.com/rivo/tview"  
)  
func main() {  
	app := tview.NewApplication()  
	list := tview.NewList().  
		AddItem("List item 1", "Some explanatory text", 'a', nil).  
		AddItem("List item 2", "Some explanatory text", 'b', nil).  
		AddItem("List item 3", "Some explanatory text", 'c', nil).  
		AddItem("List item 4", "Some explanatory text", 'd', nil).  
		AddItem("Quit", "Press to exit", 'q', func() {  
			app.Stop()  
		})  
	if err := app.SetRoot(list, true).SetFocus(list).Run(); err != nil {  
		panic(err)  
	}  
}  

これを実行すると、下のように表示されるわけです。

(2) 一行指定したい場合

こちらが一般的な形態と思いますが、AddItemメソッドを下記のように指定します。

list.AddItem(item, "", 0, nil)  

(3) 属性

属性は、下記のように適宜設定していきます。一行指定の場合は、ShowSecondaryText(false)を設定します。

list := tview.NewList().ShowSecondaryText(false).SetSelectedTextColor(tcell.ColorWhite).SetSelectedBackgroundColor(tcell.ColorAqua).SetSelectedFocusOnly(true)  

[2] Listの使用方法

以下、具体的な例を示していきましょう。

(1) List使用のプログラム例

ここでは、ベースにPagesコンテナを指定し、その上にFlexコンテナを配置します。さらに、そのFlexコンテナ上に、一行のheader、複数行のbody、一行のfooterという形で構成します。(ちなみに以後の例は、すべてこれを標準として採用していきます。)

まずは、body部に、Listデータを設定していきましょう。データは、仮のものを100件ほど渡すこととします(createListData()メソッド)。

//  
// s05_01.go  
//  
package main  
import (  
	"fmt"  
	"github.com/gdamore/tcell/v2"  
	"github.com/rivo/tview"  
)  
  
var listData = createListData()  
func createListData() []string {  
	var listData []string  
	var s string  
	for i := 0; i < 100; i++ {  
		s = "テストデータ" + fmt.Sprintf("%d", i)  
		listData = append(listData, s)  
	}  
	return listData  
}  
func getListData() []string {  
	return listData  
}  
func myButton(label string) *tview.Button {  
	button := tview.NewButton(label)  
	button.SetBackgroundColor(tcell.ColorBlack)  
	button.SetLabelColor(tcell.ColorYellow).SetLabelColorActivated(tcell.ColorBlack).SetBackgroundColorActivated(tcell.ColorYellow)  
	return button  
}  
func setList(list *tview.List) *tview.List {  
	list.Clear()  
	items := getListData()  
	for _, item := range items {  
		list.AddItem(item, "", 0, nil)  
	}  
	return list  
}  
func doformat(app *tview.Application) tview.Primitive {  
	pages := tview.NewPages()  
	footer := tview.NewTextView().SetText("これはフッター")  
	header := tview.NewFlex()  
	btnQ := myButton("<Q>").SetSelectedFunc(func() {  
		app.Stop()  
	})  
	header.AddItem(btnQ, 6, 0, true)  
	list := tview.NewList().ShowSecondaryText(false).SetSelectedTextColor(tcell.ColorWhite).SetSelectedBackgroundColor(tcell.ColorAqua).SetSelectedFocusOnly(true)  
	list = setList(list)  
	main := tview.NewFlex().SetDirection(tview.FlexRow).  
		AddItem(header, 1, 0, true).  
		AddItem(list, 0, 1, false).  
		AddItem(footer, 1, 0, false)  
	pages.AddPage("main", main, true, true)  
	pages.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {  
		switch event.Key() {  
		case tcell.KeyRune:  
			switch event.Rune() {  
			case 'q':  
				app.Stop()  
			}  
		}  
		return event  
	})  
	return pages  
}  
func main() {  
	app := tview.NewApplication()  
	pages := doformat(app)  
	if err := app.SetRoot(pages, true).EnableMouse(true).Run(); err != nil {  
		panic(err)  
	}  
}  

実行してみましょう。

$ go run s05_01.go  

カーソルがheader部のButtonから、body部のListに移らない....ですね。
これは、headerからbodyへの「フォーカス移動が出来ない状態」になっているからです。


(2) Listにフォーカスを移動させる

header部とbody部のキー入力部分に以下のコードを追加します。メソッドは、お馴染みのSetInputCaptureです。

//  
// s05_02.go  
//  
                 :  
                 :  
func doformat(app *tview.Application) tview.Primitive {  
                 :  
                 :  
	header.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {  
		switch event.Key() {  
		case tcell.KeyDown:  
			app.SetFocus(list)  
			return nil  
		}  
		return event  
	})  
	body.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {  
		switch event.Key() {  
		case tcell.KeyUp:  
			if list.GetCurrentItem() == 0 {  
				app.SetFocus(btnQ)  
				return nil  
			}  
		}  
		return event  
	})  
                 :  

実行してみましょう。

$ go run s05_02.go  

今回は問題なくフォーカスが移動しました。body部のListから、header部のButtonへの移動も問題ないでしょう。
しかし、下部にカーソルを移動していくと、画面に表示できなかったデータが、スクロールして出てきてしまいます。これでもいいのかもしれませんが、TUIでは適切でないインタフェースでしょう。やはり、ここは画面のサイズに適切な行数分だけを表示したいものです。


[3] 画面サイズに合わせた表示

さらに先に進みましょう。
今回のアプリケーションでは、データは100件ほど用意していますが、通常のターミナルは24行が標準です。headerfooterに1行ほど使用しますから、body部は22行ほどになるわけです。もちろん、決め打ちするのではなく、ターミナルのサイズに従って表示行を決定しないといけません。

(1) 画面サイズの取得

実は、tviewでは「画面サイズを取得するメソッド」がないのです。これは大きな抜けだと思います。
tview.applicationのコードを見ると、内部でtcell.Screenオブジェクトを持っているので、下記のようなメソッドを用意してくれれば済むことなんですけどね。

func (a *Application) GetScreenSize() (width, height int){  
	cols, rows := screen.Size()  
	return cols, rows  
}  

しかたがないので、自前で作成します。アプリケーションの起動前に、tcellScreenを作成して、サイズを取得しておきます。このやり方だと画面のリサイズには対応できませんが、これはtview.applicationが起動以降、サイズの再取得が出来ないのですから致し方ありません。

var cols, rows = GetScreenSize()  
func GetScreenSize() (int, int) {  
	s, _ := tcell.NewScreen()  
	s.Init()  
	cols, rows := s.Size()  
	s.Fini()  
	return cols, rows  
}  
  

下記のメソッド内で、画面サイズにあった件数(row-2)をListに格納するよう変更します。

func setList(list *tview.List) *tview.List {  
	list.Clear()  
	item := getListData()  
	for i := 0; i < rows-2; i++ {  
		list.AddItem(item[i], "", 0, nil)  
	}  
	return list  
}  

(2) 最終コード

下記のコードとなりました。

//  
// s05_03.go  
//  
package main  
  
import (  
	"fmt"  
	"github.com/gdamore/tcell/v2"  
	"github.com/rivo/tview"  
	"strconv"  
)  
  
var cols, rows = GetScreenSize()  
func GetScreenSize() (int, int) {  
	s, _ := tcell.NewScreen()  
	s.Init()  
	cols, rows := s.Size()  
	s.Fini()  
	return cols, rows  
}  
  
var listData = createListData()  
  
func createListData() []string {  
	var listData []string  
	var s string  
	for i := 0; i < 100; i++ {  
		s = "テストデータ" + fmt.Sprintf("%d", i)  
		for j := len(s); j < cols; j++ {  
			s = s + " "  
		}  
		listData = append(listData, s)  
	}  
	return listData  
}  
  
func getListData() []string {  
	return listData  
}  
  
func myButton(label string) *tview.Button {  
	button := tview.NewButton(label)  
	button.Box = tview.NewBox().SetBackgroundColor(tcell.ColorBlack)  
	button.SetLabelColor(tcell.ColorYellow).SetLabelColorActivated(tcell.ColorBlack).SetBackgroundColorActivated(tcell.ColorYellow)  
	return button  
}  
  
func setList(list *tview.List) *tview.List {  
	list.Clear()  
	item := getListData()  
	for i := 0; i < rows-2; i++ {  
		list.AddItem(item[i], "", 0, nil)  
	}  
	return list  
}  
  
func doformat(app *tview.Application) tview.Primitive {  
	list := tview.NewList().ShowSecondaryText(false).SetSelectedTextColor(tcell.ColorWhite).SetSelectedBackgroundColor(tcell.ColorAqua).SetSelectedFocusOnly(true)  
	list = setList(list)  
  
	pages := tview.NewPages()  
	footer := tview.NewTextView().SetText("これはフッター")  
	header := tview.NewFlex()  
	btnQ := myButton("<Q>").SetSelectedFunc(func() {  
		app.Stop()  
	})  
	header.AddItem(btnQ, 6, 0, true)  
  
	body := tview.NewFlex().SetDirection(tview.FlexRow).AddItem(list, 0, 1, true)  
  
	main := tview.NewFlex().SetDirection(tview.FlexRow).  
		AddItem(header, 1, 0, true).  
		AddItem(body, 0, 1, true).  
		AddItem(footer, 1, 0, false)  
	pages.AddPage("main", main, true, true)  
  
	header.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {  
		switch event.Key() {  
		case tcell.KeyDown:  
			app.SetFocus(list)  
			return nil  
		}  
		return event  
	})  
	body.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {  
		switch event.Key() {  
		case tcell.KeyUp:  
			if list.GetCurrentItem() == 0 {  
				app.SetFocus(btnQ)  
				return nil  
			}  
		}  
		return event  
	})  
	pages.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {  
		switch event.Key() {  
		case tcell.KeyRune:  
			switch event.Rune() {  
			case 'q':  
				app.Stop()  
			}  
		}  
		return event  
	})  
  
	list.SetSelectedFunc(func(index int, s string, secondary string, code rune) {  
		footer.SetText("pos:" + strconv.Itoa(index) + " data:" + s)  
	})  
  
	return pages  
}  
  
func main() {  
	app := tview.NewApplication()  
	pages := doformat(app)  
	if err := app.SetRoot(pages, true).EnableMouse(true).Run(); err != nil {  
		panic(err)  
	}  
}  
  

実行してみましょう。

$ go run s05_03.go  

これで、想定した動きになりました。
矢印キーでList内を移動できますし、Enterキーを叩くと、footer部に選択されたデータが表示されています。

しかし、画面上には22行のデータだけが表示されています。実際のデータは100件ほどあるわけですから、表示されないデータがあるのは困りものです。次回は「ページング処理」を入れて、この部分を追加していきましょう。


ソースコードについて

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