commit 9f0794c603e0910c0a01e0dcf6100d263c0ca20b Author: Zhongwei Li Date: Sat Nov 29 17:58:08 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..668d62a --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "textual-tui", + "description": "Comprehensive skill for Claude Code to build professional terminal user interfaces with Textual. Includes 40+ widgets, layouts, styling, reactive programming, and worker patterns.", + "version": "0.0.0-2025.11.28", + "author": { + "name": "aperepel", + "email": "aperepel@users.noreply.github.com" + }, + "skills": [ + "./skill" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..80ea22d --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# textual-tui + +Comprehensive skill for Claude Code to build professional terminal user interfaces with Textual. Includes 40+ widgets, layouts, styling, reactive programming, and worker patterns. diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..89d6492 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,81 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:aperepel/textual-tui-skill:", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "9539256c8558b708f3f693ae75f27ad6b2186bc3", + "treeHash": "c0fd251ca6e8f5b876758048daf99b4280bac4a3062cafe968169cf48cacca3d", + "generatedAt": "2025-11-28T10:13:56.015280Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "textual-tui", + "description": "Comprehensive skill for Claude Code to build professional terminal user interfaces with Textual. Includes 40+ widgets, layouts, styling, reactive programming, and worker patterns.", + "version": null + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "904043e50cf1f8cd4e796be63fa357201514d045bf455ec117bd40413d26baa8" + }, + { + "path": "skill/SKILL.md", + "sha256": "a41a0136d0006e349933a4d73e8e188c9a4a09dfed171135536ffad6397a7c01" + }, + { + "path": "skill/references/widgets.md", + "sha256": "37f5b57dbbfd46c51bb91a22dff725bb27396244807ac08d43948c7f166c089a" + }, + { + "path": "skill/references/official-guides-index.md", + "sha256": "216846ca4a313cbe380b68ce590a87a2c695cfe649d5e06783189486a5a13f5f" + }, + { + "path": "skill/references/layouts.md", + "sha256": "76cc95370d798193330b760dc7ad0f278c0f447c37442d4613d84d82528f3d4f" + }, + { + "path": "skill/references/styling.md", + "sha256": "c4717d48ea60aafa1a257df3dc37ab2206ea0c1f994b9a3c07717c866f57b71e" + }, + { + "path": "skill/assets/todo_app.py", + "sha256": "0685c3e400a5149b17b79b72232eb1c0385429fc458f9235ccdcfc76a7c3c9bc" + }, + { + "path": "skill/assets/worker_demo.py", + "sha256": "5583440500c878fbfa4716291bfd6bbb03d59ea1c7501d88be23f91a03e85598" + }, + { + "path": "skill/assets/dashboard_app.py", + "sha256": "875927e516035b25642f1961c15e9aff0de1fd408931b7df266a64967425b5b1" + }, + { + "path": "skill/assets/README.md", + "sha256": "657f86ea4ac5996a42046ed0242f712408582b497cf07b8e5ed0ee1835d1e0b7" + }, + { + "path": "skill/assets/data_viewer.py", + "sha256": "5925e6853408fb71584ee46c7c0716156f05c80f09fdf1eba0d5a85289ac2f43" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "7996ad5b9cdacdd1d22b1d49974a6d621f7de4fa063b45ce4d4045c21a293599" + } + ], + "dirSha256": "c0fd251ca6e8f5b876758048daf99b4280bac4a3062cafe968169cf48cacca3d" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..1a86c30 --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,703 @@ +--- +name: textual-tui +description: Build modern, interactive terminal user interfaces with Textual. Use when creating command-line applications, dashboard tools, monitoring interfaces, data viewers, or any terminal-based UI. Covers architecture, widgets, layouts, styling, event handling, reactive programming, workers for background tasks, and testing patterns. +--- + +# Textual TUI Development + +Build production-quality terminal user interfaces using Textual, a modern Python framework for creating interactive TUI applications. + +## Quick Start + +Install Textual: +```bash +pip install textual textual-dev +``` + +Basic app structure: +```python +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, Button + +class MyApp(App): + """A simple Textual app.""" + + def compose(self) -> ComposeResult: + """Create child widgets.""" + yield Header() + yield Button("Click me!", id="click") + yield Footer() + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button press.""" + self.exit() + +if __name__ == "__main__": + app = MyApp() + app.run() +``` + +Run with hot reload during development: +```bash +textual run --dev your_app.py +``` + +Use the Textual console for debugging: +```bash +textual console +``` + +## Core Architecture + +### App Lifecycle + +1. **Initialization**: Create App instance with config +2. **Composition**: Build widget tree via `compose()` method +3. **Mounting**: Widgets mounted to DOM +4. **Running**: Event loop processes messages and renders UI +5. **Shutdown**: Cleanup and exit + +### Message Passing System + +Textual uses an async message queue for all interactions: + +```python +from textual.message import Message + +class CustomMessage(Message): + """Custom message with data.""" + def __init__(self, value: int) -> None: + self.value = value + super().__init__() + +class MyWidget(Widget): + def on_click(self) -> None: + # Post message to parent + self.post_message(CustomMessage(42)) + +class MyApp(App): + def on_custom_message(self, message: CustomMessage) -> None: + # Handle message with naming convention: on_{message_name} + self.log(f"Received: {message.value}") +``` + +### Reactive Programming + +Use reactive attributes for automatic UI updates: + +```python +from textual.reactive import reactive + +class Counter(Widget): + count = reactive(0) # Reactive attribute + + def watch_count(self, new_value: int) -> None: + """Called automatically when count changes.""" + self.refresh() + + def increment(self) -> None: + self.count += 1 # Triggers watch_count +``` + +## Layout System + +### Container Layouts + +Textual provides flexible layout options: + +**Vertical Layout (default)**: +```python +def compose(self) -> ComposeResult: + yield Label("Top") + yield Label("Bottom") +``` + +**Horizontal Layout**: +```python +class MyApp(App): + CSS = """ + Screen { + layout: horizontal; + } + """ +``` + +**Grid Layout**: +```python +class MyApp(App): + CSS = """ + Screen { + layout: grid; + grid-size: 3 2; /* 3 columns, 2 rows */ + } + """ +``` + +### Sizing and Positioning + +Control widget dimensions: +```python +class MyApp(App): + CSS = """ + #sidebar { + width: 30; /* Fixed width */ + height: 100%; /* Full height */ + } + + #content { + width: 1fr; /* Remaining space */ + } + + .compact { + height: auto; /* Size to content */ + } + """ +``` + +## Styling with CSS + +Textual uses CSS-like syntax for styling. + +### Inline Styles + +```python +class StyledWidget(Widget): + DEFAULT_CSS = """ + StyledWidget { + background: $primary; + color: $text; + border: solid $accent; + padding: 1 2; + margin: 1; + } + """ +``` + +### External CSS Files + +```python +class MyApp(App): + CSS_PATH = "app.tcss" # Load from file +``` + +### Color System + +Use Textual's semantic colors: +```css +.error { background: $error; } +.success { background: $success; } +.warning { background: $warning; } +.primary { background: $primary; } +``` + +Or define custom colors: +```css +.custom { + background: #1e3a8a; + color: rgb(255, 255, 255); +} +``` + +## Common Widgets + +### Input and Forms + +```python +from textual.widgets import Input, Button, Select +from textual.containers import Container + +def compose(self) -> ComposeResult: + with Container(id="form"): + yield Input(placeholder="Enter name", id="name") + yield Select(options=[("A", 1), ("B", 2)], id="choice") + yield Button("Submit", variant="primary") + +def on_button_pressed(self, event: Button.Pressed) -> None: + name = self.query_one("#name", Input).value + choice = self.query_one("#choice", Select).value +``` + +### Data Display + +```python +from textual.widgets import DataTable, Tree, Log + +# DataTable for tabular data +table = DataTable() +table.add_columns("Name", "Age", "City") +table.add_row("Alice", 30, "NYC") + +# Tree for hierarchical data +tree = Tree("Root") +tree.root.add("Child 1") +tree.root.add("Child 2") + +# Log for streaming output +log = Log(auto_scroll=True) +log.write_line("Log entry") +``` + +### Containers and Layout + +```python +from textual.containers import ( + Container, Horizontal, Vertical, + Grid, ScrollableContainer +) + +def compose(self) -> ComposeResult: + with Vertical(): + yield Header() + with Horizontal(): + with Container(id="sidebar"): + yield Label("Menu") + with ScrollableContainer(id="content"): + yield Label("Content...") + yield Footer() +``` + +## Event Handling + +### Built-in Events + +```python +from textual.events import Key, Click, Mount + +def on_mount(self) -> None: + """Called when widget is mounted.""" + self.log("Widget mounted!") + +def on_key(self, event: Key) -> None: + """Handle all key presses.""" + if event.key == "q": + self.app.exit() + +def on_click(self, event: Click) -> None: + """Handle mouse clicks.""" + self.log(f"Clicked at {event.x}, {event.y}") +``` + +### Widget-Specific Handlers + +```python +def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle input submission.""" + self.query_one(Log).write(event.value) + +def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle table row selection.""" + row_key = event.row_key +``` + +### Keyboard Bindings + +```python +class MyApp(App): + BINDINGS = [ + ("q", "quit", "Quit"), + ("d", "toggle_dark", "Toggle dark mode"), + ("ctrl+s", "save", "Save"), + ] + + def action_quit(self) -> None: + self.exit() + + def action_toggle_dark(self) -> None: + self.dark = not self.dark +``` + +## Advanced Patterns + +### Custom Widgets + +Create reusable components: +```python +from textual.widget import Widget +from textual.widgets import Label, Button + +class StatusCard(Widget): + """A card showing status info.""" + + def __init__(self, title: str, status: str) -> None: + super().__init__() + self.title = title + self.status = status + + def compose(self) -> ComposeResult: + yield Label(self.title, classes="title") + yield Label(self.status, classes="status") +``` + +### Workers and Background Tasks + +CRITICAL: Use workers for any long-running operations to prevent blocking the UI. The event loop must remain responsive. + +#### Basic Worker Usage + +Run tasks in background threads: +```python +from textual.worker import Worker, WorkerState + +class MyApp(App): + def on_button_pressed(self, event: Button.Pressed) -> None: + # Start background task + self.run_worker(self.process_data(), exclusive=True) + + async def process_data(self) -> str: + """Long-running task.""" + # Simulate work + await asyncio.sleep(5) + return "Processing complete" +``` + +#### Worker with Progress Updates + +Update UI during processing: +```python +from textual.widgets import ProgressBar + +class MyApp(App): + def compose(self) -> ComposeResult: + yield ProgressBar(total=100, id="progress") + + def on_mount(self) -> None: + self.run_worker(self.long_task()) + + async def long_task(self) -> None: + """Task with progress updates.""" + progress = self.query_one(ProgressBar) + + for i in range(100): + await asyncio.sleep(0.1) + progress.update(progress=i + 1) + # Use call_from_thread for thread safety + self.call_from_thread(progress.update, progress=i + 1) +``` + +#### Worker Communication Patterns + +Use `call_from_thread` for thread-safe UI updates: +```python +import time +from threading import Thread + +class MyApp(App): + def on_mount(self) -> None: + self.run_worker(self.fetch_data(), thread=True) + + def fetch_data(self) -> None: + """CPU-bound task in thread.""" + # Blocking operation + result = expensive_computation() + + # Update UI safely from thread + self.call_from_thread(self.display_result, result) + + def display_result(self, result: str) -> None: + """Called on main thread.""" + self.query_one("#output").update(result) +``` + +#### Worker Cancellation + +Cancel workers when no longer needed: +```python +class MyApp(App): + worker: Worker | None = None + + def start_task(self) -> None: + # Store worker reference + self.worker = self.run_worker(self.long_task()) + + def cancel_task(self) -> None: + # Cancel running worker + if self.worker and not self.worker.is_finished: + self.worker.cancel() + self.notify("Task cancelled") + + async def long_task(self) -> None: + for i in range(1000): + await asyncio.sleep(0.1) + # Check if cancelled + if self.worker.is_cancelled: + return +``` + +#### Worker Error Handling + +Handle worker failures gracefully: +```python +class MyApp(App): + def on_mount(self) -> None: + worker = self.run_worker(self.risky_task()) + worker.name = "data_processor" # Name for debugging + + async def risky_task(self) -> str: + """Task that might fail.""" + try: + result = await fetch_from_api() + return result + except Exception as e: + self.notify(f"Error: {e}", severity="error") + raise + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + """Handle worker state changes.""" + if event.state == WorkerState.ERROR: + self.log.error(f"Worker failed: {event.worker.name}") + elif event.state == WorkerState.SUCCESS: + self.log.info(f"Worker completed: {event.worker.name}") +``` + +#### Multiple Workers + +Manage concurrent workers: +```python +class MyApp(App): + def on_mount(self) -> None: + # Run multiple workers concurrently + self.run_worker(self.task_one(), name="task1", group="processing") + self.run_worker(self.task_two(), name="task2", group="processing") + self.run_worker(self.task_three(), name="task3", group="processing") + + async def task_one(self) -> None: + await asyncio.sleep(2) + self.notify("Task 1 complete") + + async def task_two(self) -> None: + await asyncio.sleep(3) + self.notify("Task 2 complete") + + async def task_three(self) -> None: + await asyncio.sleep(1) + self.notify("Task 3 complete") + + def cancel_all_tasks(self) -> None: + """Cancel all workers in a group.""" + for worker in self.workers: + if worker.group == "processing": + worker.cancel() +``` + +#### Thread vs Process Workers + +Choose the right worker type: +```python +class MyApp(App): + def on_mount(self) -> None: + # Async task (default) - for I/O bound operations + self.run_worker(self.fetch_data()) + + # Thread worker - for CPU-bound tasks + self.run_worker(self.process_data(), thread=True) + + async def fetch_data(self) -> str: + """I/O bound: use async.""" + async with httpx.AsyncClient() as client: + response = await client.get("https://api.example.com") + return response.text + + def process_data(self) -> str: + """CPU bound: use thread.""" + # Heavy computation + result = [i**2 for i in range(1000000)] + return str(sum(result)) +``` + +#### Worker Best Practices + +1. **Always use workers for**: + - Network requests + - File I/O + - Database queries + - CPU-intensive computations + - Anything taking > 100ms + +2. **Worker patterns**: + - Use `exclusive=True` to prevent duplicate workers + - Name workers for easier debugging + - Group related workers for batch cancellation + - Always handle worker errors + +3. **Thread safety**: + - Use `call_from_thread()` for UI updates from threads + - Never modify widgets directly from threads + - Use locks for shared mutable state + +4. **Cancellation**: + - Store worker references if you need to cancel + - Check `worker.is_cancelled` in long loops + - Clean up resources in finally blocks + +### Modal Dialogs + +```python +from textual.screen import ModalScreen + +class ConfirmDialog(ModalScreen[bool]): + """Modal confirmation dialog.""" + + def compose(self) -> ComposeResult: + with Container(id="dialog"): + yield Label("Are you sure?") + with Horizontal(): + yield Button("Yes", variant="primary", id="yes") + yield Button("No", variant="error", id="no") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id == "yes") + +# Use in app +async def confirm_action(self) -> None: + result = await self.push_screen_wait(ConfirmDialog()) + if result: + self.log("Confirmed!") +``` + +### Screens and Navigation + +```python +from textual.screen import Screen + +class MainScreen(Screen): + def compose(self) -> ComposeResult: + yield Header() + yield Button("Go to Settings") + yield Footer() + + def on_button_pressed(self) -> None: + self.app.push_screen("settings") + +class SettingsScreen(Screen): + def compose(self) -> ComposeResult: + yield Label("Settings") + yield Button("Back") + + def on_button_pressed(self) -> None: + self.app.pop_screen() + +class MyApp(App): + SCREENS = { + "main": MainScreen(), + "settings": SettingsScreen(), + } +``` + +## Testing + +Test Textual apps with pytest and the Pilot API: + +```python +import pytest +from textual.pilot import Pilot +from my_app import MyApp + +@pytest.mark.asyncio +async def test_app_starts(): + app = MyApp() + async with app.run_test() as pilot: + assert app.screen is not None + +@pytest.mark.asyncio +async def test_button_click(): + app = MyApp() + async with app.run_test() as pilot: + await pilot.click("#my-button") + # Assert expected state changes + +@pytest.mark.asyncio +async def test_keyboard_input(): + app = MyApp() + async with app.run_test() as pilot: + await pilot.press("q") + # Verify app exited or state changed +``` + +## Best Practices + +### Performance + +- Use `Lazy` for expensive widgets loaded on demand +- Implement efficient `render()` methods, avoid unnecessary work +- Use reactive attributes sparingly for truly dynamic values +- Batch UI updates when processing multiple changes + +### State Management + +- Keep app state in the App instance for global access +- Use reactive attributes for UI-bound state +- Store complex state in dedicated data models +- Avoid deeply nested widget communication + +### Error Handling + +```python +from textual.widgets import RichLog + +def compose(self) -> ComposeResult: + yield RichLog(id="log") + +async def action_risky_operation(self) -> None: + try: + result = await some_async_operation() + self.notify("Success!", severity="information") + except Exception as e: + self.notify(f"Error: {e}", severity="error") + self.query_one(RichLog).write(f"[red]Error:[/] {e}") +``` + +### Accessibility + +- Always provide keyboard navigation +- Use semantic widget names and IDs +- Include ARIA-like descriptions where appropriate +- Test with screen reader compatibility in mind + +## Development Tools + +### Textual Console + +Debug running apps: +```bash +# Terminal 1: Run console +textual console + +# Terminal 2: Run app with console enabled +textual run --dev app.py +``` + +App code to enable console: +```python +self.log("Debug message") # Appears in console +self.log.info("Info level") +self.log.error("Error level") +``` + +### Textual Devtools + +Use the devtools for live inspection: +```bash +pip install textual-dev +textual run --dev app.py # Enables hot reload +``` + +## References + +- **Widget Gallery**: See references/widgets.md for comprehensive widget examples +- **Layout Patterns**: See references/layouts.md for common layout recipes +- **Styling Guide**: See references/styling.md for CSS patterns and themes +- **Official Guides Index**: See references/official-guides-index.md for URLs to all official Textual documentation guides (use web_fetch for detailed information on-demand) +- **Example Apps**: See assets/ for complete example applications + +## Common Pitfalls + +1. **Forgetting async/await**: Many Textual methods are async, always await them +2. **Blocking the event loop**: CRITICAL - Use `run_worker()` for long-running tasks (network, I/O, heavy computation). Never use `time.sleep()` or blocking operations in the main thread +3. **Incorrect message handling**: Method names must match `on_{message_name}` pattern +4. **CSS specificity issues**: Use IDs and classes appropriately for targeted styling +5. **Not using query methods**: Use `query_one()` and `query()` instead of manual traversal +6. **Thread safety violations**: Never modify widgets directly from worker threads - use `call_from_thread()` +7. **Not cancelling workers**: Workers continue running even when screens close - always cancel or store references +8. **Using time.sleep in async**: Use `await asyncio.sleep()` instead of `time.sleep()` in async functions +9. **Not handling worker errors**: Workers can fail silently - always implement error handling +10. **Wrong worker type**: Use async workers for I/O, thread workers for CPU-bound tasks diff --git a/skill/assets/README.md b/skill/assets/README.md new file mode 100644 index 0000000..e5ceec2 --- /dev/null +++ b/skill/assets/README.md @@ -0,0 +1,165 @@ +# Example Textual Applications + +Complete, working example applications demonstrating various Textual patterns and features. + +## Running the Examples + +Each example is a standalone Python file. To run: + +```bash +# Install dependencies +pip install textual textual-dev psutil + +# Run any example +python todo_app.py +python dashboard_app.py +python data_viewer.py +python worker_demo.py + +# Or with hot reload during development +textual run --dev todo_app.py +``` + +## Examples + +### todo_app.py - Todo List Application + +A fully functional todo list demonstrating: +- Input handling and form validation +- List view with custom list items +- State management with reactive attributes +- Keyboard shortcuts and bindings +- Custom styling and theming +- Toggle states and visual feedback + +**Key Features:** +- Add/delete/toggle todos +- Mark items as complete +- Statistics tracking +- Keyboard shortcuts (Ctrl+N, Space, Ctrl+D, etc.) + +**Patterns Demonstrated:** +- Custom widget creation (TodoItem) +- Reactive state updates +- Event handling (button press, input submit) +- Keyboard bindings +- CSS styling with pseudo-classes + +### dashboard_app.py - System Monitor Dashboard + +A real-time system monitoring dashboard demonstrating: +- Grid layouts for dashboard design +- Reactive data updates +- Custom composite widgets +- Data visualization with Sparkline and ProgressBar +- Real-time monitoring with intervals +- Metric cards and charts + +**Key Features:** +- Live CPU and memory monitoring +- Historical CPU usage chart +- System metrics (cores, frequency, processes, uptime) +- Auto-refresh every second +- Manual refresh with 'R' key + +**Patterns Demonstrated:** +- Grid-based layouts +- Custom widget composition (MetricCard, CPUChart, MemoryChart) +- Timed updates with `set_interval()` +- Reactive attributes for live updates +- Data visualization components + +### data_viewer.py - JSON/CSV Data Viewer + +A file browser and data viewer demonstrating: +- File system navigation with DirectoryTree +- Multiple view modes (Table, Tree, Info) with tabs +- DataTable for tabular data +- Tree widget for hierarchical data +- Modal dialogs for errors +- Search and filter functionality + +**Key Features:** +- Browse and select files from file system +- Load and display JSON and CSV files +- View data as table or tree structure +- File information panel +- Search functionality +- Error handling with modal dialogs + +**Patterns Demonstrated:** +- Horizontal split layout (sidebar + content) +- Tabbed content interface +- Modal screen dialogs +- File I/O and data parsing +- Dynamic data loading +- Event handling across multiple widgets + +### worker_demo.py - Background Task Processing + +A comprehensive worker pattern demonstration with two apps: + +**FileProcessor App:** +- Single long-running worker with progress updates +- Worker cancellation +- Real-time statistics (speed, time elapsed) +- Progress bar updates from worker +- Error handling and state management + +**MultiWorkerDemo App:** +- Multiple concurrent workers +- Group-based worker management +- Batch cancellation +- Independent progress tracking per worker + +**Key Features:** +- Simulated file processing with 100 files +- Real-time progress updates +- Worker state change monitoring +- Cancellation support +- Statistics tracking (files/sec, elapsed time) + +**Patterns Demonstrated:** +- `run_worker()` for background tasks +- Worker lifecycle management +- Thread-safe UI updates +- Progress reporting from workers +- Worker cancellation and cleanup +- Multiple concurrent workers +- Worker groups and batch operations +- Error handling in workers + +## Common Patterns + +All examples demonstrate these fundamental patterns: +- Proper app structure with `compose()` method +- Header and Footer usage +- Keyboard bindings +- CSS styling with Textual's CSS system +- Event handling with `on_*` methods +- Reactive state management +- Widget querying with `query_one()` and `query()` + +## Learning Path + +1. **Start with todo_app.py** - Learn basic input, lists, and state management +2. **Move to dashboard_app.py** - Understand layouts, custom widgets, and real-time updates +3. **Try worker_demo.py** - Master background tasks and worker patterns (IMPORTANT!) +4. **Explore data_viewer.py** - Master complex layouts, tabs, and data handling + +## Extending the Examples + +Feel free to modify these examples: +- Add persistence to todo_app (save/load from file) +- Add network monitoring to dashboard_app +- Add export functionality to data_viewer +- Add real file processing to worker_demo (use actual files) +- Customize the styling and themes +- Add new features and widgets + +## Resources + +- Textual Documentation: https://textual.textualize.io/ +- Widget Gallery: See references/widgets.md +- Layout Patterns: See references/layouts.md +- Styling Guide: See references/styling.md diff --git a/skill/assets/dashboard_app.py b/skill/assets/dashboard_app.py new file mode 100644 index 0000000..6aeb30d --- /dev/null +++ b/skill/assets/dashboard_app.py @@ -0,0 +1,256 @@ +""" +System Monitor Dashboard - Textual TUI Example + +Demonstrates: +- Grid layouts for dashboard design +- Reactive data updates +- Custom widgets +- Data visualization with Sparkline +- Real-time monitoring +- Worker threads for background tasks +""" + +from textual.app import App, ComposeResult +from textual.containers import Container, Grid +from textual.widgets import Header, Footer, Static, Sparkline, ProgressBar +from textual.reactive import reactive +from textual.worker import Worker +import psutil +import time +from collections import deque + + +class MetricCard(Static): + """A card displaying a metric with a label and value.""" + + DEFAULT_CSS = """ + MetricCard { + border: solid $primary; + padding: 1; + height: 7; + background: $surface; + } + + MetricCard .label { + text-style: bold; + color: $accent; + } + + MetricCard .value { + text-align: center; + text-style: bold; + color: $success; + margin-top: 1; + } + """ + + value = reactive("") + label = reactive("") + + def __init__(self, label: str, initial_value: str = "0") -> None: + super().__init__() + self.label = label + self.value = initial_value + + def compose(self) -> ComposeResult: + yield Static(self.label, classes="label") + yield Static(self.value, classes="value", id=f"value-{id(self)}") + + def watch_value(self, new_value: str) -> None: + """Update the value display when it changes.""" + value_widget = self.query_one(f"#value-{id(self)}", Static) + value_widget.update(new_value) + + +class CPUChart(Static): + """A widget showing CPU usage over time.""" + + DEFAULT_CSS = """ + CPUChart { + border: solid $primary; + padding: 1; + height: 12; + background: $surface; + } + + CPUChart .title { + text-style: bold; + color: $accent; + margin-bottom: 1; + } + """ + + def __init__(self) -> None: + super().__init__() + self.history = deque([0] * 50, maxlen=50) + + def compose(self) -> ComposeResult: + yield Static("CPU Usage History", classes="title") + yield Sparkline(list(self.history), id="cpu-sparkline") + yield ProgressBar(total=100, show_percentage=True, id="cpu-progress") + + def update_cpu(self, value: float) -> None: + """Update CPU usage display.""" + self.history.append(value) + + sparkline = self.query_one("#cpu-sparkline", Sparkline) + sparkline.data = list(self.history) + + progress = self.query_one("#cpu-progress", ProgressBar) + progress.update(progress=value) + + +class MemoryChart(Static): + """A widget showing memory usage.""" + + DEFAULT_CSS = """ + MemoryChart { + border: solid $primary; + padding: 1; + height: 12; + background: $surface; + } + + MemoryChart .title { + text-style: bold; + color: $accent; + margin-bottom: 1; + } + + MemoryChart .detail { + margin-top: 1; + color: $text-muted; + } + """ + + def compose(self) -> ComposeResult: + yield Static("Memory Usage", classes="title") + yield ProgressBar(total=100, show_percentage=True, id="mem-progress") + yield Static("", classes="detail", id="mem-detail") + + def update_memory(self, percent: float, used: float, total: float) -> None: + """Update memory usage display.""" + progress = self.query_one("#mem-progress", ProgressBar) + progress.update(progress=percent) + + detail = self.query_one("#mem-detail", Static) + detail.update(f"Used: {used:.1f} GB / Total: {total:.1f} GB") + + +class SystemMonitor(App): + """A system monitoring dashboard.""" + + CSS = """ + Screen { + background: $background; + } + + Header { + background: $primary; + } + + #dashboard { + padding: 1 2; + height: 1fr; + } + + #metrics-grid { + grid-size: 4; + grid-gutter: 1 2; + height: auto; + margin-bottom: 1; + } + + #charts-grid { + grid-size: 2; + grid-gutter: 1 2; + height: 1fr; + } + + #status { + dock: bottom; + height: 1; + background: $panel; + padding: 0 2; + color: $text-muted; + } + """ + + BINDINGS = [ + ("r", "refresh", "Refresh"), + ("q", "quit", "Quit"), + ] + + update_count = reactive(0) + + def compose(self) -> ComposeResult: + """Compose the dashboard UI.""" + yield Header(show_clock=True) + + with Container(id="dashboard"): + with Grid(id="metrics-grid"): + yield MetricCard("CPU Cores", f"{psutil.cpu_count()}") + yield MetricCard("CPU Freq", "0 MHz") + yield MetricCard("Processes", "0") + yield MetricCard("Uptime", "0h 0m") + + with Grid(id="charts-grid"): + yield CPUChart() + yield MemoryChart() + + yield Static("", id="status") + yield Footer() + + def on_mount(self) -> None: + """Start monitoring when app starts.""" + self.update_system_info() + self.set_interval(1.0, self.update_system_info) + + def update_system_info(self) -> None: + """Update all system information.""" + self.update_count += 1 + + # Update metrics + cpu_freq = psutil.cpu_freq() + if cpu_freq: + freq_card = self.query("MetricCard")[1] + freq_card.value = f"{cpu_freq.current:.0f} MHz" + + process_count = len(psutil.pids()) + proc_card = self.query("MetricCard")[2] + proc_card.value = f"{process_count}" + + boot_time = psutil.boot_time() + uptime_seconds = time.time() - boot_time + hours = int(uptime_seconds // 3600) + minutes = int((uptime_seconds % 3600) // 60) + uptime_card = self.query("MetricCard")[3] + uptime_card.value = f"{hours}h {minutes}m" + + # Update CPU chart + cpu_percent = psutil.cpu_percent(interval=0.1) + cpu_chart = self.query_one(CPUChart) + cpu_chart.update_cpu(cpu_percent) + + # Update memory chart + mem = psutil.virtual_memory() + mem_chart = self.query_one(MemoryChart) + mem_chart.update_memory( + mem.percent, + mem.used / (1024**3), + mem.total / (1024**3) + ) + + # Update status + status = self.query_one("#status", Static) + status.update(f"Last updated: {time.strftime('%H:%M:%S')} | Updates: {self.update_count}") + + def action_refresh(self) -> None: + """Manually refresh the data.""" + self.update_system_info() + self.notify("Data refreshed!", severity="information") + + +if __name__ == "__main__": + app = SystemMonitor() + app.run() diff --git a/skill/assets/data_viewer.py b/skill/assets/data_viewer.py new file mode 100644 index 0000000..9ebb105 --- /dev/null +++ b/skill/assets/data_viewer.py @@ -0,0 +1,304 @@ +""" +Data Viewer Application - Textual TUI Example + +Demonstrates: +- File browser and selection +- DataTable for displaying tabular data +- Tree for displaying hierarchical data +- Modal screens for dialogs +- File loading and parsing +- Search and filter functionality +""" + +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.widgets import ( + Header, Footer, DirectoryTree, DataTable, Tree, Static, + Button, Input, TabbedContent, TabPane +) +from textual.screen import ModalScreen +from textual.binding import Binding +import json +import csv +from pathlib import Path + + +class ErrorDialog(ModalScreen[bool]): + """Modal dialog for displaying errors.""" + + DEFAULT_CSS = """ + ErrorDialog { + align: center middle; + } + + #error-dialog { + width: 60; + height: 11; + border: thick $error; + background: $surface; + padding: 1 2; + } + + #error-title { + text-style: bold; + color: $error; + margin-bottom: 1; + } + + #error-message { + margin-bottom: 1; + } + """ + + def __init__(self, title: str, message: str) -> None: + super().__init__() + self.title = title + self.message = message + + def compose(self) -> ComposeResult: + with Container(id="error-dialog"): + yield Static(self.title, id="error-title") + yield Static(self.message, id="error-message") + yield Button("OK", variant="error", id="ok") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(True) + + +class DataViewer(App): + """A data viewer for JSON and CSV files.""" + + CSS = """ + Screen { + background: $background; + layout: horizontal; + } + + Header { + background: $primary; + } + + #sidebar { + width: 35; + border-right: solid $accent; + background: $panel; + } + + #file-tree { + height: 1fr; + } + + #main-content { + width: 1fr; + padding: 1 2; + } + + #toolbar { + height: auto; + margin-bottom: 1; + } + + #search-input { + width: 1fr; + margin-right: 1; + } + + #content-tabs { + height: 1fr; + } + + DataTable { + height: 100%; + } + + Tree { + height: 100%; + } + + #info-panel { + border: solid $primary; + padding: 1; + background: $surface; + } + """ + + BINDINGS = [ + Binding("ctrl+o", "open_file", "Open File"), + Binding("ctrl+f", "focus_search", "Search"), + ("q", "quit", "Quit"), + ] + + def compose(self) -> ComposeResult: + """Compose the UI.""" + yield Header(show_clock=True) + + with Container(id="sidebar"): + yield Static("📁 File Browser", id="sidebar-title") + yield DirectoryTree(str(Path.home()), id="file-tree") + + with Vertical(id="main-content"): + with Horizontal(id="toolbar"): + yield Input(placeholder="Search...", id="search-input") + yield Button("Clear", id="clear-button") + + with TabbedContent(id="content-tabs"): + with TabPane("Table View", id="table-tab"): + yield DataTable(id="data-table", cursor_type="row") + + with TabPane("Tree View", id="tree-tab"): + yield Tree("Data", id="data-tree") + + with TabPane("Info", id="info-tab"): + yield Static("No file loaded", id="info-panel") + + yield Footer() + + def on_mount(self) -> None: + """Initialize the app.""" + table = self.query_one("#data-table", DataTable) + table.show_header = True + table.zebra_stripes = True + + def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None: + """Handle file selection from the tree.""" + file_path = event.path + if file_path.suffix.lower() in ['.json', '.csv']: + self.load_file(file_path) + else: + self.notify("Only JSON and CSV files are supported", severity="warning") + + def load_file(self, file_path: Path) -> None: + """Load and display a data file.""" + try: + if file_path.suffix.lower() == '.json': + self.load_json(file_path) + elif file_path.suffix.lower() == '.csv': + self.load_csv(file_path) + + self.notify(f"Loaded: {file_path.name}", severity="information") + + except Exception as e: + self.push_screen( + ErrorDialog("Error Loading File", f"Failed to load {file_path.name}:\n{str(e)}") + ) + + def load_json(self, file_path: Path) -> None: + """Load a JSON file.""" + with open(file_path, 'r') as f: + data = json.load(f) + + # Update info panel + info = self.query_one("#info-panel", Static) + info.update(f"File: {file_path.name}\nType: JSON\nSize: {file_path.stat().st_size} bytes") + + # Update tree view + tree = self.query_one("#data-tree", Tree) + tree.clear() + tree.root.label = file_path.name + self._build_json_tree(tree.root, data) + + # Update table view (if data is a list of dicts) + if isinstance(data, list) and data and isinstance(data[0], dict): + self._populate_table_from_list(data) + else: + table = self.query_one("#data-table", DataTable) + table.clear(columns=True) + self.notify("JSON structure not suitable for table view", severity="warning") + + def load_csv(self, file_path: Path) -> None: + """Load a CSV file.""" + with open(file_path, 'r') as f: + reader = csv.DictReader(f) + data = list(reader) + + # Update info panel + info = self.query_one("#info-panel", Static) + info.update( + f"File: {file_path.name}\n" + f"Type: CSV\n" + f"Rows: {len(data)}\n" + f"Columns: {len(data[0]) if data else 0}" + ) + + # Update table view + self._populate_table_from_list(data) + + # Update tree view + tree = self.query_one("#data-tree", Tree) + tree.clear() + tree.root.label = file_path.name + for i, row in enumerate(data[:100]): # Limit to 100 for performance + row_node = tree.root.add(f"Row {i+1}") + for key, value in row.items(): + row_node.add_leaf(f"{key}: {value}") + + def _populate_table_from_list(self, data: list) -> None: + """Populate the DataTable from a list of dicts.""" + if not data: + return + + table = self.query_one("#data-table", DataTable) + table.clear(columns=True) + + # Add columns + columns = list(data[0].keys()) + table.add_columns(*columns) + + # Add rows + for row in data: + table.add_row(*[str(row.get(col, "")) for col in columns]) + + def _build_json_tree(self, node, data, max_depth: int = 10, depth: int = 0) -> None: + """Recursively build a tree from JSON data.""" + if depth >= max_depth: + node.add_leaf("... (max depth reached)") + return + + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, (dict, list)): + child = node.add(f"📂 {key}") + self._build_json_tree(child, value, max_depth, depth + 1) + else: + node.add_leaf(f"{key}: {value}") + + elif isinstance(data, list): + for i, item in enumerate(data[:50]): # Limit to 50 items for performance + if isinstance(item, (dict, list)): + child = node.add(f"[{i}]") + self._build_json_tree(child, item, max_depth, depth + 1) + else: + node.add_leaf(f"[{i}]: {item}") + + def on_input_changed(self, event: Input.Changed) -> None: + """Handle search input changes.""" + if event.input.id == "search-input": + search_term = event.value.lower() + if search_term: + self.filter_table(search_term) + + def filter_table(self, search_term: str) -> None: + """Filter table rows based on search term.""" + table = self.query_one("#data-table", DataTable) + # Note: This is a simple example. Real implementation would need + # to store original data and rebuild filtered rows + pass + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "clear-button": + search_input = self.query_one("#search-input", Input) + search_input.value = "" + + def action_focus_search(self) -> None: + """Focus the search input.""" + self.query_one("#search-input", Input).focus() + + def action_open_file(self) -> None: + """Focus the file tree.""" + self.query_one("#file-tree", DirectoryTree).focus() + + +if __name__ == "__main__": + app = DataViewer() + app.run() diff --git a/skill/assets/todo_app.py b/skill/assets/todo_app.py new file mode 100644 index 0000000..803d6a8 --- /dev/null +++ b/skill/assets/todo_app.py @@ -0,0 +1,236 @@ +""" +Todo List Application - Complete Textual TUI Example + +A fully functional todo list app demonstrating: +- Input handling and forms +- List view and selection +- State management with reactive attributes +- Custom styling +- Keyboard shortcuts +""" + +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.widgets import Header, Footer, Input, Button, ListView, ListItem, Label, Static +from textual.binding import Binding +from textual.reactive import reactive + + +class TodoItem(ListItem): + """A todo list item with completion status.""" + + def __init__(self, text: str, completed: bool = False) -> None: + super().__init__() + self.text = text + self.completed = completed + self.update_display() + + def update_display(self) -> None: + """Update the display based on completion status.""" + if self.completed: + self.add_class("completed") + prefix = "✓" + else: + self.remove_class("completed") + prefix = "○" + + # Clear and re-render + self._nodes.clear() + self._nodes.append(Label(f"{prefix} {self.text}")) + + def toggle(self) -> None: + """Toggle completion status.""" + self.completed = not self.completed + self.update_display() + + +class TodoApp(App): + """A todo list application.""" + + CSS = """ + Screen { + background: $background; + layout: vertical; + } + + Header { + background: $primary; + } + + #main-container { + height: 1fr; + padding: 1 2; + } + + #input-container { + height: auto; + margin-bottom: 1; + } + + #todo-input { + width: 1fr; + margin-right: 1; + border: solid $accent; + } + + #todo-input:focus { + border: solid $success; + } + + #add-button { + min-width: 12; + } + + #todo-list { + border: solid $primary; + height: 1fr; + background: $surface; + } + + #stats { + height: 3; + margin-top: 1; + border: solid $accent; + padding: 0 2; + background: $panel; + } + + TodoItem { + padding: 0 1; + } + + TodoItem:hover { + background: $boost; + } + + TodoItem.completed { + color: $text-muted; + text-style: strike; + } + + .stat { + margin: 0 2; + } + """ + + BINDINGS = [ + Binding("ctrl+n", "new_todo", "New Todo"), + Binding("ctrl+d", "delete_todo", "Delete"), + Binding("space", "toggle_todo", "Toggle"), + Binding("ctrl+c", "clear_completed", "Clear Completed"), + ("q", "quit", "Quit"), + ] + + total_count = reactive(0) + completed_count = reactive(0) + + def compose(self) -> ComposeResult: + """Compose the UI.""" + yield Header(show_clock=True) + + with Container(id="main-container"): + with Horizontal(id="input-container"): + yield Input( + placeholder="What needs to be done?", + id="todo-input" + ) + yield Button("Add", variant="primary", id="add-button") + + yield ListView(id="todo-list") + + with Horizontal(id="stats"): + yield Static("", id="total-stat", classes="stat") + yield Static("", id="completed-stat", classes="stat") + yield Static("", id="remaining-stat", classes="stat") + + yield Footer() + + def on_mount(self) -> None: + """Initialize the app.""" + self.query_one("#todo-input").focus() + self.update_stats() + + # Add some example todos + self.add_todo_item("Learn Textual", False) + self.add_todo_item("Build amazing TUI apps", False) + self.add_todo_item("Share with the world", False) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button press.""" + if event.button.id == "add-button": + self.add_todo_from_input() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle input submission.""" + if event.input.id == "todo-input": + self.add_todo_from_input() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + """Handle todo item selection.""" + if isinstance(event.item, TodoItem): + event.item.toggle() + self.update_stats() + + def add_todo_from_input(self) -> None: + """Add a todo from the input field.""" + todo_input = self.query_one("#todo-input", Input) + text = todo_input.value.strip() + + if text: + self.add_todo_item(text) + todo_input.value = "" + todo_input.focus() + + def add_todo_item(self, text: str, completed: bool = False) -> None: + """Add a todo item to the list.""" + list_view = self.query_one("#todo-list", ListView) + todo = TodoItem(text, completed) + list_view.append(todo) + self.update_stats() + + def action_new_todo(self) -> None: + """Focus the input for a new todo.""" + self.query_one("#todo-input").focus() + + def action_delete_todo(self) -> None: + """Delete the selected todo.""" + list_view = self.query_one("#todo-list", ListView) + if list_view.highlighted_child: + list_view.highlighted_child.remove() + self.update_stats() + + def action_toggle_todo(self) -> None: + """Toggle the selected todo.""" + list_view = self.query_one("#todo-list", ListView) + if isinstance(list_view.highlighted_child, TodoItem): + list_view.highlighted_child.toggle() + self.update_stats() + + def action_clear_completed(self) -> None: + """Clear all completed todos.""" + list_view = self.query_one("#todo-list", ListView) + for item in list_view.query(TodoItem): + if item.completed: + item.remove() + self.update_stats() + + def update_stats(self) -> None: + """Update the statistics display.""" + list_view = self.query_one("#todo-list", ListView) + todos = list(list_view.query(TodoItem)) + + total = len(todos) + completed = sum(1 for t in todos if t.completed) + remaining = total - completed + + self.total_count = total + self.completed_count = completed + + self.query_one("#total-stat", Static).update(f"Total: {total}") + self.query_one("#completed-stat", Static).update(f"Completed: {completed}") + self.query_one("#remaining-stat", Static).update(f"Remaining: {remaining}") + + +if __name__ == "__main__": + app = TodoApp() + app.run() diff --git a/skill/assets/worker_demo.py b/skill/assets/worker_demo.py new file mode 100644 index 0000000..62e3749 --- /dev/null +++ b/skill/assets/worker_demo.py @@ -0,0 +1,406 @@ +""" +File Processor - Textual TUI Worker Example + +Demonstrates: +- Worker patterns for background tasks +- Progress updates from workers +- Worker cancellation +- Error handling in workers +- Multiple concurrent workers +- Thread-safe UI updates +""" + +from textual.app import App, ComposeResult +from textual.containers import Container, Vertical, Horizontal +from textual.widgets import Header, Footer, Button, ProgressBar, Log, Static, Label +from textual.worker import Worker, WorkerState +from pathlib import Path +import asyncio +import time +import hashlib + + +class FileProcessor(App): + """File processing app demonstrating worker patterns.""" + + CSS = """ + Screen { + background: $background; + } + + Header { + background: $primary; + } + + #main { + padding: 1 2; + height: 1fr; + } + + #controls { + height: auto; + margin-bottom: 1; + } + + Button { + margin: 0 1; + } + + #progress-container { + border: solid $primary; + padding: 1; + margin-bottom: 1; + height: auto; + background: $surface; + } + + #progress-label { + margin-bottom: 1; + text-style: bold; + color: $accent; + } + + ProgressBar { + margin-bottom: 1; + } + + #stats { + height: 5; + border: solid $accent; + padding: 1; + margin-bottom: 1; + background: $panel; + } + + .stat-line { + margin: 0; + } + + #log { + border: solid $primary; + height: 1fr; + background: $surface; + } + """ + + BINDINGS = [ + ("s", "start", "Start"), + ("c", "cancel", "Cancel"), + ("r", "reset", "Reset"), + ("q", "quit", "Quit"), + ] + + def __init__(self) -> None: + super().__init__() + self.main_worker: Worker | None = None + self.processed_count = 0 + self.total_files = 0 + self.start_time = 0.0 + + def compose(self) -> ComposeResult: + """Compose the UI.""" + yield Header(show_clock=True) + + with Container(id="main"): + with Horizontal(id="controls"): + yield Button("Start Processing", variant="success", id="start") + yield Button("Cancel", variant="error", id="cancel", disabled=True) + yield Button("Reset", id="reset") + + with Container(id="progress-container"): + yield Label("Progress", id="progress-label") + yield ProgressBar(total=100, show_percentage=True, id="progress") + + with Container(id="stats"): + yield Static("Status: Idle", classes="stat-line", id="status") + yield Static("Files Processed: 0 / 0", classes="stat-line", id="files") + yield Static("Time Elapsed: 0s", classes="stat-line", id="time") + yield Static("Speed: 0 files/sec", classes="stat-line", id="speed") + + yield Log(id="log", auto_scroll=True) + + yield Footer() + + def on_mount(self) -> None: + """Initialize on mount.""" + log = self.query_one("#log", Log) + log.write_line("[bold cyan]File Processor Started[/]") + log.write_line("Click 'Start Processing' or press 'S' to begin") + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "start": + self.action_start() + elif event.button.id == "cancel": + self.action_cancel() + elif event.button.id == "reset": + self.action_reset() + + def action_start(self) -> None: + """Start file processing worker.""" + if self.main_worker and not self.main_worker.is_finished: + self.log_message("[yellow]Processing already in progress[/]") + return + + # Disable start button, enable cancel + self.query_one("#start", Button).disabled = True + self.query_one("#cancel", Button).disabled = False + + # Reset counters + self.processed_count = 0 + self.total_files = 100 # Simulated file count + self.start_time = time.time() + + # Start worker + self.log_message("[bold green]Starting file processing...[/]") + self.main_worker = self.run_worker( + self.process_files(), + name="file_processor", + group="processing" + ) + + def action_cancel(self) -> None: + """Cancel the running worker.""" + if self.main_worker and not self.main_worker.is_finished: + self.main_worker.cancel() + self.log_message("[bold red]Cancelling worker...[/]") + + def action_reset(self) -> None: + """Reset the UI.""" + if self.main_worker and not self.main_worker.is_finished: + self.log_message("[yellow]Cannot reset while processing[/]") + return + + self.processed_count = 0 + self.total_files = 0 + + progress = self.query_one("#progress", ProgressBar) + progress.update(progress=0) + + self.update_stats() + self.log_message("[cyan]Reset complete[/]") + + async def process_files(self) -> None: + """ + Worker task: Process files in background. + + Demonstrates: + - Long-running async operations + - Progress updates via call_from_thread + - Cancellation checking + - Error handling + """ + try: + for i in range(self.total_files): + # Check if cancelled + if self.main_worker and self.main_worker.is_cancelled: + self.log_message("[red]Processing cancelled![/]") + self.finalize_processing(cancelled=True) + return + + # Simulate file processing (async I/O) + await asyncio.sleep(0.05) + + # Simulate some work + filename = f"file_{i:04d}.txt" + hash_val = hashlib.md5(filename.encode()).hexdigest()[:8] + + # Update progress (thread-safe) + self.processed_count = i + 1 + progress = (self.processed_count / self.total_files) * 100 + + # Update UI from worker + self.update_progress(progress, filename, hash_val) + + # Log every 10th file + if (i + 1) % 10 == 0: + self.log_message( + f"[green]Processed {self.processed_count}/{self.total_files} files[/]" + ) + + # Completed successfully + self.log_message("[bold green]✓ All files processed successfully![/]") + self.finalize_processing(cancelled=False) + + except Exception as e: + self.log_message(f"[bold red]Error: {e}[/]") + self.finalize_processing(cancelled=False) + raise + + def update_progress(self, progress: float, filename: str, hash_val: str) -> None: + """Update progress bar and stats (called from worker).""" + progress_bar = self.query_one("#progress", ProgressBar) + progress_bar.update(progress=progress) + + # Update stats + self.update_stats() + + # Update status + status = self.query_one("#status", Static) + status.update(f"Status: Processing {filename} [{hash_val}]") + + def update_stats(self) -> None: + """Update statistics display.""" + files_stat = self.query_one("#files", Static) + files_stat.update(f"Files Processed: {self.processed_count} / {self.total_files}") + + elapsed = time.time() - self.start_time if self.start_time > 0 else 0 + time_stat = self.query_one("#time", Static) + time_stat.update(f"Time Elapsed: {elapsed:.1f}s") + + speed = self.processed_count / elapsed if elapsed > 0 else 0 + speed_stat = self.query_one("#speed", Static) + speed_stat.update(f"Speed: {speed:.1f} files/sec") + + def finalize_processing(self, cancelled: bool) -> None: + """Finalize after processing completes or is cancelled.""" + # Re-enable buttons + self.query_one("#start", Button).disabled = False + self.query_one("#cancel", Button).disabled = True + + # Update status + status = self.query_one("#status", Static) + if cancelled: + status.update("Status: Cancelled") + else: + status.update("Status: Complete") + + def log_message(self, message: str) -> None: + """Thread-safe log message.""" + log = self.query_one("#log", Log) + log.write_line(message) + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + """Handle worker state changes.""" + if event.worker.name != "file_processor": + return + + if event.state == WorkerState.PENDING: + self.log_message("[cyan]Worker: Pending...[/]") + elif event.state == WorkerState.RUNNING: + self.log_message("[cyan]Worker: Running[/]") + elif event.state == WorkerState.CANCELLED: + self.log_message("[red]Worker: Cancelled[/]") + elif event.state == WorkerState.ERROR: + self.log_message(f"[bold red]Worker: Error - {event.worker.error}[/]") + elif event.state == WorkerState.SUCCESS: + self.log_message("[green]Worker: Completed successfully[/]") + + +class MultiWorkerDemo(App): + """Demonstrates running multiple concurrent workers.""" + + CSS = """ + Screen { + background: $background; + } + + #main { + padding: 1 2; + } + + #controls { + height: auto; + margin-bottom: 1; + } + + Button { + margin: 0 1; + } + + .worker-card { + border: solid $primary; + padding: 1; + margin-bottom: 1; + background: $surface; + } + + .worker-card .title { + text-style: bold; + color: $accent; + margin-bottom: 1; + } + + #log { + border: solid $primary; + height: 20; + } + """ + + BINDINGS = [ + ("s", "start_all", "Start All"), + ("c", "cancel_all", "Cancel All"), + ("q", "quit", "Quit"), + ] + + def compose(self) -> ComposeResult: + yield Header() + + with Container(id="main"): + with Horizontal(id="controls"): + yield Button("Start All Workers", variant="success", id="start") + yield Button("Cancel All", variant="error", id="cancel") + + with Container(classes="worker-card"): + yield Label("Worker 1: Data Fetch", classes="title") + yield ProgressBar(total=100, id="worker1") + + with Container(classes="worker-card"): + yield Label("Worker 2: Data Process", classes="title") + yield ProgressBar(total=100, id="worker2") + + with Container(classes="worker-card"): + yield Label("Worker 3: Data Export", classes="title") + yield ProgressBar(total=100, id="worker3") + + yield Log(id="log", auto_scroll=True) + + yield Footer() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "start": + self.action_start_all() + elif event.button.id == "cancel": + self.action_cancel_all() + + def action_start_all(self) -> None: + """Start all workers concurrently.""" + self.run_worker(self.worker_task(1, 3.0), name="worker1", group="tasks") + self.run_worker(self.worker_task(2, 5.0), name="worker2", group="tasks") + self.run_worker(self.worker_task(3, 4.0), name="worker3", group="tasks") + + log = self.query_one("#log", Log) + log.write_line("[bold cyan]Started all workers concurrently[/]") + + def action_cancel_all(self) -> None: + """Cancel all workers in the group.""" + for worker in self.workers: + if worker.group == "tasks": + worker.cancel() + + log = self.query_one("#log", Log) + log.write_line("[bold red]Cancelled all workers[/]") + + async def worker_task(self, worker_num: int, duration: float) -> None: + """Simulated worker task.""" + log = self.query_one("#log", Log) + log.write_line(f"[cyan]Worker {worker_num} started[/]") + + progress_bar = self.query_one(f"#worker{worker_num}", ProgressBar) + steps = 100 + + for i in range(steps): + await asyncio.sleep(duration / steps) + progress_bar.update(progress=i + 1) + + log.write_line(f"[green]Worker {worker_num} completed![/]") + + +if __name__ == "__main__": + # Run the single worker demo + app = FileProcessor() + app.run() + + # Or run the multi-worker demo + # app = MultiWorkerDemo() + # app.run() diff --git a/skill/references/layouts.md b/skill/references/layouts.md new file mode 100644 index 0000000..eaeecd1 --- /dev/null +++ b/skill/references/layouts.md @@ -0,0 +1,575 @@ +# Textual Layout Patterns + +Common layout recipes for Textual applications. + +## Layout Types + +### Vertical (Default) + +Stack widgets vertically: +```python +from textual.app import App, ComposeResult +from textual.widgets import Label + +class VerticalApp(App): + def compose(self) -> ComposeResult: + yield Label("Top") + yield Label("Middle") + yield Label("Bottom") + +# Or explicit CSS +CSS = """ +Screen { + layout: vertical; +} +""" +``` + +### Horizontal + +Arrange widgets side-by-side: +```python +from textual.containers import Horizontal + +def compose(self) -> ComposeResult: + with Horizontal(): + yield Label("Left") + yield Label("Center") + yield Label("Right") + +# Or via CSS +CSS = """ +Horizontal { + height: 100%; +} + +Horizontal > Label { + width: 1fr; /* Equal distribution */ +} +""" +``` + +### Grid + +Create grid layouts: +```python +class GridApp(App): + CSS = """ + Screen { + layout: grid; + grid-size: 3 2; /* 3 columns, 2 rows */ + grid-gutter: 1; + } + + .cell { + border: solid $accent; + height: 100%; + } + """ + + def compose(self) -> ComposeResult: + for i in range(6): + yield Label(f"Cell {i+1}", classes="cell") +``` + +Advanced grid with spanning: +```python +CSS = """ +Screen { + layout: grid; + grid-size: 4; /* 4 columns, auto rows */ +} + +#header { + column-span: 4; /* Spans all columns */ +} + +#sidebar { + row-span: 2; /* Spans 2 rows */ +} +""" +``` + +### Dock Layout + +Dock widgets to edges: +```python +from textual.widgets import Header, Footer + +class DockedApp(App): + def compose(self) -> ComposeResult: + yield Header() # Docked to top + yield Label("Content") # Takes remaining space + yield Footer() # Docked to bottom + +# Custom docking +CSS = """ +#sidebar { + dock: left; + width: 30; +} + +#toolbar { + dock: top; + height: 3; +} +""" +``` + +## Common Patterns + +### Split Screen (Vertical) + +Two panels side-by-side: +```python +class SplitScreen(App): + CSS = """ + Screen { + layout: horizontal; + } + + #left-panel { + width: 30%; + border-right: solid $accent; + } + + #right-panel { + width: 70%; + } + """ + + def compose(self) -> ComposeResult: + with Container(id="left-panel"): + yield Label("Sidebar") + with Container(id="right-panel"): + yield Label("Main content") +``` + +### Split Screen (Horizontal) + +Two panels stacked: +```python +class SplitScreenHorizontal(App): + CSS = """ + Screen { + layout: vertical; + } + + #top-panel { + height: 50%; + border-bottom: solid $accent; + } + + #bottom-panel { + height: 50%; + } + """ + + def compose(self) -> ComposeResult: + with Container(id="top-panel"): + yield Label("Top content") + with Container(id="bottom-panel"): + yield Label("Bottom content") +``` + +### Three-Column Layout + +Classic sidebar-content-sidebar: +```python +class ThreeColumn(App): + CSS = """ + Screen { + layout: horizontal; + } + + #left-sidebar { + width: 20; + } + + #content { + width: 1fr; /* Take remaining space */ + } + + #right-sidebar { + width: 25; + } + """ + + def compose(self) -> ComposeResult: + with Container(id="left-sidebar"): + yield Label("Menu") + with Container(id="content"): + yield Label("Main") + with Container(id="right-sidebar"): + yield Label("Info") +``` + +### Dashboard Grid + +Grid-based dashboard: +```python +class Dashboard(App): + CSS = """ + Screen { + layout: grid; + grid-size: 2 3; /* 2 columns, 3 rows */ + grid-gutter: 1 2; /* vertical horizontal */ + } + + #header { + column-span: 2; + height: 3; + } + + .metric-card { + border: solid $primary; + padding: 1; + } + """ + + def compose(self) -> ComposeResult: + yield Header(id="header") + yield Static("Users: 1,234", classes="metric-card") + yield Static("Revenue: $12K", classes="metric-card") + yield Static("Growth: +15%", classes="metric-card") + yield Static("Active: 567", classes="metric-card") +``` + +### Centered Content + +Center content horizontally and vertically: +```python +class CenteredApp(App): + CSS = """ + Screen { + align: center middle; + } + + #dialog { + width: 60; + height: 20; + border: thick $accent; + padding: 2; + background: $surface; + } + """ + + def compose(self) -> ComposeResult: + with Container(id="dialog"): + yield Label("Centered Dialog") + yield Button("OK") +``` + +### Scrollable Content + +Handle overflow with scrolling: +```python +from textual.containers import ScrollableContainer + +class ScrollableApp(App): + CSS = """ + #content { + height: 100%; + border: solid $primary; + } + """ + + def compose(self) -> ComposeResult: + with ScrollableContainer(id="content"): + for i in range(100): + yield Label(f"Line {i+1}") +``` + +### Tabbed Interface + +Tab-based navigation: +```python +from textual.widgets import TabbedContent, TabPane + +class TabbedApp(App): + def compose(self) -> ComposeResult: + with TabbedContent(): + with TabPane("Dashboard"): + yield Label("Dashboard content") + with TabPane("Users"): + yield Label("Users content") + with TabPane("Settings"): + yield Label("Settings content") +``` + +## Sizing Strategies + +### Fixed Sizes + +Absolute dimensions: +```css +#widget { + width: 40; /* 40 columns */ + height: 20; /* 20 rows */ +} +``` + +### Fractional Units + +Proportional sizing: +```css +#sidebar { + width: 1fr; /* 1 part */ +} + +#content { + width: 3fr; /* 3 parts (3x sidebar) */ +} +``` + +### Percentage + +Relative to parent: +```css +#widget { + width: 50%; /* Half of parent width */ + height: 100%; /* Full parent height */ +} +``` + +### Auto Sizing + +Size to content: +```css +#widget { + width: auto; /* Width matches content */ + height: auto; /* Height matches content */ +} +``` + +### Min/Max Constraints + +Bounded sizing: +```css +#widget { + width: 1fr; + min-width: 30; + max-width: 80; +} +``` + +## Spacing and Alignment + +### Padding + +Space inside widget: +```css +#widget { + padding: 1; /* All sides */ + padding: 1 2; /* Vertical Horizontal */ + padding: 1 2 1 2; /* Top Right Bottom Left */ + padding-top: 1; /* Individual sides */ +} +``` + +### Margin + +Space outside widget: +```css +#widget { + margin: 1; + margin: 0 2; /* No vertical, 2 horizontal */ + margin-left: 1; +} +``` + +### Alignment + +Position within container: +```css +Container { + align: center middle; /* Horizontal Vertical */ + align: left top; + align: right bottom; +} + +/* Content alignment (for containers) */ +Container { + content-align: center middle; +} +``` + +## Responsive Layouts + +### Container Queries + +Adjust based on container size: +```python +class ResponsiveApp(App): + CSS = """ + Screen { + layout: horizontal; + } + + /* Default mobile layout */ + #content { + layout: vertical; + } + + /* Desktop layout when width > 80 */ + Screen:width-gt-80 #content { + layout: horizontal; + } + """ +``` + +### Conditional Layouts + +Switch layouts based on screen size: +```python +def compose(self) -> ComposeResult: + if self.size.width > 100: + # Wide layout + with Horizontal(): + yield self.make_sidebar() + yield self.make_content() + else: + # Narrow layout + with Vertical(): + yield self.make_content() +``` + +## Advanced Patterns + +### Modal Overlay + +Centered modal dialog: +```python +from textual.screen import ModalScreen +from textual.containers import Container + +class Modal(ModalScreen[bool]): + CSS = """ + Modal { + align: center middle; + } + + #dialog { + width: 50; + height: 15; + border: thick $accent; + background: $surface; + padding: 1; + } + """ + + def compose(self) -> ComposeResult: + with Container(id="dialog"): + yield Label("Are you sure?") + with Horizontal(): + yield Button("Yes", variant="primary") + yield Button("No", variant="error") +``` + +### Sidebar Toggle + +Collapsible sidebar: +```python +class SidebarApp(App): + show_sidebar = reactive(True) + + CSS = """ + #sidebar { + width: 30; + transition: width 200ms; + } + + #sidebar.hidden { + width: 0; + display: none; + } + """ + + def watch_show_sidebar(self, show: bool) -> None: + sidebar = self.query_one("#sidebar") + sidebar.set_class(not show, "hidden") +``` + +### Masonry Layout + +Staggered grid: +```python +class MasonryLayout(App): + CSS = """ + Screen { + layout: grid; + grid-size: 3; + grid-gutter: 1; + } + + .card { + height: auto; + border: solid $primary; + padding: 1; + } + + .card.tall { + row-span: 2; + } + """ + + def compose(self) -> ComposeResult: + yield Static("Short card", classes="card") + yield Static("Tall card\n\n\n", classes="card tall") + yield Static("Short", classes="card") +``` + +### Split Resizable + +Adjustable split panels: +```python +class ResizableSplit(App): + left_width = reactive(30) + + CSS = """ + #left { + width: var(--left-width); + } + + #right { + width: 1fr; + } + + #divider { + width: 1; + background: $accent; + } + """ + + def watch_left_width(self, width: int) -> None: + self.set_var("left-width", width) +``` + +## Layout Debugging + +Use borders to visualize layout: +```css +* { + border: solid red; /* Temporary debugging */ +} + +Container { + border: solid blue; +} + +Widget { + border: solid green; +} +``` + +Use Textual devtools: +```bash +textual run --dev app.py +``` + +Add debug info to widgets: +```python +def compose(self) -> ComposeResult: + yield Label(f"Size: {self.size}") + yield Label(f"Region: {self.region}") +``` diff --git a/skill/references/official-guides-index.md b/skill/references/official-guides-index.md new file mode 100644 index 0000000..a0af39a --- /dev/null +++ b/skill/references/official-guides-index.md @@ -0,0 +1,284 @@ +# Textual Official Guide Index + +High-level index of every guide from the official Textual documentation. Use `web_fetch` to retrieve full content on-demand when needed. + +## Getting Started + +### Installation +**URL:** https://textual.textualize.io/getting_started/ +**Topics:** Installing Textual, requirements, first app +**When to fetch:** User asks about installation, setup, or getting started + +### Tutorial +**URL:** https://textual.textualize.io/tutorial/ +**Topics:** Building a stopwatch app, complete walkthrough, reactive attributes, widgets +**When to fetch:** User wants step-by-step tutorial or building their first app + +## Core Concepts + +### App Basics +**URL:** https://textual.textualize.io/guide/app/ +**Topics:** Creating apps, running apps, compose method, mounting widgets, app lifecycle, application mode, inline mode, suspending apps +**When to fetch:** Questions about app structure, lifecycle, or basic app operations + +### Widgets +**URL:** https://textual.textualize.io/guide/widgets/ +**Topics:** Creating custom widgets, widget communication, compound widgets, render method, line API, renderables, uni-directional data flow, widget design patterns +**When to fetch:** Creating custom widgets, widget architecture, or advanced widget patterns + +### Layout +**URL:** https://textual.textualize.io/guide/layout/ +**Topics:** Vertical layout, horizontal layout, grid layout, dock layout, layers, FR units, container layouts, grid-size, grid-columns, grid-rows, row-span, column-span +**When to fetch:** Layout questions, arranging widgets, grid systems, positioning + +### CSS & Styling +**URL:** https://textual.textualize.io/guide/CSS/ +**Topics:** Textual CSS basics, selectors (type, ID, class, universal), pseudo-classes, CSS_PATH, live editing, combinator selectors +**When to fetch:** CSS syntax, selectors, styling fundamentals + +### Styles +**URL:** https://textual.textualize.io/guide/styles/ +**Topics:** Style properties, colors (hex, RGB, HSL), units (%, fr, vw, vh, w, h), box model, box-sizing, dimensions, spacing +**When to fetch:** Specific style properties, units, colors, dimensions + +### Design System / Themes +**URL:** https://textual.textualize.io/guide/design/ +**Topics:** Theme system, built-in themes, creating custom themes, color variables, semantic colors, $text variables, theme switching, design tokens +**When to fetch:** Theming, color systems, design tokens, theme customization + +## Interaction & Events + +### Input +**URL:** https://textual.textualize.io/guide/input/ +**Topics:** Keyboard input, mouse input, focus, key events, mouse events, input handling +**When to fetch:** Handling keyboard/mouse input, focus management, input events + +### Events +**URL:** https://textual.textualize.io/guide/events/ +**Topics:** Event system, message handlers, event bubbling, preventing events, custom events, event lifecycle +**When to fetch:** Event handling, custom events, event propagation + +### Actions +**URL:** https://textual.textualize.io/guide/actions/ +**Topics:** Action system, key bindings, BINDINGS, action_* methods, built-in actions, custom actions +**When to fetch:** Keyboard shortcuts, actions, key bindings + +### Reactivity +**URL:** https://textual.textualize.io/guide/reactivity/ +**Topics:** Reactive attributes, watch methods, compute methods, reactive decorators, smart refresh, recompose, data binding +**When to fetch:** Reactive programming, automatic updates, computed values, watchers + +## Advanced Features + +### Screens +**URL:** https://textual.textualize.io/guide/screens/ +**Topics:** Screen stack, push_screen, pop_screen, ModalScreen, screen navigation, installed screens, SCREENS, screen opacity +**When to fetch:** Multi-screen apps, navigation, modal dialogs, screen management + +### Query +**URL:** https://textual.textualize.io/guide/queries/ +**Topics:** Querying widgets, query_one, query, set methods, DOM traversal, selectors in queries +**When to fetch:** Finding widgets, DOM navigation, bulk operations + +### Workers +**URL:** https://textual.textualize.io/guide/workers/ +**Topics:** Background tasks, @work decorator, run_worker, thread workers, async workers, worker lifecycle, cancellation +**When to fetch:** Background processing, async operations, threading, long-running tasks + +### Animation +**URL:** https://textual.textualize.io/guide/animation/ +**Topics:** Animating styles, animate method, easing functions, duration, transitions, animation callbacks +**When to fetch:** Animations, transitions, easing, style animations + +### Command Palette +**URL:** https://textual.textualize.io/guide/command_palette/ +**Topics:** Built-in command palette, Provider class, fuzzy matching, custom commands, command discovery +**When to fetch:** Command palette, custom commands, keyboard-driven interfaces + +## Content & Display + +### Content / Markup +**URL:** https://textual.textualize.io/guide/content/ +**Topics:** Content markup, Rich renderables, styling text, markup tags, links, clickable actions, content objects +**When to fetch:** Text formatting, markup syntax, Rich renderables, styled text + +### Rich Content +**URL:** https://textual.textualize.io/guide/rich/ +**Topics:** Using Rich library, Rich renderables, tables, syntax highlighting, panels, progress bars +**When to fetch:** Rich library integration, advanced text formatting, Rich features + +## Development & Debugging + +### Devtools +**URL:** https://textual.textualize.io/guide/devtools/ +**Topics:** textual command, console, run command, live CSS editing, --dev mode, debugging, logging +**When to fetch:** Development workflow, debugging, console logging, dev tools + +### Testing +**URL:** https://textual.textualize.io/guide/testing/ +**Topics:** Testing apps, Pilot API, run_test, simulating input, snapshots, unit testing +**When to fetch:** Testing, unit tests, test automation, Pilot + +### Performance +**URL:** https://textual.textualize.io/guide/performance/ +**Topics:** Performance optimization, profiling, rendering performance, widget efficiency, best practices +**When to fetch:** Performance issues, optimization, profiling + +## Reference Documentation + +### Widgets Reference +**URL:** https://textual.textualize.io/widgets/ +**Topics:** Complete widget reference, all built-in widgets with examples +**When to fetch:** Looking for specific widget documentation, widget API details + +### Styles Reference +**URL:** https://textual.textualize.io/styles/ +**Topics:** Complete CSS properties reference, all style properties +**When to fetch:** Specific style property details, CSS reference + +### API Reference +**URL:** https://textual.textualize.io/api/ +**Topics:** Complete Python API reference +**When to fetch:** API details, method signatures, class documentation + +## Specialized Topics + +### Scrolling +**URL:** https://textual.textualize.io/guide/scrolling/ +**Topics:** Scroll views, ScrollableContainer, scrolling behavior, scroll_visible, programmatic scrolling +**When to fetch:** Scrolling issues, scroll containers, programmatic scrolling + +### Tooltips +**URL:** https://textual.textualize.io/guide/tooltips/ +**Topics:** Adding tooltips, tooltip property, tooltip customization +**When to fetch:** Tooltips, hover help text + +### Notifications +**URL:** https://textual.textualize.io/guide/notifications/ +**Topics:** Toast notifications, notify method, notification severity, notification styling +**When to fetch:** Notifications, alerts, toasts + +### Input Validation +**URL:** https://textual.textualize.io/guide/input_validation/ +**Topics:** Validating input, Validator class, built-in validators, custom validators +**When to fetch:** Form validation, input validation, validators + +### Timers +**URL:** https://textual.textualize.io/guide/timers/ +**Topics:** Scheduling tasks, set_timer, set_interval, timer callbacks, timer management +**When to fetch:** Scheduled tasks, periodic updates, timers + +### Paths +**URL:** https://textual.textualize.io/guide/paths/ +**Topics:** File paths, resource paths, CSS_PATH, path resolution +**When to fetch:** File loading, resource paths, path management + +## Additional Topics + +### FAQ +**URL:** https://textual.textualize.io/FAQ/ +**Topics:** Common questions, troubleshooting, best practices +**When to fetch:** Common issues, general questions, troubleshooting + +### Why Textual? +**URL:** https://textual.textualize.io/guide/why/ +**Topics:** Benefits of Textual, use cases, comparison with alternatives +**When to fetch:** Understanding Textual benefits, when to use Textual + +## Usage Guidelines + +### When to Fetch Guides + +1. **Don't fetch unless needed**: The skill already covers fundamentals. Only fetch when: + - User asks about a specific topic not covered in skill + - Need detailed API information + - Complex examples required + - Latest updates needed (docs may be newer than skill) + +2. **Fetch specific sections**: Use targeted URLs for relevant topics + +3. **Combine with skill knowledge**: Use fetched content to supplement, not replace, skill knowledge + +### Example Fetch Patterns + +```python +# User asks about command palette +web_fetch("https://textual.textualize.io/guide/command_palette/") + +# User needs animation details +web_fetch("https://textual.textualize.io/guide/animation/") + +# User wants testing info +web_fetch("https://textual.textualize.io/guide/testing/") + +# Need widget reference +web_fetch("https://textual.textualize.io/widgets/data_table/") +``` + +## Quick Reference by Topic + +### Need information about... + +**App Structure** → App Basics guide +**Layout & Positioning** → Layout guide +**Styling & CSS** → CSS guide, Styles guide, Design guide +**User Input** → Input guide, Events guide, Actions guide +**Custom Widgets** → Widgets guide +**Navigation** → Screens guide +**Async Tasks** → Workers guide +**Animations** → Animation guide +**Commands** → Command Palette guide +**Testing** → Testing guide +**Development** → Devtools guide +**Rich Integration** → Rich Content guide +**Form Validation** → Input Validation guide +**Auto-Updates** → Reactivity guide + +## Widget-Specific Documentation + +All built-in widgets have dedicated documentation at: +`https://textual.textualize.io/widgets/{widget_name}/` + +Common widget docs: +- **Button**: https://textual.textualize.io/widgets/button/ +- **Input**: https://textual.textualize.io/widgets/input/ +- **DataTable**: https://textual.textualize.io/widgets/data_table/ +- **Tree**: https://textual.textualize.io/widgets/tree/ +- **Select**: https://textual.textualize.io/widgets/select/ +- **TextArea**: https://textual.textualize.io/widgets/text_area/ +- **ListView**: https://textual.textualize.io/widgets/list_view/ +- **ProgressBar**: https://textual.textualize.io/widgets/progress_bar/ +- **Markdown**: https://textual.textualize.io/widgets/markdown/ +- **MarkdownViewer**: https://textual.textualize.io/widgets/markdown_viewer/ +- **DirectoryTree**: https://textual.textualize.io/widgets/directory_tree/ +- **Header**: https://textual.textualize.io/widgets/header/ +- **Footer**: https://textual.textualize.io/widgets/footer/ +- **Label**: https://textual.textualize.io/widgets/label/ +- **Static**: https://textual.textualize.io/widgets/static/ +- **Log**: https://textual.textualize.io/widgets/log/ +- **RichLog**: https://textual.textualize.io/widgets/rich_log/ +- **Sparkline**: https://textual.textualize.io/widgets/sparkline/ +- **Switch**: https://textual.textualize.io/widgets/switch/ +- **Checkbox**: https://textual.textualize.io/widgets/checkbox/ +- **RadioButton**: https://textual.textualize.io/widgets/radio_button/ +- **RadioSet**: https://textual.textualize.io/widgets/radio_set/ +- **TabbedContent**: https://textual.textualize.io/widgets/tabbed_content/ +- **ContentSwitcher**: https://textual.textualize.io/widgets/content_switcher/ +- **LoadingIndicator**: https://textual.textualize.io/widgets/loading_indicator/ + +## Container Widget Documentation + +- **Container**: https://textual.textualize.io/widgets/container/ +- **Horizontal**: https://textual.textualize.io/widgets/horizontal/ +- **Vertical**: https://textual.textualize.io/widgets/vertical/ +- **Grid**: https://textual.textualize.io/widgets/grid/ +- **ScrollableContainer**: https://textual.textualize.io/widgets/scrollable_container/ +- **VerticalScroll**: https://textual.textualize.io/widgets/vertical_scroll/ +- **HorizontalScroll**: https://textual.textualize.io/widgets/horizontal_scroll/ + +## Notes + +- All URLs follow pattern: `https://textual.textualize.io/{section}/{topic}/` +- Official docs are actively maintained and may have updates not in this skill +- Use web_fetch with specific URLs when detailed or latest information needed +- Combine official docs with skill knowledge for best results diff --git a/skill/references/styling.md b/skill/references/styling.md new file mode 100644 index 0000000..723db6f --- /dev/null +++ b/skill/references/styling.md @@ -0,0 +1,700 @@ +# Textual CSS Styling Guide + +Complete guide to styling Textual applications with TCSS (Textual CSS). + +## CSS Basics + +### Inline Styles + +Define styles directly in widget class: +```python +class MyWidget(Widget): + DEFAULT_CSS = """ + MyWidget { + background: $primary; + color: $text; + border: solid $accent; + } + """ +``` + +### External Stylesheets + +Load from file: +```python +class MyApp(App): + CSS_PATH = "app.tcss" # Load from app.tcss file +``` + +### Multiple Stylesheets + +Load multiple files: +```python +class MyApp(App): + CSS_PATH = ["base.tcss", "theme.tcss", "overrides.tcss"] +``` + +## Selectors + +### Type Selectors + +Target widget types: +```css +Button { + background: blue; +} + +Label { + color: white; +} +``` + +### ID Selectors + +Target specific widgets: +```css +#submit-button { + background: green; +} + +#error-message { + color: red; +} +``` + +### Class Selectors + +Target classes: +```css +.highlight { + background: yellow; + color: black; +} + +.card { + border: solid white; + padding: 1; +} +``` + +### Pseudo-classes + +Target widget states: +```css +Button:hover { + background: lighten($primary, 20%); +} + +Button:focus { + border: thick $accent; +} + +Input:focus { + border: solid $success; +} + +/* Disabled state */ +Button:disabled { + opacity: 50%; +} +``` + +### Descendant Selectors + +Target nested widgets: +```css +/* Any Label inside a Container */ +Container Label { + color: gray; +} + +/* Direct children only */ +Container > Label { + color: white; +} + +/* Specific nesting */ +#sidebar .menu-item { + padding: 1; +} +``` + +### Multiple Selectors + +Apply same style to multiple targets: +```css +Button, Input, Select { + border: solid $accent; +} + +.error, .warning { + font-weight: bold; +} +``` + +## Colors + +### Semantic Colors + +Use theme colors: +```css +Widget { + background: $background; + color: $text; + border: solid $primary; +} + +/* Available semantic colors */ +$primary /* Primary theme color */ +$secondary /* Secondary theme color */ +$accent /* Accent color */ +$background /* Background color */ +$surface /* Surface color */ +$panel /* Panel color */ +$text /* Primary text color */ +$text-muted /* Muted text */ +$text-disabled /* Disabled text */ +$success /* Success state */ +$warning /* Warning state */ +$error /* Error state */ +$boost /* Highlight color */ +``` + +### Color Formats + +Define custom colors: +```css +Widget { + background: #1e3a8a; /* Hex */ + color: rgb(255, 255, 255); /* RGB */ + border-color: rgba(255, 0, 0, 0.5); /* RGBA with alpha */ +} + +/* Named colors */ +Widget { + background: transparent; + color: black; + border-color: white; +} +``` + +### Color Functions + +Manipulate colors: +```css +Widget { + background: darken($primary, 20%); + color: lighten($text, 10%); + border-color: fade($accent, 50%); +} +``` + +## Typography + +### Text Style + +```css +Label { + text-style: bold; /* bold, italic, underline */ + text-style: bold italic; /* Multiple styles */ + text-style: reverse; /* Reverse colors */ + text-style: strike; /* Strikethrough */ +} +``` + +### Text Alignment + +```css +Label { + text-align: left; /* left, center, right, justify */ +} +``` + +### Text Opacity + +```css +Label { + text-opacity: 70%; /* Semi-transparent text */ +} +``` + +## Borders + +### Border Styles + +```css +Widget { + border: solid $accent; /* Solid border */ + border: dashed blue; /* Dashed */ + border: heavy green; /* Heavy */ + border: double white; /* Double */ + border: thick $primary; /* Thick */ + border: none; /* No border */ +} +``` + +### Border Sides + +```css +Widget { + border-top: solid red; + border-right: dashed blue; + border-bottom: thick green; + border-left: double white; +} +``` + +### Border Title + +```css +Widget { + border: solid $accent; + border-title-align: center; /* left, center, right */ +} +``` + +## Dimensions + +### Width + +```css +Widget { + width: 40; /* Fixed columns */ + width: 50%; /* Percentage of parent */ + width: 1fr; /* Fractional unit */ + width: auto; /* Size to content */ +} +``` + +### Height + +```css +Widget { + height: 20; /* Fixed rows */ + height: 100%; /* Full parent height */ + height: auto; /* Size to content */ +} +``` + +### Min/Max Constraints + +```css +Widget { + min-width: 20; + max-width: 80; + min-height: 10; + max-height: 50; +} +``` + +## Spacing + +### Padding + +Space inside widget: +```css +Widget { + padding: 1; /* All sides */ + padding: 1 2; /* Vertical Horizontal */ + padding: 1 2 3 4; /* Top Right Bottom Left */ +} + +/* Individual sides */ +Widget { + padding-top: 1; + padding-right: 2; + padding-bottom: 1; + padding-left: 2; +} +``` + +### Margin + +Space outside widget: +```css +Widget { + margin: 1; + margin: 0 2; + margin: 1 2 1 2; +} + +/* Individual sides */ +Widget { + margin-top: 1; + margin-right: 2; +} +``` + +## Layout Properties + +### Display + +Control visibility: +```css +Widget { + display: block; /* Visible */ + display: none; /* Hidden */ +} +``` + +### Visibility + +Alternative to display: +```css +Widget { + visibility: visible; + visibility: hidden; /* Hidden but takes space */ +} +``` + +### Opacity + +Transparency: +```css +Widget { + opacity: 100%; /* Fully opaque */ + opacity: 50%; /* Semi-transparent */ + opacity: 0%; /* Fully transparent */ +} +``` + +### Layout Type + +```css +Container { + layout: vertical; /* Stack vertically */ + layout: horizontal; /* Stack horizontally */ + layout: grid; /* Grid layout */ +} +``` + +### Grid Properties + +```css +Container { + layout: grid; + grid-size: 3 2; /* 3 columns, 2 rows */ + grid-gutter: 1 2; /* Vertical Horizontal gaps */ + grid-rows: 10 auto 1fr; /* Row sizes */ + grid-columns: 1fr 2fr; /* Column sizes */ +} + +/* Grid item spanning */ +Widget { + column-span: 2; /* Span 2 columns */ + row-span: 3; /* Span 3 rows */ +} +``` + +### Alignment + +```css +Container { + align: center middle; /* Horizontal Vertical */ + align-horizontal: left; /* left, center, right */ + align-vertical: top; /* top, middle, bottom */ +} + +/* Content alignment */ +Container { + content-align: center middle; + content-align-horizontal: right; + content-align-vertical: bottom; +} +``` + +### Scrollbars + +```css +Widget { + overflow: auto; /* Show scrollbars when needed */ + overflow: scroll; /* Always show scrollbars */ + overflow: hidden; /* No scrollbars */ +} + +/* Individual axes */ +Widget { + overflow-x: auto; + overflow-y: scroll; +} + +/* Scrollbar styling */ +Widget { + scrollbar-background: $panel; + scrollbar-color: $primary; + scrollbar-color-hover: $accent; + scrollbar-color-active: $boost; +} +``` + +## Effects + +### Transitions + +Animate property changes: +```css +Button { + background: blue; + transition: background 300ms; +} + +Button:hover { + background: lightblue; /* Animates over 300ms */ +} + +/* Multiple properties */ +Widget { + transition: background 200ms, border 150ms; +} +``` + +### Offset + +Position adjustment: +```css +Widget { + offset: 1 2; /* X Y offset */ + offset-x: 1; + offset-y: 2; +} +``` + +### Layer + +Z-index equivalent: +```css +Widget { + layer: above; /* Higher layer */ + layer: below; /* Lower layer */ +} +``` + +## Docking + +Pin widgets to edges: +```css +#header { + dock: top; + height: 3; +} + +#sidebar { + dock: left; + width: 30; +} + +#footer { + dock: bottom; + height: 3; +} +``` + +## Theme Variables + +Define reusable values: +```css +/* Define variables */ +Screen { + --card-bg: #1e3a8a; + --card-border: white; + --card-padding: 1 2; +} + +/* Use variables */ +.card { + background: var(--card-bg); + border: solid var(--card-border); + padding: var(--card-padding); +} +``` + +## Complete Theme Example + +```css +/* app.tcss */ + +/* Theme colors */ +Screen { + background: #0f172a; + color: #e2e8f0; +} + +/* Headers */ +Header { + background: #1e293b; + color: #60a5fa; + dock: top; + height: 3; +} + +Footer { + background: #1e293b; + color: #94a3b8; + dock: bottom; + height: 1; +} + +/* Buttons */ +Button { + background: #3b82f6; + color: white; + border: none; + margin: 0 1; + padding: 0 2; + min-width: 16; + transition: background 200ms; +} + +Button:hover { + background: #60a5fa; +} + +Button:focus { + border: solid #93c5fd; +} + +Button.-primary { + background: #10b981; +} + +Button.-primary:hover { + background: #34d399; +} + +Button.-error { + background: #ef4444; +} + +Button.-error:hover { + background: #f87171; +} + +/* Inputs */ +Input { + border: solid #475569; + background: #1e293b; + color: #e2e8f0; + padding: 0 1; +} + +Input:focus { + border: solid #3b82f6; +} + +/* Containers */ +.card { + background: #1e293b; + border: solid #334155; + padding: 1 2; + margin: 1; +} + +.card > .title { + text-style: bold; + color: #60a5fa; + margin-bottom: 1; +} + +/* Data tables */ +DataTable { + background: #1e293b; +} + +DataTable > .datatable--header { + background: #334155; + color: #60a5fa; + text-style: bold; +} + +DataTable > .datatable--cursor { + background: #3b82f6; +} + +/* Scrollbars */ +*::-webkit-scrollbar { + scrollbar-background: #1e293b; + scrollbar-color: #475569; +} + +*::-webkit-scrollbar:hover { + scrollbar-color: #64748b; +} +``` + +## Dark/Light Themes + +Support theme switching: +```python +class MyApp(App): + ENABLE_DARK_MODE = True + + CSS = """ + /* Dark theme (default) */ + Screen { + background: #0f172a; + color: #e2e8f0; + } + + /* Light theme */ + Screen.light { + background: #f8fafc; + color: #1e293b; + } + """ + + def action_toggle_theme(self) -> None: + self.dark = not self.dark +``` + +## Responsive Styles + +Conditional styles based on size: +```css +/* Default (small screens) */ +#sidebar { + width: 100%; +} + +/* Medium screens */ +Screen:width-gt-80 #sidebar { + width: 30; +} + +/* Large screens */ +Screen:width-gt-120 #sidebar { + width: 40; +} +``` + +## Best Practices + +1. **Use semantic colors** - Prefer `$primary` over hardcoded values +2. **Organize CSS** - Group related styles together +3. **Use classes** - Reusable styles via classes, not IDs +4. **Minimize specificity** - Avoid overly specific selectors +5. **Use transitions** - Smooth state changes +6. **Test both themes** - Ensure dark/light compatibility +7. **Keep CSS DRY** - Use variables for repeated values +8. **Document custom variables** - Comment non-obvious choices + +## Debugging Styles + +View computed styles: +```python +def on_mount(self) -> None: + widget = self.query_one("#my-widget") + self.log(widget.styles) # Log all computed styles +``` + +Use Textual devtools: +```bash +textual run --dev app.py +# Press F1 to view CSS inspector +``` + +Temporary debugging borders: +```css +* { + border: solid red; /* See all widget boundaries */ +} +``` diff --git a/skill/references/widgets.md b/skill/references/widgets.md new file mode 100644 index 0000000..5c7f990 --- /dev/null +++ b/skill/references/widgets.md @@ -0,0 +1,533 @@ +# Textual Widget Gallery + +Comprehensive examples of all built-in Textual widgets. + +## Basic Widgets + +### Label + +Display static or dynamic text: +```python +from textual.widgets import Label + +# Simple label +yield Label("Hello World") + +# With styling +yield Label("Important!", classes="highlight") + +# With markup +yield Label("[bold]Bold[/] and [italic]italic[/]") + +# Dynamic label with reactive +class DynamicLabel(Widget): + message = reactive("Initial") + + def compose(self) -> ComposeResult: + yield Label(self.message) +``` + +### Static + +Display Rich renderables: +```python +from textual.widgets import Static +from rich.table import Table + +table = Table() +table.add_column("Name") +table.add_column("Value") +table.add_row("Alpha", "100") + +yield Static(table) +``` + +### Button + +Interactive buttons with variants: +```python +from textual.widgets import Button + +# Standard button +yield Button("Click me", id="action") + +# Button variants +yield Button("Primary", variant="primary") +yield Button("Success", variant="success") +yield Button("Warning", variant="warning") +yield Button("Error", variant="error") + +# Disabled button +yield Button("Disabled", disabled=True) + +# Handle click +def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "action": + self.action_perform() +``` + +## Input Widgets + +### Input + +Single-line text input: +```python +from textual.widgets import Input + +# Basic input +yield Input(placeholder="Enter text...", id="name") + +# Password input +yield Input(placeholder="Password", password=True, id="pass") + +# Validated input +yield Input( + placeholder="Email", + validators=[Email()], # Built-in validators + id="email" +) + +# Handle submission +def on_input_submitted(self, event: Input.Submitted) -> None: + value = event.value + self.log(f"Submitted: {value}") + +# Handle changes +def on_input_changed(self, event: Input.Changed) -> None: + self.validate_live(event.value) +``` + +### TextArea + +Multi-line text editor: +```python +from textual.widgets import TextArea + +# Basic text area +yield TextArea(id="editor") + +# With initial content +yield TextArea( + text="Initial content\nLine 2", + language="python", # Syntax highlighting + theme="monokai", + id="code" +) + +# Handle changes +def on_text_area_changed(self, event: TextArea.Changed) -> None: + content = event.text_area.text +``` + +### Select + +Dropdown selection: +```python +from textual.widgets import Select + +# Basic select +options = [ + ("Option A", "a"), + ("Option B", "b"), + ("Option C", "c"), +] +yield Select(options=options, prompt="Choose...", id="choice") + +# Handle selection +def on_select_changed(self, event: Select.Changed) -> None: + value = event.value # "a", "b", or "c" + self.log(f"Selected: {value}") +``` + +### Checkbox + +Boolean input: +```python +from textual.widgets import Checkbox + +yield Checkbox("Enable feature", id="feature") + +# Handle changes +def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + is_checked = event.value + self.toggle_feature(is_checked) +``` + +### RadioButton and RadioSet + +Mutually exclusive options: +```python +from textual.widgets import RadioButton, RadioSet + +with RadioSet(id="size"): + yield RadioButton("Small") + yield RadioButton("Medium", value=True) # Default + yield RadioButton("Large") + +# Handle selection +def on_radio_set_changed(self, event: RadioSet.Changed) -> None: + selected = event.pressed.label + self.log(f"Size: {selected}") +``` + +### Switch + +Toggle switch: +```python +from textual.widgets import Switch + +yield Switch(value=True, id="notifications") + +# Handle toggle +def on_switch_changed(self, event: Switch.Changed) -> None: + is_on = event.value + self.toggle_notifications(is_on) +``` + +## Data Display Widgets + +### DataTable + +Tabular data with selection and sorting: +```python +from textual.widgets import DataTable + +table = DataTable(id="users") + +# Add columns +table.add_columns("Name", "Age", "City") + +# Add rows (returns row key) +row_key = table.add_row("Alice", 30, "NYC") +table.add_row("Bob", 25, "LA") + +# Cursor control +table.cursor_type = "row" # or "cell", "column", "none" + +# Handle selection +def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + row_key = event.row_key + row_data = event.row + self.log(f"Selected: {row_data}") + +# Update cells +table.update_cell(row_key, "Age", 31) + +# Remove rows +table.remove_row(row_key) + +# Sort +table.sort("Age", reverse=True) +``` + +### Tree + +Hierarchical data: +```python +from textual.widgets import Tree + +tree = Tree("Root", id="file-tree") + +# Add nodes +root = tree.root +folder = root.add("Folder", expand=True) +folder.add_leaf("file1.txt") +folder.add_leaf("file2.txt") + +subfolder = folder.add("Subfolder") +subfolder.add_leaf("nested.txt") + +# Handle selection +def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + node = event.node + self.log(f"Selected: {node.label}") + +# Expand/collapse +def on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: + # Load children dynamically + node = event.node + self.load_children(node) +``` + +### ListView + +List with selection: +```python +from textual.widgets import ListView, ListItem, Label + +list_view = ListView(id="menu") + +# Add items +list_view.append(ListItem(Label("Item 1"))) +list_view.append(ListItem(Label("Item 2"))) +list_view.append(ListItem(Label("Item 3"))) + +# Handle selection +def on_list_view_selected(self, event: ListView.Selected) -> None: + item = event.item + self.log(f"Selected: {item}") +``` + +### Log / RichLog + +Scrollable log output: +```python +from textual.widgets import Log, RichLog + +# Simple log +log = Log(id="output", auto_scroll=True) +log.write_line("Log entry") +log.write_lines(["Line 1", "Line 2"]) + +# Rich log with markup +rich_log = RichLog(id="rich-output", highlight=True) +rich_log.write("[bold green]Success![/]") +rich_log.write("[red]Error occurred[/]") + +# Clear log +log.clear() +``` + +### ProgressBar + +Progress indicator: +```python +from textual.widgets import ProgressBar + +# Determinate progress +progress = ProgressBar(total=100, id="progress") +progress.advance(25) # 25% +progress.update(progress=50) # 50% + +# Indeterminate progress +progress = ProgressBar(total=None) # Animated spinner +``` + +### Sparkline + +Inline data visualization: +```python +from textual.widgets import Sparkline + +data = [1, 2, 3, 5, 8, 13, 21] +yield Sparkline(data, id="chart") + +# Update data +sparkline = self.query_one(Sparkline) +sparkline.data = new_data +``` + +## Navigation Widgets + +### Header / Footer + +Standard app chrome: +```python +from textual.widgets import Header, Footer + +def compose(self) -> ComposeResult: + yield Header(show_clock=True) + # ... content ... + yield Footer() +``` + +### TabbedContent / TabPane + +Tabbed interface: +```python +from textual.widgets import TabbedContent, TabPane + +with TabbedContent(id="tabs"): + with TabPane("Tab 1", id="tab1"): + yield Label("Content 1") + with TabPane("Tab 2", id="tab2"): + yield Label("Content 2") + with TabPane("Tab 3", id="tab3"): + yield Label("Content 3") + +# Handle tab changes +def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None: + tab_id = event.pane.id + self.log(f"Switched to {tab_id}") +``` + +### ContentSwitcher + +Programmatically switch content: +```python +from textual.widgets import ContentSwitcher + +with ContentSwitcher(initial="view1", id="switcher"): + yield Label("View 1", id="view1") + yield Label("View 2", id="view2") + yield Label("View 3", id="view3") + +# Switch views +def switch_view(self, view_id: str) -> None: + switcher = self.query_one(ContentSwitcher) + switcher.current = view_id +``` + +### OptionList + +Selectable list of options: +```python +from textual.widgets import OptionList +from textual.widgets.option_list import Option + +option_list = OptionList( + Option("Option 1", id="opt1"), + Option("Option 2", id="opt2"), + Option("Option 3", id="opt3"), +) + +# Handle selection +def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + option_id = event.option.id + self.log(f"Selected: {option_id}") +``` + +## Loading Widgets + +### LoadingIndicator + +Spinning loader: +```python +from textual.widgets import LoadingIndicator + +# Show while loading +with LoadingIndicator(): + # Content loading... + pass + +# Or standalone +yield LoadingIndicator(id="loader") +``` + +### Placeholder + +Development placeholder: +```python +from textual.widgets import Placeholder + +# Quick placeholder during development +yield Placeholder("Chart goes here") +yield Placeholder(label="[b]User Profile[/]", variant="text") +``` + +## Special Widgets + +### Markdown + +Render markdown: +```python +from textual.widgets import Markdown + +markdown_content = """ +# Title +This is **bold** and *italic*. + +- Item 1 +- Item 2 + +```python +code block +``` +""" + +yield Markdown(markdown_content, id="docs") +``` + +### DirectoryTree + +File system browser: +```python +from textual.widgets import DirectoryTree + +tree = DirectoryTree("/home/user", id="files") + +# Handle file selection +def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None: + file_path = event.path + self.open_file(file_path) +``` + +## Custom Widget Example + +Create composite widgets: +```python +from textual.widget import Widget +from textual.containers import Horizontal, Vertical + +class UserCard(Widget): + """Display user information.""" + + def __init__(self, name: str, email: str, role: str) -> None: + super().__init__() + self.name = name + self.email = email + self.role = role + + def compose(self) -> ComposeResult: + with Vertical(classes="card"): + yield Label(self.name, classes="name") + yield Label(self.email, classes="email") + with Horizontal(): + yield Label(f"Role: {self.role}", classes="role") + yield Button("Edit", variant="primary") + + DEFAULT_CSS = """ + UserCard { + border: solid $primary; + padding: 1; + margin: 1; + } + + UserCard .name { + text-style: bold; + color: $text; + } + + UserCard .email { + color: $text-muted; + } + """ + +# Use the widget +yield UserCard("Alice Smith", "alice@example.com", "Admin") +``` + +## Widget Composition Patterns + +### Form Layout +```python +def compose(self) -> ComposeResult: + with Container(id="form"): + yield Label("Registration Form") + yield Input(placeholder="Name", id="name") + yield Input(placeholder="Email", id="email") + yield Input(placeholder="Password", password=True, id="pass") + with Horizontal(): + yield Button("Submit", variant="primary") + yield Button("Cancel") +``` + +### Dashboard Layout +```python +def compose(self) -> ComposeResult: + yield Header() + with Container(id="dashboard"): + with Horizontal(classes="stats"): + yield Static("[b]Users:[/] 1,234", classes="stat") + yield Static("[b]Active:[/] 567", classes="stat") + yield Static("[b]Revenue:[/] $12K", classes="stat") + with Horizontal(classes="content"): + with Vertical(id="sidebar"): + yield Label("Menu") + yield Button("Dashboard") + yield Button("Users") + yield Button("Settings") + with Container(id="main"): + yield DataTable() + yield Footer() +```