Events

Events are Data types delivered to your update method. Each event family has its own type under Plushie::Event::*.

Widget events

These are generated by user interaction with widgets. The renderer maps widget interactions to Event::Widget using the node's id.

Click

Event::Widget[type: :click, id: "save"]

Generated by button widgets when pressed.

def update(model, event)
  case event
  in Event::Widget[type: :click, id: "save"]
    save(model)
  in Event::Widget[type: :click, id: "cancel"]
    revert(model)
  else
    model
  end
end

Input

Event::Widget[type: :input, id: "search", value:]

Generated by text_input on every keystroke (when on_input is set, which is the default).

in Event::Widget[type: :input, id: "search", value:]
  model.with(search_query: value)

Submit

Event::Widget[type: :submit, id: "search", value:]

Generated by text_input when the user presses Enter (when on_submit is set).

in Event::Widget[type: :submit, id: "search", value:]
  [model.with(search_query: value), search_command(value)]

Toggle

Event::Widget[type: :toggle, id: "dark_mode", value:]

Generated by checkbox and toggler. value is true or false.

in Event::Widget[type: :toggle, id: "dark_mode", value:]
  model.with(dark_mode: value)

Select

Event::Widget[type: :select, id: "theme_picker", value:]

Generated by pick_list and combo_box when an option is selected.

in Event::Widget[type: :select, id: "theme_picker", value:]
  model.with(theme: value)

Slide

Event::Widget[type: :slide, id: "volume", value:]
Event::Widget[type: :slide_release, id: "volume", value:]

Generated by slider and vertical_slider. slide fires continuously during dragging. slide_release fires once when the user releases the slider.

in Event::Widget[type: :slide, id: "volume", value:]
  model.with(volume: value)

in Event::Widget[type: :slide_release, id: "volume", value:]
  [model.with(volume: value), save_preference(:volume, value)]

Text editor content change

Event::Widget[type: :input, id: "notes", value:]

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.

Key binding

Event::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.

in Event::Widget[type: :key_binding, id: "editor", value: "save"]
  [model, save_file(model)]

in Event::Widget[type: :key_binding, id: "editor", value: "format"]
  model.with(content: format_code(model.content))

Scroll

Event::Widget[type: :scroll, id: "log_view", data:]

Generated by scrollable when the scroll position changes (when on_scroll: true is set). The data hash contains:

data = {
  "absolute_x" => Float,    # pixel offset from left
  "absolute_y" => Float,    # pixel offset from top
  "relative_x" => Float,    # 0.0-1.0 horizontal scroll position
  "relative_y" => Float,    # 0.0-1.0 vertical scroll position
  "bounds_width" => Float,  # visible viewport width
  "bounds_height" => Float, # visible viewport height
  "content_width" => Float, # total content width
  "content_height" => Float # total content height
}
in Event::Widget[type: :scroll, id: "log_view", data:]
  at_bottom = data["relative_y"] >= 0.99
  model.with(auto_scroll: at_bottom)

Paste

Event::Widget[type: :paste, id: "url_input", value:]

Generated by text_input when the user pastes text (when on_paste: true is set). The value field contains the pasted string.

Option hovered

Event::Widget[type: :option_hovered, id: "search", value:]

Generated by combo_box when the user hovers over an option in the dropdown (when on_option_hovered: true is set).

Open / Close

Event::Widget[type: :open, id: "country_picker"]
Event::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).

Sort

Event::Widget[type: :sort, id: "users", value:]

Generated by table when a sortable column header is clicked. The value is the string key from the column descriptor.

Mouse area events

Mouse area events use the Event::MouseArea type:

Event::MouseArea[type: :right_press, id: "canvas"]
Event::MouseArea[type: :enter, id: "tooltip-target"]
Event::MouseArea[type: :move, id: "drag-zone", x:, y:]
Event::MouseArea[type: :scroll, id: "scroll-zone", delta_x:, delta_y:]

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 Event::Widget[type: :click] events.

in Event::MouseArea[type: :enter, id: "hover_zone"]
  model.with(hovered: true)

in Event::MouseArea[type: :move, id: "canvas_area", x:, y:]
  model.with(cursor: [x, y])

Canvas events

Generated by canvas widgets. Each event is opt-in via a boolean prop on the canvas node. Canvas events use the Event::Canvas type.

Event::Canvas[type: :press, id: "draw_area", x:, y:, button: "left"]
Event::Canvas[type: :release, id: "draw_area", x:, y:, button: "left"]
Event::Canvas[type: :move, id: "draw_area", x:, y:]
Event::Canvas[type: :scroll, id: "draw_area", x:, y:, delta_x:, delta_y:]

The button field is a string ("left", "right", "middle"). The x/y coordinates are relative to the canvas origin.

in Event::Canvas[type: :press, id: "draw_area", x:, y:, button: "left"]
  model.with(drawing: true, last_point: [x, y])

in Event::Canvas[type: :move, id: "draw_area", x:, y:]
  if model.drawing
    model.with(last_point: [x, y], strokes: [[x, y]] + model.strokes)
  else
    model
  end

Canvas element events

When a canvas contains elements with an interactive field, the renderer handles hit testing locally and emits semantic element events. These arrive as Event::Widget (not Event::Canvas). The id is the canvas widget ID; data["element_id"] identifies the element.

# Cursor entered an element's bounds
Event::Widget[type: :canvas_element_enter, id: "chart", data: {"element_id" => "bar-jan", **}]

# Click on an element
Event::Widget[type: :canvas_element_click, id: "chart", data: {"element_id" => "bar-jan", **}]

# Drag on a draggable element
Event::Widget[type: :canvas_element_drag, id: "chart", data: {"element_id" => "handle", **}]
in Event::Widget[type: :canvas_element_click, id: "chart", data: {"element_id" => element_id, **}]
  model.with(selected_bar: element_id)

Canvas element blurred

Event::Widget[type: :canvas_element_blurred, id: "chart", data: {"element_id" => "bar-jan"}]

Generated when a canvas element loses focus.

Canvas focused / blurred

Event::Widget[type: :canvas_focused, id: "draw_area"]
Event::Widget[type: :canvas_blurred, id: "draw_area"]

Generated when the canvas widget itself gains or loses focus.

Canvas group focused / blurred

Event::Widget[type: :canvas_group_focused, id: "chart", data: {"group_id" => "bar-group"}]
Event::Widget[type: :canvas_group_blurred, id: "chart", data: {"group_id" => "bar-group"}]

Generated when a group within a canvas gains or loses focus.

Diagnostic

Event::System[type: :diagnostic, data: {"level" => "warn", "element_id" => "broken", "code" => "missing_a11y", "message" => "..."}]

Generated by the renderer when it detects a configuration issue with a canvas element (e.g. missing accessibility metadata). Useful for development-time warnings.

Sensor events

Generated by sensor widgets when the sensor detects a size change.

Event::Sensor[type: :resize, id: "content_area", width:, height:]
in Event::Sensor[type: :resize, id: "content_area", width:, height:]
  model.with(content_size: [width, height])

PaneGrid events

Generated by pane_grid widgets during pane interactions.

Event::Pane[type: :resized, id: "editor", split:, ratio:]
Event::Pane[type: :dragged, id: "editor", pane:, target:]
Event::Pane[type: :clicked, id: "editor", pane:]
in Event::Pane[type: :clicked, id: "editor", pane:]
  model.with(active_pane: pane)

Keyboard events

Delivered when keyboard subscriptions are active (see commands.md).

Event::Key[type: :press, key: "s", modifiers: {command: true}]
Event::Key[type: :release, key: :escape]

Key fields

Event::Key[
  type:,          # :press | :release
  key:,           # :enter | "a" | ...  (named symbol or character string)
  modified_key:,  # key with modifiers applied (e.g. Shift+a = "A")
  physical_key:,  # physical key code (layout-independent)
  location:,      # :standard | :left | :right | :numpad
  modifiers:,     # KeyModifiers hash
  text:,          # text produced by key press (nil for non-printable)
  repeat:,        # true if auto-repeat event
  captured:
]

KeyModifiers

{
  shift: true/false,
  ctrl: true/false,
  alt: true/false,
  logo: true/false,
  command: true/false    # platform-aware: ctrl on Linux/Windows, logo (Cmd) on macOS
}

Key values

Named keys are symbols:

: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:

"a", "b", "1", "/", " "

Keyboard event examples

in Event::Key[type: :press, key: "s", modifiers: {command: true, **}]
  [model, save_command]

in Event::Key[type: :press, key: :escape]
  model.with(modal_open: false)

in Event::Key[type: :press, key: "z", modifiers: {command: true, shift: false, **}]
  undo(model)

in Event::Key[type: :press, key: "z", modifiers: {command: true, shift: true, **}]
  redo(model)

# Use physical_key for layout-independent bindings (e.g. WASD on non-QWERTY)
in Event::Key[type: :press, physical_key: :key_w]
  move_up(model)

IME events

Delivered when IME subscriptions are active. Input Method Editor events support CJK and other compose-based text input.

Event::Ime[type: :opened]
Event::Ime[type: :preedit, text:, cursor:]
Event::Ime[type: :commit, text:]
Event::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.

Mouse events (global)

Delivered when mouse subscriptions are active. These are global (not widget-scoped) events from the windowing system.

Event::Mouse[type: :moved, x:, y:]
Event::Mouse[type: :entered]
Event::Mouse[type: :left]
Event::Mouse[type: :button_pressed, button: :left]
Event::Mouse[type: :button_released, button: :right]
Event::Mouse[type: :wheel_scrolled, delta_x:, delta_y:, unit: :line]

The button field is a symbol (:left, :right, :middle, :back, :forward). The unit field indicates scroll units (:line or :pixel).

Touch events

Delivered when touch subscriptions are active.

Event::Touch[type: :pressed, finger_id:, x:, y:]
Event::Touch[type: :moved, finger_id:, x:, y:]
Event::Touch[type: :lifted, finger_id:, x:, y:]
Event::Touch[type: :lost, finger_id:, x:, y:]

Modifier state events

Delivered when modifier key state changes (subscription-driven).

Event::Modifiers[modifiers: {shift: true, ctrl: false, alt: false, logo: false, command: false}]

Window events

Delivered when window subscriptions are active or for lifecycle events on windows the app manages.

Event::Window[type: :close_requested, window_id: "main"]
Event::Window[type: :opened, window_id: "main", position:, width:, height:]
Event::Window[type: :closed, window_id: "main"]
Event::Window[type: :moved, window_id: "main", x:, y:]
Event::Window[type: :resized, window_id: "main", width:, height:]
Event::Window[type: :focused, window_id: "main"]
Event::Window[type: :unfocused, window_id: "main"]
Event::Window[type: :rescaled, window_id: "main", scale_factor:]
Event::Window[type: :file_hovered, window_id: "main", path:]
Event::Window[type: :file_dropped, window_id: "main", path:]
Event::Window[type: :files_hovered_left, window_id: "main"]

File drag and drop

in Event::Window[type: :file_hovered, window_id: "main", path:]
  model.with(drop_target_active: true, hovered_file: path)

in Event::Window[type: :file_dropped, window_id: "main", path:]
  [model.with(drop_target_active: false), load_file(path)]

in Event::Window[type: :files_hovered_left, window_id: "main"]
  model.with(drop_target_active: false)

System events

Event::System[type: :animation_frame, data: timestamp]
Event::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.

Event::Timer[tag: :tick, timestamp: ts]

Where timestamp is the monotonic time in milliseconds.

Command result events

Delivered when an async command completes.

Event::Async[tag: :data_loaded, result: [:ok, value]]
Event::Async[tag: :data_loaded, result: [:error, reason]]

Where tag is the symbol you passed to Command.async.

in Event::Widget[type: :click, id: "fetch"]
  [model, Command.async(-> { HTTP.get!("/api/data") }, :data_loaded)]

in Event::Async[tag: :data_loaded, result: [:ok, body]]
  model.with(data: body)

Effect result events

Delivered when a renderer effect completes.

Event::Effect[request_id: "ef_1234", result: [:ok, data]]
Event::Effect[request_id: "ef_1234", result: :cancelled]
Event::Effect[request_id: "ef_1234", result: [:error, reason]]

See effects.md.

Catch-all

Always include an else clause:

def update(model, event)
  case event
  # ... specific handlers ...
  else
    model
  end
end

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 Data types, so Ruby's pattern matching works:

# Match any click
in Event::Widget[type: :click, id:]
  handle_click(model, id)

# Match clicks with a prefix
in Event::Widget[type: :click, id: /\Anav:(.+)\z/ => id]
  section = id.delete_prefix("nav:")
  model.with(section: section.to_sym)

# Match toggle on any "setting:" prefixed checkbox
in Event::Widget[type: :toggle, id: /\Asetting:/ => id, value:]
  key = id.delete_prefix("setting:")
  model.with(settings: model.settings.merge(key => value))

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:

# Button inside the "sidebar" container
in Event::Widget[type: :click, id: "save", scope: ["sidebar", *]]
  save_sidebar(model)

# Same button ID but inside "main" container
in Event::Widget[type: :click, id: "save", scope: ["main", *]]
  save_main(model)

Accessibility action

in Event::Widget[type: :a11y_action, id:, data: {"action" => action}]
  case action
  when "Increment" then model.with(value: model.value + 1)
  when "Decrement" then model.with(value: model.value - 1)
  else model
  end

Generated when an assistive technology triggers a non-standard action on a widget. Standard AT actions are mapped to normal events: Click/Default becomes Event::Widget[type: :click], SetValue becomes Event::Widget[type: :input]. This event catches everything else.

The action is a string representation of the accesskit action (e.g. "ScrollDown", "Increment", "Decrement").

Note: This event is only generated when the renderer is built with the a11y feature flag.