可交互动态终端 <2, 刷新屏幕>
ref: github.com/gdamore/tcell/v2
我们知道,对于终端的刷新来说,如果我们直接刷新整个屏幕,将会有明显的帧刷新感,由此,我们需要只对更新的数据刷新,而跳过不变的数据.
这依赖于 0. 找到dirty数据 1. 设置输出光标的位置 2. printf (syscall.WriteConsole)
创建screen
这里有两种类型的screen,
功能是为了: NewScreen returns a default Screen suitable for the user’s terminal environment.
win下默认使用NewConsoleScreen
Terminfo is a library and database that enables programs to use display terminals in a device-independent manner.
简单来说就是一个第三方库,类似于pcap这种,用于提供posix终端控制
查看本地是否支持terminfo:
echo $TERM
- NewConsoleScreen()
- NewTerminfoScreen()
我们这里介绍windows下的NewConsoleScreen()
这些函数都是返回我们自己的定义的一个逻辑上的终端结构体.
打开输入输出
对于windows下:
in, e := syscall.Open("CONIN$", syscall.O_RDWR, 0)
out, e := syscall.Open("CONOUT$", syscall.O_RDWR, 0)
这里CONIN$,CONOUT$
,指console in/out,简单来看就是stdin/stdout,但是有时候你可能对stdin/stdout重定向到了文件,所以为了直接获取对终端的控制,用这个.
True color
所谓的true color,是指支持RGB颜色的color,即3*8=24-bit的颜色设置
但是历史原因,较古老的终端就不支持,比如cmd
加载kernel32.dll
在windows中,我们需要先加载dll,以获取某些系统调用
Go语言的built-in syscall并未包含一些不常用的系统调用
var (
k32 = syscall.NewLazyDLL("kernel32.dll")
u32 = syscall.NewLazyDLL("user32.dll")
)
注意这里NewProc指的是New Procedure
var (
procReadConsoleInput = k32.NewProc("ReadConsoleInputW")
procWaitForMultipleObjects = k32.NewProc("WaitForMultipleObjects")
procCreateEvent = k32.NewProc("CreateEventW")
procSetEvent = k32.NewProc("SetEvent")
procGetConsoleCursorInfo = k32.NewProc("GetConsoleCursorInfo")
procSetConsoleCursorInfo = k32.NewProc("SetConsoleCursorInfo")
procSetConsoleCursorPosition = k32.NewProc("SetConsoleCursorPosition")
procSetConsoleMode = k32.NewProc("SetConsoleMode")
procGetConsoleMode = k32.NewProc("GetConsoleMode")
procGetConsoleScreenBufferInfo = k32.NewProc("GetConsoleScreenBufferInfo")
procFillConsoleOutputAttribute = k32.NewProc("FillConsoleOutputAttribute")
procFillConsoleOutputCharacter = k32.NewProc("FillConsoleOutputCharacterW")
procSetConsoleWindowInfo = k32.NewProc("SetConsoleWindowInfo")
procSetConsoleScreenBufferSize = k32.NewProc("SetConsoleScreenBufferSize")
procSetConsoleTextAttribute = k32.NewProc("SetConsoleTextAttribute")
procMessageBeep = u32.NewProc("MessageBeep")
)
设置光标位置,注意
func (p *LazyProc) Call(a ...uintptr) (r1, r2 uintptr, lastErr error)
的签名,参数都要转换成uintptr
type coord struct {
x int16
y int16
}
func (c coord) uintptr() uintptr {
// little endian, put x first
return uintptr(c.x) | (uintptr(c.y) << 16)
}
// s.out 是 screen
// out, e := syscall.Open("CONOUT$", syscall.O_RDWR, 0)
procSetConsoleCursorPosition.Call(uintptr(s.out),coord{int16(x), int16(y)}.uintptr())
将以ch[0]为首地址的一段buffer输出到屏幕s.out
syscall.WriteConsole(s.out, &ch[0], uint32(len(ch)), nil, nil)
VT转义字符序列
对于更高级的终端,可以支持VT100/XTerm 转义字符
VT100/XTerm 转义字符,使用这些转义字符,那么就不用调用syscall了,只要把这些字符print出去,就能达到一些系统调用的效果,比如设置颜色,设置光标位置
const (
// VT100/XTerm escapes understood by the console
vtShowCursor = "\x1b[?25h"
vtHideCursor = "\x1b[?25l"
vtCursorPos = "\x1b[%d;%dH" // Note that it is Y then X
vtSgr0 = "\x1b[0m"
vtBold = "\x1b[1m"
vtUnderline = "\x1b[4m"
vtBlink = "\x1b[5m" // Not sure this is processed
vtReverse = "\x1b[7m"
vtSetFg = "\x1b[38;5;%dm"
vtSetBg = "\x1b[48;5;%dm"
vtSetFgRGB = "\x1b[38;2;%d;%d;%dm" // RGB
vtSetBgRGB = "\x1b[48;2;%d;%d;%dm" // RGB
)
如果是自己测试,可以使用诸如echo -e "\x1b[?25l"
可以通过GetConsoleMode
系统调用来查看终端是否支持ENABLE_VIRTUAL_TERMINAL_PROCESSING
基于cell的终端屏幕模拟
屏幕是二维的,我们用一个二维逻辑buffer(实际上用一维buffer实现)来模拟屏幕,众所周知,屏幕本质就是由像素点组成的二维阵列,在模拟中,我们__将cell作为最小单元__,它的宽度占比就是一个普通的ascii字符打印出来的宽度
cell
cell结构体存储3+3+1个字段,分别为current(3),last(3),width(1),current/last中包含main,comb,style,分别指主字,加字,风格
在大多数语言中,比如英文/中文,都不存在加字comb,只包含主字main
之所以要设置last,是为了判断这个cell是否被更新,如果更新,则要输出到屏幕覆盖旧值,如果未更新,则跳过
对于width,大多数东亚字符的width都是2,这意味着,下一个cell将不能存储东西,要跳过下一个cell
// main: primary rune
// comb: any combining character runes (which will usually be nil) combining:一般是音调,或者藏语里面的上加字下加字或元音,这些字符是依附在前一个字符身上的,即自己不占空间,在存储到cell时,通常的做法是设置main为' '(空格),将其本身放在comb里面,详见本文后面的示例
// style: the style, and the display width in cells
// width: The width can be either 1, normally, or 2 for East Asian full-width characters.
type cell struct {
currMain rune
currComb []rune
currStyle Style
lastMain rune
lastStyle Style
lastComb []rune
width int
}
// CellBuffer represents a two dimensional array of character cells.
// This is primarily intended for use by Screen implementors; it
// contains much of the common code they need. To create one, just
// declare a variable of its type; no explicit initialization is necessary.
// CellBuffer is not thread safe.
type CellBuffer struct {
w int
h int
cells []cell
}
坐标
屏幕是二维的<width , height>,但存储时,设计成一维的,对于位于(x,y)的点,通过cb.cells[(y*cb.w)+x]
取出,这里cb
代表cellbuffer
(设计成一维的,可能是为了得到一个连续的空间buffer,来模拟二维的屏幕)
对于计算机屏幕的坐标系不用多说,左上角为原点(0,0),向右是x轴正方向,向下是y轴正方向
如果想
hideCursor()
, 通常的做法是setCursor(-1,-1)
扫描buffer,刷新屏幕
这里只考虑ascii字符,对于utf-8字符完整的处理,可看源码
每检测到脏数据,就添加进一个buffer,直到遇到第一个不需要更新的数据,然后将buffer写到屏幕,不断重复.
buf := make([]uint16, 0, s.w)
wcs := buf[:]
lx, ly := -1, -1
for y := 0; y < s.h; y++ {
for x := 0; x < s.w; x++ {
mainc := s.cells.GetContent(x, y) // 注意,原本是返回mainc, combc, style, width,这里略去
// cells.Dirty()判断x,y位置的cell是否是脏数据,这意味着要刷新到屏幕覆盖旧数据
dirty := s.cells.Dirty(x, y)
if !dirty {
// write out any data queued thus far
// because we are going to skip over some
// cells
s.writeString(lx, ly, wcs)
wcs = buf[0:0]
continue
}
if len(wcs) == 0 {
lx = x
ly = y
}
wcs = append(wcs, utf16.Encode([]rune{mainc})...)
}
s.writeString(lx, ly , wcs)
wcs = buf[0:0]
}
func (s *cScreen) writeString(x, y int, style Style, ch []uint16) {
s.setCursorPos(x, y, s.vten)
syscall.WriteConsole(s.out, &ch[0], uint32(len(ch)), nil, nil)
}
问题: 为什么syscall.WriteConsole(s.out, &ch[0], uint32(len(ch)), nil, nil)
这里,ch必须是[]uint16
,len必须是uint32
?
我们来看看微软官方的开发文档给的函数签名:
// Writes a character string to a console screen buffer beginning at the current cursor location.
BOOL WINAPI WriteConsole(
_In_ HANDLE hConsoleOutput,
_In_ const VOID *lpBuffer,
_In_ DWORD nNumberOfCharsToWrite,
_Out_opt_ LPDWORD lpNumberOfCharsWritten,
_Reserved_ LPVOID lpReserved
);
lpBuffer [in] A pointer to a buffer that contains characters to be written to the console screen buffer. This is expected to be an array of either
char
forWriteConsoleA
orwchar_t
forWriteConsoleW
.
这里,wchar_t就是16位的类型(windows platform),但是注意,wchar_t,在其他平台可能是32位的,不过由于我们使用的是windows的系统调用,所以直接用就将buffer设置成[]uint16{}就行
The wchar_t type is an implementation-defined wide character type. In the Microsoft compiler, it represents a 16-bit wide character used to store Unicode encoded as UTF-16LE, the native character type on Windows operating systems.
这里UTF-16LE,LE指little endian
DWORD: double word,双字,一个字是2字节
UTF16编码
因为windows默认是UTF-16LE编码,我们需要对[]rune
编码为utf16,这里rune是int32,也就直接是unicode码点
func (s *cScreen) emitVtString(vs string) {
esc := utf16.Encode([]rune(vs))
syscall.WriteConsole(s.out, &esc[0], uint32(len(esc)), nil, nil)
}
UTF-8/UTF-16
我们知道除了ascii字符(1 byte)之外,我们还需要打印诸如中文这样的宽字符,不同的字符需要用不同的码点(数字编码)来表示,这个编码集就是Unicode
Unicode 是容纳世界所有文字符号的国际标准编码,使用四个字节为每个字符编码
但是Unicode每个字符都是四字节,对于英文来说,一个ascii字符只需要1字节,对于英文国家的人来说,如果使用unicode编码来编码字符串,就会有4倍的开销,因此,产生了UTF(Unicode Transformation Format)的各个版本,这些版本都能表示所有的Unicode字符,但是有不同的优化方式(压缩方式)
- UTF-8: 使用一至四个字节为每个字符编码,其中大部分汉字采用三个字节编码
- UTF-16: 使用二或四个字节为每个字符编码,其中大部分汉字采用两个字节编码
- UTF-32: 使用四个字节为每个字符编码
除了字符本身的编码外,还存在大端序小端序的前置记号,UTF-16是2字节,UTF-32是4字节,UTF-8没有,这些记号在存储时将放置在文件的首部
一般的,大多编程语言就原生支持unicode,只需要在前面加上\u即可, ‘\u1234’