tasktree-manager 1.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tasktree_manager-1.0.1/.claude/skills/textual-tui/SKILL.md +703 -0
- tasktree_manager-1.0.1/.claude/skills/textual-tui/assets/README.md +165 -0
- tasktree_manager-1.0.1/.claude/skills/textual-tui/assets/dashboard_app.py +256 -0
- tasktree_manager-1.0.1/.claude/skills/textual-tui/assets/data_viewer.py +304 -0
- tasktree_manager-1.0.1/.claude/skills/textual-tui/assets/todo_app.py +236 -0
- tasktree_manager-1.0.1/.claude/skills/textual-tui/assets/worker_demo.py +406 -0
- tasktree_manager-1.0.1/.claude/skills/textual-tui/references/layouts.md +575 -0
- tasktree_manager-1.0.1/.claude/skills/textual-tui/references/official-guides-index.md +284 -0
- tasktree_manager-1.0.1/.claude/skills/textual-tui/references/styling.md +700 -0
- tasktree_manager-1.0.1/.claude/skills/textual-tui/references/widgets.md +533 -0
- tasktree_manager-1.0.1/.github/templates/CHANGELOG.md.j2 +13 -0
- tasktree_manager-1.0.1/.github/workflows/ci.yml +138 -0
- tasktree_manager-1.0.1/.github/workflows/release.yml +294 -0
- tasktree_manager-1.0.1/.gitignore +57 -0
- tasktree_manager-1.0.1/CHANGELOG.md +131 -0
- tasktree_manager-1.0.1/CONTRIBUTING.md +273 -0
- tasktree_manager-1.0.1/LICENSE +21 -0
- tasktree_manager-1.0.1/OPENSPEC.md +457 -0
- tasktree_manager-1.0.1/PKG-INFO +15 -0
- tasktree_manager-1.0.1/README.md +195 -0
- tasktree_manager-1.0.1/ROADMAP.md +243 -0
- tasktree_manager-1.0.1/docs/claude-code-integration-spec.md +180 -0
- tasktree_manager-1.0.1/docs/configuration.md +678 -0
- tasktree_manager-1.0.1/docs/installation.md +419 -0
- tasktree_manager-1.0.1/docs/troubleshooting.md +1045 -0
- tasktree_manager-1.0.1/docs/user-guide.md +812 -0
- tasktree_manager-1.0.1/mise.toml +85 -0
- tasktree_manager-1.0.1/pyproject.toml +86 -0
- tasktree_manager-1.0.1/scripts/bump_version.py +283 -0
- tasktree_manager-1.0.1/tasktree/__init__.py +6 -0
- tasktree_manager-1.0.1/tasktree/_version.py +34 -0
- tasktree_manager-1.0.1/tasktree/app.py +1179 -0
- tasktree_manager-1.0.1/tasktree/services/__init__.py +17 -0
- tasktree_manager-1.0.1/tasktree/services/config.py +416 -0
- tasktree_manager-1.0.1/tasktree/services/git_ops.py +267 -0
- tasktree_manager-1.0.1/tasktree/services/models.py +119 -0
- tasktree_manager-1.0.1/tasktree/services/task_manager.py +477 -0
- tasktree_manager-1.0.1/tasktree/widgets/__init__.py +20 -0
- tasktree_manager-1.0.1/tasktree/widgets/create_modal.py +653 -0
- tasktree_manager-1.0.1/tasktree/widgets/messages_panel.py +133 -0
- tasktree_manager-1.0.1/tasktree/widgets/setup_modal.py +162 -0
- tasktree_manager-1.0.1/tasktree/widgets/status_panel.py +95 -0
- tasktree_manager-1.0.1/tasktree/widgets/task_list.py +191 -0
- tasktree_manager-1.0.1/tasktree/widgets/worktree_list.py +193 -0
- tasktree_manager-1.0.1/tests/__init__.py +1 -0
- tasktree_manager-1.0.1/tests/conftest.py +207 -0
- tasktree_manager-1.0.1/tests/test_app.py +509 -0
- tasktree_manager-1.0.1/tests/test_config.py +351 -0
- tasktree_manager-1.0.1/tests/test_git_ops.py +461 -0
- tasktree_manager-1.0.1/tests/test_integration_git.py +396 -0
- tasktree_manager-1.0.1/tests/test_memory.py +292 -0
- tasktree_manager-1.0.1/tests/test_messages_panel.py +251 -0
- tasktree_manager-1.0.1/tests/test_modals.py +463 -0
- tasktree_manager-1.0.1/tests/test_setup_modal.py +184 -0
- tasktree_manager-1.0.1/tests/test_status_panel.py +222 -0
- tasktree_manager-1.0.1/tests/test_task_manager.py +690 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: textual-tui
|
|
3
|
+
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.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Textual TUI Development
|
|
7
|
+
|
|
8
|
+
Build production-quality terminal user interfaces using Textual, a modern Python framework for creating interactive TUI applications.
|
|
9
|
+
|
|
10
|
+
## Quick Start
|
|
11
|
+
|
|
12
|
+
Install Textual:
|
|
13
|
+
```bash
|
|
14
|
+
pip install textual textual-dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Basic app structure:
|
|
18
|
+
```python
|
|
19
|
+
from textual.app import App, ComposeResult
|
|
20
|
+
from textual.widgets import Header, Footer, Button
|
|
21
|
+
|
|
22
|
+
class MyApp(App):
|
|
23
|
+
"""A simple Textual app."""
|
|
24
|
+
|
|
25
|
+
def compose(self) -> ComposeResult:
|
|
26
|
+
"""Create child widgets."""
|
|
27
|
+
yield Header()
|
|
28
|
+
yield Button("Click me!", id="click")
|
|
29
|
+
yield Footer()
|
|
30
|
+
|
|
31
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
32
|
+
"""Handle button press."""
|
|
33
|
+
self.exit()
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
app = MyApp()
|
|
37
|
+
app.run()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Run with hot reload during development:
|
|
41
|
+
```bash
|
|
42
|
+
textual run --dev your_app.py
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Use the Textual console for debugging:
|
|
46
|
+
```bash
|
|
47
|
+
textual console
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Core Architecture
|
|
51
|
+
|
|
52
|
+
### App Lifecycle
|
|
53
|
+
|
|
54
|
+
1. **Initialization**: Create App instance with config
|
|
55
|
+
2. **Composition**: Build widget tree via `compose()` method
|
|
56
|
+
3. **Mounting**: Widgets mounted to DOM
|
|
57
|
+
4. **Running**: Event loop processes messages and renders UI
|
|
58
|
+
5. **Shutdown**: Cleanup and exit
|
|
59
|
+
|
|
60
|
+
### Message Passing System
|
|
61
|
+
|
|
62
|
+
Textual uses an async message queue for all interactions:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from textual.message import Message
|
|
66
|
+
|
|
67
|
+
class CustomMessage(Message):
|
|
68
|
+
"""Custom message with data."""
|
|
69
|
+
def __init__(self, value: int) -> None:
|
|
70
|
+
self.value = value
|
|
71
|
+
super().__init__()
|
|
72
|
+
|
|
73
|
+
class MyWidget(Widget):
|
|
74
|
+
def on_click(self) -> None:
|
|
75
|
+
# Post message to parent
|
|
76
|
+
self.post_message(CustomMessage(42))
|
|
77
|
+
|
|
78
|
+
class MyApp(App):
|
|
79
|
+
def on_custom_message(self, message: CustomMessage) -> None:
|
|
80
|
+
# Handle message with naming convention: on_{message_name}
|
|
81
|
+
self.log(f"Received: {message.value}")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Reactive Programming
|
|
85
|
+
|
|
86
|
+
Use reactive attributes for automatic UI updates:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from textual.reactive import reactive
|
|
90
|
+
|
|
91
|
+
class Counter(Widget):
|
|
92
|
+
count = reactive(0) # Reactive attribute
|
|
93
|
+
|
|
94
|
+
def watch_count(self, new_value: int) -> None:
|
|
95
|
+
"""Called automatically when count changes."""
|
|
96
|
+
self.refresh()
|
|
97
|
+
|
|
98
|
+
def increment(self) -> None:
|
|
99
|
+
self.count += 1 # Triggers watch_count
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Layout System
|
|
103
|
+
|
|
104
|
+
### Container Layouts
|
|
105
|
+
|
|
106
|
+
Textual provides flexible layout options:
|
|
107
|
+
|
|
108
|
+
**Vertical Layout (default)**:
|
|
109
|
+
```python
|
|
110
|
+
def compose(self) -> ComposeResult:
|
|
111
|
+
yield Label("Top")
|
|
112
|
+
yield Label("Bottom")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Horizontal Layout**:
|
|
116
|
+
```python
|
|
117
|
+
class MyApp(App):
|
|
118
|
+
CSS = """
|
|
119
|
+
Screen {
|
|
120
|
+
layout: horizontal;
|
|
121
|
+
}
|
|
122
|
+
"""
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Grid Layout**:
|
|
126
|
+
```python
|
|
127
|
+
class MyApp(App):
|
|
128
|
+
CSS = """
|
|
129
|
+
Screen {
|
|
130
|
+
layout: grid;
|
|
131
|
+
grid-size: 3 2; /* 3 columns, 2 rows */
|
|
132
|
+
}
|
|
133
|
+
"""
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Sizing and Positioning
|
|
137
|
+
|
|
138
|
+
Control widget dimensions:
|
|
139
|
+
```python
|
|
140
|
+
class MyApp(App):
|
|
141
|
+
CSS = """
|
|
142
|
+
#sidebar {
|
|
143
|
+
width: 30; /* Fixed width */
|
|
144
|
+
height: 100%; /* Full height */
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#content {
|
|
148
|
+
width: 1fr; /* Remaining space */
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.compact {
|
|
152
|
+
height: auto; /* Size to content */
|
|
153
|
+
}
|
|
154
|
+
"""
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Styling with CSS
|
|
158
|
+
|
|
159
|
+
Textual uses CSS-like syntax for styling.
|
|
160
|
+
|
|
161
|
+
### Inline Styles
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
class StyledWidget(Widget):
|
|
165
|
+
DEFAULT_CSS = """
|
|
166
|
+
StyledWidget {
|
|
167
|
+
background: $primary;
|
|
168
|
+
color: $text;
|
|
169
|
+
border: solid $accent;
|
|
170
|
+
padding: 1 2;
|
|
171
|
+
margin: 1;
|
|
172
|
+
}
|
|
173
|
+
"""
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### External CSS Files
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
class MyApp(App):
|
|
180
|
+
CSS_PATH = "app.tcss" # Load from file
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Color System
|
|
184
|
+
|
|
185
|
+
Use Textual's semantic colors:
|
|
186
|
+
```css
|
|
187
|
+
.error { background: $error; }
|
|
188
|
+
.success { background: $success; }
|
|
189
|
+
.warning { background: $warning; }
|
|
190
|
+
.primary { background: $primary; }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Or define custom colors:
|
|
194
|
+
```css
|
|
195
|
+
.custom {
|
|
196
|
+
background: #1e3a8a;
|
|
197
|
+
color: rgb(255, 255, 255);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Common Widgets
|
|
202
|
+
|
|
203
|
+
### Input and Forms
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from textual.widgets import Input, Button, Select
|
|
207
|
+
from textual.containers import Container
|
|
208
|
+
|
|
209
|
+
def compose(self) -> ComposeResult:
|
|
210
|
+
with Container(id="form"):
|
|
211
|
+
yield Input(placeholder="Enter name", id="name")
|
|
212
|
+
yield Select(options=[("A", 1), ("B", 2)], id="choice")
|
|
213
|
+
yield Button("Submit", variant="primary")
|
|
214
|
+
|
|
215
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
216
|
+
name = self.query_one("#name", Input).value
|
|
217
|
+
choice = self.query_one("#choice", Select).value
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Data Display
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from textual.widgets import DataTable, Tree, Log
|
|
224
|
+
|
|
225
|
+
# DataTable for tabular data
|
|
226
|
+
table = DataTable()
|
|
227
|
+
table.add_columns("Name", "Age", "City")
|
|
228
|
+
table.add_row("Alice", 30, "NYC")
|
|
229
|
+
|
|
230
|
+
# Tree for hierarchical data
|
|
231
|
+
tree = Tree("Root")
|
|
232
|
+
tree.root.add("Child 1")
|
|
233
|
+
tree.root.add("Child 2")
|
|
234
|
+
|
|
235
|
+
# Log for streaming output
|
|
236
|
+
log = Log(auto_scroll=True)
|
|
237
|
+
log.write_line("Log entry")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Containers and Layout
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from textual.containers import (
|
|
244
|
+
Container, Horizontal, Vertical,
|
|
245
|
+
Grid, ScrollableContainer
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def compose(self) -> ComposeResult:
|
|
249
|
+
with Vertical():
|
|
250
|
+
yield Header()
|
|
251
|
+
with Horizontal():
|
|
252
|
+
with Container(id="sidebar"):
|
|
253
|
+
yield Label("Menu")
|
|
254
|
+
with ScrollableContainer(id="content"):
|
|
255
|
+
yield Label("Content...")
|
|
256
|
+
yield Footer()
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Event Handling
|
|
260
|
+
|
|
261
|
+
### Built-in Events
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
from textual.events import Key, Click, Mount
|
|
265
|
+
|
|
266
|
+
def on_mount(self) -> None:
|
|
267
|
+
"""Called when widget is mounted."""
|
|
268
|
+
self.log("Widget mounted!")
|
|
269
|
+
|
|
270
|
+
def on_key(self, event: Key) -> None:
|
|
271
|
+
"""Handle all key presses."""
|
|
272
|
+
if event.key == "q":
|
|
273
|
+
self.app.exit()
|
|
274
|
+
|
|
275
|
+
def on_click(self, event: Click) -> None:
|
|
276
|
+
"""Handle mouse clicks."""
|
|
277
|
+
self.log(f"Clicked at {event.x}, {event.y}")
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Widget-Specific Handlers
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
284
|
+
"""Handle input submission."""
|
|
285
|
+
self.query_one(Log).write(event.value)
|
|
286
|
+
|
|
287
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
288
|
+
"""Handle table row selection."""
|
|
289
|
+
row_key = event.row_key
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Keyboard Bindings
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
class MyApp(App):
|
|
296
|
+
BINDINGS = [
|
|
297
|
+
("q", "quit", "Quit"),
|
|
298
|
+
("d", "toggle_dark", "Toggle dark mode"),
|
|
299
|
+
("ctrl+s", "save", "Save"),
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
def action_quit(self) -> None:
|
|
303
|
+
self.exit()
|
|
304
|
+
|
|
305
|
+
def action_toggle_dark(self) -> None:
|
|
306
|
+
self.dark = not self.dark
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Advanced Patterns
|
|
310
|
+
|
|
311
|
+
### Custom Widgets
|
|
312
|
+
|
|
313
|
+
Create reusable components:
|
|
314
|
+
```python
|
|
315
|
+
from textual.widget import Widget
|
|
316
|
+
from textual.widgets import Label, Button
|
|
317
|
+
|
|
318
|
+
class StatusCard(Widget):
|
|
319
|
+
"""A card showing status info."""
|
|
320
|
+
|
|
321
|
+
def __init__(self, title: str, status: str) -> None:
|
|
322
|
+
super().__init__()
|
|
323
|
+
self.title = title
|
|
324
|
+
self.status = status
|
|
325
|
+
|
|
326
|
+
def compose(self) -> ComposeResult:
|
|
327
|
+
yield Label(self.title, classes="title")
|
|
328
|
+
yield Label(self.status, classes="status")
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Workers and Background Tasks
|
|
332
|
+
|
|
333
|
+
CRITICAL: Use workers for any long-running operations to prevent blocking the UI. The event loop must remain responsive.
|
|
334
|
+
|
|
335
|
+
#### Basic Worker Usage
|
|
336
|
+
|
|
337
|
+
Run tasks in background threads:
|
|
338
|
+
```python
|
|
339
|
+
from textual.worker import Worker, WorkerState
|
|
340
|
+
|
|
341
|
+
class MyApp(App):
|
|
342
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
343
|
+
# Start background task
|
|
344
|
+
self.run_worker(self.process_data(), exclusive=True)
|
|
345
|
+
|
|
346
|
+
async def process_data(self) -> str:
|
|
347
|
+
"""Long-running task."""
|
|
348
|
+
# Simulate work
|
|
349
|
+
await asyncio.sleep(5)
|
|
350
|
+
return "Processing complete"
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
#### Worker with Progress Updates
|
|
354
|
+
|
|
355
|
+
Update UI during processing:
|
|
356
|
+
```python
|
|
357
|
+
from textual.widgets import ProgressBar
|
|
358
|
+
|
|
359
|
+
class MyApp(App):
|
|
360
|
+
def compose(self) -> ComposeResult:
|
|
361
|
+
yield ProgressBar(total=100, id="progress")
|
|
362
|
+
|
|
363
|
+
def on_mount(self) -> None:
|
|
364
|
+
self.run_worker(self.long_task())
|
|
365
|
+
|
|
366
|
+
async def long_task(self) -> None:
|
|
367
|
+
"""Task with progress updates."""
|
|
368
|
+
progress = self.query_one(ProgressBar)
|
|
369
|
+
|
|
370
|
+
for i in range(100):
|
|
371
|
+
await asyncio.sleep(0.1)
|
|
372
|
+
progress.update(progress=i + 1)
|
|
373
|
+
# Use call_from_thread for thread safety
|
|
374
|
+
self.call_from_thread(progress.update, progress=i + 1)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
#### Worker Communication Patterns
|
|
378
|
+
|
|
379
|
+
Use `call_from_thread` for thread-safe UI updates:
|
|
380
|
+
```python
|
|
381
|
+
import time
|
|
382
|
+
from threading import Thread
|
|
383
|
+
|
|
384
|
+
class MyApp(App):
|
|
385
|
+
def on_mount(self) -> None:
|
|
386
|
+
self.run_worker(self.fetch_data(), thread=True)
|
|
387
|
+
|
|
388
|
+
def fetch_data(self) -> None:
|
|
389
|
+
"""CPU-bound task in thread."""
|
|
390
|
+
# Blocking operation
|
|
391
|
+
result = expensive_computation()
|
|
392
|
+
|
|
393
|
+
# Update UI safely from thread
|
|
394
|
+
self.call_from_thread(self.display_result, result)
|
|
395
|
+
|
|
396
|
+
def display_result(self, result: str) -> None:
|
|
397
|
+
"""Called on main thread."""
|
|
398
|
+
self.query_one("#output").update(result)
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
#### Worker Cancellation
|
|
402
|
+
|
|
403
|
+
Cancel workers when no longer needed:
|
|
404
|
+
```python
|
|
405
|
+
class MyApp(App):
|
|
406
|
+
worker: Worker | None = None
|
|
407
|
+
|
|
408
|
+
def start_task(self) -> None:
|
|
409
|
+
# Store worker reference
|
|
410
|
+
self.worker = self.run_worker(self.long_task())
|
|
411
|
+
|
|
412
|
+
def cancel_task(self) -> None:
|
|
413
|
+
# Cancel running worker
|
|
414
|
+
if self.worker and not self.worker.is_finished:
|
|
415
|
+
self.worker.cancel()
|
|
416
|
+
self.notify("Task cancelled")
|
|
417
|
+
|
|
418
|
+
async def long_task(self) -> None:
|
|
419
|
+
for i in range(1000):
|
|
420
|
+
await asyncio.sleep(0.1)
|
|
421
|
+
# Check if cancelled
|
|
422
|
+
if self.worker.is_cancelled:
|
|
423
|
+
return
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
#### Worker Error Handling
|
|
427
|
+
|
|
428
|
+
Handle worker failures gracefully:
|
|
429
|
+
```python
|
|
430
|
+
class MyApp(App):
|
|
431
|
+
def on_mount(self) -> None:
|
|
432
|
+
worker = self.run_worker(self.risky_task())
|
|
433
|
+
worker.name = "data_processor" # Name for debugging
|
|
434
|
+
|
|
435
|
+
async def risky_task(self) -> str:
|
|
436
|
+
"""Task that might fail."""
|
|
437
|
+
try:
|
|
438
|
+
result = await fetch_from_api()
|
|
439
|
+
return result
|
|
440
|
+
except Exception as e:
|
|
441
|
+
self.notify(f"Error: {e}", severity="error")
|
|
442
|
+
raise
|
|
443
|
+
|
|
444
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
445
|
+
"""Handle worker state changes."""
|
|
446
|
+
if event.state == WorkerState.ERROR:
|
|
447
|
+
self.log.error(f"Worker failed: {event.worker.name}")
|
|
448
|
+
elif event.state == WorkerState.SUCCESS:
|
|
449
|
+
self.log.info(f"Worker completed: {event.worker.name}")
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
#### Multiple Workers
|
|
453
|
+
|
|
454
|
+
Manage concurrent workers:
|
|
455
|
+
```python
|
|
456
|
+
class MyApp(App):
|
|
457
|
+
def on_mount(self) -> None:
|
|
458
|
+
# Run multiple workers concurrently
|
|
459
|
+
self.run_worker(self.task_one(), name="task1", group="processing")
|
|
460
|
+
self.run_worker(self.task_two(), name="task2", group="processing")
|
|
461
|
+
self.run_worker(self.task_three(), name="task3", group="processing")
|
|
462
|
+
|
|
463
|
+
async def task_one(self) -> None:
|
|
464
|
+
await asyncio.sleep(2)
|
|
465
|
+
self.notify("Task 1 complete")
|
|
466
|
+
|
|
467
|
+
async def task_two(self) -> None:
|
|
468
|
+
await asyncio.sleep(3)
|
|
469
|
+
self.notify("Task 2 complete")
|
|
470
|
+
|
|
471
|
+
async def task_three(self) -> None:
|
|
472
|
+
await asyncio.sleep(1)
|
|
473
|
+
self.notify("Task 3 complete")
|
|
474
|
+
|
|
475
|
+
def cancel_all_tasks(self) -> None:
|
|
476
|
+
"""Cancel all workers in a group."""
|
|
477
|
+
for worker in self.workers:
|
|
478
|
+
if worker.group == "processing":
|
|
479
|
+
worker.cancel()
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
#### Thread vs Process Workers
|
|
483
|
+
|
|
484
|
+
Choose the right worker type:
|
|
485
|
+
```python
|
|
486
|
+
class MyApp(App):
|
|
487
|
+
def on_mount(self) -> None:
|
|
488
|
+
# Async task (default) - for I/O bound operations
|
|
489
|
+
self.run_worker(self.fetch_data())
|
|
490
|
+
|
|
491
|
+
# Thread worker - for CPU-bound tasks
|
|
492
|
+
self.run_worker(self.process_data(), thread=True)
|
|
493
|
+
|
|
494
|
+
async def fetch_data(self) -> str:
|
|
495
|
+
"""I/O bound: use async."""
|
|
496
|
+
async with httpx.AsyncClient() as client:
|
|
497
|
+
response = await client.get("https://api.example.com")
|
|
498
|
+
return response.text
|
|
499
|
+
|
|
500
|
+
def process_data(self) -> str:
|
|
501
|
+
"""CPU bound: use thread."""
|
|
502
|
+
# Heavy computation
|
|
503
|
+
result = [i**2 for i in range(1000000)]
|
|
504
|
+
return str(sum(result))
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
#### Worker Best Practices
|
|
508
|
+
|
|
509
|
+
1. **Always use workers for**:
|
|
510
|
+
- Network requests
|
|
511
|
+
- File I/O
|
|
512
|
+
- Database queries
|
|
513
|
+
- CPU-intensive computations
|
|
514
|
+
- Anything taking > 100ms
|
|
515
|
+
|
|
516
|
+
2. **Worker patterns**:
|
|
517
|
+
- Use `exclusive=True` to prevent duplicate workers
|
|
518
|
+
- Name workers for easier debugging
|
|
519
|
+
- Group related workers for batch cancellation
|
|
520
|
+
- Always handle worker errors
|
|
521
|
+
|
|
522
|
+
3. **Thread safety**:
|
|
523
|
+
- Use `call_from_thread()` for UI updates from threads
|
|
524
|
+
- Never modify widgets directly from threads
|
|
525
|
+
- Use locks for shared mutable state
|
|
526
|
+
|
|
527
|
+
4. **Cancellation**:
|
|
528
|
+
- Store worker references if you need to cancel
|
|
529
|
+
- Check `worker.is_cancelled` in long loops
|
|
530
|
+
- Clean up resources in finally blocks
|
|
531
|
+
|
|
532
|
+
### Modal Dialogs
|
|
533
|
+
|
|
534
|
+
```python
|
|
535
|
+
from textual.screen import ModalScreen
|
|
536
|
+
|
|
537
|
+
class ConfirmDialog(ModalScreen[bool]):
|
|
538
|
+
"""Modal confirmation dialog."""
|
|
539
|
+
|
|
540
|
+
def compose(self) -> ComposeResult:
|
|
541
|
+
with Container(id="dialog"):
|
|
542
|
+
yield Label("Are you sure?")
|
|
543
|
+
with Horizontal():
|
|
544
|
+
yield Button("Yes", variant="primary", id="yes")
|
|
545
|
+
yield Button("No", variant="error", id="no")
|
|
546
|
+
|
|
547
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
548
|
+
self.dismiss(event.button.id == "yes")
|
|
549
|
+
|
|
550
|
+
# Use in app
|
|
551
|
+
async def confirm_action(self) -> None:
|
|
552
|
+
result = await self.push_screen_wait(ConfirmDialog())
|
|
553
|
+
if result:
|
|
554
|
+
self.log("Confirmed!")
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
### Screens and Navigation
|
|
558
|
+
|
|
559
|
+
```python
|
|
560
|
+
from textual.screen import Screen
|
|
561
|
+
|
|
562
|
+
class MainScreen(Screen):
|
|
563
|
+
def compose(self) -> ComposeResult:
|
|
564
|
+
yield Header()
|
|
565
|
+
yield Button("Go to Settings")
|
|
566
|
+
yield Footer()
|
|
567
|
+
|
|
568
|
+
def on_button_pressed(self) -> None:
|
|
569
|
+
self.app.push_screen("settings")
|
|
570
|
+
|
|
571
|
+
class SettingsScreen(Screen):
|
|
572
|
+
def compose(self) -> ComposeResult:
|
|
573
|
+
yield Label("Settings")
|
|
574
|
+
yield Button("Back")
|
|
575
|
+
|
|
576
|
+
def on_button_pressed(self) -> None:
|
|
577
|
+
self.app.pop_screen()
|
|
578
|
+
|
|
579
|
+
class MyApp(App):
|
|
580
|
+
SCREENS = {
|
|
581
|
+
"main": MainScreen(),
|
|
582
|
+
"settings": SettingsScreen(),
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
## Testing
|
|
587
|
+
|
|
588
|
+
Test Textual apps with pytest and the Pilot API:
|
|
589
|
+
|
|
590
|
+
```python
|
|
591
|
+
import pytest
|
|
592
|
+
from textual.pilot import Pilot
|
|
593
|
+
from my_app import MyApp
|
|
594
|
+
|
|
595
|
+
@pytest.mark.asyncio
|
|
596
|
+
async def test_app_starts():
|
|
597
|
+
app = MyApp()
|
|
598
|
+
async with app.run_test() as pilot:
|
|
599
|
+
assert app.screen is not None
|
|
600
|
+
|
|
601
|
+
@pytest.mark.asyncio
|
|
602
|
+
async def test_button_click():
|
|
603
|
+
app = MyApp()
|
|
604
|
+
async with app.run_test() as pilot:
|
|
605
|
+
await pilot.click("#my-button")
|
|
606
|
+
# Assert expected state changes
|
|
607
|
+
|
|
608
|
+
@pytest.mark.asyncio
|
|
609
|
+
async def test_keyboard_input():
|
|
610
|
+
app = MyApp()
|
|
611
|
+
async with app.run_test() as pilot:
|
|
612
|
+
await pilot.press("q")
|
|
613
|
+
# Verify app exited or state changed
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
## Best Practices
|
|
617
|
+
|
|
618
|
+
### Performance
|
|
619
|
+
|
|
620
|
+
- Use `Lazy` for expensive widgets loaded on demand
|
|
621
|
+
- Implement efficient `render()` methods, avoid unnecessary work
|
|
622
|
+
- Use reactive attributes sparingly for truly dynamic values
|
|
623
|
+
- Batch UI updates when processing multiple changes
|
|
624
|
+
|
|
625
|
+
### State Management
|
|
626
|
+
|
|
627
|
+
- Keep app state in the App instance for global access
|
|
628
|
+
- Use reactive attributes for UI-bound state
|
|
629
|
+
- Store complex state in dedicated data models
|
|
630
|
+
- Avoid deeply nested widget communication
|
|
631
|
+
|
|
632
|
+
### Error Handling
|
|
633
|
+
|
|
634
|
+
```python
|
|
635
|
+
from textual.widgets import RichLog
|
|
636
|
+
|
|
637
|
+
def compose(self) -> ComposeResult:
|
|
638
|
+
yield RichLog(id="log")
|
|
639
|
+
|
|
640
|
+
async def action_risky_operation(self) -> None:
|
|
641
|
+
try:
|
|
642
|
+
result = await some_async_operation()
|
|
643
|
+
self.notify("Success!", severity="information")
|
|
644
|
+
except Exception as e:
|
|
645
|
+
self.notify(f"Error: {e}", severity="error")
|
|
646
|
+
self.query_one(RichLog).write(f"[red]Error:[/] {e}")
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
### Accessibility
|
|
650
|
+
|
|
651
|
+
- Always provide keyboard navigation
|
|
652
|
+
- Use semantic widget names and IDs
|
|
653
|
+
- Include ARIA-like descriptions where appropriate
|
|
654
|
+
- Test with screen reader compatibility in mind
|
|
655
|
+
|
|
656
|
+
## Development Tools
|
|
657
|
+
|
|
658
|
+
### Textual Console
|
|
659
|
+
|
|
660
|
+
Debug running apps:
|
|
661
|
+
```bash
|
|
662
|
+
# Terminal 1: Run console
|
|
663
|
+
textual console
|
|
664
|
+
|
|
665
|
+
# Terminal 2: Run app with console enabled
|
|
666
|
+
textual run --dev app.py
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
App code to enable console:
|
|
670
|
+
```python
|
|
671
|
+
self.log("Debug message") # Appears in console
|
|
672
|
+
self.log.info("Info level")
|
|
673
|
+
self.log.error("Error level")
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### Textual Devtools
|
|
677
|
+
|
|
678
|
+
Use the devtools for live inspection:
|
|
679
|
+
```bash
|
|
680
|
+
pip install textual-dev
|
|
681
|
+
textual run --dev app.py # Enables hot reload
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
## References
|
|
685
|
+
|
|
686
|
+
- **Widget Gallery**: See references/widgets.md for comprehensive widget examples
|
|
687
|
+
- **Layout Patterns**: See references/layouts.md for common layout recipes
|
|
688
|
+
- **Styling Guide**: See references/styling.md for CSS patterns and themes
|
|
689
|
+
- **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)
|
|
690
|
+
- **Example Apps**: See assets/ for complete example applications
|
|
691
|
+
|
|
692
|
+
## Common Pitfalls
|
|
693
|
+
|
|
694
|
+
1. **Forgetting async/await**: Many Textual methods are async, always await them
|
|
695
|
+
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
|
|
696
|
+
3. **Incorrect message handling**: Method names must match `on_{message_name}` pattern
|
|
697
|
+
4. **CSS specificity issues**: Use IDs and classes appropriately for targeted styling
|
|
698
|
+
5. **Not using query methods**: Use `query_one()` and `query()` instead of manual traversal
|
|
699
|
+
6. **Thread safety violations**: Never modify widgets directly from worker threads - use `call_from_thread()`
|
|
700
|
+
7. **Not cancelling workers**: Workers continue running even when screens close - always cancel or store references
|
|
701
|
+
8. **Using time.sleep in async**: Use `await asyncio.sleep()` instead of `time.sleep()` in async functions
|
|
702
|
+
9. **Not handling worker errors**: Workers can fail silently - always implement error handling
|
|
703
|
+
10. **Wrong worker type**: Use async workers for I/O, thread workers for CPU-bound tasks
|