Selkie.git | t/ | 33-tab-bar.rakutest


use Test;
use lib 'lib';

use Selkie::Widget::TabBar;
use Selkie::Theme;
use Selkie::Event;
use Notcurses::Native::Types;

use Selkie::Test::Keys;

plan 21;

subtest "construction defaults" => {
    plan 3;
    my $t = Selkie::Widget::TabBar.new;
    ok $t.focusable, "focusable by default";
    is $t.active-index, 0, "active-index starts at 0";
    nok $t.active-name.defined, "active-name undefined when empty";
};

subtest "add-tab adds tabs in order" => {
    plan 3;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'Alpha');
    $t.add-tab(name => 'b', label => 'Beta');
    is $t.tab-names, ('a', 'b'), "names in order";
    is $t.active-name, 'a', "first added is active";
    ok $t.is-dirty, "dirty after add";
};

subtest "remove-tab drops and keeps active valid" => {
    plan 2;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'Alpha');
    $t.add-tab(name => 'b', label => 'Beta');
    $t.add-tab(name => 'c', label => 'Gamma');
    $t.select-by-name('c');
    $t.remove-tab('c');
    is $t.tab-names, ('a', 'b'), "tab removed";
    is $t.active-name, 'b', "active falls back to valid index";
};

subtest "clear-tabs empties and resets" => {
    plan 2;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    $t.clear-tabs;
    is $t.tab-names.elems, 0, "no tabs";
    nok $t.active-name.defined, "no active name";
};

subtest "select-by-name activates" => {
    plan 2;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    $t.add-tab(name => 'b', label => 'B');
    $t.select-by-name('b');
    is $t.active-name, 'b', "b is active";
    is $t.active-index, 1, "index 1";
};

subtest "select-by-name with unknown is no-op" => {
    plan 1;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    $t.select-by-name('nope');
    is $t.active-name, 'a', "unchanged";
};

subtest "on-tab-selected emits on user change" => {
    plan 2;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    $t.add-tab(name => 'b', label => 'B');
    my @emitted;
    $t.on-tab-selected.tap: -> $n { @emitted.push($n) };
    $t.select-by-name('b');
    $t.select-by-name('a');
    is @emitted[0], 'b', "first emit";
    is @emitted[1], 'a', "second emit";
};

subtest "select-by-name to same does not emit" => {
    plan 1;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    my $count = 0;
    $t.on-tab-selected.tap: -> $ { $count++ };
    $t.select-by-name('a');
    is $count, 0, "no emit when already active";
};

subtest "set-active-name-silent does not emit" => {
    plan 2;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    $t.add-tab(name => 'b', label => 'B');
    my $count = 0;
    $t.on-tab-selected.tap: -> $ { $count++ };
    $t.set-active-name-silent('b');
    is $t.active-name, 'b', "active changed";
    is $count, 0, "no emit";
};

subtest "right arrow moves active when focused" => {
    plan 1;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    $t.add-tab(name => 'b', label => 'B');
    $t.set-focused(True);
    $t.handle-event(key-event(id => NCKEY_RIGHT));
    is $t.active-name, 'b', "moved right";
};

subtest "left arrow clamps at 0" => {
    plan 1;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    $t.add-tab(name => 'b', label => 'B');
    $t.set-focused(True);
    $t.handle-event(key-event(id => NCKEY_LEFT));
    is $t.active-name, 'a', "stays on a";
};

subtest "home/end jump to ends" => {
    plan 2;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    $t.add-tab(name => 'b', label => 'B');
    $t.add-tab(name => 'c', label => 'C');
    $t.set-focused(True);
    $t.handle-event(key-event(id => NCKEY_END));
    is $t.active-name, 'c', "end → last";
    $t.handle-event(key-event(id => NCKEY_HOME));
    is $t.active-name, 'a', "home → first";
};

subtest "enter emits on active tab" => {
    plan 1;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    $t.set-focused(True);
    my $got;
    $t.on-tab-selected.tap: -> $n { $got = $n };
    $t.handle-event(key-event(id => NCKEY_ENTER));
    is $got, 'a', "Enter re-emits active";
};

# --- Back-compat -----------------------------------------------------
#
# The defaults are load-bearing: every existing consumer (App::Cantina's
# four editor screens, App::Mindmoor's PerspectiveTabs) gets exactly the
# strip it got before badges and variants existed. The golden snapshot
# xt/snapshots/golden/11-tabbar-active-inactive.snap pins the rendered
# version of this; here we pin the strings it's built from.
subtest "defaults are byte-identical to the pre-badge layout" => {
    plan 7;
    my $t = Selkie::Widget::TabBar.new;
    is $t.active-style, TabBrackets, "brackets by default";
    is $t.focus-indicator, FocusPrefix, "prefix focus indicator by default";
    is $t.badge-prefix, ' ', "single-space badge separator by default";

    $t.add-tab(name => 'a', label => 'Alpha');
    $t.add-tab(name => 'b', label => 'Beta');
    is $t.tab-display(0), '[ Alpha ]', "active tab keeps its brackets";
    is $t.tab-display(1), '  Beta  ',  "inactive tab keeps its padding";
    is $t.focus-prefix, '  ', "unfocused prefix is two blanks";
    $t.set-focused(True);
    is $t.focus-prefix, '▶ ', "focused prefix is the chevron";
};

subtest "badges: set, read, clear" => {
    plan 9;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'Alpha', badge => 4);
    $t.add-tab(name => 'b', label => 'Beta');

    is $t.badge('a'), 4, "badge from add-tab is readable raw";
    nok $t.badge('b').defined, "tab without a badge reads Nil";
    nok $t.badge('nope').defined, "unknown tab reads Nil";
    is $t.tab-display(0), '[ Alpha 4 ]', "badge renders inside the active cell";

    $t.set-badge('b', 12);
    is $t.tab-display(1), '  Beta 12  ', "badge renders inside an inactive cell";

    $t.set-badge('a', Nil);
    is $t.tab-display(0), '[ Alpha ]', "Nil clears the badge";

    $t.set-badge('b', 12);
    $t.clear-badge('b');
    nok $t.badge('b').defined, "clear-badge clears";

    $t.set-badge('nope', 9);
    is $t.tab-names, ('a', 'b'), "set-badge on an unknown name is a no-op";

    $t.set-badge-prefix(' · ');
    $t.set-badge('a', 7);
    is $t.tab-display(0), '[ Alpha · 7 ]', "badge-prefix is the separator";
};

subtest "badge formatter" => {
    plan 8;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'A');
    $t.select-index(0);

    $t.set-badge('a', 0);
    is $t.tab-display(0), '[ A 0 ]', "zero is drawn by the stock formatter";
    $t.set-badge('a', 99);
    is $t.tab-display(0), '[ A 99 ]', "99 is not clamped";
    $t.set-badge('a', 100);
    is $t.tab-display(0), '[ A 99+ ]', "100 clamps to 99+";
    $t.set-badge('a', 100_000);
    is $t.tab-display(0), '[ A 99+ ]', "so does anything larger";
    $t.set-badge('a', -3);
    is $t.tab-display(0), '[ A -3 ]', "negatives pass through";
    $t.set-badge('a', 'new');
    is $t.tab-display(0), '[ A new ]', "non-Int values stringify";

    $t.set-badge-formatter(-> $v { $v ~~ Int && $v == 0 ?? '' !! "($v)" });
    $t.set-badge('a', 5);
    is $t.tab-display(0), '[ A (5) ]', "custom formatter wins";
    $t.set-badge('a', 0);
    is $t.tab-display(0), '[ A ]',
        "empty formatter output suppresses the badge AND its separator";
};

subtest "active-style and focus-indicator" => {
    plan 8;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'Alpha');
    $t.add-tab(name => 'b', label => 'Beta');

    $t.set-active-style(TabUnderline);
    is $t.active-style, TabUnderline, "underline set";
    is $t.tab-display(0), '  Alpha  ', "underline drops the brackets";
    is $t.tab-display(1), '  Beta  ',  "inactive cells are unaffected";

    $t.set-active-style(TabPill);
    is $t.tab-display(0), '  Alpha  ', "pill drops the brackets too";

    is $t.focus-prefix, '  ', "FocusPrefix still reserves two columns";
    $t.set-focus-indicator(FocusColor);
    is $t.focus-prefix, '', "FocusColor reserves none";
    $t.set-focused(True);
    is $t.focus-prefix, '', "…focused or not";

    # The whole point of padding the non-bracket styles: the strip is
    # the same width whichever decoration is in play.
    my @widths = (TabBrackets, TabUnderline, TabPill).map: -> $style {
        $t.set-active-style($style);
        (^2).map({ $t.tab-display($_).chars }).sum;
    };
    is @widths.unique.elems, 1, "all three styles occupy identical columns";
};

subtest "style/badge setters mark dirty" => {
    plan 6;
    my $t = Selkie::Widget::TabBar.new;
    $t.add-tab(name => 'a', label => 'Alpha');

    my sub dirtied(&change --> Bool) {
        $t.clear-dirty;
        change();
        $t.is-dirty;
    }

    ok dirtied({ $t.set-active-style(TabPill) }),         "set-active-style";
    ok dirtied({ $t.set-focus-indicator(FocusColor) }),   "set-focus-indicator";
    ok dirtied({ $t.set-badge-prefix(' · ') }),           "set-badge-prefix";
    ok dirtied({ $t.set-badge-formatter(-> $v { "!$v" }) }), "set-badge-formatter";
    ok dirtied({ $t.set-badge('a', 3) }),                 "set-badge";
    nok dirtied({ $t.set-badge('a', 3) }),
        "…but re-setting the same badge doesn't repaint";
};

# --- The regression that matters -------------------------------------
#
# render() and !tab-index-at-col() must agree on every single column, or
# a click lands on the wrong tab. They agree because both go through
# !focus-prefix and !tab-display — this walks the whole product of
# (active-style × focus-indicator × badges × focus state) and asserts
# that the columns hit-testing resolves are exactly the columns the
# display strings occupy. If anyone ever reintroduces a second width
# formula, this fails.
subtest "hit-testing tracks rendered widths in every combination" => {
    my @styles     = TabBrackets, TabUnderline, TabPill;
    my @indicators = FocusPrefix, FocusColor;
    my @badge-sets = (
        'no badges' => (Nil,  Nil, Nil),
        'badged'    => (3,    Nil, 4321),   # short, absent, clamped
    );
    plan @styles.elems * @indicators.elems * @badge-sets.elems * 2 * 3;

    for @styles -> $style {
        for @indicators -> $indicator {
            for @badge-sets -> $badge-set {
                for (False, True) -> $focused {
                    my $t = Selkie::Widget::TabBar.new(
                        active-style    => $style,
                        focus-indicator => $indicator,
                    );
                    my @labels = <Inbox Today Someday>;
                    for @labels.kv -> $i, $label {
                        $t.add-tab(
                            name  => "t$i",
                            label => $label,
                            badge => $badge-set.value[$i],
                        );
                    }
                    $t.select-index(1);
                    $t.set-focused($focused);

                    my $what = "$style / $indicator / {$badge-set.key}"
                             ~ " / {$focused ?? 'focused' !! 'unfocused'}";

                    # Column → tab index, derived purely from the strings
                    # the widget says it draws.
                    my @expect = |(-1 xx $t.focus-prefix.chars);
                    @expect.append: |($_ xx $t.tab-display($_).chars)
                        for ^@labels.elems;
                    my $strip = @expect.elems;
                    @expect.append(-1, -1);     # two columns past the end

                    my @got = (^@expect.elems).map({ $t.tab-index-at-col($_) });
                    is-deeply @got.Array, @expect.Array,
                        "columns resolve to the drawn tabs — $what";
                    is $t.tab-index-at-col(-1), -1,
                        "negative column resolves to nothing — $what";
                    is $t.tab-index-at-col($strip + 100), -1,
                        "far past the strip resolves to nothing — $what";
                }
            }
        }
    }
};

subtest "hit-testing degenerates safely" => {
    plan 3;
    my $t = Selkie::Widget::TabBar.new;
    is $t.tab-index-at-col(0), -1, "empty bar resolves nothing";
    is $t.tab-display(0), '', "out-of-range tab-display is empty";
    $t.add-tab(name => 'a', label => 'A');
    is $t.tab-display(9), '', "…still empty past the last tab";
};

subtest "theme provides a tab-focus-accent slot" => {
    plan 3;
    my $theme = Selkie::Theme.default;
    ok $theme.tab-focus-accent.defined, "slot exists";
    ok $theme.slot('tab-focus-accent') === $theme.tab-focus-accent,
        "slot() resolves it";
    is-deeply ($theme.tab-focus-accent.fg, $theme.tab-focus-accent.bg,
               $theme.tab-focus-accent.bold),
              ($theme.border-focused.fg, $theme.border-focused.bg,
               $theme.border-focused.bold),
        "defaults to border-focused, so FocusColor needs no theme work";
};