| name | textual |
| description | Build terminal user interfaces (TUIs) with Textual, a Python framework for creating rich, interactive applications in the terminal. Handles widgets, layouts, CSS styling, events, screens, and async workers. Use when building TUI apps or when the user mentions textual. |
| license | MIT |
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
app.run()
app.exit(result=value)
app.push_screen(screen, callback=None)
await app.push_screen_wait(screen)
app.pop_screen()
app.switch_screen(screen)
app.install_screen(screen, name="name")
app.switch_mode("mode_name")
app.mount(widget)
app.query(selector)
app.query_one("#my-id", Widget)
app.set_focus(widget)
app.notify("Message", title="Title", severity="information")
app.set_timer(delay, callback)
app.set_interval(interval, callback)
app.call_later(callback)
app.run_worker(async_func, thread=False)
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
widget.mount(child)
widget.mount(child, before=other)
await widget.remove()
widget.remove_children()
await widget.recompose()
widget.refresh()
widget.refresh(layout=True)
widget.focus()
widget.blur()
widget.scroll_to(x, y, animate=True)
widget.scroll_home()
widget.scroll_end()
widget.scroll_to_widget(child)
widget.scroll_visible()
widget.animate("opacity", 1.0, duration=0.5)
child.data_bind(Counter.count)
Textual CSS
Textual uses a CSS-like language (TCSS) for styling. See references/CSS.md for the complete property reference.
CSS Sources
class MyApp(App):
CSS = """
Screen { background: $surface; }
"""
class MyApp(App):
CSS_PATH = "my_app.tcss"
class MyWidget(Widget):
DEFAULT_CSS = """
MyWidget { height: auto; padding: 1; }
"""
SCOPED_CSS = True
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
Screen {
background: $surface;
color: $text;
border: solid $accent;
}
$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}")
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 = [
("q", "quit", "Quit"),
("d", "toggle_dark", "Dark mode"),
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!")
def check_action(self, action: str, parameters: tuple) -> bool | None:
if action == "save" and not self.has_changes:
return False
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):
name: reactive[str] = reactive("default")
count: var[int] = var(0)
items: reactive[list] = reactive(list, layout=True, recompose=True)
def watch_name(self, old_value: str, new_value: str) -> None:
self.log(f"Name changed: {old_value} -> {new_value}")
def validate_count(self, value: int) -> int:
return max(0, 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()
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)
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):
@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")
@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")
await pilot.click("#ok")
await pilot.pause()
assert pilot.app.query_one("#label", Static).renderable == "Hello"
Additional References