Textual Cheat Sheet
Overview
Textual is a Python framework for building sophisticated Terminal User Interface (TUI) applications. Built on top of Rich, it provides a widget system with CSS-like styling, event handling, reactive data binding, and an async-first architecture. Textual apps look and behave like modern web applications but run entirely in the terminal.
Textual supports mouse input, scrolling, animations, and rich content rendering. Its CSS-based styling system allows separation of layout and appearance from logic, and its component model makes it easy to build complex, interactive applications for data visualization, dashboards, development tools, and more.
Installation
pip install textual
# With dev tools
pip install textual-dev
# Verify
python -m textual
Core Concepts
Minimal Application
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
class MyApp(App):
"""A minimal Textual application."""
CSS = """
Screen {
align: center middle;
}
#greeting {
width: 50;
height: 5;
border: heavy green;
content-align: center middle;
}
"""
def compose(self) -> ComposeResult:
yield Header()
yield Static("Hello, Textual!", id="greeting")
yield Footer()
if __name__ == "__main__":
app = MyApp()
app.run()
Widgets and Layout
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Header, Footer, Button, Static, Input, Label
class DashboardApp(App):
CSS = """
.sidebar {
width: 30;
background: $surface;
border-right: solid $primary;
padding: 1;
}
.main {
padding: 1;
}
Button {
margin: 1 0;
width: 100%;
}
"""
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
with Vertical(classes="sidebar"):
yield Label("Navigation")
yield Button("Dashboard", id="btn-dash")
yield Button("Settings", id="btn-settings")
yield Button("Logs", id="btn-logs")
with Container(classes="main"):
yield Static("Welcome to the Dashboard")
yield Input(placeholder="Search...")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
self.query_one(".main > Static", Static).update(
f"Navigated to: {event.button.label}"
)
Common Widgets
Data Table
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Header, Footer
class TableApp(App):
def compose(self) -> ComposeResult:
yield Header()
yield DataTable()
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("Name", "Status", "CPU", "Memory")
table.add_rows([
("web-01", "Running", "23%", "512MB"),
("web-02", "Running", "45%", "768MB"),
("worker-01", "Stopped", "0%", "0MB"),
("db-01", "Running", "67%", "2GB"),
])
table.cursor_type = "row"
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
self.notify(f"Selected row: {event.row_key}")
Input and Forms
from textual.app import App, ComposeResult
from textual.widgets import Input, Button, Static, Label
from textual.containers import Vertical
class FormApp(App):
CSS = """
Input { margin: 1 0; }
Button { margin: 1 0; }
"""
def compose(self) -> ComposeResult:
with Vertical():
yield Label("Login Form")
yield Input(placeholder="Username", id="username")
yield Input(placeholder="Password", password=True, id="password")
yield Button("Submit", variant="primary")
yield Static("", id="result")
def on_button_pressed(self, event: Button.Pressed) -> None:
username = self.query_one("#username", Input).value
self.query_one("#result", Static).update(
f"Logged in as: {username}"
)
Tabs and Content Switching
from textual.app import App, ComposeResult
from textual.widgets import TabbedContent, TabPane, Static, Log
class TabbedApp(App):
def compose(self) -> ComposeResult:
with TabbedContent():
with TabPane("Overview", id="overview"):
yield Static("System overview content")
with TabPane("Logs", id="logs"):
yield Log()
with TabPane("Config", id="config"):
yield Static("Configuration settings")
CSS Styling
External CSS File
# app.py
class MyApp(App):
CSS_PATH = "styles.tcss"
/* styles.tcss */
Screen {
background: $surface;
}
.sidebar {
width: 30;
background: $panel;
border-right: thick $primary;
padding: 1 2;
}
.content {
padding: 2 4;
}
Button {
width: 100%;
margin: 1 0;
}
Button:hover {
background: $accent;
}
DataTable > .datatable--header {
background: $primary;
color: $text;
}
#status-ok {
color: green;
}
#status-error {
color: red;
text-style: bold;
}
Configuration
Bindings and Actions
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Header, Footer, Static
class MyApp(App):
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("d", "toggle_dark", "Toggle Dark Mode"),
Binding("r", "refresh", "Refresh"),
Binding("ctrl+s", "save", "Save"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Static("Press keys to trigger actions", id="content")
yield Footer()
def action_toggle_dark(self) -> None:
self.dark = not self.dark
def action_refresh(self) -> None:
self.query_one("#content", Static).update("Refreshed!")
def action_save(self) -> None:
self.notify("Saved!", severity="information")
Reactive Properties
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Static
class Counter(Static):
count: reactive[int] = reactive(0)
def render(self) -> str:
return f"Count: {self.count}"
def watch_count(self, value: int) -> None:
if value > 10:
self.styles.color = "red"
def on_click(self) -> None:
self.count += 1
Advanced Usage
Workers (Background Tasks)
from textual.app import App, ComposeResult
from textual.widgets import Static, Button
from textual.worker import Worker, WorkerState
class AsyncApp(App):
def compose(self) -> ComposeResult:
yield Button("Fetch Data", id="fetch")
yield Static("Ready", id="status")
async def on_button_pressed(self, event: Button.Pressed) -> None:
self.run_worker(self.fetch_data())
async def fetch_data(self) -> None:
self.query_one("#status", Static).update("Loading...")
import asyncio
await asyncio.sleep(2) # Simulate API call
self.query_one("#status", Static).update("Data loaded!")
Screens and Navigation
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Static, Button
class SettingsScreen(Screen):
def compose(self) -> ComposeResult:
yield Static("Settings Page")
yield Button("Back", id="back")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "back":
self.app.pop_screen()
class MainApp(App):
SCREENS = {"settings": SettingsScreen}
def compose(self) -> ComposeResult:
yield Button("Settings", id="settings")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "settings":
self.push_screen("settings")
Dev Tools
# Run with dev tools
textual run --dev my_app.py
# Console for debugging
textual console
# Take screenshots
textual run my_app.py --screenshot screenshot.svg
Troubleshooting
| Issue | Solution |
|---|---|
| App crashes on start | Check compose() returns valid widgets |
| CSS not applying | Verify CSS_PATH or CSS string; check selectors |
| Key bindings not working | Ensure BINDINGS list is correct; check focus |
| Slow rendering | Reduce widget count; use virtualized lists |
| Mouse not working | Check terminal supports mouse events |
| Colors wrong | Verify terminal color support; try --force-color |
# Run with dev mode
textual run --dev app.py
# Debug CSS
textual run app.py --css-variables
# Check terminal capabilities
python -m textual