NAME
====

Selkie::App - The main entry point: event loop, screens, modals, toasts, focus

SYNOPSIS
========

```raku
use Selkie::App;
use Selkie::Layout::VBox;
use Selkie::Widget::Text;
use Selkie::Sizing;

my $app = Selkie::App.new;

my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$root.add: Selkie::Widget::Text.new(
    text   => 'Hello from Selkie',
    sizing => Sizing.fixed(1),
);

$app.add-screen('main', $root);
$app.switch-screen('main');

$app.on-key('ctrl+q', -> $ { $app.quit });
$app.run;   # blocks until quit
```

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

`Selkie::App` is what you construct to start a Selkie program. It owns the notcurses handle, the reactive store, the screen manager, the active modal (if any), the toast overlay, the focused widget, and the event loop.

Your app code:

  * Builds a widget tree

  * Registers it as a screen with `add-screen`

  * Activates a screen with `switch-screen`

  * Picks an initial focused widget with `focus`

  * Registers global keybinds with `on-key`

  * Starts the loop with `run`

The loop wakes on a 16ms input timeout (up to 60 Hz). Each wake it polls for input, dispatches events (to the focused widget, then up the parent chain, then to global keybinds), runs registered frame callbacks, ticks the store, processes any queued focus cycling, ticks the toast, and renders dirty widgets. Idle work is minimized: when nothing changed, the store's subscription walk and the composite render to the terminal are both skipped.

`run` only returns when `quit` is called or an unhandled exception reaches the top of the loop. In either case the terminal is restored before the program exits.

Theme background
----------------

When constructed with a `theme`, `Selkie::App` paints the notcurses standard plane's base cell from `$theme.base` during init so any region no widget writes to falls through to the theme background rather than the terminal's own default. Combined with `Selkie::Widget` doing the same per-plane on `init-plane` / `set-theme` / each `apply-style`, this gives themed backgrounds full-terminal coverage — no gaps between widgets or at screen edges.

The standard plane itself is exposed via `stdplane` if you need to reach it directly (e.g. to paint a custom base cell from application code).

Default keybinds
----------------

`Selkie::App` registers these out of the box so you don't have to:

  * `Tab` / `Shift-Tab` — cycle focus through focusable descendants

  * `Esc` — close the active modal (no-op if none)

  * `Ctrl+Q` — quit the app

Your own `on-key` registrations don't override these by default — if you need to, register your handler with a matching spec and call `quit` or `close-modal` yourself.

LIFECYCLE
=========

Construction calls `notcurses_init`, enables mouse support, drains any pending terminal-query responses, and registers the default keybinds. If `notcurses_init` fails, construction throws immediately.

An `END` phaser registered during construction guarantees `shutdown` runs even if the program exits abnormally (e.g. an exception before `run` is called). This means your terminal is always restored.

`shutdown` itself is exception-isolated step-by-step: a throw in modal destroy, screen-manager destroy, or `notcurses_stop` (the most common real-world cause is a NativeCall dlopen failure when the bundled notcurses library was reinstalled to a new path mid-session) doesn't abort the rest. TTY restoration, the escape-sequence backstop, and stderr-redirect teardown always get a chance to run.

`run` wraps the event loop in a `CATCH` block. If anything inside the loop throws, the terminal is restored, the error is printed to STDERR with a full backtrace, and the process exits with code 1.

EXAMPLES
========

A single-screen app
-------------------

The simplest pattern. One screen, one focused input, a quit binding:

```raku
use Selkie::App;
use Selkie::Layout::VBox;
use Selkie::Widget::TextInput;
use Selkie::Sizing;

my $app = Selkie::App.new;

my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);
my $input = Selkie::Widget::TextInput.new(sizing => Sizing.fixed(1));
$root.add($input);

$app.add-screen('main', $root);
$app.switch-screen('main');
$app.focus($input);

$input.on-submit.tap: -> $text { $app.toast("You typed: $text") };

$app.on-key('ctrl+q', -> $ { $app.quit });
$app.run;
```

Multiple screens
----------------

Register each screen with a name; switch between them with `switch-screen`. The inactive screens are parked off-screen but keep their state (widget instances, focus, scroll position):

```raku
$app.add-screen('login', $login-root);
$app.add-screen('main',  $main-root);

# Start on login:
$app.switch-screen('login');
$app.focus($login-form.password-input);

# Later, after authentication:
$app.switch-screen('main');
$app.focus($main-root.focusable-descendants.List[0]);
```

A modal dialog
--------------

Show a modal to ask the user a question. The modal traps focus — all keystrokes go to it or its descendants until closed — and `Esc` closes it automatically:

```raku
use Selkie::Widget::ConfirmModal;

my $cm = Selkie::Widget::ConfirmModal.new;
$cm.build(
    title     => 'Really delete?',
    message   => "This cannot be undone.",
    yes-label => 'Delete',
    no-label  => 'Cancel',
);
$cm.on-result.tap: -> Bool $confirmed {
    $app.close-modal;
    delete-item() if $confirmed;
};

$app.show-modal($cm.modal);
$app.focus($cm.no-button);    # default to the safe button
```

Modals stack. Calling `show-modal` while another modal is already open pushes the new modal on top — useful for, say, a confirm dialog opened from inside an editor. `close-modal` pops the topmost modal, and the previous modal becomes active again with all its keybinds intact and its pre-modal-focus restored. Repeat `close-modal` to drain the stack.

A frame callback for animation
------------------------------

`on-frame` fires on every iteration of the event loop (~60fps), even when there's no input. Use it to drive timers, animations, or pull from an external stream:

```raku
$app.on-frame: {
    $progress-bar.tick;           # indeterminate animation
    $chat-view.pull-tokens;       # pull from an LLM stream
};
```

Screen-scoped keybinds
----------------------

Scope a keybind to one screen by passing `:screen`. It fires only when that screen is active:

```raku
$app.on-key('ctrl+n', :screen('tasks'), -> $ { create-task });
$app.on-key('ctrl+n', :screen('notes'), -> $ { create-note });
$app.on-key('ctrl+q', -> $ { $app.quit });   # unscoped = every screen
```

Reacting to terminal resizes
----------------------------

`on-resize` fires whenever Selkie's polling detects a change in the host terminal's dimensions. Multiple callbacks are supported and run in registration order, after the widget tree has been re-laid-out and the new frame has been composited. Use this for cleanup that has to span the whole tree on a resize — most commonly, parking pixel-blitter sprixels (Kitty / Sixel) so they re-blit at their new positions on the next frame:

```raku
$app.on-resize: -> UInt $rows, UInt $cols {
    park-every-image-in($app.screen-manager.active-root);
};
```

SEE ALSO
========

  * [Selkie::Widget](Selkie--Widget.md) — the base role every widget composes

  * [Selkie::ScreenManager](Selkie--ScreenManager.md) — multi-screen management (used via `add-screen` / `switch-screen`)

  * [Selkie::Store](Selkie--Store.md) — the reactive state store `Selkie::App` owns

  * [Selkie::Widget::Modal](Selkie--Widget--Modal.md) — modal dialogs

  * [Selkie::Event](Selkie--Event.md) — the keyboard / mouse event abstraction

### method stdplane

```raku
method stdplane() returns Notcurses::Native::Types::NcplaneHandle
```

The notcurses standard plane — the root of the compositing tree, with the terminal's full dimensions. Exposed for apps that need to set a base cell (fill colour for otherwise-empty cells) so a theme background reaches every corner. Only valid after `run` has initialised notcurses.

### has Selkie::Theme $.theme

The theme installed on every screen's root. Defaults to `Selkie::Theme.default` if not provided to `.new`.

### has Selkie::Store $.store

The reactive store owned by this app. Constructed automatically on `.new`; every screen added to the app gets this store propagated into its widget tree. Subscribe to state paths from widgets via `self.subscribe(...)`.

### has atomicint $!resize-pending

Set asynchronously by our SIGWINCH handler when the terminal is resized. Consumed by `!maybe-check-terminal-resize`, which clears the flag and bypasses the 12 Hz rate-limit so the new dims propagate on the very next loop iteration. Atomic because the signal callback runs on whatever thread libuv dispatches to, while the main loop reads it from the application thread.

### has Mu $!resize-tap

Tap on Raku's signal(SIGWINCH) Supply. Stored so `shutdown` can close it cleanly — without explicit close, the tap keeps the App object alive past shutdown via the Supply's subscriber list, and SIGWINCH after shutdown would still try to fire the (now invalid) handler.

### has Positional @!crash-restore-taps

Taps for asynchronously-recoverable fatal signals (SIGABRT, SIGTERM, SIGHUP, SIGQUIT). When any of these arrives we run `shutdown` so the terminal returns to cooked mode and the alternate screen is exited before the process dies. Without these taps the kernel takes the default action (terminate) and Selkie's LEAVE / END / CATCH cleanup never runs, leaving the user staring at a wedged shell. SIGSEGV / SIGBUS / SIGILL / SIGFPE bypass Raku's Supply-based dispatch (the process is dead by the time the scheduler thread wakes); restoring on those requires a NativeCall sigaction-based handler running in the offending thread, which is out of scope for this change.

### has Num $.hot-hz

Hot-rate frame budget in Hz. The main loop caps itself at this rate while anything is happening; the idle ladder then steps down (to 30 / 12 / 4 Hz) after periods of inactivity. Defaults to 60 Hz — enough for smooth typing and scrolling without burning battery on passive sits. Apps doing terminal video playback, high-refresh animations, or live plot rendering can bump this higher — notcurses itself supports video, so 120 Hz+ is a legitimate use case for that flavour of app. This is a CEILING, not a floor: the loop sleeps at least `1 / $hot-hz` seconds between frames, but may sleep longer when the idle ladder has ramped down.

### has Num $.sprixel-refresh-idle-threshold

Seconds of input-idle before the next input event triggers a forced re-emit of every [Selkie::Widget::Image](Selkie--Widget--Image.md)'s sprixel. Defends against terminals that drop or fail to redraw inline graphics on tab / window-focus changes — Kitty's per-tab compositor is the canonical example: switching to another Kitty tab and back typically restores the cell buffer but **not** the direct-placement graphics that notcurses emitted there, so any Image whose owning widget hasn't dirtied since the switch stays visually blank even though Selkie's blit-plane cache thinks it's still live. The fix surface is the next input event after the user comes back: the run loop compares `now - last-activity` against this threshold and, when it crosses, calls `force-refresh-sprixels` before dispatching the input. That's the cheapest available signal — terminal focus events (CSI 1004) aren't surfaced through notcurses's input parser, and we can't read raw stdin in parallel without fighting notcurses for it. Defaults: `5e0` on Kitty (detected via `KITTY_WINDOW_ID` or `TERM` starting with `xterm-kitty`); `0e0` (disabled) elsewhere. Set to `0e0` explicitly to opt out even on Kitty, or to a higher value to reduce the frequency of refreshes during normal pauses. The cost of a triggered refresh is roughly `O(images-in-tree)` destroy-blit-plane calls plus the same number of fresh blits on the next render — sub-millisecond per Image on terminals where the image data is cached terminal-side (Kitty by image ID), a few ms per Image elsewhere. The tree walk itself is free on apps without Images.

### has Str $.error-log

Path to a file that receives everything that would normally hit stderr while the app is running — Raku warnings (`Use of uninitialized value …`), runtime failures logged via `note`, C-level libraries writing to stderr, notcurses internal diagnostics, etc. Without this, warnings splat into the TUI compositor's cell grid and produce visible garbage that stays on screen until the next full repaint — a TUI can't share stderr with its own drawing surface. When set, `Selkie::App` uses `dup2(2)` on construction to point file descriptor 2 at this file (append mode); the saved copy of the original stderr is restored on `shutdown`. Parent directory is auto-created. A new "=== session …" banner is written at the top of each run so long-lived log files stay navigable. Leave as `Str` (the type object) to disable the redirect — then stderr goes where it would normally.

### method screen-manager

```raku
method screen-manager() returns Selkie::ScreenManager
```

The screen manager. Useful for `.active-screen` and `.screen-names` — you don't typically need to manipulate it directly, since the `add-screen` and `switch-screen` methods on `Selkie::App` are preferred.

### method collect-tree-roots

```raku
method collect-tree-roots() returns List
```

Yield the live list of widget tree roots that framework-level helpers should walk: the active screen's root, every modal in the stack (top-most last), and the toast overlay if mounted. Used by [Selkie::Tree](Selkie--Tree.md)'s `mark-widgets-in-rect-dirty` (cell cleanup after sprixel destroy) and analogous helpers. Re-evaluated on every call so callers always see the current tree, even immediately after `show-modal` / `close-modal` or screen switches.

### method active-modal-or-nil

```raku
method active-modal-or-nil() returns Mu
```

The topmost open modal as a defined widget, or Nil when the stack is empty. Wraps the existing `!active-modal` private method which returns the Modal type object when empty — Nil is what [Selkie::Tree](Selkie--Tree.md)'s `current-active-modal` contract expects.

### method detect-sprixel-bug-prone-terminal

```raku
method detect-sprixel-bug-prone-terminal() returns Bool
```

True when the host terminal is one of the known sprixel-survives-tab-switch problem cases (currently: Kitty). Used by `TWEAK` to pick the default `sprixel-refresh-idle-threshold` for apps that don't override. Exposed as a regular method (callable on the type object) so the detection logic is unit-testable without spinning up notcurses — `Selkie::App.detect-sprixel-bug-prone-terminal()` runs cleanly in plain Raku.

### method force-refresh-sprixels

```raku
method force-refresh-sprixels() returns Nil
```

Walk the live tree (active screen + modals + toast) and force every [Selkie::Widget::Image](Selkie--Widget--Image.md) to re-emit its sprixel on the next render: destroy the live blit-plane (which also clears the Image's geometry cache so the next render takes the cache-miss path) and mark the Image dirty (in case it had no live blit-plane and so destroy was a no-op — the mark-widgets-in-rect-dirty walk inside destroy only fires for Images that actually had a blit to tear down). Called automatically by `run` when an input event arrives after `sprixel-refresh-idle-threshold` seconds of input-idle (the "user came back from a Kitty tab" heuristic). Apps can also call it directly — e.g., from a manual-refresh keybind, or after an operation known to bypass the auto-refresh signal.

### method mark-all-images-dirty

```raku
method mark-all-images-dirty() returns Nil
```

Walk the live tree (active screen + modals + toast) and mark every [Selkie::Widget::Image](Selkie--Widget--Image.md) dirty. Used after modal mount / unmount / toast hide / terminal resize so each Image's render method runs its geometry-diff on the next frame and emits or destroys its blit-plane as appropriate. Cheap walk; called only on occlusion-state transitions and resizes.

### method park-images-in-rect

```raku
method park-images-in-rect(
    Int :$abs-y!,
    Int :$abs-x!,
    Int :$rows! where { ... },
    Int :$cols! where { ... }
) returns Nil
```

Walk the live tree and call `park` on every [Selkie::Widget::Image](Selkie--Widget--Image.md) whose absolute screen bounds intersect the given rectangle. Used by the toast layer to push Images underneath the toast strip out of the visible area. Parked Images move their plane to an absolute-off-viewport row regardless of cascade depth, and their blit-plane is destroyed; the next layout pass that calls `reposition` on Image automatically un-parks.

### method focused

```raku
method focused() returns Selkie::Widget
```

The widget that currently has focus, or `Nil` if none.

### method event-supply

```raku
method event-supply() returns Supply
```

A Supply that emits every event received by the app. Tap this for global event logging, analytics, or cross-cutting behaviour that doesn't fit the per-widget handler model.

### method root

```raku
method root() returns Selkie::Container
```

Convenience accessor for the active screen's root container. Equivalent to `$app.screen-manager.active-root`. Returns `Nil` if no screen is active.

### method set-theme

```raku
method set-theme(
    Selkie::Theme:D $theme
) returns Mu
```

Swap the active theme at runtime. Updates the app's theme attribute, repaints the stdplane base cell, cascades `set-theme` to every registered screen's root widget (which in turn walks their subtrees), and marks every screen dirty so the next frame re-renders with the new palette. App consumers that hold their own cached Style objects derived from a theme's slots still need to rebuild those — set-theme can't reach closures that copied style values at construction time. The primary guarantee here is "every plane's base cell and every widget's inherited theme updates"; cached styles at the consumer layer are the consumer's responsibility.

### method add-screen

```raku
method add-screen(
    Str:D $name,
    Selkie::Container $root
) returns Mu
```

Register a screen under a name. The screen's root container is attached to the theme, the store, and the notcurses stdplane, then parked either at origin (if it's the first screen added) or off-screen (for subsequent screens — `switch-screen` will move it to origin when activated). Re-registering a name (common pattern: an overlay screen rebuilt each time it opens) discards any stashed per-screen focus from the previous incarnation — that widget is about to be destroyed.

### method switch-screen

```raku
method switch-screen(
    Str:D $name
) returns Mu
```

Activate a registered screen by name. The previously-active screen is parked off-screen; the new one is moved to the origin, resized to full terminal dimensions, and marked dirty so its entire subtree renders fresh on the next frame. Focus follows the user: before switching, the outgoing screen's focused widget is stashed in per-screen focus memory (if it's still attached to that screen's tree). On arrival, the incoming screen's last-focused widget is restored — or, if the screen has never been visited (or the saved reference went stale), focus lands on the first focusable widget in the new tree. Apps don't need to manage focus across screen transitions themselves.

### method set-title

```raku
method set-title(
    Str:D $title
) returns Mu
```

Set the terminal window title via OSC 0 ("icon name + window title"). Writes directly to `/dev/tty` to bypass notcurses's output buffering -- the stdplane's double-buffered render path can otherwise stomp interleaved escape sequences. Handles three common cases: =item Bare terminal -- emits `ESC]0;TITLE BEL`. =item Inside tmux (`$TMUX` set) -- wraps in the DCS passthrough (`ESC Ptmux; ... ESC \\`) so the host terminal actually sees it. Requires `set -g allow-passthrough on` in tmux >= 3.3, which is the default from 3.4 onward. =item No `/dev/tty` available (tests, piped stdin) -- silently no-op. Control characters (ESC, BEL, CR, LF) in `$title` are stripped before emission so a hostile title string can't terminate the sequence early or inject further escapes.

### method build-title-osc

```raku
method build-title-osc(
    Str:D $title,
    Bool :$tmux = Bool::False
) returns Str
```

Build the OSC sequence for a title. Factored out as a class method so tests can exercise the sanitisation + tmux-passthrough logic without needing a real tty. Public for callers that want to emit the sequence elsewhere (logging, snapshot tests, etc).

### method build-terminal-cleanup-sequence

```raku
method build-terminal-cleanup-sequence() returns Str
```

Build the "exit terminal protocol" escape sequence emitted by `shutdown` as a belt-and-suspenders layer on top of `notcurses_stop`. Why it exists: `notcurses_stop` handles most of this on most terminals, but the Kitty keyboard protocol push (`CSI ` n u>) doesn't reliably pop on iTerm2 — after the app exits every keystroke arrives as `CSI codepoint u` at the shell and the user sees literal escape codes where typing should be. The Kitty pop is the load-bearing fix; the other disables ride along on the same emit because every one is idempotent ("disable a mode that's already off" is a no-op on every terminal that parses them), so any future protocol leak that `notcurses_stop` misses is also covered. Sequences, in order: show cursor, reset SGR, mouse tracking off (every encoding variant), focus event reporting off, bracketed paste off, modify-other-keys off, Kitty kbd protocol pop ×3 (overshoots a single push in case anything else nested), alt-screen exit. Factored as a class method so tests can verify the exact bytes without spinning up a notcurses instance.

### method emit-terminal-cleanup

```raku
method emit-terminal-cleanup() returns Nil
```

Write the terminal-cleanup escape sequence to `/dev/tty` as the final step of `shutdown`. Bypasses notcurses's output buffering by going direct to the tty (same pattern as `set-title`). Best-effort: missing `/dev/tty`, failed open, or failed write are all silently swallowed — at this point the app is shutting down and there's nowhere useful to surface an error.

### method log-terminal-startup-state

```raku
method log-terminal-startup-state() returns Nil
```

One-shot startup diagnostic for terminal-pixel issues. Writes a summary of the chosen pixel implementation, cell + cell-pixel dimensions, and the env vars notcurses uses for terminal identification to `/tmp/selkie-terminal-debug.{pid}.log`. Gated on `SELKIE_TERMINAL_DEBUG=1` so it never fires in production. Used to diagnose Image-rendering bugs that differ across terminals (e.g. AvatarList renders empty in iTerm2 but works in Kitty / Terminal.app).

### method toast

```raku
method toast(
    Str:D $message,
    Num :$duration = 2e0
) returns Mu
```

Show a temporary message bar at the bottom of the screen. It auto-dismisses after `$duration` seconds (default 2). The toast overlay is created lazily on first call — subsequent toasts reuse the same widget.

### method maybe-unpark-toast

```raku
method maybe-unpark-toast(
    Bool $just-hidden
) returns Mu
```

Mark Images dirty under the now-hidden toast so they re-blit on the next render. `$!toast.tick` returns True on the frame the toast visibility flips off; the main loop already uses that to force an extra render. We hook the same signal to mark Images dirty so any blit destroyed when the toast mounted gets restored.

### method show-modal

```raku
method show-modal(
    Selkie::Widget::Modal $modal
) returns Mu
```

Show a modal dialog. The currently-focused widget is remembered and restored when the modal closes. While a modal is open, all events are routed through it (focus trap); only `Tab`, `Shift-Tab`, and `Esc` reach the app's global keybinds. Modals stack: calling `show-modal` while another modal is already open pushes the new modal on top. `close-modal` pops the top, so the previous modal becomes active again with all its keybinds intact. This is how a confirm dialog opened from inside an editor returns focus to the editor when dismissed.

### method close-modal

```raku
method close-modal() returns Mu
```

Close the topmost modal, restore the matching pre-modal focus target, and mark the now-revealed surface dirty so it re-renders over the area the closing modal covered. No-op if no modal is open. With nested modals, popping the top reveals the modal underneath — that becomes the new active modal, and focus is restored to the widget inside it that had focus right before the popped modal opened. When the stack drains to empty, focus restores against the active screen. The pre-modal focus target is validated against the live tree before restoration — if the widget was destroyed while the modal was open (e.g. the modal's action removed the previously-focused row from a list), focus falls through to the first focusable on the now-active surface instead of dangling.

### method has-modal

```raku
method has-modal() returns Bool
```

True while at least one modal is currently being displayed.

### method on-key

```raku
method on-key(
    Str:D $spec,
    &handler,
    Str :$screen
) returns Mu
```

Register a global keybind. The spec is a string matching [Selkie::Event](Selkie--Event.md)'s syntax (`'ctrl+q'`, `'f1'`, `'ctrl+shift+a'`, etc). Pass `:screen` to scope the bind to a single named screen — it will only fire when that screen is active. Leave `:screen` unset for a truly global bind like Ctrl+Q for quit. Global keybinds must include a modifier (Ctrl, Alt, Super) to avoid clashing with text input. Bare character binds belong on focusable widgets that own the key.

### method on-frame

```raku
method on-frame(
    &callback
) returns Mu
```

Register a callback that fires once per frame (~60 times per second), regardless of input. Use this for: =item Timer and countdown logic =item Animations and indeterminate progress bars (`$widget.tick`) =item Pulling from external streams that aren't tied to user input Multiple callbacks can be registered; they run in registration order.

### method on-resize

```raku
method on-resize(
    &callback
) returns Mu
```

Register a callback that fires when the terminal is resized. Receives the new `($rows, $cols)` as positional arguments. Fires after the widget tree has been re-laid-out and the post-resize frame composited, so callbacks can safely walk the live tree and take action against the new dimensions. The primary use case is bitmap (Kitty / Sixel) sprixel cleanup: pixel blitters paint directly to the terminal and don't follow plane reflows, so a sidebar avatar painted in column 3 stays in column 3 after a horizontal resize even if its plane has moved. Parking the `Selkie::Widget::Image` in the resize callback drops the stale sprixel; the next render rebuilds it at the new position.

### method focus

```raku
method focus(
    Selkie::Widget $w
) returns Mu
```

Move focus to a specific widget. The previously-focused widget's `set-focused(False)` is called (if it has one); the new widget's `set-focused(True)` is called. A `ui/focus` event is dispatched to the store so subscribers (e.g. `Selkie::Widget::Border`) can update their appearance. Passing an undefined widget is treated as "focus the first focusable on the active surface" — Selkie maintains the invariant that `$!focused` is attached whenever focusable widgets exist. The only legitimate "focus: nothing" state is a surface with zero focusables, in which case `$!focused` stays undefined.

### method widget-attached

```raku
method widget-attached(
    Selkie::Widget $w,
    $root
) returns Bool
```

True iff walking up `$w`'s parent chain reaches `$root`. Used internally to validate that a saved focus reference (in `%!screen-focus` or `@!pre-modal-focus-stack`) is still attached to the live tree before we try to restore it. O(tree depth); cheap. Public (rather than private with a leading bang) so tests can exercise the logic via the type object — `Selkie::App.widget-attached(...)` works without constructing an App instance (which would require `notcurses_init`). Apps rarely need to call this directly.

### method check-focus-invariant

```raku
method check-focus-invariant() returns Mu
```

Verify that `$!focused` is still attached to the input-owning surface (the active modal, or the active screen). If it's dangling — its container was removed, its screen was destroyed, etc. — re-focus the first focusable on the surface. No-op when focus is already valid, or when nothing was focused to begin with. Called automatically at the top of every event-loop iteration. Exposed as a public method mainly so tests can drive the guard directly without spinning `run` — apps don't normally need to call it.

### method focus-next

```raku
method focus-next() returns Mu
```

Move focus to the next focusable widget in the tree. Wraps around at the end. Bound to `Tab` by default.

### method focus-prev

```raku
method focus-prev() returns Mu
```

Move focus to the previous focusable widget. Wraps around at the beginning. Bound to `Shift-Tab` by default.

### method quit

```raku
method quit() returns Mu
```

Signal the event loop to exit. `run` returns after the current frame completes; the terminal is restored by `shutdown`.

### method run

```raku
method run() returns Mu
```

Start the event loop. Blocks until `quit` is called or an unhandled exception bubbles up. The loop wakes on a 16ms input timeout (up to 60 Hz) and handles: input polling, event dispatch, frame callbacks, store tick, focus action processing, toast tick, and rendering. Idle work is minimized on each dimension: resize polling is throttled to ~12 Hz, the store tick only walks subscriptions when events were processed, and the renderer only composites to the terminal when a widget actually rendered (or the toast just auto-dismissed). A static screen produces near-zero CPU. The loop body is wrapped in a `CATCH` block: any thrown exception triggers an orderly shutdown, prints a backtrace to STDERR, and exits the process with status 1.

### method is-paste-char

```raku
method is-paste-char(
    Selkie::Event $ev
) returns Bool
```

True when an input event is a "paste-eligible" character — a plain printable key with no Ctrl / Alt / Super modifier. Used by the drain loop to decide what to accumulate into the paste batch. Mod-Shift is allowed because capital letters arrive with it set and must be batched alongside everything else.

### method flush-paste-batch

```raku
method flush-paste-batch(
    Str:D $text
) returns Mu
```

Apply an accumulated paste batch to the focused widget's `insert-text` method. Caller has already verified that the focused widget supports the method, so this is a single dispatch plus one O(n) buffer rebuild — total cost O(n) for the whole paste, not O(n²) the per-char path would incur.

### method dispatch-mouse

```raku
method dispatch-mouse(
    Selkie::Event $ev
) returns Mu
```

Coordinate-based dispatch for mouse events. Resolves the deepest widget whose on-screen rectangle contains the click point, applies drag-capture so motion / release stay routed to the press's target, performs click-to-focus on focusable presses, annotates presses with their double / triple multiplicity, then bubbles the event up the parent chain — same consume-or-bubble rule as keyboard events. Modal isolation matches the keyboard path: clicks outside the active modal are dropped (or trigger a synthesized close on modals that opted in via `dismiss-on-click-outside`).

### method is-button-id

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

True iff the given id is one of the `NCKEY_BUTTON1..11` keycodes. Pure motion (`NCKEY_MOTION`) and other input ids return False.

### method button-from-id

```raku
method button-from-id(
    Int $id where { ... }
) returns UInt
```

Extract the 1-indexed button number from a button event id, or `0` for non-button events (motion, scroll wheel — which is technically buttons 4/5 but tracked as scroll, not press/release). Used for capture keying.

### method widget-at

```raku
method widget-at(
    Int $y,
    Int $x
) returns Selkie::Widget
```

Return the deepest widget whose on-screen rectangle contains the given absolute cell. Walks the active modal's content if a modal is open, otherwise the active screen's root. Children are tried after parents (depth-first), so a click inside a nested widget correctly resolves to the nested one. Type object if no widget matches (e.g. click in a region nothing has rendered to).

### method widget-at-in

```raku
method widget-at-in(
    $root,
    Int $y,
    Int $x
) returns Selkie::Widget
```

Public hit-test against an arbitrary root. Returns the deepest widget whose on-screen rectangle contains the given absolute cell, falling back to `$root` itself when the point is in the root's own bounds but no descendant claims it. Returns the `Selkie::Widget` type object when the root doesn't contain the point. Two-phase resolution: =item **Phase 1**: walk the whole tree looking for any widget whose `claims-overlay-at` returns True. This catches widgets that paint outside their nominal rect (open dropdowns, popovers) — the layout-aware walk would miss them because their parent's `contains-point` doesn't extend over the overlay area. =item **Phase 2**: fall through to the standard depth-first containment walk. Exposed (rather than left private) so tests can exercise the coordinate-walk logic without needing a live App instance — same pattern as widget-attached. App's mouse dispatcher uses this with the active modal / screen root resolved at call time.

### method check-terminal-resize

```raku
method check-terminal-resize() returns Bool
```

Check whether the terminal has been resized and, if so, propagate new dimensions through the widget tree and force a full terminal re-sync. Called from `!maybe-check-terminal-resize` when our SIGWINCH tap (installed in TWEAK) signals a pending resize. Also called synchronously by `!dispatch-event` when a `ResizeEvent` arrives through the input queue (legacy path; with `NCOPTION_NO_WINCH_SIGHANDLER` set, notcurses no longer emits these — but the path is harmless to keep as a defensive backstop). Calls `notcurses_refresh` at the top to force notcurses to re-query terminal dimensions via TIOCGWINSZ. Without this we'd own the SIGWINCH handler but never tell notcurses about the new size — its stdplane stays at the old dim and nothing reflows. The refresh also re-emits every cell at the (possibly new) dimensions; the trailing refresh after render does the same job against the freshly composited frame to nail down terminal state. Two refreshes per resize event is acceptable for a one-time signal-driven flow. Returns True if dims actually changed.

### method maybe-check-terminal-resize

```raku
method maybe-check-terminal-resize() returns Bool
```

Flag-gated wrapper around `!check-terminal-resize`. Called from the main loop every frame; runs the underlying check only when our SIGWINCH tap has marked `$!resize-pending`. The CAS clears the flag atomically before the check runs, so if a second SIGWINCH fires mid-check the flag will be set again and re-trigger on the next loop iteration. Returns True when a dim change was actually detected and the UI re-flowed; False otherwise. Used by the idle ladder to treat a resize as activity.

### method render-frame

```raku
method render-frame(
    Bool :$force = Bool::False
) returns Mu
```

Render any dirty parts of the widget tree and, if anything actually rendered, composite the frame to the terminal via `notcurses_render`. The composite is **gated on whether any widget rendered this frame**. On a static screen — no dirty widgets, no visible toast — the frame is a no-op: we skip the compositor, the terminal diff, and the pty writes that would otherwise run ~60 Hz while idle. The `:force` flag overrides the gate. It's set by the main loop when `Toast.tick` reports that visibility just flipped off: the previous composite still shows the toast, so we need one more render to erase it even though no widget is dirty.

### method shutdown

```raku
method shutdown() returns Mu
```

Shut down notcurses and destroy the active modal and screen manager. Idempotent — safe to call multiple times. Usually you don't call this directly; the event loop's CATCH, the END phaser, or `DESTROY` takes care of it. Each cleanup step is best-effort: an exception thrown in modal destroy, screen-manager destroy, or `notcurses_stop` (e.g. when a NativeCall dlopen fails because Notcurses-Native was reinstalled to a new path mid-session) is caught, logged via `!try-log`, and isolated so the later steps — TTY restore, the escape-sequence backstop in `!emit-terminal-cleanup`, and `!uninstall-error-log` — still run. Without this isolation, a single failure mid-shutdown would strand the terminal in raw mode / alt-screen / Kitty-kbd- protocol-pushed state.

### method set-error-log

```raku
method set-error-log(
    Str $path
) returns Mu
```

Swap the active error-log file at runtime. Tears down the current redirection (restoring fd 2 to the original stderr), updates the path, and reinstalls the redirect pointing at the new file. Passing `Str` (the type object) or an empty string disables redirection — fd 2 goes back to wherever it pointed before the first `install-error-log`. Useful for apps whose log location only becomes known after some runtime event. App::Cantina is the canonical consumer: the path is `{cantina-home}/{db-name}/error.log`, and `db-name` is only known after the user selects / creates a profile on the login screen. The app boots with `error-log` unset, then calls `set-error-log` from its post-login handler. A new session banner is written to the new log file on each invocation so interleaved runs stay navigable. No-op (save for the banner) when called with the same path it already has.

