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é.

Qu’est-ce que Textual ?
Section intitulée « Qu’est-ce que 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.
Textual vs Rich : quelle différence ?
Section intitulée « Textual vs Rich : quelle différence ? »| Critère | Rich | Textual |
|---|---|---|
| Type | Bibliothèque d’affichage | Framework d’application |
| Interactivité | Aucune (affichage seul) | Complète (clavier, souris, events) |
| Structure | Appels ponctuels (print) | Application avec boucle d’événements |
| Cas d’usage | Logs colorés, tableaux statiques | Dashboards, outils CLI interactifs |
| CSS | Non | Oui (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.
Qui utilise Textual ?
Section intitulée « Qui utilise Textual ? »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)
Prérequis
Section intitulée « Prérequis »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
Installation
Section intitulée « Installation »Crée un projet dédié pour suivre ce guide :
mkdir textual-lab && cd textual-labuv venvsource .venv/bin/activateuv pip install textual textual-devLe 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 :
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é.
Ce que tu vas construire
Section intitulée « Ce que tu vas construire »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.
Le squelette — première application
Section intitulée « Le squelette — première application »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 :
from textual.app import App, ComposeResultfrom 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 :
python monitor.py
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 avecyield(c’est un générateur Python)BINDINGSassocie des touches clavier à des actions (iciq→ quitter)HeaderetFootersont des widgets intégrés qui s’adaptent automatiquementStaticaffiche 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.
Les widgets — afficher les services
Section intitulée « Les widgets — afficher les services »Textual fournit plus de 30 widgets prêts à l’emploi. Voici les plus utiles :
| Widget | Description | Import |
|---|---|---|
Header | Barre de titre de l’app | textual.widgets |
Footer | Barre d’état avec raccourcis | textual.widgets |
Static | Texte statique (supporte Rich) | textual.widgets |
Label | Texte court | textual.widgets |
Button | Bouton cliquable avec variantes | textual.widgets |
Input | Champ de saisie texte | textual.widgets |
DataTable | Tableau de données triable | textual.widgets |
RichLog | Journal de messages scrollable | textual.widgets |
Select | Menu déroulant | textual.widgets |
Switch | Interrupteur on/off | textual.widgets |
ProgressBar | Barre de progression | textual.widgets |
Tree | Arborescence dépliable | textual.widgets |
DirectoryTree | Explorateur de fichiers | textual.widgets |
TextArea | Éditeur de texte multiligne | textual.widgets |
TabbedContent | Conteneur à onglets | textual.widgets |
Tous les widgets s’importent depuis textual.widgets. La liste complète est
disponible dans la widget gallery
officielle.
Ajouter un DataTable au moniteur
Section intitulée « Ajouter un DataTable au moniteur »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 :
from textual.app import App, ComposeResultfrom 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()
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 ligneson_mount(): appelé une fois l’interface prête, parfait pour charger les donnéesquery_one(DataTable): récupère une référence au widget (commedocument.querySelector()en JS)- Formatage Rich :
[green]● running[/]colore le texte directement dans le tableau
Mise en page — organiser l’interface
Section intitulée « Mise en page — organiser l’interface »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 :
| Conteneur | Disposition |
|---|---|
Vertical | De haut en bas (par défaut) |
Horizontal | De gauche à droite |
Grid | Grille lignes/colonnes |
Container | Conteneur générique |
Utilise le mot-clé with dans compose() pour imbriquer les widgets dans un
conteneur.
Ajouter une sidebar et un panneau d’alertes
Section intitulée « Ajouter une sidebar et un panneau d’alertes »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 :
from textual.app import App, ComposeResultfrom textual.containers import Horizontal, Verticalfrom 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ôteVertical(id=“main”) : empile le tableau et les alertes dans la colonne de droitewith: syntaxe de contexte pour imbriquer les widgets dans un conteneurCSSde layout :width: 25fixe la sidebar,width: 1frdonne le reste au contenu principal,height: 1frfait grandir le tableau pour occuper l’espace disponibleid="sidebar"/id="alerts": identifiants pour cibler les widgets en CSS
L’interface est fonctionnelle, mais brute. La section suivante va la transformer visuellement.
Textual CSS — donner du style
Section intitulée « Textual CSS — donner du style »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.
Styliser le moniteur
Section intitulée « Styliser le moniteur »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 :
from textual.app import App, ComposeResultfrom textual.containers import Horizontal, Verticalfrom 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 CSS | Ce qu’elle fait | Propriétés utilisées |
|---|---|---|
| Variables de thème | Couleurs adaptées clair/sombre | $panel, $surface, $primary, $warning |
text-style | Met le texte en gras dans la sidebar | text-style: bold |
:hover | Change le fond au survol de la souris | background: $primary 20%, tint: $warning 10% |
:dark / :light | Règles spécifiques au thème actif | #sidebar:light { background: #e8e8f0; } |
tint | Applique une couche de couleur semi-transparente | tint: $warning 10% |
| Bordure titrée | Affiche « Alertes système » dans la bordure | border-title-color, border-title-style |
[blink] | Texte clignotant (markup Rich, pas CSS) | [blink]Alertes[/blink] |
Pseudo-classes disponibles
Section intitulée « Pseudo-classes disponibles »Les pseudo-classes appliquent des styles conditionnels sans écrire de Python. Textual en fournit une douzaine :
| Pseudo-classe | S’active quand… | Cas d’usage |
|---|---|---|
:hover | La souris survole le widget | Mise en surbrillance interactive |
:focus | Le widget a le focus clavier | Mettre en valeur le champ actif |
:dark | Le thème sombre est actif | Couleurs spécifiques au mode sombre |
:light | Le thème clair est actif | Couleurs spécifiques au mode clair |
:disabled | Le widget est désactivé | Griser un bouton inactif |
:even / :odd | Position paire/impaire parmi les siblings | Zébrage de lignes |
:first-child | Premier enfant de son conteneur | Style spécial pour le 1er élément |
:focus-within | Un enfant a le focus | Encadrer 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;}text-style — formats de texte
Section intitulée « text-style — formats de texte »La propriété text-style accepte une ou plusieurs valeurs combinées :
| Valeur | Effet |
|---|---|
bold | Texte en gras |
italic | Texte en italique |
reverse | Inverse les couleurs texte/fond |
strike | Texte barré |
underline | Texte souligné |
bold italic | Combinaison : gras + italique |
#sidebar { text-style: bold; }.deprecated { text-style: strike italic; }tint et opacity — effets de transparence
Section intitulée « tint et opacity — effets de transparence »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%; }Bordures titrées
Section intitulée « Bordures titrées »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 ┑.
CSS inline vs CSS externe
Section intitulée « CSS inline vs CSS externe »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.
Classes dynamiques (Python)
Section intitulée « Classes dynamiques (Python) »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 CSSwidget.add_class("critical")widget.remove_class("critical")
# Toggle : ajoute si absente, retire si présentewidget.toggle_class("selected")
# Conditionnel : ajoute ou retire selon un booléenwidget.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électeurs disponibles
Section intitulée « Sélecteurs disponibles »| Sélecteur | Cible | Exemple dans le moniteur |
|---|---|---|
DataTable | Par type (classe Python) | DataTable { height: 1fr; } |
#sidebar | Par ID (unique) | #sidebar { width: 25; } |
.critical | Par classe CSS | .critical { tint: red 10%; } |
#sidebar:hover | Pseudo-classe (état) | #sidebar:hover { background: $primary 20%; } |
#main DataTable | Descendant | #main DataTable { border: solid green; } |
#main > DataTable | Enfant direct | #main > DataTable { margin: 1; } |
* | Universel (tous les widgets) | * { outline: solid red; } (debug) |
Propriétés courantes
Section intitulée « Propriétés courantes »| Propriété | Valeurs | Description |
|---|---|---|
width / height | auto, 100%, 1fr, 20 | Dimensions |
background | $panel, red, #ff0000 | Couleur de fond |
color | auto, white, $text | Couleur du texte |
border | solid $primary, round green, tall $primary | Bordure |
padding / margin | 1, 1 2, 1 2 3 4 | Espacement |
text-style | bold, italic, reverse, strike, underline | Format du texte |
opacity / text-opacity | 0% à 100%, 0.0 à 1.0 | Transparence (widget ou texte seul) |
tint | red 20%, rgba(0, 200, 0, 0.3) | Couche de couleur semi-transparente |
content-align | center middle, left top | Alignement du contenu |
layout | vertical, horizontal, grid | Type de disposition |
grid-size | 4 3 (colonnes lignes) | Taille de la grille |
dock | top, bottom, left, right | Ancrage |
display | block, none | Visibilité |
Les variables préfixées par $ (comme $panel, $primary, $text) s’adaptent
automatiquement au thème actif (clair ou sombre).
Événements — interagir avec l’utilisateur
Section intitulée « Événements — interagir avec l’utilisateur »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).
Méthodes de gestion d’événements
Section intitulée « Méthodes de gestion d’événements »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é !")Le décorateur @on permet de filtrer par sélecteur CSS, ce qui est plus précis
:
from textual import on
@on(Button.Pressed, "#mon-bouton")def handle_mon_bouton(self) -> None: """Appelé uniquement quand #mon-bouton est cliqué.""" self.notify("Mon bouton spécifique !")Événements courants
Section intitulée « Événements courants »| Événement | Déclenché quand… |
|---|---|
Button.Pressed | Un bouton est cliqué |
Input.Changed | Le texte d’un champ change |
Input.Submitted | L’utilisateur valide (Entrée) |
DataTable.RowSelected | Une ligne du tableau est sélectionnée |
Key | Une touche est pressée |
Mount | Le widget vient d’être ajouté au DOM |
Bindings (raccourcis clavier)
Section intitulée « Bindings (raccourcis clavier) »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.
Ajouter le filtrage au moniteur
Section intitulée « Ajouter le filtrage au moniteur »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, InputDans 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 placeholderon_input_changed(): réagit à chaque frappe pour filtrer en temps réelload_services(filtre): méthode réutilisable avec filtre optionnelaction_refresh(): bindingrpour rafraîchir + notification
Réactivité — synchroniser les données
Section intitulée « Réactivité — synchroniser les données »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écanisme | Préfixe | Rôle |
|---|---|---|
| Watch | watch_<attribut> | Exécuter du code quand la valeur change |
| Validate | validate_<attribut> | Vérifier/transformer la valeur avant affectation |
| Compute | compute_<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).
Ajouter une barre de statut réactive au moniteur
Section intitulée « Ajouter une barre de statut réactive au moniteur »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 datetimefrom textual.reactive import reactiveDans 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]etreactive[int]: attributs qui déclenchent un callback à chaque modificationwatch_last_update(): exécuté automatiquement quandlast_updatechangedock: bottom: ancre la barre de statut en bas de l’écran
Screens — naviguer entre vues
Section intitulée « Screens — naviguer entre vues »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.
Ajouter un écran de confirmation au moniteur
Section intitulée « Ajouter un écran de confirmation au moniteur »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 onfrom textual.screen import Screenfrom textual.widgets import Button, LabelAjouter 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 dedismiss()push_screen()empile un écran par-dessus l’actueldismiss(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
Modes (navigation avancée)
Section intitulée « Modes (navigation avancée) »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"Workers — tâches longues en arrière-plan
Section intitulée « Workers — tâches longues en arrière-plan »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 asynciofrom textual import workRemplacer 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)Application finale
Section intitulée « Application finale »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 :
"""Moniteur de services — application Textual complète."""import asynciofrom datetime import datetime
from textual import on, workfrom textual.app import App, ComposeResultfrom textual.containers import Horizontal, Verticalfrom textual.reactive import reactivefrom textual.screen import Screenfrom 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 :
python monitor.pyRécapitulatif de ce que chaque section a apporté :
| Section | Fonctionnalité ajoutée | Concepts Textual |
|---|---|---|
| Squelette | Header + Footer + texte | App, compose(), BINDINGS |
| Widgets | Tableau de services | DataTable, on_mount(), query_one() |
| Mise en page | Sidebar + panneau alertes | Horizontal, Vertical, with |
| CSS | Style, hover, tint, blink, thème | text-style, :hover, tint, border-title, :dark/:light |
| Événements | Filtrage + rafraîchissement | Input, on_input_changed, action_* |
| Réactivité | Barre de statut live | reactive, watch_*, dock: bottom |
| Screens | Confirmation redémarrage | Screen, push_screen, dismiss |
| Workers | Refresh asynchrone | @work, exclusive=True, asyncio |
Tester l’application
Section intitulée « Tester l’application »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 :
import asynciofrom 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éthode | Action |
|---|---|
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.
Outils de développement
Section intitulée « Outils de développement »Le paquet textual-dev fournit trois outils précieux :
| Commande | Description |
|---|---|
textual run --dev monitor.py | Rechargement à chaud (modifie le CSS, l’app se met à jour) |
textual console | Console de debug (logs, événements, arbre DOM) |
textual keys | Affiche 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.
Dépannage
Section intitulée « Dépannage »| Symptôme | Cause probable | Solution |
|---|---|---|
ModuleNotFoundError: textual | Venv non activé | source .venv/bin/activate |
| Affichage cassé, caractères bizarres | Terminal incompatible | Essaie 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 pas | Terminal 256 couleurs uniquement | Active le mode truecolor dans ton terminal |
| Le CSS ne s’applique pas | Spécificité insuffisante | Le CSS de l’app a priorité sur DEFAULT_CSS du widget |
| Workers qui ne mettent pas à jour l’UI | Appel depuis un thread | Utilise call_from_thread() pour les thread workers |
À retenir
Section intitulée « À retenir »- 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,reactivesynchronise 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é1frré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.