Skip to main content

azul_css/
shape.rs

1//! CSS Shape data structures for shape-inside, shape-outside, and clip-path
2//!
3//! These types are C-compatible (repr(C)) for use across FFI boundaries.
4
5use alloc::string::String;
6use crate::corety::{AzString, OptionF32};
7
8/// Compares two f32 values for ordering, treating NaN as equal.
9fn cmp_f32(a: f32, b: f32) -> core::cmp::Ordering {
10    a.partial_cmp(&b).unwrap_or(core::cmp::Ordering::Equal)
11}
12
13/// A 2D point for shape coordinates (using f32 for precision)
14#[derive(Debug, Copy, Clone, PartialEq)]
15#[repr(C)]
16pub struct ShapePoint {
17    pub x: f32,
18    pub y: f32,
19}
20
21impl_option!(
22    ShapePoint,
23    OptionShapePoint,
24    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd]
25);
26
27impl ShapePoint {
28    #[must_use] pub const fn new(x: f32, y: f32) -> Self {
29        Self { x, y }
30    }
31
32    #[must_use] pub const fn zero() -> Self {
33        Self { x: 0.0, y: 0.0 }
34    }
35}
36
37impl Eq for ShapePoint {}
38
39// PartialOrd delegates to Ord (NaN-as-equal) so the two stay consistent.
40impl PartialOrd for ShapePoint {
41    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
42        Some(self.cmp(other))
43    }
44}
45
46impl Ord for ShapePoint {
47    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
48        match self.x.partial_cmp(&other.x) {
49            Some(core::cmp::Ordering::Equal) => self
50                .y
51                .partial_cmp(&other.y)
52                .unwrap_or(core::cmp::Ordering::Equal),
53            other => other.unwrap_or(core::cmp::Ordering::Equal),
54        }
55    }
56}
57
58impl core::hash::Hash for ShapePoint {
59    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
60        self.x.to_bits().hash(state);
61        self.y.to_bits().hash(state);
62    }
63}
64
65impl_vec!(ShapePoint, ShapePointVec, ShapePointVecDestructor, ShapePointVecDestructorType, ShapePointVecSlice, OptionShapePoint);
66impl_vec_debug!(ShapePoint, ShapePointVec);
67impl_vec_partialord!(ShapePoint, ShapePointVec);
68impl_vec_ord!(ShapePoint, ShapePointVec);
69impl_vec_clone!(ShapePoint, ShapePointVec, ShapePointVecDestructor);
70impl_vec_partialeq!(ShapePoint, ShapePointVec);
71impl_vec_eq!(ShapePoint, ShapePointVec);
72impl_vec_hash!(ShapePoint, ShapePointVec);
73
74/// A circle shape defined by center point and radius
75#[derive(Debug, Copy, Clone)]
76#[repr(C)]
77pub struct ShapeCircle {
78    pub center: ShapePoint,
79    pub radius: f32,
80}
81
82// PartialEq is hand-written to match the to_bits Hash and the NaN-Equal Ord, so
83// Eq stays reflexive even when a radius is NaN (a preserved, if unusual, length).
84impl PartialEq for ShapeCircle {
85    fn eq(&self, other: &Self) -> bool {
86        self.cmp(other) == core::cmp::Ordering::Equal
87    }
88}
89impl Eq for ShapeCircle {}
90impl core::hash::Hash for ShapeCircle {
91    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
92        self.center.hash(state);
93        self.radius.to_bits().hash(state);
94    }
95}
96impl PartialOrd for ShapeCircle {
97    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
98        Some(self.cmp(other))
99    }
100}
101impl Ord for ShapeCircle {
102    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
103        match self.center.cmp(&other.center) {
104            core::cmp::Ordering::Equal => self
105                .radius
106                .partial_cmp(&other.radius)
107                .unwrap_or(core::cmp::Ordering::Equal),
108            other => other,
109        }
110    }
111}
112
113/// An ellipse shape defined by center point and two radii
114#[derive(Debug, Copy, Clone)]
115#[repr(C)]
116pub struct ShapeEllipse {
117    pub center: ShapePoint,
118    pub radius_x: f32,
119    pub radius_y: f32,
120}
121
122// PartialEq hand-written to match the to_bits Hash / NaN-Equal Ord (reflexive Eq).
123impl PartialEq for ShapeEllipse {
124    fn eq(&self, other: &Self) -> bool {
125        self.cmp(other) == core::cmp::Ordering::Equal
126    }
127}
128impl Eq for ShapeEllipse {}
129impl core::hash::Hash for ShapeEllipse {
130    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
131        self.center.hash(state);
132        self.radius_x.to_bits().hash(state);
133        self.radius_y.to_bits().hash(state);
134    }
135}
136impl PartialOrd for ShapeEllipse {
137    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
138        Some(self.cmp(other))
139    }
140}
141impl Ord for ShapeEllipse {
142    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
143        match self.center.cmp(&other.center) {
144            core::cmp::Ordering::Equal => match self.radius_x.partial_cmp(&other.radius_x) {
145                Some(core::cmp::Ordering::Equal) | None => self
146                    .radius_y
147                    .partial_cmp(&other.radius_y)
148                    .unwrap_or(core::cmp::Ordering::Equal),
149                Some(other) => other,
150            },
151            other => other,
152        }
153    }
154}
155
156/// A polygon shape defined by a list of points (in clockwise order)
157#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
158#[repr(C)]
159pub struct ShapePolygon {
160    pub points: ShapePointVec,
161}
162
163/// An inset rectangle with optional border radius
164/// Defined by insets from the reference box edges
165#[derive(Debug, Copy, Clone)]
166#[repr(C)]
167pub struct ShapeInset {
168    pub inset_top: f32,
169    pub inset_right: f32,
170    pub inset_bottom: f32,
171    pub inset_left: f32,
172    pub border_radius: OptionF32,
173}
174
175// PartialEq hand-written to match the to_bits Hash / NaN-Equal Ord (reflexive Eq).
176impl PartialEq for ShapeInset {
177    fn eq(&self, other: &Self) -> bool {
178        self.cmp(other) == core::cmp::Ordering::Equal
179    }
180}
181impl Eq for ShapeInset {}
182impl core::hash::Hash for ShapeInset {
183    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
184        self.inset_top.to_bits().hash(state);
185        self.inset_right.to_bits().hash(state);
186        self.inset_bottom.to_bits().hash(state);
187        self.inset_left.to_bits().hash(state);
188        self.border_radius.hash(state);
189    }
190}
191impl PartialOrd for ShapeInset {
192    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
193        Some(self.cmp(other))
194    }
195}
196impl Ord for ShapeInset {
197    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
198        cmp_f32(self.inset_top, other.inset_top)
199            .then_with(|| cmp_f32(self.inset_right, other.inset_right))
200            .then_with(|| cmp_f32(self.inset_bottom, other.inset_bottom))
201            .then_with(|| cmp_f32(self.inset_left, other.inset_left))
202            .then_with(|| self.border_radius.cmp(&other.border_radius))
203    }
204}
205
206/// An SVG-like path for shape definitions.
207/// TODO: path parsing is not yet implemented — `data` is stored but not interpreted.
208#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
209#[repr(C)]
210pub struct ShapePath {
211    pub data: AzString,
212}
213
214/// Represents a CSS shape for shape-inside, shape-outside, and clip-path.
215/// Used for both text layout (shape-inside/outside) and rendering clipping (clip-path).
216#[derive(Debug, Clone)]
217#[repr(C, u8)]
218pub enum CssShape {
219    Circle(ShapeCircle),
220    Ellipse(ShapeEllipse),
221    Polygon(ShapePolygon),
222    Inset(ShapeInset),
223    Path(ShapePath),
224}
225
226// PartialEq hand-written to match the to_bits Hash / NaN-Equal Ord (reflexive Eq
227// through the float-holding variants).
228impl PartialEq for CssShape {
229    fn eq(&self, other: &Self) -> bool {
230        self.cmp(other) == core::cmp::Ordering::Equal
231    }
232}
233impl Eq for CssShape {}
234
235impl core::hash::Hash for CssShape {
236    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
237        core::mem::discriminant(self).hash(state);
238        match self {
239            Self::Circle(c) => c.hash(state),
240            Self::Ellipse(e) => e.hash(state),
241            Self::Polygon(p) => p.hash(state),
242            Self::Inset(i) => i.hash(state),
243            Self::Path(p) => p.hash(state),
244        }
245    }
246}
247
248impl PartialOrd for CssShape {
249    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
250        Some(self.cmp(other))
251    }
252}
253
254impl Ord for CssShape {
255    // The tie-break arms `(Self::X(_), _) => Less` / `(_, Self::X(_)) => Greater`
256    // share bodies but are ORDER-DEPENDENT: they encode the variant ordering, so
257    // merging them (clippy::match_same_arms) would change the comparison result.
258    #[allow(clippy::match_same_arms)]
259    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
260        match (self, other) {
261            (Self::Circle(a), Self::Circle(b)) => a.cmp(b),
262            (Self::Ellipse(a), Self::Ellipse(b)) => a.cmp(b),
263            (Self::Polygon(a), Self::Polygon(b)) => a.cmp(b),
264            (Self::Inset(a), Self::Inset(b)) => a.cmp(b),
265            (Self::Path(a), Self::Path(b)) => a.cmp(b),
266            // Different variants: use discriminant ordering
267            (Self::Circle(_), _) => core::cmp::Ordering::Less,
268            (_, Self::Circle(_)) => core::cmp::Ordering::Greater,
269            (Self::Ellipse(_), _) => core::cmp::Ordering::Less,
270            (_, Self::Ellipse(_)) => core::cmp::Ordering::Greater,
271            (Self::Polygon(_), _) => core::cmp::Ordering::Less,
272            (_, Self::Polygon(_)) => core::cmp::Ordering::Greater,
273            (Self::Inset(_), Self::Path(_)) => core::cmp::Ordering::Less,
274            (Self::Path(_), Self::Inset(_)) => core::cmp::Ordering::Greater,
275        }
276    }
277}
278
279impl CssShape {
280    /// Creates a circle shape at the given position with the given radius
281    #[must_use] pub const fn circle(center: ShapePoint, radius: f32) -> Self {
282        Self::Circle(ShapeCircle { center, radius })
283    }
284
285    /// Creates an ellipse shape
286    #[must_use] pub const fn ellipse(center: ShapePoint, radius_x: f32, radius_y: f32) -> Self {
287        Self::Ellipse(ShapeEllipse {
288            center,
289            radius_x,
290            radius_y,
291        })
292    }
293
294    /// Creates a polygon from a list of points
295    #[must_use] pub const fn polygon(points: ShapePointVec) -> Self {
296        Self::Polygon(ShapePolygon { points })
297    }
298
299    /// Creates an inset rectangle
300    #[must_use] pub const fn inset(top: f32, right: f32, bottom: f32, left: f32) -> Self {
301        Self::Inset(ShapeInset {
302            inset_top: top,
303            inset_right: right,
304            inset_bottom: bottom,
305            inset_left: left,
306            border_radius: OptionF32::None,
307        })
308    }
309
310    /// Creates an inset rectangle with rounded corners
311    #[must_use] pub const fn inset_rounded(top: f32, right: f32, bottom: f32, left: f32, radius: f32) -> Self {
312        Self::Inset(ShapeInset {
313            inset_top: top,
314            inset_right: right,
315            inset_bottom: bottom,
316            inset_left: left,
317            border_radius: OptionF32::Some(radius),
318        })
319    }
320
321    #[must_use] pub fn print_as_css_value(&self) -> String {
322        use alloc::format;
323        match self {
324            Self::Circle(ShapeCircle { center, radius }) => {
325                format!("circle({}px at {}px {}px)", radius, center.x, center.y)
326            }
327            Self::Ellipse(ShapeEllipse { center, radius_x, radius_y }) => {
328                format!("ellipse({}px {}px at {}px {}px)", radius_x, radius_y, center.x, center.y)
329            }
330            Self::Polygon(ShapePolygon { points }) => {
331                let pts: Vec<String> = points.as_ref().iter()
332                    .map(|p| format!("{}px {}px", p.x, p.y))
333                    .collect();
334                format!("polygon({})", pts.join(", "))
335            }
336            Self::Inset(ShapeInset { inset_top, inset_right, inset_bottom, inset_left, border_radius }) => {
337                let base = format!("inset({inset_top}px {inset_right}px {inset_bottom}px {inset_left}px");
338                match border_radius {
339                    OptionF32::Some(r) => format!("{base} round {r}px)"),
340                    OptionF32::None => format!("{base})"),
341                }
342            }
343            Self::Path(ShapePath { data }) => {
344                format!("path(\"{}\")", data.as_str())
345            }
346        }
347    }
348
349    #[must_use] pub fn format_as_rust_code(&self) -> String {
350        use alloc::format;
351        match self {
352            Self::Circle(ShapeCircle { center, radius }) => {
353                format!(
354                    "CssShape::Circle(ShapeCircle {{ center: ShapePoint::new({}_f32, {}_f32), radius: {}_f32 }})",
355                    center.x, center.y, radius
356                )
357            }
358            Self::Ellipse(ShapeEllipse { center, radius_x, radius_y }) => {
359                format!(
360                    "CssShape::Ellipse(ShapeEllipse {{ center: ShapePoint::new({}_f32, {}_f32), radius_x: {}_f32, radius_y: {}_f32 }})",
361                    center.x, center.y, radius_x, radius_y
362                )
363            }
364            Self::Polygon(ShapePolygon { points }) => {
365                let pts: Vec<String> = points.as_ref().iter()
366                    .map(|p| format!("ShapePoint::new({}_f32, {}_f32)", p.x, p.y))
367                    .collect();
368                format!("CssShape::Polygon(ShapePolygon {{ points: vec![{}].into() }})", pts.join(", "))
369            }
370            Self::Inset(ShapeInset { inset_top, inset_right, inset_bottom, inset_left, border_radius }) => {
371                let br = match border_radius {
372                    OptionF32::Some(r) => format!("OptionF32::Some({r}_f32)"),
373                    OptionF32::None => String::from("OptionF32::None"),
374                };
375                format!(
376                    "CssShape::Inset(ShapeInset {{ inset_top: {inset_top}_f32, inset_right: {inset_right}_f32, inset_bottom: {inset_bottom}_f32, inset_left: {inset_left}_f32, border_radius: {br} }})"
377                )
378            }
379            Self::Path(ShapePath { data }) => {
380                format!("CssShape::Path(ShapePath {{ data: AzString::from_const_str(\"{}\") }})", data.as_str())
381            }
382        }
383    }
384}
385
386impl_option!(
387    CssShape,
388    OptionCssShape,
389    copy = false,
390    [Debug, Clone, PartialEq, Eq]
391);
392
393#[cfg(test)]
394mod autotest_generated {
395    // Float values are compared for exact bit/value identity on purpose: these
396    // tests check that constructors and (de)serialization are lossless, not that
397    // the values are approximately right.
398    #![allow(
399        clippy::float_cmp,
400        clippy::unreadable_literal,
401        clippy::cast_precision_loss
402    )]
403
404    use core::{
405        cmp::Ordering,
406        hash::{Hash, Hasher},
407    };
408    use std::collections::hash_map::DefaultHasher;
409
410    use super::*;
411    use crate::shape_parser::{parse_shape, ShapeParseError};
412
413    /// Every f32 edge value the shape types have to survive.
414    const EDGE_F32: &[f32] = &[
415        0.0,
416        -0.0,
417        1.0,
418        -1.0,
419        f32::MIN,
420        f32::MAX,
421        f32::MIN_POSITIVE,
422        f32::EPSILON,
423        f32::INFINITY,
424        f32::NEG_INFINITY,
425        f32::NAN,
426    ];
427
428    fn hash_of<T: Hash>(t: &T) -> u64 {
429        let mut h = DefaultHasher::new();
430        t.hash(&mut h);
431        h.finish()
432    }
433
434    /// Encode a shape to CSS, decode it back. Panics if the shape does not
435    /// survive its own printer -> the crate's own parser.
436    fn roundtrip(shape: &CssShape) -> CssShape {
437        let css = shape.print_as_css_value();
438        parse_shape(&css).unwrap_or_else(|e| panic!("round-trip failed for {css:?}: {e:?}"))
439    }
440
441    fn poly(coords: &[(f32, f32)]) -> CssShape {
442        let pts: Vec<ShapePoint> = coords.iter().map(|(x, y)| ShapePoint::new(*x, *y)).collect();
443        CssShape::polygon(ShapePointVec::from_vec(pts))
444    }
445
446    fn path(data: &str) -> CssShape {
447        CssShape::Path(ShapePath {
448            data: AzString::from(data),
449        })
450    }
451
452    // ---------------------------------------------------------------------
453    // cmp_f32 (private, numeric)
454    // ---------------------------------------------------------------------
455
456    #[test]
457    fn cmp_f32_orders_ordinary_values() {
458        assert_eq!(cmp_f32(1.0, 2.0), Ordering::Less);
459        assert_eq!(cmp_f32(2.0, 1.0), Ordering::Greater);
460        assert_eq!(cmp_f32(2.0, 2.0), Ordering::Equal);
461        assert_eq!(cmp_f32(-1.0, 1.0), Ordering::Less);
462    }
463
464    #[test]
465    fn cmp_f32_treats_both_zeroes_as_equal() {
466        assert_eq!(cmp_f32(0.0, -0.0), Ordering::Equal);
467        assert_eq!(cmp_f32(-0.0, 0.0), Ordering::Equal);
468    }
469
470    #[test]
471    fn cmp_f32_nan_is_equal_to_everything_and_never_panics() {
472        // Documented contract: "treating NaN as equal".
473        assert_eq!(cmp_f32(f32::NAN, f32::NAN), Ordering::Equal);
474        for &v in EDGE_F32 {
475            assert_eq!(cmp_f32(f32::NAN, v), Ordering::Equal);
476            assert_eq!(cmp_f32(v, f32::NAN), Ordering::Equal);
477        }
478    }
479
480    #[test]
481    fn cmp_f32_handles_infinities_and_limits() {
482        assert_eq!(cmp_f32(f32::INFINITY, f32::MAX), Ordering::Greater);
483        assert_eq!(cmp_f32(f32::NEG_INFINITY, f32::MIN), Ordering::Less);
484        assert_eq!(cmp_f32(f32::INFINITY, f32::INFINITY), Ordering::Equal);
485        assert_eq!(
486            cmp_f32(f32::NEG_INFINITY, f32::INFINITY),
487            Ordering::Less
488        );
489        assert_eq!(cmp_f32(f32::MIN_POSITIVE, 0.0), Ordering::Greater);
490        // Smallest subnormal still sorts above zero.
491        assert_eq!(cmp_f32(f32::from_bits(1), 0.0), Ordering::Greater);
492        assert_eq!(cmp_f32(f32::MIN, f32::MAX), Ordering::Less);
493    }
494
495    #[test]
496    fn cmp_f32_is_antisymmetric_over_all_edge_values() {
497        for &a in EDGE_F32 {
498            for &b in EDGE_F32 {
499                assert_eq!(
500                    cmp_f32(a, b),
501                    cmp_f32(b, a).reverse(),
502                    "antisymmetry broken for ({a}, {b})"
503                );
504            }
505        }
506    }
507
508    #[test]
509    fn cmp_f32_nan_equality_is_not_transitive() {
510        // Characterization of the known cost of "NaN as equal": NaN == 1.0 and
511        // NaN == 2.0, yet 1.0 < 2.0. Ord's transitivity therefore does NOT hold
512        // once a NaN is in the set, so slices holding NaN-bearing shapes must
513        // not be `sort()`ed (std may panic on a non-total order).
514        assert_eq!(cmp_f32(f32::NAN, 1.0), Ordering::Equal);
515        assert_eq!(cmp_f32(f32::NAN, 2.0), Ordering::Equal);
516        assert_eq!(cmp_f32(1.0, 2.0), Ordering::Less);
517    }
518
519    // ---------------------------------------------------------------------
520    // ShapePoint::new / ShapePoint::zero (constructors)
521    // ---------------------------------------------------------------------
522
523    #[test]
524    fn shapepoint_new_stores_fields_verbatim() {
525        let p = ShapePoint::new(3.5, -7.25);
526        assert_eq!(p.x, 3.5);
527        assert_eq!(p.y, -7.25);
528    }
529
530    #[test]
531    fn shapepoint_new_does_not_normalize_edge_values() {
532        for &x in EDGE_F32 {
533            for &y in EDGE_F32 {
534                let p = ShapePoint::new(x, y);
535                // Bit-exact: no clamping, no NaN canonicalization, no -0 flush.
536                assert_eq!(p.x.to_bits(), x.to_bits());
537                assert_eq!(p.y.to_bits(), y.to_bits());
538            }
539        }
540    }
541
542    #[test]
543    fn shapepoint_new_preserves_negative_zero_sign() {
544        let p = ShapePoint::new(-0.0, 0.0);
545        assert_eq!(p.x.to_bits(), (-0.0f32).to_bits());
546        assert_eq!(p.y.to_bits(), (0.0f32).to_bits());
547        assert!(p.x.is_sign_negative());
548    }
549
550    #[test]
551    fn shapepoint_zero_is_the_neutral_positive_zero() {
552        let z = ShapePoint::zero();
553        assert_eq!(z.x.to_bits(), 0);
554        assert_eq!(z.y.to_bits(), 0);
555        assert_eq!(z, ShapePoint::new(0.0, 0.0));
556        assert_eq!(z.cmp(&ShapePoint::new(0.0, 0.0)), Ordering::Equal);
557    }
558
559    #[test]
560    fn shapepoint_constructors_are_usable_in_const_context() {
561        const P: ShapePoint = ShapePoint::new(1.0, 2.0);
562        const Z: ShapePoint = ShapePoint::zero();
563        assert_eq!(P.x, 1.0);
564        assert_eq!(Z, ShapePoint::zero());
565        // repr(C) layout guarantee relied on across the FFI boundary.
566        assert_eq!(size_of::<ShapePoint>(), 8);
567    }
568
569    // ---------------------------------------------------------------------
570    // ShapePoint Ord / Eq / Hash invariants
571    // ---------------------------------------------------------------------
572
573    #[test]
574    fn shapepoint_ord_sorts_by_x_then_y() {
575        let mut v = vec![
576            ShapePoint::new(1.0, 5.0),
577            ShapePoint::new(-3.0, 0.0),
578            ShapePoint::new(1.0, -5.0),
579            ShapePoint::new(0.0, 0.0),
580        ];
581        v.sort(); // no NaN involved -> total order holds, sort must not panic
582        assert_eq!(
583            v,
584            vec![
585                ShapePoint::new(-3.0, 0.0),
586                ShapePoint::new(0.0, 0.0),
587                ShapePoint::new(1.0, -5.0),
588                ShapePoint::new(1.0, 5.0),
589            ]
590        );
591    }
592
593    #[test]
594    fn shapepoint_cmp_is_antisymmetric_even_with_nan() {
595        for &ax in EDGE_F32 {
596            for &ay in EDGE_F32 {
597                let a = ShapePoint::new(ax, ay);
598                for &bx in EDGE_F32 {
599                    let b = ShapePoint::new(bx, 1.0);
600                    assert_eq!(
601                        a.cmp(&b),
602                        b.cmp(&a).reverse(),
603                        "antisymmetry broken for {a:?} vs {b:?}"
604                    );
605                    // PartialOrd must agree with Ord (it delegates).
606                    assert_eq!(a.partial_cmp(&b), Some(a.cmp(&b)));
607                }
608            }
609        }
610    }
611
612    #[test]
613    fn shapepoint_cmp_with_nan_x_ignores_y_entirely() {
614        // Characterization: when the x comparison is indeterminate, cmp() returns
615        // Equal WITHOUT looking at y -- unlike ShapeEllipse::cmp, which falls
616        // through to the next field on None. The two NaN policies in this file
617        // disagree; see the report.
618        let a = ShapePoint::new(f32::NAN, 1.0);
619        let b = ShapePoint::new(f32::NAN, 2.0);
620        assert_eq!(a.cmp(&b), Ordering::Equal);
621
622        let e1 = ShapeEllipse {
623            center: ShapePoint::zero(),
624            radius_x: f32::NAN,
625            radius_y: 1.0,
626        };
627        let e2 = ShapeEllipse {
628            center: ShapePoint::zero(),
629            radius_x: f32::NAN,
630            radius_y: 2.0,
631        };
632        assert_eq!(e1.cmp(&e2), Ordering::Less);
633    }
634
635    #[test]
636    fn shapepoint_nan_is_ord_equal_but_partialeq_unequal() {
637        // Eq is implemented for ShapePoint, but PartialEq is derived on f32, so
638        // reflexivity does not actually hold for NaN while Ord reports Equal.
639        let a = ShapePoint::new(f32::NAN, 0.0);
640        let b = ShapePoint::new(f32::NAN, 0.0);
641        assert_ne!(a, b, "derived PartialEq: NaN != NaN");
642        assert_eq!(a.cmp(&b), Ordering::Equal, "Ord: NaN treated as equal");
643    }
644
645    #[test]
646    fn shapepoint_hash_is_deterministic_and_bitwise() {
647        let a = ShapePoint::new(1.5, -2.5);
648        let b = ShapePoint::new(1.5, -2.5);
649        assert_eq!(hash_of(&a), hash_of(&b));
650        assert_ne!(hash_of(&a), hash_of(&ShapePoint::new(-2.5, 1.5)));
651        // NaN hashes by bits, so an identical NaN hashes identically even though
652        // it does not compare PartialEq-equal to itself.
653        let n1 = ShapePoint::new(f32::NAN, 0.0);
654        let n2 = ShapePoint::new(f32::NAN, 0.0);
655        assert_eq!(hash_of(&n1), hash_of(&n2));
656    }
657
658    // ---------------------------------------------------------------------
659    // CssShape constructors (numeric)
660    // ---------------------------------------------------------------------
661
662    #[test]
663    fn circle_stores_center_and_radius_including_zero_and_negative() {
664        match CssShape::circle(ShapePoint::zero(), 0.0) {
665            CssShape::Circle(c) => {
666                assert_eq!(c.radius, 0.0);
667                assert_eq!(c.center, ShapePoint::zero());
668            }
669            other => panic!("expected Circle, got {other:?}"),
670        }
671        // A negative radius is CSS-invalid but accepted verbatim here (no clamp).
672        match CssShape::circle(ShapePoint::new(-1.0, -2.0), -50.0) {
673            CssShape::Circle(c) => assert_eq!(c.radius, -50.0),
674            other => panic!("expected Circle, got {other:?}"),
675        }
676    }
677
678    #[test]
679    fn circle_accepts_every_f32_edge_value_without_panicking() {
680        for &r in EDGE_F32 {
681            for &c in EDGE_F32 {
682                let shape = CssShape::circle(ShapePoint::new(c, c), r);
683                match shape {
684                    CssShape::Circle(circle) => {
685                        assert_eq!(circle.radius.to_bits(), r.to_bits());
686                    }
687                    other => panic!("expected Circle, got {other:?}"),
688                }
689            }
690        }
691    }
692
693    #[test]
694    fn ellipse_stores_both_radii_verbatim() {
695        match CssShape::ellipse(ShapePoint::new(1.0, 2.0), f32::INFINITY, f32::NEG_INFINITY) {
696            CssShape::Ellipse(e) => {
697                assert!(e.radius_x.is_infinite() && e.radius_x.is_sign_positive());
698                assert!(e.radius_y.is_infinite() && e.radius_y.is_sign_negative());
699                assert_eq!(e.center, ShapePoint::new(1.0, 2.0));
700            }
701            other => panic!("expected Ellipse, got {other:?}"),
702        }
703        match CssShape::ellipse(ShapePoint::zero(), f32::NAN, f32::MAX) {
704            CssShape::Ellipse(e) => {
705                assert!(e.radius_x.is_nan());
706                assert_eq!(e.radius_y, f32::MAX);
707            }
708            other => panic!("expected Ellipse, got {other:?}"),
709        }
710    }
711
712    #[test]
713    fn polygon_accepts_empty_and_huge_point_lists() {
714        match CssShape::polygon(ShapePointVec::from_vec(Vec::new())) {
715            CssShape::Polygon(p) => {
716                assert!(p.points.is_empty());
717                assert_eq!(p.points.len(), 0);
718            }
719            other => panic!("expected Polygon, got {other:?}"),
720        }
721
722        let big: Vec<ShapePoint> = (0..10_000)
723            .map(|i| ShapePoint::new(i as f32, -(i as f32)))
724            .collect();
725        match CssShape::polygon(ShapePointVec::from_vec(big)) {
726            CssShape::Polygon(p) => {
727                assert_eq!(p.points.len(), 10_000);
728                assert_eq!(p.points.as_ref()[9_999], ShapePoint::new(9999.0, -9999.0));
729            }
730            other => panic!("expected Polygon, got {other:?}"),
731        }
732    }
733
734    #[test]
735    fn inset_has_no_border_radius_and_keeps_side_order() {
736        match CssShape::inset(1.0, 2.0, 3.0, 4.0) {
737            CssShape::Inset(i) => {
738                assert_eq!(i.inset_top, 1.0);
739                assert_eq!(i.inset_right, 2.0);
740                assert_eq!(i.inset_bottom, 3.0);
741                assert_eq!(i.inset_left, 4.0);
742                assert_eq!(i.border_radius, OptionF32::None);
743                assert_eq!(i.border_radius.into_option(), None);
744            }
745            other => panic!("expected Inset, got {other:?}"),
746        }
747    }
748
749    #[test]
750    fn inset_accepts_min_max_and_nan_without_panicking() {
751        match CssShape::inset(f32::MIN, f32::MAX, f32::NAN, f32::NEG_INFINITY) {
752            CssShape::Inset(i) => {
753                assert_eq!(i.inset_top, f32::MIN);
754                assert_eq!(i.inset_right, f32::MAX);
755                assert!(i.inset_bottom.is_nan());
756                assert!(i.inset_left.is_infinite());
757            }
758            other => panic!("expected Inset, got {other:?}"),
759        }
760    }
761
762    #[test]
763    fn inset_rounded_keeps_even_a_css_invalid_negative_radius() {
764        match CssShape::inset_rounded(0.0, 0.0, 0.0, 0.0, -5.0) {
765            CssShape::Inset(i) => {
766                assert_eq!(i.border_radius, OptionF32::Some(-5.0));
767                assert_eq!(i.border_radius.into_option(), Some(-5.0));
768            }
769            other => panic!("expected Inset, got {other:?}"),
770        }
771        match CssShape::inset_rounded(0.0, 0.0, 0.0, 0.0, f32::NAN) {
772            CssShape::Inset(i) => match i.border_radius {
773                OptionF32::Some(r) => assert!(r.is_nan()),
774                OptionF32::None => panic!("radius was dropped"),
775            },
776            other => panic!("expected Inset, got {other:?}"),
777        }
778    }
779
780    #[test]
781    fn css_shape_constructors_are_usable_in_const_context() {
782        const CIRCLE: CssShape = CssShape::circle(ShapePoint::zero(), 5.0);
783        const ELLIPSE: CssShape = CssShape::ellipse(ShapePoint::zero(), 1.0, 2.0);
784        const INSET: CssShape = CssShape::inset(0.0, 0.0, 0.0, 0.0);
785        const ROUNDED: CssShape = CssShape::inset_rounded(0.0, 0.0, 0.0, 0.0, 1.0);
786        assert!(matches!(CIRCLE, CssShape::Circle(_)));
787        assert!(matches!(ELLIPSE, CssShape::Ellipse(_)));
788        assert!(matches!(INSET, CssShape::Inset(_)));
789        assert!(matches!(ROUNDED, CssShape::Inset(_)));
790    }
791
792    // ---------------------------------------------------------------------
793    // CssShape Ord / Hash invariants
794    // ---------------------------------------------------------------------
795
796    #[test]
797    fn css_shape_variant_order_is_circle_ellipse_polygon_inset_path() {
798        let shapes = vec![
799            path("M 0 0"),
800            CssShape::inset(1.0, 1.0, 1.0, 1.0),
801            poly(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]),
802            CssShape::ellipse(ShapePoint::zero(), 1.0, 2.0),
803            CssShape::circle(ShapePoint::zero(), 1.0),
804        ];
805        let mut sorted = shapes;
806        sorted.sort(); // finite values only -> total order, must not panic
807        let discriminants: Vec<&str> = sorted
808            .iter()
809            .map(|s| match s {
810                CssShape::Circle(_) => "circle",
811                CssShape::Ellipse(_) => "ellipse",
812                CssShape::Polygon(_) => "polygon",
813                CssShape::Inset(_) => "inset",
814                CssShape::Path(_) => "path",
815            })
816            .collect();
817        assert_eq!(
818            discriminants,
819            vec!["circle", "ellipse", "polygon", "inset", "path"]
820        );
821    }
822
823    #[test]
824    fn css_shape_cmp_is_antisymmetric_across_variants_and_nan() {
825        let shapes = vec![
826            CssShape::circle(ShapePoint::zero(), f32::NAN),
827            CssShape::circle(ShapePoint::new(f32::NAN, 0.0), 1.0),
828            CssShape::ellipse(ShapePoint::zero(), f32::NAN, f32::INFINITY),
829            poly(&[]),
830            poly(&[(f32::NAN, f32::NEG_INFINITY)]),
831            CssShape::inset(f32::NAN, f32::MAX, f32::MIN, -0.0),
832            CssShape::inset_rounded(0.0, 0.0, 0.0, 0.0, f32::NAN),
833            path(""),
834            path("\u{1F600}"),
835        ];
836        for a in &shapes {
837            for b in &shapes {
838                assert_eq!(
839                    a.cmp(b),
840                    b.cmp(a).reverse(),
841                    "antisymmetry broken for {a:?} vs {b:?}"
842                );
843                assert_eq!(a.partial_cmp(b), Some(a.cmp(b)));
844            }
845        }
846    }
847
848    #[test]
849    fn css_shape_hash_separates_variants_with_identical_payloads() {
850        let circle = CssShape::circle(ShapePoint::zero(), 1.0);
851        let ellipse = CssShape::ellipse(ShapePoint::zero(), 1.0, 1.0);
852        assert_ne!(hash_of(&circle), hash_of(&ellipse));
853        assert_eq!(
854            hash_of(&circle),
855            hash_of(&CssShape::circle(ShapePoint::zero(), 1.0))
856        );
857        // Equal values must hash equally (holds for every non-NaN, non -0.0 case).
858        let p1 = poly(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]);
859        let p2 = poly(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]);
860        assert_eq!(p1, p2);
861        assert_eq!(hash_of(&p1), hash_of(&p2));
862    }
863
864    // ---------------------------------------------------------------------
865    // print_as_css_value (getter)
866    // ---------------------------------------------------------------------
867
868    #[test]
869    fn print_as_css_value_known_constructions() {
870        assert_eq!(
871            CssShape::circle(ShapePoint::new(100.0, 100.0), 50.0).print_as_css_value(),
872            "circle(50px at 100px 100px)"
873        );
874        assert_eq!(
875            CssShape::ellipse(ShapePoint::new(1.0, 2.0), 3.0, 4.5).print_as_css_value(),
876            "ellipse(3px 4.5px at 1px 2px)"
877        );
878        assert_eq!(
879            poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]).print_as_css_value(),
880            "polygon(0px 0px, 100px 0px, 100px 100px)"
881        );
882        assert_eq!(
883            CssShape::inset(1.0, 2.0, 3.0, 4.0).print_as_css_value(),
884            "inset(1px 2px 3px 4px)"
885        );
886        assert_eq!(
887            CssShape::inset_rounded(1.0, 2.0, 3.0, 4.0, 5.0).print_as_css_value(),
888            "inset(1px 2px 3px 4px round 5px)"
889        );
890        assert_eq!(
891            path("M 0 0 L 1 1 Z").print_as_css_value(),
892            "path(\"M 0 0 L 1 1 Z\")"
893        );
894    }
895
896    #[test]
897    fn print_as_css_value_empty_polygon_emits_empty_parens() {
898        // Not valid CSS (a polygon needs >= 3 points) but must not panic.
899        assert_eq!(poly(&[]).print_as_css_value(), "polygon()");
900    }
901
902    #[test]
903    fn print_as_css_value_emits_non_css_tokens_for_nan_and_inf() {
904        // Characterization: `NaNpx` / `infpx` are NOT valid CSS lengths. The
905        // printer does not guard against non-finite inputs; see the report.
906        let s = CssShape::circle(
907            ShapePoint::new(f32::INFINITY, f32::NEG_INFINITY),
908            f32::NAN,
909        )
910        .print_as_css_value();
911        assert_eq!(s, "circle(NaNpx at infpx -infpx)");
912    }
913
914    #[test]
915    fn print_as_css_value_handles_extreme_finite_values() {
916        let s = CssShape::inset(f32::MIN, f32::MAX, f32::MIN_POSITIVE, 0.0).print_as_css_value();
917        assert!(s.starts_with("inset(-"), "got {s}");
918        assert!(s.ends_with("px)"), "got {s}");
919        assert!(!s.contains("inf"), "MIN/MAX must not print as inf: {s}");
920    }
921
922    #[test]
923    fn print_as_css_value_survives_a_huge_polygon() {
924        let coords: Vec<(f32, f32)> = (0..5_000).map(|i| (i as f32, i as f32)).collect();
925        let s = poly(&coords).print_as_css_value();
926        assert!(s.starts_with("polygon(0px 0px, "));
927        assert!(s.ends_with("4999px 4999px)"));
928        assert_eq!(s.matches(", ").count(), 4_999);
929    }
930
931    #[test]
932    fn print_as_css_value_does_not_escape_path_data() {
933        // Characterization: a quote inside the path data is emitted raw, so the
934        // printed value is no longer a well-formed CSS string. See the report.
935        let s = path("a\"b").print_as_css_value();
936        assert_eq!(s, "path(\"a\"b\")");
937        // Unicode path data is passed through byte-for-byte.
938        let uni = path("M 0 0 \u{2192} \u{1F600}").print_as_css_value();
939        assert!(uni.contains('\u{1F600}'));
940    }
941
942    // ---------------------------------------------------------------------
943    // format_as_rust_code (serializer)
944    // ---------------------------------------------------------------------
945
946    #[test]
947    fn format_as_rust_code_known_constructions() {
948        assert_eq!(
949            CssShape::circle(ShapePoint::new(1.0, 2.0), 3.0).format_as_rust_code(),
950            "CssShape::Circle(ShapeCircle { center: ShapePoint::new(1_f32, 2_f32), radius: 3_f32 \
951             })"
952        );
953        assert_eq!(
954            CssShape::ellipse(ShapePoint::new(1.0, 2.0), 3.0, 4.0).format_as_rust_code(),
955            "CssShape::Ellipse(ShapeEllipse { center: ShapePoint::new(1_f32, 2_f32), radius_x: \
956             3_f32, radius_y: 4_f32 })"
957        );
958        assert_eq!(
959            CssShape::inset(1.0, 2.0, 3.0, 4.0).format_as_rust_code(),
960            "CssShape::Inset(ShapeInset { inset_top: 1_f32, inset_right: 2_f32, inset_bottom: \
961             3_f32, inset_left: 4_f32, border_radius: OptionF32::None })"
962        );
963        assert!(CssShape::inset_rounded(0.0, 0.0, 0.0, 0.0, 5.0)
964            .format_as_rust_code()
965            .contains("border_radius: OptionF32::Some(5_f32)"));
966        assert_eq!(
967            path("M 0 0").format_as_rust_code(),
968            "CssShape::Path(ShapePath { data: AzString::from_const_str(\"M 0 0\") })"
969        );
970    }
971
972    #[test]
973    fn format_as_rust_code_is_non_empty_for_every_variant() {
974        let shapes = vec![
975            CssShape::circle(ShapePoint::zero(), 0.0),
976            CssShape::ellipse(ShapePoint::zero(), 0.0, 0.0),
977            poly(&[]),
978            CssShape::inset(0.0, 0.0, 0.0, 0.0),
979            path(""),
980        ];
981        for s in &shapes {
982            let code = s.format_as_rust_code();
983            assert!(code.starts_with("CssShape::"), "got {code}");
984            assert!(code.ends_with(')'), "got {code}");
985            assert!(!code.is_empty());
986        }
987    }
988
989    #[test]
990    fn format_as_rust_code_empty_polygon_emits_empty_vec() {
991        assert_eq!(
992            poly(&[]).format_as_rust_code(),
993            "CssShape::Polygon(ShapePolygon { points: vec![].into() })"
994        );
995        assert_eq!(
996            poly(&[(0.0, 0.0), (1.0, 1.0)]).format_as_rust_code(),
997            "CssShape::Polygon(ShapePolygon { points: vec![ShapePoint::new(0_f32, 0_f32), \
998             ShapePoint::new(1_f32, 1_f32)].into() })"
999        );
1000    }
1001
1002    #[test]
1003    fn format_as_rust_code_emits_uncompilable_tokens_for_nan_and_inf() {
1004        // Characterization of a real codegen defect: `NaN_f32` / `inf_f32` are
1005        // not valid Rust literals, so generated code containing a non-finite
1006        // shape does not compile. See the report.
1007        let code = CssShape::circle(ShapePoint::new(f32::INFINITY, f32::NEG_INFINITY), f32::NAN)
1008            .format_as_rust_code();
1009        assert!(code.contains("NaN_f32"), "got {code}");
1010        assert!(code.contains("inf_f32"), "got {code}");
1011        assert!(code.contains("-inf_f32"), "got {code}");
1012    }
1013
1014    #[test]
1015    fn format_as_rust_code_does_not_escape_path_data() {
1016        // Characterization: quotes and backslashes in path data are emitted raw,
1017        // producing uncompilable Rust. See the report.
1018        let code = path("a\"b\\c").format_as_rust_code();
1019        assert!(code.contains("from_const_str(\"a\"b\\c\")"), "got {code}");
1020    }
1021
1022    #[test]
1023    fn format_as_rust_code_survives_extreme_values() {
1024        for &v in EDGE_F32 {
1025            let code = CssShape::inset_rounded(v, v, v, v, v).format_as_rust_code();
1026            assert!(code.starts_with("CssShape::Inset("), "got {code}");
1027        }
1028    }
1029
1030    // ---------------------------------------------------------------------
1031    // Round-trip: print_as_css_value -> shape_parser::parse_shape
1032    // ---------------------------------------------------------------------
1033
1034    #[test]
1035    fn roundtrip_circle_ellipse_inset_and_path() {
1036        for shape in [
1037            CssShape::circle(ShapePoint::new(100.0, -25.5), 50.0),
1038            CssShape::circle(ShapePoint::zero(), 0.0),
1039            CssShape::ellipse(ShapePoint::new(-1.0, 2.0), 3.0, 4.5),
1040            CssShape::inset(1.0, 2.0, 3.0, 4.0),
1041            CssShape::inset(-1.0, -2.0, -3.0, -4.0),
1042            CssShape::inset_rounded(1.0, 2.0, 3.0, 4.0, 5.0),
1043            path("M 0 0 L 100 0 L 100 100 Z"),
1044            path(""),
1045        ] {
1046            assert_eq!(roundtrip(&shape), shape);
1047        }
1048    }
1049
1050    #[test]
1051    fn roundtrip_polygon_needs_at_least_three_points() {
1052        let ok = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
1053        assert_eq!(roundtrip(&ok), ok);
1054
1055        // Printer/parser asymmetry: these print fine but the crate's own parser
1056        // rejects them, so a shape can survive `print` and die on re-parse.
1057        let two = poly(&[(0.0, 0.0), (1.0, 1.0)]).print_as_css_value();
1058        assert!(matches!(
1059            parse_shape(&two),
1060            Err(ShapeParseError::InvalidSyntax(_))
1061        ));
1062        let empty = poly(&[]).print_as_css_value();
1063        assert!(matches!(
1064            parse_shape(&empty),
1065            Err(ShapeParseError::InvalidSyntax(_))
1066        ));
1067    }
1068
1069    #[test]
1070    fn roundtrip_preserves_extreme_finite_floats_bit_for_bit() {
1071        for &v in &[
1072            f32::MIN,
1073            f32::MAX,
1074            f32::MIN_POSITIVE,
1075            f32::EPSILON,
1076            f32::from_bits(1), // smallest positive subnormal
1077            1e-7,
1078            123_456.79,
1079        ] {
1080            let shape = CssShape::circle(ShapePoint::new(v, -v), v);
1081            match roundtrip(&shape) {
1082                CssShape::Circle(c) => {
1083                    assert_eq!(c.radius.to_bits(), v.to_bits(), "radius lost for {v:e}");
1084                    assert_eq!(c.center.x.to_bits(), v.to_bits(), "x lost for {v:e}");
1085                    assert_eq!(c.center.y.to_bits(), (-v).to_bits(), "y lost for {v:e}");
1086                }
1087                other => panic!("expected Circle, got {other:?}"),
1088            }
1089        }
1090    }
1091
1092    #[test]
1093    fn roundtrip_of_non_finite_values_survives_but_is_not_valid_css() {
1094        // Rust's f32 FromStr happens to accept "inf"/"NaN", so azul's own parser
1095        // reads back what its printer emitted -- but no browser would.
1096        match roundtrip(&CssShape::circle(ShapePoint::zero(), f32::INFINITY)) {
1097            CssShape::Circle(c) => assert!(c.radius.is_infinite() && c.radius > 0.0),
1098            other => panic!("expected Circle, got {other:?}"),
1099        }
1100        match roundtrip(&CssShape::circle(ShapePoint::zero(), f32::NAN)) {
1101            CssShape::Circle(c) => assert!(c.radius.is_nan()),
1102            other => panic!("expected Circle, got {other:?}"),
1103        }
1104        match roundtrip(&CssShape::inset(
1105            f32::NEG_INFINITY,
1106            f32::INFINITY,
1107            f32::NAN,
1108            0.0,
1109        )) {
1110            CssShape::Inset(i) => {
1111                assert!(i.inset_top.is_infinite() && i.inset_top < 0.0);
1112                assert!(i.inset_right.is_infinite() && i.inset_right > 0.0);
1113                assert!(i.inset_bottom.is_nan());
1114                assert_eq!(i.inset_left, 0.0);
1115            }
1116            other => panic!("expected Inset, got {other:?}"),
1117        }
1118    }
1119
1120    #[test]
1121    fn roundtrip_path_with_hostile_and_unicode_data() {
1122        // A ')' inside the data is safe (the parser scans with rfind), and so is
1123        // a bare quote (the outer quotes are stripped positionally).
1124        for data in [
1125            "M 0 0)",
1126            "M 0 0 \u{2192} \u{1F600}",
1127            "M\n0\t0",
1128            "a\"b",
1129            "\u{0}",
1130        ] {
1131            let shape = path(data);
1132            assert_eq!(roundtrip(&shape), shape, "path data {data:?} did not survive");
1133        }
1134    }
1135
1136    #[test]
1137    fn roundtrip_huge_polygon() {
1138        let coords: Vec<(f32, f32)> = (0..2_000).map(|i| (i as f32, -(i as f32))).collect();
1139        let shape = poly(&coords);
1140        assert_eq!(roundtrip(&shape), shape);
1141    }
1142}
1143