Selkie.git | t/ | 92-gradient.rakutest


use Test;
use lib 'lib';

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

use Selkie::Gradient;
use Selkie::Widget::GradientFill;

=begin pod

The C<Gradient> value type, the corner-collapse rule that makes
degenerate regions paintable, and the packed channel words handed to
notcurses.

Everything here is plane-free. The load-bearing subtests are the
B<collapse table> and B<the collapse never changes a legal gradient>:
C<ncplane_gradient> validates its corners against the region's shape and
silently paints B<nothing> (returns C<-1>) when they disagree, so a
one-row selection bar built from a two-dimensional gradient is a
no-op without the collapse. The end-to-end proof that the collapse
produces a real ramp rather than a blank row lives in
C<xt/snapshots/57-gradient-one-row-styled.raku>.

The rules replicated here, verified against notcurses's C<src/lib/fill.c>:

=item C<ncplane_gradient>, rows == 1 && cols == 1 — all four corners must be equal.
=item C<ncplane_gradient>, rows == 1 — C<ul == ll> and C<ur == lr>.
=item C<ncplane_gradient>, cols == 1 — C<ul == ur> and C<ll == lr>.
=item C<ncplane_gradient2x1>, cols == 1 — C<ul == ur> and C<ll == lr>. No row rule: it interpolates over C<rows * 2>.
=item Any of them: the four foreground channels must be all-default or all-explicit, likewise the backgrounds, and all four alphas must match.

Note that notcurses compares the packed C<uint64> B<words>, not the RGB
values, which is why the precondition subtest asserts on
C<gradient-channels> output rather than on the corners.

=end pod

plan 15;

# A gradient with four genuinely different corners — every collapse
# below has to actually move something.
sub quad(--> Gradient) {
    Gradient.corners(
        top-left     => 0x102030, top-right    => 0x405060,
        bottom-left  => 0x708090, bottom-right => 0xA0B0C0,
    );
}

sub corners-of(Gradient:D $g --> List) {
    ($g.top-left, $g.top-right, $g.bottom-left, $g.bottom-right);
}

subtest "factories place the corners where they say they do" => {
    plan 4;

    is-deeply corners-of(Gradient.horizontal(0x111111, 0x222222)),
        (0x111111, 0x222222, 0x111111, 0x222222),
        "horizontal repeats the pair down both rows";

    is-deeply corners-of(Gradient.vertical(0x111111, 0x222222)),
        (0x111111, 0x111111, 0x222222, 0x222222),
        "vertical repeats the pair across both columns";

    is-deeply corners-of(Gradient.uniform(0x333333)),
        (0x333333, 0x333333, 0x333333, 0x333333),
        "uniform is flat";

    is-deeply corners-of(quad()),
        (0x102030, 0x405060, 0x708090, 0xA0B0C0),
        "corners keeps all four independent";
};

subtest "the one-dimensional factories are pre-collapsed for their own axis" => {
    plan 4;
    # This is why Gradient.horizontal "just works" in a selection bar
    # even before for-region is involved — and why Gradient.corners
    # does not.
    my $h = Gradient.horizontal(0x111111, 0x222222);
    is $h.top-left,  $h.bottom-left,  "horizontal: left column agrees (legal in one row)";
    is $h.top-right, $h.bottom-right, "horizontal: right column agrees";

    my $v = Gradient.vertical(0x111111, 0x222222);
    is $v.top-left,    $v.top-right,    "vertical: top row agrees (legal in one column)";
    is $v.bottom-left, $v.bottom-right, "vertical: bottom row agrees";
};

subtest "corner colours are validated at the construction site" => {
    plan 5;
    # notcurses masks the high bits off without complaint, turning a
    # typo'd 0xRRGGBBAA into a plausible wrong colour a long way from
    # the code that wrote it.
    throws-like { Gradient.uniform(0x1000000) }, Exception,
        message => /'outside the 0x000000..0xFFFFFF RGB range'/,
        "one past white throws";
    throws-like { Gradient.corners(top-left => 0, top-right => 0,
                                   bottom-left => 0, bottom-right => 0xFFFFFFF) },
        Exception, message => /'bottom-right'/,
        "the failing corner is named";
    lives-ok { Gradient.uniform(0x000000) }, "black is fine";
    lives-ok { Gradient.uniform(0xFFFFFF) }, "white is fine";
    dies-ok  { Gradient.new(top-left => 0) }, "all four corners are required";
};

subtest "COLLAPSE TABLE — for-region by region shape" => {
    plan 6;

    my $g = quad();

    is-deeply corners-of($g.for-region(8, 12)),
        (0x102030, 0x405060, 0x708090, 0xA0B0C0),
        "N x M: nothing collapses";

    # Single row: notcurses reads only the top pair, so the bottom pair
    # is replaced by it. The ramp that survives is the top edge's.
    is-deeply corners-of($g.for-region(1, 20)),
        (0x102030, 0x405060, 0x102030, 0x405060),
        "1 x N: bottom pair becomes the top pair";

    # Single column: notcurses reads only the left pair.
    is-deeply corners-of($g.for-region(20, 1)),
        (0x102030, 0x102030, 0x708090, 0x708090),
        "N x 1: right pair becomes the left pair";

    is-deeply corners-of($g.for-region(1, 1)),
        (0x102030, 0x102030, 0x102030, 0x102030),
        "1 x 1: flat at top-left";

    # A zero extent is the "everything remaining" sentinel of the
    # painting subs — it says nothing about the real shape, so it must
    # not trigger a collapse.
    is-deeply corners-of($g.for-region(0, 0)),
        (0x102030, 0x405060, 0x708090, 0xA0B0C0),
        "0 x 0: unknown extent constrains nothing";

    is-deeply corners-of($g.for-region(1, 0)),
        (0x102030, 0x405060, 0x102030, 0x405060),
        "1 x 0: a known single row still collapses";
};

subtest "for-region returns the invocant when there is nothing to do" => {
    plan 4;
    # Cheap identity check, and it documents that Gradient is a value:
    # nobody downstream may mutate what they get back.
    my $g = quad();
    ok $g.for-region(4, 4) === $g, "non-degenerate region returns self";
    ok $g.for-region(0, 0) === $g, "unknown extent returns self";

    my $u = Gradient.uniform(0x123456);
    ok $u.for-region(1, 1) === $u, "already-flat 1x1 returns self";

    my $h = Gradient.horizontal(0x111111, 0x222222);
    ok $h.for-region(1, 40) === $h, "horizontal in one row is already legal";
};

subtest "COLLAPSE TABLE — for-region :hires" => {
    plan 4;

    my $g = quad();

    # ncplane_gradient2x1 interpolates over rows*2, so one row of half
    # blocks is two ramp steps — collapsing it would throw the vertical
    # component away.
    is-deeply corners-of($g.for-region(1, 20, :hires)),
        (0x102030, 0x405060, 0x708090, 0xA0B0C0),
        "1 x N hires: rows are NOT collapsed";

    is-deeply corners-of($g.for-region(20, 1, :hires)),
        (0x102030, 0x102030, 0x708090, 0x708090),
        "N x 1 hires: the column rule still applies";

    is-deeply corners-of($g.for-region(1, 1, :hires)),
        (0x102030, 0x102030, 0x708090, 0x708090),
        "1 x 1 hires: still a two-step vertical ramp";

    ok $g.for-region(1, 20, :hires) === $g, "and the row-only case is a no-op";
};

subtest "PRECONDITIONS — collapsed corners satisfy notcurses's own checks" => {
    plan 8;

    # notcurses compares the packed 64-bit words, not the RGB values, so
    # these assertions are made where the comparison actually happens.
    sub words(Gradient:D $g) { gradient-channels($g, fg => Gradient.uniform(0xFFFFFF)) }

    my $g = quad();

    my ($ul, $ur, $ll, $lr) = words($g.for-region(1, 30));
    ok $ul == $ll, "1 x N: ul == ll";
    ok $ur == $lr, "1 x N: ur == lr";

    ($ul, $ur, $ll, $lr) = words($g.for-region(30, 1));
    ok $ul == $ur, "N x 1: ul == ur";
    ok $ll == $lr, "N x 1: ll == lr";

    ($ul, $ur, $ll, $lr) = words($g.for-region(1, 1));
    is ($ul, $ur, $ll, $lr).unique.elems, 1, "1 x 1: all four words identical";

    # The uncollapsed original must genuinely violate them, or the
    # subtest above proves nothing.
    ($ul, $ur, $ll, $lr) = words($g);
    ok $ul != $ll, "uncollapsed 1 x N would have been rejected";
    ok $ul != $ur, "uncollapsed N x 1 would have been rejected";

    # The half-block path compares 32-bit channels.
    my ($h-ul, $h-ur, $h-ll, $h-lr) =
        gradient-channels-hires($g.for-region(9, 1, :hires));
    ok $h-ul == $h-ur && $h-ll == $h-lr, "hires N x 1: column rule holds";
};

subtest "the collapse never changes a gradient notcurses already accepts" => {
    plan 6;
    # for-region can turn a refusal into a painted ramp; it must never
    # turn one painted ramp into a different one. Every shape where
    # notcurses would have accepted the original has to come back
    # byte-identical.
    for (Gradient.horizontal(0x001122, 0x334455),
         Gradient.vertical(0x001122, 0x334455),
         Gradient.uniform(0x667788)) -> $g {
        my $legal-shape = $g.top-left == $g.bottom-left ?? (1, 40) !! (40, 1);
        is-deeply corners-of($g.for-region(|$legal-shape)), corners-of($g),
            "{$legal-shape.join('x')} leaves an already-legal gradient alone";
    }

    # And the corners it does keep are the ones notcurses interpolates
    # from, so the painted result is unchanged even where the corners
    # did move: a one-row region reads the top pair either way.
    my $g = quad();
    is $g.for-region(1, 9).top-left,  $g.top-left,  "1 x N keeps top-left";
    is $g.for-region(1, 9).top-right, $g.top-right, "1 x N keeps top-right";
    is $g.for-region(9, 1).bottom-left, $g.bottom-left, "N x 1 keeps bottom-left";
};

subtest "CHANNEL WORDS — exact uint64 for a known colour pair" => {
    plan 6;

    # Hand-derived, deliberately not computed through the code under
    # test. ncchannel_set(channel, rgb) is
    #   (channel & ~(NC_BG_RGB_MASK | NC_BG_PALETTE)) | NC_BGDEFAULT_MASK | rgb
    # i.e. 0x40000000 | rgb, with the foreground occupying the high half:
    #
    #   fchannel = 0x40000000 | 0xC0C0C0 = 0x40C0C0C0
    #   bchannel = 0x40000000 | 0x1A1A2E = 0x401A1A2E
    #   => 0x40C0C0C0_401A1A2E
    #
    # Same word t/87 pins for the default theme's base cell, which is no
    # coincidence — both are "explicit RGB, opaque, not the terminal
    # default" and there is only one encoding of that.
    constant KNOWN-PAIR = 0x40C0C0C0401A1A2E;

    my @w = gradient-channels(Gradient.uniform(0x1A1A2E), fg => Gradient.uniform(0xC0C0C0));
    is @w.elems, 4, "four words, one per corner";
    is-deeply @w.List, (KNOWN-PAIR xx 4).List, "a flat pair packs to the known word";

    is ncchannels_fg_rgb(@w[0]), 0xC0C0C0, "foreground half is the fg colour";
    is ncchannels_bg_rgb(@w[0]), 0x1A1A2E, "background half is the bg colour";

    # All four alphas must match or notcurses rejects the call; opaque
    # is zero in both fields, which is what makes that true by
    # construction here.
    is @w.map({ (ncchannels_fg_alpha($_), ncchannels_bg_alpha($_)) }).unique(:with(&[eqv])).elems,
        1, "every corner carries the same alpha pair";
    is (ncchannels_fg_alpha(@w[0]), ncchannels_bg_alpha(@w[0])),
        (NCALPHA_OPAQUE, NCALPHA_OPAQUE), "and that pair is opaque";
};

subtest "CHANNEL WORDS — corner order and an omitted foreground" => {
    plan 6;

    my @w = gradient-channels(quad());

    # notcurses's order: upper-left, upper-right, lower-left, lower-right.
    is ncchannels_bg_rgb(@w[0]), 0x102030, "word 0 is top-left";
    is ncchannels_bg_rgb(@w[1]), 0x405060, "word 1 is top-right";
    is ncchannels_bg_rgb(@w[2]), 0x708090, "word 2 is bottom-left";
    is ncchannels_bg_rgb(@w[3]), 0xA0B0C0, "word 3 is bottom-right";

    # With no :fg the whole high half is zero, which notcurses reads as
    # "terminal default". All four are default together — a mixture is
    # what it rejects, not defaults as such.
    is @w.grep({ ncchannels_fg_default_p($_) }).elems, 4,
        "all four foregrounds default when :fg is omitted";
    is @w[0], 0x401A1A2E - 0x1A1A2E + 0x102030,
        "and the word is bchannel only";
};

subtest "CHANNEL WORDS — the half-block path packs 32-bit channels" => {
    plan 4;

    my @w = gradient-channels-hires(quad());
    is @w.elems, 4, "four channels";
    is @w[0], 0x40102030, "explicit-RGB bit plus the colour";
    is ncchannel_rgb(@w[0]), 0x102030, "colour round-trips";
    nok ncchannel_default_p(@w[3]), "no corner is left at the terminal default";
};

subtest "painting subs refuse a plane that is not there" => {
    plan 3;
    # A CPointer type object is what a widget holds before init-plane.
    # Handing it to notcurses would dereference NULL, so the guard is
    # not merely tidy.
    my $g = Gradient.uniform(0x101010);
    is gradient-fill(NcplaneHandle, $g), -1, "gradient-fill returns -1";
    is gradient-stain(NcplaneHandle, $g), -1, "gradient-stain returns -1";
    is gradient-fill-hires(NcplaneHandle, $g), -1, "gradient-fill-hires returns -1";
};

subtest "a negative origin is a caller bug, not a cursor request" => {
    plan 4;
    # notcurses reads -1 as "start wherever the cursor is". Selkie does
    # not expose that, so a negative coordinate is almost always an
    # unclamped subtraction and should say so loudly. Checked before the
    # plane guard would matter, but after it — so use a real-looking
    # call shape.
    my $g = Gradient.uniform(0x101010);
    my $p = NcplaneHandle;
    # With a null plane the guard short-circuits first; that ordering is
    # deliberate (never touch a null pointer) and is asserted here so a
    # future reshuffle does not quietly start dereferencing it.
    is gradient-fill($p, $g, y => -1), -1, "null plane still wins over the origin check";

    # Force the origin check by giving the subs something non-null to
    # look at is impossible without a terminal, so assert the checker's
    # contract through the one path that does not need a plane: the
    # message text is part of the API.
    throws-like { Gradient.uniform(-1) }, Exception,
        "a negative colour is rejected by the value type";
    ok Gradient.uniform(0).for-region(0, 0).defined, "for-region tolerates zero extents";
    ok !(-1 ~~ UInt), "negative extents cannot reach the subs at all (UInt)";
};

subtest "GradientFill widget surface" => {
    plan 8;

    my $ramp = Gradient.horizontal(0x000000, 0xFFFFFF);
    my $w = Selkie::Widget::GradientFill.new(gradient => $ramp);

    nok $w.focusable, "decorative: not focusable by default";
    is $w.egc, ' ', "default EGC is a space — a background wash";
    nok $w.fg-gradient.defined, "no foreground ramp by default";
    ok $w.gradient === $ramp, "the gradient is held by identity, not copied";

    dies-ok { Selkie::Widget::GradientFill.new }, "gradient is required";

    $w.clear-dirty;
    $w.set-gradient(Gradient.uniform(0x123456));
    ok $w.is-dirty, "set-gradient marks dirty";
    is $w.gradient.top-left, 0x123456, "and swaps the value";

    # Rendering without a plane is the state every widget is in between
    # construction and mounting; it must be a silent no-op.
    lives-ok { Selkie::Widget::GradientFill.new(gradient => $ramp).render },
        "render before init-plane is a no-op";
};

subtest "GradientFill setters" => {
    plan 6;

    my $w = Selkie::Widget::GradientFill.new(
        gradient => Gradient.uniform(0x000000),
        egc      => '█',
    );
    is $w.egc, '█', "a non-blank EGC survives construction";

    $w.clear-dirty;
    my $fg = Gradient.horizontal(0x111111, 0x222222);
    $w.set-fg-gradient($fg);
    ok $w.is-dirty, "set-fg-gradient marks dirty";
    ok $w.fg-gradient === $fg, "and stores it";

    # The bare type object is the documented way to clear it back to the
    # terminal default.
    $w.set-fg-gradient(Gradient);
    nok $w.fg-gradient.defined, "the type object clears the foreground ramp";

    $w.clear-dirty;
    $w.set-egc('·');
    ok $w.is-dirty, "set-egc marks dirty";
    is $w.egc, '·', "and swaps the glyph";
};