コンポーネントライブラリ: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 と 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 の lexer + カスタム inline style で ANSI 付きの行に変換。packages/tui/src/components/loader.ts/packages/tui/src/components/cancellable-loader.ts— spinner。毎フレーム一行だけ動き、差分の高速パスを通る。
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を呼び、再帰的にすべての子キャッシュをクリアする。サブクラスは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空リストのフォールバック:filteredItems.length === 0のときはtheme.noMatch("No matching commands")を描くだけでエラーを投げず、呼び出し側が続行できる。Markdown画像行は折り返さない:isImageLine(line)の行はwrapTextWithAnsiを通さずそのまま push する。kitty graphics シーケンスが折り返されると壊れるからだ。Inputは単行で改行なし:InputはEditorの簡略版で、\nを含むペーストをどう扱うかはhandlePaste次第。主にSelectListの検索ボックス、SettingsListのサブメニューで使われ、複数行編集には向かない。
まとめ
components/ は Component インターフェースの実装集合:レイアウトコンポーネント(Box/Text)が組版を、インタラクションコンポーネント(Input/SelectList/Editor)が入力を、表示コンポーネント(Markdown/Loader)がレンダリングを分担する。どれも TUI クラス の diff ループで変化をターミナルに書き出す。Editor は中でも最も複雑で、エディタコンポーネント 参照。キーボードのバイトストリームがコンポーネントの handleInput に至るまでの解析は キーボード解析:kitty プロトコル 参照。