【譯】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閱讀 227,663評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,125評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,506評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,614評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,402評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,934評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,021評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,168評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,690評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,596評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,288評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,027評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,404評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,662評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,398評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,743評論 2 370

推薦閱讀更多精彩內容