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())
}
Search Document