App behaviour
Plushie::App is the only module an app developer includes. It follows the
Elm architecture: model, update, view.
Callbacks
# Required:
def init(opts) -> model | [model, Command]
def update(model, event) -> model | [model, Command]
def view(model) -> Node
# Optional:
def subscribe(model) -> [Subscription]
def handle_renderer_exit(model, exit_reason) -> model
def window_config(model) -> Hash
def settings -> Hash
init
Returns the initial model, optionally with commands. Called once when the runtime starts.
def init(_opts)
Model.new(
todos: [],
input: "",
filter: :all
)
end
# Or with a command:
def init(_opts)
model = Model.new(todos: [], loading: true)
[model, Command.async(-> { load_todos_from_disk }, :todos_loaded)]
end
The model can be any object, but Plushie::Model.define works best. The
runtime does not inspect or modify the model -- it is fully owned by the
app.
opts is a hash passed through from the runtime start call, so apps can
accept configuration at startup.
update
Receives the current model and an event, returns the next model -- optionally with commands.
def update(model, event)
case event
in Event::Widget[type: :click, id: "add_todo"]
new_todo = {id: SecureRandom.uuid, text: model.input, done: false}
model.with(todos: [new_todo] + model.todos, input: "")
in Event::Widget[type: :input, id: "todo_field", value:]
model.with(input: value)
# Returning commands:
in Event::Widget[type: :submit, id: "todo_field"]
new_todo = {id: SecureRandom.uuid, text: model.input, done: false}
updated = model.with(todos: [new_todo] + model.todos, input: "")
[updated, Command.focus("todo_field")]
else
model
end
end
Return a bare model when no side effects are needed. Return [model, command]
when you need async work, widget operations, window management, or timers.
See commands.md for the full command API.
Events are Data types under Plushie::Event::*. See events.md
for the full event taxonomy. Common families:
Event::Widget[type: :click, id: id]-- button pressEvent::Widget[type: :input, id: id, value: val]-- text input changeEvent::Widget[type: :select, id: id, value: val]-- selection changeEvent::Widget[type: :toggle, id: id, value: val]-- checkbox/toggler changeEvent::Widget[type: :submit, id: id, value: val]-- form field submissionEvent::Key[type: :press, ...]-- keyboard event (via subscription)Event::Key[type: :release, ...]-- keyboard release (via subscription)Event::Window[type: :close_requested, window_id: id]-- window close requestedEvent::Window[type: :resized, window_id: id, width: w, height: h]-- window resizedEvent::Canvas[type: :press, id: id, x: x, y: y, button: btn]-- canvas interactionEvent::Sensor[type: :resize, id: id, width: w, height: h]-- sensor size changeEvent::Pane[type: :clicked, id: id, pane: pane]-- pane grid click
view
Receives the current model, returns a UI tree.
def view(model)
window("main", title: "Todos") do
column(padding: 16, spacing: 8) do
row(spacing: 8) do
text_input("todo_field", model.input, placeholder: "What needs doing?")
("add_todo", "Add")
end
filtered_todos(model).each do |todo|
row(todo[:id], spacing: 8) do
checkbox("toggle", todo[:done])
text(todo[:text])
end
end
end
end
end
The view method is called after every update. It must be a pure function of the model. The runtime diffs the returned tree against the previous one and sends only the changes to the renderer.
UI trees are Node objects. The block-based DSL provides builder methods
for composition, but you can also build nodes directly if preferred.
Lifecycle
Plushie.run(MyApp, opts)
|
v
init(opts) -> [model, commands]
|
v
subscribe(model) -> active subscriptions
|
v
view(model) -> initial tree -> send snapshot to renderer
|
v
[event from renderer / subscription / command result]
|
v
update(model, event) -> [model, commands]
|
v
subscribe(model) -> diff subscriptions (start/stop as needed)
|
v
view(model) -> next tree -> diff -> send patch to renderer
|
v
[repeat from event]
subscribe (optional)
Returns a list of active subscriptions based on the current model. Called
after every update. The runtime diffs the list and starts/stops
subscriptions automatically.
def subscribe(model)
subs = [Subscription.on_key_press(:key_event)]
if model.auto_refresh
[Subscription.every(5000, :refresh)] + subs
else
subs
end
end
Default: [] (no subscriptions). See commands.md for the
full subscription API.
handle_renderer_exit (optional)
Called when the renderer process exits unexpectedly. Return the model to use when the renderer restarts. Default: return model unchanged.
def handle_renderer_exit(model, _reason)
model.with(status: :renderer_restarting)
end
window_config (optional)
Called when windows are opened, including at startup and after renderer restart. Default: single window with app class name as title.
def window_config(_model)
{
title: "My App",
width: 800,
height: 600,
min_size: {width: 400, height: 300},
resizable: true,
theme: :dark
}
end
settings (optional)
Called once at startup to provide application-level settings to the renderer. Returns a hash.
def settings
{
default_font: {family: "monospace"},
default_text_size: 16,
antialiasing: true,
fonts: ["priv/fonts/Inter.ttf"]
}
end
Supported keys:
default_font-- a font specification hash (same format as font props)default_text_size-- a number (pixels)antialiasing-- booleanfonts-- list of font file paths to loadvsync-- boolean (defaulttrue). Controls vertical sync.scale_factor-- number (default1.0). Global UI scale factor applied to all windows.
The runtime also merges extension_config from Plushie.configure
into the Settings wire message. This provides runtime configuration
to native widget extensions without changing your settings callback:
Plushie.configure do |config|
config.extension_config = {
"sparkline" => {"max_samples" => 1000}
}
end
Each extension receives its own section via the InitCtx passed to the
Rust-side init trait method. See Extensions for
details.
To follow the OS light/dark preference automatically, set the window
theme prop to :system. The renderer detects the current OS theme
and applies the matching built-in light or dark theme.
Default: {} (renderer uses its own defaults).
Starting the runtime
# From code:
Plushie.run(MyApp)
Plushie.run(MyApp, name: :my_app, binary: "/path/to/plushie")
# Start without blocking:
pid = Plushie.start(MyApp, name: :my_app)
# From the command line:
bundle exec ruby lib/my_app.rb
Error recovery
The runtime rescues StandardError in both update and view:
- update error: the exception is logged, the previous model is preserved, and the event is discarded. The app continues processing the next event as if nothing happened.
- view error: the exception is logged, and the previous rendered tree stays on screen. The model has already been updated, so the next successful view call will reflect the current state.
After 100 consecutive errors, log output is suppressed with periodic
reminders to avoid flooding. NoMatchingPatternError gets a special
message suggesting an else clause in your case expression.
Extension panics (Rust side) are caught by the renderer's
catch_unwind. The extension is marked "poisoned" and subsequent
renders show a red error placeholder. Other extensions and widgets
continue working. Removing the widget from the tree and re-adding
it clears the poisoned state.
See the crash-lab demo for a working example of all three failure modes and their recovery.
Testing
Apps can be tested without a renderer:
class MyAppTest < Minitest::Test
def test_adding_a_todo
model = MyApp.new.init({})
model = MyApp.new.update(model, Event::Widget.new(type: :input, id: "todo_field", value: "Buy milk"))
model = MyApp.new.update(model, Event::Widget.new(type: :click, id: "add_todo"))
assert_equal "Buy milk", model.todos.first[:text]
assert_equal "", model.input
end
def test_view_renders_todo_list
model = Model.new(todos: [{id: 1, text: "Buy milk", done: false}], input: "", filter: :all)
tree = Plushie::Tree.normalize(MyApp.new.view(model))
assert Plushie::Tree.find(tree, "todo:1")
end
end
Since update is a pure function and view returns plain nodes, no special
test infrastructure is needed. The renderer is not involved.
Configuration
Application-level configuration is set via Plushie.configure or
environment variables:
Plushie.configure do |config|
config.binary_path = "/opt/plushie/bin/plushie"
config.source_path = "~/projects/plushie"
config.build_name = "my-app-plushie"
config.extensions = [MyGauge]
config.extension_config = {"gauge" => {"precision" => 2}}
config.test_backend = :headless
end
| Key | Type | Default | Description |
|---|---|---|---|
binary_path |
String |
nil |
Explicit path to the plushie binary. Overrides all resolution. Equivalent to PLUSHIE_BINARY_PATH env var. |
source_path |
String |
nil |
Path to the plushie Rust source checkout. Used by rake plushie:build. Equivalent to PLUSHIE_SOURCE_PATH env var. |
build_name |
String |
"plushie-custom" |
Custom binary name for extension builds. |
extensions |
Array<Class> |
[] |
Extension classes to include in custom builds. |
extension_config |
Hash |
{} |
Configuration hash passed to widget extensions at runtime via the Settings wire message. |
test_backend |
Symbol |
nil |
Test backend (:mock, :headless, :windowed). Equivalent to PLUSHIE_TEST_BACKEND env var. |
test_format |
:json, :msgpack |
:msgpack |
Wire format for test sessions. Set to :json for easier debugging. |
Multi-window
Plushie supports multiple windows driven declaratively from view. Windows
are nodes in the tree -- if a window node is present, the window is open; if
it disappears, the window closes.
Returning multiple windows
view returns a list of window nodes (or a single window node for
single-window apps):
def view(model)
windows = [
window("main", title: "My App") do
main_content(model)
end
]
if model.inspector_open
inspector = window("inspector", title: "Inspector", size: [400, 600]) do
inspector_panel(model)
end
windows + [inspector]
else
windows
end
end
Single-window apps can return a single window node directly (no array needed). The runtime normalizes both forms internally.
Window identity
Each window node has an id (like all nodes). The renderer uses this ID
to track which OS window corresponds to which tree node:
- New ID appears -- renderer opens a new OS window.
- Existing ID present -- renderer updates that window's content.
- ID disappears -- renderer closes that OS window.
Window IDs must be stable strings. Do not generate random IDs per render or the renderer will close and reopen the window on every update.
Window properties
window("main",
title: "My App",
size: [800, 600],
min_size: [400, 300],
max_size: [1920, 1080],
position: [100, 100],
resizable: true,
closeable: true,
minimizable: true,
decorations: true,
transparent: false,
visible: true,
theme: :dark, # or :system to follow OS preference
level: :normal, # :normal | :always_on_top | :always_on_bottom
scale_factor: 1.5 # per-window UI scale (overrides global setting)
) do
content(model)
end
Properties are set when the window first appears. To change properties after creation, use window commands:
def update(model, event)
case event
in Event::Widget[type: :click, id: "go_fullscreen"]
[model, Command.set_window_mode("main", :fullscreen)]
else
model
end
end
Window events
Window events include the window ID so your app knows which window they came from:
def update(model, event)
case event
in Event::Window[type: :close_requested, window_id: "inspector"]
model.with(inspector_open: false)
in Event::Window[type: :close_requested, window_id: "main"]
if model.unsaved_changes
model.with(confirm_exit: true)
else
[model, Command.close_window("main")]
end
in Event::Window[type: :resized, window_id: "main", width:, height:]
model.with(window_size: [width, height])
in Event::Window[type: :focused, window_id:]
model.with(active_window: window_id)
else
model
end
end
Window close behaviour
By default, when the user clicks the close button on a window, the
renderer sends a Event::Window[type: :close_requested, ...] event instead
of closing immediately. Your app decides what to do:
# Let it close (remove it from view):
in Event::Window[type: :close_requested, window_id: "settings"]
model.with(settings_open: false)
# Block the close:
in Event::Window[type: :close_requested, window_id: "main"]
model.with(show_save_dialog: true)
If close_requested is not handled (falls through to the catch-all), the
window stays open. This prevents accidental closes. To close a window
programmatically, remove it from the tree (return view without it) or
use Command.close_window(id).
Opening windows declaratively
Windows are opened by adding window nodes to the tree returned by
view. There is no open_window command. To open a new window, set a
flag in your model and include the window node conditionally:
def update(model, event)
case event
in Event::Widget[type: :click, id: "open_settings"]
model.with(settings_open: true)
else
model
end
end
def view(model)
windows = [
window("main", title: "My App") do
main_content(model)
end
]
if model.settings_open
settings = window("settings", title: "Settings", size: [500, 400]) do
settings_panel(model)
end
windows + [settings]
else
windows
end
end
Primary window
The first window in the list returned by view is the primary window.
When the primary window is closed, the runtime exits (unless
handle_renderer_exit is overridden to prevent it).
Secondary windows can be opened and closed freely without affecting the runtime lifecycle.
Focus and active window
The renderer tracks which window has OS focus. Window focus/unfocus events are delivered as:
Event::Window[type: :focused, window_id: window_id]
Event::Window[type: :unfocused, window_id: window_id]
The app can use these to adjust behaviour (e.g., pause animations in unfocused windows, track the active window for keyboard shortcuts).
Example: dialog window
def view(model)
main = window("main", title: "App") do
main_content(model)
end
if model.confirm_dialog
dialog = window("confirm", title: "Confirm",
size: [300, 150], resizable: false,
level: :always_on_top) do
column(padding: 16, spacing: 12) do
text("prompt", "Are you sure?")
row(spacing: 8) do
("confirm_yes", "Yes")
("confirm_no", "No")
end
end
end
[main, dialog]
else
main
end
end
How props reach the renderer
Values returned by view go through several transformation stages
before reaching the wire. Understanding this pipeline helps when
debugging unexpected behaviour or writing custom extensions.
Widget builders (DSL block methods,
Plushie::Widget::*modules) returnNodeobjects with raw Ruby values -- symbols, arrays, hashes. No encoding happens here.Plushie::Tree.normalizewalks the tree and encodes each prop value via thePlushie::Encodemodule. Symbols become strings (excepttrue/false/nil), arrays stay as arrays, and custom types encode via theirEncodeimplementation. Scoped IDs are prefixed at this stage.Protocol encoding stringifies symbol keys to string keys, then serializes with JSON or MessagePack to produce wire bytes.
Each stage has a single responsibility. Widget builders don't worry about wire encoding, the Encode module doesn't worry about serialization format, and the Protocol layer doesn't know about widget types.
See running.md for more detail on the encoding pipeline and transport modes.
Renderer limits
The renderer enforces hard limits on various resources. Exceeding them results in rejection, truncation, or clamping (depending on the resource). Design your app to stay within these bounds.
| Resource | Limit | Behavior when exceeded |
|---|---|---|
Font data (load_font) |
16 MiB decoded | Rejected with warning |
| Runtime font loads | 256 per process | Rejected with warning |
| Image handles | 4096 | Error response |
| Total image bytes | 1 GiB | Error response |
| Markdown content | 1 MiB | Truncated at UTF-8 boundary with warning |
| Text editor content | 10 MiB | Truncated at UTF-8 boundary with warning |
| Window size | 1..16384 px | Clamped with warning |
| Window position | -32768..32768 | Clamped with warning |
| Tree depth | 256 levels | Rendering/caching stops descending |
Image and font limits are per-process and survive Reset. Content limits truncate at a UTF-8 character boundary.