Skip to main content

azul_layout/widgets/
card.rs

1//! Card widget — an elevated, bordered content container with rounded corners,
2//! a soft drop shadow and padding, holding arbitrary child content. A near-clone
3//! of [`crate::widgets::frame::Frame`] (a container) but without the fieldset
4//! title/header — just a single styled box wrapping the body content.
5//!
6//! Key types: [`Card`], [`CardOnClick`].
7
8use azul_core::{
9    callbacks::Update,
10    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec},
11    refany::RefAny,
12};
13use azul_css::css::BoxOrStatic;
14use azul_css::{
15    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
16    props::{
17        basic::{ColorU, PixelValueNoPercent, PixelValue, FloatValue},
18        layout::{LayoutDisplay, LayoutFlexDirection, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutFlexGrow},
19        property::{CssProperty, StyleBoxShadowValue, LayoutFlexGrowValue},
20        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleBoxShadow, BoxShadowClipMode, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius},
21    },
22    impl_option_inner, AzString,
23};
24
25use crate::callbacks::CallbackInfo;
26
27/// Card border colour (#dee2e6).
28const CARD_BORDER_COLOR: ColorU = ColorU {
29    r: 222,
30    g: 226,
31    b: 230,
32    a: 255,
33};
34/// Card background colour (white).
35const CARD_BG_COLOR: ColorU = ColorU {
36    r: 255,
37    g: 255,
38    b: 255,
39    a: 255,
40};
41/// Soft drop-shadow colour (black @ ~15% alpha).
42const CARD_SHADOW_COLOR: ColorU = ColorU {
43    r: 0,
44    g: 0,
45    b: 0,
46    a: 38,
47};
48
49const CARD_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(CARD_BG_COLOR)];
50const CARD_BG: StyleBackgroundContentVec =
51    StyleBackgroundContentVec::from_const_slice(CARD_BG_ITEMS);
52
53/// Shared drop-shadow descriptor referenced by all four edge box-shadows.
54static CARD_SHADOW: StyleBoxShadow = StyleBoxShadow {
55    offset_x: PixelValueNoPercent {
56        inner: PixelValue::const_px(0),
57    },
58    offset_y: PixelValueNoPercent {
59        inner: PixelValue::const_px(2),
60    },
61    blur_radius: PixelValueNoPercent {
62        inner: PixelValue::const_px(6),
63    },
64    spread_radius: PixelValueNoPercent {
65        inner: PixelValue::const_px(0),
66    },
67    clip_mode: BoxShadowClipMode::Outset,
68    color: CARD_SHADOW_COLOR,
69};
70
71const CARD_STYLE: &[CssPropertyWithConditions] = &[
72    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
73    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
74        LayoutFlexDirection::Column,
75    )),
76    CssPropertyWithConditions::simple(CssProperty::const_background_content(CARD_BG)),
77    // padding: 12px
78    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
79        12,
80    ))),
81    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
82        LayoutPaddingBottom::const_px(12),
83    )),
84    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
85        12,
86    ))),
87    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
88        LayoutPaddingRight::const_px(12),
89    )),
90    // border: 1px solid #dee2e6
91    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
92        LayoutBorderTopWidth::const_px(1),
93    )),
94    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
95        LayoutBorderBottomWidth::const_px(1),
96    )),
97    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
98        LayoutBorderLeftWidth::const_px(1),
99    )),
100    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
101        LayoutBorderRightWidth::const_px(1),
102    )),
103    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
104        inner: BorderStyle::Solid,
105    })),
106    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
107        StyleBorderBottomStyle {
108            inner: BorderStyle::Solid,
109        },
110    )),
111    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
112        inner: BorderStyle::Solid,
113    })),
114    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
115        StyleBorderRightStyle {
116            inner: BorderStyle::Solid,
117        },
118    )),
119    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
120        inner: CARD_BORDER_COLOR,
121    })),
122    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
123        StyleBorderBottomColor {
124            inner: CARD_BORDER_COLOR,
125        },
126    )),
127    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
128        inner: CARD_BORDER_COLOR,
129    })),
130    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
131        StyleBorderRightColor {
132            inner: CARD_BORDER_COLOR,
133        },
134    )),
135    // border-radius: 8px
136    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
137        StyleBorderTopLeftRadius::const_px(8),
138    )),
139    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
140        StyleBorderTopRightRadius::const_px(8),
141    )),
142    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
143        StyleBorderBottomLeftRadius::const_px(8),
144    )),
145    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
146        StyleBorderBottomRightRadius::const_px(8),
147    )),
148    // soft drop shadow on all four edges
149    CssPropertyWithConditions::simple(CssProperty::BoxShadowTop(StyleBoxShadowValue::Exact(
150        BoxOrStatic::Static(&raw const CARD_SHADOW),
151    ))),
152    CssPropertyWithConditions::simple(CssProperty::BoxShadowBottom(StyleBoxShadowValue::Exact(
153        BoxOrStatic::Static(&raw const CARD_SHADOW),
154    ))),
155    CssPropertyWithConditions::simple(CssProperty::BoxShadowLeft(StyleBoxShadowValue::Exact(
156        BoxOrStatic::Static(&raw const CARD_SHADOW),
157    ))),
158    CssPropertyWithConditions::simple(CssProperty::BoxShadowRight(StyleBoxShadowValue::Exact(
159        BoxOrStatic::Static(&raw const CARD_SHADOW),
160    ))),
161];
162
163/// An elevated, bordered content container with rounded corners, a soft drop
164/// shadow and padding. Holds arbitrary child content (`content`).
165#[derive(Debug, Clone, PartialEq)]
166#[repr(C)]
167pub struct Card {
168    /// The body content rendered inside the card.
169    pub content: Dom,
170    /// `flex-grow` factor applied to the card container.
171    pub flex_grow: f32,
172    /// Optional: Function to call when the card is clicked
173    pub on_click: OptionCardOnClick,
174}
175
176/// Callback function type invoked when the card container is clicked.
177pub type CardOnClickCallbackType = extern "C" fn(RefAny, CallbackInfo) -> Update;
178impl_widget_callback!(
179    CardOnClick,
180    OptionCardOnClick,
181    CardOnClickCallback,
182    CardOnClickCallbackType
183);
184
185// Host-invoker plumbing for managed-FFI bindings — see core/src/host_invoker.rs.
186azul_core::impl_managed_callback! {
187    wrapper:        CardOnClickCallback,
188    info_ty:        CallbackInfo,
189    return_ty:      Update,
190    default_ret:    Update::DoNothing,
191    invoker_static: CARD_ON_CLICK_INVOKER,
192    invoker_ty:     AzCardOnClickCallbackInvoker,
193    thunk_fn:       az_card_on_click_callback_thunk,
194    setter_fn:      AzApp_setCardOnClickCallbackInvoker,
195    from_handle_fn: AzCardOnClickCallback_createFromHostHandle,
196}
197
198impl Card {
199    /// Creates a new `Card` wrapping the given content DOM.
200    #[must_use] pub const fn create(content: Dom) -> Self {
201        Self {
202            content,
203            flex_grow: 0.0,
204            on_click: OptionCardOnClick::None,
205        }
206    }
207
208    /// Replaces `self` with an empty default card and returns the original.
209    #[must_use] pub const fn swap_with_default(&mut self) -> Self {
210        let mut s = Self::create(Dom::create_div());
211        core::mem::swap(&mut s, self);
212        s
213    }
214
215    /// Sets the body content.
216    pub fn set_content(&mut self, content: Dom) {
217        self.content = content;
218    }
219
220    /// Builder-style setter for the body content.
221    #[must_use] pub fn with_content(mut self, content: Dom) -> Self {
222        self.set_content(content);
223        self
224    }
225
226    /// Sets the flex-grow factor for the card container.
227    pub const fn set_flex_grow(&mut self, flex_grow: f32) {
228        self.flex_grow = flex_grow;
229    }
230
231    /// Builder-style setter for the flex-grow factor.
232    #[must_use] pub const fn with_flex_grow(mut self, flex_grow: f32) -> Self {
233        self.set_flex_grow(flex_grow);
234        self
235    }
236
237    /// Sets the click callback, invoked when the card container is clicked.
238    pub fn set_on_click<C: Into<CardOnClickCallback>>(&mut self, data: RefAny, on_click: C) {
239        self.on_click = Some(CardOnClick {
240            refany: data,
241            callback: on_click.into(),
242        })
243        .into();
244    }
245
246    /// Builder-style setter for the click callback.
247    #[must_use] pub fn with_on_click<C: Into<CardOnClickCallback>>(
248        mut self,
249        data: RefAny,
250        on_click: C,
251    ) -> Self {
252        self.set_on_click(data, on_click);
253        self
254    }
255
256    #[must_use] pub fn dom(self) -> Dom {
257        use azul_core::{
258            callbacks::{CoreCallback, CoreCallbackData},
259            dom::{EventFilter, HoverEventFilter},
260        };
261
262        static CARD_CLASS: &[IdOrClass] =
263            &[Class(AzString::from_const_str("__azul-native-card"))];
264
265        // Optional click callback on the card's root container (same wiring
266        // as button's on_click).
267        let callbacks = match self.on_click.into_option() {
268            Some(CardOnClick {
269                refany: data,
270                callback,
271            }) => vec![CoreCallbackData {
272                event: EventFilter::Hover(HoverEventFilter::MouseUp),
273                callback: CoreCallback {
274                    cb: callback.cb as *const () as usize,
275                    ctx: callback.ctx,
276                },
277                refany: data,
278            }],
279            None => Vec::new(),
280        };
281
282        // Prepend the (param-dependent) flex-grow, then the static card style.
283        let mut props = vec![CssPropertyWithConditions::simple(CssProperty::FlexGrow(
284            LayoutFlexGrowValue::Exact(LayoutFlexGrow {
285                inner: FloatValue::new(self.flex_grow),
286            }),
287        ))];
288        props.extend_from_slice(CARD_STYLE);
289
290        Dom::create_div()
291            .with_ids_and_classes(IdOrClassVec::from_const_slice(CARD_CLASS))
292            .with_css_props(CssPropertyWithConditionsVec::from_vec(props))
293            .with_callbacks(callbacks.into())
294            .with_children(vec![self.content].into())
295    }
296}
297
298impl Default for Card {
299    fn default() -> Self {
300        Self::create(Dom::create_div())
301    }
302}
303
304impl From<Card> for Dom {
305    fn from(c: Card) -> Self {
306        c.dom()
307    }
308}
309
310#[cfg(test)]
311#[allow(
312    clippy::float_cmp,
313    clippy::cast_precision_loss,
314    clippy::unreadable_literal,
315    clippy::too_many_lines
316)]
317mod autotest_generated {
318    use azul_core::dom::{EventFilter, HoverEventFilter, NodeType};
319    use azul_css::props::basic::length::SizeMetric;
320
321    use super::*;
322    use crate::callbacks::Callback;
323
324    // ------------------------------------------------------------------
325    // Helpers
326    // ------------------------------------------------------------------
327
328    extern "C" fn click_a(_data: RefAny, _info: CallbackInfo) -> Update {
329        Update::DoNothing
330    }
331
332    extern "C" fn click_b(_data: RefAny, _info: CallbackInfo) -> Update {
333        Update::RefreshDom
334    }
335
336    /// Content DOMs a caller can realistically hand to a card: empty text, a NUL byte,
337    /// combining marks, a ZWJ emoji sequence, RTL text and a lone surrogate-ish escape.
338    /// The card must carry all of them through to the DOM byte-for-byte — it never parses
339    /// or normalises its content.
340    const ADVERSARIAL_TEXT: [&str; 6] = [
341        "",
342        "a\0b",
343        "e\u{0301}\u{0301}\u{0301}",
344        "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}",
345        "\u{202E}gnirts desrever\u{202C}",
346        "\u{FFFD}\u{FEFF}\t\n",
347    ];
348
349    /// Every f32 the numeric surface of `set_flex_grow` has to survive. `FloatValue::new`
350    /// multiplies by 1000 and casts to `isize` — NaN, the infinities and `f32::MAX` all
351    /// hit the saturating-cast path, and anything under 0.001 truncates away.
352    const ADVERSARIAL_FLOATS: [f32; 14] = [
353        0.0,
354        -0.0,
355        1.0,
356        -1.0,
357        0.001,
358        -0.001,
359        f32::EPSILON,
360        f32::MIN_POSITIVE,
361        -f32::MIN_POSITIVE,
362        f32::MAX,
363        f32::MIN,
364        f32::INFINITY,
365        f32::NEG_INFINITY,
366        f32::NAN,
367    ];
368
369    /// The declared properties of a rendered node's inline style, in declaration order.
370    fn inline_props(dom: &Dom) -> Vec<CssProperty> {
371        dom.root
372            .style
373            .iter_inline_properties()
374            .map(|(p, _)| p.clone())
375            .collect()
376    }
377
378    /// The CSS classes of a rendered node, in declaration order.
379    fn classes(dom: &Dom) -> Vec<String> {
380        dom.root
381            .get_ids_and_classes()
382            .as_ref()
383            .iter()
384            .filter_map(|c| match c {
385                IdOrClass::Class(s) => Some(s.as_str().to_string()),
386                IdOrClass::Id(_) => None,
387            })
388            .collect()
389    }
390
391    /// The `flex-grow` factor as it actually lands in the style tree — i.e. *after* the
392    /// lossy `f32 -> isize` encoding inside `FloatValue::new`.
393    fn dom_flex_grow(dom: &Dom) -> Option<f32> {
394        dom.root
395            .style
396            .iter_inline_properties()
397            .find_map(|(p, _)| match p {
398                CssProperty::FlexGrow(v) => v.get_property().map(|f| f.inner.get()),
399                _ => None,
400            })
401    }
402
403    /// The `f32` of a `PixelValue`, asserting the length is an absolute `px`. An `em`/`%`
404    /// slipping into the card geometry would resolve against the parent font/box instead
405    /// of the intended fixed padding, border or radius.
406    fn px(pv: &PixelValue) -> f32 {
407        assert_eq!(
408            pv.metric,
409            SizeMetric::Px,
410            "card geometry must be absolute px, got {:?}",
411            pv.metric
412        );
413        pv.number.get()
414    }
415
416    /// The recursive descendant count. `Dom::estimated_total_children` is a *cached* value
417    /// that, if too small, makes `convert_dom_into_compact_dom` under-allocate its arenas
418    /// and panic on out-of-bounds writes — so it has to match this exactly.
419    fn count_descendants(dom: &Dom) -> usize {
420        dom.children
421            .as_ref()
422            .iter()
423            .map(|c| 1 + count_descendants(c))
424            .sum()
425    }
426
427    /// A chain of `depth` nested divs (depth kept modest: `Dom`'s `Drop`/`PartialEq` recurse).
428    fn nested_divs(depth: usize) -> Dom {
429        let mut d = Dom::create_div();
430        for _ in 0..depth {
431            d = Dom::create_div().with_child(d);
432        }
433        d
434    }
435
436    fn text_of(dom: &Dom) -> Option<&str> {
437        match dom.root.get_node_type() {
438            NodeType::Text(s) => Some(s.as_ref().as_str()),
439            _ => None,
440        }
441    }
442
443    // ------------------------------------------------------------------
444    // Card::create
445    // ------------------------------------------------------------------
446
447    #[test]
448    fn create_zeroes_the_numeric_field_and_leaves_the_callback_unset() {
449        let c = Card::create(Dom::create_div());
450
451        // Positive zero, not -0.0: a negative zero would flip the sign of the encoded
452        // isize on some paths and is not what "no growth" means.
453        assert_eq!(c.flex_grow.to_bits(), 0_u32, "flex_grow must start at +0.0");
454        assert!(c.on_click.is_none(), "a fresh card must have no callback");
455        assert_eq!(c.content, Dom::create_div(), "the content was not stored verbatim");
456    }
457
458    #[test]
459    fn create_with_a_div_is_exactly_the_default_card() {
460        assert_eq!(
461            Card::create(Dom::create_div()),
462            Card::default(),
463            "Default and create(div) drifted apart",
464        );
465    }
466
467    #[test]
468    fn create_stores_pathological_content_verbatim() {
469        for t in ADVERSARIAL_TEXT {
470            let c = Card::create(Dom::create_text(t));
471            assert_eq!(
472                text_of(&c.content),
473                Some(t),
474                "the card mangled or normalised its text content",
475            );
476
477            // ... and it survives the trip into the rendered DOM.
478            let dom = c.dom();
479            let child = &dom.children.as_ref()[0];
480            assert_eq!(text_of(child), Some(t), "the text was corrupted on the way into the DOM");
481        }
482    }
483
484    #[test]
485    fn create_accepts_deeply_nested_and_very_wide_content() {
486        let deep = Card::create(nested_divs(64)).dom();
487        assert_eq!(
488            deep.estimated_total_children,
489            count_descendants(&deep),
490            "the cached child count desynced for deeply nested content",
491        );
492
493        let wide = Card::create(
494            Dom::create_div().with_children((0..2000).map(|_| Dom::create_div()).collect::<Vec<_>>().into()),
495        )
496        .dom();
497        assert_eq!(
498            wide.estimated_total_children,
499            count_descendants(&wide),
500            "the cached child count desynced for very wide content",
501        );
502        assert_eq!(wide.estimated_total_children, 2001, "card div + 2000 grandchildren expected");
503    }
504
505    // ------------------------------------------------------------------
506    // Card::swap_with_default
507    // ------------------------------------------------------------------
508
509    #[test]
510    fn swap_with_default_moves_every_field_out_and_leaves_a_default() {
511        let mut c = Card::create(Dom::create_text("payload")).with_flex_grow(2.5);
512        c.set_on_click(RefAny::new(7u32), click_a as CardOnClickCallbackType);
513
514        let taken = c.swap_with_default();
515
516        assert_eq!(text_of(&taken.content), Some("payload"), "the content did not travel out");
517        assert_eq!(taken.flex_grow, 2.5, "flex_grow did not travel out");
518        assert!(taken.on_click.is_some(), "the callback did not travel out");
519
520        assert_eq!(c, Card::default(), "what was left behind is not a default card");
521        assert!(c.on_click.is_none(), "the swapped-in default still carries a callback");
522        assert_eq!(c.flex_grow.to_bits(), 0_u32, "the swapped-in default has a non-zero flex_grow");
523    }
524
525    #[test]
526    fn repeated_swap_with_default_never_accumulates_state() {
527        let mut c = Card::create(Dom::create_text("x")).with_flex_grow(1.0);
528        let _first = c.swap_with_default();
529
530        for i in 0..8 {
531            let taken = c.swap_with_default();
532            assert_eq!(taken, Card::default(), "swap #{i} handed back something other than a default");
533            assert_eq!(c, Card::default(), "swap #{i} left something other than a default behind");
534        }
535
536        // The DOM of the drained card is still a well-formed one-child card.
537        let dom = c.dom();
538        assert_eq!(dom.children.as_ref().len(), 1);
539        assert_eq!(dom_flex_grow(&dom), Some(0.0));
540    }
541
542    // ------------------------------------------------------------------
543    // Card::set_content / Card::with_content
544    // ------------------------------------------------------------------
545
546    #[test]
547    fn set_content_replaces_rather_than_appends() {
548        let mut c = Card::create(Dom::create_text("first"));
549        c.set_content(Dom::create_text("second"));
550        c.set_content(Dom::create_text("third"));
551
552        assert_eq!(text_of(&c.content), Some("third"), "the last content did not win");
553
554        let dom = c.dom();
555        assert_eq!(
556            dom.children.as_ref().len(),
557            1,
558            "re-setting the content appended a child instead of replacing it",
559        );
560        assert_eq!(text_of(&dom.children.as_ref()[0]), Some("third"));
561    }
562
563    #[test]
564    fn with_content_touches_only_the_content_field() {
565        let base = Card::create(Dom::create_text("old")).with_flex_grow(3.0);
566        let c = base.with_content(Dom::create_text("new"));
567
568        assert_eq!(text_of(&c.content), Some("new"));
569        assert_eq!(c.flex_grow, 3.0, "with_content clobbered flex_grow");
570        assert!(c.on_click.is_none(), "with_content invented a callback");
571    }
572
573    #[test]
574    fn with_content_preserves_an_already_installed_callback() {
575        let c = Card::default()
576            .with_on_click(RefAny::new(1u8), click_a as CardOnClickCallbackType)
577            .with_content(Dom::create_text("late content"));
578
579        assert!(c.on_click.is_some(), "with_content dropped the callback");
580        let dom = c.dom();
581        assert_eq!(dom.root.callbacks.as_ref().len(), 1, "the callback was lost on the way into the DOM");
582        assert_eq!(text_of(&dom.children.as_ref()[0]), Some("late content"));
583    }
584
585    // ------------------------------------------------------------------
586    // Card::set_flex_grow / Card::with_flex_grow  (numeric)
587    // ------------------------------------------------------------------
588
589    #[test]
590    fn set_flex_grow_stores_the_bit_pattern_verbatim_without_sanitising() {
591        // The setter is a plain assignment — it must not clamp, round or NaN-scrub.
592        // (Sanitisation happens later, at encode time; see the tests below.)
593        for v in ADVERSARIAL_FLOATS {
594            let mut c = Card::default();
595            c.set_flex_grow(v);
596            if v.is_nan() {
597                assert!(c.flex_grow.is_nan(), "a NaN flex_grow was silently rewritten");
598            } else {
599                assert_eq!(c.flex_grow.to_bits(), v.to_bits(), "flex_grow {v} was not stored verbatim");
600            }
601        }
602    }
603
604    #[test]
605    fn with_flex_grow_is_exactly_set_flex_grow() {
606        for v in ADVERSARIAL_FLOATS {
607            let mut by_setter = Card::default();
608            by_setter.set_flex_grow(v);
609            let by_builder = Card::default().with_flex_grow(v);
610
611            assert_eq!(
612                by_setter.flex_grow.to_bits(),
613                by_builder.flex_grow.to_bits(),
614                "the builder and the setter disagree for {v}",
615            );
616        }
617    }
618
619    #[test]
620    fn with_flex_grow_touches_only_the_numeric_field() {
621        let c = Card::create(Dom::create_text("body"))
622            .with_on_click(RefAny::new(1u8), click_a as CardOnClickCallbackType)
623            .with_flex_grow(f32::NAN);
624
625        assert_eq!(text_of(&c.content), Some("body"), "with_flex_grow clobbered the content");
626        assert!(c.on_click.is_some(), "with_flex_grow dropped the callback");
627    }
628
629    #[test]
630    fn a_nan_flex_grow_breaks_the_derived_equality_of_the_card() {
631        // `Card` derives `PartialEq` over a raw `f32`, so a NaN factor makes a card
632        // unequal to its own clone. Callers cannot use `==` as a "was it modified?"
633        // probe once a NaN is in there — pinned so nobody relies on the opposite.
634        let c = Card::default().with_flex_grow(f32::NAN);
635        assert_ne!(c, c.clone(), "NaN equality semantics changed");
636
637        // Every non-NaN factor keeps equality reflexive.
638        for v in ADVERSARIAL_FLOATS.iter().copied().filter(|v| !v.is_nan()) {
639            let c = Card::default().with_flex_grow(v);
640            assert_eq!(c, c.clone(), "a card with flex_grow {v} is not equal to its own clone");
641        }
642    }
643
644    #[test]
645    fn flex_grow_zero_encodes_to_positive_zero_even_from_negative_zero() {
646        for v in [0.0_f32, -0.0_f32] {
647            let dom = Card::default().with_flex_grow(v).dom();
648            let got = dom_flex_grow(&dom).expect("flex-grow must always be declared");
649            assert_eq!(got.to_bits(), 0_u32, "flex_grow {v} did not encode to +0.0 (got {got})");
650        }
651    }
652
653    #[test]
654    fn flex_grow_round_trips_through_the_dom_at_milli_precision() {
655        // FloatValue keeps 3 decimal places (x1000, truncating cast), so any factor that is
656        // a whole multiple of 0.001 must come back out of the DOM unchanged.
657        for v in [0.0_f32, 0.001, 0.5, 1.0, 2.5, 3.0, -1.5, 1000.0, 65536.0] {
658            let got = dom_flex_grow(&Card::default().with_flex_grow(v).dom())
659                .expect("flex-grow must always be declared");
660            assert!(
661                (got - v).abs() <= 0.001,
662                "flex_grow {v} did not survive the FloatValue encoding (got {got})",
663            );
664        }
665    }
666
667    #[test]
668    fn flex_grow_below_the_encoding_precision_truncates_to_zero() {
669        // Everything under one milli-unit is quantised away — including the sign, because
670        // the truncating cast of -0.0001 * 1000 = -0.1 lands on integer 0.
671        for v in [f32::EPSILON, f32::MIN_POSITIVE, -f32::MIN_POSITIVE, 1e-4, -1e-4, 0.0005, -0.0009] {
672            let got = dom_flex_grow(&Card::default().with_flex_grow(v).dom())
673                .expect("flex-grow must always be declared");
674            assert_eq!(got.to_bits(), 0_u32, "sub-milli flex_grow {v} did not truncate to +0.0 (got {got})");
675        }
676
677        // ... and 0.001 is genuinely the smallest factor that still registers.
678        let smallest = dom_flex_grow(&Card::default().with_flex_grow(0.001).dom()).unwrap();
679        assert!(smallest > 0.0, "0.001 is supposed to be the smallest representable factor");
680    }
681
682    #[test]
683    fn a_nan_flex_grow_does_not_panic_and_lands_on_zero() {
684        let dom = Card::default().with_flex_grow(f32::NAN).dom();
685        let got = dom_flex_grow(&dom).expect("flex-grow must always be declared");
686        assert!(!got.is_nan(), "a NaN flex-grow reached the style tree");
687        assert_eq!(got.to_bits(), 0_u32, "NaN must encode to +0.0 (saturating cast), got {got}");
688    }
689
690    #[test]
691    fn infinite_and_maximal_flex_grow_saturates_instead_of_overflowing() {
692        // `FloatValue::new` does `(v * 1000.0) as isize`; f32::MAX * 1000 already overflows to
693        // +inf, so MAX and INFINITY have to land on the same saturated bound. The point is that
694        // nothing wraps around into a *negative* factor.
695        let max_encoded = (isize::MAX as f32) / 1000.0;
696        let min_encoded = (isize::MIN as f32) / 1000.0;
697
698        for v in [f32::INFINITY, f32::MAX] {
699            let got = dom_flex_grow(&Card::default().with_flex_grow(v).dom()).unwrap();
700            assert!(got.is_finite(), "an infinite flex-grow reached the style tree for {v}");
701            assert_eq!(got, max_encoded, "{v} did not saturate at the isize upper bound");
702            assert!(got > 0.0, "{v} wrapped around into a non-positive factor");
703        }
704
705        for v in [f32::NEG_INFINITY, f32::MIN] {
706            let got = dom_flex_grow(&Card::default().with_flex_grow(v).dom()).unwrap();
707            assert!(got.is_finite(), "an infinite flex-grow reached the style tree for {v}");
708            assert_eq!(got, min_encoded, "{v} did not saturate at the isize lower bound");
709            assert!(got < 0.0, "{v} wrapped around into a non-negative factor");
710        }
711    }
712
713    #[test]
714    fn no_flex_grow_input_can_put_a_nan_or_an_infinity_into_the_style_tree() {
715        for v in ADVERSARIAL_FLOATS {
716            let dom = Card::default().with_flex_grow(v).dom();
717            let got = dom_flex_grow(&dom).unwrap_or_else(|| panic!("flex-grow disappeared for input {v}"));
718            assert!(
719                got.is_finite(),
720                "input {v} produced a non-finite flex-grow ({got}) — the layout solver would NaN out",
721            );
722        }
723    }
724
725    #[test]
726    fn flex_grow_encoding_is_monotonic() {
727        // A sign- or rounding-bug in the x1000 cast would show up as an inversion here.
728        let ascending = [-1000.0_f32, -1.5, -0.001, 0.0, 0.001, 1.5, 1000.0];
729        let mut prev = f32::NEG_INFINITY;
730        for v in ascending {
731            let got = dom_flex_grow(&Card::default().with_flex_grow(v).dom()).unwrap();
732            assert!(got >= prev, "encoding is not monotonic: {v} encoded to {got}, below the previous {prev}");
733            prev = got;
734        }
735    }
736
737    // ------------------------------------------------------------------
738    // Card::set_on_click / Card::with_on_click
739    // ------------------------------------------------------------------
740
741    #[test]
742    fn set_on_click_replaces_the_previous_callback_instead_of_appending() {
743        let mut c = Card::default();
744        assert!(c.on_click.is_none());
745
746        c.set_on_click(RefAny::new(1u32), click_a as CardOnClickCallbackType);
747        assert!(c.on_click.is_some(), "set_on_click did not store the callback");
748
749        c.set_on_click(RefAny::new(2u32), click_b as CardOnClickCallbackType);
750        let stored = c.on_click.as_ref().expect("callback must still be present");
751        assert_eq!(
752            stored.callback.cb as *const () as usize,
753            click_b as CardOnClickCallbackType as *const () as usize,
754            "the second set_on_click did not replace the first",
755        );
756
757        let dom = c.dom();
758        assert_eq!(
759            dom.root.callbacks.as_ref().len(),
760            1,
761            "a re-set callback was appended instead of replaced — the card would fire twice",
762        );
763    }
764
765    #[test]
766    fn with_on_click_round_trips_the_function_pointer_and_the_payload_into_the_dom() {
767        let cb: CardOnClickCallbackType = click_a;
768        let expected_ptr = cb as *const () as usize;
769
770        let dom = Card::create(Dom::create_text("clickable"))
771            .with_on_click(RefAny::new(0xDEAD_BEEF_u32), cb)
772            .dom();
773
774        let callbacks = dom.root.callbacks.as_ref();
775        assert_eq!(callbacks.len(), 1, "exactly one click callback is expected");
776        assert_eq!(
777            callbacks[0].event,
778            EventFilter::Hover(HoverEventFilter::MouseUp),
779            "the card must fire on mouse-up, not on any other filter",
780        );
781        assert_eq!(
782            callbacks[0].callback.cb, expected_ptr,
783            "the fn pointer was corrupted on the way into the DOM",
784        );
785
786        // The RefAny payload survives the move into the DOM, and a wrong-type downcast
787        // must fail rather than reinterpret the bytes.
788        let mut data = callbacks[0].refany.clone();
789        assert_eq!(
790            *data.downcast_ref::<u32>().expect("payload changed type"),
791            0xDEAD_BEEF,
792            "payload was corrupted",
793        );
794        assert!(data.downcast_ref::<u64>().is_none(), "a wrong-type downcast reinterpreted the payload");
795    }
796
797    #[test]
798    fn with_on_click_accepts_a_generic_callback_without_mangling_the_pointer() {
799        // The `From<Callback>` arm transmutes the fn pointer — this is the FFI path
800        // (Python/C) into the same slot, so the pointer must come out untouched.
801        let generic = Callback {
802            cb: click_a,
803            ctx: azul_core::refany::OptionRefAny::None,
804        };
805        let raw: CardOnClickCallbackType = click_a;
806        let expected_ptr = raw as *const () as usize;
807
808        let dom = Card::default().with_on_click(RefAny::new(1u8), generic).dom();
809        let callbacks = dom.root.callbacks.as_ref();
810        assert_eq!(callbacks.len(), 1);
811        assert_eq!(
812            callbacks[0].callback.cb, expected_ptr,
813            "the Callback -> CardOnClickCallback transmute mangled the pointer",
814        );
815        assert!(callbacks[0].callback.ctx.is_none(), "a native callback must carry no FFI context");
816    }
817
818    #[test]
819    fn a_card_without_a_callback_registers_no_callbacks() {
820        let dom = Card::create(Dom::create_text("inert")).with_flex_grow(1.0).dom();
821        assert!(
822            dom.root.callbacks.as_ref().is_empty(),
823            "a callback appeared on a card that was never given one",
824        );
825    }
826
827    #[test]
828    fn set_on_click_does_not_disturb_the_other_fields() {
829        let mut c = Card::create(Dom::create_text("body")).with_flex_grow(2.0);
830        c.set_on_click(RefAny::new("payload".to_string()), click_a as CardOnClickCallbackType);
831
832        assert_eq!(text_of(&c.content), Some("body"), "set_on_click clobbered the content");
833        assert_eq!(c.flex_grow, 2.0, "set_on_click clobbered flex_grow");
834
835        let dom = c.dom();
836        assert_eq!(dom_flex_grow(&dom), Some(2.0));
837        let mut data = dom.root.callbacks.as_ref()[0].refany.clone();
838        let payload = data.downcast_ref::<String>().expect("payload changed type");
839        assert_eq!(payload.as_str(), "payload", "the RefAny payload was corrupted");
840    }
841
842    // ------------------------------------------------------------------
843    // Card::dom
844    // ------------------------------------------------------------------
845
846    #[test]
847    fn dom_builds_a_single_card_div_holding_the_content_as_its_only_child() {
848        let dom = Card::create(Dom::create_text("body")).dom();
849
850        assert_eq!(*dom.root.get_node_type(), NodeType::Div, "the card container must be a div");
851        assert_eq!(
852            classes(&dom),
853            vec!["__azul-native-card".to_string()],
854            "the card class is what the UA stylesheet and tests key off",
855        );
856        assert_eq!(dom.children.as_ref().len(), 1, "the card must wrap exactly one child");
857        assert_eq!(text_of(&dom.children.as_ref()[0]), Some("body"));
858        assert_eq!(
859            dom.estimated_total_children,
860            count_descendants(&dom),
861            "the cached child count is inconsistent — compact-DOM conversion would over/under-allocate",
862        );
863    }
864
865    #[test]
866    fn dom_prepends_the_flex_grow_declaration_to_the_static_card_style() {
867        let dom = Card::default().with_flex_grow(4.0).dom();
868        let props = inline_props(&dom);
869
870        assert_eq!(
871            props.len(),
872            1 + CARD_STYLE.len(),
873            "the card's inline style must be exactly flex-grow + the static card style",
874        );
875        assert!(
876            matches!(props[0], CssProperty::FlexGrow(_)),
877            "flex-grow must come first, so nothing in the static style can shadow it",
878        );
879        assert_eq!(
880            props.iter().filter(|p| matches!(p, CssProperty::FlexGrow(_))).count(),
881            1,
882            "flex-grow was declared more than once",
883        );
884    }
885
886    #[test]
887    fn dom_carries_the_static_card_geometry() {
888        let props = inline_props(&Card::default().dom());
889
890        let mut paddings = Vec::new();
891        let mut border_widths = Vec::new();
892        let mut radii = Vec::new();
893        let mut border_colors = Vec::new();
894        let mut border_styles = Vec::new();
895        let mut display = None;
896        let mut direction = None;
897        let mut background = None;
898
899        for p in &props {
900            match p {
901                CssProperty::PaddingTop(v) => paddings.push(v.get_property().map(|x| px(&x.inner))),
902                CssProperty::PaddingBottom(v) => paddings.push(v.get_property().map(|x| px(&x.inner))),
903                CssProperty::PaddingLeft(v) => paddings.push(v.get_property().map(|x| px(&x.inner))),
904                CssProperty::PaddingRight(v) => paddings.push(v.get_property().map(|x| px(&x.inner))),
905                CssProperty::BorderTopWidth(v) => border_widths.push(v.get_property().map(|x| px(&x.inner))),
906                CssProperty::BorderBottomWidth(v) => border_widths.push(v.get_property().map(|x| px(&x.inner))),
907                CssProperty::BorderLeftWidth(v) => border_widths.push(v.get_property().map(|x| px(&x.inner))),
908                CssProperty::BorderRightWidth(v) => border_widths.push(v.get_property().map(|x| px(&x.inner))),
909                CssProperty::BorderTopLeftRadius(v) => radii.push(v.get_property().map(|x| px(&x.inner))),
910                CssProperty::BorderTopRightRadius(v) => radii.push(v.get_property().map(|x| px(&x.inner))),
911                CssProperty::BorderBottomLeftRadius(v) => radii.push(v.get_property().map(|x| px(&x.inner))),
912                CssProperty::BorderBottomRightRadius(v) => radii.push(v.get_property().map(|x| px(&x.inner))),
913                CssProperty::BorderTopColor(v) => border_colors.push(v.get_property().map(|x| x.inner)),
914                CssProperty::BorderBottomColor(v) => border_colors.push(v.get_property().map(|x| x.inner)),
915                CssProperty::BorderLeftColor(v) => border_colors.push(v.get_property().map(|x| x.inner)),
916                CssProperty::BorderRightColor(v) => border_colors.push(v.get_property().map(|x| x.inner)),
917                CssProperty::BorderTopStyle(v) => border_styles.push(v.get_property().map(|x| x.inner)),
918                CssProperty::BorderBottomStyle(v) => border_styles.push(v.get_property().map(|x| x.inner)),
919                CssProperty::BorderLeftStyle(v) => border_styles.push(v.get_property().map(|x| x.inner)),
920                CssProperty::BorderRightStyle(v) => border_styles.push(v.get_property().map(|x| x.inner)),
921                CssProperty::Display(v) => display = v.get_property().copied(),
922                CssProperty::FlexDirection(v) => direction = v.get_property().copied(),
923                CssProperty::BackgroundContent(v) => {
924                    background = v.get_property().map(|bg| bg.as_ref().to_vec());
925                }
926                _ => {}
927            }
928        }
929
930        assert_eq!(paddings, vec![Some(12.0); 4], "all four paddings must be 12px");
931        assert_eq!(border_widths, vec![Some(1.0); 4], "all four borders must be 1px");
932        assert_eq!(radii, vec![Some(8.0); 4], "all four corners must be 8px");
933        assert_eq!(border_colors, vec![Some(CARD_BORDER_COLOR); 4], "all four border colours must match");
934        assert_eq!(border_styles, vec![Some(BorderStyle::Solid); 4], "all four border styles must be solid");
935        assert_eq!(display, Some(LayoutDisplay::Flex), "the card container must be a flex box");
936        assert_eq!(direction, Some(LayoutFlexDirection::Column), "the card must stack its content in a column");
937
938        let bg = background.expect("the card must declare a background");
939        assert_eq!(bg.len(), 1, "exactly one background layer expected");
940        match &bg[0] {
941            StyleBackgroundContent::Color(c) => assert_eq!(*c, CARD_BG_COLOR, "the card background is not white"),
942            other => panic!("the card background must be a flat colour, got {other:?}"),
943        }
944    }
945
946    #[test]
947    fn dom_box_shadows_dereference_the_shared_static_descriptor() {
948        let dom = Card::default().dom();
949
950        let mut shadows = 0_usize;
951        for (p, _) in dom.root.style.iter_inline_properties() {
952            let value = match p {
953                CssProperty::BoxShadowTop(v)
954                | CssProperty::BoxShadowBottom(v)
955                | CssProperty::BoxShadowLeft(v)
956                | CssProperty::BoxShadowRight(v) => v.get_property(),
957                _ => None,
958            };
959            let Some(boxed) = value else { continue };
960
961            // This is the interesting bit: the property holds a raw `*const StyleBoxShadow`
962            // taken with `&raw const CARD_SHADOW`. Dereferencing it must yield the static.
963            let s: &StyleBoxShadow = boxed.as_ref();
964            assert_eq!(px(&s.offset_x.inner), 0.0);
965            assert_eq!(px(&s.offset_y.inner), 2.0);
966            assert_eq!(px(&s.blur_radius.inner), 6.0);
967            assert_eq!(px(&s.spread_radius.inner), 0.0);
968            assert_eq!(s.clip_mode, BoxShadowClipMode::Outset);
969            assert_eq!(s.color, CARD_SHADOW_COLOR);
970            shadows += 1;
971        }
972        assert_eq!(shadows, 4, "the card must declare a shadow on all four edges");
973    }
974
975    #[test]
976    fn dropping_card_doms_never_frees_the_static_shadow() {
977        // Every card's four box-shadows are `BoxOrStatic::Static` pointers into the *same*
978        // `CARD_SHADOW`. If a `Drop` ever treated one as `Boxed`, the second card's shadow
979        // would be a use-after-free (and the third a double-free). Build, drop, re-read.
980        let survivor = Card::default().dom();
981        for _ in 0..64 {
982            drop(Card::create(Dom::create_text("throwaway")).with_flex_grow(1.0).dom());
983        }
984
985        assert_eq!(CARD_SHADOW.offset_y.inner.number.get(), 2.0, "the static shadow was mutated or freed");
986        assert_eq!(CARD_SHADOW.color, CARD_SHADOW_COLOR);
987
988        let shadows = survivor
989            .root
990            .style
991            .iter_inline_properties()
992            .filter_map(|(p, _)| match p {
993                CssProperty::BoxShadowTop(v)
994                | CssProperty::BoxShadowBottom(v)
995                | CssProperty::BoxShadowLeft(v)
996                | CssProperty::BoxShadowRight(v) => v.get_property(),
997                _ => None,
998            })
999            .map(|b| px(&b.as_ref().blur_radius.inner))
1000            .collect::<Vec<_>>();
1001        assert_eq!(shadows, vec![6.0; 4], "a surviving card's shadows were corrupted by other cards' drops");
1002    }
1003
1004    #[test]
1005    fn dom_is_deterministic_and_never_mutates_the_shared_static_style() {
1006        let baseline = inline_props(&Card::default().dom());
1007
1008        for i in 0..32 {
1009            let props = inline_props(&Card::default().dom());
1010            assert_eq!(props.len(), baseline.len(), "build #{i} produced a different number of properties");
1011            assert_eq!(props, baseline, "build #{i} diverged — the static CARD_STYLE was mutated");
1012        }
1013    }
1014
1015    #[test]
1016    fn from_card_for_dom_is_exactly_dom() {
1017        let c = Card::create(Dom::create_text("body")).with_flex_grow(1.5);
1018        assert_eq!(Dom::from(c.clone()), c.dom(), "the From impl diverged from Card::dom");
1019    }
1020
1021    #[test]
1022    fn cards_nest_without_desyncing_the_child_counts() {
1023        let inner = Card::create(Dom::create_text("inner")).with_flex_grow(1.0).dom();
1024        let outer = Card::create(inner).with_flex_grow(2.0).dom();
1025
1026        assert_eq!(
1027            outer.estimated_total_children,
1028            count_descendants(&outer),
1029            "nesting cards desynced the cached child count",
1030        );
1031        assert_eq!(dom_flex_grow(&outer), Some(2.0), "the outer card's flex-grow was overwritten");
1032        assert_eq!(
1033            dom_flex_grow(&outer.children.as_ref()[0]),
1034            Some(1.0),
1035            "the inner card's flex-grow was overwritten",
1036        );
1037        assert_eq!(classes(&outer.children.as_ref()[0]), vec!["__azul-native-card".to_string()]);
1038    }
1039}