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}