Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:58:08 +08:00
commit 9f0794c603
13 changed files with 4258 additions and 0 deletions

165
skill/assets/README.md Normal file
View 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

View 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
View 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
View 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
View 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()