Selkie.git | docs/api/ | Selkie--Widget--Modal.md
NAME
====
Selkie::Widget::Modal - Centered overlay dialog with a dimmed, scrimmed, or transparent backdrop
SYNOPSIS
========
```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);
```
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, [Selkie::App](Selkie--App.md) routes all events through it — Tab/Shift-Tab still cycle focus within the modal, Esc auto-closes.
For common confirm/cancel dialogs, use [Selkie::Widget::ConfirmModal](Selkie--Widget--ConfirmModal.md) which wraps Modal with a pre-built button row.
`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.
Backdrops
---------
`backdrop` picks what the modal does with the screen it covers. It takes a `BackdropMode`, exported by this module (`use Selkie` does not re-export it — `use Selkie::Widget::Modal`):
* **`BackdropOpaque`** — the default, and what Modal has always drawn: a full-screen plane painted in the theme's `modal-backdrop` style, hiding the screen completely.
* **`BackdropScrim`** — the screen stays visible, tinted through the `modal-scrim` slot. Text underneath remains legible, just dimmed.
* **`BackdropNone`** — no backdrop at all. The screen shows through untouched around the dialog.
```raku
use Selkie::Widget::Modal;
my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
$m.set-backdrop(BackdropOpaque); # re-primes the planes, marks dirty
```
`dim-background` is the original boolean spelling and still works: `dim-background => True` (the default) is `BackdropOpaque`, and `dim-background => False` is `BackdropNone`. The accessor is derived from `backdrop`, so the two can never disagree. An explicit `backdrop` wins if you pass both.
**`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 `BackdropScrim` and `BackdropNone`.
Scrim
-----
A scrim is a single blend layer, not an opacity slider. Notcurses alpha is a two-bit enum (see [Selkie::Alpha](Selkie--Alpha.md)), so `AlphaBlend` means exactly one 50/50 average against whatever the compositor has accumulated underneath — per channel, with integer truncation. A black scrim over a `0x808080` background resolves to `0x404040`; over white it lands on `0x7F7F7F`, not `0x808080`.
**Want a deeper dim? Choose a darker scrim colour, not "more alpha" — there is no such thing.** Black at `AlphaBlend` is as dark as one layer gets. The knob is the theme's `modal-scrim` slot:
```raku
my $theme = Selkie::Theme.default.clone(
modal-scrim => Selkie::Style.new(
fg => 0x101018, bg => 0x101018,
fg-alpha => AlphaBlend, bg-alpha => AlphaBlend,
),
);
```
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.
### Scrim and images
**A scrim erases sprixels underneath it.** Blending sets the cell's blend count, and notcurses's `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 [Selkie::Widget::Image](Selkie--Widget--Image.md), use `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.
### Fading the scrim in
`fade-backdrop-in` ramps the scrim **colour** from the screen's own background toward the `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 (`Selkie::App.new(:animate-backdrop)`) and `Selkie::App.show-modal` is what normally calls it — reach for the method directly only if you are driving modals yourself.
```raku
$modal.fade-backdrop-in($app.tweens, duration => 0.12);
```
Alpha does not move: both channels stay `AlphaBlend` for the whole ramp, because there is no fractional alpha to ramp ([Selkie::Alpha](Selkie--Alpha.md)). What moves is the colour the blend layer contributes, from `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. `destroy` and `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 `BackdropScrim`, and a no-op when the theme has no `modal-scrim` style.
**There is no fade-out.** `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.
`set-scrim-style` / `clear-scrim-style` / `effective-scrim-style` are the underlying knobs if you want to drive the scrim colour from something other than a tween.
Frames
------
`framed => True` draws a [Selkie::Widget::Border](Selkie--Widget--Border.md) around the dialog, which is where a title and a key-hint strip go:
```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;
```
The frame is an implementation detail of the modal's chrome, not part of its content:
* `.content` keeps returning **your** widget, framed or not. So does `focusable-descendants`, and the Tab cycle is unchanged.
* The frame paints from the `modal-frame` / `modal-title` / `modal-key` theme slots, each of which defaults to an existing slot — so turning framing on changes the layout, never the palette.
* `.frame` hands you the Border itself for anything the `frame-*` knobs don't cover (glyph tables, per-edge padding, hidden edges). Changes you make there are not mirrored back into the `frame-*` accessors.
**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 `frame-padding` (default 1) on every side. A `height-ratio => 0.4` modal on a 24-row terminal is 9 rows tall and hands its content 5. `content-extent` is the plane-free way to ask, and it's what layouts with flex spacers should be sized against:
```raku
my ($rows, $cols) = $modal.content-extent(24, 80);
```
Squeeze it far enough — a tiny terminal, or generous `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 [Selkie::Widget::Border](Selkie--Widget--Border.md)'s Padding section).
EXAMPLES
========
Input dialog
------------
```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);
```
A framed dialog over a scrimmed screen
--------------------------------------
```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);
```
SEE ALSO
========
* [Selkie::Widget::Border](Selkie--Widget--Border.md) — the frame; `inner-rect`, glyph sets, titles
* [Selkie::Alpha](Selkie--Alpha.md) — why the scrim is one blend layer and not a percentage
* [Selkie::Tween](Selkie--Tween.md) — the interpolation `fade-backdrop-in` runs on
* [Selkie::Theme](Selkie--Theme.md) — `modal-backdrop`, `modal-frame`, `modal-title`, `modal-key`, `modal-scrim`
* [Selkie::Widget::ConfirmModal](Selkie--Widget--ConfirmModal.md) — pre-built yes/no confirmation
* [Selkie::Widget::FileBrowser](Selkie--Widget--FileBrowser.md) — pre-built file picker
* [Selkie::App](Selkie--App.md) — `show-modal` and `close-modal` methods
What the modal does with the screen it covers. `BackdropOpaque` is the historical behaviour and the default: a full-screen plane painted in the theme's `modal-backdrop` style. `BackdropScrim` tints the screen through `modal-scrim` instead, leaving text underneath legible. `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.
### has BackdropMode $.backdrop
What the modal does with the screen behind it. Defaults to `BackdropOpaque`, which is byte-for-byte what Modal has always drawn. Change it after construction with `set-backdrop`.
### has Bool $.dismiss-on-click-outside
When True, a primary mouse click outside the modal's content rectangle dismisses the modal — the framework calls `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 `:dismiss-on-click-outside` to their parent constructor — `HelpOverlay` defaults to True (lightweight informational overlay), `ConfirmModal` stays False (a Yes/No decision shouldn't be silently abandoned).
### has Bool $.dismissable
When False, the user cannot dismiss the modal — `Esc` is ignored while it is topmost, and click-outside dismissal is suppressed regardless of `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: `.close` and `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 $.framed
Draw a [Selkie::Widget::Border](Selkie--Widget--Border.md) around the content. Construction time only — the frame is built in `TWEAK` — and False by default, which leaves the modal exactly as it has always rendered.
### has BorderKind $.frame-style
Glyph set for the frame. Ignored unless `framed`.
### has Selkie::BorderStyle::BorderGlyphs $.frame-glyphs
Explicit glyph table for the frame, overriding `frame-style`.
### has Str $.frame-title
Title along the frame's top edge. Empty (the default) draws none.
### has TitleAlign $.frame-title-align
Placement of `frame-title`. `TitleCenter` by default — a dialog heading reads as a heading when it's centred, where a pane title reads better hard left.
### has Str $.frame-bottom-title
Title along the frame's bottom edge — the key-hint slot (`'esc cancel · ⏎ save'`). Empty by default.
### has TitleAlign $.frame-bottom-title-align
Placement of `frame-bottom-title`. `TitleCenter` by default.
### has UInt $.frame-padding
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.
### method content
```raku
method content() returns Selkie::Widget
```
The current content widget, or the `Selkie::Widget` type object when no content is set. **Always your widget** — a framed modal wraps it in a Border internally, and that Border is reached via `frame`.
### method frame
```raku
method frame() returns Selkie::Widget::Border
```
The internal [Selkie::Widget::Border](Selkie--Widget--Border.md), or the `Selkie::Widget::Border` type object when the modal isn't framed. The escape hatch for frame configuration the `frame-*` constructor knobs don't cover — custom glyph tables, per-edge padding, a hidden edge. Anything you change here is invisible to the `frame-*` accessors, which report what the modal was constructed with.
### method dim-background
```raku
method dim-background() returns Bool
```
Whether a backdrop plane is drawn at all. The original boolean spelling of `backdrop`, derived from it rather than stored alongside it so the two can't drift: True for `BackdropOpaque` and `BackdropScrim`, False for `BackdropNone`.
### method set-backdrop
```raku
method set-backdrop(
BackdropMode:D $mode
) returns Nil
```
Switch backdrop modes. Re-primes the modal's own plane base (opaque for `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 base-style
```raku
method base-style() returns Selkie::Style
```
The style stamped into the modal's own full-screen plane base cell. `BackdropOpaque` keeps the framework default (the theme's `base`), which is what makes the modal's footprint cover the screen. The other two modes return a style whose channels are both `AlphaTransparent`: the modal plane then contributes nothing, and what shows through is the scrim plane, or — for `BackdropNone` — the screen itself.
### method base-egc
```raku
method base-egc() returns Str
```
The glyph stamped into the modal's own plane base cell: a space under `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 effective-scrim-style
```raku
method effective-scrim-style() returns Selkie::Style
```
The style the backdrop plane's base cell is primed from in `BackdropScrim` mode: the `set-scrim-style` override when one is installed, and the theme's `modal-scrim` slot otherwise. Pure.
### method set-scrim-style
```raku
method set-scrim-style(
Selkie::Style:D $style
) returns Nil
```
Override the scrim colour, replacing the theme's `modal-scrim` slot until `clear-scrim-style` puts it back. Marks the modal dirty — the backdrop is only re-primed when `render` runs, and `render` only runs on a dirty modal. This is the hook `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 `AlphaBlend`: a scrim that changes alpha changes what it is, not how deep it goes.
### method clear-scrim-style
```raku
method clear-scrim-style() returns Nil
```
Drop a `set-scrim-style` override and go back to the theme slot. Marks the modal dirty. A no-op when no override is installed.
### method scrim-channels
```raku
method scrim-channels() returns UInt
```
The packed notcurses channels word the backdrop plane's base cell is primed with in `BackdropScrim` mode, built from `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 `base-style` hook to route it through; the word is built by `Selkie::Style.channels`, the same code `Selkie::Widget`'s `base-channels` goes through, so the two can't drift.
### method backdrop-fade-endpoints
```raku
method backdrop-fade-endpoints() returns List
```
The two ends of the scrim fade for the current theme, as `($from, $to)` — or the empty list when there is nothing to fade (any backdrop mode but `BackdropScrim`, or a theme with no `modal-scrim` style). `$to` is the `modal-scrim` slot itself. `$from` is that same style with both colours swapped for the theme's `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 `AlphaMode`s — is copied across from `$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 fade-backdrop-in
```raku
method fade-backdrop-in(
Selkie::Tween::TweenGroup:D $group,
Real :$duration = 0.12,
Instant :$at = Code.new
) returns Mu
```
Ramp the scrim colour up from the screen background to the theme's `modal-scrim` over `$duration` seconds, on `$group` (normally `Selkie::App.tweens`). Returns the `Selkie::Tween::Tween` so the caller can hold it, or `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: `destroy` and `set-backdrop` cancel it.
### method cancel-backdrop-fade
```raku
method cancel-backdrop-fade() returns Nil
```
Stop a backdrop fade where it stands and drop the colour override, so the scrim snaps to the theme slot. Called by `destroy` and `set-backdrop`; safe to call when nothing is animating. `Selkie::Tween::Tween.cancel` deliberately does not fire `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 `DESTROY` on the GC thread), where there is nothing left to repaint.
### method release-backdrop-tween
```raku
method release-backdrop-tween() returns Nil
```
Drop the modal's handle on a fade that finished on its own. Public only because the tween's `on-complete` closure has to reach it; treat it as internal.
### method backdrop-fading
```raku
method backdrop-fading() returns Bool
```
True while a backdrop fade is running. Mostly a testing hook.
### method on-close
```raku
method on-close() returns Supply
```
Supply that emits `True` when `close` is called or the user dismisses the modal (Esc, or a click outside when `dismiss-on-click-outside` is set). Tap this to call `$app.close-modal` and run any post-close logic.
### method set-content
```raku
method set-content(
Selkie::Widget $w,
Bool :$destroy = Bool::True
) returns Mu
```
Install `$w` as the modal's content. Re-callable to swap content during a multi-step wizard. `:destroy` (default True) destroys the outgoing widget — the common case when content isn't reused. Pass `:!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 `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. `content` answers `$w` either way.
### method set-frame-title
```raku
method set-frame-title(
Str:D $t
) returns Nil
```
Replace the frame's top title. No-op on an unframed modal. Keeps the `frame-title` accessor in step with the Border.
### method set-frame-bottom-title
```raku
method set-frame-bottom-title(
Str:D $t
) returns Nil
```
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 close
```raku
method close() returns Mu
```
Emit on `on-close`. Doesn't itself remove the modal from the App — the caller's tap is expected to call `$app.close-modal`.
### method focusable-descendants
```raku
method focusable-descendants() returns Seq
```
Focusable descendants of the modal's content subtree. `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 modal-rect
```raku
method modal-rect(
Int $rows where { ... },
Int $cols where { ... }
) returns List
```
The dialog rectangle for a terminal `$rows` x `$cols`, as `($y, $x, $rows, $cols)` relative to the modal's own (full-screen) plane. `width-ratio` / `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 `render` and `handle-resize` so the two can't drift.
### method content-extent
```raku
method content-extent(
Int $rows where { ... },
Int $cols where { ... }
) returns List
```
The dimensions the **content** widget is given for a terminal `$rows` x `$cols`, as `($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 `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 handle-resize
```raku
method handle-resize(
Int $rows where { ... },
Int $cols where { ... }
) returns Mu
```
Cascade a terminal resize to the content subtree. The content is sized to the same rectangle `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 render-backdrop
```raku
method render-backdrop(
Int $rows where { ... },
Int $cols where { ... }
) returns Mu
```
Paint (or re-place) the backdrop plane. Never reached in `BackdropNone`. The scrim path writes **nothing**: the plane's base cell carries the scrim colour with both channels blended and an empty EGC, and `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 `modal-backdrop` style.
### method handle-event
```raku
method handle-event(
Selkie::Event $ev
) returns Bool
```
Modal-level event handler. Only consults the modal's own keybinds (Esc-to-close by default). Per-content events are routed by `Selkie::App`'s dispatcher to the focused descendant inside the modal — modal-isolation is enforced at the App layer, not here.
### method destroy
```raku
method destroy() returns Mu
```
Destroy the modal: tear down the content subtree, the frame, the backdrop plane, and the modal's own plane. Always called by `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 `$!content` as well would be a double free. A backdrop fade still in flight is cancelled **first**: the modal is about to lose its planes, and a surviving tween would keep calling `set-scrim-style` on a dead widget every frame until its duration ran out.