多彩的终端

2019-06-19 ⏳2.7分钟(1.1千字)

终端,现在也叫命令行。但在历史上,确实有一种设备叫终端。其中最为著名的,可能就是 vt100 系列了。我们现在能看到的 terminal 软件都是终端设备的模拟器。虽说终端设备已经作古,但终端的通信控制协议依然有效。我们可以在命令行下显示粗体、斜体、下划线字符,也可以显示不同的颜色,甚至还能显示简单的动画,这些功能依然使用几十年前终端设备通信协议。今天就给大家说说这种协议。

不过在开始之前你得先准备好一个支持 24-bit 真彩色的终端模拟软件,我在 mac 下用的是 iTerm。

学过编程的同学对转义一定不会陌生。转义可以理解成转换含义。比如,编程语言中一般用 "\n" 表示 0x0a 换行符。\n 都是普通字符,但组合到一起却表示另外一个字符,这就是转义。这里的 \ 就是转义字符。终端也使用一套转义规则,这种规则后来被标准化为 ANSI Escape Sequences

终端使用 ESC 也就是 0x1b 作为转义字符。为开头,紧接着是一个字节,取值范围是 @A–Z[\]^_。不同的字母表示不同的含义,其中 0x1b[,叫作 Control Sequence Introducer,简写为 CSI。其他的基本都是用来控制终端设备的,现在很少用到了。为行文方便,我们统称转义序列指令。以 CSI 开头的指令有很多,大致可分四类:光标移动指令、清屏指令、字符渲染(Graphic Rendition)指令和终端控制指令。我们只说前三类。

光标移动指令

清屏指令

牛刀小试

了解了光标移动指令和清屏指令,我们就可以做点有意思的事情了。

首先在终端上打印 abcd 四个字母,然后控制光标逆时针移动。

移动光标示例

Go 代码如下:

package main

import (
  "fmt"
  "time"
)

func main() {
  fmt.Print(
    "\x1b[8C", // 向前移动 8 列,跑到屏幕中间
    "ab",      // 打印 ab
    "\x1b[1B", // 向下移动 1 行
    "\x1b[2D", // 后退 2 列,使光标移动到 a 下面
    "cd",      // 打印 cd
    "\x1b[1D", // 后退 1 列
  )

  for {
    fmt.Print("\x1b[1D") // 后退 1 列,移动到 c 上
    time.Sleep(200 * time.Millisecond)
    fmt.Print("\x1b[1A") // 上移 1 行,移动到 a 上
    time.Sleep(200 * time.Millisecond)
    fmt.Print("\x1b[1C") // 前进 1 列,移动到 b 上
    time.Sleep(200 * time.Millisecond)
    fmt.Print("\x1b[1B") // 下移 1 行,移动到 d 上
    time.Sleep(200 * time.Millisecond)
  }
}

再来一个复杂一点的进度条示例

进度条示例

同样是 Go 代码:

package main

import (
  "fmt"
  "strings"
  "time"
)

func main() {
  // 进度条最左边模拟旋转的 -
  s := []string{
    "-",  // 0/180 度
    "\\", //    45 度
    "|",  //    90 度
    "/",  //   135 度
  }

  for i := 0; ; i++ {
    k := i % 4  // 控制旋转角度
    l := i % 20 // 控制进度条长度

    if l == 0 {
      // 如果进度条超过长度则清空进度条重新绘制
      fmt.Print("\x1b[2K")
    }

    bar := strings.Repeat("=", l) + ">"

    // 进度条一共有 l + 1 + 1 个字符
    // 光标在 > 后面,需要向后移动 l+2 列才能回到第 1 列
    // 为下次重绘做好准备
    back := fmt.Sprintf("\x1b[%dD", l+2)

    fmt.Print(
      s[k], // 绘制最左边的 -
      bar,  // 绘制进度条
      back, // 将光标移动到第 1 列
    )

    time.Sleep(200 * time.Millisecond)
  }
}

我们在终端下看到的移动效果大都是这样实现的。接下来说一下字符渲染指令。

字符渲染指令

字符渲指令全称 Select Graphic Rendition,简写为 SGR。其格式为 CSI n m,以数字开头,并以 m 结尾,n 的取值范围是 0-107。又可以分成两类,一类控制字符显示样式,另一类控制显示颜色。

控制显示样式的主要指令有:

n 取值 效果 备注
0 重置所有显示效果 0x1b[m
1 显示粗体
3 显示斜体
4 显示下划线
22 关闭粗体效果
23 关闭斜体效果
24 关闭下划线效果
# echo 可以使用 \e 表示 0x1b
echo -e "\e[1mbold\e[0m"
echo -e "\e[3mitalic\e[0m"
echo -e "\e[4munderline\e[0m"
# 注意,这里可以组合几种不同的效果
echo -e "\e[1;3;4mall\e[0m"

控制颜色的指令有:

n 取值 效果 备注
30–37 设置字符颜色
38 设置字符颜色 5;n 表示 256 色;2;r;g;b 表示 24-bit 真彩色
39 恢复默设字符颜色
40–47 设置背景颜色
48 设置背景颜色 5;n 表示 256 色;2;r;g;b 表示 24-bit 真彩色
49 恢复默认背景颜色
90–97 设置字符颜色 高亮色
100–107 设置背景颜色 高亮色

最早的终端只支持 8 种颜色,分别是黑、红、绿、黄、蓝、洋红、青、白,因为数量很少,所以字符颜色直接使用 30-37 编码,背景色则使用 40-47。后来有厂商在此基础上又引入了 8 种对应亮度稍高的颜色,分别使用 90-97 和 100-107 编码。一共 16 种颜色。

随着硬件成本的不断降低,人们又生产出了可以显示 256 种颜色的终端。这次没有像 16 色那样再给 256 种颜色直接编码。而是引入了对应的 38 和 48 指令再配合扩展参数来表示 256 颜色。字符颜色:0x1b[38;5;<n>m,背景颜色:0x1b[48;5;<n>m,其中:

到现在,显卡可以显示 24-bit 真彩色了。终端指令进一步得到扩充。这次真得没法为每一种颜色直接编码了,索性直接使用颜色的 rgb 分量来表示。所以,24-bit 真彩色指令为 0x1b[38;2;<r>;<g>;<b>m,背景色对应的则是 0x1b[48;2;<r>;<g>;<b>m,这里用到了 2;<r>;<g>;<b> 扩展参数表示颜色。

最后,给出几个颜色示例:

# 使用 16 色格式编码
echo -e "\e[31mred\e[0m"
# 使用 256 色格式编码
echo -e "\e[38;5;1mred\e[0m"
# 使用 24-bit 真彩色编码
echo -e "\e[38;2;255;0;0mred\e[0m"
# 上面几个例子都会输出红色的 red

# 这是个综合性的例子,会输出
# 黄底线字,加粗、加下划线线、斜体的 red
echo -e "\e[1;3;4;38;2;255;0;0;48;2;0;255;0mred\e[0m"