Skip to content

Bibliothèque de composants : Box/Input/Markdown/SelectList

源码版本v0.73.1

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 :

  1. 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. Voir packages/tui/src/tui.ts:39-63.
  2. Rendu en cache : Text, Box, Markdown mettent tous en cache cachedLines ; 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. Voir packages/tui/src/components/text.ts:45-58.
  3. Protocole Focusable : Input, Editor implémentent Focusable (champ focused), que TUI active via setFocus ; au rendu, le composant émet un CURSOR_MARKER à la position du curseur. Voir packages/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/ :

Text.render hit le cache et retourne direct, sinon refait le 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.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 :

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 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 :

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);
  }

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 : quand TUI change de thème, il appelle Container.invalidate qui nettoie récursivement le cache de tous les children. Les sous-classes doivent overrider invalidate pour nettoyer leur cachedLines, sinon elles utilisent les lignes de l'ancien thème. Voir packages/tui/src/components/markdown.ts:110-114.
  • visibleWidth doit être correct : après padding, les lignes de Box peuvent dépasser width, et TUI crashe si c'est le cas. Box.matchCache détecte un changement de sortie de bgFn via bgSample pour décider du hit cache. Voir packages/tui/src/components/box.ts:56-65.
  • Fallback liste vide de SelectList : quand filteredItems.length === 0, on dessine theme.noMatch("No matching commands") sans lever d'erreur, pour que l'appelant continue à tourner.
  • Les lignes image de Markdown ne sont pas re-coupées : les lignes isImageLine(line) sont poussées telles quelles sans passer par wrapTextWithAnsi, parce qu'un retour à la ligne dans une séquence kitty graphics la casserait.
  • Input monoligne sans wrap : Input est une version simplifiée d'Editor ; un collage contenant \n dépend de handlePaste. Input sert surtout dans la barre de filtre d'un SelectList ou dans les sous-menus de SettingsList, 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.