Skip to main content

qframe/widgets/tree/
mod.rs

1//! Trees: nested rows that open and close, flattened to what is visible and virtualised.
2
3use crate::event::{Event, MouseButton, MouseKind};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::keymap::Key;
6use crate::style::{CellStyle, WidgetStyle};
7use crate::text;
8use crate::theme::State;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11use super::delayed::DelayedIndicator;
12use super::row::{self, LEAD};
13use super::rows::{self, RowScroll, Step};
14use super::{ContextItem, SpinnerStyle, tab_model};
15
16mod edit;
17#[cfg(test)]
18mod tests;
19
20use edit::Arrange;
21pub use edit::TreeMove;
22
23/// Cells of indentation per level.
24const INDENT: u16 = 2;
25
26/// One node of a [`Tree`], identified by a key that stays the same while the tree changes.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct TreeNode {
29    key: String,
30    label: String,
31    icon: Option<String>,
32    icon_color: Option<String>,
33    detail: Option<String>,
34    children: Vec<TreeNode>,
35    expandable: bool,
36    expanded: bool,
37    loading: bool,
38    faint: bool,
39}
40
41impl TreeNode {
42    /// A leaf named `label`, identified by `key`.
43    #[must_use]
44    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
45        Self {
46            key: key.into(),
47            label: label.into(),
48            icon: None,
49            icon_color: None,
50            detail: None,
51            children: Vec::new(),
52            expandable: false,
53            expanded: false,
54            loading: false,
55            faint: false,
56        }
57    }
58
59    /// Child nodes; a node with children can be opened.
60    #[must_use]
61    pub fn children(mut self, children: impl IntoIterator<Item = Self>) -> Self {
62        self.children = children.into_iter().collect();
63        self.expandable = self.expandable || !self.children.is_empty();
64        self
65    }
66
67    /// Marks a node as openable before its children are known, for children loaded when it
68    /// opens. Opening it sends [`Tree::on_expand`]; supply the children in a later frame.
69    #[must_use]
70    pub fn expandable(mut self, expandable: bool) -> Self {
71        self.expandable = expandable || !self.children.is_empty();
72        self
73    }
74
75    /// Whether the node is open and shows its children.
76    #[must_use]
77    pub fn expanded(mut self, expanded: bool) -> Self {
78        self.expanded = expanded;
79        self
80    }
81
82    /// Marks the node's children as being loaded. A load that takes longer than about 300 ms
83    /// shows a spinner in place of the chevron, which then stays at least about 500 ms; quicker
84    /// loads keep the chevron, so they never flash a spinner.
85    #[must_use]
86    pub fn loading(mut self, loading: bool) -> Self {
87        self.loading = loading;
88        self
89    }
90
91    /// Icon key drawn before the label, optionally in theme colour `color`.
92    #[must_use]
93    pub fn icon(mut self, key: impl Into<String>, color: Option<&str>) -> Self {
94        self.icon = Some(key.into());
95        self.icon_color = color.map(str::to_owned);
96        self
97    }
98
99    /// Faint text aligned right, e.g. a count.
100    #[must_use]
101    pub fn detail(mut self, detail: impl Into<String>) -> Self {
102        self.detail = Some(detail.into());
103        self
104    }
105
106    /// Draws the node faint while keeping it selectable.
107    #[must_use]
108    pub fn faint(mut self, faint: bool) -> Self {
109        self.faint = faint;
110        self
111    }
112}
113
114/// A visible row of the flattened tree.
115struct Flat<'a> {
116    node: &'a TreeNode,
117    depth: u16,
118    parent: Option<usize>,
119    /// The node's position among its siblings as the application gave them.
120    index: usize,
121}
122
123/// How one row is drawn besides its node.
124#[derive(Debug, Clone, Copy)]
125struct RowFlags {
126    hovered: bool,
127    selected: bool,
128    focused: bool,
129    pressed: bool,
130    spinning: bool,
131}
132
133/// Delayed loading spinners of the nodes on their way, by node key, kept in runtime memory.
134#[derive(Debug, Default)]
135struct LoadingMarks(Vec<(String, DelayedIndicator)>);
136
137/// Builds a message from a node key.
138type KeyMessage<Msg> = Box<dyn Fn(&str) -> Msg>;
139
140/// Builds a message from a node key and whether it should open.
141type ExpandMessage<Msg> = Box<dyn Fn(&str, bool) -> Msg>;
142
143/// Builds a message from a move among siblings.
144type MoveMessage<Msg> = Box<dyn Fn(TreeMove) -> Msg>;
145
146/// Builds the context menu entries of a node from its key.
147type MenuItems<Msg> = Box<dyn Fn(&str) -> Vec<ContextItem<Msg>>>;
148
149/// Nested rows that open and close, like folders.
150///
151/// The application owns the nodes, which are open and which one is selected, identified by
152/// node keys; the tree reports changes through messages. Only the open part of the tree is
153/// flattened and only the rows on screen are drawn, so large trees stay fast. Children can be
154/// loaded when a node opens: mark it [`TreeNode::expandable`], answer [`Tree::on_expand`] with a
155/// background command and mark the node [`TreeNode::loading`] meanwhile; its spinner only shows
156/// when the load is slow.
157///
158/// Rows are indented by space; openable rows carry a chevron. A hovered or selected row raises
159/// its surface and shows the pillar; only its icon and label slide one cell right. The
160/// indentation, the chevron (or the loading spinner in its place) and the detail never move, so
161/// the chevron is always where the pointer clicks it.
162///
163/// Keys while focused: ↑/↓ or k/j, PgUp/PgDn, Home/End move; → opens a node or moves to its
164/// first child; ← closes it or moves to its parent; Enter opens or closes a node with children
165/// and activates a leaf; Space activates. A click selects a row and opens, closes or activates
166/// it like Enter; a click on the chevron only opens or closes.
167///
168/// Two capabilities are off until asked for:
169///
170/// - [`reorderable`](Self::reorderable): drag a node to move it among its siblings; the siblings
171///   make room, a ghost row follows the pointer and a tinted slot shows where it lands, while the
172///   dragged node's own children fold away. Ctrl+Shift+↑/↓ moves the selected node one place. A
173///   node keeps its parent: moving under another parent is the application's own action, offered
174///   in the context menu. Held on the top or bottom row, or past them, a drag scrolls the tree one
175///   row after 400 ms and then every 150 ms. With reordering on, a click opens, closes or
176///   activates on release, so pressing a row to drag it does not open it.
177/// - [`context_menu`](Self::context_menu): a right click on a row opens a menu of actions for that
178///   node at the pointer and keeps the row raised while it is open; the menu key or Shift+F10
179///   opens the menu of the selected node below its row. Without it a right click does nothing.
180///
181/// Style keys: rows use `list-item` (`hover`, `selected`, `focus`, `pressed`), `list-item.faint`,
182/// `list-detail` and `list-header` (empty text) like [`List`](super::List); `tree-chevron`
183/// (`fg`) with `hover` and `selected`; `spinner` for loading nodes; `scrollbar`. Icons:
184/// `tree-collapsed`, `tree-expanded`, `spinner`. A drag uses `tab-drop` for the landing slot and
185/// `tab-ghost` for the row following the pointer, like the tabs; the menu uses the keys of
186/// [`ContextItem`].
187pub struct Tree<Msg> {
188    roots: Vec<TreeNode>,
189    selected: Option<String>,
190    empty: String,
191    on_select: Option<KeyMessage<Msg>>,
192    on_activate: Option<KeyMessage<Msg>>,
193    on_expand: Option<ExpandMessage<Msg>>,
194    on_move: Option<MoveMessage<Msg>>,
195    menu: Option<MenuItems<Msg>>,
196}
197
198impl<Msg: 'static> Tree<Msg> {
199    /// A tree with top-level nodes `roots`.
200    #[must_use]
201    pub fn new(roots: impl IntoIterator<Item = TreeNode>) -> Self {
202        Self {
203            roots: roots.into_iter().collect(),
204            selected: None,
205            empty: String::new(),
206            on_select: None,
207            on_activate: None,
208            on_expand: None,
209            on_move: None,
210            menu: None,
211        }
212    }
213
214    /// The key of the selected node.
215    #[must_use]
216    pub fn selected(mut self, key: Option<&str>) -> Self {
217        self.selected = key.map(str::to_owned);
218        self
219    }
220
221    /// Text shown when there are no nodes.
222    #[must_use]
223    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
224        self.empty = text.into();
225        self
226    }
227
228    /// Message for moving the selection to a node.
229    #[must_use]
230    pub fn on_select(mut self, message: impl Fn(&str) -> Msg + 'static) -> Self {
231        self.on_select = Some(Box::new(message));
232        self
233    }
234
235    /// Message for activating a node: Enter on a leaf, Space, a click on a leaf.
236    #[must_use]
237    pub fn on_activate(mut self, message: impl Fn(&str) -> Msg + 'static) -> Self {
238        self.on_activate = Some(Box::new(message));
239        self
240    }
241
242    /// Message asking to open (`true`) or close (`false`) a node.
243    #[must_use]
244    pub fn on_expand(mut self, message: impl Fn(&str, bool) -> Msg + 'static) -> Self {
245        self.on_expand = Some(Box::new(message));
246        self
247    }
248
249    /// Makes nodes reorderable among their siblings: `message(TreeMove)` asks the application to
250    /// move one. [`TreeMove::apply`] applies it to the application's list of siblings.
251    #[must_use]
252    pub fn reorderable(mut self, message: impl Fn(TreeMove) -> Msg + 'static) -> Self {
253        self.on_move = Some(Box::new(message));
254        self
255    }
256
257    /// Gives every node a context menu: `items(key)` builds the entries for the node with that key,
258    /// such as Rename, Archive or Move to. Choosing an entry sends its message.
259    #[must_use]
260    pub fn context_menu(mut self, items: impl Fn(&str) -> Vec<ContextItem<Msg>> + 'static) -> Self {
261        self.menu = Some(Box::new(items));
262        self
263    }
264
265    fn flatten(&self) -> Vec<Flat<'_>> {
266        self.flatten_with(None)
267    }
268
269    /// The visible rows, laid out for a drag when `arrange` is given.
270    fn flatten_with(&self, arrange: Option<&Arrange<'_>>) -> Vec<Flat<'_>> {
271        fn walk<'a>(
272            nodes: &'a [TreeNode],
273            parent_key: Option<&str>,
274            (depth, parent): (u16, Option<usize>),
275            arrange: Option<&Arrange<'_>>,
276            out: &mut Vec<Flat<'a>>,
277        ) {
278            let preview = arrange.filter(|arrange| arrange.parent == parent_key).and_then(|arrange| arrange.order);
279            for index in tab_model::preview_order(nodes.len(), preview) {
280                let node = &nodes[index];
281                let at = out.len();
282                out.push(Flat { node, depth, parent, index });
283                let folded = arrange.is_some_and(|arrange| arrange.key == node.key);
284                if node.expanded && !folded {
285                    walk(&node.children, Some(&node.key), (depth.saturating_add(1), Some(at)), arrange, out);
286                }
287            }
288        }
289        let mut out = Vec::new();
290        walk(&self.roots, None, (0, None), arrange, &mut out);
291        out
292    }
293
294    fn selected_index(&self, flat: &[Flat<'_>]) -> Option<usize> {
295        let key = self.selected.as_deref()?;
296        flat.iter().position(|row| row.node.key == key)
297    }
298
299    fn select(&self, cx: &mut EventCx<'_, Msg>, flat: &[Flat<'_>], index: usize) {
300        let Some(row) = flat.get(index) else { return };
301        if self.selected.as_deref() != Some(row.node.key.as_str())
302            && let Some(message) = &self.on_select
303        {
304            cx.emit(message(&row.node.key));
305        }
306    }
307
308    fn expand(&self, cx: &mut EventCx<'_, Msg>, node: &TreeNode, open: bool) -> bool {
309        match &self.on_expand {
310            Some(message) if node.expandable && node.expanded != open => {
311                cx.emit(message(&node.key, open));
312                true
313            }
314            _ => false,
315        }
316    }
317
318    fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize, node: &TreeNode) -> bool {
319        let Some(message) = &self.on_activate else {
320            return false;
321        };
322        cx.memory::<RowScroll>().flashed = Some(index);
323        cx.flash();
324        cx.emit(message(&node.key));
325        true
326    }
327
328    /// Enter and click: a node with children opens or closes, a leaf activates.
329    fn open_or_activate(&self, cx: &mut EventCx<'_, Msg>, index: usize, node: &TreeNode) -> bool {
330        if node.expandable { self.expand(cx, node, !node.expanded) } else { self.activate(cx, index, node) }
331    }
332
333    /// Moves the delayed spinner of every open row on to this frame and returns the rows whose
334    /// spinner shows. Marks of nodes that are gone or idle are forgotten.
335    fn loading_marks(cx: &mut PaintCx<'_>, flat: &[Flat<'_>]) -> Vec<usize> {
336        let now = cx.now();
337        let mut marks = std::mem::take(&mut cx.memory::<LoadingMarks>().0);
338        let mut kept = Vec::new();
339        let mut spinning = Vec::new();
340        let mut next: Option<std::time::Duration> = None;
341        for (index, row) in flat.iter().enumerate() {
342            let node = row.node;
343            let known = marks.iter().position(|(key, _)| *key == node.key);
344            if !node.expandable || (!node.loading && known.is_none()) {
345                continue;
346            }
347            let mut mark = known.map(|at| marks.swap_remove(at).1).unwrap_or_default();
348            if mark.update(node.loading, now) {
349                spinning.push(index);
350            }
351            if let Some(change) = mark.next_change(node.loading, now) {
352                next = Some(next.map_or(change, |soonest| soonest.min(change)));
353            }
354            if !mark.is_idle() {
355                kept.push((node.key.clone(), mark));
356            }
357        }
358        if let Some(delay) = next {
359            cx.request_frame_in(delay);
360        }
361        cx.memory::<LoadingMarks>().0 = kept;
362        spinning
363    }
364
365    /// Where the chevron of a row at `depth` starts, before any slide.
366    fn chevron_x(area: Rect, depth: u16) -> i32 {
367        area.x + i32::from(LEAD) + i32::from(depth.saturating_mul(INDENT))
368    }
369
370    fn paint_row(&self, cx: &mut PaintCx<'_>, rect: Rect, index: usize, row: &Flat<'_>, flags: RowFlags) {
371        let flashed = cx.memory::<RowScroll>().flashed == Some(index);
372        let states = rows::row_states(flags.hovered, flags.selected, flags.focused, flags.pressed && flashed);
373        let style = cx.style("list-item", row.node.faint.then_some("faint"), &states);
374        let slide = rows::slide(cx, &states) > 0;
375        Self::paint_node(cx, rect, row, (&style, &states), flags.spinning, slide);
376    }
377
378    /// Paints the node of `row` into `rect` in `style`, the look of a row in `states`.
379    fn paint_node(
380        cx: &mut PaintCx<'_>,
381        rect: Rect,
382        row: &Flat<'_>,
383        (style, states): (&WidgetStyle, &[State]),
384        spinning: bool,
385        slide: bool,
386    ) {
387        let node = row.node;
388        let text_style = style.text();
389        let detail_width = node.detail.as_deref().map_or(0, |d| text::width(d).saturating_add(2));
390
391        // The chevron is a fixed mark: it stays in its column while the icon and label slide.
392        // Leaves keep the chevron's column empty so labels of one level line up.
393        let chevron = if !node.expandable {
394            (" ".to_owned(), CellStyle::default())
395        } else if spinning {
396            let style = cx.style("spinner", None, &[]).text();
397            let cell = cx.animation(SpinnerStyle::Dots.animation(), style, Some(std::time::Duration::ZERO));
398            (text::truncate(&cell.glyph, 1).into_owned(), cell.style)
399        } else {
400            let key = if node.expanded { "tree-expanded" } else { "tree-collapsed" };
401            let glyph = text::truncate(&cx.env().icons().glyph(key), 1).into_owned();
402            (glyph, cx.style("tree-chevron", None, states).text())
403        };
404        let icon: Vec<row::Mark> =
405            node.icon.iter().map(|key| row::icon(cx, key, node.icon_color.as_deref(), text_style.fg)).collect();
406        let parts = row::Parts {
407            indent: row.depth.saturating_mul(INDENT),
408            fixed: &[chevron],
409            sliding: &icon,
410            label: &node.label,
411            trailing: detail_width,
412        };
413        row::paint_parts(cx, rect, style, slide, &parts);
414        if let Some(detail) = &node.detail {
415            let detail_style = cx.style("list-detail", None, states).text();
416            row::paint_trailing(cx, rect, detail, detail_style);
417        }
418    }
419}
420
421impl<Msg: 'static> Widget<Msg> for Tree<Msg> {
422    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
423        let flat = self.flatten();
424        let widest = flat
425            .iter()
426            .map(|row| {
427                // Saturating: labels and details can be wider than any screen.
428                [
429                    LEAD,
430                    row.depth.saturating_mul(INDENT),
431                    2,
432                    row.node.icon.as_ref().map_or(0, |_| 2),
433                    text::width(&row.node.label),
434                    row.node.detail.as_deref().map_or(0, |d| text::width(d).saturating_add(2)),
435                    2,
436                ]
437                .into_iter()
438                .fold(0, u16::saturating_add)
439            })
440            .max()
441            .unwrap_or_else(|| text::width(&self.empty).saturating_add(LEAD));
442        let rows = clamp_u16(i32::try_from(flat.len().max(1)).unwrap_or(i32::MAX));
443        Size::new(widest, rows).min(available)
444    }
445
446    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
447        cx.register_hit(area);
448        if self.roots.is_empty() {
449            let faint = cx.style("list-header", None, &[]).text();
450            cx.text(area.x + i32::from(LEAD), area.y, &self.empty, faint, area.width.saturating_sub(LEAD));
451            return;
452        }
453        let drag = self.drag(cx);
454        // An open context menu takes the overlay and the pointer: only the row it acts on stays
455        // raised.
456        let menu_node = self.menu_node(cx);
457        if menu_node.is_some() {
458            cx.request_overlay(area);
459        }
460        let flat = match &drag {
461            Some(drag) => {
462                let offset = cx.memory::<RowScroll>().offset;
463                let rows = Self::rows_area(area, self.flatten().len());
464                let order = self.landing(&drag.key, drag.pointer, rows, offset);
465                let parent = self.siblings(&drag.key).and_then(|(parent, _)| parent);
466                let arrange = Arrange { key: &drag.key, parent, order };
467                self.flatten_with(Some(&arrange))
468            }
469            None => self.flatten(),
470        };
471        let focused = cx.is_focused();
472        let pressed = cx.is_pressed();
473        let selected = self.selected_index(&flat);
474        let visible = usize::from(area.height);
475        let offset = cx.memory::<RowScroll>().follow(selected, flat.len(), visible);
476        let width = Self::rows_area(area, flat.len()).width;
477        let spinning = Self::loading_marks(cx, &flat);
478        let pointer = cx.pointer().filter(|_| drag.is_none() && menu_node.is_none());
479        for (row, index) in (offset..flat.len()).take(visible).enumerate() {
480            let rect = Rect::new(area.x, area.y + i32::try_from(row).unwrap_or(0), width, 1);
481            let key = flat[index].node.key.as_str();
482            if drag.as_ref().is_some_and(|drag| drag.key == key) {
483                tab_model::paint_drop_slot(cx, rect);
484                continue;
485            }
486            let hovered = pointer.is_some_and(|(x, y)| rect.contains(x, y)) || menu_node.as_deref() == Some(key);
487            let flags = RowFlags {
488                hovered,
489                selected: selected == Some(index),
490                focused,
491                pressed,
492                spinning: spinning.contains(&index),
493            };
494            self.paint_row(cx, rect, index, &flat[index], flags);
495        }
496        // The dragged node follows the pointer as a ghost row, kept inside the tree.
497        if let Some(drag) = &drag
498            && let Some(row) = flat.iter().find(|row| row.node.key == drag.key)
499            && !area.is_empty()
500        {
501            let y = drag.pointer.1.clamp(area.y, area.bottom() - 1);
502            let rect = Rect::new(area.x, y, width, 1);
503            // The ghost covers the row under it, so its surface is cleared first.
504            tab_model::paint_ghost_surface(cx, rect);
505            let ghost = cx.style("tab-ghost", None, &[]);
506            Self::paint_node(cx, rect, row, (&ghost, &[]), false, false);
507        }
508        rows::paint_scrollbar(cx, area, flat.len(), offset, None);
509    }
510
511    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
512        self.paint_menu(cx, anchor);
513    }
514
515    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
516        let area = cx.area();
517        let flat = self.flatten();
518        if self.menu_event(cx, event, &flat) {
519            return true;
520        }
521        let current = self.selected_index(&flat);
522        match event {
523            Event::Key(key) => {
524                if self.move_key(cx, key) {
525                    return true;
526                }
527                if let Some(step) = Step::from_key(key) {
528                    let Some(target) = step.apply(current, flat.len(), usize::from(area.height)) else {
529                        return false;
530                    };
531                    self.select(cx, &flat, target);
532                    return true;
533                }
534                let Some(index) = current else { return false };
535                let row = &flat[index];
536                if key.is_plain(Key::Right) || key.is_plain(Key::Char('l')) {
537                    if !row.node.expanded {
538                        return self.expand(cx, row.node, true);
539                    }
540                    if !row.node.children.is_empty() {
541                        self.select(cx, &flat, index + 1);
542                        return true;
543                    }
544                    return false;
545                }
546                if key.is_plain(Key::Left) || key.is_plain(Key::Char('h')) {
547                    if row.node.expanded {
548                        return self.expand(cx, row.node, false);
549                    }
550                    return row.parent.is_some_and(|parent| {
551                        self.select(cx, &flat, parent);
552                        true
553                    });
554                }
555                if key.is_plain(Key::Enter) {
556                    return self.open_or_activate(cx, index, row.node);
557                }
558                if key.is_plain(Key::Space) {
559                    return self.activate(cx, index, row.node);
560                }
561                false
562            }
563            Event::Mouse(mouse) => {
564                if rows::scroll_mouse(cx, mouse, area, flat.len()) {
565                    return true;
566                }
567                let offset = cx.memory::<RowScroll>().offset;
568                let index = usize::try_from(mouse.y - area.y).ok().map(|r| offset + r).filter(|i| *i < flat.len());
569                let on_chevron = index.is_some_and(|index| {
570                    let row = &flat[index];
571                    let chevron = Self::chevron_x(area, row.depth);
572                    row.node.expandable && (chevron..=chevron + 1).contains(&mouse.x)
573                });
574                if self.on_move.is_some()
575                    && !on_chevron
576                    && let Some(used) = self.reorder_pointer(cx, mouse, &flat, index)
577                {
578                    return used;
579                }
580                if mouse.kind != MouseKind::Down(MouseButton::Left) {
581                    return false;
582                }
583                let Some(index) = index else {
584                    return false;
585                };
586                let row = &flat[index];
587                if on_chevron {
588                    return self.expand(cx, row.node, !row.node.expanded);
589                }
590                self.select(cx, &flat, index);
591                self.open_or_activate(cx, index, row.node);
592                true
593            }
594            _ => false,
595        }
596    }
597
598    fn focusable(&self) -> bool {
599        !self.roots.is_empty()
600    }
601}