diff --git a/README.md b/README.md index 918d6f5..599bc9b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ For more background on why distributing agent skills through package managers ma | [Polars](https://pola.rs) | `agent-skill-polars` | DataFrame library for fast data manipulation | | [rattler-build](https://rattler.build) | `agent-skill-rattler-build` | Build conda packages with rattler-build | | [SQLAlchemy](https://www.sqlalchemy.org) | `agent-skill-sqlalchemy` | Python SQL toolkit and ORM | +| [textual](https://github.com/textualize/textual/) | `agent-skill-textual` | Python TUI framework | | [Typst](https://typst.app) | `agent-skill-typst` | Modern markup-based typesetting system | ## Usage diff --git a/recipes/textual/PROMPT.md b/recipes/textual/PROMPT.md new file mode 100644 index 0000000..d6f5124 --- /dev/null +++ b/recipes/textual/PROMPT.md @@ -0,0 +1,18 @@ +Textual skill with documentation from https://github.com/Textualize/textual + +## Structure + +- `SKILL.md` - Main skill file with core Textual concepts, App/Widget/CSS overview, and patterns +- `references/WIDGETS.md` - Complete built-in widget reference with constructors, events, and usage +- `references/CSS.md` - CSS properties, types, selectors, and units reference +- `references/EVENTS.md` - Events, messages, reactive system, and the @on decorator +- `references/SCREENS.md` - Screen management, modals, modes, and screen lifecycle +- `references/TESTING.md` - Testing with Pilot, assertions, and snapshot testing +- `references/WORKERS.md` - Async workers, thread workers, and the @work decorator + +## Content Guidelines + +- Focus on practical patterns: how to compose widgets, handle events, style with CSS +- Widget reference should cover constructor parameters, events emitted, and key methods +- CSS reference should include both TCSS syntax and Python `styles` object equivalent +- Prioritize the compose/message/reactive pattern over low-level rendering diff --git a/recipes/textual/SKILL.md b/recipes/textual/SKILL.md new file mode 100644 index 0000000..d96b42f --- /dev/null +++ b/recipes/textual/SKILL.md @@ -0,0 +1,594 @@ +--- +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 + +```python +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 + +```python +# 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](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 + +```python +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: + +```python +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 + +```python +# 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](references/CSS.md) for the complete property reference. + +### CSS Sources + +```python +# 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 + +```css +/* 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_`: + +```python +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: + +```python +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 + +```python +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](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 + +```python +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 + +```python +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: + +```python +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](references/SCREENS.md) for the complete reference. + +```python +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](references/WORKERS.md) for the complete reference. + +```python +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](references/TESTING.md) for the complete reference. + +```python +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](references/WIDGETS.md) - All built-in widgets +- [CSS Reference](references/CSS.md) - All CSS properties, types, and selectors +- [Events Reference](references/EVENTS.md) - Events, messages, and reactivity details +- [Screens Reference](references/SCREENS.md) - Screen management and modes +- [Testing Reference](references/TESTING.md) - Testing with Pilot +- [Workers Reference](references/WORKERS.md) - Async workers and threading diff --git a/recipes/textual/pixi.toml b/recipes/textual/pixi.toml new file mode 100644 index 0000000..7143909 --- /dev/null +++ b/recipes/textual/pixi.toml @@ -0,0 +1,3 @@ +[package.build.backend] +name = "pixi-build-rattler-build" +version = "*" diff --git a/recipes/textual/recipe.yaml b/recipes/textual/recipe.yaml new file mode 100644 index 0000000..9a833f2 --- /dev/null +++ b/recipes/textual/recipe.yaml @@ -0,0 +1,49 @@ +context: + skill: textual + +package: + name: agent-skill-${{ skill }} + version: "0.0.1" + +source: + url: https://raw.githubusercontent.com/Textualize/textual/refs/heads/main/LICENSE + sha256: 94f290a762376dfdb7768e42070618b0abfd2a2799eab1b1c097816c3a39eb57 + +build: + number: 0 + noarch: generic + script: + - mkdir -p $PREFIX/share/agent-skills/${{ skill }} + - cp $RECIPE_DIR/SKILL.md $PREFIX/share/agent-skills/${{ skill }}/SKILL.md + - cp -R $RECIPE_DIR/references $PREFIX/share/agent-skills/${{ skill }}/ + +requirements: + run_constraints: + - textual >=8.0.0,<9 + +tests: + - package_contents: + files: + - share/agent-skills/${{ skill }}/SKILL.md + - share/agent-skills/${{ skill }}/references/*.md + strict: true + - script: + - agentskills validate $CONDA_PREFIX/share/agent-skills/${{ skill }} + requirements: + run: + - skills-ref + +about: + summary: Agent skill for building terminal user interfaces with Textual + description: | + Build terminal user interfaces (TUIs) with Textual, a Python framework: + - Create rich, interactive terminal applications + - Use CSS-like styling for terminal widgets + - Handle events, key bindings, and screen management + - Work with 35+ built-in widgets + - Test apps with the Pilot testing framework + homepage: https://textual.textualize.io + repository: https://github.com/Textualize/textual + documentation: https://textual.textualize.io + license: MIT + license_file: LICENSE diff --git a/recipes/textual/references/CSS.md b/recipes/textual/references/CSS.md new file mode 100644 index 0000000..92b0b8f --- /dev/null +++ b/recipes/textual/references/CSS.md @@ -0,0 +1,487 @@ +# Textual CSS Reference + +Complete reference for Textual CSS (TCSS) properties, selectors, and types. + +## Selectors + +### Basic Selectors + +| Selector | Syntax | Example | Matches | +|----------|--------|---------|---------| +| Type | `TypeName` | `Button` | All Button widgets | +| ID | `#id` | `#sidebar` | Widget with `id="sidebar"` | +| Class | `.class` | `.error` | Widgets with `error` CSS class | +| Universal | `*` | `*` | All widgets | + +### Combinators + +| Combinator | Syntax | Example | Matches | +|-----------|--------|---------|---------| +| Descendant | `A B` | `Screen Button` | Buttons anywhere inside Screen | +| Child | `A > B` | `Horizontal > Button` | Direct child Buttons of Horizontal | +| Nesting | `&` | `& > .child` | Used inside nested rules | + +### Pseudo-classes + +| Pseudo-class | Matches | +|-------------|---------| +| `:hover` | Mouse is over widget | +| `:focus` | Widget has input focus | +| `:focus-within` | Widget or descendant has focus | +| `:disabled` | Widget is disabled | +| `:enabled` | Widget is enabled | +| `:dark` | App is in dark mode | +| `:light` | App is in light mode | +| `:even` / `:odd` | Even/odd children | +| `:first-child` / `:last-child` | First/last child of parent | +| `:blur` | Widget does not have focus | +| `:can-focus` | Widget is focusable | +| `:inline` | App is running inline | + +### CSS Nesting + +```css +Button { + background: $surface; + + &:hover { + background: $accent; + } + + &.primary { + background: $primary; + } + + & > Static { + color: $text; + } +} +``` + +### Specificity + +From lowest to highest: type < class < ID. Later rules win on equal specificity. Use `!important` to override. + +## CSS Variables + +```css +/* Theme variables (built-in) */ +Screen { + background: $surface; + color: $text; + border: solid $accent; +} + +/* Custom variables */ +$sidebar-width: 30; +#sidebar { + width: $sidebar-width; +} +``` + +**Theme color variables:** `$primary`, `$secondary`, `$accent`, `$foreground`, `$background`, `$surface`, `$panel`, `$boost`, `$warning`, `$error`, `$success` + +Each has shades: `$primary-lighten-1`, `$primary-lighten-2`, `$primary-lighten-3`, `$primary-darken-1`, `$primary-darken-2`, `$primary-darken-3` + +**Text variables:** `$text`, `$text-muted`, `$text-disabled` + +## CSS Types + +### `` - Length / Size + +| Unit | Description | Example | +|------|-------------|---------| +| (number) | Cell units | `width: 30;` | +| `%` | Percentage of parent | `width: 50%;` | +| `fr` | Fraction of remaining space | `width: 1fr;` | +| `vw` | Viewport width % | `width: 50vw;` | +| `vh` | Viewport height % | `height: 50vh;` | +| `w` | Container width % | `width: 50w;` | +| `h` | Container height % | `height: 50h;` | +| `auto` | Automatic | `height: auto;` | + +### `` - Color Values + +| Format | Example | +|--------|---------| +| Named | `red`, `blue`, `green`, `white`, `black` | +| Hex | `#FF0000`, `#F00` | +| Hex RGBA | `#FF000080` | +| RGB | `rgb(255, 0, 0)` | +| RGBA | `rgba(255, 0, 0, 0.5)` | +| HSL | `hsl(0, 100%, 50%)` | +| HSLA | `hsla(0, 100%, 50%, 0.5)` | +| Variable | `$accent`, `$primary` | +| Auto | `auto` (automatic contrast) | + +### `` - Border Styles + +`ascii`, `blank`, `dashed`, `double`, `heavy`, `hidden`, `hkey`, `inner`, `none`, `outer`, `panel`, `round`, `solid`, `tall`, `thick`, `vkey`, `wide` + +### `` - Text Decorations + +`none`, `bold`, `italic`, `reverse`, `strike`, `underline` (can combine: `bold italic`) + +### Other Types + +| Type | Values | +|------|--------| +| `` | `left`, `center`, `right` | +| `` | `top`, `middle`, `bottom` | +| `` | `auto`, `hidden`, `scroll` | +| `` | `relative`, `absolute` | +| `` | `left`, `center`, `right`, `justify` | +| `` | Whole numbers (e.g., `5`, `-2`) | +| `` | Decimal numbers (e.g., `0.5`, `3.14`) | +| `` | Number with `%` (e.g., `50%`) | +| `` | Identifier (e.g., `my-layer`) | +| `` | `cross`, `horizontal`, `left`, `right`, `vertical`, or character | +| `` | `none`, `thin`, `heavy`, `double` | +| `` | `default`, `pointer`, `text`, `crosshair`, etc. | + +## Layout Properties + +### layout + +Set how children are arranged. + +```css +/* CSS */ /* Python */ +layout: vertical; widget.styles.layout = "vertical" +layout: horizontal; widget.styles.layout = "horizontal" +layout: grid; widget.styles.layout = "grid" +``` + +### display + +Show or hide a widget. + +```css +display: block; widget.display = True +display: none; widget.display = False +``` + +### dock + +Fix widget to an edge of its container. + +```css +dock: top; widget.styles.dock = "top" +dock: bottom; widget.styles.dock = "bottom" +dock: left; widget.styles.dock = "left" +dock: right; widget.styles.dock = "right" +``` + +### align / content-align + +Align children or content within a widget. + +```css +align: center middle; widget.styles.align = ("center", "middle") +content-align: right top; widget.styles.content_align = ("right", "top") + +/* Individual axes */ +align-horizontal: center; +align-vertical: middle; +``` + +## Sizing Properties + +### width / height + +```css +width: 50; widget.styles.width = 50 +width: 50%; widget.styles.width = "50%" +width: 1fr; widget.styles.width = "1fr" +width: auto; widget.styles.width = "auto" +height: 100vh; widget.styles.height = "100vh" +``` + +### min-width / max-width / min-height / max-height + +```css +min-width: 20; widget.styles.min_width = 20 +max-width: 80; widget.styles.max_width = 80 +min-height: 5; widget.styles.min_height = 5 +max-height: 30; widget.styles.max_height = 30 +``` + +### box-sizing + +```css +box-sizing: border-box; /* Default: padding/border included in size */ +box-sizing: content-box; /* Padding/border added to size */ +``` + +## Spacing Properties + +### margin + +Space outside the widget. Values: 1 (all), 2 (vertical horizontal), or 4 (top right bottom left). + +```css +margin: 1; widget.styles.margin = (1, 1, 1, 1) +margin: 1 2; widget.styles.margin = (1, 2, 1, 2) +margin: 1 2 3 4; widget.styles.margin = (1, 2, 3, 4) +``` + +### padding + +Space inside the widget around content. Same value syntax as margin. + +```css +padding: 1 2; widget.styles.padding = (1, 2, 1, 2) +``` + +### offset + +Move widget relative to its normal position. + +```css +offset: 5 3; widget.styles.offset = (5, 3) +offset-x: 10; +offset-y: -5; +``` + +## Color Properties + +### background / color + +```css +background: $surface; widget.styles.background = "blue" +background: red 50%; /* With alpha */ +color: $text; widget.styles.color = "white" +color: auto; /* Automatic contrast */ +``` + +### tint / background-tint + +Blend a color over the widget or its background. + +```css +tint: red 20%; widget.styles.tint = "red 20%" +background-tint: blue 10%; +``` + +### opacity / text-opacity + +```css +opacity: 0.5; widget.styles.opacity = 0.5 +opacity: 50%; +text-opacity: 75%; widget.styles.text_opacity = "75%" +``` + +### visibility + +Hide widget while reserving its space. + +```css +visibility: visible; widget.visible = True +visibility: hidden; widget.visible = False +``` + +## Border Properties + +### border / outline + +Border draws outside content; outline draws over content. + +```css +border: solid $accent; widget.styles.border = ("solid", "$accent") +border: heavy red; +border-top: dashed blue; +border-left: solid green; + +outline: round white; widget.styles.outline = ("round", "white") +``` + +### Border Title + +```css +border-title-align: center; /* left (default), center, right */ +border-title-color: $accent; +border-title-background: $surface; +border-title-style: bold; + +border-subtitle-align: right; +border-subtitle-color: $text-muted; +``` + +Set title text in Python: + +```python +widget.border_title = "My Title" +widget.border_subtitle = "Subtitle" +``` + +## Text Properties + +### text-align + +```css +text-align: left; /* Default */ +text-align: center; +text-align: right; +text-align: justify; +``` + +### text-style + +```css +text-style: bold; +text-style: italic underline; +text-style: none; +``` + +### text-wrap / text-overflow + +```css +text-wrap: wrap; /* Default: wrap text */ +text-wrap: nowrap; /* No wrapping */ + +text-overflow: fold; /* Default: wrap to next line */ +text-overflow: ellipsis; /* Truncate with ... */ +text-overflow: clip; /* Hard clip */ +``` + +## Grid Properties + +```css +/* Define grid */ +layout: grid; +grid-size: 3 2; /* 3 columns, 2 rows */ +grid-columns: 1fr 2fr 1fr; /* Column widths */ +grid-rows: auto 1fr; /* Row heights */ +grid-gutter: 1 2; /* Vertical horizontal spacing */ + +/* Cell spanning */ +column-span: 2; /* Span 2 columns */ +row-span: 3; /* Span 3 rows */ +``` + +### Grid Example + +```css +#container { + layout: grid; + grid-size: 3; + grid-gutter: 1; + grid-columns: 1fr 2fr 1fr; +} + +#header { + column-span: 3; +} + +#sidebar { + row-span: 2; +} +``` + +## Layer Properties + +Control z-ordering with layers. + +```css +/* Define layers on container (last = topmost) */ +Screen { + layers: below default above; +} + +/* Assign widgets to layers */ +#dialog { + layer: above; +} + +#background { + layer: below; +} +``` + +## Scrollbar Properties + +### overflow + +```css +overflow: auto auto; /* Default: show when needed */ +overflow-x: hidden; /* Never show horizontal scrollbar */ +overflow-y: scroll; /* Always allow vertical scrolling */ +``` + +### scrollbar-gutter + +```css +scrollbar-gutter: auto; /* Default: no reserved space */ +scrollbar-gutter: stable; /* Reserve space for scrollbar */ +``` + +### scrollbar-size + +```css +scrollbar-size: 2 1; /* horizontal vertical */ +scrollbar-size-horizontal: 2; +scrollbar-size-vertical: 1; +``` + +### scrollbar-color / scrollbar-background + +```css +scrollbar-color: $accent; +scrollbar-background: $surface; +scrollbar-color-hover: $accent-lighten-1; +scrollbar-background-hover: $surface; +scrollbar-color-active: $accent-lighten-2; +scrollbar-background-active: $surface; +scrollbar-corner-color: $surface; +``` + +## Link Properties + +Style Textual action links in markup. + +```css +link-color: $accent; +link-background: transparent; +link-style: underline; + +link-color-hover: white; +link-background-hover: $accent; +link-style-hover: bold underline; +``` + +## Special Properties + +### hatch + +Fill background with repeating pattern. + +```css +hatch: cross red; +hatch: "T" blue 80%; +hatch: right green 50%; +``` + +### keyline + +Draw lines around child widgets in a grid. + +```css +keyline: thin green; +keyline: heavy $accent; +``` + +### pointer + +Change mouse cursor shape (requires Kitty protocol). + +```css +pointer: pointer; +pointer: grab; +``` + +### position + +```css +position: relative; /* Default: offset from normal position */ +position: absolute; /* Offset from container origin */ +``` diff --git a/recipes/textual/references/EVENTS.md b/recipes/textual/references/EVENTS.md new file mode 100644 index 0000000..a8c9b92 --- /dev/null +++ b/recipes/textual/references/EVENTS.md @@ -0,0 +1,461 @@ +# Textual Events Reference + +Complete reference for the Textual event system, messages, and reactivity. + +## Event Handling + +### Handler Naming Convention + +Handler methods are named `on_`: + +```python +from textual.events import Key, Mount, Click + +class MyWidget(Widget): + def on_mount(self) -> None: + """Called when widget is mounted.""" + + def on_key(self, event: Key) -> None: + """Called on key press.""" + + def on_click(self, event: Click) -> None: + """Called on mouse click.""" +``` + +For widget messages, use `on__`: + +```python +class MyApp(App): + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle any Button.Pressed message.""" + + def on_input_changed(self, event: Input.Changed) -> None: + """Handle any Input.Changed message.""" +``` + +### The `@on` Decorator + +Filter handlers with CSS selectors: + +```python +from textual import on +from textual.widgets import Button, Input + +class MyApp(App): + @on(Button.Pressed, "#save") + def handle_save(self) -> None: + """Only handles Button.Pressed from #save.""" + + @on(Button.Pressed, "#cancel") + def handle_cancel(self) -> None: + """Only handles Button.Pressed from #cancel.""" + + @on(Input.Changed, ".search-field") + def handle_search(self, event: Input.Changed) -> None: + """Only handles Input.Changed from widgets with .search-field class.""" +``` + +The decorator matches against the widget that sent the message, not the handler's widget. + +### Async Handlers + +Handlers can be sync or async: + +```python +class MyApp(App): + async def on_mount(self) -> None: + await self.load_data() + + def on_key(self, event: Key) -> None: + self.notify(event.key) +``` + +## Message Bubbling + +Messages bubble up through the DOM tree (child -> parent -> grandparent -> screen -> app). + +```python +class MyWidget(Widget): + def on_click(self, event: Click) -> None: + event.stop() # Stop bubbling to parent + event.prevent_default() # Suppress default behavior +``` + +## Custom Messages + +```python +from textual.message import Message + +class FileList(Widget): + class FileSelected(Message): + """Emitted when a file is selected.""" + + def __init__(self, path: str) -> None: + super().__init__() + self.path = path + + @property + def control(self) -> "FileList": + """The FileList that sent this message.""" + return self._sender # type: ignore + + def select_file(self, path: str) -> None: + self.post_message(self.FileSelected(path)) +``` + +**Message class variables:** + +| Variable | Default | Purpose | +|----------|---------|---------| +| `bubble` | `True` | Message bubbles up DOM | +| `verbose` | `False` | Verbose message (filtered in some contexts) | +| `no_dispatch` | `False` | Cannot be handled by client code | +| `namespace` | `""` | Namespace for handler name disambiguation | + +## Lifecycle Events + +### App/Widget Lifecycle + +| Event | When | Bubbles | +|-------|------|---------| +| `Load` | App running, before terminal mode | No | +| `Mount` | Widget added to DOM | No | +| `Show` | Widget first displayed | No | +| `Hide` | Widget hidden (removed, scrolled away, display=False) | No | +| `Unmount` | Widget removed from DOM | No | +| `Resize` | App or widget resized | No | + +**Lifecycle order:** Load -> Mount -> Show -> (Focus) -> ... -> (Blur) -> Hide -> Unmount + +### Resize Event + +```python +from textual.events import Resize + +def on_resize(self, event: Resize) -> None: + event.size # New size + event.virtual_size # Scrollable size + event.container_size # Container size +``` + +## Focus Events + +| Event | When | Bubbles | +|-------|------|---------| +| `Focus` | Widget receives focus | No | +| `Blur` | Widget loses focus | No | +| `DescendantFocus` | Child widget focused | Yes | +| `DescendantBlur` | Child widget blurred | Yes | +| `AppFocus` | App gains terminal focus | No | +| `AppBlur` | App loses terminal focus | No | + +```python +from textual.events import Focus, Blur + +def on_focus(self, event: Focus) -> None: + self.add_class("focused") + +def on_blur(self, event: Blur) -> None: + self.remove_class("focused") +``` + +## Key Events + +```python +from textual.events import Key + +def on_key(self, event: Key) -> None: + event.key # Key string (e.g., "ctrl+s", "a", "enter") + event.character # Printable character or None + event.is_printable # Whether it's a printable character + event.name # Key name suitable for Python identifier + event.aliases # List of key aliases +``` + +### Key Methods + +Alternative to `on_key`, handle specific keys: + +```python +class MyWidget(Widget): + def key_space(self) -> None: + """Handle space key.""" + + def key_ctrl_s(self) -> None: + """Handle Ctrl+S.""" +``` + +## Mouse Events + +| Event | When | Bubbles | +|-------|------|---------| +| `Enter` | Mouse moves over widget | Yes | +| `Leave` | Mouse moves away from widget | Yes | +| `MouseMove` | Mouse moves while over widget | Yes | +| `MouseDown` | Mouse button pressed | Yes | +| `MouseUp` | Mouse button released | Yes | +| `Click` | Widget clicked | Yes | + +### Click Event + +```python +from textual.events import Click + +def on_click(self, event: Click) -> None: + event.x, event.y # Relative coordinates + event.screen_x, event.screen_y # Absolute coordinates + event.button # Button index (1=left, 2=middle, 3=right) + event.shift, event.ctrl, event.meta # Modifier keys + event.chain # Click count (1=single, 2=double, 3=triple) +``` + +### Mouse Capture + +Force all mouse events to a specific widget: + +```python +self.capture_mouse() # Start capturing +self.release_mouse() # Stop capturing +``` + +## Screen Events + +| Event | When | Bubbles | +|-------|------|---------| +| `ScreenSuspend` | Screen is no longer active | No | +| `ScreenResume` | Screen becomes active | No | + +## Other Events + +### Paste + +```python +from textual.events import Paste + +def on_paste(self, event: Paste) -> None: + event.text # Pasted text +``` + +### Print + +Capture `print()` output: + +```python +self.begin_capture_print() + +def on_print(self, event: Print) -> None: + event.text # Printed text + event.stderr # True if stderr +``` + +## Key Bindings + +### Binding Class + +```python +from textual.binding import Binding + +BINDINGS = [ + # Simple tuple: (key, action, description) + ("q", "quit", "Quit"), + ("ctrl+s", "save", "Save"), + + # Full Binding for more control + Binding("f1", "help", "Help", show=True), + Binding("ctrl+z", "undo", "Undo", show=False), + Binding("tab", "focus_next", priority=True), # Priority: handled before focused widget + Binding("escape", "dismiss", key_display="Esc"), # Custom footer display +] +``` + +**Binding parameters:** + +| Parameter | Type | Default | Purpose | +|-----------|------|---------|---------| +| `key` | `str` | required | Key to bind (comma-separated for multiple) | +| `action` | `str` | required | Action method name (without `action_` prefix) | +| `description` | `str` | `""` | Description shown in footer | +| `show` | `bool` | `True` | Show in footer | +| `key_display` | `str \| None` | `None` | Custom display text in footer | +| `priority` | `bool` | `False` | Handle before focused widget | +| `tooltip` | `str` | `""` | Tooltip in footer | + +### Action Methods + +Actions are methods prefixed with `action_`: + +```python +class MyApp(App): + BINDINGS = [("s", "save('draft')", "Save draft")] + + def action_save(self, mode: str = "final") -> None: + """Parameters passed from action string.""" + self.notify(f"Saved as {mode}") +``` + +### Dynamic Actions + +Control binding visibility/availability at runtime: + +```python +class MyApp(App): + def check_action(self, action: str, parameters: tuple) -> bool | None: + """Return True=enabled, False=disabled (grayed), None=hidden.""" + if action == "save": + if not self.has_changes: + return False # Show as disabled + return True +``` + +### Built-in Actions + +| Action | Description | +|--------|-------------| +| `quit` | Exit the app | +| `bell` | Terminal bell | +| `focus_next` | Focus next widget | +| `focus_previous` | Focus previous widget | +| `toggle_dark` | Toggle dark mode | +| `screenshot` | Take screenshot | +| `command_palette` | Open command palette | +| `maximize` | Maximize focused widget | +| `minimize` | Restore maximized widget | + +### Action Namespaces + +```python +# In CSS links or action strings: +"app.quit" # Call action on the App +"screen.dismiss" # Call action on the current Screen +"focused.delete" # Call action on the focused widget +``` + +## Reactive System + +### reactive vs var + +```python +from textual.reactive import reactive, var + +class MyWidget(Widget): + # reactive: triggers repaint on change (default) + name: reactive[str] = reactive("default") + + # var: no automatic repaint + count: var[int] = var(0) +``` + +### Reactive Options + +```python +# All options +value: reactive[int] = reactive( + 0, # Default value (or callable for mutable defaults) + layout=False, # Recalculate layout on change + repaint=True, # Repaint widget 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 +) +``` + +### Watchers + +Called when a reactive changes: + +```python +class MyWidget(Widget): + count: reactive[int] = reactive(0) + + # 0 args + def watch_count(self) -> None: + self.refresh() + + # 1 arg (new value) + def watch_count(self, new_value: int) -> None: + self.log(f"Count is now {new_value}") + + # 2 args (old and new) + def watch_count(self, old_value: int, new_value: int) -> None: + self.log(f"Count: {old_value} -> {new_value}") +``` + +Watchers can also be async: + +```python +async def watch_query(self, query: str) -> None: + results = await self.search(query) + self.update_results(results) +``` + +### Dynamic Watcher + +Watch a reactive from outside the class: + +```python +self.watch(widget, "count", self.on_count_changed) +``` + +### Validators + +Called before a reactive is set: + +```python +class MyWidget(Widget): + count: reactive[int] = reactive(0) + + def validate_count(self, value: int) -> int: + """Clamp count to 0-100.""" + return max(0, min(100, value)) +``` + +### Compute Methods + +Derived reactive values: + +```python +class MyWidget(Widget): + first_name: reactive[str] = reactive("") + last_name: reactive[str] = reactive("") + full_name: reactive[str] = reactive("") + + def compute_full_name(self) -> str: + return f"{self.first_name} {self.last_name}" +``` + +### Data Binding + +Bind parent reactive to child: + +```python +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) +``` + +### Mutable Reactives + +For mutable types (lists, dicts), use `mutate_reactive`: + +```python +class MyWidget(Widget): + items: reactive[list] = reactive(list) + + def add_item(self, item: str) -> None: + self.items.append(item) + self.mutate_reactive(MyWidget.items) # Trigger watchers +``` + +### set_reactive + +Set a reactive without triggering watchers: + +```python +self.set_reactive(MyWidget.count, 42) +``` diff --git a/recipes/textual/references/SCREENS.md b/recipes/textual/references/SCREENS.md new file mode 100644 index 0000000..6f8fdef --- /dev/null +++ b/recipes/textual/references/SCREENS.md @@ -0,0 +1,304 @@ +# Textual Screens Reference + +Complete reference for screen management, modals, and modes. + +## Screen Basics + +A Screen is a top-level widget that occupies the entire terminal. Apps have a screen stack; only the topmost screen is visible. + +```python +from textual.screen import Screen +from textual.app import ComposeResult +from textual.widgets import Static, Button + +class SettingsScreen(Screen): + CSS = """ + SettingsScreen { + align: center middle; + } + """ + + BINDINGS = [("escape", "dismiss")] + + def compose(self) -> ComposeResult: + yield Static("Settings") + yield Button("Close", id="close") + + def on_button_pressed(self) -> None: + self.dismiss() +``` + +### Screen Class Variables + +| Variable | Type | Default | Purpose | +|----------|------|---------|---------| +| `CSS` | `str` | `""` | Inline CSS for screen | +| `CSS_PATH` | `str \| list` | `None` | CSS file path(s) | +| `BINDINGS` | `list[BindingType]` | `[]` | Key bindings | +| `AUTO_FOCUS` | `str \| None` | `"*"` | CSS selector for auto-focus on mount | + +## Screen Stack + +### Push Screen + +Add a screen to the top of the stack: + +```python +class MyApp(App): + def action_settings(self) -> None: + self.push_screen(SettingsScreen()) + + # With callback for result + def action_confirm(self) -> None: + self.push_screen(ConfirmDialog(), callback=self.on_confirm) + + def on_confirm(self, result: bool) -> None: + if result: + self.notify("Confirmed!") +``` + +### Push Screen and Wait + +Await the result directly: + +```python +class MyApp(App): + async def action_confirm(self) -> None: + result = await self.push_screen_wait(ConfirmDialog()) + if result: + self.notify("Confirmed!") +``` + +### Pop Screen + +Remove the top screen and return to the previous one: + +```python +self.pop_screen() +``` + +### Switch Screen + +Replace the top screen (no stacking): + +```python +self.switch_screen(NewScreen()) +``` + +### Dismiss + +Pop the current screen from within the screen itself, optionally returning a result: + +```python +class MyScreen(Screen): + def action_dismiss(self) -> None: + self.dismiss() # Pop with no result + + def confirm(self) -> None: + self.dismiss(result=42) # Pop and return value to callback +``` + +## Named Screens + +Pre-register screens by name: + +```python +class MyApp(App): + SCREENS = { + "settings": SettingsScreen, + "help": lambda: HelpScreen("guide.md"), + } + + def action_settings(self) -> None: + self.push_screen("settings") +``` + +### Install/Uninstall Screens + +```python +self.install_screen(SettingsScreen(), name="settings") +self.uninstall_screen("settings") +``` + +## Modal Screens + +Modal screens block interaction with screens below. Use `ModalScreen` base class. + +```python +from textual.screen import ModalScreen + +class ConfirmDialog(ModalScreen[bool]): + """A modal that returns True/False.""" + + CSS = """ + ConfirmDialog { + align: center middle; + } + + #dialog { + width: 40; + height: auto; + padding: 1 2; + border: thick $accent; + background: $surface; + } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="dialog"): + 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) +``` + +### Type-Safe Results + +`ModalScreen` is generic. Specify the result type: + +```python +class TextInputDialog(ModalScreen[str]): + def confirm(self) -> None: + value = self.query_one(Input).value + self.dismiss(value) + +# Usage: +def action_get_name(self) -> None: + self.push_screen(TextInputDialog(), callback=self.set_name) + +def set_name(self, name: str) -> None: + self.title = name +``` + +## Modes + +Modes allow multiple independent screen stacks. Each mode has its own base screen. + +```python +class MyApp(App): + MODES = { + "dashboard": DashboardScreen, + "settings": SettingsScreen, + "editor": lambda: EditorScreen("default.txt"), + } + + BINDINGS = [ + ("1", "switch_mode('dashboard')", "Dashboard"), + ("2", "switch_mode('settings')", "Settings"), + ("3", "switch_mode('editor')", "Editor"), + ] + + def on_mount(self) -> None: + self.switch_mode("dashboard") # Set initial mode +``` + +Each mode maintains its own screen stack. Switching modes preserves the stack of the previous mode. + +### Mode Management + +```python +# Switch to a mode +self.switch_mode("editor") + +# Add a mode dynamically +self.add_mode("preview", PreviewScreen) + +# Remove a mode +self.remove_mode("preview") +``` + +## Screen Lifecycle Events + +| Event | When | +|-------|------| +| `Mount` | Screen is mounted (first time or reinstalled) | +| `ScreenSuspend` | Screen is no longer the active screen | +| `ScreenResume` | Screen becomes the active screen again | +| `Unmount` | Screen is removed from the DOM | + +```python +class MyScreen(Screen): + def on_screen_resume(self) -> None: + """Called when this screen becomes active again.""" + self.refresh_data() + + def on_screen_suspend(self) -> None: + """Called when another screen covers this one.""" + self.pause_updates() +``` + +## Screen Opacity + +Screens can have transparent backgrounds, allowing the screen below to show through: + +```python +class OverlayScreen(ModalScreen): + CSS = """ + OverlayScreen { + background: rgba(0, 0, 0, 0.5); + align: center middle; + } + """ +``` + +## Common Patterns + +### Confirmation Dialog + +```python +class ConfirmScreen(ModalScreen[bool]): + def __init__(self, message: str) -> None: + self.message = message + super().__init__() + + def compose(self) -> ComposeResult: + with Vertical(id="dialog"): + yield Static(self.message) + with Horizontal(): + yield Button("OK", id="ok", variant="primary") + yield Button("Cancel", id="cancel") + + @on(Button.Pressed, "#ok") + def confirm(self) -> None: + self.dismiss(True) + + @on(Button.Pressed, "#cancel") + def cancel(self) -> None: + self.dismiss(False) + +# Usage: +async def action_delete(self) -> None: + if await self.push_screen_wait(ConfirmScreen("Delete item?")): + self.delete_item() +``` + +### Screen with Navigation + +```python +class DetailScreen(Screen): + BINDINGS = [ + ("escape", "app.pop_screen", "Back"), + ("n", "next", "Next"), + ("p", "previous", "Previous"), + ] + + def __init__(self, item_id: int) -> None: + self.item_id = item_id + super().__init__() + + def compose(self) -> ComposeResult: + yield Header() + yield Static(f"Item {self.item_id}") + yield Footer() + + def action_next(self) -> None: + self.app.switch_screen(DetailScreen(self.item_id + 1)) +``` diff --git a/recipes/textual/references/TESTING.md b/recipes/textual/references/TESTING.md new file mode 100644 index 0000000..111cfc6 --- /dev/null +++ b/recipes/textual/references/TESTING.md @@ -0,0 +1,309 @@ +# Textual Testing Reference + +Complete reference for testing Textual applications. + +## Setup + +Textual tests use `pytest` with `pytest-asyncio`: + +```bash +pip install pytest pytest-asyncio +``` + +## Basic Testing + +Use `App.run_test()` for headless testing: + +```python +import pytest +from textual.widgets import Static, Button, Input +from my_app import MyApp + +@pytest.mark.asyncio +async def test_initial_state(): + async with MyApp().run_test() as pilot: + app = pilot.app + assert app.title == "My App" + label = app.query_one("#greeting", Static) + assert "Hello" in str(label.renderable) +``` + +### run_test Parameters + +```python +async with app.run_test( + headless=True, # No terminal output (default: True) + size=(80, 24), # Terminal size (columns, rows) + message_hook=callback, # Called for every message +) as pilot: + ... +``` + +## Pilot API + +The `Pilot` object simulates user interaction. + +### Keyboard Input + +```python +async with MyApp().run_test() as pilot: + await pilot.press("a") # Press a single key + await pilot.press("ctrl+s") # Press key combo + await pilot.press("tab", "tab", "enter") # Multiple keys in sequence + await pilot.press("escape") +``` + +### Mouse Clicks + +```python +async with MyApp().run_test() as pilot: + # Click a widget by selector + await pilot.click("#submit") + + # Click by widget type + await pilot.click(Button) + + # Click with offset (relative to widget center) + await pilot.click("#canvas", offset=(10, 5)) + + # Click with modifiers + await pilot.click("#item", shift=True) + await pilot.click("#item", control=True) + + # Double/triple click + await pilot.double_click("#text") + await pilot.triple_click("#text") + + # Mouse button (1=left, 2=middle, 3=right) + await pilot.click("#item", button=3) +``` + +### Mouse Actions + +```python +async with MyApp().run_test() as pilot: + # Hover over a widget + await pilot.hover("#menu-item") + + # Mouse down/up separately + await pilot.mouse_down("#drag-handle") + await pilot.mouse_up("#drop-target") +``` + +### Pausing + +Wait for pending messages to be processed: + +```python +async with MyApp().run_test() as pilot: + await pilot.press("a") + await pilot.pause() # Wait for all pending messages + + # Or with a delay + await pilot.pause(delay=0.5) +``` + +### Resizing + +```python +async with MyApp().run_test() as pilot: + await pilot.resize_terminal(120, 40) + await pilot.pause() +``` + +### Exiting + +```python +async with MyApp().run_test() as pilot: + await pilot.exit(return_value) +``` + +### Waiting for Animations + +```python +async with MyApp().run_test() as pilot: + await pilot.wait_for_animation() + await pilot.wait_for_scheduled_animations() +``` + +## Querying Widgets + +Use CSS selectors to find widgets in tests: + +```python +async with MyApp().run_test() as pilot: + app = pilot.app + + # Query single widget + button = app.query_one("#submit", Button) + assert button.label == "Submit" + + # Query multiple widgets + buttons = app.query(Button) + assert len(buttons) == 3 + + # Check widget state + input_widget = app.query_one("#name", Input) + assert input_widget.value == "default" + + # Check CSS classes + widget = app.query_one("#status") + assert widget.has_class("active") + + # Check display/visibility + assert app.query_one("#panel").display is True + assert app.query_one("#hidden").visible is False +``` + +## Testing Patterns + +### Testing Key Bindings + +```python +@pytest.mark.asyncio +async def test_quit_binding(): + async with MyApp().run_test() as pilot: + await pilot.press("q") + # App should have exited + assert pilot.app.return_code == 0 +``` + +### Testing Screen Navigation + +```python +@pytest.mark.asyncio +async def test_screen_push(): + async with MyApp().run_test() as pilot: + await pilot.press("s") # Trigger settings screen + await pilot.pause() + + # Verify new screen + assert isinstance(pilot.app.screen, SettingsScreen) + + await pilot.press("escape") # Go back + await pilot.pause() + assert not isinstance(pilot.app.screen, SettingsScreen) +``` + +### Testing Input + +```python +@pytest.mark.asyncio +async def test_form_input(): + async with MyApp().run_test() as pilot: + # Focus the input + await pilot.click("#name-input") + + # Type text + await pilot.press("H", "e", "l", "l", "o") + await pilot.pause() + + input_widget = pilot.app.query_one("#name-input", Input) + assert input_widget.value == "Hello" + + # Submit + await pilot.press("enter") + await pilot.pause() +``` + +### Testing Reactive Updates + +```python +@pytest.mark.asyncio +async def test_counter(): + async with MyApp().run_test() as pilot: + counter = pilot.app.query_one(Counter) + assert counter.count == 0 + + await pilot.press("up") + await pilot.pause() + assert counter.count == 1 + + await pilot.press("down") + await pilot.pause() + assert counter.count == 0 +``` + +### Testing Data Table + +```python +@pytest.mark.asyncio +async def test_data_table(): + async with MyApp().run_test() as pilot: + table = pilot.app.query_one(DataTable) + assert table.row_count == 5 + assert table.get_cell_at((0, 0)) == "Alice" +``` + +### Testing with Workers + +```python +@pytest.mark.asyncio +async def test_async_loading(): + async with MyApp().run_test() as pilot: + await pilot.press("l") # Trigger load action + await pilot.pause(delay=1.0) # Wait for worker + + results = pilot.app.query_one("#results") + assert "loaded" in str(results.renderable).lower() +``` + +### Testing Notifications + +```python +@pytest.mark.asyncio +async def test_notification(): + async with MyApp().run_test(notifications=True) as pilot: + await pilot.press("s") # Trigger save + await pilot.pause() + + # Check notifications + assert len(pilot.app._notifications) > 0 +``` + +## Snapshot Testing + +Visual regression testing with `pytest-textual-snapshot`: + +```bash +pip install pytest-textual-snapshot +``` + +```python +from textual.app import App + +async def test_snapshot(snap_compare): + assert snap_compare(MyApp()) +``` + +Run with `--update-snapshot` to generate/update reference images: + +```bash +pytest --update-snapshot +``` + +### Snapshot with Interaction + +```python +async def test_snapshot_after_click(snap_compare): + async def setup(pilot): + await pilot.click("#submit") + await pilot.pause() + + assert snap_compare(MyApp(), run_before=setup) +``` + +### Snapshot with Custom Size + +```python +async def test_snapshot_large(snap_compare): + assert snap_compare(MyApp(), terminal_size=(120, 40)) +``` + +## Testing Tips + +- Always call `await pilot.pause()` after interactions to let messages process +- Use `size` parameter in `run_test()` to test responsive layouts +- Use `message_hook` to spy on messages for debugging +- For workers, use `pilot.pause(delay=...)` with appropriate delays +- Test both the happy path and edge cases (empty data, long text, etc.) diff --git a/recipes/textual/references/WIDGETS.md b/recipes/textual/references/WIDGETS.md new file mode 100644 index 0000000..b3701fa --- /dev/null +++ b/recipes/textual/references/WIDGETS.md @@ -0,0 +1,602 @@ +# Textual Widgets Reference + +Complete reference for all built-in Textual widgets. + +## Display Widgets + +### Static + +Display static text or Rich renderables. + +```python +from textual.widgets import Static + +yield Static("Hello, World!") +yield Static("Rich [bold]markup[/bold] supported") +``` + +- **Focusable:** No | **Container:** No +- **Key method:** `update(content)` to change displayed content + +### Label + +Display simple text. Subclass of Static. + +```python +from textual.widgets import Label + +yield Label("Username:") +``` + +- **Focusable:** No | **Container:** No + +### Digits + +Display numbers in large multi-line characters. Supports 0-9, A-F, +, -, ^, :, x. + +```python +from textual.widgets import Digits + +yield Digits("12:45") +``` + +- **Focusable:** No | **Container:** No +- **Key method:** `update(text)` to change display +- Respects `text-align` CSS + +### Pretty + +Display pretty-formatted Python objects. + +```python +from textual.widgets import Pretty + +yield Pretty({"key": [1, 2, 3]}) +``` + +- **Focusable:** No | **Container:** No +- **Key method:** `update(obj)` to update the displayed object + +### Rule + +Visual separator line. + +```python +from textual.widgets import Rule + +yield Rule() # Horizontal +yield Rule(orientation="vertical") # Vertical +``` + +- **Focusable:** No | **Container:** No +- **Reactive:** `orientation` (horizontal/vertical), `line_style` (solid/dashed/double/heavy/etc.) + +### Markdown + +Render markdown content. + +```python +from textual.widgets import Markdown + +yield Markdown("# Hello\n\nSome **markdown** content.") +``` + +- **Focusable:** No | **Container:** No +- **Events:** `Markdown.TableOfContentsUpdated`, `Markdown.TableOfContentsSelected`, `Markdown.LinkClicked` +- **Key method:** `update(markdown_str)` to change content + +### MarkdownViewer + +Markdown with optional table of contents and browser-like navigation. + +```python +from textual.widgets import MarkdownViewer + +yield MarkdownViewer("# Hello\n\nContent here.") +``` + +- **Focusable:** Yes | **Container:** No +- **Reactive:** `show_table_of_contents` (bool, default: `True`) + +### Sparkline + +Visual representation of numerical data as bars. + +```python +from textual.widgets import Sparkline + +yield Sparkline([1, 4, 2, 8, 5, 3]) +``` + +- **Focusable:** No | **Container:** No +- **Reactive:** `data` (Sequence[float]), `summary_function` (Callable, default: `max`) + +### LoadingIndicator + +Animated dots shown during data loading. + +```python +from textual.widgets import LoadingIndicator + +yield LoadingIndicator() + +# Or use the loading reactive on any widget: +widget.loading = True +``` + +- **Focusable:** No | **Container:** No + +### Placeholder + +Layout placeholder for prototyping. Click to cycle through variants (default/size/text). + +```python +from textual.widgets import Placeholder + +yield Placeholder("Sidebar") +``` + +- **Focusable:** No | **Container:** No +- **Reactive:** `variant` (default/size/text) + +## App Chrome + +### Header + +Display app title and subtitle at top of app. Click to toggle tall/short. + +```python +from textual.widgets import Header + +yield Header() # Shows App.title and App.sub_title +``` + +- **Focusable:** No | **Container:** No +- **Reactive:** `tall` (bool, default: `True`) + +### Footer + +Display available key bindings at bottom of app. + +```python +from textual.widgets import Footer + +yield Footer() +``` + +- **Focusable:** No | **Container:** No +- **Reactive:** `compact` (bool), `show_command_palette` (bool) +- Auto-displays bindings for the focused widget + +## Input Widgets + +### Button + +Clickable button with semantic variants. + +```python +from textual.widgets import Button + +yield Button("Submit", variant="primary", id="submit") +yield Button("Delete", variant="error", disabled=True) +``` + +- **Focusable:** Yes | **Container:** No +- **Constructor:** `label` (str), `variant` (default/primary/success/warning/error), `disabled` (bool) +- **Events:** `Button.Pressed` +- **Reactive:** `label`, `variant`, `disabled` + +### Input + +Single-line text input with validation. + +```python +from textual.widgets import Input +from textual.validation import Number + +yield Input(placeholder="Enter name...", id="name") +yield Input(type="integer", max_length=5) +yield Input(validators=[Number(minimum=0, maximum=100)]) +yield Input(password=True, placeholder="Password") +``` + +- **Focusable:** Yes | **Container:** No +- **Constructor:** `value`, `placeholder`, `type` (text/integer/number), `password` (bool), `restrict` (regex), `max_length`, `validators`, `validate_on` (changed/submitted/blur), `valid_empty` (bool) +- **Events:** `Input.Changed`, `Input.Submitted` +- **Reactive:** `value`, `placeholder`, `password`, `restrict`, `type`, `max_length`, `cursor_blink` + +### MaskedInput + +Text input with template mask for formatted input. + +```python +from textual.widgets import MaskedInput + +yield MaskedInput(template="(999) 999-9999") # Phone +yield MaskedInput(template="9999-99-99") # Date +yield MaskedInput(template="HH:HH:HH:HH:HH:HH") # MAC address +``` + +- **Focusable:** Yes | **Container:** No +- **Template characters:** `A`/`a` = letter (required/optional), `N`/`n` = alphanumeric, `9`/`0` = digit, `H`/`h` = hex, `>` = uppercase, `<` = lowercase +- **Events:** `MaskedInput.Changed`, `MaskedInput.Submitted` + +### TextArea + +Multi-line text editor with optional syntax highlighting. + +```python +from textual.widgets import TextArea + +yield TextArea("Initial content", language="python", theme="monokai") +yield TextArea.code_editor("print('hello')", language="python") +``` + +- **Focusable:** Yes | **Container:** No +- **Reactive:** `language`, `theme`, `show_line_numbers`, `indent_width`, `soft_wrap`, `read_only`, `cursor_blink` +- **Events:** `TextArea.Changed`, `TextArea.SelectionChanged` +- **Key methods:** + - Content: `text` property, `replace()`, `insert()`, `delete()`, `clear()` + - Cursor: `cursor_location`, `move_cursor()`, `move_cursor_relative()` + - Selection: `selection`, `select_line()`, `select_all()` + - Undo: `undo()`, `redo()`, `history.checkpoint()` +- **Languages:** python, javascript, markdown, json, yaml, etc. (requires `textual[syntax]`) +- **Themes:** css, dracula, github_light, monokai, vscode_dark + +### Checkbox + +Boolean toggle control. + +```python +from textual.widgets import Checkbox + +yield Checkbox("Enable notifications", value=True) +``` + +- **Focusable:** Yes | **Container:** No +- **Constructor:** `label` (str), `value` (bool, default: `False`) +- **Events:** `Checkbox.Changed` +- **Reactive:** `value` + +### RadioButton / RadioSet + +Mutually exclusive selection. + +```python +from textual.widgets import RadioButton, RadioSet + +# With strings +yield RadioSet("Small", "Medium", "Large") + +# With RadioButton objects +with RadioSet(): + yield RadioButton("Option A", value=True) + yield RadioButton("Option B") +``` + +- **RadioSet:** Container for radio buttons with mutual exclusivity +- **Events:** `RadioSet.Changed` (with `pressed` and `index` attributes) +- **Reactive:** `RadioButton.value` + +### Switch + +On/off toggle control. + +```python +from textual.widgets import Switch + +yield Switch(value=False) +``` + +- **Focusable:** Yes | **Container:** No +- **Events:** `Switch.Changed` +- **Reactive:** `value` (bool) + +### Select + +Compact dropdown for selecting one option. + +```python +from textual.widgets import Select + +yield Select( + [("Small", "s"), ("Medium", "m"), ("Large", "l")], + prompt="Choose size", +) +yield Select.from_values(["Red", "Green", "Blue"]) +``` + +- **Focusable:** Yes | **Container:** No +- **Constructor:** options (list of (display, value) tuples), `prompt`, `allow_blank`, `value` +- **Events:** `Select.Changed` +- **Reactive:** `value`, `expanded` +- **Key methods:** `set_options()`, `clear()`, `is_blank()` +- **Generic:** `Select[int]` for type-safe values. `Select.NULL` for blank state. + +### SelectionList + +Multi-select list with checkboxes. + +```python +from textual.widgets import SelectionList + +yield SelectionList[str]( + ("Python", "py", True), # (label, value, selected) + ("JavaScript", "js"), + ("Rust", "rs"), +) +``` + +- **Focusable:** Yes | **Container:** No +- **Events:** `SelectionList.SelectedChanged`, `SelectionList.SelectionToggled`, `SelectionList.SelectionHighlighted` +- **Reactive:** `highlighted` (int | None) +- **Key properties:** `selected` (list of selected values) + +### OptionList + +Vertical list of Rich renderable options. + +```python +from textual.widgets import OptionList +from textual.widgets.option_list import Option, Separator + +yield OptionList( + "Option 1", + Option("Option 2", id="opt2"), + Separator(), + Option("Option 3", disabled=True), +) +``` + +- **Focusable:** Yes | **Container:** No +- **Events:** `OptionList.OptionHighlighted`, `OptionList.OptionSelected` +- **Reactive:** `highlighted` (int | None) + +## Data Display Widgets + +### DataTable + +Interactive tabular data display with cursor, sorting, and selection. + +```python +from textual.widgets import DataTable + +class MyApp(App): + def compose(self) -> ComposeResult: + yield DataTable() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.add_columns("Name", "Age", "City") + table.add_rows([ + ["Alice", 30, "NYC"], + ["Bob", 25, "LA"], + ]) +``` + +- **Focusable:** Yes | **Container:** No +- **Reactive:** `show_header`, `show_row_labels`, `zebra_stripes`, `cursor_type` (cell/row/column/none), `show_cursor`, `fixed_rows`, `fixed_columns` +- **Events:** `CellHighlighted`, `CellSelected`, `RowHighlighted`, `RowSelected`, `ColumnHighlighted`, `ColumnSelected`, `HeaderSelected` +- **Key methods:** + - `add_columns(*labels)` / `add_column(label, key=None, width=None, default=None)` + - `add_rows(rows)` / `add_row(*cells, key=None, label=None, height=None)` + - `update_cell(row_key, column_key, value)` / `update_cell_at(coordinate, value)` + - `remove_row(key)` / `remove_column(key)` / `clear(columns=False)` + - `sort(*keys, reverse=False)` / `sort(key, key=lambda row: row[0])` + - `coordinate_to_cell_key(coordinate)` +- **Keys:** Rows and columns can be identified by key (string) instead of index + +### Tree + +Hierarchical tree structure with expandable nodes. + +```python +from textual.widgets import Tree + +tree = Tree("Root") +node = tree.root.add("Branch 1") +node.add_leaf("Leaf A") +node.add_leaf("Leaf B") +tree.root.add("Branch 2").add_leaf("Leaf C") +yield tree +``` + +- **Focusable:** Yes | **Container:** No +- **Reactive:** `show_root`, `show_guides`, `guide_depth` +- **Events:** `Tree.NodeCollapsed`, `Tree.NodeExpanded`, `Tree.NodeHighlighted`, `Tree.NodeSelected` +- **TreeNode methods:** `add(label, data=None)`, `add_leaf(label, data=None)`, `remove()`, `toggle()`, `expand()`, `collapse()` + +### DirectoryTree + +Tree view for filesystem navigation. + +```python +from textual.widgets import DirectoryTree + +yield DirectoryTree("/path/to/dir") +``` + +- **Focusable:** Yes | **Container:** No +- **Events:** `DirectoryTree.FileSelected` (with `path` attribute) +- **Key method:** `filter_paths(paths)` override for custom filtering + +### ListView / ListItem + +Scrollable list of selectable items. + +```python +from textual.widgets import ListView, ListItem + +yield ListView( + ListItem(Static("Item 1")), + ListItem(Static("Item 2")), + ListItem(Static("Item 3")), +) +``` + +- **Focusable:** Yes (ListView) | **Container:** Yes (ListView) +- **Events:** `ListView.Highlighted`, `ListView.Selected` +- **Reactive:** `index` (currently highlighted index) +- **Key methods:** `append(item)`, `clear()`, `insert(index, items)`, `pop(index)`, `remove_items(indices)` + +## Container Widgets + +### TabbedContent / TabPane + +Tabs with associated content panels. + +```python +from textual.widgets import TabbedContent, TabPane + +# Simple string labels +with TabbedContent("Settings", "Logs", "About"): + yield Static("Settings content") + yield Static("Logs content") + yield Static("About content") + +# Or with TabPane for more control +with TabbedContent(initial="logs"): + with TabPane("Settings", id="settings"): + yield Input(placeholder="Name") + with TabPane("Logs", id="logs"): + yield RichLog() +``` + +- **Focusable:** Yes | **Container:** Yes +- **Events:** `TabbedContent.TabActivated`, `TabbedContent.Cleared` +- **Reactive:** `active` (str - ID of active tab) +- **Key methods:** `add_pane(pane)`, `remove_pane(pane_id)`, `clear_panes()` + +### Tabs + +Standalone row of selectable tabs (used internally by TabbedContent). + +```python +from textual.widgets import Tabs, Tab + +yield Tabs( + Tab("First", id="first"), + Tab("Second", id="second"), +) +``` + +- **Focusable:** Yes | **Container:** No +- **Events:** `Tabs.TabActivated`, `Tabs.Cleared` +- **Reactive:** `active` (str - active tab ID) +- **Key methods:** `add_tab(tab)`, `remove_tab(tab_id)`, `clear()` + +### Collapsible + +Expandable/collapsible container with title. + +```python +from textual.widgets import Collapsible + +with Collapsible(title="Advanced Settings", collapsed=True): + yield Input(placeholder="API Key") + yield Switch(value=False) +``` + +- **Focusable:** Yes | **Container:** Yes +- **Events:** `Collapsible.Toggled` +- **Reactive:** `collapsed` (bool), `title` (str) + +### ContentSwitcher + +Show one child at a time, switching between them by ID. + +```python +from textual.widgets import ContentSwitcher + +with ContentSwitcher(initial="page1"): + yield Static("Page 1 content", id="page1") + yield Static("Page 2 content", id="page2") + +# Switch with: +self.query_one(ContentSwitcher).current = "page2" +``` + +- **Focusable:** No | **Container:** Yes +- **Reactive:** `current` (str | None - ID of visible child) + +## Log Widgets + +### Log + +Append-only text log (text only, no Rich formatting). + +```python +from textual.widgets import Log + +log = self.query_one(Log) +log.write_line("Event occurred") +log.write_lines(["Line 1", "Line 2"]) +log.clear() +``` + +- **Focusable:** Yes | **Container:** No +- **Reactive:** `max_lines` (int | None), `auto_scroll` (bool) + +### RichLog + +Scrollable log with Rich formatting support. + +```python +from textual.widgets import RichLog +from rich.table import Table + +log = self.query_one(RichLog) +log.write("Hello [bold red]World[/bold red]") +log.write(Table(...)) # Any Rich renderable +log.clear() +``` + +- **Focusable:** Yes | **Container:** No +- **Reactive:** `highlight` (bool), `markup` (bool), `max_lines` (int | None), `wrap` (bool), `min_width` (int, default: 78) +- **Key method:** `write(content)` accepts strings and Rich renderables + +## Misc Widgets + +### Link + +Clickable link that opens URL in browser. + +```python +from textual.widgets import Link + +yield Link("Visit Textual", url="https://textual.textualize.io") +``` + +- **Focusable:** Yes | **Container:** No +- **Reactive:** `text`, `url` + +### ProgressBar + +Progress indicator with optional percentage and ETA. + +```python +from textual.widgets import ProgressBar + +bar = ProgressBar(total=100, show_eta=True) +yield bar + +# Update progress: +bar.advance(10) +bar.update(progress=50) +bar.update(total=200) # Indeterminate if total is None +``` + +- **Focusable:** No | **Container:** No +- **Reactive:** `progress` (float), `total` (float | None), `percentage` (float | None, read-only) +- **Constructor:** `total`, `show_percentage` (bool), `show_eta` (bool) + +### Toast + +Notification popup. Not used directly; created via `App.notify()` or `Widget.notify()`. + +```python +self.notify("File saved!", title="Success", severity="information") +self.notify("Disk full!", severity="error", timeout=10) +``` + +- **Severities:** `information`, `warning`, `error` diff --git a/recipes/textual/references/WORKERS.md b/recipes/textual/references/WORKERS.md new file mode 100644 index 0000000..fb5c3a0 --- /dev/null +++ b/recipes/textual/references/WORKERS.md @@ -0,0 +1,254 @@ +# Textual Workers Reference + +Complete reference for async workers and threading in Textual. + +## Why Workers + +Long-running or blocking operations (network requests, file I/O, CPU-heavy tasks) must not run in event handlers directly, as they block the UI. Workers run these operations concurrently. + +## The `@work` Decorator + +The simplest way to create a worker: + +```python +from textual import work +from textual.app import App + +class MyApp(App): + @work + async def fetch_data(self, url: str) -> None: + """Runs as an async worker.""" + response = await httpx.AsyncClient().get(url) + self.notify(f"Got {len(response.content)} bytes") + + def on_mount(self) -> None: + self.fetch_data("https://api.example.com/data") +``` + +### @work Options + +```python +@work( + exclusive=False, # Cancel previous workers in same group + group="default", # Worker group name + exit_on_error=True, # Exit app on unhandled error + thread=False, # Run in thread instead of async + name="", # Worker name for debugging + description="", # Longer description +) +``` + +### Exclusive Workers + +Only one worker runs at a time in its group. Starting a new one cancels the previous: + +```python +class SearchApp(App): + @work(exclusive=True, group="search") + async def search(self, query: str) -> None: + """Previous search is cancelled when a new one starts.""" + results = await self.api.search(query) + self.display_results(results) + + def on_input_changed(self, event: Input.Changed) -> None: + self.search(event.value) # Each keystroke starts a new search +``` + +## run_worker Method + +Alternative to `@work` for more control: + +```python +class MyApp(App): + def on_mount(self) -> None: + worker = self.run_worker( + self.do_work(), + name="background-task", + group="tasks", + exclusive=True, + exit_on_error=True, + thread=False, + ) + + async def do_work(self) -> str: + await asyncio.sleep(2) + return "done" +``` + +## Thread Workers + +For blocking (non-async) operations that can't use `await`: + +```python +import httpx +from textual import work + +class MyApp(App): + @work(thread=True) + def fetch_sync(self, url: str) -> None: + """Runs in a thread. Use thread-safe operations only.""" + response = httpx.get(url) # Blocking call + self.call_from_thread(self.notify, f"Got {len(response.content)} bytes") + + @work(thread=True) + def load_file(self, path: str) -> str: + """Read a file in a thread.""" + with open(path) as f: + data = f.read() + self.call_from_thread(self.update_content, data) + return data +``` + +### Thread Safety + +Thread workers run outside the main async loop. To safely interact with the UI: + +```python +# Post a message from a thread +self.call_from_thread(self.post_message, MyMessage(data)) + +# Call any method safely +self.call_from_thread(self.notify, "Done!") + +# Update a widget from a thread +self.call_from_thread(widget.update, "New content") +``` + +**Never** directly modify widgets or post messages from a thread worker without `call_from_thread`. + +## Checking Cancellation + +Workers can be cancelled. Check `is_cancelled` for long-running operations: + +```python +from textual.worker import get_current_worker + +class MyApp(App): + @work(exclusive=True) + async def process_items(self, items: list) -> None: + worker = get_current_worker() + for item in items: + if worker.is_cancelled: + return # Stop processing + await self.process_one(item) +``` + +## Worker States + +| State | Description | +|-------|-------------| +| `PENDING` | Created but not yet running | +| `RUNNING` | Currently executing | +| `CANCELLED` | Was cancelled before completion | +| `ERROR` | Finished with an error | +| `SUCCESS` | Finished successfully | + +## Worker Events + +Workers emit `Worker.StateChanged` messages: + +```python +from textual.worker import Worker + +class MyApp(App): + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + if event.state == Worker.WorkerState.SUCCESS: + self.notify(f"Worker {event.worker.name} completed") + elif event.state == Worker.WorkerState.ERROR: + self.notify(f"Worker failed: {event.worker.error}", severity="error") +``` + +## Worker Properties + +```python +worker = self.run_worker(coro) + +worker.state # Current WorkerState +worker.is_running # True if running +worker.is_finished # True if done (success, error, or cancelled) +worker.is_cancelled # True if cancelled +worker.result # Return value (after SUCCESS) +worker.error # Exception (after ERROR) +worker.name # Worker name +worker.group # Worker group +worker.node # Widget/App that created the worker +worker.progress # Progress percentage (0-100) +``` + +## Cancelling Workers + +```python +# Cancel a specific worker +worker.cancel() + +# Cancel all workers in the default group +self.workers.cancel_group(self, "default") + +# Cancel all workers for this node +self.workers.cancel_node(self) +``` + +## Common Patterns + +### Loading Data on Mount + +```python +class DataView(Widget): + @work + async def load_data(self) -> None: + self.loading = True + try: + data = await fetch_data() + table = self.query_one(DataTable) + table.add_rows(data) + finally: + self.loading = False + + def on_mount(self) -> None: + self.load_data() +``` + +### Debounced Search + +```python +class SearchApp(App): + @work(exclusive=True, group="search") + async def search(self, query: str) -> None: + await asyncio.sleep(0.3) # Debounce + worker = get_current_worker() + if worker.is_cancelled: + return + results = await self.api.search(query) + self.display_results(results) + + def on_input_changed(self, event: Input.Changed) -> None: + self.search(event.value) +``` + +### Progress Tracking + +```python +class MyApp(App): + @work + async def process(self, items: list) -> None: + bar = self.query_one(ProgressBar) + bar.update(total=len(items)) + for i, item in enumerate(items): + await self.process_item(item) + bar.advance(1) +``` + +### Parallel Workers + +```python +class MyApp(App): + @work(group="downloads") + async def download(self, url: str) -> None: + """Multiple downloads can run in parallel (not exclusive).""" + data = await fetch(url) + self.save(url, data) + + def on_mount(self) -> None: + for url in self.urls: + self.download(url) # Each creates a separate worker +```