← Home

156. Textual (App Bonus)

RAM Manager bonus β€” live Textual TUI dashboard

156. Textual (App Bonus)

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 uses a virtual DOM diffing algorithm β€” similar to how React works in the browser. When watch_ram_percent updates the Label, Textual doesn’t redraw the entire terminal screen. It computes the minimal set of character changes needed and writes only those. This is why Textual apps are flicker-free even at 60fps, while old curses-based apps would flash and tear.

⚠️ Watch out: There is a naming convention that Textual enforces strictly: the watcher method MUST be named watch_ followed by the exact attribute name. watch_ram_percent watches ram_percent. If you name it watch_ram or update_ram_percent, Textual silently ignores it β€” no error, no warning, just no updates. Check the method name carefully when reactive isn’t working.

πŸ€” Think about it: The refresh_data method is called every second by set_interval. It calls table.clear() and then re-adds all rows. For a table with 10 rows this is fine. But what if you had 10,000 rows? What would happen to performance? Textual has a virtual flag on DataTable for virtualized scrolling β€” what problem does that solve, and why does it matter for large datasets?


πŸ–₯️ Aryan builds a live terminal dashboard using Textual. The app shows a RAM progress bar and a process table that refreshes every second. He mounts widgets in init instead of compose(), forgets Header/Footer, mutates a reactive attribute instead of using the watch_ handler, and calls self.refresh() manually instead of letting reactive do it.

Learning objectives

  • Yield widgets from compose() β€” never mount in init
  • Use reactive() + watch_ handler for live UI updates
  • Add Header() and Footer() for polished TUI chrome
  • Wire up self.set_interval() in on_mount() for polling

Key concepts

  • compose() β€” widget tree definition (generator)
  • reactive[T] β€” triggers watch_ on assignment
  • watch_{attr}(self, value) β€” reactive change handler
  • self.query_one(β€˜#id’, WidgetType) β€” DOM query
  • self.set_interval(seconds, callback) β€” recurring timer
  • Header() / Footer() β€” standard TUI chrome

Try it

Concept detail

App bonus β€” Live Textual TUI

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ RAM Manager ───────────────────┐
β”‚ RAM: 87.3%                                        β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Process      β”‚    PID β”‚ RSS MB β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ chrome       β”‚    812 β”‚    800 β”‚
β”‚ python       β”‚    421 β”‚    150 β”‚
β”‚ slack        β”‚    234 β”‚    120 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜
q Quit

Reactive pattern

ram_percent: reactive[float] = reactive(0.0)

def watch_ram_percent(self, value: float) -> None:
    # Called automatically when self.ram_percent = X
    self.query_one('#ram_label', Label).update(f'RAM: {value:.1f}%')

def refresh_data(self) -> None:
    self.ram_percent = take_snapshot()['percent']  # triggers watch_

compose() vs on_mount()

compose()on_mount()
Yield widgets (structure)Wire up logic (queries, intervals)
Runs before DOM existsRuns after DOM is ready
No self.query_one()Use self.query_one() freely

Solution

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

def take_snapshot(n: int = 10) -> dict:
    mem = psutil.virtual_memory()
    processes = []
    for proc in psutil.process_iter(['pid', 'name', 'memory_info']):
        try:
            rss_mb = proc.info['memory_info'].rss / 1e6
            processes.append({'name': proc.info['name'], 'pid': proc.info['pid'], 'rss_mb': rss_mb})
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            pass
    return {
        'percent': mem.percent,
        'used_gb': mem.used / 1e9,
        'total_gb': mem.total / 1e9,
        'processes': sorted(processes, key=lambda p: p['rss_mb'], reverse=True)[:n],
    }

class RamApp(App):
    CSS = """
    DataTable { height: 1fr; }
    Label { padding: 1; }
    """

    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:
        table = self.query_one('#proc_table', DataTable)
        table.add_columns('Process', 'PID', 'RSS MB')
        self.set_interval(1, self.refresh_data)

    def watch_ram_percent(self, value: float) -> None:
        '''Called automatically when ram_percent changes.'''
        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()

Tests

import inspect

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

def test_compose_yields_footer():
    src = inspect.getsource(RamApp.compose)
    assert 'Footer()' in src, 'yield Footer() in compose()'

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

def test_no_mount_in_init():
    src = inspect.getsource(RamApp)
    # __init__ method must not call self.mount
    if '__init__' in src:
        init_section = src[src.index('__init__'):src.index('def compose')]
        assert 'self.mount(' not in init_section, \
            'Do not call self.mount() in __init__ β€” use compose() instead'

def test_watch_handler_defined():
    src = inspect.getsource(RamApp)
    assert 'watch_ram_percent' in src, \
        'Define watch_ram_percent(self, value) to react to reactive changes'

def test_no_manual_refresh():
    src = inspect.getsource(RamApp.refresh_data)
    assert 'self.refresh()' not in src, \
        'Remove self.refresh() β€” reactive attributes trigger updates automatically'

def test_set_interval_called_in_on_mount():
    src = inspect.getsource(RamApp.on_mount)
    assert 'set_interval' in src, \
        'Call self.set_interval(1, self.refresh_data) in on_mount()'

Resources