用 Python 進行 Curses 編程?

作者

A.M. Kuchling, Eric S. Raymond

發布版本

2.04

摘要

本文檔介紹了如何使用 curses 擴展模塊控制文本模式的顯示。

curses 是什么??

curses 庫為基于文本的終端提供了獨立于終端的屏幕繪制和鍵盤處理功能;這些終端包括 VT100,Linux 控制臺以及各種程序提供的模擬終端。顯示終端支持各種控制代碼以執行常見的操作,例如移動光標,滾動屏幕和擦除區域。不同的終端使用相差很大的代碼,并且往往有自己的小怪癖。

在普遍使用圖形顯示的世界中,人們可能會問“為什么自找要麻煩”?畢竟字符單元顯示終端確實是一種過時的技術,但是在某些領域中,能夠用它們做花哨的事情仍然很有價值。一個小眾市場是在不運行 X server 的小型或嵌入式 Unix 上。另一個是需要在提供圖形支持之前運行的工具,例如操作系統安裝程序和內核配置程序。

curses 庫提供了相當基礎的功能,為程序員提供了包含多個非重疊文本窗口的顯示的抽象。窗口的內容可以通過多種方式更改---添加文本,擦除文本,更改其外觀---以及curses庫將確定需要向終端發送哪些控制代碼以產生正確的輸出。 curses并沒有提供諸多用戶界面概念,例如按鈕,復選框或對話框。如果需要這些功能,請考慮用戶界面庫,例如 Urwid

curses 庫最初是為BSD Unix 編寫的。 AT&T 的Unix 的后來的System V 版本增加了許多增強功能和新功能。如今BSD curses不再維護,被ncurses取代,ncurses是AT&T接口的開源實現。如果您使用的是Linux 或FreeBSD 等開源Unix,則您的系統幾乎肯定會使用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 函數合并來讓事情變得更簡單,比如 addstr()mvaddstr()mvwaddstr() 三個 C 函數被并入 addstr() 這一個方法。下文中會描述更多的細節。

本 HOWTO 是關于使用 curses 和 Python 編寫文本模式程序的概述。它并不被設計為一個 curses API 的完整指南;如需完整指南,請參見 ncurses 的 Python 庫指南章節和 ncurses 的 C 手冊頁。相對地,本 HOWTO 將會給你一些基本思路。

開始和結束curses應用程序?

在做任何事情之前,curses 必須先被初始化。可以通過調用函數 initscr() 來實現,它將查明終端的類型,向終端發送任何必須的設置代碼,并創建多種內部數據結構。如果此操作成功,initscr() 將會返回一個代表整個屏幕的窗口對象;它通常會遵循對應的 C 變量名被稱作 stdscr

import curses
stdscr = curses.initscr()

使用 curses 的應用程序通常會關閉按鍵自動上屏,目的是讀取按鍵并只在特定情況下展示它們。這需要調用函數 noecho()

curses.noecho()

應用程序也會廣泛地需要立即響應按鍵,而不需要按下回車鍵;這被稱為 “cbreak” 模式,與通常的緩沖輸入模式相對:

curses.cbreak()

終端通常會以多字節轉義序列的形式返回特殊按鍵,比如光標鍵和導航鍵比如 Page Up 鍵和 Home 鍵。盡管你可以編寫你的程序來應對這些序列,curses 能夠代替你做到這件事,返回一個特殊值比如 curses.KEY_LEFT。為了讓 curses 做這項工作,你需要啟用 keypad 模式:

stdscr.keypad(True)

終止一個 curses 應用程序比建立一個容易得多,你只需要調用:

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

來還原對終端作出的 curses 友好設置。然后,調用函數 endwin() 來將終端還原到它的原始操作模式:

curses.endwin()

調試一個 curses 應用程序時常會出現,一個應用程序還未能還原終端到原本的狀態就意外退出了,這會攪亂你的終端。在 Python 中這常常會發生在你的代碼中有 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() 運行你提供的可調用對象。當該可調用對象返回時,wrapper() 會還原終端到初始狀態。該可調用對象會在 try...except 這樣的結構內被調用,當它捕獲到異常時,會先還原終端再重新拋出這個異常。所以你的終端不會因為異常而被留在一個搞笑的狀態,你也可以正常閱讀異常消息和回溯信息。

Windows 和 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 的順序傳遞,并且左上角是坐標 (0,0)。這打破了正常的坐標處理約定,即 x 坐標在前。這是一個與其他計算機應用程序糟糕的差異,但這從 curses 最初被編寫出來就已是它的一部分,現在想要修改它已為時已晚。

你的應用程序能夠查明屏幕的尺寸,curses.LINEScurses.COLS 分別代表了 yx 方向上的尺寸。合理的坐標應位于 (0,0)(curses.LINES - 1, curses.COLS - 1) 范圍內。

當你調用一個方法來顯示或擦除文本時,效果并不會立即顯示。相反,你必須調用窗口對象的 refresh() 方法來更新屏幕。

這是因為 curses 最初是為 300 波特的龜速終端連接編寫的;在這些終端上,壓制重繪屏幕的時間就非常重要。相對地,當你調用 refresh() 時,curses 會累積屏幕的修改并以效率最高的方式顯示它們。打個比方,如果你的程序在一個窗口內顯示一些文本然后清楚了這個窗口,那么這些原始文本不需要被發送,因為它們甚至不曾能被看見。

在實踐中,顯式地告訴 curses 來重繪一個窗口并不會太復雜化 curses 編程。大部分程序會顯示一堆內容然后等待按鍵或者其他某些用戶側動作。你要做的事情就是,保證屏幕在暫停并等待用戶輸入前被重繪,只需要先調用 stdscr.refresh() 或者其他相關窗口的 refresh() 方法。

一個面板是一種特殊的窗口,它可以比實際的顯示屏幕更大,并且能只顯示它的一部分。創建面板需要指定面板的高度和寬度,但刷新一個面板需要給出屏幕坐標和面板的需要顯示的局部。

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) 到坐標 (20,75) 的矩形范圍內顯示面板的一個部分,被顯示的部分在面板上的坐標是 (0,0)。除了上述差異,面板與常規的窗口相同,也支持相同的方法。

如果你在屏幕上有多個窗口和面板,有一個更有效率的方法來更新窗口,避免每個部分單獨更新時煩人的屏幕閃爍。refresh() 實際上做了兩件事:

  1. 調用每個窗口的 noutrefresh() 方法來更新一個表達屏幕期望狀態的底層的數據結構。

  2. 調用函數 doupdate() 來改變物理屏幕來符合這個數據結構中記錄的期望狀態。

你可以改為調用在多個窗口上 noutrefresh() 方法來更新該數據結構,然后調用函數 doupdate() 來更新屏幕。

顯示文字?

從一名 C 語言程序員的視角來看,curses 有時看起來就像是一堆略有差異的函數組成的扭曲迷宮。舉個例子,addstr()stdscr 窗口的當前光標位置顯示一個字符串,而 mvaddstr() 則是先移動到一個給定的 y,x 坐標再顯示字符串。waddstr()addstr() 類似,但允許指定一個窗口而非默認的 stdscrmvwaddstr() 允許同時指定一個窗口和一個坐標。

幸運的是,Python 接口隱藏了所有這些細節。stdscr 和其他任何窗口一樣是一個窗口對象,并且諸如 addstr() 之類的方法接受多種參數形式。通常有四種形式。

形式

描述

strch

在當前位置顯示字符串 str 或字符 ch

strch, attr

在當前位置使用 attr 屬性顯示字符串 str 或字符 ch

y, x, strch

移動到窗口內的 y,x 位置,并顯示 strch

y, x, strch, attr

移至窗口內的 y,x 位置,并使用 attr 屬性顯示 strch

屬性允許以突出顯示形態顯示文本,比如加粗、下劃線、反相或添加顏色。這些屬性將來下一小節細說。

方法 addstr() 接受一個 Python 字符串或字節串作為用于顯示的值。字節串的內容會被原樣發送到終端。字符串會使用窗口的 encoding 屬性值編碼為字節,它默認為 locale.getpreferredencoding() 返回的系統默認編碼。

方法 addch() 接受一個字符,可以是長度為 1 的字符串,長度為 1 的字節串或者一個整數。

對于特殊擴展字符有一些常量,這些常量是大于 255 的整數。比如,ACS_PLMINUS 是一個 “加減” 符號,ACS_ULCORNER 是一個框的左上角(方便繪制邊界)。你也可以使用正確的 Unicode 字符。

窗口會記住上次操作之后光標所在位置,所以如果你忽略 y,x 坐標,字符串和字符會出現在上次操作結束的位置。你也可以通過 move(y,x) 的方法來移動光標。因為一些終端始終會顯示一個閃爍的光標,你可能會想要保證光標處于一些不會讓人感到分心的位置。在看似隨機的位置出現一個閃爍的光標會令人非常迷惑。

如果你的應用程序完全不需要一個閃爍的光標,你可以調用 curs_set(False) 來使它隱形。為與舊版本 curses 的兼容性的關系,有函數 leaveok(bool) 作為 curs_set() 的等價替換。如果 bool 是真值,curses 庫會嘗試移除閃爍光標,并且你也不必擔心它會留在一些奇怪的位置。

屬性和顏色?

字符可以以不同的方式顯示。基于文本的應用程序常常以反相顯示狀態行,一個文本查看器可能需要突出顯示某些單詞。為了支持這種用法,curses 允許你為屏幕上的每個單元指定一個屬性。

屬性值是一個整數,它的每一個二進制位代表一個不同的屬性。你可以嘗試以多種不屬性位組合來顯示文本,但 curses 不保證所有的組合都是有效的,或者看上去有明顯不同。這一點取決于用戶終端的能力,所以最穩妥的方式是只采用最常見的有效屬性,見下表。

屬性

描述

A_BLINK

閃爍文字

A_BOLD

超亮或粗體文字

A_DIM

半明亮的文字

A_REVERSE

反向視頻文本

A_STANDOUT

可用的最佳突出顯示模式

A_UNDERLINE

帶下劃線的文字

所以,為了在屏幕頂部顯示一個反相的狀態行,你可以這么編寫:

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

curses 庫還支持在提供了顏色功能的終端上顯示顏色的功能。最常見的提供顏色的終端很可能是 Linux 控制臺,采用了 xterms 配色方案。

為了使用顏色,你必須在調用完函數 initscr() 后盡快調用函數 start_color(),來初始化默認顏色集(curses.wrapper() 函數自動完成了這一點)。當它完成后,如果使用中的終端支持顯示顏色, has_colors() 會返回 True。(注意:curses 使用美式拼寫 “color”,而不是英式/加拿大拼寫 “colour”。如果你習慣了英式拼寫,你需要避免自己在這些函數上拼寫錯誤。)

curses 庫維護一個有限數量的顏色對,包括一個前景(文本)色和一個背景色。你可以使用函數 color_pair() 獲得一個顏色對對應的屬性值。它可以通過按位或運算與其他屬性,比如 A_REVERSE 組合。但再說明一遍,這種組合并不保證在所有終端上都有效。

一個樣例,用 1 號顏色對顯示一行文本:

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

如前所述, 顏色對由前景色和背景色組成。 init_pair(n, f, b) 函數改變顏色對*n*的定義, 為前景色f和背景色b。 顏色對0硬連接到黑色上的白色,不能改變。

顏色已經被編號,并且當其激活 color 模式時 start_color() 會初始化 8 種基本顏色。 它們是: 0:black, 1:red, 2:green, 3:yellow, 4:blue, 5:magenta, 6:cyan 和 7:white。 curses 模塊為這些顏色定義了相應的名稱常量: curses.COLOR_BLACK, curses.COLOR_RED 等等。

讓我們來做個綜合練習。 要將顏色 1 改為紅色文本白色背景,你應當調用:

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

當你改變一個顏色對時,任何已經使用該顏色對來顯示的文本將會更改為新的顏色。 你還可以這樣來顯示新顏色的文本:

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

某些非常花哨的終端可以將實際顏色定義修改為給定的 RGB 值。 這允許你將通常為紅色的 1 號顏色改成紫色或藍色或者任何你喜歡的顏色。 不幸的是,Linux 控制臺不支持此特性,所以我無法嘗試它,也無法提供任何示例。 想要檢查你的終端是否能做到你可以調用 can_change_color(),如果有此功能則它將返回 True。 如果你幸運地擁有一個如此優秀的終端,請查詢你的系統的幫助頁面來了解詳情。

用戶輸入?

C curses 庫提供了非常簡單的輸入機制。 Python 的 curses 模塊添加了一個基本的文本輸入控件。 (其他的庫例如 Urwid 擁有更豐富的控件集。)

有兩個方法可以從窗口獲取輸入:

  • getch() 會刷新屏幕然后等待用戶按鍵,如果之前調用過 echo() 還會顯示所按的鍵。 你還可以選擇指定一個坐標以便在暫停之前讓光標移動到那里。

  • getkey() 將做同樣的事但是會把整數轉換為字符串。 每個字符將返回為長度為 1 個字符的字符串,特殊鍵例如函數鍵將返回包含鍵名的較長字符串例如 KEY_UP^G

使用 nodelay() 窗口方法可以做到不等待用戶。 在 nodelay(True) 之后,窗口的 getch()getkey() 將成為非阻塞的。 為表明輸入未就緒,getch() 會返回 curses.ERR (值為 -1) 而 getkey() 會引發異常。 此外還有 halfdelay() 函數,它可被用來(實際地)在每個 getch() 上設置一個計時器;如果在指定的延遲內沒有輸入可用(以十分之一秒為單位),curses 將引發異常。

getch() 方法返回一個整數;如果數值在 0 到 255 之間,它代表所按下鍵的 ASCII 碼。 大于 255 的值為特殊鍵例如 Page Up, Home 或方向鍵等。 你可以將返回的值與 curses.KEY_PPAGE, curses.KEY_HOMEcurses.KEY_LEFT 等常量做比較。 你的程序主循環看起來可能是這樣:

while True:
    c = stdscr.getch()
    if c == ord('p'):
        PrintDocument()
    elif c == ord('q'):
        break  # Exit the while loop
    elif c == curses.KEY_HOME:
        x = y = 0

curses.ascii 模塊提供了一些 ASCII 類成員函數,它們接受整數或長度為 1 個字符的字符串參數;這些函數在為這樣的循環編寫更具可讀性的測試時可能會很有用。 它還提供了一些轉換函數,它們接受整數或長度為 1 個字符的字符串參數并返回同樣的類型。 例如,curses.ascii.ctrl() 返回與其參數相對應的控制字符。

還有一個可以提取整個字符串的方法 getstr()。 它并不經常被使用,因為它的功能相當受限;可用的編輯鍵只有 Backspace 和 Enter 鍵,它們會結束字符串。 也可以選擇限制為固定數量的字符。

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 的庫文檔了解更多細節。

更多的信息?

本 HOWTO 沒有涵蓋一些進階主題,例如讀取屏幕的內容或從 xterm 實例捕獲鼠標事件等,但是 curses 模塊的 Python 庫文檔頁面現在已相當完善。 接下來你應當去瀏覽一下其中的內容。

如果你對 curses 函數的細節行為有疑問,請查看你的 curses 實現版本的說明頁面,不論它是 ncurses 還是特定 Unix 廠商的版本。 說明頁面將記錄任何具體問題,并提供所有函數、屬性以及可用 ACS_* 字符的完整列表。

由于 curses API 是如此的龐大,某些函數并不被 Python 接口所支持。 這往往不是因為它們難以實現,而是因為還沒有人需要它們。 此外,Python 尚不支持與 ncurses 相關聯的菜單庫。 歡迎提供添加這些功能的補丁;請參閱 Python 開發者指南 了解有關為 Python 提交補丁的詳情。