1
//! Accordion / expander widget — one or more collapsible titled sections. Each
2
//! section is a clickable header row plus a body that shows or hides. Combines
3
//! the expand/collapse state of [`crate::widgets::tree_view::TreeView`] with a
4
//! flat list of sections (each carrying an arbitrary content [`Dom`]).
5
//!
6
//! Sections toggle independently (any number may be open at once). Clicking a
7
//! header flips that section's `is_open` flag in a per-header [`RefAny`] (the
8
//! self-contained per-row data pattern of `tree_view`), invokes the optional
9
//! user `on_toggle(section_index)`, and shows/hides the section body by setting
10
//! `display: block | none` on it via `set_css_property` (mirroring tree_view /
11
//! check_box live restyling).
12
//!
13
//! TODO2: the header is a plain styled clickable bar with no animated disclosure
14
//! chevron — a glyph cannot be re-textured via `set_css_property` without a
15
//! relayout, so an indicator that flips on toggle is deferred. The `display`
16
//! toggle itself follows the proven live-restyle pattern but the `display:none`
17
//! relayout is not GUI-verified in this build.
18
//!
19
//! Key types: [`Accordion`], [`AccordionSection`], [`AccordionOnToggle`].
20

            
21
use std::vec::Vec;
22

            
23
use azul_core::{
24
    callbacks::{CoreCallback, CoreCallbackData, Update},
25
    dom::{
26
        Dom, DomVec, EventFilter, HoverEventFilter, IdOrClass, IdOrClass::Class, IdOrClassVec,
27
        TabIndex,
28
    },
29
    refany::{OptionRefAny, RefAny},
30
};
31
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
32
use azul_css::{
33
    impl_option, impl_vec, impl_vec_clone, impl_vec_debug, impl_vec_mut, impl_vec_partialeq,
34
    props::{
35
        basic::{color::ColorU, font::{StyleFontFamily, StyleFontFamilyVec}, StyleFontSize},
36
        layout::{LayoutDisplay, LayoutFlexDirection, LayoutFlexGrow, LayoutOverflow, LayoutAlignItems, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight},
37
        property::{CssProperty, *},
38
        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleTextColor, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleCursor, StyleUserSelect, StyleTextAlign},
39
    },
40
    impl_option_inner, AzString,
41
};
42

            
43
use crate::callbacks::{Callback, CallbackInfo};
44

            
45
static ACCORDION_CLASS: &[IdOrClass] =
46
    &[Class(AzString::from_const_str("__azul-native-accordion"))];
47
static ACCORDION_SECTION_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
48
    "__azul-native-accordion-section",
49
))];
50
static ACCORDION_HEADER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
51
    "__azul-native-accordion-header",
52
))];
53
static ACCORDION_TITLE_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
54
    "__azul-native-accordion-title",
55
))];
56
static ACCORDION_BODY_CLASS: &[IdOrClass] =
57
    &[Class(AzString::from_const_str("__azul-native-accordion-body"))];
58

            
59
const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
60
const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
61
const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
62
    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
63

            
64
/// Callback invoked when a section header is clicked. The `usize` is the
65
/// zero-based index of the toggled section.
66
pub type AccordionOnToggleCallbackType = extern "C" fn(RefAny, CallbackInfo, usize) -> Update;
67
impl_widget_callback!(
68
    AccordionOnToggle,
69
    OptionAccordionOnToggle,
70
    AccordionOnToggleCallback,
71
    AccordionOnToggleCallbackType
72
);
73

            
74
azul_core::impl_managed_callback! {
75
    wrapper:        AccordionOnToggleCallback,
76
    info_ty:        CallbackInfo,
77
    return_ty:      Update,
78
    default_ret:    Update::DoNothing,
79
    invoker_static: ACCORDION_ON_TOGGLE_INVOKER,
80
    invoker_ty:     AzAccordionOnToggleCallbackInvoker,
81
    thunk_fn:       az_accordion_on_toggle_callback_thunk,
82
    setter_fn:      AzApp_setAccordionOnToggleCallbackInvoker,
83
    from_handle_fn: AzAccordionOnToggleCallback_createFromHostHandle,
84
    extra_args:     [ section_index: usize ],
85
}
86

            
87
// ---- colours ----
88
const BORDER_COLOR: ColorU = ColorU { r: 222, g: 226, b: 230, a: 255 }; // #dee2e6
89
const HEADER_BG: ColorU = ColorU { r: 248, g: 249, b: 250, a: 255 }; // #f8f9fa
90
const TEXT_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 }; // #212529
91

            
92
const HEADER_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(HEADER_BG)];
93
const HEADER_BG_VEC: StyleBackgroundContentVec =
94
    StyleBackgroundContentVec::from_const_slice(HEADER_BG_ITEMS);
95

            
96
/// One collapsible section: a header title and an arbitrary content body.
97
#[derive(Debug, Clone, PartialEq, Eq)]
98
#[repr(C)]
99
pub struct AccordionSection {
100
    /// The header text shown for this section.
101
    pub title: AzString,
102
    /// The body content revealed when the section is open.
103
    pub content: Dom,
104
    /// Whether this section starts open (body visible).
105
    pub is_open: bool,
106
}
107

            
108
impl AccordionSection {
109
    /// Creates a new collapsed section with the given title and content.
110
1088
    pub fn new<S: Into<AzString>>(title: S, content: Dom) -> Self {
111
1088
        Self {
112
1088
            title: title.into(),
113
1088
            content,
114
1088
            is_open: false,
115
1088
        }
116
1088
    }
117

            
118
    /// Builder method: sets the initial open state.
119
1081
    #[must_use] pub const fn with_open(mut self, open: bool) -> Self {
120
1081
        self.is_open = open;
121
1081
        self
122
1081
    }
123
}
124

            
125
impl_option!(AccordionSection, OptionAccordionSection, copy = false, [Debug, Clone, PartialEq, Eq]);
126
impl_vec!(
127
    AccordionSection,
128
    AccordionSectionVec,
129
    AccordionSectionVecDestructor,
130
    AccordionSectionVecDestructorType,
131
    AccordionSectionVecSlice,
132
    OptionAccordionSection
133
);
134
impl_vec_clone!(AccordionSection, AccordionSectionVec, AccordionSectionVecDestructor);
135
impl_vec_debug!(AccordionSection, AccordionSectionVec);
136
impl_vec_partialeq!(AccordionSection, AccordionSectionVec);
137
impl_vec_mut!(AccordionSection, AccordionSectionVec);
138

            
139
/// A vertical stack of collapsible titled sections.
140
#[derive(Debug, Clone, PartialEq)]
141
#[repr(C)]
142
pub struct Accordion {
143
    /// The sections, in display order.
144
    pub sections: AccordionSectionVec,
145
    /// Optional callback fired when any section header is toggled.
146
    pub on_toggle: OptionAccordionOnToggle,
147
}
148

            
149
// ---- styles ----
150

            
151
static ACCORDION_CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
152
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
153
    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
154
        LayoutFlexDirection::Column,
155
    )),
156
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
157
    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(14))),
158
    CssPropertyWithConditions::simple(CssProperty::const_font_family(SYSTEM_UI_FAMILY)),
159
    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
160
        inner: TEXT_COLOR,
161
    })),
162
    // border: 1px solid #dee2e6
163
    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
164
        LayoutBorderTopWidth::const_px(1),
165
    )),
166
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
167
        LayoutBorderBottomWidth::const_px(1),
168
    )),
169
    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
170
        LayoutBorderLeftWidth::const_px(1),
171
    )),
172
    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
173
        LayoutBorderRightWidth::const_px(1),
174
    )),
175
    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
176
        inner: BorderStyle::Solid,
177
    })),
178
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
179
        StyleBorderBottomStyle {
180
            inner: BorderStyle::Solid,
181
        },
182
    )),
183
    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
184
        inner: BorderStyle::Solid,
185
    })),
186
    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
187
        StyleBorderRightStyle {
188
            inner: BorderStyle::Solid,
189
        },
190
    )),
191
    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
192
        inner: BORDER_COLOR,
193
    })),
194
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
195
        StyleBorderBottomColor {
196
            inner: BORDER_COLOR,
197
        },
198
    )),
199
    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
200
        inner: BORDER_COLOR,
201
    })),
202
    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
203
        StyleBorderRightColor {
204
            inner: BORDER_COLOR,
205
        },
206
    )),
207
    // rounded corners, clipping the per-section separators
208
    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
209
        StyleBorderTopLeftRadius::const_px(6),
210
    )),
211
    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
212
        StyleBorderTopRightRadius::const_px(6),
213
    )),
214
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
215
        StyleBorderBottomLeftRadius::const_px(6),
216
    )),
217
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
218
        StyleBorderBottomRightRadius::const_px(6),
219
    )),
220
    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
221
    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
222
];
223

            
224
static ACCORDION_SECTION_STYLE: &[CssPropertyWithConditions] = &[
225
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
226
    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
227
        LayoutFlexDirection::Column,
228
    )),
229
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
230
    // a thin separator between stacked sections
231
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
232
        LayoutBorderBottomWidth::const_px(1),
233
    )),
234
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
235
        StyleBorderBottomStyle {
236
            inner: BorderStyle::Solid,
237
        },
238
    )),
239
    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
240
        StyleBorderBottomColor {
241
            inner: BORDER_COLOR,
242
        },
243
    )),
244
];
245

            
246
static ACCORDION_HEADER_STYLE: &[CssPropertyWithConditions] = &[
247
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
248
    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
249
    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
250
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
251
    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
252
        10,
253
    ))),
254
    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
255
        LayoutPaddingBottom::const_px(10),
256
    )),
257
    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
258
        12,
259
    ))),
260
    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
261
        LayoutPaddingRight::const_px(12),
262
    )),
263
    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
264
    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
265
    CssPropertyWithConditions::simple(CssProperty::const_background_content(HEADER_BG_VEC)),
266
];
267

            
268
static ACCORDION_TITLE_STYLE: &[CssPropertyWithConditions] = &[
269
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
270
    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
271
];
272

            
273
/// Body style when the section is OPEN: a padded block with a top separator.
274
static ACCORDION_BODY_STYLE_OPEN: &[CssPropertyWithConditions] = &[
275
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
276
    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
277
        12,
278
    ))),
279
    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
280
        LayoutPaddingBottom::const_px(12),
281
    )),
282
    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
283
        12,
284
    ))),
285
    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
286
        LayoutPaddingRight::const_px(12),
287
    )),
288
];
289

            
290
/// Body style when the section is CLOSED: not laid out at all.
291
static ACCORDION_BODY_STYLE_CLOSED: &[CssPropertyWithConditions] = &[
292
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::None)),
293
];
294

            
295
impl Accordion {
296
    /// Creates a new accordion from the given sections, with no toggle callback.
297
29
    #[must_use] pub fn new(sections: AccordionSectionVec) -> Self {
298
29
        Self {
299
29
            sections,
300
29
            on_toggle: None.into(),
301
29
        }
302
29
    }
303

            
304
    /// Creates an empty accordion.
305
13
    #[must_use] pub fn create() -> Self {
306
13
        Self::new(AccordionSectionVec::from_const_slice(&[]))
307
13
    }
308

            
309
    /// Sets the callback invoked when any section header is toggled.
310
6
    pub fn set_on_toggle<C: Into<AccordionOnToggleCallback>>(&mut self, data: RefAny, callback: C) {
311
6
        self.on_toggle = Some(AccordionOnToggle {
312
6
            callback: callback.into(),
313
6
            refany: data,
314
6
        })
315
6
        .into();
316
6
    }
317

            
318
    /// Builder method: sets the toggle callback.
319
3
    #[must_use] pub fn with_on_toggle<C: Into<AccordionOnToggleCallback>>(
320
3
        mut self,
321
3
        data: RefAny,
322
3
        callback: C,
323
3
    ) -> Self {
324
3
        self.set_on_toggle(data, callback);
325
3
        self
326
3
    }
327

            
328
    /// Replaces `self` with an empty default accordion and returns the original.
329
2
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
330
2
        let mut s = Self::create();
331
2
        core::mem::swap(&mut s, self);
332
2
        s
333
2
    }
334

            
335
    /// Renders the accordion into a [`Dom`] subtree.
336
14
    #[must_use] pub fn dom(self) -> Dom {
337
14
        let on_toggle = self.on_toggle;
338
14
        let sections = self.sections;
339

            
340
14
        let mut section_doms: Vec<Dom> = Vec::with_capacity(sections.as_ref().len());
341

            
342
80
        for (index, section) in sections.as_ref().iter().enumerate() {
343
80
            let title = Dom::create_p_with_text(section.title.clone())
344
80
                .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_TITLE_CLASS))
345
80
                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
346
80
                    ACCORDION_TITLE_STYLE,
347
                ));
348

            
349
            // Per-header self-contained click data (mirrors tree_view's NodeClickData).
350
80
            let header_data = HeaderClickData {
351
80
                index,
352
80
                is_open: section.is_open,
353
80
                on_toggle: clone_option_on_toggle(&on_toggle),
354
80
            };
355

            
356
80
            let header = Dom::create_div()
357
80
                .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_HEADER_CLASS))
358
80
                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
359
80
                    ACCORDION_HEADER_STYLE,
360
                ))
361
80
                .with_tab_index(TabIndex::Auto)
362
80
                .with_callbacks(
363
80
                    alloc::vec![CoreCallbackData {
364
80
                        event: EventFilter::Hover(HoverEventFilter::MouseUp),
365
80
                        callback: CoreCallback {
366
80
                            cb: on_accordion_header_click as usize,
367
80
                            ctx: OptionRefAny::None,
368
80
                        },
369
80
                        refany: RefAny::new(header_data),
370
80
                    }]
371
80
                    .into(),
372
                )
373
80
                .with_children(DomVec::from_vec(alloc::vec![title]));
374

            
375
80
            let body_style = if section.is_open {
376
26
                ACCORDION_BODY_STYLE_OPEN
377
            } else {
378
54
                ACCORDION_BODY_STYLE_CLOSED
379
            };
380
80
            let body = Dom::create_div()
381
80
                .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_BODY_CLASS))
382
80
                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(body_style))
383
80
                .with_children(DomVec::from_vec(alloc::vec![section.content.clone()]));
384

            
385
80
            section_doms.push(
386
80
                Dom::create_div()
387
80
                    .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_SECTION_CLASS))
388
80
                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
389
80
                        ACCORDION_SECTION_STYLE,
390
                    ))
391
80
                    .with_children(DomVec::from_vec(alloc::vec![header, body])),
392
            );
393
        }
394

            
395
14
        Dom::create_div()
396
14
            .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_CLASS))
397
14
            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
398
14
                ACCORDION_CONTAINER_STYLE,
399
            ))
400
14
            .with_children(DomVec::from_vec(section_doms))
401
14
    }
402
}
403

            
404
impl Default for Accordion {
405
1
    fn default() -> Self {
406
1
        Self::create()
407
1
    }
408
}
409

            
410
/// Clones an `OptionAccordionOnToggle` (the callback wrapper is not `Copy`).
411
84
fn clone_option_on_toggle(opt: &OptionAccordionOnToggle) -> OptionAccordionOnToggle {
412
84
    match opt.as_ref() {
413
3
        Some(AccordionOnToggle { callback, refany }) => Some(AccordionOnToggle {
414
3
            callback: callback.clone(),
415
3
            refany: refany.clone(),
416
3
        })
417
3
        .into(),
418
81
        None => None.into(),
419
    }
420
84
}
421

            
422
/// Per-header callback payload (kept internal, like `tree_view::NodeClickData`).
423
struct HeaderClickData {
424
    index: usize,
425
    is_open: bool,
426
    on_toggle: OptionAccordionOnToggle,
427
}
428

            
429
/// Header click handler. The hit node is the header (the callback-bearing node,
430
/// per `currentTarget` semantics — see `radio_group`); its next sibling is the
431
/// body. Flips this section's `is_open`, invokes the optional user callback with
432
/// the section index, then shows/hides the body via `display`.
433
8
extern "C" fn on_accordion_header_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
434
8
    let header = info.get_hit_node();
435
8
    let Some(body) = info.get_next_sibling(header) else {
436
3
        return Update::DoNothing;
437
    };
438

            
439
4
    let (now_open, result) = {
440
5
        let Some(mut hd) = data.downcast_mut::<HeaderClickData>() else {
441
1
            return Update::DoNothing;
442
        };
443
4
        hd.is_open = !hd.is_open;
444
4
        let now_open = hd.is_open;
445
4
        let index = hd.index;
446
4
        let result = match hd.on_toggle.as_mut() {
447
2
            Some(AccordionOnToggle { callback, refany }) => {
448
2
                (callback.cb)(refany.clone(), info, index)
449
            }
450
2
            None => Update::DoNothing,
451
        };
452
4
        (now_open, result)
453
    };
454

            
455
4
    let display = if now_open {
456
2
        LayoutDisplay::Block
457
    } else {
458
2
        LayoutDisplay::None
459
    };
460
4
    info.set_css_property(body, CssProperty::const_display(display));
461

            
462
4
    result
463
8
}
464

            
465
impl From<Accordion> for Dom {
466
1
    fn from(a: Accordion) -> Self {
467
1
        a.dom()
468
1
    }
469
}
470

            
471
#[cfg(all(test, feature = "std"))]
472
mod autotest_generated {
473
    use std::{
474
        collections::{BTreeMap, HashMap},
475
        sync::{Arc, Mutex},
476
    };
477

            
478
    use azul_core::{
479
        dom::{DomId, DomNodeId, NodeId, NodeType},
480
        geom::{LogicalRect, OptionLogicalPosition},
481
        gl::OptionGlContextPtr,
482
        hit_test::ScrollPosition,
483
        resources::RendererResources,
484
        styled_dom::{NodeHierarchyItemId, StyledDom},
485
        window::{MonitorVec, RawWindowHandle},
486
    };
487
    use azul_css::system::SystemStyle;
488
    use rust_fontconfig::FcFontCache;
489

            
490
    use super::*;
491
    #[cfg(feature = "icu")]
492
    use crate::icu::IcuLocalizerHandle;
493
    use crate::{
494
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
495
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
496
        window::{DomLayoutResult, LayoutWindow},
497
        window_state::FullWindowState,
498
    };
499

            
500
    // ------------------------------------------------------------------
501
    // Helpers
502
    // ------------------------------------------------------------------
503

            
504
    /// True if `node` carries the CSS class `name`.
505
    fn has_class(node: &Dom, name: &str) -> bool {
506
        node.root
507
            .get_ids_and_classes()
508
            .as_ref()
509
            .iter()
510
            .any(|c| matches!(c, Class(s) if s.as_str() == name))
511
    }
512

            
513
    /// The text of a text node, looking through the `<p>` block wrapper the
514
    /// label convention mandates (`p > text`).
515
    fn text_of(node: &Dom) -> Option<&str> {
516
        match node.root.get_node_type() {
517
            NodeType::Text(s) => Some(s.as_ref().as_str()),
518
            NodeType::P => match node.children.as_ref() {
519
                [only] => match only.root.get_node_type() {
520
                    NodeType::Text(s) => Some(s.as_ref().as_str()),
521
                    _ => None,
522
                },
523
                _ => None,
524
            },
525
            _ => None,
526
        }
527
    }
528

            
529
    /// The `display` value in a node's *inline* style, if it sets one.
530
    fn inline_display(node: &Dom) -> Option<LayoutDisplay> {
531
        node.root
532
            .style
533
            .iter_inline_properties()
534
            .find_map(|(p, _)| match p {
535
                CssProperty::Display(v) => v.get_property().copied(),
536
                _ => None,
537
            })
538
    }
539

            
540
    /// `(header, body)` of the `n`-th section of a rendered accordion DOM.
541
    fn section_parts(dom: &Dom, n: usize) -> (&Dom, &Dom) {
542
        let section = &dom.children.as_ref()[n];
543
        assert!(has_class(section, "__azul-native-accordion-section"));
544
        let children = section.children.as_ref();
545
        assert_eq!(children.len(), 2, "a section is exactly [header, body]");
546
        (&children[0], &children[1])
547
    }
548

            
549
    /// A three-node styled DOM — `root(0)` with children `header(1)` and
550
    /// `body(2)` — i.e. the exact hierarchy `on_accordion_header_click` walks
551
    /// (`hit node` -> `next sibling`).
552
    fn header_body_dom() -> StyledDom {
553
        let styled = StyledDom::create_from_dom(
554
            Dom::create_div()
555
                .with_child(Dom::create_div())
556
                .with_child(Dom::create_div()),
557
        );
558
        assert_eq!(
559
            styled.node_hierarchy.as_ref().len(),
560
            3,
561
            "fixture must flatten to exactly root/header/body"
562
        );
563
        styled
564
    }
565

            
566
    /// A `DomLayoutResult` with an *empty* layout tree: the click handler only
567
    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
568
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
569
        DomLayoutResult {
570
            styled_dom,
571
            layout_tree: LayoutTree {
572
                nodes: Vec::new(),
573
                warm: Vec::new(),
574
                cold: Vec::new(),
575
                root: 0,
576
                dom_to_layout: BTreeMap::new(),
577
                children_arena: Vec::new(),
578
                children_offsets: Vec::new(),
579
                subtree_needs_intrinsic: Vec::new(),
580
            },
581
            calculated_positions: Vec::new(),
582
            viewport: LogicalRect::zero(),
583
            display_list: Arc::new(DisplayList::default()),
584
            scroll_ids: HashMap::new(),
585
            scroll_id_to_node_id: HashMap::new(),
586
        }
587
    }
588

            
589
    /// Invokes `on_accordion_header_click` against a `LayoutWindow` holding
590
    /// `styled` (or nothing at all, when `styled` is `None`), with `hit` as the
591
    /// hit node. Returns the `Update` plus every recorded `CallbackChange`.
592
    fn run_click(styled: Option<StyledDom>, hit: usize, data: RefAny) -> (Update, Vec<CallbackChange>) {
593
        let mut layout_window =
594
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
595
        if let Some(sd) = styled {
596
            layout_window
597
                .layout_results
598
                .insert(DomId::ROOT_ID, layout_result(sd));
599
        }
600

            
601
        let renderer_resources = RendererResources::default();
602
        let previous_window_state: Option<FullWindowState> = None;
603
        let current_window_state = FullWindowState::default();
604
        let gl_context = OptionGlContextPtr::None;
605
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
606
            BTreeMap::new();
607
        let window_handle = RawWindowHandle::Unsupported;
608
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
609

            
610
        let ref_data = CallbackInfoRefData {
611
            layout_window: &layout_window,
612
            renderer_resources: &renderer_resources,
613
            previous_window_state: &previous_window_state,
614
            current_window_state: &current_window_state,
615
            gl_context: &gl_context,
616
            current_scroll_manager: &scroll_states,
617
            current_window_handle: &window_handle,
618
            system_callbacks: &system_callbacks,
619
            system_style: Arc::new(SystemStyle::default()),
620
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
621
            #[cfg(feature = "icu")]
622
            icu_localizer: IcuLocalizerHandle::default(),
623
            ctx: OptionRefAny::None,
624
        };
625

            
626
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
627

            
628
        let info = CallbackInfo::new(
629
            &ref_data,
630
            &changes,
631
            DomNodeId {
632
                dom: DomId::ROOT_ID,
633
                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
634
            },
635
            OptionLogicalPosition::None,
636
            OptionLogicalPosition::None,
637
        );
638

            
639
        let update = on_accordion_header_click(data, info);
640
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
641
        (update, recorded)
642
    }
643

            
644
    /// Every `display` write recorded in the change log, as `(node index, display)`.
645
    fn display_writes(changes: &[CallbackChange]) -> Vec<(usize, LayoutDisplay)> {
646
        let mut out = Vec::new();
647
        for change in changes {
648
            if let CallbackChange::ChangeNodeCssProperties {
649
                node_id, properties, ..
650
            } = change
651
            {
652
                for p in properties.as_ref() {
653
                    if let CssProperty::Display(v) = p {
654
                        if let Some(d) = v.get_property() {
655
                            out.push((node_id.index(), *d));
656
                        }
657
                    }
658
                }
659
            }
660
        }
661
        out
662
    }
663

            
664
    /// `is_open` of a `HeaderClickData` payload.
665
    fn payload_is_open(data: &mut RefAny) -> bool {
666
        data.downcast_ref::<HeaderClickData>()
667
            .expect("payload must still be a HeaderClickData")
668
            .is_open
669
    }
670

            
671
    /// Records the section indices it is invoked with; used as a user `on_toggle`.
672
    struct ToggleLog {
673
        calls: Vec<usize>,
674
    }
675

            
676
    extern "C" fn record_toggle(mut data: RefAny, _: CallbackInfo, index: usize) -> Update {
677
        if let Some(mut log) = data.downcast_mut::<ToggleLog>() {
678
            log.calls.push(index);
679
        }
680
        Update::RefreshDom
681
    }
682

            
683
    extern "C" fn toggle_do_nothing(_: RefAny, _: CallbackInfo, _: usize) -> Update {
684
        Update::DoNothing
685
    }
686

            
687
    fn toggle_cb(f: AccordionOnToggleCallbackType) -> AccordionOnToggleCallback {
688
        f.into()
689
    }
690

            
691
    // ------------------------------------------------------------------
692
    // AccordionSection::new / with_open  (constructor, invariants)
693
    // ------------------------------------------------------------------
694

            
695
    #[test]
696
    fn section_new_stores_args_and_starts_closed() {
697
        let content = Dom::create_div().with_child(Dom::create_text_do_not_use_without_block_level_wrapper("body"));
698
        let sec = AccordionSection::new("Title", content.clone());
699

            
700
        assert_eq!(sec.title.as_str(), "Title");
701
        assert_eq!(sec.content, content);
702
        assert!(!sec.is_open, "a fresh section must start collapsed");
703
    }
704

            
705
    #[test]
706
    fn section_new_survives_extreme_titles() {
707
        // empty, interior NUL, emoji + combining marks + RTL, and a 100k-char title
708
        let long = "ab".repeat(50_000);
709
        let cases: Vec<AzString> = alloc::vec![
710
            AzString::from(""),
711
            AzString::from("a\0b"),
712
            AzString::from("👨‍👩‍👧‍👦 e\u{0301}\u{0327} مرحبا שלום 🇩🇪"),
713
            AzString::from("\u{feff}\u{202e}rtl-override"),
714
            AzString::from(long.as_str()),
715
        ];
716

            
717
        for title in cases {
718
            let sec = AccordionSection::new(title.clone(), Dom::create_div());
719
            assert_eq!(sec.title.as_str(), title.as_str());
720
            assert!(!sec.is_open);
721

            
722
            // and the title survives the trip through the DOM unchanged
723
            let dom = Accordion::new(AccordionSectionVec::from_vec(alloc::vec![sec])).dom();
724
            let (header, _) = section_parts(&dom, 0);
725
            let title_node = &header.children.as_ref()[0];
726
            assert_eq!(text_of(title_node), Some(title.as_str()));
727
        }
728
    }
729

            
730
    #[test]
731
    fn section_with_open_sets_flag_without_touching_other_fields() {
732
        let content = Dom::create_text_do_not_use_without_block_level_wrapper("x");
733
        let base = AccordionSection::new("t", content.clone());
734

            
735
        let opened = base.clone().with_open(true);
736
        assert!(opened.is_open);
737
        assert_eq!(opened.title.as_str(), "t");
738
        assert_eq!(opened.content, content);
739

            
740
        // last write wins; applying the same value twice is idempotent
741
        assert!(!base.clone().with_open(true).with_open(false).is_open);
742
        assert!(base.clone().with_open(false).with_open(true).is_open);
743
        assert!(base.clone().with_open(true).with_open(true).is_open);
744
        assert!(!base.with_open(false).is_open);
745
    }
746

            
747
    // ------------------------------------------------------------------
748
    // Accordion::new / create / Default
749
    // ------------------------------------------------------------------
750

            
751
    #[test]
752
    fn accordion_new_preserves_section_count_and_has_no_callback() {
753
        for count in [0usize, 1, 3, 1000] {
754
            let mut sections = Vec::with_capacity(count);
755
            for i in 0..count {
756
                sections.push(
757
                    AccordionSection::new(alloc::format!("s{i}"), Dom::create_div())
758
                        .with_open(i % 2 == 0),
759
                );
760
            }
761
            let acc = Accordion::new(AccordionSectionVec::from_vec(sections));
762

            
763
            assert_eq!(acc.sections.len(), count);
764
            assert!(acc.on_toggle.is_none(), "Accordion::new sets no callback");
765
            for (i, s) in acc.sections.as_ref().iter().enumerate() {
766
                assert_eq!(s.title.as_str(), alloc::format!("s{i}"));
767
                assert_eq!(s.is_open, i % 2 == 0);
768
            }
769
        }
770
    }
771

            
772
    #[test]
773
    fn accordion_create_is_empty_and_equals_default() {
774
        let acc = Accordion::create();
775
        assert!(acc.sections.is_empty());
776
        assert!(acc.on_toggle.is_none());
777
        assert_eq!(acc, Accordion::default());
778
    }
779

            
780
    // ------------------------------------------------------------------
781
    // set_on_toggle / with_on_toggle / swap_with_default
782
    // ------------------------------------------------------------------
783

            
784
    #[test]
785
    fn set_on_toggle_last_call_wins() {
786
        let mut acc = Accordion::create();
787

            
788
        acc.set_on_toggle(RefAny::new(1u8), toggle_cb(toggle_do_nothing));
789
        assert!(acc.on_toggle.is_some());
790
        assert_eq!(
791
            acc.on_toggle.as_ref().unwrap().refany.get_type_id(),
792
            RefAny::new(1u8).get_type_id()
793
        );
794

            
795
        // a second call must *replace* (not append / leak / panic)
796
        acc.set_on_toggle(RefAny::new(9i64), toggle_cb(record_toggle));
797
        let set = acc.on_toggle.as_ref().expect("still Some");
798
        assert_eq!(set.refany.get_type_id(), RefAny::new(0i64).get_type_id());
799
        assert_eq!(set.callback, toggle_cb(record_toggle));
800
        assert_ne!(set.callback, toggle_cb(toggle_do_nothing));
801
    }
802

            
803
    #[test]
804
    fn with_on_toggle_matches_set_on_toggle() {
805
        let built = Accordion::create().with_on_toggle(RefAny::new(7u32), toggle_cb(record_toggle));
806

            
807
        let mut mutated = Accordion::create();
808
        mutated.set_on_toggle(RefAny::new(7u32), toggle_cb(record_toggle));
809

            
810
        assert!(built.on_toggle.is_some());
811
        assert_eq!(
812
            built.on_toggle.as_ref().unwrap().callback,
813
            mutated.on_toggle.as_ref().unwrap().callback
814
        );
815
        // the builder form must not disturb the sections
816
        assert!(built.sections.is_empty());
817
    }
818

            
819
    #[test]
820
    fn swap_with_default_moves_all_state_out() {
821
        let sections = AccordionSectionVec::from_vec(alloc::vec![
822
            AccordionSection::new("a", Dom::create_div()),
823
            AccordionSection::new("b", Dom::create_div()).with_open(true),
824
        ]);
825
        let mut acc = Accordion::new(sections).with_on_toggle(RefAny::new(5u8), toggle_cb(record_toggle));
826

            
827
        let original = acc.swap_with_default();
828

            
829
        assert_eq!(original.sections.len(), 2);
830
        assert!(original.on_toggle.is_some());
831
        assert!(original.sections.as_ref()[1].is_open);
832

            
833
        assert!(acc.sections.is_empty(), "self must be left empty");
834
        assert!(acc.on_toggle.is_none(), "self must lose the callback");
835
        assert_eq!(acc, Accordion::create());
836

            
837
        // swapping an already-empty accordion is a no-op, not a panic
838
        let second = acc.swap_with_default();
839
        assert_eq!(second, Accordion::create());
840
        assert_eq!(acc, Accordion::create());
841
    }
842

            
843
    // ------------------------------------------------------------------
844
    // Accordion::dom
845
    // ------------------------------------------------------------------
846

            
847
    #[test]
848
    fn dom_of_empty_accordion_has_no_children() {
849
        let dom = Accordion::create().dom();
850
        assert!(has_class(&dom, "__azul-native-accordion"));
851
        assert!(dom.children.as_ref().is_empty());
852
        assert_eq!(dom.estimated_total_children, 0);
853
    }
854

            
855
    #[test]
856
    fn dom_display_follows_is_open() {
857
        let acc = Accordion::new(AccordionSectionVec::from_vec(alloc::vec![
858
            AccordionSection::new("closed", Dom::create_text_do_not_use_without_block_level_wrapper("c0")),
859
            AccordionSection::new("open", Dom::create_text_do_not_use_without_block_level_wrapper("c1")).with_open(true),
860
        ]));
861
        let dom = acc.dom();
862
        assert_eq!(dom.children.as_ref().len(), 2);
863

            
864
        let (h0, b0) = section_parts(&dom, 0);
865
        let (h1, b1) = section_parts(&dom, 1);
866

            
867
        assert!(has_class(h0, "__azul-native-accordion-header"));
868
        assert!(has_class(b0, "__azul-native-accordion-body"));
869

            
870
        // a closed section is `display: none`, an open one `display: block`
871
        assert_eq!(inline_display(b0), Some(LayoutDisplay::None));
872
        assert_eq!(inline_display(b1), Some(LayoutDisplay::Block));
873

            
874
        // the body wraps exactly the caller's content
875
        assert_eq!(text_of(&b0.children.as_ref()[0]), Some("c0"));
876
        assert_eq!(text_of(&b1.children.as_ref()[0]), Some("c1"));
877

            
878
        // the header is focusable and carries exactly one MouseUp callback
879
        for h in [h0, h1] {
880
            assert!(matches!(h.root.get_tab_index(), Some(TabIndex::Auto)));
881
            let cbs = h.root.get_callbacks();
882
            assert_eq!(cbs.len(), 1);
883
            assert_eq!(cbs.as_ref()[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
884
            assert_eq!(
885
                cbs.as_ref()[0].callback.cb,
886
                on_accordion_header_click as usize
887
            );
888
        }
889
    }
890

            
891
    #[test]
892
    fn dom_header_payload_carries_the_section_index_and_open_state() {
893
        let count = 64usize;
894
        let mut sections = Vec::with_capacity(count);
895
        for i in 0..count {
896
            sections.push(
897
                AccordionSection::new(alloc::format!("s{i}"), Dom::create_div())
898
                    .with_open(i % 3 == 0),
899
            );
900
        }
901
        let dom = Accordion::new(AccordionSectionVec::from_vec(sections)).dom();
902

            
903
        for i in 0..count {
904
            let (header, body) = section_parts(&dom, i);
905
            let mut payload = header.root.get_callbacks().as_ref()[0].refany.clone();
906
            let hd = payload
907
                .downcast_ref::<HeaderClickData>()
908
                .expect("header payload is a HeaderClickData");
909

            
910
            assert_eq!(hd.index, i, "each header must know its own section index");
911
            assert_eq!(hd.is_open, i % 3 == 0);
912
            assert!(hd.on_toggle.is_none(), "no user callback was set");
913
            assert_eq!(
914
                inline_display(body),
915
                Some(if i % 3 == 0 {
916
                    LayoutDisplay::Block
917
                } else {
918
                    LayoutDisplay::None
919
                })
920
            );
921
        }
922
    }
923

            
924
    #[test]
925
    fn dom_child_count_cache_stays_consistent() {
926
        // deeply nested content + many sections: `estimated_total_children` must
927
        // still equal the real descendant count, otherwise the compact-DOM arena
928
        // under-allocates and panics later.
929
        let mut deep = Dom::create_text_do_not_use_without_block_level_wrapper("leaf");
930
        for _ in 0..64 {
931
            deep = Dom::create_div().with_child(deep);
932
        }
933

            
934
        let sections = AccordionSectionVec::from_vec(alloc::vec![
935
            AccordionSection::new("deep", deep),
936
            AccordionSection::new("flat", Dom::create_div()).with_open(true),
937
            AccordionSection::new("", Dom::create_div()),
938
        ]);
939
        let dom = Accordion::new(sections).dom();
940

            
941
        assert_eq!(
942
            dom.estimated_total_children,
943
            dom.recompute_estimated_total_children(),
944
            "cached descendant count desynced from the real tree"
945
        );
946
    }
947

            
948
    #[test]
949
    fn from_accordion_for_dom_matches_dom() {
950
        // Only meaningful for a section-less accordion: every `dom()` call mints
951
        // fresh per-header `RefAny`s, and two distinct `RefAny`s never compare equal.
952
        assert_eq!(Dom::from(Accordion::create()), Accordion::create().dom());
953
    }
954

            
955
    #[test]
956
    fn dom_leaves_the_original_on_toggle_payload_alive() {
957
        let log = RefAny::new(ToggleLog { calls: Vec::new() });
958
        let mut kept = log.clone();
959

            
960
        let acc = Accordion::new(AccordionSectionVec::from_vec(alloc::vec![
961
            AccordionSection::new("a", Dom::create_div()),
962
            AccordionSection::new("b", Dom::create_div()),
963
        ]))
964
        .with_on_toggle(log, toggle_cb(record_toggle));
965

            
966
        let dom = acc.dom();
967

            
968
        // every header got its own clone of the callback...
969
        for i in 0..2 {
970
            let (header, _) = section_parts(&dom, i);
971
            let mut payload = header.root.get_callbacks().as_ref()[0].refany.clone();
972
            let hd = payload.downcast_ref::<HeaderClickData>().unwrap();
973
            assert!(hd.on_toggle.is_some());
974
        }
975

            
976
        // ...and the caller's handle to the shared payload is still valid (no free)
977
        assert!(kept.downcast_ref::<ToggleLog>().unwrap().calls.is_empty());
978
    }
979

            
980
    // ------------------------------------------------------------------
981
    // clone_option_on_toggle
982
    // ------------------------------------------------------------------
983

            
984
    #[test]
985
    fn clone_option_on_toggle_of_none_is_none() {
986
        let none: OptionAccordionOnToggle = None.into();
987
        assert!(clone_option_on_toggle(&none).is_none());
988
        // cloning the clone stays None
989
        assert!(clone_option_on_toggle(&clone_option_on_toggle(&none)).is_none());
990
    }
991

            
992
    #[test]
993
    fn clone_option_on_toggle_shares_the_payload() {
994
        let mut some: OptionAccordionOnToggle = Some(AccordionOnToggle {
995
            callback: toggle_cb(record_toggle),
996
            refany: RefAny::new(0usize),
997
        })
998
        .into();
999

            
        let mut cloned = clone_option_on_toggle(&some);
        let cloned_inner = cloned.as_mut().expect("clone of Some must be Some");
        assert_eq!(cloned_inner.callback, toggle_cb(record_toggle));
        // the RefAny is shared, not deep-copied: a write through the clone is
        // visible through the original.
        *cloned_inner
            .refany
            .downcast_mut::<usize>()
            .expect("payload type is preserved") = 42;
        let original_inner = some.as_mut().unwrap();
        assert_eq!(*original_inner.refany.downcast_ref::<usize>().unwrap(), 42);
    }
    // ------------------------------------------------------------------
    // on_accordion_header_click
    // ------------------------------------------------------------------
    #[test]
    fn header_click_without_any_layout_result_is_a_noop() {
        let mut data = RefAny::new(HeaderClickData {
            index: 0,
            is_open: false,
            on_toggle: None.into(),
        });
        let (update, changes) = run_click(None, 0, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "nothing may be restyled without a body");
        assert!(!payload_is_open(&mut data), "state must not flip");
    }
    #[test]
    fn header_click_without_next_sibling_does_not_flip_state() {
        // node 2 is the *last* child -> no next sibling -> early return, and
        // crucially `is_open` must NOT have been toggled.
        let mut data = RefAny::new(HeaderClickData {
            index: 3,
            is_open: true,
            on_toggle: None.into(),
        });
        let (update, changes) = run_click(Some(header_body_dom()), 2, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(payload_is_open(&mut data), "state must be untouched");
    }
    #[test]
    fn header_click_with_stale_hit_node_is_a_noop() {
        let mut data = RefAny::new(HeaderClickData {
            index: 0,
            is_open: false,
            on_toggle: None.into(),
        });
        // node 999 does not exist in the 3-node fixture
        let (update, changes) = run_click(Some(header_body_dom()), 999, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(!payload_is_open(&mut data));
    }
    #[test]
    fn header_click_with_foreign_payload_is_a_noop() {
        // the callback-bearing node carries a RefAny of the *wrong* type
        let data = RefAny::new(0xdead_beef_u64);
        let (update, changes) = run_click(Some(header_body_dom()), 1, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert!(
            changes.is_empty(),
            "a foreign payload must not restyle the body"
        );
    }
    #[test]
    fn header_click_toggles_body_display_and_flips_state() {
        let mut data = RefAny::new(HeaderClickData {
            index: 0,
            is_open: false,
            on_toggle: None.into(),
        });
        // closed -> open
        let (update, changes) = run_click(Some(header_body_dom()), 1, data.clone());
        assert_eq!(update, Update::DoNothing, "no user callback -> DoNothing");
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(2usize, LayoutDisplay::Block)]
        );
        assert!(payload_is_open(&mut data));
        // open -> closed (same payload, so the flip must be stateful)
        let (update, changes) = run_click(Some(header_body_dom()), 1, data.clone());
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(2usize, LayoutDisplay::None)]
        );
        assert!(!payload_is_open(&mut data));
    }
    #[test]
    fn header_click_invokes_user_callback_and_propagates_its_update() {
        let mut log = RefAny::new(ToggleLog { calls: Vec::new() });
        let data = RefAny::new(HeaderClickData {
            index: 17,
            is_open: false,
            on_toggle: Some(AccordionOnToggle {
                callback: toggle_cb(record_toggle),
                refany: log.clone(),
            })
            .into(),
        });
        let (update, changes) = run_click(Some(header_body_dom()), 1, data.clone());
        // the user's return value wins over the internal DoNothing
        assert_eq!(update, Update::RefreshDom);
        // ...and the body is still restyled, even though the user callback ran
        assert_eq!(
            display_writes(&changes),
            alloc::vec![(2usize, LayoutDisplay::Block)]
        );
        assert_eq!(
            log.downcast_ref::<ToggleLog>().unwrap().calls.as_slice(),
            &[17],
            "the user callback must receive this section's index"
        );
        // a second click reports the same index again
        let (_, _) = run_click(Some(header_body_dom()), 1, data);
        assert_eq!(
            log.downcast_ref::<ToggleLog>().unwrap().calls.as_slice(),
            &[17, 17]
        );
    }
}