Files
gh-ypares-agent-skills-ypar…/skills/textual-builder/references/interactivity.md
2025-11-30 09:08:16 +08:00

7.9 KiB

Interactivity: Events, Bindings, and Actions

Key Bindings

Define keyboard shortcuts:

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

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:

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:

def update_state(self):
    self.state = "new_state"
    self.refresh_bindings()  # Update footer

Mouse Events

Handle mouse interactions:

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:

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:

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:

widget.focus()  # Give focus to widget
widget.can_focus = True  # Enable focusing (default for inputs)

Widget Messages

Handle messages from specific widgets:

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.Pressedon_button_pressed
  • Custom widget: MyWidget.ValueChangedon_my_widget_value_changed

Custom Messages

Define custom messages for your widgets:

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:

def on_button_pressed(self, event: Button.Pressed) -> None:
    event.stop()  # Don't propagate to parent
    # Handle locally

Prevent Messages

Temporarily suppress messages:

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:

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:

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

self.run_action("save")  # Trigger action by name

Notifications

Show temporary messages to user:

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:

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:

self.set_timer(2.0, self.delayed_action)  # After 2 seconds

def delayed_action(self) -> None:
    self.notify("Timer complete!")

Example: Interactive Card Selection

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}")