Skip to main content

azul_layout/widgets/
tree_view.rs

1//! Tree view widget with expandable/collapsible nodes.
2//!
3//! Provides [`TreeView`] and [`TreeViewNode`] for building hierarchical
4//! tree structures with click callbacks and recursive DOM rendering.
5
6use azul_core::{
7    callbacks::{CoreCallback, CoreCallbackData, Update},
8    dom::{
9        Dom, DomVec, EventFilter, HoverEventFilter, IdOrClass, IdOrClass::Class, IdOrClassVec,
10        TabIndex,
11    },
12    refany::RefAny,
13};
14#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
15use azul_css::{
16    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
17    props::{
18        basic::{
19            color::{ColorU, ColorOrSystem},
20            font::{StyleFontFamily, StyleFontFamilyVec},
21            *,
22        },
23        layout::*,
24        property::CssProperty,
25        style::*,
26    },
27    *,
28};
29
30use azul_css::{impl_option, impl_vec, impl_vec_clone, impl_vec_debug, impl_vec_partialeq, impl_vec_mut};
31
32use crate::callbacks::{Callback, CallbackInfo};
33
34// -- Callback type via macro --
35
36/// Callback invoked when a tree node is clicked.
37///
38/// The `usize` parameter is the depth-first index of the clicked node
39/// (0 = root, then incremented in pre-order traversal).
40pub type TreeViewOnNodeClickCallbackType = extern "C" fn(RefAny, CallbackInfo, usize) -> Update;
41impl_widget_callback!(
42    TreeViewOnNodeClick,
43    OptionTreeViewOnNodeClick,
44    TreeViewOnNodeClickCallback,
45    TreeViewOnNodeClickCallbackType
46);
47
48azul_core::impl_managed_callback! {
49    wrapper:        TreeViewOnNodeClickCallback,
50    info_ty:        CallbackInfo,
51    return_ty:      Update,
52    default_ret:    Update::DoNothing,
53    invoker_static: TREE_VIEW_ON_NODE_CLICK_INVOKER,
54    invoker_ty:     AzTreeViewOnNodeClickCallbackInvoker,
55    thunk_fn:       az_tree_view_on_node_click_callback_thunk,
56    setter_fn:      AzApp_setTreeViewOnNodeClickCallbackInvoker,
57    from_handle_fn: AzTreeViewOnNodeClickCallback_createFromHostHandle,
58    extra_args:     [ node_index: usize ],
59}
60
61// -- Font --
62
63const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
64const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
65const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
66    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
67
68// -- Colors --
69
70const TEXT_COLOR: ColorU = ColorU { r: 30, g: 30, b: 30, a: 255 };
71const SELECTED_BG: ColorU = ColorU { r: 0, g: 120, b: 215, a: 255 };
72const SELECTED_TEXT: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
73const HOVER_BG: ColorU = ColorU { r: 229, g: 243, b: 255, a: 255 };
74const ICON_COLOR: ColorU = ColorU { r: 100, g: 100, b: 100, a: 255 };
75
76// -- Tree container style --
77
78static TREE_CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
79    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
80    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
81    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
82    CssPropertyWithConditions::simple(CssProperty::const_font_family(SYSTEM_UI_FAMILY)),
83    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: TEXT_COLOR })),
84];
85
86// -- Row style (each tree node row) --
87
88static ROW_STYLE: &[CssPropertyWithConditions] = &[
89    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
90    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
91    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
92    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(2))),
93    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(LayoutPaddingBottom::const_px(2))),
94    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(4))),
95    CssPropertyWithConditions::simple(CssProperty::const_padding_right(LayoutPaddingRight::const_px(4))),
96    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
97    // Hover
98    CssPropertyWithConditions::on_hover(CssProperty::const_background_content(
99        StyleBackgroundContentVec::from_const_slice(&[StyleBackgroundContent::Color(HOVER_BG)]),
100    )),
101];
102
103// -- Selected row style --
104// NOTE: Intentionally duplicates base properties from ROW_STYLE because
105// const-slice styling does not support runtime composition. If you change
106// padding/layout in ROW_STYLE, update ROW_SELECTED_STYLE to match.
107
108static ROW_SELECTED_STYLE: &[CssPropertyWithConditions] = &[
109    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
110    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
111    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
112    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(2))),
113    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(LayoutPaddingBottom::const_px(2))),
114    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(4))),
115    CssPropertyWithConditions::simple(CssProperty::const_padding_right(LayoutPaddingRight::const_px(4))),
116    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
117    CssPropertyWithConditions::simple(CssProperty::const_background_content(
118        StyleBackgroundContentVec::from_const_slice(&[StyleBackgroundContent::Color(SELECTED_BG)]),
119    )),
120    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: SELECTED_TEXT })),
121];
122
123// -- Children container style --
124
125static CHILDREN_STYLE: &[CssPropertyWithConditions] = &[
126    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
127    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
128    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(16))),
129];
130
131// -- Disclosure icon style --
132// NOTE: Icon font-size (16px) must match LEAF_SPACER_STYLE width so that
133// leaf nodes align with parent nodes that have a disclosure icon.
134
135static ICON_STYLE: &[CssPropertyWithConditions] = &[
136    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(16))),
137    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
138    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: ICON_COLOR })),
139];
140
141// -- Leaf spacer (same width as icon, for alignment) --
142
143static LEAF_SPACER_STYLE: &[CssPropertyWithConditions] = &[
144    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(16))),
145    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
146];
147
148// -- Label style --
149
150static LABEL_STYLE: &[CssPropertyWithConditions] = &[
151    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
152    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(4))),
153];
154
155// ============================================================================
156// Data structures
157// ============================================================================
158
159/// A single node in a tree hierarchy, with optional children.
160#[derive(Debug, Clone, PartialEq)]
161#[repr(C)]
162pub struct TreeViewNode {
163    /// Display text for this node.
164    pub label: AzString,
165    /// Child nodes nested under this node.
166    pub children: TreeViewNodeVec,
167    /// Whether children are visible (only meaningful when `children` is non-empty).
168    pub is_expanded: bool,
169    /// Whether this node is visually selected.
170    pub is_selected: bool,
171}
172
173impl TreeViewNode {
174    /// Creates a new collapsed, unselected leaf node with the given label.
175    pub fn new<S: Into<AzString>>(label: S) -> Self {
176        Self {
177            label: label.into(),
178            children: TreeViewNodeVec::from_const_slice(&[]),
179            is_expanded: false,
180            is_selected: false,
181        }
182    }
183
184    /// Appends a child node.
185    pub fn add_child(&mut self, child: Self) {
186        self.children.push(child);
187    }
188
189    /// Builder method: appends a child node.
190    #[must_use] pub fn with_child(mut self, child: Self) -> Self {
191        self.children.push(child);
192        self
193    }
194
195    /// Builder method: sets the expanded state.
196    #[must_use] pub const fn with_expanded(mut self, expanded: bool) -> Self {
197        self.is_expanded = expanded;
198        self
199    }
200
201    /// Builder method: sets the selected state.
202    #[must_use] pub const fn with_selected(mut self, selected: bool) -> Self {
203        self.is_selected = selected;
204        self
205    }
206}
207
208impl_option!(TreeViewNode, OptionTreeViewNode, copy = false, [Debug, Clone, PartialEq]);
209impl_vec!(TreeViewNode, TreeViewNodeVec, TreeViewNodeVecDestructor, TreeViewNodeVecDestructorType, TreeViewNodeVecSlice, OptionTreeViewNode);
210impl_vec_clone!(TreeViewNode, TreeViewNodeVec, TreeViewNodeVecDestructor);
211impl_vec_debug!(TreeViewNode, TreeViewNodeVec);
212impl_vec_partialeq!(TreeViewNode, TreeViewNodeVec);
213impl_vec_mut!(TreeViewNode, TreeViewNodeVec);
214
215/// Hierarchical tree view widget with expandable/collapsible nodes.
216#[derive(Debug, Clone, PartialEq)]
217#[repr(C)]
218pub struct TreeView {
219    /// Root node of the tree hierarchy.
220    pub root: TreeViewNode,
221    /// Optional callback fired when any node is clicked.
222    pub on_node_click: OptionTreeViewOnNodeClick,
223}
224
225impl TreeView {
226    /// Creates a new tree view with the given root node and no click callback.
227    #[must_use] pub fn new(root: TreeViewNode) -> Self {
228        Self {
229            root,
230            on_node_click: None.into(),
231        }
232    }
233
234    /// Sets the callback invoked when any tree node is clicked.
235    pub fn set_on_node_click<C: Into<TreeViewOnNodeClickCallback>>(
236        &mut self,
237        data: RefAny,
238        callback: C,
239    ) {
240        self.on_node_click = Some(TreeViewOnNodeClick {
241            callback: callback.into(),
242            refany: data,
243        })
244        .into();
245    }
246
247    /// Builder method: sets the node-click callback.
248    #[must_use]
249    pub fn with_on_node_click<C: Into<TreeViewOnNodeClickCallback>>(
250        mut self,
251        data: RefAny,
252        callback: C,
253    ) -> Self {
254        self.set_on_node_click(data, callback);
255        self
256    }
257
258    /// Renders the tree view into a [`Dom`] subtree.
259    #[must_use] pub fn dom(self) -> Dom {
260        const TREE_CLASS: &[IdOrClass] =
261            &[Class(AzString::from_const_str("__azul-native-tree-view"))];
262
263        let on_node_click = self.on_node_click;
264        let root = self.root;
265
266        let mut children = Vec::new();
267        let mut index: usize = 0;
268        render_node(&root, &on_node_click, &mut index, &mut children);
269
270        Dom::create_div()
271            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(TREE_CONTAINER_STYLE))
272            .with_ids_and_classes(IdOrClassVec::from_const_slice(TREE_CLASS))
273            .with_children(DomVec::from_vec(children))
274    }
275}
276
277// ============================================================================
278// Internal: recursive DOM rendering
279// ============================================================================
280
281fn render_node(
282    node: &TreeViewNode,
283    on_click: &OptionTreeViewOnNodeClick,
284    index: &mut usize,
285    out: &mut Vec<Dom>,
286) {
287    let current_index = *index;
288    *index += 1;
289
290    let has_children = !node.children.as_slice().is_empty();
291
292    // Choose row style based on selection state
293    let row_style = if node.is_selected {
294        ROW_SELECTED_STYLE
295    } else {
296        ROW_STYLE
297    };
298
299    // Build the disclosure icon or spacer
300    let icon_or_spacer = if has_children {
301        let icon_name = if node.is_expanded {
302            "expand_more"
303        } else {
304            "chevron_right"
305        };
306        Dom::create_icon(AzString::from_const_str(icon_name))
307            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(ICON_STYLE))
308    } else {
309        // Empty spacer for leaf alignment
310        Dom::create_div()
311            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(LEAF_SPACER_STYLE))
312    };
313
314    // Build the label
315    let label = Dom::create_text(node.label.clone())
316        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(LABEL_STYLE));
317
318    // Build the row with click callback
319    let mut row = Dom::create_div()
320        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(row_style))
321        .with_tab_index(TabIndex::Auto)
322        .with_children(DomVec::from_vec(vec![icon_or_spacer, label]));
323
324    // Attach click callback if provided
325    if let Some(cb) = on_click.as_ref() {
326        let cb_data = NodeClickData {
327            node_index: current_index,
328            on_node_click: Some(TreeViewOnNodeClick {
329                callback: cb.callback.clone(),
330                refany: cb.refany.clone(),
331            })
332            .into(),
333        };
334        row = row.with_callbacks(
335            vec![CoreCallbackData {
336                event: EventFilter::Hover(HoverEventFilter::MouseUp),
337                refany: RefAny::new(cb_data),
338                callback: CoreCallback {
339                    cb: on_tree_node_click as usize,
340                    ctx: azul_core::refany::OptionRefAny::None,
341                },
342            }]
343            .into(),
344        );
345    }
346
347    out.push(row);
348
349    // Render children if expanded
350    if has_children && node.is_expanded {
351        let mut child_doms = Vec::new();
352        for child in node.children.as_slice() {
353            render_node(child, on_click, index, &mut child_doms);
354        }
355
356        let children_container = Dom::create_div()
357            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(CHILDREN_STYLE))
358            .with_children(DomVec::from_vec(child_doms));
359
360        out.push(children_container);
361    } else if has_children {
362        // Still count collapsed children for correct depth-first indexing
363        count_descendants(node.children.as_slice(), index);
364    }
365}
366
367/// Advance the index counter past all descendants without rendering them.
368fn count_descendants(nodes: &[TreeViewNode], index: &mut usize) {
369    for node in nodes {
370        *index += 1;
371        if !node.children.as_slice().is_empty() {
372            count_descendants(node.children.as_slice(), index);
373        }
374    }
375}
376
377// ============================================================================
378// Internal callback data
379// ============================================================================
380
381struct NodeClickData {
382    node_index: usize,
383    on_node_click: OptionTreeViewOnNodeClick,
384}
385
386// ============================================================================
387// Callbacks
388// ============================================================================
389
390extern "C" fn on_tree_node_click(mut refany: RefAny, info: CallbackInfo) -> Update {
391    let Some(mut refany) = refany.downcast_mut::<NodeClickData>() else {
392        return Update::DoNothing;
393    };
394
395    let node_index = refany.node_index;
396
397    match refany.on_node_click.as_mut() {
398        Some(TreeViewOnNodeClick { refany, callback }) => {
399            (callback.cb)(refany.clone(), info, node_index)
400        }
401        None => Update::DoNothing,
402    }
403}
404
405// ============================================================================
406// Trait impls
407// ============================================================================
408
409impl From<TreeView> for Dom {
410    fn from(tv: TreeView) -> Self {
411        tv.dom()
412    }
413}
414
415#[cfg(test)]
416mod autotest_generated {
417    use std::{
418        collections::BTreeMap,
419        sync::{Arc, Mutex},
420    };
421
422    use azul_core::{
423        dom::{DomId, DomNodeId, NodeId, NodeType},
424        geom::OptionLogicalPosition,
425        gl::OptionGlContextPtr,
426        hit_test::ScrollPosition,
427        refany::OptionRefAny,
428        resources::RendererResources,
429        styled_dom::NodeHierarchyItemId,
430        window::{MonitorVec, RawWindowHandle},
431    };
432    use azul_css::system::SystemStyle;
433    use rust_fontconfig::FcFontCache;
434
435    use super::*;
436    #[cfg(feature = "icu")]
437    use crate::icu::IcuLocalizerHandle;
438    use crate::{
439        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
440        window::LayoutWindow,
441        window_state::FullWindowState,
442    };
443
444    // ------------------------------------------------------------------
445    // Fixtures: trees
446    // ------------------------------------------------------------------
447
448    fn leaf(label: &str) -> TreeViewNode {
449        TreeViewNode::new(label)
450    }
451
452    /// Total node count of a subtree: the node itself plus every descendant,
453    /// expanded or not. This is the quantity `render_node` must advance the
454    /// index counter by, whatever the expansion state.
455    fn subtree_len(node: &TreeViewNode) -> usize {
456        1 + node
457            .children
458            .as_slice()
459            .iter()
460            .map(subtree_len)
461            .sum::<usize>()
462    }
463
464    /// A root with `n` leaf children.
465    fn wide(n: usize, expanded: bool) -> TreeViewNode {
466        let mut root = leaf("wide").with_expanded(expanded);
467        for i in 0..n {
468            root.add_child(leaf(&format!("c{i}")));
469        }
470        root
471    }
472
473    /// A left-spine chain `depth` nodes deep; `expanded` is applied to every
474    /// level. Built bottom-up so *construction* is iterative — only the
475    /// functions under test recurse.
476    fn chain(depth: usize, expanded: bool) -> TreeViewNode {
477        assert!(depth >= 1, "a chain has at least the root");
478        let mut node = leaf("tip").with_expanded(expanded);
479        for i in 1..depth {
480            node = leaf(&format!("n{i}"))
481                .with_child(node)
482                .with_expanded(expanded);
483        }
484        node
485    }
486
487    /// Four levels with alternating expansion, so both `render_node` branches
488    /// nest inside each other.
489    fn deep_mixed() -> TreeViewNode {
490        leaf("root")
491            .with_expanded(true)
492            .with_child(
493                leaf("a")
494                    .with_expanded(false) // collapsed: a1/a1x are counted, not drawn
495                    .with_child(leaf("a1").with_expanded(true).with_child(leaf("a1x"))),
496            )
497            .with_child(
498                leaf("b")
499                    .with_expanded(true)
500                    .with_child(leaf("b1"))
501                    .with_child(leaf("b2").with_expanded(true).with_child(leaf("b2x"))),
502            )
503            .with_child(leaf("c").with_selected(true))
504    }
505
506    /// Every shape whose combination of branches `render_node` /
507    /// `count_descendants` can take: leaves, expanded-but-childless nodes,
508    /// collapsed parents, expanded parents, an expanded subtree buried under a
509    /// collapsed one, and a collapsed subtree under an expanded one.
510    fn shapes() -> Vec<TreeViewNode> {
511        vec![
512            leaf("solo"),
513            leaf("solo-expanded").with_expanded(true), // expanded but childless
514            leaf("solo-selected").with_selected(true),
515            leaf("p").with_child(leaf("a")).with_child(leaf("b")),
516            leaf("p")
517                .with_child(leaf("a"))
518                .with_child(leaf("b"))
519                .with_expanded(true),
520            leaf("p")
521                .with_child(leaf("a").with_expanded(true).with_child(leaf("a1")))
522                .with_expanded(true),
523            leaf("p").with_child(leaf("a").with_expanded(true).with_child(leaf("a1"))),
524            leaf("p")
525                .with_child(leaf("a").with_child(leaf("a1")))
526                .with_expanded(true),
527            deep_mixed(),
528            wide(64, false),
529            wide(64, true),
530        ]
531    }
532
533    /// Labels chosen to break naive string handling: empty, whitespace-only,
534    /// embedded NUL (`AzString` is length-based, so it must not truncate),
535    /// ZWJ emoji, RTL, stacked combining marks, zero-width/BOM, bidi override,
536    /// control chars, and a string that looks like an icon name.
537    fn pathological_labels() -> Vec<String> {
538        vec![
539            String::new(),
540            "   ".to_string(),
541            "a\u{0}b".to_string(),
542            "👨‍👩‍👧‍👦".to_string(),
543            "مرحبا".to_string(),
544            "e\u{0301}\u{0301}\u{0301}".to_string(),
545            "\u{200b}\u{feff}".to_string(),
546            "\u{202e}gnirts".to_string(),
547            "line\nbreak\ttab\r".to_string(),
548            "chevron_right".to_string(),
549            "x".repeat(100_000),
550        ]
551    }
552
553    /// Runs `f` on a thread with a roomy stack. `render_node`,
554    /// `count_descendants` and `TreeViewNode`'s drop glue all recurse once per
555    /// tree level, and a blown stack aborts the whole test binary instead of
556    /// failing one test — the explicit stack keeps the depth assertions
557    /// meaningful rather than a coin flip on the harness default.
558    fn on_big_stack<F: FnOnce() + Send + 'static>(f: F) {
559        std::thread::Builder::new()
560            .stack_size(64 * 1024 * 1024)
561            .spawn(f)
562            .expect("spawning the deep-recursion thread failed")
563            .join()
564            .expect("deep-recursion thread panicked");
565    }
566
567    // ------------------------------------------------------------------
568    // Fixtures: DOM inspection
569    // ------------------------------------------------------------------
570
571    fn text_of(dom: &Dom) -> Option<&str> {
572        match dom.root.get_node_type() {
573            NodeType::Text(s) => Some(s.as_ref().as_str()),
574            _ => None,
575        }
576    }
577
578    fn icon_of(dom: &Dom) -> Option<&str> {
579        match dom.root.get_node_type() {
580            NodeType::Icon(s) => Some(s.as_ref().as_str()),
581            _ => None,
582        }
583    }
584
585    /// True when a node's inline style is exactly the given const style slice.
586    fn style_is(dom: &Dom, expected: &'static [CssPropertyWithConditions]) -> bool {
587        *dom.root.get_style()
588            == azul_css::css::Css::from(CssPropertyWithConditionsVec::from_const_slice(expected))
589    }
590
591    /// The `(icon-or-spacer, label)` pair of a rendered row.
592    fn row_parts(row: &Dom) -> (&Dom, &Dom) {
593        let ch = row.children.as_ref();
594        assert_eq!(ch.len(), 2, "every row is [icon|spacer, label]");
595        (&ch[0], &ch[1])
596    }
597
598    /// Every rendered row in `nodes`, in visual order. Rows are the only nodes
599    /// `render_node` gives a tab index to; everything else at this level is a
600    /// children container, which is recursed into.
601    fn collect_rows<'a>(nodes: &'a [Dom], out: &mut Vec<&'a Dom>) {
602        for n in nodes {
603            if n.root.get_tab_index().is_some() {
604                out.push(n);
605            } else {
606                collect_rows(n.children.as_ref(), out);
607            }
608        }
609    }
610
611    fn rows_of(nodes: &[Dom]) -> Vec<&Dom> {
612        let mut out = Vec::new();
613        collect_rows(nodes, &mut out);
614        out
615    }
616
617    /// The `node_index` the row's click payload carries (`None` when the row
618    /// has no callback attached).
619    fn click_index_of(row: &Dom) -> Option<usize> {
620        let mut data = row.root.get_callbacks().as_ref().first()?.refany.clone();
621        let payload = data
622            .downcast_ref::<NodeClickData>()
623            .expect("a row callback payload is always a NodeClickData");
624        let index = payload.node_index;
625        drop(payload);
626        Some(index)
627    }
628
629    /// `(node_index, label)` for every rendered row.
630    fn rendered_pairs(nodes: &[Dom]) -> Vec<(usize, String)> {
631        rows_of(nodes)
632            .iter()
633            .map(|row| {
634                let (_, label) = row_parts(row);
635                (
636                    click_index_of(row).expect("row must carry a click payload"),
637                    text_of(label)
638                        .expect("a row's second child is the label text node")
639                        .to_string(),
640                )
641            })
642            .collect()
643    }
644
645    /// Independent reference model of what `render_node` should emit:
646    /// pre-order over the *whole* tree, but only visible nodes produce a row.
647    /// Written from the documented contract, not from the implementation.
648    fn expected_pairs(node: &TreeViewNode, next: &mut usize, out: &mut Vec<(usize, String)>) {
649        let index = *next;
650        *next += 1;
651        out.push((index, node.label.as_str().to_string()));
652
653        let children = node.children.as_slice();
654        if node.is_expanded && !children.is_empty() {
655            for c in children {
656                expected_pairs(c, next, out);
657            }
658        } else {
659            // Hidden descendants still consume indices.
660            *next += subtree_len(node) - 1;
661        }
662    }
663
664    fn expected_of(tree: &TreeViewNode, start: usize) -> Vec<(usize, String)> {
665        let mut next = start;
666        let mut out = Vec::new();
667        expected_pairs(tree, &mut next, &mut out);
668        out
669    }
670
671    /// The true recursive descendant count — what `estimated_total_children`
672    /// caches and what `convert_dom_into_compact_dom` allocates from.
673    fn recursive_descendants(dom: &Dom) -> usize {
674        dom.children
675            .as_ref()
676            .iter()
677            .map(|c| 1 + recursive_descendants(c))
678            .sum()
679    }
680
681    fn assert_estimates_consistent(dom: &Dom) {
682        assert_eq!(
683            dom.estimated_total_children,
684            recursive_descendants(dom),
685            "estimated_total_children desynced from the real subtree size"
686        );
687        for c in dom.children.as_ref() {
688            assert_estimates_consistent(c);
689        }
690    }
691
692    // ------------------------------------------------------------------
693    // Fixtures: callbacks
694    // ------------------------------------------------------------------
695
696    type ClickLog = Arc<Mutex<Vec<usize>>>;
697
698    /// Offset applied by `record_click_all_windows` so the two recorders stay
699    /// distinguishable in the log.
700    const SENTINEL: usize = 1_000_000;
701
702    extern "C" fn record_click(mut data: RefAny, _info: CallbackInfo, node_index: usize) -> Update {
703        if let Some(log) = data.downcast_ref::<ClickLog>() {
704            log.lock().expect("click log poisoned").push(node_index);
705        }
706        Update::RefreshDom
707    }
708
709    /// A second callback with a *deliberately different body*: two identical
710    /// `extern "C"` bodies are fair game for identical-code folding, which
711    /// would merge their addresses and make "last write wins" vacuous.
712    extern "C" fn record_click_all_windows(
713        mut data: RefAny,
714        _info: CallbackInfo,
715        node_index: usize,
716    ) -> Update {
717        if let Some(log) = data.downcast_ref::<ClickLog>() {
718            log.lock()
719                .expect("click log poisoned")
720                .push(node_index.wrapping_add(SENTINEL));
721        }
722        Update::RefreshDomAllWindows
723    }
724
725    /// Forces the `fn`-item -> `fn`-pointer coercion the `Into` bound needs.
726    fn cb(f: TreeViewOnNodeClickCallbackType) -> TreeViewOnNodeClickCallback {
727        f.into()
728    }
729
730    fn new_log() -> ClickLog {
731        Arc::new(Mutex::new(Vec::new()))
732    }
733
734    fn entries(log: &ClickLog) -> Vec<usize> {
735        log.lock().expect("click log poisoned").clone()
736    }
737
738    fn some_click(f: TreeViewOnNodeClickCallbackType, log: &ClickLog) -> OptionTreeViewOnNodeClick {
739        Some(TreeViewOnNodeClick {
740            callback: cb(f),
741            refany: RefAny::new(log.clone()),
742        })
743        .into()
744    }
745
746    /// Invokes `on_tree_node_click` once per payload against one shared
747    /// `CallbackInfo`. `on_tree_node_click` never touches the layout results,
748    /// so an empty `LayoutWindow` is enough.
749    fn run_clicks(payloads: Vec<RefAny>) -> Vec<Update> {
750        let layout_window =
751            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
752        let renderer_resources = RendererResources::default();
753        let previous_window_state: Option<FullWindowState> = None;
754        let current_window_state = FullWindowState::default();
755        let gl_context = OptionGlContextPtr::None;
756        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
757            BTreeMap::new();
758        let window_handle = RawWindowHandle::Unsupported;
759        let system_callbacks = ExternalSystemCallbacks::rust_internal();
760
761        let ref_data = CallbackInfoRefData {
762            layout_window: &layout_window,
763            renderer_resources: &renderer_resources,
764            previous_window_state: &previous_window_state,
765            current_window_state: &current_window_state,
766            gl_context: &gl_context,
767            current_scroll_manager: &scroll_states,
768            current_window_handle: &window_handle,
769            system_callbacks: &system_callbacks,
770            system_style: Arc::new(SystemStyle::default()),
771            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
772            #[cfg(feature = "icu")]
773            icu_localizer: IcuLocalizerHandle::default(),
774            ctx: OptionRefAny::None,
775        };
776
777        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
778
779        let info = CallbackInfo::new(
780            &ref_data,
781            &changes,
782            DomNodeId {
783                dom: DomId::ROOT_ID,
784                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
785            },
786            OptionLogicalPosition::None,
787            OptionLogicalPosition::None,
788        );
789
790        payloads
791            .into_iter()
792            .map(|p| on_tree_node_click(p, info))
793            .collect()
794    }
795
796    // ==================================================================
797    // TreeViewNode::new
798    // ==================================================================
799
800    #[test]
801    fn new_defaults_to_a_collapsed_unselected_childless_node() {
802        let node = TreeViewNode::new("Root");
803
804        assert_eq!(node.label.as_str(), "Root");
805        assert!(
806            node.children.as_slice().is_empty(),
807            "a fresh node has no children"
808        );
809        assert_eq!(node.children.len(), 0);
810        assert!(
811            node.children.capacity() >= node.children.len(),
812            "len must never exceed capacity"
813        );
814        assert!(!node.is_expanded, "a fresh node is collapsed");
815        assert!(!node.is_selected, "a fresh node is unselected");
816    }
817
818    #[test]
819    fn new_preserves_pathological_labels_byte_for_byte() {
820        for label in pathological_labels() {
821            let node = TreeViewNode::new(label.clone());
822            assert_eq!(
823                node.label.as_str(),
824                label.as_str(),
825                "label must survive verbatim"
826            );
827            assert_eq!(
828                node.label.as_str().len(),
829                label.len(),
830                "an embedded NUL must not truncate the label"
831            );
832            // …and the state defaults must not depend on the label at all.
833            assert!(!node.is_expanded);
834            assert!(!node.is_selected);
835            assert!(node.children.as_slice().is_empty());
836        }
837    }
838
839    #[test]
840    fn new_accepts_every_into_azstring_source_identically() {
841        let from_str = TreeViewNode::new("same");
842        let from_string = TreeViewNode::new("same".to_string());
843        let from_azstring = TreeViewNode::new(AzString::from("same"));
844
845        assert_eq!(from_str, from_string);
846        assert_eq!(from_str, from_azstring);
847    }
848
849    #[test]
850    fn new_with_a_megabyte_label_does_not_truncate_or_panic() {
851        let huge = "λ".repeat(500_000); // 1 MB of UTF-8
852        let node = TreeViewNode::new(huge.clone());
853        assert_eq!(node.label.as_str().len(), huge.len());
854        assert_eq!(node.label.as_str(), huge);
855    }
856
857    // ==================================================================
858    // TreeViewNode::add_child / with_child
859    // ==================================================================
860
861    #[test]
862    fn add_child_and_with_child_agree() {
863        let mut mutated = leaf("root");
864        mutated.add_child(leaf("a"));
865        mutated.add_child(leaf("b"));
866
867        let built = leaf("root").with_child(leaf("a")).with_child(leaf("b"));
868
869        assert_eq!(
870            mutated, built,
871            "the builder and the mutator must produce the same node"
872        );
873    }
874
875    #[test]
876    fn add_child_preserves_order_duplicates_and_len_capacity_invariants() {
877        let n = 5_000;
878        let mut root = leaf("root");
879        for i in 0..n {
880            root.add_child(leaf(&format!("c{i}")));
881            assert_eq!(root.children.len(), i + 1, "len must track every push");
882            assert!(
883                root.children.capacity() >= root.children.len(),
884                "capacity must never fall below len"
885            );
886        }
887        // Order is insertion order, and nothing is deduplicated.
888        root.add_child(leaf("c0"));
889        assert_eq!(root.children.len(), n + 1, "duplicates are kept, not merged");
890        assert_eq!(root.children.as_slice()[0].label.as_str(), "c0");
891        assert_eq!(root.children.as_slice()[n - 1].label.as_str(), "c4999");
892        assert_eq!(root.children.as_slice()[n].label.as_str(), "c0");
893        assert_eq!(subtree_len(&root), n + 2);
894    }
895
896    #[test]
897    fn child_vec_survives_the_borrowed_to_owned_transition() {
898        // `TreeViewNode::new` seeds `children` from a *const* slice (no
899        // destructor, zero capacity). The first push has to switch it to an
900        // owned heap buffer; a clone taken afterwards must be fully
901        // independent, or dropping either one would free the other's memory.
902        let mut root = leaf("root");
903        assert_eq!(root.children.capacity(), 0);
904
905        root.add_child(leaf("a"));
906        root.add_child(leaf("b"));
907
908        let mut copy = root.clone();
909        copy.add_child(leaf("c"));
910        copy.children.as_mut()[0].label = AzString::from("mutated");
911
912        assert_eq!(root.children.len(), 2, "the original must not see the push");
913        assert_eq!(
914            root.children.as_slice()[0].label.as_str(),
915            "a",
916            "the clone must own its own child storage"
917        );
918        assert_eq!(copy.children.len(), 3);
919        assert_eq!(copy.children.as_slice()[0].label.as_str(), "mutated");
920
921        drop(copy);
922        // Original still readable after the clone is gone (no shared buffer).
923        assert_eq!(root.children.as_slice()[1].label.as_str(), "b");
924    }
925
926    #[test]
927    fn with_child_nests_arbitrarily_deep_without_panicking() {
928        on_big_stack(|| {
929            let depth = 1_000;
930            let root = chain(depth, true);
931            assert_eq!(subtree_len(&root), depth);
932
933            // Deep clone + deep drop both recurse per level as well.
934            let copy = root.clone();
935            assert_eq!(copy, root);
936            drop(copy);
937            drop(root);
938        });
939    }
940
941    // ==================================================================
942    // TreeViewNode::with_expanded / with_selected
943    // ==================================================================
944
945    #[test]
946    fn with_expanded_and_with_selected_are_orthogonal_and_idempotent() {
947        for expanded in [false, true] {
948            for selected in [false, true] {
949                let node = leaf("n").with_expanded(expanded).with_selected(selected);
950                assert_eq!(node.is_expanded, expanded);
951                assert_eq!(node.is_selected, selected);
952
953                // Order must not matter…
954                let flipped = leaf("n").with_selected(selected).with_expanded(expanded);
955                assert_eq!(node, flipped);
956
957                // …and applying the same value twice must be a no-op.
958                let twice = node
959                    .clone()
960                    .with_expanded(expanded)
961                    .with_selected(selected);
962                assert_eq!(node, twice);
963
964                // The last write wins when the value is flipped.
965                let overwritten = node.clone().with_expanded(!expanded);
966                assert_eq!(overwritten.is_expanded, !expanded);
967                assert_eq!(
968                    overwritten.is_selected, selected,
969                    "with_expanded must not touch is_selected"
970                );
971            }
972        }
973    }
974
975    #[test]
976    fn state_builders_do_not_disturb_label_or_children() {
977        let base = leaf("keep me").with_child(leaf("a")).with_child(leaf("b"));
978        let styled = base
979            .clone()
980            .with_expanded(true)
981            .with_selected(true)
982            .with_expanded(false);
983
984        assert_eq!(styled.label, base.label);
985        assert_eq!(styled.children, base.children);
986        assert!(!styled.is_expanded);
987        assert!(styled.is_selected);
988    }
989
990    #[test]
991    fn nodes_differing_only_in_state_are_not_equal() {
992        let base = leaf("n");
993        assert_ne!(base, base.clone().with_expanded(true));
994        assert_ne!(base, base.clone().with_selected(true));
995        assert_ne!(base, base.clone().with_child(leaf("a")));
996        assert_ne!(base, leaf("m"));
997    }
998
999    #[test]
1000    fn equality_ignores_how_the_child_vec_was_built() {
1001        let pushed = leaf("root").with_child(leaf("a")).with_child(leaf("b"));
1002        let from_vec = TreeViewNode {
1003            label: AzString::from("root"),
1004            children: TreeViewNodeVec::from_vec(vec![leaf("a"), leaf("b")]),
1005            is_expanded: false,
1006            is_selected: false,
1007        };
1008        assert_eq!(
1009            pushed, from_vec,
1010            "the vec's allocation strategy must not leak into equality"
1011        );
1012    }
1013
1014    // ==================================================================
1015    // TreeView::new / set_on_node_click / with_on_node_click
1016    // ==================================================================
1017
1018    #[test]
1019    fn treeview_new_keeps_the_root_intact_and_installs_no_callback() {
1020        for root in shapes() {
1021            let tv = TreeView::new(root.clone());
1022            assert_eq!(tv.root, root, "new must not rewrite the tree");
1023            assert!(
1024                tv.on_node_click.as_ref().is_none(),
1025                "new must not install a callback"
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn set_on_node_click_installs_then_overwrites() {
1032        let log = new_log();
1033        let mut tv = TreeView::new(leaf("root"));
1034
1035        tv.set_on_node_click(RefAny::new(log.clone()), cb(record_click));
1036        assert!(tv.on_node_click.as_ref().is_some());
1037
1038        tv.set_on_node_click(RefAny::new(log.clone()), cb(record_click_all_windows));
1039        let installed = tv
1040            .on_node_click
1041            .as_ref()
1042            .expect("a callback is still installed");
1043        assert_eq!(
1044            installed.callback,
1045            cb(record_click_all_windows),
1046            "the last write must win"
1047        );
1048        assert_ne!(installed.callback, cb(record_click));
1049    }
1050
1051    #[test]
1052    fn with_on_node_click_matches_set_on_node_click() {
1053        // Both sides get *clones of the same* `RefAny`: `RefAny`'s equality is
1054        // shared-identity, so two independent `RefAny::new` calls would never
1055        // compare equal no matter what the builders do.
1056        let data = RefAny::new(new_log());
1057        let mut mutated = TreeView::new(leaf("root"));
1058        mutated.set_on_node_click(data.clone(), cb(record_click));
1059
1060        let built = TreeView::new(leaf("root")).with_on_node_click(data.clone(), cb(record_click));
1061
1062        assert_eq!(mutated, built);
1063    }
1064
1065    // ==================================================================
1066    // count_descendants  (numeric: zero / min-max / overflow)
1067    // ==================================================================
1068
1069    #[test]
1070    fn count_descendants_of_an_empty_slice_is_a_no_op_even_at_usize_max() {
1071        // usize has no negative domain; the adversarial extremes are 0 and MAX.
1072        for start in [0usize, 1, usize::MAX / 2, usize::MAX - 1, usize::MAX] {
1073            let mut index = start;
1074            count_descendants(&[], &mut index);
1075            assert_eq!(
1076                index, start,
1077                "an empty slice must not touch the counter (and must not overflow at MAX)"
1078            );
1079        }
1080    }
1081
1082    #[test]
1083    fn count_descendants_counts_every_node_regardless_of_expansion() {
1084        for shape in shapes() {
1085            let nodes = shape.children.as_slice();
1086            let expected: usize = nodes.iter().map(subtree_len).sum();
1087
1088            for start in [0usize, 7, 1_000_000] {
1089                let mut index = start;
1090                count_descendants(nodes, &mut index);
1091                assert_eq!(
1092                    index - start,
1093                    expected,
1094                    "collapsed and expanded descendants must count the same"
1095                );
1096            }
1097        }
1098    }
1099
1100    #[test]
1101    fn count_descendants_reaches_exactly_usize_max_without_overflowing() {
1102        let tree = deep_mixed();
1103        let nodes = tree.children.as_slice();
1104        let total: usize = nodes.iter().map(subtree_len).sum();
1105
1106        let mut index = usize::MAX - total;
1107        count_descendants(nodes, &mut index);
1108        assert_eq!(
1109            index,
1110            usize::MAX,
1111            "landing exactly on usize::MAX must not overflow"
1112        );
1113    }
1114
1115    #[test]
1116    fn count_descendants_survives_a_deep_chain() {
1117        on_big_stack(|| {
1118            let depth = 10_000;
1119            let root = chain(depth, false);
1120            let mut index = 0usize;
1121            count_descendants(root.children.as_slice(), &mut index);
1122            assert_eq!(index, depth - 1, "every hidden descendant is counted once");
1123        });
1124    }
1125
1126    // ==================================================================
1127    // render_node  (numeric: index accounting)
1128    // ==================================================================
1129
1130    #[test]
1131    fn render_node_advance_equals_subtree_size_for_every_shape() {
1132        // The load-bearing invariant: whether a subtree is drawn or skipped,
1133        // it must consume exactly one index per node — otherwise a collapsed
1134        // sibling shifts every later row's click index.
1135        for shape in shapes() {
1136            let expected = subtree_len(&shape);
1137            for start in [0usize, 1, 12_345, usize::MAX / 4] {
1138                let mut index = start;
1139                let mut out = Vec::new();
1140                render_node(&shape, &OptionTreeViewOnNodeClick::None, &mut index, &mut out);
1141                assert_eq!(
1142                    index - start,
1143                    expected,
1144                    "index advance must equal the subtree size, expanded or not"
1145                );
1146                assert!(!out.is_empty(), "every node renders at least its own row");
1147            }
1148        }
1149    }
1150
1151    #[test]
1152    fn render_node_emits_preorder_indices_for_visible_rows_only() {
1153        for shape in shapes() {
1154            let log = new_log();
1155            let on_click = some_click(record_click, &log);
1156
1157            let mut index = 0usize;
1158            let mut out = Vec::new();
1159            render_node(&shape, &on_click, &mut index, &mut out);
1160
1161            assert_eq!(
1162                rendered_pairs(&out),
1163                expected_of(&shape, 0),
1164                "rendered rows must match the independent pre-order model"
1165            );
1166        }
1167    }
1168
1169    #[test]
1170    fn render_node_appends_and_offsets_from_a_nonzero_start_index() {
1171        let start = 12_345usize;
1172        let shape = deep_mixed();
1173        let log = new_log();
1174        let on_click = some_click(record_click, &log);
1175
1176        // Pre-existing content in `out` must be preserved, not clobbered.
1177        let mut out = vec![Dom::create_div(), Dom::create_text("sentinel")];
1178        let mut index = start;
1179        render_node(&shape, &on_click, &mut index, &mut out);
1180
1181        assert_eq!(
1182            text_of(&out[1]),
1183            Some("sentinel"),
1184            "render_node must append to `out`, never rewrite it"
1185        );
1186        assert_eq!(
1187            rendered_pairs(&out[2..]),
1188            expected_of(&shape, start),
1189            "a non-zero start index must offset every emitted index"
1190        );
1191        assert_eq!(index, start + subtree_len(&shape));
1192    }
1193
1194    #[test]
1195    fn render_node_lands_exactly_on_usize_max_without_overflowing() {
1196        // Three nodes, started so the *last* index handed out is usize::MAX - 1
1197        // and the counter finishes on usize::MAX: one node short of the cliff.
1198        let tree = leaf("root")
1199            .with_child(leaf("a"))
1200            .with_child(leaf("b"))
1201            .with_expanded(true);
1202        assert_eq!(subtree_len(&tree), 3);
1203
1204        let log = new_log();
1205        let on_click = some_click(record_click, &log);
1206
1207        let mut index = usize::MAX - 3;
1208        let mut out = Vec::new();
1209        render_node(&tree, &on_click, &mut index, &mut out);
1210
1211        assert_eq!(index, usize::MAX, "must land exactly on MAX, not wrap");
1212        let indices: Vec<usize> = rows_of(&out)
1213            .iter()
1214            .filter_map(|r| click_index_of(r))
1215            .collect();
1216        assert_eq!(
1217            indices,
1218            vec![usize::MAX - 3, usize::MAX - 2, usize::MAX - 1],
1219            "extreme indices must be carried verbatim into the click payloads"
1220        );
1221    }
1222
1223    #[cfg(all(debug_assertions, panic = "unwind"))]
1224    #[test]
1225    fn render_node_index_overflow_is_loud_not_silently_wrapped() {
1226        // `render_node` does an unguarded `*index += 1`. Starting at
1227        // usize::MAX must not quietly wrap the counter to 0 (which would give
1228        // two different rows the same click index); an overflow-checked build
1229        // has to panic instead.
1230        let node = leaf("boom");
1231        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1232            let mut index = usize::MAX;
1233            let mut out = Vec::new();
1234            render_node(&node, &OptionTreeViewOnNodeClick::None, &mut index, &mut out);
1235            index
1236        }));
1237
1238        match result {
1239            Err(_) => {} // overflow-checked build: panicked, as required
1240            Ok(index) => assert_eq!(
1241                index, 0,
1242                "without overflow checks the counter must wrap cleanly, not corrupt"
1243            ),
1244        }
1245    }
1246
1247    #[test]
1248    fn render_node_without_a_callback_attaches_none() {
1249        for shape in shapes() {
1250            let mut index = 0usize;
1251            let mut out = Vec::new();
1252            render_node(&shape, &OptionTreeViewOnNodeClick::None, &mut index, &mut out);
1253
1254            for row in rows_of(&out) {
1255                assert!(
1256                    row.root.get_callbacks().as_ref().is_empty(),
1257                    "no callback configured => no callback attached"
1258                );
1259            }
1260        }
1261    }
1262
1263    #[test]
1264    fn render_node_survives_a_deep_expanded_chain() {
1265        on_big_stack(|| {
1266            let depth = 800;
1267            let root = chain(depth, true);
1268
1269            let mut index = 0usize;
1270            let mut out = Vec::new();
1271            render_node(&root, &OptionTreeViewOnNodeClick::None, &mut index, &mut out);
1272
1273            assert_eq!(index, depth, "one index per level");
1274            assert_eq!(rows_of(&out).len(), depth, "every level renders one row");
1275            drop(out);
1276        });
1277    }
1278
1279    #[test]
1280    fn render_node_handles_a_wide_fanout() {
1281        let n = 5_000;
1282        let root = wide(n, true);
1283        let log = new_log();
1284        let on_click = some_click(record_click, &log);
1285
1286        let mut index = 0usize;
1287        let mut out = Vec::new();
1288        render_node(&root, &on_click, &mut index, &mut out);
1289
1290        assert_eq!(index, n + 1);
1291        assert_eq!(out.len(), 2, "an expanded parent emits [row, container]");
1292        assert_eq!(out[1].children.as_ref().len(), n, "every child gets a row");
1293
1294        let indices: Vec<usize> = rows_of(&out)
1295            .iter()
1296            .filter_map(|r| click_index_of(r))
1297            .collect();
1298        assert_eq!(indices, (0..=n).collect::<Vec<_>>());
1299    }
1300
1301    // ==================================================================
1302    // TreeView::dom
1303    // ==================================================================
1304
1305    #[test]
1306    fn dom_root_carries_the_container_class_and_style() {
1307        let dom = TreeView::new(leaf("root")).dom();
1308
1309        let classes = dom.root.get_ids_and_classes();
1310        assert!(
1311            classes
1312                .as_ref()
1313                .iter()
1314                .any(|c| matches!(c, IdOrClass::Class(s) if s.as_str() == "__azul-native-tree-view")),
1315            "the container must be findable by its widget class"
1316        );
1317        assert!(
1318            style_is(&dom, TREE_CONTAINER_STYLE),
1319            "the container must use the shared const style"
1320        );
1321    }
1322
1323    #[test]
1324    fn dom_leaf_renders_a_spacer_and_no_icon() {
1325        let dom = TreeView::new(leaf("only")).dom();
1326        assert_eq!(dom.children.as_ref().len(), 1, "a leaf emits just its row");
1327
1328        let row = &dom.children.as_ref()[0];
1329        let (icon, label) = row_parts(row);
1330        assert_eq!(icon_of(icon), None, "a childless node gets no disclosure icon");
1331        assert!(
1332            style_is(icon, LEAF_SPACER_STYLE),
1333            "the placeholder must use the leaf-spacer style so labels stay aligned"
1334        );
1335        assert_eq!(text_of(label), Some("only"));
1336        assert!(style_is(label, LABEL_STYLE));
1337    }
1338
1339    #[test]
1340    fn dom_expanded_parent_uses_expand_more_and_emits_a_container() {
1341        let tree = leaf("p")
1342            .with_child(leaf("a"))
1343            .with_child(leaf("b"))
1344            .with_expanded(true);
1345        let dom = TreeView::new(tree).dom();
1346
1347        assert_eq!(
1348            dom.children.as_ref().len(),
1349            2,
1350            "an expanded parent emits [row, children container]"
1351        );
1352        let (icon, _) = row_parts(&dom.children.as_ref()[0]);
1353        assert_eq!(icon_of(icon), Some("expand_more"));
1354        assert!(style_is(icon, ICON_STYLE));
1355
1356        let container = &dom.children.as_ref()[1];
1357        assert!(style_is(container, CHILDREN_STYLE));
1358        assert_eq!(container.children.as_ref().len(), 2, "both children drawn");
1359    }
1360
1361    #[test]
1362    fn dom_collapsed_parent_uses_chevron_and_draws_no_children() {
1363        let tree = leaf("p").with_child(leaf("a")).with_child(leaf("b"));
1364        let dom = TreeView::new(tree).dom();
1365
1366        assert_eq!(
1367            dom.children.as_ref().len(),
1368            1,
1369            "a collapsed parent must not emit a children container"
1370        );
1371        let (icon, _) = row_parts(&dom.children.as_ref()[0]);
1372        assert_eq!(icon_of(icon), Some("chevron_right"));
1373        assert_eq!(rows_of(dom.children.as_ref()).len(), 1, "children stay hidden");
1374    }
1375
1376    #[test]
1377    fn dom_expanded_but_childless_node_still_renders_a_spacer() {
1378        // `is_expanded` is documented as meaningful only with children.
1379        let dom = TreeView::new(leaf("empty").with_expanded(true)).dom();
1380        assert_eq!(dom.children.as_ref().len(), 1, "nothing to expand into");
1381        let (icon, _) = row_parts(&dom.children.as_ref()[0]);
1382        assert_eq!(icon_of(icon), None);
1383        assert!(style_is(icon, LEAF_SPACER_STYLE));
1384    }
1385
1386    #[test]
1387    fn dom_selected_rows_use_the_selected_style() {
1388        let tree = leaf("p")
1389            .with_expanded(true)
1390            .with_child(leaf("a").with_selected(true))
1391            .with_child(leaf("b"));
1392        let dom = TreeView::new(tree).dom();
1393        let rows = rows_of(dom.children.as_ref());
1394        assert_eq!(rows.len(), 3);
1395
1396        assert!(style_is(rows[0], ROW_STYLE), "unselected root uses ROW_STYLE");
1397        assert!(
1398            style_is(rows[1], ROW_SELECTED_STYLE),
1399            "the selected node must switch to the selected style"
1400        );
1401        assert!(style_is(rows[2], ROW_STYLE));
1402        assert!(
1403            !style_is(rows[1], ROW_STYLE),
1404            "the two row styles must be distinguishable"
1405        );
1406    }
1407
1408    #[test]
1409    fn dom_keeps_estimated_total_children_consistent_for_every_shape() {
1410        // A stale estimate makes `convert_dom_into_compact_dom` under-allocate
1411        // and panic out of bounds, so this is a crash invariant, not cosmetics.
1412        for shape in shapes() {
1413            let dom = TreeView::new(shape).dom();
1414            assert_estimates_consistent(&dom);
1415        }
1416    }
1417
1418    #[test]
1419    fn dom_labels_survive_the_round_trip_unchanged() {
1420        let labels = pathological_labels();
1421        let mut root = leaf("root").with_expanded(true);
1422        for l in &labels {
1423            root.add_child(TreeViewNode::new(l.clone()));
1424        }
1425
1426        let dom = TreeView::new(root).dom();
1427        let rows = rows_of(dom.children.as_ref());
1428        assert_eq!(rows.len(), labels.len() + 1);
1429
1430        let rendered: Vec<&str> = rows[1..]
1431            .iter()
1432            .map(|r| text_of(row_parts(r).1).expect("label text node"))
1433            .collect();
1434        let expected: Vec<&str> = labels.iter().map(String::as_str).collect();
1435        assert_eq!(rendered, expected, "labels must survive byte-for-byte");
1436    }
1437
1438    #[test]
1439    fn dom_rows_are_focusable_and_carry_exactly_one_click_callback() {
1440        let log = new_log();
1441        let tv = TreeView::new(deep_mixed())
1442            .with_on_node_click(RefAny::new(log.clone()), cb(record_click));
1443        let dom = tv.dom();
1444
1445        for row in rows_of(dom.children.as_ref()) {
1446            assert!(
1447                matches!(row.root.get_tab_index(), Some(TabIndex::Auto)),
1448                "every row must be keyboard focusable"
1449            );
1450            let cbs = row.root.get_callbacks();
1451            assert_eq!(cbs.as_ref().len(), 1, "exactly one click callback per row");
1452            assert_eq!(
1453                cbs.as_ref()[0].event,
1454                EventFilter::Hover(HoverEventFilter::MouseUp),
1455                "rows fire on mouse-up"
1456            );
1457        }
1458    }
1459
1460    #[test]
1461    fn dom_indices_skip_collapsed_subtrees_but_stay_preorder() {
1462        for shape in shapes() {
1463            let log = new_log();
1464            let dom = TreeView::new(shape.clone())
1465                .with_on_node_click(RefAny::new(log.clone()), cb(record_click))
1466                .dom();
1467
1468            assert_eq!(
1469                rendered_pairs(dom.children.as_ref()),
1470                expected_of(&shape, 0),
1471                "dom() must index nodes pre-order over the whole tree, \
1472                 including the collapsed ones it does not draw"
1473            );
1474        }
1475    }
1476
1477    #[test]
1478    fn dom_of_an_empty_labelled_tree_does_not_panic() {
1479        let dom = TreeView::new(leaf("")).dom();
1480        let rows = rows_of(dom.children.as_ref());
1481        assert_eq!(rows.len(), 1);
1482        assert_eq!(text_of(row_parts(rows[0]).1), Some(""));
1483    }
1484
1485    #[test]
1486    fn from_treeview_for_dom_matches_dom() {
1487        for shape in shapes() {
1488            let via_trait: Dom = TreeView::new(shape.clone()).into();
1489            let via_method = TreeView::new(shape).dom();
1490            assert_eq!(via_trait, via_method);
1491        }
1492    }
1493
1494    #[test]
1495    fn dom_survives_a_deep_expanded_chain() {
1496        on_big_stack(|| {
1497            let depth = 800;
1498            let dom = TreeView::new(chain(depth, true)).dom();
1499            assert_eq!(rows_of(dom.children.as_ref()).len(), depth);
1500            assert_estimates_consistent(&dom);
1501            drop(dom);
1502        });
1503    }
1504
1505    // ==================================================================
1506    // on_tree_node_click
1507    // ==================================================================
1508
1509    #[test]
1510    fn click_with_a_foreign_payload_returns_do_nothing() {
1511        // A `RefAny` of the wrong type must be rejected, not reinterpreted.
1512        let payloads = vec![
1513            RefAny::new(0usize),
1514            RefAny::new(String::from("not a NodeClickData")),
1515            RefAny::new(leaf("also not one")),
1516            RefAny::new(()),
1517        ];
1518        let updates = run_clicks(payloads);
1519        assert_eq!(
1520            updates,
1521            vec![Update::DoNothing; 4],
1522            "a foreign payload must be a no-op, not a panic or a wild call"
1523        );
1524    }
1525
1526    #[test]
1527    fn click_without_a_user_callback_returns_do_nothing() {
1528        let payloads = vec![
1529            RefAny::new(NodeClickData {
1530                node_index: 0,
1531                on_node_click: OptionTreeViewOnNodeClick::None,
1532            }),
1533            RefAny::new(NodeClickData {
1534                node_index: usize::MAX,
1535                on_node_click: OptionTreeViewOnNodeClick::None,
1536            }),
1537        ];
1538        assert_eq!(run_clicks(payloads), vec![Update::DoNothing; 2]);
1539    }
1540
1541    #[test]
1542    fn click_forwards_the_index_verbatim_including_the_extremes() {
1543        let log = new_log();
1544        let indices = vec![0usize, 1, usize::MAX / 2, usize::MAX - 1, usize::MAX];
1545
1546        let payloads: Vec<RefAny> = indices
1547            .iter()
1548            .map(|i| {
1549                RefAny::new(NodeClickData {
1550                    node_index: *i,
1551                    on_node_click: some_click(record_click, &log),
1552                })
1553            })
1554            .collect();
1555
1556        let updates = run_clicks(payloads);
1557        assert_eq!(updates, vec![Update::RefreshDom; 5]);
1558        assert_eq!(
1559            entries(&log),
1560            indices,
1561            "the node index must reach the user callback unmodified"
1562        );
1563    }
1564
1565    #[test]
1566    fn click_propagates_the_user_update_verbatim() {
1567        let log = new_log();
1568        let payloads = vec![
1569            RefAny::new(NodeClickData {
1570                node_index: 3,
1571                on_node_click: some_click(record_click, &log),
1572            }),
1573            RefAny::new(NodeClickData {
1574                node_index: 4,
1575                on_node_click: some_click(record_click_all_windows, &log),
1576            }),
1577        ];
1578
1579        assert_eq!(
1580            run_clicks(payloads),
1581            vec![Update::RefreshDom, Update::RefreshDomAllWindows],
1582            "the dispatcher must not downgrade or upgrade the user's Update"
1583        );
1584        assert_eq!(entries(&log), vec![3, 4 + SENTINEL]);
1585    }
1586
1587    #[test]
1588    fn clicking_every_rendered_row_reports_its_visual_index() {
1589        let shape = deep_mixed();
1590        let log = new_log();
1591        let dom = TreeView::new(shape.clone())
1592            .with_on_node_click(RefAny::new(log.clone()), cb(record_click))
1593            .dom();
1594
1595        let payloads: Vec<RefAny> = rows_of(dom.children.as_ref())
1596            .iter()
1597            .map(|row| {
1598                row.root
1599                    .get_callbacks()
1600                    .as_ref()
1601                    .first()
1602                    .expect("every row carries the click callback")
1603                    .refany
1604                    .clone()
1605            })
1606            .collect();
1607
1608        let expected: Vec<usize> = expected_of(&shape, 0).into_iter().map(|(i, _)| i).collect();
1609        let updates = run_clicks(payloads);
1610
1611        assert_eq!(updates, vec![Update::RefreshDom; expected.len()]);
1612        assert_eq!(
1613            entries(&log),
1614            expected,
1615            "clicking row N must report N's pre-order index, collapsed siblings included"
1616        );
1617    }
1618}