Selkie.git | lib/Selkie/ | Gradient.rakumod


=begin pod

=head1 NAME

Selkie::Gradient - Four-corner colour ramps painted across a region of a plane

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Gradient;

# A left-to-right ramp, described once and reused at any size.
my $banner = Gradient.horizontal(0x1A1A2E, 0x4A2E6E);

# Paint it as the background of the top three rows of a plane. This
# writes a space into every cell it touches, so do it BEFORE any text.
gradient-fill($plane, $banner, rows => 3);
ncplane_putstr_yx($plane, 1, 2, 'Cantina');

# Recolour text that is already on the plane, leaving the glyphs alone.
gradient-stain($plane, $banner, fg => Gradient.uniform(0xFFFFFF), rows => 1);

# Two-dimensional: a different colour in each corner.
my $sunset = Gradient.corners(
    top-left     => 0x2E1A4A,  top-right    => 0xC04A2E,
    bottom-left  => 0x1A1A2E,  bottom-right => 0x6E2E4A,
);

# Degenerate regions need the corners collapsed first — see below.
gradient-fill($plane, $sunset, y => 9, rows => 1);   # done for you

=end code

=head1 DESCRIPTION

A C<Gradient> is four RGB colours, one per corner of a rectangle.
Notcurses interpolates between them per cell, per colour component, and
writes the result into the cells' channels. It is a B<region operation>,
not a style: it does not compose with L<Selkie::Style>, C<apply-style>,
or the widget theme, and there is no C<gradient> slot on a style. You
call it on a plane, over a rectangle, at a point in your C<render> where
you know what is already there.

Three subs do the painting, and they differ in what they do to the
glyphs already in the region:

=item B<C<gradient-fill>> — B<destructive>. Writes C<:egc> (a space by default) into every cell of the region and gives it the interpolated colours. Anything that was there is gone. Call it first, then draw on top.
=item B<C<gradient-stain>> — B<non-destructive>. Leaves every glyph exactly where it is and only rewrites the colours. Call it last, after the text is down.
=item B<C<gradient-fill-hires>> — like C<gradient-fill>, but paints C<▀> half-blocks so the ramp gets twice the vertical resolution. Requires a UTF-8 locale.

All three take the region as C<:y>/C<:x> (top-left, defaulting to the
plane's origin) plus C<:rows>/C<:cols>. Following notcurses's own
convention, B<a zero extent means "everything remaining">: C<:rows(0)>
runs to the bottom of the plane, C<:cols(0)> to its right edge, and
leaving both alone covers the whole plane. All three return the number
of cells painted, or C<-1> if notcurses refused the call.

One deliberate divergence from the raw bindings: an extent that runs off
the plane is B<clamped> to what is actually there. Notcurses rejects
such a call outright and paints nothing, which in a TUI where a pane can
resize between layout and render is a much worse failure mode than a
ramp that comes up a column short. An I<origin> outside the plane is
still an error and still returns C<-1>.

=head2 The four corners, and what actually gets interpolated

Notcurses interpolates each of R, G and B independently, using integer
arithmetic, over the region's own extent. The important part is which
corners contribute:

=item In a region with both dimensions greater than 1, all four corners contribute — the value at a cell is the bilinear blend of the four.
=item In a B<single-row> region only C<top-left> and C<top-right> are read; the bottom pair is ignored entirely.
=item In a B<single-column> region only C<top-left> and C<bottom-left> are read; the right pair is ignored.
=item In a B<1×1> region only C<top-left> is read.

That is worth knowing because notcurses will not simply I<ignore> the
corners it does not use — it B<refuses the whole call> if they disagree
with the ones it does. See below.

=head2 Degenerate geometry, and why C<for-region> exists

C<ncplane_gradient> validates its corners against the region's shape
before it paints anything, and a failure is silent: it returns C<-1> and
B<not one cell is touched>. The rules are exactly these:

=item C<rows == 1> and C<cols == 1>: all four corners must be identical.
=item C<rows == 1> (any width): C<top-left> must equal C<bottom-left>, and C<top-right> must equal C<bottom-right>.
=item C<cols == 1> (any height): C<top-left> must equal C<top-right>, and C<bottom-left> must equal C<bottom-right>.

So the natural thing — describing a gradient once as a value and reusing
it at whatever size the layout hands you — blows up the moment the
layout hands you a one-row selection bar. C<Gradient.for-region> is the
fix:

=begin code :lang<raku>

my $g = Gradient.vertical(0x203040, 0x405060);

$g.for-region(1, 40);   # collapsed: bottom pair replaced by the top pair
$g.for-region(8, 1);    # collapsed: right pair replaced by the left pair
$g.for-region(1, 1);    # collapsed: all four become top-left
$g.for-region(8, 40);   # unchanged — returns the invocant itself

=end code

B<The collapse picks the top row and the left column>, never an average,
and that choice is not arbitrary: those are precisely the corners
notcurses would have read had it agreed to paint. Replacing the unread
corners with the read ones therefore produces B<exactly the colours
notcurses's own interpolation yields> — C<for-region> can turn a refusal
into a painted ramp, but it can never change a ramp that was already
legal. Averaging the corners instead would have invented a colour that
appears nowhere in the gradient.

You rarely have to call it: C<gradient-fill>, C<gradient-stain> and
C<gradient-fill-hires> resolve the region's real extent from the plane
and apply C<for-region> themselves. It is exported because it is also
the right tool when you are building channel words by hand, and because
it makes the rule testable without a terminal.

The two one-dimensional factories are pre-collapsed for their own
degenerate axis — C<Gradient.horizontal> already has C<top-left ==
bottom-left>, so it is legal in a one-row region as constructed, and
C<Gradient.vertical> is legal in a one-column region. It is
C<Gradient.corners> — and reusing a C<vertical> horizontally, or a
C<horizontal> vertically — that needs the collapse.

=head2 Foreground gradients, and what happens when you omit one

A cell has two channels. The positional argument to all three subs is
the B<background> ramp — the one you almost always want, because a
gradient's job is usually to sit behind something. The optional
C<:fg> argument ramps the foreground, which is the colour the cell's
glyph is drawn in.

If you omit C<:fg>, all four foreground channels are left at the
terminal's default colour. That is deliberate and it is legal —
notcurses rejects a I<mixture> of default and explicit channels across
the four corners, but all-four-default is fine — and for
C<gradient-fill> with the default blank C<:egc> it is invisible, since a
space has no foreground to show.

B<For C<gradient-stain> it is very much visible.> Staining recolours
real glyphs, and an omitted C<:fg> resets them to the terminal default
rather than leaving them as they were; notcurses has no "keep the
existing foreground" mode. Pass C<:fg> explicitly whenever you stain
text you care about:

=begin code :lang<raku>

gradient-stain($plane, $bg-ramp, fg => Gradient.uniform($theme.selection.fg), rows => 1);

=end code

Alpha is not exposed here. Notcurses additionally requires that all four
corners carry the I<same> alpha, and the channel words this module
builds are uniformly opaque, which satisfies that by construction. If
you need a translucent gradient, build the words yourself with
C<gradient-channels> as a starting point and add the alpha bits to all
four.

=head2 Stain skips cells that have no glyph

C<ncplane_stain> visits every cell in the region but only recolours the
ones whose gcluster is non-zero. A cell you never wrote to — including
every cell after an C<ncplane_erase>, which zeroes the whole framebuffer
so the plane's base cell shows through — has gcluster C<0> and is
skipped.

That is the single most common surprise with staining: you erase a row,
write C<'Inbox'> into it, stain the whole row width, and get a five-cell
highlight instead of a full-width bar. The fix is to give the row
something to stain — pad it with spaces out to the full width before you
stain it:

=begin code :lang<raku>

my $label = 'Inbox';
my $padded = $label ~ ' ' x (self.cols - $label.chars);
ncplane_putstr_yx(self.plane, $row, 0, $padded);
gradient-stain(self.plane, $ramp, fg => Gradient.uniform(0xFFFFFF), y => $row, rows => 1);

=end code

This is the same reason C<Selkie::Widget::ListView> and
C<Selkie::Widget::Checkbox> pad their selected rows.

=head2 Gradients do not composite across planes

Every Selkie widget owns its own notcurses plane, and a gradient is
written into the cells of B<one> plane. So the obvious layout —
a C<GradientFill> as one child of a box and a C<Text> as another —
does not give you text on a gradient. It gives you two sibling planes,
and whichever is higher in the pile wins each cell outright: the Text's
own opaque base cell paints over the gradient in every cell it covers,
including the blank ones.

Two things do work:

=item B<Paint the gradient into the same plane as the text> — call C<gradient-fill> at the top of your own widget's C<render> and C<putstr> over it. This is the banner idiom below and it is what you want almost every time.
=item B<Make the upper widget see-through> — override C<base-egc> to return C<''> (gcluster 0, so the glyph search falls through) and give C<base-style> a transparent background. See L<Selkie::Alpha> and L<Selkie::Widget>'s C<base-style> / C<base-egc> hooks. This works, but it is a per-widget opt-in, not something you get for free.

L<Selkie::Widget::GradientFill> is therefore for B<decorative panes> —
a gradient with nothing on top of it — not as a backdrop for sibling
widgets.

=head2 High resolution

C<gradient-fill-hires> wraps C<ncplane_gradient2x1>. It writes C<▀>
(upper half block) into every cell and drives the foreground from the
ramp's value at the cell's top half and the background from its bottom
half, so a vertical ramp gets twice as many distinct steps in the same
number of rows. Its geometry rules are narrower than C<gradient-fill>'s:
because the ramp is computed over C<rows × 2>, a single row is never
degenerate, and only the single-column rule applies. C<for-region>
knows this — pass C<:hires> if you are collapsing by hand.

It requires a UTF-8 locale. Without one it B<returns C<-1> and paints
nothing> rather than throwing, so a caller who wants a fallback should
check the return value:

=begin code :lang<raku>

if gradient-fill-hires($plane, $ramp) < 0 {
    gradient-fill($plane, $ramp);       # blocky, but it always works
}

=end code

=head1 EXAMPLES

=head2 Example 1 — A banner background inside your own widget

The workhorse. Fill first (destructive), then write over the top. Both
land on the same plane, so the text keeps the gradient behind it.

=begin code :lang<raku>

use Notcurses::Native::Plane;
use Selkie::Gradient;
use Selkie::Widget;

unit class My::Banner does Selkie::Widget;

has Str      $.title    is required;
has Gradient $.gradient is required;

method render() {
    return without self.plane;

    # 1. The gradient, across the whole plane. Destructive: this is
    #    also our erase, so no ncplane_erase is needed.
    gradient-fill(self.plane, $!gradient);

    # 2. The text, on top, in the same plane's cells. apply-style sets
    #    the plane's channels, so putstr paints its own background —
    #    keep the label short, or stain instead of filling (Example 3).
    self.apply-style(self.theme.overlay-title);
    ncplane_putstr_yx(self.plane, 0, 2, $!title);

    self.clear-dirty;
}

=end code

=head2 Example 2 — A decorative pane in a layout

When nothing sits on top of the gradient, the ready-made widget is
enough. Note that this is a I<sibling>, not a backdrop.

=begin code :lang<raku>

use Selkie::Layout::HBox;
use Selkie::Widget::GradientFill;

my $row = Selkie::Layout::HBox.new(sizing => Sizing.flex);
$row.add: Selkie::Widget::GradientFill.new(
    gradient => Gradient.vertical(0x2E1A4A, 0x1A1A2E),
    sizing   => Sizing.fixed(2),
);
$row.add: $main-content;   # a sibling plane — not painted over

=end code

=head2 Example 3 — A stained selection bar

The one that needs all three rules at once: erase so the row is clean,
pad so there is a glyph in every cell for the stain to catch, and
collapse the corners so a one-row region is legal. The last of those is
automatic.

=begin code :lang<raku>

method !paint-row(UInt $row, Str $label, Bool $selected) {
    my $w = self.cols;
    return if $w == 0;

    self.apply-style($selected ?? self.theme.selection !! self.theme.base);
    my $padded = $label.chars > $w
        ?? $label.substr(0, $w)
        !! $label ~ ' ' x ($w - $label.chars);
    ncplane_putstr_yx(self.plane, $row, 0, $padded);

    if $selected {
        # AFTER the text: recolour what is there, do not overwrite it.
        gradient-stain(
            self.plane,
            Gradient.horizontal(0x4A2E6E, 0x2E1A4A),
            fg   => Gradient.uniform(0xF0F0F0),
            y    => $row,
            rows => 1,
        );
    }
}

=end code

=head2 Example 4 — Building the channel words yourself

C<gradient-channels> is the pure part, exported so you can assert on it
in a plane-free test or hand it to C<ncplane_gradient> directly when you
need an argument this module does not expose.

=begin code :lang<raku>

my ($ul, $ur, $ll, $lr) = gradient-channels(
    Gradient.horizontal(0x1A1A2E, 0x4A2E6E).for-region(1, 40),
    fg => Gradient.uniform(0xC0C0C0),
);

# ... add alpha bits to all four, then:
ncplane_gradient($plane, 0, 0, 1, 40, ' ', 0, $ul, $ur, $ll, $lr);

=end code

=head1 SEE ALSO

=item L<Selkie::Widget::GradientFill> — the decorative-pane widget
=item L<Selkie::Alpha> — alpha modes, and why a gradient cannot fade
=item L<Selkie::Style> — the per-cell styling gradients deliberately do not participate in
=item L<Selkie::Widget> — C<base-style> / C<base-egc>, the see-through hooks

=end pod

unit module Selkie::Gradient;

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

#|( Four RGB corner colours describing a rectangular colour ramp.

    Build one with C<Gradient.horizontal>, C<Gradient.vertical>,
    C<Gradient.corners> or C<Gradient.uniform> rather than C<.new> —
    the factories name the intent and pre-satisfy notcurses's
    degenerate-geometry rules for their own axis.

    Immutable. Reuse one value across renders and sizes; call
    C<for-region> (or just let C<gradient-fill> do it) to adapt it to
    a one-row or one-column region. )
class Gradient is export {
    #| Colour of the top-left corner, C<0xRRGGBB>.
    has UInt $.top-left     is required;

    #| Colour of the top-right corner, C<0xRRGGBB>.
    has UInt $.top-right    is required;

    #| Colour of the bottom-left corner, C<0xRRGGBB>.
    has UInt $.bottom-left  is required;

    #| Colour of the bottom-right corner, C<0xRRGGBB>.
    has UInt $.bottom-right is required;

    submethod TWEAK() {
        # notcurses masks the high bits away silently, which turns a
        # typo'd 0xRRGGBBAA into a plausible-looking wrong colour. Fail
        # at the construction site instead.
        for (top-left     => $!top-left,     top-right    => $!top-right,
             bottom-left  => $!bottom-left,  bottom-right => $!bottom-right) -> $corner {
            unless 0 <= $corner.value <= 0xFFFFFF {
                die "Selkie::Gradient: {$corner.key} is "
                    ~ sprintf('0x%X', $corner.value)
                    ~ ", outside the 0x000000..0xFFFFFF RGB range";
            }
        }
    }

    #|( A left-to-right ramp. Both rows carry the same pair, so this is
        already legal in a single-row region.

            Gradient.horizontal(0x000000, 0xFFFFFF);   # black to white )
    method horizontal(UInt $left, UInt $right --> Gradient) {
        Gradient.new(
            top-left    => $left,  top-right    => $right,
            bottom-left => $left,  bottom-right => $right,
        );
    }

    #|( A top-to-bottom ramp. Both columns carry the same pair, so this
        is already legal in a single-column region.

            Gradient.vertical(0x2E1A4A, 0x1A1A2E);     # a dusk fade )
    method vertical(UInt $top, UInt $bottom --> Gradient) {
        Gradient.new(
            top-left    => $top,     top-right    => $top,
            bottom-left => $bottom,  bottom-right => $bottom,
        );
    }

    #|( A full two-dimensional ramp with an independent colour in each
        corner. This is the shape that needs C<for-region> before it can
        be painted into a one-row or one-column area.

            Gradient.corners(
                top-left     => 0x2E1A4A, top-right    => 0xC04A2E,
                bottom-left  => 0x1A1A2E, bottom-right => 0x6E2E4A,
            ); )
    method corners(UInt :$top-left!, UInt :$top-right!,
                   UInt :$bottom-left!, UInt :$bottom-right! --> Gradient) {
        Gradient.new(:$top-left, :$top-right, :$bottom-left, :$bottom-right);
    }

    #|( A flat colour in all four corners. Legal at every size, and the
        usual way to say "hold this channel constant" — most often as
        the C<:fg> of a stain whose background ramps.

            gradient-stain($p, $ramp, fg => Gradient.uniform(0xFFFFFF)); )
    method uniform(UInt $rgb --> Gradient) {
        Gradient.new(
            top-left    => $rgb,  top-right    => $rgb,
            bottom-left => $rgb,  bottom-right => $rgb,
        );
    }

    #|( The same gradient, with any corners notcurses would refuse to
        read in a C<$rows> × C<$cols> region replaced by the ones it
        would read.

        A single-row region takes its colours from the top pair, so the
        bottom pair is replaced by the top; a single-column region takes
        its colours from the left pair, so the right pair is replaced by
        the left; a 1×1 region collapses to C<top-left>. Because those
        are exactly the corners notcurses interpolates from, the
        returned gradient paints exactly what the original would have
        painted had notcurses not rejected it outright.

        Returns the invocant unchanged when the region is big enough in
        both directions, or when the corners already agree.

        An extent of C<0> means "not known" — matching the
        C<:rows(0)> / C<:cols(0)> "everything remaining" convention of
        the painting subs — and constrains nothing on that axis.

        Pass C<:hires> when the target is C<gradient-fill-hires>: it
        interpolates over C<rows × 2>, so a single row is not degenerate
        there and collapsing it would throw away the ramp.

            Gradient.corners(...).for-region(1, 40);        # a selection bar
            Gradient.corners(...).for-region(1, 40, :hires); # rows kept )
    method for-region(UInt $rows, UInt $cols, Bool :$hires = False --> Gradient) {
        my ($tl, $tr, $bl, $br) =
            $!top-left, $!top-right, $!bottom-left, $!bottom-right;

        # Single row: notcurses reads only the top pair (and gradient2x1
        # reads both, over rows*2, so it is exempt).
        if $rows == 1 && !$hires {
            ($bl, $br) = $tl, $tr;
        }
        # Single column: notcurses reads only the left pair. Applied
        # second so a 1x1 region ends up flat at top-left.
        if $cols == 1 {
            ($tr, $br) = $tl, $bl;
        }

        return self if $tl == $!top-left    && $tr == $!top-right
                    && $bl == $!bottom-left && $br == $!bottom-right;

        Gradient.new(
            top-left    => $tl,  top-right    => $tr,
            bottom-left => $bl,  bottom-right => $br,
        );
    }
}

# One packed 64-bit notcurses channels word: explicit RGB background,
# and an explicit RGB foreground only when the caller supplied one.
# Leaving all four foregrounds default is legal; mixing default and
# explicit across the four corners is not, which is why this is driven
# by a single "was there an fg gradient" decision upstream.
sub corner-word(UInt:D $bg-rgb, $fg-rgb --> UInt) {
    my uint64 $channels = 0;
    ncchannels_set_bg_rgb($channels, $bg-rgb);
    ncchannels_set_fg_rgb($channels, $fg-rgb) if $fg-rgb.defined;
    $channels;
}

# One 32-bit channel for the half-block path, which carries a single
# ramp rather than an fg/bg pair.
sub hires-word(UInt:D $rgb --> UInt) {
    my uint32 $channel = 0;
    ncchannel_set($channel, $rgb);
    $channel;
}

#|( The four packed C<uint64> channel words for a background gradient
    and an optional foreground gradient, in notcurses's own corner
    order: upper-left, upper-right, lower-left, lower-right.

    Pure — no plane, no painting — so this is what to assert against
    when you want to know exactly what will be handed to notcurses, and
    what to start from if you need to set channel bits (alpha, say)
    that the painting subs do not expose.

    Every word carries an explicit RGB background. The foreground is
    explicit in all four words when C<:fg> is given and left at the
    terminal default in all four when it is not — never a mixture,
    which notcurses rejects outright.

        gradient-channels(Gradient.uniform(0x1A1A2E), fg => Gradient.uniform(0xC0C0C0));
        # (0x40C0C0C0401A1A2E xx 4)

        gradient-channels(Gradient.uniform(0x1A1A2E));
        # (0x401A1A2E xx 4) — foreground half is all zero, i.e. default )
sub gradient-channels(Gradient:D $bg, Gradient :$fg --> List) is export {
    my $has-fg = $fg.defined;
    (
        corner-word($bg.top-left,     $has-fg ?? $fg.top-left     !! Nil),
        corner-word($bg.top-right,    $has-fg ?? $fg.top-right    !! Nil),
        corner-word($bg.bottom-left,  $has-fg ?? $fg.bottom-left  !! Nil),
        corner-word($bg.bottom-right, $has-fg ?? $fg.bottom-right !! Nil),
    );
}

#|( The four packed C<uint32> channels for the half-block path, in the
    same corner order. C<ncplane_gradient2x1> takes a single ramp rather
    than an fg/bg pair — each cell's foreground is the ramp's value at
    its top half and its background the value at its bottom half.

        gradient-channels-hires(Gradient.uniform(0x1A1A2E));  # (0x401A1A2E xx 4) )
sub gradient-channels-hires(Gradient:D $g --> List) is export {
    (
        hires-word($g.top-left),     hires-word($g.top-right),
        hires-word($g.bottom-left),  hires-word($g.bottom-right),
    );
}

# notcurses reads -1 as "start at the cursor" and rejects every other
# negative. Selkie takes explicit coordinates only, so a negative here
# is a caller bug and says so rather than silently painting from
# wherever the cursor happens to be.
sub check-origin(Int $y, Int $x --> Nil) {
    if $y < 0 || $x < 0 {
        die "Selkie::Gradient: region origin must be non-negative, got"
            ~ " y => $y, x => $x (notcurses's -1 'use the cursor'"
            ~ " convention is not exposed — pass the coordinates you mean)";
    }
    Nil
}

# Resolve the "0 means everything remaining" extents against the
# plane's real dimensions, so the corner collapse is computed from the
# same numbers notcurses will paint over.
#
# Oversized extents are clamped rather than passed through: notcurses
# refuses a region that runs off the plane and paints nothing at all,
# which in a TUI whose panes resize underneath the render is a worse
# failure mode than a ramp one column short. A start past the edge
# yields 0, which notcurses still rejects on its own.
sub resolve-extent(NcplaneHandle $plane, Int $y, Int $x,
                   UInt $rows, UInt $cols --> List) {
    my uint32 $dim-y = 0;
    my uint32 $dim-x = 0;
    ncplane_dim_yx($plane, $dim-y, $dim-x);
    my $avail-rows = $dim-y > $y ?? $dim-y - $y !! 0;
    my $avail-cols = $dim-x > $x ?? $dim-x - $x !! 0;
    ( ($rows || $avail-rows) min $avail-rows,
      ($cols || $avail-cols) min $avail-cols );
}

#|( Paint C<$bg> (and optionally C<:fg>) across a rectangle, B<writing
    C<:egc> into every cell it covers>. Destructive: whatever was in the
    region is replaced. Call it before you draw anything you want to
    keep — it doubles as an erase.

    The region starts at C<:y>/C<:x> (default the plane's origin) and
    runs C<:rows> × C<:cols>; a zero extent means "everything remaining"
    on that axis, so the defaults cover the whole plane. The corners are
    collapsed for the region's real shape automatically, so a one-row
    bar works with any gradient (see C<Gradient.for-region>).

    C<:egc> must be a single-column grapheme — it is stamped into every
    cell, so a double-width one misaligns the whole region. C<:styles>
    is a raw notcurses C<NCSTYLE_*> mask applied to those cells.

    Returns the number of cells painted, or C<-1> if notcurses refused
    (an origin outside the plane, or a plane that is not there).

        gradient-fill($plane, Gradient.vertical(0x2E1A4A, 0x1A1A2E));
        gradient-fill($plane, $ramp, rows => 3, egc => '·');
        gradient-fill($plane, $ramp, fg => Gradient.uniform(0xFFFFFF), egc => '█'); )
sub gradient-fill(NcplaneHandle $plane, Gradient:D $bg,
                  Gradient :$fg,
                  Int :$y = 0, Int :$x = 0,
                  UInt :$rows = 0, UInt :$cols = 0,
                  Str:D :$egc = ' ', UInt :$styles = 0 --> Int) is export {
    return -1 without $plane;
    check-origin($y, $x);

    my ($r, $c) = resolve-extent($plane, $y, $x, $rows, $cols);
    my ($ul, $ur, $ll, $lr) = gradient-channels(
        $bg.for-region($r, $c),
        fg => $fg.defined ?? $fg.for-region($r, $c) !! Gradient,
    );

    ncplane_gradient($plane, $y, $x, $r, $c, $egc, $styles, $ul, $ur, $ll, $lr);
}

#|( Recolour a rectangle without touching its glyphs. Non-destructive:
    call it B<after> the text is on the plane.

    Same region arguments as C<gradient-fill>, minus C<:egc> — the
    glyphs already there are the point.

    B<Cells with no glyph are skipped.> A cell you never wrote to has
    gcluster 0, which is the state C<ncplane_erase> leaves the whole
    plane in, and it keeps its old colours. Pad rows with spaces out to
    the full width before staining or you will get a highlight the
    length of the label instead of the width of the row.

    An omitted C<:fg> resets the glyphs' foreground to the terminal
    default; notcurses cannot leave it as it was. Pass C<:fg> whenever
    the text needs a colour.

    Returns the number of cells visited — note that this counts the
    skipped ones too — or C<-1> if notcurses refused.

        gradient-stain($plane, $ramp, fg => Gradient.uniform(0xF0F0F0),
                       y => $row, rows => 1); )
sub gradient-stain(NcplaneHandle $plane, Gradient:D $bg,
                   Gradient :$fg,
                   Int :$y = 0, Int :$x = 0,
                   UInt :$rows = 0, UInt :$cols = 0 --> Int) is export {
    return -1 without $plane;
    check-origin($y, $x);

    my ($r, $c) = resolve-extent($plane, $y, $x, $rows, $cols);
    # ncplane_stain has no degenerate-geometry rules of its own, but
    # collapsing is a no-op on the resulting colours (the unread corners
    # are unread either way), so it costs nothing and keeps every entry
    # point in this module behaving identically.
    my ($ul, $ur, $ll, $lr) = gradient-channels(
        $bg.for-region($r, $c),
        fg => $fg.defined ?? $fg.for-region($r, $c) !! Gradient,
    );

    ncplane_stain($plane, $y, $x, $r, $c, $ul, $ur, $ll, $lr);
}

#|( Paint C<$gradient> as C<▀> half-blocks, doubling the ramp's vertical
    resolution: each cell's foreground is the ramp's value at its upper
    half and its background the value at its lower half, so C<$rows>
    rows carry C<$rows × 2> distinct steps.

    Destructive, like C<gradient-fill>, and it takes a single gradient
    rather than a background/foreground pair — the two channels are the
    two halves of one ramp, not two independent ones.

    Because the ramp spans C<rows × 2>, a single row is not a degenerate
    region here; only the single-column rule applies, and the collapse
    is done for you with C<:hires>.

    B<Requires a UTF-8 locale.> Without one this returns C<-1> and
    paints nothing — it does not throw — so check the result if you want
    a fallback:

        gradient-fill-hires($plane, $ramp) >= 0
            or gradient-fill($plane, $ramp);

    Returns the number of cells painted, or C<-1>. )
sub gradient-fill-hires(NcplaneHandle $plane, Gradient:D $gradient,
                        Int :$y = 0, Int :$x = 0,
                        UInt :$rows = 0, UInt :$cols = 0 --> Int) is export {
    return -1 without $plane;
    check-origin($y, $x);

    my ($r, $c) = resolve-extent($plane, $y, $x, $rows, $cols);
    my ($ul, $ur, $ll, $lr) =
        gradient-channels-hires($gradient.for-region($r, $c, :hires));

    ncplane_gradient2x1($plane, $y, $x, $r, $c, $ul, $ur, $ll, $lr);
}