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