Skip to main content

azul_layout/widgets/
badge.rs

1//! Badge widget — a small rounded "pill" showing a short count or status string
2//! (e.g. a notification count or a status label). A stateless, single styled
3//! text node with no callback — a near-clone of [`crate::widgets::label::Label`]
4//! restyled as a coloured pill, with an optional [`BadgeKind`] colour variant
5//! (mirroring `button::ButtonType`).
6//!
7//! Key types: [`Badge`], [`BadgeKind`].
8
9use azul_core::dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec};
10use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
11use azul_css::{
12    props::{
13        basic::{color::ColorU, StyleFontSize},
14        layout::{LayoutDisplay, LayoutFlexDirection, LayoutJustifyContent, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight},
15        property::{CssProperty, *},
16        style::{StyleBackgroundContentVec, StyleBackgroundContent, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextAlign, StyleTextColor},
17    },
18    AzString,
19};
20
21/// The semantic colour variant of a [`Badge`] (mirrors `button::ButtonType`).
22#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
23#[repr(C)]
24pub enum BadgeKind {
25    /// Neutral grey badge — the default.
26    #[default]
27    Default,
28    /// Blue "primary" badge.
29    Primary,
30    /// Green "success" badge.
31    Success,
32    /// Red "danger" badge.
33    Danger,
34    /// Yellow "warning" badge (uses dark text).
35    Warning,
36    /// Cyan "info" badge (uses dark text).
37    Info,
38}
39
40impl BadgeKind {
41    /// Returns the `(background, text)` colours for this badge kind.
42    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
43    const fn colors(&self) -> (ColorU, ColorU) {
44        const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
45        const DARK: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
46        match self {
47            Self::Default => (ColorU { r: 108, g: 117, b: 125, a: 255 }, WHITE),
48            Self::Primary => (ColorU { r: 13, g: 110, b: 253, a: 255 }, WHITE),
49            Self::Success => (ColorU { r: 25, g: 135, b: 84, a: 255 }, WHITE),
50            Self::Danger => (ColorU { r: 220, g: 53, b: 69, a: 255 }, WHITE),
51            Self::Warning => (ColorU { r: 255, g: 193, b: 7, a: 255 }, DARK),
52            Self::Info => (ColorU { r: 13, g: 202, b: 240, a: 255 }, DARK),
53        }
54    }
55
56    /// CSS class name for this badge kind (mirrors `ButtonType::class_name`).
57    #[must_use] pub const fn class_name(&self) -> &'static str {
58        match self {
59            Self::Default => "__azul-badge-default",
60            Self::Primary => "__azul-badge-primary",
61            Self::Success => "__azul-badge-success",
62            Self::Danger => "__azul-badge-danger",
63            Self::Warning => "__azul-badge-warning",
64            Self::Info => "__azul-badge-info",
65        }
66    }
67}
68
69/// A small rounded pill showing a short status/count string. Stateless;
70/// renders a single styled text node.
71#[derive(Debug, Clone, PartialEq, Eq)]
72#[repr(C)]
73pub struct Badge {
74    /// The text shown inside the pill.
75    pub string: AzString,
76    /// The colour variant.
77    pub kind: BadgeKind,
78    /// The computed inline style for the pill.
79    pub badge_style: CssPropertyWithConditionsVec,
80}
81
82/// Builds the pill style for a given [`BadgeKind`]. The colours are the only
83/// kind-dependent properties, so the style is built at runtime per the recipe's
84/// "runtime vec when param-dependent" path (see `switch::build_track_style`).
85fn build_badge_style(kind: BadgeKind) -> CssPropertyWithConditionsVec {
86    let (bg, text) = kind.colors();
87    let bg_vec =
88        StyleBackgroundContentVec::from_vec(alloc::vec![StyleBackgroundContent::Color(bg)]);
89    CssPropertyWithConditionsVec::from_vec(alloc::vec![
90        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
91        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
92            LayoutFlexDirection::Row,
93        )),
94        CssPropertyWithConditions::simple(CssProperty::const_justify_content(
95            LayoutJustifyContent::Center,
96        )),
97        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
98        // Hug the content rather than stretch across a flex parent's cross axis.
99        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
100        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
101            0,
102        ))),
103        // padding: 2px 8px
104        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
105            2,
106        ))),
107        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
108            LayoutPaddingBottom::const_px(2),
109        )),
110        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
111            LayoutPaddingLeft::const_px(8),
112        )),
113        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
114            LayoutPaddingRight::const_px(8),
115        )),
116        // border-radius: 10px (pill)
117        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
118            StyleBorderTopLeftRadius::const_px(10),
119        )),
120        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
121            StyleBorderTopRightRadius::const_px(10),
122        )),
123        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
124            StyleBorderBottomLeftRadius::const_px(10),
125        )),
126        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
127            StyleBorderBottomRightRadius::const_px(10),
128        )),
129        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(12))),
130        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
131        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
132            inner: text,
133        })),
134        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg_vec)),
135    ])
136}
137
138impl Badge {
139    /// Creates a new badge with the given text and the default (grey) kind.
140    #[inline]
141    #[must_use] pub fn create(string: AzString) -> Self {
142        Self::with_kind(string, BadgeKind::Default)
143    }
144
145    /// Creates a new badge with the given text and colour variant.
146    #[inline]
147    #[must_use] pub fn with_kind(string: AzString, kind: BadgeKind) -> Self {
148        Self {
149            string,
150            kind,
151            badge_style: build_badge_style(kind),
152        }
153    }
154
155    /// Sets the colour variant, recomputing the style.
156    #[inline]
157    pub fn set_kind(&mut self, kind: BadgeKind) {
158        self.kind = kind;
159        self.badge_style = build_badge_style(kind);
160    }
161
162    /// Builder-style setter for the colour variant.
163    #[inline]
164    #[must_use] pub fn with_badge_kind(mut self, kind: BadgeKind) -> Self {
165        self.set_kind(kind);
166        self
167    }
168
169    /// Replaces `self` with an empty default badge and returns the original.
170    #[inline]
171    #[must_use] pub fn swap_with_default(&mut self) -> Self {
172        let mut s = Self::create(AzString::from_const_str(""));
173        core::mem::swap(&mut s, self);
174        s
175    }
176
177    /// Converts this badge into a DOM text node with the `__azul-native-badge` class.
178    #[inline]
179    #[must_use] pub fn dom(self) -> Dom {
180        static BADGE_CLASS: &[IdOrClass] =
181            &[Class(AzString::from_const_str("__azul-native-badge"))];
182
183        Dom::create_text(self.string)
184            .with_ids_and_classes(IdOrClassVec::from_const_slice(BADGE_CLASS))
185            .with_css_props(self.badge_style)
186    }
187}
188
189impl Default for Badge {
190    fn default() -> Self {
191        Self::create(AzString::from_const_str(""))
192    }
193}
194
195impl From<Badge> for Dom {
196    fn from(b: Badge) -> Self {
197        b.dom()
198    }
199}
200
201#[cfg(test)]
202mod autotest_generated {
203    use std::collections::HashSet;
204
205    use azul_core::dom::NodeType;
206    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
207
208    use super::*;
209
210    // ------------------------------------------------------------------
211    // Helpers
212    // ------------------------------------------------------------------
213
214    /// Every variant of `BadgeKind` — the complete input domain of `colors`,
215    /// `class_name` and `build_badge_style`.
216    const ALL_KINDS: [BadgeKind; 6] = [
217        BadgeKind::Default,
218        BadgeKind::Primary,
219        BadgeKind::Success,
220        BadgeKind::Danger,
221        BadgeKind::Warning,
222        BadgeKind::Info,
223    ];
224
225    const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
226    const DARK: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
227
228    /// The declared properties of a style vec, in declaration order.
229    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
230        v.as_ref().iter().map(|p| p.property.clone()).collect()
231    }
232
233    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length — an
234    /// `em`/`%` slipping into the pill geometry would resolve against the parent
235    /// font/box instead of the intended fixed padding or radius.
236    fn px(pv: &PixelValue) -> f32 {
237        assert_eq!(pv.metric, SizeMetric::Px, "badge geometry must be absolute px, got {:?}", pv.metric);
238        pv.number.get()
239    }
240
241    /// The four paddings in `(top, bottom, left, right)` order.
242    fn padding_px(v: &CssPropertyWithConditionsVec) -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
243        let find = |f: &dyn Fn(&CssProperty) -> Option<f32>| v.as_ref().iter().find_map(|p| f(&p.property));
244        (
245            find(&|p| match p {
246                CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
247                _ => None,
248            }),
249            find(&|p| match p {
250                CssProperty::PaddingBottom(x) => x.get_property().map(|x| px(&x.inner)),
251                _ => None,
252            }),
253            find(&|p| match p {
254                CssProperty::PaddingLeft(x) => x.get_property().map(|x| px(&x.inner)),
255                _ => None,
256            }),
257            find(&|p| match p {
258                CssProperty::PaddingRight(x) => x.get_property().map(|x| px(&x.inner)),
259                _ => None,
260            }),
261        )
262    }
263
264    /// The four corner radii, in declaration order.
265    fn radii_px(v: &CssPropertyWithConditionsVec) -> Vec<f32> {
266        v.as_ref()
267            .iter()
268            .filter_map(|p| match &p.property {
269                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
270                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
271                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
272                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
273                _ => None,
274            })
275            .collect()
276    }
277
278    fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
279        v.as_ref().iter().find_map(|p| match &p.property {
280            CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
281            _ => None,
282        })
283    }
284
285    fn text_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
286        v.as_ref().iter().find_map(|p| match &p.property {
287            CssProperty::TextColor(c) => c.get_property().map(|c| c.inner),
288            _ => None,
289        })
290    }
291
292    /// The single background layer of a style vec, asserting there is exactly one
293    /// and that it is a flat colour (a gradient would not be a `Color`).
294    fn background_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
295        let bg = v.as_ref().iter().find_map(|p| match &p.property {
296            CssProperty::BackgroundContent(b) => b.get_property(),
297            _ => None,
298        })?;
299        assert_eq!(bg.as_ref().len(), 1, "a badge must declare exactly one background layer");
300        match &bg.as_ref()[0] {
301            StyleBackgroundContent::Color(c) => Some(*c),
302            other => panic!("badge background is not a flat colour: {other:?}"),
303        }
304    }
305
306    /// Every `PixelValue` a style vec mentions (paddings, radii, font size).
307    fn all_pixel_values(v: &CssPropertyWithConditionsVec) -> Vec<PixelValue> {
308        v.as_ref()
309            .iter()
310            .filter_map(|p| match &p.property {
311                CssProperty::PaddingTop(x) => x.get_property().map(|x| x.inner),
312                CssProperty::PaddingBottom(x) => x.get_property().map(|x| x.inner),
313                CssProperty::PaddingLeft(x) => x.get_property().map(|x| x.inner),
314                CssProperty::PaddingRight(x) => x.get_property().map(|x| x.inner),
315                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| r.inner),
316                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| r.inner),
317                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| r.inner),
318                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| r.inner),
319                CssProperty::FontSize(f) => f.get_property().map(|f| f.inner),
320                _ => None,
321            })
322            .collect()
323    }
324
325    /// Perceived brightness (0..=255) of an sRGB colour, Rec.709 weights. Kept to
326    /// plain `+`/`*` (no gamma expansion) so the readability assertions below stay
327    /// exact and toolchain-independent.
328    fn luma(c: ColorU) -> f32 {
329        0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b)
330    }
331
332    /// True if `node` carries the CSS class `name`.
333    fn has_class(node: &Dom, name: &str) -> bool {
334        node.root
335            .get_ids_and_classes()
336            .as_ref()
337            .iter()
338            .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == name))
339    }
340
341    /// The properties of a rendered node's *inline* style, in declaration order.
342    fn inline_properties(node: &Dom) -> Vec<CssProperty> {
343        node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
344    }
345
346    /// Adversarial badge texts: empty, whitespace, combining marks, ZWJ emoji,
347    /// RTL, embedded NULs (`AzString` is length-based, so a NUL must not
348    /// truncate) and a string far longer than any plausible badge label.
349    fn adversarial_strings() -> Vec<String> {
350        let mut v: Vec<String> = [
351            "",
352            "9",
353            "99+",
354            " ",
355            "e\u{0301}",                                   // e + combining acute
356            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family emoji
357            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",                // RTL Hebrew
358            "\0",                                          // a single NUL
359            "a\0b",                                        // embedded NUL
360            "\u{FFFD}\u{202E}\u{200B}",                    // replacement char, RTL override, ZWSP
361            "-9223372036854775808",                        // i64::MIN as a "count"
362        ]
363        .iter()
364        .map(|s| (*s).to_string())
365        .collect();
366        v.push("x".repeat(100_000));
367        v
368    }
369
370    // ------------------------------------------------------------------
371    // BadgeKind::colors  (getter)
372    // ------------------------------------------------------------------
373
374    #[test]
375    fn colors_returns_the_documented_constants_for_every_kind() {
376        let expected = [
377            (BadgeKind::Default, ColorU { r: 108, g: 117, b: 125, a: 255 }, WHITE),
378            (BadgeKind::Primary, ColorU { r: 13, g: 110, b: 253, a: 255 }, WHITE),
379            (BadgeKind::Success, ColorU { r: 25, g: 135, b: 84, a: 255 }, WHITE),
380            (BadgeKind::Danger, ColorU { r: 220, g: 53, b: 69, a: 255 }, WHITE),
381            (BadgeKind::Warning, ColorU { r: 255, g: 193, b: 7, a: 255 }, DARK),
382            (BadgeKind::Info, ColorU { r: 13, g: 202, b: 240, a: 255 }, DARK),
383        ];
384        for (kind, bg, text) in expected {
385            assert_eq!(kind.colors(), (bg, text), "{kind:?}: wrong (background, text) pair");
386        }
387        // The doc comments promise Warning/Info are the dark-text kinds and no
388        // others: a fifth white-text kind sneaking in here is a regression.
389        for kind in ALL_KINDS {
390            let (_, text) = kind.colors();
391            let dark_text = matches!(kind, BadgeKind::Warning | BadgeKind::Info);
392            assert_eq!(text == DARK, dark_text, "{kind:?}: text colour contradicts the documented variant");
393        }
394    }
395
396    #[test]
397    fn colors_are_fully_opaque_on_every_kind() {
398        // A non-opaque pill would let the page background bleed through and
399        // silently destroy the contrast the kind was chosen for.
400        for kind in ALL_KINDS {
401            let (bg, text) = kind.colors();
402            assert_eq!(bg.a, 255, "{kind:?}: translucent background {bg:?}");
403            assert_eq!(text.a, 255, "{kind:?}: translucent text colour {text:?}");
404        }
405    }
406
407    #[test]
408    fn colors_give_every_kind_a_distinguishable_background() {
409        // Two kinds that render identically make the semantic variant useless.
410        let mut seen = HashSet::new();
411        for kind in ALL_KINDS {
412            let (bg, _) = kind.colors();
413            assert!(seen.insert((bg.r, bg.g, bg.b, bg.a)), "{kind:?}: duplicate background colour {bg:?}");
414        }
415        assert_eq!(seen.len(), ALL_KINDS.len());
416    }
417
418    #[test]
419    fn colors_pick_the_more_readable_of_the_two_text_colours() {
420        // The only real invariant of `colors()`: the text must be legible on the
421        // pill. For each kind the chosen text colour must be further from the
422        // background (in perceived brightness) than the rejected alternative,
423        // and light backgrounds must take the dark text.
424        for kind in ALL_KINDS {
425            let (bg, text) = kind.colors();
426            let other = if text == WHITE { DARK } else { WHITE };
427
428            let chosen = (luma(bg) - luma(text)).abs();
429            let rejected = (luma(bg) - luma(other)).abs();
430            assert!(
431                chosen > rejected,
432                "{kind:?}: text {text:?} (Δluma {chosen:.1}) is less readable on {bg:?} than {other:?} (Δluma {rejected:.1})"
433            );
434            assert!(chosen >= 60.0, "{kind:?}: text/background brightness gap {chosen:.1} is too low to read");
435
436            // Mid-grey split: a light pill must not carry white text.
437            let light_bg = luma(bg) >= 128.0;
438            assert_eq!(text == DARK, light_bg, "{kind:?}: bg luma {:.1} but text is {text:?}", luma(bg));
439        }
440    }
441
442    #[test]
443    fn colors_is_pure_and_the_default_kind_is_grey() {
444        assert_eq!(BadgeKind::default(), BadgeKind::Default);
445        assert_eq!(BadgeKind::default().colors(), BadgeKind::Default.colors());
446        // `colors()` takes `&self` on a `Copy` enum: repeated calls, and calls
447        // through a copy, must be side-effect free and identical.
448        for kind in ALL_KINDS {
449            let copy = kind;
450            assert_eq!(kind.colors(), kind.colors(), "{kind:?}: colors() is not pure");
451            assert_eq!(kind.colors(), copy.colors(), "{kind:?}: a copy disagrees with the original");
452        }
453    }
454
455    // ------------------------------------------------------------------
456    // BadgeKind::class_name  (getter)
457    // ------------------------------------------------------------------
458
459    #[test]
460    fn class_name_returns_the_documented_string_for_every_kind() {
461        assert_eq!(BadgeKind::Default.class_name(), "__azul-badge-default");
462        assert_eq!(BadgeKind::Primary.class_name(), "__azul-badge-primary");
463        assert_eq!(BadgeKind::Success.class_name(), "__azul-badge-success");
464        assert_eq!(BadgeKind::Danger.class_name(), "__azul-badge-danger");
465        assert_eq!(BadgeKind::Warning.class_name(), "__azul-badge-warning");
466        assert_eq!(BadgeKind::Info.class_name(), "__azul-badge-info");
467        assert_eq!(BadgeKind::default().class_name(), "__azul-badge-default");
468    }
469
470    #[test]
471    fn class_name_is_unique_per_kind_and_a_usable_css_identifier() {
472        let mut seen = HashSet::new();
473        for kind in ALL_KINDS {
474            let name = kind.class_name();
475            assert!(seen.insert(name), "{kind:?}: class name {name:?} collides with another kind");
476            assert!(!name.is_empty(), "{kind:?}: empty class name");
477            assert!(name.starts_with("__azul-badge-"), "{kind:?}: unnamespaced class {name:?}");
478            assert!(name.is_ascii(), "{kind:?}: non-ASCII class name {name:?}");
479            // A space, a dot or a `#` would silently split/re-target the selector.
480            assert!(
481                name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
482                "{kind:?}: class name {name:?} contains a CSS-significant character"
483            );
484            // The returned `&'static str` must be stable across calls.
485            assert_eq!(name.as_ptr(), kind.class_name().as_ptr(), "{kind:?}: class_name() is not a stable constant");
486        }
487        assert_eq!(seen.len(), ALL_KINDS.len());
488    }
489
490    // ------------------------------------------------------------------
491    // build_badge_style
492    // ------------------------------------------------------------------
493
494    #[test]
495    fn build_badge_style_emits_the_documented_pill_geometry() {
496        for kind in ALL_KINDS {
497            let style = build_badge_style(kind);
498            assert_eq!(padding_px(&style), (Some(2.0), Some(2.0), Some(8.0), Some(8.0)), "{kind:?}: padding is not 2px 8px");
499            assert_eq!(radii_px(&style), vec![10.0, 10.0, 10.0, 10.0], "{kind:?}: all four corners must carry a 10px radius");
500            assert_eq!(font_size_px(&style), Some(12.0), "{kind:?}: wrong font size");
501        }
502    }
503
504    #[test]
505    fn build_badge_style_radius_actually_rounds_the_pill_to_a_semicircle() {
506        // The widget's premise: the corner radius must reach at least half the
507        // content height (font + vertical padding), otherwise it renders as a
508        // rounded rectangle rather than a pill.
509        for kind in ALL_KINDS {
510            let style = build_badge_style(kind);
511            let (top, bottom, ..) = padding_px(&style);
512            let height = font_size_px(&style).expect("a font size must be declared")
513                + top.expect("padding-top")
514                + bottom.expect("padding-bottom");
515            for r in radii_px(&style) {
516                assert!(r * 2.0 >= height, "{kind:?}: radius {r} does not reach half of the {height}px pill height");
517            }
518        }
519    }
520
521    #[test]
522    fn build_badge_style_hugs_its_content_and_centres_the_text() {
523        for kind in ALL_KINDS {
524            let props = properties(&build_badge_style(kind));
525            let has = |p: &CssProperty| props.contains(p);
526
527            assert!(has(&CssProperty::const_display(LayoutDisplay::Flex)), "{kind:?}: not a flex box");
528            assert!(has(&CssProperty::const_flex_direction(LayoutFlexDirection::Row)), "{kind:?}: wrong flex direction");
529            assert!(has(&CssProperty::const_justify_content(LayoutJustifyContent::Center)), "{kind:?}: text not centred");
530            assert!(has(&CssProperty::const_align_items(LayoutAlignItems::Center)), "{kind:?}: text not centred");
531            assert!(has(&CssProperty::const_text_align(StyleTextAlign::Center)), "{kind:?}: text not centred");
532            // align-self: start + flex-grow: 0 — without both, the pill stretches
533            // across a flex parent instead of hugging its label.
534            assert!(has(&CssProperty::align_self(LayoutAlignSelf::Start)), "{kind:?}: badge stretches on the cross axis");
535            assert!(
536                has(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
537                "{kind:?}: badge grows on the main axis"
538            );
539        }
540    }
541
542    #[test]
543    fn build_badge_style_colours_track_the_kind() {
544        for kind in ALL_KINDS {
545            let style = build_badge_style(kind);
546            let (bg, text) = kind.colors();
547            assert_eq!(background_color(&style), Some(bg), "{kind:?}: emitted background != colors().0");
548            assert_eq!(text_color(&style), Some(text), "{kind:?}: emitted text colour != colors().1");
549        }
550    }
551
552    #[test]
553    fn build_badge_style_declares_every_property_at_most_once() {
554        // A duplicated declaration is a last-one-wins ambiguity: two backgrounds
555        // would make one of them silently dead.
556        for kind in ALL_KINDS {
557            let props = properties(&build_badge_style(kind));
558            let mut seen = HashSet::new();
559            for p in &props {
560                assert!(seen.insert(core::mem::discriminant(p)), "{kind:?}: duplicate declaration of {p:?}");
561            }
562            assert_eq!(seen.len(), props.len());
563        }
564    }
565
566    #[test]
567    fn build_badge_style_properties_are_all_unconditional() {
568        // A badge is stateless — a declaration gated on `:hover`/`:active` would
569        // simply never paint.
570        for kind in ALL_KINDS {
571            for p in build_badge_style(kind).as_ref() {
572                assert!(
573                    p.apply_if.as_ref().is_empty(),
574                    "{kind:?}: {:?} is conditional on a stateless widget",
575                    p.property
576                );
577            }
578        }
579    }
580
581    #[test]
582    fn build_badge_style_is_deterministic_and_kind_dependent() {
583        let len = build_badge_style(BadgeKind::Default).as_ref().len();
584        for kind in ALL_KINDS {
585            assert_eq!(
586                properties(&build_badge_style(kind)),
587                properties(&build_badge_style(kind)),
588                "{kind:?}: two builds of the same kind disagree"
589            );
590            assert_eq!(
591                build_badge_style(kind).as_ref().len(),
592                len,
593                "{kind:?}: emits a different number of declarations than Default"
594            );
595        }
596        // No two kinds may collapse onto the same style, or the variant is a no-op.
597        for (i, a) in ALL_KINDS.iter().enumerate() {
598            for b in &ALL_KINDS[i + 1..] {
599                assert_ne!(
600                    properties(&build_badge_style(*a)),
601                    properties(&build_badge_style(*b)),
602                    "{a:?} and {b:?} produce an identical style"
603                );
604            }
605        }
606    }
607
608    #[test]
609    fn build_badge_style_emits_only_finite_non_negative_px_lengths() {
610        // Guard the one numeric conversion in this file (`isize` -> `PixelValue`):
611        // a NaN/inf/negative length must never reach the layout solver.
612        for kind in ALL_KINDS {
613            let values = all_pixel_values(&build_badge_style(kind));
614            assert_eq!(values.len(), 9, "{kind:?}: expected 4 paddings + 4 radii + 1 font size");
615            for pv in values {
616                let n = px(&pv); // also asserts SizeMetric::Px
617                assert!(n.is_finite(), "{kind:?}: non-finite length {n}");
618                assert!(n >= 0.0, "{kind:?}: negative length {n}");
619                assert!(n <= 128.0, "{kind:?}: implausibly large length {n} for a badge");
620            }
621        }
622    }
623
624    // ------------------------------------------------------------------
625    // Badge::create / Badge::with_kind  (constructors)
626    // ------------------------------------------------------------------
627
628    #[test]
629    fn create_defaults_to_grey_and_keeps_the_text_verbatim() {
630        for s in adversarial_strings() {
631            let b = Badge::create(AzString::from(s.clone()));
632            assert_eq!(b.string.as_str(), s.as_str(), "the label was not preserved verbatim");
633            assert_eq!(b.string.len(), s.len(), "byte length changed (NUL truncation?)");
634            assert_eq!(b.kind, BadgeKind::Default, "create() must use the grey default kind");
635            assert_eq!(properties(&b.badge_style), properties(&build_badge_style(BadgeKind::Default)));
636        }
637    }
638
639    #[test]
640    fn with_kind_stores_both_arguments_and_the_matching_style() {
641        for kind in ALL_KINDS {
642            for s in adversarial_strings() {
643                let b = Badge::with_kind(AzString::from(s.clone()), kind);
644                assert_eq!(b.string.as_str(), s.as_str(), "{kind:?}: label not preserved");
645                assert_eq!(b.string.len(), s.len(), "{kind:?}: byte length changed");
646                assert_eq!(b.kind, kind, "{kind:?}: kind field does not match the argument");
647                // The invariant that makes `badge_style` a cache and not a lie.
648                assert_eq!(properties(&b.badge_style), properties(&build_badge_style(kind)));
649                assert_eq!(background_color(&b.badge_style), Some(kind.colors().0));
650            }
651        }
652    }
653
654    #[test]
655    fn create_is_with_kind_default() {
656        for s in ["", "99+", "\u{1F600}"] {
657            assert_eq!(
658                Badge::create(AzString::from_const_str(s)),
659                Badge::with_kind(AzString::from_const_str(s), BadgeKind::Default)
660            );
661        }
662    }
663
664    #[test]
665    fn default_badge_is_an_empty_grey_badge_and_equality_sees_every_field() {
666        let d = Badge::default();
667        assert_eq!(d, Badge::create(AzString::from_const_str("")));
668        assert_eq!(d.string.as_str(), "");
669        assert_eq!(d.kind, BadgeKind::Default);
670        assert_eq!(d.clone(), d, "Clone must preserve equality");
671
672        assert_ne!(d, Badge::create(AzString::from_const_str("9")), "the label must affect equality");
673        assert_ne!(
674            Badge::with_kind(AzString::from_const_str("9"), BadgeKind::Danger),
675            Badge::with_kind(AzString::from_const_str("9"), BadgeKind::Success),
676            "badges of different kinds must not compare equal"
677        );
678    }
679
680    // ------------------------------------------------------------------
681    // Badge::set_kind / with_badge_kind  (setters)
682    // ------------------------------------------------------------------
683
684    #[test]
685    fn set_kind_recomputes_the_style_without_growing_it() {
686        // A push-instead-of-replace bug would grow the style vec on every call and
687        // leave stale (earlier-kind) colour declarations behind, which then win or
688        // lose the cascade by accident.
689        let mut b = Badge::create(AzString::from_const_str("99+"));
690        let expected_len = build_badge_style(BadgeKind::Default).as_ref().len();
691
692        for round in 0..50 {
693            let kind = ALL_KINDS[round % ALL_KINDS.len()];
694            b.set_kind(kind);
695
696            assert_eq!(b.kind, kind, "round {round}: kind field not updated");
697            assert_eq!(
698                b.badge_style.as_ref().len(),
699                expected_len,
700                "round {round}: style vec changed length — stale declarations?"
701            );
702            assert_eq!(
703                properties(&b.badge_style),
704                properties(&build_badge_style(kind)),
705                "round {round}: style does not match a freshly built one"
706            );
707            assert_eq!(background_color(&b.badge_style), Some(kind.colors().0), "round {round}: stale background");
708            assert_eq!(b.string.as_str(), "99+", "round {round}: set_kind ate the label");
709        }
710    }
711
712    #[test]
713    fn with_badge_kind_agrees_with_set_kind_and_is_last_call_wins() {
714        let chained = Badge::create(AzString::from_const_str("9"))
715            .with_badge_kind(BadgeKind::Danger)
716            .with_badge_kind(BadgeKind::Warning)
717            .with_badge_kind(BadgeKind::Info);
718
719        let mut mutated = Badge::create(AzString::from_const_str("9"));
720        mutated.set_kind(BadgeKind::Danger);
721        mutated.set_kind(BadgeKind::Warning);
722        mutated.set_kind(BadgeKind::Info);
723
724        assert_eq!(chained, mutated, "the builder and the mutator must agree");
725        assert_eq!(chained.kind, BadgeKind::Info);
726        assert_eq!(chained.string.as_str(), "9");
727        assert_eq!(properties(&chained.badge_style), properties(&build_badge_style(BadgeKind::Info)));
728        // In particular the Danger red must be completely gone.
729        assert_eq!(background_color(&chained.badge_style), Some(BadgeKind::Info.colors().0));
730    }
731
732    #[test]
733    fn setting_the_same_kind_twice_is_idempotent() {
734        for kind in ALL_KINDS {
735            let once = Badge::with_kind(AzString::from_const_str("x"), kind);
736            let twice = once.clone().with_badge_kind(kind);
737            assert_eq!(once, twice, "{kind:?}: re-setting the same kind changed the badge");
738        }
739    }
740
741    // ------------------------------------------------------------------
742    // Badge::swap_with_default
743    // ------------------------------------------------------------------
744
745    #[test]
746    fn swap_with_default_returns_the_original_and_leaves_a_default_behind() {
747        let mut b = Badge::with_kind(AzString::from_const_str("99+"), BadgeKind::Danger);
748        let taken = b.swap_with_default();
749
750        // The returned value is the *original*, intact.
751        assert_eq!(taken.string.as_str(), "99+");
752        assert_eq!(taken.kind, BadgeKind::Danger);
753        assert_eq!(properties(&taken.badge_style), properties(&build_badge_style(BadgeKind::Danger)));
754
755        // What is left behind is a *default* badge — in particular its style must
756        // be the grey one and not a stale Danger red.
757        assert_eq!(b, Badge::default());
758        assert_eq!(b.string.as_str(), "");
759        assert_eq!(b.kind, BadgeKind::Default);
760        assert_eq!(background_color(&b.badge_style), Some(BadgeKind::Default.colors().0), "the red survived the swap");
761    }
762
763    #[test]
764    fn swap_with_default_is_idempotent_on_an_already_default_badge() {
765        let mut b = Badge::default();
766        let first = b.swap_with_default();
767        let second = b.swap_with_default();
768        assert_eq!(first, Badge::default());
769        assert_eq!(second, Badge::default());
770        assert_eq!(b, Badge::default());
771    }
772
773    #[test]
774    fn swap_with_default_survives_a_huge_label_and_repeated_swaps() {
775        let long = "x".repeat(100_000);
776        let mut b = Badge::with_kind(AzString::from(long.clone()), BadgeKind::Success);
777        for round in 0..10 {
778            let taken = b.swap_with_default();
779            if round == 0 {
780                assert_eq!(taken.string.len(), long.len(), "the long label was truncated");
781                assert_eq!(taken.kind, BadgeKind::Success);
782            } else {
783                assert_eq!(taken, Badge::default(), "round {round}: the emptied badge is not a default");
784            }
785            assert_eq!(b, Badge::default(), "round {round}: what was left behind is not a default");
786        }
787    }
788
789    // ------------------------------------------------------------------
790    // Badge::dom  (round-trip: badge -> DOM)
791    // ------------------------------------------------------------------
792
793    #[test]
794    fn dom_is_a_single_classed_text_node_carrying_the_computed_style() {
795        for kind in ALL_KINDS {
796            let badge = Badge::with_kind(AzString::from_const_str("99+"), kind);
797            let expected = properties(&badge.badge_style);
798            let dom = badge.dom();
799
800            assert!(has_class(&dom, "__azul-native-badge"), "{kind:?}: missing the widget class");
801            assert!(dom.children.as_ref().is_empty(), "{kind:?}: a badge is a single text node, not a subtree");
802            assert_eq!(inline_properties(&dom), expected, "{kind:?}: the pill lost its computed style");
803
804            match dom.root.get_node_type() {
805                NodeType::Text(s) => assert_eq!(s.as_ref().as_str(), "99+", "{kind:?}: the label was mangled"),
806                other => panic!("{kind:?}: expected a text node, got {other:?}"),
807            }
808        }
809    }
810
811    #[test]
812    fn dom_renders_the_kind_the_badge_was_last_set_to() {
813        // `dom()` consumes the *cached* style, so a `set_kind` that forgot to
814        // recompute would paint the previous colour here and nowhere else.
815        for kind in ALL_KINDS {
816            let mut badge = Badge::create(AzString::from_const_str("9"));
817            badge.set_kind(BadgeKind::Danger);
818            badge.set_kind(kind);
819            let expected = properties(&build_badge_style(kind));
820            assert_eq!(inline_properties(&badge.dom()), expected, "{kind:?}: the DOM does not show the current kind");
821        }
822    }
823
824    #[test]
825    fn dom_preserves_adversarial_labels_verbatim() {
826        for s in adversarial_strings() {
827            let dom = Badge::create(AzString::from(s.clone())).dom();
828            match dom.root.get_node_type() {
829                NodeType::Text(t) => {
830                    assert_eq!(t.as_ref().as_str(), s.as_str(), "the label changed on its way into the DOM");
831                    assert_eq!(t.as_ref().len(), s.len(), "byte length changed (NUL truncation?)");
832                }
833                other => panic!("expected a text node, got {other:?}"),
834            }
835            assert!(has_class(&dom, "__azul-native-badge"));
836        }
837    }
838
839    #[test]
840    fn from_badge_for_dom_is_exactly_dom() {
841        for kind in ALL_KINDS {
842            let badge = Badge::with_kind(AzString::from_const_str("ok"), kind);
843            let via_into: Dom = badge.clone().into();
844            let via_dom = badge.dom();
845            assert_eq!(inline_properties(&via_into), inline_properties(&via_dom), "{kind:?}: `From` diverges from `dom()`");
846            assert_eq!(via_into.root.get_node_type(), via_dom.root.get_node_type(), "{kind:?}: `From` built a different node");
847        }
848    }
849}