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