1
//! Button widget with Bootstrap-inspired type-based styling (`ButtonType`).
2

            
3
use std::vec::Vec;
4

            
5
use azul_core::{
6
    callbacks::{CoreCallbackData, Update},
7
    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, NodeType, TabIndex},
8
    refany::RefAny,
9
    resources::{ImageRef, OptionImageRef},
10
};
11
#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
12
use azul_css::{
13
    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
14
    props::{
15
        basic::{
16
            color::ColorU,
17
            font::{StyleFontFamily, StyleFontFamilyVec},
18
            *,
19
        },
20
        layout::*,
21
        property::{CssProperty, *},
22
        style::*,
23
    },
24
    system::SystemFontType,
25
    *,
26
};
27

            
28
use crate::callbacks::{Callback, CallbackInfo};
29

            
30
/// The semantic type/role of a button.
31
/// 
32
/// Each type has distinct styling to indicate its purpose to the user.
33
/// Colors are based on Bootstrap's button variants for familiarity.
34
#[repr(C)]
35
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
36
pub enum ButtonType {
37
    /// Default button style - neutral/gray appearance
38
    #[default]
39
    Default,
40
    /// Primary action button - blue, uses system accent color on macOS
41
    Primary,
42
    /// Secondary button - gray, less prominent than primary
43
    Secondary,
44
    /// Success/confirmation button - green with white text
45
    Success,
46
    /// Danger/destructive button - red with white text
47
    Danger,
48
    /// Warning button - yellow with BLACK text
49
    Warning,
50
    /// Informational button - teal/cyan with white text
51
    Info,
52
    /// Link-style button - appears as a hyperlink, no background
53
    Link,
54
}
55

            
56
impl ButtonType {
57
    /// Get the CSS class name for this button type
58
14533
    #[must_use] pub const fn class_name(&self) -> &'static str {
59
14533
        match self {
60
14430
            Self::Default => "__azul-btn-default",
61
17
            Self::Primary => "__azul-btn-primary",
62
14
            Self::Secondary => "__azul-btn-secondary",
63
15
            Self::Success => "__azul-btn-success",
64
14
            Self::Danger => "__azul-btn-danger",
65
14
            Self::Warning => "__azul-btn-warning",
66
15
            Self::Info => "__azul-btn-info",
67
14
            Self::Link => "__azul-btn-link",
68
        }
69
14533
    }
70
}
71

            
72
#[repr(C)]
73
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd)]
74
pub struct Button {
75
    /// Content (image or text) of this button, centered by default
76
    pub label: AzString,
77
    /// Optional image that is displayed next to the label
78
    pub image: OptionImageRef,
79
    /// Optional leading icon name, resolved through the registered icon
80
    /// provider (e.g. the builtin Material Icons pack: "`content_copy`").
81
    /// An empty string means "no icon".
82
    pub icon: AzString,
83
    /// Optional trailing icon name (e.g. "`arrow_drop_down`" for menu/split
84
    /// buttons). An empty string means "no trailing icon".
85
    pub trailing_icon: AzString,
86
    /// The semantic type of this button (Primary, Success, Danger, etc.)
87
    pub button_type: ButtonType,
88
    /// Style for this button container
89
    pub container_style: CssPropertyWithConditionsVec,
90
    /// Style of the label
91
    pub label_style: CssPropertyWithConditionsVec,
92
    /// Style of the image
93
    pub image_style: CssPropertyWithConditionsVec,
94
    /// Style of the leading icon
95
    pub icon_style: CssPropertyWithConditionsVec,
96
    /// Style of the trailing icon
97
    pub trailing_icon_style: CssPropertyWithConditionsVec,
98
    /// Optional: Function to call when the button is clicked
99
    pub on_click: OptionButtonOnClick,
100
}
101

            
102
pub type ButtonOnClickCallbackType = extern "C" fn(RefAny, CallbackInfo) -> Update;
103
impl_widget_callback!(
104
    ButtonOnClick,
105
    OptionButtonOnClick,
106
    ButtonOnClickCallback,
107
    ButtonOnClickCallbackType
108
);
109

            
110
// Host-invoker plumbing for managed-FFI bindings — see core/src/host_invoker.rs.
111
azul_core::impl_managed_callback! {
112
    wrapper:        ButtonOnClickCallback,
113
    info_ty:        CallbackInfo,
114
    return_ty:      Update,
115
    default_ret:    Update::DoNothing,
116
    invoker_static: BUTTON_ON_CLICK_INVOKER,
117
    invoker_ty:     AzButtonOnClickCallbackInvoker,
118
    thunk_fn:       az_button_on_click_callback_thunk,
119
    setter_fn:      AzApp_setButtonOnClickCallbackInvoker,
120
    from_handle_fn: AzButtonOnClickCallback_createFromHostHandle,
121
}
122

            
123
// ButtonType-specific styling
124
// ============================================================
125

            
126
/// Get the background color for a button type
127
54
const fn get_button_colors(button_type: ButtonType) -> (ColorU, ColorU, ColorU) {
128
    // Returns (normal, hover, active) colors
129
54
    match button_type {
130
7
        ButtonType::Default => (
131
7
            ColorU::rgb(248, 249, 250), // Light gray
132
7
            ColorU::rgb(233, 236, 239), // Darker gray on hover
133
7
            ColorU::rgb(218, 222, 226), // Even darker on active
134
7
        ),
135
7
        ButtonType::Primary => (
136
7
            ColorU::bootstrap_primary(),
137
7
            ColorU::bootstrap_primary_hover(),
138
7
            ColorU::bootstrap_primary_active(),
139
7
        ),
140
7
        ButtonType::Secondary => (
141
7
            ColorU::bootstrap_secondary(),
142
7
            ColorU::bootstrap_secondary_hover(),
143
7
            ColorU::bootstrap_secondary_active(),
144
7
        ),
145
7
        ButtonType::Success => (
146
7
            ColorU::bootstrap_success(),
147
7
            ColorU::bootstrap_success_hover(),
148
7
            ColorU::bootstrap_success_active(),
149
7
        ),
150
7
        ButtonType::Danger => (
151
7
            ColorU::bootstrap_danger(),
152
7
            ColorU::bootstrap_danger_hover(),
153
7
            ColorU::bootstrap_danger_active(),
154
7
        ),
155
7
        ButtonType::Warning => (
156
7
            ColorU::bootstrap_warning(),
157
7
            ColorU::bootstrap_warning_hover(),
158
7
            ColorU::bootstrap_warning_active(),
159
7
        ),
160
7
        ButtonType::Info => (
161
7
            ColorU::bootstrap_info(),
162
7
            ColorU::bootstrap_info_hover(),
163
7
            ColorU::bootstrap_info_active(),
164
7
        ),
165
5
        ButtonType::Link => (
166
5
            ColorU::TRANSPARENT,
167
5
            ColorU::TRANSPARENT,
168
5
            ColorU::TRANSPARENT,
169
5
        ),
170
    }
171
54
}
172

            
173
/// Get the text color for a button type
174
39
const fn get_button_text_color(button_type: ButtonType) -> ColorU {
175
39
    match button_type {
176
5
        ButtonType::Default => ColorU::rgb(33, 37, 41),   // Dark text
177
5
        ButtonType::Warning => ColorU::BLACK,             // Black text on yellow
178
4
        ButtonType::Link => ColorU::bootstrap_link(),     // Blue link color
179
25
        _ => ColorU::WHITE,                               // White text on colored buttons
180
    }
181
39
}
182

            
183
/// Build container style properties for a button type
184
18065
fn build_button_container_style(button_type: ButtonType) -> Vec<CssPropertyWithConditions> {
185
    // ⚠ BISECTION PROBE (2026-06-02, REVERT): return a MINIMAL container style — no const
186
    // background, no hover/active gradients, no conditions — to test whether the
187
    // container's COMPLEX props cause the web cascade OOB on AzButton. If web-button-nocb
188
    // RUNS with this → the complex container props are the root; if it still OOBs → the
189
    // label/structure is. Remove this `return` to restore the real button styling.
190
    // ⚠ BISECTION step 7 (REVERT): InlineFlex → Block. The cascade + inline style now work
191
    // (rules=3, disp correct), but layout returns InvalidTree + width=0 because InlineFlex
192
    // triggers the (deferred) taffy flex-algorithm lift gap. Block layout is known-good on web.
193
    // If this lays out (no InvalidTree, sized button) → confirms flex is the layout blocker.
194
18065
    return alloc::vec![
195
18065
        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
196
18065
        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(6))),
197
18065
        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(LayoutPaddingBottom::const_px(6))),
198
    ];
199
    #[allow(unreachable_code)]
200
    let (bg_normal, bg_hover, bg_active) = get_button_colors(button_type);
201
    let text_color = get_button_text_color(button_type);
202
    
203
    // Focus outline uses system accent color
204
    let focus_outline_color = ColorU::bootstrap_primary();
205
    
206
    let mut props = Vec::with_capacity(40);
207
    
208
    // Basic layout - use InlineFlex so flex properties (justify-content, align-items) work
209
    props.push(CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineFlex)));
210
    props.push(CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)));
211
    props.push(CssPropertyWithConditions::simple(CssProperty::const_justify_content(LayoutJustifyContent::Center)));
212
    props.push(CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)));
213
    // Prevent stretching when inside a flex column container
214
    props.push(CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)));
215
    props.push(CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)));
216
    props.push(CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))));
217
    
218
    // Text color
219
    props.push(CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: text_color })));
220
    
221
    // Padding (Bootstrap-like)
222
    props.push(CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(6))));
223
    props.push(CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(LayoutPaddingBottom::const_px(6))));
224
    props.push(CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(12))));
225
    props.push(CssPropertyWithConditions::simple(CssProperty::const_padding_right(LayoutPaddingRight::const_px(12))));
226
    
227
    // Border radius
228
    props.push(CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(StyleBorderTopLeftRadius::const_px(4))));
229
    props.push(CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(StyleBorderTopRightRadius::const_px(4))));
230
    props.push(CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(StyleBorderBottomLeftRadius::const_px(4))));
231
    props.push(CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(StyleBorderBottomRightRadius::const_px(4))));
232
    
233
    if button_type == ButtonType::Link {
234
        // Link buttons have no background or border
235
        props.push(CssPropertyWithConditions::simple(CssProperty::const_background_content(
236
            StyleBackgroundContentVec::from_const_slice(&[StyleBackgroundContent::Color(ColorU::TRANSPARENT)]),
237
        )));
238
        
239
        // Underline on hover - use TextDecoration::Underline variant
240
        props.push(CssPropertyWithConditions::on_hover(CssProperty::TextDecoration(StyleTextDecoration::Underline.into())));
241
    } else {
242
        // Normal background
243
        props.push(CssPropertyWithConditions::simple(CssProperty::const_background_content(
244
            StyleBackgroundContentVec::from_vec(vec![StyleBackgroundContent::Color(bg_normal)]),
245
        )));
246
        
247
        // Border (subtle for Default, transparent for others to maintain size)
248
        let border_color = if button_type == ButtonType::Default {
249
            ColorU::rgb(206, 212, 218)
250
        } else {
251
            bg_normal
252
        };
253
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_top_width(LayoutBorderTopWidth::const_px(1))));
254
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(LayoutBorderBottomWidth::const_px(1))));
255
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_left_width(LayoutBorderLeftWidth::const_px(1))));
256
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_right_width(LayoutBorderRightWidth::const_px(1))));
257
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle { inner: BorderStyle::Solid })));
258
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(StyleBorderBottomStyle { inner: BorderStyle::Solid })));
259
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle { inner: BorderStyle::Solid })));
260
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_right_style(StyleBorderRightStyle { inner: BorderStyle::Solid })));
261
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor { inner: border_color })));
262
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(StyleBorderBottomColor { inner: border_color })));
263
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor { inner: border_color })));
264
        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_right_color(StyleBorderRightColor { inner: border_color })));
265
        
266
        // Hover state
267
        props.push(CssPropertyWithConditions::on_hover(CssProperty::BackgroundContent(
268
            StyleBackgroundContentVec::from_vec(vec![StyleBackgroundContent::Color(bg_hover)]).into(),
269
        )));
270
        if button_type == ButtonType::Default {
271
            let hover_border = ColorU::rgb(173, 181, 189);
272
            props.push(CssPropertyWithConditions::on_hover(CssProperty::BorderTopColor(StyleBorderTopColor { inner: hover_border }.into())));
273
            props.push(CssPropertyWithConditions::on_hover(CssProperty::BorderBottomColor(StyleBorderBottomColor { inner: hover_border }.into())));
274
            props.push(CssPropertyWithConditions::on_hover(CssProperty::BorderLeftColor(StyleBorderLeftColor { inner: hover_border }.into())));
275
            props.push(CssPropertyWithConditions::on_hover(CssProperty::BorderRightColor(StyleBorderRightColor { inner: hover_border }.into())));
276
        }
277
        
278
        // Active (pressed) state
279
        props.push(CssPropertyWithConditions::on_active(CssProperty::BackgroundContent(
280
            StyleBackgroundContentVec::from_vec(vec![StyleBackgroundContent::Color(bg_active)]).into(),
281
        )));
282
        
283
        // Focus state - uses accent color for outline
284
        // This makes the button feel "native" as it uses the system accent
285
        props.push(CssPropertyWithConditions::on_focus(CssProperty::BorderTopColor(StyleBorderTopColor { inner: focus_outline_color }.into())));
286
        props.push(CssPropertyWithConditions::on_focus(CssProperty::BorderBottomColor(StyleBorderBottomColor { inner: focus_outline_color }.into())));
287
        props.push(CssPropertyWithConditions::on_focus(CssProperty::BorderLeftColor(StyleBorderLeftColor { inner: focus_outline_color }.into())));
288
        props.push(CssPropertyWithConditions::on_focus(CssProperty::BorderRightColor(StyleBorderRightColor { inner: focus_outline_color }.into())));
289
    }
290
    
291
    props
292
18065
}
293

            
294
/// Default style for the optional leading/trailing icon nodes: a fixed-size,
295
/// non-selectable glyph that does not flex.
296
static BUTTON_ICON_DEFAULT_STYLE: &[CssPropertyWithConditions] = &[
297
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(16))),
298
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
299
    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
300
];
301

            
302
/// Build label style properties
303
17883
fn build_button_label_style() -> Vec<CssPropertyWithConditions> {
304
    // Use system UI font
305
17883
    let font_family = StyleFontFamilyVec::from_vec(vec![
306
17883
        StyleFontFamily::SystemType(SystemFontType::Ui),
307
    ]);
308
    
309
17883
    vec![
310
17883
        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(14))),
311
17883
        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
312
17883
        CssPropertyWithConditions::simple(CssProperty::const_font_family(font_family)),
313
17883
        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
314
    ]
315
17883
}
316

            
317
impl Button {
318
    /// Create a button with `ButtonType::Default` styling.
319
    #[inline]
320
17556
    #[must_use] pub fn create(label: AzString) -> Self {
321
17556
        Self::with_type(label, ButtonType::Default)
322
17556
    }
323
    
324
    /// Create a button with a specific type (Primary, Success, Danger, etc.)
325
    #[inline]
326
17784
    #[must_use] pub fn with_type(label: AzString, button_type: ButtonType) -> Self {
327
17784
        let container_style = build_button_container_style(button_type);
328
17784
        let label_style = build_button_label_style();
329
        
330
17784
        Self {
331
17784
            label,
332
17784
            image: None.into(),
333
17784
            icon: AzString::from_const_str(""),
334
17784
            trailing_icon: AzString::from_const_str(""),
335
17784
            button_type,
336
17784
            on_click: None.into(),
337
17784
            container_style: CssPropertyWithConditionsVec::from_vec(container_style),
338
17784
            label_style: CssPropertyWithConditionsVec::from_vec(label_style.clone()),
339
17784
            image_style: CssPropertyWithConditionsVec::from_vec(label_style),
340
17784
            icon_style: CssPropertyWithConditionsVec::from_const_slice(BUTTON_ICON_DEFAULT_STYLE),
341
17784
            trailing_icon_style: CssPropertyWithConditionsVec::from_const_slice(
342
17784
                BUTTON_ICON_DEFAULT_STYLE,
343
17784
            ),
344
17784
        }
345
17784
    }
346
    
347
    /// Set the button type and update styling accordingly
348
    #[inline]
349
144
    pub fn set_button_type(&mut self, button_type: ButtonType) {
350
144
        self.button_type = button_type;
351
144
        self.container_style = CssPropertyWithConditionsVec::from_vec(build_button_container_style(button_type));
352
144
    }
353
    
354
    /// Builder method to set the button type
355
    #[inline]
356
35
    #[must_use] pub fn with_button_type(mut self, button_type: ButtonType) -> Self {
357
35
        self.set_button_type(button_type);
358
35
        self
359
35
    }
360

            
361
    #[inline]
362
    #[must_use]
363
1002
    pub fn swap_with_default(&mut self) -> Self {
364
1002
        let mut m = Self::create(AzString::from_const_str(""));
365
1002
        core::mem::swap(&mut m, self);
366
1002
        m
367
1002
    }
368

            
369
    #[inline]
370
9
    pub fn set_image(&mut self, image: ImageRef) {
371
9
        self.image = Some(image).into();
372
9
    }
373

            
374
    /// Sets the leading icon name (empty string clears it).
375
    #[inline]
376
    pub fn set_icon(&mut self, icon: AzString) {
377
        self.icon = icon;
378
    }
379

            
380
    /// Builder method to set the leading icon name.
381
    #[inline]
382
    #[must_use]
383
    pub fn with_icon(mut self, icon: AzString) -> Self {
384
        self.set_icon(icon);
385
        self
386
    }
387

            
388
    /// Sets the trailing icon name (empty string clears it).
389
    #[inline]
390
    pub fn set_trailing_icon(&mut self, icon: AzString) {
391
        self.trailing_icon = icon;
392
    }
393

            
394
    /// Builder method to set the trailing icon name.
395
    #[inline]
396
    #[must_use]
397
    pub fn with_trailing_icon(mut self, icon: AzString) -> Self {
398
        self.set_trailing_icon(icon);
399
        self
400
    }
401

            
402
    #[inline]
403
5
    pub fn set_on_click<C: Into<ButtonOnClickCallback>>(&mut self, data: RefAny, on_click: C) {
404
5
        self.on_click = Some(ButtonOnClick {
405
5
            refany: data,
406
5
            callback: on_click.into(),
407
5
        })
408
5
        .into();
409
5
    }
410

            
411
    #[inline]
412
    #[must_use]
413
2
    pub fn with_on_click<C: Into<ButtonOnClickCallback>>(
414
2
        mut self,
415
2
        data: RefAny,
416
2
        on_click: C,
417
2
    ) -> Self {
418
2
        self.set_on_click(data, on_click);
419
2
        self
420
2
    }
421

            
422
    #[inline]
423
14475
    #[must_use] pub fn dom(self) -> Dom {
424
        use azul_core::{
425
            callbacks::{CoreCallback, CoreCallbackData},
426
            dom::{EventFilter, HoverEventFilter},
427
        };
428

            
429
14475
        let callbacks = match self.on_click.into_option() {
430
            Some(ButtonOnClick {
431
96
                refany: data,
432
96
                callback,
433
96
            }) => vec![CoreCallbackData {
434
96
                event: EventFilter::Hover(HoverEventFilter::MouseUp),
435
96
                callback: CoreCallback {
436
96
                    cb: callback.cb as *const () as usize,
437
96
                    ctx: callback.ctx,
438
96
                },
439
96
                refany: data,
440
96
            }],
441
14379
            None => Vec::new(),
442
        };
443

            
444
        // Add both the base class and the type-specific class
445
        // ⚠ BISECTION step 5 (REVERT): const-str classes → HEAP classes (AzString::from(&str)
446
        // = s.to_string().into()). Decisive test: if web-button-nocb RUNS now → the const-str
447
        // CLONE (s.clone() of a NoDestructor/borrowed AzString in set_ids_and_classes) mis-lifts
448
        // (deref of unmirrored .rodata); fix = transpiler const-str mirror OR heap classes here.
449
        // If it still OOBs → the AttributeTypeVec machinery (swap/into_library_owned_vec/retain/
450
        // push/set_attributes) is the lift bug, independent of const-str.
451
14475
        let type_class = self.button_type.class_name();
452
14475
        let classes: Vec<IdOrClass> = vec![
453
14475
            Class(AzString::from("__azul-native-button")),
454
14475
            Class(AzString::from(type_class)),
455
        ];
456

            
457
        // (2026-06-10: the June-02 bisection strips are REVERTED — the underlying corruption
458
        // was the alloc collect-machinery Leaf-stub in the web transpiler, fixed there. The
459
        // label keeps its inline css; the button carries its on_click callbacks + tab index
460
        // again — without them every Button click was a silent no-op on ALL backends, and the
461
        // web route-walk discovered 0 callbacks. The FIX-A ordering (container style before
462
        // ids/classes) is kept: builder-order is semantically neutral natively.)
463
14475
        let mut button = Dom::create_node(NodeType::Button);
464

            
465
14475
        let has_icon = !self.icon.as_str().is_empty();
466
14475
        let has_image = self.image.is_some();
467
14475
        let has_trailing_icon = !self.trailing_icon.as_str().is_empty();
468

            
469
        // Child order: leading icon, image, label, trailing icon. In a
470
        // row container that reads left-to-right; a column container (large
471
        // ribbon-style buttons) stacks icon over label over arrow.
472
14475
        if has_icon {
473
14309
            button = button.with_child(
474
14309
                Dom::create_icon(self.icon).with_css_props(self.icon_style),
475
14309
            );
476
14395
        }
477

            
478
        // If an image was set via `set_image`, render it as the first child
479
        // (left of the label, since the container is a horizontal flex row).
480
14475
        if let Some(image) = self.image.into_option() {
481
8
            button = button.with_child(
482
8
                Dom::create_image(image).with_css_props(self.image_style),
483
8
            );
484
14467
        }
485

            
486
        // An empty label on an icon-only button is skipped entirely so the
487
        // (zero-size but line-height-carrying) text node cannot disturb the
488
        // icon centering. A button with no icon at all keeps its empty text
489
        // node — an all-empty button should still render as an empty label.
490
14475
        let skip_label =
491
14475
            self.label.as_str().is_empty() && (has_icon || has_image || has_trailing_icon);
492
14475
        if !skip_label {
493
10894
            button = button.with_child(
494
10894
                Dom::create_p()
495
10894
                    .with_css_props(self.label_style)
496
10894
                    .with_children(azul_core::dom::DomVec::from_vec(vec![Dom::create_text_do_not_use_without_block_level_wrapper(self.label)])),
497
10894
            );
498
10894
        }
499

            
500
14475
        if has_trailing_icon {
501
2
            button = button.with_child(
502
2
                Dom::create_icon(self.trailing_icon).with_css_props(self.trailing_icon_style),
503
2
            );
504
14473
        }
505

            
506
14475
        button
507
14475
            .with_css_props(self.container_style)
508
14475
            .with_ids_and_classes(IdOrClassVec::from_vec(classes))
509
14475
            .with_callbacks(callbacks.into())
510
14475
            .with_tab_index(TabIndex::Auto)
511
14475
    }
512
}
513

            
514
#[cfg(test)]
515
mod autotest_generated {
516
    use std::collections::HashSet;
517

            
518
    use azul_core::{
519
        dom::{EventFilter, HoverEventFilter},
520
        resources::RawImageFormat,
521
    };
522
    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
523

            
524
    use super::*;
525

            
526
    // ------------------------------------------------------------------
527
    // Helpers
528
    // ------------------------------------------------------------------
529

            
530
    /// Every variant of `ButtonType` - the complete input domain of `class_name`,
531
    /// `get_button_colors`, `get_button_text_color` and `build_button_container_style`.
532
    const ALL_TYPES: [ButtonType; 8] = [
533
        ButtonType::Default,
534
        ButtonType::Primary,
535
        ButtonType::Secondary,
536
        ButtonType::Success,
537
        ButtonType::Danger,
538
        ButtonType::Warning,
539
        ButtonType::Info,
540
        ButtonType::Link,
541
    ];
542

            
543
    const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
544
    const BLACK: ColorU = ColorU { r: 0, g: 0, b: 0, a: 255 };
545
    const TRANSPARENT: ColorU = ColorU { r: 0, g: 0, b: 0, a: 0 };
546
    /// The "dark text" of the Default button (Bootstrap `$gray-900`).
547
    const DARK: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
548

            
549
    extern "C" fn test_click(_data: RefAny, _info: CallbackInfo) -> Update {
550
        Update::DoNothing
551
    }
552

            
553
    extern "C" fn other_click(_data: RefAny, _info: CallbackInfo) -> Update {
554
        Update::RefreshDom
555
    }
556

            
557
    fn btn(label: &str, button_type: ButtonType) -> Button {
558
        Button::with_type(AzString::from(label), button_type)
559
    }
560

            
561
    /// The declared properties of a style vec, in declaration order.
562
    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
563
        v.as_ref().iter().map(|p| p.property.clone()).collect()
564
    }
565

            
566
    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length - an
567
    /// `em`/`%` slipping into the button geometry would resolve against the parent
568
    /// font/box instead of the intended fixed padding or font size.
569
    fn px(pv: &PixelValue) -> f32 {
570
        assert_eq!(pv.metric, SizeMetric::Px, "button geometry must be absolute px, got {:?}", pv.metric);
571
        pv.number.get()
572
    }
573

            
574
    fn padding_top_bottom_px(v: &CssPropertyWithConditionsVec) -> (Option<f32>, Option<f32>) {
575
        let find = |f: &dyn Fn(&CssProperty) -> Option<f32>| v.as_ref().iter().find_map(|p| f(&p.property));
576
        (
577
            find(&|p| match p {
578
                CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
579
                _ => None,
580
            }),
581
            find(&|p| match p {
582
                CssProperty::PaddingBottom(x) => x.get_property().map(|x| px(&x.inner)),
583
                _ => None,
584
            }),
585
        )
586
    }
587

            
588
    fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
589
        v.as_ref().iter().find_map(|p| match &p.property {
590
            CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
591
            _ => None,
592
        })
593
    }
594

            
595
    /// The CSS classes of a rendered node, in declaration order.
596
    fn classes(dom: &Dom) -> Vec<String> {
597
        dom.root
598
            .get_ids_and_classes()
599
            .as_ref()
600
            .iter()
601
            .filter_map(|c| match c {
602
                Class(s) => Some(s.as_str().to_string()),
603
                IdOrClass::Id(_) => None,
604
            })
605
            .collect()
606
    }
607

            
608
    /// The properties of a rendered node's *inline* style, in declaration order.
609
    fn inline_properties(dom: &Dom) -> Vec<CssProperty> {
610
        dom.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
611
    }
612

            
613
    /// The label text behind the block wrapper: `<p>` wrapping one text node.
614
    fn p_label_of(dom: &Dom) -> Option<&str> {
615
        if !matches!(dom.root.get_node_type(), NodeType::P) {
616
            return None;
617
        }
618
        let inner = dom.children.as_ref();
619
        if inner.len() != 1 {
620
            return None;
621
        }
622
        text_of(&inner[0])
623
    }
624

            
625
    fn text_of(dom: &Dom) -> Option<&str> {
626
        match dom.root.get_node_type() {
627
            NodeType::Text(s) => Some(s.as_ref().as_str()),
628
            _ => None,
629
        }
630
    }
631

            
632
    /// The recursive descendant count - `Dom::estimated_total_children` is a *cached*
633
    /// value that, if too small, makes `convert_dom_into_compact_dom` under-allocate
634
    /// its arenas and panic on out-of-bounds writes.
635
    fn count_descendants(dom: &Dom) -> usize {
636
        dom.children.as_ref().iter().map(|c| 1 + count_descendants(c)).sum()
637
    }
638

            
639
    /// Perceived brightness (0..=255) of an sRGB colour, Rec.709 weights. Kept to
640
    /// plain `+`/`*` (no gamma expansion) so the readability assertions stay exact
641
    /// and toolchain-independent.
642
    fn luma(c: ColorU) -> f32 {
643
        0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b)
644
    }
645

            
646
    /// Adversarial button labels: empty, whitespace, combining marks, ZWJ emoji, RTL,
647
    /// embedded NULs (`AzString` is length-based, so a NUL must not truncate), bidi
648
    /// overrides and a string far longer than any plausible button label.
649
    fn adversarial_labels() -> Vec<String> {
650
        let mut v: Vec<String> = [
651
            "",
652
            " ",
653
            "OK",
654
            "e\u{0301}",                                   // e + combining acute
655
            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family emoji
656
            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",                // RTL Hebrew
657
            "\0",                                          // a single NUL
658
            "a\0b",                                        // embedded NUL
659
            "\u{FFFD}\u{202E}\u{200B}",                    // replacement char, RTL override, ZWSP
660
            "…\t\r\n",                                     // control chars in a label
661
            "__azul-btn-primary",                          // a label that looks like a class name
662
        ]
663
        .iter()
664
        .map(|s| (*s).to_string())
665
        .collect();
666
        v.push("x".repeat(100_000));
667
        v
668
    }
669

            
670
    // ------------------------------------------------------------------
671
    // ButtonType::class_name  (getter)
672
    // ------------------------------------------------------------------
673

            
674
    #[test]
675
    fn class_name_returns_the_documented_class_for_every_type() {
676
        let expected = [
677
            (ButtonType::Default, "__azul-btn-default"),
678
            (ButtonType::Primary, "__azul-btn-primary"),
679
            (ButtonType::Secondary, "__azul-btn-secondary"),
680
            (ButtonType::Success, "__azul-btn-success"),
681
            (ButtonType::Danger, "__azul-btn-danger"),
682
            (ButtonType::Warning, "__azul-btn-warning"),
683
            (ButtonType::Info, "__azul-btn-info"),
684
            (ButtonType::Link, "__azul-btn-link"),
685
        ];
686
        for (ty, class) in expected {
687
            assert_eq!(ty.class_name(), class, "{ty:?}: wrong CSS class");
688
        }
689
        assert_eq!(expected.len(), ALL_TYPES.len(), "a ButtonType variant is missing from this table");
690
    }
691

            
692
    #[test]
693
    fn class_name_is_unique_per_type() {
694
        // Two types sharing a class make the semantic variant unstylable: the
695
        // stylesheet could not tell a Danger button from a Success one.
696
        let mut seen = HashSet::new();
697
        for ty in ALL_TYPES {
698
            assert!(seen.insert(ty.class_name()), "{ty:?}: duplicate class name {}", ty.class_name());
699
        }
700
        assert_eq!(seen.len(), ALL_TYPES.len());
701
    }
702

            
703
    #[test]
704
    fn class_name_is_a_well_formed_css_identifier() {
705
        // A space, quote or `.` would silently split/escape into a *different*
706
        // selector once written into a stylesheet.
707
        for ty in ALL_TYPES {
708
            let c = ty.class_name();
709
            assert!(!c.is_empty(), "{ty:?}: empty class name");
710
            assert!(c.starts_with("__azul-btn-"), "{ty:?}: class {c} lost the widget prefix");
711
            assert!(c.is_ascii(), "{ty:?}: non-ASCII class name {c}");
712
            assert!(
713
                c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'),
714
                "{ty:?}: class {c} contains a character that needs CSS escaping",
715
            );
716
        }
717
    }
718

            
719
    #[test]
720
    fn class_name_is_pure_and_const_evaluable() {
721
        // Declared `const fn` returning `&'static str`: the same variant must yield
722
        // the *same* static, call after call (no per-call allocation).
723
        const DEFAULT_CLASS: &str = ButtonType::Default.class_name();
724
        const LINK_CLASS: &str = ButtonType::Link.class_name();
725
        assert_eq!(DEFAULT_CLASS, "__azul-btn-default");
726
        assert_eq!(LINK_CLASS, "__azul-btn-link");
727

            
728
        for ty in ALL_TYPES {
729
            let a = ty.class_name();
730
            let b = ty.class_name();
731
            assert_eq!(a.as_ptr(), b.as_ptr(), "{ty:?}: class_name is not returning a stable static");
732
        }
733
    }
734

            
735
    #[test]
736
    fn class_name_of_the_default_type_matches_derived_default() {
737
        // `#[default] Default` — a reordering of the enum that moves `#[default]`
738
        // would silently restyle every `Button::create`.
739
        assert_eq!(ButtonType::default(), ButtonType::Default);
740
        assert_eq!(ButtonType::default().class_name(), "__azul-btn-default");
741
    }
742

            
743
    // ------------------------------------------------------------------
744
    // get_button_colors  (private)
745
    // ------------------------------------------------------------------
746

            
747
    #[test]
748
    fn get_button_colors_returns_the_documented_bootstrap_triples() {
749
        let expected = [
750
            (
751
                ButtonType::Default,
752
                ColorU::rgb(248, 249, 250),
753
                ColorU::rgb(233, 236, 239),
754
                ColorU::rgb(218, 222, 226),
755
            ),
756
            (
757
                ButtonType::Primary,
758
                ColorU::rgb(13, 110, 253),
759
                ColorU::rgb(11, 94, 215),
760
                ColorU::rgb(10, 88, 202),
761
            ),
762
            (
763
                ButtonType::Secondary,
764
                ColorU::rgb(108, 117, 125),
765
                ColorU::rgb(92, 99, 106),
766
                ColorU::rgb(86, 94, 100),
767
            ),
768
            (
769
                ButtonType::Success,
770
                ColorU::rgb(25, 135, 84),
771
                ColorU::rgb(21, 115, 71),
772
                ColorU::rgb(20, 108, 67),
773
            ),
774
            (
775
                ButtonType::Danger,
776
                ColorU::rgb(220, 53, 69),
777
                ColorU::rgb(187, 45, 59),
778
                ColorU::rgb(176, 42, 55),
779
            ),
780
            (
781
                ButtonType::Warning,
782
                ColorU::rgb(255, 193, 7),
783
                ColorU::rgb(255, 202, 44),
784
                ColorU::rgb(255, 205, 57),
785
            ),
786
            (
787
                ButtonType::Info,
788
                ColorU::rgb(13, 202, 240),
789
                ColorU::rgb(49, 210, 242),
790
                ColorU::rgb(61, 213, 243),
791
            ),
792
            (ButtonType::Link, TRANSPARENT, TRANSPARENT, TRANSPARENT),
793
        ];
794
        for (ty, normal, hover, active) in expected {
795
            assert_eq!(get_button_colors(ty), (normal, hover, active), "{ty:?}: wrong colour triple");
796
        }
797
    }
798

            
799
    #[test]
800
    fn get_button_colors_is_pure() {
801
        for ty in ALL_TYPES {
802
            assert_eq!(get_button_colors(ty), get_button_colors(ty), "{ty:?}: colours are not deterministic");
803
        }
804
    }
805

            
806
    #[test]
807
    fn get_button_colors_keeps_link_fully_transparent_and_every_other_type_opaque() {
808
        // A Link button that paints *any* background stops looking like a hyperlink;
809
        // a translucent solid button lets the page bleed through and destroys the
810
        // contrast the type was chosen for.
811
        for ty in ALL_TYPES {
812
            let (normal, hover, active) = get_button_colors(ty);
813
            if ty == ButtonType::Link {
814
                assert_eq!((normal, hover, active), (TRANSPARENT, TRANSPARENT, TRANSPARENT), "Link must paint nothing");
815
            } else {
816
                for (state, c) in [("normal", normal), ("hover", hover), ("active", active)] {
817
                    assert_eq!(c.a, 255, "{ty:?}: {state} background {c:?} is not opaque");
818
                }
819
            }
820
        }
821
    }
822

            
823
    #[test]
824
    fn get_button_colors_gives_every_state_a_visible_delta() {
825
        // hover == normal means the button gives no feedback on mouse-over;
826
        // active == hover means the press is invisible.
827
        for ty in ALL_TYPES {
828
            if ty == ButtonType::Link {
829
                continue; // deliberately identical: a link has no background at all
830
            }
831
            let (normal, hover, active) = get_button_colors(ty);
832
            assert_ne!(normal, hover, "{ty:?}: hover state is indistinguishable from normal");
833
            assert_ne!(hover, active, "{ty:?}: active state is indistinguishable from hover");
834
            assert_ne!(normal, active, "{ty:?}: active state is indistinguishable from normal");
835
        }
836
    }
837

            
838
    #[test]
839
    fn get_button_colors_gives_every_type_a_distinguishable_background() {
840
        // Two types that render identically make the semantic variant useless.
841
        let mut seen = HashSet::new();
842
        for ty in ALL_TYPES {
843
            let (normal, _, _) = get_button_colors(ty);
844
            assert!(seen.insert((normal.r, normal.g, normal.b, normal.a)), "{ty:?}: duplicate background {normal:?}");
845
        }
846
        assert_eq!(seen.len(), ALL_TYPES.len());
847
    }
848

            
849
    // ------------------------------------------------------------------
850
    // get_button_text_color  (private)
851
    // ------------------------------------------------------------------
852

            
853
    #[test]
854
    fn get_button_text_color_returns_the_documented_colour_for_every_type() {
855
        let expected = [
856
            (ButtonType::Default, DARK),
857
            (ButtonType::Primary, WHITE),
858
            (ButtonType::Secondary, WHITE),
859
            (ButtonType::Success, WHITE),
860
            (ButtonType::Danger, WHITE),
861
            (ButtonType::Warning, BLACK), // doc: "Warning button - yellow with BLACK text"
862
            (ButtonType::Info, WHITE),
863
            (ButtonType::Link, ColorU::rgb(13, 110, 253)),
864
        ];
865
        for (ty, text) in expected {
866
            assert_eq!(get_button_text_color(ty), text, "{ty:?}: wrong text colour");
867
        }
868
        // The `_ => WHITE` catch-all is easy to widen by accident: only these three
869
        // variants may deviate from white.
870
        for ty in ALL_TYPES {
871
            let is_special = matches!(ty, ButtonType::Default | ButtonType::Warning | ButtonType::Link);
872
            assert_eq!(get_button_text_color(ty) != WHITE, is_special, "{ty:?}: text colour contradicts the documented variant");
873
        }
874
    }
875

            
876
    #[test]
877
    fn get_button_text_color_is_pure_and_opaque() {
878
        for ty in ALL_TYPES {
879
            let c = get_button_text_color(ty);
880
            assert_eq!(c, get_button_text_color(ty), "{ty:?}: text colour is not deterministic");
881
            assert_eq!(c.a, 255, "{ty:?}: invisible (translucent) text colour {c:?}");
882
        }
883
    }
884

            
885
    #[test]
886
    fn get_button_text_color_stays_readable_on_its_own_background() {
887
        // The one real invariant of the pair: label must be legible on the fill.
888
        // NOTE: `Info` (white on #0dcaf0) is by far the weakest pairing at ~90 luma
889
        // of separation — Bootstrap and azul's own `Badge` widget both put *dark*
890
        // text on Info. The bound below is the current floor, not an endorsement;
891
        // moving Info to dark text raises its separation to ~128 and still passes.
892
        for ty in ALL_TYPES {
893
            if ty == ButtonType::Link {
894
                continue; // no fill: a link is drawn on the page background
895
            }
896
            let (bg, _, _) = get_button_colors(ty);
897
            let text = get_button_text_color(ty);
898
            let separation = (luma(bg) - luma(text)).abs();
899
            assert!(separation >= 85.0, "{ty:?}: text {text:?} on {bg:?} is unreadable (luma separation {separation:.1})");
900

            
901
            // ... and the *more* readable of the two candidates was chosen.
902
            let alt = if text == WHITE { DARK } else { WHITE };
903
            let alt_separation = (luma(bg) - luma(alt)).abs();
904
            if ty != ButtonType::Info {
905
                assert!(separation >= alt_separation, "{ty:?}: {alt:?} would be more readable than {text:?} on {bg:?}");
906
            }
907
        }
908
    }
909

            
910
    // ------------------------------------------------------------------
911
    // build_button_container_style  (private)
912
    // ------------------------------------------------------------------
913

            
914
    #[test]
915
    fn build_button_container_style_never_panics_and_is_pure() {
916
        for ty in ALL_TYPES {
917
            let a = build_button_container_style(ty);
918
            let b = build_button_container_style(ty);
919
            assert!(!a.is_empty(), "{ty:?}: a button with no container style is invisible");
920
            assert_eq!(a, b, "{ty:?}: container style is not deterministic");
921
        }
922
    }
923

            
924
    #[test]
925
    fn build_button_container_style_always_declares_display_and_symmetric_vertical_padding() {
926
        // Without a `display` the button falls back to the UA default; asymmetric
927
        // vertical padding makes the label sit off-centre.
928
        for ty in ALL_TYPES {
929
            let v = CssPropertyWithConditionsVec::from_vec(build_button_container_style(ty));
930
            assert!(
931
                v.as_ref().iter().any(|p| matches!(p.property, CssProperty::Display(_))),
932
                "{ty:?}: container declares no `display`",
933
            );
934
            let (top, bottom) = padding_top_bottom_px(&v);
935
            assert_eq!(top, Some(6.0), "{ty:?}: wrong padding-top");
936
            assert_eq!(bottom, Some(6.0), "{ty:?}: wrong padding-bottom");
937
            assert_eq!(top, bottom, "{ty:?}: vertical padding is asymmetric — the label will not be centred");
938
        }
939
    }
940

            
941
    #[test]
942
    fn build_button_container_style_currently_ignores_the_button_type() {
943
        // ⚠ CHARACTERISATION TEST — pins the 2026-06-02 BISECTION PROBE, not a
944
        // desirable behaviour. `build_button_container_style` begins with an
945
        // unconditional `return` of a 3-property minimal style, so *everything*
946
        // below it (backgrounds, borders, hover/active/focus states, and therefore
947
        // both `get_button_colors` and `get_button_text_color`) is dead code, and
948
        // all 8 button types render with the identical container style. Reverting
949
        // the probe — as its own comment instructs — will trip this test on
950
        // purpose; delete it then.
951
        let baseline = properties(&CssPropertyWithConditionsVec::from_vec(build_button_container_style(ButtonType::Default)));
952
        for ty in ALL_TYPES {
953
            let v = CssPropertyWithConditionsVec::from_vec(build_button_container_style(ty));
954
            assert_eq!(properties(&v), baseline, "{ty:?}: container style diverged — was the bisection probe reverted?");
955
            assert!(
956
                !v.as_ref().iter().any(|p| matches!(
957
                    p.property,
958
                    CssProperty::BackgroundContent(_) | CssProperty::TextColor(_)
959
                )),
960
                "{ty:?}: the probe is no longer returning early — restore the type-dependent assertions",
961
            );
962
            assert!(
963
                v.as_ref().iter().all(|p| p.apply_if.as_ref().is_empty()),
964
                "{ty:?}: the probe emits only unconditional properties",
965
            );
966
        }
967
    }
968

            
969
    // ------------------------------------------------------------------
970
    // build_button_label_style  (private)
971
    // ------------------------------------------------------------------
972

            
973
    #[test]
974
    fn build_button_label_style_declares_the_four_documented_properties_unconditionally() {
975
        let v = CssPropertyWithConditionsVec::from_vec(build_button_label_style());
976
        assert_eq!(v.len(), 4, "label style gained/lost a property: {:?}", properties(&v));
977

            
978
        assert_eq!(font_size_px(&v), Some(14.0), "wrong label font size");
979

            
980
        let align = v.as_ref().iter().find_map(|p| match &p.property {
981
            CssProperty::TextAlign(t) => t.get_property().copied(),
982
            _ => None,
983
        });
984
        assert_eq!(align, Some(StyleTextAlign::Center), "a button label must be centred");
985

            
986
        let family = v.as_ref().iter().find_map(|p| match &p.property {
987
            CssProperty::FontFamily(f) => f.get_property().cloned(),
988
            _ => None,
989
        });
990
        let family = family.expect("label style declares no font-family");
991
        assert_eq!(
992
            family.as_ref(),
993
            [StyleFontFamily::SystemType(SystemFontType::Ui)].as_slice(),
994
            "the label must use the system UI font",
995
        );
996

            
997
        let user_select = v.as_ref().iter().find_map(|p| match &p.property {
998
            CssProperty::UserSelect(u) => u.get_property().copied(),
999
            _ => None,
        });
        assert_eq!(user_select, Some(StyleUserSelect::None), "a button label must not be text-selectable");
        // Every declaration is unconditional — a stray `:hover` here would make the
        // label font/size flicker on mouse-over.
        assert!(v.as_ref().iter().all(|p| p.apply_if.as_ref().is_empty()), "label style must be unconditional");
    }
    #[test]
    fn build_button_label_style_is_pure() {
        assert_eq!(build_button_label_style(), build_button_label_style(), "label style is not deterministic");
    }
    // ------------------------------------------------------------------
    // Button::create / Button::with_type  (constructors)
    // ------------------------------------------------------------------
    #[test]
    fn create_is_exactly_with_type_default() {
        for label in adversarial_labels() {
            let a = Button::create(AzString::from(label.as_str()));
            let b = Button::with_type(AzString::from(label.as_str()), ButtonType::Default);
            assert_eq!(a, b, "create() diverged from with_type(_, Default) for a {}-byte label", label.len());
            assert_eq!(a.button_type, ButtonType::Default);
        }
    }
    #[test]
    fn with_type_holds_its_post_construction_invariants_for_every_type() {
        for ty in ALL_TYPES {
            for label in adversarial_labels() {
                let b = btn(&label, ty);
                // Fields match the arguments, byte for byte (a NUL must not truncate).
                assert_eq!(b.label.as_str(), label.as_str(), "{ty:?}: label was mangled");
                assert_eq!(b.label.as_str().len(), label.len(), "{ty:?}: label length changed");
                assert_eq!(b.button_type, ty, "{ty:?}: button_type field does not match the argument");
                // Optionals start empty.
                assert!(b.image.is_none(), "{ty:?}: a freshly built button must have no image");
                assert!(b.on_click.is_none(), "{ty:?}: a freshly built button must have no callback");
                // Styles are the ones the builders produce, and the vec lengths are
                // consistent with what was handed to `from_vec`.
                let container = build_button_container_style(ty);
                let label_style = build_button_label_style();
                assert_eq!(b.container_style.len(), container.len(), "{ty:?}: container_style length is inconsistent");
                assert_eq!(b.container_style.as_ref(), container.as_slice(), "{ty:?}: container_style does not match the builder");
                assert_eq!(b.label_style.as_ref(), label_style.as_slice(), "{ty:?}: label_style does not match the builder");
                // `with_type` deliberately reuses the label style for the image.
                assert_eq!(b.image_style.as_ref(), b.label_style.as_ref(), "{ty:?}: image_style diverged from label_style");
            }
        }
    }
    #[test]
    fn with_type_survives_a_multi_megabyte_label() {
        // 4 MiB of label: no quadratic copy, no truncation, no panic.
        let huge = "\u{1F600}".repeat(1_000_000); // 4 bytes/char
        let b = btn(&huge, ButtonType::Danger);
        assert_eq!(b.label.as_str().len(), 4_000_000);
        assert_eq!(b.label.as_str(), huge.as_str());
    }
    #[test]
    fn buttons_are_cloneable_and_clones_compare_equal() {
        for ty in ALL_TYPES {
            let b = btn("Clone me", ty);
            let c = b.clone();
            assert_eq!(b, c, "{ty:?}: clone() produced a different button");
            assert_eq!(format!("{b:?}"), format!("{c:?}"), "{ty:?}: Debug output diverged between clones");
        }
    }
    // ------------------------------------------------------------------
    // Button::set_button_type / with_button_type
    // ------------------------------------------------------------------
    #[test]
    fn set_button_type_updates_both_the_field_and_the_container_style() {
        let mut b = btn("Save", ButtonType::Default);
        for ty in ALL_TYPES {
            b.set_button_type(ty);
            assert_eq!(b.button_type, ty, "{ty:?}: field not updated");
            assert_eq!(
                b.container_style.as_ref(),
                build_button_container_style(ty).as_slice(),
                "{ty:?}: container_style was not rebuilt for the new type",
            );
        }
    }
    #[test]
    fn set_button_type_is_idempotent_and_never_accumulates_style() {
        // Re-setting the same type must *replace*, never append: an appending
        // implementation would grow the style vec without bound.
        let mut b = btn("Save", ButtonType::Primary);
        let len = b.container_style.len();
        for _ in 0..100 {
            b.set_button_type(ButtonType::Primary);
        }
        assert_eq!(b.container_style.len(), len, "container_style grew across repeated set_button_type calls");
        assert_eq!(b, btn("Save", ButtonType::Primary), "set_button_type(same) is not idempotent");
    }
    #[test]
    fn set_button_type_leaves_the_label_and_the_other_styles_untouched() {
        let mut b = btn("Delete", ButtonType::Default);
        let label_style = b.label_style.clone();
        let image_style = b.image_style.clone();
        b.set_button_type(ButtonType::Danger);
        assert_eq!(b.label.as_str(), "Delete", "set_button_type clobbered the label");
        assert_eq!(b.label_style, label_style, "set_button_type clobbered label_style");
        assert_eq!(b.image_style, image_style, "set_button_type clobbered image_style");
    }
    #[test]
    fn with_button_type_round_trips_to_with_type() {
        // create(l).with_button_type(t) must be indistinguishable from with_type(l, t).
        for ty in ALL_TYPES {
            for label in ["", "OK", "\u{1F600}\0"] {
                let built = Button::create(AzString::from(label)).with_button_type(ty);
                let direct = Button::with_type(AzString::from(label), ty);
                assert_eq!(built, direct, "{ty:?}: builder path diverged from with_type for {label:?}");
            }
        }
    }
    #[test]
    fn with_button_type_chains_take_the_last_type() {
        let b = btn("x", ButtonType::Default)
            .with_button_type(ButtonType::Primary)
            .with_button_type(ButtonType::Link)
            .with_button_type(ButtonType::Warning);
        assert_eq!(b.button_type, ButtonType::Warning);
        assert_eq!(b, btn("x", ButtonType::Warning));
    }
    // ------------------------------------------------------------------
    // Button::swap_with_default
    // ------------------------------------------------------------------
    #[test]
    fn swap_with_default_returns_the_original_and_leaves_a_default_button_behind() {
        let mut b = btn("Delete", ButtonType::Danger);
        b.set_image(ImageRef::null_image(1, 1, RawImageFormat::RGBA8, Vec::new()));
        b.set_on_click(RefAny::new(7u32), test_click as ButtonOnClickCallbackType);
        let taken = b.swap_with_default();
        // The returned value is the old button, whole.
        assert_eq!(taken.label.as_str(), "Delete");
        assert_eq!(taken.button_type, ButtonType::Danger);
        assert!(taken.image.is_some(), "the image did not travel with the swapped-out button");
        assert!(taken.on_click.is_some(), "the callback did not travel with the swapped-out button");
        // ... and what is left behind is a pristine empty Default button.
        assert_eq!(b.label.as_str(), "");
        assert_eq!(b.button_type, ButtonType::Default);
        assert!(b.image.is_none(), "the swapped-in default still carries an image");
        assert!(b.on_click.is_none(), "the swapped-in default still carries a callback");
        assert_eq!(b, Button::create(AzString::from("")), "swap_with_default left a non-default button");
    }
    #[test]
    fn swap_with_default_is_stable_under_repetition() {
        // Repeated swapping must not double-free or drift: after the first call the
        // button is already default, so every further call is a no-op swap.
        let mut b = btn("x", ButtonType::Info);
        let first = b.swap_with_default();
        assert_eq!(first.label.as_str(), "x");
        for _ in 0..1000 {
            let taken = b.swap_with_default();
            assert_eq!(taken, Button::create(AzString::from("")));
            assert_eq!(b, Button::create(AzString::from("")));
        }
    }
    // ------------------------------------------------------------------
    // Button::set_image
    // ------------------------------------------------------------------
    #[test]
    fn set_image_stores_the_image_and_replaces_a_previous_one() {
        let mut b = btn("With icon", ButtonType::Primary);
        assert!(b.image.is_none());
        b.set_image(ImageRef::null_image(16, 16, RawImageFormat::RGBA8, Vec::new()));
        assert!(b.image.is_some(), "set_image did not store the image");
        let first_id = b.image.as_ref().map(|i| i.id).expect("image must be present");
        b.set_image(ImageRef::null_image(32, 32, RawImageFormat::RGB8, Vec::new()));
        let second_id = b.image.as_ref().map(|i| i.id).expect("image must be present");
        assert_ne!(first_id, second_id, "set_image did not replace the previous image");
    }
    #[test]
    fn set_image_accepts_degenerate_and_extreme_dimensions() {
        // A null image carries only its metadata, so these must not allocate,
        // overflow (`w * h * bpp`) or panic.
        let extremes = [
            (0usize, 0usize),
            (0, 4096),
            (4096, 0),
            (1, usize::MAX),
            (usize::MAX, usize::MAX),
        ];
        for (w, h) in extremes {
            let mut b = btn("x", ButtonType::Default);
            b.set_image(ImageRef::null_image(w, h, RawImageFormat::RGBA8, Vec::new()));
            assert!(b.image.is_some(), "{w}x{h}: image was dropped");
            let dom = b.dom();
            assert_eq!(dom.children.as_ref().len(), 2, "{w}x{h}: expected an image child and a label child");
        }
    }
    // ------------------------------------------------------------------
    // Button::set_on_click / with_on_click
    // ------------------------------------------------------------------
    #[test]
    fn set_on_click_stores_the_callback_and_replaces_a_previous_one() {
        let mut b = btn("Click", ButtonType::Primary);
        assert!(b.on_click.is_none());
        b.set_on_click(RefAny::new(1u32), test_click as ButtonOnClickCallbackType);
        assert!(b.on_click.is_some(), "set_on_click did not store the callback");
        b.set_on_click(RefAny::new(2u32), other_click as ButtonOnClickCallbackType);
        let stored = b.on_click.as_ref().expect("callback must be present");
        assert_eq!(
            stored.callback.cb as *const () as usize,
            other_click as ButtonOnClickCallbackType as *const () as usize,
            "the second set_on_click did not replace the first",
        );
        // ... and it replaced rather than accumulated: the DOM still fires once.
        let dom = b.dom();
        assert_eq!(dom.root.callbacks.as_ref().len(), 1, "a re-set callback was appended instead of replaced");
    }
    #[test]
    fn with_on_click_round_trips_the_function_pointer_and_the_payload_into_the_dom() {
        let cb: ButtonOnClickCallbackType = test_click;
        let expected_ptr = cb as *const () as usize;
        let dom = btn("Click", ButtonType::Success).with_on_click(RefAny::new(0xDEAD_BEEF_u32), cb).dom();
        let callbacks = dom.root.callbacks.as_ref();
        assert_eq!(callbacks.len(), 1, "exactly one click callback is expected");
        assert_eq!(
            callbacks[0].event,
            EventFilter::Hover(HoverEventFilter::MouseUp),
            "the button must fire on mouse-up, not on any other filter",
        );
        assert_eq!(callbacks[0].callback.cb, expected_ptr, "the fn pointer was corrupted on the way into the DOM");
        // The RefAny payload survives the move into the DOM (shared, not copied).
        let mut data = callbacks[0].refany.clone();
        assert_eq!(*data.downcast_ref::<u32>().expect("payload changed type"), 0xDEAD_BEEF, "payload was corrupted");
        assert!(data.downcast_ref::<u64>().is_none(), "downcast to the wrong type must fail, not reinterpret");
    }
    #[test]
    fn with_on_click_accepts_a_generic_callback_without_mangling_the_pointer() {
        // The `From<Callback>` arm transmutes the fn pointer — this is the FFI path
        // (Python/C) into the same slot, so the pointer must come out untouched.
        let generic = Callback {
            cb: test_click,
            ctx: azul_core::refany::OptionRefAny::None,
        };
        let raw: ButtonOnClickCallbackType = test_click;
        let expected_ptr = raw as *const () as usize;
        let dom = btn("Click", ButtonType::Info).with_on_click(RefAny::new(1u8), generic).dom();
        let callbacks = dom.root.callbacks.as_ref();
        assert_eq!(callbacks.len(), 1);
        assert_eq!(callbacks[0].callback.cb, expected_ptr, "the Callback -> ButtonOnClickCallback transmute mangled the pointer");
    }
    #[test]
    fn a_button_without_a_callback_registers_no_callbacks() {
        for ty in ALL_TYPES {
            let dom = btn("Inert", ty).dom();
            assert!(dom.root.callbacks.as_ref().is_empty(), "{ty:?}: a callback appeared out of nowhere");
        }
    }
    // ------------------------------------------------------------------
    // Button::dom
    // ------------------------------------------------------------------
    #[test]
    fn dom_builds_a_focusable_button_node_with_the_base_and_type_class() {
        for ty in ALL_TYPES {
            let dom = btn("OK", ty).dom();
            assert!(matches!(dom.root.get_node_type(), NodeType::Button), "{ty:?}: root is not a Button node");
            assert_eq!(
                dom.root.flags.get_tab_index(),
                Some(TabIndex::Auto),
                "{ty:?}: the button is not keyboard-focusable",
            );
            assert_eq!(
                classes(&dom),
                vec!["__azul-native-button".to_string(), ty.class_name().to_string()],
                "{ty:?}: wrong classes (base class first, then the type class)",
            );
            assert!(!dom.root.style.is_empty(), "{ty:?}: the container style did not reach the node");
        }
    }
    #[test]
    fn dom_carries_the_container_style_on_the_root_and_the_label_style_on_the_child() {
        for ty in ALL_TYPES {
            let b = btn("OK", ty);
            let container = properties(&b.container_style);
            let label_style = properties(&b.label_style);
            let dom = b.dom();
            assert_eq!(inline_properties(&dom), container, "{ty:?}: the root inline style is not the container style");
            let children = dom.children.as_ref();
            assert_eq!(children.len(), 1, "{ty:?}: an image-less button is a Button node with exactly one <p> label child");
            assert_eq!(p_label_of(&children[0]), Some("OK"), "{ty:?}: the label was mangled");
            assert_eq!(inline_properties(&children[0]), label_style, "{ty:?}: the label style is not on the label node");
        }
    }
    #[test]
    fn dom_puts_the_image_before_the_label_and_keeps_the_child_count_cache_honest() {
        let mut b = btn("Save", ButtonType::Primary);
        b.set_image(ImageRef::null_image(16, 16, RawImageFormat::RGBA8, Vec::new()));
        let image_style = properties(&b.image_style);
        let dom = b.dom();
        let children = dom.children.as_ref();
        assert_eq!(children.len(), 2, "an image button renders the image and the label");
        assert!(matches!(children[0].root.get_node_type(), NodeType::Image(_)), "the image must come first (left of the label)");
        assert_eq!(p_label_of(&children[1]), Some("Save"), "the label must be the second child");
        assert_eq!(inline_properties(&children[0]), image_style, "the image style is not on the image node");
        // `estimated_total_children` is a cache that, if wrong, makes the compact-DOM
        // conversion under-allocate its arenas and write out of bounds.
        assert_eq!(
            dom.estimated_total_children,
            count_descendants(&dom),
            "estimated_total_children is out of sync with the real subtree",
        );
    }
    #[test]
    fn dom_child_count_cache_is_honest_without_an_image_too() {
        for ty in ALL_TYPES {
            let dom = btn("x", ty).dom();
            assert_eq!(dom.estimated_total_children, count_descendants(&dom), "{ty:?}: stale estimated_total_children");
            assert_eq!(dom.estimated_total_children, 2, "{ty:?}: an image-less button has the <p> label and its text node");
        }
    }
    #[test]
    fn dom_preserves_adversarial_labels_verbatim() {
        for label in adversarial_labels() {
            let dom = btn(&label, ButtonType::Default).dom();
            let children = dom.children.as_ref();
            assert_eq!(children.len(), 1);
            let text = p_label_of(&children[0]).expect("the label child is not a <p>-wrapped text node");
            assert_eq!(text, label.as_str(), "a {}-byte label was mangled", label.len());
            assert_eq!(text.len(), label.len(), "a NUL or a wide char truncated the label");
        }
    }
    #[test]
    fn dom_of_a_button_whose_label_looks_like_a_class_name_does_not_leak_into_the_classes() {
        // The label is user data — it must never be able to add a CSS class.
        let dom = btn("__azul-btn-danger", ButtonType::Primary).dom();
        assert_eq!(
            classes(&dom),
            vec!["__azul-native-button".to_string(), "__azul-btn-primary".to_string()],
            "the label leaked into the class list",
        );
    }
    #[test]
    fn dom_renders_the_type_the_button_was_last_set_to() {
        for ty in ALL_TYPES {
            let dom = btn("x", ButtonType::Default).with_button_type(ty).dom();
            assert!(
                classes(&dom).contains(&ty.class_name().to_string()),
                "{ty:?}: the DOM still carries the old type class",
            );
        }
    }
    #[test]
    fn dom_is_deterministic_across_identical_buttons() {
        // Same inputs, same tree: the node type, classes and inline styles must all
        // be reproducible (only the ImageRef/RefAny identities may differ).
        for ty in ALL_TYPES {
            let a = btn("Same", ty).dom();
            let b = btn("Same", ty).dom();
            assert_eq!(a.root.get_node_type(), b.root.get_node_type(), "{ty:?}: node type differs");
            assert_eq!(classes(&a), classes(&b), "{ty:?}: classes differ");
            assert_eq!(inline_properties(&a), inline_properties(&b), "{ty:?}: inline style differs");
            assert_eq!(a.children.as_ref().len(), b.children.as_ref().len(), "{ty:?}: child count differs");
        }
    }
}