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 });
}