textual
Scannednpx machina-cli add skill johnlarkin1/claude-code-extensions/textual --openclawTextual TUI Framework
Build terminal applications with Textual's web-inspired architecture: App → Screen → Widget.
Quick Start
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
class MyApp(App):
CSS_PATH = "styles.tcss"
BINDINGS = [("q", "quit", "Quit"), ("d", "toggle_dark", "Dark Mode")]
def compose(self) -> ComposeResult:
yield Header()
yield Static("Hello, World!")
yield Footer()
def action_toggle_dark(self) -> None:
self.theme = "textual-dark" if self.theme == "textual-light" else "textual-light"
if __name__ == "__main__":
MyApp().run()
Core Concepts
Widget Lifecycle
__init__()→compose()→on_mount()→on_show()/on_hide()→on_unmount()
Reactivity
from textual.reactive import reactive, var
class MyWidget(Widget):
count = reactive(0) # Triggers refresh on change
internal = var("") # No automatic refresh
def watch_count(self, new_value: int) -> None:
"""Called when count changes."""
self.styles.background = "green" if new_value > 0 else "red"
def validate_count(self, value: int) -> int:
"""Constrain values."""
return max(0, min(100, value))
Events and Messages
from textual import on
from textual.message import Message
class MyWidget(Widget):
class Selected(Message):
def __init__(self, value: str) -> None:
self.value = value
super().__init__()
def on_click(self) -> None:
self.post_message(self.Selected("item"))
class MyApp(App):
# Handler naming: on_<widget>_<message>
def on_button_pressed(self, event: Button.Pressed) -> None:
self.log(f"Button {event.button.id} pressed")
@on(Button.Pressed, "#submit") # CSS selector filtering
def handle_submit(self) -> None:
pass
Data Flow
- Attributes down: Parent sets child properties directly
- Messages up: Child posts messages to parent via
post_message()
Screens
from textual.screen import Screen
class WelcomeScreen(Screen):
BINDINGS = [("escape", "app.pop_screen", "Back")]
def compose(self) -> ComposeResult:
yield Static("Welcome!")
yield Button("Continue", id="continue")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "continue":
self.app.push_screen("main")
class MyApp(App):
SCREENS = {"welcome": WelcomeScreen, "main": MainScreen}
def on_mount(self) -> None:
self.push_screen("welcome")
Custom Widgets
Simple Widget
class Greeting(Widget):
def render(self) -> RenderResult:
return "Hello, [bold]World[/bold]!"
Compound Widget
class LabeledButton(Widget):
DEFAULT_CSS = """
LabeledButton { layout: horizontal; height: auto; }
LabeledButton Label { width: 1fr; }
"""
def __init__(self, label: str, button_text: str) -> None:
self.label_text = label
self.button_text = button_text
super().__init__()
def compose(self) -> ComposeResult:
yield Label(self.label_text)
yield Button(self.button_text)
Focusable Widget
class Counter(Widget):
can_focus = True
BINDINGS = [("up", "increment", "+"), ("down", "decrement", "-")]
count = reactive(0)
def action_increment(self) -> None:
self.count += 1
Layout Patterns
Containers
from textual.containers import Horizontal, Vertical, Grid, VerticalScroll
def compose(self) -> ComposeResult:
with Vertical():
with Horizontal():
yield Button("Left")
yield Button("Right")
with VerticalScroll():
for i in range(100):
yield Label(f"Item {i}")
Grid CSS
Grid {
layout: grid;
grid-size: 3 2; /* columns rows */
grid-columns: 1fr 2fr 1fr;
grid-gutter: 1 2;
}
#wide { column-span: 2; }
Docking
#header { dock: top; height: 3; }
#sidebar { dock: left; width: 25; }
#footer { dock: bottom; height: 1; }
Workers (Async)
from textual import work
class MyApp(App):
@work(exclusive=True) # Cancels previous
async def fetch_data(self, url: str) -> None:
async with httpx.AsyncClient() as client:
response = await client.get(url)
self.query_one("#result").update(response.text)
@work(thread=True) # For sync APIs
def sync_operation(self) -> None:
result = blocking_call()
self.call_from_thread(self.update_ui, result)
Testing
async def test_app():
app = MyApp()
async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.click("#button")
await pilot.pause() # Wait for messages
assert app.query_one("#status").render() == "Done"
Common Operations
# Query widgets
self.query_one("#id")
self.query_one(Button)
self.query(".class")
# CSS classes
widget.add_class("active")
widget.toggle_class("visible")
widget.set_class(condition, "active")
# Visibility
widget.display = True/False
# Mount/remove
self.mount(NewWidget())
widget.remove()
# Timers
self.set_interval(1.0, callback)
self.set_timer(5.0, callback)
# Exit
self.exit(return_code=0)
References
- Widget catalog and messages: See references/widgets.md
- CSS properties and selectors: See references/css.md
- Complete examples: See references/examples.md
- Official docs: https://textual.textualize.io/
Source
git clone https://github.com/johnlarkin1/claude-code-extensions/blob/main/skills/textual/SKILL.mdView on GitHub Overview
Textual enables building terminal-based user interfaces using a web-inspired App → Screen → Widget architecture. Start quickly with a minimal example and harness reactive state, event-driven messages, and screen navigation to create responsive TUI apps. It also covers custom widgets and TCSS styling to craft polished terminal interfaces.
How This Skill Works
A Textual app is composed of App, Screen, and Widget objects arranged via compose(). State is made reactive with reactive() and var, which trigger UI refreshes, while events and messages flow upward and downward through post_message() and on_<widget>_<message> handlers. Screens can be pushed or popped to navigate views, and styling is applied with TCSS.
When to Use It
- Starting a new Textual project to build a terminal UI
- Adding and navigating multiple screens and widgets
- Theming and styling with TCSS for consistent visuals
- Implementing reactive state and event-driven interactions
- Testing, debugging, and refining TUI behavior
Quick Start
- Step 1: Create a Textual App class and implement compose() to build UI elements (Header, Static, Footer) and define any bindings.
- Step 2: Add reactive state and handlers (e.g., on_button_pressed) to drive UI changes.
- Step 3: Run the script (e.g., python your_app.py) and iterate with TCSS styling and screen navigation.
Best Practices
- Define a clear App → Screen → Widget hierarchy to keep complexity manageable
- Use reactive and watch_<name> methods to drive UI changes rather than manual refreshes
- Keep data flow predictable: pass attributes down, post messages up with post_message()
- Handle events with on_<widget>_<message> and optionally constrain with CSS selectors
- Build small, reusable custom widgets (Simple/Compound) and test them in isolation
Example Use Cases
- Welcome screen with a 'Continue' button that pushes the main screen
- A Hello World Static widget used to verify rendering
- A Compound Widget combining a label and a button with DEFAULT_CSS
- A Focusable Counter widget with keyboard bindings to increment/decrement
- A simple layout using Vertical/Horizontal containers to organize content