GoLang TUI 库 tview 入门

golangtview 库是一个用 Go 语言开发的富文本 UI 库,主要用于创建终端应用程序。它提供了一组构建交互式界面的组件,如表格、表单、列表和其他各种布局组件。这些组件可以帮助我们在终端中创建出色的文本界面,从而为命令行工具或任何终端应用程序提供更加丰富和用户友好的操作界面。以下是其主要特性:

  • 终端兼容性:支持跨平台,在多种 Unix/Linux 终端和 Windows 命令提示符下运行。
  • 组件丰富:提供表格、表单、列表等多种交互界面组件。
  • 事件处理:简化了 UI 元素的布局和事件处理(如键盘和鼠标事件)。
  • 样式自定义:支持颜色和样式自定义,便于创建个性化界面。
  • 开发便利性:API 设计简洁,易于上手,降低终端 UI 开发复杂度。

为什么使用 tview

在工作中,我需要使用 tview 编写一个系统控制台程序,实现 ssh 登录成功后显示一个控制台页面,屏蔽所有按键并提供给用户一些特定的功能入口,目的是限制用户使用系统,从而保护系统及相关服务。

Hello tview

创建 go 项目并安装 tview。

1
go get github.com/rivo/tview@master

编写一个标题为“Hello, World!”的程序并显示在终端上面。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"github.com/rivo/tview"
)

func main() {
box := tview.NewBox().SetBorder(true).SetTitle("Hello, world!")
if err := tview.NewApplication().SetRoot(box, true).Run(); err != nil {
panic(err)
}
}

在上面的代码中,使用 tview.NewApplication() 创建一个 tview 应用,并使用 SetRoot() 函数设置 box 为根元素并启动程序,SetRoot() 函数的第二个参数用于指定是否全屏显示。

组件

List 列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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)
}
}

使用 tview.NewList() 创建一个 list 组件,并使用 AddItem() 函数添加一些元素,然后使用 appSetRoot() 函数将 list 设置为根元素,然后使用 SetFocus() 函数设置为焦点并启动程序。

先来看看 AddItem() 函数有哪些参数:

1
func (l *List) AddItem(mainText, secondaryText string, shortcut rune, selected func()) *List {}
  • mainText 用于显示一个列表项的主文本
  • secondaryText 用于显示一个列表项的次文本,可以添加一些长的描述文本在这里,用于描述这个列表项的作用
  • shortcut 用于绑定快捷键,它是一个 rune 类型,可以绑定数字类型和字符类型的快捷键
  • selected 参数是一个函数,表示该列表项被选中时的触发操作,为 nil 时表示什么都不做。

程序启动后,可使用设置的快捷键和上下方向键或 Tab 键选择列表项,并按下回车键触发动作。

值得注意的是,当使用快捷键选择了某个列表项后,无需再次按下回车键就能触发动作。

使用上下键或 Tab 键选择列表项后则需要按下回车键触发动作。

如果不想显示每个列表项的次要文本,需要调用 ShowSecondaryText(false) 函数来隐藏次要文本,这样就仅显示列表项的主文本了,不过此时 secondaryText 不能省略,需要传递空字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
app := tview.NewApplication()
list := tview.NewList().
AddItem("List item 1", "", 'a', nil).
AddItem("List item 2", "", 'b', nil).
AddItem("List item 3", "", 'c', nil).
AddItem("List item 4", "", 'd', nil).
AddItem("Quit", "", 'q', func() {
app.Stop()
})
list.ShowSecondaryText(false)

if err := app.SetRoot(list, true).SetFocus(list).Run(); err != nil {
panic(err)
}
}

在 UI 设计中,模态窗口属于对话框的一种,模态窗口(Modal Dialog)是指中断用户操作,用户必须完成对话框内任务(或主动关闭对话框后)才能够继续主窗口操作的弹框。

tview 中针对模态窗口做了单独的封装,使用也很简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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).SetFocus(modal).Run(); err != nil {
panic(err)
}
}

tivew 中的模态窗口由提示文本、button 和 button 触发的函数组成,AddButtons() 函数传递一个字符串切片,用于添加 button,SetDoneFunc 函数传递一个 func(buttonIndex int, buttonLabel string) ,我们可以根据 buttonIndexbuttonLabel 做一些逻辑处理,其中 buttonIndex 是 Button 在字符串切片中的索引,buttonLabel 是 Button 在字符串切片中的文本。

Form 表单

tview 对表单也做了封装,可以很方便的创建一个表单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"github.com/rivo/tview"
)

func main() {
app := tview.NewApplication()
form := tview.NewForm().
AddDropDown("Title", []string{"Mr.", "Ms.", "Mrs.", "Dr.", "Prof."}, 0, nil).
AddInputField("First name", "", 20, nil, nil).
AddInputField("Last name", "", 20, nil, nil).
AddTextArea("Address", "", 40, 0, 0, nil).
AddTextView("Notes", "This is just a demo.\nYou can enter whatever you wish.", 40, 2, true, false).
AddCheckbox("Age 18+", false, nil).
AddPasswordField("Password", "", 10, '*', nil).
AddButton("Save", nil).
AddButton("Quit", func() {
app.Stop()
})
form.SetBorder(true).SetTitle(" Enter some data ").SetTitleAlign(tview.AlignLeft)
if err := app.SetRoot(form, true).EnableMouse(true).Run(); err != nil {
panic(err)
}
}

这段代码创建一个简单的 TUI 表单应用程序。下面是代码的主要功能和组件解释:

  1. 初始化应用:通过 tview.NewApplication() 创建一个新的应用实例。
  2. 创建表单:使用 tview.NewForm() 构造一个表单,表单中包括了多种输入组件:
    • 下拉列表:包含称谓选项,初始选中第一个。
    • 输入字段:提供名字和姓氏的输入,长度限制为 20 个字符。
    • 文本区域:用于输入地址,没有字符限制。
    • 文本视图:显示静态文本作为注释或说明。
    • 复选框:询问是否年满 18 岁。
    • 密码字段:用于密码输入,显示字符为 * ,长度限制为 10 个字符。
    • 按钮:添加“保存”和“退出”按钮。“退出”按钮附有一个函数,当按钮被触发时,它会停止应用。
  3. 设置表单属性:为表单设置边框并添加标题,并将标题对齐设置为左对齐。
  4. 运行应用:将表单设置为应用的根组件,启用鼠标支持,并运行应用。如果运行过程中出现错误,程序将通过 panic(err) 抛出异常。

Pages 页面

tview 库中,Pages 组件是一个容器,用于组织和管理多个页面(或视图)。每个页面可以是任意的 tview 组件,例如 FormModalTextView 等。Pages 组件允许我们在同一个应用程序窗口中切换显示不同的页面,而无需启动新的窗口。这对于创建多步骤表单、向导界面或在同一应用中需要展示不同视图的情况非常有用。

Pages 组件的主要作用和特性包括:

  • 页面管理:允许添加、删除或切换显示的页面。每个页面都有一个唯一的标识符(ID),通过这个 ID 可以操作对应的页面。
  • 灵活的导航:支持在页面之间导航,例如,从主菜单跳转到设置页面,或在多步骤表单的不同步骤之间切换。
  • 叠加显示:可以配置 Pages 组件以叠加(overlay)模式显示页面,这对于显示对话框、警告或其他需要用户注意的信息非常有用。
  • 自定义显示逻辑:我们可以基于逻辑条件动态地添加、移除或切换页面,从而根据应用状态或用户输入创建复杂的用户界面流程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
"fmt"

"github.com/rivo/tview"
)

const pageCount = 5

func main() {
app := tview.NewApplication()
pages := tview.NewPages()
for page := 0; page < pageCount; page++ {
func(page int) {
pages.AddPage(fmt.Sprintf("page-%d", page),
tview.NewModal().
SetText(fmt.Sprintf("This is page %d. Choose where to go next.", page+1)).
AddButtons([]string{"Next", "Quit"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonIndex == 0 {
pages.SwitchToPage(fmt.Sprintf("page-%d", (page+1)%pageCount))
} else {
app.Stop()
}
}),
false,
page == 0)
}(page)
}
if err := app.SetRoot(pages, true).SetFocus(pages).Run(); err != nil {
panic(err)
}
}

这段代码演示了一个简单的多页面导航,代码解释如下:

  1. 初始化应用:通过 tview.NewApplication() 创建一个新的应用实例。
  2. 创建页面容器:使用 tview.NewPages() 创建一个页面容器,用于管理多个页面。
  3. 添加页面:循环添加页面,并设置页面 id,每个页面的 id 都是不同的,为每个页面创建一个模态对话框,每个对话框都设置有文本信息和两个按钮(”Next”和”Quit”)。
    • 文本:显示当前页面序号,并提示用户可以进行的操作。
    • 按钮:用户可以选择“Next”跳转到下一页,或“Quit”退出应用。通过 SetDoneFunc 设置按钮的响应函数。
  4. 页面导航:如果选择“Next”,应用程序会计算下一页的索引并切换到该页。如果当前页是最后一页,则跳转回第一页,实现循环浏览。选择“Quit”会调用 app.Stop(),停止应用程序。
  5. 运行应用:将页面容器设置为应用的根组件,并启动应用。如果运行过程中遇到错误,通过 panic 抛出异常。

为了更好的理解 Pages,我们把上面 List 组件的示例代码稍微改造一下,当按下 Q 键时,让程序弹出一个提示是否退出的模态窗口,当按下“确定”时程序退出,当按下“取消”时返回程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
"github.com/rivo/tview"
)

func main() {
app := tview.NewApplication()
pages := tview.NewPages()
list := tview.NewList().
AddItem("List item 1", "", 'a', nil).
AddItem("List item 2", "", 'b', nil).
AddItem("List item 3", "", 'c', nil).
AddItem("List item 4", "", 'd', nil).
AddItem("Quit", "", 'q', func() {
modal := tview.NewModal().SetText("确定要退出吗?").AddButtons([]string{"确定", "取消"})
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonIndex == 0 {
app.Stop()
} else {
pages.RemovePage("quitModalPage")
}
})
pages.AddPage("quitModalPage", modal, true, true)
})
list.ShowSecondaryText(false)
pages.AddPage("list", list, true, true)

if err := app.SetRoot(pages, true).SetFocus(list).Run(); err != nil {
panic(err)
}
}

在上面的代码中,使用 tview.NewPages() 新增了一个 Pages 组件,并使用 app.SetRoot(pages, true) 将该 pages 设置为 app 的根组件,这样整个程序是以 Pages 为基底,使用 pages.AddPage("list", list, true, true) 将列表添加到 Pages 中,然后在“Quit”这个列表项的触发函数中新增了一个模态窗口,使用 pages.AddPage() 函数将这个模态窗口添加到 pages 中,在模态窗口的 SetDoneFunc() 函数中处理当 buttonIndex == 0 时退出程序,否则使用 pages.RemovePage() 函数移除模态窗口所在的 Page,这样就实现了退出程序时提示用户。

Pages 组件有几个常用的函数:

1
2
3
4
5
6
7
func (p *Pages) AddPage(name string, item Primitive, resize, visible bool) *Pages {}
func (p *Pages) AddAndSwitchToPage(name string, item Primitive, resize bool) *Pages {}
func (p *Pages) SwitchToPage(name string) *Pages {}
func (p *Pages) RemovePage(name string) *Pages {}
func (p *Pages) HasPage(name string) bool {}
func (p *Pages) ShowPage(name string) *Pages {}
func (p *Pages) HidePage(name string) *Pages {}
  • name 参数是 Page 的 ID,在这个 App 中不能重复
  • item 参数 tview 提供的组件原语,即要添加到 Pages 中的 tview 组件
  • resize 参数判断是否自适应大小
  • visible 参数表示该 Page 的可见性

AddPage() 函数用于添加页面,当添加的页面需要在添加时立即显示出来时可以把 visible 设置为 true ,为 false 时表示仅添加页面但不显示。当页面不可见时,可以使用 ShowPage() 函数将页面设置为可见,相反可以使用 HidePage() 函数将页面设置为不可见。

SwitchToPage() 函数用于切换显示到另一个页面。

注意:使用 SwitchToPage() 函数切换显示页面会将其他所有页面设置为不可见,当需要在页面 A 上显示另一个页面 B 并且不隐藏页面 A 时不能使用此函数(例如上面在 List 中显示一个模态窗口),建议使用 ShowPage() 函数。

AddAndSwitchToPage() 函数是 AddPage()SwitchToPage() 函数的结合体,显示效果和 SwitchToPage() 是一致的。

RemovePage() 函数用于将此页面从 Pages 中移除,和 HidePage() 函数的区别在于,后者仅仅将可见性设置为 false.

基于 Pages 我们可以封装一个函数,当按下 Button 时在当前页面弹出一个模态窗口,在模态窗口中按下 Button 之后隐藏这个模态窗口,返回之前的页面,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在当前页面弹出一个模态窗口
func alert(pages *tview.Pages, id string, message string) *tview.Pages {
return pages.AddPage(
id,
tview.NewModal().
SetText(message).
AddButtons([]string{"确定"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
pages.RemovePage(id)
}),
false,
true,
)
}

alert 函数提供三个参数,下面是这三个参数的解释:

  • pages 是 tview 中的 Pages 原语,表示要在这个 Pages 中弹出模态窗口
  • id 是要添加的 Page ID
  • message 是模态窗口中要显示的提示内容

Flex 布局

tview 中,Flex 布局是一个非常灵活的容器,它允许我们以水平或垂直的方式组织和排列子组件。通过使用 Flex 布局,我们可以创建出响应式的用户界面,这些界面能够根据终端窗口的大小调整其内容的布局。这在创建动态和适应不同屏幕尺寸的终端应用程序时非常有用。

Flex 的主要特性:

  • 方向Flex 容器可以设置为水平或垂直布局。在水平布局中,子组件将被排列在一行内,依次从左到右添加;在垂直布局中,子组件将被排列在一列内,依次从上到下添加。
  • 大小调整Flex 允许子组件根据其内容自动调整大小,也可以指定固定大小或占用可用空间的比例。这种灵活性使得创建响应式布局成为可能。
  • 间距:可以为子组件之间设置间距,进一步增强布局的可控性和美观性。

Code :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"github.com/rivo/tview"
)

func main() {
app := tview.NewApplication()
flex := tview.NewFlex().
AddItem(tview.NewBox().SetBorder(true).SetTitle("Left (1/2 x width of Top)"), 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(tview.NewBox().SetBorder(true).SetTitle("Top"), 0, 1, false).
AddItem(tview.NewBox().SetBorder(true).SetTitle("Middle (3 x height of Top)"), 0, 3, false).
AddItem(tview.NewBox().SetBorder(true).SetTitle("Bottom (5 rows)"), 5, 1, false), 0, 2, false).
AddItem(tview.NewBox().SetBorder(true).SetTitle("Right (20 cols)"), 20, 1, false)
if err := app.SetRoot(flex, true).SetFocus(flex).Run(); err != nil {
panic(err)
}
}

上面的代码看似非常复杂,实则一点也不简单,让人一脸懵逼,我们先拆开看看,首先创建了一个水平布局的 Flex 布局组件(默认就是水平布局),然后使用 AddItem() 函数添加了一个 Box,先看看 AddItem() 函数的参数:

1
func (f *Flex) AddItem(item Primitive, fixedSize, proportion int, focus bool) *Flex {}
  • item: 要添加到布局中的组件,比如 Box、List。
  • fixedSize: 对于水平布局来说是固定宽度,对于垂直布局来说是固定高度。
  • proportion: 比例,按百分比算。
  • focus: 是否聚焦,即此组件是否获得焦点。

这样一看就明白了,最外层的 Flex 添加了三个组件,第一个和第三个组件都是 Box,第二个组件是另外一个 Flex,第一个 Box 组件没有指定 fixedSize ,而是指定了 proportion 比例为 1,第二个组件比例为 2,第三个组件设置为固定宽度 20(因为最外层的 Flex 是水平布局,所以是固定宽度),当 Flex 布局中同时出现比例和固定宽度时,Flex 显示的逻辑是先按照固定宽度显示,然后剩余的屏幕空间按照比例分割显示。

再看第二个组件,第二个组件是另一个 Flex,这个 Flex 使用 SetDirection(tview.FlexRow) 函数设置方向为行,即垂直布局,并添加了三个 Box,第一个和第二个 Box 比例分别是 1 和 3,第三个 Box 设置固定高度为 5。

使用 Flex 布局组件可以灵活地组织子组件的布局,通过设置方向和比例,可以轻松实现复杂的布局结构。

基于 Flex 布局,我们可以封装一个函数,返回一个 Flex,结合 Pages 可以实现将 tview 的任意组件在屏幕居中显示,并且可以指定宽度,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
func CenterScreen(widget tview.Primitive, width, height int) tview.Primitive {
return tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false).
AddItem(
tview.NewFlex().
AddItem(nil, 0, 1, false).
AddItem(widget, width, 0, true).
AddItem(nil, 0, 1, false),
height, 0, true).
AddItem(nil, 0, 1, false)
}

思路和上面的示例是一致的,先增加一个垂直布局的 Flex 并增加三个组件,第一个和第三个组件设置为 nil 用于占位,比例都是 1,不显示任何内容。第二个组件设置为一个水平布局的 Flex,同样增加两个 nil 的组件用于占位,比例都是 1,不显示任何内容,第二个组件设置为固定宽度,这样就实现了在屏幕居中显示并且可以指定组件的宽度。

Frame 框架

tview 中的 Frame 用于在其他组件上面添加 Header 和 Footer,并且可以选择性地包含标题和底部文字,这个组件通常用于为其他组件提供视觉上的界限和标注,帮助增强用户界面的整体布局和可读性,所以也属于布局组件的一种。

Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)

func main() {
app := tview.NewApplication()
frame := tview.NewFrame(tview.NewBox().SetBackgroundColor(tcell.ColorBlue)).
SetBorders(1, 1, 2, 2, 4, 4).
AddText("Header left", true, tview.AlignLeft, tcell.ColorWhite).
AddText("Header middle", true, tview.AlignCenter, tcell.ColorWhite).
AddText("Header right", true, tview.AlignRight, tcell.ColorWhite).
AddText("Header second middle", true, tview.AlignCenter, tcell.ColorRed).
AddText("Footer middle", false, tview.AlignCenter, tcell.ColorGreen).
AddText("Footer second middle", false, tview.AlignCenter, tcell.ColorGreen)
if err := app.SetRoot(frame, true).SetFocus(frame).Run(); err != nil {
panic(err)
}
}

首先使用 tview.NewFrame() 函数创建一个 Frame,第一个参数是 tview 组件,比如 Box、List 等,这里是一个背景颜色是蓝色的 Box,然后添加了四个文本,分布在 Header 上,然后在 Footer 上添加了两个文本并居中显示。

AddText() 函数定义:

1
func (f *Frame) AddText(text string, header bool, align int, color tcell.Color) *Frame {}
  • header 参数用于指定是否需要在 Header 上显示 text,值为 true 时显示在 Header 上,否则显示在 Footer 上
  • align 参数控制文字显示位置
  • color 参数指定文字颜色

SetBorders() 函数定义:

1
func (f *Frame) SetBorders(top, bottom, header, footer, left, right int) *Frame {}
  • top / bottom 参数用于控制整个 frame(包括 Header 和 Footer)在屏幕上的上下内边距
  • header / footer 参数用于控制组件和 Header / Footer 之间的内边距
  • left / right 参数用于控制组件在屏幕上的左右内边距

参考资料

GitHub: https://github.com/rivo/tview

tview Go 官方文档:https://pkg.go.dev/github.com/rivo/tview#readme-usage

一些简单容易看懂的 Demos:


GoLang TUI 库 tview 入门
https://cui.cc/golang-tview-01/
作者
南山崔崔
发布于
2024年3月26日
许可协议