Skip to main content

whisker_css/data_type/
gradient.rs

1//! `<gradient>` — color transitions usable wherever Lynx accepts an
2//! `<image>` (currently `background-image`, `mask-image`).
3//!
4//! Lynx reference: <https://lynxjs.org/api/css/data-type/gradient.html>
5//!
6//! Lynx supports three gradient functions:
7//!
8//! - [`Gradient::Linear`] — color stops along an arbitrary axis.
9//! - [`Gradient::Radial`] — color stops radiating from a focal point.
10//! - [`Gradient::Conic`] — color stops sweeping around an angle.
11//!
12//! **Repeating gradients (`repeating-linear-gradient`,
13//! `repeating-radial-gradient`, `repeating-conic-gradient`) are not
14//! supported by Lynx and are intentionally absent.** The
15//! "multi-position color stop" shorthand (`red 40% 60%`) is also not
16//! supported — express it as two separate stops at the matching
17//! positions.
18
19use core::fmt;
20
21use crate::to_css::ToCss;
22
23use super::{Angle, Color, LengthPercentage, Percentage};
24
25/// A CSS `<gradient>` value.
26#[derive(Clone, Debug, PartialEq)]
27pub enum Gradient {
28    /// `linear-gradient(<direction>, <stops>)`.
29    Linear {
30        /// Direction of the gradient axis.
31        direction: LinearDirection,
32        /// Color stops along the axis.
33        stops: Vec<ColorStop>,
34    },
35    /// `radial-gradient(<shape>, <stops>)`.
36    Radial {
37        /// Shape and extent of the radial gradient.
38        shape: RadialShape,
39        /// Color stops along the radius.
40        stops: Vec<ColorStop>,
41    },
42    /// `conic-gradient([from <angle>] [at <position>], <stops>)`.
43    Conic {
44        /// Starting angle of the sweep, if any.
45        from: Option<Angle>,
46        /// Center of the sweep as `<length-percentage> <length-percentage>`
47        /// (defaults to `50% 50%` when `None`).
48        at: Option<(LengthPercentage, LengthPercentage)>,
49        /// Color stops along the sweep.
50        stops: Vec<ColorStop>,
51    },
52}
53
54impl Gradient {
55    /// Convenience constructor for a vertical top-to-bottom linear
56    /// gradient with the given color stops.
57    pub fn linear_to_bottom(stops: impl IntoIterator<Item = ColorStop>) -> Self {
58        Self::Linear {
59            direction: LinearDirection::ToBottom,
60            stops: stops.into_iter().collect(),
61        }
62    }
63
64    /// Convenience constructor for a horizontal left-to-right linear
65    /// gradient with the given color stops.
66    pub fn linear_to_right(stops: impl IntoIterator<Item = ColorStop>) -> Self {
67        Self::Linear {
68            direction: LinearDirection::ToRight,
69            stops: stops.into_iter().collect(),
70        }
71    }
72}
73
74/// The direction component of a `linear-gradient`.
75#[derive(Copy, Clone, Debug, PartialEq)]
76pub enum LinearDirection {
77    /// `to top`.
78    ToTop,
79    /// `to right`.
80    ToRight,
81    /// `to bottom`.
82    ToBottom,
83    /// `to left`.
84    ToLeft,
85    /// `to top right`.
86    ToTopRight,
87    /// `to top left`.
88    ToTopLeft,
89    /// `to bottom right`.
90    ToBottomRight,
91    /// `to bottom left`.
92    ToBottomLeft,
93    /// Explicit angle (`<angle>`). 0deg points up; positive angles
94    /// rotate clockwise.
95    Angle(Angle),
96}
97
98impl ToCss for LinearDirection {
99    fn to_css(&self, dest: &mut dyn fmt::Write) -> fmt::Result {
100        match self {
101            LinearDirection::ToTop => dest.write_str("to top"),
102            LinearDirection::ToRight => dest.write_str("to right"),
103            LinearDirection::ToBottom => dest.write_str("to bottom"),
104            LinearDirection::ToLeft => dest.write_str("to left"),
105            LinearDirection::ToTopRight => dest.write_str("to top right"),
106            LinearDirection::ToTopLeft => dest.write_str("to top left"),
107            LinearDirection::ToBottomRight => dest.write_str("to bottom right"),
108            LinearDirection::ToBottomLeft => dest.write_str("to bottom left"),
109            LinearDirection::Angle(a) => a.to_css(dest),
110        }
111    }
112}
113
114/// The shape component of a `radial-gradient`.
115#[derive(Clone, Debug, PartialEq)]
116pub enum RadialShape {
117    /// `circle` — equal radius along both axes.
118    Circle,
119    /// `ellipse` — independent horizontal and vertical radii.
120    Ellipse,
121    /// `circle <length>` — explicit circle radius.
122    CircleSized(LengthPercentage),
123    /// `ellipse <length-percentage> <length-percentage>` —
124    /// explicit ellipse radii.
125    EllipseSized(LengthPercentage, LengthPercentage),
126}
127
128impl ToCss for RadialShape {
129    fn to_css(&self, dest: &mut dyn fmt::Write) -> fmt::Result {
130        match self {
131            RadialShape::Circle => dest.write_str("circle"),
132            RadialShape::Ellipse => dest.write_str("ellipse"),
133            RadialShape::CircleSized(r) => {
134                dest.write_str("circle ")?;
135                r.to_css(dest)
136            }
137            RadialShape::EllipseSized(rx, ry) => {
138                dest.write_str("ellipse ")?;
139                rx.to_css(dest)?;
140                dest.write_char(' ')?;
141                ry.to_css(dest)
142            }
143        }
144    }
145}
146
147/// A `<color-stop>`: a color and an optional position.
148///
149/// Lynx accepts the position as either a `<percentage>` or a
150/// `<length>` (mapped via [`LengthPercentage`]). The
151/// `<color> <position> <position>` "double-position" form is **not**
152/// supported by Lynx; emit the same color twice if you need sharp
153/// transitions.
154#[derive(Clone, Debug, PartialEq)]
155pub struct ColorStop {
156    /// Color of the stop.
157    pub color: Color,
158    /// Optional position along the gradient axis.
159    pub position: Option<StopPosition>,
160}
161
162impl ColorStop {
163    /// Color stop without an explicit position.
164    pub fn new(color: Color) -> Self {
165        Self {
166            color,
167            position: None,
168        }
169    }
170
171    /// Color stop with an explicit position.
172    pub fn at(color: Color, position: impl Into<StopPosition>) -> Self {
173        Self {
174            color,
175            position: Some(position.into()),
176        }
177    }
178}
179
180impl ToCss for ColorStop {
181    fn to_css(&self, dest: &mut dyn fmt::Write) -> fmt::Result {
182        self.color.to_css(dest)?;
183        if let Some(p) = &self.position {
184            dest.write_char(' ')?;
185            p.to_css(dest)?;
186        }
187        Ok(())
188    }
189}
190
191/// Position of a [`ColorStop`].
192#[derive(Clone, Debug, PartialEq)]
193pub enum StopPosition {
194    /// A length-percentage (`50%`, `100px`).
195    LengthPercentage(LengthPercentage),
196    /// A unit-less number, interpreted by Lynx as a fraction
197    /// (`0` = start, `1` = end).
198    Number(f32),
199}
200
201impl From<Percentage> for StopPosition {
202    fn from(p: Percentage) -> Self {
203        Self::LengthPercentage(p.into())
204    }
205}
206
207impl From<LengthPercentage> for StopPosition {
208    fn from(lp: LengthPercentage) -> Self {
209        Self::LengthPercentage(lp)
210    }
211}
212
213impl ToCss for StopPosition {
214    fn to_css(&self, dest: &mut dyn fmt::Write) -> fmt::Result {
215        match self {
216            StopPosition::LengthPercentage(lp) => lp.to_css(dest),
217            StopPosition::Number(n) => crate::to_css::write_number(dest, *n),
218        }
219    }
220}
221
222fn write_stops(dest: &mut dyn fmt::Write, stops: &[ColorStop]) -> fmt::Result {
223    let mut first = true;
224    for s in stops {
225        if !first {
226            dest.write_str(", ")?;
227        }
228        s.to_css(dest)?;
229        first = false;
230    }
231    Ok(())
232}
233
234impl ToCss for Gradient {
235    fn to_css(&self, dest: &mut dyn fmt::Write) -> fmt::Result {
236        match self {
237            Gradient::Linear { direction, stops } => {
238                dest.write_str("linear-gradient(")?;
239                direction.to_css(dest)?;
240                dest.write_str(", ")?;
241                write_stops(dest, stops)?;
242                dest.write_char(')')
243            }
244            Gradient::Radial { shape, stops } => {
245                dest.write_str("radial-gradient(")?;
246                shape.to_css(dest)?;
247                dest.write_str(", ")?;
248                write_stops(dest, stops)?;
249                dest.write_char(')')
250            }
251            Gradient::Conic { from, at, stops } => {
252                dest.write_str("conic-gradient(")?;
253                let mut wrote_header = false;
254                if let Some(a) = from {
255                    dest.write_str("from ")?;
256                    a.to_css(dest)?;
257                    wrote_header = true;
258                }
259                if let Some((x, y)) = at {
260                    if wrote_header {
261                        dest.write_char(' ')?;
262                    }
263                    dest.write_str("at ")?;
264                    x.to_css(dest)?;
265                    dest.write_char(' ')?;
266                    y.to_css(dest)?;
267                    wrote_header = true;
268                }
269                if wrote_header {
270                    dest.write_str(", ")?;
271                }
272                write_stops(dest, stops)?;
273                dest.write_char(')')
274            }
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::data_type::{Length, NamedColor};
283
284    fn red() -> Color {
285        Color::Named(NamedColor::Red)
286    }
287    fn blue() -> Color {
288        Color::Named(NamedColor::Blue)
289    }
290
291    #[test]
292    fn linear_to_bottom_two_stops() {
293        let g = Gradient::linear_to_bottom([ColorStop::new(red()), ColorStop::new(blue())]);
294        assert_eq!(g.to_css_string(), "linear-gradient(to bottom, red, blue)");
295    }
296
297    #[test]
298    fn linear_with_angle_and_positions() {
299        let g = Gradient::Linear {
300            direction: LinearDirection::Angle(Angle::Deg(45.0)),
301            stops: vec![
302                ColorStop::at(red(), Percentage(0.0)),
303                ColorStop::at(blue(), Percentage(100.0)),
304            ],
305        };
306        assert_eq!(
307            g.to_css_string(),
308            "linear-gradient(45deg, red 0%, blue 100%)"
309        );
310    }
311
312    #[test]
313    fn linear_all_keyword_directions() {
314        let cases = [
315            (LinearDirection::ToTop, "to top"),
316            (LinearDirection::ToRight, "to right"),
317            (LinearDirection::ToBottom, "to bottom"),
318            (LinearDirection::ToLeft, "to left"),
319            (LinearDirection::ToTopRight, "to top right"),
320            (LinearDirection::ToTopLeft, "to top left"),
321            (LinearDirection::ToBottomRight, "to bottom right"),
322            (LinearDirection::ToBottomLeft, "to bottom left"),
323        ];
324        for (d, expected) in cases {
325            let g = Gradient::Linear {
326                direction: d,
327                stops: vec![ColorStop::new(red())],
328            };
329            assert!(g.to_css_string().contains(expected));
330        }
331    }
332
333    #[test]
334    fn radial_circle_default() {
335        let g = Gradient::Radial {
336            shape: RadialShape::Circle,
337            stops: vec![ColorStop::new(red()), ColorStop::new(blue())],
338        };
339        assert_eq!(g.to_css_string(), "radial-gradient(circle, red, blue)");
340    }
341
342    #[test]
343    fn radial_ellipse_sized() {
344        let g = Gradient::Radial {
345            shape: RadialShape::EllipseSized(Length::Px(100.0).into(), Percentage(50.0).into()),
346            stops: vec![ColorStop::new(red())],
347        };
348        assert_eq!(g.to_css_string(), "radial-gradient(ellipse 100px 50%, red)");
349    }
350
351    #[test]
352    fn radial_circle_sized() {
353        let g = Gradient::Radial {
354            shape: RadialShape::CircleSized(Length::Px(50.0).into()),
355            stops: vec![ColorStop::new(red())],
356        };
357        assert_eq!(g.to_css_string(), "radial-gradient(circle 50px, red)");
358    }
359
360    #[test]
361    fn radial_ellipse_keyword() {
362        let g = Gradient::Radial {
363            shape: RadialShape::Ellipse,
364            stops: vec![ColorStop::new(red())],
365        };
366        assert_eq!(g.to_css_string(), "radial-gradient(ellipse, red)");
367    }
368
369    #[test]
370    fn conic_bare() {
371        let g = Gradient::Conic {
372            from: None,
373            at: None,
374            stops: vec![ColorStop::new(red()), ColorStop::new(blue())],
375        };
376        assert_eq!(g.to_css_string(), "conic-gradient(red, blue)");
377    }
378
379    #[test]
380    fn conic_from_and_at() {
381        let g = Gradient::Conic {
382            from: Some(Angle::Deg(90.0)),
383            at: Some((Percentage(50.0).into(), Percentage(50.0).into())),
384            stops: vec![ColorStop::new(red())],
385        };
386        assert_eq!(
387            g.to_css_string(),
388            "conic-gradient(from 90deg at 50% 50%, red)"
389        );
390    }
391
392    #[test]
393    fn conic_at_only() {
394        let g = Gradient::Conic {
395            from: None,
396            at: Some((Percentage(0.0).into(), Percentage(100.0).into())),
397            stops: vec![ColorStop::new(red())],
398        };
399        assert_eq!(g.to_css_string(), "conic-gradient(at 0% 100%, red)");
400    }
401
402    #[test]
403    fn conic_from_only() {
404        let g = Gradient::Conic {
405            from: Some(Angle::Turn(0.25)),
406            at: None,
407            stops: vec![ColorStop::new(red())],
408        };
409        assert_eq!(g.to_css_string(), "conic-gradient(from 0.25turn, red)");
410    }
411
412    #[test]
413    fn stop_with_number_position() {
414        let stop = ColorStop {
415            color: red(),
416            position: Some(StopPosition::Number(0.5)),
417        };
418        assert_eq!(stop.to_css_string(), "red 0.5");
419    }
420
421    #[test]
422    fn stop_position_from_impls() {
423        let p: StopPosition = Percentage(25.0).into();
424        let lp: StopPosition = LengthPercentage::Length(Length::Px(8.0)).into();
425        assert_eq!(p.to_css_string(), "25%");
426        assert_eq!(lp.to_css_string(), "8px");
427    }
428
429    #[test]
430    fn linear_to_right_helper() {
431        let g = Gradient::linear_to_right([ColorStop::new(red()), ColorStop::new(blue())]);
432        assert_eq!(g.to_css_string(), "linear-gradient(to right, red, blue)");
433    }
434}