Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -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.
|
||||
81
plugin.lock.json
Normal file
81
plugin.lock.json
Normal file
@@ -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": []
|
||||
}
|
||||
}
|
||||
703
skill/SKILL.md
Normal file
703
skill/SKILL.md
Normal file
@@ -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
|
||||
165
skill/assets/README.md
Normal file
165
skill/assets/README.md
Normal file
@@ -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
|
||||
256
skill/assets/dashboard_app.py
Normal file
256
skill/assets/dashboard_app.py
Normal file
@@ -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()
|
||||
304
skill/assets/data_viewer.py
Normal file
304
skill/assets/data_viewer.py
Normal file
@@ -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()
|
||||
236
skill/assets/todo_app.py
Normal file
236
skill/assets/todo_app.py
Normal file
@@ -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()
|
||||
406
skill/assets/worker_demo.py
Normal file
406
skill/assets/worker_demo.py
Normal file
@@ -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()
|
||||
575
skill/references/layouts.md
Normal file
575
skill/references/layouts.md
Normal file
@@ -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}")
|
||||
```
|
||||
284
skill/references/official-guides-index.md
Normal file
284
skill/references/official-guides-index.md
Normal file
@@ -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
|
||||
700
skill/references/styling.md
Normal file
700
skill/references/styling.md
Normal file
@@ -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 */
|
||||
}
|
||||
```
|
||||
533
skill/references/widgets.md
Normal file
533
skill/references/widgets.md
Normal file
@@ -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()
|
||||
```
|
||||
Reference in New Issue
Block a user