ANSI Escape Sequences

本文是科普向的文章,介绍了 Ansi Escape Sequences 在日常编码生活中的作用

Ansi Escape Sequences 可以用于控制 cursor 的位置,颜色;字体样式以及作为 Terminal 的控制命令使用。所有序列都以 ESC 字符开头,第二个字符则是所在分类的标识。

ESC 字符等价于

  • Ctrl-Key: ^[
  • 八进制: \033
  • Unicode: \u001b
  • 十六进制: \x1b

比如下面的代码是等价的,均输出红色的 hello world

In [7]: print("\033[31m{message}\033[0m".format(message="hello world"))  
hello world

In [8]: print("\x1b[31m{message}\x1b[0m".format(message="hello world"))  
hello world

In [9]: print("\u001b[31m{message}\u001b[0m".format(message="hello world"))  
hello world  

通常我们接触到比较多的分类就是:

  • ESC ]: OSC – 操作系统命令(Operating System Command)

  • ESC [: CSI - 控制序列导入器(Control Sequence Introducer)

所有分类可以参考 Wiki

CSI 序列

文本渲染

在终端输出颜色是最常见的,比如 Python 中的颜色库 colorama,其原理便是用到了 CSI 序列。在 3 bit 下前景色和背景色格式均为 \033[{颜色码}m,是通过颜色码的值范围来区别的。前景色占 [30, 37],背景色则为 [40, 47]\033[0m则可以重置我们的颜色和样式。所以我们可以将需要特殊渲染的文本放在 \033[{i}m\033[0m中间

对于 3 bit 的前颜色,可以由如下代码输出

colors = ["Black", "Red", "Green", "Yellow", "Blue", "Magenta", "Cyan", "White"]

for name, i in zip(colors, itertools.count(30)):  
    print(f"\033[{i}m{name}\033[0m")

对于 4 bit 颜色,就是在 3 bit 颜色的基础上增加了色彩深度。格式为 \033[{颜色码};1m 的表示亮色,就是在 3 bit 的基础上添加了 ;1 字符。关于不同 bit 下的颜色可以参考 色彩深度

In [32]: for name, i in zip(colors, itertools.count(30)):  
    ...:      print(f"\033[{i};1m{name}\033[0m")
    ...:

而对于 8 bit 颜色,因为如果要直接塞,占用的数值空间太大。这里就二级编码了,格式则变成了

  • 前景色ESC[38;5;{颜色码}m
  • 背景色ESC[48;5;{颜色码}m

这里颜色码的范围是 0 到 255

In [33]: for i in range(256):  
    ...:     print(f"\033[38;5;{i}m{i:6}\033[0m", end='')
    ...:     if (i + 1) % 16 == 0:
    ...:         print()
    ...:

上述代码运行结果可以参考下图

最后我们来看看 24 bit 颜色,即我们所说的 True Color 是怎么表示的。目前支持 True Color 的终端可以在这份列表中找到 ,或者执行这个脚本。这个脚本便是利用的转义序列

24 bit 下的格式为

  • 前景色 ESC[38;2;{r};{g};{b}m
  • 背景色 ESC[48;2;{r};{g};{b}m

其中 {r} {g} {b} 代表的是 RGB 颜色值。

除了颜色上的控制,我们也可以使用转义序列来控制字体风格:

  • \033[2m set dim/faint mode

  • \033[3m set italic mode

  • \033[4m set underline mode

  • \033[5m set blinking mode

  • \033[7m set inverse/reverse mode

  • \033[8m set invisible mode

  • \033[9m set strikethrough mode

光标控制

其次我们经常用 curl 或者 wget 这种命令,或者我们安装某个 Package 的时候,都会有进度条。

test.zip  100%[=====================================================>]  21.90K  --.-KB/s    in 0.02s  

这其实也可以通过 Ansi Escape Sequence 来做到。我们需要控制 cursor 的位置,因为需要在同一行上反复刷新内容

  • 向上: \033[{n}A

  • 向下: \033[{n}B

  • 向右: \033[{n}C

  • 向左: \033[{n}D

{n} 为移动的字符数目,如果移动字符数目超过当前的字符数目,则不会继续移动。比如当前 cursor 在 50 字符的位置,那么 \033[1000D 不会将 cursor 移动到上一行,会停在本行的最左侧。

import time


def loading():  
    total = 100

    for i in range(1, total + 1):
        print(
            "\033[1000D{percent:3}%  [{process:<50}]".format(
                percent=i, process="=" * int((i / 100 * 50))
            ),
            end="",
            flush=True,
        )
        time.sleep(0.05)


loading()  

以此类推,可以实现多行的进度条

import time  
import operator  
import random  
import functools


def one_loading(percent: int) -> None:  
    print(
        "\033[1000D{percent:3}%  [{process:<50}]".format(
            percent=percent, process="=" * int((percent / 100 * 50))
        ),
        end="",
        flush=True,
    )


def multi_loading(count: int) -> None:  
    process = [0] * count
    current_cursor = 0

    while True:
        if all(map(functools.partial(operator.eq, 100), process)):
            break

        for idx, current_percent in enumerate(process):
            next_percent = min(current_percent + random.randint(1, 10), 100)

            if current_cursor > idx:
                print(
                    "\033[{n}A".format(n=abs(current_cursor - idx)), end="", flush=True
                )
            elif current_cursor < idx:
                print(
                    "\033[{n}B".format(n=abs(current_cursor - idx)), end="", flush=True
                )

            current_cursor = idx
            process[idx] = next_percent
            one_loading(next_percent)
            time.sleep(0.01)


multi_loading(4)  

除此之外还有更多的光标控制序列,可以参考文档

OSC 序列

关于 OSC 序列,每个终端支持程度不一样。有些还有自定义的 OSC 序列,可以参考以下内容:

OSC 52

如果我们想在 ssh 后的 shell 中复制一段文本,最简单的方式是使用鼠标选中然后复制。如果没有鼠标,我们该如何做呢?OSC 52 可以在这个时候帮助我们。格式为 '\033]52;{clipboard};{base64_data}\07'。其中的 clipboard 用于选择使用哪个剪贴板,主剪贴板是 c,base64_data 则是 base64 编码(RFC-4648)后的内容。此操作需要在 Terminal 中勾选 "Applications in terminal may access clipboard" 的选项(如果有的话,比如 ITerm2),不同 Terminal 操作不同,可以 Google。我们可以在执行下面的代码,然后查看自己的粘贴版中的内容是否为 hello

print('\033]52;c;aGVsbG8=\07')  # Python code  

如果我们在 TMUX 下,那么我们需要使用额外的字符编码才能生效。格式为

print("\033Ptmux;\033\033]52;c;aGVsbG8=\x07\033\\")  # Python Code  

对于 Vagrant + Vim 的用户,可能对于这个 hterm/osc52.vim 比较熟悉。我一直以来也是用的这个脚本,但是这有个问题就是复制的时候会闪一下。写这篇文章的时候看了一下源代码。闪屏的原因是因为

exec("silent! !echo " . shellescape(a:str))  " Vim Code  

!echo 是执行的 shell 命令,会让 Vim 切回 Shell 环境中,然后再切回来。改进的方法就是直接向 Shell 进程 的 STDOUT 输出。可以参考 remove-copy.vim

Reference