Selkie.git | docs/api/ | Selkie--Gradient.md
NAME
====
Selkie::Gradient - Four-corner colour ramps painted across a region of a plane
SYNOPSIS
========
```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
```
DESCRIPTION
===========
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](Selkie--Style.md), `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`** — like `gradient-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-left` and `top-right` are read; the bottom pair is ignored entirely.
* In a **single-column** region only `top-left` and `bottom-left` are read; the right pair is ignored.
* In a **1×1** region only `top-left` is 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 == 1` and `cols == 1`: all four corners must be identical.
* `rows == 1` (any width): `top-left` must equal `bottom-left`, and `top-right` must equal `bottom-right`.
* `cols == 1` (any height): `top-left` must equal `top-right`, and `bottom-left` must equal `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. `Gradient.for-region` is the fix:
```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
```
**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 **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:
```raku
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:
```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);
```
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-fill` at the top of your own widget's `render` and `putstr` over it. This is the banner idiom below and it is what you want almost every time.
* **Make the upper widget see-through** — override `base-egc` to return `''` (gcluster 0, so the glyph search falls through) and give `base-style` a transparent background. See [Selkie::Alpha](Selkie--Alpha.md) and [Selkie::Widget](Selkie--Widget.md)'s `base-style` / `base-egc` hooks. This works, but it is a per-widget opt-in, not something you get for free.
[Selkie::Widget::GradientFill](Selkie--Widget--GradientFill.md) 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:
```raku
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.
```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;
}
```
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.
```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
```
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.
```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,
);
}
}
```
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.
```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);
```
SEE ALSO
========
* [Selkie::Widget::GradientFill](Selkie--Widget--GradientFill.md) — the decorative-pane widget
* [Selkie::Alpha](Selkie--Alpha.md) — alpha modes, and why a gradient cannot fade
* [Selkie::Style](Selkie--Style.md) — the per-cell styling gradients deliberately do not participate in
* [Selkie::Widget](Selkie--Widget.md) — `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
```raku
method horizontal(
Int $left where { ... },
Int $right where { ... }
) returns Selkie::Gradient::Gradient
```
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 vertical
```raku
method vertical(
Int $top where { ... },
Int $bottom where { ... }
) returns Selkie::Gradient::Gradient
```
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 corners
```raku
method corners(
Int :$top-left! where { ... },
Int :$top-right! where { ... },
Int :$bottom-left! where { ... },
Int :$bottom-right! where { ... }
) returns Selkie::Gradient::Gradient
```
A 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
```raku
method uniform(
Int $rgb where { ... }
) returns Selkie::Gradient::Gradient
```
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 `:fg` of a stain whose background ramps. gradient-stain($p, $ramp, fg => Gradient.uniform(0xFFFFFF));
### method for-region
```raku
method for-region(
Int $rows where { ... },
Int $cols where { ... },
Bool :$hires = Bool::False
) returns Selkie::Gradient::Gradient
```
The 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
```raku
sub gradient-channels(
Selkie::Gradient::Gradient:D $bg,
Selkie::Gradient::Gradient :$fg
) returns List
```
The 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
```raku
sub gradient-channels-hires(
Selkie::Gradient::Gradient:D $g
) returns List
```
The 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
```raku
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 Int
```
Paint `$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, Gradient.vertical(0x2E1A4A, 0x1A1A2E)); gradient-fill($plane, $ramp, rows => 3, egc => '·'); gradient-fill($plane, $ramp, fg => Gradient.uniform(0xFFFFFF), egc => '█');
### sub gradient-stain
```raku
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 Int
```
Recolour 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($plane, $ramp, fg => Gradient.uniform(0xF0F0F0), y => $row, rows => 1);
### sub gradient-fill-hires
```raku
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 Int
```
Paint `$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($plane, $ramp) >= 0 or gradient-fill($plane, $ramp); Returns the number of cells painted, or `-1`.