Selkie.git | t/ | 111-focus-ancestor-eligibility.rakutest


use Test;
use lib 'lib';

use Selkie::App;
use Selkie::App::Internal::FocusTree;
use Selkie::App::Internal::ScreenModalLifecycle;
use Selkie::Container;
use Selkie::Sizing;
use Selkie::Test::Store;
use Selkie::Widget;
use Selkie::Widget::Border;
use Selkie::Widget::Button;
use Selkie::Widget::Modal;

# Focus eligibility is an ANCESTOR question, not a per-widget one.
#
# `disabled` is how apps switch a whole region off, and in practice
# only the region's own container (or just its border) carries the
# flag — the controls inside it keep `disabled` at False. A rule that
# asked the focused widget alone would leave focus sitting inside a
# switched-off region: arrows and Enter reach a control the user can
# neither see nor act on, while global chords keep working. That is an
# input wedge, and it looks exactly like a hung app.
#
# This file pins the whole contract: the predicate itself, the Tab
# cycle, the per-frame invariant, per-screen focus memory, and the
# focus restored when a modal closes.

# Tiny container stub — same pattern as t/focus-invariant.rakutest.
class TestContainer does Selkie::Container {
    method render() { self!render-children; self.clear-dirty }
}

# A container whose focusable-descendants override does NOT prune
# disabled subtrees. Third-party containers are free to write their own
# traversal, and the framework cannot assume they remembered the skip —
# App's own eligibility filter is what makes the rule hold anyway.
class NaiveContainer does Selkie::Container {
    method render() { self!render-children; self.clear-dirty }
    method focusable-descendants(--> Seq) {
        gather {
            for self.children -> $child {
                take $child if $child.focusable;
                if $child ~~ Selkie::Container {
                    .take for $child.focusable-descendants;
                }
            }
        }
    }
}

# Stand-in for the App's screen surface: FocusTree only ever asks a
# screen manager for its focusable descendants and its active root.
class StubScreens {
    has $.root is rw;
    method focusable-descendants(--> Seq) {
        $!root.defined ?? $!root.focusable-descendants !! ().Seq;
    }
    method active-root() { $!root }
}

# FocusTree driven without notcurses. Everything the role reaches for
# outside itself is supplied here; the focus logic under test is the
# role's own.
class FocusHarness does Selkie::App::Internal::FocusTree {
    has $.store;
    has StubScreens $.screens .= new;

    method screen-manager() { $!screens }
    method store() { $!store }
    method focused() { self!focused-widget }
    method focus-eligible($w) { Selkie::App.focus-eligible($w) }

    method !active-modal() { Selkie::Widget }

    method focus($w --> Nil) {
        return if $w.defined && !self.focus-eligible($w);
        self!focus-widget($w);
    }
    method first-focusable-for-test() { self!first-focusable }
    method cycle-for-test(Int $d --> Nil) { self!do-focus-cycle($d) }
    method check-invariant-for-test(--> Nil) { self!check-focus-invariant }
    method remember-for-test($name, $root --> Nil) {
        self!remember-screen-focus($name, $root);
    }
    method restore-for-test($name, $root --> Nil) {
        self!restore-screen-focus($name, $root);
    }
}

# FocusTree plus the modal lifecycle, for the close-modal restore path.
# The modal stack is pushed directly rather than through `!show-modal`,
# which needs a terminal.
class ModalHarness does Selkie::App::Internal::FocusTree
                   does Selkie::App::Internal::ScreenModalLifecycle {
    has $.store;

    method screen-manager() { self!screen-manager }
    method root() { self!active-root }
    method store() { $!store }
    method focused() { self!focused-widget }
    method focus-eligible($w) { Selkie::App.focus-eligible($w) }
    method widget-attached($w, $root) { self!widget-attached-to($w, $root) }

    method focus($w --> Nil) {
        return if $w.defined && !self.focus-eligible($w);
        self!focus-widget($w);
    }

    method !mark-all-images-dirty(--> Nil) { }
    method !mark-all-dirty($w --> Nil) { }

    method push-modal-for-test($modal, $restore --> Nil) {
        @!modal-stack.push($modal);
        @!pre-modal-focus-stack.push($restore);
    }
    method close-modal-for-test(--> Nil) { self!close-modal }
}

plan 8;

subtest "focus-eligible walks the ancestor chain" => {
    plan 9;

    my $root = TestContainer.new;
    my $pane = TestContainer.new;
    my $deep = TestContainer.new;
    my $btn  = Selkie::Widget::Button.new(label => 'save');
    $root.add($pane);
    $pane.add($deep);
    $deep.add($btn);

    ok Selkie::App.focus-eligible($btn),
        'a widget in an all-enabled chain is eligible';
    nok Selkie::App.focus-eligible(Selkie::Widget),
        'an undefined widget is never eligible';

    $btn.set-disabled(True);
    nok Selkie::App.focus-eligible($btn), 'its own disabled flag rules it out';
    $btn.set-disabled(False);
    ok Selkie::App.focus-eligible($btn), 'and clearing the flag restores it';

    $deep.set-disabled(True);
    nok Selkie::App.focus-eligible($btn),
        'a disabled immediate parent rules it out';
    $deep.set-disabled(False);

    $root.set-disabled(True);
    nok Selkie::App.focus-eligible($btn),
        'a disabled root two levels up rules it out';
    nok Selkie::App.focus-eligible($pane),
        'and so does every widget between it and the root';
    $root.set-disabled(False);
    ok Selkie::App.focus-eligible($btn),
        're-enabling the ancestor makes the whole subtree eligible again';

    my $orphan = Selkie::Widget::Button.new(label => 'detached');
    ok Selkie::App.focus-eligible($orphan),
        'a widget with no parent at all is eligible — eligibility is not attachment';
};

subtest "focus() refuses a target inside a disabled subtree" => {
    plan 4;

    my $root = TestContainer.new;
    my $pane = TestContainer.new;
    my $a = Selkie::Widget::Button.new(label => 'A');
    my $b = Selkie::Widget::Button.new(label => 'B');
    $root.add($a);
    $root.add($pane);
    $pane.add($b);

    my $h = FocusHarness.new(store => mock-store());
    $h.screens.root = $root;

    $h.focus($a);
    is $h.focused.label, 'A', 'preconditions: focus is on A';

    $pane.set-disabled(True);
    $h.focus($b);
    is $h.focused.label, 'A',
        'focusing into a disabled pane is a no-op — focus stays where it was';

    $pane.set-disabled(False);
    $h.focus($b);
    is $h.focused.label, 'B', 'the same call is accepted once the pane is live';

    ok $b.is-focused, 'and the widget itself was told it has focus';
};

subtest "the Tab cycle only lands on eligible widgets" => {
    plan 6;

    # A container that hands back its children without pruning disabled
    # subtrees: the App-level filter has to hold the line on its own.
    my $root = NaiveContainer.new;
    my $a    = Selkie::Widget::Button.new(label => 'A');
    my $pane = NaiveContainer.new;
    my $b    = Selkie::Widget::Button.new(label => 'B');
    my $c    = Selkie::Widget::Button.new(label => 'C');
    $root.add($a);
    $root.add($pane);
    $pane.add($b);
    $root.add($c);

    my $h = FocusHarness.new(store => mock-store());
    $h.screens.root = $root;

    is $root.focusable-descendants.List.map(*.label).join(','), 'A,B,C',
        'preconditions: the naive walk really does yield all three';

    $pane.set-disabled(True);
    is $h.first-focusable-for-test.label, 'A',
        'first-focusable skips nothing while the first candidate is live';

    $h.focus($a);
    $h.cycle-for-test(1);
    is $h.focused.label, 'C',
        'Tab jumps over B, which sits inside the disabled pane';

    $h.cycle-for-test(1);
    is $h.focused.label, 'A', 'and wraps around the shortened cycle';

    $h.cycle-for-test(-1);
    is $h.focused.label, 'C', 'Shift-Tab honours the same filter';

    # Everything switched off: no eligible target anywhere.
    $root.set-disabled(True);
    nok $h.first-focusable-for-test.defined,
        'a fully disabled surface has no first-focusable';
};

subtest "a disabled Border evicts focus from the content it frames" => {
    plan 3;

    # The reported shape: a collapsible pane switches itself off by
    # disabling its border, and leaves the content widget's own flag
    # alone. Border.focusable-descendants asks about its CONTENT, so
    # this subtree stays in the Tab cycle unless the App-level rule
    # looks upward.
    my $root   = NaiveContainer.new;
    my $keep   = Selkie::Widget::Button.new(label => 'elsewhere');
    my $border = Selkie::Widget::Border.new(sizing => Sizing.flex);
    my $inner  = Selkie::Widget::Button.new(label => 'in-pane');
    $border.set-content($inner);
    $root.add($keep);
    $root.add($border);

    $border.set-disabled(True);
    is $border.focusable-descendants.List.map(*.label).join(','), 'in-pane',
        'preconditions: a disabled Border still offers its enabled content';
    $border.set-disabled(False);

    my $h = FocusHarness.new(store => mock-store());
    $h.screens.root = $root;
    $h.focus($inner);
    is $h.focused.label, 'in-pane', 'preconditions: focus is inside the pane';

    $border.set-disabled(True);
    $h.check-invariant-for-test;
    is $h.focused.label, 'elsewhere',
        'the per-frame invariant re-homes focus out of the disabled pane';
};

subtest "the per-frame invariant re-homes on an ancestor disable" => {
    plan 6;

    my $root = TestContainer.new;
    my $a    = Selkie::Widget::Button.new(label => 'A');
    my $pane = TestContainer.new;
    my $b    = Selkie::Widget::Button.new(label => 'B');
    $root.add($a);
    $root.add($pane);
    $pane.add($b);

    my $h = FocusHarness.new(store => mock-store());
    $h.screens.root = $root;
    $h.focus($b);
    is $h.focused.label, 'B', 'preconditions: focus is on B, inside the pane';

    $h.check-invariant-for-test;
    is $h.focused.label, 'B', 'a healthy focus is left exactly where it is';

    # Only the PANE is disabled; B's own flag stays False, which is
    # what the pre-fix check looked at.
    $pane.set-disabled(True);
    nok $b.disabled, 'preconditions: the focused widget itself is not disabled';

    $h.check-invariant-for-test;
    is $h.focused.label, 'A', 'focus is re-homed to the first eligible widget';

    # And the original reason the guard exists still works. Note that
    # `remove` destroys the child without clearing its `parent`, so the
    # removed subtree still walks up to the live root — it is the
    # is-destroyed half of the rule that catches this, not attachment.
    $pane.set-disabled(False);
    $h.focus($b);
    $root.remove($pane);
    ok $b.is-destroyed, 'preconditions: removal destroyed the focused widget';
    $h.check-invariant-for-test;
    is $h.focused.label, 'A', 'a removed focus is still re-homed';
};

subtest "the invariant leaves focus alone when nothing is eligible" => {
    plan 2;

    my $root = TestContainer.new;
    my $only = Selkie::Widget::Button.new(label => 'only');
    $root.add($only);

    my $h = FocusHarness.new(store => mock-store());
    $h.screens.root = $root;
    $h.focus($only);
    is $h.focused.label, 'only', 'preconditions: focus is on the only control';

    $root.set-disabled(True);
    $h.check-invariant-for-test;
    nok $h.focused.defined,
        'with no eligible target anywhere, focus clears rather than staying stale';
};

subtest "per-screen focus memory re-validates before restoring" => {
    plan 3;

    my $root = TestContainer.new;
    my $a    = Selkie::Widget::Button.new(label => 'A');
    my $pane = TestContainer.new;
    my $b    = Selkie::Widget::Button.new(label => 'B');
    $root.add($a);
    $root.add($pane);
    $pane.add($b);

    my $h = FocusHarness.new(store => mock-store());
    $h.screens.root = $root;

    $h.focus($b);
    $h.remember-for-test('main', $root);
    $h.focus($a);
    $h.restore-for-test('main', $root);
    is $h.focused.label, 'B', 'a still-eligible memory is restored';

    $h.remember-for-test('main', $root);
    $pane.set-disabled(True);
    $h.focus($a);
    $h.restore-for-test('main', $root);
    is $h.focused.label, 'A',
        'a memory that became ineligible falls back to first-focusable';

    $pane.set-disabled(False);
    $h.restore-for-test('main', $root);
    is $h.focused.label, 'A',
        'and the stale memory was dropped rather than resurrected later';
};

subtest "closing a modal restores focus only to an eligible target" => {
    plan 3;

    my $root = TestContainer.new;
    my $a    = Selkie::Widget::Button.new(label => 'A');
    my $pane = TestContainer.new;
    my $b    = Selkie::Widget::Button.new(label => 'B');
    $root.add($a);
    $root.add($pane);
    $pane.add($b);

    my $h = ModalHarness.new(store => mock-store());
    $h.screen-manager.add-screen('main', $root);
    $h.screen-manager.switch-to('main');

    # Modal opens over a focused B, then closes with nothing changed.
    $h.focus($b);
    $h.push-modal-for-test(
        Selkie::Widget::Modal.new(
            content => Selkie::Widget::Button.new(label => 'ok')),
        $b);
    $h.close-modal-for-test;
    is $h.focused.label, 'B', 'an untouched pre-modal focus is restored';

    # Same again, but the pane behind the modal is switched off while
    # the modal is up — the restore target is no longer reachable.
    $h.focus($b);
    $h.push-modal-for-test(
        Selkie::Widget::Modal.new(
            content => Selkie::Widget::Button.new(label => 'ok')),
        $b);
    $pane.set-disabled(True);
    $h.close-modal-for-test;
    is $h.focused.label, 'A',
        'a pre-modal focus that became ineligible falls back to first-focusable';

    # A detached restore target keeps behaving as it always did.
    $pane.set-disabled(False);
    $h.focus($a);
    my $stray = Selkie::Widget::Button.new(label => 'stray');
    $h.push-modal-for-test(
        Selkie::Widget::Modal.new(
            content => Selkie::Widget::Button.new(label => 'ok')),
        $stray);
    $h.close-modal-for-test;
    is $h.focused.label, 'A',
        'a restore target outside the live tree falls back too';
};