mrkeyoor.com_
Thu 06 Aug 01:00 UTC
PyPICLI & Toolingupdated 05 Aug 2026

textual

Textual is a Python framework for building full terminal user interfaces: real apps with buttons, data tables, tree views, text areas, and mouse support, running inside the terminal or served to a browser via textual serve. You compose widgets in Python, style them with a CSS dialect (TCSS), and react to messages and key bindings, a model lifted deliberately from web development. It sits on top of Rich (same author) and is async under the hood without forcing async on your code.

Verdict

The most capable and best-documented way to build serious terminal UIs in Python, and genuinely fun to use. Accept the churn of frequent majors and the post-2025 slower maintenance reality before betting a long-lived product on it; for simple prompts and output, smaller tools do the job.

API stability3/5Eight major versions in roughly four years tells the story: core concepts are settled, but widget APIs and defaults shift between majors and upgrade notes are required reading.
Docs5/5textual.textualize.io is among the best documentation in Python: a real tutorial, a guide per concept, a widget gallery with screenshots, an API reference, and a runnable demo via python -m textual.
Maintenance3/5Releases continued into 2026, but the company behind the project wound down in 2025, the last push at review time was three and a half weeks old, and around 340 issues and PRs sit open; active, but no longer a funded full-time team.
Ecosystem4/5Serious apps are built on it (Harlequin, Posting, memray's UI, Trogon), there is a growing third-party widget scene, and textual-serve/textual-web extend it to browsers; it is still niche next to web frameworks.

Use it if

  • You are building an interactive terminal tool (dashboard, DB client, log explorer) that outgrew print statements and input() prompts
  • Your users live over SSH or in headless environments where shipping an Electron or web app is not an option
  • You want one codebase that runs in the terminal and in a browser, which textual serve gives you for free
  • You want a real testing story for TUIs; run_test() with a Pilot object can simulate keys and clicks in CI without a terminal
Skip it if

Setup reality

pip install textual is pure Python and painless; add textual-dev for the dev console you will definitely need, because print() debugging does not work when your app owns the terminal (textual console in a second terminal captures logs and prints). The real cost is the learning curve: compose(), message handlers, reactive attributes, workers for anything blocking, and TCSS files are their own mental model, closer to learning a small web framework than to using click. Terminal quirks (color depth, emoji width, Windows terminals) still leak through on odd setups.

Patterns

Smallest useful appminimal-app

from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static

class HelloApp(App):
    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("Hello, terminal")
        yield Footer()

if __name__ == "__main__":
    HelloApp().run()

run() takes over the whole terminal until quit (ctrl+q by default). compose() yields widgets in document order; layout beyond stacking comes from containers and TCSS.

Style widgets with Textual CSSstyle-with-tcss

from textual.app import App, ComposeResult
from textual.widgets import Static

class StyledApp(App):
    CSS = """
    Screen { align: center middle; }
    #banner {
        width: 50%;
        padding: 1 2;
        background: $primary;
        border: heavy $accent;
    }
    """

    def compose(self) -> ComposeResult:
        yield Static("Centered and boxed", id="banner")

Use CSS_PATH = "app.tcss" for a separate file; with the textual-dev console running, edits to that file hot-reload live. Selectors are widget class names, #id, and .class, like the web but not identical.

React to button presseshandle-button-press

from textual import on
from textual.app import App, ComposeResult
from textual.widgets import Button, Static

class ButtonsApp(App):
    def compose(self) -> ComposeResult:
        yield Button("Go", id="go", variant="primary")
        yield Static("", id="status")

    @on(Button.Pressed, "#go")
    def start(self) -> None:
        self.query_one("#status", Static).update("started")

The @on decorator with a selector beats one giant on_button_pressed handler full of if-chains. query_one raises if the selector matches nothing, which surfaces typos early.

Reactive state that updates the UIreactive-attributes

from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Static

class Counter(Static):
    count = reactive(0)

    def watch_count(self, value: int) -> None:
        self.update(f"count: {value}")

    def on_click(self) -> None:
        self.count += 1

Assigning to a reactive triggers watch_<name> and a refresh automatically. Mutating a list inside a reactive does not trigger it; reassign a new list or call mutate_reactive.

Run blocking work without freezing the UIbackground-work

import httpx
from textual import work
from textual.app import App

class WeatherApp(App):
    @work(exclusive=True)
    async def fetch(self, city: str) -> None:
        async with httpx.AsyncClient() as client:
            r = await client.get(URL, params={"q": city})
        self.query_one("#out").update(r.text)

    def on_input_changed(self, event) -> None:
        self.fetch(event.value)

Anything slow in a message handler freezes the whole app. Use @work; for sync/blocking functions add thread=True, and exclusive=True cancels the previous worker, which is exactly right for type-ahead.

Show tabular datadata-table

from textual.app import App, ComposeResult
from textual.widgets import DataTable

ROWS = [("ada", 36), ("grace", 45)]

class TableApp(App):
    def compose(self) -> ComposeResult:
        yield DataTable()

    def on_mount(self) -> None:
        table = self.query_one(DataTable)
        table.add_columns("name", "age")
        table.add_rows(ROWS)
        table.cursor_type = "row"

Populate in on_mount, not compose; the widget must exist first. Handle DataTable.RowSelected for enter/click on a row. Tens of thousands of rows are fine; it renders lazily.

Key bindings with footer hintskey-bindings

from textual.app import App
from textual.widgets import Footer

class MyApp(App):
    BINDINGS = [
        ("d", "toggle_dark", "Dark mode"),
        ("q", "quit", "Quit"),
        ("r", "refresh_data", "Refresh"),
    ]

    def action_refresh_data(self) -> None:
        ...

A binding maps to an action_<name> method; the third tuple element is the label the Footer widget displays. Widgets can define their own BINDINGS that apply when focused.

Modal confirmation dialogmodal-screen

from textual.app import App
from textual.screen import ModalScreen
from textual.widgets import Button, Label

class ConfirmScreen(ModalScreen[bool]):
    def compose(self):
        yield Label("Delete this item?")
        yield Button("Yes", id="yes")
        yield Button("No", id="no")

    def on_button_pressed(self, event) -> None:
        self.dismiss(event.button.id == "yes")

# in the app:
#   if await self.push_screen_wait(ConfirmScreen()): ...

Screens are typed on their dismiss value. push_screen_wait must run inside a worker; the callback form push_screen(screen, callback) works from ordinary handlers.

Update on a timerperiodic-updates

from datetime import datetime
from textual.app import App, ComposeResult
from textual.widgets import Digits

class ClockApp(App):
    def compose(self) -> ComposeResult:
        yield Digits("")

    def on_ready(self) -> None:
        self.set_interval(1, self.tick)

    def tick(self) -> None:
        self.query_one(Digits).update(f"{datetime.now():%T}")

set_interval returns a Timer you can pause or stop, and it does not fire while the app is suspended. Keep the callback fast; slow work still belongs in a worker.

Drive the app in teststest-with-pilot

import pytest

@pytest.mark.asyncio
async def test_counter():
    app = CounterApp()
    async with app.run_test() as pilot:
        await pilot.press("up", "up", "up")
        await pilot.click("#reset")
        assert app.query_one(Counter).count == 0

run_test runs the real app headless, so CI needs no terminal tricks. Call await pilot.pause() after actions that post messages to let the queue drain before asserting.

Alternatives

PackageRegistryPick it when
richPyPIYou want beautiful static output, tables, and progress bars without an interactive app; it is also Textual's own foundation.
urwidPyPIYou want the battle-hardened classic TUI toolkit and can accept a much older API style.
prompt-toolkitPyPIYou are building REPLs, autocompleting prompts, or editor-like input rather than widget-based full-screen apps.
questionaryPyPIYou just need polished interactive prompts (select, confirm, text) in an otherwise ordinary CLI.