Selkie.git | lib/Selkie/ | App.rakumod


=begin pod

=head1 NAME

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

=head1 SYNOPSIS

=begin code :lang<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

=end code

=head1 DESCRIPTION

C<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:

=item Builds a widget tree
=item Registers it as a screen with C<add-screen>
=item Activates a screen with C<switch-screen>
=item Picks an initial focused widget with C<focus>
=item Registers global keybinds with C<on-key>
=item Starts the loop with C<run>

The loop runs at the hot rate (default 60 Hz, see C<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 B<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.

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

=head2 Theme background

When constructed with a C<theme>, C<Selkie::App> paints the notcurses
standard plane's base cell from C<$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 C<Selkie::Widget>
doing the same per-plane on C<init-plane> / C<set-theme> / each
C<apply-style>, this gives themed backgrounds full-terminal coverage
— no gaps between widgets or at screen edges.

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

=head2 Default keybinds

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

=item C<Tab> / C<Shift-Tab> — cycle focus through focusable descendants
=item C<Esc> — close the active modal (no-op if none)
=item C<Ctrl+Q> — quit the app

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

=head2 Fragmented terminal reports

Terminals answer capability queries (device attributes, cursor
position, window geometry, C<XTGETTCAP>, C<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
C<[?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.

C<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
C<Selkie::App::Internal::TerminalReport> for the grammar and the
reasoning.

=head1 LIFECYCLE

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

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

C<shutdown> itself is exception-isolated step-by-step: a throw in modal
destroy, screen-manager destroy, or C<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.

C<run> wraps the event loop in a C<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.

=head1 EXAMPLES

=head2 A single-screen app

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

=begin code :lang<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;

=end code

=head2 Multiple screens

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

=begin code :lang<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]);

=end code

=head2 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 C<Esc>
closes it automatically:

=begin code :lang<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

=end code

Modals stack. Calling C<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. C<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 C<close-modal> to drain the stack.

=head2 A frame callback for animation

C<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:

=begin code :lang<raku>

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

=end code

=head2 Tweened animation

C<on-frame> is the raw seam; for anything that should take a fixed
amount of B<time>, use C<tweens> instead. The app owns one
L<Selkie::Tween> 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:

=begin code :lang<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) },
);

=end code

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

=head2 Built-in motion (opt-in)

Two of the framework's own overlays can animate themselves. Both are
B<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:

=begin code :lang<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);

=end code

=item B<C<animate-backdrop>> — C<show-modal> ramps the scrim colour of a C«backdrop => BackdropScrim» modal from the screen background to the theme's C<modal-scrim> over ~120 ms. Modals on any other backdrop mode are untouched. There is deliberately B<no out-fade>: C<close-modal> tears the modal's planes down synchronously, and an animation outliving its plane is a crash waiting for a slow frame. See L<Selkie::Widget::Modal>'s C<fade-backdrop-in>.
=item B<C<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 L<Selkie::Widget::Toast>.

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

=head2 Screen-scoped keybinds

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

=begin code :lang<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

=end code

=head2 Reacting to terminal resizes

C<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 C<($rows, $cols)>:

=begin code :lang<raku>

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

=end code

=head1 SEE ALSO

=item L<Selkie::Widget> — the base role every widget composes
=item L<Selkie::ScreenManager> — multi-screen management (used via C<add-screen> / C<switch-screen>)
=item L<Selkie::Store> — the reactive state store C<Selkie::App> owns
=item L<Selkie::Widget::Modal> — modal dialogs
=item L<Selkie::Event> — the keyboard / mouse event abstraction

=end pod

use Selkie::App::Internal::Animation;
use Selkie::App::Internal::ErrorLog;
use Selkie::App::Internal::Dispatch;
use Selkie::App::Internal::HitTest;
use Selkie::App::Internal::IdleBudget;
use Selkie::App::Internal::FocusTree;
use Selkie::App::Internal::OverlayTree;
use Selkie::App::Internal::RenderLoop;
use Selkie::App::Internal::ScreenModalLifecycle;
use Selkie::App::Internal::Terminal;
use Selkie::App::Internal::TerminalPlatform;
use Selkie::App::Internal::TerminalSequences;

unit class Selkie::App
    does Selkie::App::Internal::Animation
    does Selkie::App::Internal::Dispatch
    does Selkie::App::Internal::ErrorLog
    does Selkie::App::Internal::FocusTree
    does Selkie::App::Internal::OverlayTree
    does Selkie::App::Internal::RenderLoop
    does Selkie::App::Internal::ScreenModalLifecycle
    does Selkie::App::Internal::Terminal;

use NativeCall;

use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;
use Notcurses::Native::Input;
use Notcurses::Native::Channel;
use Notcurses::Native::Context;

use Selkie::Widget;
use Selkie::Container;
use Selkie::Theme;
use Selkie::Sizing;
use Selkie::Layout::VBox;
use Selkie::ScreenManager;
use Selkie::Widget::Modal;
use Selkie::Store;
use Selkie::Tween;

has NotcursesHandle $!nc;
has NcplaneHandle $!stdplane;

#| 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
#| C<run> has initialised notcurses.
method stdplane(--> NcplaneHandle) { $!stdplane }

#| The theme installed on every screen's root. Defaults to
#| C<Selkie::Theme.default> if not provided to C<.new>.
has Selkie::Theme $.theme;

#| The reactive store owned by this app. Constructed automatically on
#| C<.new>; every screen added to the app gets this store propagated
#| into its widget tree. Subscribe to state paths from widgets via
#| C<self.subscribe(...)>.
has Selkie::Store $.store = Selkie::Store.new;

#|( Taps for asynchronously-recoverable fatal signals (SIGABRT,
    SIGTERM, SIGHUP, SIGQUIT). When any of these arrives we run
    C<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 @!crash-restore-taps;

#|( Hot-rate frame budget in Hz. The main loop caps itself at this
    rate while the B<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 C<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
    C<1 / $hot-hz> seconds between frames, but may sleep longer
    when the idle ladder has ramped down. )
has Num $.hot-hz = 60e0;

#|( Seconds of user-idle before the run loop begins forcing every
    L<Selkie::Widget::Image> 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 B<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
    C<sprixel-refresh-idle-interval> until the user interacts again.

    Defaults: C<0.5e0> on Kitty (detected via C<KITTY_WINDOW_ID> or
    C<TERM> starting with C<xterm-kitty>); C<0e0> (disabled) elsewhere.
    Set to C<0e0> explicitly to opt out even on Kitty.

    The cost of a triggered refresh is roughly C<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-threshold;

#|( Minimum seconds between automatic idle sprixel refreshes once
    C<sprixel-refresh-idle-threshold> has been crossed. Defaults to
    C<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 Num $.sprixel-refresh-idle-interval;

#|( Path to a file that receives Raku stderr while the app is running — Raku warnings
    (C<Use of uninitialized value …>), runtime failures logged via
    C<note>, and, on POSIX, C-level fd 2 writes. Windows also redirects
    native writers that obtain C<STD_ERROR_HANDLE> with C<GetStdHandle>;
    already-initialized private CRT C<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, C<Selkie::App> redirects stderr to this file (append mode)
    on construction and restores it on C<shutdown>. POSIX uses C<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 C<Str> (the type object) to disable the redirect — then
    stderr goes where it would normally. )
has Str $.error-log;

#|( Fade a C<BackdropScrim> modal's scrim in when C<show-modal> puts it
    up, instead of stamping it on at full strength.

    B<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
    C<BackdropScrim> mode.

    There is no matching fade-out. C<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 L<Selkie::Widget::Modal>'s
    "Fading the scrim in".

    Runtime-settable via C<set-animate-backdrop>. )
has Bool $.animate-backdrop = False;

#|( Fade the toast bar in on show and out as its duration runs down,
    instead of having it appear and vanish.

    B<False by default>, on the same reasoning as C<animate-backdrop>: a
    consumer that never asks for motion pays nothing and sees nothing
    change. Timings live on L<Selkie::Widget::Toast>
    (C<fade-in-seconds>, C<fade-out-seconds>).

    Runtime-settable via C<set-animate-toast>. Takes effect from the
    next C<toast> call. )
has Bool $.animate-toast = False;

#| The screen manager. Useful for C<.active-screen> and C<.screen-names>
#| — you don't typically need to manipulate it directly, since the
#| C<add-screen> and C<switch-screen> methods on C<Selkie::App> are
#| preferred.
method screen-manager(--> Selkie::ScreenManager) { self!screen-manager }

method !notcurses-handle(--> NotcursesHandle) {
    $!nc;
}

#|( True when the host terminal is one of the known
    sprixel-survives-tab-switch problem cases (currently: Kitty).
    Used by C<TWEAK> to pick the default
    C<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 —
    C<Selkie::App.detect-sprixel-bug-prone-terminal()> runs
    cleanly in plain Raku. )
method detect-sprixel-bug-prone-terminal(--> Bool) {
    app-detect-sprixel-bug-prone-terminal;
}

#|( Walk the live tree (active screen + modals + toast) and force
    every L<Selkie::Widget::Image> 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 C<run> when an input event arrives after
    C<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 force-refresh-sprixels(--> Nil) {
    self!force-refresh-sprixels;
}

#| The widget that currently has focus, or C<Nil> if none.
method focused(--> Selkie::Widget) { self!focused-widget }

#| 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 event-supply(--> Supply) { self!app-event-supply }

#| Convenience accessor for the active screen's root container. Equivalent
#| to C<$app.screen-manager.active-root>. Returns C<Nil> if no screen is
#| active.
method root(--> Selkie::Container) { self!active-root }

submethod TWEAK() {
    $!theme //= Selkie::Theme.default;

    # Pick the auto-default for the idle-refresh threshold based on
    # the host terminal. Apps that pass a value explicitly skip
    # detection; passing 0e0 opts out even on a bug-prone terminal.
    # See the C<$.sprixel-refresh-idle-threshold> attribute doc for
    # the full rationale.
    unless $!sprixel-refresh-idle-threshold.defined {
        $!sprixel-refresh-idle-threshold = self.detect-sprixel-bug-prone-terminal
            ?? 0.5e0
            !! 0e0;
    }
    $!sprixel-refresh-idle-interval //= 0.5e0;

    # Redirect stderr to the log file BEFORE notcurses_init so any
    # banner text or terminal-capability probe output notcurses emits
    # during startup lands in the log instead of fighting with the
    # splash screen. C<NCOPTION_SUPPRESS_BANNERS> below covers the
    # intentional banner, but notcurses still writes diagnostics on
    # some terminals at init.
    self!install-error-log($!error-log) if $!error-log.defined && $!error-log.chars > 0;

    # On POSIX, NCOPTION_NO_WINCH_SIGHANDLER lets Selkie own SIGWINCH via
    # Raku's C<signal()> Supply. Letting notcurses install one too creates a
    # sigaction race — whoever installs second wins, and on macOS notcurses's
    # handler can absorb SIGWINCH without waking our event loop. Windows has
    # no SIGWINCH path here; notcurses reports resize as NCKEY_RESIZE input,
    # which dispatch calls through to C<!check-terminal-resize> directly.
    my $nc-flags = NCOPTION_SUPPRESS_BANNERS;
    $nc-flags +|= NCOPTION_NO_WINCH_SIGHANDLER
        if terminal-resize-signals().elems;
    my $opts = NotcursesOptions.new(flags => $nc-flags);

    # Capture the real terminal's termios state before notcurses enters
    # cbreak/raw-style mode. notcurses_stop should restore this itself,
    # but keeping our own snapshot gives Selkie a final backstop if an
    # exception or native failure leaves notcurses's restore path short.
    $!saved-tty-state = self!capture-tty-state;

    $!nc = notcurses_init($opts, Pointer);
    die "Failed to initialize notcurses" without $!nc;

    # Disable IXON / IXOFF flow control so Ctrl+Q / Ctrl+S reach the
    # application as keystrokes instead of being eaten by the tty
    # driver as XON / XOFF. notcurses's cbreak mode clears ECHO /
    # ICANON / ICRNL but leaves IXON set — which means on macOS
    # Terminal.app (and other terminals with default IXON on) our
    # Ctrl+Q quit keybind silently doesn't fire. Kitty has IXON off
    # by default so it worked there.
    #
    # notcurses_stop on shutdown restores the original termios state
    # (captured before notcurses touched it), so IXON comes back on
    # automatically when the app exits. No separate restore needed
    # on our side.
    disable-controlling-terminal-flow-control;

    notcurses_mice_enable($!nc, NCMICE_BUTTON_EVENT +| NCMICE_DRAG_EVENT);

    $!stdplane = notcurses_stdplane($!nc);
    my uint32 $r = 0;
    my uint32 $c = 0;
    notcurses_stddim_yx($!nc, $r, $c);
    self!set-terminal-size($r, $c);

    self!log-terminal-startup-state;

    # Paint the stdplane's base cell using the theme's `base` style
    # so gaps between widgets fall through to the theme's background
    # instead of the terminal's default. Notcurses composites child
    # planes over the stdplane, and any cell not explicitly written
    # shows the base-cell's channels.
    self!paint-stdplane-base if $!theme.defined;

    # Drain any pending terminal responses (color queries, etc.)
    my $drain-timeout = Timespec.new(tv_sec => 0, tv_nsec => 50_000_000);
    loop {
        my $ni = Ncinput.new;
        my $id = notcurses_get($!nc, $drain-timeout, $ni);
        last if $id == 0;
    }

    self!register-focus-handlers;

    # Wire the framework-level provider closures used by widgets that
    # need to reach beyond their own subtree:
    #   * terminal-viewport    — final intersection step in
    #     Selkie::Widget.effective-bounds, so widgets clipped past the
    #     terminal always have empty bounds.
    #   * tree-roots           — live list of trees to walk for
    #     mark-widgets-in-rect-dirty (cell cleanup after sprixel
    #     destroy in Selkie::Widget::Image).
    #   * active-modal         — topmost open modal or Nil, used by
    #     widgets to detect occlusion.
    self!install-tree-providers;

    # Install our SIGWINCH handler. The tap fires on whatever thread
    # libuv dispatches to (not the main loop's thread), so we use an
    # atomic CAS to set the flag — the main loop reads it from
    # C<!maybe-check-terminal-resize> on every iteration. Setting the
    # flag also wakes the chunked sleep in C<run> within ~16ms, so a
    # resize during deep idle (when the loop sleeps for up to 250ms)
    # is picked up almost immediately instead of waiting for input.
    self!install-resize-tap;

    # Crash-restore taps for the recoverable POSIX fatal signals. Windows
    # selects none: its console lifecycle is not a POSIX signal boundary.
    # The Raku
    # Supply dispatches on a scheduler thread, so the tap body runs
    # asynchronously — fine for these signals because the kernel
    # doesn't kill the process until the default action runs (and we
    # control that here). For each signal we run shutdown (idempotent;
    # restores the TTY and tears notcurses down) and then exit with
    # the conventional 128+signum code so callers know which signal
    # killed us.
    #
    # `signal()` is a Raku built-in returning a Supply per signal name.
    # We can't tap on SIGSEGV/SIGBUS/SIGILL/SIGFPE here usefully —
    # those are synchronous fatals whose default action runs before
    # the scheduler dispatches the tap, so the process is dead before
    # any Raku code in the tap body could execute. Restoring on those
    # requires a NativeCall sigaction handler in the offending thread;
    # leave that for a follow-up if it becomes necessary.
    my $self-for-trap = self;
    for terminal-crash-signals() -> $sig {
        my $tap = try signal($sig).tap: {
            $self-for-trap.shutdown;
            exit 128 + $sig.Int;
        };
        @!crash-restore-taps.push($tap) if $tap.defined;
    }

    # Safety net for unhandled exceptions between .new and .run. shutdown
    # is idempotent so there's no harm if run's CATCH already ran it.
    my $self = self;
    END { $self.shutdown if $self.defined }
}

# Apply $!theme.base to the stdplane's base cell so cells that no
# widget writes fall through to the theme background instead of
# whatever the terminal was using before. Called from init and from
# live theme swaps via C<set-theme>.
method !paint-stdplane-base() {
    return unless $!theme.defined && $!stdplane;
    my $base = $!theme.base;
    return without $base;
    my uint64 $channels = 0;
    if $base.fg.defined { ncchannels_set_fg_rgb($channels, $base.fg) }
    if $base.bg.defined { ncchannels_set_bg_rgb($channels, $base.bg) }
    ncplane_set_base($!stdplane, ' ', 0, $channels);
}

#|( 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 set-theme(Selkie::Theme:D $theme) {
    $!theme = $theme;
    self!paint-stdplane-base;
    self!set-theme-on-screens($theme);
}

method !register-focus-handlers() {
    $!store.register-handler('ui/focus', -> $store, %ev {
        (db => { ui => { focused-widget => %ev<widget> } },);
    });

    $!store.register-handler('ui/focus-next', -> $store, %ev {
        (db => { ui => { focus-action => 'next' } },);
    });

    $!store.register-handler('ui/focus-prev', -> $store, %ev {
        (db => { ui => { focus-action => 'prev' } },);
    });

    self.on-key: 'tab', -> $ { $!store.dispatch('ui/focus-next') };
    self.on-key: 'shift+tab', -> $ { $!store.dispatch('ui/focus-prev') };
    self.on-key: 'esc', -> $ { self!close-modal-via-esc };
    self.on-key: 'ctrl+q', -> $ { self.quit };
}

# --- Screen management ---

#|( 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 — C<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 add-screen(Str:D $name, Selkie::Container $root) {
    self!add-screen($name, $root);
}

#|( 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 switch-screen(Str:D $name) {
    self!switch-screen($name);
}

# --- Terminal title -----------------------------------------------------

#|( Set the terminal window title via OSC 0 ("icon name + window title").
    Writes directly to C</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 C<ESC]0;TITLE BEL>.
    =item Inside tmux (C<$TMUX> set) -- wraps in the DCS passthrough
      (C<ESC Ptmux; ... ESC \\>) so the host terminal actually sees it.
      Requires C<set -g allow-passthrough on> in tmux >= 3.3, which is
      the default from 3.4 onward.
    =item No C</dev/tty> available (tests, piped stdin) -- silently no-op.

    Control characters (ESC, BEL, CR, LF) in C<$title> are stripped before
    emission so a hostile title string can't terminate the sequence early
    or inject further escapes. )
method set-title(Str:D $title) {
    my $osc = Selkie::App.build-title-osc($title, :tmux(?%*ENV<TMUX>));
    write-controlling-terminal($osc);
}

#|( 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-title-osc(Str:D $title, Bool :$tmux = False --> Str) {
    app-title-osc($title, :$tmux);
}

#|( Build the "exit terminal protocol" escape sequence emitted by
    C<shutdown> as a belt-and-suspenders layer on top of
    C<notcurses_stop>.

    Why it exists: C<notcurses_stop> handles most of this on most
    terminals, but the Kitty keyboard protocol push (C<CSI > n u>)
    doesn't reliably pop on iTerm2 — after the app exits every
    keystroke arrives as C<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
    C<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 build-terminal-cleanup-sequence(--> Str) {
    app-terminal-cleanup-sequence;
}

#|( 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 C<$*TMPDIR/selkie-terminal-debug.{pid}.log>.
    Gated on C<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 !log-terminal-startup-state(--> Nil) {
    my $env = %*ENV<SELKIE_TERMINAL_DEBUG>;
    return unless $env.defined && $env ne '' && $env ne '0';

    my $pixel-impl = notcurses_check_pixel_support($!nc);
    my %impl-names =
        0 => 'NCPIXEL_NONE',
        1 => 'NCPIXEL_SIXEL',
        2 => 'NCPIXEL_LINUXFB',
        3 => 'NCPIXEL_ITERM2',
        4 => 'NCPIXEL_KITTY_STATIC',
        5 => 'NCPIXEL_KITTY_ANIMATED',
        6 => 'NCPIXEL_KITTY_SELFREF';
    my $impl-name = %impl-names{$pixel-impl} // "UNKNOWN($pixel-impl)";

    my uint32 $pxy = 0; my uint32 $pxx = 0;
    my uint32 $cdy = 0; my uint32 $cdx = 0;
    my uint32 $bmy = 0; my uint32 $bmx = 0;
    ncplane_pixel_geom($!stdplane, $pxy, $pxx, $cdy, $cdx, $bmy, $bmx);

    my $log = qq:to/END/;
        ===== Selkie terminal startup diagnostic =====
        PID: $*PID
        TERM:         {%*ENV<TERM>         // '(unset)'}
        TERM_PROGRAM: {%*ENV<TERM_PROGRAM> // '(unset)'}
        TERM_PROGRAM_VERSION: {%*ENV<TERM_PROGRAM_VERSION> // '(unset)'}
        LC_TERMINAL:  {%*ENV<LC_TERMINAL>  // '(unset)'}
        LC_TERMINAL_VERSION: {%*ENV<LC_TERMINAL_VERSION> // '(unset)'}
        COLORTERM:    {%*ENV<COLORTERM>    // '(unset)'}
        Pixel implementation: $impl-name (raw={$pixel-impl})
        Terminal cells:    rows={$!rows} cols={$!cols}
        Terminal pixels:   y={$pxy} x={$pxx}
        Cell pixel dims:   y={$cdy} x={$cdx}
        Bitmap max emit:   y={$bmy} x={$bmx}
        ==============================================
        END
    my $path = $*TMPDIR.add("selkie-terminal-debug.{$*PID}.log");
    try spurt $path, $log, :append;
}

# --- Toast ---

#|( Show a temporary message bar at the bottom of the screen. It auto-dismisses
    after C<$duration> seconds (default 2). The toast overlay is created
    lazily on first call — subsequent toasts reuse the same widget. )
method toast(Str:D $message, Num :$duration = 2e0) {
    self!show-toast($message, :$duration);
}

# --- Modal support ---

#|( 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 C<Tab>, C<Shift-Tab>, and C<Esc>
    reach the app's global keybinds.

    Modals stack: calling C<show-modal> while another modal is already
    open pushes the new modal on top. C<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 show-modal(Selkie::Widget::Modal $modal) {
    self!show-modal($modal);
}

#|( 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 close-modal() {
    self!close-modal;
}

#| True while at least one modal is currently being displayed.
method has-modal(--> Bool) { self!has-modal }

# --- Keybinds ---

#|( Register a global keybind. The spec is a string matching
    L<Selkie::Event>'s syntax (C<'ctrl+q'>, C<'f1'>, C<'ctrl+shift+a'>, etc).

    Pass C<:screen> to scope the bind to a single named screen — it will
    only fire when that screen is active. Leave C<: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-key(Str:D $spec, &handler, Str :$screen) {
    self!register-global-keybind($spec, &handler, :$screen);
}

#|( 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 (C<$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-frame(&callback, Str :$name = '') {
    self!register-frame-callback(&callback, :$name);
}

#|( Register a callback that fires when the terminal is resized.
    Receives the new C<($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 on-resize(&callback) {
    self!register-resize-callback(&callback);
}

# --- Animation ---

#|( The app's L<Selkie::Tween> 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 C<'selkie-tweens'>
    frame callback that drives it — an app that never animates never
    allocates either. Read L<Selkie::Tween>'s Pod before using this:
    every C<on-update> must mark something dirty, and tweens must be
    bounded rather than ambient, or the idle ladder never engages
    again. )
method tweens(--> TweenGroup) {
    self!tween-group;
}

#|( Increment the animation refcount, pinning the render loop to the
    hot frame budget (C<hot-hz>) regardless of how long the user has
    been idle. Returns the new count.

    C<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
    C<end-animation> on a path that cannot be skipped:

    =begin code :lang<raku>

    $app.begin-animation;
    LEAVE $app.end-animation;

    =end code

    An unbalanced C<begin-animation> holds the app at 60 Hz for the
    rest of the session. )
method begin-animation(--> Int) {
    self!begin-animation;
}

#|( 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 C<end-animation> is harmless. )
method end-animation(--> Int) {
    self!end-animation;
}

#| 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 animating(--> Bool) {
    self!animating;
}

#|( Turn the scrimmed-modal backdrop fade on or off at runtime — a
    "reduce motion" preference, say. Applies to the next C<show-modal>;
    a fade already running is left to finish. )
method set-animate-backdrop(Bool:D $on --> Nil) {
    $!animate-backdrop = $on;
    Nil;
}

#|( Turn the toast fade on or off at runtime. Either way it reaches the
    toast widget on the next C<toast> call, which is where the flag is
    read. )
method set-animate-toast(Bool:D $on --> Nil) {
    $!animate-toast = $on;
    Nil;
}

# --- Focus management ---

#|( Move focus to a specific widget. The previously-focused widget's
    C<set-focused(False)> is called (if it has one); the new widget's
    C<set-focused(True)> is called. A C<ui/focus> event is dispatched
    to the store so subscribers (e.g. C<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 C<$!focused> is attached whenever focusable widgets exist.
    The only legitimate "focus: nothing" state is a surface with zero
    focusables, in which case C<$!focused> stays undefined.

    A C<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(Selkie::Widget $w) {
    # An undefined $w is the documented "focus the first focusable"
    # request rather than a target, so it bypasses the gate and falls
    # through to !focus-widget's own handling.
    return if $w.defined && !self.focus-eligible($w);
    self!focus-widget($w);
}

#|( True iff C<$w> is a widget C<focus> will actually move focus to:
    defined, and not C<disabled>.

    Deliberately does B<not> consult C<focusable>. C<focus> has always
    accepted a non-focusable widget when an app asks for it explicitly
    — C<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
    C<widget-attached> is: C<Selkie::App.focus-eligible(...)> works
    against the type object, so the rule is testable without
    constructing an App (which would require C<notcurses_init>). )
method focus-eligible(Selkie::Widget $w --> Bool) {
    $w.defined && !$w.disabled;
}

#|( True iff walking up C<$w>'s parent chain reaches C<$root>. Used
    internally to validate that a saved focus reference (in
    C<%!screen-focus> or C<@!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 — C<Selkie::App.widget-attached(...)>
    works without constructing an App instance (which would require
    C<notcurses_init>). Apps rarely need to call this directly. )
method widget-attached(Selkie::Widget $w, $root --> Bool) {
    self!widget-attached-to($w, $root);
}

#|( Verify that C<$!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 C<run> — apps don't normally need to
    call it. )
method check-focus-invariant() {
    self!check-focus-invariant;
}

#| Move focus to the next focusable widget in the tree. Wraps around at
#| the end. Bound to C<Tab> by default.
method focus-next() {
    self!focus-next;
}

#| Move focus to the previous focusable widget. Wraps around at the
#| beginning. Bound to C<Shift-Tab> by default.
method focus-prev() {
    self!focus-prev;
}

# --- Lifecycle ---

#| Signal the event loop to exit. C<run> returns after the current frame
#| completes; the terminal is restored by C<shutdown>.
method quit() {
    self!request-quit;
}

#|( 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. C<$user-idle-for> counts seconds since the last input event,
    resize, or toast visibility change; C<$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 C<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.

    C<: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 C<animating> and L<Selkie::Tween>.

    A package sub (not a closure in C<run>) so the tier math is unit
    testable without standing up notcurses. )
our sub pick-frame-budget(
    Num:D $hot-budget,
    Num:D $user-idle-for,
    Num:D $store-idle-for,
    Bool:D :$animating = False,
    --> Num:D
) {
    pick-app-frame-budget($hot-budget, $user-idle-for, $store-idle-for, :$animating);
}

#|( Start the event loop. Blocks until C<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 C<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 C<CATCH> block: any thrown exception
    triggers an orderly shutdown, prints a backtrace to STDERR, and
    exits the process with status 1. )
method run() {
    self!run-loop;
}

#|( Public hit-test against an arbitrary root. Returns the deepest
    widget whose on-screen rectangle contains the given absolute cell,
    falling back to C<$root> itself when the point is in the root's
    own bounds but no descendant claims it. Returns the C<Selkie::Widget>
    type object when the root doesn't contain the point.

    Two-phase resolution:

    =item B<Phase 1>: walk the whole tree looking for any widget whose
      C<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
      C<contains-point> doesn't extend over the overlay area.
    =item B<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 L<widget-attached>. App's mouse dispatcher uses this
    with the active modal / screen root resolved at call time. )
method widget-at-in($root, Int $y, Int $x --> Selkie::Widget) {
    app-widget-at-in($root, $y, $x);
}

#|( 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 C<DESTROY>
    takes care of it.

    Each cleanup step is best-effort: an exception thrown in modal
    destroy, screen-manager destroy, or C<notcurses_stop> (e.g. when a
    NativeCall dlopen fails because Notcurses-Native was reinstalled to
    a new path mid-session) is caught, logged via C<!try-log>, and
    isolated so the later steps — TTY restore, the escape-sequence
    backstop in C<!emit-terminal-cleanup>, and C<!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 shutdown() {
    # Close the SIGWINCH tap before tearing down notcurses — if a
    # signal arrives after notcurses_stop but before the tap is
    # closed, the handler would set a flag on a half-destroyed App.
    # The .? guards repeat shutdown calls; tap exposes no idempotent
    # close.
    self!close-resize-tap;

    # Same reasoning for the crash-restore taps — if a recoverable
    # fatal signal arrives during shutdown itself, we don't want the
    # tap to fire and call shutdown again (it's idempotent, but the
    # re-entry would log spurious notcurses-stop errors). After this
    # point the default action takes over for the signals we'd been
    # tapping.
    .?close for @!crash-restore-taps;
    @!crash-restore-taps = ();

    # Drain any in-flight async-effect workers before tearing down the
    # widget tree / notcurses. Without this, a worker thread that
    # completes after notcurses_stop can dispatch into handlers whose
    # planes / native handles are already gone (SIGBUS on plane ops,
    # NPE on the destroyed handle). The drain is bounded so a wedged
    # worker can't strand shutdown — see Store.drain-async.
    self!try-log('drain-async', { $.store.drain-async }) if $.store;

    # Tear down every modal and registered screen before notcurses_stop.
    # The helper wraps each throw-prone destroy step in !try-log so a
    # single widget failure cannot strand the terminal cleanup below.
    self!shutdown-screen-modal-lifecycle;
    # Restore before and after notcurses_stop. The first restore protects
    # users if notcurses_stop itself aborts; the second restore wins if
    # notcurses restores a stale termios snapshot. Both restores go
    # through !try-log like every other step: a bare throw here once
    # skipped notcurses_stop and the whole backstop chain below (the
    # spawn-based stty restore failing async when the process cwd had
    # been renamed away — see Internal::Terminal for the full story).
    self!try-log('restore-tty-pre-stop', { self!restore-tty-state });
    # notcurses_stop is the most likely thrower in practice: if the
    # bundled Notcurses-Native library was reinstalled to a new
    # binaries-notcurses-3.0.17-r{N} path mid-session, NativeCall's
    # lazy dlopen fails here and the exception would otherwise skip the
    # entire backstop below.
    self!try-log('notcurses-stop', { notcurses_stop($!nc) if $!nc });
    $!nc = NotcursesHandle;
    self!try-log('restore-tty-post-stop', { self!restore-tty-state });
    # Escape-sequence backstop on top of notcurses_stop. Catches
    # terminal-mode leaks where notcurses_stop's per-protocol disable
    # didn't take — notably the Kitty keyboard protocol push on
    # iTerm2 3.5+, which leaves every keystroke arriving at the shell
    # as `CSI codepoint u` instead of a raw byte. Idempotent on
    # terminals where notcurses_stop already handled it. Doubly load-
    # bearing when notcurses_stop itself threw: without this emit the
    # alt-screen stays active and the Kitty kbd protocol stays pushed.
    self!emit-terminal-cleanup;
    # Stderr goes last so any diagnostics notcurses_stop emits still
    # land in the log file rather than the cleared terminal.
    self!uninstall-error-log;
}

method DESTROY() {
    self.shutdown if $!nc;
}

#|( 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
    C<Str> (the type object) or an empty string disables redirection
    and restores the stderr views saved by C<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
    C<{cantina-home}/{db-name}/error.log>, and C<db-name> is only
    known after the user selects / creates a profile on the login
    screen. The app boots with C<error-log> unset, then calls
    C<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. )
method set-error-log(Str $path) {
    self!uninstall-error-log;
    $!error-log = $path;
    self!install-error-log($path) if $path.defined && $path.trim.chars > 0;
}