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:
compose()not__init__— mount widgets incompose(). The DOM doesn’t exist yet in__init__.reactive+watch_— whenself.ram_percent = valuechanges the reactive,watch_ram_percent(value)is called automatically. Noself.refresh()needed.Header()andFooter()— 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 changedBasic 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):
passTests
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'
)