Textual Skill
Textual is a Python framework for building rich terminal user interfaces (TUIs). It provides a widget toolkit with CSS-based styling, an event system with message passing, reactive data binding, screen management, and async worker support.
When to Use This Skill
Use Textual when:
- Building interactive terminal applications
- Creating dashboards, forms, or data browsers in the terminal
- Need rich UI with layout, styling, and input handling
- Want CSS-like styling for terminal apps
- Building apps with multiple screens or modal dialogs
Architecture
App (application root)
└── Screen (layered views, one active at a time)
└── Widget (UI components, nested in a DOM tree)
└── Child Widgets...
- App is the top-level container. It manages screens, themes, key bindings, and the event loop.
- Screen is a full-screen layer within the app. Screens stack; only the top screen is visible.
- Widget is a UI component. Widgets form a DOM tree, can have children, and are styled with CSS.
Creating an App
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: 40;
padding: 1 2;
border: solid green;
text-align: center;
}
"""
BINDINGS = [
("q", "quit", "Quit"),
("d", "toggle_dark", "Toggle dark mode"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Static("Hello, World!", id="greeting")
yield Footer()
def action_toggle_dark(self) -> None:
self.theme = "textual-light" if self.theme == "textual-dark" else "textual-dark"
if __name__ == "__main__":
MyApp().run()
App Class Variables
| Variable | Type | Purpose |
|---|---|---|
CSS |
str |
Inline CSS rules |
CSS_PATH |
str | list[str] |
Path(s) to .tcss CSS files |
BINDINGS |
list[BindingType] |
Key bindings |
TITLE |
str |
App title (shown in Header) |
SUB_TITLE |
str |
App subtitle |
SCREENS |
dict[str, Callable] |
Named screen factories |
MODES |
dict[str, str | Callable] |
App modes with base screens |
COMMANDS |
set[type[Provider]] |
Command palette providers |
ENABLE_COMMAND_PALETTE |
bool |
Enable command palette (default: True) |
Key App Methods
# Run the app
app.run()
# Exit with optional return value
app.exit(result=value)
# Screen management
app.push_screen(screen, callback=None) # Push screen onto stack
await app.push_screen_wait(screen) # Push and await dismiss result
app.pop_screen() # Pop top screen
app.switch_screen(screen) # Replace top screen
app.install_screen(screen, name="name") # Install named screen
# Mode management
app.switch_mode("mode_name")
# Widget management
app.mount(widget) # Mount widget on current screen
app.query(selector) # Query DOM with CSS selector
app.query_one("#my-id", Widget) # Query single widget
app.set_focus(widget) # Set focus to widget
# Notifications
app.notify("Message", title="Title", severity="information") # information/warning/error
# Timers
app.set_timer(delay, callback) # One-shot timer
app.set_interval(interval, callback) # Repeating timer
app.call_later(callback) # Call on next idle
# Workers
app.run_worker(async_func, thread=False) # Run async or threaded worker
# Suspend terminal to run external process
async with app.suspend():
os.system("vim file.txt")
Widgets
Built-in Widgets
Textual ships with 35+ widgets. See references/WIDGETS.md for the complete reference. Key widgets:
| Widget | Purpose |
|---|---|
Header, Footer |
App chrome with title and key bindings |
Static, Label |
Display text |
Button |
Clickable button with variants |
Input, TextArea |
Text input (single/multi-line) |
Select, SelectionList |
Dropdown and multi-select |
DataTable |
Interactive tabular data |
Tree, DirectoryTree |
Hierarchical tree views |
ListView |
Scrollable list of items |
TabbedContent |
Tabbed content panels |
Markdown |
Render markdown content |
ProgressBar |
Progress indicator |
RichLog |
Scrollable formatted log |
Checkbox, RadioSet, Switch |
Toggle controls |
Custom Widgets
from textual.widget import Widget
from textual.reactive import reactive
class Counter(Widget):
"""A custom counter widget."""
DEFAULT_CSS = """
Counter {
height: auto;
padding: 1 2;
border: solid $accent;
}
"""
BINDINGS = [
("up", "increment", "Increment"),
("down", "decrement", "Decrement"),
]
can_focus = True
count: reactive[int] = reactive(0)
def render(self) -> str:
return f"Count: {self.count}"
def action_increment(self) -> None:
self.count += 1
def action_decrement(self) -> None:
self.count -= 1
def watch_count(self, old_value: int, new_value: int) -> None:
"""Called when count changes."""
if new_value > 10:
self.notify("Count exceeded 10!", severity="warning")
Composition
Build widget trees with compose(). Use context managers for containers:
from textual.containers import Horizontal, Vertical, Grid
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
yield Button("OK", variant="primary", id="ok")
yield Button("Cancel", id="cancel")
yield Footer()
Containers
| Container | Layout | Description |
|---|---|---|
Container |
vertical | Basic expanding container |
Vertical |
vertical | Expanding, no scrollbars |
VerticalGroup |
vertical | Auto height, no scrollbars |
VerticalScroll |
vertical | With vertical scrollbar |
Horizontal |
horizontal | Expanding, no scrollbars |
HorizontalGroup |
horizontal | Auto height, no scrollbars |
HorizontalScroll |
horizontal | With horizontal scrollbar |
ScrollableContainer |
vertical | Full scrollbars, focusable |
Center |
vertical | Horizontal center alignment |
Middle |
vertical | Vertical middle alignment |
Grid |
grid | Grid layout |
Key Widget Methods
# Composition and mounting
widget.mount(child) # Add child widget
widget.mount(child, before=other) # Insert before another
await widget.remove() # Remove from DOM
widget.remove_children() # Remove all children
await widget.recompose() # Rebuild compose tree
# Rendering
widget.refresh() # Request repaint
widget.refresh(layout=True) # Request layout recalc
# Focus
widget.focus() # Take focus
widget.blur() # Release focus
# Scrolling
widget.scroll_to(x, y, animate=True)
widget.scroll_home()
widget.scroll_end()
widget.scroll_to_widget(child)
widget.scroll_visible()
# Animation
widget.animate("opacity", 1.0, duration=0.5)
# Data binding
child.data_bind(Counter.count) # Bind reactive from parent
Textual CSS
Textual uses a CSS-like language (TCSS) for styling. See references/CSS.md for the complete property reference.
CSS Sources
# Inline CSS on the class
class MyApp(App):
CSS = """
Screen { background: $surface; }
"""
# External .tcss file
class MyApp(App):
CSS_PATH = "my_app.tcss"
# Widget-scoped CSS
class MyWidget(Widget):
DEFAULT_CSS = """
MyWidget { height: auto; padding: 1; }
"""
SCOPED_CSS = True # Default: True. Selectors only match within this widget.
Selectors
| Selector | Syntax | Example |
|---|---|---|
| Type | WidgetType |
Button { color: red; } |
| ID | #id |
#sidebar { width: 30; } |
| Class | .class |
.error { color: red; } |
| Universal | * |
* { margin: 1; } |
| Pseudo-class | :state |
Button:hover { background: $accent; } |
| Child | Parent > Child |
Horizontal > Button { width: 1fr; } |
| Descendant | Ancestor Descendant |
Screen Input { border: solid; } |
| Nesting | & |
& > .child { ... } (inside nested rules) |
Pseudo-classes: :hover, :focus, :focus-within, :disabled, :enabled, :dark, :light, :even, :odd, :first-child, :last-child, :blur, :can-focus, :inline
Key CSS Properties
| Category | Properties |
|---|---|
| Layout | layout (vertical/horizontal/grid), display, dock, align, content-align |
| Sizing | width, height, min-width, max-width, min-height, max-height |
| Spacing | margin, padding, offset |
| Colors | background, color, tint, opacity, text-opacity |
| Borders | border, outline, border-title-align |
| Text | text-align, text-style, text-wrap, text-overflow |
| Scrolling | overflow, scrollbar-size, scrollbar-color |
| Grid | grid-size, grid-columns, grid-rows, grid-gutter, column-span, row-span |
| Position | position (relative/absolute), layer, layers |
Units
| Unit | Description | Example |
|---|---|---|
| (number) | Cell units | width: 30; |
% |
Percentage of parent | width: 50%; |
fr |
Fraction of remaining space | width: 1fr; |
vw / vh |
Viewport width/height | width: 50vw; |
w / h |
Container width/height % | width: 50w; |
auto |
Automatic sizing | height: auto; |
CSS Variables
/* Use theme variables */
Screen {
background: $surface;
color: $text;
border: solid $accent;
}
/* Custom variables */
$my-color: red;
.highlight { background: $my-color; }
Events and Messages
Handler Naming Convention
Event handlers are methods named on_<event_name>:
from textual import on
from textual.events import Key, Mount
from textual.widgets import Button
class MyApp(App):
def on_mount(self) -> None:
"""Called when app is mounted."""
self.title = "My App"
def on_key(self, event: Key) -> None:
"""Called on any key press."""
self.notify(f"Key: {event.key}")
# Widget-specific message handler (ClassName_MessageName)
def on_button_pressed(self, event: Button.Pressed) -> None:
self.notify(f"Button pressed: {event.button.id}")
The @on Decorator
Use @on for CSS-selector-filtered handlers:
from textual import on
class MyApp(App):
@on(Button.Pressed, "#ok")
def handle_ok(self) -> None:
self.notify("OK pressed")
@on(Button.Pressed, "#cancel")
def handle_cancel(self) -> None:
self.notify("Cancelled")
@on(Input.Changed, "#search")
def handle_search(self, event: Input.Changed) -> None:
self.notify(f"Search: {event.value}")
Custom Messages
from textual.message import Message
class MyWidget(Widget):
class Selected(Message):
"""Emitted when item is selected."""
def __init__(self, item: str) -> None:
super().__init__()
self.item = item
def on_click(self) -> None:
self.post_message(self.Selected("item-1"))
Messages bubble up through the DOM. Use event.stop() to stop propagation. Use event.prevent_default() to suppress default behavior.
Common Events
See references/EVENTS.md for the complete reference.
| Event | When |
|---|---|
Mount |
Widget added to DOM |
Unmount |
Widget removed from DOM |
Key |
Key pressed |
Click |
Widget clicked |
Focus / Blur |
Widget gains/loses focus |
Resize |
App or widget resized |
Show / Hide |
Widget becomes visible/hidden |
Key Bindings and Actions
from textual.binding import Binding
class MyApp(App):
BINDINGS = [
# Simple: (key, action, description)
("q", "quit", "Quit"),
("d", "toggle_dark", "Dark mode"),
# Full Binding object for more control
Binding("ctrl+s", "save", "Save", show=True, priority=True),
Binding("ctrl+z", "undo", "Undo", show=False),
]
def action_save(self) -> None:
"""Action methods are prefixed with action_."""
self.notify("Saved!")
# Dynamic actions: return True/False/None to enable/disable/hide
def check_action(self, action: str, parameters: tuple) -> bool | None:
if action == "save" and not self.has_changes:
return False # Disable (grayed out in footer)
return True
Built-in actions: quit, bell, focus_next, focus_previous, toggle_dark, screenshot, command_palette, maximize, minimize
Reactivity
from textual.reactive import reactive, var
class MyWidget(Widget):
# reactive: triggers refresh on change
name: reactive[str] = reactive("default")
# var: no automatic refresh
count: var[int] = var(0)
# With options
items: reactive[list] = reactive(list, layout=True, recompose=True)
# Watcher: called when value changes
def watch_name(self, old_value: str, new_value: str) -> None:
self.log(f"Name changed: {old_value} -> {new_value}")
# Validator: called before value is set
def validate_count(self, value: int) -> int:
return max(0, value) # Clamp to non-negative
# Compute: derived value
display_name: reactive[str] = reactive("")
def compute_display_name(self) -> str:
return self.name.upper()
Reactive Options
| Option | Default | Effect |
|---|---|---|
repaint |
True |
Refresh widget on change |
layout |
False |
Recalculate layout on change |
init |
False |
Call watcher on mount |
always_update |
False |
Call watcher even if value unchanged |
recompose |
False |
Rebuild compose tree on change |
bindings |
False |
Refresh bindings on change |
Data Binding
Pass reactive values from parent to child:
class Child(Widget):
value: reactive[str] = reactive("")
class Parent(Widget):
value: reactive[str] = reactive("hello")
def compose(self) -> ComposeResult:
yield Child().data_bind(Parent.value)
Screens
See references/SCREENS.md for the complete reference.
from textual.screen import Screen, ModalScreen
class SettingsScreen(Screen):
BINDINGS = [("escape", "dismiss")]
def compose(self) -> ComposeResult:
yield Static("Settings")
yield Button("Close", id="close")
def on_button_pressed(self) -> None:
self.dismiss()
# Modal with result
class ConfirmDialog(ModalScreen[bool]):
def compose(self) -> ComposeResult:
with Vertical():
yield Static("Are you sure?")
with Horizontal():
yield Button("Yes", id="yes", variant="primary")
yield Button("No", id="no")
@on(Button.Pressed, "#yes")
def confirm(self) -> None:
self.dismiss(True)
@on(Button.Pressed, "#no")
def cancel(self) -> None:
self.dismiss(False)
# Usage
class MyApp(App):
def action_settings(self) -> None:
self.push_screen(SettingsScreen())
def action_confirm(self) -> None:
self.push_screen(ConfirmDialog(), callback=self.handle_confirm)
def handle_confirm(self, confirmed: bool) -> None:
if confirmed:
self.notify("Confirmed!")
Workers
See references/WORKERS.md for the complete reference.
from textual.worker import Worker, get_current_worker
from textual import work
class MyApp(App):
# Decorator approach
@work(exclusive=True)
async def fetch_data(self, url: str) -> None:
worker = get_current_worker()
response = await fetch(url)
if not worker.is_cancelled:
self.notify(f"Got {len(response)} bytes")
# Thread worker for blocking I/O
@work(thread=True)
def load_file(self, path: str) -> str:
with open(path) as f:
return f.read()
Testing
See references/TESTING.md for the complete reference.
import pytest
from my_app import MyApp
@pytest.mark.asyncio
async def test_app():
async with MyApp().run_test() as pilot:
await pilot.press("q") # Press key
await pilot.click("#ok") # Click widget
await pilot.pause() # Wait for messages
# Assert on app state
assert pilot.app.query_one("#label", Static).renderable == "Hello"
Additional References
- Widgets Reference - All built-in widgets
- CSS Reference - All CSS properties, types, and selectors
- Events Reference - Events, messages, and reactivity details
- Screens Reference - Screen management and modes
- Testing Reference - Testing with Pilot
- Workers Reference - Async workers and threading