Skip to main content

frus_widgets/
tree.rs

1//! [`Tree`]: a **controlled** hierarchical tree. The application holds the structure
2//! and the expanded nodes, and passes only the **visible rows**, flat (with their
3//! depth). The widget does nothing but render.
4//!
5//! Two distinct gestures, as in a file explorer: clicking the **chevron** expands or
6//! collapses the node (`on_toggle`), clicking **anywhere else** on the row **selects**
7//! it (`on_select`, leaves included). Vertical **guide lines** make the indentation
8//! levels visible, and the selected row is highlighted.
9
10use std::rc::Rc;
11
12use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
13use frus_layout::{Dimension, FlexDirection, Style};
14
15use crate::interaction::Status;
16use crate::theme::Theme;
17use crate::widget::Widget;
18
19const ROW_H: f32 = 32.0;
20const INDENT: f32 = 20.0;
21/// The chevron's size. **Geometry, not type**: it has to fit inside `INDENT`, so it is
22/// stated beside the indent it lives in rather than taken from the type scale.
23const CHEVRON_SIZE: f32 = 16.0;
24
25/// The style the rows are drawn in: what the caller said, else what the theme says, else
26/// the reference's — the reference builds its rows out of list tiles, whose title is
27/// `bodyLarge`.
28///
29/// **Resolved once**, so that the number the box is measured with is the number the glyphs
30/// are drawn at. Resolving is the single place the reader's font setting is applied
31/// (milestone 403); a size that never passes through it is a size the reader cannot change.
32fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
33    over.or(theme.and_then(|t| t.widgets.tree.text_style))
34        .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
35        .resolved()
36}
37
38/// One tree row: indented, a chevron if the node has children, **guide lines** back to its
39/// ancestors, and a **selection** background. Clicking the chevron expands or collapses
40/// (`toggle`), the rest selects (`select`) — told apart by
41/// [`positional_click`](Widget::positional_click).
42struct Row<Msg> {
43    depth: usize,
44    label: String,
45    expandable: bool,
46    expanded: bool,
47    selected: bool,
48    /// Toggle message (collapsible nodes only).
49    toggle: Option<Msg>,
50    /// Selection message (if the tree is selectable).
51    select: Option<Msg>,
52    text_style: Option<TextStyle>,
53}
54
55impl<Msg: Clone> Row<Msg> {
56    /// The chevron's local start x, within the row's box.
57    fn chevron_start(&self) -> f32 {
58        self.depth as f32 * INDENT
59    }
60
61    fn sizing(&self, theme: Option<&Theme>) -> Style {
62        // **Intrinsic** width = indentation + chevron + label (+ margin): without it the row
63        // would have no width at all (no child to measure) and the selection background
64        // would be invisible. The `Tree` (a column, align Stretch) then stretches every
65        // row out to the widest one.
66        let style = label_style(self.text_style, theme);
67        let text_w = frus_text::measure_resolved(&self.label, &style).width;
68        let width = self.chevron_start() + INDENT + text_w + ROW_PAD_R;
69        Style {
70            width: Dimension::Length(width),
71            height: Dimension::Length(frus_text::line_box(ROW_H, &style, 0.0)),
72            ..Default::default()
73        }
74    }
75}
76
77/// Margin right of the label (the hover/selection background overruns the text a little).
78const ROW_PAD_R: f32 = 12.0;
79
80impl<Msg: Clone> Widget<Msg> for Row<Msg> {
81    fn style(&self) -> Style {
82        self.sizing(None)
83    }
84
85    fn style_themed(&self, theme: &Theme) -> Style {
86        self.sizing(Some(theme))
87    }
88
89    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
90        &[]
91    }
92
93    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
94        let o = status.opacity;
95        let clickable = self.toggle.is_some() || self.select.is_some();
96        // Background: selection (a primary tint) wins, otherwise the hover state.
97        if self.selected {
98            let bg = theme.surface.lerp(theme.primary, 0.16);
99            scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
100        } else if clickable && status.hover_progress > 0.0 {
101            let bg = theme.state_layer(theme.surface, theme.on_surface, &status);
102            scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
103        }
104
105        // Vertical guide lines: one per ancestor level, centred in its indentation step.
106        for d in 0..self.depth {
107            let gx = bounds.x + d as f32 * INDENT + INDENT * 0.5;
108            scene.draw_rect(
109                Rect::new(gx, bounds.y, 1.0, bounds.height),
110                theme.border.fade(o * 0.6),
111                0.0,
112                0.0,
113                Color::TRANSPARENT,
114            );
115        }
116
117        let x = bounds.x + self.chevron_start();
118        let style = label_style(self.text_style, Some(theme));
119        let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
120        if self.expandable {
121            scene.text(
122                Point::new(x, ty),
123                if self.expanded { "▾" } else { "▸" }.to_string(),
124                // The chevron is an icon drawn as a glyph, so it keeps its size: it lives
125                // in a fixed indent, and type that grew would push the label out of it.
126                &ResolvedTextStyle::exact(CHEVRON_SIZE),
127                theme.muted.fade(o),
128            );
129        }
130        scene.text(
131            Point::new(x + INDENT, ty),
132            self.label.clone(),
133            &style,
134            theme.on_surface.fade(o),
135        );
136    }
137
138    fn positional_click(
139        &self,
140        local_x: f32,
141        _local_y: f32,
142        _width: f32,
143        _height: f32,
144    ) -> Option<Msg> {
145        // The chevron expands or collapses; the rest of the row selects, or toggles by default.
146        let start = self.chevron_start();
147        if self.expandable && local_x >= start && local_x < start + INDENT {
148            return self.toggle.clone();
149        }
150        self.select.clone().or_else(|| self.toggle.clone())
151    }
152
153    fn on_click(&self) -> Option<Msg> {
154        // Keyboard fallback (Enter/Space): the main action is selection, otherwise toggling.
155        self.select.clone().or_else(|| self.toggle.clone())
156    }
157
158    fn focusable(&self) -> bool {
159        self.toggle.is_some() || self.select.is_some()
160    }
161}
162
163/// A hierarchical tree (visible rows, flattened).
164pub struct Tree<Msg = crate::callback::Callback> {
165    on_toggle: Box<dyn Fn(u64) -> Msg>,
166    on_select: Option<Rc<dyn Fn(u64) -> Msg>>,
167    selected: Option<u64>,
168    nodes: Vec<(u64, usize, String, bool, bool)>,
169    text_style: Option<TextStyle>,
170    children: Vec<Box<dyn Widget<Msg>>>,
171}
172
173impl<Msg: Clone + 'static> Tree<Msg> {
174    /// Creates a tree; `on_toggle(id)` is emitted when a collapsible node's **chevron** is clicked.
175    pub fn new<R: crate::callback::IntoMsg<Msg>>(on_toggle: impl Fn(u64) -> R + 'static) -> Self {
176        Self {
177            on_toggle: Box::new(crate::callback::handler1(on_toggle)),
178            on_select: None,
179            selected: None,
180            nodes: Vec::new(),
181            text_style: None,
182            children: Vec::new(),
183        }
184    }
185
186    /// The rows' type, over the theme's and the reference's.
187    #[must_use]
188    pub fn text_style(mut self, style: TextStyle) -> Self {
189        self.text_style = Some(style);
190        self.rebuild();
191        self
192    }
193
194    /// Makes the nodes **selectable**: `on_select(id)` when the row's body is clicked (outside
195    /// the chevron), **leaves included**. Without it, clicking the row expands or collapses.
196    pub fn on_select<R: crate::callback::IntoMsg<Msg>>(
197        mut self,
198        on_select: impl Fn(u64) -> R + 'static,
199    ) -> Self {
200        self.on_select = Some(Rc::new(crate::callback::handler1(on_select)));
201        self.rebuild();
202        self
203    }
204
205    /// Marks the **selected** node (highlighted). `None` = none.
206    pub fn selected(mut self, id: Option<u64>) -> Self {
207        self.selected = id;
208        self.rebuild();
209        self
210    }
211
212    /// Adds a visible row: `id`, `depth` (indentation), `label`, whether the node has
213    /// children (`expandable`) and whether it is expanded (`expanded`).
214    pub fn node(
215        mut self,
216        id: u64,
217        depth: usize,
218        label: impl Into<String>,
219        expandable: bool,
220        expanded: bool,
221    ) -> Self {
222        self.nodes
223            .push((id, depth, label.into(), expandable, expanded));
224        self.rebuild();
225        self
226    }
227
228    fn rebuild(&mut self) {
229        self.children = self
230            .nodes
231            .iter()
232            .map(|(id, depth, label, expandable, expanded)| {
233                Box::new(Row {
234                    depth: *depth,
235                    label: label.clone(),
236                    expandable: *expandable,
237                    expanded: *expanded,
238                    selected: self.selected == Some(*id),
239                    toggle: expandable.then(|| (self.on_toggle)(*id)),
240                    select: self.on_select.as_ref().map(|f| f(*id)),
241                    text_style: self.text_style,
242                }) as Box<dyn Widget<Msg>>
243            })
244            .collect();
245    }
246}
247
248impl<Msg: Clone> Widget<Msg> for Tree<Msg> {
249    fn style(&self) -> Style {
250        Style {
251            flex_direction: FlexDirection::Column,
252            gap: 1.0,
253            ..Default::default()
254        }
255    }
256
257    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
258        &self.children
259    }
260
261    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
262
263    fn on_click(&self) -> Option<Msg> {
264        None
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[derive(Clone, Debug, PartialEq)]
273    enum Msg {
274        Toggle(u64),
275        Select(u64),
276    }
277
278    #[test]
279    fn expandable_nodes_toggle_leaves_do_not() {
280        // Without `on_select`, the row behaves as before: the folder toggles, the leaf does not.
281        let tree = Tree::new(Msg::Toggle)
282            .node(1, 0, "Folder", true, true)
283            .node(2, 1, "file.txt", false, false);
284        let rows = Widget::<Msg>::children(&tree);
285        assert_eq!(rows.len(), 2);
286        assert_eq!(rows[0].on_click(), Some(Msg::Toggle(1))); // a collapsible folder
287        assert_eq!(rows[1].on_click(), None); // a leaf
288    }
289
290    #[test]
291    fn chevron_toggles_body_selects() {
292        // With `on_select`, the chevron expands/collapses and the row body selects — leaves included.
293        let tree = Tree::new(Msg::Toggle)
294            .on_select(Msg::Select)
295            .selected(Some(2))
296            .node(1, 0, "Folder", true, true)
297            .node(2, 1, "file.txt", false, false);
298        let rows = Widget::<Msg>::children(&tree);
299        // A collapsible node (depth 0): chevron in [0, INDENT) → toggle; beyond that → selection.
300        assert_eq!(
301            rows[0].positional_click(INDENT * 0.5, 0.0, 200.0, ROW_H),
302            Some(Msg::Toggle(1))
303        );
304        assert_eq!(
305            rows[0].positional_click(INDENT * 3.0, 0.0, 200.0, ROW_H),
306            Some(Msg::Select(1))
307        );
308        // A leaf (depth 1): no chevron zone → everything selects (even under the indentation step).
309        assert_eq!(
310            rows[1].positional_click(INDENT * 0.5, 0.0, 200.0, ROW_H),
311            Some(Msg::Select(2))
312        );
313        // Keyboard (on_click): the main action is selection.
314        assert_eq!(rows[0].on_click(), Some(Msg::Select(1)));
315    }
316}