Au moment de la publication de mon article sur la création de CLI en Deno l'ami Thierry Chantier m'a défié de créer un TUI avec Deno ! Vous vous en doutez : j'ai relevé le défie !

CLI vs TUI

Commençons par le BA.ba : un CLI c'est un outil en ligne de commande (CLI = Command Line Interface) qu'on appelle à chaque fois qu'on va vouloir effectuer une action, un TUI (Terminal User Interface) va être appelé une fois puis on va pouvoir manipuler l'outil dans le terminal comme on le ferait avec une GUI (Graphical User Interface) habituel au clavier ou à la souris.

Les TUI ont encore un intérêt fort de nos jours, ils consomment peu de ressource, fonctionnent très bien en local mais aussi via SSH sur une machine distante !

On trouve beaucoup de TUI extrêmement pratique ! Je pense que celui que j'ai le plus utilisé est surement NCDU qui permet de naviguer dans une vue du système de fichier mettant en avant la taille occupée par les fichiers / dossiers, le second c'est TestDisk qui permet de naviguer dans un système de fichier sans s'occuper de l'index pour aller chercher des fichiers supprimés mais encore présent physiquement sur le disque (vous n'imaginez pas comment ça peut sauver la vie !), et plus récemment j'utilise quotidiennement git-interactive-rebase-tool qui offre un menu interactif pour faire un rebase avec git.

Créer le projet

Je ne vais tout détailler, je vous renvoie à mon article précédent. Mais en résumé :

mkdir hello-tui
deno init
deno install npm:figlet npm:crayon

Modifier le fichier deno.json pour ajouter tui et tui/components :

{
  "tasks": {
    "dev": "deno run -A --watch main.ts"
  },
  "imports": {
    "crayon": "npm:crayon@^6.0.1",
    "figlet": "npm:figlet@^1.9.3",
    "tui": "https://deno.land/x/tui@2.1.11/mod.ts",
    "tui/components": "https://deno.land/x/tui@2.1.11/src/components/mod.ts",
    "@std/assert": "jsr:@std/assert@1"
  }
}

Note 1 : ça vous montre que Deno n'est pas limité à JSR ou NPM, il peut tirer des modules depuis n'importe quelle URL http(s), par contre il faut créer les entrées manuellement dans le fichier deno.json.

Note 2 : vous noterez aussi que j'ai ajouté -A dans la commande de la task dev, pour ne pas avoir à m'occuper des permissions (-A indique à Deno d'ouvrir toutes les permissions). C'est une mauvaise pratique, mais pour la démo et pour le dev ça fonctionnera très bien, il sera toujours temps de revenir dessus une fois le projet complet.

Premier affichage

import { crayon } from "crayon";
import {
  handleInput,
  handleKeyboardControls,
  handleMouseControls,
  Tui,
} from "tui";

const tui = new Tui({
  style: crayon.bgBlack, // Make background black
  refreshRate: 1000 / 60, // Run in 60FPS
});

tui.dispatch(); // Close Tui on CTRL+C

handleInput(tui);
handleMouseControls(tui);
handleKeyboardControls(tui);

tui.run();

En l'état ça n'est pas très impressionnant, mais ça va afficher un fond noir sur l'ensemble du terminal et attendre. Si vous avez un doute si ça fonctionne vous pouvez toujours changer la valeur de style dans le constructeur de Tui pour voir varier la couleur de fond.

Premier affichage avec variation du fond entre noir et vert

Hello World!

Ajoutons un peu de texte :


import {
  ...
  Signal,
} from "tui";
import { Text } from "tui/components";
...

const text = new Signal("Hello, world!");

new Text({
  parent: tui,
  text,
  multiCodePointSupport: true,
  theme: {
    base: crayon.magenta,
  },
  rectangle: { column: 1, row: 1 },
  zIndex: 0,
});

tui.run();
Affichage du texte "Hello, world!" en magenta

Toujours pas très impressionnant, mais on voit globalement ce qui est important avec TUI : comment créer une brique visuelle, comment l'ajouter l'écran en définissant son parent, comment créer un élément qui change de manière réactive via un Signal (si vous faite du Angular ou du SolidJS, ça devrait pas vous surprendre !).

Donc maintenant, il suffit de dérouler pour avoir un affichage d'une bannière ASCII comme pour notre précédent CLI !

Afficher une bannière ASCII

Comme pour le CLI, on va utiliser la librairie figlet pour générer notre bannière. Sauf que figlet va nous fournir une chaîne de caractère multi-lignes, or à l'étape précédente, on utilisait un Text pour l'affichage qui ne supporte qu'une seule ligne de texte. On va donc remplacer notre new Text(...) par un new Label(...) qui gère le multi-ligne.

Comme j'ai prévu dans rendre ce TUI un peu interactif, on va vouloir rester sur des Signal pour la génération de la bannière : à chaque changement de text, mettre à jour un autre Signal qui se nommera asciiText avec la bannière correspondante.

Pour ça c'est facile :

...
const text = new Signal("Hello, world!");
const asciiText = new Signal("");

new Effect(async () => {
  asciiText.value = await figlet(text.value, { font: "Standard" });
});

new Label({
  ...
  text: asciiText,
  ...
});
...

Le résultat :

Affichage de la bannière mais avec le texte en fond transparent alors que le reste est noir

Ça fonctionne, mais c'est moche… Donc on va ajuster un peu le style pour avoir le texte et le fond qui prennent les couleurs par défaut du terminal, chez moi en blanc sur partiellement transparent.

Pour faire ça, il faut supprimer style: crayon.bgBlack, dans le new Tui() et le base: crayon.magenta dans la partie thème du new Label().

Bannière en blanc sur transparent (couleurs par défaut de mon terminal

Note : je fais avec mes goûts / habitudes / préférences, sentez-vous libre de mettre les couleurs / styles qui vous conviennent à vous !

Rendons ça dynamique !

L'idée maintenant serait de pouvoir choisir dynamiquement le texte qu'on affiche sous forme de bannière.

Pour ça il nous faut donc un Input (champs de texte d'une seule ligne), et chaque changement de valeur mettra à jour la bannière. Et après on suit la même logique que pour le label : on le crée (avec des options très similaires), on le positionne (on va en profiter pour déplacer le label plus bas au passage) et on affiche. À noter qu'on a pas besoin d'un listener sur l'input, comme on lui passe une référence vers notre Signal text il sera mis à jour automatiquement !

...
import { Input, Label } from "tui/components";

...

const text = new Signal("Hello, world!");
const asciiText = new Signal("");

...

new Input({
  parent: tui,
  placeholder: "type here",
  theme: {
    base: crayon.bgLightBlack,
    focused: crayon.bgLightGreen,
    active: crayon.bgYellow,
    cursor: { base: crayon.blue },
  },
  text,
  rectangle: { column: 1, row: 1, width: 40 },
  zIndex: 0,
});

new Label({
  ...
  rectangle: { column: 1, row: 3 },
  ...
});
...
Affichage du résultat avec un input mais qui n'écrase pas l'affichage précédent

Comme on peut le voir, à chaque fois qu'on change la valeur de l'input, on voit le texte changer mais le contenu précédent n'est pas écrasé 🫠 Du coup je vais prendre une option de facilité (il y a peut-être mieux) et je vais ajouter des espaces en "bourrage" à la suite du texte pour effacer un éventuel précédent texte 💩

...
new Effect(async () => {
  const whiteSpaces = new Array(100).fill(" ").join("");
  asciiText.value = await figlet(text.value + whiteSpaces, {
    font: "Standard",
  });
});
...
Avec des espaces en bourrage ça fonctionne mieux d'un coup !

Et avec une police dynamique ?

Je me dis que quitte à faire un TUI autant aller au bout et proposer une police dynamique pour le texte en ASCII. Donc prenons la liste des polices gérées par Figlet et faisons en une liste déroulante (Combobox ici). C'est très proche de ce qu'on a fait pour l'Input à part qu'on va en plus donner une liste de valeur possible.

À noter que le composant Combobox garde la valeur sélectionnée par son index dans la liste, donc par facilité d'utilisation, je crée un Signal de type Computed (comprendre Signal qui est dérivé dynamiquement et de manière réactive d'un ou plusieurs autre(s) Signal(s)) pour avoir directement la police actuelle.

...
import { ComboBox, Input, Label } from "tui/components";
...

const AVAILABLE_FONTS = [
  "1Row", "3-D", "3D Diagonal", "3D-ASCII", "3x5", "4Max", "5 Line Oblique", "Standard", "Ghost", "Big", 
  "Block", "Bubble", "Digital", "Ivrit", "Mini", "Script", "Shadow", "Slant", "Small", "Speed", "Tinker-Toy"
];
const DEFAULT_FONT = 7;
const fontIndex = new Signal<number | undefined>(DEFAULT_FONT);
const font = new Computed(() =>
  AVAILABLE_FONTS[fontIndex.value ?? DEFAULT_FONT]
);
...
new Effect(async () => {
  ...
  asciiText.value = await figlet(text.value + whiteSpaces, {
    font: font.value,
  });
});

...

new ComboBox({
  parent: tui,
  items: AVAILABLE_FONTS,
  placeholder: "choose a style",
  selectedItem: fontIndex,
  theme: {
    base: crayon.bgGreen,
    focused: crayon.bgLightGreen,
    active: crayon.bgYellow,
  },
  rectangle: {
    column: 45,
    row: 1,
    height: 1,
    width: 14,
  },
  zIndex: 0,
});
On voit le changement de police et un crash à la fin...

Comme vous pouvez le voir : c'est moche et ça crash... C'est moche parce qu'à la fermeture du menu rien n'est re-rendu pour cacher ce qu'on avait avant 🫠 Ça crash quand on passe sur une police qui prend moins de ligne que la précédente...

Je n'ai pas vu d'option particulière pour gérer ça donc j'ai choisi très simplement d'ajouter des lignes blanches à la suite du texte en ascii pour faire du bourrage à nouveau et combler les lignes "manquantes" (clairement, la lib devrait gérer ça pour moi).

new Effect(async () => {
  ...
  let newAsciiText = await figlet(text.value + whiteSpaces, {
    font: font.value,
  });
  const newAsciiTextRows = newAsciiText.split("\n").length;
  const asciiTextRows = asciiText.value.split("\n").length;
  if (newAsciiTextRows < asciiTextRows) {
    newAsciiText += "\n" +
      new Array(asciiTextRows - newAsciiTextRows).fill(whiteSpaces).join("\n");
  }
  asciiText.value = newAsciiText;
});
Avec un bourrage pour les lignes "manquantes" ça fonctionne

Pour le fait que le menu soit toujours plus ou moins visible parce qu'il manque un re-rendu, c'est dû au fait que j'ai choisi d'avoir un fond transparent... Donc c'est à noter : si vous voulez avoir un fond transparent, c'est compliqué de faire un TUI propre... On va donc mettre un fond noir un peu partout et tout devrait rentrer dans l'ordre. Au passage on peut aussi éviter d'ajouter des espaces en bourrage à la suite du texte pour effacer le texte précédent.

...
const tui = new Tui({
  ...
  style: crayon.bgBlack,
});
...
new Effect(async () => {
  ...
  let newAsciiText = await figlet(text.value, { font: font.value });
  ...
});
...
new Label({
  ...
  theme: {
    base: crayon.bgBlack,
  },
  ...
});
...
Avec un fond sur nos différents composant ça fonctionne mieux

Il reste en fait un seul dernier problème : la liste déroulante passe derrière le Label, pour ça il faut jouer avec le paramètre zIndex pour faire passer la liste déroulante au premier plan (comme on ferait en CSS finalement).

...
new ComboBox({
    ...
  zIndex: 1000,
});
...
Avec le zIndex c'est parfait

Avec un vrai layout ?

Jusque-là, on plaçait manuellement nos éléments à l'écran en les positionnant à la ligne/colonne près, avec des tailles fixes. Or avec TUI, on peut très bien utiliser des composants de layout tout près qui permettent de se faciliter la vie.

Il existe plusieurs layouts : horizontal, vertical et grid.

On voudrait garder un positionnement similaire à ce qu'on a : l'input en haut à gauche, le combobox en haut à droite et le maximum de place pour le label contenant la bannière ASCII.

Déjà on va ajouter une entrée dans le deno.json pour accéder aux layouts (comme on avait fait pour les composants) :

{
  ...
  "imports": {
    ...
    "tui/layout": "https://deno.land/x/tui@2.1.11/src/layout/mod.ts",
    ...
  }
}

Ensuite on crée notre layout en lui donnant la référence du Rectangle de l'instance de Tui pour que le layout prenne toute la place, et on définit un pattern pour positionner nos éléments :

...
import { GridLayout } from "tui/layout";
...
const layout = new GridLayout({
  rectangle: tui.rectangle,
  pattern: [
    ["input", "combobox"],
    ["result", "result"],
    ["result", "result"],
    ["result", "result"],
    ["result", "result"],
    ["result", "result"],
    ["result", "result"],
  ],
  gapX: 1,
  gapY: 1,
});
...

L'idée ici c'est d'indiquer qu'on veut que la ligne avec input et combobox prenne 6 fois moins de place en hauteur que result.

Ensuite on va créer des instances de Rectangle, InputRectangle et LabelRectangle qui vont bien à partir du rectangle que produit le layout pour chaque élément, car contrairement à ce que la documentation dit, on ne peut pas utiliser en direct ce que donne le layout, car on a un problème de compatibilité de type… Au passage on en profite pour forcer une hauteur à 1 pour le combobox.

...
const inputRectangle = new Computed((): InputRectangle => {
  const rect = layout.element("input");
  return ({
    column: rect.value.column,
    row: rect.value.row,
    width: rect.value.width,
  });
});
const comboboxRectangle = new Computed((): Rectangle => {
  const rect = layout.element("combobox");
  return ({
    column: rect.value.column,
    row: rect.value.row,
    width: rect.value.width,
    height: 1,
  });
});
const labelRectangle = new Computed((): LabelRectangle => {
  const rect = layout.element("result");
  return ({
    column: rect.value.column,
    row: rect.value.row,
    width: rect.value.width,
    height: rect.value.height,
  });
});
...

Ensuite on va utiliser les différents rectangles qu'on a créés sur nos composants :

...
new Input({
  parent: tui,
  placeholder: "type here",
  theme: {
    base: crayon.bgLightBlack,
    focused: crayon.bgLightGreen,
    active: crayon.bgYellow,
    cursor: { base: crayon.blue },
  },
  text,
  rectangle: inputRectangle,
  zIndex: 0,
});

new ComboBox({
  parent: tui,
  items: AVAILABLE_FONTS,
  placeholder: "choose a style",
  selectedItem: fontIndex,
  theme: {
    base: crayon.bgGreen,
    focused: crayon.bgLightGreen,
    active: crayon.bgYellow,
  },
  rectangle: comboboxRectangle,
  zIndex: 1000,
});

new Label({
  parent: tui,
  text: asciiText,
  multiCodePointSupport: true,
  theme: {
    base: crayon.bgBlack,
  },
  rectangle: labelRectangle,
  zIndex: 0,
  overwriteRectangle: true,
});
...
Avec un grid layout

Avec un Grid Layout ça ne semble pas tellement différent visuellement, je suis d'accord. Par contre ça ouvre la porte à un layout plus complexe, assez facilement.

Conclusion

Je m'arrête là pour cette démo de TUI en Deno. J'ai choisi de le faire avec la lib TUI car bien qu'écrite par un indépendant elle est écrite en pure-typescript pour Deno ça me paraissait un bon choix.

Cette librairie n'est pas parfaite, on peut surement trouver mieux en allant chercher dans les dépendances NPM, mais cette lib fonctionne bien, et je pense qu'il manquerait juste un peu d'effort pour en faire un lib vraiment complète !

En tout cas, j'ai bien relevé le défi de Thierry : j'ai créé un TUI avec Deno ! 😎 Je n'avais jamais créé de TUI et je pense que je le referais à l'occasion vu comment c'est finalement assez facile et pratique pour certains trucs ! 🤓

Source :

Crédit photo : Générée via Mistral AI avec le prompt suivant :

A cozy, magical developer's workshop inspired by Studio Ghibli, blending retro-futuristic and cyberpunk-light elements. The scene features a friendly anthropomorphic fox character (wearing glasses and a hoodie) sitting at a vintage CRT terminal, coding a TUI (Terminal User Interface) in Deno. The terminal screen displays an ASCII art banner dynamically changing fonts, an input field, and a combobox, just like in a Deno TUI demo. Beside the fox, a small, cute robot assistant—made of retro tech parts like gears, wires, and a tiny screen displaying a Deno logo—holds a floppy disk and points at the terminal with excitement. The workshop is filled with warm, soft lighting, floating holographic Deno logos (turquoise triangles), and retro tech like floppy disks, tangled cables, and a glowing neon desk lamp. The background includes lush greenery, floating books, and a starry night sky visible through a large window, creating a dreamy contrast between nature and technology. The color palette is a harmonious mix of pastel pinks, blues, and greens, with vibrant turquoise and neon accents highlighting the tech elements. The atmosphere is whimsical, creative, and inviting, evoking both the charm of Studio Ghibli and the nostalgia of 80s/90s cyberpunk. The fox is focused and happy, surrounded by tiny magical sparks and glowing code snippets floating in the air. The robot assistant has a playful, helpful expression, adding a touch of humor and warmth. The overall style is semi-realistic with a soft anime aesthetic, highly detailed, and visually rich, balancing organic and technological elements seamlessly.