Aller au contenu principal

Un widget Tui custom : trois bugs et un chat dégoûté.

Créez un widget personnalisé Tui pour afficher un GIF dans le terminal avec Symfony Terminal. Découvrez les étapes pour étendre AbstractWidget, implémenter render(RenderContext): array et utiliser onAttach(WidgetContext) pour animer une image.

9 min de lecture
Sommaire · 6

Depuis que je développe avec Symfony, je rêve d'avoir mes pages d'erreur et mes échecs CI ponctués par mon GIF préféré : un chat dégoûté qui me juge. Le talk Symfony Live de cette année sur la conception de TUI en PHP grâce au composant Symfony Terminal m'a donné l'occasion. Trois jours, trois bugs — un canal alpha qui multiplie par zéro, un clone PHP qui ne clone rien, un compositor qui ignore son propre invalidate() — un widget custom et un hook make qa.

Ce billet ne benchmark pas Tui contre les TUI Rust ou Python, et ne prétend pas que mon widget est production-ready. C'est trois bugs et un chat, rien de plus.

Ma commande qui affiche un chat dégoûté

Pourquoi un widget custom

Le composant symfony/tui ne fournit pas d'AnimatedImageWidget pour le moment dans la branche 8.1. Les widgets disponibles couvrent le texte, les listes, les progressions, les éditeurs mais pas les images. Donc si tu veux animer un GIF dans le terminal, tu écris ton widget.

Concrètement, il faut étendre AbstractWidget, implémenter render(RenderContext): array<string> qui retourne une chaîne par ligne du terminal, et utiliser le hook onAttach(WidgetContext) pour s'inscrire au scheduler de la boucle.

Le rendu choisi est le demi-bloc ANSI 24-bit. Une cellule terminal (un caractère) accueille deux pixels verticaux : on imprime  (UPPER HALF BLOCK, U+2580) en mettant le pixel haut en foreground et le pixel bas en background. La résolution verticale double sans toucher à la grille de cellules — on reste dans le système Tui, le compositing et le dirty tracking fonctionnent normalement. La piste alternative était Kitty graphics protocol (poussée d'image PNG inline) ; pixel-perfect mais elle court-circuite le composant, donc impossible d'overlay du texte par exemple.

Le pattern complet ressemble à ça (extrait de src/Tui/Widget/GifWidget.php) :

PHP
final class GifWidget extends AbstractWidget
{
    /** @var list<list<list<int>>> bakedFrames[$frame][$y][$x] = packed RGB int */
    private array $bakedFrames = [];
    private int $currentFrame = 0;
    private ?string $tickId = null;
    private ?WidgetContext $ctx = null;

    public function __construct(
        private readonly string $gifPath,
    ) {}

    public function render(RenderContext $context): array
    {
        // Retourne une string par ligne terminale — chaque string peut
        // contenir des séquences ANSI, Tui les ignore pour le calcul
        // de largeur visible.
    }

    protected function onAttach(WidgetContext $context): void
    {
        $this->ctx = $context;
        $this->tickId = $context->scheduleTick(fn () => $this->advance(), 0.1);
    }
}

scheduleTick est ce qui fait avancer la frame courante toutes les n secondes. C'est aussi le premier endroit où l'on bénéficie de la base Revolt/Fibers du composant : le tick partage l'event loop avec l'input clavier, les fetch HTTP async, les autres widgets. On n'écrit pas un while (true) { render(); usleep(100_000); } qui bloquerait tout. La loop reste interactive pendant l'animation.

Bug 1 — Mon chat n'a aucune couleur

Première version du widget, j'écris ça pour traiter la transparence des GIF :

PHP
$clone = clone $frame;
$clone->resizeImage($targetW, $targetH * 2, Imagick::FILTER_LANCZOS, 1);
$clone->setImageBackgroundColor('white');
$clone = $clone->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN);

L'idée semblait raisonnable : aplatir chaque frame sur un fond blanc pour éviter les bordures noires fantômes. Le résultat affiché était assez décevant avec un chat presque noir. Pas mon chat orange, un chat, mais foncé, brun-rouge, comme si quelqu'un avait baissé l'exposition à 30 %.

PHP
// Sans flatten
$px1 = $clone->exportImagePixels(0, 0, 20, 10, 'RGB', Imagick::PIXEL_CHAR);
// pixel (5,5) : R=217 G=222 B=213  ← gris clair, normal

// Avec flatten
$clone2->setImageBackgroundColor('white');
$clone2 = $clone2->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN);
$px2 = $clone2->exportImagePixels(...);
// pixel (5,5) : R=49 G=16 B=16  ← presque noir

Ce que je n'avais pas compris : coalesceImages() transforme les delta-frames du GIF en frames pleines et résout déjà la transparence. Les frames qui en sortent sont en RGB pur, sans canal alpha problématique. Refaire un mergeImageLayers derrière, c'est demander à Imagick de re-composer sur un fond avec un canal alpha qui n'existe plus. La composition multiplie les couleurs par zéro et ça donne du noir.

Leçon évidente après coup ! L'API Imagick est énorme, et beaucoup de méthodes empilent leur effet sur ce qu'un appel précédent a déjà fait.

Bug 2 — Mon chat est en couleur, mais immobile

Deuxième version, le widget tourne, on voit du orange. L'animation ne se déclenche pas — le chat reste figé sur la première frame du GIF. Or advance() était appelé : un debug dans /tmp/cat-debug.log me confirme que la méthode tourne à 100 ms d'intervalle, frame 0 → frame 1 → frame 2 → … → frame 18 en boucle, exactement la cadence native du fichier.

// Bake les 70 frames à la taille terminale
$w = new GifWidget('/app/devops/qa/images/cat-disgusted.gif');
$ctx = new RenderContext(60, 20, ...);
$w->render($ctx); // déclenche bake() → bakedFrames[0..69]

// Reflection pour inspecter ce qu'on a stocké
$bf = (new ReflectionClass($w))->getProperty('bakedFrames');
$frames = $bf->getValue($w);
echo sprintf('%06X', $frames[0][5][5]);   // 432B2C
echo sprintf('%06X', $frames[30][5][5]);  // 432B2C
echo sprintf('%06X', $frames[60][5][5]);  // 432B2C

Les 70 frames bakées contiennent les mêmes pixels. Pas une animation figée à l'écran : un buffer figé en RAM. Les couleurs venaient bien de Imagick, mais venaient d'une seule image, recopiée 70 fois.

PHP
foreach ($imagick as $frame) {
    $clone = clone $frame;          // ← ici
    $clone->resizeImage(...);
    $pixels = $clone->exportImagePixels(...);
}

clone $frame est l'opérateur PHP standard. Il fait une copie superficielle. Pour la majorité des objets PHP, c'est ce qu'on veut : on duplique les propriétés, on garde les références aux objets enfants. Sauf qu'Imagick n'est pas un objet PHP au sens normal ; c'est une enveloppe vers une structure C native qui contient le buffer pixel.

La conséquence est que tous mes $clone étaient des handles PHP différents pointant vers le même buffer Imagick. Quand resizeImage modifiait ce buffer, il le modifiait pour tout le monde. La dernière frame écrasait toutes les précédentes.

Imagick::getImage() est la méthode officielle pour faire une vraie copie isolée. Le commentaire que j'ai laissé dans le code (extrait de bake()) cristallise la leçon :

PHP
foreach ($imagick as $frame) {
    // ⚠ `clone $frame` (PHP shallow) ne duplique PAS le buffer pixel
    // — toutes les frames se retrouvent à pointer sur le MÊME pixel
    // buffer après `resizeImage()`, et bake() produit 70 frames
    // identiques. `Imagick::getImage()` retourne une vraie copie
    // isolée (vérifié : pixel (35,20) varie bien selon le frame
    // après resize).
    $clone = $frame->getImage();
    $clone->resizeImage($targetW, $targetH * 2, \Imagick::FILTER_LANCZOS, 1);
    // ...
}

Bug 3 — Les frames bakées sont distinctes, mais l'écran ne bouge toujours pas

Le compteur avance, les frames bakées sont distinctes (pixels vérifiés), renderCurrentFrame() produit bien des chaînes ANSI différentes selon $currentFrame. Et l'écran ne change pas.

PHP
$this->currentFrame = ($this->currentFrame + 1) % \count($this->bakedFrames);
$this->renderCurrentFrame();
$this->invalidate();

invalidate() est documenté comme « marque le widget dirty et invalide le cache de rendu ». Ce que je n'avais pas saisi : marquer dirty ne déclenche pas un re-render immédiat. Ça dit au composant qu'au prochain tick de loop, ce widget doit être recalculé. Mais le tick suivant n'arrive pas tout seul tant qu'on ne l'a pas demandé.

La méthode qui dit « tu rends maintenant », c'est requestRender(true) sur le contexte du widget. Sans elle, le compositor reste sur sa frame précédente parce qu'il n'a pas reçu le signal de redessiner. Le diff réel dans advance() :

PHP
$this->currentFrame = ($this->currentFrame + 1) % \count($this->bakedFrames);
 $this->renderCurrentFrame();
 $this->invalidate();
+
+// `invalidate()` marque le widget dirty mais NE force PAS le
+// re-render — il attend le prochain tick du loop. Sans
+// `requestRender(true)`, le compositor ne sait pas qu'il doit
+// redessiner, et l'animation reste figée sur la première frame.
+if ($this->ctx instanceof WidgetContext) {
+    $this->ctx->requestRender(true);
+}

On apprend trois choses : coalesceImages couvre déjà le cas qu'on croyait à couvrir. clone PHP sur un objet Imagick ne copie rien d'utile. Et invalidate() dans un compositor TUI veut dire « marque comme à recalculer » — pas « redessine ».

Le hook Makefile

Une fois le widget OK, brancher sur make qa est trivial. Le seul piège est de propager le code de retour d'origine et de rester silencieux en CI.

CAT_DISGUSTED := [ -t 1 ] && docker compose exec php bin/console app:cat-disgusted 2>/dev/null || true

qa: ## QA: Run blocking quality checks (PHP + front + Ansible)
	@$(MAKE) -s _qa-real || { $(CAT_DISGUSTED) ; exit 1; }

_qa-real: qa-stan qa-cs qa-twig qa-deptrac qa-security qa-rector qa-container qa-prod-boot qa-biome qa-stylelint qa-knip qa-ansible
	@echo ""
	@echo "$(GREEN)✓ All quality checks passed.$(RESET)"

Trois choses à dire :

  • [ -t 1 ] est l'idiome standard pour vérifier que stdout est un TTY. Sur GitHub Actions ce test est faux, donc le chat se taît et on n'a pas d'escape sequences ANSI dans les logs.
  • || true derrière la commande Docker absorbe aussi le cas où php n'est pas démarré (un dev qui lance make qa sans avoir fait make up). Le exit 1 qui suit conserve le code d'erreur d'origine.
  • Le split qa / _qa-real est volontaire : make qa reste l'entrée publique, _qa-real est l'implémentation. C'est la même technique que make lui-même utilise pour ses cibles internes.

La commande elle-même (src/Command/Fun/CatDisgustedCommand.php) auto-quit après 3 secondes — le chat ne bloque jamais un workflow, même si on lance la commande à la main et qu'on oublie de la fermer :

PHP
$startedAt = microtime(true);
$tui->scheduleInterval(static function () use ($tui, $startedAt): void {
    if ((microtime(true) - $startedAt) >= self::TIMEOUT_SECONDS) {
        $tui->stop();
    }
}, 0.1);

$tui->run();

Le mot de la fin

Un chat qui juge mon code à chaque make qa cassé : un vieux rêve qui se réalise, et qui me fera toujours rire — voire laisser traîner quelques erreurs exprès.

Le vrai cadeau de symfony/tui n'est pas le gag. C'est qu'on peut enfin écrire des CLI PHP avec une personnalité — un process d'installation qui s'anime, un dashboard qui respire, un runner qui réagit — sur un event loop sérieux, dans le même process que nos services Doctrine. La DX qu'on regardait avec envie chez cargo, bun ou gh, elle est désormais à portée de composer require. Reste à voir ce que la communauté en fait : moi j'ai déjà trois idées sur l'établi, et un GIF de Hannibal qui attend son tour.

GIF via GIPHY

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques anonymes via Umami Cloud (hébergement UE) : pages vues, source du trafic, navigateur. Pas de cookie tiers, pas de profilage, pas de partage commercial.