Plushie has two kinds of custom widgets: pure Elixir (compose existing widgets or draw with canvas) and native (Rust-backed, for custom GPU rendering). This reference covers the macro API, handler protocol, event flow, state management, testing, and native widget integration.

For a narrative introduction, see the Custom Widgets guide.

Widget macro API

use Plushie.Widget generates a struct, constructor, setters, protocol implementations, and (for stateful widgets) the Handler behaviour. The macro detects which callbacks and declarations are present and generates the appropriate code.

Declarations

MacroPurposeExample
widget/2Type name and optionswidget :gauge or widget :gauge, container: true
prop/3Typed propertyprop :label, :string, default: "Hello"
event/2Event declarationevent :change, value: :number
state/1Internal state fieldsstate hover: nil, count: 0
command/2Widget command (native)command :reset, value: :number
rust_crate/1Rust crate path (native)rust_crate "path/to/crate"
rust_constructor/1Rust constructor (native)rust_constructor "gauge::new()"

Widget type name

widget :my_widget declares the type name. This name appears in custom event tuples ({:my_widget, :change}) and is used for protocol dispatch. Choose a unique name across your application. Duplicate type names are not validated at compile time and will cause event routing confusion.

Prop types

Built-in type atoms: :string, :number, :boolean, :color, :length, :padding, :alignment, :style, :font, :atom, :map, :any.

For list-typed props, use the {:list, :type} form:

prop :tags, {:list, :string}
prop :points, {:list, :number}

You can also use any module that implements the Plushie.Event.EventType behaviour as a type. The behaviour requires a single parse/1 callback that returns {:ok, value} or :error. This is useful for domain-specific types in event declarations.

Container widgets

widget :my_widget, container: true adds a :children field to the struct and configures new/2 to accept :do blocks. Children are available in view/3 via props.children:

def view(id, props, state) do
  import Plushie.UI
  column id: id do
    props.children
  end
end

Note: container: true does not auto-generate push/2 or extend/2 methods. If you need the pipeline API for your container widget, define them manually:

def push(%__MODULE__{} = w, child), do: %{w | children: [child | w.children]}
def extend(%__MODULE__{} = w, children), do: %{w | children: w.children ++ children}

Event routing

  • event :name, value: :type - scalar payload, delivered in WidgetEvent.value
  • event :name, data: [field: :type, ...] - structured payload, delivered in WidgetEvent.data as an atom-keyed map

Custom events appear in WidgetEvent.type as {widget_type, :event_name} tuples (e.g. {:my_widget, :change}). Built-in event names (:click, :toggle, etc.) use the standard spec automatically.

Generated code

use Plushie.Widget generates:

  • defstruct with :id, all declared props, :a11y, :event_rate (plus :children if container: true)
  • @type t and @type option
  • new/2 constructor with prop validation
  • Per-prop setter functions (for pipeline style)
  • with_options/2 for applying keyword options
  • Plushie.Widget.WidgetProtocol implementation
  • Plushie.Widget.Handler behaviour (if view/2 or view/3 defined)
  • __initial_state__/0 - returns the default state map
  • __widget__?/0 - returns true (marker function)
  • __option_keys__/0 - valid option names for compile-time validation
  • __option_types__/0 - nested struct types for do-block syntax

For native widgets, additionally generates:

  • native_crate/0, rust_constructor/0, type_names/0
  • Per-command functions (e.g. set_value/2 for command :set_value)

Handler protocol

Plushie.Widget.Handler

Callbacks

CallbackSignatureRequired?
view/2(id, props) -> treeYes (stateless)
view/3(id, props, state) -> treeYes (stateful)
handle_event/2(event, state) -> resultNo
subscribe/2(props, state) -> [sub]No

If only view/2 is defined (no state), the macro generates a view/3 adapter that ignores the state argument. This means the normalisation pipeline always calls view/3 uniformly.

handle_event/2 return values

All clauses must return one of:

ReturnEffect
{:emit, family, data}Emit event to parent. Original replaced.
{:emit, family, data, new_state}Emit and update state.
{:update_state, new_state}Update state. No event to parent.
:consumedSuppress the event entirely.
:ignoredPass through unchanged. Next handler in scope chain gets it.

Default behaviour when handle_event/2 is not defined:

  • If the widget declares events: :consumed (opaque by default)
  • If no events declared: :ignored (transparent by default)

Widget tiers

TierDeclarationsBehaviour
Statelesswidget, prop, view/2Transparent to events. No state.
Stateful+ state, view/3, handle_event/2Captures and transforms events. Has state.
Full lifecycle+ subscribe/2, eventEvents, state, subscriptions.

The macro detects which tier applies at compile time based on what callbacks and declarations are present.

Event flow

Events walk the scope chain from innermost to outermost:

  1. Event arrives with id and reversed scope list.
  2. Runtime builds handler chain from the scope using the widget handler registry.
  3. Each handler's handle_event/2 is called in order (innermost first).
  4. {:emit, ...} replaces the event and continues to the next handler.
  5. :consumed stops propagation.
  6. :ignored passes the original event to the next handler.
  7. If no handler captures, the event reaches update/2.

Canvas background events use the unified pointer types (:press, :release, :move, :scroll) and are delivered to update/2 like any other event. They are not auto-consumed.

Widget-scoped subscriptions

Widgets can declare their own subscriptions via subscribe/2. These are automatically namespaced per widget instance to prevent collisions with app subscriptions or other widget instances:

@impl Plushie.Widget.Handler
def subscribe(_props, state) do
  if state.animating do
    [Plushie.Subscription.every(16, :animate)]
  end
end

The runtime wraps each subscription tag in a tuple: {:__widget__, window_id, widget_id, inner_tag}. Timer events matching this structure are intercepted and routed through the widget's handle_event/2 instead of reaching update/2. The inner tag (e.g. :animate) is what the widget sees in the event.

Multiple instances of the same widget each get independent subscriptions. If instance A is animating and instance B is not, only A receives timer events.

State management

State lifecycle

Widget state follows tree presence:

  • Appear: widget enters the tree -> state initialised from __initial_state__/0
  • Update: handle_event/2 returns {:update_state, new_state} or {:emit, ..., new_state} -> state updated
  • Re-render: view/3 called with current state on each update cycle
  • Disappear: widget leaves the tree -> state cleaned up

There are no explicit mount or unmount callbacks. If the widget's ID changes between renders, the runtime sees it as a removal and re-creation, resetting state.

State storage

The runtime maintains widget state in the handler registry, keyed by {window_id, scoped_id}. During the render cycle, states are placed in the process dictionary so Plushie.Tree.normalize/1 can look them up when it encounters widget placeholders. New widgets (no stored state) fall back to __initial_state__/0.

Placeholder-to-rendered pipeline

  1. MyWidget.new(id, opts) returns a widget struct.
  2. Plushie.Widget.to_node/1 (via WidgetProtocol) converts to a widget_placeholder node tagged with the module and props.
  3. During Plushie.Tree.normalize/1, placeholders are detected.
  4. State is looked up from the registry or initialised from __initial_state__/0.
  5. view/3 is called with the ID, props, and state.
  6. The rendered output replaces the placeholder and is normalised recursively.
  7. Widget metadata (module, state, event specs) is attached to the final node's :meta field.
  8. The runtime derives the handler registry from the tree for O(1) event dispatch lookups.

Custom widget IDs are transparent to scoping. They do not create scope boundaries. Children rendered by a widget inherit the parent container's scope, not the widget's ID. See Scoped IDs.

Testing custom widgets

Plushie.Test.WidgetCase hosts a single widget in a test harness connected to a real renderer:

defmodule MyApp.GaugeTest do
  use Plushie.Test.WidgetCase, widget: MyApp.Gauge

  setup do
    init_widget("gauge", value: 50, max: 100)
  end

  test "displays initial value" do
    assert_text("#gauge/value", "50%")
  end

  test "emits change event on click" do
    click("#gauge/increment")
    assert last_event().type == {:gauge, :change}
  end
end

init_widget/2 starts the widget with the given ID and props. The harness wraps it in a window and records emitted events.

WidgetCase helpers

HelperPurpose
last_event/0Most recently emitted WidgetEvent, or nil
events/0All emitted events, newest first

All standard test helpers (click/1, find!/1, assert_text/2, model/0, etc.) are also available. See the Testing reference for the full API.

Native widget integration

When you need rendering capabilities beyond what built-in widgets and canvas offer (custom GPU drawing, new input types, performance-critical visuals), build a native widget backed by Rust.

Elixir side

defmodule MyApp.Gauge do
  use Plushie.Widget, :native_widget

  widget :gauge
  prop :value, :number
  rust_crate "path/to/gauge_crate"
  rust_constructor "gauge::new()"
  event :value_changed, data: [value: :number]
  command :set_value, value: :number
end

Rust side

Implement the WidgetExtension trait from plushie_ext::prelude::*:

MethodPhaseRequired?
type_names()RegistrationYes
config_key()RegistrationYes
render()View (immutable)Yes
init()StartupNo
prepare()Pre-view (mutable)No
handle_event()Event dispatchNo
handle_command()Command dispatchNo
cleanup()Node removalNo

Panic isolation

The renderer wraps all mutable extension methods in catch_unwind. Three consecutive render panics auto-poison the extension (red placeholder shown). Poisoned state is cleared on the next full snapshot (triggered by tree changes or renderer restart).

Build system

mix plushie.build auto-detects native widgets via Plushie.Widget.WidgetProtocol consolidation. It generates a Cargo workspace with a main.rs that registers each native widget via its rust_constructor expression. No configuration needed. Add a native widget to your dependencies and it appears in the next build.

See the Mix Tasks reference for build details.

See also