[cli] 刷新屏幕

可交互动态终端 <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 for WriteConsoleA or wchar_t for WriteConsoleW.

这里,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字符,但是有不同的优化方式(压缩方式)

  1. UTF-8: 使用一至四个字节为每个字符编码,其中大部分汉字采用三个字节编码
  2. UTF-16: 使用二或四个字节为每个字符编码,其中大部分汉字采用两个字节编码
  3. UTF-32: 使用四个字节为每个字符编码

除了字符本身的编码外,还存在大端序小端序的前置记号,UTF-16是2字节,UTF-32是4字节,UTF-8没有,这些记号在存储时将放置在文件的首部

一般的,大多编程语言就原生支持unicode,只需要在前面加上\u即可, ‘\u1234’