使用特殊字符实现console UI

我们经常见到一些开源软件在安装或运行时,实现了某种程度上的基于Linux命令行终端的界面,使我们可以更直观的感受软件运行情况或进度, 例如下面的测试进度变化:

使用Python时,我们可以通过print()函数和某些特殊字符达到上面的效果。

先看一下Python的print()函数定义,从下面的帮助文档,我们可以看到print()有4个关键字参数: sep, end, file, flush, 其中sep表示当打印多个参数时,各参数间的分隔符,默认为一个空格;end表示一次打印结束时添加的结尾标识符,默认为换行符;file为类似文件的字节流对象,默认为sys.stdout; flush 指示是否强制将缓冲区数据写入输出设备,对于sys.stdout来讲,就是显示器。

print(...)
    print(value, ..., sep=' ', end='\n', file=sys.stdout, flush=False)
    
    Prints the values to a stream, or to sys.stdout by default.
    Optional keyword arguments:
    file:  a file-like object (stream); defaults to the current sys.stdout.
    sep:   string inserted between values, default a space.
    end:   string appended after the last value, default a newline.
    flush: whether to forcibly flush the stream.

再看一下转义字符'\r', 该字符使的输入位置重新回到本行开头。利用print()的end参数和'\r', 我们可以实现一个的进度条:

代码如下:

import time

for i in range(1, 11):
    percent = i * 10
    if i < 10:
        bar = '[' + '=' * (i * 5) + '>]'
        print('{}% {}\r'.format(percent, bar), end='')
    else:
        bar = '[' + '=' * (i * 5) + ']'
        print('{}% {}\r'.format(percent, bar))
    time.sleep(1)

 

我们也可以使用特定的字符组合控制输出的颜色, 例如

代码如下:

#utils/textcolor.py

class TextColor:
    NONE = "\033[0m"
    RED = "\033[0;31m"
    BLACK = "\033[0;30m"
    WHITE = "\033[1;37m"
    GREEN = "\033[0;32m"
    BLUE = "\033[0;34m"
    YELLOW = "\033[1;33m"
    BROWN = "\033[0;33m"
    CYAN = "\033[0;36m"
    PURPLE = "\033[0;35m"
    DARK_GRAY = "\033[1;30m"
    LIGHT_BLUE = "\033[1;34m"
    LIGHT_GREEN = "\033[1;32m"
    LIGHT_CYAN = "\033[1;36m"
    LIGHT_RED = "\033[1;31m"
    LIGHT_PURPLE = "\033[1;35m"
    LIGHT_GRAY = "\033[0;37m"

# colors.py
from utils.textcolor import TextColor

print(TextColor.RED + '红色' + TextColor.NONE, end=' ')
print(TextColor.GREEN + '绿色' + TextColor.NONE, end=' ')
print(TextColor.BLUE + '蓝色' + TextColor.NONE, end=' ')
print(TextColor.YELLOW + '黄色' + TextColor.NONE, end=' ')
print(TextColor.WHITE + '白色' + TextColor.NONE, end=' ')
print(TextColor.LIGHT_GRAY + '浅灰色')

 其实,添加颜色只是ANSI控制码中的一种, ANSI控制码可以帮我们实现很多功能,一个完整的列表如下:

\033[0m 关闭所有属性

\033[1m 设置高亮度

\033[4m 下划线

\033[5m 闪烁

\033[7m 反显

\033[8m 消隐

\033[30m -- \033[37m 设置前景色

\033[40m -- \033[47m 设置背景色

\033[nA 光标上移n行

\033[nB 光标下移n行

\033[nC 光标右移n行

\033[nD 光标左移n行

\033[y;xH设置光标位置

\033[2J 清屏

\033[K 清除从光标到行尾的内容

\033[s 保存光标位置

\033[u 恢复光标位置

\033[?25l 隐藏光标

\033[?25h 显示光标

 现在回到本文的开始:如何实现类似test.py的效果?这其中最主要的难点是我们不仅要修改/覆盖当前行的输出内容,还要修改/覆盖非当前行的内容。一个最直接简单的思路是我们用python dict变量记录所有输出内容和它相对应的打印输出时的行号(需注意的是,如果运行过程中涉及到滚屏,所记录的的行号也应该相应递减: 每滚动一行,所记录的行号减1),在我们准备更新某条信息时,先查找它被输出到了哪一行,然后把光标定位到相应行,打印输出更新后的信息,最后把光标恢复到当前行。除此之外,我们可能还希望在整个过程中隐藏光标,屏蔽键盘输入回显。基于以上所述,下列特殊字符将会被用到:

\033[y;xH设置光标位置

\033[K 清除从光标到行尾的内容

\033[s 保存光标位置

\033[u 恢复光标位置

\033[?25l 隐藏光标

\033[?25h 显示光标

除此外,我们用打印”\x1b[6n“时的输出来得到当前光标的位置,源代码如下:

test.py:

 1 import time
 2 import os
 3 from os import path
 4 import sys
 5 
 6 sys.path.append(path.dirname(__file__))
 7 import utils.termops_new as termops
 8 from utils.termops_new import TextColor
 9 
10 script_status_list = [ 
11         ('tests/test1.py', 'Not Started'),
12         ('tests/test1.py', 'Running'),
13         ('tests/test2.py', 'Not Started'),
14         ('tests/test1.py', 'Finished'),
15         ('tests/test2.py', 'Running'),
16         ('tests/test3.py', 'Not Started'),
17         ('tests/test2.py', 'Failed'),
18         ('tests/test3.py', 'Running'),
19         ('tests/test4.py', 'Not Started'),
20         ('tests/test4.py', 'Finished'),
21         ('tests/test3.py', 'Finished'),
22 ]
23 
24 
25 def colored_status(status):
26     if status == 'Not Started':
27         return TextColor.LIGHT_GRAY + status + TextColor.NONE
28     elif status == 'Running':
29         return TextColor.WHITE + status + TextColor.NONE
30     elif status == 'Finished':
31         return TextColor.GREEN + status + TextColor.NONE
32     elif status == 'Failed':
33         return TextColor.RED+ status + TextColor.NONE
34  
35 
36 def formatted_msg(test, status):
37     return '\t' + test + '-'*20 + '[' + colored_status(status) + ']'
38 
39 
40 if __name__ == '__main__':
41     termsize = os.get_terminal_size()
42     oldattr = termops.disable_term_echo()
43     termops.hide_cursor()
44     pos_map = dict() 
45 
46     try:
47         # firstly, output all inital status
48         for test, status in script_status_list:
49             time.sleep(1)
50 
51             if test in pos_map.keys():
52                 # Update printed information
53                 termops.save_cursor_pos()
54                 x, y = pos_map[test]
55                 termops.print_at(x, y, formatted_msg(test, status))
56                 time.sleep(1)
57                 termops.restore_cursor_pos()
58             else:
59                 # First print
60                 x, y = termops.get_cursor_pos()
61 
62                 # save output position for later update
63                 pos_map[test] = (x, y)
64                 termops.print_at(x, y, formatted_msg(test, status), end='\n') 
65                 time.sleep(1)
66 
67                 # if scroll up, all printed lines should be moved 1 line up
68                 if x == termsize.lines:
69                     for k in pos_map.keys():
70                         oldx, oldy = pos_map[k]
71                         pos_map[k] = (oldx-1, oldy)
72     finally:
73         termops.enable_term_echo(oldattr)
74         termops.show_cursor()
75 
76     print('')
View Code

utils/termops.py

 1 import re
 2 import sys
 3 import termios
 4 import tty
 5 
 6 
 7 class TextColor:
 8     NONE = "\033[0m"
 9     RED = "\033[0;31m"
10     BLACK = "\033[0;30m"
11     WHITE = "\033[1;37m"
12     GREEN = "\033[0;32m"
13     BLUE = "\033[0;34m"
14     YELLOW = "\033[1;33m"
15     BROWN = "\033[0;33m"
16     CYAN = "\033[0;36m"
17     PURPLE = "\033[0;35m"
18     DARK_GRAY = "\033[1;30m"
19     LIGHT_BLUE = "\033[1;34m"
20     LIGHT_GREEN = "\033[1;32m"
21     LIGHT_CYAN = "\033[1;36m"
22     LIGHT_RED = "\033[1;31m"
23     LIGHT_PURPLE = "\033[1;35m"
24     LIGHT_GRAY = "\033[0;37m"
25 
26 
27 def get_cursor_pos():
28 
29     buf = ""
30     stdin = sys.stdin.fileno()
31     tattr = termios.tcgetattr(stdin)
32 
33     try:
34         tty.setcbreak(stdin, termios.TCSANOW)
35         sys.stdout.write("\x1b[6n")
36         sys.stdout.flush()
37 
38         while True:
39             buf += sys.stdin.read(1)
40             if buf[-1] == "R":
41                 break
42     finally:
43         termios.tcsetattr(stdin, termios.TCSANOW, tattr) 
44 
45     try:
46         matches = re.match(r"^\x1b\[(\d*);(\d*)R", buf)
47         groups = matches.groups()
48     except AttributeError:
49         return None, None
50 
51     return (int(groups[0]), int(groups[1]))
52 
53 
54 def disable_term_echo():
55     fd = sys.stdin.fileno()
56     oldattr = termios.tcgetattr(fd)
57     newattr = termios.tcgetattr(fd)
58     newattr[3] = newattr[3] & ~termios.ECHO
59     termios.tcsetattr(fd, termios.TCSADRAIN, newattr)
60     return oldattr
61 
62 def enable_term_echo(attr):
63     fd = sys.stdin.fileno()
64     termios.tcsetattr(fd, termios.TCSADRAIN, attr)
65 
66 def save_cursor_pos():
67     sys.stdout.write('\033[s')
68     sys.stdout.flush()
69 
70 def restore_cursor_pos():
71     sys.stdout.write('\033[u')
72     sys.stdout.flush()
73 
74 def print_at(x, y, msg, end=''):
75     pos_str = '\033[{};{}H'.format(x, y)
76     clean_line_str = '\033[K'
77     print(pos_str + clean_line_str + msg, end=end)
78     sys.stdout.flush()
79 
80 def hide_cursor():
81     sys.stdout.write("\033[?25l")
82     sys.stdout.flush()
83 
84 def show_cursor():
85     sys.stdout.write("\033[?25h")
86     sys.stdout.flush()
View Code

 

posted on 2019-04-20 20:45  wangwenzhi2019  阅读(594)  评论(0编辑  收藏  举报

导航