Skip to main content

azul_css/props/style/
selection.rs

1//! CSS properties for styling text selections (`-azul-selection-*`).
2//!
3//! Defines the following properties (analogous to the `::selection` pseudo-element):
4//!
5//! - `-azul-selection-background-color` ([`SelectionBackgroundColor`])
6//! - `-azul-selection-color` ([`SelectionColor`])
7//! - `-azul-selection-radius` ([`SelectionRadius`])
8
9use alloc::string::String;
10
11use crate::props::{
12    basic::color::{parse_css_color, ColorU, CssColorParseError, CssColorParseErrorOwned},
13    formatter::PrintAsCssValue,
14};
15
16/// Default selection highlight background — light blue, similar to macOS.
17const DEFAULT_SELECTION_BG: ColorU = ColorU::new(173, 214, 255, 255);
18
19// --- -azul-selection-background-color ---
20
21/// Parsed value for the `-azul-selection-background-color` CSS property.
22#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
23#[repr(C)]
24pub struct SelectionBackgroundColor {
25    pub inner: ColorU,
26}
27
28impl Default for SelectionBackgroundColor {
29    fn default() -> Self {
30        Self {
31            inner: DEFAULT_SELECTION_BG,
32        }
33    }
34}
35
36impl PrintAsCssValue for SelectionBackgroundColor {
37    fn print_as_css_value(&self) -> String {
38        self.inner.to_hash()
39    }
40}
41
42impl crate::codegen::format::FormatAsRustCode for SelectionBackgroundColor {
43    fn format_as_rust_code(&self, _tabs: usize) -> String {
44        format!(
45            "SelectionBackgroundColor {{ inner: {} }}",
46            crate::codegen::format::format_color_value(&self.inner)
47        )
48    }
49}
50
51/// Parses a `-azul-selection-background-color` CSS value.
52#[cfg(feature = "parser")]
53/// # Errors
54///
55/// Returns an error if `input` is not a valid CSS `selection-background-color` value.
56pub fn parse_selection_background_color(
57    input: &str,
58) -> Result<SelectionBackgroundColor, CssColorParseError<'_>> {
59    parse_css_color(input).map(|inner| SelectionBackgroundColor { inner })
60}
61
62// --- -azul-selection-color ---
63
64/// Parsed value for the `-azul-selection-color` CSS property.
65#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
66#[repr(C)]
67pub struct SelectionColor {
68    pub inner: ColorU,
69}
70
71impl Default for SelectionColor {
72    fn default() -> Self {
73        Self {
74            inner: ColorU::BLACK,
75        }
76    }
77}
78
79impl PrintAsCssValue for SelectionColor {
80    fn print_as_css_value(&self) -> String {
81        self.inner.to_hash()
82    }
83}
84
85impl crate::codegen::format::FormatAsRustCode for SelectionColor {
86    fn format_as_rust_code(&self, _tabs: usize) -> String {
87        format!(
88            "SelectionColor {{ inner: {} }}",
89            crate::codegen::format::format_color_value(&self.inner)
90        )
91    }
92}
93
94/// Parses a `-azul-selection-color` CSS value.
95#[cfg(feature = "parser")]
96/// # Errors
97///
98/// Returns an error if `input` is not a valid CSS `selection-color` value.
99pub fn parse_selection_color(input: &str) -> Result<SelectionColor, CssColorParseError<'_>> {
100    parse_css_color(input).map(|inner| SelectionColor { inner })
101}
102
103// --- -azul-selection-radius ---
104
105use crate::props::basic::{
106    pixel::{parse_pixel_value, CssPixelValueParseError, CssPixelValueParseErrorOwned, PixelValue},
107    SizeMetric,
108};
109
110/// Parsed value for the `-azul-selection-radius` CSS property.
111#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
112#[repr(C)]
113pub struct SelectionRadius {
114    pub inner: PixelValue,
115}
116
117impl Default for SelectionRadius {
118    fn default() -> Self {
119        Self {
120            inner: PixelValue::zero(),
121        }
122    }
123}
124
125impl PrintAsCssValue for SelectionRadius {
126    fn print_as_css_value(&self) -> String {
127        self.inner.to_string()
128    }
129}
130
131impl crate::codegen::format::FormatAsRustCode for SelectionRadius {
132    fn format_as_rust_code(&self, _tabs: usize) -> String {
133        // Use the Display implementation of PixelValue to get a string like "5px" or "1em"
134        format!(
135            "SelectionRadius {{ inner: PixelValue::from_metric(SizeMetric::{:?}, {}) }}",
136            self.inner.metric,
137            self.inner.number.get()
138        )
139    }
140}
141
142/// Parses a `-azul-selection-radius` CSS value.
143#[cfg(feature = "parser")]
144/// # Errors
145///
146/// Returns an error if `input` is not a valid CSS `selection-radius` value.
147pub fn parse_selection_radius(input: &str) -> Result<SelectionRadius, CssPixelValueParseError<'_>> {
148    parse_pixel_value(input).map(|inner| SelectionRadius { inner })
149}
150
151#[cfg(all(test, feature = "parser"))]
152mod autotest_generated {
153    use std::{
154        collections::hash_map::DefaultHasher,
155        hash::{Hash, Hasher},
156    };
157
158    use super::*;
159    use crate::codegen::format::FormatAsRustCode;
160
161    fn hash_of<T: Hash>(t: &T) -> u64 {
162        let mut h = DefaultHasher::new();
163        t.hash(&mut h);
164        h.finish()
165    }
166
167    /// Every metric whose `Display` string round-trips through `parse_pixel_value`.
168    ///
169    /// `Vmin` is deliberately absent — see
170    /// [`known_bug_vmin_radius_is_rejected_by_metric_table_order`].
171    const ROUND_TRIPPABLE_METRICS: [SizeMetric; 11] = [
172        SizeMetric::Px,
173        SizeMetric::Pt,
174        SizeMetric::Em,
175        SizeMetric::Rem,
176        SizeMetric::In,
177        SizeMetric::Cm,
178        SizeMetric::Mm,
179        SizeMetric::Percent,
180        SizeMetric::Vw,
181        SizeMetric::Vh,
182        SizeMetric::Vmax,
183    ];
184
185    /// Inputs that must never panic in *any* of the three parsers in this module,
186    /// regardless of which one they were aimed at.
187    fn hostile_corpus() -> Vec<String> {
188        vec![
189            String::new(),
190            " ".to_string(),
191            "\t\n\r\u{b}\u{c}".to_string(),
192            "\u{a0}".to_string(),   // NBSP: is White_Space, so trim() eats it
193            "\u{200b}".to_string(), // ZWSP: NOT White_Space, survives the trim
194            "\0".to_string(),
195            "#\0\0\0".to_string(),
196            ";".to_string(),
197            "}{".to_string(),
198            ")rgb(".to_string(),
199            "#".to_string(),
200            "##".to_string(),
201            "#zzz".to_string(),
202            "#-fff000f".to_string(), // from_str_radix must not accept a sign here
203            "rgb(".to_string(),
204            "rgb()".to_string(),
205            "rgb(1,2)".to_string(),
206            "rgb(-1,-1,-1)".to_string(),
207            "rgb(999,999,999)".to_string(),
208            "rgba(0,0,0,NaN)".to_string(),
209            "rgba(255,255,255,inf)".to_string(),
210            "hsl(400,-10%,999%)".to_string(),
211            "-".to_string(),
212            "--".to_string(),
213            "+".to_string(),
214            "e".to_string(),
215            "E9".to_string(),
216            "NaN".to_string(),
217            "inf".to_string(),
218            "-inf".to_string(),
219            "infinity".to_string(),
220            "0x10px".to_string(),
221            "px".to_string(),
222            "vmin".to_string(),
223            "5 5px".to_string(),
224            "5px;".to_string(),
225            "%".to_string(),
226            "-0".to_string(),
227            i64::MAX.to_string(),
228            i64::MIN.to_string(),
229            format!("{}px", i64::MAX),
230            "1e400px".to_string(),
231            "\u{1F600}".to_string(),
232            "#\u{1F600}".to_string(), // 4 *bytes* -> hits the #rgba branch
233            "#\u{e9}1".to_string(),   // 3 *bytes* -> hits the #rgb branch
234            "a\u{0301}\u{0301}\u{0301}".to_string(), // stacked combining marks
235            "\u{202e}der".to_string(), // RTL override
236            "\u{130}".to_string(),    // dotted capital I: to_lowercase() expands
237            "RED".to_string(),
238            "red red".to_string(),
239        ]
240    }
241
242    #[test]
243    fn hostile_inputs_never_panic_in_any_selection_parser() {
244        for input in hostile_corpus() {
245            let _ = parse_selection_background_color(&input);
246            let _ = parse_selection_color(&input);
247
248            // Anything the radius parser *accepts* must be a finite fixed-point
249            // value: `FloatValue` stores `value * 1000` in an isize, and the `as`
250            // cast saturates (NaN -> 0, +-inf -> isize::MAX/MIN). No input may
251            // smuggle a NaN/infinite length into the layout engine.
252            if let Ok(r) = parse_selection_radius(&input) {
253                assert!(
254                    r.inner.number.get().is_finite(),
255                    "{input:?} produced a non-finite radius"
256                );
257            }
258        }
259    }
260
261    // --- empty / whitespace ---
262
263    #[test]
264    fn empty_and_whitespace_only_input_is_rejected() {
265        // All of these trim to "" (U+00A0 and U+2003 have White_Space=yes).
266        for blank in ["", " ", "   ", "\t\n", "\r\n\t ", "\u{a0}", "\u{2003}"] {
267            assert!(
268                matches!(
269                    parse_selection_background_color(blank),
270                    Err(CssColorParseError::EmptyInput)
271                ),
272                "{blank:?} should be EmptyInput"
273            );
274            assert!(
275                matches!(
276                    parse_selection_color(blank),
277                    Err(CssColorParseError::EmptyInput)
278                ),
279                "{blank:?} should be EmptyInput"
280            );
281            assert!(
282                matches!(
283                    parse_selection_radius(blank),
284                    Err(CssPixelValueParseError::EmptyString)
285                ),
286                "{blank:?} should be EmptyString"
287            );
288        }
289
290        // ZWSP is *not* White_Space, so it is not trimmed away and must be
291        // rejected as a value rather than reported as "empty".
292        assert!(parse_selection_color("\u{200b}").is_err());
293        assert!(parse_selection_radius("\u{200b}").is_err());
294    }
295
296    // --- garbage / malformed ---
297
298    #[test]
299    fn malformed_color_input_is_rejected_without_panicking() {
300        for bad in [
301            "#",
302            "##",
303            "#f",
304            "#ff",
305            "#fffff",
306            "#zzz",
307            "#gggggg",
308            "rgb(",
309            "rgb()",
310            "rgb(1,2)",
311            "rgb(1,2,3",
312            "rgba(1,2,3)",
313            "hsl(1,2)",
314            "not-a-color",
315            "}{",
316            ";",
317            ")rgb(",
318            "rgb (0,0,0)", // CSS forbids a space before the paren
319        ] {
320            assert!(
321                parse_selection_background_color(bad).is_err(),
322                "{bad:?} was accepted as a background color"
323            );
324            assert!(
325                parse_selection_color(bad).is_err(),
326                "{bad:?} was accepted as a color"
327            );
328        }
329    }
330
331    #[test]
332    fn malformed_radius_input_is_rejected_without_panicking() {
333        for bad in [
334            "px", "em", "%", "5 5px", "5px;", "px5", "5pxpx", "--5px", "5,px", "#5px", "5 px x",
335            "0x10px", "e", "+",
336        ] {
337            assert!(
338                parse_selection_radius(bad).is_err(),
339                "{bad:?} was accepted as a radius"
340            );
341        }
342
343        // A bare unit reports "the number is missing", not a generic parse error.
344        assert!(matches!(
345            parse_selection_radius("px"),
346            Err(CssPixelValueParseError::NoValueGiven("px", SizeMetric::Px))
347        ));
348    }
349
350    // --- leading / trailing junk ---
351
352    #[test]
353    fn surrounding_whitespace_is_trimmed_but_trailing_junk_is_rejected() {
354        // Trimmed, deterministically.
355        assert_eq!(
356            parse_selection_background_color("  #ff0000ff  ")
357                .unwrap()
358                .inner,
359            ColorU::new(255, 0, 0, 255)
360        );
361        assert_eq!(
362            parse_selection_color("\t red \n").unwrap().inner,
363            ColorU::new(255, 0, 0, 255)
364        );
365        assert_eq!(
366            parse_selection_radius("   5px \n").unwrap().inner,
367            PixelValue::px(5.0)
368        );
369        // Internal whitespace between number and unit is trimmed too.
370        assert_eq!(
371            parse_selection_radius("5   px").unwrap().inner,
372            PixelValue::px(5.0)
373        );
374
375        // Rejected: a valid value followed by junk is never silently truncated.
376        for junk in ["#ff0000;", "red;", "red garbage", "#ff0000ff extra"] {
377            assert!(
378                parse_selection_color(junk).is_err(),
379                "{junk:?} was silently truncated to a valid color"
380            );
381        }
382        for junk in ["5px;", "5px garbage", "5px 6px"] {
383            assert!(
384                parse_selection_radius(junk).is_err(),
385                "{junk:?} was silently truncated to a valid radius"
386            );
387        }
388    }
389
390    // --- extremely long / deeply nested ---
391
392    #[test]
393    fn extremely_long_input_neither_panics_nor_hangs() {
394        let long_hex = format!("#{}", "f".repeat(1_000_000));
395        assert!(parse_selection_background_color(&long_hex).is_err());
396        assert!(parse_selection_color(&long_hex).is_err());
397
398        let long_word = "z".repeat(1_000_000);
399        assert!(parse_selection_color(&long_word).is_err());
400        assert!(parse_selection_radius(&long_word).is_err());
401
402        // 1M digits overflow f32 to +inf, which the fixed-point cast then
403        // saturates to isize::MAX -> a huge but *finite* length.
404        let long_number = format!("{}px", "9".repeat(1_000_000));
405        let r = parse_selection_radius(&long_number).unwrap();
406        assert!(r.inner.number.get().is_finite());
407        assert_eq!(r.inner, PixelValue::px(f32::INFINITY));
408    }
409
410    #[test]
411    fn deeply_nested_input_does_not_stack_overflow() {
412        let open = "(".repeat(10_000);
413        assert!(parse_selection_color(&open).is_err());
414        assert!(parse_selection_radius(&open).is_err());
415
416        let unclosed = "rgb(".repeat(10_000);
417        assert!(parse_selection_background_color(&unclosed).is_err());
418
419        let balanced = format!("{}{}", "rgb(".repeat(10_000), ")".repeat(10_000));
420        assert!(parse_selection_background_color(&balanced).is_err());
421    }
422
423    // --- unicode ---
424
425    #[test]
426    fn unicode_input_is_rejected_without_slicing_through_a_char() {
427        // `parse_color_no_hash` dispatches on the *byte* length, so multi-byte
428        // input can reach the 3-/4-byte hex branches. It must error there rather
429        // than index into the middle of a char.
430        for u in [
431            "#\u{e9}1",            // 2-byte é + '1' == 3 bytes
432            "#\u{1F600}",          // 4-byte emoji == the #rgba branch
433            "#\u{e9}\u{e9}\u{e9}", // 6 bytes == the from_str_radix branch
434            "\u{1F600}",
435            "a\u{0301}\u{0301}",
436            "\u{202e}red",
437            "\u{130}", // to_lowercase() expands this to two chars
438            "r\u{e9}d",
439        ] {
440            assert!(
441                parse_selection_background_color(u).is_err(),
442                "{u:?} was accepted as a color"
443            );
444            assert!(parse_selection_color(u).is_err(), "{u:?} was accepted");
445            assert!(parse_selection_radius(u).is_err(), "{u:?} was accepted");
446        }
447    }
448
449    // --- numeric boundaries / saturation ---
450
451    #[test]
452    fn radius_nan_and_infinity_saturate_instead_of_leaking_into_layout() {
453        // LENIENCY (worth knowing): `parse_pixel_value` falls back to
454        // `str::parse::<f32>()`, which accepts "NaN"/"inf"/"infinity". CSS does
455        // not. The values are at least clamped by the fixed-point cast, so no
456        // NaN/inf ever reaches layout — that saturation is what is pinned here.
457        let nan = parse_selection_radius("NaN").unwrap();
458        assert_eq!(nan.inner.number.get(), 0.0, "NaN must collapse to 0");
459        assert!(nan.inner.number.get().is_finite());
460
461        let pos_inf = parse_selection_radius("inf").unwrap();
462        assert!(pos_inf.inner.number.get().is_finite());
463        assert!(pos_inf.inner.number.get() > 0.0);
464
465        let neg_inf = parse_selection_radius("-inf").unwrap();
466        assert!(neg_inf.inner.number.get().is_finite());
467        assert!(neg_inf.inner.number.get() < 0.0);
468
469        // Every route to +inf saturates to the *same* clamped value.
470        assert_eq!(pos_inf.inner, PixelValue::px(f32::INFINITY));
471        assert_eq!(
472            parse_selection_radius("1e40px").unwrap().inner,
473            PixelValue::px(f32::INFINITY)
474        );
475        assert_eq!(
476            parse_selection_radius(&format!("{}px", i64::MAX))
477                .unwrap()
478                .inner,
479            PixelValue::px(f32::INFINITY)
480        );
481        assert_eq!(
482            parse_selection_radius(&format!("{}px", i64::MIN))
483                .unwrap()
484                .inner,
485            PixelValue::px(f32::NEG_INFINITY)
486        );
487    }
488
489    #[test]
490    fn radius_zero_boundaries_are_normalized() {
491        assert_eq!(
492            parse_selection_radius("0px").unwrap().inner,
493            PixelValue::px(0.0)
494        );
495        // -0.0 * 1000 -> -0.0 -> `as isize` -> 0: no distinct negative zero survives.
496        assert_eq!(
497            parse_selection_radius("-0px").unwrap().inner,
498            parse_selection_radius("0px").unwrap().inner
499        );
500        assert_eq!(
501            parse_selection_radius("-0.0px").unwrap().inner,
502            PixelValue::zero()
503        );
504
505        // Sub-milli values truncate to zero (the fixed-point grid is 1/1000).
506        assert_eq!(
507            parse_selection_radius("1e-45px")
508                .unwrap()
509                .inner
510                .number
511                .get(),
512            0.0
513        );
514        assert_eq!(
515            parse_selection_radius("0.0001px")
516                .unwrap()
517                .inner
518                .number
519                .get(),
520            0.0
521        );
522        // ...but the smallest representable step survives intact.
523        assert_eq!(
524            parse_selection_radius("0.001px")
525                .unwrap()
526                .inner
527                .number
528                .get(),
529            0.001
530        );
531    }
532
533    #[test]
534    fn radius_accepts_unitless_numbers_which_css_would_reject() {
535        // LENIENCY: CSS only allows a unitless length for `0`. This parser treats
536        // *any* bare number as px. Pinned so a future tightening is a visible,
537        // deliberate change rather than a silent one.
538        assert_eq!(
539            parse_selection_radius("12").unwrap().inner,
540            PixelValue::px(12.0)
541        );
542        assert_eq!(
543            parse_selection_radius("-7.5").unwrap().inner,
544            PixelValue::px(-7.5)
545        );
546        assert_eq!(
547            parse_selection_radius("1e3").unwrap().inner,
548            PixelValue::px(1000.0)
549        );
550    }
551
552    #[test]
553    fn color_component_boundaries_are_enforced() {
554        assert_eq!(
555            parse_selection_color("rgb(0,0,0)").unwrap().inner,
556            ColorU::new(0, 0, 0, 255)
557        );
558        assert_eq!(
559            parse_selection_color("rgb(255,255,255)").unwrap().inner,
560            ColorU::new(255, 255, 255, 255)
561        );
562        // Out-of-range / negative / non-numeric components must not wrap around.
563        for bad in [
564            "rgb(256,0,0)",
565            "rgb(-1,0,0)",
566            "rgb(999999999999999999999,0,0)",
567            "rgba(0,0,0,2.5)",
568            "rgba(0,0,0,-1)",
569        ] {
570            assert!(
571                parse_selection_background_color(bad).is_err(),
572                "{bad:?} was accepted — a component wrapped instead of erroring"
573            );
574        }
575    }
576
577    // --- round-trip: encode == decode ---
578
579    #[test]
580    fn background_color_round_trips_through_print_as_css_value() {
581        for c in [
582            ColorU::new(0, 0, 0, 0),
583            ColorU::new(255, 255, 255, 255),
584            ColorU::new(0, 0, 0, 255),
585            ColorU::new(173, 214, 255, 255),
586            ColorU::new(1, 2, 3, 4),
587            ColorU::new(254, 253, 252, 251),
588            ColorU::new(255, 0, 128, 1),
589            SelectionBackgroundColor::default().inner,
590        ] {
591            let v = SelectionBackgroundColor { inner: c };
592            let printed = v.print_as_css_value();
593            let reparsed = parse_selection_background_color(&printed)
594                .unwrap_or_else(|e| panic!("{printed:?} did not re-parse: {e:?}"));
595            assert_eq!(reparsed, v, "round-trip changed the value via {printed:?}");
596            // ...and printing is idempotent (a fixpoint, not a drift).
597            assert_eq!(reparsed.print_as_css_value(), printed);
598        }
599    }
600
601    #[test]
602    fn selection_color_round_trips_through_print_as_css_value() {
603        for c in [
604            ColorU::new(0, 0, 0, 0),
605            ColorU::new(255, 255, 255, 255),
606            ColorU::new(18, 52, 86, 120),
607            ColorU::new(255, 0, 0, 255),
608            SelectionColor::default().inner,
609        ] {
610            let v = SelectionColor { inner: c };
611            let printed = v.print_as_css_value();
612            let reparsed = parse_selection_color(&printed)
613                .unwrap_or_else(|e| panic!("{printed:?} did not re-parse: {e:?}"));
614            assert_eq!(reparsed, v);
615            assert_eq!(reparsed.print_as_css_value(), printed);
616        }
617    }
618
619    #[test]
620    fn every_byte_of_the_color_channels_survives_the_round_trip() {
621        // The 8-digit hex writer and the from_str_radix reader must agree on
622        // channel order for *every* channel value, not just the pretty ones.
623        for b in 0u8..=255 {
624            let c = ColorU::new(b, 255 - b, b.wrapping_mul(3), 255 - b / 2);
625            let v = SelectionColor { inner: c };
626            assert_eq!(
627                parse_selection_color(&v.print_as_css_value()).unwrap(),
628                v,
629                "channel round-trip failed for {c:?}"
630            );
631        }
632    }
633
634    #[test]
635    fn radius_round_trips_for_every_metric_except_vmin() {
636        for metric in ROUND_TRIPPABLE_METRICS {
637            for value in [0.0_f32, 1.0, 1.5, 12.0, -3.25, 0.001, -0.5, 999.999] {
638                let v = SelectionRadius {
639                    inner: PixelValue::from_metric(metric, value),
640                };
641                let printed = v.print_as_css_value();
642                let reparsed = parse_selection_radius(&printed)
643                    .unwrap_or_else(|e| panic!("{printed:?} did not re-parse: {e:?}"));
644                assert_eq!(
645                    reparsed, v,
646                    "round-trip changed {value} {metric:?} via {printed:?}"
647                );
648                assert_eq!(reparsed.inner.metric, metric);
649                assert_eq!(reparsed.print_as_css_value(), printed);
650            }
651        }
652    }
653
654    /// KNOWN BUG (root cause characterized in `props::basic::pixel`): the metric
655    /// table in `parse_pixel_value` tests the `"in"` suffix *before* `"vmin"`, so
656    /// `5vmin` strips to `5vm`, which is not an f32. Every `vmin` selection radius
657    /// is therefore rejected outright, even though it is valid CSS.
658    ///
659    /// WHEN pixel.rs IS FIXED (longest-suffix-first, or move vmax/vmin ahead of
660    /// "in"), this test fails — replace it with the positive assertion:
661    ///     `assert_eq!(parse_selection_radius("5vmin").unwrap().inner.metric`, `SizeMetric::Vmin`);
662    /// and add `SizeMetric::Vmin` to `ROUND_TRIPPABLE_METRICS`.
663    #[test]
664    fn known_bug_vmin_radius_is_rejected_by_metric_table_order() {
665        // FIXED (as this pin's own message instructed): the metric-order bug is fixed,
666        // so "5vmin" now parses to SizeMetric::Vmin.
667        assert_eq!(
668            parse_selection_radius("5vmin").unwrap().inner.metric,
669            SizeMetric::Vmin
670        );
671
672        // And it now round-trips instead of being print-only:
673        let v = SelectionRadius {
674            inner: PixelValue::from_metric(SizeMetric::Vmin, 5.0),
675        };
676        assert_eq!(v.print_as_css_value(), "5vmin");
677        assert_eq!(
678            parse_selection_radius(&v.print_as_css_value())
679                .unwrap()
680                .inner
681                .metric,
682            SizeMetric::Vmin
683        );
684
685        // The sibling viewport units are fine — only the unit that *ends in* an
686        // earlier metric is shadowed, which is what makes this easy to miss.
687        assert_eq!(
688            parse_selection_radius("5vmax").unwrap().inner.metric,
689            SizeMetric::Vmax
690        );
691        assert_eq!(
692            parse_selection_radius("5vw").unwrap().inner.metric,
693            SizeMetric::Vw
694        );
695        assert_eq!(
696            parse_selection_radius("5vh").unwrap().inner.metric,
697            SizeMetric::Vh
698        );
699        assert_eq!(
700            parse_selection_radius("5in").unwrap().inner.metric,
701            SizeMetric::In
702        );
703    }
704
705    /// KNOWN BUG: the 6-/8-digit hex branches parse with `u32::from_str_radix`,
706    /// which accepts a leading `+`. So `#+fff000f` is accepted as a color even
707    /// though `+` is not a hex digit and this is not valid CSS. The 3-/4-digit
708    /// branches use a per-byte hex decoder and correctly reject it.
709    ///
710    /// WHEN color.rs IS FIXED (reject any non-hex-digit byte before the radix
711    /// conversion), this test fails — flip the two `unwrap()`s to `is_err()`.
712    #[test]
713    fn hex_color_rejects_a_leading_plus_sign() {
714        // FIXED (as this pin's own message instructed): the 6-/8-digit branches now
715        // reject any non-hex-digit byte before the radix conversion, so a leading '+'
716        // (which u32::from_str_radix used to swallow) is an error like every other
717        // non-hex character.
718        assert!(parse_selection_color("#+fff000f").is_err());
719        assert!(parse_selection_background_color("#+fff00").is_err());
720
721        // A leading '-' was already rejected (unsigned from_str_radix refuses it), and
722        // the short branches reject '+' too via the per-byte hex decoder.
723        assert!(parse_selection_color("#-fff000f").is_err());
724        assert!(parse_selection_color("#+ff").is_err());
725        assert!(parse_selection_color("#+fff").is_err());
726    }
727
728    // --- defaults / getters / invariants ---
729
730    #[test]
731    fn defaults_are_the_documented_values_and_re_parse_to_themselves() {
732        assert_eq!(
733            SelectionBackgroundColor::default().inner,
734            ColorU::new(173, 214, 255, 255)
735        );
736        assert_eq!(
737            SelectionBackgroundColor::default().inner,
738            DEFAULT_SELECTION_BG
739        );
740        assert_eq!(SelectionColor::default().inner, ColorU::BLACK);
741        assert_eq!(SelectionRadius::default().inner, PixelValue::zero());
742
743        assert_eq!(
744            SelectionBackgroundColor::default().print_as_css_value(),
745            "#add6ffff"
746        );
747        assert_eq!(SelectionColor::default().print_as_css_value(), "#000000ff");
748        assert_eq!(SelectionRadius::default().print_as_css_value(), "0px");
749
750        assert_eq!(
751            parse_selection_background_color("#add6ffff").unwrap(),
752            SelectionBackgroundColor::default()
753        );
754        assert_eq!(
755            parse_selection_color("#000000ff").unwrap(),
756            SelectionColor::default()
757        );
758        assert_eq!(
759            parse_selection_radius("0px").unwrap(),
760            SelectionRadius::default()
761        );
762    }
763
764    #[test]
765    fn equal_values_hash_and_compare_equal() {
766        let a = SelectionColor {
767            inner: ColorU::new(1, 2, 3, 4),
768        };
769        let b = SelectionColor {
770            inner: ColorU::new(1, 2, 3, 4),
771        };
772        assert_eq!(a, b);
773        assert_eq!(hash_of(&a), hash_of(&b));
774        assert_eq!(a.cmp(&b), core::cmp::Ordering::Equal);
775
776        let black = SelectionColor {
777            inner: ColorU::new(0, 0, 0, 255),
778        };
779        let white = SelectionColor {
780            inner: ColorU::new(255, 255, 255, 255),
781        };
782        assert!(black < white);
783        assert!(white > black);
784
785        // Parsing the same input twice is deterministic (no interior state).
786        assert_eq!(
787            parse_selection_radius("1.5em").unwrap(),
788            parse_selection_radius("1.5em").unwrap()
789        );
790        assert_eq!(
791            hash_of(&parse_selection_radius("1.5em").unwrap()),
792            hash_of(&parse_selection_radius("1.5em").unwrap())
793        );
794    }
795
796    #[test]
797    fn format_as_rust_code_emits_a_constructor_for_each_type() {
798        let radius = SelectionRadius {
799            inner: PixelValue::px(5.0),
800        };
801        assert_eq!(
802            radius.format_as_rust_code(0),
803            "SelectionRadius { inner: PixelValue::from_metric(SizeMetric::Px, 5) }"
804        );
805        assert_eq!(
806            SelectionRadius {
807                inner: PixelValue::from_metric(SizeMetric::Percent, 50.0),
808            }
809            .format_as_rust_code(0),
810            "SelectionRadius { inner: PixelValue::from_metric(SizeMetric::Percent, 50) }"
811        );
812        assert_eq!(
813            SelectionRadius::default().format_as_rust_code(0),
814            "SelectionRadius { inner: PixelValue::from_metric(SizeMetric::Px, 0) }"
815        );
816
817        // The colour formatter's exact spelling belongs to codegen::format; only
818        // the wrapper shape is this module's contract.
819        let bg = SelectionBackgroundColor::default().format_as_rust_code(0);
820        assert!(bg.starts_with("SelectionBackgroundColor { inner: "), "{bg}");
821        assert!(bg.ends_with(" }"), "{bg}");
822        let fg = SelectionColor::default().format_as_rust_code(0);
823        assert!(fg.starts_with("SelectionColor { inner: "), "{fg}");
824        assert!(fg.ends_with(" }"), "{fg}");
825
826        // Indentation is documented as ignored — every depth prints the same.
827        assert_eq!(radius.format_as_rust_code(0), radius.format_as_rust_code(9));
828    }
829
830    #[test]
831    fn parse_errors_survive_the_owned_round_trip() {
832        // The borrowed errors are re-hydrated from their owned form (used to send
833        // errors across the FFI boundary); the message must not change.
834        let color_err = parse_selection_color("not-a-color").unwrap_err();
835        let owned: CssColorParseErrorOwned = color_err.to_contained();
836        assert_eq!(format!("{:?}", owned.to_shared()), format!("{color_err:?}"));
837
838        let px_err = parse_selection_radius("5 5px").unwrap_err();
839        let owned_px: CssPixelValueParseErrorOwned = px_err.to_contained();
840        assert_eq!(format!("{:?}", owned_px.to_shared()), format!("{px_err:?}"));
841
842        // An error message must never be empty — it is surfaced to stylesheet authors.
843        assert!(!format!("{color_err:?}").is_empty());
844        assert!(!format!("{px_err:?}").is_empty());
845    }
846
847    #[test]
848    fn the_two_color_properties_agree_on_every_input() {
849        // Both delegate to `parse_css_color`; the only difference is the wrapper.
850        // A divergence would mean one of them grew its own (wrong) grammar.
851        for input in hostile_corpus() {
852            let bg = parse_selection_background_color(&input);
853            let fg = parse_selection_color(&input);
854            match (bg, fg) {
855                (Ok(b), Ok(f)) => assert_eq!(b.inner, f.inner, "diverged on {input:?}"),
856                (Err(_), Err(_)) => {}
857                (b, f) => panic!("{input:?} parsed inconsistently: {b:?} vs {f:?}"),
858            }
859        }
860    }
861}