347 lines
7.9 KiB
Markdown
347 lines
7.9 KiB
Markdown
# 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}")
|
|
```
|