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:
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 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 QuitReactive 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 exists | Runs 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()'