Selkie.git | lib/Selkie/App/Internal/ | ScreenModalLifecycle.rakumod
=begin pod
=head1 NAME
Selkie::App::Internal::ScreenModalLifecycle - internal screen and modal lifecycle role for Selkie::App
=head1 DESCRIPTION
Implementation detail composed by C<Selkie::App>. Use
C<Selkie::App.screen-manager>, C<Selkie::App.root>, C<Selkie::App.add-screen>,
C<Selkie::App.switch-screen>, C<Selkie::App.show-modal>,
C<Selkie::App.close-modal>, and C<Selkie::App.has-modal> from application code.
=end pod
unit role Selkie::App::Internal::ScreenModalLifecycle;
use Selkie::Container;
use Selkie::ScreenManager;
use Selkie::Theme;
use Selkie::Trace;
use Selkie::Widget;
use Selkie::Widget::Modal;
has Selkie::ScreenManager $!screen-manager = Selkie::ScreenManager.new;
# Modal stack: each show-modal pushes; close-modal pops the top. Nested
# modals keep their own focus-restore target in the parallel stack below.
has Selkie::Widget::Modal @!modal-stack;
has Selkie::Widget @!pre-modal-focus-stack;
method !screen-manager(--> Selkie::ScreenManager) {
$!screen-manager;
}
method !active-root(--> Selkie::Container) {
$!screen-manager.active-root;
}
method !modal-stack-list(--> List) {
@!modal-stack.List;
}
method !active-modal-or-nil(--> Mu) {
@!modal-stack ?? @!modal-stack.tail !! Nil;
}
method !active-modal() {
@!modal-stack ?? @!modal-stack.tail !! Selkie::Widget::Modal;
}
method !has-modal(--> Bool) {
?@!modal-stack;
}
# Shared by the global Esc keybind (App.rakumod) and the click-outside
# check (Dispatch.rakumod) so the two user-dismissal paths can never
# drift apart on what "dismissable" means. `.can` guards against a
# hypothetical modal-stack element that predates the attribute (or a
# test double that doesn't declare it) the same way the neighbouring
# `dismiss-on-click-outside` check already does; a modal with no
# opinion is dismissable, matching the attribute's own True default.
method !modal-is-dismissable(Mu $modal --> Bool) {
return True unless $modal.defined;
return True unless $modal.can('dismissable');
?$modal.dismissable;
}
method !set-theme-on-screens(Selkie::Theme:D $theme --> Nil) {
for $!screen-manager.screen-names -> $name {
my $root = $!screen-manager.screen($name);
if $root.defined {
$root.set-theme($theme);
$root.mark-dirty;
}
}
}
method !add-screen(Str:D $name, Selkie::Container $root --> Nil) {
my ($rows, $cols) = self!terminal-size;
$root.set-theme(self.theme);
$root.set-store(self.store);
$root.init-plane(self.stdplane, y => 0, x => 0, rows => $rows, cols => $cols);
$root.set-viewport(abs-y => 0, abs-x => 0, rows => $rows, cols => $cols);
if $!screen-manager.screen-names.elems > 0 {
# Newly-added inactive screens must be parked via park(), not plain
# reposition, so widgets with auxiliary native resources clean up.
$root.park;
}
$!screen-manager.add-screen($name, $root);
self!clear-screen-focus($name);
}
method !switch-screen(Str:D $name --> Nil) {
my ($rows, $cols) = self!terminal-size;
my $old-root = self!active-root;
my $old-name = $!screen-manager.active-screen;
# The whole swap is one span. Parking the outgoing screen tears down
# every sprixel in it, and each teardown walks the widget trees
# (Selkie::Tree), so this is where a screen swap's cost actually
# lives — invisible from `ui.input.dispatch` alone, which is what
# made a 295ms close-frame a black box in the 2026-08 capture.
my $span = Selkie::Trace.enabled
?? Selkie::Trace.start('screen.switch', cat => 'ui',
args => %(from => ($old-name // ''), to => $name,
rows => $rows, cols => $cols))
!! Nil;
my Str $trace-exit = 'switched';
LEAVE {
$span.finish(exit => $trace-exit,
subscriptions => (self.store.defined
?? self.store.subscription-count !! -1)) with $span;
}
self!remember-screen-focus($old-name, $old-root);
$!screen-manager.switch-to($name);
my $new-root = self!active-root;
# Park the outgoing screen so sprixels and other owned native resources
# are torn down before the incoming screen paints over them.
$old-root.park if $old-root && $old-root !=== $new-root;
if $new-root {
$new-root.reposition(0, 0);
$new-root.resize($rows, $cols);
$new-root.mark-dirty;
}
$trace-exit = 'same-screen' if $old-root && $old-root === $new-root;
self!restore-screen-focus($name, $new-root);
}
method !show-modal(Selkie::Widget::Modal $modal --> Nil) {
my ($rows, $cols) = self!terminal-size;
@!pre-modal-focus-stack.push(self!focused-widget);
@!modal-stack.push($modal);
$modal.set-theme(self.theme);
$modal.set-store(self.store);
$modal.init-plane(self.stdplane, y => 0, x => 0, rows => $rows, cols => $cols);
$modal.mark-dirty;
self!maybe-fade-backdrop($modal);
# Non-modal Images are now occluded; dirty them so their render path
# destroys or re-emits sprixels against the new active-modal state.
self!mark-all-images-dirty;
my @fd = $modal.focusable-descendants.List;
self.focus(@fd[0]) if @fd;
}
# Opt-in scrim fade, split out of `!show-modal` so the decision can be
# driven (and the flag-off case proved) without a terminal.
#
# `fade-backdrop-in` is itself a no-op on any backdrop mode but
# BackdropScrim and on a theme with no `modal-scrim` slot, so the flag
# is the only guard needed here. With the flag off — the default, and
# what every consumer that hasn't opted in sees — `!tween-group` is
# never touched, so the app allocates no group and registers no
# per-frame ticker.
#
# The modal owns the tween it hands back: `close-modal` destroys the
# modal, and `Modal.destroy` cancels the fade, which is what makes
# closing mid-fade safe rather than a callback pointed at a freed
# plane. Nothing to unwind here.
method !maybe-fade-backdrop(Selkie::Widget::Modal:D $modal --> Mu) {
return Nil unless self.animate-backdrop;
$modal.fade-backdrop-in(self!tween-group);
}
#|( Esc-driven dismissal: a no-op unless a modal is open and that
modal's C<dismissable> attribute allows it, so a modal built with
C<:!dismissable> can only ever be closed programmatically (its own
C<.close>, or C<Selkie::App.close-modal> called by application
code). )
method !close-modal-via-esc(--> Nil) {
return unless self!has-modal;
return unless self!modal-is-dismissable(self!active-modal);
self!close-modal;
}
method !close-modal(--> Nil) {
return without @!modal-stack;
my $closed = @!modal-stack.pop;
my $restore = @!pre-modal-focus-stack.pop;
$closed.destroy;
self!mark-all-images-dirty;
my $root = self!focus-root;
if $restore.defined && self.widget-attached($restore, $root) {
self.focus($restore);
} else {
self.focus(self!first-focusable);
}
if @!modal-stack {
self!mark-all-dirty(@!modal-stack.tail);
} else {
self!mark-all-dirty(self.root) if self.root;
}
}
method !shutdown-screen-modal-lifecycle(--> Nil) {
while @!modal-stack {
my $m = @!modal-stack.pop;
@!pre-modal-focus-stack.pop;
self!try-log("modal-destroy", { $m.destroy if $m.defined });
}
self!try-log('screen-manager-destroy', { $!screen-manager.destroy });
}