Skip to main content

azul_css/props/style/
box_shadow.rs

1//! Shared types for CSS shadow properties (used by both `box-shadow` and `text-shadow`).
2
3use crate::corety::AzString;
4use alloc::string::{String, ToString};
5use core::fmt;
6
7use crate::props::{
8    basic::{
9        color::{parse_css_color, ColorU, CssColorParseError, CssColorParseErrorOwned},
10        pixel::{
11            parse_pixel_value_no_percent, CssPixelValueParseError, CssPixelValueParseErrorOwned,
12            PixelValueNoPercent,
13        },
14    },
15    formatter::PrintAsCssValue,
16};
17
18/// What direction should a `box-shadow` be clipped in (inset or outset).
19#[derive(Debug, Default, Copy, Clone, PartialEq, Ord, PartialOrd, Eq, Hash)]
20#[repr(C)]
21pub enum BoxShadowClipMode {
22    #[default]
23    Outset,
24    Inset,
25}
26
27impl fmt::Display for BoxShadowClipMode {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::Outset => Ok(()), // Outset is the default, not written
31            Self::Inset => write!(f, "inset"),
32        }
33    }
34}
35
36/// Represents a single CSS shadow value, shared by both `box-shadow` and `text-shadow`.
37#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
38#[repr(C)]
39pub struct StyleBoxShadow {
40    pub offset_x: PixelValueNoPercent,
41    pub offset_y: PixelValueNoPercent,
42    pub blur_radius: PixelValueNoPercent,
43    pub spread_radius: PixelValueNoPercent,
44    pub clip_mode: BoxShadowClipMode,
45    pub color: ColorU,
46}
47
48impl Default for StyleBoxShadow {
49    fn default() -> Self {
50        Self {
51            offset_x: PixelValueNoPercent::default(),
52            offset_y: PixelValueNoPercent::default(),
53            blur_radius: PixelValueNoPercent::default(),
54            spread_radius: PixelValueNoPercent::default(),
55            clip_mode: BoxShadowClipMode::default(),
56            color: ColorU::BLACK,
57        }
58    }
59}
60
61impl StyleBoxShadow {
62    /// Scales the pixel values of the shadow for a given DPI factor.
63    pub fn scale_for_dpi(&mut self, scale_factor: f32) {
64        self.offset_x.scale_for_dpi(scale_factor);
65        self.offset_y.scale_for_dpi(scale_factor);
66        self.blur_radius.scale_for_dpi(scale_factor);
67        self.spread_radius.scale_for_dpi(scale_factor);
68    }
69}
70
71impl PrintAsCssValue for StyleBoxShadow {
72    fn print_as_css_value(&self) -> String {
73        let mut components = Vec::new();
74
75        if self.clip_mode == BoxShadowClipMode::Inset {
76            components.push("inset".to_string());
77        }
78        components.push(self.offset_x.to_string());
79        components.push(self.offset_y.to_string());
80
81        // Only print blur, spread, and color if they are not default, for brevity
82        if self.blur_radius.inner.number.get() != 0.0
83            || self.spread_radius.inner.number.get() != 0.0
84        {
85            components.push(self.blur_radius.to_string());
86        }
87        if self.spread_radius.inner.number.get() != 0.0 {
88            components.push(self.spread_radius.to_string());
89        }
90        if self.color != ColorU::BLACK {
91            // Assuming black is the default
92            components.push(self.color.to_hash());
93        }
94
95        components.join(" ")
96    }
97}
98
99// Formatting to Rust code for StyleBoxShadow
100impl crate::codegen::format::FormatAsRustCode for StyleBoxShadow {
101    fn format_as_rust_code(&self, tabs: usize) -> String {
102        let t = String::from("    ").repeat(tabs);
103        format!(
104            "StyleBoxShadow {{\r\n{}    offset_x: {},\r\n{}    offset_y: {},\r\n{}    color: \
105             {},\r\n{}    blur_radius: {},\r\n{}    spread_radius: {},\r\n{}    clip_mode: \
106             BoxShadowClipMode::{:?},\r\n{}}}",
107            t,
108            crate::codegen::format::format_pixel_value_no_percent(&self.offset_x),
109            t,
110            crate::codegen::format::format_pixel_value_no_percent(&self.offset_y),
111            t,
112            crate::codegen::format::format_color_value(&self.color),
113            t,
114            crate::codegen::format::format_pixel_value_no_percent(&self.blur_radius),
115            t,
116            crate::codegen::format::format_pixel_value_no_percent(&self.spread_radius),
117            t,
118            self.clip_mode,
119            t
120        )
121    }
122}
123
124// --- PARSER ---
125
126/// Error returned when parsing a CSS shadow value fails.
127#[derive(Clone, PartialEq)]
128pub enum CssShadowParseError<'a> {
129    TooManyOrTooFewComponents(&'a str),
130    ValueParseErr(CssPixelValueParseError<'a>),
131    ColorParseError(CssColorParseError<'a>),
132}
133
134impl_debug_as_display!(CssShadowParseError<'a>);
135impl_display! { CssShadowParseError<'a>, {
136    TooManyOrTooFewComponents(e) => format!("Expected 2 to 4 length values for box-shadow, found an invalid number of components in: \"{}\"", e),
137    ValueParseErr(e) => format!("Invalid length value in box-shadow: {}", e),
138    ColorParseError(e) => format!("Invalid color value in box-shadow: {}", e),
139}}
140
141impl_from!(
142    CssPixelValueParseError<'a>,
143    CssShadowParseError::ValueParseErr
144);
145impl_from!(CssColorParseError<'a>, CssShadowParseError::ColorParseError);
146
147/// Owned version of `CssShadowParseError`.
148#[derive(Debug, Clone, PartialEq)]
149#[repr(C, u8)]
150pub enum CssShadowParseErrorOwned {
151    TooManyOrTooFewComponents(AzString),
152    ValueParseErr(CssPixelValueParseErrorOwned),
153    ColorParseError(CssColorParseErrorOwned),
154}
155
156impl CssShadowParseError<'_> {
157    /// Converts the borrowed error into an owned version for storage.
158    #[must_use]
159    pub fn to_contained(&self) -> CssShadowParseErrorOwned {
160        match self {
161            CssShadowParseError::TooManyOrTooFewComponents(s) => {
162                CssShadowParseErrorOwned::TooManyOrTooFewComponents((*s).to_string().into())
163            }
164            CssShadowParseError::ValueParseErr(e) => {
165                CssShadowParseErrorOwned::ValueParseErr(e.to_contained())
166            }
167            CssShadowParseError::ColorParseError(e) => {
168                CssShadowParseErrorOwned::ColorParseError(e.to_contained())
169            }
170        }
171    }
172}
173
174impl CssShadowParseErrorOwned {
175    /// Converts the owned error back into a borrowed version.
176    #[must_use]
177    pub fn to_shared(&self) -> CssShadowParseError<'_> {
178        match self {
179            Self::TooManyOrTooFewComponents(s) => {
180                CssShadowParseError::TooManyOrTooFewComponents(s.as_str())
181            }
182            Self::ValueParseErr(e) => CssShadowParseError::ValueParseErr(e.to_shared()),
183            Self::ColorParseError(e) => CssShadowParseError::ColorParseError(e.to_shared()),
184        }
185    }
186}
187
188/// Parses a CSS box-shadow, such as `"5px 10px #888 inset"`.
189///
190/// Note: This parser does not handle the `none` keyword, as that is handled by the
191/// `CssPropertyValue` enum wrapper. It also does not handle comma-separated lists
192/// of multiple shadows; it only parses a single shadow value.
193#[cfg(feature = "parser")]
194/// # Errors
195///
196/// Returns an error if `input` is not a valid CSS `box-shadow` value.
197pub fn parse_style_box_shadow(input: &str) -> Result<StyleBoxShadow, CssShadowParseError<'_>> {
198    let mut parts: Vec<&str> = input.split_whitespace().collect();
199    let mut shadow = StyleBoxShadow::default();
200
201    // The `inset` keyword can appear anywhere. Find it, set the flag, and remove it.
202    if let Some(pos) = parts.iter().position(|&p| p == "inset") {
203        shadow.clip_mode = BoxShadowClipMode::Inset;
204        parts.remove(pos);
205    }
206
207    // The color can also be anywhere. Find it, set the color, and remove it.
208    // It's the only part that isn't a length. We iterate from the back because
209    // it's slightly more common for the color to be last.
210    if let Some((pos, color)) = parts
211        .iter()
212        .enumerate()
213        .rev()
214        .find_map(|(i, p)| parse_css_color(p).ok().map(|c| (i, c)))
215    {
216        shadow.color = color;
217        parts.remove(pos);
218    }
219
220    // The remaining parts must be 2, 3, or 4 length values.
221    match parts.len() {
222        2..=4 => {
223            shadow.offset_x = parse_pixel_value_no_percent(parts[0])?;
224            shadow.offset_y = parse_pixel_value_no_percent(parts[1])?;
225            if parts.len() > 2 {
226                shadow.blur_radius = parse_pixel_value_no_percent(parts[2])?;
227            }
228            if parts.len() > 3 {
229                shadow.spread_radius = parse_pixel_value_no_percent(parts[3])?;
230            }
231        }
232        _ => return Err(CssShadowParseError::TooManyOrTooFewComponents(input)),
233    }
234
235    Ok(shadow)
236}
237
238#[cfg(all(test, feature = "parser"))]
239mod tests {
240    use super::*;
241    use crate::props::basic::pixel::PixelValue;
242
243    fn px_no_percent(val: f32) -> PixelValueNoPercent {
244        PixelValueNoPercent {
245            inner: PixelValue::px(val),
246        }
247    }
248
249    #[test]
250    fn test_parse_box_shadow_simple() {
251        let result = parse_style_box_shadow("10px 5px").unwrap();
252        assert_eq!(result.offset_x, px_no_percent(10.0));
253        assert_eq!(result.offset_y, px_no_percent(5.0));
254        assert_eq!(result.blur_radius, px_no_percent(0.0));
255        assert_eq!(result.spread_radius, px_no_percent(0.0));
256        assert_eq!(result.color, ColorU::BLACK);
257        assert_eq!(result.clip_mode, BoxShadowClipMode::Outset);
258    }
259
260    #[test]
261    fn test_parse_box_shadow_with_color() {
262        let result = parse_style_box_shadow("10px 5px #888").unwrap();
263        assert_eq!(result.offset_x, px_no_percent(10.0));
264        assert_eq!(result.offset_y, px_no_percent(5.0));
265        assert_eq!(result.color, ColorU::new_rgb(0x88, 0x88, 0x88));
266    }
267
268    #[test]
269    fn test_parse_box_shadow_with_blur() {
270        let result = parse_style_box_shadow("5px 10px 20px").unwrap();
271        assert_eq!(result.offset_x, px_no_percent(5.0));
272        assert_eq!(result.offset_y, px_no_percent(10.0));
273        assert_eq!(result.blur_radius, px_no_percent(20.0));
274    }
275
276    #[test]
277    fn test_parse_box_shadow_with_spread() {
278        let result = parse_style_box_shadow("2px 2px 2px 1px rgba(0,0,0,0.2)").unwrap();
279        assert_eq!(result.offset_x, px_no_percent(2.0));
280        assert_eq!(result.offset_y, px_no_percent(2.0));
281        assert_eq!(result.blur_radius, px_no_percent(2.0));
282        assert_eq!(result.spread_radius, px_no_percent(1.0));
283        assert_eq!(result.color, ColorU::new(0, 0, 0, 51));
284    }
285
286    #[test]
287    fn test_parse_box_shadow_inset() {
288        let result = parse_style_box_shadow("inset 0 0 10px #000").unwrap();
289        assert_eq!(result.clip_mode, BoxShadowClipMode::Inset);
290        assert_eq!(result.offset_x, px_no_percent(0.0));
291        assert_eq!(result.offset_y, px_no_percent(0.0));
292        assert_eq!(result.blur_radius, px_no_percent(10.0));
293        assert_eq!(result.color, ColorU::BLACK);
294    }
295
296    #[test]
297    fn test_parse_box_shadow_mixed_order() {
298        let result = parse_style_box_shadow("5px 1em red inset").unwrap();
299        assert_eq!(result.clip_mode, BoxShadowClipMode::Inset);
300        assert_eq!(result.offset_x, px_no_percent(5.0));
301        assert_eq!(
302            result.offset_y,
303            PixelValueNoPercent {
304                inner: PixelValue::em(1.0)
305            }
306        );
307        assert_eq!(result.color, ColorU::RED);
308    }
309
310    #[test]
311    fn test_parse_box_shadow_invalid() {
312        assert!(parse_style_box_shadow("10px").is_err());
313        assert!(parse_style_box_shadow("10px 5px 4px 3px 2px").is_err());
314        // Two colors: rposition picks "blue" as the color, leaving "red" which
315        // fails to parse as a pixel value.
316        assert!(parse_style_box_shadow("10px 5px red blue").is_err());
317        assert!(parse_style_box_shadow("10% 5px").is_err()); // No percent allowed
318    }
319}
320
321#[cfg(all(test, feature = "parser"))]
322mod autotest_generated {
323    use super::*;
324    use crate::{
325        codegen::format::FormatAsRustCode,
326        props::basic::{
327            pixel::{CssPixelValueParseError, PixelValue},
328            SizeMetric,
329        },
330    };
331
332    fn px(val: f32) -> PixelValueNoPercent {
333        PixelValueNoPercent {
334            inner: PixelValue::px(val),
335        }
336    }
337
338    const fn shadow(
339        offset_x: PixelValueNoPercent,
340        offset_y: PixelValueNoPercent,
341        blur_radius: PixelValueNoPercent,
342        spread_radius: PixelValueNoPercent,
343        clip_mode: BoxShadowClipMode,
344        color: ColorU,
345    ) -> StyleBoxShadow {
346        StyleBoxShadow {
347            offset_x,
348            offset_y,
349            blur_radius,
350            spread_radius,
351            clip_mode,
352            color,
353        }
354    }
355
356    /// Every component of a shadow, as raw f32s.
357    fn numbers(s: &StyleBoxShadow) -> [f32; 4] {
358        [
359            s.offset_x.inner.number.get(),
360            s.offset_y.inner.number.get(),
361            s.blur_radius.inner.number.get(),
362            s.spread_radius.inner.number.get(),
363        ]
364    }
365
366    /// A representative corpus of shadows that are exactly representable in the
367    /// fixed-point `FloatValue` encoding (<= 3 decimal places).
368    fn round_trip_corpus() -> Vec<StyleBoxShadow> {
369        vec![
370            StyleBoxShadow::default(),
371            shadow(
372                px(1.0),
373                px(2.0),
374                px(0.0),
375                px(0.0),
376                BoxShadowClipMode::Outset,
377                ColorU::BLACK,
378            ),
379            // blur only
380            shadow(
381                px(1.0),
382                px(2.0),
383                px(3.0),
384                px(0.0),
385                BoxShadowClipMode::Outset,
386                ColorU::BLACK,
387            ),
388            // spread only: forces a "0px" blur to be printed as a placeholder
389            shadow(
390                px(1.0),
391                px(2.0),
392                px(0.0),
393                px(4.0),
394                BoxShadowClipMode::Outset,
395                ColorU::BLACK,
396            ),
397            // negative offsets + inset + named color
398            shadow(
399                px(-5.5),
400                px(-7.25),
401                px(2.5),
402                px(1.125),
403                BoxShadowClipMode::Inset,
404                ColorU::RED,
405            ),
406            // non-px metric
407            shadow(
408                PixelValueNoPercent {
409                    inner: PixelValue::em(1.5),
410                },
411                PixelValueNoPercent {
412                    inner: PixelValue::pt(2.0),
413                },
414                px(0.0),
415                px(0.0),
416                BoxShadowClipMode::Inset,
417                ColorU::BLACK,
418            ),
419            // fully transparent black -- differs from ColorU::BLACK only in alpha
420            shadow(
421                px(0.0),
422                px(0.0),
423                px(0.0),
424                px(0.0),
425                BoxShadowClipMode::Outset,
426                ColorU::new(0, 0, 0, 0),
427            ),
428            // every channel distinct, incl. a non-opaque alpha
429            shadow(
430                px(0.0),
431                px(0.0),
432                px(9.0),
433                px(0.0),
434                BoxShadowClipMode::Inset,
435                ColorU::new(1, 2, 3, 4),
436            ),
437        ]
438    }
439
440    // ---------------------------------------------------------------
441    // serializer: BoxShadowClipMode::fmt
442    // ---------------------------------------------------------------
443
444    #[test]
445    fn clip_mode_display_outset_is_empty_inset_is_keyword() {
446        // Outset is the CSS default and is deliberately NOT written out.
447        assert_eq!(BoxShadowClipMode::Outset.to_string(), "");
448        assert_eq!(BoxShadowClipMode::Inset.to_string(), "inset");
449    }
450
451    #[test]
452    fn clip_mode_display_default_does_not_panic() {
453        let default: BoxShadowClipMode = Default::default();
454        assert_eq!(default, BoxShadowClipMode::Outset);
455        assert_eq!(default.to_string(), "");
456        // Debug (derived) must stay non-empty even though Display is empty.
457        assert_eq!(format!("{:?}", BoxShadowClipMode::Outset), "Outset");
458        assert_eq!(format!("{:?}", BoxShadowClipMode::Inset), "Inset");
459    }
460
461    #[test]
462    fn clip_mode_display_with_format_flags_does_not_panic() {
463        // The impl writes straight to the formatter, so width/fill/precision are
464        // ignored rather than applied -- assert only that nothing panics and the
465        // keyword survives.
466        assert!(format!("{:>16}", BoxShadowClipMode::Inset).contains("inset"));
467        assert!(format!("{:*<16}", BoxShadowClipMode::Inset).contains("inset"));
468        assert!(format!("{:.2}", BoxShadowClipMode::Inset).contains("inset"));
469        // Outset writes nothing at all, whatever the flags.
470        assert_eq!(format!("{:>16}", BoxShadowClipMode::Outset), "");
471    }
472
473    #[test]
474    fn clip_mode_display_is_repeatable_and_ordered() {
475        // Same value must serialize identically every time.
476        for _ in 0..4 {
477            assert_eq!(BoxShadowClipMode::Inset.to_string(), "inset");
478        }
479        // Derived Ord: the default variant sorts first.
480        assert!(BoxShadowClipMode::Outset < BoxShadowClipMode::Inset);
481    }
482
483    // ---------------------------------------------------------------
484    // numeric: StyleBoxShadow::scale_for_dpi
485    // ---------------------------------------------------------------
486
487    fn scaled(mut s: StyleBoxShadow, factor: f32) -> StyleBoxShadow {
488        s.scale_for_dpi(factor);
489        s
490    }
491
492    fn all_ones() -> StyleBoxShadow {
493        shadow(
494            px(1.0),
495            px(2.0),
496            px(4.0),
497            px(8.0),
498            BoxShadowClipMode::Inset,
499            ColorU::RED,
500        )
501    }
502
503    #[test]
504    fn scale_for_dpi_zero_zeroes_every_length() {
505        let s = scaled(all_ones(), 0.0);
506        assert_eq!(numbers(&s), [0.0, 0.0, 0.0, 0.0]);
507        // -0.0 must not leak a negative zero into the fixed-point encoding.
508        let neg = scaled(all_ones(), -0.0);
509        assert_eq!(numbers(&neg), [0.0, 0.0, 0.0, 0.0]);
510        assert_eq!(neg.offset_x.inner.number.number(), 0);
511    }
512
513    #[test]
514    fn scale_for_dpi_identity_and_double() {
515        let s = scaled(all_ones(), 1.0);
516        assert_eq!(numbers(&s), [1.0, 2.0, 4.0, 8.0]);
517        let d = scaled(all_ones(), 2.0);
518        assert_eq!(numbers(&d), [2.0, 4.0, 8.0, 16.0]);
519        let h = scaled(all_ones(), 0.5);
520        assert_eq!(numbers(&h), [0.5, 1.0, 2.0, 4.0]);
521    }
522
523    #[test]
524    fn scale_for_dpi_negative_flips_sign_deterministically() {
525        let s = scaled(all_ones(), -1.5);
526        assert_eq!(numbers(&s), [-1.5, -3.0, -6.0, -12.0]);
527    }
528
529    #[test]
530    fn scale_for_dpi_nan_yields_zero_never_nan() {
531        // `f32 as isize` saturates and maps NaN -> 0, so a NaN scale factor
532        // collapses the shadow to zero instead of poisoning it with NaN.
533        let s = scaled(all_ones(), f32::NAN);
534        for n in numbers(&s) {
535            assert!(!n.is_nan(), "NaN leaked into the fixed-point encoding");
536            assert_eq!(n, 0.0);
537        }
538    }
539
540    #[test]
541    fn scale_for_dpi_infinity_saturates_to_finite() {
542        let pos = scaled(all_ones(), f32::INFINITY);
543        for n in numbers(&pos) {
544            assert!(n.is_finite(), "+inf scale produced a non-finite value");
545            assert!(n > 0.0);
546        }
547
548        let neg = scaled(all_ones(), f32::NEG_INFINITY);
549        for n in numbers(&neg) {
550            assert!(n.is_finite(), "-inf scale produced a non-finite value");
551            assert!(n < 0.0);
552        }
553    }
554
555    #[test]
556    fn scale_for_dpi_float_extremes_do_not_panic() {
557        // MAX: 1.0 * f32::MAX * 1000.0 overflows f32 -> inf -> saturates on cast.
558        for n in numbers(&scaled(all_ones(), f32::MAX)) {
559            assert!(n.is_finite());
560            assert!(n > 0.0);
561        }
562        for n in numbers(&scaled(all_ones(), f32::MIN)) {
563            assert!(n.is_finite());
564            assert!(n < 0.0);
565        }
566        // Subnormal / tiny factors underflow to exactly zero (3-decimal precision).
567        assert_eq!(numbers(&scaled(all_ones(), f32::MIN_POSITIVE)), [0.0; 4]);
568        assert_eq!(numbers(&scaled(all_ones(), f32::EPSILON)), [0.0; 4]);
569    }
570
571    #[test]
572    fn scale_for_dpi_saturation_is_stable_under_repetition() {
573        // Scaling an already-saturated shadow again must stay finite (no panic,
574        // no NaN, no wraparound to the opposite sign).
575        let mut s = all_ones();
576        for _ in 0..8 {
577            s.scale_for_dpi(1e30);
578            for n in numbers(&s) {
579                assert!(n.is_finite());
580                assert!(n > 0.0, "saturating scale wrapped to a negative value");
581            }
582        }
583    }
584
585    #[test]
586    fn scale_for_dpi_quantizes_below_the_fixed_point_precision() {
587        // FloatValue keeps 3 decimals; anything smaller truncates to zero.
588        let s = scaled(all_ones(), 0.0001);
589        assert_eq!(numbers(&s), [0.0, 0.0, 0.0, 0.0]);
590    }
591
592    #[test]
593    fn scale_for_dpi_on_default_is_a_noop() {
594        for factor in [0.0, 1.0, 2.0, -3.0, f32::NAN, f32::INFINITY, f32::MAX] {
595            let s = scaled(StyleBoxShadow::default(), factor);
596            assert_eq!(
597                numbers(&s),
598                [0.0, 0.0, 0.0, 0.0],
599                "default shadow changed under scale {factor}"
600            );
601        }
602    }
603
604    #[test]
605    fn scale_for_dpi_preserves_metric_color_and_clip_mode() {
606        let mut s = shadow(
607            PixelValueNoPercent {
608                inner: PixelValue::em(2.0),
609            },
610            PixelValueNoPercent {
611                inner: PixelValue::pt(3.0),
612            },
613            PixelValueNoPercent {
614                inner: PixelValue::rem(4.0),
615            },
616            px(5.0),
617            BoxShadowClipMode::Inset,
618            ColorU::new(1, 2, 3, 4),
619        );
620        s.scale_for_dpi(2.0);
621
622        // Only the *numbers* are scaled -- units are never converted.
623        assert_eq!(s.offset_x.inner.metric, SizeMetric::Em);
624        assert_eq!(s.offset_y.inner.metric, SizeMetric::Pt);
625        assert_eq!(s.blur_radius.inner.metric, SizeMetric::Rem);
626        assert_eq!(s.spread_radius.inner.metric, SizeMetric::Px);
627        assert_eq!(numbers(&s), [4.0, 6.0, 8.0, 10.0]);
628
629        // ... and the non-numeric fields are untouched.
630        assert_eq!(s.clip_mode, BoxShadowClipMode::Inset);
631        assert_eq!(s.color, ColorU::new(1, 2, 3, 4));
632    }
633
634    // ---------------------------------------------------------------
635    // getters: CssShadowParseError::to_contained / ..Owned::to_shared
636    // ---------------------------------------------------------------
637
638    /// One error of each `CssShadowParseError` variant.
639    fn error_corpus() -> Vec<CssShadowParseError<'static>> {
640        vec![
641            CssShadowParseError::TooManyOrTooFewComponents("1px"),
642            CssShadowParseError::TooManyOrTooFewComponents(""),
643            CssShadowParseError::TooManyOrTooFewComponents("\u{1F600}\u{0301} \u{202E}"),
644            CssShadowParseError::ValueParseErr(CssPixelValueParseError::EmptyString),
645            CssShadowParseError::ValueParseErr(CssPixelValueParseError::InvalidPixelValue("abc")),
646            CssShadowParseError::ValueParseErr(CssPixelValueParseError::NoValueGiven(
647                "px",
648                SizeMetric::Px,
649            )),
650            CssShadowParseError::ValueParseErr(CssPixelValueParseError::ValueParseErr(
651                "x".parse::<f32>().unwrap_err(),
652                "x",
653            )),
654            CssShadowParseError::ValueParseErr(CssPixelValueParseError::ValueParseErr(
655                "".parse::<f32>().unwrap_err(),
656                "",
657            )),
658            CssShadowParseError::ColorParseError(parse_css_color("notacolor").unwrap_err()),
659            CssShadowParseError::ColorParseError(parse_css_color("#gg").unwrap_err()),
660            CssShadowParseError::ColorParseError(parse_css_color("rgb(1,2").unwrap_err()),
661        ]
662    }
663
664    #[test]
665    fn shadow_error_to_contained_to_shared_is_lossless() {
666        for e in error_corpus() {
667            let owned = e.to_contained();
668            assert_eq!(owned.to_shared(), e, "borrow -> own -> borrow lost data");
669        }
670    }
671
672    #[test]
673    fn shadow_error_owned_to_shared_to_contained_is_lossless() {
674        for e in error_corpus() {
675            let owned = e.to_contained();
676            assert_eq!(
677                owned.to_shared().to_contained(),
678                owned,
679                "own -> borrow -> own lost data"
680            );
681        }
682    }
683
684    #[test]
685    fn shadow_error_to_contained_preserves_the_input_string_verbatim() {
686        for input in [
687            "",
688            "   ",
689            "\u{1F600}",
690            "e\u{0301}\u{0301}\u{0301}",
691            "10px 5px 4px 3px 2px",
692        ] {
693            let owned = CssShadowParseError::TooManyOrTooFewComponents(input).to_contained();
694            match owned {
695                CssShadowParseErrorOwned::TooManyOrTooFewComponents(s) => {
696                    assert_eq!(s.as_str(), input);
697                }
698                other => panic!("wrong variant: {other:?}"),
699            }
700        }
701    }
702
703    #[test]
704    fn shadow_error_to_contained_survives_a_very_long_input() {
705        let long = "x".repeat(200_000);
706        let owned = CssShadowParseError::TooManyOrTooFewComponents(&long).to_contained();
707        match owned.to_shared() {
708            CssShadowParseError::TooManyOrTooFewComponents(s) => {
709                assert_eq!(s.len(), 200_000);
710            }
711            other => panic!("wrong variant: {other:?}"),
712        }
713    }
714
715    #[test]
716    fn shadow_error_display_and_debug_are_non_empty() {
717        for e in error_corpus() {
718            assert!(!e.to_string().is_empty(), "empty Display for {e:?}");
719            // Debug is implemented as Display for this type.
720            assert_eq!(format!("{e:?}"), e.to_string());
721            assert!(!format!("{:?}", e.to_contained()).is_empty());
722        }
723    }
724
725    #[test]
726    fn shadow_error_to_contained_round_trips_real_parser_errors() {
727        // Errors as they are actually produced by the parser, not hand-built.
728        for bad in ["", "1px", "abc 1px", "10% 5px", "1 2 3 4 5"] {
729            let e = parse_style_box_shadow(bad).unwrap_err();
730            assert_eq!(e.to_contained().to_shared(), e);
731        }
732    }
733
734    // ---------------------------------------------------------------
735    // parser: parse_style_box_shadow -- malformed / boundary / unicode
736    // ---------------------------------------------------------------
737
738    #[test]
739    fn parse_valid_minimal_positive_control() {
740        let s = parse_style_box_shadow("10px 5px").unwrap();
741        assert_eq!(
742            s,
743            shadow(
744                px(10.0),
745                px(5.0),
746                px(0.0),
747                px(0.0),
748                BoxShadowClipMode::Outset,
749                ColorU::BLACK,
750            )
751        );
752    }
753
754    #[test]
755    fn parse_empty_and_whitespace_only_input_is_err() {
756        for input in [
757            "", " ", "   ", "\t", "\n", "\t\n\r ", "\u{00a0}", "\u{3000}",
758        ] {
759            let e = parse_style_box_shadow(input).unwrap_err();
760            assert_eq!(
761                e,
762                CssShadowParseError::TooManyOrTooFewComponents(input),
763                "unexpected error for {input:?}"
764            );
765        }
766    }
767
768    #[test]
769    fn parse_garbage_is_err_and_never_panics() {
770        for input in [
771            "!!!",
772            "@#$%^&*",
773            "; drop table",
774            "{}{}{}",
775            "\\\\\\",
776            "\0\0",
777            "1px",                 // too few
778            "1px 2px 3px 4px 5px", // too many
779            "inset",               // keyword only
780            "red",                 // color only
781            "inset red",           // keyword + color, no lengths
782            "inset red 1px",       // only one length left
783            "1px 2px red blue",    // two colors: "red" is left over as a length
784            "inset inset 1px 2px", // second "inset" is not removed
785            "10% 5px",             // percent is rejected
786            "1px 10%",
787            "1px 2px 3%",
788            "px px",
789            "in in",
790            "1px 2px 3px 4px 5px red inset",
791        ] {
792            assert!(
793                parse_style_box_shadow(input).is_err(),
794                "expected Err for {input:?}"
795            );
796        }
797    }
798
799    #[test]
800    fn parse_leading_trailing_junk_is_trimmed_or_rejected_deterministically() {
801        // Surrounding whitespace is absorbed by split_whitespace.
802        let padded = parse_style_box_shadow("   10px    5px   ").unwrap();
803        assert_eq!(padded, parse_style_box_shadow("10px 5px").unwrap());
804
805        // Trailing punctuation is NOT stripped -- it makes the length unparseable.
806        for input in ["10px 5px;", "10px, 5px", "10px 5px !important", "10px 5px}"] {
807            let e = parse_style_box_shadow(input).unwrap_err();
808            assert!(
809                matches!(e, CssShadowParseError::ValueParseErr(_))
810                    || matches!(e, CssShadowParseError::TooManyOrTooFewComponents(_)),
811                "unexpected error kind for {input:?}: {e:?}"
812            );
813        }
814    }
815
816    #[test]
817    fn parse_component_count_boundaries() {
818        assert!(parse_style_box_shadow("1px").is_err()); // 1 -> too few
819        assert!(parse_style_box_shadow("1px 2px").is_ok()); // 2 -> ok
820        assert!(parse_style_box_shadow("1px 2px 3px").is_ok()); // 3 -> ok
821        assert!(parse_style_box_shadow("1px 2px 3px 4px").is_ok()); // 4 -> ok
822        assert!(parse_style_box_shadow("1px 2px 3px 4px 5px").is_err()); // 5 -> too many
823
824        // The `inset` keyword and the color do not count toward the 2..=4 budget.
825        assert!(parse_style_box_shadow("inset red 1px 2px 3px 4px").is_ok());
826        // ... but five lengths are still too many, even with them present.
827        assert!(parse_style_box_shadow("inset red 1px 2px 3px 4px 5px").is_err());
828    }
829
830    #[test]
831    fn parse_is_insensitive_to_keyword_and_color_position() {
832        let expected = shadow(
833            px(1.0),
834            px(2.0),
835            px(0.0),
836            px(0.0),
837            BoxShadowClipMode::Inset,
838            ColorU::RED,
839        );
840        for input in [
841            "inset red 1px 2px",
842            "red inset 1px 2px",
843            "1px 2px red inset",
844            "1px 2px inset red",
845            "red 1px 2px inset",
846            "1px red 2px inset",
847            "inset 1px red 2px",
848        ] {
849            assert_eq!(
850                parse_style_box_shadow(input).unwrap(),
851                expected,
852                "position of inset/color changed the result for {input:?}"
853            );
854        }
855    }
856
857    #[test]
858    fn parse_accepts_every_color_syntax() {
859        for (input, expected) in [
860            ("1px 2px #888", ColorU::new_rgb(0x88, 0x88, 0x88)),
861            ("1px 2px #ff0000", ColorU::RED),
862            ("1px 2px #ff0000ff", ColorU::RED),
863            ("1px 2px #f00f", ColorU::RED),
864            ("1px 2px rgb(255,0,0)", ColorU::RED),
865            ("1px 2px rgba(255,0,0,1.0)", ColorU::RED),
866            ("1px 2px red", ColorU::RED),
867            ("1px 2px RED", ColorU::RED),
868            ("1px 2px transparent", ColorU::new(0, 0, 0, 0)),
869        ] {
870            let s = parse_style_box_shadow(input).unwrap();
871            assert_eq!(s.color, expected, "wrong color for {input:?}");
872            assert_eq!(s.offset_x, px(1.0));
873            assert_eq!(s.offset_y, px(2.0));
874        }
875    }
876
877    #[test]
878    fn parse_bare_zero_is_a_pixel_value_not_a_color() {
879        let s = parse_style_box_shadow("0 0").unwrap();
880        assert_eq!(s.offset_x, px(0.0));
881        assert_eq!(s.offset_y, px(0.0));
882        assert_eq!(s.offset_x.inner.metric, SizeMetric::Px);
883        assert_eq!(
884            s.color,
885            ColorU::BLACK,
886            "a bare 0 was eaten by the color parser"
887        );
888    }
889
890    #[test]
891    fn parse_boundary_numbers_are_finite_and_defined() {
892        // Signed zeroes collapse to a single +0 in the fixed-point encoding.
893        let zeroes = parse_style_box_shadow("-0 -0").unwrap();
894        assert_eq!(numbers(&zeroes)[0], 0.0);
895        assert_eq!(numbers(&zeroes)[1], 0.0);
896        assert!(!numbers(&zeroes)[0].is_sign_negative());
897
898        // Huge finite values saturate on the f32 -> isize cast rather than wrap.
899        for input in [
900            "1e30px 1e30px",
901            "9223372036854775807px 9223372036854775807px", // i64::MAX
902            "340282350000000000000000000000000000000px 1px", // ~f32::MAX
903        ] {
904            let s = parse_style_box_shadow(input).unwrap();
905            let n = numbers(&s)[0];
906            assert!(n.is_finite(), "{input:?} produced a non-finite offset");
907            assert!(n > 0.0, "{input:?} saturated to the wrong sign");
908        }
909        let neg = parse_style_box_shadow("-1e30px 1px").unwrap();
910        assert!(numbers(&neg)[0].is_finite() && numbers(&neg)[0] < 0.0);
911
912        // Tiny values underflow to exactly zero (3 decimals of precision).
913        let tiny = parse_style_box_shadow("1e-30px 0.00001px").unwrap();
914        assert_eq!(numbers(&tiny)[0], 0.0);
915        assert_eq!(numbers(&tiny)[1], 0.0);
916    }
917
918    #[test]
919    fn parse_nan_and_infinity_tokens_are_defined_not_poisonous() {
920        // f32::from_str accepts "NaN"/"inf", so these reach the fixed-point cast.
921        // NaN as isize == 0, so the value collapses to zero rather than staying NaN.
922        let nan = parse_style_box_shadow("NaN NaN").unwrap();
923        for n in numbers(&nan) {
924            assert!(!n.is_nan(), "NaN survived into a parsed shadow");
925            assert_eq!(n, 0.0);
926        }
927        let nan_px = parse_style_box_shadow("NaNpx 1px").unwrap();
928        assert_eq!(numbers(&nan_px)[0], 0.0);
929
930        // inf saturates to isize::MAX / 1000 -- finite, signed correctly.
931        let inf = parse_style_box_shadow("inf 1px").unwrap();
932        assert!(numbers(&inf)[0].is_finite() && numbers(&inf)[0] > 0.0);
933        let neg_inf = parse_style_box_shadow("-inf 1px").unwrap();
934        assert!(numbers(&neg_inf)[0].is_finite() && numbers(&neg_inf)[0] < 0.0);
935        let infinity = parse_style_box_shadow("1px infinity").unwrap();
936        assert!(numbers(&infinity)[1].is_finite());
937
938        // A bare "in" is the inch metric with no value -> Err, not a panic.
939        assert!(parse_style_box_shadow("in 1px").is_err());
940    }
941
942    #[test]
943    fn parse_never_yields_a_percent_metric() {
944        for input in [
945            "1px 2px",
946            "0 0",
947            "1em 2rem 3pt 4in",
948            "5vw 5vh 5vmin 5vmax",
949            "1cm 2mm",
950            "inset 1px 2px red",
951        ] {
952            let s = parse_style_box_shadow(input).unwrap();
953            for m in [
954                s.offset_x.inner.metric,
955                s.offset_y.inner.metric,
956                s.blur_radius.inner.metric,
957                s.spread_radius.inner.metric,
958            ] {
959                assert_ne!(
960                    m,
961                    SizeMetric::Percent,
962                    "percent metric leaked from {input:?}"
963                );
964            }
965        }
966    }
967
968    #[test]
969    fn parse_unicode_input_does_not_panic() {
970        // parse_color_no_hash indexes by BYTE length, so multi-byte tokens behind
971        // a '#' must error instead of slicing through a char boundary.
972        for input in [
973            "\u{1F600}",
974            "\u{1F600} \u{1F600}",
975            "1px 2px \u{1F600}",
976            "1px 2px #\u{1F600}", // 4-byte char -> hits the len==4 hex branch
977            "1px 2px #\u{e9}1",   // 3 bytes -> hits the len==3 hex branch
978            "1px 2px #\u{e9}\u{e9}\u{e9}\u{e9}", // 8 bytes -> hits the len==8 branch
979            "e\u{0301}\u{0301} 1px",
980            "\u{202E}1px 2px",
981            "\u{0661}px \u{0662}px", // arabic-indic digits
982            "1px 2px",             // fullwidth digits
983            "1px 2px \u{fffd}",
984        ] {
985            let _ = parse_style_box_shadow(input); // must not panic
986        }
987
988        // Unicode whitespace still splits tokens, so this is a valid shadow.
989        let nbsp = parse_style_box_shadow("10px\u{00a0}5px").unwrap();
990        assert_eq!(nbsp, parse_style_box_shadow("10px 5px").unwrap());
991    }
992
993    #[test]
994    fn parse_extremely_long_input_terminates() {
995        // 50k length tokens: rejected on the component count, no hang.
996        let many = "1px ".repeat(50_000);
997        assert!(matches!(
998            parse_style_box_shadow(&many),
999            Err(CssShadowParseError::TooManyOrTooFewComponents(_))
1000        ));
1001
1002        // A single 1M-char garbage token.
1003        let long_garbage = "a".repeat(1_000_000);
1004        assert!(parse_style_box_shadow(&long_garbage).is_err());
1005
1006        // A single token of 50k digits: f32 parses it as inf, which then saturates.
1007        let huge = format!("{}px 1px", "9".repeat(50_000));
1008        let s = parse_style_box_shadow(&huge).unwrap();
1009        assert!(numbers(&s)[0].is_finite());
1010        assert!(numbers(&s)[0] > 0.0);
1011
1012        // A very long *valid* input padded with whitespace.
1013        let padded = format!("{}1px 2px{}", " ".repeat(100_000), " ".repeat(100_000));
1014        assert_eq!(
1015            parse_style_box_shadow(&padded).unwrap(),
1016            parse_style_box_shadow("1px 2px").unwrap()
1017        );
1018    }
1019
1020    #[test]
1021    fn parse_deeply_nested_input_does_not_stack_overflow() {
1022        let open = "(".repeat(10_000);
1023        let nested = format!("{}{}", open, ")".repeat(10_000));
1024        assert!(parse_style_box_shadow(&nested).is_err());
1025        assert!(parse_style_box_shadow(&open).is_err());
1026        assert!(parse_style_box_shadow(&format!("1px 2px rgb{nested}")).is_err());
1027        assert!(parse_style_box_shadow(&format!("1px 2px {}", "[".repeat(10_000))).is_err());
1028    }
1029
1030    // ---------------------------------------------------------------
1031    // round-trip: print_as_css_value <-> parse_style_box_shadow
1032    // ---------------------------------------------------------------
1033
1034    #[test]
1035    fn print_then_parse_is_the_identity() {
1036        for original in round_trip_corpus() {
1037            let printed = original.print_as_css_value();
1038            let reparsed = parse_style_box_shadow(&printed)
1039                .unwrap_or_else(|e| panic!("printed {printed:?} does not re-parse: {e:?}"));
1040            assert_eq!(reparsed, original, "round-trip changed {printed:?}");
1041        }
1042    }
1043
1044    #[test]
1045    fn parse_then_print_then_parse_is_idempotent() {
1046        for input in [
1047            "10px 5px",
1048            "5px 10px 20px",
1049            "2px 2px 2px 1px rgba(0,0,0,0.2)",
1050            "inset 0 0 10px #000",
1051            "5px 1em red inset",
1052            "1px 2px transparent",
1053            "-3px -4px 0 2px #12345678",
1054        ] {
1055            let first = parse_style_box_shadow(input).unwrap();
1056            let printed = first.print_as_css_value();
1057            let second = parse_style_box_shadow(&printed).unwrap();
1058            assert_eq!(first, second, "{input:?} -> {printed:?} was not idempotent");
1059            // Printing is stable: same value, same string.
1060            assert_eq!(printed, second.print_as_css_value());
1061        }
1062    }
1063
1064    #[test]
1065    fn print_omits_defaults_for_brevity() {
1066        let default = StyleBoxShadow::default();
1067        assert_eq!(default.print_as_css_value(), "0px 0px");
1068
1069        // A black shadow never writes a color...
1070        let black = shadow(
1071            px(1.0),
1072            px(2.0),
1073            px(3.0),
1074            px(0.0),
1075            BoxShadowClipMode::Outset,
1076            ColorU::BLACK,
1077        );
1078        assert_eq!(black.print_as_css_value(), "1px 2px 3px");
1079        assert!(!black.print_as_css_value().contains('#'));
1080
1081        // ...and an outset shadow never writes the `inset` keyword.
1082        assert!(!black.print_as_css_value().contains("inset"));
1083    }
1084
1085    #[test]
1086    fn print_writes_inset_first_and_color_last() {
1087        let s = shadow(
1088            px(1.0),
1089            px(2.0),
1090            px(0.0),
1091            px(0.0),
1092            BoxShadowClipMode::Inset,
1093            ColorU::RED,
1094        );
1095        assert_eq!(s.print_as_css_value(), "inset 1px 2px #ff0000ff");
1096    }
1097
1098    #[test]
1099    fn print_emits_a_placeholder_blur_when_only_the_spread_is_set() {
1100        // A spread cannot be positional without a blur before it, so a zero blur
1101        // must still be written -- otherwise the spread would re-parse as a blur.
1102        let s = shadow(
1103            px(1.0),
1104            px(2.0),
1105            px(0.0),
1106            px(4.0),
1107            BoxShadowClipMode::Outset,
1108            ColorU::BLACK,
1109        );
1110        assert_eq!(s.print_as_css_value(), "1px 2px 0px 4px");
1111        let reparsed = parse_style_box_shadow(&s.print_as_css_value()).unwrap();
1112        assert_eq!(reparsed.blur_radius, px(0.0));
1113        assert_eq!(reparsed.spread_radius, px(4.0));
1114    }
1115
1116    #[test]
1117    fn print_of_extreme_values_does_not_panic() {
1118        // Saturated / NaN-scaled shadows must still serialize to *something*.
1119        for factor in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, 1e30] {
1120            let s = scaled(all_ones(), factor);
1121            let printed = s.print_as_css_value();
1122            assert!(!printed.is_empty());
1123            assert!(
1124                !printed.contains("NaN"),
1125                "NaN reached the CSS output: {printed}"
1126            );
1127            assert!(
1128                !printed.contains("inf"),
1129                "inf reached the CSS output: {printed}"
1130            );
1131        }
1132    }
1133
1134    #[test]
1135    fn format_as_rust_code_is_well_formed_for_extremes() {
1136        for s in round_trip_corpus() {
1137            let code = s.format_as_rust_code(0);
1138            assert!(code.starts_with("StyleBoxShadow {"));
1139            assert!(code.contains("offset_x:"));
1140            assert!(code.contains("offset_y:"));
1141            assert!(code.contains("blur_radius:"));
1142            assert!(code.contains("spread_radius:"));
1143            assert!(code.contains("clip_mode: BoxShadowClipMode::"));
1144            assert!(code.contains("color:"));
1145        }
1146        // Indentation is applied, and extreme values do not panic the formatter.
1147        let inset = scaled(all_ones(), f32::INFINITY);
1148        let code = inset.format_as_rust_code(3);
1149        assert!(code.contains("clip_mode: BoxShadowClipMode::Inset"));
1150        assert!(code.contains("            offset_x:"));
1151    }
1152
1153    // ---------------------------------------------------------------
1154    // predicates / invariants on StyleBoxShadow itself
1155    // ---------------------------------------------------------------
1156
1157    #[test]
1158    fn default_shadow_is_an_opaque_black_outset_at_the_origin() {
1159        let d = StyleBoxShadow::default();
1160        assert_eq!(d.clip_mode, BoxShadowClipMode::Outset);
1161        assert_eq!(d.color, ColorU::BLACK);
1162        assert_eq!(numbers(&d), [0.0, 0.0, 0.0, 0.0]);
1163        assert_eq!(d.offset_x.inner.metric, SizeMetric::Px);
1164        // The parser's baseline must agree with Default.
1165        assert_eq!(parse_style_box_shadow("0 0").unwrap(), d);
1166    }
1167
1168    #[test]
1169    fn equality_is_component_wise() {
1170        let base = all_ones();
1171        assert_eq!(base, base);
1172        assert_ne!(base, StyleBoxShadow::default());
1173
1174        let mut clip = base;
1175        clip.clip_mode = BoxShadowClipMode::Outset;
1176        assert_ne!(base, clip, "clip_mode is ignored by PartialEq");
1177
1178        let mut color = base;
1179        color.color = ColorU::BLACK;
1180        assert_ne!(base, color, "color is ignored by PartialEq");
1181
1182        let mut spread = base;
1183        spread.spread_radius = px(9.0);
1184        assert_ne!(base, spread, "spread_radius is ignored by PartialEq");
1185    }
1186}