Selkie.git | lib/Selkie/Widget/ | Modal.rakumod


=begin pod

=head1 NAME

Selkie::Widget::Modal - Centered overlay dialog with a dimmed, scrimmed, or transparent backdrop

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Widget::Modal;
use Selkie::Layout::VBox;
use Selkie::Widget::Button;
use Selkie::Sizing;

my $modal = Selkie::Widget::Modal.new(
    width-ratio    => 0.5,
    height-ratio   => 0.3,
    dim-background => True,
);

my $content = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$content.add: $some-text;
my $ok = Selkie::Widget::Button.new(label => 'OK', sizing => Sizing.fixed(1));
$content.add($ok);
$modal.set-content($content);

$ok.on-press.tap:    -> $ { $app.close-modal };
$modal.on-close.tap: -> $ { $app.close-modal };

$app.show-modal($modal);
$app.focus($ok);

=end code

=head1 DESCRIPTION

A dialog rendered centered on screen, sized as a fraction of the
terminal. The background is dimmed by default so the dialog stands out.
While the modal is active, L<Selkie::App> routes all events through it —
Tab/Shift-Tab still cycle focus within the modal, Esc auto-closes.

For common confirm/cancel dialogs, use L<Selkie::Widget::ConfirmModal>
which wraps Modal with a pre-built button row.

C<set-content(:!destroy)> lets you swap content without destroying the
outgoing widget — useful for multi-step wizards where each step is a
separate content widget.

=head2 Backdrops

C<backdrop> picks what the modal does with the screen it covers. It
takes a C<BackdropMode>, exported by this module (C<use Selkie> does not
re-export it — C<use Selkie::Widget::Modal>):

=item B<C<BackdropOpaque>> — the default, and what Modal has always drawn: a full-screen plane painted in the theme's C<modal-backdrop> style, hiding the screen completely.
=item B<C<BackdropScrim>> — the screen stays visible, tinted through the C<modal-scrim> slot. Text underneath remains legible, just dimmed.
=item B<C<BackdropNone>> — no backdrop at all. The screen shows through untouched around the dialog.

=begin code :lang<raku>

use Selkie::Widget::Modal;

my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
$m.set-backdrop(BackdropOpaque);      # re-primes the planes, marks dirty

=end code

C<dim-background> is the original boolean spelling and still works:
C«dim-background => True» (the default) is C<BackdropOpaque>, and
C«dim-background => False» is C<BackdropNone>. The accessor is derived
from C<backdrop>, so the two can never disagree. An explicit C<backdrop>
wins if you pass both.

B<C«dim-background => False» used to show a flat rectangle> rather than
the screen behind it: the modal's own full-screen plane carries an
opaque base cell, which occluded everything even when no dim plane was
drawn. It now genuinely reveals the screen — the modal's base cell goes
fully transparent, with no glyph of its own, in both C<BackdropScrim>
and C<BackdropNone>.

=head2 Scrim

A scrim is a single blend layer, not an opacity slider. Notcurses alpha
is a two-bit enum (see L<Selkie::Alpha>), so C<AlphaBlend> means exactly
one 50/50 average against whatever the compositor has accumulated
underneath — per channel, with integer truncation. A black scrim over a
C<0x808080> background resolves to C<0x404040>; over white it lands on
C<0x7F7F7F>, not C<0x808080>.

B<Want a deeper dim? Choose a darker scrim colour, not "more alpha" —
there is no such thing.> Black at C<AlphaBlend> is as dark as one layer
gets. The knob is the theme's C<modal-scrim> slot:

=begin code :lang<raku>

my $theme = Selkie::Theme.default.clone(
    modal-scrim => Selkie::Style.new(
        fg => 0x101018, bg => 0x101018,
        fg-alpha => AlphaBlend, bg-alpha => AlphaBlend,
    ),
);

=end code

Both channels blend independently, so the scrim dims the glyphs showing
through it as well as the background behind them. Nothing is written to
the scrim plane at all — its base cell does the work, primed with an
empty EGC (gcluster 0, the sentinel notcurses's glyph search reads as
"keep looking further down the pile") so the text underneath survives.

=head3 Scrim and images

B<A scrim erases sprixels underneath it.> Blending sets the cell's
blend count, and notcurses's C<paint_sprixel> path wipes any cell that
has been blended into — so a bitmap that was visible behind the modal
disappears entirely rather than dimming. This is a property of the
compositor, not something Selkie can work around.

If the screen behind your modal contains a L<Selkie::Widget::Image>, use
C<BackdropOpaque>: the image is hidden either way, and the opaque path
hides it deliberately instead of half-way. Cell content — text, boxes,
gradients — is unaffected and scrims exactly as you'd expect.

=head3 Fading the scrim in

C<fade-backdrop-in> ramps the scrim B<colour> from the screen's own
background toward the C<modal-scrim> slot over a fifth of a second or
so, which reads as the dim deepening under the dialog. It is opt-in at
the app level (C«Selkie::App.new(:animate-backdrop)») and
C<Selkie::App.show-modal> is what normally calls it — reach for the
method directly only if you are driving modals yourself.

=begin code :lang<raku>

$modal.fade-backdrop-in($app.tweens, duration => 0.12);

=end code

Alpha does not move: both channels stay C<AlphaBlend> for the whole
ramp, because there is no fractional alpha to ramp (L<Selkie::Alpha>).
What moves is the colour the blend layer contributes, from
C«theme.base.bg» — a 50/50 mix of the background with itself, i.e. no
visible change over the bulk of a screen — to the scrim colour. Glyphs
sitting on a non-background colour are tinted a little from the first
frame; that is the exact limit of what a two-bit alpha can express, and
it is why the ramp is short.

The tween is owned by the modal. C<destroy> and C<set-backdrop> cancel
it, so closing a dialog mid-fade can never leave a callback pointed at a
freed plane. It is a no-op outside C<BackdropScrim>, and a no-op when
the theme has no C<modal-scrim> style.

B<There is no fade-out.> C<Selkie::App.close-modal> destroys the modal
synchronously — that is what makes the focus restore, the plane teardown
and the reveal of whatever was behind happen in one step — so an
out-fade would mean keeping a popped modal alive, and painting into it,
for another tenth of a second. A half-working out-fade that touches a
torn-down plane is worse than no out-fade, so dialogs resolve in and
close instantly.

C<set-scrim-style> / C<clear-scrim-style> / C<effective-scrim-style> are
the underlying knobs if you want to drive the scrim colour from
something other than a tween.

=head2 Frames

C«framed => True» draws a L<Selkie::Widget::Border> around the dialog,
which is where a title and a key-hint strip go:

=begin code :lang<raku>

use Selkie::BorderStyle;

my $modal = Selkie::Widget::Modal.new(
    width-ratio        => 0.5,
    height-ratio       => 0.4,
    backdrop           => BackdropScrim,
    framed             => True,
    frame-style        => BorderRounded,
    frame-title        => 'Rename project',
    frame-bottom-title => 'esc cancel · ⏎ save',
    frame-padding      => 1,
);
$modal.set-content($form);

$modal.set-frame-bottom-title('esc cancel') if $form.pristine;

=end code

The frame is an implementation detail of the modal's chrome, not part of
its content:

=item C<.content> keeps returning B<your> widget, framed or not. So does C<focusable-descendants>, and the Tab cycle is unchanged.
=item The frame paints from the C<modal-frame> / C<modal-title> / C<modal-key> theme slots, each of which defaults to an existing slot — so turning framing on changes the layout, never the palette.
=item C<.frame> hands you the Border itself for anything the C<frame-*> knobs don't cover (glyph tables, per-edge padding, hidden edges). Changes you make there are not mirrored back into the C<frame-*> accessors.

B<The frame eats interior space.> Content inside a framed modal gets the
frame's inner rectangle: two rows and two columns for the edges, plus
C<frame-padding> (default 1) on every side. A C«height-ratio => 0.4»
modal on a 24-row terminal is 9 rows tall and hands its content 5.
C<content-extent> is the plane-free way to ask, and it's what layouts
with flex spacers should be sized against:

=begin code :lang<raku>

my ($rows, $cols) = $modal.content-extent(24, 80);

=end code

Squeeze it far enough — a tiny terminal, or generous C<frame-padding> —
and the interior collapses to zero on one axis, at which point the
Border parks the content until there is room for it again (see
L<Selkie::Widget::Border>'s Padding section).

=head1 EXAMPLES

=head2 Input dialog

=begin code :lang<raku>

my $modal = Selkie::Widget::Modal.new(width-ratio => 0.4, height-ratio => 0.2);
my $body = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$body.add: Selkie::Widget::Text.new(text => 'Rename', sizing => Sizing.fixed(1));
my $input = Selkie::Widget::TextInput.new(sizing => Sizing.fixed(1));
$body.add($input);
$modal.set-content($body);

$input.on-submit.tap: -> $new-name {
    $app.close-modal;
    $app.store.dispatch('rename', :$new-name);
};

$app.show-modal($modal);
$app.focus($input);

=end code

=head2 A framed dialog over a scrimmed screen

=begin code :lang<raku>

use Selkie::Widget::Modal;
use Selkie::BorderStyle;

my $modal = Selkie::Widget::Modal.new(
    width-ratio        => 0.6,
    height-ratio       => 0.5,
    backdrop           => BackdropScrim,
    framed             => True,
    frame-style        => BorderRounded,
    frame-title        => 'Preferences',
    frame-bottom-title => 'tab move · ⏎ apply · esc close',
);
$modal.set-content($prefs-form);
$app.show-modal($modal);

=end code

=head1 SEE ALSO

=item L<Selkie::Widget::Border> — the frame; C<inner-rect>, glyph sets, titles
=item L<Selkie::Alpha> — why the scrim is one blend layer and not a percentage
=item L<Selkie::Tween> — the interpolation C<fade-backdrop-in> runs on
=item L<Selkie::Theme> — C<modal-backdrop>, C<modal-frame>, C<modal-title>, C<modal-key>, C<modal-scrim>
=item L<Selkie::Widget::ConfirmModal> — pre-built yes/no confirmation
=item L<Selkie::Widget::FileBrowser> — pre-built file picker
=item L<Selkie::App> — C<show-modal> and C<close-modal> methods

=end pod

use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;

use Selkie::Alpha;
use Selkie::BorderStyle;
use Selkie::Widget;
use Selkie::Container;
use Selkie::Event;
use Selkie::Style;
use Selkie::Tween;
use Selkie::Widget::Border;

unit class Selkie::Widget::Modal does Selkie::Container;

#|( What the modal does with the screen it covers.

    C<BackdropOpaque> is the historical behaviour and the default: a
    full-screen plane painted in the theme's C<modal-backdrop> style.
    C<BackdropScrim> tints the screen through C<modal-scrim> instead,
    leaving text underneath legible. C<BackdropNone> leaves the screen
    entirely alone. See the class Pod for the alpha rules that make the
    last two possible — and for why a scrim erases bitmaps. )
enum BackdropMode is export (
    BackdropOpaque => 'opaque',
    BackdropScrim  => 'scrim',
    BackdropNone   => 'none',
);

# Both channels transparent, no colours: the modal's own full-screen
# plane then contributes nothing at all to the composite. Paired with an
# empty base EGC (gcluster 0) it is see-through for glyphs as well as
# colour, which is what lets the scrim plane — or, in BackdropNone, the
# screen itself — come through. Built once; Style is immutable.
my $TRANSPARENT-BASE = Selkie::Style.new(
    fg-alpha => AlphaTransparent,
    bg-alpha => AlphaTransparent,
);

has Selkie::Widget $!content;
has Rat $.width-ratio = 0.8;
has Rat $.height-ratio = 0.6;

#|( What the modal does with the screen behind it. Defaults to
    C<BackdropOpaque>, which is byte-for-byte what Modal has always
    drawn. Change it after construction with C<set-backdrop>. )
has BackdropMode $.backdrop = BackdropOpaque;

#|( When True, a primary mouse click outside the modal's content
    rectangle dismisses the modal — the framework calls
    C<Selkie::App.close-modal>, restoring the pre-modal focus and
    revealing whatever was behind. Default False matches the
    keyboard focus-trap behavior: stray clicks in the dimmed
    backdrop are ignored. Subclasses override the default by
    passing C<:dismiss-on-click-outside> to their parent
    constructor — C<HelpOverlay> defaults to True (lightweight
    informational overlay), C<ConfirmModal> stays False (a Yes/No
    decision shouldn't be silently abandoned). )
has Bool $.dismiss-on-click-outside = False;

#|( When False, the user cannot dismiss the modal — C<Esc> is ignored
    while it is topmost, and click-outside dismissal is suppressed
    regardless of C<dismiss-on-click-outside> (a modal the user can't
    Esc out of shouldn't have a stray-click back door either).
    Programmatic dismissal is untouched: C<.close> and
    C<Selkie::App.close-modal> still work, so application code (e.g.
    a "processing, please wait" modal that only the completion
    handler may close) can dismiss it on its own terms. Default True
    matches every modal's historical behaviour. )
has Bool $.dismissable = True;

#|( Draw a L<Selkie::Widget::Border> around the content. Construction
    time only — the frame is built in C<TWEAK> — and False by default,
    which leaves the modal exactly as it has always rendered. )
has Bool $.framed = False;

#| Glyph set for the frame. Ignored unless C<framed>.
has BorderKind $.frame-style = BorderSingle;

#| Explicit glyph table for the frame, overriding C<frame-style>.
has BorderGlyphs $.frame-glyphs;

#| Title along the frame's top edge. Empty (the default) draws none.
has Str $.frame-title = '';

#|( Placement of C<frame-title>. C<TitleCenter> by default — a dialog
    heading reads as a heading when it's centred, where a pane title
    reads better hard left. )
has TitleAlign $.frame-title-align = TitleCenter;

#|( Title along the frame's bottom edge — the key-hint slot
    (C<'esc cancel · ⏎ save'>). Empty by default. )
has Str $.frame-bottom-title = '';

#| Placement of C<frame-bottom-title>. C<TitleCenter> by default.
has TitleAlign $.frame-bottom-title-align = TitleCenter;

#|( Cells of clearance between the frame and the content, on all four
    edges. One by default: a dialog wants breathing room where a data
    pane usually doesn't. )
has UInt $.frame-padding = 1;

has Selkie::Widget::Border $!frame;
has NcplaneHandle $!bg-plane;
has Supplier $!close-supplier = Supplier.new;

# Scrim colour override, set per frame while `fade-backdrop-in` runs and
# cleared when it lands. Undefined means "use the theme slot", which is
# the state every modal that never animates stays in forever.
has Selkie::Style $!scrim-override;

# The live fade tween, or the type object. Held so the modal can cancel
# its own animation: `destroy` and `set-backdrop` both run through
# `cancel-backdrop-fade`, which is what guarantees no on-update ever
# fires against a torn-down plane.
has Tween $!backdrop-tween;

# Two construction-time chores, both of which have to happen before the
# first render:
#
#   1. Translate the legacy `dim-background => False` spelling into
#      BackdropNone. An explicitly-passed `backdrop` wins, which is why
#      TWEAK inspects the named argument rather than the attribute — the
#      attribute can't tell "passed BackdropOpaque" from "defaulted".
#   2. Build the frame. It is registered as a Container child so the
#      framework's own cascades (set-store, set-theme, park, the render
#      loop's mark-all-dirty) reach it without Modal reimplementing any
#      of them; `content` deliberately still answers the user's widget.
#
# Plain comment rather than a #|( ) declarator block on purpose: the
# generated API docs would render a submethod signature with its object
# address, churning docs/ on every mi6 build.
submethod TWEAK(Bool :$dim-background, BackdropMode :$backdrop) {
    $!backdrop = BackdropNone
        if $dim-background.defined && !$dim-background && !$backdrop.defined;
    self!build-frame if $!framed;
}

method !build-frame(--> Nil) {
    $!frame = Selkie::Widget::Border.new(
        border-style       => $!frame-style,
        border-glyphs      => $!frame-glyphs,
        title              => $!frame-title,
        title-align        => $!frame-title-align,
        bottom-title       => $!frame-bottom-title,
        bottom-title-align => $!frame-bottom-title-align,
        padding            => $!frame-padding,
        # Modal chrome has slots of its own, and it never reacts to
        # focus: a modal is a focus trap, so something inside it is
        # focused essentially always, and a focus-reactive frame would
        # sit permanently in the focused style. Pointing both slots at
        # modal-frame keeps the focus subscription (and therefore
        # Border's descendant walk) honest while the colour stays put.
        style-slot         => 'modal-frame',
        focused-style-slot => 'modal-frame',
        title-slot         => 'modal-title',
        bottom-title-slot  => 'modal-key',
    );
    self.add($!frame);
}

#| The current content widget, or the C<Selkie::Widget> type object
#| when no content is set. B<Always your widget> — a framed modal wraps
#| it in a Border internally, and that Border is reached via C<frame>.
method content(--> Selkie::Widget) { $!content }

#|( The internal L<Selkie::Widget::Border>, or the
    C<Selkie::Widget::Border> type object when the modal isn't framed.

    The escape hatch for frame configuration the C<frame-*> constructor
    knobs don't cover — custom glyph tables, per-edge padding, a hidden
    edge. Anything you change here is invisible to the C<frame-*>
    accessors, which report what the modal was constructed with. )
method frame(--> Selkie::Widget::Border) { $!frame }

#|( Whether a backdrop plane is drawn at all. The original boolean
    spelling of C<backdrop>, derived from it rather than stored
    alongside it so the two can't drift: True for C<BackdropOpaque> and
    C<BackdropScrim>, False for C<BackdropNone>. )
method dim-background(--> Bool) { $!backdrop !=== BackdropNone }

#|( Switch backdrop modes. Re-primes the modal's own plane base (opaque
    for C<BackdropOpaque>, fully transparent otherwise), drops the
    backdrop plane so the next render builds one primed for the new mode
    — erase preserves a base cell, so reusing the old plane would carry
    the old mode's base with it — and marks the modal dirty. No-op when
    the mode is already active.

    A backdrop fade in flight is cancelled and its colour override
    dropped: the tween was interpolating toward a scrim the modal is no
    longer drawing. )
method set-backdrop(BackdropMode:D $mode --> Nil) {
    return if $mode === $!backdrop;
    self.cancel-backdrop-fade;
    $!backdrop = $mode;
    self!sync-plane-base;
    if $!bg-plane {
        ncplane_destroy($!bg-plane);
        $!bg-plane = NcplaneHandle;
    }
    self.mark-dirty;
}

#|( The style stamped into the modal's own full-screen plane base cell.

    C<BackdropOpaque> keeps the framework default (the theme's C<base>),
    which is what makes the modal's footprint cover the screen. The
    other two modes return a style whose channels are both
    C<AlphaTransparent>: the modal plane then contributes nothing, and
    what shows through is the scrim plane, or — for C<BackdropNone> —
    the screen itself. )
method base-style(--> Selkie::Style) {
    $!backdrop === BackdropOpaque ?? self.theme.base !! $TRANSPARENT-BASE;
}

#|( The glyph stamped into the modal's own plane base cell: a space
    under C<BackdropOpaque>, and the empty string otherwise. An empty
    EGC stores gcluster 0, which notcurses's glyph search treats as
    "this cell contributes nothing — keep looking further down the
    pile"; without it a transparent base would still cover the text
    underneath with blanks. )
method base-egc(--> Str) {
    $!backdrop === BackdropOpaque ?? ' ' !! '';
}

#|( The style the backdrop plane's base cell is primed from in
    C<BackdropScrim> mode: the C<set-scrim-style> override when one is
    installed, and the theme's C<modal-scrim> slot otherwise. Pure. )
method effective-scrim-style(--> Selkie::Style) {
    $!scrim-override // self.theme.modal-scrim;
}

#|( Override the scrim colour, replacing the theme's C<modal-scrim>
    slot until C<clear-scrim-style> puts it back. Marks the modal dirty
    — the backdrop is only re-primed when C<render> runs, and C<render>
    only runs on a dirty modal.

    This is the hook C<fade-backdrop-in> drives, and the one to reach
    for if you want the scrim to track something other than a tween
    (a job's progress, say). Keep both alphas C<AlphaBlend>: a scrim
    that changes alpha changes what it is, not how deep it goes. )
method set-scrim-style(Selkie::Style:D $style --> Nil) {
    $!scrim-override = $style;
    self.mark-dirty;
    Nil;
}

#| Drop a C<set-scrim-style> override and go back to the theme slot.
#| Marks the modal dirty. A no-op when no override is installed.
method clear-scrim-style(--> Nil) {
    return without $!scrim-override;
    $!scrim-override = Selkie::Style;
    self.mark-dirty;
    Nil;
}

#|( The packed notcurses channels word the backdrop plane's base cell is
    primed with in C<BackdropScrim> mode, built from
    C<effective-scrim-style>. Pure — no plane needed — so it's the thing
    to assert against when checking what a custom scrim style will
    actually produce.

    The backdrop plane is not a Widget, so there is no C<base-style>
    hook to route it through; the word is built by
    C<Selkie::Style.channels>, the same code C<Selkie::Widget>'s
    C<base-channels> goes through, so the two can't drift. )
method scrim-channels(--> UInt) {
    my $scrim = self.effective-scrim-style;
    $scrim.defined ?? $scrim.channels !! 0;
}

#|( The two ends of the scrim fade for the current theme, as
    C<($from, $to)> — or the empty list when there is nothing to fade
    (any backdrop mode but C<BackdropScrim>, or a theme with no
    C<modal-scrim> style).

    C<$to> is the C<modal-scrim> slot itself. C<$from> is that same
    style with both colours swapped for the theme's C<base> background:
    a blend layer whose colour already matches the surface underneath,
    which is the closest thing to "no scrim yet" that a two-bit alpha
    allows. Every discrete attribute — the flags and, critically, both
    C<AlphaMode>s — is copied across from C<$to>, so nothing snaps at
    the midpoint and the ramp is pure colour.

    Pure, and public so a consumer can assert the endpoints of its own
    palette without running a frame. )
method backdrop-fade-endpoints(--> List) {
    return () unless $!backdrop === BackdropScrim;
    my $to = self.theme.modal-scrim;
    return () without $to;
    my $neutral = self.theme.base.bg;
    return () without $neutral;
    my $from = Selkie::Style.new(
        fg            => $neutral,
        bg            => $neutral,
        bold          => $to.bold,
        italic        => $to.italic,
        underline     => $to.underline,
        strikethrough => $to.strikethrough,
        fg-alpha      => $to.fg-alpha,
        bg-alpha      => $to.bg-alpha,
    );
    ($from, $to);
}

#|( Ramp the scrim colour up from the screen background to the theme's
    C<modal-scrim> over C<$duration> seconds, on C<$group> (normally
    C<Selkie::App.tweens>). Returns the C<Selkie::Tween::Tween> so the
    caller can hold it, or C<Nil> when there is nothing to fade.

    Cancels any fade already in flight rather than stacking a second
    one, and clears the colour override when it lands so the modal
    settles on the theme slot exactly — a fade that stopped one rounding
    step short would leave every scrimmed dialog a shade off.

    The modal owns the returned tween: C<destroy> and C<set-backdrop>
    cancel it. )
method fade-backdrop-in(
    TweenGroup:D $group,
    Real :$duration = 0.12,
    Instant :$at = now,
    --> Mu
) {
    my @ends = self.backdrop-fade-endpoints;
    return Nil unless @ends;
    my ($from, $to) = @ends;

    self.cancel-backdrop-fade;
    self.set-scrim-style($from);

    my $self-ref = self;
    $!backdrop-tween = $group.add(
        Tween.new(
            :$duration,
            easing      => EaseOutQuad,
            on-update   => -> Num $t {
                $self-ref.set-scrim-style(lerp-style($from, $to, $t));
            },
            on-complete => {
                $self-ref.clear-scrim-style;
                $self-ref.release-backdrop-tween;
            },
        ),
        :$at,
    );
}

#|( Stop a backdrop fade where it stands and drop the colour override,
    so the scrim snaps to the theme slot. Called by C<destroy> and
    C<set-backdrop>; safe to call when nothing is animating.

    C<Selkie::Tween::Tween.cancel> deliberately does not fire
    C<on-complete>, so the clear is done here rather than relied on.
    The dirty mark is skipped on a modal that has no plane: this runs on
    the teardown path (and, in the worst case, from C<DESTROY> on the GC
    thread), where there is nothing left to repaint. )
method cancel-backdrop-fade(--> Nil) {
    with $!backdrop-tween {
        .cancel;
        $!backdrop-tween = Tween;
    }
    return without $!scrim-override;
    $!scrim-override = Selkie::Style;
    self.mark-dirty if self.plane;
    Nil;
}

#| Drop the modal's handle on a fade that finished on its own. Public
#| only because the tween's C<on-complete> closure has to reach it;
#| treat it as internal.
method release-backdrop-tween(--> Nil) {
    $!backdrop-tween = Tween;
    Nil;
}

#| True while a backdrop fade is running. Mostly a testing hook.
method backdrop-fading(--> Bool) {
    $!backdrop-tween.defined && $!backdrop-tween.running;
}

#|( Supply that emits C<True> when C<close> is called or the user
    dismisses the modal (Esc, or a click outside when
    C<dismiss-on-click-outside> is set). Tap this to call
    C<$app.close-modal> and run any post-close logic. )
method on-close(--> Supply) { $!close-supplier.Supply }

#|( Install C<$w> as the modal's content. Re-callable to swap content
    during a multi-step wizard.

    C<:destroy> (default True) destroys the outgoing widget — the
    common case when content isn't reused. Pass C<:!destroy> to keep
    the outgoing widget alive (its plane is parked far off-screen so
    its last-rendered cells don't bleed through behind the new
    content); call C<set-content> with it again later to reinstall.

    On a framed modal the swap is delegated to the frame, which has
    identical destroy / park semantics. C<content> answers C<$w> either
    way. )
method set-content(Selkie::Widget $w, Bool :$destroy = True) {
    if $!frame {
        $!frame.set-content($w, :$destroy);
    } else {
        if $!content && $destroy {
            $!content.destroy;
        } elsif $!content && $!content.plane {
            $!content.park;
        }
        $w.parent = self;
    }
    $!content = $w;
    self.mark-dirty;
}

#|( Replace the frame's top title. No-op on an unframed modal. Keeps
    the C<frame-title> accessor in step with the Border. )
method set-frame-title(Str:D $t --> Nil) {
    $!frame-title = $t;
    $!frame.set-title($t) if $!frame;
}

#|( Replace the frame's bottom title — the key-hint strip, which
    usually changes as the dialog's state does. No-op on an unframed
    modal. )
method set-frame-bottom-title(Str:D $t --> Nil) {
    $!frame-bottom-title = $t;
    $!frame.set-bottom-title($t) if $!frame;
}

#| Emit on C<on-close>. Doesn't itself remove the modal from the App
#| — the caller's tap is expected to call C<$app.close-modal>.
method close() {
    $!close-supplier.emit(True);
}

#| Focusable descendants of the modal's content subtree. C<Selkie::App>
#| uses this to scope Tab / Shift-Tab cycling to within the active
#| modal — keyboard focus never escapes to the surrounding screen
#| while the modal is up. The frame is chrome and is never focusable,
#| so this is the same walk framed or not. Disabled content contributes
#| nothing — a modal whose content is disabled traps focus with nowhere
#| to put it, which is the honest answer while it stays disabled.
method focusable-descendants(--> Seq) {
    return ().Seq without $!content;
    return ().Seq if $!content.disabled;
    gather {
        take $!content if $!content.focusable;
        if $!content ~~ Selkie::Container {
            .take for $!content.focusable-descendants;
        }
    }
}

#|( The dialog rectangle for a terminal C<$rows> x C<$cols>, as
    C<($y, $x, $rows, $cols)> relative to the modal's own (full-screen)
    plane.

    C<width-ratio> / C<height-ratio> of the terminal, floored, with the
    3x10 floor that keeps a dialog drawable, then centred. Both origins
    are clamped at 0 for the case where that floor is larger than the
    terminal itself.

    Plane-free, and shared by C<render> and C<handle-resize> so the two
    can't drift. )
method modal-rect(UInt $rows, UInt $cols --> List) {
    my UInt $modal-rows = ($rows * $!height-ratio).floor.UInt max 3;
    my UInt $modal-cols = ($cols * $!width-ratio).floor.UInt max 10;
    my Int $y = (($rows - $modal-rows) / 2).floor;
    my Int $x = (($cols - $modal-cols) / 2).floor;
    ($y max 0, $x max 0, $modal-rows, $modal-cols);
}

#|( The dimensions the B<content> widget is given for a terminal
    C<$rows> x C<$cols>, as C<($rows, $cols)>.

    On an unframed modal that's the dialog rectangle itself. On a framed
    one the frame's edges and padding come off first, via
    C<Selkie::Widget::Border.inner-rect> — so this is the number to size
    a content layout against, and the number that decides whether a flex
    spacer has anywhere to go. A zero on either axis means the interior
    has collapsed and the Border will park the content.

    Plane-free; safe to call before the modal has ever rendered. )
method content-extent(UInt $rows, UInt $cols --> List) {
    my ($, $, $modal-rows, $modal-cols) = self.modal-rect($rows, $cols);
    return ($modal-rows, $modal-cols) without $!frame;
    my ($, $, $inner-rows, $inner-cols)
        = $!frame.inner-rect($modal-rows, $modal-cols);
    ($inner-rows, $inner-cols);
}

#|( Cascade a terminal resize to the content subtree. The content is
    sized to the same rectangle C<render> will give it — the dialog
    rectangle on an unframed modal, the frame's interior on a framed one
    — so its layout pass sees the right dimensions before the next
    render frame rather than one frame late.

    A collapsed interior (zero rows or columns) is skipped: notcurses
    refuses a zero-dimension resize, and the frame parks the content in
    that case anyway. )
method handle-resize(UInt $rows, UInt $cols) {
    my $changed = $rows != self.rows || $cols != self.cols;
    return unless $changed;
    self.resize($rows, $cols);
    self!on-resize;
    my ($, $, $modal-rows, $modal-cols) = self.modal-rect($rows, $cols);
    if $!frame {
        $!frame.handle-resize($modal-rows, $modal-cols);
        if $!content {
            my ($content-rows, $content-cols)
                = self.content-extent($rows, $cols);
            $!content.handle-resize($content-rows, $content-cols)
                if $content-rows > 0 && $content-cols > 0;
        }
    } elsif $!content {
        $!content.handle-resize($modal-rows, $modal-cols);
    }
}

method render() {
    return without self.plane;

    my UInt $parent-rows = self.rows;
    my UInt $parent-cols = self.cols;
    my ($modal-y, $modal-x, $modal-rows, $modal-cols)
        = self.modal-rect($parent-rows, $parent-cols);

    # Backdrop
    unless $!backdrop === BackdropNone {
        self!render-backdrop($parent-rows, $parent-cols);
    }

    # Position the dialog. The frame, when there is one, takes the whole
    # dialog rectangle and places the content inside its own inner rect;
    # otherwise the content takes the rectangle directly. Identical code
    # either way, so the unframed path is exactly what it always was.
    my $dialog = $!frame // $!content;
    if $dialog {
        if $dialog.plane {
            $dialog.reposition($modal-y, $modal-x);
            $dialog.handle-resize($modal-rows, $modal-cols);
        } else {
            $dialog.init-plane(self.plane,
                y => $modal-y, x => $modal-x,
                rows => $modal-rows, cols => $modal-cols);
        }
        # Propagate absolute viewport. Neither reposition nor
        # handle-resize updates abs-y / abs-x, and the dialog's
        # internal layout-children cascade uses self.abs-y as the
        # origin for its children — without this call, every
        # descendant's abs-y stays frozen at its pre-modal value.
        $dialog.set-viewport(
            abs-y => self.abs-y + $modal-y,
            abs-x => self.abs-x + $modal-x,
            rows  => $modal-rows,
            cols  => $modal-cols,
        );
        $dialog.mark-dirty unless $dialog.is-dirty;
        $dialog.render;
        # Greyscale post-pass. `$dialog` is the frame on a framed modal
        # and the content otherwise; either way it is the subtree the
        # modal just rendered, so one check covers both shapes. A
        # disabled modal (backdrop and all) is greyed by the render
        # loop's hook instead.
        $dialog.apply-disabled-effect if $dialog.disabled;
    }

    self.clear-dirty;
}

#|( Paint (or re-place) the backdrop plane. Never reached in
    C<BackdropNone>.

    The scrim path writes B<nothing>: the plane's base cell carries the
    scrim colour with both channels blended and an empty EGC, and
    C<ncplane_erase> — which preserves the base cell and releases every
    other — spreads it across the whole plane. Every cell then
    contributes a 50/50 mix and no glyph, which is precisely a scrim.

    The opaque path is the original one, down to the per-row fill: a
    fully-written plane of spaces in the C<modal-backdrop> style. )
method !render-backdrop(UInt $rows, UInt $cols) {
    if $!bg-plane {
        ncplane_move_yx($!bg-plane, 0, 0);
        ncplane_resize_simple($!bg-plane, $rows, $cols);
    } else {
        my $opts = NcplaneOptions.new(y => 0, x => 0, :$rows, :$cols);
        $!bg-plane = ncplane_create(self.plane, $opts);
    }
    return without $!bg-plane;

    if $!backdrop === BackdropScrim {
        ncplane_set_base($!bg-plane, '', 0, self.scrim-channels);
        ncplane_erase($!bg-plane);
        return;
    }

    my $style = self.theme.modal-backdrop;
    ncplane_set_bg_rgb($!bg-plane, $style.bg) if $style.bg.defined;
    ncplane_set_fg_rgb($!bg-plane, $style.fg) if $style.fg.defined;
    ncplane_erase($!bg-plane);

    my $fill = ' ' x $cols;
    for ^$rows -> $row {
        ncplane_putstr_yx($!bg-plane, $row, 0, $fill);
    }
}

#| Modal-level event handler. Only consults the modal's own keybinds
#| (Esc-to-close by default). Per-content events are routed by
#| C<Selkie::App>'s dispatcher to the focused descendant inside the
#| modal — modal-isolation is enforced at the App layer, not here.
method handle-event(Selkie::Event $ev --> Bool) {
    self!check-keybinds($ev);
}

#|( Destroy the modal: tear down the content subtree, the frame, the
    backdrop plane, and the modal's own plane. Always called by
    C<Selkie::App> when the modal is removed from the stack — apps don't
    usually call this directly.

    On a framed modal the content belongs to the frame, so the whole
    subtree goes down through the Container child list exactly once —
    destroying C<$!content> as well would be a double free.

    A backdrop fade still in flight is cancelled B<first>: the modal is
    about to lose its planes, and a surviving tween would keep calling
    C<set-scrim-style> on a dead widget every frame until its duration
    ran out. )
method destroy() {
    self.cancel-backdrop-fade;
    if $!frame {
        self.clear;
        $!frame = Selkie::Widget::Border;
    } elsif $!content {
        $!content.destroy;
    }
    $!content = Selkie::Widget;
    if $!bg-plane {
        ncplane_destroy($!bg-plane);
        $!bg-plane = NcplaneHandle;
    }
    self!destroy-plane;
}