Aller au contenu
Développement medium

Textual : créer des interfaces terminal en Python

46 min de lecture

Tu veux afficher des tableaux, des boutons ou des formulaires directement dans ton terminal, sans navigateur ni interface graphique ? Textual est un framework Python qui transforme le terminal en véritable interface utilisateur interactive. Ce guide te montre comment construire un moniteur de services complet, de zéro jusqu’à une application aboutie. Chaque section ajoute une fonctionnalité : à la fin, tu auras une app opérationnelle. Prérequis : Python 3.9+ et uv installé.

logo textual

Textual est un framework Python open source (34 000+ stars sur GitHub) développé par l’équipe de Rich (la bibliothèque de formatage terminal). Il permet de créer des TUI (Text User Interface — des interfaces utilisateur qui fonctionnent dans le terminal) avec des composants modernes : boutons, tableaux, champs de saisie, onglets, arbres de fichiers, et bien plus.

Pense à Textual comme un mini-navigateur web intégré au terminal. Tout comme une page web utilise HTML pour la structure et CSS pour le style, une application Textual utilise des widgets Python pour la structure et du Textual CSS (TCSS) pour le style. La différence : tout tourne dans le terminal, y compris en SSH.

CritèreRichTextual
TypeBibliothèque d’affichageFramework d’application
InteractivitéAucune (affichage seul)Complète (clavier, souris, events)
StructureAppels ponctuels (print)Application avec boucle d’événements
Cas d’usageLogs colorés, tableaux statiquesDashboards, outils CLI interactifs
CSSNonOui (Textual CSS)

Rich reste utile pour le formatage simple. Textual s’appuie d’ailleurs sur Rich en interne pour le rendu. Choisis Rich pour embellir des sorties ponctuelles, Textual pour construire une application interactive.

Plusieurs projets notables s’appuient sur Textual en production :

  • Toad : outil de migration de bases de données
  • Posting : client API (comme Postman, mais dans le terminal)
  • Toolong : visualiseur de logs
  • Harlequin : client de bases de données SQL
  • Dolphie : monitoring MySQL/MariaDB en temps réel
  • Memray : profileur mémoire Python (Bloomberg)

Avant de commencer, assure-toi d’avoir :

  • Python 3.9 ou supérieur (python3 --version)
  • uv installé pour la gestion des packages (uv --version)
  • Un terminal moderne (la plupart fonctionnent : GNOME Terminal, iTerm2, Windows Terminal, Alacritty)
  • Des notions de base en Python : classes, fonctions, décorateurs

Crée un projet dédié pour suivre ce guide :

Fenêtre de terminal
mkdir textual-lab && cd textual-lab
uv venv
source .venv/bin/activate
uv pip install textual textual-dev

Le paquet textual contient le framework. Le paquet textual-dev fournit des outils de développement (console de debug, rechargement à chaud, serveur web de prévisualisation).

Vérification :

Fenêtre de terminal
python -c "import textual; print(textual.__version__)"

Tu dois voir 8.0.0 (ou supérieur). Si tu obtiens une erreur ModuleNotFoundError, vérifie que ton venv est bien activé.

Tout au long de ce guide, tu vas construire un seul fichier monitor.py qui s’enrichit à chaque section. L’application finale est un moniteur de services avec :

  • Un tableau de services avec statut en couleur
  • Une sidebar de navigation et un panneau d’alertes
  • Un style CSS professionnel avec thème clair/sombre
  • Un champ de recherche pour filtrer les services en temps réel
  • Une barre de statut réactive qui se met à jour automatiquement
  • Un écran de confirmation pour redémarrer un service
  • Un worker asynchrone pour rafraîchir les données en arrière-plan

Chaque section enseigne un concept et l’applique immédiatement au moniteur.

Chaque application Textual est une classe qui hérite de App. La méthode compose() déclare les widgets à afficher, comme un template HTML. On commence par le minimum viable :

monitor.py
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
class ServiceMonitor(App):
"""Moniteur de services."""
BINDINGS = [("q", "quit", "Quitter")]
def compose(self) -> ComposeResult:
yield Header()
yield Static("[bold]Moniteur de services[/]\n\nEn construction...")
yield Footer()
if __name__ == "__main__":
ServiceMonitor().run()

Lance l’application :

Fenêtre de terminal
python monitor.py

moniteur de services minimal

Tu verras un en-tête, le message « Moniteur de services », et un pied de page avec la touche q pour quitter. Quelques points importants :

  • compose() retourne les widgets avec yield (c’est un générateur Python)
  • BINDINGS associe des touches clavier à des actions (ici q → quitter)
  • Header et Footer sont des widgets intégrés qui s’adaptent automatiquement
  • Static affiche du texte (supporte le formatage Rich avec [bold], [red], etc.)

Pour quitter, appuie sur q ou Ctrl+Q. C’est le squelette — on va le faire évoluer.

Textual fournit plus de 30 widgets prêts à l’emploi. Voici les plus utiles :

WidgetDescriptionImport
HeaderBarre de titre de l’apptextual.widgets
FooterBarre d’état avec raccourcistextual.widgets
StaticTexte statique (supporte Rich)textual.widgets
LabelTexte courttextual.widgets
ButtonBouton cliquable avec variantestextual.widgets
InputChamp de saisie textetextual.widgets
DataTableTableau de données triabletextual.widgets
RichLogJournal de messages scrollabletextual.widgets
SelectMenu déroulanttextual.widgets
SwitchInterrupteur on/offtextual.widgets
ProgressBarBarre de progressiontextual.widgets
TreeArborescence dépliabletextual.widgets
DirectoryTreeExplorateur de fichierstextual.widgets
TextAreaÉditeur de texte multilignetextual.widgets
TabbedContentConteneur à ongletstextual.widgets

Tous les widgets s’importent depuis textual.widgets. La liste complète est disponible dans la widget gallery officielle.

Pour afficher les services, on remplace le Static par un DataTable — un tableau interactif avec colonnes et tri. La méthode on_mount() est appelée quand l’app est prête : c’est l’endroit idéal pour remplir le tableau.

Remplace le contenu de monitor.py :

monitor.py
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, DataTable
SERVICES = [
("nginx", 80, "running"),
("postgresql", 5432, "running"),
("redis", 6379, "running"),
("prometheus", 9090, "stopped"),
("grafana", 3000, "running"),
("alertmanager", 9093, "stopped"),
]
class ServiceMonitor(App):
"""Moniteur de services."""
BINDINGS = [("q", "quit", "Quitter")]
def compose(self) -> ComposeResult:
yield Header()
yield DataTable()
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("Service", "Port", "Status")
for name, port, status in SERVICES:
marker = (
"[green]● running[/]" if status == "running"
else "[red]● stopped[/]"
)
table.add_row(name, str(port), marker)
if __name__ == "__main__":
ServiceMonitor().run()

moniteur de services minimal

Lance-le et tu devrais voir un vrai tableau avec tes 6 services, chacun avec un indicateur coloré vert ou rouge. Tu peux naviguer dans les lignes avec les flèches. Ce qu’on a ajouté :

  • DataTable : tableau interactif avec édition, tri et sélection de lignes
  • on_mount() : appelé une fois l’interface prête, parfait pour charger les données
  • query_one(DataTable) : récupère une référence au widget (comme document.querySelector() en JS)
  • Formatage Rich : [green]● running[/] colore le texte directement dans le tableau

Par défaut, les widgets s’empilent verticalement (de haut en bas). Pour organiser l’interface autrement, Textual propose des conteneurs (containers) importés depuis textual.containers :

ConteneurDisposition
VerticalDe haut en bas (par défaut)
HorizontalDe gauche à droite
GridGrille lignes/colonnes
ContainerConteneur générique

Utilise le mot-clé with dans compose() pour imbriquer les widgets dans un conteneur.

On réorganise le moniteur avec un layout en deux colonnes : une sidebar à gauche et le contenu principal à droite (tableau + alertes).

Les conteneurs seuls ne suffisent pas : il faut un minimum de CSS de layout pour répartir l’espace. Sans lui, le Static de la sidebar prendrait toute la largeur et le tableau serait invisible. On ajoute donc un bloc CSS qui fixe les dimensions — la mise en forme visuelle (couleurs, bordures) viendra à la section suivante.

Remplace le contenu de monitor.py :

monitor.py
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Footer, DataTable, Static
SERVICES = [
("nginx", 80, "running"),
("postgresql", 5432, "running"),
("redis", 6379, "running"),
("prometheus", 9090, "stopped"),
("grafana", 3000, "running"),
("alertmanager", 9093, "stopped"),
]
class ServiceMonitor(App):
"""Moniteur de services."""
CSS = """
#sidebar {
width: 25;
}
#main {
width: 1fr;
}
DataTable {
height: 1fr;
}
#alerts {
height: auto;
max-height: 8;
}
"""
BINDINGS = [("q", "quit", "Quitter")]
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
yield Static(
"[bold]Navigation[/]\n\n• Services\n• Alertes\n• Logs",
id="sidebar",
)
with Vertical(id="main"):
yield DataTable()
yield Static(
"[bold]Alertes[/]\n\n"
"[red]CRIT[/] disk 94% on node-2\n"
"[yellow]WARN[/] memory pressure node-1\n"
"[green]OK[/] backup completed",
id="alerts",
)
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("Service", "Port", "Status")
for name, port, status in SERVICES:
marker = (
"[green]● running[/]" if status == "running"
else "[red]● stopped[/]"
)
table.add_row(name, str(port), marker)
if __name__ == "__main__":
ServiceMonitor().run()

Lance-le et tu verras la sidebar (25 colonnes) à gauche, le tableau en haut à droite, et les alertes en bas à droite. Ce qu’on a ajouté :

  • Horizontal : dispose la sidebar et le contenu côte à côte
  • Vertical (id=“main”) : empile le tableau et les alertes dans la colonne de droite
  • with : syntaxe de contexte pour imbriquer les widgets dans un conteneur
  • CSS de layout : width: 25 fixe la sidebar, width: 1fr donne le reste au contenu principal, height: 1fr fait grandir le tableau pour occuper l’espace disponible
  • id="sidebar" / id="alerts" : identifiants pour cibler les widgets en CSS

L’interface est fonctionnelle, mais brute. La section suivante va la transformer visuellement.

Textual utilise un dialecte CSS appelé TCSS (Textual CSS). La syntaxe est très proche du CSS web, mais adaptée au terminal. On va enrichir le moniteur avec des bordures, couleurs, pseudo-classes et effets visuels avancés.

On ajoute un CSS complet avec variables de thème, pseudo-classes pour le thème clair/sombre, survol interactif, titre de bordure sur le panneau d’alertes, et texte clignotant via Rich markup. Remplace le contenu de monitor.py :

monitor.py
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Footer, DataTable, Static
SERVICES = [
("nginx", 80, "running"),
("postgresql", 5432, "running"),
("redis", 6379, "running"),
("prometheus", 9090, "stopped"),
("grafana", 3000, "running"),
("alertmanager", 9093, "stopped"),
]
class ServiceMonitor(App):
"""Moniteur de services."""
CSS = """
/* --- Layout de base --- */
#sidebar {
width: 25;
background: $panel;
padding: 1 2;
border-right: tall $primary;
text-style: bold;
}
#main {
width: 1fr;
}
DataTable {
height: 1fr;
}
/* --- Panneau d'alertes avec bordure titrée --- */
#alerts {
height: auto;
max-height: 8;
background: $surface;
border: solid $warning;
border-title-color: $warning;
border-title-style: bold;
padding: 1 2;
margin: 1 0 0 0;
}
/* --- Pseudo-classes :hover (survol souris) --- */
#sidebar:hover {
background: $primary 20%;
}
#alerts:hover {
tint: $warning 10%;
}
/* --- Pseudo-classes :dark / :light (thème) --- */
#sidebar:light {
background: #e8e8f0;
color: #333333;
}
"""
BINDINGS = [
("q", "quit", "Quitter"),
("d", "toggle_dark", "Thème"),
]
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
yield Static(
"[bold]Navigation[/]\n\n"
"• Services\n• [blink]Alertes[/blink]\n• Logs",
id="sidebar",
)
with Vertical(id="main"):
yield DataTable()
alerts = Static(
"[red bold]CRIT[/] disk 94% on node-2\n"
"[yellow]WARN[/] memory pressure node-1\n"
"[green]OK[/] backup completed",
id="alerts",
)
alerts.border_title = "Alertes système"
yield alerts
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("Service", "Port", "Status")
for name, port, status in SERVICES:
marker = (
"[green]● running[/]" if status == "running"
else "[red]● stopped[/]"
)
table.add_row(name, str(port), marker)
if __name__ == "__main__":
ServiceMonitor().run()

Lance-le et appuie sur d pour basculer entre thème clair et sombre. Passe la souris sur la sidebar ou les alertes pour voir les effets de survol. Le mot « Alertes » dans la sidebar clignote grâce au markup Rich [blink]...[/blink].

Technique CSSCe qu’elle faitPropriétés utilisées
Variables de thèmeCouleurs adaptées clair/sombre$panel, $surface, $primary, $warning
text-styleMet le texte en gras dans la sidebartext-style: bold
:hoverChange le fond au survol de la sourisbackground: $primary 20%, tint: $warning 10%
:dark / :lightRègles spécifiques au thème actif#sidebar:light { background: #e8e8f0; }
tintApplique une couche de couleur semi-transparentetint: $warning 10%
Bordure titréeAffiche « Alertes système » dans la bordureborder-title-color, border-title-style
[blink]Texte clignotant (markup Rich, pas CSS)[blink]Alertes[/blink]

Les pseudo-classes appliquent des styles conditionnels sans écrire de Python. Textual en fournit une douzaine :

Pseudo-classeS’active quand…Cas d’usage
:hoverLa souris survole le widgetMise en surbrillance interactive
:focusLe widget a le focus clavierMettre en valeur le champ actif
:darkLe thème sombre est actifCouleurs spécifiques au mode sombre
:lightLe thème clair est actifCouleurs spécifiques au mode clair
:disabledLe widget est désactivéGriser un bouton inactif
:even / :oddPosition paire/impaire parmi les siblingsZébrage de lignes
:first-childPremier enfant de son conteneurStyle spécial pour le 1er élément
:focus-withinUn enfant a le focusEncadrer un formulaire actif

Exemple concret : griser un bouton désactivé tout en gardant le hover pour les autres :

Button:disabled {
opacity: 50%;
text-style: strike;
}
Button:hover {
background: $accent;
}

La propriété text-style accepte une ou plusieurs valeurs combinées :

ValeurEffet
boldTexte en gras
italicTexte en italique
reverseInverse les couleurs texte/fond
strikeTexte barré
underlineTexte souligné
bold italicCombinaison : gras + italique
#sidebar { text-style: bold; }
.deprecated { text-style: strike italic; }

Deux propriétés permettent de simuler la transparence dans le terminal :

tint applique une couche de couleur semi-transparente par-dessus le widget entier (contenu + fond). L’alpha contrôle l’intensité :

/* Alerte : teinte rouge légère au survol */
#alerts:hover { tint: red 15%; }
/* Widget désactivé : teinte grise */
.disabled { tint: grey 40%; }

opacity rend le widget plus ou moins visible en le mélangeant avec le fond. Utile pour indiquer un état inactif :

/* Widget semi-transparent */
#sidebar:disabled { opacity: 50%; }
/* Texte seul semi-transparent (fond intact) */
.muted { text-opacity: 60%; }

La propriété border-title (définie en Python) s’affiche dans le cadre de la bordure. Le CSS contrôle la couleur, le style et l’alignement du titre :

#alerts {
border: solid $warning;
border-title-color: $warning;
border-title-style: bold;
border-title-align: center; /* left (défaut), center, right */
}

En Python, assigne le titre avant ou après le yield :

alerts = Static("...", id="alerts")
alerts.border_title = "Alertes système"

Le titre apparaît directement dans la ligne supérieure de la bordure : ┍ Alertes système ┑.

Il y a deux façons de déclarer le CSS :

CSS inline (variable de classe) — c’est ce qu’on utilise dans le moniteur avec CSS = """...""" dans la classe App. Pratique pour les petits projets ou les prototypes.

CSS externe (fichier .tcss) — pour les projets plus ambitieux, extrait le CSS dans un fichier séparé. L’avantage majeur : le mode --dev recharge le style à chaud.

class ServiceMonitor(App):
CSS_PATH = "monitor.tcss"

Si tu lances l’application avec textual run --dev monitor.py, et que tu modifies le fichier .tcss : l’interface se met à jour instantanément sans redémarrer.

Les classes CSS peuvent être ajoutées ou retirées à la volée depuis le code Python. C’est le mécanisme recommandé pour changer l’apparence d’un widget selon l’état de l’application :

# Ajouter/retirer une classe CSS
widget.add_class("critical")
widget.remove_class("critical")
# Toggle : ajoute si absente, retire si présente
widget.toggle_class("selected")
# Conditionnel : ajoute ou retire selon un booléen
widget.set_class(cpu > 90, "critical")

Le CSS correspondant :

.critical {
background: $error;
text-style: bold;
tint: red 10%;
}

C’est la méthode recommandée pour les changements d’état visuels : pas de manipulation directe des styles en Python, tout passe par les classes CSS.

SélecteurCibleExemple dans le moniteur
DataTablePar type (classe Python)DataTable { height: 1fr; }
#sidebarPar ID (unique)#sidebar { width: 25; }
.criticalPar classe CSS.critical { tint: red 10%; }
#sidebar:hoverPseudo-classe (état)#sidebar:hover { background: $primary 20%; }
#main DataTableDescendant#main DataTable { border: solid green; }
#main > DataTableEnfant direct#main > DataTable { margin: 1; }
*Universel (tous les widgets)* { outline: solid red; } (debug)
PropriétéValeursDescription
width / heightauto, 100%, 1fr, 20Dimensions
background$panel, red, #ff0000Couleur de fond
colorauto, white, $textCouleur du texte
bordersolid $primary, round green, tall $primaryBordure
padding / margin1, 1 2, 1 2 3 4Espacement
text-stylebold, italic, reverse, strike, underlineFormat du texte
opacity / text-opacity0% à 100%, 0.0 à 1.0Transparence (widget ou texte seul)
tintred 20%, rgba(0, 200, 0, 0.3)Couche de couleur semi-transparente
content-aligncenter middle, left topAlignement du contenu
layoutvertical, horizontal, gridType de disposition
grid-size4 3 (colonnes lignes)Taille de la grille
docktop, bottom, left, rightAncrage
displayblock, noneVisibilité

Les variables préfixées par $ (comme $panel, $primary, $text) s’adaptent automatiquement au thème actif (clair ou sombre).

Textual utilise un système d’événements asynchrone. Quand l’utilisateur clique sur un bouton, tape du texte ou appuie sur une touche, un message est envoyé au widget puis remonte dans l’arbre du DOM (c’est le bubbling, comme dans un navigateur).

Il existe deux façons de réagir aux événements :

Nomme ta méthode on_<widget>_<message>. Textual l’appelle automatiquement :

def on_button_pressed(self, event: Button.Pressed) -> None:
"""Appelé quand n'importe quel bouton est cliqué."""
self.notify(f"Bouton {event.button.id} cliqué !")
ÉvénementDéclenché quand…
Button.PressedUn bouton est cliqué
Input.ChangedLe texte d’un champ change
Input.SubmittedL’utilisateur valide (Entrée)
DataTable.RowSelectedUne ligne du tableau est sélectionnée
KeyUne touche est pressée
MountLe widget vient d’être ajouté au DOM

Les bindings associent des touches à des actions (méthodes préfixées par action_) :

BINDINGS = [
("q", "quit", "Quitter"),
("d", "toggle_dark", "Thème"),
("r", "refresh", "Rafraîchir"),
]
def action_refresh(self) -> None:
"""Action déclenchée par la touche 'r'."""
self.notify("Données rafraîchies !")

Le widget Footer affiche automatiquement les bindings disponibles.

On ajoute un Input pour filtrer les services en temps réel, et un binding r pour rafraîchir. Voici les modifications à apporter à monitor.py :

Nouveaux imports :

from textual.widgets import Header, Footer, DataTable, Static, Input

Dans le CSS, ajouter :

Input {
margin: 0 0 1 0;
}

Dans les BINDINGS, ajouter :

("r", "refresh", "Rafraîchir"),

Dans compose(), ajouter l’Input avant le DataTable :

yield Input(placeholder="Filtrer les services...", id="filter")

Extraire le chargement dans une méthode et ajouter les handlers d’événements :

def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("Service", "Port", "Status")
self.load_services()
def load_services(self, filtre: str = "") -> None:
table = self.query_one(DataTable)
table.clear()
for name, port, status in SERVICES:
if filtre and filtre.lower() not in name.lower():
continue
marker = (
"[green]● running[/]" if status == "running"
else "[red]● stopped[/]"
)
table.add_row(name, str(port), marker)
def on_input_changed(self, event: Input.Changed) -> None:
"""Filtre les services à chaque frappe."""
self.load_services(filtre=event.value)
def action_refresh(self) -> None:
"""Rafraîchit la liste des services."""
filtre = self.query_one(Input).value
self.load_services(filtre=filtre)
self.notify("Services rafraîchis !")

Une fois ces modifications intégrées, tape « nginx » dans le champ de filtre : seul nginx reste affiché. Efface le texte : tous les services réapparaissent. Appuie sur r : une notification confirme le rafraîchissement.

Le fichier complet avec toutes ces modifications est disponible dans la section Application finale. Ce qu’on a ajouté :

  • Input : champ de saisie avec placeholder
  • on_input_changed() : réagit à chaque frappe pour filtrer en temps réel
  • load_services(filtre) : méthode réutilisable avec filtre optionnel
  • action_refresh() : binding r pour rafraîchir + notification

La réactivité est l’un des atouts majeurs de Textual. Un attribut réactif (reactive) déclenche automatiquement des actions quand sa valeur change : rafraîchir l’affichage, exécuter du code, mettre à jour d’autres widgets.

from textual.reactive import reactive
class MonApp(App):
count: reactive[int] = reactive(0)
def watch_count(self, count: int) -> None:
"""Appelé automatiquement quand 'count' change."""
self.query_one("#display", Static).update(f"Valeur : {count}")

Trois mécanismes réactifs sont disponibles :

MécanismePréfixeRôle
Watchwatch_<attribut>Exécuter du code quand la valeur change
Validatevalidate_<attribut>Vérifier/transformer la valeur avant affectation
Computecompute_<attribut>Calculer la valeur à partir d’autres attributs

Par défaut, modifier un attribut réactif redessine le widget automatiquement. Tu peux désactiver ce comportement avec reactive(0, repaint=False).

On ajoute une barre de statut en bas qui affiche le nombre de services et l’heure du dernier rafraîchissement. Ces informations se mettent à jour automatiquement grâce à reactive.

Nouveaux imports :

from datetime import datetime
from textual.reactive import reactive

Dans le CSS, ajouter :

#status-bar {
dock: bottom;
height: 1;
background: $primary;
color: $text;
padding: 0 2;
}

Ajouter les attributs réactifs à la classe :

last_update: reactive[str] = reactive("")
service_count: reactive[int] = reactive(0)

Dans compose(), ajouter avant le Footer :

yield Static(id="status-bar")

Modifier load_services() pour alimenter les réactifs :

def load_services(self, filtre: str = "") -> None:
table = self.query_one(DataTable)
table.clear()
count = 0
for name, port, status in SERVICES:
if filtre and filtre.lower() not in name.lower():
continue
marker = (
"[green]● running[/]" if status == "running"
else "[red]● stopped[/]"
)
table.add_row(name, str(port), marker)
count += 1
self.service_count = count
self.last_update = datetime.now().strftime("%H:%M:%S")

Ajouter le watcher :

def watch_last_update(self, value: str) -> None:
"""Met à jour la barre de statut quand last_update change."""
if value:
self.query_one("#status-bar", Static).update(
f"Services : {self.service_count} | Mis à jour : {value}"
)

Résultat attendu : la barre de statut en bas affiche « Services : 6 | Mis à jour : 14:32:05 ». Filtre les services : le compteur se met à jour automatiquement.

Toutes ces modifications s’assemblent dans la section Application finale. Ce qu’on a ajouté :

  • reactive[str] et reactive[int] : attributs qui déclenchent un callback à chaque modification
  • watch_last_update() : exécuté automatiquement quand last_update change
  • dock: bottom : ancre la barre de statut en bas de l’écran

Les screens (écrans) permettent de créer des vues multiples dans ton application. Un écran est un conteneur de widgets qui occupe tout le terminal. Tu peux empiler les écrans (stack), ce qui est pratique pour des dialogues de confirmation ou des pages de paramètres.

Quand l’utilisateur sélectionne un service dans le tableau, on affiche un écran de confirmation pour le redémarrer. Le résultat (oui/non) est retourné via un callback.

Nouveaux imports :

from textual import on
from textual.screen import Screen
from textual.widgets import Button, Label

Ajouter la classe ConfirmRestart avant ServiceMonitor :

class ConfirmRestart(Screen[bool]):
"""Écran de confirmation de redémarrage."""
CSS = """
ConfirmRestart { align: center middle; }
#dialog {
width: 50;
height: 11;
border: thick $primary;
background: $surface;
padding: 1 2;
}
#question {
width: 100%;
content-align: center middle;
margin: 1 0;
}
.dialog-buttons {
width: 100%;
height: auto;
align: center middle;
}
Button { margin: 0 2; }
"""
def __init__(self, service_name: str) -> None:
self.service_name = service_name
super().__init__()
def compose(self) -> ComposeResult:
with Vertical(id="dialog"):
yield Label(
f"Redémarrer [bold]{self.service_name}[/bold] ?",
id="question",
)
with Horizontal(classes="dialog-buttons"):
yield Button("Confirmer", id="yes", variant="success")
yield Button("Annuler", id="no", variant="error")
@on(Button.Pressed, "#yes")
def confirm(self) -> None:
self.dismiss(True)
@on(Button.Pressed, "#no")
def cancel(self) -> None:
self.dismiss(False)

Ajouter les handlers dans ServiceMonitor :

def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Ouvre la confirmation quand on sélectionne un service."""
row = event.data_table.get_row(event.row_key)
service_name = str(row[0])
self.push_screen(
ConfirmRestart(service_name), callback=self.handle_restart
)
def handle_restart(self, confirmed: bool) -> None:
"""Callback appelé quand l'écran de confirmation se ferme."""
if confirmed:
self.notify("Service redémarré !", severity="information")
else:
self.notify("Redémarrage annulé.", severity="warning")

Résultat attendu : navigue sur un service et appuie sur Entrée — le dialogue de confirmation apparaît. Clique « Confirmer » ou « Annuler ».

Retrouve le code complet dans la section Application finale. Points clés des screens :

  • Screen[bool] : le type entre crochets indique le type de retour de dismiss()
  • push_screen() empile un écran par-dessus l’actuel
  • dismiss(valeur) ferme l’écran et retourne la valeur au callback
  • @on(Button.Pressed, "#yes") : le décorateur cible un bouton précis par sélecteur CSS

Pour des applications avec plusieurs vues principales (dashboard, paramètres, aide), utilise les modes plutôt que l’empilement :

class MonApp(App):
BINDINGS = [
("d", "switch_mode('dashboard')", "Dashboard"),
("s", "switch_mode('settings')", "Paramètres"),
]
MODES = {
"dashboard": DashboardScreen,
"settings": SettingsScreen,
}
DEFAULT_MODE = "dashboard"

Quand tu fais un appel réseau, lis un fichier volumineux ou exécutes un calcul long, il ne faut jamais bloquer la boucle d’événements. Les workers exécutent ces tâches en arrière-plan pendant que l’interface reste réactive.

Ajouter un rafraîchissement asynchrone au moniteur

Section intitulée « Ajouter un rafraîchissement asynchrone au moniteur »

On transforme le binding r pour qu’il lance un worker asynchrone qui simule un appel réseau (1 seconde de délai) :

Nouveaux imports :

import asyncio
from textual import work

Remplacer action_refresh() et ajouter le worker :

@work(exclusive=True)
async def refresh_services(self) -> None:
"""Rafraîchit les services en arrière-plan."""
self.query_one("#status-bar", Static).update("Rafraîchissement...")
await asyncio.sleep(1) # Simule un appel réseau
filtre = self.query_one(Input).value
self.load_services(filtre=filtre)
self.notify("Services rafraîchis !")
def action_refresh(self) -> None:
self.refresh_services()

Résultat attendu : appuie sur r — la barre de statut affiche « Rafraîchissement… » pendant 1 seconde, puis les données se rechargent avec une notification. Pendant ce temps, l’interface reste interactive — tu peux taper dans le champ de filtre.

Le fichier monitor.py complet intégrant toutes ces étapes est dans la section Application finale.

Le décorateur @work transforme une coroutine en worker. L’option exclusive=True annule le worker précédent si un nouveau est lancé (utile pour les recherches en temps réel où chaque frappe relance la requête).

Les workers supportent aussi les threads pour le code synchrone bloquant :

@work(thread=True)
def load_big_file(self, path: str) -> None:
"""Exécuté dans un thread séparé."""
with open(path) as f:
data = f.read()
# Depuis un thread, utilise call_from_thread pour modifier l'UI
self.app.call_from_thread(self.update_ui, data)

Voici le monitor.py complet avec toutes les fonctionnalités construites au fil des sections. C’est le résultat final — un moniteur de services fonctionnel avec tableau interactif, filtrage en temps réel, barre de statut réactive, confirmation de redémarrage et rafraîchissement asynchrone :

monitor.py
"""Moniteur de services — application Textual complète."""
import asyncio
from datetime import datetime
from textual import on, work
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import (
Button,
DataTable,
Footer,
Header,
Input,
Label,
Static,
)
SERVICES = [
("nginx", 80, "running"),
("postgresql", 5432, "running"),
("redis", 6379, "running"),
("prometheus", 9090, "stopped"),
("grafana", 3000, "running"),
("alertmanager", 9093, "stopped"),
]
class ConfirmRestart(Screen[bool]):
"""Écran de confirmation de redémarrage."""
CSS = """
ConfirmRestart { align: center middle; }
#dialog {
width: 50;
height: 11;
border: thick $primary;
background: $surface;
padding: 1 2;
}
#question {
width: 100%;
content-align: center middle;
margin: 1 0;
}
.dialog-buttons {
width: 100%;
height: auto;
align: center middle;
}
Button { margin: 0 2; }
"""
def __init__(self, service_name: str) -> None:
self.service_name = service_name
super().__init__()
def compose(self) -> ComposeResult:
with Vertical(id="dialog"):
yield Label(
f"Redémarrer [bold]{self.service_name}[/bold] ?",
id="question",
)
with Horizontal(classes="dialog-buttons"):
yield Button("Confirmer", id="yes", variant="success")
yield Button("Annuler", id="no", variant="error")
@on(Button.Pressed, "#yes")
def confirm(self) -> None:
self.dismiss(True)
@on(Button.Pressed, "#no")
def cancel(self) -> None:
self.dismiss(False)
class ServiceMonitor(App):
"""Moniteur de services."""
TITLE = "Service Monitor"
CSS = """
#sidebar {
width: 25;
background: $panel;
padding: 1 2;
border-right: tall $primary;
text-style: bold;
}
#sidebar:hover {
background: $primary 20%;
}
#sidebar:light {
background: #e8e8f0;
color: #333333;
}
#main {
width: 1fr;
}
Input {
margin: 0 0 1 0;
}
DataTable {
height: 1fr;
}
#alerts {
height: auto;
max-height: 8;
background: $surface;
border: solid $warning;
border-title-color: $warning;
border-title-style: bold;
padding: 1 2;
margin: 1 0 0 0;
}
#alerts:hover {
tint: $warning 10%;
}
#status-bar {
dock: bottom;
height: 1;
background: $primary;
color: $text;
padding: 0 2;
}
"""
BINDINGS = [
("q", "quit", "Quitter"),
("d", "toggle_dark", "Thème"),
("r", "refresh", "Rafraîchir"),
]
last_update: reactive[str] = reactive("")
service_count: reactive[int] = reactive(0)
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Horizontal():
yield Static(
"[bold]Navigation[/]\n\n"
"• Services\n• [blink]Alertes[/blink]\n• Logs",
id="sidebar",
)
with Vertical(id="main"):
yield Input(placeholder="Filtrer les services...", id="filter")
yield DataTable()
alerts = Static(
"[red bold]CRIT[/] disk 94% on node-2\n"
"[yellow]WARN[/] memory pressure node-1\n"
"[green]OK[/] backup completed",
id="alerts",
)
alerts.border_title = "Alertes système"
yield alerts
yield Static(id="status-bar")
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("Service", "Port", "Status")
self.load_services()
def load_services(self, filtre: str = "") -> None:
table = self.query_one(DataTable)
table.clear()
count = 0
for name, port, status in SERVICES:
if filtre and filtre.lower() not in name.lower():
continue
marker = (
"[green]● running[/]" if status == "running"
else "[red]● stopped[/]"
)
table.add_row(name, str(port), marker)
count += 1
self.service_count = count
self.last_update = datetime.now().strftime("%H:%M:%S")
def watch_last_update(self, value: str) -> None:
if value:
self.query_one("#status-bar", Static).update(
f"Services : {self.service_count} | Mis à jour : {value}"
)
def on_input_changed(self, event: Input.Changed) -> None:
self.load_services(filtre=event.value)
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
row = event.data_table.get_row(event.row_key)
service_name = str(row[0])
self.push_screen(
ConfirmRestart(service_name), callback=self.handle_restart
)
def handle_restart(self, confirmed: bool) -> None:
if confirmed:
self.notify("Service redémarré !", severity="information")
else:
self.notify("Redémarrage annulé.", severity="warning")
@work(exclusive=True)
async def refresh_services(self) -> None:
"""Rafraîchit les services en arrière-plan."""
self.query_one("#status-bar", Static).update("Rafraîchissement...")
await asyncio.sleep(1)
filtre = self.query_one(Input).value
self.load_services(filtre=filtre)
self.notify("Services rafraîchis !")
def action_refresh(self) -> None:
self.refresh_services()
if __name__ == "__main__":
ServiceMonitor().run()

Lance-le :

Fenêtre de terminal
python monitor.py

Récapitulatif de ce que chaque section a apporté :

SectionFonctionnalité ajoutéeConcepts Textual
SqueletteHeader + Footer + texteApp, compose(), BINDINGS
WidgetsTableau de servicesDataTable, on_mount(), query_one()
Mise en pageSidebar + panneau alertesHorizontal, Vertical, with
CSSStyle, hover, tint, blink, thèmetext-style, :hover, tint, border-title, :dark/:light
ÉvénementsFiltrage + rafraîchissementInput, on_input_changed, action_*
RéactivitéBarre de statut livereactive, watch_*, dock: bottom
ScreensConfirmation redémarrageScreen, push_screen, dismiss
WorkersRefresh asynchrone@work, exclusive=True, asyncio

Textual inclut un framework de test intégré basé sur asyncio. La méthode run_test() lance l’application en mode headless (sans terminal réel) et fournit un objet Pilot pour simuler les interactions :

test_monitor.py
import asyncio
from monitor import ServiceMonitor
async def test_monitor():
app = ServiceMonitor()
async with app.run_test() as pilot:
# Vérifie que les 6 services sont affichés
from textual.widgets import DataTable
table = app.query_one(DataTable)
assert table.row_count == 6
# Vérifie le filtrage
from textual.widgets import Input
app.query_one(Input).value = "nginx"
await pilot.pause()
await pilot.pause()
assert table.row_count == 1
# Vérifie la réactivité
assert app.service_count == 1
assert app.last_update != ""
# Remet le filtre à zéro
app.query_one(Input).value = ""
await pilot.pause()
await pilot.pause()
assert table.row_count == 6
print("Tous les tests passent ✓")
asyncio.run(test_monitor())

Les méthodes du Pilot :

MéthodeAction
pilot.click("#id")Simule un clic souris sur un widget
pilot.press("a", "b")Simule des pressions de touches
pilot.pause()Attend que tous les messages soient traités

Pour des tests plus avancés, le plugin pytest-textual-snapshot permet de faire du test de régression visuelle en comparant des captures SVG de l’application.

Le paquet textual-dev fournit trois outils précieux :

CommandeDescription
textual run --dev monitor.pyRechargement à chaud (modifie le CSS, l’app se met à jour)
textual consoleConsole de debug (logs, événements, arbre DOM)
textual keysAffiche les événements clavier en temps réel

Le mode --dev est particulièrement utile pour ajuster le CSS : modifie ton fichier .tcss, enregistre, et l’interface se met à jour instantanément sans redémarrer.

SymptômeCause probableSolution
ModuleNotFoundError: textualVenv non activésource .venv/bin/activate
Affichage cassé, caractères bizarresTerminal incompatibleEssaie un autre terminal (GNOME Terminal, iTerm2)
NoMatches dans query_one()L’ID n’existe pas ou le widget n’est pas encore montéVérifie l’ID, utilise on_mount plutôt que __init__
Les couleurs ne s’affichent pasTerminal 256 couleurs uniquementActive le mode truecolor dans ton terminal
Le CSS ne s’applique pasSpécificité insuffisanteLe CSS de l’app a priorité sur DEFAULT_CSS du widget
Workers qui ne mettent pas à jour l’UIAppel depuis un threadUtilise call_from_thread() pour les thread workers
  • Textual transforme le terminal en interface graphique avec plus de 30 widgets intégrés, un système CSS, et une gestion d’événements asynchrone.
  • Construire une app progressivement : commence par le squelette (App + compose()), ajoute les widgets, organise le layout, style le CSS, puis enrichis avec événements, réactivité, screens et workers.
  • compose() déclare l’interface, on_* gère les événements, reactive synchronise les données avec l’affichage.
  • Textual CSS (TCSS) fonctionne comme le CSS web : sélecteurs par type, ID ou classe, avec des variables de thème ($primary, $panel).
  • Les conteneurs (Horizontal, Vertical, Grid) organisent les widgets, et l’unité 1fr répartit l’espace équitablement.
  • Les screens permettent la navigation multi-vues, avec push_screen() / dismiss() pour le stack et les modes pour les vues principales.
  • Les workers (@work) exécutent les opérations longues sans bloquer l’interface.
  • Le framework de test intégré (run_test() + Pilot) permet de tester les interactions sans terminal réel.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.