Back to Oh My Openagent

Textual TUI

packages/omo-codex/plugin/skills/programming/references/python/textual-tui.md

4.5.125.1 KB
Original Source

Textual TUI

Textual builds rich, mouse-aware, scrollable, mobile-style TUIs on top of rich. Replaces curses, urwid, blessed.

Install

bash
uv add textual
uv add --dev textual-dev   # textual console + run --dev for hot reload

Minimal app

python
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:

bash
uv run python counter.py

For hot reload during development:

bash
uv run textual run --dev counter.py

Reactive attributes

Textual's reactive() descriptor turns a class attribute into something that watches assignments and re-renders automatically. Replaces the manual query_one + update dance.

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

Async work — workers

NEVER block the event loop. For network/disk/CPU work, use @work (creates a worker) or run_worker.

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

Action handlers

Bind keys to method calls via BINDINGS and action_* methods.

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

CSS

Textual's CSS supports selectors, variables ($primary, $boost), animations. Inline via CSS = "..." or external via CSS_PATH = "app.tcss".

css
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).

Testing

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

When NOT to use Textual

NeedUse
One-off CLI with structured outputtyper + rich
Progress bar in a scriptrich.progress
Tabular display of query resultsrich.table
Full-screen app with state, input, mouseTextual

A pretty CLI is not a TUI. Reach for Textual when the user expects to navigate a UI, not when you want colours.

Sources