【译】Python Curses 编程

本文介绍如何使用 curses 扩展去控制命令行模式的显示。

原文链接:https://docs.python.org/3.6/howto/curses.html
原文作者: A.M. Kuchling, Eric S. Raymond
版本:Release-2.04

什么是 curses?

curses 是为文本终端提供一个界面绘图和键盘输入响应的库。这些终端包括 VT100s、Linux终端,和不同程序提供的模拟终端。终端支持使用不同的控制代码来实现相同的操作,类似移动光标、滚动屏幕、擦出屏幕区域。不同的终端的控制代码大部分都不相同,并且有着自己的特殊操作习惯和技巧。

在这个图形化显示的时代,有人也许会问,为什么还需要这种终端操作的库,基于字符的终端显示确实是一个过时的技术,但是还是有很多有价值的场景能够使用终端显示做出一些十分迷人的产物。其中一个场景就是在并不具备图形现实的便携式和嵌入式 Linux 中,另外还有就是在安装系统或者内核配置的时候,这些操作都不得不在图像界面启动之前操作。

Curses 提供基本的功能,为程序员提供一个不重复的文本窗口。窗口的内容可以通过不同的方式改变--添加文本,删除文本、修改外观等等。curses 会屏蔽掉底层终端命令的不同,计算出你需要执行的命令。curses 并不提供类似按钮、复选框、或者对话这种用户界面。如果你需要这些元素可以使用一些类似 Urwid 的用户界面库。

Curses 一开始是为了 BSD Unix写的;后来的 AT&T 的 System V 版本的 Unix 对原有功能做了增强,同时添加了许多新的功能。 BSD curses 就不在维护了,而是被 ncurses 替代了。ncurses 是一个 AT&T 接口的一个开源实现。如果你正在使用一个开源的 Unix ,类似Linux或者FreeBSD,你的系统应该已经包含了ncurses。因为现在大多数的商业 Unix 版本都是基于 System V 的代码,这里描述的功能理论上也会存在。尽管如此,一些老版本 unix 的 curses 可能并不会有很好的支持。

Windows 版本的 Python 并不包含 curses 模块。一个类似的替代版本 UniCurses。你也可以尝试使用Fredrik Lundh 写的the Console module,虽然和curses使用的API不一样,但是也可以提供基于光标的输出,并且为鼠标和键盘提供全方位支持。

Python curses 模块

这个 python 模块是针对 curses 对C语言支持的简单封装,如果你已经熟悉了C语言的 curses 编程,在 Python 中应用这些知识也会变得非常简单。最大的不同就是 Python 接口会比 C语言函数更加简单,由于合并了一些C语言中的不同函数。比如 addstr(),mvaddstr(), 和mvwaddstr()被合并成了一个函数addstr()。后面你会看到更多这样的例子。

这篇教程是使用 curses 和 Python 编写文本程序的介绍,并不尝试成为一个 curses API 的复杂手册。因此,查看 Python curses 手册和 C 语言的 ncurses 手册会带给你更详细的 API 介绍。

一个 curses 程序的开始和结束

在开始之前,curses必须先经过初始化。通过调用initscr()来实现。这个函数会判断终端类型,并发送一些启动需要的指令给终端、创建内部的数据结构。如果执行成功,initscr() 返回一个代表整个屏幕的窗口类;这根据C语言的相关变量名通常命名为stdscr.

import Curses
stdscr = curses.initscr()

通常 curses 会挂壁屏幕回显,为了只在特定条件下才会读取键盘输入并显示。这需要调用 noecho()方法。

curses.noecho()

应用程序通常需要对键盘输入立即做出响应,而不需要特意的按下回车键;这叫做 cbreak 模式,与之对应的是常用的缓冲输入模式。

curses.cbreak()

终端通常返回特殊键作为多字节转义序列,比如光标键、Home键、Page Up等, curses 可以让你的程序根据转义序列执行相应的代码。让 curses 可以响应这些特殊值,需要开启 keypad 模式。

stdscr.keypad(True)

结束一个 curses 应用比启动简单多了。只需要执行下面的方法:

curses.nocbreak()
stdscr.keypad(False)
curses.echo()

为了恢复 curses 的终端设置。调用 endwin() 方法重置为原来的操作模式

curses.endwin()

在你调试你的程序的时候,一个经常出现的问题就是你会把你的终端搞得一团糟,通常是因为你的代码产生了 bug 并且引发了一个没有捕获的异常。例如:键盘输入不会在回显在屏幕上,这回让终端使用起来很困难。

在 Python 中你可以使用 curses.wrapper()来避免这种问题,让调试变得简单。

from curses import wrapper

def main(stdscr):
  # Clear screen
  stdscr.clear()
  # This raises ZeroDivisionError when i == 10.
  for i in range(0, 11):
      v = i-10
      stdscr.addstr(i, 0, '10 divided by {} is {}'.format(v, 10/v))

  stdscr.refresh()
  stdscr.getkey()

wrapper(main)

wrapper()函数接收一个可调用对象并且执行上文描述的初始化过程。如果支持颜色配置,同时会初始化颜色配置。然后会运行你的代码。一旦代码返回,wrapper() 会重置终端一开始的状态,并且代码会放在try
...except中执行,如果获取异常会将终端重置为原始状态然后将异常抛出。因此,在有异常抛出的时候,你的终端不会处在一个可笑的状态,并且能够根据异常信息定位问题。

窗口和 Pad

窗口是 curses 中最基本的元素。一个窗口代表着屏幕中的一块矩形区域,支持展示文本,删除文本,用户输入等等。

initscr()函数返回的stdscr对象就是一个覆盖了整个屏幕的一个窗口对象。对于许多程序来说一个窗口就足够了,但是有时候也需要将屏幕分割为不同的窗口,以便于分别重绘和清除这些窗口。newwin()函数创建一个给定大小的新窗口,并返回这个窗口类。

begin_x = 20; begin_y = 7
height = 5; width = 40
win = curses.newwin(height, width, begin_y, begin_x)

注意到 curses 的坐标系统是不同寻常的。坐标通常是以 y,x 的格式,坐标的原点在窗口的左上角。这和通常程序中处理坐标时以 x 开头的方式是不同的。虽然这样令人有些不舒服,但是这是 curses 诞生的时候就是这样设置的,现在修改为时已晚。

你可以通过curses.LINEScurses.COLS来配置屏幕的大小。从(0,0)(curses.LINES - 1, curses.COLS - 1)就是都是可以使用的坐标。

当你调用函数去展示或者擦除文本,效果并不会立即展现在屏幕上。你必须调用窗口实例的refresh()方法才能更新屏幕显示。

这是因为 curses 终端连接的速度比较慢,减少屏幕重绘时间变得十分有必要。因此,Curses 会积累修改,当你调用refresh()方法的时候,以最有效率的方式来重绘窗口。举例说明:如果你在一个窗口添加了一些文本,然后又清除了这个窗口的内容,这样添加文本的操作就变得没有必要了,因为你不会看到被添加的文本。

实际上,显式的通知 curses 来刷新窗口并不会对编程增加很多的复杂性。大部分程序都是在经历一系列的活动之后等待用户的操作,只需需要在等待用户输入之前调用stdscr.refresh()或者refresh()方法。

Pad 是窗口的特殊形式,它可以比屏幕面积更大,并且可以每次只展示 pad 的一部分。创建pad 需要制定pad的高和宽,刷新需要给定pad的在屏幕上显示部分的坐标。

pad = curses.newpad(100, 100)
# These loops fill the pad with letters; addch() is
# explained in the next section
for y in range(0, 99):
    for x in range(0, 99):
        pad.addch(y,x, ord('a') + (x*x+y*y) % 26)

# Displays a section of the pad in the middle of the screen.
# (0,0) : coordinate of upper-left corner of pad area to display.
# (5,5) : coordinate of upper-left corner of window area to be filled
#         with pad content.
# (20, 75) : coordinate of lower-right corner of window area to be
#          : filled with pad content.
pad.refresh( 0,0, 5,5, 20,75)

这个refresh()方法会在屏幕的(5,5)(10.75)的部分显示pad的一部分;显示部分左上角的pad坐标是(0,0)。除此之外,pad和窗口的使用都是相同的,并且又相同的方法。

如果你需要多个窗口和pad协作的话,又一个更加高效的方式来刷新屏幕并避免每个部分更新时候烦人的闪烁。refresh()实际上做了两件事:

  • 调用每个窗口的noutrefresh()方法更新屏幕显示的底层数据结构,并不会刷新屏幕。
  • 调用 doupdate()方法来将上面的数据结构物理的刷新到屏幕上。

所以,你可以在一些窗口调用noutrefresh()方法更新数据结构,然后调用doupdate()方法来刷新屏幕。

展示文本

C 语言程序员也许会觉得 cursers 的方法像迷宫一样错综复杂,方法之间有着细微的区别。例如:addstr() 会在当前 stdscr 窗口的光标处展示文字,而mvaddstr()则是在指定的坐标展示文字。waddstr()功能和addstr()类似,但是允许指定窗口而不是默认的stdscrmvwaddstr允许同时指定窗口和坐标。

幸运的是,Python 的接口隐藏了这些细节。stdscr是一个和其他相同的窗口类,类似addstr()的方法可以接收不同的参数类型。
通常包含一下四种参数类型。

参数类型 描述
str 或者 ch 在当前位置展示 str 或者 ch
str 或者 ch,attr 在当前位置使用属性 attr 展示str或者ch
y,x str 或者 ch 在坐标 y,x 展示 str 或者 ch
y,x str 或者 ch, attr 在坐标 y,x 使用attr 属性显示 str 或者 ch

属性(attributes) 可以突出显示文本,类似粗体、下划线、反向码,或者为文本着色。这会在下面的部分详细解释。

addstr()函数在终端显示一个字符串或者字节串。字节串直接鸳鸯发送给终端,字符串通过窗口属性编码为字节发送给终端,默认的系统编码可以通过locale.getpreferredcoding()获得。

addch()函数可以接收一个字符,可以是一个长度为1的字符串,或者长度为1的字节串,或者一个整型数。

针对扩展的字符提供了一些常量,这些常量都大于255。例如:ACS_PLMINUS 代表 +/- 符号,ACS_ULCORNER 代表一个 box(处理绘制边框) 的左上角。你还可以使用其他合适 Unicode 字符。

窗口会自动记住上次操作之后光标的位置,如果你不使用坐标,所有的的操作都会开始于上次结束的地方。你也可以通过 move(y,x) 移动光标。因为有些终端光标是默认闪烁的,将光标移动到一些不是那么烦人的位置是很有必要的。

如果你的应用不需要闪烁的光标,你可以是调用curs_set(False)将光标设置为不可见。为了和旧版本的 curses 版本兼容,leaveok(bool)curs_set有着相同的功能。当bool是true的时候,光标就会变得不可见,你也不必担心光标会在一些奇怪的地方闪烁。

属性和颜色

字符可以用不同的方式展示。基于文本的应用程序通常使用负片显示状态。文本编辑器往往需要高亮一些特定的单词。 curses 支持通过属性来为每个字符进行配置以支持上面的描述。

一个属性是一个整数,每一个 bit 都代表着不同的属性。你可以尝试设置不同的bit位来达到不同的效果,不过 curses 不保证所有的组合都是可用的,也不保证不同的组合就一定是不同的显示。这取决于被使用的终端的能力,所以使用大部分终端都会支持的属性是最明智的。列表如下

属性 描述
A_BLINK 字符闪烁
A_BOLD 高亮或者加粗字符
A_DIM 半高亮字符
A_REVERSE 负片显示字符
A_UNDERLINE 为字符添加下划线

所以,想要在屏幕顶端显示负片字符状态,可以使用下面的代码:

stdscr.addstr(0, 0, "Current mode: Typing mode", curses.A_REVERSE)
stdscr.refresh()

curses 同样支持为支持颜色显示的终端文字添加颜色。

如果想使用颜色功能,必须要在执行玩initscr()之后执行start_color()函数(curses.wrapper()会自动执行)来初始化颜色管理,如果终端支持颜色掩饰,那么调用 has_colors()函数会返回 TRUE。

curses 维护了有限的颜色搭配,包括前景色(文字颜色)和背景色,可以通过使用color_pair()函数来设置字体颜色,类似 A_REVERSE 也是按照bit位的属性设置的,同样的,不保证所有的组合都能够在所有的终端上正确显示。

使用颜色组合1来显示文字的例子:

stdscr.addstr("Pretty text", curses.color_pair(1))
stdscr.refresh()

像上文说的那样,每个颜色模式分为前景色和背景色。init_pair(n, f, b) 方法会修改颜色模式n的前景色为f,背景色为b。颜色模式0表示黑底白字,不可以修改。

颜色是被编号的,start_color()方法会初始化八种基本颜色,分别是:0:黑色,1:红色,2:绿色,3:黄色,4:蓝色,5:洋红色,6:青色,7:白色。curses 同样也为这些颜色设置了常量值,curses.COLOR_BLACK, curses.COLOR_RED, curses.COLOR_GREEN, 等等。

让我来实际应用一下,将1号颜色模式修改为白底红字:

curses.init_pair(1, curses.COLOR_RED, curses.COLOR_WHITE)

当你修改一个颜色模式的时候,所有使用了这个颜色模式的文字都会刷新为新的颜色。你也可以通过下面的方式来添加文字

stdscr.addstr(0, 0, "RED ALERT!", curs_set.color_pair(1))

很多终端支持通过给定的 RGB 值来修改颜色。这可以将颜色模式修改为任何你想要的颜色。不幸的是,Linux 标准终端并不支持,所以我无法演示,或给出例子。你可以通过调用can_change_color()函数来确定你的终端是否支持这个功能。如果你的终端恰好返回的是 True,表示支持,可以查阅 man 手册来获取更多的信息。

用户输入

C 语言的 curses提供一个很简单的输入方式。python 的 curses 增加了一些基本的输入技巧(其他类似 Urwid 的库提供了更多种类的输入技巧)。

在一个窗口上获取输入有两种方式:

  • getch()刷新屏幕并等待用户输入一个按键,如果echo()在这之前被调用,输入的按键会同步显示在屏幕上。你一可以指定坐标控制显示的位置。
  • getkey()和上面的函数做了同样的事情,不同的是将整数转换成了字符串。单字符返回一个单字符串,特殊按键会返回一个长串,类似KEY_UP或者^G

通过调用nodelay()函数可以实现不等待用户的输入,如果设置nodelay(True)getch()getkey()函数不会再等待输入。如果没有输入,getch()会返回 curses.ERR(值为-1),getkey()函数则是会抛出一个异常。getch()还有一个halfdelay()函数可以设定在指定的时间(单位为十分之一秒)内如果没有得到用户输入才会抛出异常。

getch()函数返回一个整数。如果在0~255范围内代表的是 ASCII 码,如果大于255,则可能是一些特殊按键类似: Page Up,Home,或者光标按键。你可以通过输入值和一些常量的比较确定输入。常量类似:curses.KEY_PPAGE, curses.KEY_HOME, curses.KEY_LEFT。所以你的代码主循环有可能是这样的:

while True:
  c = stdscr.getch()
  if c == ord('p'):
    PrintDocunment()
  elif c == ord('q'):
    break
  elif c == curses.KEY_HOME:
    x = y = 0

curses.ascii模块提供了ASCII处理函数,参数为一个整数或者单字符。对于书写更加可读的代码很有用。同样提供接收一个整数或者字符的对话函数。例如:curses.ascii.ctrl()根据参数返回控制字符。

还有一个可以获得整个字符串的函数,就是getstr(),由于功能限制很少使用。仅有的编辑按键就是 backspace 和 Enter 按键。 getstr()可以用于获取指定长度的字符串。

curses.echo()            # Enable echoing of characters

# Get a 15-character string, with the cursor on the top line
s = stdscr.getstr(0,0, 15)

curses.textpad模块提供一个支持类似 Emacs 键盘快捷键的文本框。Textbox的不同方法支持输入编辑和聚合编辑结果,无论是不是有多余的空格。
下面是例子

import curses
from curses.textpad import Textbox, rectangle

def main(stdscr):
    stdscr.addstr(0, 0, "Enter IM message: (hit Ctrl-G to send)")

    editwin = curses.newwin(5,30, 2,1)
    rectangle(stdscr, 1,0, 1+5+1, 1+30+1)
    stdscr.refresh()

    box = Textbox(editwin)

    # Let the user edit until Ctrl-G is struck.
    box.edit()

    # Get resulting contents
    message = box.gather()

更多内容参考curses.textpad的文档。

更多信息

这篇教程并没有包含一些高级主题,例如读取屏幕的信息,捕获鼠标的动作。但是 Python 的 curses 模块的文档现在已经完成了,下一步你应该阅读他们。

如果你还对 curses 函数的一些行为细节只有怀疑,查询你的 curses 实现的文档吧,无论是 ncurses 或者其他 Unix 实现。手册中会记录各种小技巧,并提供完整的函数列表、属性,还有那些 ACS_* 字符可用。

因为 curses 的 API 非常繁杂,因此有一些函数并没有得到 Python 的支持。这通常不是因为这些函数很难实现,而是因为这些函数已经没人需要了。同样,Python 也不支持 ncurses 的菜单库。欢迎大家是实现这些没有实现的功能,阅读Python Developer’s Guide学习如何为 Python 提交代码。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容