使用ANSI转义码构建自己的命令行(2016)

2021-02-06 20:03:57

每个人都习惯于在终端上打印输出,该终端会随着新文本的出现而滚动,但这并不是您所能做的:您的程序可以为文本加上颜色,上下,左右或上下移动光标屏幕上的部分,如果您以后要重新打印它们。这就是让Git等程序实现其动态进度指示器,而Vim或Bash实现其编辑器的功能,这些编辑器使您可以修改已经显示的文本而无需滚动终端。

有诸如Readline,JLine或Python Prompt Toolkit之类的库可以帮助您使用各种编程语言来完成此操作,但是您也可以自己执行。这篇文章将探讨如何从任何命令行程序控制终端的基础知识,并提供Python示例,以及您自己的代码如何直接利用终端必须提供的所有特殊功能。

关于作者:Haoyi是一名软件工程师,并且是许多开源Scala工具(例如Ammonite REPL和Mill Build Tool)的作者。如果您喜欢此博客上的内容,那么您可能还会喜欢Haoyi的《动手Scala编程》一书

大多数程序与Unix终端进行交互的方式是通过ANSI转义码。这些是您的程序可以打印的特殊代码,以提供终端说明。各种终端支持这些代码的不同子集,因此很难找到权威的代码。每个代码的功能列表。维基百科和其他许多网站一样,都列出了它们的合理列表。

尽管如此,仍然可以编写使用ANSI转义码的程序,并且至少可以在常见的Unix系统(例如Ubuntu或OS-X)上运行(尽管Windows无法运行,在此我将不做介绍,自己的冒险!)。这篇文章将探讨Ansi逃逸代码的基础知识,并演示如何从第一原理中使用它们来编写自己的交互式命令行:

最基本的Ansi逸出代码是呈现文本所涉及的代码。这些使您可以在打印的文本中添加颜色,背景色或其他装饰等装饰,但不要做任何花哨的事情。您打印的文本仍将最终显示在终端的底部,并仍使您的终端滚动,只是现在它将是彩色文本,而不是终端具有的默认黑白配色方案。

您可以对文本执行的最基本操作是为文本着色。 Ansi的颜色看起来都像

\ u001b字符是从大多数Ansi逃逸开始的特殊字符;大多数语言都允许使用此语法表示特殊字符,例如Java,Python和Javascript都允许使用\ u001b语法。

请注意,我们如何需要在字符串前面加上u,即u" ..."为了使其在Python 2.7.10中有效。在Python 3或其他语言中,这不是必需的。

看看从打印的“ Hello World”开始,红色最终如何溢出到>>>中。提示。实际上,我们在此提示符下键入的任何代码也将被涂成红色,并且随后的输出也将被涂成红色!这就是Ansi颜色的工作方式:一旦您打印出启用颜色的特殊代码,该颜色就会永久存在,直到其他人打印出另一种颜色的代码或打印出“重置代码”以将其禁用为止。

我们可以看到提示变成白色。通常,您应该始终记得以“重置”结束要打印的所有彩色字符串,以确保您不会意外

为了避免这种情况,我们需要确保我们用Reset代码结束彩色字符串:

在打印完字符串后,它将适当地重设颜色。您还可以在字符串的一半处进行重置,以使下半部分不着色:

我们已经看到了红色和重置的工作方式。最基本的终端有一组8种不同的颜色:

我们可以通过打印每种颜色的一个字母,然后重新设置来演示:

打印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看起来与普通文本相同。如果我们为终端选择了不同的颜色方案,那就相反了:

打印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很难辨认。

除了8种颜色的基本设置外,大多数终端还支持" bright"或"粗体"颜色。这些都有自己的一组代码,它们反映了正常的颜色,但是在代码中附加了; 1:

请注意,“重置”是相同的:这是重置代码,用于重置所有颜色和文本效果。

并看到它们确实比基本的8种颜色要亮得多。现在,即使黑色A足够亮,在黑色背景上也可以看到灰色,而白色H现在甚至比默认文本颜色还要亮。

导入sysfor i在范围(0,16)中:对于j在范围(0,16)中:code = str(i * 16 + j)sys.stdout.write(u" \ u001b [38; 5;&# 34; +代码+" m" + code.ljust(4))打印u" \ u001b [0m"

在这里,我们使用sys.stdout.write而不是print,因此我们可以在同一行上打印多个项目,但是否则,这很容易解释。从0到255的每个代码对应一种特定的颜色。

Ansi转义码使您可以设置文本背景的颜色,就像您设置前背的颜色一样。例如,这8种背景色与代码相对应:

打印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"打印u" \ u001b [44; 1m A \ u001b [45; 1m B \ u001b [46; 1m C \ u001b [47; 1m D \ u001b [0m"

请注意,背景颜色的亮色不会改变背景,而是使前景文本更亮。这是不直观的,但这只是它的工作方式。

导入sysfor i在范围(0,16)中:对于j在范围(0,16)中:代码= str(i * 16 + j)sys.stdout.write(u" \ u001b [48; 5;&# 34; +代码+" m" + code.ljust(4))打印u" \ u001b [0m"

下一组Ansi转义码更为复杂:它们使您可以在终端窗口中移动光标或擦除其中的一部分。这些是Ansi转义码,Bash等程序使用它来使您响应输入键在输入命令中左右移动光标。

为了利用这些,首先让我们建立一个“正常”基线。 Python提示可以。

在这里,我们添加了一个time.sleep(10),以便可以看到它的实际作用。我们可以看到,如果我们打印出一些东西,那么首先打印出输出,然后将光标移至下一行:

然后它会打印下一个提示,并将光标移到它的右侧。

因此,这就是游标已经到达的基线。我们该怎么办?

使用光标导航Ansi转义码最简单的方法是发出加载提示:

导入时间,sysdef loading():打印" Loading ..."对于范围(0,100)中的i:time.sleep(0.1)sys.stdout.write(u" \ u001b [1000D" + str(i +1)+"%" )sys.stdout.flush()打印loading()

由于它使用stdout.write而不是print,因此可以在同一行上打印从1%到100%的文本。但是,在打印每个百分比之前,它首先打印\ u001b [1000D,这意味着将光标向左移动1000个字符)。这应该将其一直移到屏幕的左侧,这样就可以使新打印的百分比覆盖旧的百分比。因此,在函数返回之前,我们看到加载百分比从1%无缝变为100%:

可能很难想象光标在哪里移动,但是我们可以轻松放慢速度并添加更多睡眠以使代码显示给我们:

导入时间,sysdef loading():打印" Loading ..."对于范围(0,100)中的i:time.sleep(1)sys.stdout.write(u" \ u001b [1000D")sys.stdout.flush()time.sleep(1)sys.stdout .write(str(i +1)+"%")sys.stdout.flush()打印loading()

在这里,我们拆分了写入"向左移动"的写入转义代码,来自写入百分比进度指示器的写入。我们还在它们之间增加了1秒钟的睡眠时间,使我们有机会看到它们之间的光标。状态,而不仅仅是最终结果:

现在,我们可以看到光标在新打印的百分比覆盖旧的百分比之前向左移动到屏幕边缘。

既然我们知道了如何使用Ansi转义码制作一个自我更新的进度条来控制终端,那么将其修改为更高级的操作变得相对容易,例如屏幕上有一个ASCII栏:

导入时间,sysdef loading():打印" Loading ..."对于范围(0,100)中的i:time.sleep(0.1)width =(i + 1)/ 4 bar =" [" +"#" *宽度+" " *(25-width)+"]" sys.stdout.write(u" \ u001b [1000D" + bar)sys.stdout.flush()打印loading()

这可以按您预期的那样工作:循环的每次迭代,将擦除整个行,并绘制新版本的ASCII条。

我们甚至可以使用向上和向下光标移动来一次绘制多个进度条:

导入时间,sys,randomdef加载(计数):all_progress = [0] *计数sys.stdout.write(" \ n" *计数)#确保我们有空间在任何(x <在all_progress中x等于100:time.sleep(0.01)#随机增加未完成的进度值之一= [[i,v)for enumerate(all_progress)中的(i,v),如果v< 100]索引,_ = random.choice(未完成)all_progress [index] + = 1#绘制进度条sys.stdout.write(u" \ u001b [1000D")#向左移动sys.stdout.write (u" \ u001b [" + str(count)+" A")#在all_progress中向上移动以取得进度:width = progress / 4 print" [" +"#" *宽度+" " *(25-width)+"]" loading()

确保我们有足够的空间绘制进度条!这是通过编写" \ n"来完成的*计数功能启动的时间。这将创建一系列使终端滚动的换行符,从而确保在终端底部精确计数空白行,以便在其上显示进度条

用all_progress数组模拟正在进行的多件事,并使该数组中的各个插槽随机填充

使用Up ansi代码每次将光标计数行向上移动,因此我们可以每行打印一次计数进度条

也许下次您编写并行下载大量文件的命令行应用程序,或执行类似的并行任务时,您可以编写类似的基于Ansi-escape-code的进度条,以便用户可以看到他们的命令进展如何。

当然,到目前为止,所有这些进度提示都是虚假的:它们并没有真正监视任何任务的进度。但是,它们演示了如何使用Ansi转义代码将动态进度指示器放置在您编写的任何命令行程序中,因此,当您有可以监视其进度的内容时,现在就可以放置动态更新进度了吧。

您可以使用Ansi转义码执行的更有趣的操作之一是实现命令行。 Bash,Python,Ruby都有自己的内置命令行,可让您键入命令并编辑其文本,然后再提交执行。尽管它看起来很特殊,但实际上该命令行只是通过Ansi转义码与终端交互的另一个程序!由于我们知道如何使用Ansi转义码,因此我们也可以这样做并编写我们自己的命令行。

到目前为止,我们尚未完成的与命令行相关的第一件事就是接受用户输入。可以使用以下代码完成此操作:

import sys,ttydef command_line():tty.setraw(sys.stdin)而True:char = sys.stdin.read(1)如果ord(char)== 3:#CTRL-C break;打印ord(char)sys.stdout.write(u" \ u001b [1000D")#一直向左移动

实际上,我们使用setraw来确保原始字符输入直接进入我们的过程(没有回显或缓冲或任何东西),然后读取并回显我们看到的字符代码,直到出现3(这是CTRL-C,常见的现有REPL的命令)。由于我们已打开tty.setraw打印,因此不再将光标重置到左侧,因此我们需要在每次打印后以\ u001b [1000D手动向左移动。

如果您在Python提示符下运行此命令(CTRL-C退出)并尝试输入一些字符,您将看到:

(左,右,上,下)是(27 91 68,27 91 67,27 91 65,27 91 66)。这可能取决于您的终端和操作系统。

因此,我们可以尝试制作我们的第一个原始命令行,该命令行简单地回显用户键入的内容:

当用户按下Enter键时,在该点打印用户输入,换行,然后开始新的空输入。

当用户按下箭头键时,使用我们在上面看到的Ansi转义码向左或向右移动光标

显然,这大大简化了;我们甚至还没有介绍所有存在的所有不同类型的ASCII字符,请不要忘记所有Unicode东西!但是,对于简单的概念验证就足够了。

当用户按下Enter键时,在该点打印用户输入,换行,然后开始新的空输入。

import sys,tty def command_line():tty.setraw(sys.stdin)而True:#每行循环#为带有光标输入的输入字符串定义数据模型=""而True:#每个字符循环char = ord(sys.stdin.read(1))#读取一个char并获取char代码#如果char == 3,则管理内部数据模型:#CTRL-C返回elif 32< = char< = 126:输入=输入+ chr(char)elif char in {10,13}:sys.stdout.write(u" \ u001b [1000D")print" \ nechoing。 ..&#34 ;,输入input ="" #打印当前输入字符串sys.stdout.write(u" \ u001b [1000D")#一直向左移动sys.stdout.write(input)sys.stdout.flush()

正如我们期望的那样,箭头键不起作用,并导致打印出奇数[D [A [C [B]字符,这与我们在上面看到的箭头键代码相对应。接下来,我们将开始处理该工作。不过,我们可以输入文本,然后使用Enter提交。

下一步将是让用户使用箭头键来移动光标。默认情况下,Bash,Python和其他命令行提供了此功能,但是当我们在此处实现自己的命令行时,我们必须自己做。我们知道箭头键Left和Right对应于字符代码27 91 68、27 91 67的序列,因此我们可以在代码中进行检查以检查它们并适当地移动光标索引变量

import sys,tty def command_line():tty.setraw(sys.stdin)而True:#每行循环#为带有光标输入的输入字符串定义数据模型="" index = 0,但为True时:#每个字符循环char = ord(sys.stdin.read(1))#读取一个char并获取char代码#如果char == 3,则管理内部数据模型:#CTRL-C返回elif 32< =字符< = 126:输入=输入[:索引] + chr(字符)+输入[索引:]索引+ = 1 elif char in {10,13}:sys.stdout.write(u&#34 ; \ u001b [1000D")打印" \ nechoing ...&#34 ;,输入input ="" index = 0 elif char == 27:next1,next2 = ord(sys.stdin.read(1)),ord(sys.stdin.read(1))if next1 == 91:if next2 == 68:#左index = max(0,index-1)elif next2 == 67:#右index = min(len(input),index + 1)#打印当前输入字符串sys.stdout.write(u" \ u001b [ 1000D")#一直向左移动sys.stdout.write(input)sys.stdout.write(u" \ u001b [1000D")#如果索引>再次向左移动0:sys.stdout.write(u" \ u001b [" + str(index)+" C")#也将光标移到索引sys.stdout.flush()

现在,我们维护一个索引变量。以前,光标始终位于输入的右端,因为您不能使用箭头键将其向左移动,而新输入始终会附加在右端。现在,我们需要保留一个单独的索引,该索引不一定在输入的结尾,并且当用户输入字符时,我们会将其拼接到正确位置的输入中。

我们检查char == 27,然后再检查接下来的两个字符以标识向左和**向右箭头键,并递增/递减光标的索引(确保将其保留在输入字符串中)。

写入输入后,我们现在必须手动将光标一直移到最左侧,然后将其向右正确移动与光标索引相对应的字符数。以前,光标始终位于我们输入的最右边,因为箭头键不起作用,但是现在光标可以在任何地方。

要使Home和End(或Fn-Left和Fn-Right)以及类似Bash的快捷键(如Esc-f和Esc-B)正常工作,需要付出更多的努力,但是从原则上讲,这些都不困难:您只需要写下它们产生的代码序列与本节开始时相同的方式,并使它们适当地更改我们的光标索引。

功能列表上要实现的最后一件事是删除:使用Backspace会导致光标消失之前先产生一个字符,然后将光标向左移动1。

如您所见,删除是有效的,因为删除字符后,当我按Enter提交时,它们不再回显我。但是,即使删除它们,角色仍然坐在屏幕上!至少直到它们被新字符覆盖为止,如上例中的第三行所示。

问题在于,到目前为止,我们还没有真正清除整行内容:我们一直只是将新字符写在旧字符上,假设新字符的字符串会更长并且覆盖它们。一旦我们可以删除字符,这将不再成立。

解决方法是使用Clear Line Ansi转义码\ u001b [0K,这是一组Ansi转义码中的一个,它使您可以清除终端的各个部分:

清除从光标到行尾的所有字符。这样一来,我们可以确保在删除并重新打印较短的输入后,所有" leftover"我们不会覆盖的文本仍会从屏幕上正确清除。

import sys,tty def command_line():tty.setraw(sys.stdin)而True:#每行循环#为带有光标输入的输入字符串定义数据模型="" index = 0,但为True时:#每个字符循环char = ord(sys.stdin.read(1))#读取一个char并获取char代码#如果char == 3,则管理内部数据模型:#CTRL-C返回elif 32< =字符< = 126:输入=输入[:索引] + chr(字符)+输入[索引:]索引+ = 1 elif char in {10,13}:sys.stdout.write(u&#34 ; \ u001b [1000D")打印" \ nechoing ...&#34 ;,输入input ="" index = 0 elif char == 27:next1,next2 = ord(sys.stdin.read(1)),ord(sys.stdin.read(1))if next1 == 91:if next2 == 68:#左index = max(0,index-1)elif next2 == 67:#右index = min(len(input),index + 1)elif char == 127:input = input [:index-1] + input [index :] index-= 1#打印当前输入字符串sys.stdout.write(u" \ u001b [1000D")#一直向左移动sys.stdout.write(u" \ u001b [0K& #34;)#清除sys.stdout.write(input)行sys.stdout.write(u" \ u001b [1000D")#如果索引>再次向左移动0:sys.stdout.write(u" \ u001b [" + str(index)+" C")#也将光标移到索引sys.stdout.flush()

此时,值得放置一些sys.stdout.flush();。在每次sys.stdout.write之后,将time.sleep(0.2); s放入代码中,只是看它是否起作用。如果这样做,您将看到类似以下内容:

通常,当您使用此代码时,所有这些都会立即发生

......