Building Professional Terminal UIs with Textual and Python: Widgets, Layouts, and Real-time Updates

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Six months ago, I was shipping a Python monitoring dashboard using tkinter. It worked — barely. The event loop was brittle, cross-platform rendering was inconsistent, and every new widget felt like negotiating with a framework from 2003. A colleague pointed me toward Textual, and I rewrote that dashboard in a weekend. It’s been running in production ever since, and I haven’t touched tkinter since.

What follows is what I actually learned building real TUI apps with Textual — not the toy examples from README files, but production-grade stuff with reactive state, live data, and multi-screen navigation.

Quick Start: Get a Working App in 5 Minutes

Install Textual with the dev extras — the CSS inspector alone makes it worth it:

pip install textual[dev]

Here’s the smallest useful Textual app — a live system monitor skeleton:

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

class MonitorApp(App):
    CSS = """
    Static {
        border: solid green;
        padding: 1;
        margin: 1;
    }
    """

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("CPU: loading...", id="cpu")
        yield Static("Memory: loading...", id="mem")
        yield Footer()

if __name__ == "__main__":
    app = MonitorApp()
    app.run()

Run it:

python monitor.py

You get a full-screen terminal app with a header, footer, and styled boxes — no configuration, no Tk root window, no mainloop boilerplate. That first run was the moment I realized tkinter and I were done.

Deep Dive: Widgets, Layouts, and the CSS System

How Layouts Actually Work

Textual uses a CSS-inspired layout engine running entirely in the terminal. If you’ve done any frontend work, the positioning model will feel familiar fast. Layouts are defined either inline (as class-level CSS strings) or in external .tcss files.

The two layout modes you’ll use constantly are horizontal and vertical. A split-panel layout looks like this:

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

class SplitApp(App):
    CSS = """
    #sidebar {
        width: 30%;
        background: $panel;
        border-right: solid $accent;
    }
    #main {
        width: 70%;
    }
    """

    def compose(self) -> ComposeResult:
        yield Header()
        with Horizontal():
            with Vertical(id="sidebar"):
                yield Static("Navigation")
                yield Static("Settings")
            with Vertical(id="main"):
                yield Static("Main content area")
        yield Footer()

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

The $panel and $accent variables come from Textual’s built-in theme. They adapt automatically when users switch between dark and light mode with the default keybind.

Reactive Properties: The Core Concept

Reactive properties are Textual’s most important concept — and what separates it from every other TUI library. They’re class-level attributes that automatically trigger UI re-renders when their values change. No manual refresh calls, no event dispatch. Assign a new value; the widget updates.

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

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

    def render(self) -> str:
        return f"Count: {self.count}"

class CounterApp(App):
    def compose(self) -> ComposeResult:
        yield CounterWidget(id="counter")

    def on_mount(self) -> None:
        self.set_interval(1, self.increment)

    def increment(self) -> None:
        self.query_one("#counter", CounterWidget).count += 1

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

The counter increments every second without any manual refresh. reactive handles the dirty-checking and re-render scheduling internally. Six months running this in a production monitoring dashboard — reading CPU, memory, and disk metrics every second — zero memory leaks, zero missed updates under sustained load.

Advanced Usage: Real-time Updates and DataTable

Background Workers for Live Data

For real-time data (metrics, logs, API polling), Textual provides a work decorator that runs coroutines as background tasks without blocking the UI thread:

import asyncio
import psutil
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
from textual.reactive import reactive
from textual import work

class CPUWidget(Static):
    cpu_pct = reactive(0.0)

    def render(self) -> str:
        bar = "█" * int(self.cpu_pct / 5)
        return f"CPU [{bar:<20}] {self.cpu_pct:.1f}%"

class LiveMonitor(App):
    def compose(self) -> ComposeResult:
        yield Header()
        yield CPUWidget(id="cpu")
        yield Footer()

    def on_mount(self) -> None:
        self.poll_metrics()

    @work(exclusive=True)
    async def poll_metrics(self) -> None:
        while True:
            cpu = psutil.cpu_percent(interval=None)
            self.query_one("#cpu", CPUWidget).cpu_pct = cpu
            await asyncio.sleep(1)

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

DataTable for Structured Data

DataTable handles large tabular datasets efficiently — it virtualizes rows, so rendering 10,000 rows costs about the same as rendering 10:

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

ROWS = [
    ("Process", "PID", "CPU%", "MEM%"),
    ("nginx", 1234, "0.1", "0.8"),
    ("postgres", 5678, "2.3", "12.4"),
    ("python", 9101, "18.7", "3.2"),
]

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

    def on_mount(self) -> None:
        table = self.query_one(DataTable)
        table.add_columns(*ROWS[0])
        table.add_rows(ROWS[1:])

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

You get sortable columns, keyboard navigation, and cursor highlighting for free. Adding row selection with callbacks is two more lines of code.

Textual DevTools

The CSS inspector is something I didn’t expect to find in a terminal framework. Run your app with:

textual run --dev monitor.py

Then open the inspector in a second terminal:

textual console

You get a live CSS inspector that highlights widgets on hover, shows computed styles, and lets you modify CSS in real time. That’s browser DevTools territory — I didn’t expect it here. Layout changes that used to mean a full restart now take about 10 seconds to test.

Practical Tips from Six Months in Production

Use .tcss Files for Anything Beyond 20 Lines

Inline CSS strings work for quick prototypes. Past that, separate your styles into app.tcss and reference it with:

class MyApp(App):
    CSS_PATH = "app.tcss"

Your editor’s syntax highlighting will pick it up, and the DevTools console can hot-reload style changes without restarting the app.

Screen-Based Navigation for Multi-View Apps

Multi-page navigation uses Textual’s Screen system. Push and pop screens like a stack:

from textual.screen import Screen
from textual.app import App
from textual.widgets import Button

class DetailScreen(Screen):
    def compose(self):
        yield Button("Back", id="back")

    def on_button_pressed(self, event):
        self.app.pop_screen()

class MainApp(App):
    def compose(self):
        yield Button("Open Detail", id="detail")

    def on_button_pressed(self, event):
        self.push_screen(DetailScreen())

That’s exactly how I handle configuration panels and drill-down views in the monitoring dashboard. Each view is a separate Screen; keyboard shortcuts make navigation feel like a real app.

Watch Out for Thread Safety

Textual’s UI runs on an asyncio event loop. If you’re pulling data from a regular (non-async) thread — say, a subprocess or a socket listener — use app.call_from_thread() to post updates safely:

import threading

def background_reader(app):
    # Running in a regular thread
    while True:
        data = fetch_some_data()
        app.call_from_thread(app.update_display, data)

thread = threading.Thread(target=background_reader, args=(app,), daemon=True)
thread.start()

Skipping this causes subtle race conditions that only appear under load — I hit this early on and spent a day debugging it.

Key Bindings and the Footer Widget

Declare key bindings at the class level and the Footer widget automatically renders them as a help bar at the bottom of the screen:

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

    def action_refresh(self) -> None:
        # Custom refresh logic
        self.poll_metrics()

The method name must match action_<binding_name>. action_quit and action_toggle_dark are built-in — you don’t need to implement those.

When Textual Isn’t the Right Call

Textual adds real complexity for simple scripts. Need a progress bar for a one-shot command? Use Rich instead — Textual is built on top of it anyway. Textual earns its complexity when you need persistent interactivity: dashboards, configuration managers, log viewers, database browsers. If users need to navigate, filter, and act on live data over time, it’s the right tool.

My current production stack uses it for a deployment monitor that tracks services across three environments. The team adopted it immediately because it behaves like a real application — keyboard shortcuts, dark mode, mouse support — running entirely over SSH without X forwarding or VNC.

Share: