Skip to main content

agg_gui/widgets/tree_view/
mod.rs

1//! `TreeView` — compositional tree widget with expand/collapse, multi-select,
2//! keyboard navigation, and drag-and-drop reordering.
3//!
4//! Each visible row is represented by a `TreeRow` child widget stored in
5//! `row_widgets`.  The framework recurses into these children after `paint()`
6//! returns, so the `clip_rect` set at the end of `paint()` is active during
7//! child painting.
8
9mod drag;
10mod node;
11pub mod row;
12mod widget_impl;
13
14use drag::{apply_drop, compute_drop_target};
15use node::{flatten_visible, DragState, DropPosition, FlatRow};
16pub use node::{NodeIcon, TreeNode};
17pub use row::{ExpandToggle, NodeIconWidget, TreeRow};
18
19use std::sync::Arc;
20
21use crate::event::{EventResult, Key, Modifiers};
22use crate::geometry::{Point, Rect, Size};
23use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
24use crate::text::Font;
25use crate::widget::Widget;
26
27const SCROLLBAR_W: f64 = 10.0;
28const DRAG_THRESHOLD: f64 = 4.0;
29
30// ---------------------------------------------------------------------------
31// RowMeta
32// ---------------------------------------------------------------------------
33
34/// Metadata for one visible row; parallel to `row_widgets` after `layout()`.
35struct RowMeta {
36    /// Index into `self.nodes` for this row.
37    node_idx: usize,
38    /// Bounds of the `ExpandToggle` in **TreeView-local** coordinates.
39    /// `None` if the node has no children.
40    toggle_rect: Option<Rect>,
41}
42
43// ---------------------------------------------------------------------------
44// TreeView struct
45// ---------------------------------------------------------------------------
46
47pub struct TreeView {
48    bounds: Rect,
49    /// One `TreeRow` per currently-visible node; rebuilt each `layout()` call.
50    row_widgets: Vec<Box<dyn Widget>>,
51    base: WidgetBase,
52    /// Parallel to `row_widgets` — metadata for hit-testing in `on_event()`.
53    row_metas: Vec<RowMeta>,
54
55    pub nodes: Vec<TreeNode>,
56
57    // Scroll state
58    scroll_offset: f64,
59    content_height: f64,
60
61    // Row metrics
62    pub row_height: f64,
63    pub indent_width: f64,
64    pub font: Arc<Font>,
65    pub font_size: f64,
66
67    // Interaction
68    pub drag_enabled: bool,
69    /// When `true`, clicking anywhere on a row that has children also toggles
70    /// its expansion state.  When `false` (the default), only the expand-toggle
71    /// arrow collapses/expands; clicks elsewhere only select.
72    ///
73    /// Set to `true` for file-explorer-style trees (the demo Tree tab).
74    /// Leave `false` for the inspector tree, where clicking selects without
75    /// accidentally collapsing an expanded branch.
76    pub toggle_on_row_click: bool,
77    hover_repaint: bool,
78    focused: bool,
79    /// Flat-row index of the row under the cursor.
80    hovered_row: Option<usize>,
81    /// Node index used as the keyboard cursor / shift-click anchor.
82    cursor_node: Option<usize>,
83    /// Active drag gesture.
84    drag: Option<DragState>,
85    /// Current computed drop target.
86    drop_target: Option<DropPosition>,
87
88    // Scrollbar drag
89    hovered_scrollbar: bool,
90    dragging_scrollbar: bool,
91    sb_drag_start_y: f64,
92    sb_drag_start_offset: f64,
93
94    /// Hash of the row-content state at the last `layout()` rebuild —
95    /// covers everything that affects WHICH `TreeRow` widgets exist
96    /// (node order, label text, expand / select / hover / focus state)
97    /// but NOT what affects only their bounds (viewport size, scroll
98    /// offset).  When the next `layout()` finds an unchanged signature,
99    /// it reuses the cached `row_widgets` Vec — preserving each Label's
100    /// backbuffer cache — and only repositions them.  Without this,
101    /// resizing a window with a 250+-row tree (the inspector) re-rasterised
102    /// every label every frame.
103    last_row_content_sig: Option<u64>,
104}
105
106// ---------------------------------------------------------------------------
107// Construction
108// ---------------------------------------------------------------------------
109
110impl TreeView {
111    pub fn new(font: Arc<Font>) -> Self {
112        Self {
113            bounds: Rect::default(),
114            row_widgets: Vec::new(),
115            base: WidgetBase::new(),
116            row_metas: Vec::new(),
117            nodes: Vec::new(),
118            scroll_offset: 0.0,
119            content_height: 0.0,
120            row_height: 24.0,
121            indent_width: 16.0,
122            font,
123            font_size: 13.0,
124            drag_enabled: false,
125            toggle_on_row_click: false,
126            hover_repaint: true,
127            focused: false,
128            hovered_row: None,
129            cursor_node: None,
130            drag: None,
131            drop_target: None,
132            hovered_scrollbar: false,
133            dragging_scrollbar: false,
134            sb_drag_start_y: 0.0,
135            sb_drag_start_offset: 0.0,
136            last_row_content_sig: None,
137        }
138    }
139
140    /// Hash of everything that affects WHICH `TreeRow` widgets we'd build
141    /// — but not their bounds.  Used by `layout()` to skip rebuilding the
142    /// row widget vec when the user is just resizing the parent (window
143    /// resize, scroll, etc.) and the underlying node list hasn't moved.
144    ///
145    /// `hovered_row` is intentionally NOT part of the signature.  Hover
146    /// is painted by `TreeView::paint` from `self.hovered_row` directly,
147    /// without touching the per-row `TreeRow` widgets, so a hover flip
148    /// doesn't throw away the row vec (and with it every cached `Label`
149    /// backbuffer).
150    fn row_content_signature(&self) -> u64 {
151        use std::hash::{Hash, Hasher};
152        let mut h = std::collections::hash_map::DefaultHasher::new();
153        self.nodes.len().hash(&mut h);
154        for n in &self.nodes {
155            n.label.hash(&mut h);
156            n.parent.hash(&mut h);
157            n.order.hash(&mut h);
158            n.is_expanded.hash(&mut h);
159            n.is_selected.hash(&mut h);
160            (n.icon as u8).hash(&mut h);
161        }
162        self.focused.hash(&mut h);
163        // Drag state affects which row to skip in the build.
164        self.drag
165            .as_ref()
166            .map(|d| (d.live, d.node_idx))
167            .hash(&mut h);
168        self.font_size.to_bits().hash(&mut h);
169        self.row_height.to_bits().hash(&mut h);
170        self.indent_width.to_bits().hash(&mut h);
171        h.finish()
172    }
173
174    pub fn with_row_height(mut self, h: f64) -> Self {
175        self.row_height = h;
176        self
177    }
178    pub fn with_indent_width(mut self, w: f64) -> Self {
179        self.indent_width = w;
180        self
181    }
182    pub fn with_font_size(mut self, s: f64) -> Self {
183        self.font_size = s;
184        self
185    }
186    pub fn with_drag_enabled(mut self) -> Self {
187        self.drag_enabled = true;
188        self
189    }
190    pub fn with_toggle_on_row_click(mut self) -> Self {
191        self.toggle_on_row_click = true;
192        self
193    }
194    pub fn with_hover_repaint(mut self, repaint: bool) -> Self {
195        self.hover_repaint = repaint;
196        self
197    }
198
199    pub fn with_margin(mut self, m: Insets) -> Self {
200        self.base.margin = m;
201        self
202    }
203    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
204        self.base.h_anchor = h;
205        self
206    }
207    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
208        self.base.v_anchor = v;
209        self
210    }
211    pub fn with_min_size(mut self, s: Size) -> Self {
212        self.base.min_size = s;
213        self
214    }
215    pub fn with_max_size(mut self, s: Size) -> Self {
216        self.base.max_size = s;
217        self
218    }
219
220    /// Add a root-level node; returns its index.
221    pub fn add_root(&mut self, label: impl Into<String>, icon: NodeIcon) -> usize {
222        let order = self.nodes.iter().filter(|n| n.parent.is_none()).count() as u32;
223        let idx = self.nodes.len();
224        self.nodes.push(TreeNode::new(label, icon, None, order));
225        idx
226    }
227
228    /// Add a child of `parent_idx`; returns its index.
229    pub fn add_child(
230        &mut self,
231        parent_idx: usize,
232        label: impl Into<String>,
233        icon: NodeIcon,
234    ) -> usize {
235        let order = self
236            .nodes
237            .iter()
238            .filter(|n| n.parent == Some(parent_idx))
239            .count() as u32;
240        let idx = self.nodes.len();
241        self.nodes
242            .push(TreeNode::new(label, icon, Some(parent_idx), order));
243        idx
244    }
245
246    /// Expand the node at `idx`.
247    pub fn expand(&mut self, idx: usize) {
248        if idx < self.nodes.len() {
249            self.nodes[idx].is_expanded = true;
250        }
251    }
252}
253
254// ---------------------------------------------------------------------------
255// Geometry helpers
256// ---------------------------------------------------------------------------
257
258impl TreeView {
259    fn scrollbar_x(&self) -> f64 {
260        self.bounds.width - SCROLLBAR_W
261    }
262
263    fn max_scroll(&self) -> f64 {
264        (self.content_height - self.bounds.height).max(0.0)
265    }
266
267    fn thumb_metrics(&self) -> Option<(f64, f64)> {
268        let h = self.bounds.height;
269        if self.content_height <= h {
270            return None;
271        }
272        let ratio = h / self.content_height;
273        let thumb_h = (h * ratio).max(20.0);
274        let track_h = h - thumb_h;
275        let thumb_y = track_h * (1.0 - self.scroll_offset / self.max_scroll());
276        Some((thumb_y, thumb_h))
277    }
278
279    /// Is `local_pos` in the scrollbar strip?
280    fn in_scrollbar(&self, local_pos: Point) -> bool {
281        local_pos.x >= self.scrollbar_x()
282    }
283
284    /// Returns the flat-row index (into `row_metas`/`row_widgets`) for the row
285    /// under `pos` in TreeView-local coordinates, or `None`.
286    fn row_index_at(&self, pos: Point) -> Option<usize> {
287        for (i, widget) in self.row_widgets.iter().enumerate() {
288            let b = widget.bounds();
289            // Clamp to visible content area — rows scrolled off-screen have b.y < 0
290            // or b.y + b.height > self.bounds.height; exclude those slivers.
291            if pos.y >= b.y.max(0.0)
292                && pos.y < (b.y + b.height).min(self.bounds.height)
293                && pos.x >= 0.0
294                && pos.x < self.bounds.width - SCROLLBAR_W
295            {
296                return Some(i);
297            }
298        }
299        None
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Selection helpers
305// ---------------------------------------------------------------------------
306
307impl TreeView {
308    fn select_single(&mut self, node_idx: usize) {
309        for n in &mut self.nodes {
310            n.is_selected = false;
311        }
312        self.nodes[node_idx].is_selected = true;
313        self.cursor_node = Some(node_idx);
314    }
315
316    fn toggle_select(&mut self, node_idx: usize) {
317        self.nodes[node_idx].is_selected = !self.nodes[node_idx].is_selected;
318        self.cursor_node = Some(node_idx);
319    }
320
321    fn range_select(&mut self, anchor_node: usize, target_node: usize, rows: &[FlatRow]) {
322        let a = rows.iter().position(|r| r.node_idx == anchor_node);
323        let b = rows.iter().position(|r| r.node_idx == target_node);
324        if let (Some(a), Some(b)) = (a, b) {
325            let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
326            for n in &mut self.nodes {
327                n.is_selected = false;
328            }
329            for r in &rows[lo..=hi] {
330                self.nodes[r.node_idx].is_selected = true;
331            }
332        }
333        self.cursor_node = Some(target_node);
334    }
335
336    fn move_cursor(&mut self, delta: i32, rows: &[FlatRow]) {
337        if rows.is_empty() {
338            return;
339        }
340        let cur_flat = self
341            .cursor_node
342            .and_then(|ni| rows.iter().position(|r| r.node_idx == ni))
343            .unwrap_or(0);
344        let new_flat = (cur_flat as i32 + delta).clamp(0, rows.len() as i32 - 1) as usize;
345        let ni = rows[new_flat].node_idx;
346        self.select_single(ni);
347        // Scroll to keep the new row visible.
348        self.scroll_to_row(new_flat);
349    }
350
351    /// Returns the node index currently under the cursor, or `None`.
352    pub fn hovered_node_idx(&self) -> Option<usize> {
353        self.hovered_row
354            .and_then(|ri| self.row_metas.get(ri).map(|m| m.node_idx))
355    }
356
357    /// Clear the hover state — useful when the mouse leaves the area the
358    /// parent considers part of the tree (e.g. into the InspectorPanel's
359    /// header or property pane).  Bumps the invalidation epoch so the
360    /// previously-hovered row's background re-rasterises.
361    pub fn clear_hover(&mut self) {
362        if self.hovered_row.is_some() || self.hovered_scrollbar {
363            self.hovered_row = None;
364            self.hovered_scrollbar = false;
365            crate::animation::request_draw();
366        }
367    }
368
369    fn scroll_to_row(&mut self, flat_idx: usize) {
370        // `row_widgets` bounds reflect the `scroll_offset` from the last `layout()` call.
371        // The framework calls `layout()` every frame before rendering, so `scroll_offset`
372        // changes here will be reflected before the next mouse hit-test.
373        // Y-up coordinates: y_bottom is the lower edge (smaller Y) and y_top is the upper edge (larger Y).
374        let y_bottom =
375            self.bounds.height - (flat_idx as f64 + 1.0) * self.row_height + self.scroll_offset;
376        let y_top = y_bottom + self.row_height;
377        if y_bottom < 0.0 {
378            self.scroll_offset = (self.scroll_offset - y_bottom).min(self.max_scroll());
379        } else if y_top > self.bounds.height {
380            self.scroll_offset = (self.scroll_offset - (y_top - self.bounds.height)).max(0.0);
381        }
382    }
383}
384
385// ---------------------------------------------------------------------------
386// Widget impl
387// ---------------------------------------------------------------------------
388
389// ---------------------------------------------------------------------------
390// Mouse event handlers
391// ---------------------------------------------------------------------------
392
393impl TreeView {
394    fn handle_mouse_move(&mut self, pos: Point) -> EventResult {
395        let old_hovered_scrollbar = self.hovered_scrollbar;
396        let old_hovered_row = self.hovered_row;
397        self.hovered_scrollbar = self.in_scrollbar(pos);
398
399        if self.dragging_scrollbar {
400            if let Some((_, thumb_h)) = self.thumb_metrics() {
401                let h = self.bounds.height;
402                let track_h = (h - thumb_h).max(1.0);
403                let delta_y = self.sb_drag_start_y - pos.y;
404                let spp = self.max_scroll() / track_h;
405                self.scroll_offset =
406                    (self.sb_drag_start_offset + delta_y * spp).clamp(0.0, self.max_scroll());
407            }
408            return EventResult::Consumed;
409        }
410
411        if let Some(drag) = &mut self.drag {
412            let dx = pos.x - drag.current_pos.x;
413            let dy = pos.y - drag.current_pos.y;
414            drag.current_pos = pos;
415            if !drag.live && (dx * dx + dy * dy).sqrt() > DRAG_THRESHOLD {
416                drag.live = true;
417            }
418            if drag.live {
419                let node_idx = drag.node_idx;
420                let rows = flatten_visible(&self.nodes);
421                self.drop_target = compute_drop_target(
422                    pos,
423                    &rows,
424                    &self.nodes,
425                    self.bounds.height,
426                    self.row_height,
427                    self.scroll_offset,
428                    self.drag.as_ref().unwrap(),
429                );
430                let _ = node_idx;
431            }
432            return EventResult::Consumed;
433        }
434
435        self.hovered_row = self.row_index_at(pos);
436        if self.hover_repaint
437            && (self.hovered_scrollbar != old_hovered_scrollbar
438                || self.hovered_row != old_hovered_row)
439        {
440            EventResult::Consumed
441        } else {
442            EventResult::Ignored
443        }
444    }
445
446    fn handle_mouse_down(&mut self, pos: Point, mods: Modifiers) -> EventResult {
447        if self.in_scrollbar(pos) {
448            self.dragging_scrollbar = true;
449            self.sb_drag_start_y = pos.y;
450            self.sb_drag_start_offset = self.scroll_offset;
451            return EventResult::Consumed;
452        }
453
454        let Some(flat_i) = self.row_index_at(pos) else {
455            return EventResult::Ignored;
456        };
457        let meta = &self.row_metas[flat_i];
458        let node_idx = meta.node_idx;
459
460        // Expand/collapse: any click on a row with children toggles it when
461        // `toggle_on_row_click` is enabled (file-explorer style).  Otherwise
462        // only the expand-toggle arrow triggers expansion so that clicking a
463        // row in the inspector tree selects it without accidentally collapsing
464        // a branch the user was browsing.
465        if self.toggle_on_row_click {
466            if meta.toggle_rect.is_some() {
467                self.nodes[node_idx].is_expanded = !self.nodes[node_idx].is_expanded;
468            }
469        } else if let Some(tr) = meta.toggle_rect {
470            if pos.x >= tr.x && pos.x < tr.x + tr.width && pos.y >= tr.y && pos.y < tr.y + tr.height
471            {
472                self.nodes[node_idx].is_expanded = !self.nodes[node_idx].is_expanded;
473            }
474        }
475
476        // Selection
477        if mods.ctrl {
478            self.toggle_select(node_idx);
479        } else if mods.shift {
480            if let Some(a) = self.cursor_node {
481                let rows2 = flatten_visible(&self.nodes);
482                self.range_select(a, node_idx, &rows2);
483            } else {
484                self.select_single(node_idx);
485            }
486        } else {
487            self.select_single(node_idx);
488            if self.drag_enabled {
489                let y_bot = self.row_widgets[flat_i].bounds().y;
490                self.drag = Some(DragState {
491                    node_idx,
492                    _cursor_row_offset: pos.y - y_bot,
493                    current_pos: pos,
494                    live: false,
495                });
496            }
497        }
498
499        EventResult::Consumed
500    }
501
502    fn handle_mouse_up(&mut self, pos: Point) -> EventResult {
503        // Scrollbar drag end
504        if self.dragging_scrollbar {
505            self.dragging_scrollbar = false;
506            return EventResult::Consumed;
507        }
508
509        // Node drag end
510        if let Some(drag) = self.drag.take() {
511            if drag.live {
512                if let Some(target) = self.drop_target.take() {
513                    apply_drop(&mut self.nodes, drag.node_idx, target);
514                }
515            } else {
516                // Was a click, not a drag — finalize single-select.
517                self.select_single(drag.node_idx);
518            }
519            self.drop_target = None;
520            return EventResult::Consumed;
521        }
522
523        let _ = pos;
524        EventResult::Ignored
525    }
526
527    fn handle_key_down(&mut self, key: &Key, mods: Modifiers) -> EventResult {
528        let rows = flatten_visible(&self.nodes);
529        match key {
530            Key::ArrowDown => {
531                self.move_cursor(1, &rows);
532                EventResult::Consumed
533            }
534            Key::ArrowUp => {
535                self.move_cursor(-1, &rows);
536                EventResult::Consumed
537            }
538            Key::ArrowRight => {
539                if let Some(ni) = self.cursor_node {
540                    if !self.nodes[ni].is_expanded
541                        && rows.iter().any(|r| r.node_idx == ni && r.has_children)
542                    {
543                        self.nodes[ni].is_expanded = true;
544                    } else {
545                        // Move to first child
546                        if rows.iter().any(|r| r.node_idx == ni) {
547                            self.move_cursor(1, &rows);
548                        }
549                    }
550                }
551                EventResult::Consumed
552            }
553            Key::ArrowLeft => {
554                if let Some(ni) = self.cursor_node {
555                    if self.nodes[ni].is_expanded {
556                        self.nodes[ni].is_expanded = false;
557                    } else if let Some(parent_idx) = self.nodes[ni].parent {
558                        self.select_single(parent_idx);
559                        if let Some(fi) = rows.iter().position(|r| r.node_idx == parent_idx) {
560                            self.scroll_to_row(fi);
561                        }
562                    }
563                }
564                EventResult::Consumed
565            }
566            Key::Char(' ') | Key::Enter => {
567                if let Some(ni) = self.cursor_node {
568                    if rows.iter().any(|r| r.node_idx == ni && r.has_children) {
569                        self.nodes[ni].is_expanded = !self.nodes[ni].is_expanded;
570                    }
571                }
572                EventResult::Consumed
573            }
574            Key::Tab => EventResult::Ignored, // let App handle focus advancement
575            _ => {
576                let _ = mods;
577                EventResult::Ignored
578            }
579        }
580    }
581}