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