Selkie.git | lib/Selkie/App/Internal/ | FocusTree.rakumod


=begin pod

=head1 NAME

Selkie::App::Internal::FocusTree - internal focus and tree-state role for Selkie::App

=head1 DESCRIPTION

Implementation detail composed by C<Selkie::App>. Use C<Selkie::App.focus>,
C<Selkie::App.focused>, C<Selkie::App.focus-next>, C<Selkie::App.focus-prev>,
and C<Selkie::App.widget-attached> from application code.

Every path here — first-focusable, the Tab cycle, the per-frame
invariant, and per-screen focus memory — decides what focus may land on
through C<Selkie::App.focus-eligible>, which is the one place the rule
lives. See "Focus eligibility" in L<Selkie::App>.

=end pod

unit role Selkie::App::Internal::FocusTree;

use Selkie::Widget;
use Selkie::Tree;

has Selkie::Widget $!focused;

# Per-screen focus memory: screen name -> last-focused widget on that screen.
has %!screen-focus;

method !focused-widget(--> Selkie::Widget) {
    $!focused;
}

method !focus-widget(Selkie::Widget $w --> Nil) {
    $!focused.set-focused(False) if $!focused.defined && $!focused.can('set-focused');
    my $target = $w.defined ?? $w !! self!first-focusable;
    $!focused = $target;
    $target.set-focused(True) if $target.defined && $target.can('set-focused');
    self.store.dispatch('ui/focus', widget => $target);
}

method !clear-screen-focus(Str:D $name --> Nil) {
    %!screen-focus{$name}:delete;
}

method !remember-screen-focus($name, $root --> Nil) {
    if $name.defined && $!focused.defined
        && self!widget-attached-to($!focused, $root)
    {
        %!screen-focus{$name} = $!focused;
    }
}

# A remembered focus has to clear the same bar a fresh one does: the
# screen it belongs to may well have had a region disabled while it was
# away, and restoring into it would re-create the wedge the focus
# invariant exists to prevent.
method !restore-screen-focus(Str:D $name, $root --> Nil) {
    my $target = %!screen-focus{$name};
    if $target.defined && self.focus-eligible($target)
        && self!widget-attached-to($target, $root)
    {
        self!focus-widget($target);
    } else {
        %!screen-focus{$name}:delete;
        self!focus-widget(self!first-focusable);
    }
}

# The eligibility filter is not redundant with focusable-descendants'
# own disabled skip. That walk starts *below* the surface root, so a
# disabled root (or modal) still yields its children; it also can't
# speak for a custom container whose focusable-descendants override
# forgets the skip. Running every candidate past App.focus-eligible
# makes the App-level rule the single authority on what focus can land
# on, at one short ancestor walk per candidate — and only when focus
# actually moves, never per frame.
method !first-focusable(--> Selkie::Widget) {
    my $top = self!active-modal;
    my @fd = $top.defined
        ?? $top.focusable-descendants.List
        !! self.screen-manager.focusable-descendants.List;
    @fd.first({ self.focus-eligible($_) }) // Selkie::Widget;
}

method !focus-root() {
    my $top = self!active-modal;
    $top.defined ?? $top !! self.screen-manager.active-root;
}

method !widget-attached-to(Selkie::Widget $w, $root --> Bool) {
    return False without $w;
    return False without $root;
    my $node = $w;
    my int $hops = 0;
    while $node.defined {
        return True if $node === $root;
        $node = next-ancestor($node, $hops);
        $hops = $hops + 1;
    }
    False;
}

# Four ways focus goes stale, all re-homed the same way:
#
#   1. The focused widget was detached (container removed, screen
#      destroyed) — the original reason this guard exists. Note that
#      `Selkie::Container.remove` destroys a child without clearing
#      its `parent`, so removal alone does not fail the attachment
#      test; App.focus-eligible's is-destroyed check is what catches
#      that half.
#   2. The focused widget was destroyed while it held focus — a
#      rebuilt list, a swapped pane, a container clear.
#   3. The focused widget was disabled while it held focus. Nothing
#      else would evict it: set-disabled runs on the widget, which has
#      no route back to the App, so keystrokes would keep arriving at
#      a greyed-out control until the user Tabbed away.
#   4. An ANCESTOR of the focused widget was disabled while it held
#      focus — the collapsed-pane shape, where an app switches a
#      region off by disabling the container (or just its border) and
#      leaves the controls inside it alone. Case 3's check never saw
#      this: the focused widget's own flag is still False. The result
#      was a wedge — arrows and Enter went to a control the user could
#      neither see nor act on, while global chords still worked, which
#      is indistinguishable from a hung app.
#
# App.focus-eligible owns the rule and walks the chain; first-focusable
# applies the same rule, so the re-home always lands somewhere live.
method !check-focus-invariant(--> Nil) {
    return unless $!focused.defined;
    return if self.focus-eligible($!focused)
           && self!widget-attached-to($!focused, self!focus-root);
    self!focus-widget(self!first-focusable);
}

method !focus-next(--> Nil) {
    self!do-focus-cycle(1);
}

method !focus-prev(--> Nil) {
    self!do-focus-cycle(-1);
}

method !process-focus-actions(--> Nil) {
    my $action = self.store.get-in('ui', 'focus-action');
    if $action.defined {
        self.store.assoc-in('ui', 'focus-action', value => Nil);
        given $action {
            when 'next' { self!do-focus-cycle(1) }
            when 'prev' { self!do-focus-cycle(-1) }
        }
    }
}

method !do-focus-cycle(Int $direction --> Nil) {
    my $top = self!active-modal;
    my @candidates = $top.defined
        ?? $top.focusable-descendants.List
        !! self.screen-manager.focusable-descendants.List;
    # Same authority as !first-focusable: Tab can only ever land where
    # `focus` itself would accept a target.
    my @focusable = @candidates.grep({ self.focus-eligible($_) }).List;
    return unless @focusable;

    if $!focused.defined {
        my $idx = @focusable.first(* === $!focused, :k);
        if $idx.defined {
            self!focus-widget(@focusable[($idx + $direction) % @focusable.elems]);
        } else {
            self!focus-widget(@focusable[$direction > 0 ?? 0 !! *-1]);
        }
    } else {
        self!focus-widget(@focusable[$direction > 0 ?? 0 !! *-1]);
    }
}