plushie/widget
Custom widget system.
Custom widgets are pure Gleam widgets that produce UI via canvas shapes with runtime-managed internal state and event transformation. They sit between the renderer and the app, intercepting events in the scope chain and emitting semantic events.
Defining a widget
import plushie/widget
import plushie/canvas/shape
import plushie/event.{type Event}
type StarState { StarState(hover: Int) }
type StarProps { StarProps(rating: Int, max: Int) }
pub fn star_rating_def() -> widget.WidgetDef(StarState, StarProps) {
widget.WidgetDef(
init: fn() { StarState(hover: 0) },
view: view_stars,
handle_event: handle_star_event,
subscriptions: fn(_, _) { [] },
cache_key: option.None,
)
}
pub fn star_rating(id: String, props: StarProps) -> Node {
widget.build(star_rating_def(), id, props)
}
How it works
build creates a placeholder canvas node tagged with metadata.
During tree normalization, the runtime detects the tag, looks up
the widget’s state from the registry, calls view, and
recursively normalizes the output. The normalized tree carries
metadata for registry derivation after each view cycle.
Events flow through the scope chain before reaching app.update.
Each widget in the chain gets a chance to handle the event:
Ignored passes through, Consumed stops the chain, and
Emit(kind, data) replaces the event with a CustomWidget event and
continues. The runtime fills in id and scope automatically
from the widget’s position in the tree.
Types
Result of dispatching an event through the widget chain.
pub type DispatchResult {
Dispatched(option.Option(event.Event))
Bypassed(event.Event)
}
Constructors
-
Dispatched(option.Option(event.Event))Handlers were consulted; Some = event to deliver, None = consumed.
-
Bypassed(event.Event)No handlers in scope; event was not routed through any widget. Raw canvas events should reach update/2; widget-internal events from a widget scope should be auto-consumed at the call site.
Result of a widget’s event handler.
pub type EventAction {
Ignored
Consumed
Emit(kind: String, data: dynamic.Dynamic)
UpdateState
}
Constructors
-
IgnoredNot handled: continue to next handler in scope chain.
-
ConsumedCaptured, no output: stop chain, don’t dispatch to app.
-
Emit(kind: String, data: dynamic.Dynamic)Captured with semantic event. The runtime constructs a CustomWidget event with the widget’s id/scope filled in automatically.
kindis the event family (e.g., “click”, “select”, “change”).datacarries event-specific payload as Dynamic. -
UpdateStateCaptured with internal state change only. Like Consumed but signals that the widget’s state was updated (triggers re-render).
The widget registry: maps window-aware widget keys to entries.
pub type Registry =
dict.Dict(String, RegistryEntry)
A registry entry for a widget instance. Stores type-erased state and pre-bound closures so the registry can be heterogeneous.
pub type RegistryEntry {
RegistryEntry(
view: fn(String) -> node.Node,
handle_event: fn(event.Event) -> #(EventAction, RegistryEntry),
subscriptions: fn() -> List(subscription.Subscription),
state: dynamic.Dynamic,
props: dynamic.Dynamic,
def: dynamic.Dynamic,
)
}
Constructors
-
RegistryEntry( view: fn(String) -> node.Node, handle_event: fn(event.Event) -> #(EventAction, RegistryEntry), subscriptions: fn() -> List(subscription.Subscription), state: dynamic.Dynamic, props: dynamic.Dynamic, def: dynamic.Dynamic, )Arguments
- view
-
Produce the widget’s node tree given its scoped ID.
- handle_event
-
Handle an event. Returns the action and an updated entry.
- subscriptions
-
Collect subscriptions for this widget instance.
- state
-
The widget’s current state as Dynamic (for re-injection during normalization).
- props
-
The widget’s props as Dynamic (for re-injection).
- def
-
The widget def as Dynamic (for re-injection).
Definition of a widget’s behaviour.
state is the widget’s internal state (managed by the runtime).
props is the widget’s input from the parent view function.
For stateless composites, use simple or with_handler instead
of constructing WidgetDef directly.
pub type WidgetDef(state, props) {
WidgetDef(
init: fn() -> state,
view: fn(String, props, state) -> node.Node,
handle_event: fn(event.Event, state) -> #(EventAction, state),
subscriptions: fn(props, state) -> List(
subscription.Subscription,
),
cache_key: option.Option(fn(props, state) -> dynamic.Dynamic),
)
}
Constructors
-
WidgetDef( init: fn() -> state, view: fn(String, props, state) -> node.Node, handle_event: fn(event.Event, state) -> #(EventAction, state), subscriptions: fn(props, state) -> List( subscription.Subscription, ), cache_key: option.Option(fn(props, state) -> dynamic.Dynamic), )Arguments
- init
-
Create the initial state for a new widget instance.
- view
-
Produce the widget’s node tree from its id, props, and state.
- handle_event
-
Handle an event. Returns the action and (possibly updated) state.
- subscriptions
-
Subscriptions for this widget instance.
- cache_key
-
Optional cache key derivation for view memoization.
When
Some(f),f(props, state)produces a cheap comparison key. If the key matches the previous render cycle, the normalizer can skip callingviewand reuse the cached subtree.The normalizer does not use this field yet. Declaring it now lets widget authors opt in; the optimization will land in a future release.
Packed widget metadata stored under a single meta key. The fields are type-erased PropValues wrapping the original typed values via identity coercion. State is None on initial placeholders (before normalization) and Some after render_placeholder attaches it.
pub opaque type WidgetMeta
Values
pub fn build(
def: WidgetDef(state, props),
id: String,
props: props,
) -> node.Node
Build a placeholder node for a widget.
The returned node has kind “canvas” and carries metadata props that the runtime uses during normalization to produce the real canvas tree with the widget’s current state.
pub fn collect_subscriptions(
registry: dict.Dict(String, RegistryEntry),
) -> List(subscription.Subscription)
Collect subscriptions from all widgets in the registry.
Each subscription’s tag is namespaced with the widget’s scoped ID so the runtime can route timer events back to the correct widget.
pub fn derive_registry(
tree: node.Node,
) -> dict.Dict(String, RegistryEntry)
Derive the registry from a normalized tree.
Walks the tree and extracts widget metadata from nodes. Returns a fresh registry with entries for all widgets found in the tree.
pub fn dispatch_through_widgets(
registry: dict.Dict(String, RegistryEntry),
ev: event.Event,
) -> #(DispatchResult, dict.Dict(String, RegistryEntry))
Route an event through widget handlers in the scope chain.
Returns Dispatched(Some(event)) when handlers were consulted but
the event passed through, Dispatched(None) when consumed, or
Bypassed(event) when no handlers existed in the event’s scope.
pub fn emit_bool(kind: String, value: Bool) -> EventAction
Emit a bool value. Convenience for Emit(kind, dynamic.bool(value)).
pub fn emit_float(kind: String, value: Float) -> EventAction
Emit a float value. Convenience for Emit(kind, dynamic.float(value)).
pub fn emit_int(kind: String, value: Int) -> EventAction
Emit an int value. Convenience for Emit(kind, dynamic.int(value)).
pub fn emit_none(kind: String) -> EventAction
Emit with no payload value. Convenience for Emit(kind, dynamic.nil()).
pub fn emit_string(kind: String, value: String) -> EventAction
Emit a string value. Convenience for Emit(kind, dynamic.string(value)).
pub fn empty_registry() -> dict.Dict(String, RegistryEntry)
Create an empty registry.
pub fn event_id(ev: event.Event) -> String
Extract the local widget ID from an event.
Convenience accessor that delegates to extract_target.
Returns an empty string for events that don’t carry an ID
(system events, timer events, etc.).
pub fn event_scope(ev: event.Event) -> List(String)
Extract the scope from an event.
Convenience accessor that delegates to extract_target.
Returns an empty list for events that don’t carry scope
(system events, timer events, etc.).
pub fn event_window_id(ev: event.Event) -> String
Extract the window ID from an event.
Convenience accessor that delegates to extract_target.
Returns an empty string for events that don’t carry a window ID
(system events, timer events, etc.).
pub fn extract_target(
ev: event.Event,
) -> option.Option(event.EventTarget)
Extract the EventTarget from a scoped event.
Returns Some(target) for widget, pointer, element, and pane events that carry scope identity. Returns None for events without a target (system events, timer events, key events, etc.).
pub fn handle_widget_timer(
registry: dict.Dict(String, RegistryEntry),
tag: String,
timestamp: Int,
) -> #(
option.Option(event.Event),
dict.Dict(String, RegistryEntry),
)
Route a timer event to the correct widget.
If the timer tag is namespaced, look up the widget, create a TimerTick with the inner tag, dispatch through the widget’s handler, and return the result. Emitted events are dispatched through the scope chain so parent widgets can intercept.
Returns #(Some(event), registry) if the event should reach
app.update, or #(None, registry) if handled internally.
For non-widget timers, returns None; the caller is
responsible for constructing the appropriate TimerTick.
pub fn is_placeholder(node: node.Node) -> Bool
Check if a node is a widget placeholder (has widget metadata).
pub fn is_widget_tag(tag: String) -> Bool
Check if a subscription tag is namespaced for a widget.
pub fn make_entry(
def: WidgetDef(state, props),
props: props,
state: state,
) -> RegistryEntry
Create a registry entry from a typed def, props, and state. The entry captures the concrete types in closures.
pub fn merge_standard_props(
rendered_props: dict.Dict(String, node.PropValue),
placeholder_props: dict.Dict(String, node.PropValue),
) -> dict.Dict(String, node.PropValue)
Merge standard widget props (a11y, event_rate) from the placeholder into the rendered node’s props. Called during normalization.
pub fn parse_widget_tag(
tag: String,
) -> option.Option(#(String, String))
Parse a namespaced tag into (widget_id, inner_tag). Returns None if the tag isn’t namespaced.
pub fn render_placeholder(
node: node.Node,
window_id: String,
scoped_id: String,
local_id: String,
registry: dict.Dict(String, RegistryEntry),
) -> option.Option(#(node.Node, RegistryEntry))
Render a widget placeholder using the registry.
Returns the rendered + normalized canvas node and an updated registry entry, or None if the node isn’t a placeholder or the widget isn’t in the registry.
pub fn set_a11y(
node: node.Node,
accessibility: a11y.A11y,
) -> node.Node
Attach accessibility properties to a widget placeholder. These are automatically forwarded to the rendered output during tree normalization; widget authors don’t need to handle them.
pub fn set_event_rate(node: node.Node, rate: Int) -> node.Node
Attach an event rate limit to a widget placeholder. Forwarded to the rendered output automatically.
A rate of zero means “track only, never emit”: the renderer tracks the event source but suppresses delivery entirely.
pub fn simple(
view: fn(String, props) -> node.Node,
) -> WidgetDef(Nil, props)
Create a stateless view-only widget. Events from child widgets pass through to the app’s update (transparent to events).
let labeled_input_def = widget.simple(fn(id, props: InputProps) {
ui.column(id, [], [
ui.text_(id <> "/label", props.label),
ui.text_input(id <> "/input", props.value, []),
])
})
pub fn widget_key(window_id: String, scoped_id: String) -> String
pub fn with_handler(
view: fn(String, props) -> node.Node,
handle_event: fn(event.Event) -> EventAction,
) -> WidgetDef(Nil, props)
Create a stateless widget with an event handler. The handler intercepts events from child widgets and can transform, consume, or ignore them.
let note_card_def = widget.with_handler(
fn(id, props: CardProps) {
ui.column(id, [], [
ui.text_(id <> "/title", props.title),
ui.button_(id <> "/open", "Open"),
])
},
fn(event) {
case event {
event.Widget(event.Click(target: event.EventTarget(id: "open", ..))) ->
widget.Emit("open", dynamic.nil())
_ -> widget.Ignored
}
},
)