Skip to main content

ravenrs_widgets/
tree.rs

1//! Items inside items: a tree that opens, selects, checks and filters.
2
3use std::collections::BTreeSet;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, Point, Role, Toggled};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
13use crate::inputs::check_box_around;
14use crate::{IconName, View, util};
15
16const LEVEL_INDENT: f32 = 20.0;
17const OVERSCAN: usize = 6;
18
19type KeyHandler = Rc<dyn Fn(&str)>;
20
21/// One item of a [`TreeView`], with the items under it.
22#[derive(Clone, Debug, PartialEq)]
23#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
24pub struct TreeNode {
25    key: String,
26    label: String,
27    icon: Option<IconName>,
28    children: Vec<TreeNode>,
29    lazy: bool,
30    disabled: bool,
31}
32
33#[cfg_attr(feature = "live", ravenrs_live::expose)]
34impl TreeNode {
35    /// An item the app knows by `key`, showing `label`.
36    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
37        Self {
38            key: key.into(),
39            label: label.into(),
40            icon: None,
41            children: Vec::new(),
42            lazy: false,
43            disabled: false,
44        }
45    }
46
47    /// An icon before the label.
48    pub fn icon(mut self, icon: IconName) -> Self {
49        self.icon = Some(icon);
50        self
51    }
52
53    /// Appends an item under this one.
54    pub fn child(mut self, child: TreeNode) -> Self {
55        self.children.push(child);
56        self
57    }
58
59    /// Appends items under this one.
60    pub fn children(mut self, children: impl IntoIterator<Item = TreeNode>) -> Self {
61        self.children.extend(children);
62        self
63    }
64
65    /// It can open although its items are not here yet; [`TreeView::on_expand`] brings them.
66    pub fn lazy(mut self) -> Self {
67        self.lazy = true;
68        self
69    }
70
71    /// It can be seen and opened, but not selected or checked.
72    pub fn disabled(mut self, disabled: bool) -> Self {
73        self.disabled = disabled;
74        self
75    }
76
77    fn can_open(&self) -> bool {
78        self.lazy || !self.children.is_empty()
79    }
80
81    fn matches(&self, query: &str) -> bool {
82        self.label.to_lowercase().contains(query) || self.children.iter().any(|child| child.matches(query))
83    }
84
85    /// The keys at the tips of the branch: what a check really holds.
86    fn collect_tips(&self, out: &mut Vec<String>) {
87        if self.children.is_empty() {
88            if !self.disabled {
89                out.push(self.key.clone());
90            }
91        } else {
92            for child in &self.children {
93                child.collect_tips(out);
94            }
95        }
96    }
97
98    fn check_state(&self, checked: &BTreeSet<String>) -> Toggled {
99        let mut tips = Vec::new();
100        self.collect_tips(&mut tips);
101        let on = tips.iter().filter(|tip| checked.contains(*tip)).count();
102        if on == 0 {
103            Toggled::False
104        } else if on == tips.len() {
105            Toggled::True
106        } else {
107            Toggled::Mixed
108        }
109    }
110}
111
112/// A line of the tree as it stands: open branches are followed, closed ones are not.
113#[derive(Clone, Debug)]
114struct Line {
115    node: TreeNode,
116    level: u32,
117    parent: Option<usize>,
118    open: bool,
119}
120
121fn lines(nodes: &[TreeNode], expanded: &BTreeSet<String>, query: &str) -> Vec<Line> {
122    fn walk(
123        nodes: &[TreeNode],
124        level: u32,
125        parent: Option<usize>,
126        expanded: &BTreeSet<String>,
127        query: &str,
128        out: &mut Vec<Line>,
129    ) {
130        for node in nodes {
131            if !query.is_empty() && !node.matches(query) {
132                continue;
133            }
134            // A filter has to show what it found, wherever it is.
135            let open = node.can_open()
136                && (expanded.contains(&node.key)
137                    || (!query.is_empty() && node.children.iter().any(|c| c.matches(query))));
138            let here = out.len();
139            out.push(Line { node: node.clone(), level, parent, open });
140            if open {
141                walk(&node.children, level + 1, Some(here), expanded, query, out);
142            }
143        }
144    }
145    let mut out = Vec::new();
146    walk(nodes, 1, None, expanded, query, &mut out);
147    out
148}
149
150/// A list of items that open to show more items: a chooser, a menu of a module, the
151/// structure of a document. Arrows move and open, Enter opens the item, Space checks it
152/// or selects it. With a [`height`](Self::height), only the visible lines are built.
153///
154/// ```
155/// use ravenrs_reactive::State;
156/// use ravenrs_widgets::{TreeNode, TreeView, Ui};
157///
158/// let selected = State::new(None::<String>);
159/// let tree = TreeView::new(vec![
160///     TreeNode::new("sales", "Sales").child(TreeNode::new("orders", "Orders")),
161///     TreeNode::new("stock", "Stock"),
162/// ])
163/// .selected(selected.binding());
164/// let mut ui = Ui::new(tree, ravenrs_style::Theme::raven_dark());
165/// ui.update();
166/// ```
167#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
168pub struct TreeView {
169    nodes: Vec<TreeNode>,
170    selected: Option<Binding<Option<String>>>,
171    expanded: Option<Binding<BTreeSet<String>>>,
172    checked: Option<Binding<BTreeSet<String>>>,
173    query: String,
174    on_open: Option<KeyHandler>,
175    on_expand: Option<KeyHandler>,
176    height: Option<f32>,
177    row_height: f32,
178    label: Option<String>,
179}
180
181impl fmt::Debug for TreeView {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        f.debug_struct("TreeView").field("nodes", &self.nodes.len()).finish_non_exhaustive()
184    }
185}
186
187#[cfg_attr(feature = "live", ravenrs_live::expose)]
188impl TreeView {
189    /// A tree of these items, all closed.
190    pub fn new(nodes: Vec<TreeNode>) -> Self {
191        Self {
192            nodes,
193            selected: None,
194            expanded: None,
195            checked: None,
196            query: String::new(),
197            on_open: None,
198            on_expand: None,
199            height: None,
200            row_height: 32.0,
201            label: None,
202        }
203    }
204
205    /// The item selected, by key.
206    pub fn selected(mut self, selected: Binding<Option<String>>) -> Self {
207        self.selected = Some(selected);
208        self
209    }
210
211    /// The items open, by key, to keep or to set from outside.
212    pub fn expanded(mut self, expanded: Binding<BTreeSet<String>>) -> Self {
213        self.expanded = Some(expanded);
214        self
215    }
216
217    /// Shows a check box on every line, bound to the keys of the items at the tips.
218    /// Checking a branch checks everything under it, and a branch partly under
219    /// check says so.
220    pub fn checked(mut self, checked: Binding<BTreeSet<String>>) -> Self {
221        self.checked = Some(checked);
222        self
223    }
224
225    /// Shows only the items with this text, and the branches that lead to them.
226    pub fn filter(mut self, text: impl Into<String>) -> Self {
227        self.query = text.into().trim().to_lowercase();
228        self
229    }
230
231    /// Runs with the key on Enter or on a double click.
232    pub fn on_open(mut self, handler: impl Fn(&str) + 'static) -> Self {
233        self.on_open = Some(Rc::new(handler));
234        self
235    }
236
237    /// Runs with the key when an item opens, to bring what is under a [`TreeNode::lazy`] one.
238    pub fn on_expand(mut self, handler: impl Fn(&str) + 'static) -> Self {
239        self.on_expand = Some(Rc::new(handler));
240        self
241    }
242
243    /// Scrolls inside this height, building only the lines in sight.
244    pub fn height(mut self, height: f32) -> Self {
245        self.height = Some(height);
246        self
247    }
248
249    /// Height of a line in logical pixels. 32 by default.
250    pub fn row_height(mut self, height: f32) -> Self {
251        self.row_height = height.max(24.0);
252        self
253    }
254
255    /// The name screen readers announce.
256    pub fn label(mut self, label: impl Into<String>) -> Self {
257        self.label = Some(label.into());
258        self
259    }
260}
261
262#[derive(Clone)]
263struct TreeActions {
264    selected: Option<Binding<Option<String>>>,
265    expanded: Binding<BTreeSet<String>>,
266    checked: Option<Binding<BTreeSet<String>>>,
267    on_open: Option<KeyHandler>,
268    on_expand: Option<KeyHandler>,
269    active: State<Option<String>>,
270}
271
272#[cfg_attr(feature = "live", ravenrs_live::expose)]
273impl TreeActions {
274    fn set_open(&self, key: &str, open: bool) {
275        self.expanded.update(|set| {
276            if open {
277                set.insert(key.to_owned());
278            } else {
279                set.remove(key);
280            }
281        });
282        if open && let Some(on_expand) = &self.on_expand {
283            on_expand(key);
284        }
285    }
286
287    fn select(&self, node: &TreeNode) {
288        self.active.set(Some(node.key.clone()));
289        if !node.disabled
290            && let Some(selected) = &self.selected
291        {
292            selected.set(Some(node.key.clone()));
293        }
294    }
295
296    fn toggle_check(&self, node: &TreeNode) {
297        let Some(checked) = &self.checked else { return };
298        if node.disabled {
299            return;
300        }
301        let mut tips = Vec::new();
302        node.collect_tips(&mut tips);
303        let all_on = node.check_state(&checked.get()) == Toggled::True;
304        checked.update(|set| {
305            for tip in tips {
306                if all_on {
307                    set.remove(&tip);
308                } else {
309                    set.insert(tip);
310                }
311            }
312        });
313    }
314
315    fn open_item(&self, node: &TreeNode) {
316        self.select(node);
317        if !node.disabled
318            && let Some(on_open) = &self.on_open
319        {
320            on_open(&node.key);
321        }
322    }
323}
324
325struct TreeLine<'a> {
326    line: &'a Line,
327    actions: &'a TreeActions,
328    active: bool,
329    focused: bool,
330    selected: bool,
331    check: Option<Toggled>,
332    row_height: f32,
333}
334
335impl View for TreeLine<'_> {
336    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
337        let theme = *cx.theme();
338        let Line { node, level, open, .. } = self.line;
339        let ink = theme.color(Semantic::Ink);
340        let muted = theme.color(Semantic::InkMuted);
341        let tone = |color: Color| if node.disabled { util::faded(color) } else { color };
342        let mut parts = Vec::new();
343        if node.can_open() {
344            let chevron = if *open { IconName::ChevronDown } else { IconName::ChevronRight };
345            let glyph = util::icon(cx, chevron, 16.0, muted);
346            let button = cx.container(
347                LayoutStyle {
348                    width: Some(20.0),
349                    height: Some(self.row_height),
350                    align: Align::Center,
351                    justify: Justify::Center,
352                    ..LayoutStyle::stack(Axis::Horizontal)
353                },
354                &[glyph],
355                Paint::None,
356            );
357            let (actions, key, open) = (self.actions.clone(), node.key.clone(), *open);
358            cx.on_click(button, Rc::new(move || actions.set_open(&key, !open)));
359            cx.semantics(button, Semantics::hidden());
360            parts.push(button);
361        } else {
362            parts.push(cx.container(
363                LayoutStyle { width: Some(20.0), ..LayoutStyle::leaf() },
364                &[],
365                Paint::None,
366            ));
367        }
368        if let Some(check) = self.check {
369            let state = util::EditorState { disabled: node.disabled, read_only: false };
370            let mark = match check {
371                Toggled::True => Some(IconName::Check),
372                Toggled::Mixed => Some(IconName::Minus),
373                _ => None,
374            }
375            .map(|icon| util::icon(cx, icon, 14.0, theme.color(Semantic::OnAccent)));
376            let square =
377                check_box_around(cx, &theme, !matches!(check, Toggled::False), false, state, mark.as_slice());
378            let (actions, item) = (self.actions.clone(), node.clone());
379            cx.on_click(square, Rc::new(move || actions.toggle_check(&item)));
380            cx.semantics(square, Semantics::hidden());
381            parts.push(square);
382        }
383        if let Some(icon) = node.icon {
384            parts.push(util::icon(cx, icon, 16.0, tone(muted)));
385        }
386        let text = cx.text_truncated(
387            &node.label,
388            &util::attrs(&theme, TextStyle::Body, None),
389            tone(ink),
390            LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::leaf() },
391        );
392        cx.semantics(text, Semantics::hidden());
393        parts.push(text);
394        let fill = if self.selected {
395            theme.color(Semantic::Accent).with_alpha(0.14)
396        } else if cx.is_hovered() {
397            ink.with_alpha(0.05)
398        } else {
399            Color::TRANSPARENT
400        };
401        let border = (self.active && self.focused)
402            .then(|| Border { width: 1.5, color: theme.color(Semantic::Accent) });
403        let row = cx.container(
404            LayoutStyle {
405                height: Some(self.row_height),
406                padding: Insets {
407                    left: 4.0 + (*level as f32 - 1.0) * LEVEL_INDENT,
408                    right: 8.0,
409                    ..Insets::ZERO
410                },
411                spacing: 6.0,
412                align: Align::Center,
413                align_self: Some(Align::Stretch),
414                ..LayoutStyle::stack(Axis::Horizontal)
415            },
416            &parts,
417            Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, border, ..QuadPaint::new(fill) }),
418        );
419        let (select, item) = (self.actions.clone(), node.clone());
420        cx.on_click(row, Rc::new(move || select.select(&item)));
421        let (opener, item) = (self.actions.clone(), node.clone());
422        cx.on_double_click(row, Rc::new(move || opener.open_item(&item)));
423        cx.cursor(row, CursorIcon::Pointer);
424        let mut semantics = Semantics::new(Role::TreeItem)
425            .name(node.label.clone())
426            .level(*level)
427            .selected(self.selected)
428            .disabled(node.disabled)
429            .active(self.active);
430        if node.can_open() {
431            semantics = semantics.expanded(*open);
432        }
433        if let Some(check) = self.check {
434            semantics = semantics.toggled(check);
435        }
436        cx.semantics(row, semantics);
437        row
438    }
439}
440
441fn key_handler(
442    all: Rc<Vec<Line>>,
443    at: usize,
444    actions: TreeActions,
445    reveal: State<bool>,
446) -> crate::KeyHandler {
447    Rc::new(move |key, _| {
448        let Some(line) = all.get(at) else { return false };
449        let go = |index: usize| {
450            if let Some(line) = all.get(index) {
451                actions.active.set(Some(line.node.key.clone()));
452                reveal.set(true);
453            }
454        };
455        match key {
456            Key::ArrowDown => go((at + 1).min(all.len().saturating_sub(1))),
457            Key::ArrowUp => go(at.saturating_sub(1)),
458            Key::Home => go(0),
459            Key::End => go(all.len().saturating_sub(1)),
460            Key::ArrowRight if line.node.can_open() && !line.open => actions.set_open(&line.node.key, true),
461            Key::ArrowRight if line.open => go(at + 1),
462            Key::ArrowRight => {}
463            Key::ArrowLeft if line.open => actions.set_open(&line.node.key, false),
464            Key::ArrowLeft => {
465                if let Some(parent) = line.parent {
466                    go(parent);
467                }
468            }
469            Key::Enter => actions.open_item(&line.node),
470            Key::Space if actions.checked.is_some() => actions.toggle_check(&line.node),
471            Key::Space => actions.select(&line.node),
472            _ => return false,
473        }
474        true
475    })
476}
477
478impl View for TreeView {
479    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
480        let local: State<BTreeSet<String>> = cx.use_state(BTreeSet::new);
481        let expanded = self.expanded.clone().unwrap_or_else(|| local.binding());
482        let active: State<Option<String>> = cx.use_state(|| None);
483        let reveal: State<bool> = cx.use_state(|| false);
484        let all = Rc::new(lines(&self.nodes, &expanded.get(), &self.query));
485        let selected = self.selected.as_ref().and_then(Binding::get);
486        let checked = self.checked.as_ref().map(Binding::get);
487        // The keyboard's place: where it was, else the selection, else the top.
488        let wanted = active.get().or_else(|| selected.clone());
489        let at = wanted.and_then(|key| all.iter().position(|l| l.node.key == key)).unwrap_or(0);
490        let actions = TreeActions {
491            selected: self.selected.clone(),
492            expanded,
493            checked: self.checked.clone(),
494            on_open: self.on_open.clone(),
495            on_expand: self.on_expand.clone(),
496            active: active.clone(),
497        };
498        let rh = self.row_height;
499        let focused = cx.is_focused();
500        let offset = cx.scroll_offset();
501        let range = match self.height {
502            Some(height) => {
503                let viewport = cx.scroll_viewport().map_or(height, |size| size.height);
504                let top = at as f32 * rh;
505                if *reveal.peek() {
506                    reveal.set_if_changed(false);
507                    if top < offset.y {
508                        cx.set_scroll_offset(Point::new(0.0, top));
509                    } else if top + rh > offset.y + viewport {
510                        cx.set_scroll_offset(Point::new(0.0, top + rh - viewport));
511                    }
512                }
513                let first = ((offset.y / rh).floor() as usize).saturating_sub(OVERSCAN).min(all.len());
514                let last = (((offset.y + viewport) / rh).ceil() as usize + OVERSCAN).min(all.len());
515                first..last
516            }
517            None => 0..all.len(),
518        };
519        let mut rows = Vec::with_capacity(range.len() + 2);
520        rows.push(cx.container(
521            LayoutStyle { height: Some(range.start as f32 * rh), ..LayoutStyle::leaf() },
522            &[],
523            Paint::None,
524        ));
525        for i in range.clone() {
526            let line = &all[i];
527            let row = TreeLine {
528                line,
529                actions: &actions,
530                active: i == at,
531                focused,
532                selected: selected.as_deref() == Some(line.node.key.as_str()),
533                check: checked.as_ref().map(|set| line.node.check_state(set)),
534                row_height: rh,
535            };
536            rows.push(cx.keyed_child(util::hash_text(&line.node.key), &row));
537        }
538        rows.push(cx.container(
539            LayoutStyle {
540                height: Some(all.len().saturating_sub(range.end) as f32 * rh),
541                ..LayoutStyle::leaf()
542            },
543            &[],
544            Paint::None,
545        ));
546        let content = cx.container(
547            LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
548            &rows,
549            Paint::None,
550        );
551        let body = cx.container(
552            LayoutStyle {
553                height: self.height,
554                min_height: Some(0.0),
555                overflow_hidden: self.height.is_some(),
556                align: Align::Stretch,
557                align_self: Some(Align::Stretch),
558                ..LayoutStyle::stack(Axis::Vertical)
559            },
560            &[content],
561            Paint::None,
562        );
563        if self.height.is_some() {
564            cx.scrollable_virtual(body, false);
565        }
566        let name = self.label.clone().unwrap_or_else(|| cx.labels().tree_name.clone());
567        cx.semantics(body, Semantics::new(Role::Tree).name(name));
568        cx.focusable(body, 4.0);
569        cx.on_key(body, key_handler(all, at, actions, reveal));
570        body
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    fn sample() -> Vec<TreeNode> {
579        vec![
580            TreeNode::new("a", "Alpha")
581                .child(TreeNode::new("a1", "One").child(TreeNode::new("deep", "Deep")))
582                .child(TreeNode::new("a2", "Two")),
583            TreeNode::new("b", "Beta"),
584        ]
585    }
586
587    #[test]
588    fn closed_branches_are_not_followed() {
589        let keys = |open: &[&str]| -> Vec<String> {
590            let open = open.iter().map(|s| (*s).to_owned()).collect();
591            lines(&sample(), &open, "").into_iter().map(|l| l.node.key).collect()
592        };
593        assert_eq!(keys(&[]), ["a", "b"]);
594        assert_eq!(keys(&["a"]), ["a", "a1", "a2", "b"]);
595        assert_eq!(keys(&["a", "a1"]), ["a", "a1", "deep", "a2", "b"]);
596        assert_eq!(keys(&["a1"]), ["a", "b"], "a closed parent hides what is open inside");
597    }
598
599    #[test]
600    fn a_filter_opens_the_way_to_what_it_finds() {
601        let found = lines(&sample(), &BTreeSet::new(), "deep");
602        let keys: Vec<_> = found.iter().map(|l| l.node.key.as_str()).collect();
603        assert_eq!(keys, ["a", "a1", "deep"]);
604        assert_eq!(found[2].parent, Some(1));
605        assert_eq!(found[2].level, 3);
606    }
607
608    #[test]
609    fn a_branch_is_checked_by_what_is_under_it() {
610        let root = &sample()[0];
611        let set = |keys: &[&str]| keys.iter().map(|s| (*s).to_owned()).collect::<BTreeSet<_>>();
612        assert_eq!(root.check_state(&set(&[])), Toggled::False);
613        assert_eq!(root.check_state(&set(&["deep"])), Toggled::Mixed);
614        assert_eq!(root.check_state(&set(&["deep", "a2"])), Toggled::True);
615    }
616}