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 字段),TUIsetFocus 时设置,组件 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 协议