# Scoped Widget IDs

Named containers automatically scope the IDs of their children. This
gives every widget a unique, hierarchical identity that reflects where
it lives in the tree, without requiring the developer to manually
construct long prefixed strings.

## How it works

<!-- test: test_normalize_builds_scoped_ids, test_auto_id_containers_do_not_scope, test_window_nodes_do_not_scope, test_slash_in_user_id_raises -- keep this code block in sync with the test -->

When `tree.normalize()` processes a node with an explicit (non-auto) ID,
it pushes that ID onto a scope chain. All descendant nodes have their
IDs prefixed with the scope path, joined by `/`.

```
sidebar (container)        ->  id: "sidebar"
  form (container)         ->  id: "sidebar/form"
    email (text_input)     ->  id: "sidebar/form/email"
    save (button)          ->  id: "sidebar/form/save"
```

### What creates scope

- **Named containers** -- any node with an explicit ID (not starting
  with `auto:`) pushes its ID onto the scope chain.

### What does NOT create scope

- **Auto-ID containers** -- IDs starting with `auto:` (generated by
  the builder layer) pass through without adding scope.
- **Window nodes** -- `type: "window"` nodes never scope, since windows
  are logical boundaries, not containment boundaries.

### Slash validation

User-provided IDs cannot contain `/`. Attempting to normalize a tree
with a manually slashed ID raises a `ValueError`. The `/` separator
is reserved for the scoping mechanism.

## Events

When the renderer emits an event for a widget, the wire ID is the full
scoped path (e.g. `"sidebar/form/save"`). The protocol decode layer
splits it into a local `id` and a `scope` tuple:

<!-- test: test_click_scope_structure -- keep this code block in sync with the test -->
```python
Click(id="save", scope=("form", "sidebar"))
```

The `scope` tuple is in **reverse order** -- nearest parent first. This
design optimises the common case of matching on the immediate parent:

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

def update(self, model, event):
    match event:
        # Match on local ID only (ignores scope entirely)
        case Click(id="save"):
            return handle_save(model)

        # Match on ID + immediate parent
        case Click(id="save", scope=("form", *_)):
            return handle_form_save(model)

        # Bind the parent for dynamic lists
        case Toggle(id="done", scope=(item_id, *_)):
            return toggle_item(model, item_id)
```

### Reconstructing the full path

Use `plushie.events.target()` to get the full forward-order path:

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

event = Click(id="save", scope=("form", "sidebar"))
target(event)
# => "sidebar/form/save"
```

## Which event classes carry scope

- `Click`, `Input`, `Submit`, `Toggle`, `Select`, `Slide`, `SlideRelease`
- `Scroll`, `Paste`, `Sort`, `Open`, `Close`
- `CanvasElementClick`, `CanvasElementEnter`, `CanvasElementClick`, `CanvasElementDragEnd`
- `MouseAreaPress`, `MouseAreaRelease`, `MouseAreaMove`, `MouseAreaEnter`, `MouseAreaExit`
- `PaneResize`, `PaneDrag`
- `SensorResize`

Subscription events (KeyPress, KeyRelease, MouseMove, etc.) are global
and do not carry scope.

## Tree search

`tree.find()` and `tree.exists()` support both full scoped paths and
local IDs:

<!-- test: test_find_by_full_scoped_path, test_find_falls_back_to_local_id, test_exists_with_scoped_path -- keep this code block in sync with the test -->
```python
from plushie.tree import find, exists

# Exact match on scoped path
find(tree, "sidebar/form/save")

# Falls back to local ID match (last segment)
find(tree, "save")
```

Exact matches take priority. Local ID fallback only triggers when the
target doesn't contain `/` and no exact match was found.

## Test selectors

Test helpers (`find()`, `click()`, etc.) accept both local and scoped
selectors:

```python
# Local ID -- resolved against the current tree
session.find("#save")
session.click("#save")

# Full scoped path
session.find("#sidebar/form/save")
```

The backends resolve local IDs to their full scoped path using the
Python-side tree before sending queries to the renderer.

## Dynamic lists

When rendering a list of items, wrap each item in a named container
(row, column, container) using the item's ID. The container creates
a scope for the item's children, giving each instance unique IDs
without manual prefixing.

<!-- test: test_dynamic_list_scoping -- keep this code block in sync with the test -->
```python
def view(self, model):
    return ui.column(
        *(
            ui.row(item.id,
                ui.checkbox("done", item.completed),
                ui.button("delete", "X"),
            )
            for item in model.items
        ),
        id="todo_list",
    )
```

This produces IDs like `"todo_list/item_1/done"` and
`"todo_list/item_2/delete"`. In `update()`, bind the item ID
from the scope:

<!-- test: test_dynamic_list_scope_matching -- keep this code block in sync with the test -->
```python
def update(self, model, event):
    match event:
        case Toggle(id="done", scope=(item_id, *_)):
            return toggle_item(model, item_id)

        case Click(id="delete", scope=(item_id, *_)):
            return delete_item(model, item_id)
```

The `*_` captures and ignores the `"todo_list"` scope above, so the
same pattern works if the list is moved to a different part of the tree.

## Pattern matching tips

The reversed scope tuple is designed for ergonomic pattern matching:

<!-- test: test_depth_agnostic_match, test_exact_depth_match, test_no_scope_match -- keep this code block in sync with the test -->
```python
match event:
    # Depth-agnostic: works whether "search" is at root or deeply nested
    case Click(id="query", scope=("search", *_)):
        ...

    # Exact depth: only matches if "search" is the only scope ancestor
    case Click(id="query", scope=("search",)):
        ...

    # No scope: only matches unscoped widgets
    case Click(id="save", scope=()):
        ...
```

## Accessibility cross-references

The a11y props `labelled_by`, `described_by`, and `error_message`
reference other widgets by ID. During normalization, these references
are automatically resolved relative to the current scope:

```python
from plushie.types import A11y

ui.container("form",
    ui.text("name_label", "Name:"),
    ui.text_input("name", model.name,
        a11y=A11y(labelled_by="name_label"),
    ),
)
```

On the wire, `labelled_by` becomes `"form/name_label"`, matching
the scoped ID of the label widget. References that already contain
`/` (full paths) pass through unchanged.

## Inspect output

All scoped event dataclasses include scope in their `repr` output:

```
Click(id='save', scope=('form', 'sidebar'))
Input(id='email', value='test@example.com', scope=('form',))
CanvasElementClick(id='panel', element_id='drawing', x=42, y=100, scope=())
SensorResize(id='measure', width=800.0, height=600.0, scope=('content',))
```

Use `plushie.events.target(event)` to get the full forward-order
path string programmatically.
