Skip to content

元件庫:Box/Input/Markdown/SelectList

源码版本v0.73.1

@mariozechner/pi-tuicomponents/ 資料夾放可複用元件,都實作 Component 介面(render(width): string[] + invalidate())。元件分三類:佈局類別(BoxTextTruncatedTextSpacer)、互動類別(InputSelectListSettingsListEditor)、展示類別(MarkdownImageLoader)。TUI 自己不實作具體元件,只管排程元件樹。

職責

components/ 做三件事:

  1. 統一介面:所有元件實作 Component 介面,render(width) 回傳帶 ANSI 轉義的行陣列,invalidate() 在主題變更時清快取。見 packages/tui/src/tui.ts:39-63
  2. 快取渲染:TextBoxMarkdown 都快取 cachedLines,文字和寬度都沒變就直接回傳上次結果,避免每幀重新 word-wrap。見 packages/tui/src/components/text.ts:45-58
  3. Focusable 協定:InputEditor 實作 Focusable(focused 欄位),TUI 呼叫 setFocus 時設定,元件 render 時在游標位置發 CURSOR_MARKER。見 packages/tui/src/tui.ts:74-82

設計動機

為什麼所有元件都回傳 string[] 而不是直接 write?因為 TUI 要做差分:它需要拿到每一行的最終內容(含 ANSI)才能和上一幀比對。元件只負責生成行,不關心怎麼寫到終端機——這讓元件可測試(純函式 render(width) → string[])、可組合(Container 拼接 children 行)。BoxbgFn 給整塊上背景色時,透過 applyBackgroundToLine 把背景色插到已有 ANSI 之間,而不是包一層,這樣 visibleWidth 仍能算對。

關鍵檔案

資料夾 packages/tui/src/components/ 下:

Text.render 命中快取直接回傳,否則重新 word-wrap:

typescript
// packages/tui/src/components/text.ts:45-58
render(width: number): string[] {
  if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
    return this.cachedLines;
  }
  if (!this.text || this.text.trim() === "") {
    const result: string[] = [];
    this.cachedText = this.text;
    this.cachedWidth = width;
    this.cachedLines = result;
    return result;
  }
  // Replace tabs with 3 spaces

SelectList.renderstartIndex 做視窗捲動,只繪 maxVisible 條,選中項加前綴高亮:

typescript
// packages/tui/src/components/select-list.ts:74-100
render(width: number): string[] {
  const lines: string[] = [];
  if (this.filteredItems.length === 0) {
    lines.push(this.theme.noMatch("  No matching commands"));
    return lines;
  }
  const primaryColumnWidth = this.getPrimaryColumnWidth();
  const startIndex = Math.max(
    0,
    Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
  );
  const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);

Markdown.render 先 marked lexer 出 token,每個 token 轉 styled 行,再 wrapTextWithAnsi 按列寬重折行,最後 padding/background:

typescript
// packages/tui/src/components/markdown.ts:116-159
render(width: number): string[] {
  if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
    return this.cachedLines;
  }
  const contentWidth = Math.max(1, width - this.paddingX * 2);
  // ...
  const tokens = markdownParser.lexer(normalizedText);
  const renderedLines: string[] = [];
  for (let i = 0; i < tokens.length; i++) {
    const token = tokens[i];
    const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
    renderedLines.push(...tokenLines);
  }

資料流

元件 render 的輸出怎麼進 TUI diff:

邊界與失敗

  • 快取失效靠 invalidate:TUI 主題切換時呼叫 Container.invalidate 遞迴清所有 child 快取。子類別必須重寫 invalidate 清自己的 cachedLines,否則會用舊主題的行。見 packages/tui/src/components/markdown.ts:110-114
  • visibleWidth 必須算對:Box 在 padding 後行寬可能超 width,TUI 偵測到會 crash。Box.matchCache 透過 bgSample 偵測 bgFn 輸出變化以決定快取命中。見 packages/tui/src/components/box.ts:56-65
  • SelectList 空清單 fallback:filteredItems.length === 0 時繪 theme.noMatch("No matching commands"),不拋例外,讓呼叫方繼續執行。
  • Markdown 圖像行不折行:isImageLine(line) 的行直接 push 不走 wrapTextWithAnsi,因為 kitty graphics 序列被折行會破壞。
  • Input 單行無換行:InputEditor 的簡化版,貼上含 \n 會怎樣取決於 handlePaste——Input 主要用在 SelectList 搜尋框、SettingsList 子選單,不適合編輯多行。

小結

components/Component 介面的實作集合:佈局元件(Box/Text)管排版,互動元件(Input/SelectList/Editor)管輸入,展示元件(Markdown/Loader)管渲染。它們都靠 TUI 類別 的 diff 迴圈把變化寫到終端機;Editor 是其中最複雜的,見 編輯器元件;鍵盤位元組流到元件 handleInput 的解析見 鍵盤解析:kitty 協定