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
prop :label, :string
prop :value, :string
prop :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
enduse 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.
prop declarations define typed properties with optional defaults. Available
types include :string, :number, :boolean, :color, :length,
:padding, :font, :atom, :map, :any, and others. See
Plushie.Widget for the full list.
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
prop :files, :any # list of filenames
prop :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
endIn 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
prop :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
endstate 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
prop :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
endThe 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
prop :value, :number, default: 0
event :change, value: :number
@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
endThe event :change, value: :number 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 data: instead of value::
event :change, data: [hue: :number, saturation: :number, value: :number]These arrive in WidgetEvent.data 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
endWidget 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
prop :value, :number, default: 0
prop :max, :number, 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 * :math.pi()
canvas id, width: 120, height: 70 do
layer "gauge" do
# Background arc
path([arc(60, 60, 50, :math.pi(), 0)],
stroke: stroke("#ddd", 8, cap: :round)
)
# Value arc
path([arc(60, 60, 50, :math.pi(), :math.pi() + angle)],
stroke: stroke("#3b82f6", 8, cap: :round)
)
# Value text
text(40, 55, "#{round(pct * 100)}%", fill: "#333", size: 16)
end
end
end
endCanvas-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:
- Your view calls
MyWidget.new(id, opts), which returns a widget struct. - During tree normalization, the struct is converted to a placeholder node tagged with the widget module and props.
- The runtime detects the placeholder, looks up stored state (or uses
initial defaults for new widgets), and calls
view/3. - The rendered output replaces the placeholder in the final tree.
- Widget metadata (module, state, event handlers) is attached to the
node's
:metafield. - The runtime derives a handler registry from the tree 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
prop :value, :number
rust_crate "path/to/gauge_crate"
rust_constructor "gauge::new()"
endOn the Rust side, you implement the WidgetExtension trait with render(),
and optionally init(), prepare(), handle_event(), 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.
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
endWidgetCase 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
stateandhandle_event/2to 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/2that 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