iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

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

CLI 編輯器的鍵盤輸入:Raw Mode 與按鍵解析

  • 分享至 

  • xImage
  •  

在開發 CLI 工具時,除了處理 git status 這類單次執行的命令,我們經常需要打造更具互動性的介面——例如按鍵即時過濾的選單、滾動檢視日誌的 Viewport、REPL 提示字元,或是全螢幕的 TUI 工具。

這些互動式 CLI 介面背後,都依賴相同的四個底層機制:逐鍵讀取輸入、控制畫面重繪、換算座標與捲動範圍,以及管理記憶體中的文字狀態。為了徹底掌握這些 CLI 互動能力,接下來我們將用 Go 實作一個終端機文字編輯器來學習這幾項底層機制:

一般 CLI 以完整命令為一次操作。例如要查看 Git 工作目錄的狀態,你會先在 Shell 輸入:

$ git status

輸入 g、i、t 的過程中,Shell 只是在命令列上收集文字。按下 Enter 後,Shell 才啟動 git,把 status 當成參數交給它。git 不需要逐鍵處理這段輸入,只需要執行完整的 git status 命令。

終端機編輯器啟動後,游標、文字與畫面都要跟著單一按鍵改變。先看一個編輯器畫面,游標目前停在 world 的行尾:

┌──────────────────────────────────────┐
│ hello                                │
│ world▌                               │
│ ~                                    │
│ ~                                    │
├──────────────────────────────────────┤
│ notes.txt                       2:6  │
└──────────────────────────────────────┘

進入這個畫面後,每個按鍵都要在按下時生效:

  • 按 ←:游標移到 d 前面,畫面變成 worl▌d
  • 輸入 !:在游標處插入字元,畫面變成 worl!▌d
  • 按 Ctrl+S:把目前內容存回 notes.txt

這些操作都不能等待 Enter。編輯器必須逐鍵讀取文字、方向鍵與快捷鍵,更新內容與游標,再重畫畫面。互動式選單、REPL、即時 dashboard 與全螢幕 TUI 也使用相同的輸入方式。

接下來的四篇將依序完成這四個部分:

  1. 輸入:切換 canonical mode 與 raw mode,逐鍵讀取 stdin,並把文字、Ctrl 組合鍵與 escape sequences 轉成統一的按鍵事件。
  2. 畫面控制:把 escape sequences 寫入 stdout,控制 alternate screen、游標與文字樣式,再用 buffer 一次送出完整畫面,避免重繪時閃爍。
  3. 檔案瀏覽:把檔案載入行陣列,換算檔案座標與螢幕座標,並在游標超出可見範圍時捲動 viewport。
  4. 文字編輯:把行陣列擴充成可編輯的 text buffer,處理插入、刪除與跨行操作,再加入未存檔狀態、原子寫入、離開保護與 Undo/Redo。

這篇先專注完成第一個部分:輸入路徑。要讓編輯器能即時回應每一次按鍵,我們必須處理兩個問題:

  1. 讓終端機在按鍵按下時立刻傳送資料:預設的 Canonical Mode 會先在終端機收集輸入,直到 Enter 才整行送出。我們需要將終端機切換至 Raw Mode,讓使用者按下按鍵的瞬間,原始 bytes 就立刻送進程式的 stdin。
  2. 把收到的 bytes 還原成按鍵事件:程式從 stdin 讀到的只是 byte 序列(例如按 a 收到 97,按 ↑ 則收到三個連續 bytes 27 91 65)。我們需要實作 readKey 函式,把這些 bytes 解析成統一的 Key 事件(如 KeyArrowUp),交給編輯器更新狀態。

整個輸入流程如下:

https://ithelp.ithome.com.tw/upload/images/20261005/20111896lkji1BY7o2.png

切換 raw mode

一般 CLI 啟動時,連接作業系統與程式的終端機通道(tty)預設使用 canonical mode。在這個模式下,tty 驅動程式會先幫你收集輸入並自動把字元顯示在畫面上,直到按下 Enter 才會把整行資料一次交給程式。按下 Ctrl+C 時,tty 驅動程式也會直接中斷並結束程式,不會把 Ctrl+C 當成一般的快捷鍵傳給程式。

CLI 編輯器不能讓 tty 先做這些處理。方向鍵與文字要在按下時立刻交給編輯器,畫面上的文字要由編輯器重畫,Ctrl+C、Ctrl+S 等組合鍵也要保留給編輯器當成快捷鍵。

Raw mode 是 tty 的設定,可以在終端機執行 stty raw 手動切換,再用 stty sane 還原。我們在程式啟動時直接透過代碼進入 raw mode,並在程式結束時自動還原原本的終端機設定。

開啟 cli-sample/go-editor-tutorial/raw-mode/main.go。我們使用 golang.org/x/term 套件的 term.MakeRaw 函式將 stdin 所連接的終端機切換至 Raw Mode。呼叫 defer term.Restore(fd, oldState) 在 main 返回時把終端機還原為原本的設定,避免離開程式後的 Shell 停在 Raw Mode 而無法正常操作。

func main() {
	// x/term 透過 file descriptor 修改 stdin 所連接的 tty。
	fd := int(os.Stdin.Fd())

	// 進入 raw mode,並保存切換前的 termios 設定。
	oldState, err := term.MakeRaw(fd)
	if err != nil {
		panic(err)
	}

	// main 返回時離開 raw mode,還原原本的終端機設定。
	defer term.Restore(fd, oldState)

	// 接下來才進入讀取按鍵的迴圈。
}

在 raw mode 中逐鍵讀取

切換至 Raw Mode 後,程式就能直接讀到鍵盤送出的原始 byte 數值。在開始實作編輯器的 readKey 之前,我們先用一段測試迴圈來觀察輸入行為,看看按下字母、Ctrl 組合鍵與方向鍵時,終端機到底會傳進哪些數字。按 q 可以結束測試:

buf := make([]byte, 1)
for {
	os.Stdin.Read(buf)
	fmt.Printf("%d\r\n", buf[0])
	if buf[0] == 'q' {
		break
	}
}

在範例的raw-mode 觀察 byte 迴圈:

go run ./raw-mode

執行後:

  • 按 a,印出 97——立刻印,不用等 Enter,畫面上也沒有多出一個 a
  • 按 Ctrl+C,印出 3——程式沒有離開,它只是一個普通的 byte 了
  • 按 ↑,依序印出 27、91、65——同一個方向鍵會產生三個 bytes

這段測試程式展示了 Raw Mode 的本質:它只負責把原始 bytes 傳給程式,不會自動告訴程式 27 91 65 代表方向鍵。在正式的編輯器中,我們不會直接印出這些數字,而是透過接下來要寫的 readKey 函式,把這些數值解析成主迴圈能處理的按鍵事件。

把 byte 序列解析成一個 Key

終端機傳進來的按鍵主要分為兩類:

  • 單個 ASCII 碼:包含一般可列印字元(如字母 a 數值為 97)與 Ctrl 組合鍵(如 Ctrl+C 數值為 3 的控制碼)。
  • Esc 控制序列:包含方向鍵、Home、End 等特殊鍵,會送出以 Esc(ASCII 27)開頭的多位元序列。

為了讓主迴圈能用同一個型別處理所有按鍵,我們自訂 Key 型別。一般字元與 Ctrl 碼直接保存原始數值;方向鍵等特殊功能鍵則從 Unicode 範圍之外(0x110000)開始定義常數,避免與可輸入的字元重疊:

type Key int32

const (
	KeyArrowUp Key = 0x110000 + iota // Unicode 上限之後起跳
	KeyArrowDown
	KeyArrowLeft
	KeyArrowRight
	KeyPageUp
	KeyPageDown
	KeyHome
	KeyEnd
	KeyDelete
	KeyEsc
)

有了 Key 型別後,readKey 的解析邏輯以第一個 byte 是否為 27 作為分流邊界:

  • 第一個 byte 不是 27(一般字元與 Ctrl 鍵):直接轉換為 Key。ASCII 可列印字元(32–126)與 Ctrl 鍵(1–26)都是單一 byte,readKey 讀到後不需要再讀下一個 byte:

    if c != 27 {
    	return Key(c)
    }
    
  • 第一個 byte 是 27(Esc 控制序列):按下 ↑ 時,stdin 會連續送出 27、91、65。如果直接分開處理,主迴圈會誤認成按了 Esc、[、A 三次輸入。readKey 必須繼續讀齊後續 bytes 辨認出 KeyArrowUp;如果 27 後面沒有其他資料,才代表使用者單純按了 Esc 鍵(KeyEsc)。

readKey 依照 stdin 收到的序列,還原為對應的按鍵事件:

  • 方向鍵:收到 ESC [ A、ESC [ B、ESC [ C、ESC [ D 時,分別回傳 KeyArrowUp、KeyArrowDown、KeyArrowRight、KeyArrowLeft。
  • 翻頁鍵:收到 ESC [ 5 ~ 或 ESC [ 6 ~ 時,回傳 KeyPageUp 或 KeyPageDown。
  • Home / End 鍵:收到 ESC [ H、ESC [ 1 ~ 或 ESC O H 時回傳 KeyHome;收到 ESC [ F、ESC [ 4 ~ 或 ESC O F 時回傳 KeyEnd。
  • Delete 鍵:收到 ESC [ 3 ~ 時,回傳 KeyDelete。

Esc 鍵本身也是 27,所以 readKey 讀到 27 時會等待 50ms。期限內沒有其他資料,表示使用者只按了 Esc;收到後續 bytes,則繼續解析特殊鍵。不同終端機可能使用不同序列表示 Home 與 End,解析後都會回傳相同的 KeyHome 或 KeyEnd。

readKey 的處理方式如下:

func readKey() Key {
	c, err := readByte()
	if err != nil {
		return ctrl('q') // stdin 關閉時讓主迴圈離開。
	}
	if c != 27 {
		return Key(c) // 一般字元與 Ctrl 組合鍵
	}

	// 讀到 27:逾時表示單獨的 Esc,否則繼續解析特殊鍵。
	seq0, ok := readByteTimeout()
	if !ok {
		return KeyEsc
	}
	if seq0 == '[' {
		seq1, _ := readByteTimeout()
		switch seq1 {
		case 'A':
			return KeyArrowUp
		// ... B C D、H F、數字+'~' 的分支
		}
	}
	// ESC O H/ESC O F 等序列。
	return KeyEsc
}

readByteTimeout 只在解析 ESC 開頭的序列時設定 50ms 期限,讀完後會清除期限,因此下一次 readKey 仍會持續等待使用者按鍵。

在範例 go-editor-tutorial/read-keys 執行

go run ./read-keys

執行後按下字母、Ctrl 組合鍵或方向鍵,程式會印出解析後的按鍵名稱(如 a、Ctrl+A、ArrowUp),按 Ctrl+Q 即可離開。完成這一步後,我們成功把終端機的 Raw Mode 原始 bytes 還原為統一的按鍵事件,接通了編輯器與互動式 CLI 的輸入路徑。

處理完輸入後,下一步是畫面輸出。我們將學習用 Escape Sequences 清除終端機、控制游標位置與繪製狀態列,並透過 Buffer 一次性寫入消除重繪時的畫面閃爍。


上一篇
打造 AI Chat CLI 串流對話工具
下一篇
CLI 編輯器的畫面控制:清除、游標移動與重繪
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言