Plushie.Command (Plushie v0.7.0)

Copy Markdown View Source

Commands describe side effects that update/2 wants the runtime to perform.

They are plain data: inspectable, testable, serializable. The runtime interprets them after update/2 returns. Nothing executes inside update.

Categories

Result delivery

Commands deliver results back to update/2 through three mechanisms:

  • Async/Stream: async/2 delivers %Plushie.Event.AsyncEvent{tag: tag, result: result}. stream/2 delivers %Plushie.Event.StreamEvent{tag: tag, value: value} for each chunk.
  • Window and system queries: window_size/2, window_mode/2, etc. deliver %Plushie.Event.SystemEvent{} structs through update/2. The type field identifies the query kind, tag holds the stringified event tag, and value holds the result payload. For example, system_theme(:my_tag) delivers %SystemEvent{type: :system_theme, tag: "my_tag", value: "dark"}.
  • Platform effects: Plushie.Effect functions deliver %Plushie.Event.EffectEvent{tag: tag, result: result}. The tag matches the atom you provided when creating the effect command. Timeouts deliver the same struct with result: {:error, :timeout}. See Plushie.Effect.

Usage

def update(model, %Plushie.Event.WidgetEvent{type: :click, id: "save"}) do
  cmd = Plushie.Command.task(fn -> save(model) end, :save_result)
  {model, cmd}
end

def update(model, %Plushie.Event.AsyncEvent{tag: :save_result, result: :ok}), do: %{model | saved: true}

Multiple commands can be issued at once via batch/1:

cmd = Plushie.Command.batch([
  Plushie.Command.focus("name_input"),
  Plushie.Command.send_after(5000, :auto_save)
])

Summary

Types

Tag atom used to identify async results in update/2.

t()

A command to be dispatched by the runtime.

Stable string identifier for a widget node in the UI tree.

Stable string identifier for a window node in the UI tree.

Functions

Standalone DSL for declaring typed command functions.

Advance the animation clock by one frame in headless/test mode.

Sets whether the system can automatically organize windows into tabs.

Triggers a screen reader announcement without a visible widget.

Issue multiple commands. Commands in the batch execute sequentially in list order, with state threaded through each.

Cancel a running async or stream command by its tag.

Wraps an already-resolved value in a command. The runtime immediately dispatches msg_fn.(value) through update/2 without spawning a task.

Exit the application.

Queries which widget currently has focus.

Focus the widget or canvas element identified by widget_id.

Move focus to the next focusable widget.

Move focus to the next focusable widget within the subtree rooted at scope. Only widgets that are descendants of the scope widget are considered; focus wraps at the subtree boundary.

Move focus to the previous focusable widget.

Move focus to the previous focusable widget within the subtree rooted at scope. See focus_next_within/1 for semantics.

Loads a font at runtime from binary data.

A no-op command. Returned implicitly when update/2 returns a bare model.

Close a pane in the pane grid.

Maximize a pane in the pane grid.

Restore all panes from maximized state.

Split a pane in the pane grid along the given axis.

Swap two panes in the pane grid.

Send event through update/2 after delay_ms milliseconds.

Run fun as a streaming async task. The function receives an emit callback that sends intermediate results to update/2 as %Plushie.Event.StreamEvent{tag: event_tag, value: value}. The function's final return value is delivered as %Plushie.Event.AsyncEvent{tag: event_tag, result: result}.

Query system information (OS, CPU, memory, graphics).

Query the current system theme (light/dark mode).

Run fun asynchronously in a Task. When it returns, the runtime dispatches %Plushie.Event.AsyncEvent{tag: event_tag, result: result} through update/2.

Computes a SHA-256 hash of the renderer's current tree state.

Send a batch of widget commands processed atomically in one cycle.

Send a command to a widget by ID.

Types

event_tag()

@type event_tag() :: atom()

Tag atom used to identify async results in update/2.

t()

@type t() :: %Plushie.Command{payload: map(), type: atom()}

A command to be dispatched by the runtime.

Always a %Command{} struct. batch/1 wraps multiple commands into a single struct with type: :batch. The runtime normalizes bare lists internally, but the public type is always a struct.

widget_id()

@type widget_id() :: String.t()

Stable string identifier for a widget node in the UI tree.

window_id()

@type window_id() :: String.t()

Stable string identifier for a window node in the UI tree.

Functions

__using__(opts)

(macro)

Standalone DSL for declaring typed command functions.

Use this in any module to generate command builder functions from typed declarations. The same command macro used in native widgets works here, producing functions that return %Command{} structs.

Example

defmodule Plushie.Command.Text do
  use Plushie.Command

  command :select_all
  command :move_cursor_to, value: :integer
  command :select_range, fields: [start_pos: :integer, end_pos: :integer]
end

Generates:

def select_all(widget_id) when is_binary(widget_id)
def move_cursor_to(widget_id, value) when is_binary(widget_id) and is_integer(value)
def select_range(widget_id, start_pos, end_pos) when is_binary(widget_id) and ...

advance_frame(timestamp)

@spec advance_frame(timestamp :: non_neg_integer()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Advance the animation clock by one frame in headless/test mode.

Sends an advance_frame message to the renderer with the given timestamp (monotonic milliseconds). If on_animation_frame is subscribed, the renderer emits an animation_frame event back.

This is a test/headless-only command. In normal daemon mode the renderer drives animation frames from the display vsync.

Example

Plushie.Command.advance_frame(16)

allow_automatic_tabbing(enabled)

@spec allow_automatic_tabbing(enabled :: boolean()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Sets whether the system can automatically organize windows into tabs.

This is a macOS-specific setting. On other platforms it is a no-op. See: https://developer.apple.com/documentation/appkit/nswindow/1646657-allowsautomaticwindowtabbing

announce(text, politeness \\ :polite)

@spec announce(text :: String.t(), politeness :: :polite | :assertive) ::
  %Plushie.Command{
    payload: term(),
    type: term()
  }

Triggers a screen reader announcement without a visible widget.

The text is announced by assistive technology as a live-region update. The default politeness is :polite which is the correct choice for most toast-style feedback (saves, confirmations, counts). Pass :assertive to interrupt the user's current announcement for urgent context.

Example

Command.announce("File saved successfully")
Command.announce("3 search results found", :polite)
Command.announce("Connection lost", :assertive)

batch(commands)

@spec batch(commands :: t() | [t()]) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Issue multiple commands. Commands in the batch execute sequentially in list order, with state threaded through each.

Accepts a single command, a list of commands, or a nested list; anything List.wrap/1 can normalize.

cancel(event_tag)

@spec cancel(event_tag :: atom()) :: %Plushie.Command{payload: term(), type: term()}

Cancel a running async or stream command by its tag.

If the task has already completed, this is a no-op. The runtime tracks running tasks by their event tag and terminates the associated process.

Example

Command.cancel(:file_import)

clear_images()

See Plushie.Command.Image.clear_images/0.

close_window(window_id)

See Plushie.Command.Window.close_window/1.

create_image(handle, data)

See Plushie.Command.Image.create_image/2.

create_image_rgba(handle, width, height, pixels)

See Plushie.Command.Image.create_image_rgba/4.

delete_image(handle)

See Plushie.Command.Image.delete_image/1.

disable_mouse_passthrough(window_id)

See Plushie.Command.Window.disable_mouse_passthrough/1.

dispatch(value, msg_fn)

@spec dispatch(value :: term(), msg_fn :: (term() -> term())) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Wraps an already-resolved value in a command. The runtime immediately dispatches msg_fn.(value) through update/2 without spawning a task.

Useful for lifting a pure value into the command pipeline.

The mapper receives only the value, not the model. Do not capture the current model in the closure: it will be stale by the time the event is processed. The mapper should produce an event struct that update/2 handles by reading the current model at that point.

drag_resize_window(window_id, direction)

See Plushie.Command.Window.drag_resize_window/2.

drag_window(window_id)

See Plushie.Command.Window.drag_window/1.

enable_mouse_passthrough(window_id)

See Plushie.Command.Window.enable_mouse_passthrough/1.

exit()

@spec exit() :: %Plushie.Command{payload: term(), type: term()}

Exit the application.

find_focused(tag)

@spec find_focused(tag :: atom()) :: %Plushie.Command{payload: term(), type: term()}

Queries which widget currently has focus.

The result arrives in update/2 as %SystemEvent{type: :find_focused, tag: tag, value: %{"focused" => "..." | nil}}.

Note: if no widget is focused, the "focused" field may be nil.

focus(widget_id)

@spec focus(widget_id :: widget_id()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Focus the widget or canvas element identified by widget_id.

Supports scoped paths for canvas elements: "canvas/element" focuses the element within the canvas. Supports window-qualified paths: "main#email" or "main#canvas/element".

focus_next()

@spec focus_next() :: %Plushie.Command{payload: term(), type: term()}

Move focus to the next focusable widget.

focus_next_within(scope)

@spec focus_next_within(scope :: widget_id()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Move focus to the next focusable widget within the subtree rooted at scope. Only widgets that are descendants of the scope widget are considered; focus wraps at the subtree boundary.

Useful for menus, pane grids, and any other keyboard container that wants a bounded Tab cycle without leaking focus to siblings.

Example

Command.focus_next_within("main#menu")

focus_previous()

@spec focus_previous() :: %Plushie.Command{payload: term(), type: term()}

Move focus to the previous focusable widget.

focus_previous_within(scope)

@spec focus_previous_within(scope :: widget_id()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Move focus to the previous focusable widget within the subtree rooted at scope. See focus_next_within/1 for semantics.

focus_window(window_id)

See Plushie.Command.Window.focus_window/1.

is_maximized(window_id, tag)

See Plushie.Command.WindowQuery.is_maximized/2.

is_minimized(window_id, tag)

See Plushie.Command.WindowQuery.is_minimized/2.

list_images(tag)

See Plushie.Command.Image.list_images/1.

load_font(family, data)

@spec load_font(family :: String.t(), data :: binary()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Loads a font at runtime from binary data.

The font data should be the raw bytes of a TrueType (.ttf) or OpenType (.otf) font file. Once loaded, the font can be referenced by family in widget font props.

Example

font_data = File.read!("path/to/CustomFont.ttf")
Plushie.Command.load_font("CustomFont", font_data)

maximize_window(window_id, maximized \\ true)

See Plushie.Command.Window.maximize_window/2.

minimize_window(window_id, minimized \\ true)

See Plushie.Command.Window.minimize_window/2.

monitor_size(window_id, tag)

See Plushie.Command.WindowQuery.monitor_size/2.

move_cursor_to(widget_id, value)

See Plushie.Command.Text.move_cursor_to/2.

move_cursor_to_end(widget_id)

See Plushie.Command.Text.move_cursor_to_end/1.

move_cursor_to_front(widget_id)

See Plushie.Command.Text.move_cursor_to_front/1.

move_window(window_id, x, y)

See Plushie.Command.Window.move_window/3.

none()

@spec none() :: %Plushie.Command{payload: term(), type: term()}

A no-op command. Returned implicitly when update/2 returns a bare model.

pane_close(pane_grid_id, pane_id)

@spec pane_close(pane_grid_id :: widget_id(), pane_id :: String.t()) ::
  %Plushie.Command{
    payload: term(),
    type: term()
  }

Close a pane in the pane grid.

pane_maximize(pane_grid_id, pane_id)

@spec pane_maximize(pane_grid_id :: widget_id(), pane_id :: String.t()) ::
  %Plushie.Command{
    payload: term(),
    type: term()
  }

Maximize a pane in the pane grid.

pane_restore(pane_grid_id)

@spec pane_restore(pane_grid_id :: widget_id()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Restore all panes from maximized state.

pane_split(pane_grid_id, pane_id, axis, new_pane_id)

@spec pane_split(
  pane_grid_id :: widget_id(),
  pane_id :: String.t(),
  axis :: atom() | String.t(),
  new_pane_id :: String.t()
) :: %Plushie.Command{payload: term(), type: term()}

Split a pane in the pane grid along the given axis.

pane_swap(pane_grid_id, pane_a, pane_b)

@spec pane_swap(
  pane_grid_id :: widget_id(),
  pane_a :: String.t(),
  pane_b :: String.t()
) ::
  %Plushie.Command{payload: term(), type: term()}

Swap two panes in the pane grid.

raw_id(window_id, tag)

See Plushie.Command.WindowQuery.raw_id/2.

request_attention(window_id, urgency \\ nil)

See Plushie.Command.Window.request_attention/2.

resize_window(window_id, width, height)

See Plushie.Command.Window.resize_window/3.

scale_factor(window_id, tag)

See Plushie.Command.WindowQuery.scale_factor/2.

screenshot(window_id, tag)

See Plushie.Command.Window.screenshot/2.

scroll_by(widget_id, x, y)

See Plushie.Command.Scroll.scroll_by/3.

scroll_to(widget_id, x, y)

See Plushie.Command.Scroll.scroll_to/3.

select_all(widget_id)

See Plushie.Command.Text.select_all/1.

select_range(widget_id, start_pos, end_pos)

See Plushie.Command.Text.select_range/3.

send_after(delay_ms, event)

@spec send_after(delay_ms :: non_neg_integer(), event :: term()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Send event through update/2 after delay_ms milliseconds.

If a timer with the same event term is already pending, the previous timer is canceled and replaced. This prevents duplicate deliveries when send_after is called repeatedly for the same event.

set_icon(window_id, rgba_data, width, height)

See Plushie.Command.Window.set_icon/4.

set_max_size(window_id, width, height)

See Plushie.Command.Window.set_max_size/3.

set_min_size(window_id, width, height)

See Plushie.Command.Window.set_min_size/3.

set_resizable(window_id, resizable)

See Plushie.Command.Window.set_resizable/2.

set_resize_increments(window_id, width, height)

See Plushie.Command.Window.set_resize_increments/3.

set_window_level(window_id, level)

See Plushie.Command.Window.set_window_level/2.

set_window_mode(window_id, mode)

See Plushie.Command.Window.set_window_mode/2.

show_system_menu(window_id)

See Plushie.Command.Window.show_system_menu/1.

snap_to(widget_id, x, y)

See Plushie.Command.Scroll.snap_to/3.

snap_to_end(widget_id)

See Plushie.Command.Scroll.snap_to_end/1.

stream(fun, event_tag)

@spec stream(fun :: ((term() -> :ok) -> term()), event_tag :: atom()) ::
  %Plushie.Command{
    payload: term(),
    type: term()
  }

Run fun as a streaming async task. The function receives an emit callback that sends intermediate results to update/2 as %Plushie.Event.StreamEvent{tag: event_tag, value: value}. The function's final return value is delivered as %Plushie.Event.AsyncEvent{tag: event_tag, result: result}.

Only one task per tag can be active. If a task with the same tag is already running, it is killed and replaced. Use unique tags if you need concurrent streams.

This is sugar over spawning a process manually. You can achieve the same thing with bare Task and send/2 if you prefer direct Elixir patterns.

Example

Command.stream(fn emit ->
  for chunk <- File.stream!("big.csv") do
    emit.({:chunk, process(chunk)})
  end
  :done
end, :file_import)

system_info(tag)

@spec system_info(tag :: event_tag()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Query system information (OS, CPU, memory, graphics).

The result arrives in update/2 as %Plushie.Event.SystemEvent{type: :system_info, tag: tag, value: info} where tag is the stringified event tag and info is a map with keys: "system_name", "system_kernel", "system_version", "system_short_version", "cpu_brand", "cpu_cores", "memory_total", "memory_used", "graphics_backend", "graphics_adapter".

System info is always available (the sysinfo iced feature is enabled unconditionally).

Example

def update(model, %Plushie.Event.WidgetEvent{type: :click, id: "sys_info"}) do
  {model, Plushie.Command.system_info(:sys_info)}
end

def update(model, %Plushie.Event.SystemEvent{type: :system_info, tag: "sys_info", value: info}) do
  %{model | system: info}
end

system_theme(tag)

@spec system_theme(tag :: event_tag()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Query the current system theme (light/dark mode).

The result arrives in update/2 as %Plushie.Event.SystemEvent{type: :system_theme, tag: tag, value: mode} where tag is the stringified event tag and mode is "light", "dark", or "none" (when no system preference is detected). Returns "none" on Linux systems without a desktop environment. Apps should provide a theme fallback.

Example

def update(model, %Plushie.Event.WidgetEvent{type: :click, id: "check_theme"}) do
  {model, Plushie.Command.system_theme(:theme_result)}
end

def update(model, %Plushie.Event.SystemEvent{type: :system_theme, tag: "theme_result", value: mode}) do
  %{model | theme_mode: mode}
end

task(fun, event_tag)

@spec task(fun :: fun(), event_tag :: atom()) :: %Plushie.Command{
  payload: term(),
  type: term()
}

Run fun asynchronously in a Task. When it returns, the runtime dispatches %Plushie.Event.AsyncEvent{tag: event_tag, result: result} through update/2.

Only one task per tag can be active. If a task with the same tag is already running, it is killed and replaced. Use unique tags if you need concurrent tasks.

toggle_decorations(window_id)

See Plushie.Command.Window.toggle_decorations/1.

toggle_maximize(window_id)

See Plushie.Command.Window.toggle_maximize/1.

tree_hash(tag)

@spec tree_hash(tag :: atom()) :: %Plushie.Command{payload: term(), type: term()}

Computes a SHA-256 hash of the renderer's current tree state.

The result arrives in update/2 as %SystemEvent{type: :tree_hash, tag: tag, value: %{"hash" => "..."}}.

update_image(handle, data)

See Plushie.Command.Image.update_image/2.

update_image_rgba(handle, width, height, pixels)

See Plushie.Command.Image.update_image_rgba/4.

widget_batch(commands)

@spec widget_batch(commands :: [{String.t(), String.t(), term()}]) ::
  %Plushie.Command{
    payload: term(),
    type: term()
  }

Send a batch of widget commands processed atomically in one cycle.

Each command in the list is a {id, family, value} tuple. All commands are applied before any resulting events are emitted.

widget_command(id, family, value \\ nil)

@spec widget_command(id :: String.t(), family :: String.t(), value :: term()) ::
  %Plushie.Command{
    payload: term(),
    type: term()
  }

Send a command to a widget by ID.

Commands use the unified wire format matching events:

{"type": "command", "id": "gauge", "family": "set_value", "value": 72.0}

The value defaults to nil for commands with no payload (e.g. reset). The family string identifies the operation. For native widgets, it maps to the Rust widget's handle_widget_op dispatch. For built-in widgets, the renderer handles it directly.

window_mode(window_id, tag)

See Plushie.Command.WindowQuery.window_mode/2.

window_position(window_id, tag)

See Plushie.Command.WindowQuery.window_position/2.

window_size(window_id, tag)

See Plushie.Command.WindowQuery.window_size/2.