Skip to main content

oxiui_render_soft/
gradient.rs

1//! Linear and radial gradient fills with multiple colour stops (sRGB interpolation).
2
3use crate::clip::ClipRect;
4use crate::framebuffer::{pack_rgba, Framebuffer};
5use oxiui_core::Color;
6
7/// A single gradient colour stop at normalized `offset` in `[0, 1]`.
8#[derive(Clone, Copy, Debug)]
9pub struct GradientStop {
10    /// Position along the gradient axis, `0.0` = start, `1.0` = end.
11    pub offset: f32,
12    /// Colour at this offset.
13    pub color: Color,
14}
15
16impl GradientStop {
17    /// Construct a stop.
18    pub fn new(offset: f32, color: Color) -> Self {
19        Self {
20            offset: offset.clamp(0.0, 1.0),
21            color,
22        }
23    }
24}
25
26/// A linear gradient defined by an axis (`start` → `end`) and colour stops.
27#[derive(Clone, Debug)]
28pub struct LinearGradient {
29    /// Axis start point (logical pixels).
30    pub start: (f32, f32),
31    /// Axis end point (logical pixels).
32    pub end: (f32, f32),
33    /// Colour stops, sorted by ascending `offset`.
34    pub stops: Vec<GradientStop>,
35}
36
37impl LinearGradient {
38    /// Construct a two-stop gradient from `from` at `start` to `to` at `end`.
39    pub fn two_stop(start: (f32, f32), end: (f32, f32), from: Color, to: Color) -> Self {
40        Self {
41            start,
42            end,
43            stops: vec![GradientStop::new(0.0, from), GradientStop::new(1.0, to)],
44        }
45    }
46
47    /// Construct from arbitrary stops; they are sorted by offset internally.
48    pub fn new(start: (f32, f32), end: (f32, f32), mut stops: Vec<GradientStop>) -> Self {
49        stops.sort_by(|a, b| {
50            a.offset
51                .partial_cmp(&b.offset)
52                .unwrap_or(core::cmp::Ordering::Equal)
53        });
54        Self { start, end, stops }
55    }
56
57    /// Sample the gradient colour at normalized position `t` in `[0, 1]`.
58    pub fn sample(&self, t: f32) -> Color {
59        if self.stops.is_empty() {
60            return Color(0, 0, 0, 0);
61        }
62        let t = t.clamp(0.0, 1.0);
63        // Before first / after last stop.
64        if t <= self.stops[0].offset {
65            return self.stops[0].color;
66        }
67        let last = self.stops.len() - 1;
68        if t >= self.stops[last].offset {
69            return self.stops[last].color;
70        }
71        // Find the bracketing pair.
72        for w in self.stops.windows(2) {
73            let a = &w[0];
74            let b = &w[1];
75            if t >= a.offset && t <= b.offset {
76                let span = (b.offset - a.offset).max(f32::EPSILON);
77                let local = (t - a.offset) / span;
78                return lerp_color(&a.color, &b.color, local);
79            }
80        }
81        self.stops[last].color
82    }
83
84    /// Fill the rectangle `[x, x+w) × [y, y+h)` of `fb` with this gradient,
85    /// clipped to `clip`. Each pixel is projected onto the gradient axis.
86    pub fn fill_rect(&self, fb: &mut Framebuffer, clip: &ClipRect, x: f32, y: f32, w: f32, h: f32) {
87        if w <= 0.0 || h <= 0.0 {
88            return;
89        }
90        let (sx, sy) = self.start;
91        let (ex, ey) = self.end;
92        let axis_x = ex - sx;
93        let axis_y = ey - sy;
94        let len_sq = axis_x * axis_x + axis_y * axis_y;
95        let x0 = (x.floor() as i64).max(clip.x0).max(0);
96        let y0 = (y.floor() as i64).max(clip.y0).max(0);
97        let x1 = ((x + w).ceil() as i64).min(clip.x1);
98        let y1 = ((y + h).ceil() as i64).min(clip.y1);
99        for py in y0..y1 {
100            for px in x0..x1 {
101                let t = if len_sq <= f32::EPSILON {
102                    0.0
103                } else {
104                    let rx = px as f32 + 0.5 - sx;
105                    let ry = py as f32 + 0.5 - sy;
106                    (rx * axis_x + ry * axis_y) / len_sq
107                };
108                let c = self.sample(t);
109                let Color(r, g, b, a) = c;
110                fb.blend(px as u32, py as u32, pack_rgba(r, g, b, a));
111            }
112        }
113    }
114}
115
116/// Linearly interpolate two colours in sRGB component space at `t` in `[0, 1]`.
117pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color {
118    let t = t.clamp(0.0, 1.0);
119    let mix = |x: u8, y: u8| -> u8 {
120        let v = x as f32 + (y as f32 - x as f32) * t;
121        v.round().clamp(0.0, 255.0) as u8
122    };
123    Color(mix(a.0, b.0), mix(a.1, b.1), mix(a.2, b.2), mix(a.3, b.3))
124}
125
126// ---------------------------------------------------------------------------
127// Radial gradient
128// ---------------------------------------------------------------------------
129
130/// A radial gradient defined by a centre point, a radius, and colour stops.
131///
132/// The gradient transitions from `stops[0]` at the centre to `stops[-1]` at
133/// the edge of the circle. Beyond the radius the last stop colour is used.
134#[derive(Clone, Debug)]
135pub struct RadialGradient {
136    /// Centre of the gradient circle (logical pixels).
137    pub center: (f32, f32),
138    /// Radius of the gradient circle (logical pixels).
139    pub radius: f32,
140    /// Colour stops, sorted by ascending `offset` (`0.0` = centre, `1.0` = edge).
141    pub stops: Vec<GradientStop>,
142}
143
144impl RadialGradient {
145    /// Construct a two-stop radial gradient from `inner` at the centre to
146    /// `outer` at the edge.
147    pub fn two_stop(center: (f32, f32), radius: f32, inner: Color, outer: Color) -> Self {
148        Self {
149            center,
150            radius,
151            stops: vec![GradientStop::new(0.0, inner), GradientStop::new(1.0, outer)],
152        }
153    }
154
155    /// Construct from arbitrary stops; they are sorted by offset internally.
156    pub fn new(center: (f32, f32), radius: f32, mut stops: Vec<GradientStop>) -> Self {
157        stops.sort_by(|a, b| {
158            a.offset
159                .partial_cmp(&b.offset)
160                .unwrap_or(core::cmp::Ordering::Equal)
161        });
162        Self {
163            center,
164            radius,
165            stops,
166        }
167    }
168
169    /// Sample the gradient colour at pixel `(px, py)`.
170    ///
171    /// The normalised position `t` is the Euclidean distance from `center`
172    /// divided by `radius`, clamped to `[0, 1]`.
173    pub fn color_at(&self, px: f32, py: f32) -> Color {
174        if self.stops.is_empty() {
175            return Color(0, 0, 0, 0);
176        }
177        let r = self.radius.max(f32::EPSILON);
178        let dx = px - self.center.0;
179        let dy = py - self.center.1;
180        let dist = (dx * dx + dy * dy).sqrt();
181        let t = (dist / r).clamp(0.0, 1.0);
182        self.sample(t)
183    }
184
185    /// Sample the gradient at normalised position `t` in `[0, 1]`.
186    pub fn sample(&self, t: f32) -> Color {
187        if self.stops.is_empty() {
188            return Color(0, 0, 0, 0);
189        }
190        let t = t.clamp(0.0, 1.0);
191        if t <= self.stops[0].offset {
192            return self.stops[0].color;
193        }
194        let last = self.stops.len() - 1;
195        if t >= self.stops[last].offset {
196            return self.stops[last].color;
197        }
198        for w in self.stops.windows(2) {
199            let a = &w[0];
200            let b = &w[1];
201            if t >= a.offset && t <= b.offset {
202                let span = (b.offset - a.offset).max(f32::EPSILON);
203                let local = (t - a.offset) / span;
204                return lerp_color(&a.color, &b.color, local);
205            }
206        }
207        self.stops[last].color
208    }
209
210    /// Fill the rectangle `[x, x+w) × [y, y+h)` of `fb` with this radial
211    /// gradient, clipped to `clip`.
212    pub fn fill_rect(&self, fb: &mut Framebuffer, clip: &ClipRect, x: f32, y: f32, w: f32, h: f32) {
213        if w <= 0.0 || h <= 0.0 {
214            return;
215        }
216        let x0 = (x.floor() as i64).max(clip.x0).max(0);
217        let y0 = (y.floor() as i64).max(clip.y0).max(0);
218        let x1 = ((x + w).ceil() as i64).min(clip.x1);
219        let y1 = ((y + h).ceil() as i64).min(clip.y1);
220        for py in y0..y1 {
221            for px in x0..x1 {
222                let c = self.color_at(px as f32 + 0.5, py as f32 + 0.5);
223                let Color(r, g, b, a) = c;
224                fb.blend(px as u32, py as u32, pack_rgba(r, g, b, a));
225            }
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn lerp_endpoints_and_midpoint() {
236        let red = Color(255, 0, 0, 255);
237        let blue = Color(0, 0, 255, 255);
238        assert_eq!(lerp_color(&red, &blue, 0.0), red);
239        assert_eq!(lerp_color(&red, &blue, 1.0), blue);
240        let mid = lerp_color(&red, &blue, 0.5);
241        // Midpoint between red and blue is purple-ish.
242        assert!((120..=135).contains(&mid.0));
243        assert!((120..=135).contains(&mid.2));
244        assert_eq!(mid.1, 0);
245    }
246
247    #[test]
248    fn sample_two_stop() {
249        let g = LinearGradient::two_stop(
250            (0.0, 0.0),
251            (10.0, 0.0),
252            Color(255, 0, 0, 255),
253            Color(0, 0, 255, 255),
254        );
255        assert_eq!(g.sample(0.0), Color(255, 0, 0, 255));
256        assert_eq!(g.sample(1.0), Color(0, 0, 255, 255));
257        let m = g.sample(0.5);
258        assert!((120..=135).contains(&m.0));
259    }
260
261    #[test]
262    fn fill_rect_horizontal_midpoint_is_purple() {
263        let mut fb = Framebuffer::with_fill(10, 1, Color(0, 0, 0, 255));
264        let clip = ClipRect::full(10, 1);
265        let g = LinearGradient::two_stop(
266            (0.0, 0.0),
267            (10.0, 0.0),
268            Color(255, 0, 0, 255),
269            Color(0, 0, 255, 255),
270        );
271        g.fill_rect(&mut fb, &clip, 0.0, 0.0, 10.0, 1.0);
272        // Left edge near red.
273        let (lr, _, _, _) = fb.get_rgba(0, 0).expect("left");
274        assert!(lr > 200);
275        // Right edge near blue.
276        let (_, _, rb, _) = fb.get_rgba(9, 0).expect("right");
277        assert!(rb > 200);
278        // Middle is a blend of both channels.
279        let (mr, _, mb, _) = fb.get_rgba(5, 0).expect("mid");
280        assert!(mr > 0 && mb > 0);
281    }
282
283    #[test]
284    fn multi_stop_sorted() {
285        let g = LinearGradient::new(
286            (0.0, 0.0),
287            (1.0, 0.0),
288            vec![
289                GradientStop::new(1.0, Color(0, 0, 255, 255)),
290                GradientStop::new(0.0, Color(255, 0, 0, 255)),
291                GradientStop::new(0.5, Color(0, 255, 0, 255)),
292            ],
293        );
294        // Sorted, so sample at 0.5 is the green stop.
295        assert_eq!(g.sample(0.5), Color(0, 255, 0, 255));
296    }
297
298    #[test]
299    fn radial_gradient_midpoint() {
300        // Center = red (t=0), edge = blue (t=1), midpoint ≈ purple.
301        let g = RadialGradient::two_stop(
302            (5.0, 5.0),
303            10.0,
304            Color(255, 0, 0, 255),
305            Color(0, 0, 255, 255),
306        );
307        // At centre → red.
308        let c_center = g.color_at(5.0, 5.0);
309        assert_eq!(c_center, Color(255, 0, 0, 255));
310        // At radius distance → blue.
311        let c_edge = g.color_at(15.0, 5.0); // 10px right of center
312        assert_eq!(c_edge, Color(0, 0, 255, 255));
313        // At half-radius → mid purple.
314        let c_mid = g.color_at(10.0, 5.0); // 5px right of center
315        assert!((100..=160).contains(&c_mid.0), "r={}", c_mid.0);
316        assert!((100..=160).contains(&c_mid.2), "b={}", c_mid.2);
317    }
318
319    #[test]
320    fn radial_gradient_fill_rect() {
321        let mut fb = Framebuffer::with_fill(20, 20, Color(0, 0, 0, 255));
322        let clip = ClipRect::full(20, 20);
323        let g = RadialGradient::two_stop(
324            (10.0, 10.0),
325            10.0,
326            Color(255, 0, 0, 255),
327            Color(0, 0, 255, 255),
328        );
329        g.fill_rect(&mut fb, &clip, 0.0, 0.0, 20.0, 20.0);
330        // Centre pixel should be near red.
331        let (r, _, b, _) = fb.get_rgba(10, 10).expect("centre");
332        assert!(r > b, "centre should be red-dominant (r={r}, b={b})");
333        // Corner pixel (far from centre) should be near blue.
334        let (r2, _, b2, _) = fb.get_rgba(0, 0).expect("corner");
335        assert!(b2 >= r2, "corner should be blue-dominant (r={r2}, b={b2})");
336    }
337}