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

            
9
use azul_core::dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec};
10
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
11
use 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)]
24
pub 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

            
40
impl 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
669
    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
669
        match self {
47
192
            Self::Default => (ColorU { r: 108, g: 117, b: 125, a: 255 }, WHITE),
48
94
            Self::Primary => (ColorU { r: 13, g: 110, b: 253, a: 255 }, WHITE),
49
93
            Self::Success => (ColorU { r: 25, g: 135, b: 84, a: 255 }, WHITE),
50
102
            Self::Danger => (ColorU { r: 220, g: 53, b: 69, a: 255 }, WHITE),
51
93
            Self::Warning => (ColorU { r: 255, g: 193, b: 7, a: 255 }, DARK),
52
95
            Self::Info => (ColorU { r: 13, g: 202, b: 240, a: 255 }, DARK),
53
        }
54
669
    }
55

            
56
    /// CSS class name for this badge kind (mirrors `ButtonType::class_name`).
57
19
    #[must_use] pub const fn class_name(&self) -> &'static str {
58
19
        match self {
59
4
            Self::Default => "__azul-badge-default",
60
3
            Self::Primary => "__azul-badge-primary",
61
3
            Self::Success => "__azul-badge-success",
62
3
            Self::Danger => "__azul-badge-danger",
63
3
            Self::Warning => "__azul-badge-warning",
64
3
            Self::Info => "__azul-badge-info",
65
        }
66
19
    }
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)]
73
pub 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`).
85
483
fn build_badge_style(kind: BadgeKind) -> CssPropertyWithConditionsVec {
86
483
    let (bg, text) = kind.colors();
87
483
    let bg_vec =
88
483
        StyleBackgroundContentVec::from_vec(alloc::vec![StyleBackgroundContent::Color(bg)]);
89
483
    CssPropertyWithConditionsVec::from_vec(alloc::vec![
90
483
        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
91
483
        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
92
483
            LayoutFlexDirection::Row,
93
        )),
94
483
        CssPropertyWithConditions::simple(CssProperty::const_justify_content(
95
483
            LayoutJustifyContent::Center,
96
        )),
97
483
        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
98
        // Hug the content rather than stretch across a flex parent's cross axis.
99
483
        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
100
483
        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
101
            0,
102
        ))),
103
        // padding: 2px 8px
104
483
        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
105
            2,
106
        ))),
107
483
        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
108
483
            LayoutPaddingBottom::const_px(2),
109
        )),
110
483
        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
111
483
            LayoutPaddingLeft::const_px(8),
112
        )),
113
483
        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
114
483
            LayoutPaddingRight::const_px(8),
115
        )),
116
        // border-radius: 10px (pill)
117
483
        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
118
483
            StyleBorderTopLeftRadius::const_px(10),
119
        )),
120
483
        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
121
483
            StyleBorderTopRightRadius::const_px(10),
122
        )),
123
483
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
124
483
            StyleBorderBottomLeftRadius::const_px(10),
125
        )),
126
483
        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
127
483
            StyleBorderBottomRightRadius::const_px(10),
128
        )),
129
483
        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(12))),
130
483
        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
131
483
        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
132
483
            inner: text,
133
483
        })),
134
483
        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg_vec)),
135
    ])
136
483
}
137

            
138
impl Badge {
139
    /// Creates a new badge with the given text and the default (grey) kind.
140
    #[inline]
141
78
    #[must_use] pub fn create(string: AzString) -> Self {
142
78
        Self::with_kind(string, BadgeKind::Default)
143
78
    }
144

            
145
    /// Creates a new badge with the given text and colour variant.
146
    #[inline]
147
175
    #[must_use] pub fn with_kind(string: AzString, kind: BadgeKind) -> Self {
148
175
        Self {
149
175
            string,
150
175
            kind,
151
175
            badge_style: build_badge_style(kind),
152
175
        }
153
175
    }
154

            
155
    /// Sets the colour variant, recomputing the style.
156
    #[inline]
157
74
    pub fn set_kind(&mut self, kind: BadgeKind) {
158
74
        self.kind = kind;
159
74
        self.badge_style = build_badge_style(kind);
160
74
    }
161

            
162
    /// Builder-style setter for the colour variant.
163
    #[inline]
164
9
    #[must_use] pub fn with_badge_kind(mut self, kind: BadgeKind) -> Self {
165
9
        self.set_kind(kind);
166
9
        self
167
9
    }
168

            
169
    /// Replaces `self` with an empty default badge and returns the original.
170
    #[inline]
171
13
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
172
13
        let mut s = Self::create(AzString::from_const_str(""));
173
13
        core::mem::swap(&mut s, self);
174
13
        s
175
13
    }
176

            
177
    /// Converts this badge into a `<p>` pill carrying the
178
    /// `__azul-native-badge` class and wrapping a bare text node.
179
    ///
180
    /// The pill's background, padding and border-radius live on the `<p>`: a
181
    /// `NodeType::Text` node is always inline-level and owns no rect, so those
182
    /// properties would never paint on a raw text node.
183
    #[inline]
184
38
    #[must_use] pub fn dom(self) -> Dom {
185
        static BADGE_CLASS: &[IdOrClass] =
186
            &[Class(AzString::from_const_str("__azul-native-badge"))];
187

            
188
38
        Dom::create_p_with_text(self.string)
189
38
            .with_ids_and_classes(IdOrClassVec::from_const_slice(BADGE_CLASS))
190
38
            .with_css_props(self.badge_style)
191
38
    }
192
}
193

            
194
impl Default for Badge {
195
25
    fn default() -> Self {
196
25
        Self::create(AzString::from_const_str(""))
197
25
    }
198
}
199

            
200
impl From<Badge> for Dom {
201
6
    fn from(b: Badge) -> Self {
202
6
        b.dom()
203
6
    }
204
}
205

            
206
#[cfg(test)]
207
mod autotest_generated {
208
    use std::collections::HashSet;
209

            
210
    use azul_core::dom::NodeType;
211
    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
212

            
213
    use super::*;
214

            
215
    // ------------------------------------------------------------------
216
    // Helpers
217
    // ------------------------------------------------------------------
218

            
219
    /// Every variant of `BadgeKind` — the complete input domain of `colors`,
220
    /// `class_name` and `build_badge_style`.
221
    const ALL_KINDS: [BadgeKind; 6] = [
222
        BadgeKind::Default,
223
        BadgeKind::Primary,
224
        BadgeKind::Success,
225
        BadgeKind::Danger,
226
        BadgeKind::Warning,
227
        BadgeKind::Info,
228
    ];
229

            
230
    const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
231
    const DARK: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
232

            
233
    /// The declared properties of a style vec, in declaration order.
234
    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
235
        v.as_ref().iter().map(|p| p.property.clone()).collect()
236
    }
237

            
238
    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length — an
239
    /// `em`/`%` slipping into the pill geometry would resolve against the parent
240
    /// font/box instead of the intended fixed padding or radius.
241
    fn px(pv: &PixelValue) -> f32 {
242
        assert_eq!(pv.metric, SizeMetric::Px, "badge geometry must be absolute px, got {:?}", pv.metric);
243
        pv.number.get()
244
    }
245

            
246
    /// The four paddings in `(top, bottom, left, right)` order.
247
    fn padding_px(v: &CssPropertyWithConditionsVec) -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
248
        let find = |f: &dyn Fn(&CssProperty) -> Option<f32>| v.as_ref().iter().find_map(|p| f(&p.property));
249
        (
250
            find(&|p| match p {
251
                CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
252
                _ => None,
253
            }),
254
            find(&|p| match p {
255
                CssProperty::PaddingBottom(x) => x.get_property().map(|x| px(&x.inner)),
256
                _ => None,
257
            }),
258
            find(&|p| match p {
259
                CssProperty::PaddingLeft(x) => x.get_property().map(|x| px(&x.inner)),
260
                _ => None,
261
            }),
262
            find(&|p| match p {
263
                CssProperty::PaddingRight(x) => x.get_property().map(|x| px(&x.inner)),
264
                _ => None,
265
            }),
266
        )
267
    }
268

            
269
    /// The four corner radii, in declaration order.
270
    fn radii_px(v: &CssPropertyWithConditionsVec) -> Vec<f32> {
271
        v.as_ref()
272
            .iter()
273
            .filter_map(|p| match &p.property {
274
                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
275
                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
276
                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
277
                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
278
                _ => None,
279
            })
280
            .collect()
281
    }
282

            
283
    fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
284
        v.as_ref().iter().find_map(|p| match &p.property {
285
            CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
286
            _ => None,
287
        })
288
    }
289

            
290
    fn text_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
291
        v.as_ref().iter().find_map(|p| match &p.property {
292
            CssProperty::TextColor(c) => c.get_property().map(|c| c.inner),
293
            _ => None,
294
        })
295
    }
296

            
297
    /// The single background layer of a style vec, asserting there is exactly one
298
    /// and that it is a flat colour (a gradient would not be a `Color`).
299
    fn background_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
300
        let bg = v.as_ref().iter().find_map(|p| match &p.property {
301
            CssProperty::BackgroundContent(b) => b.get_property(),
302
            _ => None,
303
        })?;
304
        assert_eq!(bg.as_ref().len(), 1, "a badge must declare exactly one background layer");
305
        match &bg.as_ref()[0] {
306
            StyleBackgroundContent::Color(c) => Some(*c),
307
            other => panic!("badge background is not a flat colour: {other:?}"),
308
        }
309
    }
310

            
311
    /// Every `PixelValue` a style vec mentions (paddings, radii, font size).
312
    fn all_pixel_values(v: &CssPropertyWithConditionsVec) -> Vec<PixelValue> {
313
        v.as_ref()
314
            .iter()
315
            .filter_map(|p| match &p.property {
316
                CssProperty::PaddingTop(x) => x.get_property().map(|x| x.inner),
317
                CssProperty::PaddingBottom(x) => x.get_property().map(|x| x.inner),
318
                CssProperty::PaddingLeft(x) => x.get_property().map(|x| x.inner),
319
                CssProperty::PaddingRight(x) => x.get_property().map(|x| x.inner),
320
                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| r.inner),
321
                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| r.inner),
322
                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| r.inner),
323
                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| r.inner),
324
                CssProperty::FontSize(f) => f.get_property().map(|f| f.inner),
325
                _ => None,
326
            })
327
            .collect()
328
    }
329

            
330
    /// Perceived brightness (0..=255) of an sRGB colour, Rec.709 weights. Kept to
331
    /// plain `+`/`*` (no gamma expansion) so the readability assertions below stay
332
    /// exact and toolchain-independent.
333
    fn luma(c: ColorU) -> f32 {
334
        0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b)
335
    }
336

            
337
    /// True if `node` carries the CSS class `name`.
338
    fn has_class(node: &Dom, name: &str) -> bool {
339
        node.root
340
            .get_ids_and_classes()
341
            .as_ref()
342
            .iter()
343
            .any(|c| matches!(c, Class(s) if s.as_str() == name))
344
    }
345

            
346
    /// The properties of a rendered node's *inline* style, in declaration order.
347
    fn inline_properties(node: &Dom) -> Vec<CssProperty> {
348
        node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
349
    }
350

            
351
    /// The text carried by a text node, looking through the `<p>` block
352
    /// wrapper the label convention mandates (`p > text`).
353
    fn text_of(node: &Dom) -> Option<&str> {
354
        match node.root.get_node_type() {
355
            NodeType::Text(s) => Some(s.as_ref().as_str()),
356
            NodeType::P => match node.children.as_ref() {
357
                [only] => match only.root.get_node_type() {
358
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
359
                    _ => None,
360
                },
361
                _ => None,
362
            },
363
            _ => None,
364
        }
365
    }
366

            
367
    /// Adversarial badge texts: empty, whitespace, combining marks, ZWJ emoji,
368
    /// RTL, embedded NULs (`AzString` is length-based, so a NUL must not
369
    /// truncate) and a string far longer than any plausible badge label.
370
    fn adversarial_strings() -> Vec<String> {
371
        let mut v: Vec<String> = [
372
            "",
373
            "9",
374
            "99+",
375
            " ",
376
            "e\u{0301}",                                   // e + combining acute
377
            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family emoji
378
            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",                // RTL Hebrew
379
            "\0",                                          // a single NUL
380
            "a\0b",                                        // embedded NUL
381
            "\u{FFFD}\u{202E}\u{200B}",                    // replacement char, RTL override, ZWSP
382
            "-9223372036854775808",                        // i64::MIN as a "count"
383
        ]
384
        .iter()
385
        .map(|s| (*s).to_string())
386
        .collect();
387
        v.push("x".repeat(100_000));
388
        v
389
    }
390

            
391
    // ------------------------------------------------------------------
392
    // BadgeKind::colors  (getter)
393
    // ------------------------------------------------------------------
394

            
395
    #[test]
396
    fn colors_returns_the_documented_constants_for_every_kind() {
397
        let expected = [
398
            (BadgeKind::Default, ColorU { r: 108, g: 117, b: 125, a: 255 }, WHITE),
399
            (BadgeKind::Primary, ColorU { r: 13, g: 110, b: 253, a: 255 }, WHITE),
400
            (BadgeKind::Success, ColorU { r: 25, g: 135, b: 84, a: 255 }, WHITE),
401
            (BadgeKind::Danger, ColorU { r: 220, g: 53, b: 69, a: 255 }, WHITE),
402
            (BadgeKind::Warning, ColorU { r: 255, g: 193, b: 7, a: 255 }, DARK),
403
            (BadgeKind::Info, ColorU { r: 13, g: 202, b: 240, a: 255 }, DARK),
404
        ];
405
        for (kind, bg, text) in expected {
406
            assert_eq!(kind.colors(), (bg, text), "{kind:?}: wrong (background, text) pair");
407
        }
408
        // The doc comments promise Warning/Info are the dark-text kinds and no
409
        // others: a fifth white-text kind sneaking in here is a regression.
410
        for kind in ALL_KINDS {
411
            let (_, text) = kind.colors();
412
            let dark_text = matches!(kind, BadgeKind::Warning | BadgeKind::Info);
413
            assert_eq!(text == DARK, dark_text, "{kind:?}: text colour contradicts the documented variant");
414
        }
415
    }
416

            
417
    #[test]
418
    fn colors_are_fully_opaque_on_every_kind() {
419
        // A non-opaque pill would let the page background bleed through and
420
        // silently destroy the contrast the kind was chosen for.
421
        for kind in ALL_KINDS {
422
            let (bg, text) = kind.colors();
423
            assert_eq!(bg.a, 255, "{kind:?}: translucent background {bg:?}");
424
            assert_eq!(text.a, 255, "{kind:?}: translucent text colour {text:?}");
425
        }
426
    }
427

            
428
    #[test]
429
    fn colors_give_every_kind_a_distinguishable_background() {
430
        // Two kinds that render identically make the semantic variant useless.
431
        let mut seen = HashSet::new();
432
        for kind in ALL_KINDS {
433
            let (bg, _) = kind.colors();
434
            assert!(seen.insert((bg.r, bg.g, bg.b, bg.a)), "{kind:?}: duplicate background colour {bg:?}");
435
        }
436
        assert_eq!(seen.len(), ALL_KINDS.len());
437
    }
438

            
439
    #[test]
440
    fn colors_pick_the_more_readable_of_the_two_text_colours() {
441
        // The only real invariant of `colors()`: the text must be legible on the
442
        // pill. For each kind the chosen text colour must be further from the
443
        // background (in perceived brightness) than the rejected alternative,
444
        // and light backgrounds must take the dark text.
445
        for kind in ALL_KINDS {
446
            let (bg, text) = kind.colors();
447
            let other = if text == WHITE { DARK } else { WHITE };
448

            
449
            let chosen = (luma(bg) - luma(text)).abs();
450
            let rejected = (luma(bg) - luma(other)).abs();
451
            assert!(
452
                chosen > rejected,
453
                "{kind:?}: text {text:?} (Δluma {chosen:.1}) is less readable on {bg:?} than {other:?} (Δluma {rejected:.1})"
454
            );
455
            assert!(chosen >= 60.0, "{kind:?}: text/background brightness gap {chosen:.1} is too low to read");
456

            
457
            // Mid-grey split: a light pill must not carry white text.
458
            let light_bg = luma(bg) >= 128.0;
459
            assert_eq!(text == DARK, light_bg, "{kind:?}: bg luma {:.1} but text is {text:?}", luma(bg));
460
        }
461
    }
462

            
463
    #[test]
464
    fn colors_is_pure_and_the_default_kind_is_grey() {
465
        assert_eq!(BadgeKind::default(), BadgeKind::Default);
466
        assert_eq!(BadgeKind::default().colors(), BadgeKind::Default.colors());
467
        // `colors()` takes `&self` on a `Copy` enum: repeated calls, and calls
468
        // through a copy, must be side-effect free and identical.
469
        for kind in ALL_KINDS {
470
            let copy = kind;
471
            assert_eq!(kind.colors(), kind.colors(), "{kind:?}: colors() is not pure");
472
            assert_eq!(kind.colors(), copy.colors(), "{kind:?}: a copy disagrees with the original");
473
        }
474
    }
475

            
476
    // ------------------------------------------------------------------
477
    // BadgeKind::class_name  (getter)
478
    // ------------------------------------------------------------------
479

            
480
    #[test]
481
    fn class_name_returns_the_documented_string_for_every_kind() {
482
        assert_eq!(BadgeKind::Default.class_name(), "__azul-badge-default");
483
        assert_eq!(BadgeKind::Primary.class_name(), "__azul-badge-primary");
484
        assert_eq!(BadgeKind::Success.class_name(), "__azul-badge-success");
485
        assert_eq!(BadgeKind::Danger.class_name(), "__azul-badge-danger");
486
        assert_eq!(BadgeKind::Warning.class_name(), "__azul-badge-warning");
487
        assert_eq!(BadgeKind::Info.class_name(), "__azul-badge-info");
488
        assert_eq!(BadgeKind::default().class_name(), "__azul-badge-default");
489
    }
490

            
491
    #[test]
492
    fn class_name_is_unique_per_kind_and_a_usable_css_identifier() {
493
        let mut seen = HashSet::new();
494
        for kind in ALL_KINDS {
495
            let name = kind.class_name();
496
            assert!(seen.insert(name), "{kind:?}: class name {name:?} collides with another kind");
497
            assert!(!name.is_empty(), "{kind:?}: empty class name");
498
            assert!(name.starts_with("__azul-badge-"), "{kind:?}: unnamespaced class {name:?}");
499
            assert!(name.is_ascii(), "{kind:?}: non-ASCII class name {name:?}");
500
            // A space, a dot or a `#` would silently split/re-target the selector.
501
            assert!(
502
                name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
503
                "{kind:?}: class name {name:?} contains a CSS-significant character"
504
            );
505
            // The returned `&'static str` must be stable across calls.
506
            assert_eq!(name.as_ptr(), kind.class_name().as_ptr(), "{kind:?}: class_name() is not a stable constant");
507
        }
508
        assert_eq!(seen.len(), ALL_KINDS.len());
509
    }
510

            
511
    // ------------------------------------------------------------------
512
    // build_badge_style
513
    // ------------------------------------------------------------------
514

            
515
    #[test]
516
    fn build_badge_style_emits_the_documented_pill_geometry() {
517
        for kind in ALL_KINDS {
518
            let style = build_badge_style(kind);
519
            assert_eq!(padding_px(&style), (Some(2.0), Some(2.0), Some(8.0), Some(8.0)), "{kind:?}: padding is not 2px 8px");
520
            assert_eq!(radii_px(&style), vec![10.0, 10.0, 10.0, 10.0], "{kind:?}: all four corners must carry a 10px radius");
521
            assert_eq!(font_size_px(&style), Some(12.0), "{kind:?}: wrong font size");
522
        }
523
    }
524

            
525
    #[test]
526
    fn build_badge_style_radius_actually_rounds_the_pill_to_a_semicircle() {
527
        // The widget's premise: the corner radius must reach at least half the
528
        // content height (font + vertical padding), otherwise it renders as a
529
        // rounded rectangle rather than a pill.
530
        for kind in ALL_KINDS {
531
            let style = build_badge_style(kind);
532
            let (top, bottom, ..) = padding_px(&style);
533
            let height = font_size_px(&style).expect("a font size must be declared")
534
                + top.expect("padding-top")
535
                + bottom.expect("padding-bottom");
536
            for r in radii_px(&style) {
537
                assert!(r * 2.0 >= height, "{kind:?}: radius {r} does not reach half of the {height}px pill height");
538
            }
539
        }
540
    }
541

            
542
    #[test]
543
    fn build_badge_style_hugs_its_content_and_centres_the_text() {
544
        for kind in ALL_KINDS {
545
            let props = properties(&build_badge_style(kind));
546
            let has = |p: &CssProperty| props.contains(p);
547

            
548
            assert!(has(&CssProperty::const_display(LayoutDisplay::Flex)), "{kind:?}: not a flex box");
549
            assert!(has(&CssProperty::const_flex_direction(LayoutFlexDirection::Row)), "{kind:?}: wrong flex direction");
550
            assert!(has(&CssProperty::const_justify_content(LayoutJustifyContent::Center)), "{kind:?}: text not centred");
551
            assert!(has(&CssProperty::const_align_items(LayoutAlignItems::Center)), "{kind:?}: text not centred");
552
            assert!(has(&CssProperty::const_text_align(StyleTextAlign::Center)), "{kind:?}: text not centred");
553
            // align-self: start + flex-grow: 0 — without both, the pill stretches
554
            // across a flex parent instead of hugging its label.
555
            assert!(has(&CssProperty::align_self(LayoutAlignSelf::Start)), "{kind:?}: badge stretches on the cross axis");
556
            assert!(
557
                has(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
558
                "{kind:?}: badge grows on the main axis"
559
            );
560
        }
561
    }
562

            
563
    #[test]
564
    fn build_badge_style_colours_track_the_kind() {
565
        for kind in ALL_KINDS {
566
            let style = build_badge_style(kind);
567
            let (bg, text) = kind.colors();
568
            assert_eq!(background_color(&style), Some(bg), "{kind:?}: emitted background != colors().0");
569
            assert_eq!(text_color(&style), Some(text), "{kind:?}: emitted text colour != colors().1");
570
        }
571
    }
572

            
573
    #[test]
574
    fn build_badge_style_declares_every_property_at_most_once() {
575
        // A duplicated declaration is a last-one-wins ambiguity: two backgrounds
576
        // would make one of them silently dead.
577
        for kind in ALL_KINDS {
578
            let props = properties(&build_badge_style(kind));
579
            let mut seen = HashSet::new();
580
            for p in &props {
581
                assert!(seen.insert(core::mem::discriminant(p)), "{kind:?}: duplicate declaration of {p:?}");
582
            }
583
            assert_eq!(seen.len(), props.len());
584
        }
585
    }
586

            
587
    #[test]
588
    fn build_badge_style_properties_are_all_unconditional() {
589
        // A badge is stateless — a declaration gated on `:hover`/`:active` would
590
        // simply never paint.
591
        for kind in ALL_KINDS {
592
            for p in build_badge_style(kind).as_ref() {
593
                assert!(
594
                    p.apply_if.as_ref().is_empty(),
595
                    "{kind:?}: {:?} is conditional on a stateless widget",
596
                    p.property
597
                );
598
            }
599
        }
600
    }
601

            
602
    #[test]
603
    fn build_badge_style_is_deterministic_and_kind_dependent() {
604
        let len = build_badge_style(BadgeKind::Default).as_ref().len();
605
        for kind in ALL_KINDS {
606
            assert_eq!(
607
                properties(&build_badge_style(kind)),
608
                properties(&build_badge_style(kind)),
609
                "{kind:?}: two builds of the same kind disagree"
610
            );
611
            assert_eq!(
612
                build_badge_style(kind).as_ref().len(),
613
                len,
614
                "{kind:?}: emits a different number of declarations than Default"
615
            );
616
        }
617
        // No two kinds may collapse onto the same style, or the variant is a no-op.
618
        for (i, a) in ALL_KINDS.iter().enumerate() {
619
            for b in &ALL_KINDS[i + 1..] {
620
                assert_ne!(
621
                    properties(&build_badge_style(*a)),
622
                    properties(&build_badge_style(*b)),
623
                    "{a:?} and {b:?} produce an identical style"
624
                );
625
            }
626
        }
627
    }
628

            
629
    #[test]
630
    fn build_badge_style_emits_only_finite_non_negative_px_lengths() {
631
        // Guard the one numeric conversion in this file (`isize` -> `PixelValue`):
632
        // a NaN/inf/negative length must never reach the layout solver.
633
        for kind in ALL_KINDS {
634
            let values = all_pixel_values(&build_badge_style(kind));
635
            assert_eq!(values.len(), 9, "{kind:?}: expected 4 paddings + 4 radii + 1 font size");
636
            for pv in values {
637
                let n = px(&pv); // also asserts SizeMetric::Px
638
                assert!(n.is_finite(), "{kind:?}: non-finite length {n}");
639
                assert!(n >= 0.0, "{kind:?}: negative length {n}");
640
                assert!(n <= 128.0, "{kind:?}: implausibly large length {n} for a badge");
641
            }
642
        }
643
    }
644

            
645
    // ------------------------------------------------------------------
646
    // Badge::create / Badge::with_kind  (constructors)
647
    // ------------------------------------------------------------------
648

            
649
    #[test]
650
    fn create_defaults_to_grey_and_keeps_the_text_verbatim() {
651
        for s in adversarial_strings() {
652
            let b = Badge::create(AzString::from(s.clone()));
653
            assert_eq!(b.string.as_str(), s.as_str(), "the label was not preserved verbatim");
654
            assert_eq!(b.string.len(), s.len(), "byte length changed (NUL truncation?)");
655
            assert_eq!(b.kind, BadgeKind::Default, "create() must use the grey default kind");
656
            assert_eq!(properties(&b.badge_style), properties(&build_badge_style(BadgeKind::Default)));
657
        }
658
    }
659

            
660
    #[test]
661
    fn with_kind_stores_both_arguments_and_the_matching_style() {
662
        for kind in ALL_KINDS {
663
            for s in adversarial_strings() {
664
                let b = Badge::with_kind(AzString::from(s.clone()), kind);
665
                assert_eq!(b.string.as_str(), s.as_str(), "{kind:?}: label not preserved");
666
                assert_eq!(b.string.len(), s.len(), "{kind:?}: byte length changed");
667
                assert_eq!(b.kind, kind, "{kind:?}: kind field does not match the argument");
668
                // The invariant that makes `badge_style` a cache and not a lie.
669
                assert_eq!(properties(&b.badge_style), properties(&build_badge_style(kind)));
670
                assert_eq!(background_color(&b.badge_style), Some(kind.colors().0));
671
            }
672
        }
673
    }
674

            
675
    #[test]
676
    fn create_is_with_kind_default() {
677
        for s in ["", "99+", "\u{1F600}"] {
678
            assert_eq!(
679
                Badge::create(AzString::from_const_str(s)),
680
                Badge::with_kind(AzString::from_const_str(s), BadgeKind::Default)
681
            );
682
        }
683
    }
684

            
685
    #[test]
686
    fn default_badge_is_an_empty_grey_badge_and_equality_sees_every_field() {
687
        let d = Badge::default();
688
        assert_eq!(d, Badge::create(AzString::from_const_str("")));
689
        assert_eq!(d.string.as_str(), "");
690
        assert_eq!(d.kind, BadgeKind::Default);
691
        assert_eq!(d.clone(), d, "Clone must preserve equality");
692

            
693
        assert_ne!(d, Badge::create(AzString::from_const_str("9")), "the label must affect equality");
694
        assert_ne!(
695
            Badge::with_kind(AzString::from_const_str("9"), BadgeKind::Danger),
696
            Badge::with_kind(AzString::from_const_str("9"), BadgeKind::Success),
697
            "badges of different kinds must not compare equal"
698
        );
699
    }
700

            
701
    // ------------------------------------------------------------------
702
    // Badge::set_kind / with_badge_kind  (setters)
703
    // ------------------------------------------------------------------
704

            
705
    #[test]
706
    fn set_kind_recomputes_the_style_without_growing_it() {
707
        // A push-instead-of-replace bug would grow the style vec on every call and
708
        // leave stale (earlier-kind) colour declarations behind, which then win or
709
        // lose the cascade by accident.
710
        let mut b = Badge::create(AzString::from_const_str("99+"));
711
        let expected_len = build_badge_style(BadgeKind::Default).as_ref().len();
712

            
713
        for round in 0..50 {
714
            let kind = ALL_KINDS[round % ALL_KINDS.len()];
715
            b.set_kind(kind);
716

            
717
            assert_eq!(b.kind, kind, "round {round}: kind field not updated");
718
            assert_eq!(
719
                b.badge_style.as_ref().len(),
720
                expected_len,
721
                "round {round}: style vec changed length — stale declarations?"
722
            );
723
            assert_eq!(
724
                properties(&b.badge_style),
725
                properties(&build_badge_style(kind)),
726
                "round {round}: style does not match a freshly built one"
727
            );
728
            assert_eq!(background_color(&b.badge_style), Some(kind.colors().0), "round {round}: stale background");
729
            assert_eq!(b.string.as_str(), "99+", "round {round}: set_kind ate the label");
730
        }
731
    }
732

            
733
    #[test]
734
    fn with_badge_kind_agrees_with_set_kind_and_is_last_call_wins() {
735
        let chained = Badge::create(AzString::from_const_str("9"))
736
            .with_badge_kind(BadgeKind::Danger)
737
            .with_badge_kind(BadgeKind::Warning)
738
            .with_badge_kind(BadgeKind::Info);
739

            
740
        let mut mutated = Badge::create(AzString::from_const_str("9"));
741
        mutated.set_kind(BadgeKind::Danger);
742
        mutated.set_kind(BadgeKind::Warning);
743
        mutated.set_kind(BadgeKind::Info);
744

            
745
        assert_eq!(chained, mutated, "the builder and the mutator must agree");
746
        assert_eq!(chained.kind, BadgeKind::Info);
747
        assert_eq!(chained.string.as_str(), "9");
748
        assert_eq!(properties(&chained.badge_style), properties(&build_badge_style(BadgeKind::Info)));
749
        // In particular the Danger red must be completely gone.
750
        assert_eq!(background_color(&chained.badge_style), Some(BadgeKind::Info.colors().0));
751
    }
752

            
753
    #[test]
754
    fn setting_the_same_kind_twice_is_idempotent() {
755
        for kind in ALL_KINDS {
756
            let once = Badge::with_kind(AzString::from_const_str("x"), kind);
757
            let twice = once.clone().with_badge_kind(kind);
758
            assert_eq!(once, twice, "{kind:?}: re-setting the same kind changed the badge");
759
        }
760
    }
761

            
762
    // ------------------------------------------------------------------
763
    // Badge::swap_with_default
764
    // ------------------------------------------------------------------
765

            
766
    #[test]
767
    fn swap_with_default_returns_the_original_and_leaves_a_default_behind() {
768
        let mut b = Badge::with_kind(AzString::from_const_str("99+"), BadgeKind::Danger);
769
        let taken = b.swap_with_default();
770

            
771
        // The returned value is the *original*, intact.
772
        assert_eq!(taken.string.as_str(), "99+");
773
        assert_eq!(taken.kind, BadgeKind::Danger);
774
        assert_eq!(properties(&taken.badge_style), properties(&build_badge_style(BadgeKind::Danger)));
775

            
776
        // What is left behind is a *default* badge — in particular its style must
777
        // be the grey one and not a stale Danger red.
778
        assert_eq!(b, Badge::default());
779
        assert_eq!(b.string.as_str(), "");
780
        assert_eq!(b.kind, BadgeKind::Default);
781
        assert_eq!(background_color(&b.badge_style), Some(BadgeKind::Default.colors().0), "the red survived the swap");
782
    }
783

            
784
    #[test]
785
    fn swap_with_default_is_idempotent_on_an_already_default_badge() {
786
        let mut b = Badge::default();
787
        let first = b.swap_with_default();
788
        let second = b.swap_with_default();
789
        assert_eq!(first, Badge::default());
790
        assert_eq!(second, Badge::default());
791
        assert_eq!(b, Badge::default());
792
    }
793

            
794
    #[test]
795
    fn swap_with_default_survives_a_huge_label_and_repeated_swaps() {
796
        let long = "x".repeat(100_000);
797
        let mut b = Badge::with_kind(AzString::from(long.clone()), BadgeKind::Success);
798
        for round in 0..10 {
799
            let taken = b.swap_with_default();
800
            if round == 0 {
801
                assert_eq!(taken.string.len(), long.len(), "the long label was truncated");
802
                assert_eq!(taken.kind, BadgeKind::Success);
803
            } else {
804
                assert_eq!(taken, Badge::default(), "round {round}: the emptied badge is not a default");
805
            }
806
            assert_eq!(b, Badge::default(), "round {round}: what was left behind is not a default");
807
        }
808
    }
809

            
810
    // ------------------------------------------------------------------
811
    // Badge::dom  (round-trip: badge -> DOM)
812
    // ------------------------------------------------------------------
813

            
814
    #[test]
815
    fn dom_is_a_single_classed_pill_carrying_the_computed_style() {
816
        for kind in ALL_KINDS {
817
            let badge = Badge::with_kind(AzString::from_const_str("99+"), kind);
818
            let expected = properties(&badge.badge_style);
819
            let dom = badge.dom();
820

            
821
            assert!(has_class(&dom, "__azul-native-badge"), "{kind:?}: missing the widget class");
822
            assert!(
823
                matches!(dom.root.get_node_type(), NodeType::P),
824
                "{kind:?}: the pill box must be the <p>, not a rect-less text node"
825
            );
826
            assert_eq!(dom.children.as_ref().len(), 1, "{kind:?}: a badge is one <p> wrapping one text node");
827
            assert_eq!(inline_properties(&dom), expected, "{kind:?}: the pill lost its computed style");
828
            assert!(
829
                inline_properties(&dom.children.as_ref()[0]).is_empty(),
830
                "{kind:?}: styling belongs on the <p>, not on the text node"
831
            );
832

            
833
            assert_eq!(text_of(&dom), Some("99+"), "{kind:?}: the label was mangled");
834
        }
835
    }
836

            
837
    #[test]
838
    fn dom_renders_the_kind_the_badge_was_last_set_to() {
839
        // `dom()` consumes the *cached* style, so a `set_kind` that forgot to
840
        // recompute would paint the previous colour here and nowhere else.
841
        for kind in ALL_KINDS {
842
            let mut badge = Badge::create(AzString::from_const_str("9"));
843
            badge.set_kind(BadgeKind::Danger);
844
            badge.set_kind(kind);
845
            let expected = properties(&build_badge_style(kind));
846
            assert_eq!(inline_properties(&badge.dom()), expected, "{kind:?}: the DOM does not show the current kind");
847
        }
848
    }
849

            
850
    #[test]
851
    fn dom_preserves_adversarial_labels_verbatim() {
852
        for s in adversarial_strings() {
853
            let dom = Badge::create(AzString::from(s.clone())).dom();
854
            match dom.children.as_ref() {
855
                [only] => match only.root.get_node_type() {
856
                    NodeType::Text(t) => {
857
                        assert_eq!(t.as_ref().as_str(), s.as_str(), "the label changed on its way into the DOM");
858
                        assert_eq!(t.as_ref().len(), s.len(), "byte length changed (NUL truncation?)");
859
                    }
860
                    other => panic!("expected a text node, got {other:?}"),
861
                },
862
                other => panic!("expected exactly one text child, got {} children", other.len()),
863
            }
864
            assert!(has_class(&dom, "__azul-native-badge"));
865
        }
866
    }
867

            
868
    #[test]
869
    fn from_badge_for_dom_is_exactly_dom() {
870
        for kind in ALL_KINDS {
871
            let badge = Badge::with_kind(AzString::from_const_str("ok"), kind);
872
            let via_into: Dom = badge.clone().into();
873
            let via_dom = badge.dom();
874
            assert_eq!(inline_properties(&via_into), inline_properties(&via_dom), "{kind:?}: `From` diverges from `dom()`");
875
            assert_eq!(via_into.root.get_node_type(), via_dom.root.get_node_type(), "{kind:?}: `From` built a different node");
876
        }
877
    }
878
}