Selkie.git | t/ | 96-disabled.rakutest


use Test;
use lib 'lib';

use Selkie::Alpha;
use Selkie::App;
use Selkie::Container;
use Selkie::Layout::VBox;
use Selkie::Sizing;
use Selkie::Style;
use Selkie::Theme;
use Selkie::Widget;
use Selkie::Widget::Border;
use Selkie::Widget::Button;
use Selkie::Widget::Modal;
use Selkie::Widget::Text;

use Notcurses::Native::Channel;
use Notcurses::Native::Types;

=begin pod

The C<disabled> axis: greying, the focus filtering it drives, and the
C<Selkie::Style> arithmetic underneath it.

Everything here is plane-free. The plane half — that a greyed base cell
and a C<ncplane_greyscale>'d framebuffer actually agree on screen — is
pinned by the C<58-disabled-button-styled> snapshot in C<xt/>.

What is being guarded:

=item B<The Rec. 601 table, with truncation.> C<Style.greyscale-rgb> exists to reproduce C<ncplane_greyscale> bit-for-bit, because Selkie greys base cells in Raku and every other cell in C. Round instead of truncate and the two disagree by one on most colours — visible as a faint seam between a widget's written cells and its background. The reference values below were read back off a real greyed plane.
=item B<The achromatic identity.> C<grey(v, v, v) == v>. Floating-point weights sum to a hair under 1.0 and truncation then eats a unit, so a naive implementation greys C<0xFFFFFF> to C<0xFEFEFE> — the integer form here can't.
=item B<Disabled subtrees leave the Tab cycle whole.> Not just the disabled widget: everything under it. All three C<focusable-descendants> implementations, because they are three separate walks.
=item B<Re-enabling repaints.> Greying is destructive, so C<set-disabled(False)> has to put the base cells back and dirty the tree, or the widget stays grey forever.

=end pod

plan 14;

class TestContainer does Selkie::Container {
    method render() { self!render-children; self.clear-dirty }
}

# A leaf that records how often it was rendered and greyed.
class Leaf does Selkie::Widget {
    has Int $.renders is rw = 0;
    has Int $.greys   is rw = 0;
    method render() { $!renders++; self.clear-dirty }
    method apply-disabled-effect(--> Nil) { $!greys++ }
}

sub btn(Str $label --> Selkie::Widget::Button) {
    Selkie::Widget::Button.new(label => $label, sizing => Sizing.fixed(1));
}

# --- Style.greyscale-rgb ---------------------------------------------

subtest 'greyscale-rgb reproduces the Rec. 601 reference table' => {
    # (colour, expected grey component). Every expectation was read
    # back from a plane that notcurses had greyed for real, so this is
    # a table of observed behaviour, not of the formula's own opinion.
    my @table =
        # pure primaries — the weights themselves, x255
        (0xFF0000, 0x4C),   # 0.299 * 255 = 76.245  -> 76
        (0x00FF00, 0x95),   # 0.587 * 255 = 149.685 -> 149  (round = 150)
        (0x0000FF, 0x1D),   # 0.114 * 255 = 29.07   -> 29
        # achromatic identity across the range
        (0x000000, 0x00),
        (0x050505, 0x05),
        (0x0A0A0A, 0x0A),
        (0x333333, 0x33),
        (0x808080, 0x80),
        (0xC0C0C0, 0xC0),   # the default theme's base fg
        (0xFFFFFF, 0xFF),
        # mixed hues
        (0x1A1A2E, 0x1C),   # the default theme's base bg -> 28.28  -> 28
        (0x7AA2F7, 0x9F),   # 159.73 -> 159 (round = 160)
        (0x123456, 0x2D),   # 45.71  -> 45  (round = 46)
        (0xABCDEF, 0xC6),   # 198.71 -> 198 (round = 199)
        (0x5F00D7, 0x34),   # 52.915 -> 52  (round = 53)
        ;
    plan @table.elems;
    for @table -> ($rgb, $want) {
        is Selkie::Style.greyscale-rgb($rgb), $want * 0x010101,
            sprintf('%06X greys to %02X%02X%02X', $rgb, $want, $want, $want);
    }
};

subtest 'greyscale-rgb truncates where rounding would differ' => {
    plan 6;
    # 0x00FF00 is the cleanest boundary in the whole space: the exact
    # value is 149.685, so trunc and round land on different bytes and
    # notcurses picks the low one.
    is Selkie::Style.greyscale-rgb(0x00FF00), 0x959595,
        'pure green truncates to 149 (0x95)';
    isnt Selkie::Style.greyscale-rgb(0x00FF00), 0x969696,
        'and is not the rounded 150 (0x96)';

    # A second, independent boundary from a colour an app would
    # actually use.
    is Selkie::Style.greyscale-rgb(0x7AA2F7), 0x9F9F9F,
        'a themed blue truncates to 159 (0x9F)';
    isnt Selkie::Style.greyscale-rgb(0x7AA2F7), 0xA0A0A0,
        'and is not the rounded 160 (0xA0)';

    # White is the case a float implementation gets wrong in the other
    # direction: 0.299 + 0.587 + 0.114 is under 1.0 in binary floating
    # point, so 255 * that truncates to 254.
    is Selkie::Style.greyscale-rgb(0xFFFFFF), 0xFFFFFF,
        'white stays white — no floating-point shortfall';
    is Selkie::Style.greyscale-rgb(0x2E2E2E), 0x2E2E2E,
        'and every other grey is its own greyscale';
};

subtest 'greyscale-rgb ignores bits above the low 24' => {
    plan 2;
    is Selkie::Style.greyscale-rgb(0xFF000000 +| 0x00FF00), 0x959595,
        'high bits are masked off, as notcurses does';
    is Selkie::Style.greyscale-rgb(0), 0, 'zero is zero';
};

subtest 'Style.greyscale carries everything that is not a colour' => {
    plan 10;
    my $s = Selkie::Style.new(
        fg => 0x00FF00, bg => 0x1A1A2E,
        bold => True, italic => True,
        underline => True, strikethrough => True,
        fg-alpha => AlphaBlend, bg-alpha => AlphaTransparent,
    );
    my $g = $s.greyscale;

    isa-ok $g, Selkie::Style, 'greyscale returns a Style';
    is $g.fg, 0x959595, 'fg greyed';
    is $g.bg, 0x1C1C1C, 'bg greyed';
    ok $g.bold,          'bold preserved';
    ok $g.italic,        'italic preserved';
    ok $g.underline,     'underline preserved';
    ok $g.strikethrough, 'strikethrough preserved';
    is $g.fg-alpha, AlphaBlend,       'fg-alpha preserved';
    is $g.bg-alpha, AlphaTransparent, 'bg-alpha preserved';
    is $s.fg, 0x00FF00, 'the original is untouched';
};

subtest 'Style.greyscale leaves undefined colours undefined' => {
    plan 6;
    my $none = Selkie::Style.new.greyscale;
    nok $none.fg.defined, 'an unset fg stays unset, not black';
    nok $none.bg.defined, 'an unset bg stays unset, not black';

    my $fg-only = Selkie::Style.new(fg => 0xFF0000).greyscale;
    is  $fg-only.fg, 0x4C4C4C, 'the set channel greys';
    nok $fg-only.bg.defined,   'the unset one is left alone';

    # Black is a colour and must survive as one — the two states are
    # not interchangeable: undefined means "inherit".
    my $black = Selkie::Style.new(fg => 0x000000).greyscale;
    ok  $black.fg.defined, 'explicit black stays defined';
    is  $black.fg, 0x000000, 'and stays black';
};

# --- Style.channels, the consolidated base-cell packer ----------------

subtest 'Style.channels is the one packer behind every base cell' => {
    plan 6;
    # The same derivation t/87 pins for base-channels, arrived at from
    # the style side: ncchannel_set ORs in NC_BGDEFAULT_MASK (0x40000000)
    # with the rgb, fg in the high half and bg in the low half.
    constant DEFAULT-BASE-CHANNELS = 0x40C0C0C0401A1A2E;
    my $base = Selkie::Theme.default.base;
    is $base.channels, DEFAULT-BASE-CHANNELS,
        'the default theme base packs to the pre-alpha word';

    my $w = Leaf.new;
    is $w.base-channels, $base.channels,
        'Widget.base-channels routes through it';

    my $m = Selkie::Widget::Modal.new;
    is $m.scrim-channels, Selkie::Theme.default.modal-scrim.channels,
        'Modal.scrim-channels routes through it too';

    # Alpha rides in the same word.
    my $scrim = Selkie::Style.new(
        fg => 0x000000, bg => 0x000000,
        fg-alpha => AlphaBlend, bg-alpha => AlphaBlend,
    );
    is $scrim.channels, 0x5000000050000000,
        'blended black packs alpha into both halves';
    is Selkie::Style.new.channels, 0,
        'a style with nothing set packs to zero';
    is ncchannels_fg_rgb(Selkie::Style.new(fg => 0x123456).channels), 0x123456,
        'and the fg rgb reads back out';
};

subtest 'a greyed base style packs to a greyed channel word' => {
    plan 3;
    my $base = Selkie::Theme.default.base;
    my $grey = $base.greyscale.channels;
    is ncchannels_fg_rgb($grey), 0xC0C0C0,
        'the base fg is already achromatic, so it is unchanged';
    is ncchannels_bg_rgb($grey), 0x1C1C1C,
        'the base bg greys to 28';
    is ncchannels_bg_alpha($grey), NCALPHA_OPAQUE,
        'greying does not disturb the alpha bits';
};

subtest 'greyscale-channel leaves everything that is not a colour alone' => {
    plan 8;
    # 0x40000000 = "this channel carries a real colour". Clear means
    # "inherit", and an inherited channel must survive greying or the
    # cell stops falling through — which is the entire reason Selkie
    # does not call notcurses's own ncplane_greyscale.
    is Selkie::Style.greyscale-channel(0x4000FF00), 0x40959595,
        'an explicit colour greys, keeping its flag bits';
    is Selkie::Style.greyscale-channel(0x0000FF00), 0x0000FF00,
        'an inherited channel is returned byte-identical';
    is Selkie::Style.greyscale-channel(0), 0,
        'a wholly empty channel is left empty';
    is Selkie::Style.greyscale-channel(0x480000AB), 0x480000AB,
        'a palette-indexed channel is left alone — an index has no RGB';

    # Alpha lives in the flag bits and must ride through.
    is Selkie::Style.greyscale-channel(0x5000FF00), 0x50959595,
        'blend alpha preserved';
    is Selkie::Style.greyscale-channel(0x6000FF00), 0x60959595,
        'transparent alpha preserved';

    # And greying is idempotent, which is what lets the render post-pass
    # run on every frame a disabled widget re-renders.
    my $once = Selkie::Style.greyscale-channel(0x407AA2F7);
    is Selkie::Style.greyscale-channel($once), $once,
        'greying an already-grey channel is a no-op';
    is $once, 0x409F9F9F, 'and the first pass truncated to 159';
};

subtest 'greyscale-channels splits the 64-bit word correctly' => {
    plan 4;
    # fg in the high half, bg in the low half — the same layout
    # Style.channels produces.
    my $word = Selkie::Style.new(fg => 0x00FF00, bg => 0x1A1A2E).channels;
    my $grey = Selkie::Style.greyscale-channels($word);
    is $grey, Selkie::Style.new(fg => 0x959595, bg => 0x1C1C1C).channels,
        'both halves grey independently';
    isnt $grey, $word, 'and the word actually changed';

    # A word where only one half is set: the empty half must stay empty
    # rather than becoming an explicit black.
    my $fg-only = Selkie::Style.new(fg => 0xFF0000).channels;
    is Selkie::Style.greyscale-channels($fg-only),
        Selkie::Style.new(fg => 0x4C4C4C).channels,
        'an unset background half stays unset';
    is Selkie::Style.greyscale-channels(0), 0,
        'an empty word greys to an empty word — nothing to write back';
};

# --- The disabled flag ------------------------------------------------

subtest 'disabled defaults to False and set-disabled dirties both ways' => {
    plan 9;
    my $box = TestContainer.new;
    my $leaf = Leaf.new;
    $box.add($leaf);
    $box.clear-dirty;
    $leaf.clear-dirty;

    nok $box.disabled,  'a fresh widget is not disabled';
    nok $leaf.disabled, 'nor is a fresh child';

    $box.set-disabled(True);
    ok $box.disabled,      'set-disabled(True) takes';
    ok $box.is-dirty,      'disabling dirties the widget';
    ok $leaf.is-dirty,     'and the whole subtree, so the greying pass reaches it';
    nok $leaf.disabled,    'without disabling the children themselves';

    $box.clear-dirty;
    $leaf.clear-dirty;
    $box.set-disabled(True);
    nok $box.is-dirty, 'setting the state it already has is a no-op';

    $box.set-disabled(False);
    nok $box.disabled, 'set-disabled(False) takes';
    ok $box.is-dirty,
        're-enabling dirties too — greying is destructive, so it has to repaint';
};

subtest 'apply-disabled-effect is a safe no-op without a plane' => {
    plan 4;
    my $leaf = Leaf.new;
    lives-ok { Selkie::Widget::Text.new(text => 'hi').apply-disabled-effect },
        'a never-mounted widget greys without touching a null plane';
    lives-ok { Selkie::Widget::Border.new.apply-disabled-effect },
        'nor does an empty Border';

    # Plane-less does not mean childless: the recursion has to keep
    # going, because a grouping node with no plane of its own can still
    # own mounted children.
    my $box = TestContainer.new;
    $box.add($leaf);
    $box.apply-disabled-effect;
    is $leaf.greys, 1, 'the recursion reaches children of a plane-less parent';

    my $border = Selkie::Widget::Border.new;
    my $inner = Leaf.new;
    $border.set-content($inner);
    $border.apply-disabled-effect;
    is $inner.greys, 1, 'and content, not just children';
};

subtest 'the render post-pass greys only what rendered, and only if disabled' => {
    plan 5;
    my $box = TestContainer.new;
    my $on  = Leaf.new;
    my $off = Leaf.new;
    $box.add($on);
    $box.add($off);
    $off.set-disabled(True);

    $box.render;
    is $on.renders,  1, 'both children rendered';
    is $off.renders, 1, 'including the disabled one';
    is $on.greys,    0, 'the enabled child is not greyed';
    is $off.greys,   1, 'the disabled one is';

    # Nothing dirty: nothing renders, so nothing re-greys. The cells are
    # already grey from the pass above — re-converting them every frame
    # would be pure waste.
    $box.clear-dirty;
    $on.clear-dirty;
    $off.clear-dirty;
    $box.render;
    is $off.greys, 1, 'a clean disabled child is not re-greyed';
};

# --- Focus ------------------------------------------------------------

subtest 'disabled subtrees drop out of all three focus walks' => {
    plan 8;

    # 1. Container.
    my $box = TestContainer.new;
    my $a = btn('a');
    my $inner-box = TestContainer.new;
    my $b = btn('b');
    my $c = btn('c');
    $inner-box.add($b);
    $inner-box.add($c);
    $box.add($a);
    $box.add($inner-box);

    is $box.focusable-descendants.List.elems, 3, 'all three reachable to start';

    $a.set-disabled(True);
    is-deeply $box.focusable-descendants.List.map(*.label).List, ('b', 'c'),
        'a disabled leaf drops out';

    $a.set-disabled(False);
    $inner-box.set-disabled(True);
    is-deeply $box.focusable-descendants.List.map(*.label).List, ('a',),
        'disabling a container takes its whole subtree with it';
    $inner-box.set-disabled(False);

    # 2. Border.
    my $border = Selkie::Widget::Border.new;
    my $vbox = Selkie::Layout::VBox.new(sizing => Sizing.flex);
    my $d = btn('d');
    $vbox.add($d);
    $border.set-content($vbox);
    is $border.focusable-descendants.List.elems, 1, 'Border reaches into content';
    $vbox.set-disabled(True);
    is $border.focusable-descendants.List.elems, 0,
        'disabled Border content contributes nothing';
    $vbox.set-disabled(False);

    # 3. Modal.
    my $modal = Selkie::Widget::Modal.new;
    my $mvbox = Selkie::Layout::VBox.new(sizing => Sizing.flex);
    my $e = btn('e');
    $mvbox.add($e);
    $modal.set-content($mvbox);
    is $modal.focusable-descendants.List.elems, 1, 'Modal reaches into content';
    $mvbox.set-disabled(True);
    is $modal.focusable-descendants.List.elems, 0,
        'disabled Modal content contributes nothing';

    # And a disabled leaf inside a live modal, so the Modal walk's own
    # recursion is covered rather than just its early return.
    $mvbox.set-disabled(False);
    $e.set-disabled(True);
    is $modal.focusable-descendants.List.elems, 0,
        'a disabled leaf inside a live modal drops out';
};

subtest 'App refuses to focus a disabled widget' => {
    plan 5;
    # Selkie::App.new calls notcurses_init, so the rule is exercised
    # through the class method Selkie::App.focus consults — the same
    # arrangement t/focus-invariant uses for widget-attached.
    my $b = btn('ok');
    ok Selkie::App.focus-eligible($b), 'an ordinary widget is eligible';

    $b.set-disabled(True);
    nok Selkie::App.focus-eligible($b), 'a disabled one is not';

    $b.set-disabled(False);
    ok Selkie::App.focus-eligible($b), 'and is eligible again once re-enabled';

    nok Selkie::App.focus-eligible(Selkie::Widget),
        'an undefined widget is not a target';

    # focusable is a separate axis: focus() has always accepted an
    # explicitly-passed non-focusable widget, and disabled must not
    # quietly start gating that too.
    ok Selkie::App.focus-eligible(Selkie::Widget::Text.new(text => 'x')),
        'a non-focusable widget is still eligible for an explicit focus call';
};