通過Ansi Escape Codes酷炫玩轉命令列!

wooyoo發表於2017-11-25

引言

你是否:

  • 好奇過命令列裡那些花裡胡哨的進度條是如何實現的?
  • 好奇過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,但是隻有游標在後退,字元並沒有被刪掉。然後我再按下Enter鍵,通過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/…
歡迎評論和轉載!
訂閱下方微信公眾號,獲取第一手資訊!

相關文章