NAME
====

Selkie::App::Internal::Dispatch - internal input dispatch role for Selkie::App

DESCRIPTION
===========

Implementation detail composed by `Selkie::App`. Use `Selkie::App.on-key`, `Selkie::App.event-supply`, and widget `handle-event` methods from application code.

Input tracing
-------------

Setting `SELKIE_TERMINAL_DEBUG=1` makes this role log every raw event it pulls off notcurses, and every event a widget claims, to `STDERR`:

    Selkie input at=<timestamp> raw-id=<id> evtype=<n> modifiers=<n> alt=<n> focus=<Widget::Class>
    Selkie input handled at=<timestamp> id=<id> by=<Widget::Class>

This exists to diagnose input that arrives but never reaches the intended widget — the class of bug where a keypress is swallowed, misrouted, or lands a keystroke late. It answers "did notcurses deliver it, and who took it?".

The trace deliberately records **no** effective text, key character, or widget value: only the numeric event identity and the receiving class name. That is what makes it safe to leave enabled while a password field has focus. Do not add decoded text here.

The same variable also enables an unrelated one-shot pixel diagnostic at startup — see `Selkie::App`.

### method trace-raw-input

```raku
method trace-raw-input(
    Notcurses::Native::Types::Ncinput $ni
) returns Nil
```

Safe, opt-in native input trace. Deliberately records no effective text or widget value: this is usable while a password field has focus.

### method collect-escape-burst

```raku
method collect-escape-burst(
    $nc,
    @burst
) returns List
```

How many events the fragmented-report guard will look ahead over. Generously above the longest real reply (an XTGETTCAP answer runs to a few dozen characters) and far below a paste, which must keep reaching `flush-paste-batch` in one piece. How long the guard will hold a bare Escape waiting to see whether an introducer follows it. Deliberately under one hot frame: an Escape keypress closes a modal, and that must stay instant. The cost of this being short is that a terminal reply fragmented **exactly** after its Escape byte is missed; the cost of it being long is felt by every Escape the user presses. How long the guard will hold an Escape that is already followed by a control-sequence introducer, waiting for the rest of the report to arrive. Matches the quiet-gap the app's start-up drain uses. This window is only ever entered by input that is already a valid partial control sequence and nothing else, so the latency is not something ordinary typing can provoke: the very first character that cannot belong to a report ends the wait immediately and everything collected is dispatched in order. Poll interval while waiting for a fragmented report to complete. Drain the input queue behind an Escape and return the events that survive report-stripping, appending everything read to `@burst` so the caller can count it. Waits — briefly, and only while what has arrived so far is still a viable partial report — for the rest of a fragmented reply. Under load a terminal's answer to a capability query reaches notcurses in pieces, and notcurses replays each piece as its own burst of keypresses; without the wait the guard sees `\e[?64;1;2`, cannot match it, and types it into whatever has focus a frame before the `;6;9;15;18;21;22c` that would have completed it.

### method burst-text

```raku
method burst-text(
    @events
) returns Str
```

The leading run of single-character events as text, which is what the recogniser works on. Stops at the first event with no character — a synthesized key, a mouse report, a resize — because a control sequence cannot span one.

### method escape-event

```raku
method escape-event(
    Selkie::Event $ev
) returns Bool
```

True for a bare Escape keypress — no modifiers, no mouse, the literal `0x1b` that notcurses replays as the first byte of an escape sequence it gave up on.

### method strip-terminal-reports

```raku
method strip-terminal-reports(
    @burst
) returns List
```

Drop every complete terminal report from the front of an Escape-led burst, returning the events that survive. Reports are only ever stripped from the head: once a character that cannot be part of one is reached the rest of the burst is returned untouched, so a reply immediately followed by real typing (which is exactly what the login-screen incident looked like — `\e[?64;1;2;6;9;15;18;21;22c` arriving between two of the user's keystrokes) loses the reply and keeps the typing. Events with no character — synthesized keys, mouse, resize — end the scan: they cannot be part of a control sequence, and a report cannot span one.

### method note-widget-destroyed

```raku
method note-widget-destroyed(
    Mu $
) returns Nil
```

Called (indirectly, through `Selkie::Tree`'s widget-destroyed observer) whenever any widget in the process is destroyed. Runs on the destroying thread — possibly the GC finalizer thread — so it does nothing but raise a flag; `!prune-mouse-capture` does the work on the render thread.

### method capture-routable

```raku
method capture-routable(
    Mu $w
) returns Bool
```

True when a captured widget can still be delivered to: it exists, it has not been torn down, and it is still attached to the surface that owns input. The attachment test is what catches a widget whose screen was swapped out from under a held button; `is-destroyed` catches the case attachment cannot, since `Selkie::Container.remove` destroys a child without clearing its `parent`, leaving a dead widget that still walks up to the root.

### method drop-unroutable-captures

```raku
method drop-unroutable-captures() returns Nil
```

Drop every capture entry that is no longer routable. Cheap and bounded — the table holds at most one entry per mouse button. The key list is materialised into an Array first: deleting from a Hash while iterating its live `.keys` Seq is undefined behaviour.

### method prune-mouse-capture

```raku
method prune-mouse-capture() returns Nil
```

Prune the capture table if — and only if — a widget has been destroyed since the last check, so the common case costs one atomic read.

### method suppress-duplicate-press

```raku
method suppress-duplicate-press(
    $target,
    Int:D $btn where { ... },
    Int:D $y,
    Int:D $x
) returns Bool
```

True when this press should be dropped as a duplicate of the last accepted press: same button, same cell, inside the window. Widgets that expose `debounce-ms` choose their own window (0 disables); everything else gets the DUPLICATE-PRESS-MS floor, so a driver double-fire can't reach ANY widget twice — including click-count consumers, where the duplicate would otherwise register as a phantom double-click. The window is keyed on the cell rather than the widget: a duplicate that lands after the first press already changed the layout (committed a dropdown, opened a modal) is still the same physical click and still gets dropped.

### method is-wheel-id

```raku
method is-wheel-id(
    Int $id where { ... }
) returns Bool
```

Wheel events ride the button encoding (4 = up, 5 = down) but are not clicks — see the wheel note in `!dispatch-mouse`.

