使用ANSI escape code编写你自己的命令行程序

本文是对lihaoyi发表于2016年7月2日的一篇文章《Build your own Command Line with ANSI eascape codes》的译文,译者出于对此篇文章的兴趣和期望更多人能看到故进行翻译(还有一部分原因是锻炼英语翻译能力😋)。由于译者翻译水平尚且欠佳,翻译内容可能会有谬误的地方,望见谅。

以下是正式的翻译内容。


所有人都曾在一个滚动后出现新的文本的终端中编程打印一些输出,但是那并不是你所能做的全部:你的程序可以为你的文本设置颜色,控制光标的上下左右移动,或者清空部分屏幕上的内容为了想要之后重新打印他们。正是这些功能使得像是Git那样的程序能够实现动态的进度条,还有像Vim或者Bash那样的编辑方式能够实现在不滚动屏幕的情况下使你改变现已显示的文本内容。

即使存在着像ReadlineJLine,或者Python Prompt Toolkit这样可以帮助你在各种编程语言中完成上述功能的工具库(轮子),但是你同样能够自己实现它。这篇推文将会用Python举例,带你探索怎样能够在任何的命令行程序中控制终端,同时讨论你自己的代码怎样直接利用所有这些终端提供的特性。


大多数程序都是通过ANSI escape codes来和Unix终端进行交互的。这些你编写的特殊的代码可以根据终端指令打印相应内容。各种终端对这些代码有着不同的支持,因此你很难找到一个这些代码具体作用的“权威”列表。不过,就像其他网站那样,维基百科也有一个比较合理的列表

尽管如此,利用ANSI escape codes编写程序也是可行的,至少能够运行于像是Ubuntu或者OS-X这样常见的Unix系统中(但是Windows不行,同时我不会在此介绍,这太冒险!)(译者注:现在10以上的Windows系统也支持了)。这篇推文将讨论Ansi escape codes的存在,同时演示如何利用它们编写你自己的交互式命令行。


首先,让我们打开一个朴实无华的Python交互程序:

那么让我们开始吧!

富文本

最基础的Ansi escape codes的作用是渲染文本。这些渲染选项有颜色背景颜色,或者其他对你打印文本的装饰,但是不要被他们任意一个所迷惑了。你打印出来的文本还是会显示在终端下方,也仍然会使你的终端可以滚动,只是现在会变成其他颜色的文本而不是你终端里面配色方案上默认的黑底白字。

颜色

你能对文本做的最基本的事就是控制它的颜色。设置Ansi颜色的代码看起来像这样:

  • 红色\u001b[31m

  • 重制颜色\u001b[0m

很多的Ansi escapes都是以\u001b这个字符串开头的;很多的编程语言的语法都允许使用这种特殊的字符串,比如:Java,Python和Javascript都支持\u001b语法。

译者注:如果是像C语言这种底层编程语言,我们的格式就是\033(其实python等高级编程语言也支持这种语法)。查看ascii表就会发现,033是八进制的escape ascii码,而001b是十六进制的escape ascii码。所以这实际上是一个历史遗留问题,现在的字符普遍使用uincode编码,所以一律写成了\u001b这种十六进制格式。

举个🌰,我们想要打印一个红色的"Hello World":

print u"\u001b[31mHello World"

注意我们需要在字符串前面加上u(使用u"..."这种前缀是为了在Python 2.7.10上正常运心。如果是Python 3或者其他编程语言就没必要这么写了)

仔细看红色是从输出Hello World开始的,结束到下一次的>>>提示符。事实上,接下来我们敲进去的任何字符都会被渲染成红色!这就是Ansi颜色如何工作的:一旦你输入了打印了一个特殊代码去开启某种颜色,这种颜色会一直存在直到打印出特殊代码去更换为另一种颜色,或者打印重制颜色的代码去关闭颜色。

我们可以通过打印重制颜色代码去除颜色:

print u"\u001b[0m"

于是我们可以看到命令行提示符又重返黑底白字了。总的来说,你应该一直记得使用重制颜色代码来结束颜色的使用,确保你的程序不会发生意外。

为了防止意外,我们需要在输出有颜色的字符串之后使用重制颜色代码:

print u"\u001b[31mHello World\u001b[0m"

上图正确地在字符串输出之后重制了颜色。你也可以在输出字符串到一半的时候使用重制颜色,去得到一个一半没有着色的字符串:

print u"\u001b[31mHello\u001b[0m World"

其他颜色

我们已经看到红色重制颜色怎么工作的。最基础的终端有着8中不同的颜色:

  • 黑色\u001b[30m
  • 红色\u001b[31m
  • 绿色\u001b[32m
  • 黄色\u001b[33m
  • 蓝色\u001b[34m
  • 品红色\u001b[35m
  • 青色\u001b[36m
  • 白色\u001b[37m
  • 重制颜色\u001b[0m

我们可以用不同的字母来打印出每种颜色,最后使用重制颜色:

print u"\u001b[30m A \u001b[31m B \u001b[32m C \u001b[33m D \u001b[0m"
print u"\u001b[34m E \u001b[35m F \u001b[36m G \u001b[37m H \u001b[0m"

注意黑色的A在黑色的终端北京下完全看不见了,同时白色的H看起来就像平常的输出文本。如果我们选择一个不同的配色方案,显示结果可能完全相反:

print u"\u001b[30;1m A \u001b[31;1m B \u001b[32;1m C \u001b[33;1m D \u001b[0m"
print u"\u001b[34;1m E \u001b[35;1m F \u001b[36;1m G \u001b[37;1m H \u001b[0m"

上图中黑色的A变得明显了,白色的H反而难以辨认。

更多颜色

多数的终端,除了基本的八种颜色之外,同样支持"亮色"或者"加粗颜色"。这些颜色有他们自己的编码规则,类似于之前的普通颜色代码,只不过要在后面加上;1:

  • 亮黑色\u001b[30;1m
  • 亮红色\u001b[31;1m
  • 亮绿色\u001b[32;1m
  • 亮黄色\u001b[33;1m
  • 亮蓝色\u001b[34;1m
  • 亮品红色\u001b[35;1m
  • 亮青色\u001b[36;1m
  • 亮白色\u001b[37;1m
  • 重制颜色\u001b[0m

注意重制颜色还是一样的:这种重制效果针对于所有的颜色和文本效果。

我们可以打印出这些亮色并看看效果:

我们看到他们确实比原先的8种颜色亮很多。甚至黑色的A现在都在黑色背景下亮得变成了一个可见的灰色,而且白色的H现在甚至比默认的白色文本更亮了。

256色

最后,在16色推出之后,一些终端支持一种有256种颜色的扩展颜色集合。

下面是他们的编码形式:

  • \u001b[38;5;${ID}m
import sys
for i in range(0, 16):
    for j in range(0, 16):
        code = str(i * 16 + j)
        sys.stdout.write(u"\u001b[38;5;" + code + "m " + code.ljust(4))
    print u"\u001b[0m"

我们使用了sys.stdout.write而不是print,所以我们可以在同一行种打印各种文本,不然以原来的方式就不好说明。每种代码从0到255分别对应于一种特定的颜色。

背景颜色

Ansi escape codes让你设置背景颜色的方式和设置文本颜色的方式是一样的。比如,8种背景颜色对应的代码为:

  • 黑色背景\u001b[40m
  • 红色背景\u001b[41m
  • 绿色背景\u001b[42m
  • 黄色背景\u001b[43m
  • 蓝色背景\u001b[44m
  • 品红色背景\u001b[45m
  • 青色背景\u001b[46m
  • 白色背景\u001b[47m

亮色的版本为:

  • 亮黑色背景\u001b[40;1m
  • 亮红色背景\u001b[41;1m
  • 亮绿色背景\u001b[42;1m
  • 亮黄色背景\u001b[43;1m
  • 亮蓝色背景\u001b[44;1m
  • 亮品红色背景\u001b[45;1m
  • 亮青色背景\u001b[46;1m
  • 亮白色背景\u001b[47;1m

重制渲染也是一样的:

  • 重制\u001b[0m

我们可以打印出他们看看运行效果:

print u"\u001b[40m A \u001b[41m B \u001b[42m C \u001b[43m D \u001b[0m"
print u"\u001b[44m A \u001b[45m B \u001b[46m C \u001b[47m D \u001b[0m"
print u"\u001b[40;1m A \u001b[41;1m B \u001b[42;1m C \u001b[43;1m D \u001b[0m"
print u"\u001b[44;1m A \u001b[45;1m B \u001b[46;1m C \u001b[47;1m D \u001b[0m"

注意亮色的背景颜色不会改变背景,不过会使得前景中的文本变亮。这看起来不直观但是它就是这么工作的。

256色背景也附上:

import sys
for i in range(0, 16):
    for j in range(0, 16):
        code = str(i * 16 + j)
        sys.stdout.write(u"\u001b[48;5;" + code + "m " + code.ljust(4))
    print u"\u001b[0m"

装饰

除了颜色,背景颜色,Ansi escape codes同时允许在文本上装饰一些其他效果:

  • 加粗\u001b[1m
  • 下划线\u001b[4m
  • 颜色反转\u001b[7m

他们可以像下面那样单独使用:

print u"\u001b[1m BOLD \u001b[0m\u001b[4m Underline \u001b[0m\u001b[7m Reversed \u001b[0m"

或者同时使用:

print u"\u001b[1m\u001b[4m\u001b[7m BOLD Underline Reversed \u001b[0m"

也可以混合上前景和背景颜色一起:

print u"\u001b[1m\u001b[31m Red Bold \u001b[0m"
print u"\u001b[4m\u001b[44m Blue Background Underline \u001b[0m"

光标导航

接下来的Ansi escape codes会更加复杂:他们允许你在终端窗口中移动光标,或者清楚一部分光标。他们就是像Bash那样的程序可以让你通过方向键左右移动光标的原因。

最基本的移动你光标上下左右的代码:

  • 上移\u001b[{n}A
  • 下移\u001b[{n}B
  • 右移\u001b[{n}C
  • 左移\u001b[{n}D

为了使用他们,首先对“正常”的Python提示符建立初步了解。

为此,我们加入了一个time.sleep(10)来使我们能看到它的工作方式。我们可以看到如果我们打印一些文本,他会先打印出一行,然后将光标移动到下一行。

import time
print "Hello I Am A Cow"; time.sleep(10)

然后它打印下一个提示符再将光标右移一个字符:

所以这就是对光标的移动方式的初步了解。那么我们可以怎么利用它呢?

进度指示器

我们利用光标移动功能的Ansi escape codes能够做的最简单东西就是像这样一个加载进度提示:

import time, sys
def loading():
    print "Loading..."
    for i in range(0, 100):
        time.sleep(0.1)
        sys.stdout.write(u"\u001b[1000D" + str(i + 1) + "%")
        sys.stdout.flush()
    print

loading()

它打印出1%100%的文本,因为使用了stdout.write而不是print,所有百分数都显示在了一行中。不过,在打印每个百分数之前它先打印了\u001b[1000D(这意味着“将光标向左移动1000个字符)。这应该让光标一路移动到屏幕最左边,所以新打印的百分数会覆盖掉之前的。因此,我们看到在函数返回之前,加载状态的百分数流畅地从1%变化到100%

译者注:这里我们使用sys.stdout.flush()的作用是让缓冲区显式地一点点地输出内容。在Python中,stdout具有缓冲区,即不会每一次写入就输出内容,而是会在得到相应的指令后才将缓冲区的内容输出,也就是说如果没有flush就会在sleep完之后一次性输出完所有百分数。

从你的视角上看到光标在移动的轨迹可能有点困难,但是我们可以简单地将它放缓并加入更多的sleep函数来让代码的效果展示出来:

import time, sys
def loading():
    print "Loading..."
    for i in range(0, 100):
        time.sleep(1)
        sys.stdout.write(u"\u001b[1000D")
        sys.stdout.flush()
        time.sleep(1)
        sys.stdout.write(str(i + 1) + "%")
        sys.stdout.flush()
    print

loading()

在此,我们将打印“向左移动”的escape code,和打印出百分数进度指示器的write函数分开。我们同时在中间加入了一秒钟的休眠时间,让我们有机会看到光标在“其间”的每种状态,而不是得到结果。

现在,我们可以看到在新百分数覆盖旧百分数之前,光标移动到了屏幕左边缘。

ASCII码的进度条

现在我们知道如何利用Ansi escape codes控制终端编写一个自更新的进度条,那么将它改进得美观些也相对简单,比如让进度条显示ASCII字符横过屏幕。

import time, sys
def loading():
    print "Loading..."
    for i in range(0, 100):
        time.sleep(0.1)
        width = (i + 1) / 4
        bar = "[" + "#" * width + " " * (25 - width) + "]"
        sys.stdout.write(u"\u001b[1000D" +  bar)
        sys.stdout.flush()
    print

loading()

这将会和你预期的效果一样:每次循环,整行都会被全新的ASCII字符条所覆盖。

我们甚至能使用上移下移光标来让我们一次性画出多条进度条。

import time, sys, random
def loading(count):
    all_progress = [0] * count
    sys.stdout.write("\n" * count) # Make sure we have space to draw the bars
    while any(x < 100 for x in all_progress):
        time.sleep(0.01)
        # Randomly increment one of our progress values
        unfinished = [(i, v) for (i, v) in enumerate(all_progress) if v < 100]
        index, _ = random.choice(unfinished)
        all_progress[index] += 1

        # Draw the progress bars
        sys.stdout.write(u"\u001b[1000D") # Move left
        sys.stdout.write(u"\u001b[" + str(count) + "A") # Move up
        for progress in all_progress:
            width = progress / 4
            print "[" + "#" * width + " " * (25 - width) + "]"

loading()

译者注:any() 函数用于判断给定的可迭代参数 iterable 是否全部为 False,则返回 False,如果有一个为 True,则返回 True;enumerate() 函数用于将一个可遍历的数据对象(如列表、元组或字符串)组合为一个索引序列,同时列出数据和数据下标;choice() 方法返回一个列表,元组或字符串的随机项。

在这个代码片段中,我们必须做几个我们之前没做过的事:

  • 确保我们有足够的空间画出进度条!可以在函数开始时编写"\n" * count来实现。这会创建出一系列能让终端滚动的新行,确保在终端底部有count行空行用来进度条的渲染。
  • 使用all_progress数组存储多行的累加数,是数组中的每个变量随机增加。
  • 每次使用上移ansi code来移动count行,借此我们能打印count条进度条。

它也确实成了!

大概下次你要是写一个需要平行下载很多文件的命令行程序,或者做一些类似的平行任务,你可以写一个类似于基于Ansi escape code的进度条,这样用户就可以看到他们的命令正在进行。

当然,所有这些进度指示到现在都是假的:他们都不是真的在监督任务进度。然而,他们向你展示了怎样在你编写的任意命令行程序中,使用Ansi escape codes去放置一个动态的进度指示器。所以当你确实有一些可以检测的进度,你就有能力把一个完美的在线更新的进度条放上去了。

编写一个命令行

你想要用Ansi escape codes实现的有趣事之一可能是实现一个真正的命令行。Bash,Python,Ruby,他们都有内置命令行,这些命令行让你敲入一个指令并可以在执行前编辑命令文本。尽管这看起来很特殊,但实际上这种命令行就只是另一个通过Ansi escape codes跟终端交互的程序!既然我们已经知道如何使用Ansi escape codes,我们就同样能够实现一个我们自己的命令行。

用户输入

我们编写命令行需要做的第一件事,就是我们现在还没有做过的——接收用户输入。这能通过以下代码实现:

import sys, tty
def command_line():
    tty.setraw(sys.stdin)
    while True:
        char = sys.stdin.read(1)
        if ord(char) == 3: # CTRL-C
            break;
        print ord(char)
        sys.stdout.write(u"\u001b[1000D") # Move all the way left

我们使用setraw来确保我们的字符串输入能直接进入到我们的程序(不需要应答或者缓存等等),然后读入和反应字符代码,直到接收到了3(这个是CTRL-C的ascii码)。因为我们已经打开了tty.setrawpirnt不再将光标重制到左边,所以我们需要每次print之后使用\u001b[1000D手动移动光标到左边。

译者注:作者在这里使用setraw系统库函数效果更好的说法是相较于普通的input函数,setraw不会做多余的事,但输入的同时需要没有字符的显示(类似于在终端输入密码)

如果你在Python提示符中运行上述代码(使用CTRL-C退出),尝试敲入一些字符串之后,你会发现:

  • AZ6590aZ97122
  • 事实上,从32126的每个字符都代表着一个 可打印字符
  • (左,右,上,下)分别是(27 91 68, 37 91 67, 37 91 65, 37 91 66)。取决于你的终端和操作系统,这有所不同。
  • 回车是1310(它在不同的电脑上不一样)(译者注:大多数都是13,比如我的Mac电脑),退格是127

因此,我们可以尝试创建我们第一个初始的命令行,它的功能是简单地将我们输入的东西输出:

  • 当用户输入一个可打印字符时,将它打印出来
  • 当用户输入回车时,打印出用户输入,并在新的一行上接收新的输入。
  • 当用户输入退格,删除光标前的字符
  • 当用户输入方向键,使用Ansi escape codes将光标左移或者右移

这显然非常的简陋;我们甚至没有覆盖所有存在的ASCII码,更别说Unicode那些东西!然而它对于我们掌握简单的概念来说是高效。

一个基础的命令行

首先,让我们先实现实现前两个特性:

  • 当用户输入一个可打印字符时,将它打印出来
  • 当用户输入回车时,打印出用户输入,并在新的一行上接收新的输入。

没有退格,没有键盘导航,啥都没有。那些东西都之后再写。

最终得出的结果代码看起来会像是这样的:

import sys, tty

def command_line():
    tty.setraw(sys.stdin)
    while True: # loop for each line
    # Define data-model for an input-string with a cursor
        input = ""
        while True: # loop for each character
            char = ord(sys.stdin.read(1)) # read one char and get char code

            # Manage internal data-model
            if char == 3: # CTRL-C
                return
            elif 32 <= char <= 126:
                input = input + chr(char)
            elif char in {10, 13}: # Enter character
                sys.stdout.write(u"\u001b[1000D")
                print "\nechoing... ", input
                input = ""

            # Print current input-string
            sys.stdout.write(u"\u001b[1000D")  # Move all the way left
            sys.stdout.write(input)
            sys.stdout.flush()

你可以看看它是如何运行的:

就像我们期待的那样,方向键不会正常运作,同样[D [A [C [B这些代表着方向键被打印出来的字符也没有反应。别急,我们会再之后实现它们的。不过,我们可以输入文本并且通过Enter键来提交。

试着将上面的代码粘到你自己的Python提示符中,运行着玩玩!

光标导航

下一步将会是让用户可以使用方向键移动他们的光标。这对于Bash,Python和其他的命令行来说是默认提供的,但是我们得在此自己实现这一点。我们知道方向键左移右移对应于字符序列27 91 6827 91 67,所以我们可以加入这些字符序列来检查是否有方向移动,并且将移动光标的距离存入index变量中。

import sys, tty

def command_line():
    tty.setraw(sys.stdin)
    while True: # loop for each line
    # Define data-model for an input-string with a cursor
        input = ""
        index = 0
        while True: # loop for each character
            char = ord(sys.stdin.read(1)) # read one char and get char code

            # Manage internal data-model
            if char == 3: # CTRL-C
                return
            elif 32 <= char <= 126:
                input = input[:index] + chr(char) + input[index:]
                index += 1
            elif char in {10, 13}:
                sys.stdout.write(u"\u001b[1000D")
                print "\nechoing... ", input
                input = ""
                index = 0
            elif char == 27:
                next1, next2 = ord(sys.stdin.read(1)), ord(sys.stdin.read(1))
                if next1 == 91:
                    if next2 == 68: # Left
                        index = max(0, index - 1)
                    elif next2 == 67: # Right
                        index = min(len(input), index + 1)

            # Print current input-string
            sys.stdout.write(u"\u001b[1000D") # Move all the way left
            sys.stdout.write(input)
            sys.stdout.write(u"\u001b[1000D") # Move all the way left again
            if index > 0:
                sys.stdout.write(u"\u001b[" + str(index) + "C") # Move cursor too index
            sys.stdout.flush()

主要的三点变化:

  • 我们创建了一个index变量。在之前,光标总是在input的最右侧,因为你不能按方向键控制它移动,而新的输入总是在之前输入的右边。现在,我们需要设置一个index,它在输入右边时可能没什么用,但是当用户输入一个字符时,我们利用它将光标放置到输入中正确的位置上。
  • 我们会检查char == 27,同时也检查随后的两个字符是否为标识着左移右移方向键,然后根据判断来增减我们的index(还要确保光标仍在input字符串上)
  • 在输入完input之后,我们现在将光标移到左边,然后必须根据index的值手动将光标向右移动相应的字符个数,使光标在正确的位置上。因为方向键没有作用,之前光标总是在输入的最右边,但是现在光标可以移动到任意位置了。

就像你看到的,运行结果如下:

为了使Home键End键(或者说是Fn-LeftFn-Right)也能起作用,可能还要花更多的精力——像Bash的快捷键Esc-fEsc-B也是如此。但是从原理上来说这已经没有什么困难的了:你只是需要像我们在这节开头做 的那样,编写产生那些键位的字符序列,然后改变index的值控制光标正确移动罢了。

删除

在待完成的功能项中最后一项就是删除:使用退格键应该能使光标之前的字符小时,同时光标向左移动一个字符距离。可以通过直白地加入以下代码来完成:

+ elif char == 127:
+     input = input[:index-1] + input[index:]
+     index -= 1

它确实成功运行了,但是不知怎么回事,结果我们预期的不太一样:

就像你看到的,删除功能完成了,每次我删除字符并按Enter进行提交之后,删除的字符不再会在echo返回中显示。不过,就算我进行了删除,这些删除的字符还是会显示在屏幕上!最后直到他们被新的字符串覆盖掉,就像上面🌰中的第三行那样。

问题是,目前为止,我们从来没有真正地清除一整行的字符输入:我们总是让新的字符把旧的字符覆盖掉,默认新的字符串长度会长到能覆盖掉旧的。只要我们可以删除字符了,这种默认就不起作用了。

一种解决办法是使用行清除Ansi escape codes\u001b[0K,一种能让你在终端上清除一部分内容的Ansi escape codes:

  • 屏幕清除\u001b[{n}J清除屏幕上内容
    • n=0清除从光标位置到屏幕最后的位置上的内容
    • n=1清除从光标位置到屏幕最开始位置上的内容
    • n=2清除整个屏幕上的内容
  • 行清除\u001b[{n}K清除当前行中的内容
    • n=0清除从光标位置到行尾之间的内容
    • n=1清除从光标位置到行首之间的内容
    • n=2清除整行

所以需要加入的代码为:

+ sys.stdout.write(u"\u001b[0K")

清除当前光标到行尾之前的内容。这使得当我们删除然后重新打印一个更短的输入之后,能确保“多余出来”的没被覆盖的文本被正确清除掉。

最后的代码长这个样子:

import sys, tty

def command_line():
    tty.setraw(sys.stdin)
    while True: # loop for each line
    # Define data-model for an input-string with a cursor
        input = ""
        index = 0
        while True: # loop for each character
            char = ord(sys.stdin.read(1)) # read one char and get char code

            # Manage internal data-model
            if char == 3: # CTRL-C
                return
            elif 32 <= char <= 126:
                input = input[:index] + chr(char) + input[index:]
                index += 1
            elif char in {10, 13}:
                sys.stdout.write(u"\u001b[1000D")
                print "\nechoing... ", input
                input = ""
                index = 0
            elif char == 27:
                next1, next2 = ord(sys.stdin.read(1)), ord(sys.stdin.read(1))
                if next1 == 91:
                    if next2 == 68: # Left
                        index = max(0, index - 1)
                    elif next2 == 67: # Right
                        index = min(len(input), index + 1)
            elif char == 127:
                input = input[:index-1] + input[index:]
                index -= 1
            # Print current input-string
            sys.stdout.write(u"\u001b[1000D") # Move all the way left
            sys.stdout.write(u"\u001b[0K")    # Clear the line
            sys.stdout.write(input)
            sys.stdout.write(u"\u001b[1000D") # Move all the way left again
            if index > 0:
                sys.stdout.write(u"\u001b[" + str(index) + "C") # Move cursor too index
            sys.stdout.flush()

然后将这些代码粘到你的命令行程序中,他将完美运行!

为此,在每次的sys.stdout.write之后,我们有必要加入一些sys.stdout.flush(); time.sleep(0.2)这样的代码进去,让我们能够显式地看到程序究竟是如何运行的。如果你真这么做了,你将会看到类似于下面结果:

当你每次简单的输入一个字符,程序会执行下面的工作:

  • 光标移动到当前行的最左边sys.stdout.write(u"\u001b[1000D")
  • 整行内容会被清除sys.stdout.write(u"\u001b[0K")
  • 当前的输入会被打印sys.stdout.write(input)
  • 光标重新移动到行首sys.stdout.write(u"\u001b[1000D")
  • 光标移动到正确的索引位置上sys.stdout.write(u"\u001b[" + str(index) + "C")

正常来说,当你正在运行这段代码,上述步骤会不断发生——只要.flush()被调用了。不过,这仍然值得我们去看看运行过程中究竟发生了什么,从而能够在它运行或者调试它的时候理解它的原理。

这就结束了?

我们现在有了一个最简陋版本的命令行程序,它由sys.stdin.readsys.stdout.write以及使用ANSI escape codes来控制终端共同实现。它缺乏功能性,缺少很多“标准”命令行程序提供的热键,比如:

  • Alt-f来向右移动一个单词
  • Alt-b来向左移动一个单词
  • Alt-Backspace来删除一个光标左边的单词
  • ...很多其他命令行的热键,有些在此列出

现在的这个程序还不具有鲁棒性(不够稳健robust)来执行像是多行字符串输入这样的功能,但是单行的字符串输入对于给用户去输入或显示一个自定义的命令行来说,已经足够长了

不管怎么说,实现对诸如热键和边界情况输入的鲁棒性的支持就仅仅是更多的重复工作:提出一个还没实现的功能,然后搞清楚正确的内部逻辑,思考哪些ANSI escape codes能够让终端像我们预期那样运行即可。

这里还有其他以后可能有用的终端命令;维基百科的escape codes表是一个很好的列表(在表中的CSI对应于我们的\u001b),但是这里有一部分是比较有用的:


  • 上移\u001b[{n}A将光标向上移动n个字符
  • 下移\u001b[{n}B将光标向下移动n个字符
  • 右移\u001b[{n}C将光标向右移动n个字符
  • 左移\u001b[{n}D将光标向左移动n个字符

  • 下一行\u001b[{n}E将光标向上移动n行并移动到行首
  • 上一行\u001b[{n}F将光标向上移动n行并移动到行首

  • 设置列\u001b[{n}G将光标移动到第n列
  • 设置位置\u001b[{n};{m}H将光标移动到第n行第m列

  • 屏幕清除\u001b[{n}J清除屏幕上内容
    • n=0清除从光标位置到屏幕最后的位置上的内容
    • n=1清除从光标位置到屏幕最开始位置上的内容
    • n=2清除整个屏幕上的内容
  • 行清除\u001b[{n}K清除当前行中的内容
    • n=0清除从光标位置到行尾之间的内容
    • n=1清除从光标位置到行首之间的内容
    • n=2清除整行

  • 保存位置\u001b[{s}保存光标当前位置
  • 存储位置\u001b[{u}存储上次光标保存的位置

这些是你在尝试控制光标和终端时可能用得上的代码,而且对一系列类似的东西都可能有用:比如实现终端游戏,命令行,像Vim或者Emacs那样的文本编辑器,其他所有终端有关的程序。尽管有时候ansi escape codes会让我们困惑于控制代码究竟怎么运行的,不过我们总可以在代码中加上time.sleep来一探究竟。到目前为止,让我们正式宣布探索的结束...

自定义你的命令行程序

如果你已经看到了这里,那么你已经掌握了通过Ansi escape codes来控制终端的输出颜色,编写各种动态进度指示器,以及最后编写一个小巧的,简陋的,能返回用户输入字符串的命令行程序。你可能认为我们所做的探索是以实用程度递增的顺序编排的:改变输入的颜色很酷,但是每种编程语言已经有了自己的命令行程序,有什么必要再去重新实现他们呢?更何况已经有很多像是Readline或者JLine那样的库可以快速帮你去实现?

事实证明,在2016年,还是有一些必要的情况去重构你的命令行程序。很多已经存在的命令行库并不那么灵活,不能支持像语法高亮等基本的输入情况。如果你想要建立一个普通的远程/本地连接的程序,像是下拉菜单或者Shift-LeftShift-Right这种高亮选中你部分输入的功能键,多数现存的实现库会让你不知所措。

不过,现在我们有自己可以从头开始实现的思路,语法高亮只需要在input字符串前简单地调用syntax_highlight函数,就能实现打印前设置合适的代码颜色:

+            sys.stdout.write(syntax_highlight(input))
-            sys.stdout.write(input)

为了演示我现在将使用一个模拟语法高亮效果的函数,这个函数会高亮行尾的空格——这是多数程序员讨厌的。

就像这样简单:

def syntax_highlight(input):
   stripped = input.rstrip()
   return stripped + u"\u001b[41m" + " " *  (len(input) - len(stripped)) + u"\u001b[0m"

然后就实现了:

再次说明,这是另一个小的例子,但是你可以想象如果将syntax_highlight的实现换成像是Pygments的功能,就可以呈现出在几乎所有的编程语言的命令行中所看到的实时语法高亮。仅仅这样,我们就向之前的代码中加入了实现自定义语法高亮的代码。还不错嘛!

下面是完整的代码,如果你想要复制粘贴自己运行以下的话:

import sys, tty

def syntax_highlight(input):
    stripped = input.rstrip()
    return stripped + u"\u001b[41m" + " " *  (len(input) - len(stripped)) + u"\u001b[0m"

def command_line():
    tty.setraw(sys.stdin)
    while True: # loop for each line
        # Define data-model for an input-string with a cursor
        input = ""
        index = 0
        while True: # loop for each character
            char = ord(sys.stdin.read(1)) # read one char and get char code

            # Manage internal data-model
            if char == 3: # CTRL-C
                return
            elif 32 <= char <= 126:
                input = input[:index] + chr(char) + input[index:]
                index += 1
            elif char in {10, 13}:
                sys.stdout.write(u"\u001b[1000D")
                print "\nechoing... ", input
                input = ""
                index = 0
            elif char == 27:
                next1, next2 = ord(sys.stdin.read(1)), ord(sys.stdin.read(1))
                if next1 == 91:
                    if next2 == 68: # Left
                        index = max(0, index - 1)
                    elif next2 == 67: # Right
                        index = min(len(input), index + 1)
            elif char == 127:
                input = input[:index-1] + input[index:]
                index -= 1
            # Print current input-string
            sys.stdout.write(u"\u001b[1000D")
            sys.stdout.write(u"\u001b[0K")
            sys.stdout.write(syntax_highlight(input))
            sys.stdout.write(u"\u001b[1000D")
            if index > 0:
                sys.stdout.write(u"\u001b[" + str(index) + "C")
            sys.stdout.flush()

除了语法高亮,现在我们已经拥有了自己的相对简陋的DIY命令行,还有无数的可能正等着我们去探索:创建下拉菜单仅仅是将光标移动到正确的位置然后打印正确的内容;实现Shift-LeftShift-Right来高亮选中文本也仅仅是识别正确的输入z字符(在Mac-OSX/iTerm上是27 91 49 59 50 6827 91 49 59 50 67)之后在打印前运用一些背景颜色或者颜色反转

实现起来可能会有些枯燥无味,但是实现的过程都是很直接的:只要你熟悉Ansi codes的基本用法,你能够使用他们与终端进行交互,那么任何你想要的特性都只是编写代码来让奇迹发生。

总结

使你的命令行程序拥有的这些“花里胡哨”的交互方式是大多数传统的命令行程序所不具备的。尽管用Readline实现语法高亮绝对不会超过4行代码!但是要你自己去实现的话,一切皆有可能。

最近,出现了一大推新的命令行函数库,诸如Python Prompt ToolkitFish Shell还有Ammonite Scala REPL (我自己开发的项目)都提供了一种比传统基于Readline/JLine的命令行更丰富的命令行使用体验,他们有着如语法高亮和多行编辑等更出色的特性。

同时还有桌面端样式的Shift-Left/Shift-Right选中,以及使用TabShift-Tab触发的类似于IDE的块缩进:

为了创建一个像上面一样的工具,你需要自己理解如何直接与终端交互的多种方式。同时我们在之前实现的最小的命令行很显然并不完整也不具备鲁棒性,你可以很直接地编程(如果你无聊的话)加入更多的特性,让它变成多数人所接受的命令行那样。在这之后,你就可以和目前的那些函数库看齐,去实现更多像Readline/JLine这些现存库提供的特性和交互方式。

你可能会想要实现一个现有编程语言没有的崭新的REPL?你也可能想要写一个有更丰富特性和功能的REPL来代替之前的那个?或许你喜欢Python Prompt Toolkit那样提供编写python命令行程序的工具,然后想要在Javascript里面也整一个?或者你决定实现你自己的像Vim或Emacs的命令行文本编辑器,甚至想要做的更好?

结果是,学习足够多的Ansi escape codes知识来实现你自己的多种终端交互功能并没有开始时看起来的那么难。通过较少的控制指令,你就可以实现你的终端交互功能,同时为软件工程领域相对落后的领域带来进步。

你曾有发现你需要在自己的命令行程序中使用这些Ansi escape codes吗?用来做什么功能?让我们在下面的评论区中探讨吧!

posted @ 2022-10-02 14:32  Rokelamen  阅读(299)  评论(0编辑  收藏  举报