Skip to Content
ftui-styleGradients

Gradients

ftui_style::table_theme::Gradient is a small but load-bearing type — a list of color stops in [0.0, 1.0] with linear interpolation between them. Tables use gradients for sweeps and heatmaps; VFX uses them for metaballs and particle trails; widgets use them for progress bars and pressure indicators.

The shape

pub struct Gradient { stops: Vec<(f32, PackedRgba)>, // (position, color) } impl Gradient { pub fn new(stops: Vec<(f32, PackedRgba)>) -> Self; pub fn stops(&self) -> &[(f32, PackedRgba)]; pub fn sample(&self, t: f32) -> PackedRgba; }

A valid gradient has:

  • At least one stop. A single-stop gradient is a constant color.
  • Stops in ascending order of position. new sorts them, so you can pass them in any order.
  • Positions in [0.0, 1.0]. sample(t) clamps t to this range.

Sampling

let g = Gradient::new(vec![ (0.0, PackedRgba::rgb(0, 0, 0)), (0.5, PackedRgba::rgb(255, 0, 0)), (1.0, PackedRgba::rgb(255, 255, 0)), ]); g.sample(0.0); // #000000 (black) g.sample(0.25); // halfway from black to red g.sample(0.5); // #FF0000 (red) g.sample(0.75); // halfway from red to yellow g.sample(1.0); // #FFFF00 (yellow)

The interpolation is linear in RGB space between adjacent stops. That is simple and fast; it is also occasionally wrong in perceptual terms (linear RGB blends can “grey out” near the midpoint).

If you need perceptual interpolation (OKLab, LCH), build it on top. The gradient type is deliberately a simple primitive — swap colors through a perceptual→sRGB conversion before you construct the Gradient, and interpolation will approximate perceptual blending.

ASCII visualization

Stops at (0.0, black), (0.5, red), (1.0, yellow):

t: 0.0 0.25 0.5 0.75 1.0 ▼ ▼ ▼ ▼ ▼ ██░░░░░░░░░░▓▓▓▓▓▓▓▓▓▓▓▓██████████████████████ black ── dark-red ── red ── orange ──── yellow

Stops are the corners; anything between is a linear interpolation.

Worked example — a progress bar color

progress_color.rs
use ftui_render::cell::PackedRgba; use ftui_style::table_theme::Gradient; fn progress_color(progress: f32) -> PackedRgba { let palette = Gradient::new(vec![ (0.0, PackedRgba::rgb(220, 50, 50)), // red (0 %) (0.5, PackedRgba::rgb(230, 210, 80)), // amber (50 %) (1.0, PackedRgba::rgb( 60, 200, 100)), // green (100 %) ]); palette.sample(progress.clamp(0.0, 1.0)) } let c = progress_color(0.92);

Integration with table theming

Inside TableEffect, a gradient paints a row, a column, or a section:

TableEffectRule::new( TableEffectTarget::Column(4), // the "latency" column TableEffect::GradientSweep { gradient: Gradient::new(vec![ (0.0, PackedRgba::rgb(50, 200, 100)), (0.5, PackedRgba::rgb(220, 200, 60)), (1.0, PackedRgba::rgb(220, 60, 60)), ]), /* sweep axis, phase, etc. */ }, ) .priority(20);

See table theme for the full effect rule vocabulary.

Serialization

Gradients round-trip through GradientSpec:

pub struct GradientSpec { pub stops: Vec<GradientStopSpec>, } pub struct GradientStopSpec { /* pos + rgba */ } GradientSpec::from_gradient(&g); // serde-friendly let back = spec.to_gradient();

Validation at spec-load time rejects empty stop lists and out-of-range positions before the gradient is used.

Pitfalls

Two identical stop positions produce a hard step. (0.5, red), (0.5, blue) means “red up to 0.5, blue after.” Useful for stepped scales; surprising if unintended.

RGB linear interpolation greys out the midpoint. Going from blue to yellow through (0.5, ?) produces a greyish olive, not a clean green. If you need a pleasant midpoint, add an explicit stop there.

Positions are f32. Don’t use it as a key in a HashMap; use the u16 or u32 quantization of your own gradients for caching.

Where to go next