Skip to main content

fop_types/
gradient.rs

1//! Gradient types for backgrounds
2
3use crate::{Color, Point};
4use std::fmt;
5
6/// Color stop in a gradient
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub struct ColorStop {
9    /// Offset along the gradient (0.0-1.0)
10    pub offset: f64,
11    /// Color at this offset
12    pub color: Color,
13}
14
15impl ColorStop {
16    /// Create a new color stop
17    #[must_use = "this returns a new value without modifying anything"]
18    pub fn new(offset: f64, color: Color) -> Self {
19        Self {
20            offset: offset.clamp(0.0, 1.0),
21            color,
22        }
23    }
24}
25
26/// Gradient type
27#[derive(Debug, Clone, PartialEq)]
28pub enum Gradient {
29    /// Linear gradient from start point to end point
30    Linear {
31        /// Starting point of the gradient (normalized coordinates 0.0-1.0)
32        start_point: Point,
33        /// Ending point of the gradient (normalized coordinates 0.0-1.0)
34        end_point: Point,
35        /// Color stops along the gradient
36        color_stops: Vec<ColorStop>,
37    },
38    /// Radial gradient from center with radius
39    Radial {
40        /// Center point of the gradient (normalized coordinates 0.0-1.0)
41        center: Point,
42        /// Radius of the gradient (normalized coordinate 0.0-1.0)
43        radius: f64,
44        /// Color stops along the gradient
45        color_stops: Vec<ColorStop>,
46    },
47}
48
49impl Gradient {
50    /// Create a linear gradient
51    #[must_use = "this returns a new value without modifying anything"]
52    pub fn linear(start_point: Point, end_point: Point, color_stops: Vec<ColorStop>) -> Self {
53        Self::Linear {
54            start_point,
55            end_point,
56            color_stops,
57        }
58    }
59
60    /// Create a radial gradient
61    #[must_use = "this returns a new value without modifying anything"]
62    pub fn radial(center: Point, radius: f64, color_stops: Vec<ColorStop>) -> Self {
63        Self::Radial {
64            center,
65            radius: radius.max(0.0),
66            color_stops,
67        }
68    }
69
70    /// Create a linear gradient from an angle in degrees
71    ///
72    /// Angle is measured clockwise from top (0° = to top, 90° = to right, etc.)
73    #[must_use = "this returns a new value without modifying anything"]
74    pub fn linear_from_angle(angle_deg: f64, color_stops: Vec<ColorStop>) -> Self {
75        use std::f64::consts::PI;
76
77        // Convert angle to radians and adjust for coordinate system
78        // CSS angles: 0° = to top, 90° = to right
79        // We need to convert to start/end points
80        let angle_rad = (angle_deg - 90.0) * PI / 180.0;
81
82        // Calculate start and end points on a unit square
83        let cos_a = angle_rad.cos();
84        let sin_a = angle_rad.sin();
85
86        // Calculate the gradient line endpoints
87        let start_x = 0.5 - cos_a * 0.5;
88        let start_y = 0.5 - sin_a * 0.5;
89        let end_x = 0.5 + cos_a * 0.5;
90        let end_y = 0.5 + sin_a * 0.5;
91
92        use crate::Length;
93        Self::Linear {
94            start_point: Point::new(
95                Length::from_pt(start_x * 100.0),
96                Length::from_pt(start_y * 100.0),
97            ),
98            end_point: Point::new(
99                Length::from_pt(end_x * 100.0),
100                Length::from_pt(end_y * 100.0),
101            ),
102            color_stops,
103        }
104    }
105}
106
107impl fmt::Display for ColorStop {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write!(f, "{} {}%", self.color, self.offset * 100.0)
110    }
111}
112
113impl fmt::Display for Gradient {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Gradient::Linear {
117                start_point,
118                end_point,
119                color_stops,
120            } => {
121                write!(f, "linear-gradient({} to {}", start_point, end_point)?;
122                for stop in color_stops {
123                    write!(f, ", {}", stop)?;
124                }
125                write!(f, ")")
126            }
127            Gradient::Radial {
128                center,
129                radius,
130                color_stops,
131            } => {
132                write!(f, "radial-gradient(circle at {}, radius {}", center, radius)?;
133                for stop in color_stops {
134                    write!(f, ", {}", stop)?;
135                }
136                write!(f, ")")
137            }
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::Length;
146
147    #[test]
148    fn test_color_stop() {
149        let stop = ColorStop::new(0.5, Color::RED);
150        assert_eq!(stop.offset, 0.5);
151        assert_eq!(stop.color, Color::RED);
152
153        // Test clamping
154        let stop_clamped = ColorStop::new(1.5, Color::BLUE);
155        assert_eq!(stop_clamped.offset, 1.0);
156    }
157
158    #[test]
159    fn test_linear_gradient() {
160        let stops = vec![
161            ColorStop::new(0.0, Color::RED),
162            ColorStop::new(1.0, Color::BLUE),
163        ];
164
165        let gradient = Gradient::linear(
166            Point::new(Length::ZERO, Length::ZERO),
167            Point::new(Length::from_pt(100.0), Length::from_pt(100.0)),
168            stops,
169        );
170
171        match gradient {
172            Gradient::Linear { color_stops, .. } => {
173                assert_eq!(color_stops.len(), 2);
174            }
175            _ => panic!("Expected linear gradient"),
176        }
177    }
178
179    #[test]
180    fn test_radial_gradient() {
181        let stops = vec![
182            ColorStop::new(0.0, Color::WHITE),
183            ColorStop::new(1.0, Color::BLACK),
184        ];
185
186        let gradient = Gradient::radial(
187            Point::new(Length::from_pt(50.0), Length::from_pt(50.0)),
188            0.5,
189            stops,
190        );
191
192        match gradient {
193            Gradient::Radial {
194                radius,
195                color_stops,
196                ..
197            } => {
198                assert_eq!(radius, 0.5);
199                assert_eq!(color_stops.len(), 2);
200            }
201            _ => panic!("Expected radial gradient"),
202        }
203    }
204
205    #[test]
206    fn test_linear_from_angle() {
207        let stops = vec![
208            ColorStop::new(0.0, Color::RED),
209            ColorStop::new(1.0, Color::BLUE),
210        ];
211
212        // 0 degrees = to top
213        let gradient = Gradient::linear_from_angle(0.0, stops.clone());
214        assert!(matches!(gradient, Gradient::Linear { .. }));
215
216        // 90 degrees = to right
217        let gradient = Gradient::linear_from_angle(90.0, stops);
218        assert!(matches!(gradient, Gradient::Linear { .. }));
219    }
220
221    #[test]
222    fn test_color_stop_display() {
223        let stop = ColorStop::new(0.5, Color::RED);
224        assert_eq!(format!("{}", stop), "#FF0000 50%");
225
226        let stop_zero = ColorStop::new(0.0, Color::BLACK);
227        assert_eq!(format!("{}", stop_zero), "#000000 0%");
228
229        let stop_full = ColorStop::new(1.0, Color::WHITE);
230        assert_eq!(format!("{}", stop_full), "#FFFFFF 100%");
231    }
232
233    #[test]
234    fn test_linear_gradient_display() {
235        let stops = vec![
236            ColorStop::new(0.0, Color::RED),
237            ColorStop::new(1.0, Color::BLUE),
238        ];
239
240        let gradient = Gradient::linear(
241            Point::new(Length::ZERO, Length::ZERO),
242            Point::new(Length::from_pt(100.0), Length::from_pt(100.0)),
243            stops,
244        );
245
246        let display = format!("{}", gradient);
247        assert!(display.starts_with("linear-gradient("));
248        assert!(display.contains("#FF0000 0%"));
249        assert!(display.contains("#0000FF 100%"));
250    }
251
252    #[test]
253    fn test_radial_gradient_display() {
254        let stops = vec![
255            ColorStop::new(0.0, Color::WHITE),
256            ColorStop::new(1.0, Color::BLACK),
257        ];
258
259        let gradient = Gradient::radial(
260            Point::new(Length::from_pt(50.0), Length::from_pt(50.0)),
261            0.5,
262            stops,
263        );
264
265        let display = format!("{}", gradient);
266        assert!(display.starts_with("radial-gradient("));
267        assert!(display.contains("#FFFFFF 0%"));
268        assert!(display.contains("#000000 100%"));
269    }
270}
271
272#[cfg(test)]
273mod gradient_extra_tests {
274    use super::*;
275    use crate::Length;
276
277    // --- ColorStop ---
278
279    #[test]
280    fn test_color_stop_at_zero() {
281        let s = ColorStop::new(0.0, Color::BLACK);
282        assert_eq!(s.offset, 0.0);
283    }
284
285    #[test]
286    fn test_color_stop_at_one() {
287        let s = ColorStop::new(1.0, Color::WHITE);
288        assert_eq!(s.offset, 1.0);
289    }
290
291    #[test]
292    fn test_color_stop_clamp_above_one() {
293        let s = ColorStop::new(1.5, Color::RED);
294        assert_eq!(s.offset, 1.0);
295    }
296
297    #[test]
298    fn test_color_stop_clamp_below_zero() {
299        let s = ColorStop::new(-0.5, Color::BLUE);
300        assert_eq!(s.offset, 0.0);
301    }
302
303    #[test]
304    fn test_color_stop_preserves_color() {
305        let s = ColorStop::new(0.5, Color::GREEN);
306        assert_eq!(s.color, Color::GREEN);
307    }
308
309    #[test]
310    fn test_color_stop_display_50_pct() {
311        let s = ColorStop::new(0.5, Color::RED);
312        let display = format!("{}", s);
313        assert!(display.contains("50%"));
314        assert!(display.contains("#FF0000"));
315    }
316
317    #[test]
318    fn test_color_stop_display_0_pct() {
319        let s = ColorStop::new(0.0, Color::BLACK);
320        assert!(format!("{}", s).contains("0%"));
321    }
322
323    #[test]
324    fn test_color_stop_display_100_pct() {
325        let s = ColorStop::new(1.0, Color::WHITE);
326        assert!(format!("{}", s).contains("100%"));
327    }
328
329    // --- Linear gradient ---
330
331    #[test]
332    fn test_linear_gradient_has_correct_stops() {
333        let stops = vec![
334            ColorStop::new(0.0, Color::RED),
335            ColorStop::new(0.5, Color::GREEN),
336            ColorStop::new(1.0, Color::BLUE),
337        ];
338        let g = Gradient::linear(
339            Point::new(Length::ZERO, Length::ZERO),
340            Point::new(Length::from_pt(100.0), Length::ZERO),
341            stops,
342        );
343        match g {
344            Gradient::Linear { color_stops, .. } => {
345                assert_eq!(color_stops.len(), 3);
346                assert_eq!(color_stops[1].color, Color::GREEN);
347            }
348            _ => panic!("Expected linear"),
349        }
350    }
351
352    #[test]
353    fn test_linear_gradient_single_stop() {
354        let stops = vec![ColorStop::new(0.0, Color::RED)];
355        let g = Gradient::linear(
356            Point::new(Length::ZERO, Length::ZERO),
357            Point::new(Length::from_pt(10.0), Length::ZERO),
358            stops,
359        );
360        match g {
361            Gradient::Linear { color_stops, .. } => {
362                assert_eq!(color_stops.len(), 1);
363            }
364            _ => panic!("Expected linear"),
365        }
366    }
367
368    #[test]
369    fn test_linear_gradient_empty_stops() {
370        let g = Gradient::linear(
371            Point::new(Length::ZERO, Length::ZERO),
372            Point::new(Length::from_pt(10.0), Length::ZERO),
373            vec![],
374        );
375        match g {
376            Gradient::Linear { color_stops, .. } => {
377                assert!(color_stops.is_empty());
378            }
379            _ => panic!("Expected linear"),
380        }
381    }
382
383    // --- Radial gradient ---
384
385    #[test]
386    fn test_radial_gradient_radius_clamped_non_negative() {
387        let g = Gradient::radial(
388            Point::new(Length::from_pt(50.0), Length::from_pt(50.0)),
389            -1.0, // negative radius should be clamped to 0
390            vec![],
391        );
392        match g {
393            Gradient::Radial { radius, .. } => {
394                assert!(radius >= 0.0);
395            }
396            _ => panic!("Expected radial"),
397        }
398    }
399
400    #[test]
401    fn test_radial_gradient_center() {
402        let center = Point::new(Length::from_pt(30.0), Length::from_pt(40.0));
403        let g = Gradient::radial(center, 0.7, vec![]);
404        match g {
405            Gradient::Radial { center: c, .. } => {
406                assert_eq!(c, center);
407            }
408            _ => panic!("Expected radial"),
409        }
410    }
411
412    #[test]
413    fn test_radial_gradient_stops_count() {
414        let stops = vec![
415            ColorStop::new(0.0, Color::WHITE),
416            ColorStop::new(0.5, Color::rgb(128, 128, 128)),
417            ColorStop::new(1.0, Color::BLACK),
418        ];
419        let g = Gradient::radial(
420            Point::new(Length::from_pt(50.0), Length::from_pt(50.0)),
421            0.5,
422            stops,
423        );
424        match g {
425            Gradient::Radial { color_stops, .. } => {
426                assert_eq!(color_stops.len(), 3);
427            }
428            _ => panic!("Expected radial"),
429        }
430    }
431
432    // --- linear_from_angle ---
433
434    #[test]
435    fn test_linear_from_angle_0_is_linear() {
436        let g = Gradient::linear_from_angle(
437            0.0,
438            vec![
439                ColorStop::new(0.0, Color::RED),
440                ColorStop::new(1.0, Color::BLUE),
441            ],
442        );
443        assert!(matches!(g, Gradient::Linear { .. }));
444    }
445
446    #[test]
447    fn test_linear_from_angle_90_is_linear() {
448        let g = Gradient::linear_from_angle(
449            90.0,
450            vec![
451                ColorStop::new(0.0, Color::RED),
452                ColorStop::new(1.0, Color::BLUE),
453            ],
454        );
455        assert!(matches!(g, Gradient::Linear { .. }));
456    }
457
458    #[test]
459    fn test_linear_from_angle_180_is_linear() {
460        let g = Gradient::linear_from_angle(180.0, vec![]);
461        assert!(matches!(g, Gradient::Linear { .. }));
462    }
463
464    #[test]
465    fn test_linear_from_angle_360_is_linear() {
466        let g = Gradient::linear_from_angle(360.0, vec![]);
467        assert!(matches!(g, Gradient::Linear { .. }));
468    }
469
470    // --- Display ---
471
472    #[test]
473    fn test_linear_gradient_display_contains_linear() {
474        let g = Gradient::linear(
475            Point::new(Length::ZERO, Length::ZERO),
476            Point::new(Length::from_pt(100.0), Length::ZERO),
477            vec![
478                ColorStop::new(0.0, Color::RED),
479                ColorStop::new(1.0, Color::BLUE),
480            ],
481        );
482        let s = format!("{}", g);
483        assert!(s.starts_with("linear-gradient("));
484    }
485
486    #[test]
487    fn test_radial_gradient_display_contains_radial() {
488        let g = Gradient::radial(
489            Point::new(Length::from_pt(50.0), Length::from_pt(50.0)),
490            0.5,
491            vec![
492                ColorStop::new(0.0, Color::WHITE),
493                ColorStop::new(1.0, Color::BLACK),
494            ],
495        );
496        let s = format!("{}", g);
497        assert!(s.starts_with("radial-gradient("));
498    }
499
500    #[test]
501    fn test_gradient_display_contains_stop_colors() {
502        let g = Gradient::linear(
503            Point::new(Length::ZERO, Length::ZERO),
504            Point::new(Length::from_pt(10.0), Length::ZERO),
505            vec![
506                ColorStop::new(0.0, Color::RED),
507                ColorStop::new(1.0, Color::BLUE),
508            ],
509        );
510        let s = format!("{}", g);
511        assert!(s.contains("#FF0000"));
512        assert!(s.contains("#0000FF"));
513    }
514}