Skip to main content

azul_layout/widgets/
accordion.rs

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
21use std::vec::Vec;
22
23use 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};
31use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
32use 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
43use crate::callbacks::{Callback, CallbackInfo};
44
45static ACCORDION_CLASS: &[IdOrClass] =
46    &[Class(AzString::from_const_str("__azul-native-accordion"))];
47static ACCORDION_SECTION_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
48    "__azul-native-accordion-section",
49))];
50static ACCORDION_HEADER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
51    "__azul-native-accordion-header",
52))];
53static ACCORDION_TITLE_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
54    "__azul-native-accordion-title",
55))];
56static ACCORDION_BODY_CLASS: &[IdOrClass] =
57    &[Class(AzString::from_const_str("__azul-native-accordion-body"))];
58
59const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
60const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
61const 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.
66pub type AccordionOnToggleCallbackType = extern "C" fn(RefAny, CallbackInfo, usize) -> Update;
67impl_widget_callback!(
68    AccordionOnToggle,
69    OptionAccordionOnToggle,
70    AccordionOnToggleCallback,
71    AccordionOnToggleCallbackType
72);
73
74azul_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 ----
88const BORDER_COLOR: ColorU = ColorU { r: 222, g: 226, b: 230, a: 255 }; // #dee2e6
89const HEADER_BG: ColorU = ColorU { r: 248, g: 249, b: 250, a: 255 }; // #f8f9fa
90const TEXT_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 }; // #212529
91
92const HEADER_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(HEADER_BG)];
93const 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)]
99pub 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
108impl AccordionSection {
109    /// Creates a new collapsed section with the given title and content.
110    pub fn new<S: Into<AzString>>(title: S, content: Dom) -> Self {
111        Self {
112            title: title.into(),
113            content,
114            is_open: false,
115        }
116    }
117
118    /// Builder method: sets the initial open state.
119    #[must_use] pub const fn with_open(mut self, open: bool) -> Self {
120        self.is_open = open;
121        self
122    }
123}
124
125impl_option!(AccordionSection, OptionAccordionSection, copy = false, [Debug, Clone, PartialEq, Eq]);
126impl_vec!(
127    AccordionSection,
128    AccordionSectionVec,
129    AccordionSectionVecDestructor,
130    AccordionSectionVecDestructorType,
131    AccordionSectionVecSlice,
132    OptionAccordionSection
133);
134impl_vec_clone!(AccordionSection, AccordionSectionVec, AccordionSectionVecDestructor);
135impl_vec_debug!(AccordionSection, AccordionSectionVec);
136impl_vec_partialeq!(AccordionSection, AccordionSectionVec);
137impl_vec_mut!(AccordionSection, AccordionSectionVec);
138
139/// A vertical stack of collapsible titled sections.
140#[derive(Debug, Clone, PartialEq)]
141#[repr(C)]
142pub 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
151static 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
224static 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
246static 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
268static 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.
274static 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.
291static ACCORDION_BODY_STYLE_CLOSED: &[CssPropertyWithConditions] = &[
292    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::None)),
293];
294
295impl Accordion {
296    /// Creates a new accordion from the given sections, with no toggle callback.
297    #[must_use] pub fn new(sections: AccordionSectionVec) -> Self {
298        Self {
299            sections,
300            on_toggle: None.into(),
301        }
302    }
303
304    /// Creates an empty accordion.
305    #[must_use] pub fn create() -> Self {
306        Self::new(AccordionSectionVec::from_const_slice(&[]))
307    }
308
309    /// Sets the callback invoked when any section header is toggled.
310    pub fn set_on_toggle<C: Into<AccordionOnToggleCallback>>(&mut self, data: RefAny, callback: C) {
311        self.on_toggle = Some(AccordionOnToggle {
312            callback: callback.into(),
313            refany: data,
314        })
315        .into();
316    }
317
318    /// Builder method: sets the toggle callback.
319    #[must_use] pub fn with_on_toggle<C: Into<AccordionOnToggleCallback>>(
320        mut self,
321        data: RefAny,
322        callback: C,
323    ) -> Self {
324        self.set_on_toggle(data, callback);
325        self
326    }
327
328    /// Replaces `self` with an empty default accordion and returns the original.
329    #[must_use] pub fn swap_with_default(&mut self) -> Self {
330        let mut s = Self::create();
331        core::mem::swap(&mut s, self);
332        s
333    }
334
335    /// Renders the accordion into a [`Dom`] subtree.
336    #[must_use] pub fn dom(self) -> Dom {
337        let on_toggle = self.on_toggle;
338        let sections = self.sections;
339
340        let mut section_doms: Vec<Dom> = Vec::with_capacity(sections.as_ref().len());
341
342        for (index, section) in sections.as_ref().iter().enumerate() {
343            let title = Dom::create_text(section.title.clone())
344                .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_TITLE_CLASS))
345                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
346                    ACCORDION_TITLE_STYLE,
347                ));
348
349            // Per-header self-contained click data (mirrors tree_view's NodeClickData).
350            let header_data = HeaderClickData {
351                index,
352                is_open: section.is_open,
353                on_toggle: clone_option_on_toggle(&on_toggle),
354            };
355
356            let header = Dom::create_div()
357                .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_HEADER_CLASS))
358                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
359                    ACCORDION_HEADER_STYLE,
360                ))
361                .with_tab_index(TabIndex::Auto)
362                .with_callbacks(
363                    alloc::vec![CoreCallbackData {
364                        event: EventFilter::Hover(HoverEventFilter::MouseUp),
365                        callback: CoreCallback {
366                            cb: on_accordion_header_click as usize,
367                            ctx: OptionRefAny::None,
368                        },
369                        refany: RefAny::new(header_data),
370                    }]
371                    .into(),
372                )
373                .with_children(DomVec::from_vec(alloc::vec![title]));
374
375            let body_style = if section.is_open {
376                ACCORDION_BODY_STYLE_OPEN
377            } else {
378                ACCORDION_BODY_STYLE_CLOSED
379            };
380            let body = Dom::create_div()
381                .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_BODY_CLASS))
382                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(body_style))
383                .with_children(DomVec::from_vec(alloc::vec![section.content.clone()]));
384
385            section_doms.push(
386                Dom::create_div()
387                    .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_SECTION_CLASS))
388                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
389                        ACCORDION_SECTION_STYLE,
390                    ))
391                    .with_children(DomVec::from_vec(alloc::vec![header, body])),
392            );
393        }
394
395        Dom::create_div()
396            .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_CLASS))
397            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
398                ACCORDION_CONTAINER_STYLE,
399            ))
400            .with_children(DomVec::from_vec(section_doms))
401    }
402}
403
404impl Default for Accordion {
405    fn default() -> Self {
406        Self::create()
407    }
408}
409
410/// Clones an `OptionAccordionOnToggle` (the callback wrapper is not `Copy`).
411fn clone_option_on_toggle(opt: &OptionAccordionOnToggle) -> OptionAccordionOnToggle {
412    match opt.as_ref() {
413        Some(AccordionOnToggle { callback, refany }) => Some(AccordionOnToggle {
414            callback: callback.clone(),
415            refany: refany.clone(),
416        })
417        .into(),
418        None => None.into(),
419    }
420}
421
422/// Per-header callback payload (kept internal, like `tree_view::NodeClickData`).
423struct 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`.
433extern "C" fn on_accordion_header_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
434    let header = info.get_hit_node();
435    let Some(body) = info.get_next_sibling(header) else {
436        return Update::DoNothing;
437    };
438
439    let (now_open, result) = {
440        let Some(mut hd) = data.downcast_mut::<HeaderClickData>() else {
441            return Update::DoNothing;
442        };
443        hd.is_open = !hd.is_open;
444        let now_open = hd.is_open;
445        let index = hd.index;
446        let result = match hd.on_toggle.as_mut() {
447            Some(AccordionOnToggle { callback, refany }) => {
448                (callback.cb)(refany.clone(), info, index)
449            }
450            None => Update::DoNothing,
451        };
452        (now_open, result)
453    };
454
455    let display = if now_open {
456        LayoutDisplay::Block
457    } else {
458        LayoutDisplay::None
459    };
460    info.set_css_property(body, CssProperty::const_display(display));
461
462    result
463}
464
465impl From<Accordion> for Dom {
466    fn from(a: Accordion) -> Self {
467        a.dom()
468    }
469}
470
471#[cfg(all(test, feature = "std"))]
472mod 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, IdOrClass::Class(s) if s.as_str() == name))
511    }
512
513    /// The text of a `NodeType::Text` node (`None` for any other node type).
514    fn text_of(node: &Dom) -> Option<&str> {
515        match node.root.get_node_type() {
516            NodeType::Text(s) => Some(s.as_ref().as_str()),
517            _ => None,
518        }
519    }
520
521    /// The `display` value in a node's *inline* style, if it sets one.
522    fn inline_display(node: &Dom) -> Option<LayoutDisplay> {
523        node.root
524            .style
525            .iter_inline_properties()
526            .find_map(|(p, _)| match p {
527                CssProperty::Display(v) => v.get_property().copied(),
528                _ => None,
529            })
530    }
531
532    /// `(header, body)` of the `n`-th section of a rendered accordion DOM.
533    fn section_parts(dom: &Dom, n: usize) -> (&Dom, &Dom) {
534        let section = &dom.children.as_ref()[n];
535        assert!(has_class(section, "__azul-native-accordion-section"));
536        let children = section.children.as_ref();
537        assert_eq!(children.len(), 2, "a section is exactly [header, body]");
538        (&children[0], &children[1])
539    }
540
541    /// A three-node styled DOM โ€” `root(0)` with children `header(1)` and
542    /// `body(2)` โ€” i.e. the exact hierarchy `on_accordion_header_click` walks
543    /// (`hit node` -> `next sibling`).
544    fn header_body_dom() -> StyledDom {
545        let styled = StyledDom::create_from_dom(
546            Dom::create_div()
547                .with_child(Dom::create_div())
548                .with_child(Dom::create_div()),
549        );
550        assert_eq!(
551            styled.node_hierarchy.as_ref().len(),
552            3,
553            "fixture must flatten to exactly root/header/body"
554        );
555        styled
556    }
557
558    /// A `DomLayoutResult` with an *empty* layout tree: the click handler only
559    /// walks `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
560    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
561        DomLayoutResult {
562            styled_dom,
563            layout_tree: LayoutTree {
564                nodes: Vec::new(),
565                warm: Vec::new(),
566                cold: Vec::new(),
567                root: 0,
568                dom_to_layout: BTreeMap::new(),
569                children_arena: Vec::new(),
570                children_offsets: Vec::new(),
571                subtree_needs_intrinsic: Vec::new(),
572            },
573            calculated_positions: Vec::new(),
574            viewport: LogicalRect::zero(),
575            display_list: DisplayList::default(),
576            scroll_ids: HashMap::new(),
577            scroll_id_to_node_id: HashMap::new(),
578        }
579    }
580
581    /// Invokes `on_accordion_header_click` against a `LayoutWindow` holding
582    /// `styled` (or nothing at all, when `styled` is `None`), with `hit` as the
583    /// hit node. Returns the `Update` plus every recorded `CallbackChange`.
584    fn run_click(styled: Option<StyledDom>, hit: usize, data: RefAny) -> (Update, Vec<CallbackChange>) {
585        let mut layout_window =
586            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
587        if let Some(sd) = styled {
588            layout_window
589                .layout_results
590                .insert(DomId::ROOT_ID, layout_result(sd));
591        }
592
593        let renderer_resources = RendererResources::default();
594        let previous_window_state: Option<FullWindowState> = None;
595        let current_window_state = FullWindowState::default();
596        let gl_context = OptionGlContextPtr::None;
597        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
598            BTreeMap::new();
599        let window_handle = RawWindowHandle::Unsupported;
600        let system_callbacks = ExternalSystemCallbacks::rust_internal();
601
602        let ref_data = CallbackInfoRefData {
603            layout_window: &layout_window,
604            renderer_resources: &renderer_resources,
605            previous_window_state: &previous_window_state,
606            current_window_state: &current_window_state,
607            gl_context: &gl_context,
608            current_scroll_manager: &scroll_states,
609            current_window_handle: &window_handle,
610            system_callbacks: &system_callbacks,
611            system_style: Arc::new(SystemStyle::default()),
612            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
613            #[cfg(feature = "icu")]
614            icu_localizer: IcuLocalizerHandle::default(),
615            ctx: OptionRefAny::None,
616        };
617
618        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
619
620        let info = CallbackInfo::new(
621            &ref_data,
622            &changes,
623            DomNodeId {
624                dom: DomId::ROOT_ID,
625                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
626            },
627            OptionLogicalPosition::None,
628            OptionLogicalPosition::None,
629        );
630
631        let update = on_accordion_header_click(data, info);
632        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
633        (update, recorded)
634    }
635
636    /// Every `display` write recorded in the change log, as `(node index, display)`.
637    fn display_writes(changes: &[CallbackChange]) -> Vec<(usize, LayoutDisplay)> {
638        let mut out = Vec::new();
639        for change in changes {
640            if let CallbackChange::ChangeNodeCssProperties {
641                node_id, properties, ..
642            } = change
643            {
644                for p in properties.as_ref() {
645                    if let CssProperty::Display(v) = p {
646                        if let Some(d) = v.get_property() {
647                            out.push((node_id.index(), *d));
648                        }
649                    }
650                }
651            }
652        }
653        out
654    }
655
656    /// `is_open` of a `HeaderClickData` payload.
657    fn payload_is_open(data: &mut RefAny) -> bool {
658        data.downcast_ref::<HeaderClickData>()
659            .expect("payload must still be a HeaderClickData")
660            .is_open
661    }
662
663    /// Records the section indices it is invoked with; used as a user `on_toggle`.
664    struct ToggleLog {
665        calls: Vec<usize>,
666    }
667
668    extern "C" fn record_toggle(mut data: RefAny, _: CallbackInfo, index: usize) -> Update {
669        if let Some(mut log) = data.downcast_mut::<ToggleLog>() {
670            log.calls.push(index);
671        }
672        Update::RefreshDom
673    }
674
675    extern "C" fn toggle_do_nothing(_: RefAny, _: CallbackInfo, _: usize) -> Update {
676        Update::DoNothing
677    }
678
679    fn toggle_cb(f: AccordionOnToggleCallbackType) -> AccordionOnToggleCallback {
680        f.into()
681    }
682
683    // ------------------------------------------------------------------
684    // AccordionSection::new / with_open  (constructor, invariants)
685    // ------------------------------------------------------------------
686
687    #[test]
688    fn section_new_stores_args_and_starts_closed() {
689        let content = Dom::create_div().with_child(Dom::create_text("body"));
690        let sec = AccordionSection::new("Title", content.clone());
691
692        assert_eq!(sec.title.as_str(), "Title");
693        assert_eq!(sec.content, content);
694        assert!(!sec.is_open, "a fresh section must start collapsed");
695    }
696
697    #[test]
698    fn section_new_survives_extreme_titles() {
699        // empty, interior NUL, emoji + combining marks + RTL, and a 100k-char title
700        let long = "ab".repeat(50_000);
701        let cases: Vec<AzString> = alloc::vec![
702            AzString::from(""),
703            AzString::from("a\0b"),
704            AzString::from("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ e\u{0301}\u{0327} ู…ุฑุญุจุง ืฉืœื•ื ๐Ÿ‡ฉ๐Ÿ‡ช"),
705            AzString::from("\u{feff}\u{202e}rtl-override"),
706            AzString::from(long.as_str()),
707        ];
708
709        for title in cases {
710            let sec = AccordionSection::new(title.clone(), Dom::create_div());
711            assert_eq!(sec.title.as_str(), title.as_str());
712            assert!(!sec.is_open);
713
714            // and the title survives the trip through the DOM unchanged
715            let dom = Accordion::new(AccordionSectionVec::from_vec(alloc::vec![sec])).dom();
716            let (header, _) = section_parts(&dom, 0);
717            let title_node = &header.children.as_ref()[0];
718            assert_eq!(text_of(title_node), Some(title.as_str()));
719        }
720    }
721
722    #[test]
723    fn section_with_open_sets_flag_without_touching_other_fields() {
724        let content = Dom::create_text("x");
725        let base = AccordionSection::new("t", content.clone());
726
727        let opened = base.clone().with_open(true);
728        assert!(opened.is_open);
729        assert_eq!(opened.title.as_str(), "t");
730        assert_eq!(opened.content, content);
731
732        // last write wins; applying the same value twice is idempotent
733        assert!(!base.clone().with_open(true).with_open(false).is_open);
734        assert!(base.clone().with_open(false).with_open(true).is_open);
735        assert!(base.clone().with_open(true).with_open(true).is_open);
736        assert!(!base.with_open(false).is_open);
737    }
738
739    // ------------------------------------------------------------------
740    // Accordion::new / create / Default
741    // ------------------------------------------------------------------
742
743    #[test]
744    fn accordion_new_preserves_section_count_and_has_no_callback() {
745        for count in [0usize, 1, 3, 1000] {
746            let mut sections = Vec::with_capacity(count);
747            for i in 0..count {
748                sections.push(
749                    AccordionSection::new(alloc::format!("s{i}"), Dom::create_div())
750                        .with_open(i % 2 == 0),
751                );
752            }
753            let acc = Accordion::new(AccordionSectionVec::from_vec(sections));
754
755            assert_eq!(acc.sections.len(), count);
756            assert!(acc.on_toggle.is_none(), "Accordion::new sets no callback");
757            for (i, s) in acc.sections.as_ref().iter().enumerate() {
758                assert_eq!(s.title.as_str(), alloc::format!("s{i}"));
759                assert_eq!(s.is_open, i % 2 == 0);
760            }
761        }
762    }
763
764    #[test]
765    fn accordion_create_is_empty_and_equals_default() {
766        let acc = Accordion::create();
767        assert!(acc.sections.is_empty());
768        assert!(acc.on_toggle.is_none());
769        assert_eq!(acc, Accordion::default());
770    }
771
772    // ------------------------------------------------------------------
773    // set_on_toggle / with_on_toggle / swap_with_default
774    // ------------------------------------------------------------------
775
776    #[test]
777    fn set_on_toggle_last_call_wins() {
778        let mut acc = Accordion::create();
779
780        acc.set_on_toggle(RefAny::new(1u8), toggle_cb(toggle_do_nothing));
781        assert!(acc.on_toggle.is_some());
782        assert_eq!(
783            acc.on_toggle.as_ref().unwrap().refany.get_type_id(),
784            RefAny::new(1u8).get_type_id()
785        );
786
787        // a second call must *replace* (not append / leak / panic)
788        acc.set_on_toggle(RefAny::new(9i64), toggle_cb(record_toggle));
789        let set = acc.on_toggle.as_ref().expect("still Some");
790        assert_eq!(set.refany.get_type_id(), RefAny::new(0i64).get_type_id());
791        assert_eq!(set.callback, toggle_cb(record_toggle));
792        assert_ne!(set.callback, toggle_cb(toggle_do_nothing));
793    }
794
795    #[test]
796    fn with_on_toggle_matches_set_on_toggle() {
797        let built = Accordion::create().with_on_toggle(RefAny::new(7u32), toggle_cb(record_toggle));
798
799        let mut mutated = Accordion::create();
800        mutated.set_on_toggle(RefAny::new(7u32), toggle_cb(record_toggle));
801
802        assert!(built.on_toggle.is_some());
803        assert_eq!(
804            built.on_toggle.as_ref().unwrap().callback,
805            mutated.on_toggle.as_ref().unwrap().callback
806        );
807        // the builder form must not disturb the sections
808        assert!(built.sections.is_empty());
809    }
810
811    #[test]
812    fn swap_with_default_moves_all_state_out() {
813        let sections = AccordionSectionVec::from_vec(alloc::vec![
814            AccordionSection::new("a", Dom::create_div()),
815            AccordionSection::new("b", Dom::create_div()).with_open(true),
816        ]);
817        let mut acc = Accordion::new(sections).with_on_toggle(RefAny::new(5u8), toggle_cb(record_toggle));
818
819        let original = acc.swap_with_default();
820
821        assert_eq!(original.sections.len(), 2);
822        assert!(original.on_toggle.is_some());
823        assert!(original.sections.as_ref()[1].is_open);
824
825        assert!(acc.sections.is_empty(), "self must be left empty");
826        assert!(acc.on_toggle.is_none(), "self must lose the callback");
827        assert_eq!(acc, Accordion::create());
828
829        // swapping an already-empty accordion is a no-op, not a panic
830        let second = acc.swap_with_default();
831        assert_eq!(second, Accordion::create());
832        assert_eq!(acc, Accordion::create());
833    }
834
835    // ------------------------------------------------------------------
836    // Accordion::dom
837    // ------------------------------------------------------------------
838
839    #[test]
840    fn dom_of_empty_accordion_has_no_children() {
841        let dom = Accordion::create().dom();
842        assert!(has_class(&dom, "__azul-native-accordion"));
843        assert!(dom.children.as_ref().is_empty());
844        assert_eq!(dom.estimated_total_children, 0);
845    }
846
847    #[test]
848    fn dom_display_follows_is_open() {
849        let acc = Accordion::new(AccordionSectionVec::from_vec(alloc::vec![
850            AccordionSection::new("closed", Dom::create_text("c0")),
851            AccordionSection::new("open", Dom::create_text("c1")).with_open(true),
852        ]));
853        let dom = acc.dom();
854        assert_eq!(dom.children.as_ref().len(), 2);
855
856        let (h0, b0) = section_parts(&dom, 0);
857        let (h1, b1) = section_parts(&dom, 1);
858
859        assert!(has_class(h0, "__azul-native-accordion-header"));
860        assert!(has_class(b0, "__azul-native-accordion-body"));
861
862        // a closed section is `display: none`, an open one `display: block`
863        assert_eq!(inline_display(b0), Some(LayoutDisplay::None));
864        assert_eq!(inline_display(b1), Some(LayoutDisplay::Block));
865
866        // the body wraps exactly the caller's content
867        assert_eq!(text_of(&b0.children.as_ref()[0]), Some("c0"));
868        assert_eq!(text_of(&b1.children.as_ref()[0]), Some("c1"));
869
870        // the header is focusable and carries exactly one MouseUp callback
871        for h in [h0, h1] {
872            assert!(matches!(h.root.get_tab_index(), Some(TabIndex::Auto)));
873            let cbs = h.root.get_callbacks();
874            assert_eq!(cbs.len(), 1);
875            assert_eq!(cbs.as_ref()[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
876            assert_eq!(
877                cbs.as_ref()[0].callback.cb,
878                on_accordion_header_click as usize
879            );
880        }
881    }
882
883    #[test]
884    fn dom_header_payload_carries_the_section_index_and_open_state() {
885        let count = 64usize;
886        let mut sections = Vec::with_capacity(count);
887        for i in 0..count {
888            sections.push(
889                AccordionSection::new(alloc::format!("s{i}"), Dom::create_div())
890                    .with_open(i % 3 == 0),
891            );
892        }
893        let dom = Accordion::new(AccordionSectionVec::from_vec(sections)).dom();
894
895        for i in 0..count {
896            let (header, body) = section_parts(&dom, i);
897            let mut payload = header.root.get_callbacks().as_ref()[0].refany.clone();
898            let hd = payload
899                .downcast_ref::<HeaderClickData>()
900                .expect("header payload is a HeaderClickData");
901
902            assert_eq!(hd.index, i, "each header must know its own section index");
903            assert_eq!(hd.is_open, i % 3 == 0);
904            assert!(hd.on_toggle.is_none(), "no user callback was set");
905            assert_eq!(
906                inline_display(body),
907                Some(if i % 3 == 0 {
908                    LayoutDisplay::Block
909                } else {
910                    LayoutDisplay::None
911                })
912            );
913        }
914    }
915
916    #[test]
917    fn dom_child_count_cache_stays_consistent() {
918        // deeply nested content + many sections: `estimated_total_children` must
919        // still equal the real descendant count, otherwise the compact-DOM arena
920        // under-allocates and panics later.
921        let mut deep = Dom::create_text("leaf");
922        for _ in 0..64 {
923            deep = Dom::create_div().with_child(deep);
924        }
925
926        let sections = AccordionSectionVec::from_vec(alloc::vec![
927            AccordionSection::new("deep", deep),
928            AccordionSection::new("flat", Dom::create_div()).with_open(true),
929            AccordionSection::new("", Dom::create_div()),
930        ]);
931        let dom = Accordion::new(sections).dom();
932
933        assert_eq!(
934            dom.estimated_total_children,
935            dom.recompute_estimated_total_children(),
936            "cached descendant count desynced from the real tree"
937        );
938    }
939
940    #[test]
941    fn from_accordion_for_dom_matches_dom() {
942        // Only meaningful for a section-less accordion: every `dom()` call mints
943        // fresh per-header `RefAny`s, and two distinct `RefAny`s never compare equal.
944        assert_eq!(Dom::from(Accordion::create()), Accordion::create().dom());
945    }
946
947    #[test]
948    fn dom_leaves_the_original_on_toggle_payload_alive() {
949        let log = RefAny::new(ToggleLog { calls: Vec::new() });
950        let mut kept = log.clone();
951
952        let acc = Accordion::new(AccordionSectionVec::from_vec(alloc::vec![
953            AccordionSection::new("a", Dom::create_div()),
954            AccordionSection::new("b", Dom::create_div()),
955        ]))
956        .with_on_toggle(log, toggle_cb(record_toggle));
957
958        let dom = acc.dom();
959
960        // every header got its own clone of the callback...
961        for i in 0..2 {
962            let (header, _) = section_parts(&dom, i);
963            let mut payload = header.root.get_callbacks().as_ref()[0].refany.clone();
964            let hd = payload.downcast_ref::<HeaderClickData>().unwrap();
965            assert!(hd.on_toggle.is_some());
966        }
967
968        // ...and the caller's handle to the shared payload is still valid (no free)
969        assert!(kept.downcast_ref::<ToggleLog>().unwrap().calls.is_empty());
970    }
971
972    // ------------------------------------------------------------------
973    // clone_option_on_toggle
974    // ------------------------------------------------------------------
975
976    #[test]
977    fn clone_option_on_toggle_of_none_is_none() {
978        let none: OptionAccordionOnToggle = None.into();
979        assert!(clone_option_on_toggle(&none).is_none());
980        // cloning the clone stays None
981        assert!(clone_option_on_toggle(&clone_option_on_toggle(&none)).is_none());
982    }
983
984    #[test]
985    fn clone_option_on_toggle_shares_the_payload() {
986        let mut some: OptionAccordionOnToggle = Some(AccordionOnToggle {
987            callback: toggle_cb(record_toggle),
988            refany: RefAny::new(0usize),
989        })
990        .into();
991
992        let mut cloned = clone_option_on_toggle(&some);
993        let cloned_inner = cloned.as_mut().expect("clone of Some must be Some");
994        assert_eq!(cloned_inner.callback, toggle_cb(record_toggle));
995
996        // the RefAny is shared, not deep-copied: a write through the clone is
997        // visible through the original.
998        *cloned_inner
999            .refany
1000            .downcast_mut::<usize>()
1001            .expect("payload type is preserved") = 42;
1002
1003        let original_inner = some.as_mut().unwrap();
1004        assert_eq!(*original_inner.refany.downcast_ref::<usize>().unwrap(), 42);
1005    }
1006
1007    // ------------------------------------------------------------------
1008    // on_accordion_header_click
1009    // ------------------------------------------------------------------
1010
1011    #[test]
1012    fn header_click_without_any_layout_result_is_a_noop() {
1013        let mut data = RefAny::new(HeaderClickData {
1014            index: 0,
1015            is_open: false,
1016            on_toggle: None.into(),
1017        });
1018
1019        let (update, changes) = run_click(None, 0, data.clone());
1020
1021        assert_eq!(update, Update::DoNothing);
1022        assert!(changes.is_empty(), "nothing may be restyled without a body");
1023        assert!(!payload_is_open(&mut data), "state must not flip");
1024    }
1025
1026    #[test]
1027    fn header_click_without_next_sibling_does_not_flip_state() {
1028        // node 2 is the *last* child -> no next sibling -> early return, and
1029        // crucially `is_open` must NOT have been toggled.
1030        let mut data = RefAny::new(HeaderClickData {
1031            index: 3,
1032            is_open: true,
1033            on_toggle: None.into(),
1034        });
1035
1036        let (update, changes) = run_click(Some(header_body_dom()), 2, data.clone());
1037
1038        assert_eq!(update, Update::DoNothing);
1039        assert!(changes.is_empty());
1040        assert!(payload_is_open(&mut data), "state must be untouched");
1041    }
1042
1043    #[test]
1044    fn header_click_with_stale_hit_node_is_a_noop() {
1045        let mut data = RefAny::new(HeaderClickData {
1046            index: 0,
1047            is_open: false,
1048            on_toggle: None.into(),
1049        });
1050
1051        // node 999 does not exist in the 3-node fixture
1052        let (update, changes) = run_click(Some(header_body_dom()), 999, data.clone());
1053
1054        assert_eq!(update, Update::DoNothing);
1055        assert!(changes.is_empty());
1056        assert!(!payload_is_open(&mut data));
1057    }
1058
1059    #[test]
1060    fn header_click_with_foreign_payload_is_a_noop() {
1061        // the callback-bearing node carries a RefAny of the *wrong* type
1062        let data = RefAny::new(0xdead_beef_u64);
1063
1064        let (update, changes) = run_click(Some(header_body_dom()), 1, data.clone());
1065
1066        assert_eq!(update, Update::DoNothing);
1067        assert!(
1068            changes.is_empty(),
1069            "a foreign payload must not restyle the body"
1070        );
1071    }
1072
1073    #[test]
1074    fn header_click_toggles_body_display_and_flips_state() {
1075        let mut data = RefAny::new(HeaderClickData {
1076            index: 0,
1077            is_open: false,
1078            on_toggle: None.into(),
1079        });
1080
1081        // closed -> open
1082        let (update, changes) = run_click(Some(header_body_dom()), 1, data.clone());
1083        assert_eq!(update, Update::DoNothing, "no user callback -> DoNothing");
1084        assert_eq!(
1085            display_writes(&changes),
1086            alloc::vec![(2usize, LayoutDisplay::Block)]
1087        );
1088        assert!(payload_is_open(&mut data));
1089
1090        // open -> closed (same payload, so the flip must be stateful)
1091        let (update, changes) = run_click(Some(header_body_dom()), 1, data.clone());
1092        assert_eq!(update, Update::DoNothing);
1093        assert_eq!(
1094            display_writes(&changes),
1095            alloc::vec![(2usize, LayoutDisplay::None)]
1096        );
1097        assert!(!payload_is_open(&mut data));
1098    }
1099
1100    #[test]
1101    fn header_click_invokes_user_callback_and_propagates_its_update() {
1102        let mut log = RefAny::new(ToggleLog { calls: Vec::new() });
1103        let data = RefAny::new(HeaderClickData {
1104            index: 17,
1105            is_open: false,
1106            on_toggle: Some(AccordionOnToggle {
1107                callback: toggle_cb(record_toggle),
1108                refany: log.clone(),
1109            })
1110            .into(),
1111        });
1112
1113        let (update, changes) = run_click(Some(header_body_dom()), 1, data.clone());
1114
1115        // the user's return value wins over the internal DoNothing
1116        assert_eq!(update, Update::RefreshDom);
1117        // ...and the body is still restyled, even though the user callback ran
1118        assert_eq!(
1119            display_writes(&changes),
1120            alloc::vec![(2usize, LayoutDisplay::Block)]
1121        );
1122        assert_eq!(
1123            log.downcast_ref::<ToggleLog>().unwrap().calls.as_slice(),
1124            &[17],
1125            "the user callback must receive this section's index"
1126        );
1127
1128        // a second click reports the same index again
1129        let (_, _) = run_click(Some(header_body_dom()), 1, data);
1130        assert_eq!(
1131            log.downcast_ref::<ToggleLog>().unwrap().calls.as_slice(),
1132            &[17, 17]
1133        );
1134    }
1135}