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};
14use azul_css::{
15    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
16    props::{
17        basic::{
18            color::{ColorU, ColorOrSystem},
19            font::{StyleFontFamily, StyleFontFamilyVec},
20            *,
21        },
22        layout::*,
23        property::CssProperty,
24        style::*,
25    },
26    *,
27};
28
29use azul_css::{impl_option, impl_vec, impl_vec_clone, impl_vec_debug, impl_vec_partialeq, impl_vec_mut};
30
31use crate::callbacks::{Callback, CallbackInfo};
32
33// -- Callback type via macro --
34
35/// Callback invoked when a tree node is clicked.
36///
37/// The `usize` parameter is the depth-first index of the clicked node
38/// (0 = root, then incremented in pre-order traversal).
39pub type TreeViewOnNodeClickCallbackType = extern "C" fn(RefAny, CallbackInfo, usize) -> Update;
40impl_widget_callback!(
41    TreeViewOnNodeClick,
42    OptionTreeViewOnNodeClick,
43    TreeViewOnNodeClickCallback,
44    TreeViewOnNodeClickCallbackType
45);
46
47azul_core::impl_managed_callback! {
48    wrapper:        TreeViewOnNodeClickCallback,
49    info_ty:        CallbackInfo,
50    return_ty:      Update,
51    default_ret:    Update::DoNothing,
52    invoker_static: TREE_VIEW_ON_NODE_CLICK_INVOKER,
53    invoker_ty:     AzTreeViewOnNodeClickCallbackInvoker,
54    thunk_fn:       az_tree_view_on_node_click_callback_thunk,
55    setter_fn:      AzApp_setTreeViewOnNodeClickCallbackInvoker,
56    from_handle_fn: AzTreeViewOnNodeClickCallback_createFromHostHandle,
57    extra_args:     [ node_index: usize ],
58}
59
60// -- Font --
61
62const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
63const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
64const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
65    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
66
67// -- Colors --
68
69const TEXT_COLOR: ColorU = ColorU { r: 30, g: 30, b: 30, a: 255 };
70const SELECTED_BG: ColorU = ColorU { r: 0, g: 120, b: 215, a: 255 };
71const SELECTED_TEXT: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
72const HOVER_BG: ColorU = ColorU { r: 229, g: 243, b: 255, a: 255 };
73const ICON_COLOR: ColorU = ColorU { r: 100, g: 100, b: 100, a: 255 };
74
75// -- Tree container style --
76
77static TREE_CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
78    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
79    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
80    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
81    CssPropertyWithConditions::simple(CssProperty::const_font_family(SYSTEM_UI_FAMILY)),
82    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: TEXT_COLOR })),
83];
84
85// -- Row style (each tree node row) --
86
87static ROW_STYLE: &[CssPropertyWithConditions] = &[
88    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
89    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
90    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
91    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(2))),
92    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(LayoutPaddingBottom::const_px(2))),
93    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(4))),
94    CssPropertyWithConditions::simple(CssProperty::const_padding_right(LayoutPaddingRight::const_px(4))),
95    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
96    // Hover
97    CssPropertyWithConditions::on_hover(CssProperty::const_background_content(
98        StyleBackgroundContentVec::from_const_slice(&[StyleBackgroundContent::Color(HOVER_BG)]),
99    )),
100];
101
102// -- Selected row style --
103// NOTE: Intentionally duplicates base properties from ROW_STYLE because
104// const-slice styling does not support runtime composition. If you change
105// padding/layout in ROW_STYLE, update ROW_SELECTED_STYLE to match.
106
107static ROW_SELECTED_STYLE: &[CssPropertyWithConditions] = &[
108    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
109    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
110    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
111    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(2))),
112    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(LayoutPaddingBottom::const_px(2))),
113    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(4))),
114    CssPropertyWithConditions::simple(CssProperty::const_padding_right(LayoutPaddingRight::const_px(4))),
115    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
116    CssPropertyWithConditions::simple(CssProperty::const_background_content(
117        StyleBackgroundContentVec::from_const_slice(&[StyleBackgroundContent::Color(SELECTED_BG)]),
118    )),
119    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: SELECTED_TEXT })),
120];
121
122// -- Children container style --
123
124static CHILDREN_STYLE: &[CssPropertyWithConditions] = &[
125    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
126    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
127    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(16))),
128];
129
130// -- Disclosure icon style --
131// NOTE: Icon font-size (16px) must match LEAF_SPACER_STYLE width so that
132// leaf nodes align with parent nodes that have a disclosure icon.
133
134static ICON_STYLE: &[CssPropertyWithConditions] = &[
135    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(16))),
136    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
137    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: ICON_COLOR })),
138];
139
140// -- Leaf spacer (same width as icon, for alignment) --
141
142static LEAF_SPACER_STYLE: &[CssPropertyWithConditions] = &[
143    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(16))),
144    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
145];
146
147// -- Label style --
148
149static LABEL_STYLE: &[CssPropertyWithConditions] = &[
150    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
151    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(4))),
152];
153
154// ============================================================================
155// Data structures
156// ============================================================================
157
158/// A single node in a tree hierarchy, with optional children.
159#[derive(Debug, Clone, PartialEq)]
160#[repr(C)]
161pub struct TreeViewNode {
162    /// Display text for this node.
163    pub label: AzString,
164    /// Child nodes nested under this node.
165    pub children: TreeViewNodeVec,
166    /// Whether children are visible (only meaningful when `children` is non-empty).
167    pub is_expanded: bool,
168    /// Whether this node is visually selected.
169    pub is_selected: bool,
170}
171
172impl TreeViewNode {
173    /// Creates a new collapsed, unselected leaf node with the given label.
174    pub fn new<S: Into<AzString>>(label: S) -> Self {
175        Self {
176            label: label.into(),
177            children: TreeViewNodeVec::from_const_slice(&[]),
178            is_expanded: false,
179            is_selected: false,
180        }
181    }
182
183    /// Appends a child node.
184    pub fn add_child(&mut self, child: TreeViewNode) {
185        self.children.push(child);
186    }
187
188    /// Builder method: appends a child node.
189    pub fn with_child(mut self, child: TreeViewNode) -> Self {
190        self.children.push(child);
191        self
192    }
193
194    /// Builder method: sets the expanded state.
195    pub fn with_expanded(mut self, expanded: bool) -> Self {
196        self.is_expanded = expanded;
197        self
198    }
199
200    /// Builder method: sets the selected state.
201    pub fn with_selected(mut self, selected: bool) -> Self {
202        self.is_selected = selected;
203        self
204    }
205}
206
207impl_option!(TreeViewNode, OptionTreeViewNode, copy = false, [Debug, Clone, PartialEq]);
208impl_vec!(TreeViewNode, TreeViewNodeVec, TreeViewNodeVecDestructor, TreeViewNodeVecDestructorType, TreeViewNodeVecSlice, OptionTreeViewNode);
209impl_vec_clone!(TreeViewNode, TreeViewNodeVec, TreeViewNodeVecDestructor);
210impl_vec_debug!(TreeViewNode, TreeViewNodeVec);
211impl_vec_partialeq!(TreeViewNode, TreeViewNodeVec);
212impl_vec_mut!(TreeViewNode, TreeViewNodeVec);
213
214/// Hierarchical tree view widget with expandable/collapsible nodes.
215#[derive(Debug, Clone, PartialEq)]
216#[repr(C)]
217pub struct TreeView {
218    /// Root node of the tree hierarchy.
219    pub root: TreeViewNode,
220    /// Optional callback fired when any node is clicked.
221    pub on_node_click: OptionTreeViewOnNodeClick,
222}
223
224impl TreeView {
225    /// Creates a new tree view with the given root node and no click callback.
226    pub fn new(root: TreeViewNode) -> Self {
227        Self {
228            root,
229            on_node_click: None.into(),
230        }
231    }
232
233    /// Sets the callback invoked when any tree node is clicked.
234    pub fn set_on_node_click<C: Into<TreeViewOnNodeClickCallback>>(
235        &mut self,
236        data: RefAny,
237        callback: C,
238    ) {
239        self.on_node_click = Some(TreeViewOnNodeClick {
240            callback: callback.into(),
241            refany: data,
242        })
243        .into();
244    }
245
246    /// Builder method: sets the node-click callback.
247    pub fn with_on_node_click<C: Into<TreeViewOnNodeClickCallback>>(
248        mut self,
249        data: RefAny,
250        callback: C,
251    ) -> Self {
252        self.set_on_node_click(data, callback);
253        self
254    }
255
256    /// Renders the tree view into a [`Dom`] subtree.
257    pub fn dom(self) -> Dom {
258        let on_node_click = self.on_node_click;
259        let root = self.root;
260
261        const TREE_CLASS: &[IdOrClass] =
262            &[Class(AzString::from_const_str("__azul-native-tree-view"))];
263
264        let mut children = Vec::new();
265        let mut index: usize = 0;
266        render_node(&root, &on_node_click, &mut index, &mut children);
267
268        Dom::create_div()
269            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(TREE_CONTAINER_STYLE))
270            .with_ids_and_classes(IdOrClassVec::from_const_slice(TREE_CLASS))
271            .with_children(DomVec::from_vec(children))
272    }
273}
274
275// ============================================================================
276// Internal: recursive DOM rendering
277// ============================================================================
278
279fn render_node(
280    node: &TreeViewNode,
281    on_click: &OptionTreeViewOnNodeClick,
282    index: &mut usize,
283    out: &mut Vec<Dom>,
284) {
285    let current_index = *index;
286    *index += 1;
287
288    let has_children = !node.children.as_slice().is_empty();
289
290    // Choose row style based on selection state
291    let row_style = if node.is_selected {
292        ROW_SELECTED_STYLE
293    } else {
294        ROW_STYLE
295    };
296
297    // Build the disclosure icon or spacer
298    let icon_or_spacer = if has_children {
299        let icon_name = if node.is_expanded {
300            "expand_more"
301        } else {
302            "chevron_right"
303        };
304        Dom::create_icon(AzString::from_const_str(icon_name))
305            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(ICON_STYLE))
306    } else {
307        // Empty spacer for leaf alignment
308        Dom::create_div()
309            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(LEAF_SPACER_STYLE))
310    };
311
312    // Build the label
313    let label = Dom::create_text(node.label.clone())
314        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(LABEL_STYLE));
315
316    // Build the row with click callback
317    let mut row = Dom::create_div()
318        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(row_style))
319        .with_tab_index(TabIndex::Auto)
320        .with_children(DomVec::from_vec(vec![icon_or_spacer, label]));
321
322    // Attach click callback if provided
323    if let Some(ref cb) = on_click.as_ref() {
324        let cb_data = NodeClickData {
325            node_index: current_index,
326            on_node_click: Some(TreeViewOnNodeClick {
327                callback: cb.callback.clone(),
328                refany: cb.refany.clone(),
329            })
330            .into(),
331        };
332        row = row.with_callbacks(
333            vec![CoreCallbackData {
334                event: EventFilter::Hover(HoverEventFilter::MouseUp),
335                refany: RefAny::new(cb_data),
336                callback: CoreCallback {
337                    cb: on_tree_node_click as usize,
338                    ctx: azul_core::refany::OptionRefAny::None,
339                },
340            }]
341            .into(),
342        );
343    }
344
345    out.push(row);
346
347    // Render children if expanded
348    if has_children && node.is_expanded {
349        let mut child_doms = Vec::new();
350        for child in node.children.as_slice() {
351            render_node(child, on_click, index, &mut child_doms);
352        }
353
354        let children_container = Dom::create_div()
355            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(CHILDREN_STYLE))
356            .with_children(DomVec::from_vec(child_doms));
357
358        out.push(children_container);
359    } else if has_children {
360        // Still count collapsed children for correct depth-first indexing
361        count_descendants(node.children.as_slice(), index);
362    }
363}
364
365/// Advance the index counter past all descendants without rendering them.
366fn count_descendants(nodes: &[TreeViewNode], index: &mut usize) {
367    for node in nodes {
368        *index += 1;
369        if !node.children.as_slice().is_empty() {
370            count_descendants(node.children.as_slice(), index);
371        }
372    }
373}
374
375// ============================================================================
376// Internal callback data
377// ============================================================================
378
379struct NodeClickData {
380    node_index: usize,
381    on_node_click: OptionTreeViewOnNodeClick,
382}
383
384// ============================================================================
385// Callbacks
386// ============================================================================
387
388extern "C" fn on_tree_node_click(mut refany: RefAny, info: CallbackInfo) -> Update {
389    let mut refany = match refany.downcast_mut::<NodeClickData>() {
390        Some(s) => s,
391        None => return Update::DoNothing,
392    };
393
394    let node_index = refany.node_index;
395
396    match refany.on_node_click.as_mut() {
397        Some(TreeViewOnNodeClick { refany, callback }) => {
398            (callback.cb)(refany.clone(), info.clone(), node_index)
399        }
400        None => Update::DoNothing,
401    }
402}
403
404// ============================================================================
405// Trait impls
406// ============================================================================
407
408impl From<TreeView> for Dom {
409    fn from(tv: TreeView) -> Dom {
410        tv.dom()
411    }
412}