The pad saves experiments to the local filesystem, but so far it uses
synchronous file I/O directly in update/2. In this chapter we add
asynchronous commands for background work, platform effects for native
dialogs, and multi-window support. By the end, the pad will have
import/export via file dialogs, clipboard integration, and the ability to
detach an experiment into its own window.
Async commands
Plushie.Command.async/2 runs a function in a separate process and delivers
the result as an event:
alias Plushie.Command
def update(model, %WidgetEvent{type: :click, id: "fetch"}) do
cmd = Command.async(fn ->
# This runs in a Task, not in the runtime process
{:ok, fetch_data_from_api()}
end, :data_loaded)
{%{model | status: :loading}, cmd}
endThe second argument, :data_loaded, is a tag that identifies this task.
The result arrives as a Plushie.Event.AsyncEvent struct with the same tag:
alias Plushie.Event.AsyncEvent
def update(model, %AsyncEvent{tag: :data_loaded, result: {:ok, data}}) do
%{model | status: :done, data: data}
end
def update(model, %AsyncEvent{tag: :data_loaded, result: {:error, reason}}) do
%{model | status: :error, error: inspect(reason)}
endA few things to know about async:
- One task per tag. Starting a new async with the same tag kills the previous one. This prevents stale results from a superseded request.
- Results are nonce-checked. If a task is killed and its result arrives late, the runtime discards it silently.
- The function runs in a linked Task. Exceptions in the function become
{:error, reason}results, wherereasonis the exception struct.
Platform effects
Effects are asynchronous requests to the renderer for platform operations: file dialogs, clipboard access, and notifications. Unlike async commands (which run Elixir code), effects are handled by the renderer binary.
File dialogs
Every effect takes an atom tag as its first argument. The tag identifies the effect in result matching, so there is no need to store request IDs in your model.
alias Plushie.Effect
def update(model, %WidgetEvent{type: :click, id: "import"}) do
{model, Effect.file_open(:import, title: "Import Experiment", filters: [{"Elixir", "*.ex"}])}
endThe result arrives as a Plushie.Event.EffectEvent struct. Match on the tag:
alias Plushie.Event.EffectEvent
def update(model, %EffectEvent{tag: :import, result: {:ok, %{path: path}}}) do
source = File.read!(path)
# ... load the experiment
end
def update(model, %EffectEvent{tag: :import, result: :cancelled}) do
model # user closed the dialog
end
def update(model, %EffectEvent{tag: :import, result: {:error, reason}}) do
%{model | error: inspect(reason)}
endAvailable file dialogs:
Effect.file_open/2- single file selectionEffect.file_open_multiple/2- multiple file selectionEffect.file_save/2- save dialogEffect.directory_select/2- directory selectionEffect.directory_select_multiple/2- multiple directories
Clipboard
# Copy text to clipboard
{model, Effect.clipboard_write(:copy, model.source)}
# Read from clipboard
{model, Effect.clipboard_read(:paste)}
# Result: %EffectEvent{tag: :paste, result: {:ok, %{text: content}}}Also available: clipboard_read_html/1, clipboard_write_html/3,
clipboard_clear/1. On Linux, clipboard_read_primary/1 and
clipboard_write_primary/2 access the middle-click selection buffer.
Notifications
{model, Effect.notification(:exported, "Exported", "Experiment saved to #{path}")}Options include :icon, :timeout, and :urgency (:low, :normal,
:critical).
Effect timeouts
Effects have default timeouts: 120 seconds for file dialogs (the user may
browse for a while), 5 seconds for clipboard and notifications. If the
renderer does not respond in time, you get {:error, :timeout}.
Applying it: import and export
Add import and export buttons to the pad toolbar:
row padding: {4, 8}, spacing: 8 do
button("save", "Save", style: :primary)
button("import", "Import")
button("export", "Export")
checkbox("auto-save", model.auto_save)
text("auto-label", "Auto-save")
endHandle the events. Each effect gets a distinct tag, so matching the result is straightforward:
def update(model, %WidgetEvent{type: :click, id: "import"}) do
{model, Effect.file_open(:import, title: "Import Experiment")}
end
def update(model, %WidgetEvent{type: :click, id: "export"}) do
{model, Effect.file_save(:export, title: "Export Experiment")}
end
def update(model, %EffectEvent{tag: :import, result: {:ok, %{path: path}}}) do
source = File.read!(path)
%{model | source: source}
end
def update(model, %EffectEvent{tag: :export, result: {:ok, %{path: path}}}) do
File.write!(path, model.source)
model
end
def update(model, %EffectEvent{tag: tag, result: :cancelled}) when tag in [:import, :export] do
model
endApplying it: copy code
Add a "Copy" button that copies the current experiment source to the clipboard:
def update(model, %WidgetEvent{type: :click, id: "copy"}) do
{model, Effect.clipboard_write(:copy, model.source)}
endStreaming and cancellation
For long-running work that produces intermediate results:
cmd = Command.stream(fn emit ->
for {line, n} <- Enum.with_index(File.stream!("large.csv"), 1) do
emit.(%{line: n, data: parse(line)})
end
:done
end, :csv_import)Each emit.() call delivers a Plushie.Event.StreamEvent struct. The final
return value arrives as a Plushie.Event.AsyncEvent struct, same as regular async.
To cancel a running async or stream:
{model, Command.cancel(:csv_import)}For one-shot delayed events (not recurring like subscriptions):
{model, Command.send_after(3000, {:clear_status})}See the Commands reference for details.
Batching
Combine multiple commands from a single update:
{model, Command.batch([
Effect.clipboard_write(:copy, model.source),
Effect.notification(:copied, "Copied", "Source copied to clipboard"),
Command.focus("editor")
])}Commands in a batch execute in order.
Multi-window
A Plushie app can have multiple windows. Return a list of window nodes
from view/1:
def view(model) do
windows = [
window "main", title: "Plushie Pad" do
# ... main pad UI
end
]
if model.detached do
windows ++ [
window "experiment", title: "Experiment: #{model.active_file}" do
container "detached-preview", padding: 16 do
model.preview
end
end
]
else
windows
end
endA few important things about multi-window:
Window IDs must be stable strings. If a window ID changes between renders, the renderer closes the old window and opens a new one. Use consistent, predictable IDs.
exit_on_close_request is a per-window prop that controls whether
closing the window triggers app exit. There is no "primary window" concept --
each window independently decides its close behaviour:
window "main", title: "App", exit_on_close_request: true do
# Closing this window exits the app
end
window "settings", title: "Settings", exit_on_close_request: false do
# Closing this window just closes the window
endclose_requested vs closed: When the user clicks the window's close
button, the renderer sends a :close_requested event, not a :closed event.
Your app decides whether to actually close. If exit_on_close_request is
true (the default), the runtime exits. Otherwise, the event arrives in
update/2 and you handle it:
alias Plushie.Event.WindowEvent
def update(model, %WindowEvent{type: :close_requested, window_id: "experiment"}) do
%{model | detached: false}
endDaemon mode keeps the app running after the last window closes. Set
:daemon in Plushie.start_link/2 options. When all windows close, you
receive %SystemEvent{type: :all_windows_closed} in update/2.
Applying it: detach experiment
Add a "Detach" button that opens the experiment in its own window:
def update(model, %WidgetEvent{type: :click, id: "detach"}) do
%{model | detached: true}
end
def update(model, %WindowEvent{type: :close_requested, window_id: "experiment"}) do
%{model | detached: false}
endThe view conditionally adds a second window when model.detached is true.
Closing the experiment window sets the flag back to false, and the experiment
returns to the preview pane.
Error handling patterns
Always handle both success and failure for async and effects:
def update(model, %AsyncEvent{tag: :export, result: {:ok, _}}) do
%{model | status: "Exported"}
end
def update(model, %AsyncEvent{tag: :export, result: {:error, reason}}) do
%{model | error: "Export failed: #{inspect(reason)}"}
end
def update(model, %EffectEvent{tag: _tag, result: :cancelled}) do
model # user cancelled the dialog, not an error
endThe :cancelled result is distinct from {:error, reason}. A user
cancelling a file dialog is expected behaviour, not a failure.
Verify it
Effect stubs let you control what the renderer returns for platform operations. Register a stub before triggering the effect, and the renderer responds with your stub instead of executing the real operation:
test "import loads experiment from file" do
register_effect_stub("file_open", {:ok, %{path: "/tmp/test.ex"}})
click("#import")
# The stub responds with the path; verify the source was loaded
assert model().source != ""
end
test "detach button opens second window" do
click("#detach")
assert model().detached == true
endThe full effect stubbing API is covered in chapter 15.
Try it
Write experiments to try these concepts:
- Build a button that triggers
Command.asyncwith a simulated slow operation (Process.sleep(2000)). Show a loading indicator while the task runs, then display the result. - Try
Command.streamto deliver progress updates. Show a progress bar that fills as chunks arrive. - Try
Command.batchto combine a clipboard write with a notification. - Build a two-window experiment: a main window and a secondary window that opens conditionally. Close the secondary window and see it disappear from the view.
In the next chapter, we will explore the canvas system for custom 2D drawing.
Next: Canvas