Current section
Files
Jump to
Current section
Files
docs/events.md
# Events
Events are structs delivered to your `update/2` callback. Each event family
has its own struct under `Toddy.Event.*`.
```elixir
alias Toddy.Event.{Widget, Key, Mouse, Touch, Ime, Window, Effect, System, Sensor, MouseArea, Canvas, Pane}
```
## Widget events
These are generated by user interaction with widgets. The renderer maps
widget interactions to `%Widget{}` structs using the node's `id`.
### Click
```elixir
%Widget{type: :click, id: "save"}
```
Generated by `button` widgets when pressed.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :click, id: "save"}), do: save(model)
def update(model, %Widget{type: :click, id: "cancel"}), do: revert(model)
```
### Input
```elixir
%Widget{type: :input, id: "search", value: value}
```
Generated by `text_input` on every keystroke (when `on_input` is set,
which is the default).
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :input, id: "search", value: value}) do
%{model | search_query: value}
end
```
### Submit
```elixir
%Widget{type: :submit, id: "search", value: query}
```
Generated by `text_input` when the user presses Enter (when `on_submit`
is set).
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :submit, id: "search", value: query}) do
{%{model | search_query: query}, search_command(query)}
end
```
### Toggle
```elixir
%Widget{type: :toggle, id: "dark_mode", value: enabled}
```
Generated by `checkbox` and `toggler`. `value` is `true` or `false`.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :toggle, id: "dark_mode", value: enabled}) do
%{model | dark_mode: enabled}
end
```
### Select
```elixir
%Widget{type: :select, id: "theme_picker", value: theme}
```
Generated by `pick_list` and `combo_box` when an option is selected.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :select, id: "theme_picker", value: theme}) do
%{model | theme: theme}
end
```
### Slide
```elixir
%Widget{type: :slide, id: "volume", value: value}
%Widget{type: :slide_release, id: "volume", value: value}
```
Generated by `slider` and `vertical_slider`. `slide` fires continuously
during dragging. `slide_release` fires once when the user releases the
slider.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :slide, id: "volume", value: value}) do
%{model | volume: value}
end
def update(model, %Widget{type: :slide_release, id: "volume", value: value}) do
# Persist the final value
{%{model | volume: value}, save_preference(:volume, value)}
end
```
### Text editor content change
```elixir
%Widget{type: :input, id: "notes", value: content}
```
Generated by `text_editor` on content changes. The `value` string
contains the full editor text after each edit. This is the same event
type as `text_input`.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :input, id: "notes", value: content}) do
%{model | notes_content: content}
end
```
### Key binding
```elixir
%Widget{type: :key_binding, id: "editor", value: "save"}
```
Generated by `text_editor` widgets when a declarative key binding rule
with a `"custom"` action matches. The `id` is the text editor's node ID.
The `value` is the custom tag string from the binding rule.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :key_binding, id: "editor", value: "save"}) do
{model, save_file(model)}
end
def update(model, %Widget{type: :key_binding, id: "editor", value: "format"}) do
%{model | content: format_code(model.content)}
end
```
### Scroll
```elixir
%Widget{type: :scroll, id: "log_view", data: %{
absolute_x: float,
absolute_y: float,
relative_x: float,
relative_y: float,
bounds: {width, height},
content_bounds: {width, height}
}}
```
Generated by `scrollable` when the scroll position changes (when
`on_scroll: true` is set). The viewport data includes both absolute pixel
offsets and relative (0.0-1.0) scroll positions, plus the visible bounds
and total content bounds.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :scroll, id: "log_view", data: viewport}) do
at_bottom = viewport.relative_y >= 0.99
%{model | auto_scroll: at_bottom}
end
```
### Paste
```elixir
%Widget{type: :paste, id: "url_input", value: text}
```
Generated by `text_input` when the user pastes text (when `on_paste: true`
is set). The `value` field contains the pasted string.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :paste, id: "url_input", value: text}) do
%{model | url: String.trim(text)}
end
```
### Option hovered
```elixir
%Widget{type: :option_hovered, id: "search", value: value}
```
Generated by `combo_box` when the user hovers over an option in the
dropdown (when `on_option_hovered: true` is set). The `value` is the
hovered option string.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :option_hovered, id: "search", value: value}) do
%{model | preview: value}
end
```
### Open / Close
```elixir
%Widget{type: :open, id: "country_picker"}
%Widget{type: :close, id: "country_picker"}
```
Generated by `pick_list` and `combo_box` when the dropdown menu opens or
closes (when `on_open: true` and/or `on_close: true` are set).
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :open, id: "country_picker"}) do
%{model | picker_open: true}
end
def update(model, %Widget{type: :close, id: "country_picker"}) do
%{model | picker_open: false}
end
```
### Sort
```elixir
%Widget{type: :sort, id: "users", value: column_key}
```
Generated by `table` when a sortable column header is clicked. The
`value` is the string key from the column descriptor.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :sort, id: "users", value: column_key}) do
order = if model.sort_by == column_key, do: flip(model.sort_order), else: "asc"
%{model | sort_by: column_key, sort_order: order}
end
```
### Mouse area events
Mouse area events use the `%MouseArea{}` struct:
```elixir
alias Toddy.Event.MouseArea
%MouseArea{type: :right_press, id: "canvas"}
%MouseArea{type: :enter, id: "tooltip-target"}
%MouseArea{type: :move, id: "drag-zone", x: x, y: y}
%MouseArea{type: :scroll, id: "scroll-zone", delta_x: dx, delta_y: dy}
```
Available types: `:right_press`, `:right_release`, `:middle_press`,
`:middle_release`, `:double_click`, `:enter`, `:exit`, `:move`, `:scroll`.
Each event requires its corresponding boolean prop to be set on the
mouse_area widget. Without the prop, the event is not emitted.
Note: left press/release events from mouse_area are delivered as
`%Widget{type: :click}` events.
```elixir
alias Toddy.Event.MouseArea
def update(model, %MouseArea{type: :enter, id: "hover_zone"}) do
%{model | hovered: true}
end
def update(model, %MouseArea{type: :move, id: "canvas_area", x: x, y: y}) do
%{model | cursor: {x, y}}
end
```
### Canvas events
Generated by `canvas` widgets. Each event is opt-in via a boolean prop on
the canvas node. Canvas events use the `%Canvas{}` struct.
```elixir
alias Toddy.Event.Canvas
%Canvas{type: :press, id: "draw_area", x: x, y: y, button: "left"}
%Canvas{type: :release, id: "draw_area", x: x, y: y, button: "left"}
%Canvas{type: :move, id: "draw_area", x: x, y: y}
%Canvas{type: :scroll, id: "draw_area", x: x, y: y, delta_x: dx, delta_y: dy}
```
The `button` field is a string (`"left"`, `"right"`, `"middle"`). The
`x`/`y` coordinates are relative to the canvas origin.
```elixir
alias Toddy.Event.Canvas
def update(model, %Canvas{type: :press, id: "draw_area", x: x, y: y, button: "left"}) do
%{model | drawing: true, last_point: {x, y}}
end
def update(model, %Canvas{type: :move, id: "draw_area", x: x, y: y}) do
if model.drawing do
%{model | last_point: {x, y}, strokes: [{x, y} | model.strokes]}
else
model
end
end
```
### Sensor events
Generated by `sensor` widgets when the sensor detects a size change.
```elixir
alias Toddy.Event.Sensor
%Sensor{type: :resize, id: "content_area", width: w, height: h}
```
```elixir
alias Toddy.Event.Sensor
def update(model, %Sensor{type: :resize, id: "content_area", width: w, height: h}) do
%{model | content_size: {w, h}}
end
```
### PaneGrid events
Generated by `pane_grid` widgets during pane interactions. Pane events
use the `%Pane{}` struct.
```elixir
alias Toddy.Event.Pane
%Pane{type: :resized, id: "editor", split: split, ratio: ratio}
%Pane{type: :dragged, id: "editor", pane: source, target: target}
%Pane{type: :clicked, id: "editor", pane: pane}
```
```elixir
alias Toddy.Event.Pane
def update(model, %Pane{type: :resized, id: "editor", split: split, ratio: ratio}) do
put_in(model, [:splits, split], ratio)
end
def update(model, %Pane{type: :clicked, id: "editor", pane: pane}) do
%{model | active_pane: pane}
end
```
## Keyboard events
Delivered when keyboard subscriptions are active (see
[commands.md](commands.md)).
```elixir
alias Toddy.Event.Key
%Key{type: :press, key: "s", modifiers: %{command: true}}
%Key{type: :release, key: :escape}
```
### Key struct
```elixir
%Toddy.Event.Key{
type: :press | :release,
key: :enter | "a" | ..., # logical key (named atom or character string)
modified_key: :enter | "A" | ..., # key with modifiers applied (e.g. Shift+a = "A")
physical_key: atom(), # physical key code (layout-independent)
location: :standard | :left | :right | :numpad,
modifiers: %Toddy.KeyModifiers{},
text: String.t() | nil, # text produced by the key press (nil for non-printable)
repeat: boolean(), # true if this is an auto-repeat event
captured: boolean()
}
```
### KeyModifiers struct
```elixir
%Toddy.KeyModifiers{
shift: boolean(),
ctrl: boolean(),
alt: boolean(),
logo: boolean(),
command: boolean() # platform-aware: ctrl on Linux/Windows, logo (Cmd) on macOS
}
```
### Key values
Named keys are atoms:
```elixir
:enter, :escape, :tab, :backspace, :delete,
:arrow_up, :arrow_down, :arrow_left, :arrow_right,
:home, :end, :page_up, :page_down,
:f1, :f2, ... :f12,
:space
```
Character keys are single-character strings:
```elixir
"a", "b", "1", "/", " "
```
### Keyboard event examples
```elixir
alias Toddy.Event.Key
def update(model, %Key{type: :press, key: "s", modifiers: %{command: true}}) do
{model, save_command()}
end
def update(model, %Key{type: :press, key: :escape}) do
%{model | modal_open: false}
end
def update(model, %Key{type: :press, key: "z", modifiers: %{command: true, shift: false}}) do
undo(model)
end
def update(model, %Key{type: :press, key: "z", modifiers: %{command: true, shift: true}}) do
redo(model)
end
# Use physical_key for layout-independent bindings (e.g. WASD on non-QWERTY)
def update(model, %Key{type: :press, physical_key: :key_w}) do
move_up(model)
end
# Use text field for text input handling
def update(model, %Key{type: :press, text: text}) when text != nil do
append_char(model, text)
end
```
## IME events
Delivered when IME subscriptions are active (see
[commands.md](commands.md)). Input Method Editor events support CJK and
other compose-based text input.
```elixir
alias Toddy.Event.Ime
%Ime{type: :opened}
%Ime{type: :preedit, text: text, cursor: {start, end_pos} | nil}
%Ime{type: :commit, text: text}
%Ime{type: :closed}
```
`preedit` fires during composition with the in-progress text and optional
cursor range. `commit` fires when the user finalises a composed string.
```elixir
alias Toddy.Event.Ime
def update(model, %Ime{type: :preedit, text: text}) do
%{model | composing: text}
end
def update(model, %Ime{type: :commit, text: text}) do
%{model | composing: nil, value: model.value <> text}
end
```
## Mouse events (global)
Delivered when mouse subscriptions are active. These are global
(not widget-scoped) events from the windowing system.
```elixir
alias Toddy.Event.Mouse
%Mouse{type: :moved, x: x, y: y}
%Mouse{type: :entered}
%Mouse{type: :left}
%Mouse{type: :button_pressed, button: :left}
%Mouse{type: :button_released, button: :right}
%Mouse{type: :wheel_scrolled, delta_x: dx, delta_y: dy, unit: :line}
```
The `button` field is an atom (`:left`, `:right`, `:middle`, `:back`, `:forward`).
The `unit` field indicates scroll units (`:line` or `:pixel`).
```elixir
alias Toddy.Event.Mouse
def update(model, %Mouse{type: :moved, x: x, y: y}) do
%{model | cursor: {x, y}}
end
def update(model, %Mouse{type: :button_pressed, button: :left}) do
%{model | mouse_down: true}
end
```
## Touch events
Delivered when touch subscriptions are active.
```elixir
alias Toddy.Event.Touch
%Touch{type: :pressed, finger_id: fid, x: x, y: y}
%Touch{type: :moved, finger_id: fid, x: x, y: y}
%Touch{type: :lifted, finger_id: fid, x: x, y: y}
%Touch{type: :lost, finger_id: fid, x: x, y: y}
```
```elixir
alias Toddy.Event.Touch
def update(model, %Touch{type: :pressed, x: x, y: y}) do
%{model | touch_start: {x, y}}
end
```
## Modifier state events
Delivered when modifier key state changes (subscription-driven).
```elixir
alias Toddy.Event.Modifiers
%Modifiers{shift: true, ctrl: false, alt: false, logo: false, command: false}
```
```elixir
def update(model, %Toddy.Event.Modifiers{} = mods) do
%{model | shift_held: mods.shift}
end
```
## Window events
Delivered when window subscriptions are active or for lifecycle events
on windows the app manages.
```elixir
alias Toddy.Event.Window
%Window{type: :close_requested, window_id: "main"}
%Window{type: :opened, window_id: "main", position: {x, y}, width: w, height: h}
%Window{type: :closed, window_id: "main"}
%Window{type: :moved, window_id: "main", x: x, y: y}
%Window{type: :resized, window_id: "main", width: w, height: h}
%Window{type: :focused, window_id: "main"}
%Window{type: :unfocused, window_id: "main"}
%Window{type: :rescaled, window_id: "main", scale_factor: factor}
%Window{type: :file_hovered, window_id: "main", path: path}
%Window{type: :file_dropped, window_id: "main", path: path}
%Window{type: :files_hovered_left, window_id: "main"}
```
### Handling window close
```elixir
alias Toddy.Event.Widget
alias Toddy.Event.Window
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
```
### File drag and drop
```elixir
alias Toddy.Event.Window
def update(model, %Window{type: :file_hovered, window_id: "main", path: path}) do
%{model | drop_target_active: true, hovered_file: path}
end
def update(model, %Window{type: :file_dropped, window_id: "main", path: path}) do
{%{model | drop_target_active: false}, load_file(path)}
end
def update(model, %Window{type: :files_hovered_left, window_id: "main"}) do
%{model | drop_target_active: false}
end
```
## System events
```elixir
alias Toddy.Event.System
%System{type: :animation_frame, data: timestamp}
%System{type: :theme_changed, data: mode}
```
Animation frame events are delivered on each frame when an animation
subscription is active. Theme changed events fire when the OS theme
switches. The `data` field holds the timestamp or the mode string
(`"light"` or `"dark"`).
## Timer events
Delivered by timer subscriptions.
```elixir
alias Toddy.Event.Timer
%Timer{tag: :tick, timestamp: ts}
```
Where `timestamp` is the monotonic time in milliseconds.
## Command result events
Delivered when an async command completes.
```elixir
{event_tag, result}
```
Where `event_tag` is the atom or tuple you passed to
`Toddy.Command.async/2`.
```elixir
alias Toddy.Event.Widget
def update(model, %Widget{type: :click, id: "fetch"}) do
{model, Toddy.Command.async(fn -> HTTP.get!("/api/data") end, :data_loaded)}
end
def update(model, {:data_loaded, %{body: body}}) do
%{model | data: body}
end
```
## Effect result events
Delivered when a renderer effect completes.
```elixir
alias Toddy.Event.Effect
%Effect{request_id: "ef_1234", result: {:ok, path}}
%Effect{request_id: "ef_1234", result: {:error, reason}}
```
The `request_id` is the auto-generated effect ID (e.g. `"ef_1234"`), not
the effect kind name.
```elixir
alias Toddy.Event.Effect
def update(model, %Effect{result: {:ok, path}}) do
{model, load_file(path)}
end
def update(model, %Effect{result: {:error, reason}}) do
%{model | error: reason}
end
```
See [effects.md](effects.md).
### Accessibility action
```elixir
{:a11y_action, id, action_name}
```
Generated when an assistive technology triggers a non-standard action on a
widget. Standard AT actions are mapped to normal events: Click/Default
becomes `%Widget{type: :click, id: id}`, SetValue becomes
`%Widget{type: :input, id: id, value: value}`. This event catches
everything else.
The `action_name` is a string representation of the accesskit action (e.g.
`"ScrollDown"`, `"Increment"`, `"Decrement"`).
```elixir
def update(model, {:a11y_action, "volume", "Increment"}) do
%{model | volume: min(model.volume + 5, 100)}
end
```
Note: This event is only generated when the renderer is built with the
`a11y` feature flag.
## Catch-all
Always include a catch-all clause:
```elixir
def update(model, _event), do: model
```
Unknown events are silently ignored. This is important for forward
compatibility -- new widget types or renderer versions may emit events
your app does not yet handle.
## Pattern matching tips
Events are structs, so all of Elixir's pattern matching works:
```elixir
alias Toddy.Event.Widget
# Match any click
def update(model, %Widget{type: :click, id: id}) do
IO.inspect(id, label: "clicked")
handle_click(model, id)
end
# Match clicks with a prefix
def update(model, %Widget{type: :click, id: "nav:" <> section}) do
%{model | section: String.to_existing_atom(section)}
end
# Match toggle on any "setting:" prefixed checkbox
def update(model, %Widget{type: :toggle, id: "setting:" <> key, value: value}) do
put_in(model, [:settings, String.to_existing_atom(key)], value)
end
```