Packages

Native desktop GUIs from Elixir, powered by iced

Retired package: Renamed - Package has been renamed to plushie: hex.pm/packages/plushie

Current section

Files

Jump to
toddy docs app-behaviour.md
Raw

docs/app-behaviour.md

# App behaviour
`Toddy.App` is the only behaviour an app developer implements. It follows the
Elm architecture: model, update, view.
## Callbacks
```elixir
@callback init(opts :: keyword()) :: model | {model, Toddy.Command.t()}
@callback update(model, event) :: model | {model, Toddy.Command.t()}
@callback view(model) :: Toddy.Iced.ui_node()
# Optional:
@callback subscribe(model) :: [Toddy.Subscription.t()]
@callback handle_renderer_exit(model, exit_reason) :: model
@callback window_config(model) :: map()
@callback settings() :: keyword()
```
### init/1
Returns the initial model, optionally with commands. Called once when the
runtime starts.
```elixir
def init(_opts) do
%{
todos: [],
input: "",
filter: :all
}
end
# Or with a command:
def init(_opts) do
model = %{todos: [], loading: true}
{model, Toddy.Command.async(fn -> load_todos_from_disk() end, :todos_loaded)}
end
```
The model can be any term, but plain maps work best. The runtime does not
inspect or modify the model -- it is fully owned by the app.
`opts` is a keyword list passed through from the runtime start call, so
apps can accept configuration at startup.
### update/2
Receives the current model and an event, returns the next model -- optionally
with commands.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :click, id: "add_todo"}) do
new_todo = %{id: System.unique_integer(), text: model.input, done: false}
%{model | todos: [new_todo | model.todos], input: ""}
end
def update(model, %Widget{type: :input, id: "todo_field", value: value}) do
%{model | input: value}
end
# Returning commands:
def update(model, %Widget{type: :submit, id: "todo_field"}) do
new_todo = %{id: System.unique_integer(), text: model.input, done: false}
model = %{model | todos: [new_todo | model.todos], input: ""}
{model, Toddy.Command.focus("todo_field")}
end
def update(model, _event), do: model
```
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](commands.md) for the full command API.
Events are structs under `Toddy.Event.*`. See [events.md](events.md) for
the full event taxonomy. Common families:
- `%Widget{type: :click, id: id}` -- button press
- `%Widget{type: :input, id: id, value: val}` -- text input change
- `%Widget{type: :select, id: id, value: val}` -- selection change
- `%Widget{type: :toggle, id: id, value: val}` -- checkbox/toggler change
- `%Widget{type: :submit, id: id, value: val}` -- form field submission
- `%Key{type: :press, ...}` -- keyboard event (via subscription)
- `%Key{type: :release, ...}` -- keyboard release (via subscription)
- `%Window{type: :close_requested, window_id: id}` -- window close requested
- `%Window{type: :resized, window_id: id, width: w, height: h}` -- window resized
- `%Canvas{type: :press, id: id, x: x, y: y, button: btn}` -- canvas interaction
- `%Sensor{type: :resize, id: id, width: w, height: h}` -- sensor size change
- `%Pane{type: :clicked, id: id, pane: pane}` -- pane grid click
### view/1
Receives the current model, returns a UI tree.
```elixir
def view(model) do
import Toddy.UI
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?")
button("add_todo", "Add")
end
for todo <- filtered_todos(model) do
row todo.id, spacing: 8 do
checkbox("toggle", todo.done)
text(todo.text)
end
end
end
end
end
```
The view function 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 plain maps. The `Toddy.UI`
module provides builder functions and a `do` block syntax for composition,
but you can also build maps directly if preferred.
## Lifecycle
```
start_runtime(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/1 (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.
```elixir
def subscribe(model) do
subs = [Toddy.Subscription.on_key_press(:key_event)]
if model.auto_refresh do
[Toddy.Subscription.every(5000, :refresh) | subs]
else
subs
end
end
```
Default: `[]` (no subscriptions). See [commands.md](commands.md) for the
full subscription API.
### handle_renderer_exit/2 (optional)
Called when the renderer process exits unexpectedly. Return the model to
use when the renderer restarts. Default: return model unchanged.
```elixir
def handle_renderer_exit(model, _reason) do
%{model | status: :renderer_restarting}
end
```
### window_config/1 (optional)
Called when windows are opened, including at startup and after renderer
restart. Default: single window with app module name as title.
```elixir
def window_config(_model) do
%{
title: "My App",
width: 800,
height: 600,
min_size: %{width: 400, height: 300},
resizable: true,
theme: :dark
}
end
```
### settings/0 (optional)
Called once at startup to provide application-level settings to the
renderer. Returns a keyword list.
```elixir
def settings do
[
default_font: %{family: "monospace"},
default_text_size: 16,
antialiasing: true,
fonts: ["priv/fonts/Inter.ttf"]
]
end
```
Supported keys:
- `default_font` -- a font specification map (same format as font props)
- `default_text_size` -- a number (pixels)
- `antialiasing` -- boolean
- `fonts` -- list of font file paths to load
- `vsync` -- boolean (default `true`). Controls vertical sync.
- `scale_factor` -- number (default `1.0`). Global UI scale factor applied
to all windows.
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
```elixir
# From IEx or application code:
{:ok, pid} = Toddy.start_link(MyApp)
{:ok, pid} = Toddy.start_link(MyApp, name: :my_app, binary: "/path/to/toddy")
# Under a supervisor:
children = [
{Toddy, app: MyApp, name: :my_app}
]
# From mix:
# mix toddy.gui MyApp
# mix toddy.gui MyApp --release
```
## Testing
Apps can be tested without a renderer:
```elixir
test "adding a todo" do
model = MyApp.init([])
model = MyApp.update(model, %Widget{type: :input, id: "todo_field", value: "Buy milk"})
model = MyApp.update(model, %Widget{type: :click, id: "add_todo"})
assert [%{text: "Buy milk"}] = model.todos
assert model.input == ""
end
test "view renders todo list" do
model = %{todos: [%{id: 1, text: "Buy milk", done: false}], input: "", filter: :all}
tree = MyApp.view(model)
assert Toddy.Tree.find(tree, "todo:1")
end
```
Since `update` is a pure function and `view` returns plain maps, no special
test infrastructure is needed. The renderer is not involved.
## Configuration
Application-level configuration is set via `config :toddy, key, value` in
your `config.exs` (or per-environment config files).
| Key | Type | Default | Description |
|---|---|---|---|
| `:test_backend` | `:pooled_mock \| :headless \| :windowed` | `:pooled_mock` | Test backend used by `Toddy.Test.Case`. Override per-run with `TODDY_TEST_BACKEND` env var. |
| `:test_format` | `:json \| :msgpack` | `:msgpack` | Wire format for test sessions. Set to `:json` for easier debugging. |
| `:extension_config` | `map()` | `%{}` | Configuration map passed to widget extensions at runtime. |
## Multi-window
Toddy supports multiple windows driven declaratively from `view/1`. 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/1` returns a list of window nodes (or a single window node for
single-window apps):
```elixir
def view(model) do
import Toddy.UI
windows = [
window "main", title: "My App" do
main_content(model)
end
]
if model.inspector_open do
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 list
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
```elixir
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:
```elixir
def update(model, %Widget{type: :click, id: "go_fullscreen"}) do
{model, Toddy.Command.set_window_mode("main", :fullscreen)}
end
```
### Window events
Window events include the window ID so your app knows which window they
came from:
```elixir
def update(model, %Window{type: :close_requested, window_id: "inspector"}) do
%{model | inspector_open: false}
end
def update(model, %Window{type: :close_requested, window_id: "main"}) do
if model.unsaved_changes do
%{model | confirm_exit: true}
else
{model, Toddy.Command.close_window("main")}
end
end
def update(model, %Window{type: :resized, window_id: "main", width: width, height: height}) do
%{model | window_size: {width, height}}
end
def update(model, %Window{type: :focused, window_id: window_id}) do
%{model | active_window: window_id}
end
```
### Window close behaviour
By default, when the user clicks the close button on a window, the
renderer sends a `%Window{type: :close_requested, window_id: window_id}` event instead
of closing immediately. Your app decides what to do:
```elixir
# Let it close (remove it from view):
def update(model, %Window{type: :close_requested, window_id: "settings"}) do
%{model | settings_open: false}
end
# Block the close:
def update(model, %Window{type: :close_requested, window_id: "main"}) do
%{model | show_save_dialog: true}
end
```
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/1` without it) or
use `Toddy.Command.close_window(id)`.
### Opening windows declaratively
Windows are opened by adding window nodes to the tree returned by
`view/1`. There is no `open_window` command. To open a new window, set a
flag in your model and include the window node conditionally:
```elixir
def update(model, %Widget{type: :click, id: "open_settings"}) do
%{model | settings_open: true}
end
def view(model) do
import Toddy.UI
windows = [
window "main", title: "My App" do
main_content(model)
end
]
if model.settings_open do
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/1` is the primary window.
When the primary window is closed, the runtime exits (unless
`handle_renderer_exit/2` 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:
```elixir
%Window{type: :focused, window_id: window_id}
%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
```elixir
def view(model) do
import Toddy.UI
main = window "main", title: "App" do
main_content(model)
end
if model.confirm_dialog do
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
button("confirm_yes", "Yes")
button("confirm_no", "No")
end
end
end
[main, dialog]
else
main
end
end
```