Bibliothèque de composants : Box/Input/Markdown/SelectList
Le répertoire components/ de @mariozechner/pi-tui regroupe les composants réutilisables, qui implémentent tous l'interface Component (render(width): string[] + invalidate()). Ils se répartissent en trois familles : layout (Box, Text, TruncatedText, Spacer), interaction (Input, SelectList, SettingsList, Editor) et affichage (Markdown, Image, Loader). TUI lui-même n'implémente pas de composant précis, il se contente de scheduler l'arbre des composants.
Responsabilités
components/ fait trois choses :
- Interface unifiée : tous les composants implémentent l'interface
Component;render(width)renvoie un tableau de lignes avec escapes ANSI,invalidate()nettoie le cache lors d'un changement de thème. Voirpackages/tui/src/tui.ts:39-63. - Rendu en cache :
Text,Box,Markdownmettent tous en cachecachedLines; si le texte et la largeur n'ont pas changé, on retourne le résultat précédent pour éviter de refaire le word-wrap à chaque frame. Voirpackages/tui/src/components/text.ts:45-58. - Protocole Focusable :
Input,EditorimplémententFocusable(champfocused), queTUIactive viasetFocus; au rendu, le composant émet unCURSOR_MARKERà la position du curseur. Voirpackages/tui/src/tui.ts:74-82.
Motifs de conception
Pourquoi tous les composants renvoient-ils string[] au lieu d'écrire directement ? Parce que TUI doit faire le diff : il a besoin du contenu final de chaque ligne (ANSI inclus) pour comparer avec la frame précédente. Le composant ne fait que produire les lignes, sans se soucier de comment elles sont écrites sur le terminal — cela rend les composants testables (fonction pure render(width) → string[]) et composables (Container concatène les lignes des children). Quand Box applique une couleur de fond à tout un bloc via bgFn, il utilise applyBackgroundToLine pour insérer la couleur de fond entre les séquences ANSI existantes plutôt que d'envelopper d'une couche, de sorte que visibleWidth continue à calculer correctement.
Fichiers clés
Sous le répertoire packages/tui/src/components/ :
packages/tui/src/components/box.ts:14-60— conteneurBox,paddingX/Y+bgFnapplique un fond à tout le bloc de children, cache de rendu.packages/tui/src/components/text.ts:7-58—Texttexte multiligne + word-wrap auto + fonction de fond personnalisée.packages/tui/src/components/truncated-text.ts:7-50—TruncatedTextne garde que la première ligne et la tronque à la largeur, pour la barre d'état.packages/tui/src/components/input.ts:18-50—Inputentrée monoligne, scroll horizontal, gère lui-même paste/kill-ring/undo (même implémentation queEditor).packages/tui/src/components/select-list.ts:40-100—SelectListliste à sélection haut/bas, avec filter, double colonne (principale + description), indicateur de scroll.packages/tui/src/components/settings-list.ts:34-83—SettingsListpanneau de settings, embedde uneInputde recherche + récursion en sous-menus.packages/tui/src/components/markdown.ts:78-159—Markdownutilise le lexer marked + style inline personnalisé pour produire des lignes ANSI.packages/tui/src/components/loader.ts/packages/tui/src/components/cancellable-loader.ts— spinner, chaque frame ne bouge qu'une ligne, tombe pile sur le fast path du diff.
Text.render hit le cache et retourne direct, sinon refait le 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 utilise startIndex pour fenêtrer le scroll, ne dessine que maxVisible entrées, et surligne l'entrée sélectionnée avec un préfixe :
// 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 fait d'abord lexer les tokens avec marked, convertit chaque token en lignes stylées, puis wrapTextWithAnsi refait la coupe par largeur de colonne, et enfin 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);
}Flux de données
Comment la sortie de render des composants arrive au diff TUI :
Limites et cas d'échec
- Invalidation du cache via
invalidate: quandTUIchange de thème, il appelleContainer.invalidatequi nettoie récursivement le cache de tous les children. Les sous-classes doivent overriderinvalidatepour nettoyer leurcachedLines, sinon elles utilisent les lignes de l'ancien thème. Voirpackages/tui/src/components/markdown.ts:110-114. visibleWidthdoit être correct : après padding, les lignes deBoxpeuvent dépasserwidth, etTUIcrashe si c'est le cas.Box.matchCachedétecte un changement de sortie debgFnviabgSamplepour décider du hit cache. Voirpackages/tui/src/components/box.ts:56-65.- Fallback liste vide de
SelectList: quandfilteredItems.length === 0, on dessinetheme.noMatch("No matching commands")sans lever d'erreur, pour que l'appelant continue à tourner. - Les lignes image de
Markdownne sont pas re-coupées : les lignesisImageLine(line)sont poussées telles quelles sans passer parwrapTextWithAnsi, parce qu'un retour à la ligne dans une séquence kitty graphics la casserait. Inputmonoligne sans wrap :Inputest une version simplifiée d'Editor; un collage contenant\ndépend dehandlePaste.Inputsert surtout dans la barre de filtre d'unSelectListou dans les sous-menus deSettingsList, pas à éditer plusieurs lignes.
Synthèse
components/ est un ensemble d'implémentations de l'interface Component : les composants de layout (Box/Text) gèrent la mise en page, les composants d'interaction (Input/SelectList/Editor) gèrent l'entrée, les composants d'affichage (Markdown/Loader) gèrent le rendu. Tous s'appuient sur la boucle diff de la classe TUI pour écrire les changements sur le terminal ; Editor est le plus complexe d'entre eux, voir composant éditeur ; pour le parsing du flux d'octets clavier vers le handleInput des composants, voir parsing clavier : protocole kitty.