Skip to main content

frus_core/
gradient.rs

1//! **Gradients**, as the reference has them (`painting/gradient.dart`, milestone 644): a
2//! linear one between two points, a radial one round a centre (with an optional focal
3//! point), and a sweep round a centre. Each runs through a list of colours at optional
4//! stops, and says what happens past its ends ([`TileMode`]).
5//!
6//! The geometry is the reference's, **relative to the box** it fills: points are
7//! alignments (`-1` to `1` across the box), a radius is a fraction of the box's shorter side,
8//! and angles are radians clockwise from the right. The renderer turns them into pixels
9//! for the box it paints, so a gradient described once fills any box the same way.
10
11use crate::{Alignment, AlignmentGeometry, Color, TextDirection};
12
13/// What a gradient does past its ends (`dart:ui`'s `TileMode`).
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum TileMode {
16    /// The end colours carry on. The default.
17    #[default]
18    Clamp,
19    /// The gradient starts again.
20    Repeated,
21    /// The gradient comes back the other way, and so on.
22    Mirror,
23    /// Nothing past the ends.
24    Decal,
25}
26
27/// **A gradient along a line**, from `begin` to `end` (`gradient.dart`'s
28/// `LinearGradient`). Unset, from the centre of the left side to the centre of the right.
29#[derive(Clone, Debug, PartialEq)]
30pub struct LinearGradient {
31    /// Where the first colour is.
32    pub begin: AlignmentGeometry,
33    /// Where the last colour is.
34    pub end: AlignmentGeometry,
35    /// The colours, first to last.
36    pub colors: Vec<Color>,
37    /// Where each colour is, from `0` to `1`, as many as there are colours; `None` spreads
38    /// them evenly.
39    pub stops: Option<Vec<f32>>,
40    /// Past the ends.
41    pub tile_mode: TileMode,
42    /// A turn of the whole gradient about the box's centre, in radians clockwise — the
43    /// reference's `GradientRotation`.
44    pub rotation: f32,
45}
46
47/// **A gradient round a centre** (`gradient.dart`'s `RadialGradient`): the first colour at
48/// `center`, the last on the circle `radius` round it. With a `focal` point, the first colour
49/// is on the circle `focal_radius` round that point instead, and the gradient runs between
50/// the two circles.
51#[derive(Clone, Debug, PartialEq)]
52pub struct RadialGradient {
53    /// The centre. Unset, the box's.
54    pub center: AlignmentGeometry,
55    /// The radius, as a fraction of the box's shorter side. Unset, a half.
56    pub radius: f32,
57    /// The colours, first to last.
58    pub colors: Vec<Color>,
59    /// Where each colour is; `None` spreads them evenly.
60    pub stops: Option<Vec<f32>>,
61    /// Past the ends.
62    pub tile_mode: TileMode,
63    /// Where the first colour's circle is centred, when not at `center`.
64    pub focal: Option<AlignmentGeometry>,
65    /// The first colour's circle's radius, as a fraction of the shorter side.
66    pub focal_radius: f32,
67    /// A turn of the whole gradient about the box's centre, in radians clockwise.
68    pub rotation: f32,
69}
70
71/// **A gradient turning round a centre** (`gradient.dart`'s `SweepGradient`): from
72/// `start_angle` to `end_angle`, in radians clockwise from the right.
73#[derive(Clone, Debug, PartialEq)]
74pub struct SweepGradient {
75    /// The centre. Unset, the box's.
76    pub center: AlignmentGeometry,
77    /// Where the first colour is. Unset, `0`, the right.
78    pub start_angle: f32,
79    /// Where the last colour is. Unset, a whole turn.
80    pub end_angle: f32,
81    /// The colours, first to last.
82    pub colors: Vec<Color>,
83    /// Where each colour is; `None` spreads them evenly.
84    pub stops: Option<Vec<f32>>,
85    /// Past the ends.
86    pub tile_mode: TileMode,
87    /// A turn of the whole gradient about the box's centre, in radians clockwise.
88    pub rotation: f32,
89}
90
91/// Any of the three.
92#[derive(Clone, Debug, PartialEq)]
93pub enum Gradient {
94    /// Along a line.
95    Linear(LinearGradient),
96    /// Round a centre.
97    Radial(RadialGradient),
98    /// Turning round a centre.
99    Sweep(SweepGradient),
100}
101
102fn physical(a: Alignment) -> AlignmentGeometry {
103    AlignmentGeometry::Physical(a)
104}
105
106impl LinearGradient {
107    /// From the centre of the left side to the centre of the right, through `colors`.
108    pub fn new(colors: impl Into<Vec<Color>>) -> Self {
109        Self {
110            begin: physical(Alignment::CENTER_LEFT),
111            end: physical(Alignment::CENTER_RIGHT),
112            colors: colors.into(),
113            stops: None,
114            tile_mode: TileMode::Clamp,
115            rotation: 0.0,
116        }
117    }
118
119    /// Where the first and the last colours are.
120    #[must_use]
121    pub fn between(
122        mut self,
123        begin: impl Into<AlignmentGeometry>,
124        end: impl Into<AlignmentGeometry>,
125    ) -> Self {
126        self.begin = begin.into();
127        self.end = end.into();
128        self
129    }
130}
131
132impl RadialGradient {
133    /// Centred, half the shorter side round, through `colors`.
134    pub fn new(colors: impl Into<Vec<Color>>) -> Self {
135        Self {
136            center: physical(Alignment::CENTER),
137            radius: 0.5,
138            colors: colors.into(),
139            stops: None,
140            tile_mode: TileMode::Clamp,
141            focal: None,
142            focal_radius: 0.0,
143            rotation: 0.0,
144        }
145    }
146}
147
148impl SweepGradient {
149    /// Centred, a whole turn from the right, through `colors`.
150    pub fn new(colors: impl Into<Vec<Color>>) -> Self {
151        Self {
152            center: physical(Alignment::CENTER),
153            start_angle: 0.0,
154            end_angle: std::f32::consts::TAU,
155            colors: colors.into(),
156            stops: None,
157            tile_mode: TileMode::Clamp,
158            rotation: 0.0,
159        }
160    }
161}
162
163impl From<LinearGradient> for Gradient {
164    fn from(g: LinearGradient) -> Self {
165        Gradient::Linear(g)
166    }
167}
168
169impl From<RadialGradient> for Gradient {
170    fn from(g: RadialGradient) -> Self {
171        Gradient::Radial(g)
172    }
173}
174
175impl From<SweepGradient> for Gradient {
176    fn from(g: SweepGradient) -> Self {
177        Gradient::Sweep(g)
178    }
179}
180
181impl Gradient {
182    /// The colours.
183    pub fn colors(&self) -> &[Color] {
184        match self {
185            Gradient::Linear(g) => &g.colors,
186            Gradient::Radial(g) => &g.colors,
187            Gradient::Sweep(g) => &g.colors,
188        }
189    }
190
191    fn colors_mut(&mut self) -> &mut Vec<Color> {
192        match self {
193            Gradient::Linear(g) => &mut g.colors,
194            Gradient::Radial(g) => &mut g.colors,
195            Gradient::Sweep(g) => &mut g.colors,
196        }
197    }
198
199    /// The stops as given.
200    pub fn stops(&self) -> Option<&[f32]> {
201        match self {
202            Gradient::Linear(g) => g.stops.as_deref(),
203            Gradient::Radial(g) => g.stops.as_deref(),
204            Gradient::Sweep(g) => g.stops.as_deref(),
205        }
206    }
207
208    /// What happens past the ends.
209    pub fn tile_mode(&self) -> TileMode {
210        match self {
211            Gradient::Linear(g) => g.tile_mode,
212            Gradient::Radial(g) => g.tile_mode,
213            Gradient::Sweep(g) => g.tile_mode,
214        }
215    }
216
217    /// The turn about the box's centre, in radians clockwise.
218    pub fn rotation(&self) -> f32 {
219        match self {
220            Gradient::Linear(g) => g.rotation,
221            Gradient::Radial(g) => g.rotation,
222            Gradient::Sweep(g) => g.rotation,
223        }
224    }
225
226    /// The stops in use: the ones given when there is one per colour, otherwise the
227    /// colours spread evenly from `0` to `1` (`gradient.dart`'s `_impliedStops`).
228    pub fn resolved_stops(&self) -> Vec<f32> {
229        let n = self.colors().len();
230        match self.stops() {
231            Some(stops) if stops.len() == n => stops.to_vec(),
232            _ if n <= 1 => vec![0.0; n],
233            _ => (0..n).map(|i| i as f32 / (n - 1) as f32).collect(),
234        }
235    }
236
237    /// **The colour at `t`**, between the colours either side of it, the end colours held
238    /// past the stops. What the renderer bakes into its ramp; `t` is already tiled.
239    pub fn sample(&self, t: f32) -> Color {
240        let colors = self.colors();
241        match colors {
242            [] => Color::TRANSPARENT,
243            [only] => *only,
244            _ => {
245                let stops = self.resolved_stops();
246                if t <= stops[0] {
247                    return colors[0];
248                }
249                for i in 1..colors.len() {
250                    if t <= stops[i] {
251                        let span = stops[i] - stops[i - 1];
252                        let k = if span > 0.0 {
253                            (t - stops[i - 1]) / span
254                        } else {
255                            1.0
256                        };
257                        return colors[i - 1].lerp(colors[i], k);
258                    }
259                }
260                colors[colors.len() - 1]
261            }
262        }
263    }
264
265    /// The same gradient with its start and end made physical for `direction`.
266    #[must_use]
267    pub fn resolve(&self, direction: TextDirection) -> Gradient {
268        let fix = |a: AlignmentGeometry| physical(a.resolve(direction));
269        match self {
270            Gradient::Linear(g) => Gradient::Linear(LinearGradient {
271                begin: fix(g.begin),
272                end: fix(g.end),
273                ..g.clone()
274            }),
275            Gradient::Radial(g) => Gradient::Radial(RadialGradient {
276                center: fix(g.center),
277                focal: g.focal.map(fix),
278                ..g.clone()
279            }),
280            Gradient::Sweep(g) => Gradient::Sweep(SweepGradient {
281                center: fix(g.center),
282                ..g.clone()
283            }),
284        }
285    }
286
287    /// Every colour's opacity times `factor` — the reference's `Gradient.scale`, which is
288    /// how a gradient on one side of a transition arrives or leaves.
289    #[must_use]
290    pub fn scale(&self, factor: f32) -> Gradient {
291        let mut g = self.clone();
292        for c in g.colors_mut() {
293            *c = c.fade(factor);
294        }
295        g
296    }
297
298    /// The gradient `t` of the way to `other` (`gradient.dart`'s `Gradient.lerp`): two of
299    /// the same kind with as many colours travel colour by colour, stop by stop and point
300    /// by point; anything else fades one out and the other in.
301    #[must_use]
302    pub fn lerp(a: Option<&Gradient>, b: Option<&Gradient>, t: f32) -> Option<Gradient> {
303        let mix = |x: f32, y: f32| x + (y - x) * t;
304        let mix_a = |x: AlignmentGeometry, y: AlignmentGeometry| {
305            let (x, y) = (x.resolve(TextDirection::Ltr), y.resolve(TextDirection::Ltr));
306            physical(Alignment::new(mix(x.x, y.x), mix(x.y, y.y)))
307        };
308        let colors = |x: &[Color], y: &[Color]| -> Vec<Color> {
309            x.iter().zip(y).map(|(p, q)| p.lerp(*q, t)).collect()
310        };
311        let stops = |x: &Gradient, y: &Gradient| -> Option<Vec<f32>> {
312            if x.stops().is_none() && y.stops().is_none() {
313                return None;
314            }
315            Some(
316                x.resolved_stops()
317                    .iter()
318                    .zip(y.resolved_stops())
319                    .map(|(p, q)| mix(*p, q))
320                    .collect(),
321            )
322        };
323        match (a, b) {
324            (None, None) => None,
325            (Some(a), None) => Some(a.scale(1.0 - t)),
326            (None, Some(b)) => Some(b.scale(t)),
327            (Some(a), Some(b)) if a.colors().len() == b.colors().len() => {
328                let tile = if t < 0.5 {
329                    a.tile_mode()
330                } else {
331                    b.tile_mode()
332                };
333                match (a, b) {
334                    (Gradient::Linear(x), Gradient::Linear(y)) => {
335                        Some(Gradient::Linear(LinearGradient {
336                            begin: mix_a(x.begin, y.begin),
337                            end: mix_a(x.end, y.end),
338                            colors: colors(&x.colors, &y.colors),
339                            stops: stops(a, b),
340                            tile_mode: tile,
341                            rotation: mix(x.rotation, y.rotation),
342                        }))
343                    }
344                    (Gradient::Radial(x), Gradient::Radial(y)) => {
345                        Some(Gradient::Radial(RadialGradient {
346                            center: mix_a(x.center, y.center),
347                            radius: mix(x.radius, y.radius),
348                            colors: colors(&x.colors, &y.colors),
349                            stops: stops(a, b),
350                            tile_mode: tile,
351                            focal: match (x.focal, y.focal) {
352                                (None, None) => None,
353                                (p, q) => Some(mix_a(p.unwrap_or(x.center), q.unwrap_or(y.center))),
354                            },
355                            focal_radius: mix(x.focal_radius, y.focal_radius),
356                            rotation: mix(x.rotation, y.rotation),
357                        }))
358                    }
359                    (Gradient::Sweep(x), Gradient::Sweep(y)) => {
360                        Some(Gradient::Sweep(SweepGradient {
361                            center: mix_a(x.center, y.center),
362                            start_angle: mix(x.start_angle, y.start_angle),
363                            end_angle: mix(x.end_angle, y.end_angle),
364                            colors: colors(&x.colors, &y.colors),
365                            stops: stops(a, b),
366                            tile_mode: tile,
367                            rotation: mix(x.rotation, y.rotation),
368                        }))
369                    }
370                    _ => Some(if t < 0.5 {
371                        a.scale(1.0 - 2.0 * t)
372                    } else {
373                        b.scale(2.0 * t - 1.0)
374                    }),
375                }
376            }
377            (Some(a), Some(b)) => Some(if t < 0.5 {
378                a.scale(1.0 - 2.0 * t)
379            } else {
380                b.scale(2.0 * t - 1.0)
381            }),
382        }
383    }
384}
385
386/// Tiles `t` past the gradient's ends (`dart:ui`'s `TileMode`): `None` where nothing is
387/// drawn.
388pub fn tile(t: f32, mode: TileMode) -> Option<f32> {
389    match mode {
390        TileMode::Clamp => Some(t.clamp(0.0, 1.0)),
391        TileMode::Repeated => Some(t - t.floor()),
392        TileMode::Mirror => {
393            let m = t.rem_euclid(2.0);
394            Some(if m <= 1.0 { m } else { 2.0 - m })
395        }
396        TileMode::Decal => (0.0..=1.0).contains(&t).then_some(t),
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    const RED: Color = Color::rgb(1.0, 0.0, 0.0);
405    const GREEN: Color = Color::rgb(0.0, 1.0, 0.0);
406    const BLUE: Color = Color::rgb(0.0, 0.0, 1.0);
407
408    /// **Colours spread evenly unless told**, and between two stops a colour is the mix of
409    /// its neighbours; past the stops, the end colours.
410    #[test]
411    fn colours_are_sampled_between_their_stops() {
412        let even = Gradient::from(LinearGradient::new(vec![RED, GREEN, BLUE]));
413        assert_eq!(even.resolved_stops(), vec![0.0, 0.5, 1.0]);
414        assert_eq!(even.sample(0.0), RED);
415        assert_eq!(even.sample(0.5), GREEN);
416        assert_eq!(even.sample(0.25), RED.lerp(GREEN, 0.5));
417        assert_eq!(even.sample(1.0), BLUE);
418
419        let mut stopped = LinearGradient::new(vec![RED, BLUE]);
420        stopped.stops = Some(vec![0.2, 0.6]);
421        let stopped = Gradient::from(stopped);
422        assert_eq!(stopped.sample(0.1), RED, "held before the first stop");
423        let mid = stopped.sample(0.4);
424        assert!(
425            (mid.r - 0.5).abs() < 1e-6 && (mid.b - 0.5).abs() < 1e-6,
426            "half way between the stops: {mid:?}"
427        );
428        assert_eq!(stopped.sample(0.9), BLUE, "and after the last");
429
430        // Stops that do not match the colours are not used.
431        let mut wrong = LinearGradient::new(vec![RED, GREEN, BLUE]);
432        wrong.stops = Some(vec![0.0, 1.0]);
433        assert_eq!(Gradient::from(wrong).resolved_stops(), vec![0.0, 0.5, 1.0]);
434    }
435
436    /// **Past the ends**: clamped, repeated, mirrored, or nothing.
437    #[test]
438    fn the_tile_modes() {
439        assert_eq!(tile(1.4, TileMode::Clamp), Some(1.0));
440        assert_eq!(tile(-0.3, TileMode::Clamp), Some(0.0));
441        assert!((tile(1.25, TileMode::Repeated).unwrap() - 0.25).abs() < 1e-6);
442        assert!((tile(1.25, TileMode::Mirror).unwrap() - 0.75).abs() < 1e-6);
443        assert!((tile(-0.25, TileMode::Mirror).unwrap() - 0.25).abs() < 1e-6);
444        assert_eq!(tile(1.25, TileMode::Decal), None);
445        assert_eq!(tile(0.5, TileMode::Decal), Some(0.5));
446    }
447
448    /// **The reference's defaults.**
449    #[test]
450    fn the_defaults_are_the_reference_s() {
451        let l = LinearGradient::new(vec![RED, BLUE]);
452        assert_eq!(l.begin, physical(Alignment::CENTER_LEFT));
453        assert_eq!(l.end, physical(Alignment::CENTER_RIGHT));
454        let r = RadialGradient::new(vec![RED, BLUE]);
455        assert_eq!((r.radius, r.focal, r.focal_radius), (0.5, None, 0.0));
456        let s = SweepGradient::new(vec![RED, BLUE]);
457        assert_eq!((s.start_angle, s.end_angle), (0.0, std::f32::consts::TAU));
458        assert_eq!(l.tile_mode, TileMode::Clamp);
459    }
460
461    /// **Two of a kind travel point by point and colour by colour; a gradient on one side
462    /// only fades.**
463    #[test]
464    fn gradients_lerp_as_the_reference_s() {
465        let a = Gradient::from(LinearGradient::new(vec![RED, RED]));
466        let b = Gradient::from(
467            LinearGradient::new(vec![BLUE, BLUE])
468                .between(Alignment::TOP_CENTER, Alignment::BOTTOM_CENTER),
469        );
470        let mid = Gradient::lerp(Some(&a), Some(&b), 0.5).unwrap();
471        match &mid {
472            Gradient::Linear(g) => {
473                assert_eq!(g.colors[0], RED.lerp(BLUE, 0.5));
474                assert_eq!(g.begin, physical(Alignment::new(-0.5, -0.5)));
475            }
476            other => panic!("{other:?}"),
477        }
478        let fading = Gradient::lerp(Some(&a), None, 0.25).unwrap();
479        assert!((fading.colors()[0].a - 0.75).abs() < 1e-6);
480        // Of different kinds: one out, then the other in.
481        let sweep = Gradient::from(SweepGradient::new(vec![GREEN, GREEN]));
482        assert!(matches!(
483            Gradient::lerp(Some(&a), Some(&sweep), 0.25),
484            Some(Gradient::Linear(_))
485        ));
486        assert!(matches!(
487            Gradient::lerp(Some(&a), Some(&sweep), 0.75),
488            Some(Gradient::Sweep(_))
489        ));
490    }
491
492    /// **A gradient painted at an opacity carries it in its colours**, as a fill does: a
493    /// decoration fading in or out fades its gradient too.
494    #[test]
495    fn a_faded_gradient_rectangle_carries_the_fade() {
496        let mut scene = crate::Scene::new();
497        scene.shaded_rect(
498            crate::Rect::new(0.0, 0.0, 10.0, 10.0),
499            Gradient::from(LinearGradient::new(vec![RED, BLUE])),
500            0.5,
501            0.0,
502            0.0,
503            Color::TRANSPARENT,
504        );
505        match &scene.primitives()[0] {
506            crate::Primitive::Rect {
507                shader: Some(g),
508                color,
509                ..
510            } => {
511                assert_eq!(g.colors()[1].a, 0.5);
512                assert_eq!(
513                    *color,
514                    RED.fade(0.5),
515                    "the first colour, for what reads a colour"
516                );
517            }
518            other => panic!("{other:?}"),
519        }
520    }
521}