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 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, 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:

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:

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-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 and Selkie::Widget'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 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 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.

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

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::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

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

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

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(ramp, fg => Gradient.uniform(0xFFFFFF));

method for-region

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

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

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

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, 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 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(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 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(ramp) >= 0 or gradient-fill(ramp); Returns the number of cells painted, or -1.

Selkie v0.11.1

High-level TUI framework built on Notcurses

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Notcurses::Native:ver<0.4.1+>:auth<zef:apogee>

Test Dependencies

Provides

  • Selkie
  • Selkie::Align
  • Selkie::Alpha
  • Selkie::App
  • Selkie::App::Internal::Animation
  • Selkie::App::Internal::Dispatch
  • Selkie::App::Internal::ErrorLog
  • Selkie::App::Internal::FocusTree
  • Selkie::App::Internal::HitTest
  • Selkie::App::Internal::IdleBudget
  • Selkie::App::Internal::OverlayTree
  • Selkie::App::Internal::RenderLoop
  • Selkie::App::Internal::ScreenModalLifecycle
  • Selkie::App::Internal::Terminal
  • Selkie::App::Internal::TerminalSequences
  • Selkie::BorderStyle
  • Selkie::Container
  • Selkie::EffectiveBounds
  • Selkie::Event
  • Selkie::Gradient
  • Selkie::Layout::Allocate
  • Selkie::Layout::HBox
  • Selkie::Layout::Split
  • Selkie::Layout::VBox
  • Selkie::Plot::Palette
  • Selkie::Plot::Scaler
  • Selkie::Plot::Ticks
  • Selkie::ScreenManager
  • Selkie::Sizing
  • Selkie::Store
  • Selkie::Style
  • Selkie::Test::Focus
  • Selkie::Test::Keys
  • Selkie::Test::Snapshot
  • Selkie::Test::Snapshot::Harness
  • Selkie::Test::Store
  • Selkie::Test::Supply
  • Selkie::Test::Tree
  • Selkie::Theme
  • Selkie::Trace
  • Selkie::Tree
  • Selkie::Tween
  • Selkie::Widget
  • Selkie::Widget::Axis
  • Selkie::Widget::BarChart
  • Selkie::Widget::Border
  • Selkie::Widget::Button
  • Selkie::Widget::CardList
  • Selkie::Widget::Checkbox
  • Selkie::Widget::CommandPalette
  • Selkie::Widget::ConfirmModal
  • Selkie::Widget::FileBrowser
  • Selkie::Widget::FocusableByDefault
  • Selkie::Widget::GradientFill
  • Selkie::Widget::Heatmap
  • Selkie::Widget::HelpOverlay
  • Selkie::Widget::Histogram
  • Selkie::Widget::Image
  • Selkie::Widget::Legend
  • Selkie::Widget::LineChart
  • Selkie::Widget::ListView
  • Selkie::Widget::Modal
  • Selkie::Widget::MultiLineInput
  • Selkie::Widget::PasswordStrength
  • Selkie::Widget::Plot
  • Selkie::Widget::ProgressBar
  • Selkie::Widget::RadioGroup
  • Selkie::Widget::RichText
  • Selkie::Widget::RichText::Span
  • Selkie::Widget::ScatterPlot
  • Selkie::Widget::ScrollView
  • Selkie::Widget::Select
  • Selkie::Widget::Sparkline
  • Selkie::Widget::Spinner
  • Selkie::Widget::TabBar
  • Selkie::Widget::Table
  • Selkie::Widget::Text
  • Selkie::Widget::TextInput
  • Selkie::Widget::TextInput::HighlightSpan
  • Selkie::Widget::TextStream
  • Selkie::Widget::Toast
  • Selkie::Widget::ViewportedCardList

Documentation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.