sequoia-core 0.1.1

Sequoia GUI framework — core types (geometry, color, events)
Documentation
#![forbid(unsafe_code)]

use crate::paint::Color;

#[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, Default, PartialEq)]
pub struct GradientStops {
    stops: Vec<GradientStop>,
}

pub const MAX_GRADIENT_STOPS: usize = 4;

impl GradientStops {
    pub fn new(mut stops: Vec<GradientStop>) -> Self {
        for s in &mut stops {
            s.at = if s.at.is_finite() {
                s.at.clamp(0.0, 1.0)
            } else {
                0.0
            };
        }
        stops.sort_by(|a, b| a.at.partial_cmp(&b.at).unwrap_or(std::cmp::Ordering::Equal));

        let mut out: Vec<GradientStop> = Vec::with_capacity(stops.len());
        for s in stops {
            if let Some(last) = out.last_mut()
                && (last.at - s.at).abs() < f32::EPSILON
            {
                *last = s;
                continue;
            }
            out.push(s);
        }
        Self { stops: out }
    }

    pub fn solid(color: Color) -> Self {
        Self {
            stops: vec![GradientStop::new(0.0, color)],
        }
    }

    pub fn two(from: Color, to: Color) -> Self {
        Self::new(vec![
            GradientStop::new(0.0, from),
            GradientStop::new(1.0, to),
        ])
    }

    pub fn is_empty(&self) -> bool {
        self.stops.is_empty()
    }

    pub fn len(&self) -> usize {
        self.stops.len()
    }

    pub fn stops(&self) -> &[GradientStop] {
        &self.stops
    }

    pub fn for_gpu(&self) -> Option<([[f32; 4]; MAX_GRADIENT_STOPS], [f32; 4], u32)> {
        if self.stops.is_empty() || self.stops.len() > MAX_GRADIENT_STOPS {
            return None;
        }
        let mut colors = [[0.0; 4]; MAX_GRADIENT_STOPS];
        let mut pos = [0.0; 4];
        for (i, s) in self.stops.iter().enumerate() {
            let c = s.color.to_linear();
            colors[i] = [c.r, c.g, c.b, c.a];
            pos[i] = s.at;
        }

        if self.stops.len() == 1 {
            for i in 1..MAX_GRADIENT_STOPS {
                colors[i] = colors[0];
                pos[i] = 1.0;
            }
        }
        Some((colors, pos, self.stops.len() as u32))
    }

    pub fn cache_key(&self) -> u64 {
        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
        let mut mix = |b: u8| {
            hash ^= u64::from(b);
            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
        };
        for s in &self.stops {
            for b in s.at.to_bits().to_le_bytes() {
                mix(b);
            }
            let (r, g, b, a) = s.color.to_rgba8();
            mix(r);
            mix(g);
            mix(b);
            mix(a);
        }
        hash ^= 0x9e37_79b9_7f4a_7c15;
        hash
    }

    pub fn sample_linear(&self, t: f32) -> [f32; 4] {
        let Some(first) = self.stops.first() else {
            return [0.0, 0.0, 0.0, 0.0];
        };
        if self.stops.len() == 1 || !t.is_finite() {
            let c = first.color.to_linear();
            return [c.r, c.g, c.b, c.a];
        }
        let t = t.clamp(0.0, 1.0);
        if t <= first.at {
            let c = first.color.to_linear();
            return [c.r, c.g, c.b, c.a];
        }
        let last = self.stops[self.stops.len() - 1];
        if t >= last.at {
            let c = last.color.to_linear();
            return [c.r, c.g, c.b, c.a];
        }
        for pair in self.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::EPSILON {
                    0.0
                } else {
                    ((t - a.at) / span).clamp(0.0, 1.0)
                };
                let ca = a.color.to_linear();
                let cb = b.color.to_linear();

                let mix = |x: f32, y: f32| x + (y - x) * k;
                return [
                    mix(ca.r, cb.r),
                    mix(ca.g, cb.g),
                    mix(ca.b, cb.b),
                    mix(ca.a, cb.a),
                ];
            }
        }
        let c = last.color.to_linear();
        [c.r, c.g, c.b, c.a]
    }

    pub fn bake_lut256(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(256 * 4);
        for i in 0..256 {
            let t = i as f32 / 255.0;
            let [r, g, b, a] = self.sample_linear(t);
            let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
            out.push(q(r));
            out.push(q(g));
            out.push(q(b));
            out.push(q(a));
        }
        out
    }
}

impl From<Vec<GradientStop>> for GradientStops {
    fn from(v: Vec<GradientStop>) -> Self {
        Self::new(v)
    }
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub enum GradientKind {
    Linear { angle_deg: f32 },
    Radial { radius: f32 },
}

impl GradientKind {
    pub fn horizontal() -> Self {
        Self::Linear { angle_deg: 0.0 }
    }

    pub fn vertical() -> Self {
        Self::Linear { angle_deg: 90.0 }
    }

    pub fn cover() -> Self {
        Self::Radial { radius: 0.0 }
    }

    pub fn direction(self) -> (f32, f32) {
        match self {
            Self::Linear { angle_deg } => {
                let a = if angle_deg.is_finite() {
                    angle_deg.to_radians()
                } else {
                    0.0
                };
                (a.cos(), a.sin())
            }
            Self::Radial { .. } => (0.0, 0.0),
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
pub enum Fill {
    Solid(Color),
    Gradient {
        kind: GradientKind,
        stops: GradientStops,
    },
}

impl Fill {
    pub fn solid(color: Color) -> Self {
        Self::Solid(color)
    }

    pub fn linear(stops: impl Into<GradientStops>, angle_deg: f32) -> Self {
        Self::Gradient {
            kind: GradientKind::Linear { angle_deg },
            stops: stops.into(),
        }
    }

    pub fn horizontal(stops: impl Into<GradientStops>) -> Self {
        Self::Gradient {
            kind: GradientKind::horizontal(),
            stops: stops.into(),
        }
    }

    pub fn vertical(stops: impl Into<GradientStops>) -> Self {
        Self::Gradient {
            kind: GradientKind::vertical(),
            stops: stops.into(),
        }
    }

    pub fn radial(stops: impl Into<GradientStops>) -> Self {
        Self::Gradient {
            kind: GradientKind::cover(),
            stops: stops.into(),
        }
    }

    pub fn radial_radius(stops: impl Into<GradientStops>, radius: f32) -> Self {
        let radius = if radius.is_finite() {
            radius.max(0.0)
        } else {
            0.0
        };
        Self::Gradient {
            kind: GradientKind::Radial { radius },
            stops: stops.into(),
        }
    }

    pub fn is_solid(&self) -> bool {
        matches!(self, Self::Solid(_))
    }

    pub fn is_visible(&self) -> bool {
        match self {
            Self::Solid(c) => c.a > 0.0,
            Self::Gradient { stops, .. } => !stops.is_empty(),
        }
    }
}

impl From<Color> for Fill {
    fn from(c: Color) -> Self {
        Self::Solid(c)
    }
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Stroke {
    pub width: f32,
    pub rounded: bool,
}

impl Stroke {
    pub fn new(width: f32) -> Self {
        Self {
            width: if width.is_finite() {
                width.max(0.0)
            } else {
                0.0
            },
            rounded: false,
        }
    }

    pub fn round(width: f32) -> Self {
        Self {
            width: if width.is_finite() {
                width.max(0.0)
            } else {
                0.0
            },
            rounded: true,
        }
    }

    pub fn is_visible(self) -> bool {
        self.width > 0.0
    }
}

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

    #[test]
    fn stops_sort_and_clamp() {
        let s = GradientStops::new(vec![
            GradientStop::new(1.5, Color::BLUE),
            GradientStop::new(-1.0, Color::RED),
            GradientStop::new(0.5, Color::GREEN),
        ]);
        let ats: Vec<f32> = s.stops().iter().map(|x| x.at).collect();
        assert_eq!(ats, vec![0.0, 0.5, 1.0]);
    }

    #[test]
    fn empty_is_transparent() {
        let s = GradientStops::new(vec![]);
        assert_eq!(s.sample_linear(0.5), [0.0, 0.0, 0.0, 0.0]);
        assert_eq!(s.bake_lut256().len(), 256 * 4);
    }

    #[test]
    fn linear_mid_is_not_dark() {
        let s = GradientStops::two(Color::RED, Color::BLUE);
        let mid = s.sample_linear(0.5);

        assert!((mid[0] - 0.5).abs() < 1e-5, "{mid:?}");
        assert!((mid[2] - 0.5).abs() < 1e-5, "{mid:?}");

        let gray = GradientStops::two(Color::WHITE, Color::BLACK).sample_linear(0.5);
        assert!((gray[0] - 0.5).abs() < 1e-5, "{gray:?}");
    }

    #[test]
    fn lut_roundtrip_ends() {
        let s = GradientStops::two(Color::RED, Color::BLUE);
        let lut = s.bake_lut256();
        assert_eq!(lut.len(), 1024);
        assert_eq!(&lut[0..4], &[255, 0, 0, 255]);
        assert_eq!(&lut[1020..1024], &[0, 0, 255, 255]);
    }

    #[test]
    fn cache_key_stable() {
        let a = GradientStops::two(Color::RED, Color::BLUE).cache_key();
        let b = GradientStops::two(Color::RED, Color::BLUE).cache_key();
        let c = GradientStops::two(Color::BLUE, Color::RED).cache_key();
        assert_eq!(a, b);
        assert_ne!(a, c);
    }
}