sequoia-draw 0.1.1

Sequoia GUI framework — vector paths and rasterization
Documentation
use sequoia_core::{Color, Point, Vec2};

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GradientStop {
    pub at: f32,

    pub color: Color,
}

impl GradientStop {
    pub fn new(at: f32, color: Color) -> Self {
        Self { at, color }
    }
}

#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Gradient {
    Linear {
        from: Point,

        to: Point,

        stops: Vec<GradientStop>,
    },

    Radial {
        center: Point,

        radius: f32,

        stops: Vec<GradientStop>,
    },
}

impl Gradient {
    pub fn linear(
        from: impl Into<Point>,
        to: impl Into<Point>,
        stops: impl Into<Vec<GradientStop>>,
    ) -> Self {
        Self::Linear {
            from: from.into(),
            to: to.into(),
            stops: sorted(stops.into()),
        }
    }

    pub fn radial(
        center: impl Into<Point>,
        radius: f32,
        stops: impl Into<Vec<GradientStop>>,
    ) -> Self {
        Self::Radial {
            center: center.into(),
            radius,
            stops: sorted(stops.into()),
        }
    }

    pub fn stops(&self) -> &[GradientStop] {
        match self {
            Self::Linear { stops, .. } | Self::Radial { stops, .. } => stops,
        }
    }

    pub fn is_vertical(&self) -> bool {
        match self {
            Self::Linear { from, to, .. } => {
                let d = Vec2::new(to.x - from.x, to.y - from.y);
                d.y.abs() > d.x.abs()
            }
            Self::Radial { .. } => false,
        }
    }

    pub fn direction(&self) -> Vec2 {
        match self {
            Self::Linear { from, to, .. } => Vec2::new(to.x - from.x, to.y - from.y),
            Self::Radial { .. } => Vec2::ZERO,
        }
    }

    pub fn color_at(&self, p: Point) -> Color {
        let t = match self {
            Self::Linear { from, to, .. } => {
                let d = Vec2::new(to.x - from.x, to.y - from.y);
                let len2 = d.x * d.x + d.y * d.y;
                if len2 <= f32::MIN_POSITIVE {
                    0.0
                } else {
                    ((p.x - from.x) * d.x + (p.y - from.y) * d.y) / len2
                }
            }
            Self::Radial { center, radius, .. } => {
                if !(*radius > 0.0) || !radius.is_finite() {
                    0.0
                } else {
                    let d = Vec2::new(p.x - center.x, p.y - center.y);
                    (d.x * d.x + d.y * d.y).sqrt() / *radius
                }
            }
        };
        sample_stops(t, self.stops())
    }
}

fn sorted(mut stops: Vec<GradientStop>) -> Vec<GradientStop> {
    stops.sort_by(|a, b| a.at.partial_cmp(&b.at).unwrap_or(std::cmp::Ordering::Equal));
    stops
}

fn sample_stops(t: f32, stops: &[GradientStop]) -> Color {
    let Some(first) = stops.first() else {
        return Color::TRANSPARENT;
    };
    if t <= first.at || stops.len() == 1 {
        return first.color;
    }
    let last = stops[stops.len() - 1];
    if t >= last.at {
        return last.color;
    }
    for pair in stops.windows(2) {
        let (a, b) = (pair[0], pair[1]);
        if t <= b.at {
            let span = b.at - a.at;
            let k = if span <= f32::MIN_POSITIVE {
                0.0
            } else {
                (t - a.at) / span
            };
            return lerp_color(a.color, b.color, k);
        }
    }
    last.color
}

fn lerp_color(a: Color, b: Color, k: f32) -> Color {
    let k = k.clamp(0.0, 1.0);
    let mix = |x: f32, y: f32| x + (y - x) * k;
    Color {
        r: mix(a.r, b.r),
        g: mix(a.g, b.g),
        b: mix(a.b, b.b),
        a: mix(a.a, b.a),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn red() -> Color {
        Color::rgb8(255, 0, 0)
    }

    fn blue() -> Color {
        Color::rgb8(0, 0, 255)
    }

    #[test]
    fn linear_gradient_runs_along_the_axis() {
        let g = Gradient::linear(
            (0.0, 0.0),
            (100.0, 0.0),
            vec![
                GradientStop::new(0.0, red()),
                GradientStop::new(1.0, blue()),
            ],
        );
        assert_eq!(g.color_at(Point::new(0.0, 50.0)), red());
        assert_eq!(g.color_at(Point::new(100.0, 50.0)), blue());
        let mid = g.color_at(Point::new(50.0, 0.0));
        assert!(
            (mid.r - 0.5).abs() < 1e-6 && (mid.b - 0.5).abs() < 1e-6,
            "{mid:?}"
        );

        assert_eq!(g.color_at(Point::new(-40.0, 0.0)), red());
        assert_eq!(g.color_at(Point::new(400.0, 0.0)), blue());
        assert!(!g.is_vertical());
    }

    #[test]
    fn vertical_linear_gradient_is_detected() {
        let g = Gradient::linear(
            (0.0, 0.0),
            (0.0, 100.0),
            vec![
                GradientStop::new(0.0, red()),
                GradientStop::new(1.0, blue()),
            ],
        );
        assert!(g.is_vertical());
        let quarter = g.color_at(Point::new(80.0, 25.0));
        assert!((quarter.b - 0.25).abs() < 1e-6, "{quarter:?}");
    }

    #[test]
    fn radial_gradient_uses_distance() {
        let g = Gradient::radial(
            (50.0, 50.0),
            40.0,
            vec![
                GradientStop::new(0.0, red()),
                GradientStop::new(1.0, blue()),
            ],
        );
        assert_eq!(g.color_at(Point::new(50.0, 50.0)), red());
        assert_eq!(g.color_at(Point::new(90.0, 50.0)), blue());
        assert!(!g.is_vertical());
        let mid = g.color_at(Point::new(50.0, 70.0));
        assert!((mid.b - 0.5).abs() < 1e-6, "{mid:?}");

        let dot = Gradient::radial((0.0, 0.0), 0.0, vec![GradientStop::new(0.0, red())]);
        assert_eq!(dot.color_at(Point::new(10.0, 10.0)), red());
    }

    #[test]
    fn stops_are_sorted_and_three_stops_interpolate() {
        let g = Gradient::linear(
            (0.0, 0.0),
            (100.0, 0.0),
            vec![
                GradientStop::new(1.0, blue()),
                GradientStop::new(0.0, red()),
                GradientStop::new(0.5, Color::rgb8(0, 255, 0)),
            ],
        );
        let at = g.stops().iter().map(|s| s.at).collect::<Vec<_>>();
        assert_eq!(at, vec![0.0, 0.5, 1.0], "input order does not matter");
        let mid = g.color_at(Point::new(25.0, 0.0));
        assert!(
            (mid.g - 0.5).abs() < 1e-6 && (mid.r - 0.5).abs() < 1e-6,
            "{mid:?}"
        );
    }

    #[test]
    fn empty_gradient_is_transparent() {
        let g = Gradient::linear((0.0, 0.0), (10.0, 0.0), Vec::new());
        assert_eq!(g.color_at(Point::new(5.0, 0.0)), Color::TRANSPARENT);
    }

    #[test]
    fn alpha_interpolates_too() {
        let g = Gradient::linear(
            (0.0, 0.0),
            (10.0, 0.0),
            vec![
                GradientStop::new(0.0, Color { a: 0.0, ..red() }),
                GradientStop::new(1.0, Color { a: 1.0, ..red() }),
            ],
        );
        assert!((g.color_at(Point::new(5.0, 0.0)).a - 0.5).abs() < 1e-6);
    }
}