Selkie--Widget--Modal

NAME

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

SYNOPSIS

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

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), 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:

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, 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.

$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). 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 around the dialog, which is where a title and a key-hint strip go:

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:

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's Padding section).

EXAMPLES

Input dialog

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

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

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 $.framed

Draw a Selkie::Widget::Border 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

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

method frame() returns Selkie::Widget::Border

The internal Selkie::Widget::Border, 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

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

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

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

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

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

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

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

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

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 AlphaModes โ€” 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

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

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

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

method backdrop-fading() returns Bool

True while a backdrop fade is running. Mostly a testing hook.

method on-close

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

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

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

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

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

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

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

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

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

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

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

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.

Selkie v0.11.1

High-level TUI framework built on Notcurses

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Notcurses::Native:ver<0.4.1+>:auth<zef:apogee>

Test Dependencies

Provides

  • Selkie
  • Selkie::Align
  • Selkie::Alpha
  • Selkie::App
  • Selkie::App::Internal::Animation
  • Selkie::App::Internal::Dispatch
  • Selkie::App::Internal::ErrorLog
  • Selkie::App::Internal::FocusTree
  • Selkie::App::Internal::HitTest
  • Selkie::App::Internal::IdleBudget
  • Selkie::App::Internal::OverlayTree
  • Selkie::App::Internal::RenderLoop
  • Selkie::App::Internal::ScreenModalLifecycle
  • Selkie::App::Internal::Terminal
  • Selkie::App::Internal::TerminalSequences
  • Selkie::BorderStyle
  • Selkie::Container
  • Selkie::EffectiveBounds
  • Selkie::Event
  • Selkie::Gradient
  • Selkie::Layout::Allocate
  • Selkie::Layout::HBox
  • Selkie::Layout::Split
  • Selkie::Layout::VBox
  • Selkie::Plot::Palette
  • Selkie::Plot::Scaler
  • Selkie::Plot::Ticks
  • Selkie::ScreenManager
  • Selkie::Sizing
  • Selkie::Store
  • Selkie::Style
  • Selkie::Test::Focus
  • Selkie::Test::Keys
  • Selkie::Test::Snapshot
  • Selkie::Test::Snapshot::Harness
  • Selkie::Test::Store
  • Selkie::Test::Supply
  • Selkie::Test::Tree
  • Selkie::Theme
  • Selkie::Trace
  • Selkie::Tree
  • Selkie::Tween
  • Selkie::Widget
  • Selkie::Widget::Axis
  • Selkie::Widget::BarChart
  • Selkie::Widget::Border
  • Selkie::Widget::Button
  • Selkie::Widget::CardList
  • Selkie::Widget::Checkbox
  • Selkie::Widget::CommandPalette
  • Selkie::Widget::ConfirmModal
  • Selkie::Widget::FileBrowser
  • Selkie::Widget::FocusableByDefault
  • Selkie::Widget::GradientFill
  • Selkie::Widget::Heatmap
  • Selkie::Widget::HelpOverlay
  • Selkie::Widget::Histogram
  • Selkie::Widget::Image
  • Selkie::Widget::Legend
  • Selkie::Widget::LineChart
  • Selkie::Widget::ListView
  • Selkie::Widget::Modal
  • Selkie::Widget::MultiLineInput
  • Selkie::Widget::PasswordStrength
  • Selkie::Widget::Plot
  • Selkie::Widget::ProgressBar
  • Selkie::Widget::RadioGroup
  • Selkie::Widget::RichText
  • Selkie::Widget::RichText::Span
  • Selkie::Widget::ScatterPlot
  • Selkie::Widget::ScrollView
  • Selkie::Widget::Select
  • Selkie::Widget::Sparkline
  • Selkie::Widget::Spinner
  • Selkie::Widget::TabBar
  • Selkie::Widget::Table
  • Selkie::Widget::Text
  • Selkie::Widget::TextInput
  • Selkie::Widget::TextInput::HighlightSpan
  • Selkie::Widget::TextStream
  • Selkie::Widget::Toast
  • Selkie::Widget::ViewportedCardList

Documentation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is trademark of the Yet Another Society. All rights reserved.

Built with Podlite โ€” the markup and publishing tools behind this site.