通过Ansi Escape Codes酷炫玩转命令行!

引言

你是否:

  • 好奇过命令行里那些花里胡哨的进度条是如何实现的?
  • 好奇过Spring Boot为什么能够打印五颜六色的日志?
  • 好奇过Python或者PHP等脚本语言的交互式命令行是如何实现的?
  • 好奇过Vim或者Emacs等在Terminal中的编辑器是怎么实现的?

如果你曾经好奇过,或者被这段话勾起了你的好奇心,那么你绝对不能错过这篇文章!

背景

通过本文你可以学到:

  1. 何为Ansi Escape Codes以及它们能干什么?
  2. Ansi Escape Codes的一些高级应用。
  3. JDK9中Jshell的使用。

事先声明,本文主要参考:www.lihaoyi.com/post/Buildy…。原文思路清晰,案例生动形象,排版优秀,实为良心之作。但是由于原文是用英语书写且用Python作为演示,所以本后端小菜鸡不要脸地将其翻译一遍,并且用JDK9的Jshell做演示,方便广大的Javaer学习。

本文所有的代码已经推到Github中,地址为:github.com/Lovelcp/blo…。强烈建议大家将代码clone下来跑一下看看效果,加深自己的印象。

环境

  • Mac或Linux或者WIn10操作系统。除了Win10之外的Windows系统暂时不支持Ansi Escape Codes。
  • 因为本文采用Jshell作为演示工具,所以大家需要安装最近刚正式发布的JDK9。

OK!一切准备就绪,让我们开始吧!

富文本

Ansi Escape Codes最基础的用途就是让控制台显示的文字以富文本的形式输出,比如设置字体颜色、背景颜色以及各种样式。让我们先来学习如何设置字体颜色,而不用再忍受那枯燥的黑白二色!

字体颜色

通过Ansi指令(即Ansi Escape Codes)给控制台的文字上色是最为常见的操作。比如:

  • 红色:\u001b[31m
  • 重置:\u001b[0m

绝大部分Ansi Escape Codes都以\u001b开头。让我们通过Java代码来输出一段红色的Hello World

System.out.print("\u001b[31mHello World");

从上图中,我们可以看到,不仅Hello World是变成了红色,而且接下来的jshell>提示符也变成了红色。其实不管你接下来输入什么字符,它们的字体颜色都是红色。直到你输入了其他颜色的Ansi指令,或者输入了重置指令,字体的颜色才会不再是红色。

让我们尝试输入重置指令来恢复字体的颜色:

System.out.print("\u001b[0m");

很好!jshell>提示符恢复为了白色。所以一个最佳实践就是,最好在所有改变字体颜色或者样式的Ansi Escape Codes的最后加上重置指令,以免造成意想不到的后果。举个例子:

System.out.print("\u001b[31mHello World\u001b[0m");

当然,重置指令可以被添加在任何位置,比如我们可以将其插在Hello World的中间,使得Hello是红色,但是World是白色:

System.out.print("\u001b[31mHello\u001b[0m World");

8色

刚才我们介绍了红色以及重置命令。基本上所有的控制台都支持以下8种颜色:

  • 黑色:\u001b[30m
  • 红色:\u001b[31m
  • 绿色:\u001b[32m
  • 黄色:\u001b[33m
  • 蓝色:\u001b[34m
  • 洋红色:\u001b[35m
  • 青色:\u001b[36m
  • 白色:\u001b[37m
  • 重置:\u001b[0m

不如将它们都输出看一下:

System.out.print("\u001b[30m A \u001b[31m B \u001b[32m C \u001b[33m D \u001b[0m");
System.out.print("\u001b[34m E \u001b[35m F \u001b[36m G \u001b[37m H \u001b[0m");

注意,A因为是黑色所以与控制台融为一体了。

16色

大多数的控制台,除了支持刚才提到的8色外,还可以输出在此之上更加明亮的8种颜色:

  • 亮黑色:\u001b[30;1m
  • 亮红色:\u001b[31;1m
  • 亮绿色:\u001b[32;1m
  • 亮黄色:\u001b[33;1m
  • 亮蓝色:\u001b[34;1m
  • 亮洋红色:\u001b[35;1m
  • 亮青色:\u001b[36;1m
  • 亮白色:\u001b[37;1m

亮色指令分别在原来对应颜色的指令中间加上;1。我们将所有的16色在控制台打印,方便大家进行比对:

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

从图中我们可以清晰地看到,下面的8色比上面的8色显得更加明亮。比如,原来黑色的A,在黑色的控制台背景下,几乎无法看到,但是一旦通过亮黑色输出后,对比度变得更高,变得更好辨识了。

256色

最后,除了16色外,某些控制台支持输出256色。指令的形式如下:

  • \u001b[38;5;${ID}m

让我们输出256色矩阵:

for (int i = 0; i < 16; i++) {
    for (int j = 0; j < 16; j++) {
        int code = i * 16 + j;
        System.out.printf("\u001b[38;5;%dm%-4d", code, code);
    }
    System.out.println("\u001b[0m");
}

关于字体颜色我们就介绍到这,接下来我们来介绍背景色。

背景颜色

刚才所说的字体颜色可以统称为前景色(foreground color)。那么理所当然,我们可以设置文本的背景颜色:

  • 黑色背景:\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

首先让我们看看16色背景:

System.out.print("\u001b[40m A \u001b[41m B \u001b[42m C \u001b[43m D \u001b[0m");
System.out.print("\u001b[44m A \u001b[45m B \u001b[46m C \u001b[47m D \u001b[0m");
System.out.print("\u001b[40;1m A \u001b[41;1m B \u001b[42;1m C \u001b[43;1m D \u001b[0m");
System.out.print("\u001b[44;1m A \u001b[45;1m B \u001b[46;1m C \u001b[47;1m D \u001b[0m");

值得注意的是,亮色背景并不是背景颜色显得更加明亮,而是让对应的前景色显得更加明亮。虽然这点有点不太直观,但是实际表现就是如此。

让我们再来试试256背景色,首先指令如下:

  • \u001b[48;5;${ID}m

同样输出256色矩阵:

for (int i = 0; i < 16; i++) {
    for (int j = 0; j < 16; j++) {
        int code = i * 16 + j;
        System.out.printf("\u001b[48;5;%dm%-4d", code, code);
    }
    System.out.println("\u001b[0m");
}

感觉要被亮瞎眼了呢!至此,颜色设置已经介绍完毕,让我们接着学习样式设置。

样式

除了给文本设置颜色之外,我们还可以给文本设置样式:

  • 粗体:\u001b[1m
  • 下划线:\u001b[4m
  • 反色:\u001b[7m

样式分别使用的效果:

System.out.print("\u001b[1m BOLD \u001b[0m\u001b[4m Underline \u001b[0m\u001b[7m Reversed \u001b[0m");

或者结合使用:

System.out.print("\u001b[1m\u001b[4m\u001b[7m BOLD Underline Reversed \u001b[0m");

甚至还可以和颜色结合使用:

System.out.print("\u001b[1m\u001b[31m Red Bold \u001b[0m");
System.out.print("\u001b[4m\u001b[44m Blue Background Underline \u001b[0m");

是不是很简单,是不是很酷!学会了这些,我们已经能够写出十分酷炫的命令行脚本了。但是如果要实现更复杂的功能(比如进度条),我们还需要掌握更加牛逼的光标控制指令!

光标控制

Ansi Escape Code里更加复杂的指令就是光标控制。通过这些指令,我们可以自由地移动我们的光标至屏幕的任何位置。比如在Vim的命令模式下,我们可以使用H/J/K/L这四个键实现光标的上下左右移动。

最基础的光标控制指令如下:

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

通过光标控制的特性,我们能够实现大量有趣且酷炫的功能。首先我们来看看怎么实现一个进度条。

进度数字显示

作为进度条,怎么可以没有进度数字显示呢?所以我们先来实现进度条进度数字的刷新:

void loading() throws InterruptedException {
    System.out.println("Loading...");
    for (int i = 1; i <= 100; i++) {
        Thread.sleep(100);
        System.out.print("\u001b[1000D" + i + "%");
    }
}

从图中我们可以看到,进度在同一行从1%不停地刷新到100%。为了进度只在同一行显示,我们在代码中使用了System.out.print而不是System.out.println。在打印每个进度之前,我们使用了\u001b[1000D指令,目的是为了将光标移动到当前行的最左边也就是行首。然后重新打印新的进度,新的进度数字会覆盖刚才的进度数字,循环往复,这就实现了上图的效果。

PS:\u001b[1000D表示将光标往左移动1000个字符。这里的1000表示光标移动的距离,只要你能够确保光标能够移动到最左端,随便设置多少比如设置2000都可以。

为了方便大家更加轻松地理解光标的移动过程,让我们放慢进度条刷新的频率:

void loading() throws InterruptedException {
    System.out.println("Loading...");
    for (int i = 1; i <= 100; i++) {
        System.out.print("\u001b[1000D");
        Thread.sleep(1000);
        System.out.print(i + "%");
        Thread.sleep(1000);
    }
}

现在我们可以清晰地看到:

  1. 从左到右打印进度,光标移至行尾。
  2. 光标移至行首,原进度数字还在。
  3. 从左到右打印新进度,新的数字会覆盖老的数字。光标移至行尾。
  4. 循环往复。

Ascii进度条

好了,我们现在已经知道如何通过Ansi Escape Code实现进度数字的显示和刷新,剩下的就是实现进度的读条。废话不多说,我们直接上代码和效果图:

void loading() throws InterruptedException {
    System.out.println("Loading...");
    for (int i = 1; i <= 100; i++) {
        int width = i / 4;
        String left = "[" + String.join("", Collections.nCopies(width, "#"));
        String right = String.join("", Collections.nCopies(25 - width, " ")) + "]";
        System.out.print("\u001b[1000D" + left + right);
        Thread.sleep(100);
    }
}

由上图我们可以看到,每次循环过后,读条就会增加。原理和数字的刷新一样,相信大家阅读代码就能理解,这里就不再赘述。

让我们来点更酷的吧!利用Ansi的光标向上以及向下的指令,我们还可以同时打印出多条进度条:

void loading(int count) throws InterruptedException {
    System.out.print(String.join("", Collections.nCopies(count, "\n"))); // 初始化进度条所占的空间
    List<Integer> allProgress = new ArrayList<>(Collections.nCopies(count, 0));
    while (true) {
        Thread.sleep(10);

        // 随机选择一个进度条,增加进度
        List<Integer> unfinished = new LinkedList<>();
        for (int i = 0; i < allProgress.size(); i++) {
            if (allProgress.get(i) < 100) {
                unfinished.add(i);
            }
        }
        if (unfinished.isEmpty()) {
            break;
        }
        int index = unfinished.get(new Random().nextInt(unfinished.size()));
        allProgress.set(index, allProgress.get(index) + 1); // 进度+1

        // 绘制进度条
        System.out.print("\u001b[1000D"); // 移动到最左边
        System.out.print("\u001b[" + count + "A"); // 往上移动
        for (Integer progress : allProgress) {
            int width = progress / 4;
            String left = "[" + String.join("", Collections.nCopies(width, "#"));
            String right = String.join("", Collections.nCopies(25 - width, " ")) + "]";
            System.out.println(left + right);
        }
    }
}

在上述代码中:

  • 我们首先执行System.out.print(String.join("", Collections.nCopies(count, "\n")));打印出多个空行,这可以保证我们有足够的空间来打印进度条。
  • 接下来我们随机增加一个进度条的进度,并且打印出所有进度条。
  • 最后我们调用向上指令,将光标移回到最上方,继续下一个循环,直到所有进度条都到达100%。

实际效果如下:

效果真是太棒啦!剩下将读条和数字结合在一起的工作就交给读者啦。学会了这招,当你下次如果要做一个在命令行下载文件的小工具,这时候这些知识就派上用场啦!

制作命令行

最后,最为酷炫的事情莫过于利用Ansi Escape Codes实现一个个性化的命令行(Command-Line)。我们平常使用的Bash以及一些解释型语言比如Python、Ruby等都有自己的REPL命令行。接下来,让我们揭开他们神秘的面纱,了解他们背后实现的原理。

PS:由于在Jshell中,方向键、后退键等一些特殊键有自己的作用,所以接下来无法通过Jshell演示。需要自己手动进行编译运行代码才能看到实际效果。

一个最简单的命令行

首先,我们来实现一个最简单的命令行,简单到只实现下面两种功能:

  • 当用户输入一个可打印的字符时,比如abcd等,则在控制台显示。
  • 当用户输入回车时,另起一行,输出刚才用户输入的所有字符,然后再另起一行,继续接受用户的输入。

那么这个最简单的命令行的实现代码会长这样:

import java.io.IOException;

public class CommandLine {
    public static void main(String[] args) throws IOException, InterruptedException {
        // 设置命令行为raw模式,否则会自动解析方向键以及后退键,并且直到按下回车read方法才会返回
        String[] cmd = { "/bin/sh", "-c", "stty raw </dev/tty" };
        Runtime.getRuntime()
               .exec(cmd)
               .waitFor();
        while (true) {
            String input = "";
            while (true) {
                char ch = (char) System.in.read();
                if (ch == 3) {
                    // CTRL-C
                    return;
                }
                else if (ch >= 32 && ch <= 126) {
                    // 普通字符
                    input += ch;
                }
                else if (ch == 10 || ch == 13) {
                    // 回车
                    System.out.println();
                    System.out.print("\u001b[1000D");
                    System.out.println("echo: " + input);
                    input = "";
                }

                System.out.print("\u001b[1000D"); // 首先将光标移动到最左侧
                System.out.print(input); // 重新输出input
                System.out.flush();
            }
        }
    }
}

好的,让我们来说明一下代码中的关键点:

  1. 首先最关键的是我们需要将我们的命令行设置为raw模式,这可以避免JVM帮我们解析方向键,回退键以及对用户输入进行缓冲。大家可以试一下不设置raw模式然后看一下效果,就可以理解我说的话了。

  2. 通过System.in.read()方法获取用户输入,然后对其ascii值进行分析。

  3. 如果发现用户输入的是回车的话,我们这时需要打印刚才用户输入的所有字符。但是我们需要注意,由于设置了raw模式,不移动光标直接打印的话,光标的位置不会移到行首,如下图:

    所以这里需要再次调用System.out.print("\u001b[1000D");将光标移到行首。

好了,让我们来看一下效果吧:

成功了!但是有个缺点,那就是命令行并没有解析方向键,反而以[D[A[C[B输出(见动图)。这样我们只能一直往后面写而无法做到将光标移动到前面实现插入的效果。所以接下来就让我们给命令行加上解析方向键的功能吧!

光标移动

简单起见,我们仅需实现按下方向键的左右两键时能控制光标左右移动。左右两键对应的ascii码分别为27 91 6827 91 67。所以我们只要在代码中加上对这两串ascii码的解析即可:

import java.io.IOException;

public class CommandLine {
    public static void main(String[] args) throws IOException, InterruptedException {
        // 设置命令行为raw模式,否则会自动解析方向键以及后退键,并且直到按下回车read方法才会返回
        String[] cmd = { "/bin/sh", "-c", "stty raw </dev/tty" };
        Runtime.getRuntime()
               .exec(cmd)
               .waitFor();
        while (true) {
            String input = "";
            int index = 0;
            while (true) {
                char ch = (char) System.in.read();
                if (ch == 3) {
                    // CTRL-C
                    return;
                }
                else if (ch >= 32 && ch <= 126) {
                    // 普通字符
                    input = input.substring(0, index) + ch + input.substring(index, input.length());
                    index++;
                }
                else if (ch == 10 || ch == 13) {
                    // 回车
                    System.out.println();
                    System.out.print("\u001b[1000D");
                    System.out.println("echo: " + input);
                    input = "";
                    index = 0;
                }
                else if (ch == 27) {
                    // 左右方向键
                    char next1 = (char) System.in.read();
                    char next2 = (char) System.in.read();
                    if (next1 == 91) {
                        if (next2 == 68) {
                            // 左方向键
                            index = Math.max(0, index - 1);
                        }
                        else if (next2 == 67) {
                            // 右方向键
                            index = Math.min(input.length(), index + 1);
                        }
                    }
                }

                System.out.print("\u001b[1000D"); // 将光标移动到最左侧
                System.out.print(input);
                System.out.print("\u001b[1000D"); // 再次将光标移动到最左侧
                if (index > 0) {
                    System.out.print("\u001b[" + index + "C"); // 将光标移动到index处
                }
                System.out.flush();
            }
        }
    }
}

效果如下:

It works!但是这个命令行还不支持删除,我们无法通过Backspace键删去敲错的字符。有了刚才的经验,实现删除功能也十分简单!

删除

照着刚才的思路,我们可能会在处理用户输入的地方,加上如下的代码:

else if (ch == 127) {
    // 删除
    if (index > 0) {
        input = input.substring(0, index - 1) + input.substring(index, input.length());
        index -= 1;
    }
}

但是这段代码存在点问题,让我们看一下效果图:

从图中我们可以看到:

  • 第一次,当我输入了11234566,然后不停地按下删除键,想要删掉34566,但是只有光标在后退,字符并没有被删掉。然后我再按下回车键,通过echo的字符串我们发现删除实际上已经成功,只是控制台在显示的时候出了点问题。
  • 第二次,我先输入123456,然后按下删除键,删掉456,光标退到3。然后我再继续不断地输入0,我们发现随着0覆盖了原来的456显示的位置。

所以删除的确产生了效果,但是我们要解决被删除的字符还在显示的这个bug。为了实现删除的效果,我们先来学习一下Ansi里的删除指令:

  • 清除屏幕:\u001b[{n}J为指令。
    • n=0:清除光标到屏幕末尾的所有字符。
    • n=1:清除屏幕开头到光标的所有字符。
    • n=2:清除整个屏幕的字符。
  • 清除行:\u001b[{n}K为指令。
    • n=0:清除光标到当前行末所有的字符。
    • n=1:清除当前行到光标的所有字符。
    • n=2:清除当前行。

所以我们的思路就是不管用户输入了什么,我们先利用System.out.print("\u001b[0K");清除当前行,此时光标回到了行首,这时再输出正确的字符。完整代码如下:

import java.io.IOException;

public class CommandLine {
    public static void main(String[] args) throws IOException, InterruptedException {
        // 设置命令行为raw模式,否则会自动解析方向键以及后退键,并且直到按下回车read方法才会返回
        String[] cmd = { "/bin/sh", "-c", "stty raw </dev/tty" };
        Runtime.getRuntime()
               .exec(cmd)
               .waitFor();
        while (true) {
            String input = "";
            int index = 0;
            while (true) {
                char ch = (char) System.in.read();
                if (ch == 3) {
                    // CTRL-C
                    return;
                }
                else if (ch >= 32 && ch <= 126) {
                    // 普通字符
                    input = input.substring(0, index) + ch + input.substring(index, input.length());
                    index++;
                }
                else if (ch == 10 || ch == 13) {
                    // 回车
                    System.out.println();
                    System.out.print("\u001b[1000D");
                    System.out.println("echo: " + input);
                    input = "";
                    index = 0;
                }
                else if (ch == 27) {
                    // 左右方向键
                    char next1 = (char) System.in.read();
                    char next2 = (char) System.in.read();
                    if (next1 == 91) {
                        if (next2 == 68) {
                            // 左方向键
                            index = Math.max(0, index - 1);
                        }
                        else if (next2 == 67) {
                            // 右方向键
                            index = Math.min(input.length(), index + 1);
                        }
                    }
                }
                else if (ch == 127) {
                    // 删除
                    if (index > 0) {
                        input = input.substring(0, index - 1) + input.substring(index, input.length());
                        index -= 1;
                    }
                }
                System.out.print("\u001b[1000D"); // 将光标移动到最左侧
                System.out.print("\u001b[0K"); // 清除光标所在行的全部内容
                System.out.print(input);
                System.out.print("\u001b[1000D"); // 再次将光标移动到最左侧
                if (index > 0) {
                    System.out.print("\u001b[" + index + "C"); // 将光标移动到index处
                }
                System.out.flush();
            }
        }
    }
}

让我们来看一下效果:

OK,成功了!那么至此为止,我们已经实现了一个最小化的命令行,它能够支持用户进行输入,并且能够左右移动光标以及删除他不想要的字符。但是它还缺失了很多命令行的特性,比如不支持解析像Alt-fCtrl-r等常见的快捷键,也不支持输入Unicode字符等等。但是,只要我们掌握了刚才的知识,这些特性都可以方便地实现。比如,我们可以给刚才的命令行加上简单的语法高亮——末尾如果有多余的空格则将这些空格标红,效果如下:

实现的代码也很简单,可以参考Github项目里的CustomisedCommandLine类。

最后,再介绍一下其他一些有用的Ansi Escape Codes:

  • 光标向上移动:\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将光标移至第nm列,坐标原点从屏幕左上角开始。
  • 保存光标当前所在位置:\u001b[{s}
  • 读取光标上一次保存的位置:\u001b[{u}

光标按行移动的测试代码参考Github项目里的LineMovementTest类,设置光标位置的测试代码参考Github项目里的PositionTest类。如果想了解更多的Ansi Escape Codes请参考维基百科

总结

通过本文的学习,我相信大家已经掌握了如何通过Ansi Escape Codes实现控制台的富文本输出以及控制台光标的自定义移动。那么文章一开始的那4个好奇,大家心中是否已经有了答案了呢?最后,还是强烈建议英文好的同学去阅读一下原文:www.lihaoyi.com/post/Buildy…。祝大家周末愉快!

本文首发于kissyu.org/2017/11/25/…
欢迎评论和转载!
订阅下方微信公众号,获取第一手资讯!

关注下面的标签,发现更多相似文章
评论
说说你的看法