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.
newsorts them, so you can pass them in any order. - Positions in
[0.0, 1.0].sample(t)clampstto 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 ──── yellowStops are the corners; anything between is a linear interpolation.
Worked example — a progress bar color
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.