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.
=end pod
unit role Selkie::App::Internal::FocusTree;
use Selkie::Widget;
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;
}
}
method !restore-screen-focus(Str:D $name, $root --> Nil) {
my $target = %!screen-focus{$name};
if $target.defined && self!widget-attached-to($target, $root) {
self!focus-widget($target);
} else {
%!screen-focus{$name}:delete;
self!focus-widget(self!first-focusable);
}
}
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[0] // 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;
while $node.defined {
return True if $node === $root;
$node = $node.parent;
}
False;
}
# Two ways focus goes stale, both re-homed the same way:
#
# 1. The focused widget was detached (container removed, screen
# destroyed) — the original reason this guard exists.
# 2. 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. first-focusable
# already skips disabled subtrees, so the re-home lands somewhere
# live.
method !check-focus-invariant(--> Nil) {
return unless $!focused.defined;
return if !$!focused.disabled
&& 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 @focusable = $top.defined
?? $top.focusable-descendants.List
!! self.screen-manager.focusable-descendants.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]);
}
}