Events

Every interaction in a Plushie app produces an event. A button click, a keystroke in a text input, a checkbox toggle. Each one arrives in your update as a typed value from the plushie/event.Event union. Understanding what events look like and how to match on them is essential for building anything beyond a static layout.

In this chapter we take a closer look at events and add an event log to the pad that shows every event as it happens. Interact with a widget in the preview, see the event it produces.

The Event union

plushie/event.Event is a flat sum type. Every callback your update receives is one of its variants:

pub type Event {
  Widget(WidgetEvent)
  Key(KeyEvent)
  Window(WindowEvent)
  Timer(TimerEvent)
  Async(AsyncEvent)
  Stream(StreamEvent)
  Effect(EffectEvent)
  System(SystemEvent)
  Ime(ImeEvent)
  ModifiersChanged(ModifiersEvent)
  Error(ErrorEvent)
  Session(SessionEvent)
}

The outer variant tells you the family. The inner record carries the details. Pattern-match on both in one case arm. This chapter focuses on the families you reach for daily: Widget, Key, and the EventTarget that every widget event carries. For the full taxonomy, see the Events reference.

WidgetEvent

Most user-interaction events arrive as Widget(...) wrapping a WidgetEvent variant. Every variant carries an EventTarget:

pub type EventTarget {
  EventTarget(window_id: String, id: String, scope: List(String), full: String)
}

id is the widget’s local ID. scope is the ancestor container chain (nearest parent first, with the window ID last). window_id is the source window. full is the canonical wire ID like "main#sidebar/form/save".

Click

No payload beyond the target:

case event {
  Widget(Click(target: EventTarget(id: "save", ..))) -> save(model)
  _ -> model
}

The .. elides fields we don’t care about.

Input

Text content changed. value carries the full current text, not the delta. Fires on every keystroke while the input is focused:

case event {
  Widget(Input(target: EventTarget(id: "search", ..), value: text)) ->
    Model(..model, query: text)
  _ -> model
}

Toggle

A toggler or checkbox flipped. value is the new boolean state:

case event {
  Widget(Toggle(target: EventTarget(id: "dark_mode", ..), value: on)) ->
    Model(..model, dark_mode: on)
  _ -> model
}

Submit

A text input was submitted with Enter (requires text_input.OnSubmit(True) on the builder). value is the submitted text:

case event {
  Widget(Submit(target: EventTarget(id: "new-name", ..), value: name)) ->
    create(model, name)
  _ -> model
}

Slide

A slider moved during a drag. value is the current float position:

case event {
  Widget(Slide(target: EventTarget(id: "volume", ..), value: level)) ->
    Model(..model, volume: level)
  _ -> model
}

Match SlideRelease instead if you only want the final value on release.

Select

A pick list or combo box selection:

case event {
  Widget(Select(target: EventTarget(id: "theme", ..), value: choice)) ->
    Model(..model, theme: choice)
  _ -> model
}

Scope: identifying widgets in lists

When many widgets share the same local ID (like a “delete” button in each row of a list), scope tells you which container they belong to. Match on the head of the list to recover the row’s dynamic ID:

case event {
  Widget(Click(target: EventTarget(id: "delete", scope: [file, ..], ..))) ->
    delete_file(model, file)
  _ -> model
}

We’ll use scope extensively in chapter 6 when building the file list. For now, know that scope exists and carries the container ancestry.

Keyboard events

Keyboard events come from a subscription, not a widget. They arrive as Key(KeyEvent(...)):

case event {
  Key(KeyEvent(event_type: KeyPressed, key: "Escape", ..)) ->
    close_dialog(model)
  _ -> model
}

The modifiers field is a Modifiers record with shift, ctrl, alt, logo, and command. Use command for cross-platform shortcuts: it’s Ctrl on Linux and Windows, Command on macOS.

case event {
  // Save on Ctrl+S / Cmd+S.
  Key(KeyEvent(event_type: KeyPressed, key: "s", modifiers: m, ..))
    if m.command
  -> save(model)

  // Undo on Ctrl+Z / Cmd+Z (without Shift).
  Key(KeyEvent(event_type: KeyPressed, key: "z", modifiers: m, ..))
    if m.command && !m.shift
  -> undo(model)

  _ -> model
}

The guard runs after the structural match, so key and modifier conditions compose naturally. Subscribing to key events requires subscription.on_key_press(), covered in chapter 10.

Pointer events

Canvas input, pointer_area, and the sensor widget deliver pointer events as WidgetEvent variants: Press, Release, Move, Scroll, Enter, Exit, DoubleClick. The pointer field identifies the device (Mouse, Touch, Pen) and button identifies the mouse button.

case event {
  Widget(Press(
    target: EventTarget(id: "canvas", ..),
    pointer: Mouse,
    button: LeftButton,
    x: x,
    y: y,
    ..,
  )) -> select_at(model, x, y)

  Widget(Press(
    target: EventTarget(id: "canvas", ..),
    pointer: Touch,
    finger: Some(fid),
    ..,
  )) -> touch_start(model, fid)

  _ -> model
}

One unified pointer family handles every input device. There is no separate MouseEvent or TouchEvent.

Other families at a glance

The rest of the Event variants surface in later chapters:

Each gets its own chapter. For the full field lists, see the Events reference.

Adding an event log to the pad

The best way to learn events is to see them. We’ll add an event log at the bottom of the pad that shows every event as it fires.

Update the model

Add an event_log field and initialize it to [] in init:

pub type Model {
  Model(
    source: String,
    preview: Option(Node),
    error: Option(String),
    event_log: List(String),
  )
}

The catch-all logs events

The catch-all clause at the bottom of update is the perfect place to log. Anything not handled by a specific arm gets recorded:

fn update(model: Model, evt: Event) -> #(Model, Command(Event)) {
  case evt {
    Widget(Input(target: EventTarget(id: "editor", ..), value: s)) ->
      #(Model(..model, source: s), command.none())

    Widget(Click(target: EventTarget(id: "save", ..))) ->
      #(save_and_render(model), command.none())

    // Log everything else for the event-log panel.
    _ -> #(log_event(model, evt), command.none())
  }
}

The log_event helper

Each event becomes one trimmed string.inspect entry, prepended to the log and capped at 20 entries:

import gleam/list
import gleam/string

fn log_event(model: Model, evt: Event) -> Model {
  let entry = string.inspect(evt)
  let trimmed = case string.length(entry) > 80 {
    True -> string.slice(entry, 0, 77) <> "..."
    False -> entry
  }
  Model(..model, event_log: [trimmed, ..list.take(model.event_log, 19)])
}

string.inspect formats the event as a Gleam literal. Clicking a button produces an entry like:

Widget(Click(target: EventTarget(window_id: "main", id: "btn", ..)))

Typing in a text input produces:

Widget(Input(target: EventTarget(..), value: "hello"))

The log shows you exactly what to pattern-match on.

The event log view

Render the log as a scrollable column of monospace text lines beneath the editor and toolbar:

import plushie/prop/font.{Monospace}
import plushie/prop/length.{Fixed}
import plushie/widget/column
import plushie/widget/scrollable
import plushie/widget/text

fn event_log_pane(model: Model) -> Node {
  ui.scrollable("event-log", [scrollable.Height(Fixed(120.0))], [
    ui.column(
      "log-lines",
      [column.Spacing(2.0), column.Padding(padding.all(4.0))],
      list.map(model.event_log, fn(entry) {
        ui.text(entry, entry, [text.Size(11.0), text.Font(Monospace)])
      }),
    ),
  ])
}

Each entry uses itself as the text widget’s ID; because inspected events differ by field values, IDs stay unique within the column. Drop event_log_pane(model) into the root column after the toolbar and the log fills in as you interact with the preview.

A gallery experiment

Load an experiment with a gallery of common widgets and watch the log as you click:

ui.column("root", [column.Padding(padding.all(16.0)), column.Spacing(12.0)], [
  ui.text("title", "Widget Gallery", [text.Size(20.0)]),
  ui.button_("btn", "Button"),
  ui.checkbox("check", "Check me", False, []),
  ui.text_input("input", "", [text_input.Placeholder("Type here...")]),
  ui.slider("slide", #(0.0, 100.0), 50.0, []),
  ui.toggler("toggle", "Switch", False, []),
])

The event log is your best teacher from here on. Every new widget you encounter produces events, and the log shows you their shape without having to check the reference.

Try it


Next: Lists and Inputs

Search Document