Events
Events are constructors of the Event union type, delivered to your update
function. All events live in the plushie/event module.
import plushie/event.{
type Event, WidgetClick, WidgetInput, WidgetSubmit, WidgetToggle,
WidgetSelect, WidgetSlide, KeyPress, KeyRelease, MouseMoved,
TouchPressed, ImeCommit, WindowCloseRequested, WindowResized,
EffectResponse, SensorResize, MouseAreaEnter, CanvasPress,
PaneClicked,
}
Widget events
These are generated by user interaction with widgets. The renderer maps
widget interactions to specific Event constructors using the node’s id.
Widget events carry an id (the widget’s local ID after scope splitting)
and a scope (list of ancestor container IDs, nearest first). For example,
a button “save” inside container “form” produces
WidgetClick(id: "save", scope: ["form"]).
Click
WidgetClick(id: "save", scope: [])
Generated by button widgets when pressed.
fn update(model: Model, event: Event) {
case event {
WidgetClick(id: "save", ..) -> save(model)
WidgetClick(id: "cancel", ..) -> revert(model)
_ -> #(model, command.none())
}
}
Input
WidgetInput(id: "search", scope: [], value: value)
Generated by text_input on every keystroke (when on_input is set,
which is the default).
case event {
WidgetInput(id: "search", value:, ..) ->
#(Model(..model, search_query: value), command.none())
_ -> #(model, command.none())
}
Submit
WidgetSubmit(id: "search", scope: [], value: query)
Generated by text_input when the user presses Enter (when on_submit
is set).
case event {
WidgetSubmit(id: "search", value: query, ..) ->
#(Model(..model, search_query: query), search_command(query))
_ -> #(model, command.none())
}
Toggle
WidgetToggle(id: "dark_mode", scope: [], value: enabled)
Generated by checkbox and toggler. value is True or False.
case event {
WidgetToggle(id: "dark_mode", value: enabled, ..) ->
#(Model(..model, dark_mode: enabled), command.none())
_ -> #(model, command.none())
}
Select
WidgetSelect(id: "theme_picker", scope: [], value: theme)
Generated by pick_list and combo_box when an option is selected.
case event {
WidgetSelect(id: "theme_picker", value: theme, ..) ->
#(Model(..model, theme:), command.none())
_ -> #(model, command.none())
}
Slide
WidgetSlide(id: "volume", scope: [], value: value)
WidgetSlideRelease(id: "volume", scope: [], value: value)
Generated by slider and vertical_slider. WidgetSlide fires continuously
during dragging. WidgetSlideRelease fires once when the user releases the
slider.
case event {
WidgetSlide(id: "volume", value:, ..) ->
#(Model(..model, volume: value), command.none())
WidgetSlideRelease(id: "volume", value:, ..) ->
// Persist the final value
#(Model(..model, volume: value), save_preference("volume", value))
_ -> #(model, command.none())
}
Text editor content change
WidgetInput(id: "notes", scope: [], 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
constructor as text_input.
case event {
WidgetInput(id: "notes", value: content, ..) ->
#(Model(..model, notes_content: content), command.none())
_ -> #(model, command.none())
}
Key binding
WidgetKeyBinding(id: "editor", scope: [], 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.
case event {
WidgetKeyBinding(id: "editor", value: "save", ..) ->
#(model, save_file(model))
WidgetKeyBinding(id: "editor", value: "format", ..) ->
#(Model(..model, content: format_code(model.content)), command.none())
_ -> #(model, command.none())
}
Scroll
WidgetScroll(id: "log_view", scope: [], data: ScrollData(
absolute_x: 0.0,
absolute_y: 150.0,
relative_x: 0.0,
relative_y: 0.75,
bounds_width: 400.0,
bounds_height: 300.0,
content_width: 400.0,
content_height: 600.0,
))
Generated by scrollable when the scroll position changes (when
on_scroll: True is set). The ScrollData record includes both absolute
pixel offsets and relative (0.0-1.0) scroll positions, plus the visible
bounds and total content bounds.
case event {
WidgetScroll(id: "log_view", data: viewport, ..) -> {
let at_bottom = viewport.relative_y >=. 0.99
#(Model(..model, auto_scroll: at_bottom), command.none())
}
_ -> #(model, command.none())
}
Paste
WidgetPaste(id: "url_input", scope: [], value: text)
Generated by text_input when the user pastes text (when on_paste: True
is set). The value field contains the pasted string.
case event {
WidgetPaste(id: "url_input", value: text, ..) ->
#(Model(..model, url: string.trim(text)), command.none())
_ -> #(model, command.none())
}
Option hovered
WidgetOptionHovered(id: "search", scope: [], 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.
case event {
WidgetOptionHovered(id: "search", value:, ..) ->
#(Model(..model, preview: value), command.none())
_ -> #(model, command.none())
}
Open / Close
WidgetOpen(id: "country_picker", scope: [])
WidgetClose(id: "country_picker", scope: [])
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).
case event {
WidgetOpen(id: "country_picker", ..) ->
#(Model(..model, picker_open: True), command.none())
WidgetClose(id: "country_picker", ..) ->
#(Model(..model, picker_open: False), command.none())
_ -> #(model, command.none())
}
Sort
WidgetSort(id: "users", scope: [], value: column_key)
Generated by table when a sortable column header is clicked. The
value is the string key from the column descriptor.
case event {
WidgetSort(id: "users", value: column_key, ..) -> {
let order = case model.sort_by == column_key {
True -> flip(model.sort_order)
False -> "asc"
}
#(Model(..model, sort_by: column_key, sort_order: order), command.none())
}
_ -> #(model, command.none())
}
Mouse area events
Mouse area events have their own constructors:
MouseAreaRightPress(id: "canvas", scope: [])
MouseAreaEnter(id: "tooltip-target", scope: [])
MouseAreaMove(id: "drag-zone", scope: [], x: x, y: y)
MouseAreaScroll(id: "scroll-zone", scope: [], delta_x: dx, delta_y: dy)
Available constructors: MouseAreaRightPress, MouseAreaRightRelease,
MouseAreaMiddlePress, MouseAreaMiddleRelease, MouseAreaDoubleClick,
MouseAreaEnter, MouseAreaExit, MouseAreaMove, MouseAreaScroll.
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
WidgetClick events.
case event {
MouseAreaEnter(id: "hover_zone", ..) ->
#(Model(..model, hovered: True), command.none())
MouseAreaMove(id: "canvas_area", x:, y:, ..) ->
#(Model(..model, cursor_x: x, cursor_y: y), command.none())
_ -> #(model, command.none())
}
Canvas events
Generated by canvas widgets. Each event is opt-in via a boolean prop on
the canvas node. Canvas events have their own constructors.
CanvasPress(id: "draw_area", scope: [], x: x, y: y, button: "left")
CanvasRelease(id: "draw_area", scope: [], x: x, y: y, button: "left")
CanvasMove(id: "draw_area", scope: [], x: x, y: y)
CanvasScroll(id: "draw_area", scope: [], 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.
case event {
CanvasPress(id: "draw_area", x:, y:, button: "left", ..) ->
#(Model(..model, drawing: True, last_x: x, last_y: y), command.none())
CanvasMove(id: "draw_area", x:, y:, ..) ->
case model.drawing {
True ->
#(
Model(..model, last_x: x, last_y: y, strokes: [#(x, y), ..model.strokes]),
command.none(),
)
False -> #(model, command.none())
}
_ -> #(model, command.none())
}
Canvas shape events
When a canvas contains shapes with an interactive field (see
composition patterns),
the renderer handles hit testing locally and emits semantic shape
events. These arrive as WidgetEvent constructors. The
id is the canvas widget ID; data contains the shape details.
// Cursor entered a shape's bounds
WidgetEvent(kind: "canvas_shape_enter", id: "chart", ..)
// Cursor left a shape's bounds
WidgetEvent(kind: "canvas_shape_leave", id: "chart", ..)
// Click on a shape
WidgetEvent(kind: "canvas_shape_click", id: "chart", ..)
// Drag on a draggable shape
WidgetEvent(kind: "canvas_shape_drag", id: "chart", ..)
// Drag ended
WidgetEvent(kind: "canvas_shape_drag_end", id: "chart", ..)
// Shape received keyboard focus
WidgetEvent(kind: "canvas_shape_focused", id: "chart", ..)
Hover styles, pressed styles, cursors, and tooltips on shapes are handled by the renderer locally – no round-trip needed. Shape events give the host semantic actions (clicks, drags, focus changes) instead of raw coordinates.
case event {
WidgetEvent(kind: "canvas_shape_click", id: "chart", data:, ..) -> {
// Use gleam/dynamic/decode to extract shape_id from data
#(model, command.none())
}
_ -> #(model, command.none())
}
Sensor events
Generated by sensor widgets when the sensor detects a size change.
SensorResize(id: "content_area", scope: [], width: w, height: h)
case event {
SensorResize(id: "content_area", width:, height:, ..) ->
#(Model(..model, content_width: width, content_height: height), command.none())
_ -> #(model, command.none())
}
PaneGrid events
Generated by pane_grid widgets during pane interactions. Pane events
have their own constructors.
PaneResized(id: "editor", scope: [], split: split, ratio: ratio)
PaneDragged(id: "editor", scope: [], pane: source, target: target, action: action, ..)
PaneClicked(id: "editor", scope: [], pane: pane)
case event {
PaneResized(id: "editor", split:, ratio:, ..) ->
#(update_split_ratio(model, split, ratio), command.none())
PaneClicked(id: "editor", pane:, ..) ->
#(Model(..model, active_pane: pane), command.none())
_ -> #(model, command.none())
}
Keyboard events
Delivered when keyboard subscriptions are active (see commands.md).
KeyPress(key: "s", modifiers: Modifiers(..modifiers_none(), command: True), ..)
KeyRelease(key: "Escape", ..)
KeyPress / KeyRelease fields
KeyPress(
key: String, // logical key ("Enter", "a", "Escape", etc.)
modified_key: String, // key with modifiers applied (e.g. Shift+a = "A")
modifiers: Modifiers,
physical_key: Option(String), // physical key code (layout-independent)
location: KeyLocation, // Standard, LeftSide, RightSide, Numpad
text: Option(String), // text produced (None for non-printable)
repeat: Bool, // True for auto-repeat events
captured: Bool, // True if a subscription already consumed this event
)
The captured field indicates whether a subscription (such as a key
binding on a text editor widget) has already consumed this event. Apps
can check this to skip captured events and avoid double-processing.
The modified_key field provides the key value after applying modifier
transforms. For example, Shift+a produces modified_key: "A". This
falls back to the unmodified key when no transform applies.
Modifiers
Modifiers(
shift: Bool,
ctrl: Bool,
alt: Bool,
logo: Bool, // Super/Windows key
command: Bool, // macOS Command key
)
Use event.modifiers_none() for a baseline with all modifiers off.
Key values
Keys are strings. Named keys use their standard names:
"Enter", "Escape", "Tab", "Backspace", "Delete",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"Home", "End", "PageUp", "PageDown",
"F1", "F2", ... "F12",
"Space"
Character keys are single-character strings:
"a", "b", "1", "/", " "
Keyboard event examples
case event {
KeyPress(key: "s", modifiers: Modifiers(command: True, ..), ..) ->
#(model, save_command())
KeyPress(key: "Escape", ..) ->
#(Model(..model, modal_open: False), command.none())
KeyPress(key: "z", modifiers: Modifiers(command: True, shift: False, ..), ..) ->
undo(model)
KeyPress(key: "z", modifiers: Modifiers(command: True, shift: True, ..), ..) ->
redo(model)
// Use physical_key for layout-independent bindings (e.g. WASD on non-QWERTY)
KeyPress(physical_key: option.Some("KeyW"), ..) ->
move_up(model)
// Use text field for text input handling
KeyPress(text: option.Some(text), ..) ->
append_char(model, text)
_ -> #(model, command.none())
}
IME events
Delivered when IME subscriptions are active (see commands.md). Input Method Editor events support CJK and other compose-based text input.
ImeOpened(captured: False)
ImePreedit(text: text, cursor: option.Some(#(start, end_pos)), captured: False)
ImeCommit(text: text, captured: False)
ImeClosed(captured: False)
ImePreedit fires during composition with the in-progress text and optional
cursor range. ImeCommit fires when the user finalises a composed string.
case event {
ImePreedit(text:, ..) ->
#(Model(..model, composing: text), command.none())
ImeCommit(text:, ..) ->
#(Model(..model, composing: "", value: model.value <> text), command.none())
_ -> #(model, command.none())
}
Mouse events (global)
Delivered when mouse subscriptions are active. These are global (not widget-scoped) events from the windowing system.
MouseMoved(x: x, y: y, captured: False)
MouseEntered(captured: False)
MouseLeft(captured: False)
MouseButtonPressed(button: LeftButton, captured: False)
MouseButtonReleased(button: RightButton, captured: False)
MouseWheelScrolled(delta_x: dx, delta_y: dy, unit: Line, captured: False)
The button field is a MouseButton type (LeftButton, RightButton,
MiddleButton, BackButton, ForwardButton, OtherButton(String)).
The unit field is a ScrollUnit type (Line or Pixel).
case event {
MouseMoved(x:, y:, ..) ->
#(Model(..model, cursor_x: x, cursor_y: y), command.none())
MouseButtonPressed(button: LeftButton, ..) ->
#(Model(..model, mouse_down: True), command.none())
_ -> #(model, command.none())
}
Touch events
Delivered when touch subscriptions are active.
TouchPressed(finger_id: fid, x: x, y: y, captured: False)
TouchMoved(finger_id: fid, x: x, y: y, captured: False)
TouchLifted(finger_id: fid, x: x, y: y, captured: False)
TouchLost(finger_id: fid, x: x, y: y, captured: False)
case event {
TouchPressed(x:, y:, ..) ->
#(Model(..model, touch_start_x: x, touch_start_y: y), command.none())
_ -> #(model, command.none())
}
Modifier state events
Delivered when modifier key state changes (subscription-driven).
ModifiersChanged(
modifiers: Modifiers(shift: True, ctrl: False, alt: False, logo: False, command: False),
captured: False,
)
case event {
ModifiersChanged(modifiers:, ..) ->
#(Model(..model, shift_held: modifiers.shift), command.none())
_ -> #(model, command.none())
}
Window events
Delivered when window subscriptions are active or for lifecycle events on windows the app manages.
WindowCloseRequested(window_id: "main")
WindowOpened(window_id: "main", width: w, height: h, position_x: pos_x, position_y: pos_y, scale_factor: factor)
WindowClosed(window_id: "main")
WindowMoved(window_id: "main", x: x, y: y)
WindowResized(window_id: "main", width: w, height: h)
WindowFocused(window_id: "main")
WindowUnfocused(window_id: "main")
WindowRescaled(window_id: "main", scale_factor: factor)
WindowFileHovered(window_id: "main", path: path)
WindowFileDropped(window_id: "main", path: path)
WindowFilesHoveredLeft(window_id: "main")
Handling window close
case event {
WindowCloseRequested(window_id: "main") ->
case model.unsaved_changes {
True -> #(Model(..model, confirm_exit: True), command.none())
False -> #(model, command.close_window("main"))
}
_ -> #(model, command.none())
}
File drag and drop
case event {
WindowFileHovered(window_id: "main", path:) ->
#(Model(..model, drop_target_active: True, hovered_file: path), command.none())
WindowFileDropped(window_id: "main", path:) ->
#(Model(..model, drop_target_active: False), load_file(path))
WindowFilesHoveredLeft(window_id: "main") ->
#(Model(..model, drop_target_active: False), command.none())
_ -> #(model, command.none())
}
System events
AnimationFrame(timestamp: timestamp)
ThemeChanged(theme: 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 theme field holds the mode string ("light" or "dark").
Timer events
Delivered by timer subscriptions.
TimerTick(tag: "tick", timestamp: ts)
Where timestamp is the monotonic time in milliseconds.
Command result events
Delivered when an async command completes.
AsyncResult(tag: "fetch", result: Ok(value))
AsyncResult(tag: "fetch", result: Error(reason))
Where tag is the string you passed to command.async.
case event {
WidgetClick(id: "fetch", ..) ->
#(model, command.async(fn() { fetch_data() }, "data_loaded"))
AsyncResult(tag: "data_loaded", result: Ok(value)) -> {
let data = case decode.run(value, decode.string) {
Ok(s) -> s
Error(_) -> "unexpected"
}
#(Model(..model, data:), command.none())
}
AsyncResult(tag: "data_loaded", result: Error(_)) ->
#(Model(..model, error: "fetch failed"), command.none())
_ -> #(model, command.none())
}
Effect result events
Delivered when a renderer effect completes.
EffectResponse(request_id: "ef_1234", result: EffectOk(data))
EffectResponse(request_id: "ef_1234", result: EffectCancelled)
EffectResponse(request_id: "ef_1234", result: EffectError(reason))
The request_id is the auto-generated effect ID, not the effect kind name.
The result is an EffectResult type with constructors EffectOk(Dynamic),
EffectCancelled, and EffectError(Dynamic).
case event {
EffectResponse(result: EffectOk(data), ..) ->
#(model, load_file_from_result(data))
EffectResponse(result: EffectError(reason), ..) ->
#(Model(..model, error: reason), command.none())
_ -> #(model, command.none())
}
See effects.md.
Accessibility action
// Standard AT actions are mapped to normal events: Click/Default
// becomes WidgetClick, SetValue becomes WidgetInput.
// Non-standard actions arrive as WidgetEvent:
WidgetEvent(kind: "a11y_action", id: "volume", value: action_data, ..)
The action data contains the accesskit action name (e.g.
"ScrollDown", "Increment", "Decrement").
Note: This event is only generated when the renderer is built with the
a11y feature flag.
Catch-all
Always include a catch-all clause:
_ -> #(model, command.none())
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 union constructors, so Gleam’s pattern matching works naturally:
case event {
// Match any click
WidgetClick(id:, ..) -> {
io.debug("clicked: " <> id)
handle_click(model, id)
}
// Match clicks with a prefix
WidgetClick(id: "nav:" <> section, ..) ->
#(Model(..model, section:), command.none())
// Match toggle on any "setting:" prefixed checkbox
WidgetToggle(id: "setting:" <> key, value:, ..) ->
#(update_setting(model, key, value), command.none())
_ -> #(model, command.none())
}
Scope matching
Widget events include a scope field listing ancestor container IDs,
nearest first. You can pattern match on scope to distinguish events from
different contexts:
case event {
// Button inside the "sidebar" container
WidgetClick(id: "save", scope: ["sidebar", ..], ..) ->
save_sidebar(model)
// Same button ID but inside "main" container
WidgetClick(id: "save", scope: ["main", ..], ..) ->
save_main(model)
_ -> #(model, command.none())
}