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

            
8
use azul_core::{
9
    callbacks::Update,
10
    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec},
11
    refany::RefAny,
12
};
13
use azul_css::css::BoxOrStatic;
14
use 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

            
25
use crate::callbacks::CallbackInfo;
26

            
27
/// Card border colour (#dee2e6).
28
const CARD_BORDER_COLOR: ColorU = ColorU {
29
    r: 222,
30
    g: 226,
31
    b: 230,
32
    a: 255,
33
};
34
/// Card background colour (white).
35
const 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).
42
const CARD_SHADOW_COLOR: ColorU = ColorU {
43
    r: 0,
44
    g: 0,
45
    b: 0,
46
    a: 38,
47
};
48

            
49
const CARD_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(CARD_BG_COLOR)];
50
const CARD_BG: StyleBackgroundContentVec =
51
    StyleBackgroundContentVec::from_const_slice(CARD_BG_ITEMS);
52

            
53
/// Shared drop-shadow descriptor referenced by all four edge box-shadows.
54
static 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

            
71
const 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)]
167
pub 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.
177
pub type CardOnClickCallbackType = extern "C" fn(RefAny, CallbackInfo) -> Update;
178
impl_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.
186
azul_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

            
198
impl Card {
199
    /// Creates a new `Card` wrapping the given content DOM.
200
257
    #[must_use] pub const fn create(content: Dom) -> Self {
201
257
        Self {
202
257
            content,
203
257
            flex_grow: 0.0,
204
257
            on_click: OptionCardOnClick::None,
205
257
        }
206
257
    }
207

            
208
    /// Replaces `self` with an empty default card and returns the original.
209
10
    #[must_use] pub const fn swap_with_default(&mut self) -> Self {
210
10
        let mut s = Self::create(Dom::create_div());
211
10
        core::mem::swap(&mut s, self);
212
10
        s
213
10
    }
214

            
215
    /// Sets the body content.
216
4
    pub fn set_content(&mut self, content: Dom) {
217
4
        self.content = content;
218
4
    }
219

            
220
    /// Builder-style setter for the body content.
221
2
    #[must_use] pub fn with_content(mut self, content: Dom) -> Self {
222
2
        self.set_content(content);
223
2
        self
224
2
    }
225

            
226
    /// Sets the flex-grow factor for the card container.
227
175
    pub const fn set_flex_grow(&mut self, flex_grow: f32) {
228
175
        self.flex_grow = flex_grow;
229
175
    }
230

            
231
    /// Builder-style setter for the flex-grow factor.
232
147
    #[must_use] pub const fn with_flex_grow(mut self, flex_grow: f32) -> Self {
233
147
        self.set_flex_grow(flex_grow);
234
147
        self
235
147
    }
236

            
237
    /// Sets the click callback, invoked when the card container is clicked.
238
8
    pub fn set_on_click<C: Into<CardOnClickCallback>>(&mut self, data: RefAny, on_click: C) {
239
8
        self.on_click = Some(CardOnClick {
240
8
            refany: data,
241
8
            callback: on_click.into(),
242
8
        })
243
8
        .into();
244
8
    }
245

            
246
    /// Builder-style setter for the click callback.
247
4
    #[must_use] pub fn with_on_click<C: Into<CardOnClickCallback>>(
248
4
        mut self,
249
4
        data: RefAny,
250
4
        on_click: C,
251
4
    ) -> Self {
252
4
        self.set_on_click(data, on_click);
253
4
        self
254
4
    }
255

            
256
169
    #[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
169
        let callbacks = match self.on_click.into_option() {
268
            Some(CardOnClick {
269
5
                refany: data,
270
5
                callback,
271
5
            }) => vec![CoreCallbackData {
272
5
                event: EventFilter::Hover(HoverEventFilter::MouseUp),
273
5
                callback: CoreCallback {
274
5
                    cb: callback.cb as *const () as usize,
275
5
                    ctx: callback.ctx,
276
5
                },
277
5
                refany: data,
278
5
            }],
279
164
            None => Vec::new(),
280
        };
281

            
282
        // Prepend the (param-dependent) flex-grow, then the static card style.
283
169
        let mut props = vec![CssPropertyWithConditions::simple(CssProperty::FlexGrow(
284
169
            LayoutFlexGrowValue::Exact(LayoutFlexGrow {
285
169
                inner: FloatValue::new(self.flex_grow),
286
169
            }),
287
169
        ))];
288
169
        props.extend_from_slice(CARD_STYLE);
289

            
290
169
        Dom::create_div()
291
169
            .with_ids_and_classes(IdOrClassVec::from_const_slice(CARD_CLASS))
292
169
            .with_css_props(CssPropertyWithConditionsVec::from_vec(props))
293
169
            .with_callbacks(callbacks.into())
294
169
            .with_children(vec![self.content].into())
295
169
    }
296
}
297

            
298
impl Default for Card {
299
159
    fn default() -> Self {
300
159
        Self::create(Dom::create_div())
301
159
    }
302
}
303

            
304
impl From<Card> for Dom {
305
1
    fn from(c: Card) -> Self {
306
1
        c.dom()
307
1
    }
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
)]
317
mod 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
                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_do_not_use_without_block_level_wrapper(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_do_not_use_without_block_level_wrapper("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_do_not_use_without_block_level_wrapper("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_do_not_use_without_block_level_wrapper("first"));
549
        c.set_content(Dom::create_text_do_not_use_without_block_level_wrapper("second"));
550
        c.set_content(Dom::create_text_do_not_use_without_block_level_wrapper("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_do_not_use_without_block_level_wrapper("old")).with_flex_grow(3.0);
566
        let c = base.with_content(Dom::create_text_do_not_use_without_block_level_wrapper("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_do_not_use_without_block_level_wrapper("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_do_not_use_without_block_level_wrapper("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_do_not_use_without_block_level_wrapper("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_do_not_use_without_block_level_wrapper("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_do_not_use_without_block_level_wrapper("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_do_not_use_without_block_level_wrapper("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_do_not_use_without_block_level_wrapper("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))
            .collect::<Vec<_>>();
        assert_eq!(shadows, vec![6.0; 4], "a surviving card's shadows were corrupted by other cards' drops");
    }
    #[test]
    fn dom_is_deterministic_and_never_mutates_the_shared_static_style() {
        let baseline = inline_props(&Card::default().dom());
        for i in 0..32 {
            let props = inline_props(&Card::default().dom());
            assert_eq!(props.len(), baseline.len(), "build #{i} produced a different number of properties");
            assert_eq!(props, baseline, "build #{i} diverged — the static CARD_STYLE was mutated");
        }
    }
    #[test]
    fn from_card_for_dom_is_exactly_dom() {
        let c = Card::create(Dom::create_text_do_not_use_without_block_level_wrapper("body")).with_flex_grow(1.5);
        assert_eq!(Dom::from(c.clone()), c.dom(), "the From impl diverged from Card::dom");
    }
    #[test]
    fn cards_nest_without_desyncing_the_child_counts() {
        let inner = Card::create(Dom::create_text_do_not_use_without_block_level_wrapper("inner")).with_flex_grow(1.0).dom();
        let outer = Card::create(inner).with_flex_grow(2.0).dom();
        assert_eq!(
            outer.estimated_total_children,
            count_descendants(&outer),
            "nesting cards desynced the cached child count",
        );
        assert_eq!(dom_flex_grow(&outer), Some(2.0), "the outer card's flex-grow was overwritten");
        assert_eq!(
            dom_flex_grow(&outer.children.as_ref()[0]),
            Some(1.0),
            "the inner card's flex-grow was overwritten",
        );
        assert_eq!(classes(&outer.children.as_ref()[0]), vec!["__azul-native-card".to_string()]);
    }
}