Skip to main content

kui_core/
gradient.rs

1//! Gradients: what a box's `gradient` row paints
2//! (`docs/adr/0042-a-gradient-is-an-image-the-core-paints.md`).
3//!
4//! A gradient is a shape — a direction, or a centre — and two or more
5//! stops, defined on the box's **unit square** and stretched to the box.
6//! The core rasterizes it once into a small RGBA image in the atlas, keyed
7//! by what it is and not by where it is drawn, and the box paints it with
8//! one `Image` quad: a strip for a gradient along an axis, a square for
9//! any other. So a renderer that draws an image draws a gradient, and a
10//! box that resizes, or a thousand boxes that share one, rasterize
11//! nothing.
12//!
13//! ```rust
14//! use kui_core::{Color, Gradient, Side};
15//!
16//! let g = Gradient::to(Side::Bottom, [Color::hex(0x1e2030ff), Color::hex(0x14161eff)]);
17//! assert_eq!(g.raster_size(), (1, 256));
18//! ```
19
20use crate::color::Color;
21use crate::geom::Vec2;
22use crate::value::Value;
23
24/// One stop: a colour, and where along the gradient it sits, 0 to 1. A
25/// stop with no position is spaced evenly between its neighbours that
26/// have one, the first defaulting to 0 and the last to 1, as CSS spaces
27/// them.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct Stop {
30    pub color: Color,
31    pub at: Option<f32>,
32}
33
34impl From<Color> for Stop {
35    fn from(color: Color) -> Self {
36        Stop { color, at: None }
37    }
38}
39
40impl From<(Color, f32)> for Stop {
41    fn from((color, at): (Color, f32)) -> Self {
42        Stop {
43            color,
44            at: Some(at),
45        }
46    }
47}
48
49/// Where a linear gradient runs to: a side or a corner of the box.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum Side {
52    Right,
53    BottomRight,
54    Bottom,
55    BottomLeft,
56    Left,
57    TopLeft,
58    Top,
59    TopRight,
60}
61
62impl Side {
63    /// Every side with its spelling, clockwise from east — an eighth of a
64    /// turn apart, which is what [`Self::turns`] reads off.
65    pub const ALL: [(Side, &'static str); 8] = [
66        (Side::Right, "right"),
67        (Side::BottomRight, "bottom right"),
68        (Side::Bottom, "bottom"),
69        (Side::BottomLeft, "bottom left"),
70        (Side::Left, "left"),
71        (Side::TopLeft, "top left"),
72        (Side::Top, "top"),
73        (Side::TopRight, "top right"),
74    ];
75
76    /// The side as an angle: turns clockwise from east.
77    pub fn turns(self) -> f32 {
78        self as u8 as f32 / 8.0
79    }
80
81    /// `"bottom"`, `"top left"` — or the corner's words the other way
82    /// round, as CSS takes them.
83    pub fn parse(s: &str) -> Option<Side> {
84        let mut words: Vec<&str> = s.split_whitespace().collect();
85        words.sort_unstable();
86        Side::ALL
87            .iter()
88            .find(|(_, name)| {
89                let mut want: Vec<&str> = name.split(' ').collect();
90                want.sort_unstable();
91                want == words
92            })
93            .map(|&(side, _)| side)
94    }
95}
96
97/// What a gradient runs along.
98#[derive(Clone, Copy, Debug, PartialEq)]
99enum Shape {
100    /// Along a direction — a unit vector, clockwise from east with y
101    /// down — measured in the unit square: 0 at the corner it leaves, 1
102    /// at the corner it reaches.
103    Linear { dx: f32, dy: f32 },
104    /// Out from a centre (fractions of the box) to the farthest corner:
105    /// an ellipse once the square is stretched to the box.
106    Radial { at: Vec2 },
107}
108
109/// A box's gradient: see the module's notes. Built by [`Gradient::to`],
110/// [`Gradient::angle`] or [`Gradient::radial`], or read from plain data by
111/// [`parse_with`].
112#[derive(Clone, Debug, PartialEq)]
113pub struct Gradient {
114    shape: Shape,
115    /// The stops with every position resolved, in order.
116    stops: Stops,
117    /// The shape and the stops as one number: the atlas slot's key.
118    key: u64,
119}
120
121/// A gradient's stops: up to four in place — nearly every gradient a
122/// view declares, and declares again every frame, so they cost it no
123/// allocation — and a list past that.
124#[derive(Clone, Debug)]
125enum Stops {
126    Few([(Color, f32); 4], u8),
127    Many(Vec<(Color, f32)>),
128}
129
130impl Stops {
131    fn push(&mut self, stop: (Color, f32)) {
132        match self {
133            Stops::Few(held, n) if (*n as usize) < held.len() => {
134                held[*n as usize] = stop;
135                *n += 1;
136            }
137            Stops::Few(held, _) => {
138                let mut list = Vec::with_capacity(16);
139                list.extend_from_slice(held);
140                list.push(stop);
141                *self = Stops::Many(list);
142            }
143            Stops::Many(list) => list.push(stop),
144        }
145    }
146}
147
148impl std::ops::Deref for Stops {
149    type Target = [(Color, f32)];
150    fn deref(&self) -> &Self::Target {
151        match self {
152            Stops::Few(held, n) => &held[..*n as usize],
153            Stops::Many(list) => list,
154        }
155    }
156}
157
158impl std::ops::DerefMut for Stops {
159    fn deref_mut(&mut self) -> &mut Self::Target {
160        match self {
161            Stops::Few(held, n) => &mut held[..*n as usize],
162            Stops::Many(list) => list,
163        }
164    }
165}
166
167impl PartialEq for Stops {
168    fn eq(&self, other: &Self) -> bool {
169        **self == **other
170    }
171}
172
173/// Texels along a strip, for a gradient along an axis.
174pub const STRIP: u32 = 256;
175/// Texels on a side of the square every other gradient is drawn into.
176pub const SQUARE: u32 = 128;
177
178impl Gradient {
179    /// A linear gradient towards a side or a corner of the box. To a
180    /// corner the half-way line runs through the other two corners at any
181    /// aspect, which is CSS's `to bottom right`.
182    pub fn to<S: Into<Stop>>(side: Side, stops: impl IntoIterator<Item = S>) -> Self {
183        Self::angle(side.turns(), stops)
184    }
185
186    /// A linear gradient along `turns`, clockwise from east (0.25 is
187    /// downwards), measured in the box's unit square — so an eighth of a
188    /// turn runs corner to corner whatever the box's aspect, which CSS's
189    /// `45deg`, measured in pixels, does not.
190    pub fn angle<S: Into<Stop>>(turns: f32, stops: impl IntoIterator<Item = S>) -> Self {
191        let (dx, dy) = direction(turns);
192        Self::new(Shape::Linear { dx, dy }, stops)
193    }
194
195    /// A radial gradient from the middle of the box out to its corners.
196    pub fn radial<S: Into<Stop>>(stops: impl IntoIterator<Item = S>) -> Self {
197        Self::radial_at(Vec2::new(0.5, 0.5), stops)
198    }
199
200    /// A radial gradient from `at` — fractions of the box, `(0.5, 0)` the
201    /// middle of its top edge — out to the corner farthest from it.
202    pub fn radial_at<S: Into<Stop>>(at: Vec2, stops: impl IntoIterator<Item = S>) -> Self {
203        Self::new(Shape::Radial { at }, stops)
204    }
205
206    fn new<S: Into<Stop>>(shape: Shape, stops: impl IntoIterator<Item = S>) -> Self {
207        // One list, built once: a stop with no position waits as a NaN
208        // for `resolve` to place it.
209        let mut list = Stops::Few([(Color::TRANSPARENT, 0.0); 4], 0);
210        for s in stops {
211            let s: Stop = s.into();
212            let at = s.at.filter(|a| a.is_finite()).map(|a| a.clamp(0.0, 1.0));
213            list.push((s.color, at.unwrap_or(f32::NAN)));
214        }
215        let mut stops = list;
216        resolve(&mut stops);
217        let mut g = Gradient {
218            shape,
219            stops,
220            key: 0,
221        };
222        g.key = g.hash();
223        g
224    }
225
226    /// Whether there is anything to paint: two stops or more, and a shape
227    /// made of numbers.
228    pub fn is_drawable(&self) -> bool {
229        let shape = match self.shape {
230            Shape::Linear { dx, dy } => dx.is_finite() && dy.is_finite(),
231            Shape::Radial { at } => at.x.is_finite() && at.y.is_finite(),
232        };
233        shape && self.stops.len() >= 2
234    }
235
236    /// The key its raster is held under: equal for equal gradients,
237    /// whatever box draws them.
238    pub fn key(&self) -> u64 {
239        self.key
240    }
241
242    /// The stops as resolved: a colour and its position, in order.
243    pub fn stops(&self) -> &[(Color, f32)] {
244        &self.stops
245    }
246
247    fn hash(&self) -> u64 {
248        const PRIME: u64 = 0x0000_0100_0000_01b3;
249        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
250        let mut mix = |v: u32| {
251            h ^= u64::from(v);
252            h = h.wrapping_mul(PRIME);
253        };
254        match self.shape {
255            // The direction and not the angle, so a whole turn more is
256            // the same key.
257            Shape::Linear { dx, dy } => {
258                mix(1);
259                mix(dx.to_bits());
260                mix(dy.to_bits());
261            }
262            Shape::Radial { at } => {
263                mix(2);
264                mix(at.x.to_bits());
265                mix(at.y.to_bits());
266            }
267        }
268        for (c, at) in self.stops.iter() {
269            for lane in c.lanes() {
270                mix(lane.to_bits());
271            }
272            mix(at.to_bits());
273        }
274        h
275    }
276
277    /// The raster's size in texels: a strip along the axis the gradient
278    /// runs on, or the square.
279    pub fn raster_size(&self) -> (u32, u32) {
280        match self.shape {
281            Shape::Linear { dy: 0.0, .. } => (STRIP, 1),
282            Shape::Linear { dx: 0.0, .. } => (1, STRIP),
283            Shape::Linear { .. } => (SQUARE, SQUARE),
284            Shape::Radial { .. } => (SQUARE, SQUARE),
285        }
286    }
287
288    /// The texels its slot takes: [`Self::raster_size`] and a gutter of
289    /// one all round.
290    pub fn slot_size(&self) -> (u32, u32) {
291        let (w, h) = self.raster_size();
292        (w + 2, h + 2)
293    }
294
295    /// The raster: [`Self::slot_size`] texels of straight RGBA, row after
296    /// row, each the gradient at its texel's centre. The quad draws the
297    /// inner [`Self::raster_size`] of them; the gutter is the gradient
298    /// carried on a texel past each edge, because the quad is stretched
299    /// and sampled linearly, and at the box's edge the sampler reads half
300    /// a texel past the rect it was given — a strip one texel high would
301    /// otherwise fade into whatever the atlas holds above and below it.
302    pub fn rasterize(&self) -> Vec<u8> {
303        let (w, h) = self.raster_size();
304        let mut out = Vec::with_capacity(((w + 2) * (h + 2) * 4) as usize);
305        if w.min(h) == 1 {
306            // A strip: each texel its own mix.
307            for row in 0..h + 2 {
308                let y = (row as f32 - 0.5) / h as f32;
309                for col in 0..w + 2 {
310                    let x = (col as f32 - 0.5) / w as f32;
311                    out.extend_from_slice(&bytes(self.color_at(self.along(x, y))));
312                }
313            }
314            return out;
315        }
316        // A square: seventeen thousand texels of at most a few hundred
317        // different colours. The ramp is mixed once, finely, and each
318        // texel reads it — a quarter of an 8-bit level off at the most.
319        const RAMP: usize = 1024;
320        let ramp: Vec<[u8; 4]> = (0..=RAMP)
321            .map(|i| bytes(self.color_at(i as f32 / RAMP as f32)))
322            .collect();
323        for row in 0..h + 2 {
324            let y = (row as f32 - 0.5) / h as f32;
325            for col in 0..w + 2 {
326                let x = (col as f32 - 0.5) / w as f32;
327                let t = self.along(x, y).clamp(0.0, 1.0);
328                out.extend_from_slice(&ramp[(t * RAMP as f32 + 0.5) as usize]);
329            }
330        }
331        out
332    }
333
334    /// The colour at a point of the box, as fractions of it: `(0, 0)`
335    /// its top left, `(1, 1)` its bottom right. What the raster samples,
336    /// and what a test of the stretched raster compares it with.
337    pub fn color_in(&self, x: f32, y: f32) -> Color {
338        self.color_at(self.along(x, y))
339    }
340
341    /// How far along the gradient a point of the box is.
342    #[inline]
343    fn along(&self, x: f32, y: f32) -> f32 {
344        match self.shape {
345            Shape::Linear { dx, dy } => {
346                // The corners the direction leaves and reaches are this
347                // far apart along it, in the unit square.
348                let len = dx.abs() + dy.abs();
349                ((x - 0.5) * dx + (y - 0.5) * dy) / len + 0.5
350            }
351            Shape::Radial { at } => {
352                let far = (at.x.max(1.0 - at.x)).hypot(at.y.max(1.0 - at.y));
353                let (rx, ry) = (x - at.x, y - at.y);
354                (rx * rx + ry * ry).sqrt() / if far > 0.0 { far } else { 1.0 }
355            }
356        }
357    }
358
359    /// The colour `t` of the way along: the stops either side mixed in
360    /// straight sRGB with the alpha premultiplied, as CSS mixes them, so
361    /// a fade to transparent does not pass through grey. Before the first
362    /// stop it is the first, past the last the last.
363    pub fn color_at(&self, t: f32) -> Color {
364        let stops = &self.stops;
365        let Some(&(first, first_at)) = stops.first() else {
366            return Color::TRANSPARENT;
367        };
368        if t.is_nan() || t <= first_at {
369            return first;
370        }
371        for pair in stops.windows(2) {
372            let ((a, a_at), (b, b_at)) = (pair[0], pair[1]);
373            if t <= b_at {
374                let span = b_at - a_at;
375                let u = if span > 0.0 { (t - a_at) / span } else { 1.0 };
376                let alpha = a.a + (b.a - a.a) * u;
377                if alpha <= 0.0 {
378                    // Nothing shows; keep a colour the sampler can mix
379                    // towards without darkening the texel beside it.
380                    return Color {
381                        a: 0.0,
382                        ..a.lerp(b, u)
383                    };
384                }
385                let lane = |a_c: f32, b_c: f32| (a_c * a.a + (b_c * b.a - a_c * a.a) * u) / alpha;
386                return Color {
387                    r: lane(a.r, b.r),
388                    g: lane(a.g, b.g),
389                    b: lane(a.b, b.b),
390                    a: alpha,
391                };
392            }
393        }
394        stops[stops.len() - 1].0
395    }
396}
397
398fn bytes(c: Color) -> [u8; 4] {
399    [c.r, c.g, c.b, c.a].map(|lane| (lane.clamp(0.0, 1.0) * 255.0).round() as u8)
400}
401
402/// The unit vector `turns` clockwise from east. An eighth of a turn is
403/// read off a table — the sides and corners, exactly on their axes and
404/// diagonals and with no trigonometry, which is every gradient a `to`
405/// spells — and anything else is its cosine and sine.
406fn direction(turns: f32) -> (f32, f32) {
407    const D: f32 = std::f32::consts::FRAC_1_SQRT_2;
408    const EIGHTHS: [(f32, f32); 8] = [
409        (1.0, 0.0),
410        (D, D),
411        (0.0, 1.0),
412        (-D, D),
413        (-1.0, 0.0),
414        (-D, -D),
415        (0.0, -1.0),
416        (D, -D),
417    ];
418    let eighths = turns.rem_euclid(1.0) * 8.0;
419    if eighths == eighths.round() {
420        return EIGHTHS[eighths as usize & 7];
421    }
422    let a = eighths * (std::f32::consts::TAU / 8.0);
423    (a.cos(), a.sin())
424}
425
426/// Fills in the positions `Gradient::new` left as NaN: a first with none
427/// is 0, a last with none 1, a run with none is spaced evenly between its
428/// neighbours, and a position behind the one before it is raised to it.
429fn resolve(stops: &mut [(Color, f32)]) {
430    let n = stops.len();
431    if n == 0 {
432        return;
433    }
434    if stops[0].1.is_nan() {
435        stops[0].1 = 0.0;
436    }
437    if stops[n - 1].1.is_nan() {
438        stops[n - 1].1 = 1.0;
439    }
440    let mut floor = 0.0f32;
441    for (_, at) in stops.iter_mut().filter(|s| !s.1.is_nan()) {
442        floor = floor.max(*at);
443        *at = floor;
444    }
445    let mut i = 0;
446    while i + 1 < n {
447        // The next placed stop; the unplaced ones between are spaced
448        // evenly up to it.
449        let j = (i + 1..n).find(|&j| !stops[j].1.is_nan()).unwrap_or(n - 1);
450        let (here, there) = (stops[i].1, stops[j].1);
451        for (n, stop) in stops[i + 1..j].iter_mut().enumerate() {
452            stop.1 = here + (there - here) * ((n + 1) as f32 / (j - i) as f32);
453        }
454        i = j;
455    }
456}
457
458/// Reads a gradient from plain data, the form every binding carries:
459///
460/// - `{ to = "bottom", stops = {…} }` — a side or a corner ([`Side`]);
461/// - `{ angle = 0.125, stops = {…} }` — turns clockwise from east;
462/// - `{ radial = true, at = {0.5, 0}, stops = {…} }` — `at` optional.
463///
464/// No `to`, `angle` or `radial` is `to = "bottom"`. A stop is a colour —
465/// `0xRRGGBBAA`, `"#hex"`, or with `refs` a `$name` — or a
466/// `{colour, position}` pair. A `$name` that misses is remembered on the
467/// refs for the binding to raise, and its stop is left out.
468pub fn parse_with(
469    v: &Value,
470    mut refs: Option<&mut crate::tokens::NameRefs<'_>>,
471) -> Result<Gradient, String> {
472    let Value::Map(fields) = v else {
473        return Err("gradient must be an object with stops".into());
474    };
475    let num = |v: &Value, what: &str| {
476        v.as_float()
477            .map(|n| n as f32)
478            .filter(|n| n.is_finite())
479            .ok_or_else(|| format!("gradient: {what} must be a number"))
480    };
481    let (mut to, mut angle, mut radial, mut at, mut stops) = (None, None, false, None, None);
482    for (k, v) in fields {
483        match k.as_str() {
484            "to" => {
485                let side = v.as_str().and_then(Side::parse).ok_or_else(|| {
486                    "gradient: `to` is a side or a corner (\"bottom\", \"top right\", …)"
487                        .to_string()
488                })?;
489                to = Some(side);
490            }
491            "angle" => angle = Some(num(v, "`angle`")?),
492            "radial" => {
493                radial = v
494                    .as_bool()
495                    .ok_or("gradient: `radial` must be true or false")?
496            }
497            "at" => match v.as_list() {
498                Some([x, y]) => at = Some(Vec2::new(num(x, "`at`")?, num(y, "`at`")?)),
499                _ => return Err("gradient: `at` is an [x, y] pair".into()),
500            },
501            "stops" => stops = v.as_list(),
502            other => return Err(format!("gradient: unknown field `{other}`")),
503        }
504    }
505    let Some(list) = stops else {
506        return Err("gradient needs `stops`, a list of colours".into());
507    };
508    let mut color = |v: &Value, i: usize| -> Result<Option<Color>, String> {
509        if let Some(hit) = refs.as_deref_mut().and_then(|r| r.color_ref(v)) {
510            return Ok(hit);
511        }
512        crate::slots::color_value(v)
513            .map(Some)
514            .map_err(|e| format!("gradient stop {i}: {e}"))
515    };
516    let mut out = Vec::with_capacity(list.len());
517    for (i, stop) in list.iter().enumerate() {
518        let (c, at) = match stop {
519            Value::List(pair) => match pair.as_slice() {
520                [c, at] => (
521                    color(c, i)?,
522                    Some(num(at, &format!("stop {i}'s position"))?),
523                ),
524                _ => {
525                    return Err(format!(
526                        "gradient stop {i}: a colour or a [colour, at] pair"
527                    ));
528                }
529            },
530            c => (color(c, i)?, None),
531        };
532        if let Some(color) = c {
533            out.push(Stop { color, at });
534        }
535    }
536    if list.len() < 2 {
537        return Err("gradient needs two stops or more".into());
538    }
539    let linear = |turns: f32| {
540        let (dx, dy) = direction(turns);
541        Shape::Linear { dx, dy }
542    };
543    let shape = match (radial, to, angle) {
544        (true, None, None) => Shape::Radial {
545            at: at.unwrap_or(Vec2::new(0.5, 0.5)),
546        },
547        (false, Some(side), None) => linear(side.turns()),
548        (false, None, Some(turns)) => linear(turns),
549        (false, None, None) => linear(Side::Bottom.turns()),
550        _ => return Err("gradient: one of `to`, `angle` and `radial`".into()),
551    };
552    if at.is_some() && !radial {
553        return Err("gradient: `at` is a radial gradient's centre".into());
554    }
555    Ok(Gradient::new(shape, out))
556}
557
558/// [`parse_with`] with no token lookup: a `$name` is an error.
559pub fn parse(v: &Value) -> Result<Gradient, String> {
560    parse_with(v, None)
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    const RED: Color = Color {
568        r: 1.0,
569        g: 0.0,
570        b: 0.0,
571        a: 1.0,
572    };
573    const BLUE: Color = Color {
574        r: 0.0,
575        g: 0.0,
576        b: 1.0,
577        a: 1.0,
578    };
579
580    fn texel(g: &Gradient, x: u32, y: u32) -> [u8; 4] {
581        // Past the gutter.
582        let (w, _) = g.slot_size();
583        let px = g.rasterize();
584        let i = (((y + 1) * w + x + 1) * 4) as usize;
585        [px[i], px[i + 1], px[i + 2], px[i + 3]]
586    }
587
588    #[test]
589    fn a_side_is_a_strip_and_runs_the_way_it_says() {
590        let right = Gradient::to(Side::Right, [RED, BLUE]);
591        assert_eq!(right.raster_size(), (STRIP, 1));
592        assert_eq!(texel(&right, 0, 0), [255, 0, 0, 255]);
593        assert_eq!(texel(&right, 255, 0), [0, 0, 255, 255]);
594        assert_eq!(texel(&right, 128, 0), [127, 0, 128, 255]);
595        let left = Gradient::to(Side::Left, [RED, BLUE]);
596        assert_eq!(left.raster_size(), (STRIP, 1));
597        assert_eq!(texel(&left, 0, 0), [0, 0, 255, 255]);
598        let up = Gradient::to(Side::Top, [RED, BLUE]);
599        assert_eq!(up.raster_size(), (1, STRIP));
600        assert_eq!(texel(&up, 0, 255), [255, 0, 0, 255]);
601        // A whole turn on is the same gradient.
602        assert_eq!(
603            Gradient::angle(1.25, [RED, BLUE]).key(),
604            Gradient::to(Side::Bottom, [RED, BLUE]).key()
605        );
606    }
607
608    /// To a corner: red where it leaves, blue where it arrives, and the
609    /// half-way line through the other two corners.
610    #[test]
611    fn a_corner_runs_corner_to_corner_in_the_unit_square() {
612        let g = Gradient::to(Side::BottomRight, [RED, BLUE]);
613        assert_eq!(g.raster_size(), (SQUARE, SQUARE));
614        let n = SQUARE - 1;
615        let [r, _, b, _] = texel(&g, 0, 0);
616        assert!(r > 250 && b < 5, "{r} {b}");
617        let [r, _, b, _] = texel(&g, n, n);
618        assert!(r < 5 && b > 250, "{r} {b}");
619        for (x, y) in [(n, 0), (0, n)] {
620            let [r, _, b, _] = texel(&g, x, y);
621            assert!(r.abs_diff(b) <= 1, "{r} {b}");
622        }
623    }
624
625    #[test]
626    fn a_radial_reaches_its_farthest_corner() {
627        let g = Gradient::radial([RED, BLUE]);
628        let mid = SQUARE / 2;
629        let [r, _, b, _] = texel(&g, mid, mid);
630        assert!(r > 250 && b < 5);
631        let [r, _, b, _] = texel(&g, 0, 0);
632        assert!(r < 5 && b > 250);
633        // From the top edge the bottom corners are the far ones.
634        let top = Gradient::radial_at(Vec2::new(0.5, 0.0), [RED, BLUE]);
635        let [r, _, b, _] = texel(&top, 0, SQUARE - 1);
636        assert!(r < 5 && b > 250);
637        assert_ne!(top.key(), g.key());
638    }
639
640    #[test]
641    fn stops_without_a_position_are_spaced_between_those_with() {
642        let g = Gradient::to(
643            Side::Right,
644            [
645                Stop::from(RED),
646                Stop::from(BLUE),
647                Stop::from((RED, 0.5)),
648                Stop::from(BLUE),
649                Stop::from(RED),
650            ],
651        );
652        let at: Vec<f32> = g.stops().iter().map(|s| s.1).collect();
653        assert_eq!(at, [0.0, 0.25, 0.5, 0.75, 1.0]);
654        // One behind the stop before it is raised to it: a hard edge.
655        let g = Gradient::to(Side::Right, [(RED, 0.6), (BLUE, 0.4)]);
656        let at: Vec<f32> = g.stops().iter().map(|s| s.1).collect();
657        assert_eq!(at, [0.6, 0.6]);
658        assert_eq!(g.color_at(0.5), RED);
659        assert_eq!(g.color_at(0.7), BLUE);
660    }
661
662    /// Red fading out: half-way it is still red, at half the alpha — not
663    /// the dark red a straight mix with transparent black would give.
664    #[test]
665    fn a_fade_to_transparent_keeps_its_colour() {
666        let g = Gradient::to(Side::Right, [RED, Color::TRANSPARENT]);
667        let c = g.color_at(0.5);
668        assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 0.5));
669    }
670
671    #[test]
672    fn one_stop_or_a_number_that_is_not_one_draws_nothing() {
673        assert!(!Gradient::to(Side::Right, [RED]).is_drawable());
674        assert!(!Gradient::angle(f32::NAN, [RED, BLUE]).is_drawable());
675        assert!(Gradient::to(Side::Right, [RED, BLUE]).is_drawable());
676    }
677
678    #[test]
679    fn plain_data_reads_as_the_builders_do() {
680        let stops = || Value::list([Value::str("#ff0000"), Value::Int(0x0000ffff)]);
681        let g = parse(&Value::map([
682            ("to", Value::str("right bottom")),
683            ("stops", stops()),
684        ]));
685        assert_eq!(g, Ok(Gradient::to(Side::BottomRight, [RED, BLUE])));
686        let g = parse(&Value::map([("stops", stops())]));
687        assert_eq!(g, Ok(Gradient::to(Side::Bottom, [RED, BLUE])));
688        let g = parse(&Value::map([
689            ("radial", Value::Bool(true)),
690            ("at", Value::floats(&[0.5, 0.0])),
691            (
692                "stops",
693                Value::list([
694                    Value::str("#ff0000"),
695                    Value::list([Value::str("#0000ff"), Value::float(0.8)]),
696                ]),
697            ),
698        ]));
699        assert_eq!(
700            g,
701            Ok(Gradient::radial_at(
702                Vec2::new(0.5, 0.0),
703                [Stop::from(RED), Stop::from((BLUE, 0.8))]
704            ))
705        );
706        let bad = |v: Value| parse(&v).unwrap_err();
707        assert!(bad(Value::map([("to", Value::str("up")), ("stops", stops())])).contains("side"));
708        assert!(
709            bad(Value::map([("stops", Value::list([Value::str("#fff")]))])).contains("two stops")
710        );
711        assert!(bad(Value::map([("angle", Value::float(0.1))])).contains("needs `stops`"));
712        // RG117: it read as "not radial" and drew a linear one.
713        assert!(
714            bad(Value::map([
715                ("radial", Value::str("yes")),
716                ("stops", stops())
717            ]))
718            .contains("true or false")
719        );
720        assert!(
721            bad(Value::map([
722                ("angle", Value::float(0.1)),
723                ("radial", Value::Bool(true)),
724                ("stops", stops())
725            ]))
726            .contains("one of")
727        );
728    }
729}