Golang库推荐: tcell实现对终端的坐标式绘图

引入话题

vim无疑是linux玩家必备的编辑工具, 即使在命令行模式下(假如你现在在使用linux桌面版, 现在你可以按下CTRL+ALT+F2进入命令行模式, 按CTRL+ALT+F1切回), 也能使用这样的编辑工具。它能够接管整个屏幕, 并将文字显示在上面。那么怎么实现这种”接管”整个终端屏幕的软件呢?在golang下就提供了tcell这样方便的工具, 使得我们能以坐标系的方式操纵整个终端屏幕。

这个库是跨平台的, 不同于c语言的ncurses, 它的api更友好, 且跨平台支持效果很好。

go的ncurses移植(github.com/rthornton128/goncurses)支持很垃圾, 究其原因, 是因为go接管了所有信号, 而这个c库设置了信号处理函数, 导致此库的某些接口效果很诡异。因此在任何情况下都不要使用goncurses。

快速体验

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
package main

import (
"time"

"github.com/gdamore/tcell"
)

func main() {
screen, err := tcell.NewScreen()
if err != nil {
panic(err)
}
if err := screen.Init(); err != nil {
panic(err)
}

screen.SetContent(0, 0, '你', nil, tcell.StyleDefault)
screen.SetContent(2, 0, '好', nil, tcell.StyleDefault)
screen.SetContent(4, 0, '世', nil, tcell.StyleDefault)
screen.SetContent(6, 0, '界', nil, tcell.StyleDefault)

screen.Show()
time.Sleep(time.Second * 3)

screen.Fini()
}

这个程序将会接管整个终端屏幕, 并在屏幕左上角打印出你好世界, 并且在三秒后程序结束。Show代表刷新屏幕, 使之前的更改展示到屏幕上。Init将修改终端的一些属性, 使得我们的程序能够接管整个屏幕。Fini(finalize的缩写)将还原之前覆写的终端属性, 必须得调用这个, 否则在程序结束后, 终端的行为会不正常。

关键函数是SetContent, 它能以坐标的形式指定某个单元格的内容。函数签名如下:

1
func (tcell.Screen).SetContent(x int, y int, mainc rune, combc []rune, style tcell.Style)

其中左上角第一个单元格的坐标被定义为(0,0), 关于计算机屏幕的坐标系一般都是这样定义的

mainc代表要设置的字符, 可以是任何一个utf8字符(包括占用两个宽度的东亚字符)。combc全称combining character, 这个字符涉及到unicode的细节, 在大多数情况下用不着, 不提它了。

值得一提的是, 东亚字符占两个宽度, 一个ASCII字符只占一个宽度, 这就是为什么上面的代码设置每个字符都要间隔两个单元。如果要判定一个rune是否为宽字符, 可以用runewidth库。

程序员要保证不要出现宽字符”交叉”的情况。如果这么写, 是未定义的:

1
2
screen.SetContent(0, 0, '你', nil, tcell.StyleDefault)
screen.SetContent(1, 0, '好', nil, tcell.StyleDefault)

默认不显示光标, 如果要操作光标, 调用screen.ShowCursonscreen.HideCurson

丰富的色彩和字符特效

现在的终端效果很厉害, 能让终端产生有颜色的字符, 可以让它带有下划线, 可以让它能够闪烁, 可以粗体/斜体。通过SetContent的style参数就能让它有各种各样的效果:

1
2
screen.SetContent(0, 0, '你', nil, tcell.StyleDefault.Underline(true))
screen.SetContent(2, 0, '好', nil, tcell.StyleDefault.Underline(true).Foreground(tcell.ColorRebeccaPurple))

tcell设计了一个Style结构体, 能够链式地设置效果。tcell.StyleDefault是终端的默认显示配置, 在它的基础上修改需求是很好的实践。

tcell的事件队列机制

我们需要能响应终端的一些事件, 比如用户按下按键是最常见的事件, tcell提供了基于事件的消息传递, 需要用户做一个事件循环, 不断poll获取事件

下面的程序会在用户按下CTRL+C时退出程序, 按下普通的字符时将其显示到(0,0)处:

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
35
36
37
38
package main

import (
"github.com/gdamore/tcell"
)

func main() {
screen, err := tcell.NewScreen()
if err != nil {
panic(err)
}
if err := screen.Init(); err != nil {
panic(err)
}

screen.SetContent(0, 0, '你', nil, tcell.StyleDefault.Underline(true))
screen.SetContent(2, 0, '好', nil, tcell.StyleDefault.Underline(true).Foreground(tcell.ColorRebeccaPurple))
screen.Show()

for {
ev := screen.PollEvent()
switch event := ev.(type) {
case *tcell.EventKey:
if event.Key() == tcell.KeyCtrlC {
goto out
}

// 说明用户打的是普通的字符
if event.Key() == tcell.KeyRune {
screen.SetContent(0, 0, event.Rune(), nil, tcell.StyleDefault)
screen.Show()
}
}
}

out:
screen.Fini()
}

PollEvent会阻塞等待一个事件的到来, 事件有以下类型:

  • EventResize: 用户的终端改变大小
  • EventKey: 用户敲击键盘
  • EventInterrupt: 可以用来打断PollEvent的阻塞, 里面可以放置自己的数据类型, 用来实现自定义事件
  • EventMouse: 用户鼠标事件

有的人很好奇, 为毛一个终端, 你还能有EventMouse? 明明我的linux命令行模式连鼠标都没有啊?

其实这是因为终端模拟器的支持, 我们现在的桌面系统, 是linux的桌面模式, 为了在桌面模式上使用到终端, 有人发明了终端模拟器, 它支持了更多的”能力”, 其中鼠标点击就是其中之一。可以调用screen.HasMouse()检查是否支持鼠标, screen.EnableMouse/DisableMouse开启关闭, 默认是关闭的。

此外, 用户能够自己发送事件, 它会被PollEvent捕获到。其中PostEvent在事件队列已满的情况下返回ErrEventQFull, PostEventWait在时间队列已满的情况下等待

优秀的设计模式

让一个单独的协程负责Poll事件并进行绘图(事件循环), 事件循环里面永远不要包含耗时的操作(否则绘图就阻塞住了)。如果要在事件循环中启动耗时任务, 请开一个新的协程, 执行完毕后发送事件, 永远用事件通信。

我做的二次开发库推荐

开过mc服务器的人可能知道, mc服务器的后台控制器很炫酷, 在终端的最后一行专门用来输入指令, 不会被后台满天的刷屏给影响。于是我就设计了这么一个库, 它基于tcell(因此跨平台), 能很容易地实现这样的效果。此库支持了左右滑动(当一行输出太长的时候, 可以按左键右键进行水平滑动), 且在终端屏幕大小改变的时候重绘, 表现依然正常。点击此处查看。