组件库:Box/Input/Markdown/SelectList
@mariozechner/pi-tui 的 components/ 目录放可复用组件,都实现 Component 接口(render(width): string[] + invalidate())。组件分三类:布局类(Box、Text、TruncatedText、Spacer)、交互类(Input、SelectList、SettingsList、Editor)、展示类(Markdown、Image、Loader)。TUI 自己不实现具体组件,只管调度组件树。
职责
components/ 做三件事:
- 统一接口:所有组件实现
Component接口,render(width)返回带 ANSI 转义的行数组,invalidate()在主题变更时清缓存。见packages/tui/src/tui.ts:39-63。 - 缓存渲染:
Text、Box、Markdown都缓存cachedLines,文本和宽度都没变就直接返回上次结果,避免每帧重新 word-wrap。见packages/tui/src/components/text.ts:45-58。 - Focusable 协议:
Input、Editor实现Focusable(focused字段),TUI调setFocus时设置,组件 render 时在光标位置发CURSOR_MARKER。见packages/tui/src/tui.ts:74-82。
设计动机
为什么所有组件都返回 string[] 而不是直接 write?因为 TUI 要做差分:它需要拿到每一行的最终内容(含 ANSI)才能和上一帧比对。组件只负责生成行,不关心怎么写到终端——这让组件可测试(纯函数 render(width) → string[])、可组合(Container 拼接 children 行)。Box 用 bgFn 给整块上背景色时,通过 applyBackgroundToLine 把背景色插到已有 ANSI 之间,而不是包一层,这样 visibleWidth 仍能算对。
关键文件
目录 packages/tui/src/components/ 下:
packages/tui/src/components/box.ts:14-60—Box容器,paddingX/Y+bgFn给 children 整块上背景,带 render cache。packages/tui/src/components/text.ts:7-58—Text多行文本 + 自动换行 + 自定义背景函数。packages/tui/src/components/truncated-text.ts:7-50—TruncatedText只取首行按宽度截断,用于状态栏。packages/tui/src/components/input.ts:18-50—Input单行输入,横向滚动,自己管 paste/kill-ring/undo(和Editor同一套)。packages/tui/src/components/select-list.ts:40-100—SelectList上下选择列表,带 filter、双列(主列+描述)、scroll indicator。packages/tui/src/components/settings-list.ts:34-83—SettingsList设置面板,内嵌Input搜索 + 子菜单递归。packages/tui/src/components/markdown.ts:78-159—Markdown用 marked 词法 + 自定义 inline style 转带 ANSI 的行。packages/tui/src/components/loader.ts/packages/tui/src/components/cancellable-loader.ts— spinner,每帧只动一行,正好走 diff 快路径。
Text.render 命中缓存直接返回,否则重新 word-wrap:
// 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 spacesSelectList.render 用 startIndex 做窗口滚动,只画 maxVisible 条,选中项加前缀高亮:
// 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:
// 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单行无换行:Input是Editor的简化版,粘贴含\n会怎样取决于handlePaste——Input主要用在SelectList搜索框、SettingsList子菜单,不适合编辑多行。
小结
components/ 是 Component 接口的实现集合:布局组件(Box/Text)管排版,交互组件(Input/SelectList/Editor)管输入,展示组件(Markdown/Loader)管渲染。它们都靠 TUI 类 的 diff 循环把变化写到终端;Editor 是其中最复杂的,见 编辑器组件;键盘字节流到组件 handleInput 的解析见 键盘解析:kitty 协议。