Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:08:16 +08:00
commit fc569e5620
38 changed files with 4997 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
# Textual Basics
## App Structure
Every Textual app follows this pattern:
```python
from textual.app import App, ComposeResult
from textual.widgets import Widget
class MyApp(App):
"""Docstring describing the app."""
# Optional: Link to external CSS file
CSS_PATH = "app.tcss"
# Optional: Inline CSS
CSS = """
Screen {
align: center middle;
}
"""
def compose(self) -> ComposeResult:
"""Create child widgets."""
yield Widget()
def on_mount(self) -> None:
"""Called when app is mounted and ready."""
pass
if __name__ == "__main__":
app = MyApp()
app.run()
```
## Compose Method
The `compose()` method yields widgets to add to the app. It's called once during initialization:
```python
def compose(self) -> ComposeResult:
yield Header()
yield ContentWidget()
yield Footer()
```
## Mounting
- `on_mount()`: Called when the app/widget is fully mounted and ready
- `mount()`: Dynamically add widgets after app starts (returns a coroutine)
```python
async def on_key(self) -> None:
# Must await when modifying mounted widgets
await self.mount(NewWidget())
self.query_one(Button).label = "Modified!"
```
## Reactive Attributes
Reactive attributes automatically update the UI when changed:
```python
from textual.reactive import reactive
class Counter(Widget):
count = reactive(0) # Initial value
def watch_count(self, new_value: int) -> None:
"""Called automatically when count changes."""
self.query_one(Label).update(f"Count: {new_value}")
def increment(self) -> None:
self.count += 1 # Triggers watch_count
```
### Reactive with Bindings
Set `bindings=True` to auto-refresh footer bindings when reactive changes:
```python
class MyApp(App):
page = reactive(0, bindings=True)
def check_action(self, action: str, parameters) -> bool | None:
"""Return None to disable action."""
if action == "next" and self.page == MAX_PAGES:
return None # Dims the key in footer
return True
```
## Querying Widgets
Find widgets in the DOM:
```python
# Get one widget (raises if not found)
button = self.query_one(Button)
button = self.query_one("#my-id")
# Get multiple widgets
all_buttons = self.query(Button)
for button in all_buttons:
pass
# Get with CSS selector
widget = self.query_one("#container .special-class")
```
## Messages and Events
### Built-in Events
Handle with `on_<event>` methods:
```python
def on_mount(self) -> None:
"""When mounted."""
pass
def on_key(self, event: events.Key) -> None:
"""Key pressed."""
if event.key == "escape":
self.exit()
```
### Widget Messages
Handle messages from child widgets:
```python
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Button was clicked."""
self.notify(f"Button {event.button.id} clicked!")
def on_input_changed(self, event: Input.Changed) -> None:
"""Input text changed."""
self.value = event.value
```
### Custom Messages
Define custom messages in your widgets:
```python
from textual.message import Message
class MyWidget(Widget):
class ValueChanged(Message):
"""Posted when value changes."""
def __init__(self, value: int) -> None:
super().__init__()
self.value = value
def update_value(self, new_value: int) -> None:
self.value = new_value
self.post_message(self.ValueChanged(new_value))
# Handle in parent
def on_my_widget_value_changed(self, event: MyWidget.ValueChanged) -> None:
self.notify(f"New value: {event.value}")
```
## Preventing Message Propagation
Stop messages from bubbling to parent:
```python
def on_switch_changed(self, event: Switch.Changed) -> None:
event.stop() # Don't propagate to parent
# Handle here
```
## Preventing Reactive Watchers
Temporarily prevent reactive watchers from firing:
```python
with self.prevent(MyWidget.ValueChanged):
self.value = new_value # Won't trigger watch_value or post message
```

View File

@@ -0,0 +1,346 @@
# Interactivity: Events, Bindings, and Actions
## Key Bindings
Define keyboard shortcuts:
```python
from textual.app import App
from textual.binding import Binding
class MyApp(App):
BINDINGS = [
("q", "quit", "Quit"), # key, action, description
("s", "save", "Save"),
("ctrl+c", "copy", "Copy"),
Binding("f1", "help", "Help", show=True, priority=True),
]
def action_save(self) -> None:
"""Actions are methods prefixed with 'action_'."""
self.notify("Saved!")
def action_copy(self) -> None:
self.notify("Copied!")
def action_help(self) -> None:
self.notify("Help content...")
```
### Binding Options
```python
Binding(
key="f1",
action="help",
description="Help",
show=True, # Show in footer (default: True)
priority=True, # Prioritize over widget bindings
)
```
### Dynamic Bindings
Refresh bindings when state changes:
```python
class MyApp(App):
page = reactive(0, bindings=True) # Auto-refresh bindings
def check_action(self, action: str, parameters) -> bool | None:
"""Control action availability."""
if action == "next" and self.page >= MAX_PAGES:
return None # Disables and dims the key
if action == "previous" and self.page == 0:
return None
return True # Enabled
```
Or manually refresh:
```python
def update_state(self):
self.state = "new_state"
self.refresh_bindings() # Update footer
```
## Mouse Events
Handle mouse interactions:
```python
from textual import events
class MyWidget(Widget):
def on_click(self, event: events.Click) -> None:
"""Widget was clicked."""
self.notify(f"Clicked at {event.x}, {event.y}")
def on_mouse_move(self, event: events.MouseMove) -> None:
"""Mouse moved over widget."""
pass
def on_enter(self, event: events.Enter) -> None:
"""Mouse entered widget."""
self.add_class("hover")
def on_leave(self, event: events.Leave) -> None:
"""Mouse left widget."""
self.remove_class("hover")
```
## Keyboard Events
Handle key presses:
```python
from textual import events
class MyApp(App):
def on_key(self, event: events.Key) -> None:
"""Any key pressed."""
if event.key == "escape":
self.exit()
elif event.key == "space":
self.toggle_pause()
def key_r(self, event: events.Key) -> None:
"""Specific key handler (press 'r')."""
self.reset()
```
## Focus Events
Track focus changes:
```python
def on_focus(self, event: events.Focus) -> None:
"""Widget gained focus."""
self.border_title = "Focused"
def on_blur(self, event: events.Blur) -> None:
"""Widget lost focus."""
self.border_title = ""
```
Programmatic focus:
```python
widget.focus() # Give focus to widget
widget.can_focus = True # Enable focusing (default for inputs)
```
## Widget Messages
Handle messages from specific widgets:
```python
from textual.widgets import Button, Input, Switch
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Any button pressed."""
button_id = event.button.id
self.notify(f"Button {button_id} pressed")
def on_input_changed(self, event: Input.Changed) -> None:
"""Input text changed."""
self.update_preview(event.value)
def on_input_submitted(self, event: Input.Submitted) -> None:
"""User pressed Enter in input."""
self.process(event.value)
def on_switch_changed(self, event: Switch.Changed) -> None:
"""Switch toggled."""
self.feature_enabled = event.value
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Row in table selected."""
row_key = event.row_key
```
### Message Naming Convention
Handler method: `on_{widget_type}_{message_name}`
- Converts to snake_case
- Example: `Button.Pressed``on_button_pressed`
- Custom widget: `MyWidget.ValueChanged``on_my_widget_value_changed`
## Custom Messages
Define custom messages for your widgets:
```python
from textual.message import Message
from textual.widget import Widget
class Card(Widget):
class Selected(Message):
"""Posted when card is selected."""
def __init__(self, card_id: str, value: int) -> None:
super().__init__()
self.card_id = card_id
self.value = value
def on_click(self) -> None:
self.post_message(self.Selected(self.id, self.value))
# Handle in parent
def on_card_selected(self, event: Card.Selected) -> None:
self.notify(f"Card {event.card_id} (value: {event.value}) selected")
```
## Message Control
### Stop Propagation
Prevent message from bubbling to parent:
```python
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop() # Don't propagate to parent
# Handle locally
```
### Prevent Messages
Temporarily suppress messages:
```python
with widget.prevent(Switch.Changed):
widget.value = True # Won't emit Changed message
```
Useful when programmatically updating to avoid infinite loops.
## Actions
Actions are methods that can be triggered by bindings or programmatically:
```python
class MyApp(App):
BINDINGS = [
("n", "next_page", "Next"),
("p", "prev_page", "Previous"),
]
def action_next_page(self) -> None:
self.page += 1
self.refresh_view()
def action_prev_page(self) -> None:
self.page -= 1
self.refresh_view()
```
### Parameterized Actions
Pass parameters to actions:
```python
BINDINGS = [
("r", "add_color('red')", "Red"),
("g", "add_color('green')", "Green"),
("b", "add_color('blue')", "Blue"),
]
def action_add_color(self, color: str) -> None:
self.add_widget(ColorBar(color))
```
### Programmatic Action Calls
```python
self.run_action("save") # Trigger action by name
```
## Notifications
Show temporary messages to user:
```python
self.notify("File saved successfully!")
self.notify("Error occurred", severity="error")
self.notify("Warning!", severity="warning")
self.notify("Info message", severity="information", timeout=5)
```
## Timers
Schedule repeated actions:
```python
def on_mount(self) -> None:
self.set_interval(1.0, self.update_timer) # Every 1 second
def update_timer(self) -> None:
self.elapsed += 1
self.query_one("#timer").update(str(self.elapsed))
```
One-time delayed action:
```python
self.set_timer(2.0, self.delayed_action) # After 2 seconds
def delayed_action(self) -> None:
self.notify("Timer complete!")
```
## Example: Interactive Card Selection
```python
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widget import Widget
from textual.widgets import Label, Static
from textual.message import Message
class Card(Widget):
DEFAULT_CSS = """
Card {
width: 12;
height: 10;
border: round white;
background: $panel;
}
Card:hover {
background: $boost;
}
Card.selected {
border: double cyan;
background: $accent;
}
"""
class Selected(Message):
def __init__(self, card: "Card") -> None:
super().__init__()
self.card = card
def __init__(self, suit: str, value: str) -> None:
super().__init__()
self.suit = suit
self.value = value
def compose(self) -> ComposeResult:
yield Label(f"{self.value}\n{self.suit}")
def on_click(self) -> None:
self.post_message(self.Selected(self))
class CardGame(App):
def compose(self) -> ComposeResult:
with Horizontal(id="hand"):
yield Card("", "A")
yield Card("", "K")
yield Card("", "Q")
def on_card_selected(self, event: Card.Selected) -> None:
# Deselect all
for card in self.query(Card):
card.remove_class("selected")
# Select clicked
event.card.add_class("selected")
self.notify(f"Selected {event.card.value} of {event.card.suit}")
```

View File

@@ -0,0 +1,298 @@
# Layout and Positioning
## Layout Types
### Vertical (Default)
Stacks widgets vertically:
```css
Container {
layout: vertical;
}
```
### Horizontal
Arranges widgets side-by-side:
```css
Container {
layout: horizontal;
}
```
### Grid
Grid layout with rows and columns:
```css
Grid {
layout: grid;
grid-size: 3 2; /* 3 columns, 2 rows */
grid-gutter: 1 2; /* vertical horizontal spacing */
}
```
#### Grid Cell Spanning
Make widgets span multiple cells:
```css
#header {
column-span: 3; /* Span 3 columns */
}
#sidebar {
row-span: 2; /* Span 2 rows */
}
```
#### Grid Rows and Columns
Define row heights and column widths:
```css
Grid {
grid-size: 2 3;
grid-rows: 1fr 6 25%; /* Flexible, fixed 6, 25% */
grid-columns: 1fr 2fr; /* 1:2 ratio */
}
```
## Alignment
### Screen/Container Alignment
Center content within screen:
```css
Screen {
align: center middle; /* horizontal vertical */
}
```
Options: `left`, `center`, `right` × `top`, `middle`, `bottom`
### Content Alignment
Align content within a widget:
```css
MyWidget {
content-align: center middle;
text-align: center;
}
```
## Docking
Pin widgets to screen edges:
```css
#header {
dock: top;
height: 3;
}
#sidebar {
dock: left;
width: 20;
}
#footer {
dock: bottom;
}
```
Docking order matters - earlier docked widgets take priority.
## Sizing
### Fixed Sizes
```css
Widget {
width: 50; /* 50 cells */
height: 10; /* 10 rows */
}
```
### Relative Sizes
```css
Widget {
width: 50%; /* 50% of parent */
height: 100%;
}
```
### Fractional Units
Share available space proportionally:
```css
#left {
width: 1fr; /* Gets 1 part */
}
#right {
width: 2fr; /* Gets 2 parts (twice as wide) */
}
```
### Auto Sizing
Fit content:
```css
Widget {
width: auto;
height: auto;
}
```
### Min/Max Constraints
```css
Widget {
min-width: 20;
max-width: 80;
min-height: 5;
max-height: 30;
}
```
## Spacing
### Margin
Space outside widget border:
```css
Widget {
margin: 1; /* All sides */
margin: 1 2; /* vertical horizontal */
margin: 1 2 3 4; /* top right bottom left */
}
```
### Padding
Space inside widget border:
```css
Widget {
padding: 1; /* All sides */
padding: 1 2; /* vertical horizontal */
}
```
## Visibility
### Display
Show or hide widgets:
```css
#hidden {
display: none;
}
#visible {
display: block;
}
```
Toggle in Python:
```python
widget.display = False # Hide
widget.display = True # Show
```
### Visibility
Similar to display but reserves space:
```css
Widget {
visibility: hidden; /* Hidden but takes space */
visibility: visible;
}
```
## Layers
Control stacking order:
```css
#background {
layer: below;
}
#popup {
layer: above;
}
```
## Scrolling
### Enable Scrolling
```css
Container {
overflow-x: auto; /* Horizontal scrolling */
overflow-y: auto; /* Vertical scrolling */
overflow: auto auto; /* Both */
}
```
### Programmatic Scrolling
```python
# Scroll to specific position
container.scroll_to(x=0, y=100)
# Scroll widget into view
widget.scroll_visible()
# Scroll to end
self.screen.scroll_end(animate=True)
```
## Example: Card Game Layout
```css
Screen {
layout: vertical;
}
#opponent-hand {
dock: top;
height: 12;
layout: horizontal;
align: center top;
}
#play-area {
height: 1fr;
layout: grid;
grid-size: 5 3;
align: center middle;
}
#player-hand {
dock: bottom;
height: 15;
layout: horizontal;
align: center bottom;
padding: 1;
}
.card {
width: 12;
height: 10;
margin: 0 1;
}
```

View File

@@ -0,0 +1,323 @@
# Styling with CSS
## CSS Files
Link external CSS file:
```python
class MyApp(App):
CSS_PATH = "app.tcss" # Textual CSS file
```
Or inline CSS:
```python
class MyApp(App):
CSS = """
Screen {
background: $background;
}
"""
```
## Selectors
### Type Selectors
Target all widgets of a type:
```css
Button {
width: 100%;
}
Label {
color: cyan;
}
```
### ID Selectors
Target specific widget:
```css
#my-button {
background: red;
}
#header {
dock: top;
}
```
### Class Selectors
Target widgets with specific class:
```css
.card {
border: round white;
padding: 1;
}
.selected {
background: yellow;
}
```
Add classes in Python:
```python
widget = Label("Text", classes="card selected")
# or
widget.add_class("highlighted")
widget.remove_class("selected")
widget.toggle_class("active")
```
### Pseudo-classes
Style based on state:
```css
Button:hover {
background: $accent;
}
Button:focus {
border: double green;
}
Input:disabled {
opacity: 0.5;
}
```
Common pseudo-classes: `:hover`, `:focus`, `:focus-within`, `:disabled`, `:enabled`
### Combinators
```css
/* Direct children */
Container > Label {
color: white;
}
/* Descendants */
Container Label {
margin: 1;
}
/* Class and type */
Label.card {
border: round;
}
```
## Colors
### Named Colors
```css
Widget {
color: red;
background: blue;
border: green;
}
```
### Hex Colors
```css
Widget {
color: #ff0000;
background: #00ff0088; /* With alpha */
}
```
### RGB/RGBA
```css
Widget {
color: rgb(255, 0, 0);
background: rgba(0, 255, 0, 0.5);
}
```
### Theme Variables
Use built-in theme colors:
```css
Widget {
background: $background;
color: $text;
border: $primary;
}
```
Common theme variables:
- `$background` - Main background
- `$surface` - Surface color
- `$panel` - Panel background
- `$boost` - Highlighted background
- `$primary` - Primary accent
- `$secondary` - Secondary accent
- `$accent` - Accent color
- `$text` - Main text color
- `$text-muted` - Muted text
- `$foreground-muted` - Dimmed foreground
## Borders
### Border Styles
```css
Widget {
border: solid red; /* Style and color */
border: round cyan; /* Rounded border */
border: double white; /* Double line */
border: dashed yellow; /* Dashed */
border: heavy green; /* Heavy/thick */
border: tall blue; /* Tall characters */
}
```
### Border Sides
```css
Widget {
border-top: solid red;
border-bottom: round blue;
border-left: double green;
border-right: dashed yellow;
}
```
### Border Title
```css
Widget {
border: round white;
border-title-align: center;
}
```
Set title in Python:
```python
widget.border_title = "My Widget"
```
## Text Styling
### Text Properties
```css
Label {
text-style: bold;
text-style: italic;
text-style: bold italic;
text-style: underline;
text-style: strike;
}
```
### Text Alignment
```css
Static {
text-align: left;
text-align: center;
text-align: right;
}
```
## Keylines
Add separators between grid cells or flex items:
```css
Grid {
keyline: thin green;
keyline: thick $primary;
}
```
Note: Must be on a container with a layout.
## Opacity
```css
Widget {
opacity: 0.5; /* 50% transparent */
opacity: 0; /* Fully transparent */
opacity: 1; /* Fully opaque */
}
```
## Tint
Apply color overlay:
```css
Widget {
tint: rgba(255, 0, 0, 0.3); /* Red tint */
}
```
## Rich Markup
Use Rich markup in text:
```python
label = Label("[bold cyan]Hello[/bold cyan] [red]World[/red]")
label.update("[underline]Updated[/underline]")
```
Common markup:
- `[bold]...[/bold]` - Bold
- `[italic]...[/italic]` - Italic
- `[color]...[/color]` - Colored (e.g., `[red]`, `[#ff0000]`)
- `[underline]...[/underline]` - Underline
- `[strike]...[/strike]` - Strikethrough
- `[link=...]...[/link]` - Link
## Example: Card Styling
```css
.card {
width: 12;
height: 10;
border: round $secondary;
background: $panel;
padding: 1;
content-align: center middle;
}
.card:hover {
background: $boost;
border: heavy $primary;
}
.card.selected {
background: $accent;
border: double $primary;
}
.card.disabled {
opacity: 0.5;
tint: rgba(0, 0, 0, 0.5);
}
.card-title {
text-style: bold;
text-align: center;
color: $text;
}
.card-value {
text-align: center;
color: $text-muted;
}
```

View File

@@ -0,0 +1,241 @@
# Common Widgets
## Display Widgets
### Label / Static
Display static or updatable text:
```python
from textual.widgets import Label, Static
# Label is just an alias for Static
label = Label("Hello World")
static = Static("Initial text")
# Update later
static.update("New text")
static.update("[bold]Rich markup[/bold]")
```
### Placeholder
Useful for prototyping layouts:
```python
from textual.widgets import Placeholder
# Shows widget ID and size info
yield Placeholder("Custom label", id="p1")
yield Placeholder(variant="size") # Shows dimensions
yield Placeholder(variant="text") # Shows placeholder text
```
## Input Widgets
### Button
```python
from textual.widgets import Button
yield Button("Click Me", id="my-button")
yield Button("Disabled", disabled=True)
# Handle click
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id
self.notify(f"{button_id} clicked!")
```
### Input
Single-line text input:
```python
from textual.widgets import Input
yield Input(placeholder="Enter text...", id="name-input")
def on_input_changed(self, event: Input.Changed) -> None:
self.text_value = event.value
def on_input_submitted(self, event: Input.Submitted) -> None:
# User pressed Enter
self.process_input(event.value)
```
### TextArea
Multi-line text editor:
```python
from textual.widgets import TextArea
text_area = TextArea()
text_area.load_text("Initial content")
# Get content
content = text_area.text
```
### Switch
Toggle switch (like checkbox):
```python
from textual.widgets import Switch
yield Switch(value=True) # Initially on
def on_switch_changed(self, event: Switch.Changed) -> None:
is_on = event.value
self.toggle_feature(is_on)
```
## Data Display
### DataTable
Display tabular data:
```python
from textual.widgets import DataTable
table = DataTable()
# Add columns
table.add_columns("Name", "Age", "Country")
# Add rows
table.add_row("Alice", 30, "USA")
table.add_row("Bob", 25, "UK")
# Add row with custom label
from rich.text import Text
label = Text("1", style="bold cyan")
table.add_row("Charlie", 35, "Canada", label=label)
# Configuration
table.zebra_stripes = True # Alternating row colors
table.cursor_type = "row" # "cell", "row", "column", or "none"
table.show_header = True
table.show_row_labels = True
# Handle selection
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
row_key = event.row_key
row_data = table.get_row(row_key)
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
value = event.value
coordinate = event.coordinate
```
## Layout Containers
### Container
Generic container for grouping widgets:
```python
from textual.containers import Container
with Container(id="sidebar"):
yield Label("Title")
yield Button("Action")
```
### Vertical / Horizontal / VerticalScroll / HorizontalScroll
Directional containers:
```python
from textual.containers import Vertical, Horizontal, VerticalScroll
with Horizontal():
yield Button("Left")
yield Button("Right")
with VerticalScroll():
for i in range(100):
yield Label(f"Item {i}")
```
### Grid
Grid layout container:
```python
from textual.containers import Grid
with Grid(id="my-grid"):
yield Label("A")
yield Label("B")
yield Label("C")
yield Label("D")
# Style in CSS:
# Grid {
# grid-size: 2 2; /* 2 columns, 2 rows */
# }
```
## App Widgets
### Header / Footer
Standard app chrome:
```python
from textual.widgets import Header, Footer
def compose(self) -> ComposeResult:
yield Header()
# ... content ...
yield Footer()
```
Footer automatically shows key bindings defined in BINDINGS.
## Custom Widgets
Create reusable components:
```python
from textual.widget import Widget
from textual.widgets import Label, Button
class Card(Widget):
"""A card widget with title and content."""
DEFAULT_CSS = """
Card {
width: 30;
height: 15;
border: round white;
padding: 1;
}
"""
def __init__(self, title: str, content: str) -> None:
super().__init__()
self.title = title
self.content = content
def compose(self) -> ComposeResult:
yield Label(self.title, classes="card-title")
yield Label(self.content, classes="card-content")
yield Button("Select", id=f"select-{self.title}")
```
### Render Method
For simple custom widgets that just render text:
```python
from textual.widget import Widget
class FizzBuzz(Widget):
def render(self) -> str:
return "FizzBuzz!"
```