# Events in Mob — User Guide

Comprehensive guide to receiving events from widgets, gestures, and the
device. For the underlying design, see [`event_model.md`](event_model.md).

## TL;DR

Every event in Mob — taps, text changes, gestures, scroll, device lifecycle —
arrives in your handler as one of two shapes:

```elixir
# Legacy / Phase 1-3 widgets:
{:tap, tag}
{:change, tag, value}
{:focus, tag}  {:blur, tag}  {:submit, tag}

# Canonical envelope (recommended for new code):
{:mob_event, %Mob.Event.Address{}, event_atom, payload}
```

Either path delivers the same logical event; the canonical envelope just
includes more context (screen, component path, render generation).

`Mob.Event.Bridge` converts legacy → canonical so screens can opt into the
new shape one at a time.

## Quick reference

### Taps and text input

```elixir
button("Save", on_tap: {self(), :save})

text_field(value: @email, on_change: {self(), :email_changed},
                          on_focus:  {self(), :email_focused},
                          on_blur:   {self(), :email_blurred},
                          on_submit: {self(), :email_submitted})

# In handle_info/2:
def handle_info({:tap, :save}, socket), do: ...
def handle_info({:change, :email_changed, value}, socket), do: ...
```

### Selection (lists)

```elixir
%{type: :list, props: %{id: :options, items: @options}, children: []}

def handle_info({:select, :options, index}, socket), do: ...
```

Mob does not currently expose a core picker, menu, or segmented-control
primitive. The native `on_select` bridge slot is reserved for a future
selection primitive; ordinary list selection is implemented by tappable rows
and includes the row index as shown above.

### Gestures

```elixir
button("Avatar",
  on_long_press: {self(), :show_menu},
  on_double_tap: {self(), :zoom})

card(
  on_swipe_left:  {self(), :delete},
  on_swipe_right: {self(), :archive},
  on_swipe:       {self(), :any_swipe})  # also fires; payload includes direction

def handle_info({:long_press, :show_menu}, socket), do: ...
def handle_info({:swipe, :any_swipe, direction}, socket), do: ...   # :left | :right | :up | :down
```

Press and release, for anything held (hold-to-talk, push-to-record):

```elixir
box(on_press_in: {self(), :mic}, on_press_out: {self(), :mic})

def handle_info({:press_in, :mic}, socket), do: ...   # finger down
def handle_info({:press_out, :mic}, socket), do: ...  # lifted or cancelled; always follows press_in
```

They observe without consuming, on any node type, and a quick tap delivers
`press_in`, `press_out`, then `tap`. See the components guide,
"Press and release".

### Scroll — three tiers

**Tier 1 — raw deltas** (rarely needed; throttle defaults to 30 Hz):

```elixir
scroll(on_scroll: {self(), :feed})

def handle_info({:scroll, :feed, %{y: y, dy: dy, phase: phase}}, socket), do: ...
```

Override the throttle when you need higher fidelity:

```elixir
scroll(on_scroll: {self(), :feed, throttle: 16})         # 60 Hz
scroll(on_scroll: {self(), :feed, throttle: 0})          # raw firing rate (escape hatch)
scroll(on_scroll: {self(), :feed, debounce: 200})        # only after stillness
scroll(on_scroll: {self(), :feed, throttle: 50, delta: 8})  # 20 Hz + 8px deadzone
```

**Tier 2 — semantic events** (use these by default):

```elixir
scroll(
  on_scroll_began:    {self(), :feed_began},
  on_scroll_ended:    {self(), :feed_ended},
  on_scroll_settled:  {self(), :feed_settled},   # fires after deceleration
  on_top_reached:     {self(), :pull_to_refresh},
  on_scrolled_past:   {self(), :show_back_to_top, 600})  # threshold = 600 px

def handle_info({:scroll_began, :feed_began}, socket), do: ...
def handle_info({:scrolled_past, :show_back_to_top}, socket), do: ...
```

Pagination is not a scroll event: `on_end_reached` belongs to `<LazyList>` and
arrives as `{:tap, tag}` (see [Infinite scroll](#infinite-scroll)).

**Tier 3 — native-side, no BEAM round-trip** (parallax, sticky headers, fades):

```elixir
image(src: "hero.jpg",
  parallax: %{ratio: 0.5, container: :main_scroll})

navbar(
  fade_on_scroll: %{container: :main_scroll, fade_after: 100, fade_over: 60})

header(
  sticky_when_scrolled_past: %{container: :main_scroll, threshold: 200})
```

These never deliver events to BEAM — they're computed natively at display
refresh rate.

### IME composition (CJK / Korean / Vietnamese / accent input)

Languages with multi-stage input show a "marked" or "composing" region
before the user picks a final character. Apps that read text mid-keystroke
(search-as-you-type, network sync) need to know about composition state to
avoid sending partial input.

```elixir
text_field(value: @text,
  on_change:  {self(), :text},      # fires on every change (including composing)
  on_compose: {self(), :ime})       # fires on composition phase changes

# Events:
# {:compose, :ime, %{phase: :began,     text: "n"}}     # composition started
# {:compose, :ime, %{phase: :updating,  text: "ni"}}    # composing
# {:compose, :ime, %{phase: :committed, text: "你"}}    # user picked candidate
# {:compose, :ime, %{phase: :cancelled, text: ""}}      # user dismissed IME
```

Commit-only handler pattern (the typical use case):

```elixir
def handle_info({:compose, _id, %{phase: :began}}, socket),
  do: {:noreply, assign(socket, :composing, true)}

def handle_info({:compose, _id, %{phase: :committed, text: text}}, socket) do
  # Real commit replaces whatever raw text we got during composition.
  {:noreply, assign(socket, composing: false, text: text)}
end

def handle_info({:compose, _id, %{phase: :cancelled}}, socket),
  do: {:noreply, assign(socket, :composing, false)}

def handle_info({:change, _id, value}, %{assigns: %{composing: true}} = socket),
  do: {:noreply, socket}                 # ignore raw text while composing

def handle_info({:change, _id, value}, socket),
  do: {:noreply, assign(socket, :text, value)}
```

For most apps (forms that read the final value on submit), you don't need
`on_compose` at all — UIKit/Compose handle IME natively and the committed
text ends up in `value` correctly. Only opt in when partial input matters.

### Device lifecycle

```elixir
# Subscribe in mount/2 (or anywhere — process is monitored, auto-cleaned):
Mob.Device.subscribe()                  # default: :app, :display, :audio, :memory
Mob.Device.subscribe(:all)              # all categories
Mob.Device.subscribe([:thermal, :power])

# Receive events:
def handle_info({:mob_device, :did_enter_background}, socket), do: ...
def handle_info({:mob_device, :thermal_state_changed, :serious}, socket), do: ...
def handle_info({:mob_device, :battery_level_changed, pct}, socket), do: ...
```

See [`event_model.md`](event_model.md) for the full event vocabulary.

## When to use Tier 1 vs Tier 2 vs Tier 3 (scroll)

| You want to... | Use |
|---|---|
| Trigger pagination at the bottom of a list | `<LazyList on_end_reached={...}>` |
| Show a "back to top" button after 600 px | Tier 2 — `on_scrolled_past` |
| Hide a navbar while user is actively scrolling | Tier 2 — `on_scroll_began` / `on_scroll_settled` |
| Run analytics on "user reached product N" | Tier 2 — `on_scrolled_past` |
| Smoothly fade a header from 100 % to 0 % over 60 px | Tier 3 — `fade_on_scroll` |
| Parallax a hero image | Tier 3 — `parallax` |
| Animate something as a function of scroll position | Tier 3 |
| You **really** need raw scroll deltas | Tier 1 (and explain why in code review) |
| Scroll-driven game / drawing canvas | Tier 1 with `throttle: 0` |

The rule of thumb from React Native's experience: if your code looks like
"compute a transform from scroll position," it belongs in Tier 3, not Tier 1.

## Throttle / debounce / delta semantics

```elixir
on_scroll: {pid, tag, opts}
```

`opts` accepts:

- `throttle: ms` — minimum interval between emissions. Default 33 ms (≈ 30 Hz)
  for scroll, 16 ms (60 Hz) for drag/pinch/rotate, 33 ms for pointer move.
  `0` disables throttling.
- `debounce: ms` — only emit after `ms` ms of no events. Default 0 (off).
- `delta: number` — minimum change in x or y (or scale, or degrees) to trigger
  an emit. Default 1 px for scroll/drag, 0.01 (1 %) for pinch, 1° for rotate,
  4 px for pointer.
- `leading: bool` — emit the first event of a burst. Default `true`.
- `trailing: bool` — emit the final event after debounce window expires.
  Default `true` for most events; `false` for `pointer_move`.

**Phase-boundary events always fire**, regardless of throttle. So
`{:scroll, tag, %{phase: :began}}` and `{:scroll, tag, %{phase: :ended}}` are
guaranteed to deliver even if `throttle: 1000`.

## Event payload reference

### `{:scroll, tag, payload}`
```elixir
%{
  x: 0.0,          # current x offset in px
  y: 1240.0,       # current y offset in px
  dx: 0.0,         # delta since last emitted event (px)
  dy: 12.0,
  velocity_x: 0.0, # px/sec
  velocity_y: 720.0,
  phase: :began | :dragging | :decelerating | :ended,
  ts: 18472,       # ms since boot (monotonic; safe for diffs)
  seq: 891         # monotonic counter per handle, detects drops
}
```

### `{:drag, tag, payload}`
Same shape minus `velocity_x`/`velocity_y`.

### `{:pinch, tag, payload}` / `{:rotate, tag, payload}`
```elixir
%{scale: 1.25,   velocity: 0.3,  phase: ..., ts: ..., seq: ...}     # pinch
%{degrees: 45.0, velocity: 0.1,  phase: ..., ts: ..., seq: ...}     # rotate
```

### `{:pointer_move, tag, payload}`
```elixir
%{x: 320.0, y: 480.0, ts: ..., seq: ...}
```

### `{:swipe, tag, direction}`
`direction` is `:left | :right | :up | :down`.

### Tier 2 single-fire events
`{:scroll_began, tag}`, `{:scroll_ended, tag}`, `{:scroll_settled, tag}`,
`{:top_reached, tag}`, `{:scrolled_past, tag}` — no
payload. The `tag` identifies the source widget.

## The canonical envelope

For new code, prefer the canonical envelope. Use `Mob.Event.Bridge`:

```elixir
def handle_info(msg, socket) do
  case Mob.Event.Bridge.legacy_to_canonical(msg, __MODULE__) do
    {:ok, {:mob_event, addr, event, payload}} ->
      handle_canonical(addr, event, payload, socket)

    :passthrough ->
      # Not a recognised event — handle normally.
      ...
  end
end

defp handle_canonical(%Address{widget: :button, id: :save}, :tap, _, socket) do ...
defp handle_canonical(%Address{widget: :scroll, id: list_id},
                       :scroll, %{y: y, phase: :ended}, socket) do ...
defp handle_canonical(%Address{widget: :list, id: list_id, instance: index},
                       :select, _, socket) do ...
```

The address gives you `screen`, `component_path`, `widget`, `id`, `instance`,
`render_id` — which is *much* richer matching power than the legacy 2-tuple.

## Targeting events to non-screen processes

For Phase 4+ widgets (gestures and beyond), you can target a specific process
other than the screen:

```elixir
button("Pause", on_tap: :pause, target: MyApp.AudioPlayer)
button("Sync",  on_tap: :sync,  target: {:via, Registry, {:workers, "sync"}})
button("Cancel", on_tap: :cancel, target: :screen)        # explicit
button("Save",  on_tap: :save,   target: :parent)         # default
button("Use",   on_tap: :use,    target: {:component, :outer_form})
```

In-tree targets (`:parent`, `:screen`, `{:component, _}`) get framework
guarantees: render-id staleness check, auto-cleanup on widget unmount.
External targets (registered atom, pid, `{:via, ...}`) are best-effort —
the framework just sends the message and trusts the recipient exists.

(Note: `target:` is currently in the design phase; the renderer landing in a
follow-up batch will wire it up. Today, every widget's events go to the pid
you put in `on_tap: {pid, ...}`.)

## Stateful components own their subtree's events

If you write a reusable component (e.g., a date picker, an autocomplete,
a chart), declare it as `Mob.Event.Component`:

```elixir
defmodule MyApp.Form do
  use Mob.Event.Component

  def mount(props, state), do: {:ok, Map.put(state, :email, "")}

  def render(state) do
    column(...)  # contains text fields, buttons
  end

  def handle_event(%Address{id: :email}, :change, value, state) do
    {:noreply, %{state | email: value}}    # internal — screen doesn't see
  end

  def handle_event(%Address{id: :submit}, :tap, _, state) do
    send(state.parent, {:form_submitted, state.email})  # escalate semantic event
    {:noreply, state}
  end
end
```

Widget events inside the component default to landing here, not the screen.
The screen sees only `:form_submitted` — clean encapsulation, regardless of
how many widgets the component contains internally.

## Debugging — `Mob.Event.Trace`

Live-watch every event in IEx — native input (taps, changes, gestures,
scroll) as the screen receives it, and anything delivered through
`Mob.Event.dispatch/4`:

```elixir
Mob.Event.Trace.subscribe()             # all events
# or with a filter:
Mob.Event.Trace.subscribe(fn addr -> addr.widget == :scroll end)

# From a shell on another node, name the pid to deliver to:
:rpc.call(node, Mob.Event.Trace, :subscribe, [self(), nil])

# Now in your IEx session:
flush()
# {:mob_trace, %Address{widget: :button, id: :save}, :tap, nil}
# {:mob_trace, %Address{widget: :scroll, id: :feed},
#               :scroll, %{y: 240.0, dy: 8.0, phase: :dragging, seq: 12}}
# ...

Mob.Event.Trace.unsubscribe()   # or Mob.Event.Trace.stop() for every tracer
```

Native input arrives at a screen as the legacy tuples above, not through
`Mob.Event`, so `Mob.Screen.Server` names each one by its canonical address
(`Mob.Event.NativeInput`) and traces it when it arrives — payload included.
An event delivered through `dispatch/4` is traced there and not again. Not
traced: input for a screen that has died (it never arrives; see the
`:undeliverable` receipt in `guides/agentic_coding.md`), a tag that is not a
valid address id, and ordinary `handle_info/2` messages such as timers.

Tracers are monitored, so one that exits stops being traced to on its own.
When no tracers are registered, each event reads an empty list from
`:persistent_term` and returns — native input is not even given an address.
Zero impact on production performance.

Tracing shows every event; a **receipt** (`Mob.Agent.Receipts`) says what one
action did. Discrete native input — taps, text and toggle changes, focus,
blur, submit, dismiss, list-row selects, single-fire gestures — gets a receipt;
display-rate streams (scroll, drag, pinch, rotate, pointer move, IME
composition, a slider drag) do not, so they cannot evict the actions an agent
asks about. A receipt names the input by address and never carries its
payload.

## Performance notes

- **Tap-family events** (tap, change, focus, blur, submit, select): one
  `enif_send` per event. ~1–10 µs. Negligible.
- **Gestures** (long-press, double-tap, swipe): same — single user-level
  events.
- **High-frequency events** (scroll, drag, pinch, rotate, pointer move):
  throttled and delta-thresholded native-side **before** the BEAM crossing.
  Default 30 Hz cap means at most 30 `enif_send` per active scroll session,
  even if the underlying scroll is 120 Hz.
- **Tier 3 native primitives** (parallax, fade, sticky): zero BEAM
  involvement during the scroll. Animation runs at display refresh rate
  natively.

## Migration — from `register_tap` to canonical

The framework still uses `register_tap` (returning integer handles) under
the hood. The visible API has not changed: continue to write
`on_tap: {pid, tag}`. As you migrate screens to use `Mob.Event.Bridge`
or stateful components, the legacy shapes keep working — both arrive at
the same handler.

When/if `Mob.List` is migrated to a stateful component, its row-tap shape
(`{:tap, {:list, id, :select, idx}}`) will change to a canonical envelope
emitted from the list's pid. The bridge already handles this conversion
transparently for screens that opt in.

## Common patterns

The first two patterns load data with `Mob.Socket.start_async/3`, so the
screen stays responsive while the work runs and the result arrives in
`handle_async/3` (see [Screen Lifecycle](screen_lifecycle.md#handle_async-3)).

### Pull-to-refresh

```elixir
~MOB"""
<Scroll on_top_reached={{self(), :refresh}}>
  ...rows...
</Scroll>
"""

def handle_info({:top_reached, :refresh}, socket) do
  {:noreply,
   socket
   |> Mob.Socket.assign(:refreshing, true)
   |> Mob.Socket.start_async(:refresh, fn -> reload_feed() end)}
end

def handle_async(:refresh, {:ok, items}, socket),
  do: {:noreply, Mob.Socket.assign(socket, items: items, refreshing: false)}

def handle_async(:refresh, {:exit, _reason}, socket),
  do: {:noreply, Mob.Socket.assign(socket, :refreshing, false)}
```

A second pull while a refresh is running starts a new one and the older one is
discarded: only the newest result reaches `handle_async/3`.

### Infinite scroll

`<LazyList>`'s `on_end_reached` fires when the last row appears, delivered as
`{:tap, tag}`. `<Scroll>` has no end-of-content event.

```elixir
~MOB"""
<LazyList on_end_reached={{self(), :load_more}}>
  ...rows...
</LazyList>
"""

def handle_info({:tap, :load_more}, %{assigns: %{loading: true}} = socket),
  do: {:noreply, socket}

def handle_info({:tap, :load_more}, socket) do
  page = socket.assigns.page + 1

  {:noreply,
   socket
   |> Mob.Socket.assign(:loading, true)
   |> Mob.Socket.start_async(:page, fn -> load_page(page) end)}
end

def handle_async(:page, {:ok, rows}, socket) do
  {:noreply,
   socket
   |> Mob.Socket.update(:items, &(&1 ++ rows))
   |> Mob.Socket.update(:page, &(&1 + 1))
   |> Mob.Socket.assign(:loading, false)}
end

def handle_async(:page, {:exit, _reason}, socket),
  do: {:noreply, Mob.Socket.assign(socket, :loading, false)}
```

The platforms re-fire `on_end_reached` differently, so the handler ignores a
tap while a page is loading. On iOS it fires again only after the row count
changes, so a failed or empty page doesn't retry when the user scrolls back to
the end; offer a retry button for that case. On Android it fires each time the
last row comes back into view. See [`:lazy_list`](components.md#lazy_list).

### Show "back to top" button

```elixir
column(spacing: 0) do
  scroll(
    on_scrolled_past: {self(), :show_back_to_top, 600},
    on_top_reached:   {self(), :hide_back_to_top}) do
    ...long content...
  end

  if @show_back_to_top, do: floating_button("↑", on_tap: {self(), :scroll_to_top})
end
```

### Card stack with swipe-to-dismiss

```elixir
for {card, idx} <- Enum.with_index(@cards) do
  card(id: card.id,
       on_swipe_left:  {self(), {:dismiss, card.id}},
       on_swipe_right: {self(), {:save, card.id}}) do
    ...card contents...
  end
end

def handle_info({:swipe_left, {:dismiss, id}}, socket), do: ...
def handle_info({:swipe_right, {:save, id}}, socket), do: ...
```

### Photo viewer with pinch-to-zoom and pan

```elixir
image(src: @url,
      on_pinch: {self(), :zoom},
      on_drag:  {self(), :pan})

def handle_info({:pinch, :zoom, %{scale: scale, phase: :ended}}, socket) do
  # Final zoom level — commit it.
  {:noreply, assign(socket, :zoom, scale)}
end

def handle_info({:pinch, :zoom, %{scale: scale, phase: :dragging}}, socket) do
  # Live update — typically you'd render with this on the way to the final.
  {:noreply, assign(socket, :live_zoom, scale)}
end
```

### Hero parallax with native-only animation

```elixir
scroll(id: :main, on_scroll_began: {self(), :hide_chrome}) do
  image(
    src: "hero.jpg",
    parallax: %{ratio: 0.5, container: :main})  # NEVER hits BEAM during scroll

  ...content...
end
```

## Anti-patterns

❌ **Don't put `on_scroll` with no throttle and synchronous work in the
handler.** A slow handler at 60 Hz will overflow the screen GenServer's
mailbox and lag the app. If you really need every frame, use Tier 3.

❌ **Don't use `String.to_atom/1` to derive `id` from user data.** Atoms are
not GC'd. Use binaries for data-derived IDs:
```elixir
# ❌ leaks
on_tap: {self(), String.to_atom("contact_#{contact.id}")}

# ✅ safe
on_tap: {self(), {:contact, contact.id}}
on_tap: {self(), "contact:#{contact.id}"}
```

❌ **Don't compute layout from scroll deltas in BEAM.** The frame budget is
~16 ms; a BEAM round-trip plus computation can easily exceed it. Use Tier 3.

❌ **Don't override `target:` to a process you don't control its lifecycle of.**
External targets are best-effort — if the target dies, your event is silently
dropped (logged in dev). For "fire and forget" that's fine; for "must
receive" it's a footgun.

## Where to find more

- [`event_model.md`](event_model.md) — design contract, address shape, ID rules
- [`event_audit.md`](https://github.com/GenericJam/mob/blob/master/guides/event_audit.md) — current native emitters, migration plan
- [`PLAN.md`](https://github.com/GenericJam/mob/blob/master/PLAN.md) — roadmap; what's done, what's coming
