← Home

155. Textual

Terminal User Interfaces with Textual

155. Textual

The final form: a live terminal UI that auto-refreshes every second.

🤔 Socratic question: Why would anyone want a terminal UI in 2025 when we have beautiful web UIs? Because terminals are available on every server in the world — no browser, no GUI, no graphics card needed. SSH into a server in Antarctica and your terminal app still works. Remote servers, containers, embedded devices — terminals rule.

🏛️ Origin story: Will McGugan (who built Rich) quit his job to build Textual full-time. He raised VC funding for his company Textualize. The tool you’re building right now — a terminal dashboard — is the exact vision he had. Mr. Robot, that hacking show everyone talks about? The “hacker aesthetic” terminal UIs you see in it? Built with tools like this. You’re building something that looks genuinely cool.

📺 YouTube search: “Textual TUI Python Will McGugan” or “building terminal apps python 2024”

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

class RamApp(App):
    BINDINGS = [('q', 'quit', 'Quit')]

    ram_percent: reactive[float] = reactive(0.0)

    def compose(self) -> ComposeResult:
        yield Header()
        yield Label('RAM: —', id='ram_label')
        yield DataTable(id='proc_table')
        yield Footer()

    def on_mount(self) -> None:
        self.query_one('#proc_table', DataTable).add_columns('Process', 'PID', 'RSS MB')
        self.set_interval(1, self.refresh_data)

    def watch_ram_percent(self, value: float) -> None:
        color = 'red' if value > 80 else 'green'
        self.query_one('#ram_label', Label).update(f'[{color}]RAM: {value:.1f}%[/]')

    def refresh_data(self) -> None:
        snapshot = take_snapshot()
        self.ram_percent = snapshot['percent']   # triggers watch_ram_percent
        table = self.query_one('#proc_table', DataTable)
        table.clear()
        for p in snapshot['processes']:
            color = 'red' if p['rss_mb'] > 500 else 'green'
            table.add_row(p['name'], str(p['pid']), f"[{color}]{p['rss_mb']:.0f}[/]")

if __name__ == '__main__':
    RamApp().run()

Three things Textual requires you to get right:

  1. compose() not __init__ — mount widgets in compose(). The DOM doesn’t exist yet in __init__.
  2. reactive + watch_ — when self.ram_percent = value changes the reactive, watch_ram_percent(value) is called automatically. No self.refresh() needed.
  3. Header() and Footer() — always include these. They give users keyboard shortcuts and visual context.

💡 Real-world: htop (the terminal process monitor you used in Chapter 1) is built in C with ncurses — the predecessor to Textual. k9s (the Kubernetes dashboard every DevOps engineer uses) is a TUI. lazygit (a beautiful terminal git client) is a TUI. You’re building in the same tradition as the most-loved developer tools.

💡 Fun fact: Textual’s reactive system is directly inspired by React’s state management. When you assign self.ram_percent = 87.5, Textual compares the new value to the old one, and if different, schedules a re-render of affected widgets — exactly like React’s setState. Will McGugan explicitly cited React as inspiration. You’re using the same mental model that powers Facebook’s UI, just in a terminal.

⚠️ Watch out: self.query_one(DataTable) finds a widget by type — but if you have TWO DataTable widgets, it raises TooManyMatches. Always add id= when you have multiple widgets of the same type: DataTable(id='proc_table') and self.query_one('#proc_table', DataTable). The #id selector works just like CSS.

🤔 Think about it: compose() is a generator function — it uses yield, not return. Textual calls it lazily, building the DOM one widget at a time. Why does Textual use a generator here instead of having you return a list? What advantage does a generator give Textual when building potentially deep widget trees? (Hint: think about memory and lazy evaluation.)


🖥️ Aryan wants a real TUI dashboard for the RAM manager — not just Rich.Live but a proper app with keyboard shortcuts and live-updating widgets. His Textual app mounts widgets in the wrong lifecycle method, forgets to call super().compose(), and uses self.refresh() when reactive attributes handle it automatically.

Learning objectives

  • Use compose() to yield widgets, on_mount() for setup
  • Use reactive() for auto-updating attributes
  • Add watch_attr() methods that fire on reactive changes
  • Query widgets with query_one(Type) or query_one(‘#id’)
  • Use set_interval() for periodic background updates

Key concepts

  • compose() — yield widgets to build layout
  • on_mount() — called after compose, set up data/timers
  • reactive(default) — triggers watch_attr() on change
  • watch_attr(value) — auto-called on reactive change
  • query_one(Type) — find widget by type
  • set_interval(seconds, fn) — recurring background call

Try it

Concept detail

Terminal UIs with Textual

pip install textual — full TUI framework built on Rich.

App lifecycle

compose()    →  yield widgets (layout declaration)
on_mount()   →  setup after widgets exist (data, timers)
on_key()     →  keyboard events
watch_*()    →  reactive attribute changed

Basic app

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

class MyApp(App):
    CSS = 'Static { height: 3; }'

    count: reactive[int] = reactive(0)

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static('0', id='counter')
        yield Footer()

    def on_mount(self) -> None:
        self.set_interval(1.0, self.tick)

    def tick(self) -> None:
        self.count += 1   # triggers watch_count automatically

    def watch_count(self, value: int) -> None:
        self.query_one('#counter', Static).update(str(value))

if __name__ == '__main__':
    MyApp().run()

DataTable

def on_mount(self) -> None:
    table = self.query_one(DataTable)
    table.add_columns('Name', 'PID', 'RAM')

def refresh_data(self) -> None:
    table = self.query_one(DataTable)
    table.clear()
    table.add_row('chrome', '812', '1800 MB')

Keyboard shortcuts

def on_key(self, event) -> None:
    if event.key == 'q':
        self.exit()
    elif event.key == 'r':
        self.refresh_data()

Textual CSS

DataTable { height: 1fr; }       /* fill available height */
Static#status { height: 3; }     /* 3 lines tall */
.highlight { background: red; }

Solution

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

class RamApp(App):
    CSS = '''
    DataTable { height: 1fr; }
    Static#status { height: 3; }
    '''

    # reactive — any change auto-triggers watch_ram_percent()
    ram_percent: reactive[float] = reactive(0.0)

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static('Loading...', id='status')
        yield DataTable()
        yield Footer()

    def on_mount(self) -> None:
        table = self.query_one(DataTable)
        table.add_columns('Process', 'PID', 'RSS MB')
        # Start a worker to update data every second
        self.set_interval(1.0, self.refresh_data)

    def watch_ram_percent(self, value: float) -> None:
        '''Called automatically when ram_percent changes.'''
        color = 'red' if value > 80 else ('yellow' if value > 60 else 'green')
        self.query_one('#status', Static).update(
            f'[{color}]RAM: {value:.1f}%[/]'
        )

    def refresh_data(self) -> None:
        import psutil
        mem = psutil.virtual_memory()
        self.ram_percent = mem.percent   # triggers watch_ram_percent automatically

        table = self.query_one(DataTable)
        table.clear()
        procs = sorted(
            psutil.process_iter(['pid', 'name', 'memory_info']),
            key=lambda p: p.info['memory_info'].rss,
            reverse=True
        )
        for proc in procs[:10]:
            try:
                rss = proc.info['memory_info'].rss / 1e6
                table.add_row(proc.info['name'], str(proc.info['pid']), f'{rss:.1f}')
            except (psutil.NoSuchProcess, psutil.AccessDenied):
                pass

Tests

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

def test_compose_yields_datatable():
    import inspect
    src = inspect.getsource(RamApp.compose)
    assert 'DataTable' in src, 'compose() must yield a DataTable'

def test_compose_yields_header():
    import inspect
    src = inspect.getsource(RamApp.compose)
    assert 'Header' in src, 'compose() must yield a Header'

def test_no_mount_in_init():
    import inspect
    src = inspect.getsource(RamApp)
    # __init__ should not call self.mount()
    init_lines = []
    in_init = False
    for line in src.splitlines():
        if 'def __init__' in line:
            in_init = True
        elif in_init and line.strip().startswith('def '):
            in_init = False
        if in_init:
            init_lines.append(line)
    assert not any('self.mount' in l for l in init_lines), (
        'Do not call self.mount() in __init__ — use compose() instead'
    )

def test_uses_reactive():
    import inspect
    src = inspect.getsource(RamApp)
    assert 'reactive' in src, 'Use reactive() for auto-updating attributes'

def test_watch_method_exists():
    assert hasattr(RamApp, 'watch_ram_percent'), (
        'Add watch_ram_percent() — called automatically when ram_percent changes'
    )

def test_no_manual_refresh_for_reactive():
    import inspect
    src = inspect.getsource(RamApp.watch_ram_percent) if hasattr(RamApp, 'watch_ram_percent') else ''
    # watch methods shouldn't need to call self.refresh()
    assert 'self.refresh()' not in src, (
        'watch_ methods do not need self.refresh() — reactive handles it'
    )

Resources