Skip to main content

palette_core/
gradient.rs

1//! Multi-stop color gradients with perceptual interpolation.
2//!
3//! Gradients are defined in theme TOML files and resolved alongside the rest of
4//! the palette. Stops can be hex literals or token references to palette colors.
5//! Interpolation runs in OKLab (default) or OKLCH color space.
6//!
7//! # Defining gradients in TOML
8//!
9//! ```toml
10//! [gradient.heat]
11//! stops = ["#2563EB", "#F59E0B", "#EF4444"]
12//! space = "oklch"
13//!
14//! [gradient.brand]
15//! stops = ["base.background", "semantic.info"]
16//! ```
17//!
18//! # Interpolating
19//!
20//! ```
21//! use palette_core::gradient::{Gradient, GradientStop, ColorSpace};
22//! use palette_core::Color;
23//!
24//! let stops = vec![
25//!     GradientStop { color: Color::from_hex("#000000").unwrap(), position: 0.0 },
26//!     GradientStop { color: Color::from_hex("#FFFFFF").unwrap(), position: 1.0 },
27//! ];
28//! let gradient = Gradient::new(stops, ColorSpace::OkLab).unwrap();
29//!
30//! let mid = gradient.at(0.5);   // perceptual midpoint
31//! let css = gradient.to_css();  // linear-gradient(in oklab, #000000, #FFFFFF)
32//! ```
33
34use std::sync::Arc;
35
36use crate::color::Color;
37use crate::error::PaletteError;
38use crate::manipulation::{
39    lerp_oklab, lerp_oklch, oklab_to_srgb, oklch_to_oklab, srgb_to_oklab, srgb_to_oklch,
40};
41
42/// Interpolation color space for gradient stops.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44#[cfg_attr(feature = "snapshot", derive(serde::Serialize))]
45pub enum ColorSpace {
46    /// Perceptually uniform interpolation (default).
47    #[default]
48    OkLab,
49    /// Polar interpolation with shortest-arc hue travel.
50    OkLch,
51}
52
53/// A color reference in an unresolved gradient definition.
54///
55/// Produced during `Palette::from_manifest()`. Token references are validated
56/// against known section/field names at parse time so that `resolve()` can
57/// look them up infallibly.
58#[derive(Debug, Clone, PartialEq, Eq)]
59#[cfg_attr(feature = "snapshot", derive(serde::Serialize))]
60pub enum GradientColor {
61    /// A concrete hex color parsed at load time.
62    Literal(Color),
63    /// A reference to a palette token: `"section.field"`.
64    Token {
65        /// Section name (e.g. `"base"`).
66        section: Arc<str>,
67        /// Field name within that section (e.g. `"foreground"`).
68        field: Arc<str>,
69    },
70}
71
72/// An unresolved gradient definition with typed stops.
73///
74/// Stored on [`Palette`](crate::Palette) after `from_manifest()`.
75/// Each stop is a `(GradientColor, position)` pair with positions in \[0, 1\].
76#[derive(Debug, Clone, PartialEq)]
77#[cfg_attr(feature = "snapshot", derive(serde::Serialize))]
78pub struct GradientDef {
79    stops: Box<[(GradientColor, f64)]>,
80    space: ColorSpace,
81}
82
83impl GradientDef {
84    /// Build a gradient definition from validated stops.
85    ///
86    /// Callers must ensure ≥ 2 stops with sorted positions.
87    pub(crate) fn new(stops: Box<[(GradientColor, f64)]>, space: ColorSpace) -> Self {
88        Self { stops, space }
89    }
90
91    /// The typed stops in this gradient definition.
92    pub fn stops(&self) -> &[(GradientColor, f64)] {
93        &self.stops
94    }
95
96    /// The interpolation color space.
97    pub fn space(&self) -> ColorSpace {
98        self.space
99    }
100}
101
102/// A single stop in a gradient.
103#[derive(Debug, Clone, Copy, PartialEq)]
104#[cfg_attr(feature = "snapshot", derive(serde::Serialize))]
105pub struct GradientStop {
106    /// The color at this stop.
107    pub color: Color,
108    /// Position in \[0, 1\].
109    pub position: f64,
110}
111
112/// A resolved gradient with concrete color stops, ready for interpolation.
113#[derive(Debug, Clone, PartialEq)]
114#[cfg_attr(feature = "snapshot", derive(serde::Serialize))]
115pub struct Gradient {
116    stops: Box<[GradientStop]>,
117    space: ColorSpace,
118}
119
120impl Gradient {
121    /// Construct a gradient from pre-validated stops (skips validation).
122    ///
123    /// Callers must guarantee ≥ 2 stops with monotonically increasing positions.
124    /// Used when stops were already validated at parse time.
125    pub(crate) fn new_unchecked(stops: impl Into<Box<[GradientStop]>>, space: ColorSpace) -> Self {
126        Self {
127            stops: stops.into(),
128            space,
129        }
130    }
131
132    /// Construct a gradient from concrete color stops.
133    ///
134    /// Returns `Err` if fewer than 2 stops or positions are not monotonically
135    /// increasing.
136    pub fn new(
137        stops: impl Into<Box<[GradientStop]>>,
138        space: ColorSpace,
139    ) -> Result<Self, PaletteError> {
140        let stops = stops.into();
141        match stops.len() < 2 {
142            true => return Err(PaletteError::InsufficientStops { count: stops.len() }),
143            false => {}
144        }
145        for stop in stops.iter() {
146            match stop.position.is_nan() || !(0.0..=1.0).contains(&stop.position) {
147                true => {
148                    return Err(PaletteError::InvalidGradientPosition {
149                        position: stop.position,
150                    });
151                }
152                false => {}
153            }
154        }
155        let sorted = stops.windows(2).all(|w| w[0].position <= w[1].position);
156        match sorted {
157            true => Ok(Self { stops, space }),
158            false => Err(PaletteError::UnsortedStops),
159        }
160    }
161
162    /// Interpolate the gradient at position `t` (clamped to \[0, 1\]).
163    /// NaN returns the first stop color.
164    pub fn at(&self, t: f64) -> Color {
165        let t = match t.is_nan() {
166            true => 0.0,
167            false => t.clamp(0.0, 1.0),
168        };
169        interpolate_at(&self.stops, self.space, t)
170    }
171
172    /// Sample `n` evenly spaced colors from the gradient.
173    ///
174    /// - `n == 0`: returns empty slice
175    /// - `n == 1`: returns `[at(0.0)]`
176    /// - `n >= 2`: endpoints guaranteed exact
177    pub fn sample(&self, n: usize) -> Box<[Color]> {
178        match n {
179            0 => Box::new([]),
180            1 => Box::new([self.at(0.0)]),
181            _ => {
182                let divisor = (n - 1) as f64;
183                (0..n)
184                    .map(|i| self.at(i as f64 / divisor))
185                    .collect::<Vec<_>>()
186                    .into_boxed_slice()
187            }
188        }
189    }
190
191    /// The color stops in this gradient.
192    pub fn stops(&self) -> &[GradientStop] {
193        &self.stops
194    }
195
196    /// The interpolation color space.
197    pub fn space(&self) -> ColorSpace {
198        self.space
199    }
200
201    /// Emit a CSS `linear-gradient()` expression.
202    ///
203    /// Positions are omitted when stops are evenly spaced (CSS default).
204    /// No direction is included — callers prepend one if needed.
205    pub fn to_css(&self) -> Box<str> {
206        use std::fmt::Write;
207
208        let space_str = match self.space {
209            ColorSpace::OkLab => "oklab",
210            ColorSpace::OkLch => "oklch",
211        };
212
213        let mut buf = String::with_capacity(64);
214        let _ = write!(buf, "linear-gradient(in {space_str},");
215
216        let evenly_spaced = is_evenly_spaced(&self.stops);
217
218        for (i, stop) in self.stops.iter().enumerate() {
219            let hex = stop.color.to_hex();
220            match evenly_spaced {
221                true => {
222                    let _ = write!(buf, " {hex}");
223                }
224                false => {
225                    let pct = stop.position * 100.0;
226                    let _ = write!(buf, " {hex} {pct}%");
227                }
228            }
229            match i < self.stops.len() - 1 {
230                true => buf.push(','),
231                false => {}
232            }
233        }
234
235        buf.push(')');
236        buf.into_boxed_str()
237    }
238}
239
240/// Check whether stops are evenly spaced (equal intervals from first to last).
241fn is_evenly_spaced(stops: &[GradientStop]) -> bool {
242    match stops.len() {
243        0..=2 => true,
244        n => {
245            let first = stops[0].position;
246            let last = stops[n - 1].position;
247            let step = (last - first) / (n - 1) as f64;
248            stops
249                .iter()
250                .enumerate()
251                .all(|(i, s)| (s.position - (first + step * i as f64)).abs() < 1e-9)
252        }
253    }
254}
255
256/// Find the bounding stops for `t` and interpolate.
257fn interpolate_at(stops: &[GradientStop], space: ColorSpace, t: f64) -> Color {
258    // Exact endpoint checks
259    let first = stops[0];
260    let last = stops[stops.len() - 1];
261    match () {
262        _ if t <= first.position => return first.color,
263        _ if t >= last.position => return last.color,
264        _ => {}
265    }
266
267    // Find the segment containing t via binary search on positions
268    let idx = match stops[1..].iter().position(|s| s.position >= t) {
269        Some(i) => i,
270        None => return last.color,
271    };
272
273    let a = &stops[idx];
274    let b = &stops[idx + 1];
275
276    // Exact stop hit — no interpolation needed
277    let span = b.position - a.position;
278    match span <= f64::EPSILON {
279        true => return a.color,
280        false => {}
281    }
282
283    let local_t = (t - a.position) / span;
284    interpolate_colors(a.color, b.color, space, local_t)
285}
286
287fn interpolate_colors(a: Color, b: Color, space: ColorSpace, t: f64) -> Color {
288    match space {
289        ColorSpace::OkLab => {
290            let lab_a = srgb_to_oklab(a);
291            let lab_b = srgb_to_oklab(b);
292            oklab_to_srgb(lerp_oklab(lab_a, lab_b, t))
293        }
294        ColorSpace::OkLch => {
295            let lch_a = srgb_to_oklch(a);
296            let lch_b = srgb_to_oklch(b);
297            oklab_to_srgb(oklch_to_oklab(lerp_oklch(lch_a, lch_b, t)))
298        }
299    }
300}