As apps grow, model management gets complex. Tracking undo history, managing list selection, navigating between views, searching and sorting data. These are recurring patterns that Plushie provides as standalone helper modules.
Each helper is a pure data structure. No processes, no side effects, no
framework coupling. You store them in your model and update them in
update/2. They compose freely: use one, some, or all.
This chapter introduces each helper with an isolated example, then applies it to the pad.
Plushie.Undo
Plushie.Undo tracks reversible actions with an undo/redo stack.
alias Plushie.Undo
undo = Undo.new(%{text: ""})
# Apply a change
undo = Undo.apply(undo, %{
apply: fn state -> %{state | text: "hello"} end,
undo: fn state -> %{state | text: ""} end,
label: "Type hello"
})
Undo.current(undo) # %{text: "hello"}
Undo.can_undo?(undo) # true
undo = Undo.undo(undo)
Undo.current(undo) # %{text: ""}
Undo.can_redo?(undo) # true
undo = Undo.redo(undo)
Undo.current(undo) # %{text: "hello"}Each command is a map with :apply and :undo functions, plus an optional
:label for display.
Coalescing
Rapid sequential changes (like keystrokes) can be grouped into a single undo
step. Add :coalesce and :coalesce_window_ms:
Undo.apply(undo, %{
apply: fn state -> %{state | text: state.text <> "a"} end,
undo: fn state -> %{state | text: String.slice(state.text, 0..-2//1)} end,
coalesce: :typing,
coalesce_window_ms: 500
})Changes with the same :coalesce key within the time window merge into one
undo entry. One Ctrl+Z undoes the entire burst.
Applying it: editor undo/redo
Track editor changes with Undo. Add undo: Undo.new("") to the model:
def update(model, %WidgetEvent{type: :input, id: "editor", value: source}) do
undo = Undo.apply(model.undo, %{
apply: fn _old -> source end,
undo: fn _new -> model.source end,
coalesce: :typing,
coalesce_window_ms: 500
})
%{model | source: source, undo: undo, dirty: true}
end
def update(model, %KeyEvent{key: "z", modifiers: %{command: true, shift: false}}) do
if Undo.can_undo?(model.undo) do
undo = Undo.undo(model.undo)
%{model | undo: undo, source: Undo.current(undo)}
else
model
end
end
def update(model, %KeyEvent{key: "z", modifiers: %{command: true, shift: true}}) do
if Undo.can_redo?(model.undo) do
undo = Undo.redo(model.undo)
%{model | undo: undo, source: Undo.current(undo)}
else
model
end
endSee Plushie.Undo for the full API.
Plushie.Data
Plushie.Data provides a query pipeline for filtering, searching, sorting,
and paginating in-memory collections.
alias Plushie.Data
records = [
%{name: "Alice", role: "dev"},
%{name: "Bob", role: "design"},
%{name: "Carol", role: "dev"}
]
result = Data.query(records,
search: {[:name, :role], "dev"},
sort: {:asc, :name},
page: 1,
page_size: 10
)
result.entries # [%{name: "Alice", ...}, %{name: "Carol", ...}]
result.total # 2The pipeline runs in order: filter -> search -> sort -> paginate -> group. All options are optional.
| Option | Type | Description |
|---|---|---|
:filter | (record -> boolean) | Predicate function |
:search | {[field_keys], query} | Case-insensitive substring match |
:sort | {:asc | :desc, field} or list | Sort by field(s) |
:page | integer | 1-based page number |
:page_size | integer | Records per page (default 25) |
:group | atom | Group results by field |
Applying it: search experiments
Add a search bar to the sidebar that filters the file list:
# In the model
search_query: ""
# In update/2
def update(model, %WidgetEvent{type: :input, id: "search", value: query}) do
%{model | search_query: query}
end
# In the sidebar view
filtered = if model.search_query == "" do
model.files
else
Data.query(
Enum.map(model.files, &%{name: &1}),
search: {[:name], model.search_query}
).entries |> Enum.map(& &1.name)
endSee Plushie.Data for the full API.
Plushie.Selection
Plushie.Selection manages selection state for lists with three modes:
alias Plushie.Selection
sel = Selection.new(mode: :multi)
sel = Selection.select(sel, "file_a")
sel = Selection.toggle(sel, "file_b")
Selection.selected?(sel, "file_a") # true
Selection.selected?(sel, "file_b") # true
Selection.selected(sel) # MapSet.new(["file_a", "file_b"])
sel = Selection.clear(sel)
Selection.selected(sel) # MapSet.new([])Modes:
:single- at most one item selected. Selecting a new item deselects the previous one.:multi- any number of items.toggle/2adds or removes.:range- contiguous selection with an anchor.range_select/2selects everything between the anchor and the target.
Applying it: multi-select experiments
Add selection to the file list for bulk operations. Use a dedicated "select" checkbox alongside each file entry:
# In the model
selection: Selection.new(mode: :multi)
# Toggle selection via checkbox
def update(model, %WidgetEvent{type: :toggle, id: "select", scope: [file | _]}) do
%{model | selection: Selection.toggle(model.selection, file)}
end
# In the file list view, add a checkbox per file:
checkbox("select", Selection.selected?(model.selection, file))
# Visual highlight in the sidebar
style: cond do
file == model.active_file -> :primary
Selection.selected?(model.selection, file) -> :secondary
true -> :text
endAdd a "Delete Selected" button that removes all selected experiments.
See Plushie.Selection for the full API.
Plushie.Route
Plushie.Route manages a navigation stack for multi-view apps:
alias Plushie.Route
route = Route.new(:editor)
Route.current(route) # :editor
route = Route.push(route, :browser, %{sort: :name})
Route.current(route) # :browser
Route.params(route) # %{sort: :name}
Route.can_go_back?(route) # true
route = Route.pop(route)
Route.current(route) # :editorThe stack is LIFO. The root entry never pops. pop/1 on a single-entry
stack returns it unchanged.
Applying it: view switching
Add a browser view to the pad that shows all experiments in a grid with previews:
# In the model
route: Route.new(:editor)
# In update/2
def update(model, %WidgetEvent{type: :click, id: "show-browser"}) do
%{model | route: Route.push(model.route, :browser)}
end
def update(model, %WidgetEvent{type: :click, id: "back-to-editor"}) do
%{model | route: Route.pop(model.route)}
end
# In view/1
case Route.current(model.route) do
:editor -> editor_view(model)
:browser -> browser_view(model)
endSee Plushie.Route for the full API.
Plushie.State
Plushie.State wraps a map with path-based access and revision tracking:
alias Plushie.State
state = State.new(%{settings: %{theme: :dark, font_size: 14}})
State.get(state, [:settings, :theme]) # :dark
State.revision(state) # 0
state = State.put(state, [:settings, :font_size], 16)
State.revision(state) # 1Every mutation increments the revision counter. This is useful for change detection. If the revision has not changed, neither has the data.
Transactions group multiple mutations into a single revision bump:
state = State.begin_transaction(state)
state = State.put(state, [:settings, :theme], :light)
state = State.put(state, [:settings, :font_size], 18)
state = State.commit_transaction(state) # revision increments oncerollback_transaction/1 reverts to the pre-transaction snapshot.
Applying it: pad settings
Store pad settings (theme, font size, auto-save preference) in a State container:
# In the model
settings: State.new(%{theme: :dark, font_size: 14, auto_save: false})
# Update a setting
settings = State.put(model.settings, [:theme], :nord)See Plushie.State for the full API.
Plushie.Animation.Tween
For most property animations, renderer-side transitions (chapter 9)
are simpler and more performant. Plushie.Animation.Tween is for cases
that need frame-by-frame control in Elixir (canvas animations, physics
simulations, or values that drive model logic rather than widget props).
alias Plushie.Animation.Tween
anim = Tween.new(from: 0.0, to: 1.0, duration: 300, easing: :ease_out)
anim = Tween.start(anim, System.monotonic_time(:millisecond))On each frame, advance the animation and read the current value:
anim = Tween.advance(anim, current_timestamp)
Tween.value(anim) # number between 0.0 and 1.0, eased
Tween.finished?(anim) # true when duration has elapsedTween requires a subscription to on_animation_frame for the timestamp
source:
def subscribe(model) do
subs = [...]
if model.animating do
[Plushie.Subscription.on_animation_frame(:frame) | subs]
else
subs
end
end
def update(model, %Plushie.Event.SystemEvent{type: :animation_frame, data: ts}) do
anim = Tween.advance(model.anim, ts)
if Tween.finished?(anim) do
%{model | anim: nil, animating: false, opacity: 1.0}
else
%{model | anim: anim, opacity: Tween.value(anim)}
end
endApplying it: view transitions
Animate the transition between editor and browser views with a fade:
# When switching views, start an opacity animation
anim = Tween.new(from: 0.0, to: 1.0, duration: 200, easing: :ease_out)
anim = Tween.start(anim, System.monotonic_time(:millisecond))
%{model | route: Route.push(model.route, :browser), anim: anim, animating: true}In the view, use Tween.value(model.anim) as the opacity on the container.
See Plushie.Animation.Tween for the full API including spring mode,
redirect, and the complete easing catalogue.
Verify it
Test that undo/redo works on the editor:
test "ctrl+z undoes editor changes" do
type_text("#editor", "new content")
source_after_edit = model().source
press("ctrl+z")
assert model().source != source_after_edit
press("ctrl+shift+z")
assert model().source == source_after_edit
endThis verifies the full undo/redo cycle through the real runtime. Typing creates an undo entry, Ctrl+Z reverts it, Ctrl+Shift+Z restores it.
Try it
Write state management experiments in your pad:
- Build a mini text editor with undo/redo. Show the undo history with
Undo.history/1. - Create a filterable list with
Data.query. Add sort controls that toggle between:ascand:desc. - Build a multi-select list with checkboxes. Show the selection count. Add a "Select All" and "Clear" button.
- Create a two-view app with
Route: a list view and a detail view. UseRoute.params/1to pass the selected item ID.
In the next chapter, we will test the pad and its extracted widgets.
Next: Testing