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