iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Software Development

30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用系列 第 25 篇

CLI 編輯器的畫面控制:清除、游標移動與重繪

  • 分享至 

  • xImage
  •  

前一篇處理輸入:終端機把按鍵編碼成 bytes,程式從 stdin 讀取。這一篇處理輸出:程式把 bytes 寫進 stdout,終端機再把它們顯示成畫面。

像 cat 或 ls 這類 CLI 工具把 stdout 當作連續的字元串流(stream)寫入:只要輸出包含換行符號(\n),終端機就會將游標移到下一行;當內容到達視窗底部時,終端機便自動向上捲動舊內容。全螢幕編輯器則不同,它需要掌控整個視窗,在任意行列位置繪製文字、定位游標,並在每次按鍵輸入後重新繪製畫面。

終端機與編輯器其實是兩個獨立的程式。編輯器不能直接呼叫終端機內部的「清空畫面」函式,只能把 bytes 寫進 stdout。終端機收到一般文字,例如 hello,就把文字畫出來;收到以 ESC 開頭的特定序列,就把它解讀成控制畫面的命令。例如 \x1b[2J 由四個 bytes 組成,代表清空整個畫面。

常用的幾個(\x1b 就是 ESC,27):

序列 動作
\x1b[2J 清空整個畫面
\x1b[H 游標移到左上角
\x1b[{y};{x}H 游標移到第 y 行第 x 欄(1-based,不是 0-based)
\x1b[K 清除游標到行尾
\x1b[?25l / \x1b[?25h 隱藏 / 顯示游標
\x1b[7m / \x1b[m 反白 / 還原樣式
\x1b[?1049h / \x1b[?1049l 切到 / 切回 alternate screen(下面馬上講)

例如以下程式表示先清空畫面並把游標移到左上角,再反白顯示 hello:

fmt.Print("\x1b[2J\x1b[H")   // 清畫面、游標回左上
fmt.Print("\x1b[7mhello\x1b[m") // 反白印出 hello

準備全螢幕畫面

為了不覆蓋使用者原本在 Shell 的命令與輸出紀錄,我們需要先將終端機切換到全螢幕模式。

終端機設有兩個畫面緩衝區:Shell 平常使用的 main screen,負責保存命令輸出與捲動歷史;alternate screen 則是另一個獨立的全螢幕畫面,程式可以在裡面反覆重畫內容。進入 alternate screen 時,終端機顯示新的空白畫面;離開時切回 main screen,原本的 Shell 畫面與游標位置便會完整恢復。

os.Stdout.WriteString("\x1b[?1049h")       // 進場:切到 alternate screen
defer os.Stdout.WriteString("\x1b[?1049l") // 退場:切回主畫面

\x1b[?1049h 切到 alternate screen,\x1b[?1049l 切回 main screen。用 defer 安排切回動作,程式正常離開時就不會把使用者留在 alternate screen。

切換到全螢幕畫面後,編輯器必須知道視窗的寬度與高度,才能決定畫面上要印出幾列文字,以及把狀態列固定在最底部。我們呼叫 term.GetSize 取得終端機視窗的欄數 w 與列數 h;若查詢失敗(例如在不支援的環境下執行),則預設備用尺寸 80 × 24,讓畫面仍可繼續繪製:

w, h, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
	w, h = 80, 24
}

畫面繪製與游標移動都需要知道視窗尺寸以及游標在畫面上的位置,因此我們將這些狀態集中管理在 editor 中:

type editor struct {
	screenRows int // 內容區列數,不含狀態列
	screenCols int // 終端機欄數
	cx, cy     int // 游標位置,0-based
}

screenCols 保存終端機的總欄數。cx、cy 保存游標位於第幾欄與第幾列,兩者都從 0 開始計算。

screenRows 保存的是內容區列數,不是終端機的總列數。最下面一列要畫狀態列,所以 updateSize 把 h - 1 存進 screenRows:

e.screenCols = w
e.screenRows = h - 1

到這裡,程式啟動後已能切換到 alternate screen,並在 editor 保存終端機尺寸與游標位置。

用主迴圈等待按鍵與更新畫面

準備好全螢幕環境與視窗尺寸後,接著我們要處理使用者的按鍵輸入,並即時將更新後的結果顯示在畫面上。

前一篇實現的 readKey 會等待並讀取一個按鍵,refreshScreen 則會按照 editor 目前的狀態畫出完整畫面。把這些動作放進主迴圈,程式就能持續回應使用者:先畫出目前狀態,再等待下一個按鍵,收到按鍵後更新狀態,接著回到迴圈開頭重畫。

因此,程式啟動時會先顯示第一個畫面。每當使用者按下按鍵,processKeypress 就更新 editor 的狀態,下一輪的 refreshScreen 再把更新結果顯示出來:

for {
	e.refreshScreen() // 依目前狀態重畫
	key := readKey()  // 等待一個按鍵
	if e.processKeypress(key) {
		return
	}
}

processKeypress 負責分派按鍵行為:收到 Ctrl+Q 時回傳 true 通知主迴圈離開;收到方向鍵時則呼叫 moveCursor 更新游標座標:

func (e *editor) processKeypress(key Key) bool {
	switch key {
	case ctrl('q'):
		return true
	case KeyArrowUp, KeyArrowDown, KeyArrowLeft, KeyArrowRight:
		e.moveCursor(key)
	}
	return false
}

方向鍵只會透過 moveCursor 修改記憶體中的 cx 與 cy;當主迴圈進入下一次迭代呼叫 refreshScreen 時,才會把游標繪製到新座標。按下 Ctrl+Q 則會讓 processKeypress 回傳 true 結束主迴圈,並觸發 defer 登記的退場還原動作。

重畫畫面時避免閃爍

在主迴圈中,每次按鍵都會觸發一次重畫。如果直覺地先清空整個畫面再逐行寫入 ~,會引發畫面閃爍的問題。例如以下的寫法:

fmt.Print("\x1b[2J\x1b[H") // 清空畫面、游標回左上
for y := 0; y < e.screenRows; y++ {
	fmt.Print("~\r\n")
}

這段程式把「清空畫面」與「逐行重畫」拆成多次寫入。第一個 fmt.Print 寫出 \x1b[2J 後,終端機會立刻清空畫面;接下來的迴圈才把 ~ 一行一行寫回去。終端機不知道程式還沒畫完,可能在任意一次 fmt.Print 後顯示目前收到的內容,使用者就會看到全空白或只畫到一半的畫面。每次按鍵都重複一次這個過程,畫面便會持續閃爍。

避免閃爍需要做三件事:

  1. 整個畫面組在 bytes.Buffer 裡,最後一次 write 送出。 終端機收到的是一個完整的更新,沒有中間狀態可以被看到。
  2. 不用 \x1b[2J 整面清空,改成每行畫完用 \x1b[K 清那一行的尾巴。 舊內容被新內容直接覆蓋,不存在「全空白」的時刻。
  3. 重畫期間用 \x1b[?25l 隱藏游標,完成後再用 \x1b[?25h 顯示。 游標不會跟著輸出過程在畫面上跳動。

執行以下命令後,程式會先清空畫面,再逐行寫入文字。按住任意鍵連續觸發重畫,可以看到畫面閃爍:

go run ./render-screen/flicker

加上 -buffered 後,程式會在 buffer 裡組好整個畫面再一次寫入,並以逐行覆蓋取代整面清空。同樣按住任意鍵,畫面不會閃爍:

go run ./render-screen/flicker -buffered

refreshScreen 用 bytes.Buffer 解決畫面閃爍。先把整個畫面的繪製命令寫進 buffer,等內容全部組好後,再呼叫一次 os.Stdout.Write 送給終端機。重畫順序在 buffer 中分成三步:

  1. 隱藏游標並重置位置:寫入 \x1b[?25l 隱藏游標,並用 \x1b[H 將游標移到左上角準備開始繪製。
  2. 繪製內容列與狀態列:呼叫 drawRows 繪製內容(每行結尾用 \x1b[K 清除舊行尾),接著呼叫 drawStatusBar 印出狀態列。
  3. 定位游標與恢復顯示:用 \x1b[%d;%dH 將游標移回使用者目前的編輯座標(注意轉為 1-based),並寫入 \x1b[?25h 恢復顯示游標。
func (e *editor) refreshScreen() {
	var b bytes.Buffer
	b.WriteString("\x1b[?25l") // 重畫期間藏游標
	b.WriteString("\x1b[H")    // 游標回左上,從頭開始畫
	e.drawRows(&b)
	e.drawStatusBar(&b)
	fmt.Fprintf(&b, "\x1b[%d;%dH", e.cy+1, e.cx+1) // 游標放到 (cx, cy),注意 1-based
	b.WriteString("\x1b[?25h")
	os.Stdout.Write(b.Bytes())
}

func (e *editor) drawRows(b *bytes.Buffer) {
	for y := 0; y < e.screenRows; y++ {
		b.WriteString("~")
		b.WriteString("\x1b[K\r\n") // 清到行尾,取代整面清空
	}
}

\x1b[H 出現在開頭是為了畫圖:接下來的內容從左上角開始逐行覆蓋舊畫面。結尾的定位則是給使用者看的:畫圖過程中游標一路跟著輸出跑到了畫面最底,不放回 (cx, cy) 它就停在狀態列上。

drawStatusBar 用開頭表格裡的反白:寫 \x1b[7m、印狀態文字、用空白補滿整行寬度、\x1b[m 還原樣式。

使用者拖拉視窗時

當使用者拖拉改變終端機視窗大小時,作業系統會向程式發送 SIGWINCH 訊號。編輯器收到訊號後需要重新取得視窗尺寸,並根據新尺寸重新繪製畫面。

接收 SIGWINCH 訊號需要使用 Channel。我們在背景啟動一個 Goroutine 監聽訊號 Channel,當收到視窗改變通知時設定 Atomic Flag;主迴圈重畫時便能安全地檢查該 Flag,避免兩個 Goroutine 同時存取 editor 狀態與寫入終端機所造成的資料競爭(Data Race)。

var resized atomic.Bool

func watchResize() {
	ch := make(chan os.Signal, 1)
	signal.Notify(ch, syscall.SIGWINCH)
	go func() {
		for range ch {
			resized.Store(true)
		}
	}()
}

在 refreshScreen 執行繪製前,程式會先檢查並重置 resized Flag;若偵測到視窗尺寸變更,便呼叫 updateSize 重新計算規格:

if resized.Swap(false) {
	e.updateSize()
}

接著執行:

go run ./render-screen

程式啟動後會切換到全螢幕畫面,內容區每行開頭均會印出 ~,最下方則顯示狀態列。此時可以使用方向鍵移動游標,或按下 Ctrl+Q 離開程式並復原原本的終端機畫面;若在執行期間拖拉調整視窗大小,按下任意按鍵後便會以新尺寸重新繪製。

目前編輯器已能接管終端機視窗並流暢重畫,但畫面上只有空殼 ~,且 cx 與 cy 僅代表螢幕視窗內的座標。下一篇將載入真實檔案內容,並透過 viewport 處理檔案行數大於視窗尺寸時的捲動與顯示。


上一篇
CLI 編輯器的鍵盤輸入:Raw Mode 與按鍵解析
下一篇
CLI 編輯器的檔案瀏覽:載入檔案與 Viewport
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言