Commands and Effects

Copy Markdown View Source

Commands are pure data returned from update/2 and init/1. The runtime executes them after the update cycle completes. They are how your app triggers side effects: background work, focus changes, window operations, platform effects, and more.

See Plushie.Command for full API docs and Plushie.Effect for platform effect functions.

Returning commands

update/2 and init/1 support three return forms:

# Bare model (no commands)
def update(model, _event), do: model

# Model + single command
def update(model, %WidgetEvent{type: :click, id: "save"}) do
  {model, Command.focus("editor")}
end

# Model + command list
def update(model, %WidgetEvent{type: :click, id: "export"}) do
  {model, [
    Effect.file_save(:export, title: "Export"),
    Effect.notification(:notify, "Exporting", "Saving to file...")
  ]}
end

Invalid return shapes (e.g. {model, :ok} or 3-element tuples) raise ArgumentError immediately. See App Lifecycle for details.

Command categories

All functions live in Plushie.Command unless noted otherwise.

Control flow

FunctionPurpose
none/0No-op command (useful in conditional pipelines)
done/2Lift a value into the command pipeline. Calls mapper.(value) and queues the result for update/2 in the next message cycle, no task spawned.
batch/1Execute a list of commands sequentially
exit/0Shut down the app

batch/1 executes commands in order via Enum.reduce. Each command threads through the runtime state sequentially. Use it to combine multiple side effects from a single update/2 clause.

Async

FunctionPurpose
async/2Run a function in a background Task. Result delivered as %AsyncEvent{tag: tag, result: result}.
stream/2Run a function with an emit callback. Each emit.(value) delivers %StreamEvent{tag: tag, value: value}. Final return delivered as %AsyncEvent{}.
cancel/1Kill an in-flight async or stream task by tag.
send_after/2One-shot delayed event. Sends event through update/2 after delay_ms milliseconds. If a timer with the same event is already pending, the old one is cancelled.

send_after/2 is a one-shot timer (fires once). For recurring timers, use Plushie.Subscription.every/2 instead.

Focus

FunctionPurpose
focus/1Set focus on a widget by scoped ID path
focus_element/2Set focus on a canvas element within a canvas
focus_next/0Move focus to the next focusable widget
focus_previous/0Move focus to the previous focusable widget

Text

FunctionPurpose
select_all/1Select all text in a text input/editor
move_cursor_to_front/1Move cursor to start
move_cursor_to_end/1Move cursor to end
move_cursor_to/2Move cursor to a specific position
select_range/3Select a text range

Scroll

FunctionPurpose
scroll_to/2Scroll to absolute position
snap_to/3Snap scroll to a position
snap_to_end/1Snap scroll to the end
scroll_by/3Scroll by a relative offset

Window operations

FunctionPurpose
close_window/1Close a window
resize_window/3Set window size
move_window/3Set window position
maximize_window/2Maximize/unmaximize
minimize_window/2Minimize/unminimize
set_window_mode/2Set fullscreen/windowed mode
toggle_maximize/1Toggle maximized state
toggle_decorations/1Toggle window decorations
gain_focus/1Bring window to front
set_window_level/2Set window z-level
drag_window/1Begin window drag
drag_resize_window/2Begin window resize drag
screenshot/2Capture window screenshot

Screenshot results arrive as a raw {:screenshot_response, data} tuple in update/2 (not a SystemEvent). The data map contains name, hash, width, height, and optionally rgba binary image data.

Window queries

FunctionPurpose
get_window_size/2Query window dimensions
get_window_position/2Query window position
is_maximized/2Query maximized state
is_minimized/2Query minimized state
get_mode/2Query fullscreen/windowed mode
get_scale_factor/2Query DPI scale factor
raw_id/2Query platform window handle
monitor_size/2Query monitor dimensions

Results arrive as %SystemEvent{type: type, tag: tag, data: data}. The tag matches the atom you provided. Data maps use atom keys: %{width: 800, height: 600}.

System

FunctionPurpose
get_system_theme/1Query OS light/dark preference
get_system_info/1Query system information
allow_automatic_tabbing/1macOS automatic tab management
announce/1Screen reader announcement

announce/1 triggers a live-region assertion for assistive technology. The text is immediately spoken by the screen reader without requiring a visible widget. Use for status updates, error notifications, and dynamic content.

Other

FunctionPurpose
load_font/1Load a font from binary data at runtime
tree_hash/1Query structural hash of the UI tree
find_focused/1Query which widget has focus
advance_frame/1Manual animation frame advance (test/headless mode)
widget_command/3Command to a native Rust widget
pane_split/4Split a pane in a pane grid
pane_close/2Close a pane

Platform effects

All functions live in Plushie.Effect. Each takes an atom tag as its first argument and returns a Plushie.Command struct. Results arrive as %Plushie.Event.EffectEvent{tag: tag, result: result} in update/2.

File dialogs

FunctionPurpose
file_open/2Single file picker
file_open_multiple/2Multi-file picker
file_save/2Save dialog
directory_select/2Single directory picker
directory_select_multiple/2Multi-directory picker
{model, Effect.file_open(:import, title: "Import", filters: [{"Elixir", "*.ex"}])}

def update(model, %EffectEvent{tag: :import, result: {:ok, %{path: path}}}), do: ...
def update(model, %EffectEvent{tag: :import, result: :cancelled}), do: model

Clipboard

FunctionPurpose
clipboard_read/1Read plain text
clipboard_write/2Write plain text
clipboard_read_html/1Read HTML content
clipboard_write_html/3Write HTML content (with optional alt text)
clipboard_clear/1Clear clipboard
clipboard_read_primary/1Read primary selection (Linux)
clipboard_write_primary/2Write primary selection (Linux)

Notifications

{model, Effect.notification(:saved, "Exported", "File saved to #{path}")}

Options: :icon, :timeout (auto-dismiss ms), :urgency (:low, :normal, :critical), :sound.

Async mechanics

  • One task per tag. Starting async/2 or stream/2 with a tag that is already in-flight kills the previous task. Use unique tags for concurrent work.

  • Nonce-based stale rejection. Each task gets a nonce at creation. Results from killed tasks carry a stale nonce and are silently discarded.

  • Crashes become errors. If the task process crashes (exception, exit), the result is {:error, {:crashed, reason}}.

  • Renderer restarts. In-flight async tasks survive a renderer restart (they run in the BEAM, not the renderer). Results may be stale if the work depended on renderer state.

Streaming

cmd = Command.stream(fn emit ->
  for chunk <- fetch_chunks() do
    emit.(%{progress: chunk.index, data: chunk.data})
  end
  :done
end, :import)

Each emit.() delivers a %StreamEvent{tag: :import, value: value}. The function's final return value is delivered directly as %AsyncEvent{tag: :import, result: :done}. Wrap in {:ok, ...} yourself if you want to follow the async convention.

Effect lifecycle

  • Tag-based matching. Every effect takes an atom tag. The tag returns in %EffectEvent{tag: tag} for direct pattern matching.

  • One effect per tag. Starting a new effect with a tag that has a pending request discards the previous one.

  • Default timeouts: file dialogs 120s, clipboard/notifications 5s. Override with :timeout option.

  • Timeout delivery. result: {:error, :timeout}.

  • Cancellation. User dismissing a dialog delivers result: :cancelled (not an error).

  • Effect stubs. register_effect_stub(:file_open, {:ok, %{path: "..."}}) intercepts effects by kind in tests. See the Testing reference.

DIY patterns

The runtime is a GenServer. You can bypass the command system and send messages directly:

def update(model, %WidgetEvent{type: :click, id: "fetch"}) do
  pid = self()
  spawn(fn ->
    result = MyApp.HTTP.get!("/api/data")
    send(pid, {:fetched, result})
  end)
  model
end

def update(model, {:fetched, result}) do
  %{model | data: result}
end

This is useful for integrating with existing OTP infrastructure -- supervisors, GenServers, Phoenix PubSub. Messages arrive as events in the next update cycle. The tradeoff: you lose tag-based cancellation and stale-result rejection that async/2 provides.

See also