Selkie--Gradient
NAME
Selkie::Gradient - Four-corner colour ramps painted across a region of a plane
SYNOPSIS
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 youDESCRIPTION
A 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 region operation, not a style: it does not compose with Selkie::Style, apply-style, or the widget theme, and there is no gradient slot on a style. You call it on a plane, over a rectangle, at a point in your 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:
gradient-fillā destructive. Writes: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.gradient-stainā non-destructive. Leaves every glyph exactly where it is and only rewrites the colours. Call it last, after the text is down.gradient-fill-hiresā likegradient-fill, but paintsāhalf-blocks so the ramp gets twice the vertical resolution. Requires a UTF-8 locale.
All three take the region as :y/:x (top-left, defaulting to the plane's origin) plus :rows/:cols. Following notcurses's own convention, a zero extent means "everything remaining": :rows(0) runs to the bottom of the plane, :cols(0) to its right edge, and leaving both alone covers the whole plane. All three return the number of cells painted, or -1 if notcurses refused the call.
One deliberate divergence from the raw bindings: an extent that runs off the plane is 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 origin outside the plane is still an error and still returns -1.
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:
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.
In a single-row region only
top-leftandtop-rightare read; the bottom pair is ignored entirely.In a single-column region only
top-leftandbottom-leftare read; the right pair is ignored.In a 1Ć1 region only
top-leftis read.
That is worth knowing because notcurses will not simply ignore the corners it does not use ā it refuses the whole call if they disagree with the ones it does. See below.
Degenerate geometry, and why for-region exists
ncplane_gradient validates its corners against the region's shape before it paints anything, and a failure is silent: it returns -1 and not one cell is touched. The rules are exactly these:
rows == 1andcols == 1: all four corners must be identical.rows == 1(any width):top-leftmust equalbottom-left, andtop-rightmust equalbottom-right.cols == 1(any height):top-leftmust equaltop-right, andbottom-leftmust equalbottom-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. Gradient.for-region is the fix:
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 itselfThe 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 exactly the colours notcurses's own interpolation yields ā 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: gradient-fill, gradient-stain and gradient-fill-hires resolve the region's real extent from the plane and apply 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 ā Gradient.horizontal already has top-left == bottom-left, so it is legal in a one-row region as constructed, and Gradient.vertical is legal in a one-column region. It is Gradient.corners ā and reusing a vertical horizontally, or a horizontal vertically ā that needs the collapse.
Foreground gradients, and what happens when you omit one
A cell has two channels. The positional argument to all three subs is the background ramp ā the one you almost always want, because a gradient's job is usually to sit behind something. The optional :fg argument ramps the foreground, which is the colour the cell's glyph is drawn in.
If you omit :fg, all four foreground channels are left at the terminal's default colour. That is deliberate and it is legal ā notcurses rejects a mixture of default and explicit channels across the four corners, but all-four-default is fine ā and for gradient-fill with the default blank :egc it is invisible, since a space has no foreground to show.
For gradient-stain it is very much visible. Staining recolours real glyphs, and an omitted :fg resets them to the terminal default rather than leaving them as they were; notcurses has no "keep the existing foreground" mode. Pass :fg explicitly whenever you stain text you care about:
gradient-stain($plane, $bg-ramp, fg => Gradient.uniform($theme.selection.fg), rows => 1);Alpha is not exposed here. Notcurses additionally requires that all four corners carry the 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 gradient-channels as a starting point and add the alpha bits to all four.
Stain skips cells that have no glyph
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 ncplane_erase, which zeroes the whole framebuffer so the plane's base cell shows through ā has gcluster 0 and is skipped.
That is the single most common surprise with staining: you erase a row, write '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:
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);This is the same reason Selkie::Widget::ListView and Selkie::Widget::Checkbox pad their selected rows.
Gradients do not composite across planes
Every Selkie widget owns its own notcurses plane, and a gradient is written into the cells of one plane. So the obvious layout ā a GradientFill as one child of a box and a 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:
Paint the gradient into the same plane as the text ā call
gradient-fillat the top of your own widget'srenderandputstrover it. This is the banner idiom below and it is what you want almost every time.Make the upper widget see-through ā override
base-egcto return''(gcluster 0, so the glyph search falls through) and givebase-stylea transparent background. See Selkie::Alpha and Selkie::Widget'sbase-style/base-egchooks. This works, but it is a per-widget opt-in, not something you get for free.
Selkie::Widget::GradientFill is therefore for decorative panes ā a gradient with nothing on top of it ā not as a backdrop for sibling widgets.
High resolution
gradient-fill-hires wraps ncplane_gradient2x1. It writes ā (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 gradient-fill's: because the ramp is computed over rows Ć 2, a single row is never degenerate, and only the single-column rule applies. for-region knows this ā pass :hires if you are collapsing by hand.
It requires a UTF-8 locale. Without one it returns -1 and paints nothing rather than throwing, so a caller who wants a fallback should check the return value:
if gradient-fill-hires($plane, $ramp) < 0 {
gradient-fill($plane, $ramp); # blocky, but it always works
}EXAMPLES
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.
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;
}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 sibling, not a backdrop.
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 overExample 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.
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,
);
}
}Example 4 ā Building the channel words yourself
gradient-channels is the pure part, exported so you can assert on it in a plane-free test or hand it to ncplane_gradient directly when you need an argument this module does not expose.
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);SEE ALSO
Selkie::Widget::GradientFill ā the decorative-pane widget
Selkie::Alpha ā alpha modes, and why a gradient cannot fade
Selkie::Style ā the per-cell styling gradients deliberately do not participate in
Selkie::Widget ā
base-style/base-egc, the see-through hooks
class Selkie::Gradient::Gradient
Four RGB corner colours describing a rectangular colour ramp. Build one with Gradient.horizontal, Gradient.vertical, Gradient.corners or Gradient.uniform rather than .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 for-region (or just let gradient-fill do it) to adapt it to a one-row or one-column region.
has UInt $.top-left
Colour of the top-left corner, 0xRRGGBB.
has UInt $.top-right
Colour of the top-right corner, 0xRRGGBB.
has UInt $.bottom-left
Colour of the bottom-left corner, 0xRRGGBB.
has UInt $.bottom-right
Colour of the bottom-right corner, 0xRRGGBB.
method horizontal
method horizontal(
Int $left where { ... },
Int $right where { ... }
) returns Selkie::Gradient::GradientA 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 vertical
method vertical(
Int $top where { ... },
Int $bottom where { ... }
) returns Selkie::Gradient::GradientA 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 corners
method corners(
Int :$top-left! where { ... },
Int :$top-right! where { ... },
Int :$bottom-left! where { ... },
Int :$bottom-right! where { ... }
) returns Selkie::Gradient::GradientA full two-dimensional ramp with an independent colour in each corner. This is the shape that needs 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 uniform
method uniform(
Int $rgb where { ... }
) returns Selkie::Gradient::GradientA flat colour in all four corners. Legal at every size, and the usual way to say "hold this channel constant" ā most often as the :fg of a stain whose background ramps. gradient-stain(ramp, fg => Gradient.uniform(0xFFFFFF));
method for-region
method for-region(
Int $rows where { ... },
Int $cols where { ... },
Bool :$hires = Bool::False
) returns Selkie::Gradient::GradientThe same gradient, with any corners notcurses would refuse to read in a $rows Ć $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 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 0 means "not known" ā matching the :rows(0) / :cols(0) "everything remaining" convention of the painting subs ā and constrains nothing on that axis. Pass :hires when the target is gradient-fill-hires: it interpolates over 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
sub gradient-channels
sub gradient-channels(
Selkie::Gradient::Gradient:D $bg,
Selkie::Gradient::Gradient :$fg
) returns ListThe four packed 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 :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-hires
sub gradient-channels-hires(
Selkie::Gradient::Gradient:D $g
) returns ListThe four packed uint32 channels for the half-block path, in the same corner order. 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-fill
sub gradient-fill(
Notcurses::Native::Types::NcplaneHandle $plane,
Selkie::Gradient::Gradient:D $bg,
Selkie::Gradient::Gradient :$fg,
Int :$y = 0,
Int :$x = 0,
Int :$rows where { ... } = 0,
Int :$cols where { ... } = 0,
Str:D :$egc = " ",
Int :$styles where { ... } = 0
) returns IntPaint $bg (and optionally :fg) across a rectangle, writing :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 :y/:x (default the plane's origin) and runs :rows Ć :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 Gradient.for-region). :egc must be a single-column grapheme ā it is stamped into every cell, so a double-width one misaligns the whole region. :styles is a raw notcurses NCSTYLE_* mask applied to those cells. Returns the number of cells painted, or -1 if notcurses refused (an origin outside the plane, or a plane that is not there). gradient-fill(plane, plane, $ramp, fg => Gradient.uniform(0xFFFFFF), egc => 'ā');
sub gradient-stain
sub gradient-stain(
Notcurses::Native::Types::NcplaneHandle $plane,
Selkie::Gradient::Gradient:D $bg,
Selkie::Gradient::Gradient :$fg,
Int :$y = 0,
Int :$x = 0,
Int :$rows where { ... } = 0,
Int :$cols where { ... } = 0
) returns IntRecolour a rectangle without touching its glyphs. Non-destructive: call it after the text is on the plane. Same region arguments as gradient-fill, minus :egc ā the glyphs already there are the point. Cells with no glyph are skipped. A cell you never wrote to has gcluster 0, which is the state 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 :fg resets the glyphs' foreground to the terminal default; notcurses cannot leave it as it was. Pass :fg whenever the text needs a colour. Returns the number of cells visited ā note that this counts the skipped ones too ā or -1 if notcurses refused. gradient-stain(ramp, fg => Gradient.uniform(0xF0F0F0), y => $row, rows => 1);
sub gradient-fill-hires
sub gradient-fill-hires(
Notcurses::Native::Types::NcplaneHandle $plane,
Selkie::Gradient::Gradient:D $gradient,
Int :$y = 0,
Int :$x = 0,
Int :$rows where { ... } = 0,
Int :$cols where { ... } = 0
) returns IntPaint $gradient as ā 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 $rows rows carry $rows Ć 2 distinct steps. Destructive, like 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 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 :hires. Requires a UTF-8 locale. Without one this returns -1 and paints nothing ā it does not throw ā so check the result if you want a fallback: gradient-fill-hires(ramp) >= 0 or gradient-fill(ramp); Returns the number of cells painted, or -1.