Search documentation of Plushie

# Custom Widgets

Copy Markdown View Source

As the pad has grown, the view function has gotten larger. The file list, event log, and preview pane are each self-contained pieces of UI with their own rendering logic. In this chapter we extract them into custom widgets -- reusable modules that encapsulate UI and behaviour.

Plushie has two kinds of custom widgets: pure Elixir (compose existing widgets or draw custom visuals with canvas and SVG) and native (Rust-backed, for custom GPU rendering). This chapter covers pure Elixir widgets. Native widgets get a brief section at the end, with full details in the Custom Widgets reference.

## Stateless widgets

The simplest custom widget is stateless: it takes props, returns a UI tree, and has no internal state. Events from widgets inside it pass through transparently to the parent app.

    defmodule PlushiePad.LabeledInput do
      use Plushie.Widget

      widget :labeled_input

      field :label, :string
      field :value, :string
      field :placeholder, :string, default: ""

      def view(id, props) do
        import Plushie.UI

        column id: id, spacing: 4 do
          text(props.label, size: 12)
          text_input("input", props.value, placeholder: props.placeholder)
        end
      end
    end

`use Plushie.Widget` generates a struct, a `new/2` constructor, setter functions, and the protocol implementation that connects it to the rendering pipeline.

`widget :labeled_input` declares the widget's type name. This must be unique across your application. It is used for event namespacing and protocol dispatch.

`field` declarations define typed properties with optional defaults. Primitive shortcuts (`:integer`, `:float`, `:string`, `:boolean`, `:atom`, `:any`, `:map`) and domain type modules (`Plushie.Type.Color`, `Plushie.Type.Length`, etc.) are accepted. See `Plushie.Type` for details.

`view/2` receives the widget ID and a props map. It returns a UI tree using the same DSL as `view/1`. Events from widgets inside (like the `text_input`) flow through to the parent app's `update/2` without any special handling.

Use it in a view:

    PlushiePad.LabeledInput.new("email",
      label: "Email",
      value: model.email,
      placeholder: "you@example.com"
    )

### Applying it: extract FileList

The file list sidebar is a good candidate. It takes the files list and active file, renders the sidebar, and events (select, delete) pass through to the pad's `update/2`:

    defmodule PlushiePad.FileList do
      use Plushie.Widget

      widget :file_list
      field :files, :any        # list of filenames
      field :active_file, :any  # currently selected filename

      def view(id, props) do
        import Plushie.UI

        column id: id, width: 200, height: :fill, padding: 8, spacing: 8 do
          text("sidebar-title", "Experiments", size: 14)

          scrollable "file-scroll", height: :fill do
            keyed_column spacing: 2 do
              for file <- props.files do
                container file do
                  row spacing: 4 do
                    button("select", file,
                      width: :fill,
                      style: if(file == props.active_file, do: :primary, else: :text)
                    )
                    button("delete", "x")
                  end
                end
              end
            end
          end
        end
      end
    end

In the pad's view:

    PlushiePad.FileList.new("sidebar",
      files: model.files,
      active_file: model.active_file
    )

The pad's `update/2` still handles the select and delete events via scoped IDs. The widget is transparent to events.

## Stateful widgets

When a widget needs internal state that the parent app should not manage, add `state` declarations and a three-argument `view/3`:

    defmodule PlushiePad.CollapsiblePanel do
      use Plushie.Widget

      widget :collapsible_panel, container: true

      field :title, :string

      state expanded: true

      @impl Plushie.Widget.Handler
      def view(id, props, state) do
        import Plushie.UI

        column id: id, spacing: 4 do
          button("toggle", if(state.expanded, do: "- #{props.title}", else: "+ #{props.title}"))

          if state.expanded do
            container "content", padding: 8 do
              props.children
            end
          end
        end
      end

      @impl Plushie.Widget.Handler
      def handle_event(%Plushie.Event.WidgetEvent{type: :click, id: "toggle"}, state) do
        {:update_state, %{state | expanded: not state.expanded}}
      end

      def handle_event(_event, _state), do: :ignored
    end

`state expanded: true` declares internal state with a default value. The runtime manages this state. It persists across re-renders as long as the widget remains in the tree.

`view/3` receives the ID, props, and current state.

`container: true` on the widget declaration allows the widget to accept children via `props.children`.

## Handling events

The `handle_event/2` callback intercepts events before they reach the parent app. All clauses must return one of:

Return value | Effect
--- | ---
`{:emit, family, data}` | Emit a new event to the parent. Original is replaced.
`{:emit, family, data, new_state}` | Emit to parent and update internal state.
`{:update_state, new_state}` | Update internal state. No event reaches the parent.
`:consumed` | Suppress the event. Nothing reaches the parent.
`:ignored` | Pass through. The event continues to the parent unchanged.

Events walk up the widget scope chain. If a widget returns `:ignored`, the next widget handler in the chain gets a chance. If no handler captures the event, it reaches `update/2`.

### Applying it: extract EventLog

The event log can track its own expanded/collapsed state:

    defmodule PlushiePad.EventLog do
      use Plushie.Widget

      widget :event_log

      field :events, :any  # list of event description strings

      state expanded: true

      @impl Plushie.Widget.Handler
      def view(id, props, state) do
        import Plushie.UI

        column id: id, spacing: 4 do
          row spacing: 8 do
            button("toggle-log", if(state.expanded, do: "Hide Log", else: "Show Log"))
            text("count", "#{length(props.events)} events", size: 12)
          end

          if state.expanded do
            scrollable "log-scroll", height: 120 do
              column spacing: 2, padding: 4 do
                for {entry, i} <- Enum.with_index(props.events) do
                  text("log-#{i}", entry, size: 12, font: :monospace)
                end
              end
            end
          end
        end
      end

      @impl Plushie.Widget.Handler
      def handle_event(%Plushie.Event.WidgetEvent{type: :click, id: "toggle-log"}, state) do
        {:update_state, %{state | expanded: not state.expanded}}
      end

      def handle_event(_event, _state), do: :ignored
    end

The toggle button updates the widget's internal state. All other events (from the log entries, if any were interactive) pass through via `:ignored`.

## Declaring events

When a widget should emit semantic events to the parent, declare them with `event`:

    defmodule PlushiePad.RatingWidget do
      use Plushie.Widget

      widget :rating

      field :value, :float, default: 0

      event :change, value: :float

      @impl Plushie.Widget.Handler
      def view(id, props, _state) do
        import Plushie.UI

        row id: id, spacing: 4 do
          for i <- 1..5 do
            button("star-#{i}", if(i <= props.value, do: "★", else: "☆"))
          end
        end
      end

      @impl Plushie.Widget.Handler
      def handle_event(%Plushie.Event.WidgetEvent{type: :click, id: "star-" <> n}, _state) do
        {:emit, :change, String.to_integer(n)}
      end

      def handle_event(_event, _state), do: :ignored
    end

The `event :change, value: :float` declaration tells the framework that this widget emits `:change` events with a numeric value. In the parent app, these arrive as:

    %WidgetEvent{type: {:rating, :change}, id: "my-rating", value: 3}

Custom widget event types are tuples: `{widget_type, event_name}`. This distinguishes them from built-in events.

For events with multiple fields, use `fields:` instead of `value:`:

    event :change, fields: [hue: :float, saturation: :float, value: :float]

These arrive in `WidgetEvent.value` as an atom-keyed map.

## Widget subscriptions

Widgets can declare their own subscriptions via the optional `subscribe/2` callback:

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

Widget subscriptions are automatically namespaced per instance. Timer events are routed through the widget's `handle_event/2`, not the app's `update/2`. Multiple instances of the same widget each get independent subscriptions.

## Canvas-based widgets

A widget's `view/2` can return a canvas instead of layout widgets:

    defmodule PlushiePad.Gauge do
      use Plushie.Widget

      widget :gauge

      field :value, :float, default: 0
      field :max, :float, default: 100

      @impl Plushie.Widget.Handler
      def view(id, props, _state) do
        import Plushie.UI

        pct = min(props.value / props.max, 1.0)
        angle = pct * 180

        canvas id, width: 120, height: 70 do
          layer "gauge" do
            # Background arc
            path([arc(60, 60, 50, 180, 0)],
              stroke: stroke("#ddd", 8, cap: :round)
            )
            # Value arc
            path([arc(60, 60, 50, 180, 180 + angle)],
              stroke: stroke("#3b82f6", 8, cap: :round)
            )
            # Value text
            text(40, 55, "#{round(pct * 100)}%", fill: "#333", size: 16)
          end
        end
      end
    end

Canvas-based widgets with interactivity combine `handle_event/2` with canvas events (`:press`, `:enter`, `:drag`, etc.) to build rich custom controls like colour pickers, drawing tools, and data visualisations. You can also embed SVG content in canvas layers (as shown in chapter 12). Design your visuals in a vector editor and use them as interactive widget elements.

## The widget lifecycle

Understanding the lifecycle helps when debugging:

1. Your view calls `MyWidget.new(id, opts)`, which returns a widget struct.
2. During tree normalization, the struct is converted to a placeholder node tagged with the widget module and props.
3. The runtime detects the placeholder, looks up stored state (or uses initial defaults for new widgets), and calls `view/3`.
4. The rendered output replaces the placeholder in the final tree.
5. Widget metadata (module, state, event handlers) is accumulated in the `NormalizeCtx` during normalization, not attached to tree nodes.
6. The runtime derives a handler registry from the context for event dispatch.

There are no explicit mount or unmount callbacks. Tree presence is the lifecycle. When a widget appears in the tree, it is "mounted" with initial state. When it disappears, its state is cleaned up. This is why widget IDs must be stable. A changing ID looks like a removal and re-creation.

## Native widgets

When you need rendering capabilities beyond what built-in widgets offer -- custom GPU drawing, new input types, or performance-critical visuals, you can build a native widget backed by Rust:

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

      widget :gauge
      field :value, :float
      rust_crate "path/to/gauge_crate"
      rust_constructor "gauge::new()"
    end

On the Rust side, you implement the `PlushieWidget` trait with `render()`, and optionally `init()`, `prepare()`, `handle_message()`, and `cleanup()`.

`mix plushie.build` auto-detects native widgets via protocol consolidation, generates a Cargo workspace that includes them, and builds the renderer binary with your widgets registered. If you later remove a native widget dependency, run `mix clean` to clear stale BEAM files. Without this, protocol consolidation may still detect the removed widget.

Native widgets are an escape hatch for when pure Elixir composition is not enough. Most apps will never need them. See the Custom Widgets reference for the full Rust integration guide.

## Verify it

Test the EventLog widget using `Plushie.Test.WidgetCase`:

    defmodule PlushiePad.EventLogTest do
      use Plushie.Test.WidgetCase, widget: PlushiePad.EventLog

      setup do
        init_widget("log", events: ["click on btn", "input on name"])
      end

      test "shows event entries" do
        element = find!({:text, "click on btn"})
        assert element.type == "text"
      end
    end

`WidgetCase` hosts a single widget in a test harness. The `init_widget/2` call creates the widget with the given props. This is covered in detail in chapter 15.

## Try it

Build custom widgets in your pad experiments:

- Start with a stateless widget that composes a label and an input. Use it in another experiment.
- Add `state` and `handle_event/2` to make a collapsible section.
- Declare a custom event and match on the `{widget_type, :event_name}` tuple in the parent experiment.
- Build a canvas-based widget: a simple progress ring, a mini sparkline, or a colour swatch.
- Try a widget with `subscribe/2` that animates on a timer.

In the next chapter, we will enhance the pad with state management helpers for undo, search, selection, and navigation.

Next: State Management

← Previous Page Canvas
Next Page → State Management