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

引言

你是否:html

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

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

背景

经过本文你能够学到:git

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

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

本文全部的代码已经推到Github中,地址为:github.com/Lovelcp/blo…。强烈建议你们将代码clone下来跑一下看看效果,加深本身的印象。shell

环境

  • Mac或Linux或者WIn10操做系统。除了Win10以外的Windows系统暂时不支持Ansi Escape Codes。
  • 由于本文采用Jshell做为演示工具,因此你们须要安装最近刚正式发布的JDK9。

OK!一切准备就绪,让咱们开始吧!后端

富文本

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

字体颜色

经过Ansi指令(即Ansi Escape Codes)给控制台的文字上色是最为常见的操做。好比:dom

  • 红色:\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/…
欢迎评论和转载!
订阅下方微信公众号,获取第一手资讯!

相关文章
相关标签/搜索