Selkie.git | t/ | 16-border.rakutest
use Test;
use lib 'lib';
use Selkie::Widget;
use Selkie::Container;
use Selkie::Widget::Border;
use Selkie::BorderStyle;
use Selkie::Style;
use Selkie::Theme;
class TestWidget does Selkie::Widget {
method render() { self.clear-dirty }
}
class TestContainer does Selkie::Container {
method render() { self!render-children; self.clear-dirty }
}
plan 18;
subtest "construction defaults" => {
plan 3;
my $b = Selkie::Widget::Border.new;
nok $b.content.defined, "no content initially";
is $b.title, '', "empty title";
nok $b.has-focus, "no focus by default";
};
subtest "style/title defaults reproduce the pre-0.11 frame" => {
plan 8;
# Back-compat proof for the glyph-set / title-alignment work: a
# default-constructed Border must still describe exactly the frame
# it drew before those knobs existed — single-line box, left title
# at column 2, space affixes, no bottom title. Anything that moves
# here moves every golden in the distro and downstream.
my $b = Selkie::Widget::Border.new;
is $b.border-style, BorderSingle, "single-line glyph kind";
nok $b.border-glyphs.defined, "no glyph-table override";
is-deeply
($b.effective-glyphs.top-left, $b.effective-glyphs.top-right,
$b.effective-glyphs.bottom-left, $b.effective-glyphs.bottom-right,
$b.effective-glyphs.horizontal, $b.effective-glyphs.vertical),
('┌', '┐', '└', '┘', '─', '│'),
"the historical glyph table";
is $b.title-align, TitleLeft, "titles left-aligned";
is $b.bottom-title, '', "no bottom title";
is $b.bottom-title-align, TitleLeft, "bottom alignment defaults left";
is $b.title-prefix ~ '|' ~ $b.title-suffix, ' | ',
"one space either side of the title";
is Selkie::Widget::Border.title-column(
$b.title-align, 24, ' Settings '),
2, "left titles still start at column 2";
};
subtest "set-content" => {
plan 3;
my $b = Selkie::Widget::Border.new;
my $w = TestWidget.new;
$b.set-content($w);
ok $b.content === $w, "content set";
ok $w.parent === $b, "parent set";
ok $b.is-dirty, "dirty after set-content";
};
subtest "set-content replaces previous" => {
plan 1;
my $b = Selkie::Widget::Border.new;
$b.set-content(TestWidget.new);
my $w2 = TestWidget.new;
$b.set-content($w2);
ok $b.content === $w2, "content replaced";
};
subtest "set-title" => {
plan 2;
my $b = Selkie::Widget::Border.new;
$b.set-title("Test Panel");
is $b.title, "Test Panel", "title set";
ok $b.is-dirty, "dirty after set-title";
};
subtest "title via constructor" => {
plan 1;
my $b = Selkie::Widget::Border.new(title => "Panel");
is $b.title, "Panel", "title from constructor";
};
subtest "set-has-focus" => {
plan 3;
my $b = Selkie::Widget::Border.new;
$b.clear-dirty;
$b.set-has-focus(True);
ok $b.has-focus, "focus set";
ok $b.is-dirty, "dirty after focus change";
$b.clear-dirty;
$b.set-has-focus(True);
nok $b.is-dirty, "no-op when same value";
};
subtest "style override marks dirty and can be cleared" => {
plan 3;
my $b = Selkie::Widget::Border.new;
my $style = Selkie::Style.new(fg => 0xFF0000, bold => True);
$b.clear-dirty;
$b.set-style-override($style);
ok $b.is-dirty, "setting style override marks border dirty";
$b.clear-dirty;
$b.clear-style-override;
ok $b.is-dirty, "clearing style override marks border dirty";
ok $b.can('clear-style-override'),
"clear-style-override remains part of the public API";
};
subtest "focus-from-store defaults to True" => {
plan 1;
my $b = Selkie::Widget::Border.new;
ok $b.focus-from-store,
"store-driven focus is the default";
};
subtest "focus-from-store=False makes Border ignore store focus changes" => {
plan 2;
# The failure mode we're guarding against: a parent container with
# richer selection semantics (e.g. CardList) calls set-has-focus on
# a child Border, then a store focus change arrives and Border's
# subscription / render override wipes the explicit value.
#
# With focus-from-store=False, the Border must not react to store
# focus changes at all — set-has-focus is the only writer.
use Selkie::Store;
my $store = Selkie::Store.new;
my $b = Selkie::Widget::Border.new;
$b.focus-from-store = False;
$b.set-store($store);
$b.set-has-focus(True);
$b.clear-dirty;
# A focused-widget change in the store normally marks every Border
# in the tree dirty via its subscription. With the opt-out, $b
# should be unaffected.
my $other = TestWidget.new;
$store.assoc-in('ui', 'focused-widget', value => $other);
$store.tick;
nok $b.is-dirty,
"store focus change did not mark the opted-out Border dirty";
ok $b.has-focus,
"set-has-focus value survives the store change";
};
subtest "focusable-descendants - no content" => {
plan 1;
my $b = Selkie::Widget::Border.new;
is $b.focusable-descendants.List.elems, 0, "no descendants without content";
};
subtest "focusable-descendants - focusable content" => {
plan 1;
my $b = Selkie::Widget::Border.new;
$b.set-content(TestWidget.new(focusable => True));
is $b.focusable-descendants.List.elems, 1, "finds focusable content";
};
subtest "focusable-descendants - container content" => {
plan 1;
my $b = Selkie::Widget::Border.new;
my $c = TestContainer.new;
$c.add(TestWidget.new(focusable => True));
$c.add(TestWidget.new(focusable => True));
$b.set-content($c);
is $b.focusable-descendants.List.elems, 2, "finds descendants in container";
};
subtest "focusable-descendants - non-focusable content" => {
plan 1;
my $b = Selkie::Widget::Border.new;
$b.set-content(TestWidget.new);
is $b.focusable-descendants.List.elems, 0, "non-focusable not included";
};
subtest "dirty propagates through border" => {
plan 1;
my $b = Selkie::Widget::Border.new;
my $w = TestWidget.new;
$b.set-content($w);
$b.clear-dirty;
$w.clear-dirty;
$w.mark-dirty;
ok $b.is-dirty, "border dirty when content dirty";
};
subtest "destroy cleans up content" => {
plan 1;
my $b = Selkie::Widget::Border.new;
$b.set-content(TestWidget.new);
$b.destroy;
nok $b.content.defined, "content cleared after destroy";
};
subtest "theme-slot defaults are the back-compat proof" => {
plan 6;
# The frame is resolved by slot NAME now, so it follows a live theme
# swap. These four defaults are what keep that free: 'border' /
# 'border-focused' resolve to the very same Style objects the old
# direct accessors returned, and an undefined title slot skips the
# extra apply-style entirely, so a stock Border makes exactly the
# native calls it always did.
my $b = Selkie::Widget::Border.new;
is $b.style-slot, 'border', "unfocused frame paints from the border slot";
is $b.focused-style-slot, 'border-focused', "focused frame from border-focused";
nok $b.title-slot.defined, "top title has no slot of its own";
nok $b.bottom-title-slot.defined, "nor does the bottom title";
my $theme = Selkie::Theme.default;
ok $theme.slot('border') === $theme.border,
"slot('border') is the identical object the accessor returns";
ok $theme.slot('border-focused') === $theme.border-focused,
"…and so is slot('border-focused')";
};
subtest "theme slots are addressable, including custom ones" => {
plan 4;
my $accent = Selkie::Style.new(fg => 0xFF00FF, bold => True);
my $theme = Selkie::Theme.default.clone(custom => { 'panel' => $accent });
my $b = Selkie::Widget::Border.new(
style-slot => 'panel',
focused-style-slot => 'panel',
title-slot => 'modal-title',
bottom-title-slot => 'nonexistent-slot',
);
ok $theme.slot('panel') === $accent, "an app's custom slot resolves";
is $b.style-slot, 'panel', "constructor sets the unfocused slot";
is $b.title-slot, 'modal-title', "and the title slot";
ok $theme.slot('nonexistent-slot') === $theme.base,
"an unknown name falls back to base rather than throwing";
};