packages/shared-skills/skills/programming/references/python/textual-tui.md
Textual builds rich, mouse-aware, scrollable, mobile-style TUIs on top of rich. Replaces curses, urwid, blessed.
uv add textual
uv add --dev textual-dev # textual console + run --dev for hot reload
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, Label
from textual.containers import Vertical
class CounterApp(App[None]):
"""A trivial counter app."""
BINDINGS = [("q", "quit", "Quit")]
CSS = """
#count {
height: 3;
content-align: center middle;
background: $boost;
}
"""
count: int = 0
def compose(self) -> ComposeResult:
yield Header()
with Vertical():
yield Label("0", id="count")
yield Button("Increment", id="inc", variant="primary")
yield Button("Reset", id="reset", variant="warning")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "inc":
self.count += 1
elif event.button.id == "reset":
self.count = 0
self.query_one("#count", Label).update(str(self.count))
if __name__ == "__main__":
CounterApp().run()
Run:
uv run python counter.py
For hot reload during development:
uv run textual run --dev counter.py
Textual's reactive() descriptor turns a class attribute into something that watches assignments and re-renders automatically. Replaces the manual query_one + update dance.
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Label
class CountWidget(Label):
count: reactive[int] = reactive(0)
def render(self) -> str:
return f"Count: {self.count}"
class CounterApp(App[None]):
def compose(self) -> ComposeResult:
yield CountWidget()
def on_key(self, event) -> None:
if event.key == "space":
self.query_one(CountWidget).count += 1
reactive() triggers render() (or watch_<attr> and validate_<attr> callbacks if defined). Use recompose=True if you need to call compose() again on change.
NEVER block the event loop. For network/disk/CPU work, use @work (creates a worker) or run_worker.
import httpx
from textual.app import App, ComposeResult
from textual.widgets import Input, Static
from textual.work import work
class FetchApp(App[None]):
def compose(self) -> ComposeResult:
yield Input(placeholder="URL", id="url")
yield Static(id="result")
@work(exclusive=True)
async def fetch(self, url: str) -> None:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
self.query_one("#result", Static).update(f"{response.status_code} - {len(response.text)} bytes")
def on_input_submitted(self, event: Input.Submitted) -> None:
self.fetch(event.value)
exclusive=True cancels the previous worker if the user submits a new URL before the first finishes. Workers integrate with Textual's lifecycle - they're cancelled when the app exits.
@work is asyncio-flavoured under the hood. That is fine - it does not violate the no-asyncio rule because you are calling Textual's API, not importing asyncio yourself. Inside the worker body, use httpx.AsyncClient and other anyio-friendly libraries.
Bind keys to method calls via BINDINGS and action_* methods.
class App(App):
BINDINGS = [
("ctrl+s", "save", "Save"),
("ctrl+r", "reload", "Reload"),
]
def action_save(self) -> None:
# Called on ctrl+s
...
def action_reload(self) -> None:
...
Bindings can also include the priority=True flag to fire before children get a chance.
Textual's CSS supports selectors, variables ($primary, $boost), animations. Inline via CSS = "..." or external via CSS_PATH = "app.tcss".
Screen {
background: $surface;
color: $text;
layout: vertical;
}
#sidebar {
width: 30;
background: $boost;
}
Button.danger {
background: $error;
}
Reload with r in dev mode (textual run --dev).
import pytest
from myapp import CounterApp
@pytest.mark.anyio
async def test_counter_increments() -> None:
app = CounterApp()
async with app.run_test() as pilot:
await pilot.click("#inc")
await pilot.click("#inc")
assert app.count == 2
pilot.click(selector), pilot.press("q"), pilot.pause() for waiting on the next frame.
| Need | Use |
|---|---|
| One-off CLI with structured output | typer + rich |
| Progress bar in a script | rich.progress |
| Tabular display of query results | rich.table |
| Full-screen app with state, input, mouse | Textual |
A pretty CLI is not a TUI. Reach for Textual when the user expects to navigate a UI, not when you want colours.