首发于编码
多彩的终端

多彩的终端

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

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

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

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

光标移动指令

  • CSI n A 表示将光标向上移动 n 行,如 0x1b[1A 表示向上移动一行。
  • CSI n B 表示将光标向下移动 n 行,如 0x1b[1B 表示向上移动一行。
  • CSI n C 表示将光标向前移动 n 列,如 0x1b[1C 表示向前移动一列。
  • CSI n D 表示将光标向后移动 n 列,如 0x1b[1D 表示向后移动一列。

清屏指令

  • CSI n J 表示清空屏幕
  • n = 0 清空光标以下区域
  • n = 1 清空光标以上区域
  • n = 2 清空全部区域
  • CSI n K 表示清空光标所在行
  • n = 0 清空光标到行尾的内容
  • n = 1 清空光标到行首的内容
  • n = 2 清空全部区域

牛刀小试

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

首先在终端上打印 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。又可以分成两类,一类控制字符显示样式,另一类控制显示颜色。

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

  • 0 重置所有显示效果
  • 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"

可以得到如下输出:

控制颜色的指令有:

  • 30–37 设置字符颜色
  • 38 设置字符颜色
  • 39 恢复默设字符颜色
  • 40–47 设置背景颜色
  • 48 设置背景颜色
  • 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,其中:

  • n 取 0-7 时表示原来的 30-37 标准色
  • n 取 8-15 时表示原来的 90-97 高亮标准色
  • n 取 16-231 时表示 216 种颜色,计算公式为:16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
  • n 取 232-255 时表示 24 级灰度。

到现在,显卡可以显示 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"

编辑于 2019-07-31

文章被以下专栏收录