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

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

What does NOT create scope

Slash validation

User-provided IDs cannot contain /. Attempting to normalize a tree with a manually slashed ID logs a warning. 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 list:

WidgetClick(id: "save", scope: ["form", "sidebar"])

The scope list is in reverse order – nearest parent first. This design optimises the common case of matching on the immediate parent:

fn update(model, event) {
  case event {
    // Match on local ID only (ignores scope entirely)
    WidgetClick(id: "save", ..) -> ...

    // Match on ID + immediate parent
    WidgetClick(id: "save", scope: ["form", ..], ..) -> ...

    // Bind the parent for dynamic lists
    WidgetToggle(id: "done", scope: [item_id, ..], ..) ->
      toggle_item(model, item_id)
  }
}

Which event constructors carry scope

Subscription events (key, mouse, touch, IME, modifiers) are global and do not carry scope.

Tree search

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

import plushie/tree

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

// Falls back to local ID match (last segment)
tree.find(tree_node, "save")

The convenience delegates ui.find and ui.exists call through to the same functions.

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

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.

// View
ui.column("todo_list", [], [
  // map over model.items:
  ui.row(item.id, [], [
    ui.checkbox("done", "Done", item.completed, []),
    ui.button_("delete", "X"),
  ]),
  // ...
])

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

fn update(model, event) {
  case event {
    WidgetToggle(id: "done", scope: [item_id, ..], ..) ->
      toggle_item(model, item_id)

    WidgetClick(id: "delete", scope: [item_id, ..], ..) ->
      delete_item(model, item_id)

    _ -> #(model, command.none())
  }
}

The .. in the scope pattern 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 list is designed for ergonomic pattern matching:

case event {
  // Depth-agnostic: works whether "search" is at root or deeply nested
  WidgetInput(id: "query", scope: ["search", ..], ..) -> ...

  // Exact depth: only matches if "search" is the only scope ancestor
  WidgetInput(id: "query", scope: ["search"], ..) -> ...

  // No scope: only matches unscoped widgets
  WidgetClick(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:

ui.container("form", [], [
  ui.text("name_label", "Name:", []),
  ui.text_input("name", model.name, [
    // a11y props would be set via the widget builder layer
  ]),
])

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

Scoped events carry the full path in their fields. When debugging, inspect the event to see the scoped ID:

WidgetClick(id: "sidebar/form/save", scope: ["form", "sidebar"], ..)
WidgetInput(id: "email", scope: [], value: "test@example.com", ..)
CanvasPress(id: "panel/drawing", scope: ["panel"], x: 42.0, y: 100.0, ..)
SensorResize(id: "content/measure", scope: ["content"], width: 800.0, height: 600.0)

The id field contains the full scoped path. The scope field contains the reversed ancestor chain (nearest parent first).

Search Document