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    fn row_content_signature(&self) -> u64 {
145        use std::hash::{Hash, Hasher};
146        let mut h = std::collections::hash_map::DefaultHasher::new();
147        self.nodes.len().hash(&mut h);
148        for n in &self.nodes {
149            n.label.hash(&mut h);
150            n.parent.hash(&mut h);
151            n.order.hash(&mut h);
152            n.is_expanded.hash(&mut h);
153            n.is_selected.hash(&mut h);
154            (n.icon as u8).hash(&mut h);
155        }
156        self.hovered_row.hash(&mut h);
157        self.focused.hash(&mut h);
158        // Drag state affects which row to skip in the build.
159        self.drag
160            .as_ref()
161            .map(|d| (d.live, d.node_idx))
162            .hash(&mut h);
163        self.font_size.to_bits().hash(&mut h);
164        self.row_height.to_bits().hash(&mut h);
165        self.indent_width.to_bits().hash(&mut h);
166        h.finish()
167    }
168
169    pub fn with_row_height(mut self, h: f64) -> Self {
170        self.row_height = h;
171        self
172    }
173    pub fn with_indent_width(mut self, w: f64) -> Self {
174        self.indent_width = w;
175        self
176    }
177    pub fn with_font_size(mut self, s: f64) -> Self {
178        self.font_size = s;
179        self
180    }
181    pub fn with_drag_enabled(mut self) -> Self {
182        self.drag_enabled = true;
183        self
184    }
185    pub fn with_toggle_on_row_click(mut self) -> Self {
186        self.toggle_on_row_click = true;
187        self
188    }
189    pub fn with_hover_repaint(mut self, repaint: bool) -> Self {
190        self.hover_repaint = repaint;
191        self
192    }
193
194    pub fn with_margin(mut self, m: Insets) -> Self {
195        self.base.margin = m;
196        self
197    }
198    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
199        self.base.h_anchor = h;
200        self
201    }
202    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
203        self.base.v_anchor = v;
204        self
205    }
206    pub fn with_min_size(mut self, s: Size) -> Self {
207        self.base.min_size = s;
208        self
209    }
210    pub fn with_max_size(mut self, s: Size) -> Self {
211        self.base.max_size = s;
212        self
213    }
214
215    /// Add a root-level node; returns its index.
216    pub fn add_root(&mut self, label: impl Into<String>, icon: NodeIcon) -> usize {
217        let order = self.nodes.iter().filter(|n| n.parent.is_none()).count() as u32;
218        let idx = self.nodes.len();
219        self.nodes.push(TreeNode::new(label, icon, None, order));
220        idx
221    }
222
223    /// Add a child of `parent_idx`; returns its index.
224    pub fn add_child(
225        &mut self,
226        parent_idx: usize,
227        label: impl Into<String>,
228        icon: NodeIcon,
229    ) -> usize {
230        let order = self
231            .nodes
232            .iter()
233            .filter(|n| n.parent == Some(parent_idx))
234            .count() as u32;
235        let idx = self.nodes.len();
236        self.nodes
237            .push(TreeNode::new(label, icon, Some(parent_idx), order));
238        idx
239    }
240
241    /// Expand the node at `idx`.
242    pub fn expand(&mut self, idx: usize) {
243        if idx < self.nodes.len() {
244            self.nodes[idx].is_expanded = true;
245        }
246    }
247}
248
249// ---------------------------------------------------------------------------
250// Geometry helpers
251// ---------------------------------------------------------------------------
252
253impl TreeView {
254    fn scrollbar_x(&self) -> f64 {
255        self.bounds.width - SCROLLBAR_W
256    }
257
258    fn max_scroll(&self) -> f64 {
259        (self.content_height - self.bounds.height).max(0.0)
260    }
261
262    fn thumb_metrics(&self) -> Option<(f64, f64)> {
263        let h = self.bounds.height;
264        if self.content_height <= h {
265            return None;
266        }
267        let ratio = h / self.content_height;
268        let thumb_h = (h * ratio).max(20.0);
269        let track_h = h - thumb_h;
270        let thumb_y = track_h * (1.0 - self.scroll_offset / self.max_scroll());
271        Some((thumb_y, thumb_h))
272    }
273
274    /// Is `local_pos` in the scrollbar strip?
275    fn in_scrollbar(&self, local_pos: Point) -> bool {
276        local_pos.x >= self.scrollbar_x()
277    }
278
279    /// Returns the flat-row index (into `row_metas`/`row_widgets`) for the row
280    /// under `pos` in TreeView-local coordinates, or `None`.
281    fn row_index_at(&self, pos: Point) -> Option<usize> {
282        for (i, widget) in self.row_widgets.iter().enumerate() {
283            let b = widget.bounds();
284            // Clamp to visible content area — rows scrolled off-screen have b.y < 0
285            // or b.y + b.height > self.bounds.height; exclude those slivers.
286            if pos.y >= b.y.max(0.0)
287                && pos.y < (b.y + b.height).min(self.bounds.height)
288                && pos.x >= 0.0
289                && pos.x < self.bounds.width - SCROLLBAR_W
290            {
291                return Some(i);
292            }
293        }
294        None
295    }
296}
297
298// ---------------------------------------------------------------------------
299// Selection helpers
300// ---------------------------------------------------------------------------
301
302impl TreeView {
303    fn select_single(&mut self, node_idx: usize) {
304        for n in &mut self.nodes {
305            n.is_selected = false;
306        }
307        self.nodes[node_idx].is_selected = true;
308        self.cursor_node = Some(node_idx);
309    }
310
311    fn toggle_select(&mut self, node_idx: usize) {
312        self.nodes[node_idx].is_selected = !self.nodes[node_idx].is_selected;
313        self.cursor_node = Some(node_idx);
314    }
315
316    fn range_select(&mut self, anchor_node: usize, target_node: usize, rows: &[FlatRow]) {
317        let a = rows.iter().position(|r| r.node_idx == anchor_node);
318        let b = rows.iter().position(|r| r.node_idx == target_node);
319        if let (Some(a), Some(b)) = (a, b) {
320            let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
321            for n in &mut self.nodes {
322                n.is_selected = false;
323            }
324            for r in &rows[lo..=hi] {
325                self.nodes[r.node_idx].is_selected = true;
326            }
327        }
328        self.cursor_node = Some(target_node);
329    }
330
331    fn move_cursor(&mut self, delta: i32, rows: &[FlatRow]) {
332        if rows.is_empty() {
333            return;
334        }
335        let cur_flat = self
336            .cursor_node
337            .and_then(|ni| rows.iter().position(|r| r.node_idx == ni))
338            .unwrap_or(0);
339        let new_flat = (cur_flat as i32 + delta).clamp(0, rows.len() as i32 - 1) as usize;
340        let ni = rows[new_flat].node_idx;
341        self.select_single(ni);
342        // Scroll to keep the new row visible.
343        self.scroll_to_row(new_flat);
344    }
345
346    /// Returns the node index currently under the cursor, or `None`.
347    pub fn hovered_node_idx(&self) -> Option<usize> {
348        self.hovered_row
349            .and_then(|ri| self.row_metas.get(ri).map(|m| m.node_idx))
350    }
351
352    fn scroll_to_row(&mut self, flat_idx: usize) {
353        // `row_widgets` bounds reflect the `scroll_offset` from the last `layout()` call.
354        // The framework calls `layout()` every frame before rendering, so `scroll_offset`
355        // changes here will be reflected before the next mouse hit-test.
356        // Y-up coordinates: y_bottom is the lower edge (smaller Y) and y_top is the upper edge (larger Y).
357        let y_bottom =
358            self.bounds.height - (flat_idx as f64 + 1.0) * self.row_height + self.scroll_offset;
359        let y_top = y_bottom + self.row_height;
360        if y_bottom < 0.0 {
361            self.scroll_offset = (self.scroll_offset - y_bottom).min(self.max_scroll());
362        } else if y_top > self.bounds.height {
363            self.scroll_offset = (self.scroll_offset - (y_top - self.bounds.height)).max(0.0);
364        }
365    }
366}
367
368// ---------------------------------------------------------------------------
369// Widget impl
370// ---------------------------------------------------------------------------
371
372// ---------------------------------------------------------------------------
373// Mouse event handlers
374// ---------------------------------------------------------------------------
375
376impl TreeView {
377    fn handle_mouse_move(&mut self, pos: Point) -> EventResult {
378        let old_hovered_scrollbar = self.hovered_scrollbar;
379        let old_hovered_row = self.hovered_row;
380        self.hovered_scrollbar = self.in_scrollbar(pos);
381
382        if self.dragging_scrollbar {
383            if let Some((_, thumb_h)) = self.thumb_metrics() {
384                let h = self.bounds.height;
385                let track_h = (h - thumb_h).max(1.0);
386                let delta_y = self.sb_drag_start_y - pos.y;
387                let spp = self.max_scroll() / track_h;
388                self.scroll_offset =
389                    (self.sb_drag_start_offset + delta_y * spp).clamp(0.0, self.max_scroll());
390            }
391            return EventResult::Consumed;
392        }
393
394        if let Some(drag) = &mut self.drag {
395            let dx = pos.x - drag.current_pos.x;
396            let dy = pos.y - drag.current_pos.y;
397            drag.current_pos = pos;
398            if !drag.live && (dx * dx + dy * dy).sqrt() > DRAG_THRESHOLD {
399                drag.live = true;
400            }
401            if drag.live {
402                let node_idx = drag.node_idx;
403                let rows = flatten_visible(&self.nodes);
404                self.drop_target = compute_drop_target(
405                    pos,
406                    &rows,
407                    &self.nodes,
408                    self.bounds.height,
409                    self.row_height,
410                    self.scroll_offset,
411                    self.drag.as_ref().unwrap(),
412                );
413                let _ = node_idx;
414            }
415            return EventResult::Consumed;
416        }
417
418        self.hovered_row = self.row_index_at(pos);
419        if self.hover_repaint
420            && (self.hovered_scrollbar != old_hovered_scrollbar
421                || self.hovered_row != old_hovered_row)
422        {
423            EventResult::Consumed
424        } else {
425            EventResult::Ignored
426        }
427    }
428
429    fn handle_mouse_down(&mut self, pos: Point, mods: Modifiers) -> EventResult {
430        if self.in_scrollbar(pos) {
431            self.dragging_scrollbar = true;
432            self.sb_drag_start_y = pos.y;
433            self.sb_drag_start_offset = self.scroll_offset;
434            return EventResult::Consumed;
435        }
436
437        let Some(flat_i) = self.row_index_at(pos) else {
438            return EventResult::Ignored;
439        };
440        let meta = &self.row_metas[flat_i];
441        let node_idx = meta.node_idx;
442
443        // Expand/collapse: any click on a row with children toggles it when
444        // `toggle_on_row_click` is enabled (file-explorer style).  Otherwise
445        // only the expand-toggle arrow triggers expansion so that clicking a
446        // row in the inspector tree selects it without accidentally collapsing
447        // a branch the user was browsing.
448        if self.toggle_on_row_click {
449            if meta.toggle_rect.is_some() {
450                self.nodes[node_idx].is_expanded = !self.nodes[node_idx].is_expanded;
451            }
452        } else if let Some(tr) = meta.toggle_rect {
453            if pos.x >= tr.x && pos.x < tr.x + tr.width && pos.y >= tr.y && pos.y < tr.y + tr.height
454            {
455                self.nodes[node_idx].is_expanded = !self.nodes[node_idx].is_expanded;
456            }
457        }
458
459        // Selection
460        if mods.ctrl {
461            self.toggle_select(node_idx);
462        } else if mods.shift {
463            if let Some(a) = self.cursor_node {
464                let rows2 = flatten_visible(&self.nodes);
465                self.range_select(a, node_idx, &rows2);
466            } else {
467                self.select_single(node_idx);
468            }
469        } else {
470            self.select_single(node_idx);
471            if self.drag_enabled {
472                let y_bot = self.row_widgets[flat_i].bounds().y;
473                self.drag = Some(DragState {
474                    node_idx,
475                    _cursor_row_offset: pos.y - y_bot,
476                    current_pos: pos,
477                    live: false,
478                });
479            }
480        }
481
482        EventResult::Consumed
483    }
484
485    fn handle_mouse_up(&mut self, pos: Point) -> EventResult {
486        // Scrollbar drag end
487        if self.dragging_scrollbar {
488            self.dragging_scrollbar = false;
489            return EventResult::Consumed;
490        }
491
492        // Node drag end
493        if let Some(drag) = self.drag.take() {
494            if drag.live {
495                if let Some(target) = self.drop_target.take() {
496                    apply_drop(&mut self.nodes, drag.node_idx, target);
497                }
498            } else {
499                // Was a click, not a drag — finalize single-select.
500                self.select_single(drag.node_idx);
501            }
502            self.drop_target = None;
503            return EventResult::Consumed;
504        }
505
506        let _ = pos;
507        EventResult::Ignored
508    }
509
510    fn handle_key_down(&mut self, key: &Key, mods: Modifiers) -> EventResult {
511        let rows = flatten_visible(&self.nodes);
512        match key {
513            Key::ArrowDown => {
514                self.move_cursor(1, &rows);
515                EventResult::Consumed
516            }
517            Key::ArrowUp => {
518                self.move_cursor(-1, &rows);
519                EventResult::Consumed
520            }
521            Key::ArrowRight => {
522                if let Some(ni) = self.cursor_node {
523                    if !self.nodes[ni].is_expanded
524                        && rows.iter().any(|r| r.node_idx == ni && r.has_children)
525                    {
526                        self.nodes[ni].is_expanded = true;
527                    } else {
528                        // Move to first child
529                        if rows.iter().any(|r| r.node_idx == ni) {
530                            self.move_cursor(1, &rows);
531                        }
532                    }
533                }
534                EventResult::Consumed
535            }
536            Key::ArrowLeft => {
537                if let Some(ni) = self.cursor_node {
538                    if self.nodes[ni].is_expanded {
539                        self.nodes[ni].is_expanded = false;
540                    } else if let Some(parent_idx) = self.nodes[ni].parent {
541                        self.select_single(parent_idx);
542                        if let Some(fi) = rows.iter().position(|r| r.node_idx == parent_idx) {
543                            self.scroll_to_row(fi);
544                        }
545                    }
546                }
547                EventResult::Consumed
548            }
549            Key::Char(' ') | Key::Enter => {
550                if let Some(ni) = self.cursor_node {
551                    if rows.iter().any(|r| r.node_idx == ni && r.has_children) {
552                        self.nodes[ni].is_expanded = !self.nodes[ni].is_expanded;
553                    }
554                }
555                EventResult::Consumed
556            }
557            Key::Tab => EventResult::Ignored, // let App handle focus advancement
558            _ => {
559                let _ = mods;
560                EventResult::Ignored
561            }
562        }
563    }
564}