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";
};