Skip to main content

azul_layout/widgets/
label.rs

1//! Label widget for displaying static text with platform-specific default styling.
2
3use azul_core::dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec};
4use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
5#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
6use azul_css::{
7    props::{
8        basic::*,
9        layout::*,
10        property::{CssProperty, *},
11        style::*,
12    },
13    *,
14};
15
16/// A static text label widget with platform-appropriate default styling.
17#[derive(Debug, Clone)]
18#[repr(C)]
19pub struct Label {
20    pub string: AzString,
21    pub label_style: CssPropertyWithConditionsVec,
22}
23
24const SANS_SERIF_STR: &str = "system:ui";
25const SANS_SERIF: AzString = AzString::from_const_str(SANS_SERIF_STR);
26const SANS_SERIF_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SANS_SERIF)];
27const SANS_SERIF_FAMILY: StyleFontFamilyVec =
28    StyleFontFamilyVec::from_const_slice(SANS_SERIF_FAMILIES);
29
30/// Standard label text color (#4C4C4C), matching platform UI defaults.
31const COLOR_4C4C4C: ColorU = ColorU {
32    r: 76,
33    g: 76,
34    b: 76,
35    a: 255,
36};
37
38static LABEL_STYLE_DEFAULT: &[CssPropertyWithConditions] = &[
39    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
40    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
41        LayoutFlexDirection::Column,
42    )),
43    CssPropertyWithConditions::simple(CssProperty::const_justify_content(
44        LayoutJustifyContent::Center,
45    )),
46    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
47    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
48    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
49        inner: COLOR_4C4C4C,
50    })),
51    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
52    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
53    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
54];
55
56static LABEL_STYLE_MAC: &[CssPropertyWithConditions] = &[
57    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
58    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
59        LayoutFlexDirection::Column,
60    )),
61    CssPropertyWithConditions::simple(CssProperty::const_justify_content(
62        LayoutJustifyContent::Center,
63    )),
64    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
65    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
66    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
67        inner: COLOR_4C4C4C,
68    })),
69    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(12))),
70    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
71    CssPropertyWithConditions::simple(CssProperty::const_font_family(SANS_SERIF_FAMILY)),
72];
73
74/// No default styling on unsupported platforms (e.g. WASM, FreeBSD);
75/// callers should provide explicit styles via `label_style`.
76static LABEL_STYLE_OTHER: &[CssPropertyWithConditions] = &[];
77
78impl Label {
79    /// Creates a new label with the given text and platform-specific default styling.
80    #[inline]
81    #[must_use] pub fn create(string: AzString) -> Self {
82        Self {
83            string,
84            #[cfg(target_os = "windows")]
85            label_style: CssPropertyWithConditionsVec::from_const_slice(LABEL_STYLE_DEFAULT),
86            #[cfg(target_os = "linux")]
87            label_style: CssPropertyWithConditionsVec::from_const_slice(LABEL_STYLE_DEFAULT),
88            #[cfg(target_os = "macos")]
89            label_style: CssPropertyWithConditionsVec::from_const_slice(LABEL_STYLE_MAC),
90            #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
91            label_style: CssPropertyWithConditionsVec::from_const_slice(LABEL_STYLE_OTHER),
92        }
93    }
94
95    /// Replaces `self` with an empty default label, returning the original.
96    #[inline]
97    #[must_use]
98    pub fn swap_with_default(&mut self) -> Self {
99        let mut s = Self::create(AzString::from_const_str(""));
100        core::mem::swap(&mut s, self);
101        s
102    }
103
104    /// Converts this label into a DOM text node with the `__azul-native-label` class.
105    #[inline]
106    #[must_use] pub fn dom(self) -> Dom {
107        static LABEL_CLASS: &[IdOrClass] =
108            &[Class(AzString::from_const_str("__azul-native-label"))];
109
110        Dom::create_text(self.string)
111            .with_ids_and_classes(IdOrClassVec::from_const_slice(LABEL_CLASS))
112            .with_css_props(self.label_style)
113    }
114}
115
116impl From<Label> for Dom {
117    fn from(l: Label) -> Self {
118        l.dom()
119    }
120}
121
122#[cfg(test)]
123mod autotest_generated {
124    use std::collections::HashSet;
125
126    use azul_core::dom::NodeType;
127    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
128
129    use super::*;
130
131    // ------------------------------------------------------------------
132    // Helpers
133    // ------------------------------------------------------------------
134
135    /// The number of declarations each *populated* platform table carries.
136    const DECL_COUNT: usize = 9;
137
138    /// The class `dom()` stamps onto the text node.
139    const LABEL_CLASS_NAME: &str = "__azul-native-label";
140
141    /// The style table `Label::create` is expected to pick on *this* target.
142    ///
143    /// Uses `cfg!` rather than `#[cfg]` so every branch still type-checks on
144    /// every platform — a table that stopped compiling on macOS would otherwise
145    /// only be caught by a macOS CI run.
146    fn expected_table() -> &'static [CssPropertyWithConditions] {
147        if cfg!(target_os = "macos") {
148            LABEL_STYLE_MAC
149        } else if cfg!(any(target_os = "windows", target_os = "linux")) {
150            LABEL_STYLE_DEFAULT
151        } else {
152            LABEL_STYLE_OTHER
153        }
154    }
155
156    /// The font size `Label::create` bakes in on this target — `None` on the
157    /// platforms that are documented to get no default styling at all.
158    fn expected_font_size() -> Option<f32> {
159        if cfg!(target_os = "macos") {
160            Some(12.0)
161        } else if cfg!(any(target_os = "windows", target_os = "linux")) {
162            Some(13.0)
163        } else {
164            None
165        }
166    }
167
168    /// True when this target is one of the platforms that gets a real table.
169    fn target_is_styled() -> bool {
170        !expected_table().is_empty()
171    }
172
173    /// The declared properties of a style vec, in declaration order.
174    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
175        v.as_ref().iter().map(|p| p.property.clone()).collect()
176    }
177
178    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length. A
179    /// font size declared in `em`/`%` would resolve against the *parent* font
180    /// and make the "platform default" scale with whatever it is dropped into.
181    fn px(pv: &PixelValue) -> f32 {
182        assert_eq!(pv.metric, SizeMetric::Px, "label lengths must be absolute px, got {:?}", pv.metric);
183        pv.number.get()
184    }
185
186    fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
187        v.as_ref().iter().find_map(|p| match &p.property {
188            CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
189            _ => None,
190        })
191    }
192
193    fn text_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
194        v.as_ref().iter().find_map(|p| match &p.property {
195            CssProperty::TextColor(c) => c.get_property().map(|c| c.inner),
196            _ => None,
197        })
198    }
199
200    fn flex_grow(v: &CssPropertyWithConditionsVec) -> Option<f32> {
201        v.as_ref().iter().find_map(|p| match &p.property {
202            CssProperty::FlexGrow(f) => f.get_property().map(|f| f.inner.get()),
203            _ => None,
204        })
205    }
206
207    fn font_families(v: &CssPropertyWithConditionsVec) -> Option<Vec<StyleFontFamily>> {
208        v.as_ref().iter().find_map(|p| match &p.property {
209            CssProperty::FontFamily(f) => f.get_property().map(|f| f.as_ref().to_vec()),
210            _ => None,
211        })
212    }
213
214    /// True if `node` carries the CSS class `name`.
215    fn has_class(node: &Dom, name: &str) -> bool {
216        node.root
217            .get_ids_and_classes()
218            .as_ref()
219            .iter()
220            .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == name))
221    }
222
223    /// The properties of a rendered node's *inline* style, in declaration order.
224    fn inline_properties(node: &Dom) -> Vec<CssProperty> {
225        node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
226    }
227
228    /// The text carried by a `NodeType::Text` node (`None` for any other type).
229    fn text_of(node: &Dom) -> Option<&str> {
230        match node.root.get_node_type() {
231            NodeType::Text(s) => Some(s.as_ref().as_str()),
232            _ => None,
233        }
234    }
235
236    /// `Label` derives neither `PartialEq` nor `Default`, so compare field-wise.
237    fn assert_same_label(a: &Label, b: &Label, ctx: &str) {
238        assert_eq!(a.string, b.string, "{ctx}: the string differs");
239        assert_eq!(a.label_style, b.label_style, "{ctx}: the style differs");
240    }
241
242    /// Inputs a label must survive verbatim. Empty, whitespace-only, embedded
243    /// NUL, combining marks, ZWJ sequences, bidi overrides, strings that look
244    /// like this widget's own sentinels, and one very large allocation.
245    fn adversarial_strings() -> Vec<String> {
246        let mut v: Vec<String> = [
247            "",
248            "Label",
249            " ",
250            "\t\n\r",
251            "e\u{0301}",                                   // e + combining acute
252            "\u{212B}",                                    // ANGSTROM SIGN (NFC-folds to U+00C5)
253            "\u{C5}",                                      // the folded form — must stay distinct
254            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family emoji
255            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",                // RTL Hebrew
256            "\0",                                          // a single NUL
257            "a\0b",                                        // embedded NUL
258            "\u{FFFD}\u{202E}\u{200B}",                    // replacement, RTL override, ZWSP
259            "system:ui",                                   // the font sentinel this file bakes in
260            "__azul-native-label",                         // this widget's own class name
261            "}\n#pwned { color: red; }",                   // CSS-injection shaped
262            "<div>&amp;</div>",                            // markup shaped
263            "-9223372036854775808",                        // i64::MIN
264        ]
265        .iter()
266        .map(|s| (*s).to_string())
267        .collect();
268        v.push("x".repeat(200_000));
269        v.push("\0".repeat(1_000));
270        v
271    }
272
273    // ------------------------------------------------------------------
274    // Label::create  (constructor)
275    // ------------------------------------------------------------------
276
277    #[test]
278    fn create_stores_every_adversarial_string_verbatim() {
279        // The string travels through `AzString` (an FFI `U8Vec`), so a
280        // C-string-style NUL truncation or a lossy UTF-8 round-trip would show
281        // up as a shortened or mangled label here first.
282        for s in adversarial_strings() {
283            let label = Label::create(AzString::from(s.clone()));
284            assert_eq!(label.string.as_str(), s.as_str(), "the string was rewritten");
285            assert_eq!(label.string.len(), s.len(), "byte length changed (NUL truncation?)");
286            assert_eq!(
287                label.string.chars().count(),
288                s.chars().count(),
289                "char count changed (lossy UTF-8 round-trip?)"
290            );
291        }
292    }
293
294    #[test]
295    fn create_never_normalises_or_folds_the_string() {
296        // U+212B and U+00C5 render identically but are different code points;
297        // a normalisation pass hidden in the string plumbing would silently
298        // merge them and break byte-exact round-trips through FFI.
299        let angstrom = Label::create(AzString::from("\u{212B}".to_string()));
300        let a_ring = Label::create(AzString::from("\u{C5}".to_string()));
301        assert_ne!(angstrom.string, a_ring.string, "the two forms were normalised into one");
302        assert_eq!(angstrom.string.len(), 3, "U+212B must stay a 3-byte sequence");
303        assert_eq!(a_ring.string.len(), 2, "U+00C5 must stay a 2-byte sequence");
304    }
305
306    #[test]
307    fn create_uses_the_platform_table_for_this_target() {
308        let label = Label::create(AzString::from_const_str("hello"));
309        assert_eq!(
310            properties(&label.label_style),
311            expected_table().iter().map(|p| p.property.clone()).collect::<Vec<_>>(),
312            "the constructor picked the wrong platform style table"
313        );
314        assert_eq!(font_size_px(&label.label_style), expected_font_size());
315    }
316
317    #[test]
318    fn create_is_deterministic_and_independent_of_the_string() {
319        // The style must not depend on the text: two labels built from wildly
320        // different strings must carry byte-identical styling.
321        let a = Label::create(AzString::from_const_str(""));
322        let b = Label::create(AzString::from("x".repeat(100_000)));
323        assert_eq!(a.label_style, b.label_style, "the style varies with the label text");
324        assert_same_label(&a, &Label::create(AzString::from_const_str("")), "repeat construction");
325    }
326
327    #[test]
328    fn constructed_style_vec_has_consistent_length_and_capacity() {
329        let v = Label::create(AzString::from_const_str("hi")).label_style;
330        assert_eq!(v.len(), v.as_ref().len(), "len() disagrees with the slice view");
331        assert!(v.capacity() >= v.len(), "capacity {} < len {}", v.capacity(), v.len());
332        if target_is_styled() {
333            assert_eq!(v.len(), DECL_COUNT, "unexpected number of declarations");
334        } else {
335            assert!(v.is_empty(), "unsupported platforms are documented to get no styling");
336        }
337    }
338
339    #[test]
340    fn cloning_and_dropping_never_corrupts_the_shared_static_style() {
341        // `label_style` borrows a `'static` slice (`NoDestructor`) while
342        // `string` may be heap-owned. A clone that wrongly claims ownership of
343        // the static, or a `Drop` that frees it, would corrupt every future
344        // label — so churn hard and re-check both the original and a fresh build.
345        let base = Label::create(AzString::from("churn \u{1F600}".to_string()));
346        let expected_props = properties(&base.label_style);
347        for round in 0..1000 {
348            let c = base.clone();
349            assert_eq!(c.string.as_str(), "churn \u{1F600}", "clone {round}: the string diverged");
350            assert_eq!(properties(&c.label_style), expected_props, "clone {round}: the style diverged");
351            drop(c);
352        }
353        assert_eq!(base.string.as_str(), "churn \u{1F600}", "the original string was damaged");
354        assert_eq!(properties(&base.label_style), expected_props, "the original style was damaged");
355        assert_eq!(
356            properties(&Label::create(AzString::from_const_str("x")).label_style),
357            expected_props,
358            "a freshly built label disagrees after 1000 clone/drop cycles"
359        );
360    }
361
362    #[test]
363    fn a_const_str_label_survives_clone_churn_of_its_static_backing() {
364        // `AzString::from_const_str` is `NoDestructor`-backed; `clone_self`
365        // copies it. A shallow clone plus a `Drop` that freed the `'static`
366        // would be a use-after-free the very next read.
367        let base = Label::create(AzString::from_const_str(LABEL_CLASS_NAME));
368        for round in 0..1000 {
369            let c = base.clone();
370            assert_eq!(c.string.as_str(), LABEL_CLASS_NAME, "clone {round}: static backing corrupted");
371            drop(c);
372        }
373        assert_eq!(base.string.as_str(), LABEL_CLASS_NAME);
374    }
375
376    // ------------------------------------------------------------------
377    // The platform style tables
378    // ------------------------------------------------------------------
379
380    #[test]
381    fn the_two_populated_tables_differ_only_in_font_size() {
382        // They are hand-duplicated blocks: a fix applied to one and not the
383        // other is the failure mode this file is most exposed to.
384        assert_eq!(LABEL_STYLE_DEFAULT.len(), DECL_COUNT);
385        assert_eq!(LABEL_STYLE_MAC.len(), DECL_COUNT, "the two tables have diverged in length");
386        for (i, (d, m)) in LABEL_STYLE_DEFAULT.iter().zip(LABEL_STYLE_MAC.iter()).enumerate() {
387            assert_eq!(
388                core::mem::discriminant(&d.property),
389                core::mem::discriminant(&m.property),
390                "declaration {i} is a different property on the two platforms"
391            );
392            if matches!(d.property, CssProperty::FontSize(_)) {
393                assert_ne!(d.property, m.property, "the two tables were expected to differ in font size");
394            } else {
395                assert_eq!(d.property, m.property, "declaration {i} drifted between the two tables");
396            }
397        }
398    }
399
400    #[test]
401    fn label_style_other_is_empty_as_documented() {
402        // The doc comment promises "no default styling on unsupported
403        // platforms"; a stray declaration here would style WASM/FreeBSD
404        // differently from everything the other two tables were tuned against.
405        assert!(LABEL_STYLE_OTHER.is_empty(), "the fallback table is no longer empty");
406    }
407
408    #[test]
409    fn every_declaration_is_unconditional() {
410        // A label is stateless — a declaration gated on `:hover`/`:active`
411        // would simply never paint.
412        for (name, table) in [("default", LABEL_STYLE_DEFAULT), ("mac", LABEL_STYLE_MAC)] {
413            for p in table {
414                assert!(
415                    p.apply_if.as_ref().is_empty(),
416                    "{name}: {:?} is conditional on a stateless widget",
417                    p.property
418                );
419            }
420        }
421    }
422
423    #[test]
424    fn no_property_is_declared_twice() {
425        // A duplicated declaration is a last-one-wins ambiguity: two font sizes
426        // would make one of them silently dead.
427        for (name, table) in [("default", LABEL_STYLE_DEFAULT), ("mac", LABEL_STYLE_MAC)] {
428            let mut seen = HashSet::new();
429            for p in table {
430                assert!(
431                    seen.insert(core::mem::discriminant(&p.property)),
432                    "{name}: duplicate declaration of {:?}",
433                    p.property
434                );
435            }
436            assert_eq!(seen.len(), table.len());
437        }
438    }
439
440    #[test]
441    fn the_text_colour_is_the_documented_opaque_grey() {
442        // The doc comment pins it to #4C4C4C; a translucent label would let the
443        // background bleed through the glyphs.
444        assert_eq!(COLOR_4C4C4C, ColorU { r: 76, g: 76, b: 76, a: 255 });
445        assert_eq!(COLOR_4C4C4C.a, 255, "a translucent label lets the background bleed through");
446        assert_eq!(COLOR_4C4C4C.r, COLOR_4C4C4C.g, "the label colour is not neutral grey");
447        assert_eq!(COLOR_4C4C4C.g, COLOR_4C4C4C.b, "the label colour is not neutral grey");
448
449        for (name, table) in [("default", LABEL_STYLE_DEFAULT), ("mac", LABEL_STYLE_MAC)] {
450            let v = CssPropertyWithConditionsVec::from_const_slice(table);
451            assert_eq!(text_color(&v), Some(COLOR_4C4C4C), "{name}: wrong text colour");
452        }
453    }
454
455    #[test]
456    fn every_font_size_is_a_finite_positive_absolute_px() {
457        // Guard the `isize` -> `PixelValue` fixed-point conversion: a NaN, an
458        // infinity, a zero or a negative size must never reach the shaper.
459        for (name, table, want) in [("default", LABEL_STYLE_DEFAULT, 13.0), ("mac", LABEL_STYLE_MAC, 12.0)] {
460            let v = CssPropertyWithConditionsVec::from_const_slice(table);
461            let size = font_size_px(&v).expect("a label must declare a font size");
462            assert!(size.is_finite(), "{name}: non-finite font size {size}");
463            assert!(!size.is_nan(), "{name}: NaN font size");
464            assert!(size > 0.0, "{name}: a {size}px label is invisible");
465            assert!(size <= 128.0, "{name}: {size}px is implausible for a UI label");
466            assert_eq!(size, want, "{name}: unexpected font size");
467        }
468    }
469
470    #[test]
471    fn the_fixed_point_length_encoding_round_trips() {
472        // encode == decode for every numeric constant this file bakes in.
473        assert_eq!(PixelValue::const_px(13).number.get(), 13.0);
474        assert_eq!(PixelValue::const_px(12).number.get(), 12.0);
475        assert_eq!(StyleFontSize::const_px(13).inner.number.get(), 13.0);
476        assert_eq!(StyleFontSize::const_px(12).inner.number.get(), 12.0);
477        assert_eq!(LayoutFlexGrow::const_new(1).inner.get(), 1.0);
478
479        // ...and the values that actually landed in the tables are the ones the
480        // declarations asked for.
481        let d: Vec<CssProperty> = LABEL_STYLE_DEFAULT.iter().map(|p| p.property.clone()).collect();
482        assert!(d.contains(&CssProperty::const_font_size(StyleFontSize::const_px(13))));
483        let m: Vec<CssProperty> = LABEL_STYLE_MAC.iter().map(|p| p.property.clone()).collect();
484        assert!(m.contains(&CssProperty::const_font_size(StyleFontSize::const_px(12))));
485    }
486
487    #[test]
488    fn flex_grow_is_exactly_one_and_not_a_rounding_artefact() {
489        // `FloatValue` stores a fixed-point `isize`; a botched encode/decode
490        // would show up as 0.999 or -0.0 rather than a clean 1.
491        for (name, table) in [("default", LABEL_STYLE_DEFAULT), ("mac", LABEL_STYLE_MAC)] {
492            let v = CssPropertyWithConditionsVec::from_const_slice(table);
493            let g = flex_grow(&v).expect("flex-grow must be declared");
494            assert!(g.is_finite(), "{name}: non-finite flex-grow {g}");
495            assert_eq!(g, 1.0, "{name}: flex-grow is {g}, not 1");
496            assert!(g.is_sign_positive(), "{name}: flex-grow decoded as -0.0");
497        }
498    }
499
500    #[test]
501    fn the_font_family_is_a_single_system_ui_entry() {
502        // `SANS_SERIF` / `SANS_SERIF_FAMILY` are `const`, so each mention
503        // materialises a fresh value — bind them once and read them back.
504        let sentinel: AzString = SANS_SERIF;
505        let family_vec: StyleFontFamilyVec = SANS_SERIF_FAMILY;
506
507        assert_eq!(SANS_SERIF_STR, "system:ui");
508        assert_eq!(sentinel.as_str(), SANS_SERIF_STR, "the const AzString lost its backing str");
509        assert_eq!(sentinel.as_str().len(), SANS_SERIF_STR.len());
510        assert_eq!(SANS_SERIF_FAMILIES.len(), 1, "the fallback chain changed length");
511        assert_eq!(family_vec.as_ref().len(), 1, "the const vec disagrees with its slice");
512
513        for (name, table) in [("default", LABEL_STYLE_DEFAULT), ("mac", LABEL_STYLE_MAC)] {
514            let v = CssPropertyWithConditionsVec::from_const_slice(table);
515            let fams = font_families(&v).expect("a label must declare a font family");
516            assert_eq!(fams.len(), 1, "{name}: unexpected fallback chain length");
517            match &fams[0] {
518                // Pinned as-is: this file spells the UI font as a *named*
519                // family whose name happens to be the `system:ui` sentinel,
520                // not as `StyleFontFamily::SystemType(SystemFontType::Ui)`.
521                StyleFontFamily::System(s) => assert_eq!(s.as_str(), "system:ui", "{name}: wrong family name"),
522                other => panic!("{name}: unexpected font family variant {other:?}"),
523            }
524        }
525    }
526
527    #[test]
528    fn the_centering_declarations_are_mutually_consistent() {
529        // A label centres its text both ways; each half of that contract lives
530        // in a different declaration, so assert them together.
531        for (name, table) in [("default", LABEL_STYLE_DEFAULT), ("mac", LABEL_STYLE_MAC)] {
532            let props: Vec<CssProperty> = table.iter().map(|p| p.property.clone()).collect();
533            let has = |p: &CssProperty| props.contains(p);
534            assert!(has(&CssProperty::const_display(LayoutDisplay::Flex)), "{name}: not a flex box");
535            assert!(
536                has(&CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
537                "{name}: not a column"
538            );
539            assert!(
540                has(&CssProperty::const_justify_content(LayoutJustifyContent::Center)),
541                "{name}: not centred on the main axis"
542            );
543            assert!(
544                has(&CssProperty::const_align_items(LayoutAlignItems::Center)),
545                "{name}: not centred on the cross axis"
546            );
547            assert!(
548                has(&CssProperty::const_text_align(StyleTextAlign::Center)),
549                "{name}: the text itself is not centred"
550            );
551        }
552    }
553
554    // ------------------------------------------------------------------
555    // Label::swap_with_default
556    // ------------------------------------------------------------------
557
558    #[test]
559    fn swap_with_default_returns_the_original_and_leaves_an_empty_label() {
560        let mut label = Label::create(AzString::from("original \u{1F600}".to_string()));
561        let expected_style = label.label_style.clone();
562        let taken = label.swap_with_default();
563
564        // The returned value is the *original*, intact.
565        assert_eq!(taken.string.as_str(), "original \u{1F600}", "the wrong value was returned");
566        assert_eq!(taken.label_style, expected_style, "the returned label lost its style");
567
568        // What is left behind is a freshly created empty label — not a hollowed
569        // out husk with a dangling string or an empty style vec.
570        assert_eq!(label.string.as_str(), "", "what was left behind is not empty");
571        assert!(label.string.is_empty());
572        assert_same_label(&label, &Label::create(AzString::from_const_str("")), "left-behind label");
573    }
574
575    #[test]
576    fn swap_with_default_is_idempotent_on_an_already_empty_label() {
577        let mut label = Label::create(AzString::from_const_str(""));
578        let first = label.swap_with_default();
579        let second = label.swap_with_default();
580        assert_eq!(first.string.as_str(), "");
581        assert_eq!(second.string.as_str(), "");
582        assert_same_label(&label, &Label::create(AzString::from_const_str("")), "after two swaps");
583    }
584
585    #[test]
586    fn repeated_swaps_never_corrupt_the_static_backed_style() {
587        // `mem::swap` moves a vec that borrows a `'static` slice; 200 rounds of
588        // swap-and-drop would surface a double free or a dangling `ptr`.
589        let mut label = Label::create(AzString::from("swap me".to_string()));
590        for round in 0..200 {
591            let taken = label.swap_with_default();
592            if round == 0 {
593                assert_eq!(taken.string.as_str(), "swap me", "round 0: wrong value returned");
594            } else {
595                assert_eq!(taken.string.as_str(), "", "round {round}: the emptied slot was not empty");
596            }
597            assert_eq!(properties(&taken.label_style), properties(&label.label_style), "round {round}");
598            assert_eq!(label.string.as_str(), "", "round {round}: what was left behind is not empty");
599            drop(taken);
600        }
601        assert_same_label(&label, &Label::create(AzString::from_const_str("")), "after 200 swaps");
602    }
603
604    #[test]
605    fn swap_with_default_returns_a_custom_style_untouched() {
606        // Both fields are `pub`, so a caller can hand-build a label. The swap
607        // must hand that style back rather than rewriting it on the way out.
608        let custom = CssPropertyWithConditionsVec::from_vec(vec![CssPropertyWithConditions::simple(
609            CssProperty::const_font_size(StyleFontSize::const_px(42)),
610        )]);
611        let mut label = Label {
612            string: AzString::from_const_str("custom"),
613            label_style: custom,
614        };
615        let taken = label.swap_with_default();
616        assert_eq!(taken.string.as_str(), "custom");
617        assert_eq!(taken.label_style.len(), 1, "the custom style was rewritten on the way out");
618        assert_eq!(font_size_px(&taken.label_style), Some(42.0));
619        // ...and the slot is refilled with the platform default, not the custom one.
620        assert_eq!(font_size_px(&label.label_style), expected_font_size(), "the 42px override survived");
621    }
622
623    #[test]
624    fn swap_with_default_hands_back_a_huge_string_without_truncating_it() {
625        let huge = "\u{1F600}".repeat(50_000);
626        let mut label = Label::create(AzString::from(huge.clone()));
627        let taken = label.swap_with_default();
628        assert_eq!(taken.string.len(), huge.len(), "the huge string was truncated on the way out");
629        assert_eq!(taken.string.as_str(), huge.as_str());
630        assert!(label.string.is_empty());
631    }
632
633    // ------------------------------------------------------------------
634    // Label::dom  (round-trip: label -> DOM)
635    // ------------------------------------------------------------------
636
637    #[test]
638    fn dom_is_a_single_classed_text_node_carrying_the_computed_style() {
639        let label = Label::create(AzString::from_const_str("Hello"));
640        let expected = properties(&label.label_style);
641        let dom = label.dom();
642
643        assert_eq!(text_of(&dom), Some("Hello"), "the label is not a text node, or was mangled");
644        assert!(has_class(&dom, LABEL_CLASS_NAME), "missing the widget class");
645        assert_eq!(
646            dom.root.get_ids_and_classes().as_ref().len(),
647            1,
648            "expected exactly one class and no ids"
649        );
650        assert!(dom.children.as_ref().is_empty(), "a label is a leaf, not a subtree");
651        assert_eq!(dom.estimated_total_children, 0, "a leaf must not claim descendants");
652        assert!(dom.css.as_ref().is_empty(), "a label must not attach a scoped stylesheet");
653        assert!(dom.root.callbacks.as_ref().is_empty(), "a static widget must not bind callbacks");
654        assert_eq!(inline_properties(&dom), expected, "the label lost its computed style");
655    }
656
657    #[test]
658    fn dom_preserves_adversarial_labels_verbatim() {
659        for s in adversarial_strings() {
660            let dom = Label::create(AzString::from(s.clone())).dom();
661            let t = text_of(&dom).expect("expected a text node");
662            assert_eq!(t, s.as_str(), "the label changed on its way into the DOM");
663            assert_eq!(t.len(), s.len(), "byte length changed (NUL truncation?)");
664            assert!(has_class(&dom, LABEL_CLASS_NAME), "the class was lost for {s:?}");
665            // The text must never leak into the style, and the style must never
666            // vary with the text.
667            assert_eq!(inline_properties(&dom).len(), expected_table().len(), "style varies with the text");
668        }
669    }
670
671    #[test]
672    fn dom_of_an_empty_label_is_still_a_classed_text_node() {
673        // The empty label is what `swap_with_default` leaves behind, so it is
674        // the one input guaranteed to be rendered somewhere.
675        let dom = Label::create(AzString::from_const_str("")).dom();
676        assert_eq!(text_of(&dom), Some(""), "the empty label is not a text node");
677        assert!(has_class(&dom, LABEL_CLASS_NAME));
678        assert_eq!(inline_properties(&dom).len(), expected_table().len());
679    }
680
681    #[test]
682    fn dom_renders_the_style_field_verbatim_even_when_hand_built() {
683        // `dom()` consumes `label_style` as-is. A hand-built label must be
684        // rendered with exactly the style it was given — no re-derivation.
685        let custom = CssPropertyWithConditionsVec::from_vec(vec![
686            CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(99))),
687            CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
688        ]);
689        let dom = Label {
690            string: AzString::from_const_str("hand built"),
691            label_style: custom.clone(),
692        }
693        .dom();
694        assert_eq!(inline_properties(&dom), properties(&custom), "the custom style was rewritten");
695        assert!(has_class(&dom, LABEL_CLASS_NAME), "a hand-built label lost the widget class");
696        assert_eq!(text_of(&dom), Some("hand built"));
697    }
698
699    #[test]
700    fn dom_of_a_style_less_label_carries_no_inline_declarations() {
701        // The `LABEL_STYLE_OTHER` shape: an empty style vec must produce an
702        // empty inline style, not a phantom rule block.
703        let dom = Label {
704            string: AzString::from_const_str("bare"),
705            label_style: CssPropertyWithConditionsVec::from_const_slice(LABEL_STYLE_OTHER),
706        }
707        .dom();
708        assert!(inline_properties(&dom).is_empty(), "an empty style vec produced declarations");
709        assert!(has_class(&dom, LABEL_CLASS_NAME));
710        assert_eq!(text_of(&dom), Some("bare"));
711    }
712
713    #[test]
714    fn the_widget_class_is_a_namespaced_ascii_css_identifier() {
715        let dom = Label::create(AzString::from_const_str("x")).dom();
716        let classes = dom.root.get_ids_and_classes();
717        let name = classes
718            .as_ref()
719            .iter()
720            .find_map(|c| match c {
721                IdOrClass::Class(s) => Some(s.as_str().to_string()),
722                IdOrClass::Id(_) => None,
723            })
724            .expect("the label must carry a class");
725
726        assert_eq!(name, LABEL_CLASS_NAME);
727        assert!(!name.is_empty(), "empty class name");
728        assert!(name.is_ascii(), "non-ASCII class name {name:?}");
729        assert!(name.starts_with("__azul-native-"), "unnamespaced class {name:?}");
730        // A space, a dot or a `#` would silently split/re-target the selector.
731        assert!(
732            name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
733            "class name {name:?} contains a CSS-significant character"
734        );
735    }
736
737    #[test]
738    fn a_label_whose_text_is_the_class_name_does_not_gain_a_second_class() {
739        // `dom()` sets ids-and-classes *after* the text node is built; a
740        // confusion between the two would show up as an extra attribute here.
741        let dom = Label::create(AzString::from_const_str(LABEL_CLASS_NAME)).dom();
742        assert_eq!(dom.root.get_ids_and_classes().as_ref().len(), 1, "the text leaked into the class list");
743        assert_eq!(text_of(&dom), Some(LABEL_CLASS_NAME));
744    }
745
746    #[test]
747    fn from_label_for_dom_is_exactly_dom() {
748        for s in ["", "ok", "\u{1F600}\0"] {
749            let label = Label::create(AzString::from(s.to_string()));
750            let via_into: Dom = label.clone().into();
751            let via_dom = label.dom();
752            assert_eq!(via_into, via_dom, "{s:?}: `From` diverges from `dom()`");
753        }
754    }
755
756    #[test]
757    fn building_many_doms_never_corrupts_the_shared_static_class_list() {
758        // `dom()` hands a `'static`-backed `IdOrClassVec` to each node it
759        // builds; 1000 build-and-drop rounds would surface a double free.
760        for round in 0..1000 {
761            let dom = Label::create(AzString::from_const_str("churn")).dom();
762            assert!(has_class(&dom, LABEL_CLASS_NAME), "round {round}: the static class list was corrupted");
763            drop(dom);
764        }
765        let dom = Label::create(AzString::from_const_str("churn")).dom();
766        assert!(has_class(&dom, LABEL_CLASS_NAME), "the static class list did not survive the churn");
767        assert_eq!(inline_properties(&dom).len(), expected_table().len());
768    }
769}