Selkie.git | docs/api/ | Selkie--App.md


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 runs at the hot rate (default 60 Hz, see `hot-hz`) while the user is interacting, then steps down through an idle ladder — 30 Hz, 12 Hz, and finally 4 Hz after two minutes — so a passive TUI costs near-zero battery. Background store traffic (streaming repaints, worker progress events) keeps the loop ticking at up to 30 Hz, but only **user** activity (input, resize) drives it back to the hot rate. 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.

Fragmented terminal reports
---------------------------

Terminals answer capability queries (device attributes, cursor position, window geometry, `XTGETTCAP`, `XTVERSION`, the kitty keyboard and graphics protocols, OSC colour queries) by writing an escape sequence back on the tty, indistinguishable at the byte level from the user typing it. notcurses recognises and swallows those replies — but only when the whole reply arrives in a single read. A reply split across two reads, which takes a scheduling delay of under a millisecond between the terminal's two writes, is replayed to the application one keypress per byte.

Into a focused text input, that is a line of garbage nobody typed. It is not theoretical: it put twenty-five characters of `[?64;1;2;6;9;15;18;21;22c` in front of a passphrase in a masked login field, on a machine whose cores were all busy, where the only visible symptom was a few extra bullets and a rejected password.

`Selkie::App` defends against this in its input dispatch. An Escape-led burst is reassembled — waiting briefly, and only while what has arrived so far is still a valid partial control sequence — and any complete terminal report is dropped instead of dispatched. Ordinary typing cannot be caught by it: a match needs both a byte sequence nobody types and an arrival rate nobody can type at. Nothing is required of application code; see `Selkie::App::Internal::TerminalReport` for the grammar and the reasoning.

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
};
```

Tweened animation
-----------------

`on-frame` is the raw seam; for anything that should take a fixed amount of **time**, use `tweens` instead. The app owns one [Selkie::Tween](Selkie--Tween.md) group, ticks it once per frame, and — crucially — holds the render loop at the hot rate while anything in it is running, so a 150 ms flash isn't sampled twice because the user had been idle for a minute:

```raku
use Selkie::Tween;

$app.tweens.add: Tween.new(
    duration    => 0.15,
    easing      => EaseOutQuad,
    on-update   => -> Num $t {
        $row.set-style(lerp-style($flash-style, $normal-style, $t));
        $row.mark-dirty;              # nothing renders without this
    },
    on-complete => { $row.set-style($normal-style) },
);
```

When the last tween finishes the app drops back onto the idle ladder by itself. For animation that isn't an interpolation, `begin-animation` / `end-animation` pin the loop directly (refcounted, clamped at zero).

Built-in motion (opt-in)
------------------------

Two of the framework's own overlays can animate themselves. Both are **off by default** — an app that doesn't ask for motion renders exactly what it always did, down to the byte — and both are one constructor flag:

```raku
my $app = Selkie::App.new(
    theme            => $theme,
    animate-backdrop => True,    # scrimmed modals fade their dim in
    animate-toast    => True,    # toasts resolve in and dissolve out
);

# Or later, e.g. from a settings screen:
$app.set-animate-backdrop(False);
```

  * **`animate-backdrop`** — `show-modal` ramps the scrim colour of a `backdrop => BackdropScrim` modal from the screen background to the theme's `modal-scrim` over ~120 ms. Modals on any other backdrop mode are untouched. There is deliberately **no out-fade**: `close-modal` tears the modal's planes down synchronously, and an animation outliving its plane is a crash waiting for a slow frame. See [Selkie::Widget::Modal](Selkie--Widget--Modal.md)'s `fade-backdrop-in`.

  * **`animate-toast`** — the toast bar fades up out of the screen background over ~100 ms and dissolves back over the last ~200 ms of its lifetime. See [Selkie::Widget::Toast](Selkie--Widget--Toast.md).

Both ride the same `tweens` group, so they pin the render loop to the hot budget only while they are actually running, and neither is ambient. Fades interpolate **colour**, never alpha — notcurses alpha is a two-bit enum ([Selkie::Alpha](Selkie--Alpha.md)).

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. Selkie marks every Image dirty during the resize pass so pixel blitters re-diff geometry and re-emit from the framework path; callbacks are for app-owned layout caches or telemetry that needs the final `($rows, $cols)`:

```raku
$app.on-resize: -> UInt $rows, UInt $cols {
    $layout-cache.recompute(:$rows, :$cols);
};
```

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 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 the **user** is interacting (input, resize); the idle ladder then steps down (to 30 / 12 / 4 Hz) after periods of inactivity. Background store traffic — streaming repaints, worker progress dispatches — holds the loop at no more than 30 Hz on its own; only user activity drives the full hot rate (see `pick-frame-budget`). 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 user-idle before the run loop begins forcing every [Selkie::Widget::Image](Selkie--Widget--Image.md) to re-emit its 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. User idle specifically — background store traffic (a streaming response, image-gen progress) doesn't reset the clock, so a tab switch during a long-running job still refreshes. The loop also runs this check before dispatching the first input after an idle gap, but it no longer waits for that input: once this threshold is crossed, the idle loop keeps re-emitting at `sprixel-refresh-idle-interval` until the user interacts again. Defaults: `0.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. The cost of a triggered refresh is roughly `O(images-in-tree)` destroy-blit-plane calls plus fresh blits for visible Images on the next render. The tree walk itself is free on apps without Images.

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

Minimum seconds between automatic idle sprixel refreshes once `sprixel-refresh-idle-threshold` has been crossed. Defaults to `0.5e0`, which means a Kitty tab regains images within one idle frame or two at Selkie's deep-idle 4 Hz cadence without continuously re-emitting on every frame.

### has Str $.error-log

Path to a file that receives Raku stderr while the app is running — Raku warnings (`Use of uninitialized value …`), runtime failures logged via `note`, and, on POSIX, C-level fd 2 writes. Windows also redirects native writers that obtain `STD_ERROR_HANDLE` with `GetStdHandle`; already-initialized private CRT `stderr` streams cannot be changed safely in process. 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` redirects stderr to this file (append mode) on construction and restores it on `shutdown`. POSIX uses `dup2`; Windows rebinds Raku's process error handle and independently installs an append-only Win32 standard-error handle. Parent directory is auto-created. A new "=== session …" banner is written at the top of each run so long-lived log files stay navigable. If the platform redirect cannot be established, logging fails open and application startup continues. Leave as `Str` (the type object) to disable the redirect — then stderr goes where it would normally.

### has Bool $.animate-backdrop

Fade a `BackdropScrim` modal's scrim in when `show-modal` puts it up, instead of stamping it on at full strength. **False by default**, and that default is load-bearing: an app that doesn't opt in registers no tween, allocates no tween group, and composites the identical cells it always did. Nothing else about the modal changes — the fade only touches the scrim colour, and only in `BackdropScrim` mode. There is no matching fade-out. `close-modal` destroys the modal synchronously, so an out-fade would have to paint into a widget that has already given up its planes. See [Selkie::Widget::Modal](Selkie--Widget--Modal.md)'s "Fading the scrim in". Runtime-settable via `set-animate-backdrop`.

### has Bool $.animate-toast

Fade the toast bar in on show and out as its duration runs down, instead of having it appear and vanish. **False by default**, on the same reasoning as `animate-backdrop`: a consumer that never asks for motion pays nothing and sees nothing change. Timings live on [Selkie::Widget::Toast](Selkie--Widget--Toast.md) (`fade-in-seconds`, `fade-out-seconds`). Runtime-settable via `set-animate-toast`. Takes effect from the next `toast` call.

### 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 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 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 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 `$*TMPDIR/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 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,
    Str :$name = ""
) 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, refresh app-owned layout caches, and log the final dimensions. Selkie handles Image/sprixel dirtying internally during the resize pass; consumers should not need a resize callback just to force framework Images to re-blit.

### method tweens

```raku
method tweens() returns Selkie::Tween::TweenGroup
```

The app's [Selkie::Tween](Selkie--Tween.md) group. Add a tween to it and the app takes care of the rest: ticking it once per frame, holding the render loop at the hot budget for as long as it runs, and dropping it when it finishes. =begin code :lang<raku> $app.tweens.add: Tween.new( duration => 0.15, easing => EaseOutQuad, on-update => -> Num $t { $row.set-style(lerp-style($hit, $calm, $t)); $row.mark-dirty }, ); =end code Created on first call, along with the single `'selkie-tweens'` frame callback that drives it — an app that never animates never allocates either. Read [Selkie::Tween](Selkie--Tween.md)'s Pod before using this: every `on-update` must mark something dirty, and tweens must be bounded rather than ambient, or the idle ladder never engages again.

### method begin-animation

```raku
method begin-animation() returns Int
```

Increment the animation refcount, pinning the render loop to the hot frame budget (`hot-hz`) regardless of how long the user has been idle. Returns the new count. `tweens` manages this for you; call it directly only for animation that isn't an interpolation — a spinner running alongside a background job, say — and always pair it with `end-animation` on a path that cannot be skipped: =begin code :lang<raku> $app.begin-animation; LEAVE $app.end-animation; =end code An unbalanced `begin-animation` holds the app at 60 Hz for the rest of the session.

### method end-animation

```raku
method end-animation() returns Int
```

Decrement the animation refcount, releasing the render loop back to the idle ladder when it reaches zero. Returns the new count. Clamped at zero: an extra `end-animation` is harmless.

### method animating

```raku
method animating() returns Bool
```

True while the animation refcount is above zero. The render loop checks this once per frame and skips the idle ladder when it's set.

### method set-animate-backdrop

```raku
method set-animate-backdrop(
    Bool:D $on
) returns Nil
```

Turn the scrimmed-modal backdrop fade on or off at runtime — a "reduce motion" preference, say. Applies to the next `show-modal`; a fade already running is left to finish.

### method set-animate-toast

```raku
method set-animate-toast(
    Bool:D $on
) returns Nil
```

Turn the toast fade on or off at runtime. Either way it reaches the toast widget on the next `toast` call, which is where the flag is read.

### 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. A `disabled` widget is refused outright: the call is a no-op and focus stays where it was. That mirrors the Tab cycle and click-to-focus, both of which skip disabled subtrees, and it means code that focuses a field after a store update doesn't have to re-check whether the field happens to be disabled right now.

### method focus-eligible

```raku
method focus-eligible(
    Selkie::Widget $w
) returns Bool
```

True iff `$w` is a widget `focus` will actually move focus to: defined, and not `disabled`. Deliberately does **not** consult `focusable`. `focus` has always accepted a non-focusable widget when an app asks for it explicitly — `focusable` gates the Tab cycle, not direct focus — and narrowing that here would break apps that focus a custom surface by hand. Public (rather than private with a leading bang) for the same reason `widget-attached` is: `Selkie::App.focus-eligible(...)` works against the type object, so the rule is testable without constructing an App (which would require `notcurses_init`).

### 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`.

### sub pick-frame-budget

```raku
sub pick-frame-budget(
    Num:D $hot-budget,
    Num:D $user-idle-for,
    Num:D $store-idle-for,
    Bool:D :$animating = Bool::False
) returns Num:D
```

Pick the frame budget (seconds the loop may spend on this iteration, i.e. 1/Hz) from how long each activity source has been idle. `$user-idle-for` counts seconds since the last input event, resize, or toast visibility change; `$store-idle-for` counts seconds since the last store event or write. User activity drives the full ladder back to the hot rate. Store activity alone is floored at `IDLE-HALF` (30 Hz) — background dispatch traffic keeps the loop responsive enough to drain the queue promptly without pinning it at 60 Hz for the duration of a long-running worker. Whichever source wants the faster rate wins, and the result is clamped to never exceed the configured hot rate. `:animating` short-circuits all of that and returns the hot budget — a running animation has to be sampled at full rate whether or not anything else has happened lately, and unlike poking the user-activity clock this leaves no 30-second hot tail behind once the animation ends. See `animating` and [Selkie::Tween](Selkie--Tween.md). A package sub (not a closure in `run`) so the tier math is unit testable without standing up notcurses.

### method run

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

Start the event loop. Blocks until `quit` is called or an unhandled exception bubbles up. Each iteration handles: input polling, event dispatch, frame callbacks, store tick, focus action processing, toast tick, and rendering. The tick rate follows the idle ladder (see `pick-frame-budget`): the hot rate while the user interacts, stepping down to 4 Hz at deep idle; store-only activity holds it at no more than 30 Hz. 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 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 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 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 and restores the stderr views saved by `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.