Skip to main content

azul_layout/widgets/
button.rs

1//! Button widget with Bootstrap-inspired type-based styling (`ButtonType`).
2
3use std::vec::Vec;
4
5use 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
12use 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
28use 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)]
36pub 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
56impl ButtonType {
57    /// Get the CSS class name for this button type
58    #[must_use] pub const fn class_name(&self) -> &'static str {
59        match self {
60            Self::Default => "__azul-btn-default",
61            Self::Primary => "__azul-btn-primary",
62            Self::Secondary => "__azul-btn-secondary",
63            Self::Success => "__azul-btn-success",
64            Self::Danger => "__azul-btn-danger",
65            Self::Warning => "__azul-btn-warning",
66            Self::Info => "__azul-btn-info",
67            Self::Link => "__azul-btn-link",
68        }
69    }
70}
71
72#[repr(C)]
73#[derive(Debug, Clone, PartialEq, Eq, PartialOrd)]
74pub 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    /// The semantic type of this button (Primary, Success, Danger, etc.)
80    pub button_type: ButtonType,
81    /// Style for this button container
82    pub container_style: CssPropertyWithConditionsVec,
83    /// Style of the label
84    pub label_style: CssPropertyWithConditionsVec,
85    /// Style of the image
86    pub image_style: CssPropertyWithConditionsVec,
87    /// Optional: Function to call when the button is clicked
88    pub on_click: OptionButtonOnClick,
89}
90
91pub type ButtonOnClickCallbackType = extern "C" fn(RefAny, CallbackInfo) -> Update;
92impl_widget_callback!(
93    ButtonOnClick,
94    OptionButtonOnClick,
95    ButtonOnClickCallback,
96    ButtonOnClickCallbackType
97);
98
99// Host-invoker plumbing for managed-FFI bindings — see core/src/host_invoker.rs.
100azul_core::impl_managed_callback! {
101    wrapper:        ButtonOnClickCallback,
102    info_ty:        CallbackInfo,
103    return_ty:      Update,
104    default_ret:    Update::DoNothing,
105    invoker_static: BUTTON_ON_CLICK_INVOKER,
106    invoker_ty:     AzButtonOnClickCallbackInvoker,
107    thunk_fn:       az_button_on_click_callback_thunk,
108    setter_fn:      AzApp_setButtonOnClickCallbackInvoker,
109    from_handle_fn: AzButtonOnClickCallback_createFromHostHandle,
110}
111
112// ButtonType-specific styling
113// ============================================================
114
115/// Get the background color for a button type
116const fn get_button_colors(button_type: ButtonType) -> (ColorU, ColorU, ColorU) {
117    // Returns (normal, hover, active) colors
118    match button_type {
119        ButtonType::Default => (
120            ColorU::rgb(248, 249, 250), // Light gray
121            ColorU::rgb(233, 236, 239), // Darker gray on hover
122            ColorU::rgb(218, 222, 226), // Even darker on active
123        ),
124        ButtonType::Primary => (
125            ColorU::bootstrap_primary(),
126            ColorU::bootstrap_primary_hover(),
127            ColorU::bootstrap_primary_active(),
128        ),
129        ButtonType::Secondary => (
130            ColorU::bootstrap_secondary(),
131            ColorU::bootstrap_secondary_hover(),
132            ColorU::bootstrap_secondary_active(),
133        ),
134        ButtonType::Success => (
135            ColorU::bootstrap_success(),
136            ColorU::bootstrap_success_hover(),
137            ColorU::bootstrap_success_active(),
138        ),
139        ButtonType::Danger => (
140            ColorU::bootstrap_danger(),
141            ColorU::bootstrap_danger_hover(),
142            ColorU::bootstrap_danger_active(),
143        ),
144        ButtonType::Warning => (
145            ColorU::bootstrap_warning(),
146            ColorU::bootstrap_warning_hover(),
147            ColorU::bootstrap_warning_active(),
148        ),
149        ButtonType::Info => (
150            ColorU::bootstrap_info(),
151            ColorU::bootstrap_info_hover(),
152            ColorU::bootstrap_info_active(),
153        ),
154        ButtonType::Link => (
155            ColorU::TRANSPARENT,
156            ColorU::TRANSPARENT,
157            ColorU::TRANSPARENT,
158        ),
159    }
160}
161
162/// Get the text color for a button type
163const fn get_button_text_color(button_type: ButtonType) -> ColorU {
164    match button_type {
165        ButtonType::Default => ColorU::rgb(33, 37, 41),   // Dark text
166        ButtonType::Warning => ColorU::BLACK,             // Black text on yellow
167        ButtonType::Link => ColorU::bootstrap_link(),     // Blue link color
168        _ => ColorU::WHITE,                               // White text on colored buttons
169    }
170}
171
172/// Build container style properties for a button type
173fn build_button_container_style(button_type: ButtonType) -> Vec<CssPropertyWithConditions> {
174    // ⚠ BISECTION PROBE (2026-06-02, REVERT): return a MINIMAL container style — no const
175    // background, no hover/active gradients, no conditions — to test whether the
176    // container's COMPLEX props cause the web cascade OOB on AzButton. If web-button-nocb
177    // RUNS with this → the complex container props are the root; if it still OOBs → the
178    // label/structure is. Remove this `return` to restore the real button styling.
179    // ⚠ BISECTION step 7 (REVERT): InlineFlex → Block. The cascade + inline style now work
180    // (rules=3, disp correct), but layout returns InvalidTree + width=0 because InlineFlex
181    // triggers the (deferred) taffy flex-algorithm lift gap. Block layout is known-good on web.
182    // If this lays out (no InvalidTree, sized button) → confirms flex is the layout blocker.
183    return alloc::vec![
184        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
185        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(6))),
186        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(LayoutPaddingBottom::const_px(6))),
187    ];
188    #[allow(unreachable_code)]
189    let (bg_normal, bg_hover, bg_active) = get_button_colors(button_type);
190    let text_color = get_button_text_color(button_type);
191    
192    // Focus outline uses system accent color
193    let focus_outline_color = ColorU::bootstrap_primary();
194    
195    let mut props = Vec::with_capacity(40);
196    
197    // Basic layout - use InlineFlex so flex properties (justify-content, align-items) work
198    props.push(CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::InlineFlex)));
199    props.push(CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)));
200    props.push(CssPropertyWithConditions::simple(CssProperty::const_justify_content(LayoutJustifyContent::Center)));
201    props.push(CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)));
202    // Prevent stretching when inside a flex column container
203    props.push(CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)));
204    props.push(CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)));
205    props.push(CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))));
206    
207    // Text color
208    props.push(CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: text_color })));
209    
210    // Padding (Bootstrap-like)
211    props.push(CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(6))));
212    props.push(CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(LayoutPaddingBottom::const_px(6))));
213    props.push(CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(12))));
214    props.push(CssPropertyWithConditions::simple(CssProperty::const_padding_right(LayoutPaddingRight::const_px(12))));
215    
216    // Border radius
217    props.push(CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(StyleBorderTopLeftRadius::const_px(4))));
218    props.push(CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(StyleBorderTopRightRadius::const_px(4))));
219    props.push(CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(StyleBorderBottomLeftRadius::const_px(4))));
220    props.push(CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(StyleBorderBottomRightRadius::const_px(4))));
221    
222    if button_type == ButtonType::Link {
223        // Link buttons have no background or border
224        props.push(CssPropertyWithConditions::simple(CssProperty::const_background_content(
225            StyleBackgroundContentVec::from_const_slice(&[StyleBackgroundContent::Color(ColorU::TRANSPARENT)]),
226        )));
227        
228        // Underline on hover - use TextDecoration::Underline variant
229        props.push(CssPropertyWithConditions::on_hover(CssProperty::TextDecoration(StyleTextDecoration::Underline.into())));
230    } else {
231        // Normal background
232        props.push(CssPropertyWithConditions::simple(CssProperty::const_background_content(
233            StyleBackgroundContentVec::from_vec(vec![StyleBackgroundContent::Color(bg_normal)]),
234        )));
235        
236        // Border (subtle for Default, transparent for others to maintain size)
237        let border_color = if button_type == ButtonType::Default {
238            ColorU::rgb(206, 212, 218)
239        } else {
240            bg_normal
241        };
242        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_top_width(LayoutBorderTopWidth::const_px(1))));
243        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(LayoutBorderBottomWidth::const_px(1))));
244        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_left_width(LayoutBorderLeftWidth::const_px(1))));
245        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_right_width(LayoutBorderRightWidth::const_px(1))));
246        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle { inner: BorderStyle::Solid })));
247        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(StyleBorderBottomStyle { inner: BorderStyle::Solid })));
248        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle { inner: BorderStyle::Solid })));
249        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_right_style(StyleBorderRightStyle { inner: BorderStyle::Solid })));
250        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor { inner: border_color })));
251        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(StyleBorderBottomColor { inner: border_color })));
252        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor { inner: border_color })));
253        props.push(CssPropertyWithConditions::simple(CssProperty::const_border_right_color(StyleBorderRightColor { inner: border_color })));
254        
255        // Hover state
256        props.push(CssPropertyWithConditions::on_hover(CssProperty::BackgroundContent(
257            StyleBackgroundContentVec::from_vec(vec![StyleBackgroundContent::Color(bg_hover)]).into(),
258        )));
259        if button_type == ButtonType::Default {
260            let hover_border = ColorU::rgb(173, 181, 189);
261            props.push(CssPropertyWithConditions::on_hover(CssProperty::BorderTopColor(StyleBorderTopColor { inner: hover_border }.into())));
262            props.push(CssPropertyWithConditions::on_hover(CssProperty::BorderBottomColor(StyleBorderBottomColor { inner: hover_border }.into())));
263            props.push(CssPropertyWithConditions::on_hover(CssProperty::BorderLeftColor(StyleBorderLeftColor { inner: hover_border }.into())));
264            props.push(CssPropertyWithConditions::on_hover(CssProperty::BorderRightColor(StyleBorderRightColor { inner: hover_border }.into())));
265        }
266        
267        // Active (pressed) state
268        props.push(CssPropertyWithConditions::on_active(CssProperty::BackgroundContent(
269            StyleBackgroundContentVec::from_vec(vec![StyleBackgroundContent::Color(bg_active)]).into(),
270        )));
271        
272        // Focus state - uses accent color for outline
273        // This makes the button feel "native" as it uses the system accent
274        props.push(CssPropertyWithConditions::on_focus(CssProperty::BorderTopColor(StyleBorderTopColor { inner: focus_outline_color }.into())));
275        props.push(CssPropertyWithConditions::on_focus(CssProperty::BorderBottomColor(StyleBorderBottomColor { inner: focus_outline_color }.into())));
276        props.push(CssPropertyWithConditions::on_focus(CssProperty::BorderLeftColor(StyleBorderLeftColor { inner: focus_outline_color }.into())));
277        props.push(CssPropertyWithConditions::on_focus(CssProperty::BorderRightColor(StyleBorderRightColor { inner: focus_outline_color }.into())));
278    }
279    
280    props
281}
282
283/// Build label style properties
284fn build_button_label_style() -> Vec<CssPropertyWithConditions> {
285    // Use system UI font
286    let font_family = StyleFontFamilyVec::from_vec(vec![
287        StyleFontFamily::SystemType(SystemFontType::Ui),
288    ]);
289    
290    vec![
291        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(14))),
292        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
293        CssPropertyWithConditions::simple(CssProperty::const_font_family(font_family)),
294        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
295    ]
296}
297
298impl Button {
299    /// Create a button with `ButtonType::Default` styling.
300    #[inline]
301    #[must_use] pub fn create(label: AzString) -> Self {
302        Self::with_type(label, ButtonType::Default)
303    }
304    
305    /// Create a button with a specific type (Primary, Success, Danger, etc.)
306    #[inline]
307    #[must_use] pub fn with_type(label: AzString, button_type: ButtonType) -> Self {
308        let container_style = build_button_container_style(button_type);
309        let label_style = build_button_label_style();
310        
311        Self {
312            label,
313            image: None.into(),
314            button_type,
315            on_click: None.into(),
316            container_style: CssPropertyWithConditionsVec::from_vec(container_style),
317            label_style: CssPropertyWithConditionsVec::from_vec(label_style.clone()),
318            image_style: CssPropertyWithConditionsVec::from_vec(label_style),
319        }
320    }
321    
322    /// Set the button type and update styling accordingly
323    #[inline]
324    pub fn set_button_type(&mut self, button_type: ButtonType) {
325        self.button_type = button_type;
326        self.container_style = CssPropertyWithConditionsVec::from_vec(build_button_container_style(button_type));
327    }
328    
329    /// Builder method to set the button type
330    #[inline]
331    #[must_use] pub fn with_button_type(mut self, button_type: ButtonType) -> Self {
332        self.set_button_type(button_type);
333        self
334    }
335
336    #[inline]
337    #[must_use]
338    pub fn swap_with_default(&mut self) -> Self {
339        let mut m = Self::create(AzString::from_const_str(""));
340        core::mem::swap(&mut m, self);
341        m
342    }
343
344    #[inline]
345    pub fn set_image(&mut self, image: ImageRef) {
346        self.image = Some(image).into();
347    }
348
349    #[inline]
350    pub fn set_on_click<C: Into<ButtonOnClickCallback>>(&mut self, data: RefAny, on_click: C) {
351        self.on_click = Some(ButtonOnClick {
352            refany: data,
353            callback: on_click.into(),
354        })
355        .into();
356    }
357
358    #[inline]
359    #[must_use]
360    pub fn with_on_click<C: Into<ButtonOnClickCallback>>(
361        mut self,
362        data: RefAny,
363        on_click: C,
364    ) -> Self {
365        self.set_on_click(data, on_click);
366        self
367    }
368
369    #[inline]
370    #[must_use] pub fn dom(self) -> Dom {
371        use azul_core::{
372            callbacks::{CoreCallback, CoreCallbackData},
373            dom::{EventFilter, HoverEventFilter},
374        };
375
376        let callbacks = match self.on_click.into_option() {
377            Some(ButtonOnClick {
378                refany: data,
379                callback,
380            }) => vec![CoreCallbackData {
381                event: EventFilter::Hover(HoverEventFilter::MouseUp),
382                callback: CoreCallback {
383                    cb: callback.cb as *const () as usize,
384                    ctx: callback.ctx,
385                },
386                refany: data,
387            }],
388            None => Vec::new(),
389        };
390
391        // Add both the base class and the type-specific class
392        // ⚠ BISECTION step 5 (REVERT): const-str classes → HEAP classes (AzString::from(&str)
393        // = s.to_string().into()). Decisive test: if web-button-nocb RUNS now → the const-str
394        // CLONE (s.clone() of a NoDestructor/borrowed AzString in set_ids_and_classes) mis-lifts
395        // (deref of unmirrored .rodata); fix = transpiler const-str mirror OR heap classes here.
396        // If it still OOBs → the AttributeTypeVec machinery (swap/into_library_owned_vec/retain/
397        // push/set_attributes) is the lift bug, independent of const-str.
398        let type_class = self.button_type.class_name();
399        let classes: Vec<IdOrClass> = vec![
400            Class(AzString::from("__azul-native-button")),
401            Class(AzString::from(type_class)),
402        ];
403
404        // (2026-06-10: the June-02 bisection strips are REVERTED — the underlying corruption
405        // was the alloc collect-machinery Leaf-stub in the web transpiler, fixed there. The
406        // label keeps its inline css; the button carries its on_click callbacks + tab index
407        // again — without them every Button click was a silent no-op on ALL backends, and the
408        // web route-walk discovered 0 callbacks. The FIX-A ordering (container style before
409        // ids/classes) is kept: builder-order is semantically neutral natively.)
410        let label_dom = Dom::create_text(self.label)
411            .with_css_props(self.label_style);
412
413        let mut button = Dom::create_node(NodeType::Button);
414
415        // If an image was set via `set_image`, render it as the first child
416        // (left of the label, since the container is a horizontal flex row).
417        if let Some(image) = self.image.into_option() {
418            button = button.with_child(
419                Dom::create_image(image).with_css_props(self.image_style),
420            );
421        }
422
423        button
424            .with_child(label_dom)
425            .with_css_props(self.container_style)
426            .with_ids_and_classes(IdOrClassVec::from_vec(classes))
427            .with_callbacks(callbacks.into())
428            .with_tab_index(TabIndex::Auto)
429    }
430}
431
432#[cfg(test)]
433mod autotest_generated {
434    use std::collections::HashSet;
435
436    use azul_core::{
437        dom::{EventFilter, HoverEventFilter},
438        resources::RawImageFormat,
439    };
440    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
441
442    use super::*;
443
444    // ------------------------------------------------------------------
445    // Helpers
446    // ------------------------------------------------------------------
447
448    /// Every variant of `ButtonType` — the complete input domain of `class_name`,
449    /// `get_button_colors`, `get_button_text_color` and `build_button_container_style`.
450    const ALL_TYPES: [ButtonType; 8] = [
451        ButtonType::Default,
452        ButtonType::Primary,
453        ButtonType::Secondary,
454        ButtonType::Success,
455        ButtonType::Danger,
456        ButtonType::Warning,
457        ButtonType::Info,
458        ButtonType::Link,
459    ];
460
461    const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
462    const BLACK: ColorU = ColorU { r: 0, g: 0, b: 0, a: 255 };
463    const TRANSPARENT: ColorU = ColorU { r: 0, g: 0, b: 0, a: 0 };
464    /// The "dark text" of the Default button (Bootstrap `$gray-900`).
465    const DARK: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
466
467    extern "C" fn test_click(_data: RefAny, _info: CallbackInfo) -> Update {
468        Update::DoNothing
469    }
470
471    extern "C" fn other_click(_data: RefAny, _info: CallbackInfo) -> Update {
472        Update::RefreshDom
473    }
474
475    fn btn(label: &str, button_type: ButtonType) -> Button {
476        Button::with_type(AzString::from(label), button_type)
477    }
478
479    /// The declared properties of a style vec, in declaration order.
480    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
481        v.as_ref().iter().map(|p| p.property.clone()).collect()
482    }
483
484    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length — an
485    /// `em`/`%` slipping into the button geometry would resolve against the parent
486    /// font/box instead of the intended fixed padding or font size.
487    fn px(pv: &PixelValue) -> f32 {
488        assert_eq!(pv.metric, SizeMetric::Px, "button geometry must be absolute px, got {:?}", pv.metric);
489        pv.number.get()
490    }
491
492    fn padding_top_bottom_px(v: &CssPropertyWithConditionsVec) -> (Option<f32>, Option<f32>) {
493        let find = |f: &dyn Fn(&CssProperty) -> Option<f32>| v.as_ref().iter().find_map(|p| f(&p.property));
494        (
495            find(&|p| match p {
496                CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
497                _ => None,
498            }),
499            find(&|p| match p {
500                CssProperty::PaddingBottom(x) => x.get_property().map(|x| px(&x.inner)),
501                _ => None,
502            }),
503        )
504    }
505
506    fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
507        v.as_ref().iter().find_map(|p| match &p.property {
508            CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
509            _ => None,
510        })
511    }
512
513    /// The CSS classes of a rendered node, in declaration order.
514    fn classes(dom: &Dom) -> Vec<String> {
515        dom.root
516            .get_ids_and_classes()
517            .as_ref()
518            .iter()
519            .filter_map(|c| match c {
520                IdOrClass::Class(s) => Some(s.as_str().to_string()),
521                IdOrClass::Id(_) => None,
522            })
523            .collect()
524    }
525
526    /// The properties of a rendered node's *inline* style, in declaration order.
527    fn inline_properties(dom: &Dom) -> Vec<CssProperty> {
528        dom.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
529    }
530
531    fn text_of(dom: &Dom) -> Option<&str> {
532        match dom.root.get_node_type() {
533            NodeType::Text(s) => Some(s.as_ref().as_str()),
534            _ => None,
535        }
536    }
537
538    /// The recursive descendant count — `Dom::estimated_total_children` is a *cached*
539    /// value that, if too small, makes `convert_dom_into_compact_dom` under-allocate
540    /// its arenas and panic on out-of-bounds writes.
541    fn count_descendants(dom: &Dom) -> usize {
542        dom.children.as_ref().iter().map(|c| 1 + count_descendants(c)).sum()
543    }
544
545    /// Perceived brightness (0..=255) of an sRGB colour, Rec.709 weights. Kept to
546    /// plain `+`/`*` (no gamma expansion) so the readability assertions stay exact
547    /// and toolchain-independent.
548    fn luma(c: ColorU) -> f32 {
549        0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b)
550    }
551
552    /// Adversarial button labels: empty, whitespace, combining marks, ZWJ emoji, RTL,
553    /// embedded NULs (`AzString` is length-based, so a NUL must not truncate), bidi
554    /// overrides and a string far longer than any plausible button label.
555    fn adversarial_labels() -> Vec<String> {
556        let mut v: Vec<String> = [
557            "",
558            " ",
559            "OK",
560            "e\u{0301}",                                   // e + combining acute
561            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family emoji
562            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",                // RTL Hebrew
563            "\0",                                          // a single NUL
564            "a\0b",                                        // embedded NUL
565            "\u{FFFD}\u{202E}\u{200B}",                    // replacement char, RTL override, ZWSP
566            "…\t\r\n",                                     // control chars in a label
567            "__azul-btn-primary",                          // a label that looks like a class name
568        ]
569        .iter()
570        .map(|s| (*s).to_string())
571        .collect();
572        v.push("x".repeat(100_000));
573        v
574    }
575
576    // ------------------------------------------------------------------
577    // ButtonType::class_name  (getter)
578    // ------------------------------------------------------------------
579
580    #[test]
581    fn class_name_returns_the_documented_class_for_every_type() {
582        let expected = [
583            (ButtonType::Default, "__azul-btn-default"),
584            (ButtonType::Primary, "__azul-btn-primary"),
585            (ButtonType::Secondary, "__azul-btn-secondary"),
586            (ButtonType::Success, "__azul-btn-success"),
587            (ButtonType::Danger, "__azul-btn-danger"),
588            (ButtonType::Warning, "__azul-btn-warning"),
589            (ButtonType::Info, "__azul-btn-info"),
590            (ButtonType::Link, "__azul-btn-link"),
591        ];
592        for (ty, class) in expected {
593            assert_eq!(ty.class_name(), class, "{ty:?}: wrong CSS class");
594        }
595        assert_eq!(expected.len(), ALL_TYPES.len(), "a ButtonType variant is missing from this table");
596    }
597
598    #[test]
599    fn class_name_is_unique_per_type() {
600        // Two types sharing a class make the semantic variant unstylable: the
601        // stylesheet could not tell a Danger button from a Success one.
602        let mut seen = HashSet::new();
603        for ty in ALL_TYPES {
604            assert!(seen.insert(ty.class_name()), "{ty:?}: duplicate class name {}", ty.class_name());
605        }
606        assert_eq!(seen.len(), ALL_TYPES.len());
607    }
608
609    #[test]
610    fn class_name_is_a_well_formed_css_identifier() {
611        // A space, quote or `.` would silently split/escape into a *different*
612        // selector once written into a stylesheet.
613        for ty in ALL_TYPES {
614            let c = ty.class_name();
615            assert!(!c.is_empty(), "{ty:?}: empty class name");
616            assert!(c.starts_with("__azul-btn-"), "{ty:?}: class {c} lost the widget prefix");
617            assert!(c.is_ascii(), "{ty:?}: non-ASCII class name {c}");
618            assert!(
619                c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'),
620                "{ty:?}: class {c} contains a character that needs CSS escaping",
621            );
622        }
623    }
624
625    #[test]
626    fn class_name_is_pure_and_const_evaluable() {
627        // Declared `const fn` returning `&'static str`: the same variant must yield
628        // the *same* static, call after call (no per-call allocation).
629        const DEFAULT_CLASS: &str = ButtonType::Default.class_name();
630        const LINK_CLASS: &str = ButtonType::Link.class_name();
631        assert_eq!(DEFAULT_CLASS, "__azul-btn-default");
632        assert_eq!(LINK_CLASS, "__azul-btn-link");
633
634        for ty in ALL_TYPES {
635            let a = ty.class_name();
636            let b = ty.class_name();
637            assert_eq!(a.as_ptr(), b.as_ptr(), "{ty:?}: class_name is not returning a stable static");
638        }
639    }
640
641    #[test]
642    fn class_name_of_the_default_type_matches_derived_default() {
643        // `#[default] Default` — a reordering of the enum that moves `#[default]`
644        // would silently restyle every `Button::create`.
645        assert_eq!(ButtonType::default(), ButtonType::Default);
646        assert_eq!(ButtonType::default().class_name(), "__azul-btn-default");
647    }
648
649    // ------------------------------------------------------------------
650    // get_button_colors  (private)
651    // ------------------------------------------------------------------
652
653    #[test]
654    fn get_button_colors_returns_the_documented_bootstrap_triples() {
655        let expected = [
656            (
657                ButtonType::Default,
658                ColorU::rgb(248, 249, 250),
659                ColorU::rgb(233, 236, 239),
660                ColorU::rgb(218, 222, 226),
661            ),
662            (
663                ButtonType::Primary,
664                ColorU::rgb(13, 110, 253),
665                ColorU::rgb(11, 94, 215),
666                ColorU::rgb(10, 88, 202),
667            ),
668            (
669                ButtonType::Secondary,
670                ColorU::rgb(108, 117, 125),
671                ColorU::rgb(92, 99, 106),
672                ColorU::rgb(86, 94, 100),
673            ),
674            (
675                ButtonType::Success,
676                ColorU::rgb(25, 135, 84),
677                ColorU::rgb(21, 115, 71),
678                ColorU::rgb(20, 108, 67),
679            ),
680            (
681                ButtonType::Danger,
682                ColorU::rgb(220, 53, 69),
683                ColorU::rgb(187, 45, 59),
684                ColorU::rgb(176, 42, 55),
685            ),
686            (
687                ButtonType::Warning,
688                ColorU::rgb(255, 193, 7),
689                ColorU::rgb(255, 202, 44),
690                ColorU::rgb(255, 205, 57),
691            ),
692            (
693                ButtonType::Info,
694                ColorU::rgb(13, 202, 240),
695                ColorU::rgb(49, 210, 242),
696                ColorU::rgb(61, 213, 243),
697            ),
698            (ButtonType::Link, TRANSPARENT, TRANSPARENT, TRANSPARENT),
699        ];
700        for (ty, normal, hover, active) in expected {
701            assert_eq!(get_button_colors(ty), (normal, hover, active), "{ty:?}: wrong colour triple");
702        }
703    }
704
705    #[test]
706    fn get_button_colors_is_pure() {
707        for ty in ALL_TYPES {
708            assert_eq!(get_button_colors(ty), get_button_colors(ty), "{ty:?}: colours are not deterministic");
709        }
710    }
711
712    #[test]
713    fn get_button_colors_keeps_link_fully_transparent_and_every_other_type_opaque() {
714        // A Link button that paints *any* background stops looking like a hyperlink;
715        // a translucent solid button lets the page bleed through and destroys the
716        // contrast the type was chosen for.
717        for ty in ALL_TYPES {
718            let (normal, hover, active) = get_button_colors(ty);
719            if ty == ButtonType::Link {
720                assert_eq!((normal, hover, active), (TRANSPARENT, TRANSPARENT, TRANSPARENT), "Link must paint nothing");
721            } else {
722                for (state, c) in [("normal", normal), ("hover", hover), ("active", active)] {
723                    assert_eq!(c.a, 255, "{ty:?}: {state} background {c:?} is not opaque");
724                }
725            }
726        }
727    }
728
729    #[test]
730    fn get_button_colors_gives_every_state_a_visible_delta() {
731        // hover == normal means the button gives no feedback on mouse-over;
732        // active == hover means the press is invisible.
733        for ty in ALL_TYPES {
734            if ty == ButtonType::Link {
735                continue; // deliberately identical: a link has no background at all
736            }
737            let (normal, hover, active) = get_button_colors(ty);
738            assert_ne!(normal, hover, "{ty:?}: hover state is indistinguishable from normal");
739            assert_ne!(hover, active, "{ty:?}: active state is indistinguishable from hover");
740            assert_ne!(normal, active, "{ty:?}: active state is indistinguishable from normal");
741        }
742    }
743
744    #[test]
745    fn get_button_colors_gives_every_type_a_distinguishable_background() {
746        // Two types that render identically make the semantic variant useless.
747        let mut seen = HashSet::new();
748        for ty in ALL_TYPES {
749            let (normal, _, _) = get_button_colors(ty);
750            assert!(seen.insert((normal.r, normal.g, normal.b, normal.a)), "{ty:?}: duplicate background {normal:?}");
751        }
752        assert_eq!(seen.len(), ALL_TYPES.len());
753    }
754
755    // ------------------------------------------------------------------
756    // get_button_text_color  (private)
757    // ------------------------------------------------------------------
758
759    #[test]
760    fn get_button_text_color_returns_the_documented_colour_for_every_type() {
761        let expected = [
762            (ButtonType::Default, DARK),
763            (ButtonType::Primary, WHITE),
764            (ButtonType::Secondary, WHITE),
765            (ButtonType::Success, WHITE),
766            (ButtonType::Danger, WHITE),
767            (ButtonType::Warning, BLACK), // doc: "Warning button - yellow with BLACK text"
768            (ButtonType::Info, WHITE),
769            (ButtonType::Link, ColorU::rgb(13, 110, 253)),
770        ];
771        for (ty, text) in expected {
772            assert_eq!(get_button_text_color(ty), text, "{ty:?}: wrong text colour");
773        }
774        // The `_ => WHITE` catch-all is easy to widen by accident: only these three
775        // variants may deviate from white.
776        for ty in ALL_TYPES {
777            let is_special = matches!(ty, ButtonType::Default | ButtonType::Warning | ButtonType::Link);
778            assert_eq!(get_button_text_color(ty) != WHITE, is_special, "{ty:?}: text colour contradicts the documented variant");
779        }
780    }
781
782    #[test]
783    fn get_button_text_color_is_pure_and_opaque() {
784        for ty in ALL_TYPES {
785            let c = get_button_text_color(ty);
786            assert_eq!(c, get_button_text_color(ty), "{ty:?}: text colour is not deterministic");
787            assert_eq!(c.a, 255, "{ty:?}: invisible (translucent) text colour {c:?}");
788        }
789    }
790
791    #[test]
792    fn get_button_text_color_stays_readable_on_its_own_background() {
793        // The one real invariant of the pair: label must be legible on the fill.
794        // NOTE: `Info` (white on #0dcaf0) is by far the weakest pairing at ~90 luma
795        // of separation — Bootstrap and azul's own `Badge` widget both put *dark*
796        // text on Info. The bound below is the current floor, not an endorsement;
797        // moving Info to dark text raises its separation to ~128 and still passes.
798        for ty in ALL_TYPES {
799            if ty == ButtonType::Link {
800                continue; // no fill: a link is drawn on the page background
801            }
802            let (bg, _, _) = get_button_colors(ty);
803            let text = get_button_text_color(ty);
804            let separation = (luma(bg) - luma(text)).abs();
805            assert!(separation >= 85.0, "{ty:?}: text {text:?} on {bg:?} is unreadable (luma separation {separation:.1})");
806
807            // ... and the *more* readable of the two candidates was chosen.
808            let alt = if text == WHITE { DARK } else { WHITE };
809            let alt_separation = (luma(bg) - luma(alt)).abs();
810            if ty != ButtonType::Info {
811                assert!(separation >= alt_separation, "{ty:?}: {alt:?} would be more readable than {text:?} on {bg:?}");
812            }
813        }
814    }
815
816    // ------------------------------------------------------------------
817    // build_button_container_style  (private)
818    // ------------------------------------------------------------------
819
820    #[test]
821    fn build_button_container_style_never_panics_and_is_pure() {
822        for ty in ALL_TYPES {
823            let a = build_button_container_style(ty);
824            let b = build_button_container_style(ty);
825            assert!(!a.is_empty(), "{ty:?}: a button with no container style is invisible");
826            assert_eq!(a, b, "{ty:?}: container style is not deterministic");
827        }
828    }
829
830    #[test]
831    fn build_button_container_style_always_declares_display_and_symmetric_vertical_padding() {
832        // Without a `display` the button falls back to the UA default; asymmetric
833        // vertical padding makes the label sit off-centre.
834        for ty in ALL_TYPES {
835            let v = CssPropertyWithConditionsVec::from_vec(build_button_container_style(ty));
836            assert!(
837                v.as_ref().iter().any(|p| matches!(p.property, CssProperty::Display(_))),
838                "{ty:?}: container declares no `display`",
839            );
840            let (top, bottom) = padding_top_bottom_px(&v);
841            assert_eq!(top, Some(6.0), "{ty:?}: wrong padding-top");
842            assert_eq!(bottom, Some(6.0), "{ty:?}: wrong padding-bottom");
843            assert_eq!(top, bottom, "{ty:?}: vertical padding is asymmetric — the label will not be centred");
844        }
845    }
846
847    #[test]
848    fn build_button_container_style_currently_ignores_the_button_type() {
849        // ⚠ CHARACTERISATION TEST — pins the 2026-06-02 BISECTION PROBE, not a
850        // desirable behaviour. `build_button_container_style` begins with an
851        // unconditional `return` of a 3-property minimal style, so *everything*
852        // below it (backgrounds, borders, hover/active/focus states, and therefore
853        // both `get_button_colors` and `get_button_text_color`) is dead code, and
854        // all 8 button types render with the identical container style. Reverting
855        // the probe — as its own comment instructs — will trip this test on
856        // purpose; delete it then.
857        let baseline = properties(&CssPropertyWithConditionsVec::from_vec(build_button_container_style(ButtonType::Default)));
858        for ty in ALL_TYPES {
859            let v = CssPropertyWithConditionsVec::from_vec(build_button_container_style(ty));
860            assert_eq!(properties(&v), baseline, "{ty:?}: container style diverged — was the bisection probe reverted?");
861            assert!(
862                !v.as_ref().iter().any(|p| matches!(
863                    p.property,
864                    CssProperty::BackgroundContent(_) | CssProperty::TextColor(_)
865                )),
866                "{ty:?}: the probe is no longer returning early — restore the type-dependent assertions",
867            );
868            assert!(
869                v.as_ref().iter().all(|p| p.apply_if.as_ref().is_empty()),
870                "{ty:?}: the probe emits only unconditional properties",
871            );
872        }
873    }
874
875    // ------------------------------------------------------------------
876    // build_button_label_style  (private)
877    // ------------------------------------------------------------------
878
879    #[test]
880    fn build_button_label_style_declares_the_four_documented_properties_unconditionally() {
881        let v = CssPropertyWithConditionsVec::from_vec(build_button_label_style());
882        assert_eq!(v.len(), 4, "label style gained/lost a property: {:?}", properties(&v));
883
884        assert_eq!(font_size_px(&v), Some(14.0), "wrong label font size");
885
886        let align = v.as_ref().iter().find_map(|p| match &p.property {
887            CssProperty::TextAlign(t) => t.get_property().copied(),
888            _ => None,
889        });
890        assert_eq!(align, Some(StyleTextAlign::Center), "a button label must be centred");
891
892        let family = v.as_ref().iter().find_map(|p| match &p.property {
893            CssProperty::FontFamily(f) => f.get_property().cloned(),
894            _ => None,
895        });
896        let family = family.expect("label style declares no font-family");
897        assert_eq!(
898            family.as_ref(),
899            [StyleFontFamily::SystemType(SystemFontType::Ui)].as_slice(),
900            "the label must use the system UI font",
901        );
902
903        let user_select = v.as_ref().iter().find_map(|p| match &p.property {
904            CssProperty::UserSelect(u) => u.get_property().copied(),
905            _ => None,
906        });
907        assert_eq!(user_select, Some(StyleUserSelect::None), "a button label must not be text-selectable");
908
909        // Every declaration is unconditional — a stray `:hover` here would make the
910        // label font/size flicker on mouse-over.
911        assert!(v.as_ref().iter().all(|p| p.apply_if.as_ref().is_empty()), "label style must be unconditional");
912    }
913
914    #[test]
915    fn build_button_label_style_is_pure() {
916        assert_eq!(build_button_label_style(), build_button_label_style(), "label style is not deterministic");
917    }
918
919    // ------------------------------------------------------------------
920    // Button::create / Button::with_type  (constructors)
921    // ------------------------------------------------------------------
922
923    #[test]
924    fn create_is_exactly_with_type_default() {
925        for label in adversarial_labels() {
926            let a = Button::create(AzString::from(label.as_str()));
927            let b = Button::with_type(AzString::from(label.as_str()), ButtonType::Default);
928            assert_eq!(a, b, "create() diverged from with_type(_, Default) for a {}-byte label", label.len());
929            assert_eq!(a.button_type, ButtonType::Default);
930        }
931    }
932
933    #[test]
934    fn with_type_holds_its_post_construction_invariants_for_every_type() {
935        for ty in ALL_TYPES {
936            for label in adversarial_labels() {
937                let b = btn(&label, ty);
938
939                // Fields match the arguments, byte for byte (a NUL must not truncate).
940                assert_eq!(b.label.as_str(), label.as_str(), "{ty:?}: label was mangled");
941                assert_eq!(b.label.as_str().len(), label.len(), "{ty:?}: label length changed");
942                assert_eq!(b.button_type, ty, "{ty:?}: button_type field does not match the argument");
943
944                // Optionals start empty.
945                assert!(b.image.is_none(), "{ty:?}: a freshly built button must have no image");
946                assert!(b.on_click.is_none(), "{ty:?}: a freshly built button must have no callback");
947
948                // Styles are the ones the builders produce, and the vec lengths are
949                // consistent with what was handed to `from_vec`.
950                let container = build_button_container_style(ty);
951                let label_style = build_button_label_style();
952                assert_eq!(b.container_style.len(), container.len(), "{ty:?}: container_style length is inconsistent");
953                assert_eq!(b.container_style.as_ref(), container.as_slice(), "{ty:?}: container_style does not match the builder");
954                assert_eq!(b.label_style.as_ref(), label_style.as_slice(), "{ty:?}: label_style does not match the builder");
955                // `with_type` deliberately reuses the label style for the image.
956                assert_eq!(b.image_style.as_ref(), b.label_style.as_ref(), "{ty:?}: image_style diverged from label_style");
957            }
958        }
959    }
960
961    #[test]
962    fn with_type_survives_a_multi_megabyte_label() {
963        // 4 MiB of label: no quadratic copy, no truncation, no panic.
964        let huge = "\u{1F600}".repeat(1_000_000); // 4 bytes/char
965        let b = btn(&huge, ButtonType::Danger);
966        assert_eq!(b.label.as_str().len(), 4_000_000);
967        assert_eq!(b.label.as_str(), huge.as_str());
968    }
969
970    #[test]
971    fn buttons_are_cloneable_and_clones_compare_equal() {
972        for ty in ALL_TYPES {
973            let b = btn("Clone me", ty);
974            let c = b.clone();
975            assert_eq!(b, c, "{ty:?}: clone() produced a different button");
976            assert_eq!(format!("{b:?}"), format!("{c:?}"), "{ty:?}: Debug output diverged between clones");
977        }
978    }
979
980    // ------------------------------------------------------------------
981    // Button::set_button_type / with_button_type
982    // ------------------------------------------------------------------
983
984    #[test]
985    fn set_button_type_updates_both_the_field_and_the_container_style() {
986        let mut b = btn("Save", ButtonType::Default);
987        for ty in ALL_TYPES {
988            b.set_button_type(ty);
989            assert_eq!(b.button_type, ty, "{ty:?}: field not updated");
990            assert_eq!(
991                b.container_style.as_ref(),
992                build_button_container_style(ty).as_slice(),
993                "{ty:?}: container_style was not rebuilt for the new type",
994            );
995        }
996    }
997
998    #[test]
999    fn set_button_type_is_idempotent_and_never_accumulates_style() {
1000        // Re-setting the same type must *replace*, never append: an appending
1001        // implementation would grow the style vec without bound.
1002        let mut b = btn("Save", ButtonType::Primary);
1003        let len = b.container_style.len();
1004        for _ in 0..100 {
1005            b.set_button_type(ButtonType::Primary);
1006        }
1007        assert_eq!(b.container_style.len(), len, "container_style grew across repeated set_button_type calls");
1008        assert_eq!(b, btn("Save", ButtonType::Primary), "set_button_type(same) is not idempotent");
1009    }
1010
1011    #[test]
1012    fn set_button_type_leaves_the_label_and_the_other_styles_untouched() {
1013        let mut b = btn("Delete", ButtonType::Default);
1014        let label_style = b.label_style.clone();
1015        let image_style = b.image_style.clone();
1016        b.set_button_type(ButtonType::Danger);
1017        assert_eq!(b.label.as_str(), "Delete", "set_button_type clobbered the label");
1018        assert_eq!(b.label_style, label_style, "set_button_type clobbered label_style");
1019        assert_eq!(b.image_style, image_style, "set_button_type clobbered image_style");
1020    }
1021
1022    #[test]
1023    fn with_button_type_round_trips_to_with_type() {
1024        // create(l).with_button_type(t) must be indistinguishable from with_type(l, t).
1025        for ty in ALL_TYPES {
1026            for label in ["", "OK", "\u{1F600}\0"] {
1027                let built = Button::create(AzString::from(label)).with_button_type(ty);
1028                let direct = Button::with_type(AzString::from(label), ty);
1029                assert_eq!(built, direct, "{ty:?}: builder path diverged from with_type for {label:?}");
1030            }
1031        }
1032    }
1033
1034    #[test]
1035    fn with_button_type_chains_take_the_last_type() {
1036        let b = btn("x", ButtonType::Default)
1037            .with_button_type(ButtonType::Primary)
1038            .with_button_type(ButtonType::Link)
1039            .with_button_type(ButtonType::Warning);
1040        assert_eq!(b.button_type, ButtonType::Warning);
1041        assert_eq!(b, btn("x", ButtonType::Warning));
1042    }
1043
1044    // ------------------------------------------------------------------
1045    // Button::swap_with_default
1046    // ------------------------------------------------------------------
1047
1048    #[test]
1049    fn swap_with_default_returns_the_original_and_leaves_a_default_button_behind() {
1050        let mut b = btn("Delete", ButtonType::Danger);
1051        b.set_image(ImageRef::null_image(1, 1, RawImageFormat::RGBA8, Vec::new()));
1052        b.set_on_click(RefAny::new(7u32), test_click as ButtonOnClickCallbackType);
1053
1054        let taken = b.swap_with_default();
1055
1056        // The returned value is the old button, whole.
1057        assert_eq!(taken.label.as_str(), "Delete");
1058        assert_eq!(taken.button_type, ButtonType::Danger);
1059        assert!(taken.image.is_some(), "the image did not travel with the swapped-out button");
1060        assert!(taken.on_click.is_some(), "the callback did not travel with the swapped-out button");
1061
1062        // ... and what is left behind is a pristine empty Default button.
1063        assert_eq!(b.label.as_str(), "");
1064        assert_eq!(b.button_type, ButtonType::Default);
1065        assert!(b.image.is_none(), "the swapped-in default still carries an image");
1066        assert!(b.on_click.is_none(), "the swapped-in default still carries a callback");
1067        assert_eq!(b, Button::create(AzString::from("")), "swap_with_default left a non-default button");
1068    }
1069
1070    #[test]
1071    fn swap_with_default_is_stable_under_repetition() {
1072        // Repeated swapping must not double-free or drift: after the first call the
1073        // button is already default, so every further call is a no-op swap.
1074        let mut b = btn("x", ButtonType::Info);
1075        let first = b.swap_with_default();
1076        assert_eq!(first.label.as_str(), "x");
1077        for _ in 0..1000 {
1078            let taken = b.swap_with_default();
1079            assert_eq!(taken, Button::create(AzString::from("")));
1080            assert_eq!(b, Button::create(AzString::from("")));
1081        }
1082    }
1083
1084    // ------------------------------------------------------------------
1085    // Button::set_image
1086    // ------------------------------------------------------------------
1087
1088    #[test]
1089    fn set_image_stores_the_image_and_replaces_a_previous_one() {
1090        let mut b = btn("With icon", ButtonType::Primary);
1091        assert!(b.image.is_none());
1092
1093        b.set_image(ImageRef::null_image(16, 16, RawImageFormat::RGBA8, Vec::new()));
1094        assert!(b.image.is_some(), "set_image did not store the image");
1095        let first_id = b.image.as_ref().map(|i| i.id).expect("image must be present");
1096
1097        b.set_image(ImageRef::null_image(32, 32, RawImageFormat::RGB8, Vec::new()));
1098        let second_id = b.image.as_ref().map(|i| i.id).expect("image must be present");
1099        assert_ne!(first_id, second_id, "set_image did not replace the previous image");
1100    }
1101
1102    #[test]
1103    fn set_image_accepts_degenerate_and_extreme_dimensions() {
1104        // A null image carries only its metadata, so these must not allocate,
1105        // overflow (`w * h * bpp`) or panic.
1106        let extremes = [
1107            (0usize, 0usize),
1108            (0, 4096),
1109            (4096, 0),
1110            (1, usize::MAX),
1111            (usize::MAX, usize::MAX),
1112        ];
1113        for (w, h) in extremes {
1114            let mut b = btn("x", ButtonType::Default);
1115            b.set_image(ImageRef::null_image(w, h, RawImageFormat::RGBA8, Vec::new()));
1116            assert!(b.image.is_some(), "{w}x{h}: image was dropped");
1117            let dom = b.dom();
1118            assert_eq!(dom.children.as_ref().len(), 2, "{w}x{h}: expected an image child and a label child");
1119        }
1120    }
1121
1122    // ------------------------------------------------------------------
1123    // Button::set_on_click / with_on_click
1124    // ------------------------------------------------------------------
1125
1126    #[test]
1127    fn set_on_click_stores_the_callback_and_replaces_a_previous_one() {
1128        let mut b = btn("Click", ButtonType::Primary);
1129        assert!(b.on_click.is_none());
1130
1131        b.set_on_click(RefAny::new(1u32), test_click as ButtonOnClickCallbackType);
1132        assert!(b.on_click.is_some(), "set_on_click did not store the callback");
1133
1134        b.set_on_click(RefAny::new(2u32), other_click as ButtonOnClickCallbackType);
1135        let stored = b.on_click.as_ref().expect("callback must be present");
1136        assert_eq!(
1137            stored.callback.cb as *const () as usize,
1138            other_click as ButtonOnClickCallbackType as *const () as usize,
1139            "the second set_on_click did not replace the first",
1140        );
1141
1142        // ... and it replaced rather than accumulated: the DOM still fires once.
1143        let dom = b.dom();
1144        assert_eq!(dom.root.callbacks.as_ref().len(), 1, "a re-set callback was appended instead of replaced");
1145    }
1146
1147    #[test]
1148    fn with_on_click_round_trips_the_function_pointer_and_the_payload_into_the_dom() {
1149        let cb: ButtonOnClickCallbackType = test_click;
1150        let expected_ptr = cb as *const () as usize;
1151
1152        let dom = btn("Click", ButtonType::Success).with_on_click(RefAny::new(0xDEAD_BEEF_u32), cb).dom();
1153
1154        let callbacks = dom.root.callbacks.as_ref();
1155        assert_eq!(callbacks.len(), 1, "exactly one click callback is expected");
1156        assert_eq!(
1157            callbacks[0].event,
1158            EventFilter::Hover(HoverEventFilter::MouseUp),
1159            "the button must fire on mouse-up, not on any other filter",
1160        );
1161        assert_eq!(callbacks[0].callback.cb, expected_ptr, "the fn pointer was corrupted on the way into the DOM");
1162
1163        // The RefAny payload survives the move into the DOM (shared, not copied).
1164        let mut data = callbacks[0].refany.clone();
1165        assert_eq!(*data.downcast_ref::<u32>().expect("payload changed type"), 0xDEAD_BEEF, "payload was corrupted");
1166        assert!(data.downcast_ref::<u64>().is_none(), "downcast to the wrong type must fail, not reinterpret");
1167    }
1168
1169    #[test]
1170    fn with_on_click_accepts_a_generic_callback_without_mangling_the_pointer() {
1171        // The `From<Callback>` arm transmutes the fn pointer — this is the FFI path
1172        // (Python/C) into the same slot, so the pointer must come out untouched.
1173        let generic = Callback {
1174            cb: test_click,
1175            ctx: azul_core::refany::OptionRefAny::None,
1176        };
1177        let raw: ButtonOnClickCallbackType = test_click;
1178        let expected_ptr = raw as *const () as usize;
1179
1180        let dom = btn("Click", ButtonType::Info).with_on_click(RefAny::new(1u8), generic).dom();
1181        let callbacks = dom.root.callbacks.as_ref();
1182        assert_eq!(callbacks.len(), 1);
1183        assert_eq!(callbacks[0].callback.cb, expected_ptr, "the Callback -> ButtonOnClickCallback transmute mangled the pointer");
1184    }
1185
1186    #[test]
1187    fn a_button_without_a_callback_registers_no_callbacks() {
1188        for ty in ALL_TYPES {
1189            let dom = btn("Inert", ty).dom();
1190            assert!(dom.root.callbacks.as_ref().is_empty(), "{ty:?}: a callback appeared out of nowhere");
1191        }
1192    }
1193
1194    // ------------------------------------------------------------------
1195    // Button::dom
1196    // ------------------------------------------------------------------
1197
1198    #[test]
1199    fn dom_builds_a_focusable_button_node_with_the_base_and_type_class() {
1200        for ty in ALL_TYPES {
1201            let dom = btn("OK", ty).dom();
1202
1203            assert!(matches!(dom.root.get_node_type(), NodeType::Button), "{ty:?}: root is not a Button node");
1204            assert_eq!(
1205                dom.root.flags.get_tab_index(),
1206                Some(TabIndex::Auto),
1207                "{ty:?}: the button is not keyboard-focusable",
1208            );
1209            assert_eq!(
1210                classes(&dom),
1211                vec!["__azul-native-button".to_string(), ty.class_name().to_string()],
1212                "{ty:?}: wrong classes (base class first, then the type class)",
1213            );
1214            assert!(!dom.root.style.is_empty(), "{ty:?}: the container style did not reach the node");
1215        }
1216    }
1217
1218    #[test]
1219    fn dom_carries_the_container_style_on_the_root_and_the_label_style_on_the_child() {
1220        for ty in ALL_TYPES {
1221            let b = btn("OK", ty);
1222            let container = properties(&b.container_style);
1223            let label_style = properties(&b.label_style);
1224            let dom = b.dom();
1225
1226            assert_eq!(inline_properties(&dom), container, "{ty:?}: the root inline style is not the container style");
1227
1228            let children = dom.children.as_ref();
1229            assert_eq!(children.len(), 1, "{ty:?}: an image-less button is a Button node with exactly one text child");
1230            assert_eq!(text_of(&children[0]), Some("OK"), "{ty:?}: the label was mangled");
1231            assert_eq!(inline_properties(&children[0]), label_style, "{ty:?}: the label style is not on the label node");
1232        }
1233    }
1234
1235    #[test]
1236    fn dom_puts_the_image_before_the_label_and_keeps_the_child_count_cache_honest() {
1237        let mut b = btn("Save", ButtonType::Primary);
1238        b.set_image(ImageRef::null_image(16, 16, RawImageFormat::RGBA8, Vec::new()));
1239        let image_style = properties(&b.image_style);
1240        let dom = b.dom();
1241
1242        let children = dom.children.as_ref();
1243        assert_eq!(children.len(), 2, "an image button renders the image and the label");
1244        assert!(matches!(children[0].root.get_node_type(), NodeType::Image(_)), "the image must come first (left of the label)");
1245        assert_eq!(text_of(&children[1]), Some("Save"), "the label must be the second child");
1246        assert_eq!(inline_properties(&children[0]), image_style, "the image style is not on the image node");
1247
1248        // `estimated_total_children` is a cache that, if wrong, makes the compact-DOM
1249        // conversion under-allocate its arenas and write out of bounds.
1250        assert_eq!(
1251            dom.estimated_total_children,
1252            count_descendants(&dom),
1253            "estimated_total_children is out of sync with the real subtree",
1254        );
1255    }
1256
1257    #[test]
1258    fn dom_child_count_cache_is_honest_without_an_image_too() {
1259        for ty in ALL_TYPES {
1260            let dom = btn("x", ty).dom();
1261            assert_eq!(dom.estimated_total_children, count_descendants(&dom), "{ty:?}: stale estimated_total_children");
1262            assert_eq!(dom.estimated_total_children, 1, "{ty:?}: an image-less button has exactly one descendant");
1263        }
1264    }
1265
1266    #[test]
1267    fn dom_preserves_adversarial_labels_verbatim() {
1268        for label in adversarial_labels() {
1269            let dom = btn(&label, ButtonType::Default).dom();
1270            let children = dom.children.as_ref();
1271            assert_eq!(children.len(), 1);
1272            let text = text_of(&children[0]).expect("the label child is not a text node");
1273            assert_eq!(text, label.as_str(), "a {}-byte label was mangled", label.len());
1274            assert_eq!(text.len(), label.len(), "a NUL or a wide char truncated the label");
1275        }
1276    }
1277
1278    #[test]
1279    fn dom_of_a_button_whose_label_looks_like_a_class_name_does_not_leak_into_the_classes() {
1280        // The label is user data — it must never be able to add a CSS class.
1281        let dom = btn("__azul-btn-danger", ButtonType::Primary).dom();
1282        assert_eq!(
1283            classes(&dom),
1284            vec!["__azul-native-button".to_string(), "__azul-btn-primary".to_string()],
1285            "the label leaked into the class list",
1286        );
1287    }
1288
1289    #[test]
1290    fn dom_renders_the_type_the_button_was_last_set_to() {
1291        for ty in ALL_TYPES {
1292            let dom = btn("x", ButtonType::Default).with_button_type(ty).dom();
1293            assert!(
1294                classes(&dom).contains(&ty.class_name().to_string()),
1295                "{ty:?}: the DOM still carries the old type class",
1296            );
1297        }
1298    }
1299
1300    #[test]
1301    fn dom_is_deterministic_across_identical_buttons() {
1302        // Same inputs, same tree: the node type, classes and inline styles must all
1303        // be reproducible (only the ImageRef/RefAny identities may differ).
1304        for ty in ALL_TYPES {
1305            let a = btn("Same", ty).dom();
1306            let b = btn("Same", ty).dom();
1307            assert_eq!(a.root.get_node_type(), b.root.get_node_type(), "{ty:?}: node type differs");
1308            assert_eq!(classes(&a), classes(&b), "{ty:?}: classes differ");
1309            assert_eq!(inline_properties(&a), inline_properties(&b), "{ty:?}: inline style differs");
1310            assert_eq!(a.children.as_ref().len(), b.children.as_ref().len(), "{ty:?}: child count differs");
1311        }
1312    }
1313}