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

  • Ignored

    Not handled: continue to next handler in scope chain.

  • Consumed

    Captured, 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. kind is the event family (e.g., “click”, “select”, “change”). data carries event-specific payload as Dynamic.

  • UpdateState

    Captured 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 calling view and 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
    }
  },
)
Search Document