Skip to content

編輯器元件:輸入、IME、kill-ring、undo

源码版本v0.73.1

Editor@mariozechner/pi-tui 裡最重的元件——單檔 2292 行,承擔多行文字編輯的全部髒活:鍵盤輸入分發、IME 候選視窗定位、bracketed paste 處理、Emacs 風格 kill/yank、fish 風格 undo coalescing、slash 命令與符號觸發 autocomplete。InteractiveMode 用它當主輸入框,擴充也能透過 EditorComponent 介面替換實作(vim/emacs 模式)。

職責

Editor 做四件事:

  1. 輸入分發:handleInput(data) 是 raw 終端機位元組流入口,按順序處理 jump mode、bracketed paste、undo、autocomplete 導航、普通字元。見 packages/tui/src/components/editor.ts:534-630
  2. 多行編輯:addNewLine 在游標處切行,insertTextAtCursorInternal 處理貼上/補全插入的多行文字。見 packages/tui/src/components/editor.ts:1152-1175packages/tui/src/components/editor.ts:980-1023
  3. kill-ring / undo:Emacs kill/yank + fish 風格 undo coalescing,kill 連續刪除可累積,yank 後 yank-pop 迴圈 ring。見 packages/tui/src/components/editor.ts:1817-1896packages/tui/src/kill-ring.ts:1-50
  4. IME 游標定位:render 在游標位置前插 CURSOR_MARKER(零寬 APC 序列),TUI 提取後把硬體游標移到該位置,IME 候選視窗就能貼住游標。見 packages/tui/src/components/editor.ts:474-501

設計動機

為什麼不用 Node.js 的 readline?因為它沒有 IME 支援、沒有多行、沒有 undo、沒有 autocomplete 鉤子。Editor 把這些做進同一元件,關鍵決策有三個。一是 undo coalescing:連續輸入字元預設併成一個 undo 單元(空格觸發新單元),避免每個字元都 push 一次快照導致 undo 雜訊。二是 kill-ring 累積:連續 deleteWordBackwards 把刪除文字拼到同一個 ring 項裡,反向刪除時 prepend,正向 append,這樣 yank 一次能恢復整段。三是 paste marker:大段貼上不直接塞進文字,而是替換成 [paste #N +M lines] 占位,展開時再呼叫 getExpandedText,避免幾千行貼上把渲染拖死。

關鍵檔案

handleInput 進來先偵測 bracketed paste 起止 marker,貼上期間累積到 pasteBuffer 直到收到 \x1b[201~:

typescript
// packages/tui/src/components/editor.ts:559-582
if (data.includes("\x1b[200~")) {
  this.isInPaste = true;
  this.pasteBuffer = "";
  data = data.replace("\x1b[200~", "");
}
if (this.isInPaste) {
  this.pasteBuffer += data;
  const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
  if (endIndex !== -1) {
    const pasteContent = this.pasteBuffer.substring(0, endIndex);
    if (pasteContent.length > 0) this.handlePaste(pasteContent);
    this.isInPaste = false;
    // ...
  }
}

undo coalescing 把連續 word 字元合併成一個 undo 單元,空格單獨成單元:

typescript
// packages/tui/src/components/editor.ts:1027-1037
// - Consecutive word chars coalesce into one undo unit
// - Space captures state before itself (so undo removes space+following word together)
// - Each space is separately undoable
if (!skipUndoCoalescing) {
  if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
    this.pushUndoSnapshot();
  }
  this.lastAction = "type-word";
}

yank 從 kill-ring 取堆疊頂端插入,yank-pop 必須緊跟 yank,先刪上次 yank 的文字再 rotate ring:

typescript
// packages/tui/src/components/editor.ts:1832-1848
private yankPop(): void {
  if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
  this.pushUndoSnapshot();
  this.deleteYankedText();
  this.killRing.rotate();
  const text = this.killRing.peek()!;
  this.insertYankedText(text);
  this.lastAction = "yank";
}

資料流

raw stdin → Editor.handleInput → 狀態變更 → onChange → 外部 requestRender:

邊界與失敗

  • tmux extended-keys-format=csi-u 把控制位元組重編碼:handlePaste 用正規表示式把 \x1b[<cp>;5u 還原成原始位元組,避免 newline 被當成 ESC + [106;5u 漏進編輯器。見 packages/tui/src/components/editor.ts:1091-1101
  • 大貼上走 marker:超閾值文字替換成 [paste #N +M lines],真正內容存在 pasteRegistry,getExpandedText 展開。渲染只繪 marker,不會卡。
  • undo 跨 paste 原子:handlePaste 入口 pushUndoSnapshot 一次,貼上後單次 undo 回到貼上前狀態。
  • jump mode 攔截下一鍵:進入 jump 後下一個 printable 字元不插入文字,而是觸發 jumpToChar,ctrl 字元取消 jump。見 packages/tui/src/components/editor.ts:538-556
  • submit 語義可設定:shouldSubmitOnBackslashEnter 根據 keybindings 裡 tui.input.submitenter 還是 shift+enter 決定 \n 是否提交。見 packages/tui/src/components/editor.ts:1177-1188

小結

Editor 是個五臟俱全的終端機編輯器:輸入分發、IME、kill-ring、undo、autocomplete 都在一個類別裡。它依賴 TUI 的差分渲染和 CURSOR_MARKER 機制定位硬體游標,見 TUI 類別:差分渲染的排程中樞;鍵盤位元組流怎麼解析成 key id 見 鍵盤解析:kitty 協定;autocomplete 下拉用 SelectList,見 元件庫