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