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
- 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 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
WidgetClick,WidgetInput,WidgetSubmit,WidgetToggle,WidgetSelect,WidgetSlide,WidgetPaste,WidgetScroll, etc.CanvasPress,CanvasRelease,CanvasMove,CanvasPinchMouseAreaRightPress,MouseAreaEnter,MouseAreaMove, etc.PaneDragged,PaneResized,PaneClickedSensorResize
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).