# Textual Textual is a Python framework for building cross-platform terminal user interfaces (TUIs). It provides a modern, declarative API inspired by web development patterns, allowing developers to create rich, interactive applications that run in the terminal or web browser. Textual combines an async-first architecture with a CSS-based styling system, reactive state management, and a comprehensive widget library to enable rapid development of sophisticated console applications. The framework features a component-based architecture where applications are built by composing widgets within an App class. Textual handles keyboard and mouse input, manages layout through CSS, and provides automatic screen refresh. Built on top of Rich for rendering, Textual supports themes, animations, and a powerful testing framework for writing robust UI tests. ## App Class - Application Foundation The App class is the base class for all Textual applications. It manages the application lifecycle, screens, widgets, and event processing. Subclass App to create your application, implement `compose()` to define the UI, and call `run()` to start the app. ```python from textual.app import App, ComposeResult from textual.widgets import Header, Footer, Button, Static class MyApp(App): """A Textual application example.""" CSS = """ Screen { align: center middle; } #greeting { padding: 1 2; background: $primary; } """ BINDINGS = [ ("q", "quit", "Quit"), ("d", "toggle_dark", "Toggle Dark Mode"), ] def compose(self) -> ComposeResult: """Create child widgets for the app.""" yield Header() yield Static("Hello, Textual!", id="greeting") yield Button("Click Me", id="click-btn", variant="primary") yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button press events.""" if event.button.id == "click-btn": self.query_one("#greeting", Static).update("Button clicked!") def action_toggle_dark(self) -> None: """Toggle dark mode.""" self.theme = "textual-light" if self.theme == "textual-dark" else "textual-dark" if __name__ == "__main__": app = MyApp() app.run() ``` ## Widget Class - Building UI Components Widgets are reusable UI components that render content and handle events. Create custom widgets by subclassing Widget and implementing `render()` for simple content or `compose()` for compound widgets. Widgets support reactive attributes, CSS styling, and event handling. ```python from textual.app import App, ComposeResult from textual.widget import Widget from textual.widgets import Static, Button, Label from textual.reactive import reactive from textual.containers import Horizontal class Counter(Widget): """A custom counter widget with increment/decrement buttons.""" count = reactive(0) DEFAULT_CSS = """ Counter { layout: horizontal; height: auto; padding: 1; border: solid $accent; } Counter Label { width: 10; text-align: center; } """ def compose(self) -> ComposeResult: yield Button("-", id="decrement", variant="error") yield Label(str(self.count)) yield Button("+", id="increment", variant="success") def watch_count(self, count: int) -> None: """Called when count changes.""" self.query_one(Label).update(str(count)) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "increment": self.count += 1 elif event.button.id == "decrement": self.count -= 1 class CounterApp(App): def compose(self) -> ComposeResult: yield Counter() yield Counter() if __name__ == "__main__": CounterApp().run() ``` ## Reactive Attributes - Smart State Management Reactive attributes automatically trigger UI updates when their values change. Use `reactive()` for attributes that should refresh the widget, `var()` for non-refreshing reactive attributes. Implement `watch_*` methods to respond to changes and `validate_*` methods to validate values. ```python from textual.app import App, ComposeResult from textual.reactive import reactive, var from textual.widgets import Static, Input from textual.color import Color class ColorDisplay(Static): """Widget that displays a color preview.""" # Reactive attributes with automatic refresh color_value = reactive("blue", layout=True) # Non-refreshing reactive (use var) click_count = var(0) def render(self) -> str: return f"Color: {self.color_value} (clicked {self.click_count} times)" def validate_color_value(self, value: str) -> str: """Validate the color value.""" try: Color.parse(value) return value except Exception: return "red" # Default to red if invalid def watch_color_value(self, old_value: str, new_value: str) -> None: """Called when color_value changes.""" try: color = Color.parse(new_value) self.styles.background = color except Exception: pass def on_click(self) -> None: self.click_count += 1 class ColorApp(App): def compose(self) -> ComposeResult: yield Input(placeholder="Enter a color (e.g., red, #ff0000)") yield ColorDisplay(id="display") def on_input_changed(self, event: Input.Changed) -> None: self.query_one("#display", ColorDisplay).color_value = event.value if __name__ == "__main__": ColorApp().run() ``` ## Event Handling and the @on Decorator Events are messages sent between widgets in response to user actions. Handle events with `on_*` methods following the naming convention, or use the `@on` decorator for more control. The decorator allows CSS selectors to filter which widgets trigger the handler. ```python from textual import on from textual.app import App, ComposeResult from textual.widgets import Button, Input, Static from textual.containers import Vertical class EventApp(App): CSS = """ #output { height: 5; border: solid green; } """ def compose(self) -> ComposeResult: yield Input(placeholder="Type something...", id="text-input") yield Button("Submit", id="submit", variant="primary") yield Button("Clear", id="clear", variant="warning") yield Button("Quit", id="quit", variant="error") yield Static("Output will appear here", id="output") # Method naming convention: on__ def on_input_changed(self, event: Input.Changed) -> None: """Called when any Input widget changes.""" self.query_one("#output", Static).update(f"Typing: {event.value}") # Using @on decorator with CSS selector to target specific widgets @on(Button.Pressed, "#submit") def handle_submit(self) -> None: """Only triggered by the submit button.""" text = self.query_one("#text-input", Input).value self.query_one("#output", Static).update(f"Submitted: {text}") @on(Button.Pressed, "#clear") def handle_clear(self) -> None: """Only triggered by the clear button.""" self.query_one("#text-input", Input).value = "" self.query_one("#output", Static).update("Cleared!") @on(Button.Pressed, "#quit") def handle_quit(self) -> None: """Only triggered by the quit button.""" self.exit() if __name__ == "__main__": EventApp().run() ``` ## CSS Styling System Textual uses a CSS dialect similar to web CSS for styling widgets. Define styles in external `.tcss` files via `CSS_PATH`, inline via `CSS` class variable, or in widget's `DEFAULT_CSS`. Selectors support type, ID, class, and pseudo-class matching. ```python from textual.app import App, ComposeResult from textual.widgets import Static, Button from textual.containers import Container, Horizontal class StyledApp(App): CSS = """ /* Type selector */ Screen { background: $surface; } /* ID selector */ #main-container { width: 80%; height: auto; margin: 2 4; padding: 1 2; border: heavy $primary; } /* Class selector */ .title { text-style: bold; color: $text; text-align: center; width: 100%; } /* Descendant selector */ #main-container Button { margin: 1; } /* Pseudo-class selector */ Button:hover { background: $accent; } Button:focus { border: double $success; } /* Component classes (for custom widgets) */ .warning-box { background: $warning 20%; color: $warning; padding: 1; } """ def compose(self) -> ComposeResult: with Container(id="main-container"): yield Static("Styled Textual App", classes="title") yield Static("This is a warning message", classes="warning-box") with Horizontal(): yield Button("Primary", variant="primary") yield Button("Success", variant="success") yield Button("Warning", variant="warning") yield Button("Error", variant="error") if __name__ == "__main__": StyledApp().run() ``` ## Screens and Navigation Screens are full-terminal containers that manage widgets. Apps can have multiple screens organized in a stack. Push screens to show new views, pop to return to previous screens. Use `ModalScreen` for dialogs that overlay the current screen. ```python from textual.app import App, ComposeResult from textual.screen import Screen, ModalScreen from textual.widgets import Button, Static, Label from textual.containers import Center, Vertical class ConfirmScreen(ModalScreen[bool]): """A modal confirmation dialog.""" CSS = """ ConfirmScreen { align: center middle; } #dialog { width: 40; height: auto; border: thick $error; background: $surface; padding: 1 2; } #dialog Button { width: 100%; margin-top: 1; } """ def __init__(self, message: str) -> None: super().__init__() self.message = message def compose(self) -> ComposeResult: with Vertical(id="dialog"): yield Label(self.message) yield Button("Yes", id="yes", variant="success") yield Button("No", id="no", variant="error") def on_button_pressed(self, event: Button.Pressed) -> None: self.dismiss(event.button.id == "yes") class SettingsScreen(Screen): """Settings screen.""" BINDINGS = [("escape", "app.pop_screen", "Back")] def compose(self) -> ComposeResult: yield Static("Settings Screen") yield Button("Back to Main", id="back") def on_button_pressed(self) -> None: self.app.pop_screen() class MainApp(App): BINDINGS = [ ("s", "push_screen('settings')", "Settings"), ("q", "request_quit", "Quit"), ] SCREENS = {"settings": SettingsScreen} def compose(self) -> ComposeResult: yield Static("Main Screen - Press S for Settings, Q to Quit") async def action_request_quit(self) -> None: """Show confirmation dialog before quitting.""" if await self.push_screen_wait(ConfirmScreen("Are you sure you want to quit?")): self.exit() if __name__ == "__main__": MainApp().run() ``` ## Containers and Layout Containers are widgets that hold other widgets and control their layout. Use `Vertical`, `Horizontal`, `Grid`, and `ScrollableContainer` for common layouts. Containers can be nested to create complex UIs. ```python from textual.app import App, ComposeResult from textual.containers import ( Container, Vertical, Horizontal, Grid, ScrollableContainer, VerticalScroll, Center ) from textual.widgets import Static, Button class LayoutApp(App): CSS = """ .box { height: 5; border: solid $primary; padding: 1; } #sidebar { width: 20; background: $surface-darken-1; } #main { width: 1fr; } #grid-container { grid-size: 3; grid-gutter: 1; height: auto; } """ def compose(self) -> ComposeResult: # Horizontal layout with sidebar and main content with Horizontal(): # Sidebar with vertical layout with Vertical(id="sidebar"): yield Button("Home") yield Button("Settings") yield Button("Help") # Main content area with scrolling with VerticalScroll(id="main"): yield Static("Grid Layout Example:", classes="box") # Grid container with Grid(id="grid-container"): for i in range(9): yield Static(f"Item {i+1}", classes="box") # Center aligned content with Center(): yield Button("Centered Button", variant="primary") if __name__ == "__main__": LayoutApp().run() ``` ## Workers - Background Tasks Workers allow long-running or async operations to run in the background without blocking the UI. Use `run_worker()` or the `@work` decorator to create workers. Workers can be exclusive (cancelling previous workers) and support both async coroutines and threaded functions. ```python import asyncio from textual import work from textual.app import App, ComposeResult from textual.widgets import Static, Button, ProgressBar from textual.worker import Worker, WorkerState class WorkerApp(App): CSS = """ #status { height: 3; border: solid green; } ProgressBar { margin: 1; } """ def compose(self) -> ComposeResult: yield Button("Start Task", id="start") yield Button("Cancel Task", id="cancel") yield ProgressBar(id="progress", total=100, show_eta=True) yield Static("Ready", id="status") @work(exclusive=True) # Cancel previous worker when starting new one async def do_work(self) -> str: """Background task that updates progress.""" progress = self.query_one("#progress", ProgressBar) status = self.query_one("#status", Static) for i in range(101): # Check if worker was cancelled if self.workers.any_in_state(WorkerState.CANCELLED): status.update("Cancelled!") return "cancelled" progress.progress = i status.update(f"Working... {i}%") await asyncio.sleep(0.05) return "completed" def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "start": self.do_work() # Decorated method returns Worker, runs in background elif event.button.id == "cancel": # Cancel all running workers for worker in self.workers: worker.cancel() def on_worker_state_changed(self, event: Worker.StateChanged) -> None: """Handle worker state changes.""" if event.state == WorkerState.SUCCESS: self.query_one("#status", Static).update(f"Done: {event.worker.result}") if __name__ == "__main__": WorkerApp().run() ``` ## DOM Queries - Finding Widgets Query methods allow finding widgets in the DOM tree. Use `query_one()` for single widgets and `query()` for multiple matches. Queries support CSS selectors and type filtering. ```python from textual.app import App, ComposeResult from textual.widgets import Static, Button, Input, Label from textual.containers import Vertical class QueryApp(App): def compose(self) -> ComposeResult: with Vertical(): yield Input(id="name-input", placeholder="Enter name") yield Input(id="email-input", placeholder="Enter email") yield Button("Submit", id="submit", classes="primary-btn") yield Button("Clear", id="clear", classes="secondary-btn") yield Label("", id="output") yield Static("Item 1", classes="item") yield Static("Item 2", classes="item") yield Static("Item 3", classes="item") def on_button_pressed(self, event: Button.Pressed) -> None: # query_one with selector - returns single widget or raises NoMatches output = self.query_one("#output", Label) if event.button.id == "submit": # query_one with type - returns first widget of that type name_input = self.query_one("#name-input", Input) email_input = self.query_one("#email-input", Input) output.update(f"Name: {name_input.value}, Email: {email_input.value}") elif event.button.id == "clear": # query returns DOMQuery - iterate or use loop-free methods for input_widget in self.query(Input): input_widget.value = "" output.update("Cleared all inputs!") def on_mount(self) -> None: # Query with class selector items = self.query(".item") self.log(f"Found {len(items)} items") # Filter queries further buttons = self.query(Button).filter(".primary-btn") # Loop-free operations self.query(".item").add_class("highlighted") if __name__ == "__main__": QueryApp().run() ``` ## Testing Apps with Pilot Textual provides a testing framework using the Pilot class. Use `run_test()` to run apps in headless mode and simulate user interactions like key presses and clicks. ```python import pytest from textual.app import App, ComposeResult from textual.widgets import Button, Static, Input class TestableApp(App): """App to demonstrate testing.""" def compose(self) -> ComposeResult: yield Input(id="text-input") yield Button("Submit", id="submit") yield Static("Waiting...", id="output") def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "submit": text = self.query_one("#text-input", Input).value self.query_one("#output", Static).update(f"Submitted: {text}") # Test file: test_app.py @pytest.mark.asyncio async def test_button_click(): """Test that clicking submit updates the output.""" app = TestableApp() async with app.run_test() as pilot: # Simulate typing await pilot.press("H", "e", "l", "l", "o") # Simulate clicking the submit button await pilot.click("#submit") # Assert the output was updated output = app.query_one("#output", Static) assert "Hello" in output.renderable @pytest.mark.asyncio async def test_keyboard_navigation(): """Test keyboard interactions.""" app = TestableApp() async with app.run_test() as pilot: # Press tab to navigate await pilot.press("tab") # Press enter on focused button await pilot.press("enter") # Wait for messages to process await pilot.pause() @pytest.mark.asyncio async def test_different_screen_size(): """Test app with different terminal size.""" app = TestableApp() async with app.run_test(size=(120, 40)) as pilot: # App runs at 120x40 instead of default 80x24 assert app.size.width == 120 assert app.size.height == 40 ``` ## Built-in Widgets Reference Textual includes a comprehensive set of built-in widgets for common UI patterns. Key widgets include Button, Input, DataTable, Tree, Select, TextArea, Markdown, and many more. ```python from textual.app import App, ComposeResult from textual.widgets import ( Button, Input, Label, Static, DataTable, Tree, Select, Switch, ProgressBar, Checkbox, RadioButton, RadioSet, TextArea, Markdown, TabbedContent, TabPane, ListView, ListItem, Header, Footer ) from textual.containers import Horizontal, Vertical class WidgetShowcase(App): CSS = """ .section { height: auto; margin: 1; padding: 1; border: solid $primary; } DataTable { height: 10; } TextArea { height: 8; } """ def compose(self) -> ComposeResult: yield Header() with TabbedContent(): with TabPane("Forms", id="forms"): with Vertical(classes="section"): yield Label("Form Widgets:") yield Input(placeholder="Text input") yield Select([("Option 1", 1), ("Option 2", 2)], prompt="Select...") with Horizontal(): yield Checkbox("Enable feature") yield Switch() with RadioSet(): yield RadioButton("Choice A") yield RadioButton("Choice B") yield Button("Submit", variant="primary") with TabPane("Data", id="data"): yield DataTable(id="table") yield Tree("Root", id="tree") with TabPane("Content", id="content"): yield TextArea(id="editor") yield ProgressBar(total=100) yield Footer() def on_mount(self) -> None: # Populate DataTable table = self.query_one("#table", DataTable) table.add_columns("Name", "Value", "Status") table.add_rows([ ("Item 1", "100", "Active"), ("Item 2", "200", "Pending"), ("Item 3", "300", "Complete"), ]) # Populate Tree tree = self.query_one("#tree", Tree) parent = tree.root.add("Parent") parent.add_leaf("Child 1") parent.add_leaf("Child 2") if __name__ == "__main__": WidgetShowcase().run() ``` ## Custom Messages Create custom messages to communicate between widgets. Define message classes inside your widget class and use `post_message()` to send them. Messages bubble up the DOM tree by default. ```python from textual.app import App, ComposeResult from textual.message import Message from textual.widget import Widget from textual.widgets import Static, Button from textual.containers import Vertical class ColorPicker(Widget): """Custom widget that emits ColorSelected messages.""" class ColorSelected(Message): """Emitted when a color is selected.""" def __init__(self, color: str) -> None: self.color = color super().__init__() DEFAULT_CSS = """ ColorPicker { layout: horizontal; height: auto; } ColorPicker Button { margin: 0 1; } """ COLORS = ["red", "green", "blue", "yellow", "purple"] def compose(self) -> ComposeResult: for color in self.COLORS: yield Button(color.title(), id=color) def on_button_pressed(self, event: Button.Pressed) -> None: # Stop the Button.Pressed from bubbling event.stop() # Post our custom message self.post_message(self.ColorSelected(event.button.id)) class DisplayWidget(Static): """Widget that displays the selected color.""" def on_mount(self) -> None: self.update("Select a color") class ColorApp(App): CSS = """ #display { height: 5; border: solid $primary; padding: 1; margin: 1; } """ def compose(self) -> ComposeResult: yield ColorPicker() yield DisplayWidget(id="display") def on_color_picker_color_selected(self, message: ColorPicker.ColorSelected) -> None: """Handle the custom ColorSelected message.""" display = self.query_one("#display", DisplayWidget) display.update(f"Selected: {message.color}") display.styles.background = message.color if __name__ == "__main__": ColorApp().run() ``` Textual is ideal for building command-line tools, data dashboards, system monitors, text editors, and any application that benefits from a rich terminal interface. Its async architecture makes it well-suited for applications that need to handle network requests, file I/O, or other concurrent operations while maintaining a responsive UI. The framework's design patterns encourage clean separation of concerns through CSS styling, reactive state management, and message-based communication. Applications can be thoroughly tested using the built-in testing framework, and the development experience is enhanced by live CSS reloading and the developer console. Textual's widgets can also be served over the web using Textual Web, enabling terminal applications to be accessed through a browser without modification.