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