# Events

Events are frozen dataclasses delivered to your `update()` method. Each
event family has its own class in `plushie.events`.

```python
from plushie.events import (
    # Widget events
    Click, Input, Submit, Toggle, Select, Slide, SlideRelease,
    Scroll, Paste, Sort, Open, Close, OptionHovered, KeyBinding,
    # Mouse area events
    MouseAreaRightPress, MouseAreaRightRelease,
    MouseAreaMiddlePress, MouseAreaMiddleRelease,
    MouseAreaDoubleClick, MouseAreaEnter, MouseAreaExit,
    MouseAreaMove, MouseAreaScroll,
    # Canvas events
    CanvasPress, CanvasRelease, CanvasMove, CanvasScroll,
    CanvasElementClick, CanvasElementDrag, CanvasElementDragEnd,
    CanvasElementEnter, CanvasElementLeave, CanvasElementFocused,
    # Sensor / PaneGrid events
    SensorResize, PaneResized, PaneDragged, PaneClicked,
    # Keyboard events
    KeyPress, KeyRelease, ModifiersChanged,
    # Mouse events (global)
    MouseMove, MouseEnter, MouseLeave,
    MouseButtonPress, MouseButtonRelease, MouseWheel,
    # Touch events
    TouchPress, TouchMove, TouchLift, TouchLost,
    # IME events
    ImeOpen, ImePreedit, ImeCommit, ImeClose,
    # Window events
    WindowCloseRequested, WindowOpen, WindowClosed, WindowMoved,
    WindowResized, WindowFocused, WindowUnfocused, WindowRescaled,
    FileHovered, FileDropped, FilesHoveredLeft,
    # System events
    AnimationFrame, ThemeChanged,
    # Timer / Async / Stream / Effect events
    TimerTick, AsyncResult, StreamChunk, EffectResult,
    # Accessibility
    A11yAction,
)
```

## Widget events

These are generated by user interaction with widgets. The renderer maps
widget interactions to event dataclasses using the node's `id`.

### Click

<!-- test: TestClickConstruct -- keep this code block in sync with the test -->
```python
Click(id="save")
```

Generated by `button` widgets when pressed.

<!-- test: TestClickMatch -- keep this code block in sync with the test -->
```python
from plushie.events import Click

def update(self, model, event):
    match event:
        case Click(id="save"):
            return save(model)
        case Click(id="cancel"):
            return revert(model)
```

### Input

```python
Input(id="search", value=value)
```

Generated by `text_input` on every keystroke (when `on_input` is set,
which is the default).

<!-- test: TestInputMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Input

def update(self, model, event):
    match event:
        case Input(id="search", value=value):
            return replace(model, search_query=value)
```

### Submit

```python
Submit(id="search", value=query)
```

Generated by `text_input` when the user presses Enter (when `on_submit`
is set).

<!-- test: TestSubmitMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Submit

def update(self, model, event):
    match event:
        case Submit(id="search", value=query):
            return replace(model, search_query=query), search_command(query)
```

### Toggle

```python
Toggle(id="dark_mode", value=enabled)
```

Generated by `checkbox` and `toggler`. `value` is `True` or `False`.

<!-- test: TestToggleMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Toggle

def update(self, model, event):
    match event:
        case Toggle(id="dark_mode", value=enabled):
            return replace(model, dark_mode=enabled)
```

### Select

```python
Select(id="theme_picker", value=theme)
```

Generated by `pick_list` and `combo_box` when an option is selected.

<!-- test: TestSelectMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Select

def update(self, model, event):
    match event:
        case Select(id="theme_picker", value=theme):
            return replace(model, theme=theme)
```

### Slide

```python
Slide(id="volume", value=value)
SlideRelease(id="volume", value=value)
```

Generated by `slider` and `vertical_slider`. `Slide` fires continuously
during dragging. `SlideRelease` fires once when the user releases the
slider.

<!-- test: TestSlideMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Slide, SlideRelease

def update(self, model, event):
    match event:
        case Slide(id="volume", value=value):
            return replace(model, volume=value)
        case SlideRelease(id="volume", value=value):
            # Persist the final value
            return replace(model, volume=value), save_preference("volume", value)
```

### Text editor content change

```python
Input(id="notes", value=content)
```

Generated by `text_editor` on content changes. The `value` string
contains the full editor text after each edit. This is the same event
class as `text_input`.

<!-- test: TestInputMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Input

def update(self, model, event):
    match event:
        case Input(id="notes", value=content):
            return replace(model, notes_content=content)
```

### Key binding

```python
KeyBinding(id="editor", value="save")
```

Generated by `text_editor` widgets when a declarative key binding rule
with a `"custom"` action matches. The `id` is the text editor's node ID.
The `value` is the custom tag string from the binding rule.

<!-- test: TestKeyBindingMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import KeyBinding

def update(self, model, event):
    match event:
        case KeyBinding(id="editor", value="save"):
            return model, save_file(model)
        case KeyBinding(id="editor", value="format"):
            return replace(model, content=format_code(model.content))
```

### Scroll

```python
Scroll(id="log_view", data=ScrollData(
    absolute_x=..., absolute_y=...,
    relative_x=..., relative_y=...,
    bounds_width=..., bounds_height=...,
    content_width=..., content_height=...,
))
```

Generated by `scrollable` when the scroll position changes (when
`on_scroll=True` is set). The viewport data includes both absolute pixel
offsets and relative (0.0-1.0) scroll positions, plus the visible bounds
and total content bounds.

<!-- test: TestScrollMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Scroll

def update(self, model, event):
    match event:
        case Scroll(id="log_view", data=viewport):
            at_bottom = viewport.relative_y >= 0.99
            return replace(model, auto_scroll=at_bottom)
```

### Paste

```python
Paste(id="url_input", value=text)
```

Generated by `text_input` when the user pastes text (when `on_paste=True`
is set). The `value` field contains the pasted string.

<!-- test: TestPasteMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Paste

def update(self, model, event):
    match event:
        case Paste(id="url_input", value=text):
            return replace(model, url=text.strip())
```

### Option hovered

```python
OptionHovered(id="search", value=value)
```

Generated by `combo_box` when the user hovers over an option in the
dropdown (when `on_option_hovered=True` is set). The `value` is the
hovered option string.

<!-- test: TestOptionHoveredMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import OptionHovered

def update(self, model, event):
    match event:
        case OptionHovered(id="search", value=value):
            return replace(model, preview=value)
```

### Open / Close

```python
Open(id="country_picker")
Close(id="country_picker")
```

Generated by `pick_list` and `combo_box` when the dropdown menu opens or
closes (when `on_open=True` and/or `on_close=True` are set).

<!-- test: TestOpenCloseMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Open, Close

def update(self, model, event):
    match event:
        case Open(id="country_picker"):
            return replace(model, picker_open=True)
        case Close(id="country_picker"):
            return replace(model, picker_open=False)
```

### Sort

```python
Sort(id="users", value=column_key)
```

Generated by `table` when a sortable column header is clicked. The
`value` is the string key from the column descriptor.

<!-- test: TestSortMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Sort

def update(self, model, event):
    match event:
        case Sort(id="users", value=column_key):
            order = flip(model.sort_order) if model.sort_by == column_key else "asc"
            return replace(model, sort_by=column_key, sort_order=order)
```

### Mouse area events

Mouse area events use dedicated dataclasses per interaction type:

```python
from plushie.events import (
    MouseAreaRightPress, MouseAreaEnter, MouseAreaMove, MouseAreaScroll,
)

MouseAreaRightPress(id="canvas")
MouseAreaEnter(id="tooltip-target")
MouseAreaMove(id="drag-zone", x=x, y=y)
MouseAreaScroll(id="scroll-zone", delta_x=dx, delta_y=dy)
```

Available classes: `MouseAreaRightPress`, `MouseAreaRightRelease`,
`MouseAreaMiddlePress`, `MouseAreaMiddleRelease`, `MouseAreaDoubleClick`,
`MouseAreaEnter`, `MouseAreaExit`, `MouseAreaMove`, `MouseAreaScroll`.

Each event requires its corresponding boolean prop to be set on the
mouse_area widget. Without the prop, the event is not emitted.

Note: left press/release events from mouse_area are delivered as
`Click` events.

<!-- test: TestMouseAreaEnterMatch, TestMouseAreaMoveMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import MouseAreaEnter, MouseAreaMove

def update(self, model, event):
    match event:
        case MouseAreaEnter(id="hover_zone"):
            return replace(model, hovered=True)
        case MouseAreaMove(id="canvas_area", x=x, y=y):
            return replace(model, cursor=(x, y))
```

### Canvas events

Generated by `canvas` widgets. Each event is opt-in via a boolean prop on
the canvas node. Canvas events use dedicated dataclasses.

```python
from plushie.events import CanvasPress, CanvasRelease, CanvasMove, CanvasScroll

CanvasPress(id="draw_area", x=x, y=y, button="left")
CanvasRelease(id="draw_area", x=x, y=y, button="left")
CanvasMove(id="draw_area", x=x, y=y)
CanvasScroll(id="draw_area", x=x, y=y, delta_x=dx, delta_y=dy)
```

The `button` field is a string (`"left"`, `"right"`, `"middle"`). The
`x`/`y` coordinates are relative to the canvas origin.

<!-- test: TestCanvasPressMatch, TestCanvasMoveMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import CanvasPress, CanvasMove

def update(self, model, event):
    match event:
        case CanvasPress(id="draw_area", x=x, y=y, button="left"):
            return replace(model, drawing=True, last_point=(x, y))
        case CanvasMove(id="draw_area", x=x, y=y) if model.drawing:
            return replace(model, last_point=(x, y), strokes=[*model.strokes, (x, y)])
```

### Canvas shape events

When a canvas contains shapes with an `interactive` field, the renderer
handles hit testing locally and emits semantic shape events. These arrive
as dedicated `CanvasElement*` dataclasses. The `id` is the canvas widget
ID; `element_id` identifies the shape.

```python
from plushie.events import (
    CanvasElementEnter, CanvasElementLeave, CanvasElementClick,
    CanvasElementDrag, CanvasElementDragEnd, CanvasElementFocused,
)

# Cursor entered a shape's bounds
CanvasElementEnter(id="chart", element_id="bar-jan", x=15.0, y=70.0)

# Cursor left a shape's bounds
CanvasElementLeave(id="chart", element_id="bar-jan")

# Click on a shape
CanvasElementClick(id="chart", element_id="bar-jan", x=15.0, y=70.0, button="left")

# Drag on a draggable shape (rate-limited by event_rate)
CanvasElementDrag(id="chart", element_id="handle", x=50.0, y=80.0, delta_x=2.0, delta_y=-1.0)

# Drag ended
CanvasElementDragEnd(id="chart", element_id="handle", x=52.0, y=79.0)

# Shape received keyboard focus (Tab/Arrow navigation)
CanvasElementFocused(id="chart", element_id="bar-jan")
```

Hover styles, pressed styles, cursors, and tooltips on shapes are
handled by the renderer locally -- no round-trip needed. Shape events
give the host semantic actions (clicks, drags, focus changes) instead
of raw coordinates.

<!-- test: TestCanvasElementClickMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import CanvasElementClick

def update(self, model, event):
    match event:
        case CanvasElementClick(id="chart", element_id=element_id):
            return replace(model, selected_bar=element_id)
```

### Sensor events

Generated by `sensor` widgets when the sensor detects a size change.

```python
from plushie.events import SensorResize

SensorResize(id="content_area", width=w, height=h)
```

<!-- test: TestSensorResizeMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import SensorResize

def update(self, model, event):
    match event:
        case SensorResize(id="content_area", width=w, height=h):
            return replace(model, content_size=(w, h))
```

### PaneGrid events

Generated by `pane_grid` widgets during pane interactions.

```python
from plushie.events import PaneResized, PaneDragged, PaneClicked

PaneResized(id="editor", split=split, ratio=ratio)
PaneDragged(id="editor", pane=source, target=target, action="dropped")
PaneClicked(id="editor", pane=pane)
```

<!-- test: TestPaneResizedMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import PaneResized, PaneClicked

def update(self, model, event):
    match event:
        case PaneResized(id="editor", split=split, ratio=ratio):
            splits = {**model.splits, split: ratio}
            return replace(model, splits=splits)
        case PaneClicked(id="editor", pane=pane):
            return replace(model, active_pane=pane)
```

## Keyboard events

Delivered when keyboard subscriptions are active (see
[commands.md](commands.md)).

```python
from plushie.events import KeyPress, KeyRelease

KeyPress(key="s", modified_key="s", modifiers=KeyModifiers(command=True))
KeyRelease(key="Escape", modified_key="Escape", modifiers=KeyModifiers())
```

### KeyPress / KeyRelease dataclass

```python
@dataclass(frozen=True, slots=True)
class KeyPress:
    key: str                          # logical key ("a", "Enter", "ArrowUp", etc.)
    modified_key: str                 # key with modifiers applied (e.g. Shift+a = "A")
    modifiers: KeyModifiers
    physical_key: str | None = None   # physical key code (layout-independent)
    location: KeyLocation = "standard"  # "standard", "left", "right", "numpad"
    text: str | None = None           # text produced by the key press (None for non-printable)
    repeat: bool = False              # True if this is an auto-repeat event
    captured: bool = False
```

### KeyModifiers dataclass

```python
@dataclass(frozen=True, slots=True)
class KeyModifiers:
    shift: bool = False
    ctrl: bool = False
    alt: bool = False
    logo: bool = False
    command: bool = False    # platform-aware: ctrl on Linux/Windows, logo (Cmd) on macOS
```

### Key values

Named keys are strings matching their logical name:

```python
"Enter", "Escape", "Tab", "Backspace", "Delete",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"Home", "End", "PageUp", "PageDown",
"F1", "F2", ... "F12",
"Space"
```

Character keys are single-character strings:

```python
"a", "b", "1", "/", " "
```

### Keyboard event examples

<!-- test: TestKeyPressMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import KeyPress
from plushie.types import KeyModifiers

def update(self, model, event):
    match event:
        case KeyPress(key="s", modifiers=KeyModifiers(command=True)):
            return model, save_command()

        case KeyPress(key="Escape"):
            return replace(model, modal_open=False)

        case KeyPress(key="z", modifiers=KeyModifiers(command=True, shift=False)):
            return undo(model)

        case KeyPress(key="z", modifiers=KeyModifiers(command=True, shift=True)):
            return redo(model)

        # Use physical_key for layout-independent bindings (e.g. WASD on non-QWERTY)
        case KeyPress(physical_key="KeyW"):
            return move_up(model)

        # Use text field for text input handling
        case KeyPress(text=text) if text is not None:
            return append_char(model, text)
```

## IME events

Delivered when IME subscriptions are active (see
[commands.md](commands.md)). Input Method Editor events support CJK and
other compose-based text input.

```python
from plushie.events import ImeOpen, ImePreedit, ImeCommit, ImeClose

ImeOpen()
ImePreedit(text=text, cursor=(start, end_pos))
ImeCommit(text=text)
ImeClose()
```

`ImePreedit` fires during composition with the in-progress text and optional
cursor range. `ImeCommit` fires when the user finalises a composed string.

<!-- test: TestImePreeditMatch, TestImeCommitMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import ImePreedit, ImeCommit

def update(self, model, event):
    match event:
        case ImePreedit(text=text):
            return replace(model, composing=text)
        case ImeCommit(text=text):
            return replace(model, composing=None, value=model.value + text)
```

## Mouse events (global)

Delivered when mouse subscriptions are active. These are global
(not widget-scoped) events from the windowing system.

```python
from plushie.events import (
    MouseMove, MouseEnter, MouseLeave,
    MouseButtonPress, MouseButtonRelease, MouseWheel,
)

MouseMove(x=x, y=y)
MouseEnter()
MouseLeave()
MouseButtonPress(button="left")
MouseButtonRelease(button="right")
MouseWheel(delta_x=dx, delta_y=dy, unit="line")
```

The `button` field is a string (`"left"`, `"right"`, `"middle"`, `"back"`,
`"forward"`). The `unit` field indicates scroll units (`"line"` or `"pixel"`).

<!-- test: TestMouseMoveMatch, TestMouseButtonPressMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import MouseMove, MouseButtonPress

def update(self, model, event):
    match event:
        case MouseMove(x=x, y=y):
            return replace(model, cursor=(x, y))
        case MouseButtonPress(button="left"):
            return replace(model, mouse_down=True)
```

## Touch events

Delivered when touch subscriptions are active.

```python
from plushie.events import TouchPress, TouchMove, TouchLift, TouchLost

TouchPress(finger_id=fid, x=x, y=y)
TouchMove(finger_id=fid, x=x, y=y)
TouchLift(finger_id=fid, x=x, y=y)
TouchLost(finger_id=fid, x=x, y=y)
```

<!-- test: TestTouchPressMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import TouchPress

def update(self, model, event):
    match event:
        case TouchPress(x=x, y=y):
            return replace(model, touch_start=(x, y))
```

## Modifier state events

Delivered when modifier key state changes (subscription-driven).

<!-- test: TestKeyModifiersConstruct -- keep this code block in sync with the test -->
```python
from plushie.events import ModifiersChanged
from plushie.types import KeyModifiers

ModifiersChanged(modifiers=KeyModifiers(shift=True), captured=False)
```

The `captured` field indicates whether modifier state was captured by a
text input widget.

<!-- test: TestModifiersChangedMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import ModifiersChanged
from plushie.types import KeyModifiers

def update(self, model, event):
    match event:
        case ModifiersChanged(modifiers=KeyModifiers(shift=True)):
            return replace(model, shift_held=True)
```

## Window events

Delivered when window subscriptions are active or for lifecycle events
on windows the app manages.

<!-- test: TestWindowResizedMatch, TestWindowFocusedMatch -- keep this code block in sync with the test -->
```python
from plushie.events import (
    WindowCloseRequested, WindowOpen, WindowClosed, WindowMoved,
    WindowResized, WindowFocused, WindowUnfocused, WindowRescaled,
    FileHovered, FileDropped, FilesHoveredLeft,
)

WindowCloseRequested(window_id="main")
WindowOpen(window_id="main", width=w, height=h, scale_factor=1.0, position_x=x, position_y=y)
WindowClosed(window_id="main")
WindowMoved(window_id="main", x=x, y=y)
WindowResized(window_id="main", width=w, height=h)
WindowFocused(window_id="main")
WindowUnfocused(window_id="main")
WindowRescaled(window_id="main", scale_factor=factor)
FileHovered(window_id="main", path=path)
FileDropped(window_id="main", path=path)
FilesHoveredLeft(window_id="main")
```

### Handling window close

<!-- test: TestWindowCloseRequestedMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.commands import Command
from plushie.events import WindowCloseRequested

def update(self, model, event):
    match event:
        case WindowCloseRequested(window_id="main"):
            if model.unsaved_changes:
                return replace(model, confirm_exit=True)
            return model, Command.close_window("main")
```

### File drag and drop

<!-- test: TestFileDragDropMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import FileHovered, FileDropped, FilesHoveredLeft

def update(self, model, event):
    match event:
        case FileHovered(window_id="main", path=path):
            return replace(model, drop_target_active=True, hovered_file=path)
        case FileDropped(window_id="main", path=path):
            return replace(model, drop_target_active=False), load_file(path)
        case FilesHoveredLeft(window_id="main"):
            return replace(model, drop_target_active=False)
```

## System events

<!-- test: TestAnimationFrameConstruct, TestThemeChangedConstruct -- keep this code block in sync with the test -->
```python
from plushie.events import AnimationFrame, ThemeChanged

AnimationFrame(timestamp=timestamp)
ThemeChanged(theme=mode)
```

Animation frame events are delivered on each frame when an animation
subscription is active. Theme changed events fire when the OS theme
switches. The `theme` field holds `"light"` or `"dark"`.

## Timer events

Delivered by timer subscriptions.

<!-- test: TestTimerTickMatch -- keep this code block in sync with the test -->
```python
from plushie.events import TimerTick

TimerTick(tag="tick", timestamp=ts)
```

Where `timestamp` is the monotonic time in milliseconds.

## Command result events

Delivered when an async command completes.

```python
from plushie.events import AsyncResult

AsyncResult(tag="data_loaded", value=result, error=None)
AsyncResult(tag="data_loaded", value=None, error=reason)
```

Where `tag` is the string you passed to `Command.task()`. On success,
`value` holds the return value and `error` is `None`. On failure,
`error` holds the exception and `value` is `None`.

<!-- test: TestAsyncResultMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import Click, AsyncResult
from plushie.commands import Command

def update(self, model, event):
    match event:
        case Click(id="fetch"):
            return replace(model, loading=True), Command.task(fetch_data, "data_loaded")
        case AsyncResult(tag="data_loaded", value=body) if body is not None:
            return replace(model, loading=False, data=body)
        case AsyncResult(tag="data_loaded", error=err) if err is not None:
            return replace(model, loading=False, error=str(err))
```

## Stream events

Delivered when a streaming command emits an intermediate value.

<!-- test: TestStreamChunkMatch -- keep this code block in sync with the test -->
```python
from plushie.events import StreamChunk, AsyncResult

StreamChunk(tag="file_import", value=progress_value)
AsyncResult(tag="file_import", value=final_result)
```

`StreamChunk` fires for each call to the `emit` callback.
`AsyncResult` fires when the stream function returns its final value.

## Effect result events

Delivered when a renderer effect completes.

```python
from plushie.events import EffectResult

EffectResult(request_id="ef_1", status="ok", result=data)
EffectResult(request_id="ef_1", status="cancelled")
EffectResult(request_id="ef_1", status="error", error=reason)
```

The `request_id` is the auto-generated effect ID (e.g. `"ef_1"`), not
the effect kind name.

<!-- test: TestEffectResultMatch -- keep this code block in sync with the test -->
```python
from dataclasses import replace
from plushie.events import EffectResult

def update(self, model, event):
    match event:
        case EffectResult(status="ok", result=result):
            return model, load_file(result["path"])
        case EffectResult(status="error", error=reason):
            return replace(model, error=reason)
```

See [effects.md](effects.md).

## Accessibility action

```python
from plushie.events import A11yAction

A11yAction(id="volume", action="Increment")
```

Generated when an assistive technology triggers a non-standard action on a
widget. Standard AT actions are mapped to normal events: Click/Default
becomes `Click(id=id)`, SetValue becomes `Input(id=id, value=value)`.
This event catches everything else.

The `action` is a string representation of the accesskit action (e.g.
`"ScrollDown"`, `"Increment"`, `"Decrement"`).

```python
from dataclasses import replace
from plushie.events import A11yAction

def update(self, model, event):
    match event:
        case A11yAction(id="volume", action="Increment"):
            return replace(model, volume=min(model.volume + 5, 100))
```

Note: This event is only generated when the renderer is built with the
`a11y` feature flag.

## Catch-all

Always include a catch-all clause:

<!-- test: TestCatchAll -- keep this code block in sync with the test -->
```python
def update(self, model, event):
    match event:
        # ... specific handlers ...
        case _:
            return model
```

Unknown events are silently ignored. This is important for forward
compatibility -- new widget types or renderer versions may emit events
your app does not yet handle.

## Pattern matching tips

Events are frozen dataclasses, so Python's structural pattern matching
works naturally:

<!-- test: TestPrefixMatch -- keep this code block in sync with the test -->
```python
from plushie.events import Click, Toggle

def update(self, model, event):
    match event:
        # Match any click
        case Click(id=widget_id):
            print(f"clicked: {widget_id}")
            return handle_click(model, widget_id)

        # Match clicks with a prefix
        case Click(id=widget_id) if widget_id.startswith("nav:"):
            section = widget_id.removeprefix("nav:")
            return replace(model, section=section)

        # Match toggle on any "setting:" prefixed checkbox
        case Toggle(id=widget_id, value=value) if widget_id.startswith("setting:"):
            key = widget_id.removeprefix("setting:")
            return replace(model, settings={**model.settings, key: value})
```

### Scope matching

Widget events include a `scope` field -- a tuple of ancestor container IDs,
nearest first. You can pattern match on scope to distinguish events from
different contexts:

<!-- test: TestScopeMatch -- keep this code block in sync with the test -->
```python
from plushie.events import Click

def update(self, model, event):
    match event:
        # Button inside the "sidebar" container
        case Click(id="save", scope=("sidebar", *_)):
            return save_sidebar(model)

        # Same button ID but inside "main" container
        case Click(id="save", scope=("main", *_)):
            return save_main(model)
```
