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 node;
10mod drag;
11pub mod row;
12
13pub use node::{NodeIcon, TreeNode};
14pub use row::{ExpandToggle, NodeIconWidget, TreeRow};
15use node::{DragState, DropPosition, FlatRow, flatten_visible};
16use drag::{apply_drop, compute_drop_target, paint_drop_child_highlight,
17           paint_drop_line, paint_ghost};
18use row::{EXPAND_W, icon_color};
19
20use std::sync::Arc;
21
22use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
23use crate::geometry::{Point, Rect, Size};
24use crate::draw_ctx::DrawCtx;
25use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
26use crate::text::Font;
27use crate::widget::Widget;
28
29const SCROLLBAR_W: f64 = 10.0;
30const DRAG_THRESHOLD: f64 = 4.0;
31
32// ---------------------------------------------------------------------------
33// RowMeta
34// ---------------------------------------------------------------------------
35
36/// Metadata for one visible row; parallel to `row_widgets` after `layout()`.
37struct RowMeta {
38    /// Index into `self.nodes` for this row.
39    node_idx: usize,
40    /// Bounds of the `ExpandToggle` in **TreeView-local** coordinates.
41    /// `None` if the node has no children.
42    toggle_rect: Option<Rect>,
43}
44
45// ---------------------------------------------------------------------------
46// TreeView struct
47// ---------------------------------------------------------------------------
48
49pub struct TreeView {
50    bounds: Rect,
51    /// One `TreeRow` per currently-visible node; rebuilt each `layout()` call.
52    row_widgets: Vec<Box<dyn Widget>>,
53    base: WidgetBase,
54    /// Parallel to `row_widgets` — metadata for hit-testing in `on_event()`.
55    row_metas: Vec<RowMeta>,
56
57    pub nodes: Vec<TreeNode>,
58
59    // Scroll state
60    scroll_offset: f64,
61    content_height: f64,
62
63    // Row metrics
64    pub row_height: f64,
65    pub indent_width: f64,
66    pub font: Arc<Font>,
67    pub font_size: f64,
68
69    // Interaction
70    pub drag_enabled: bool,
71    /// When `true`, clicking anywhere on a row that has children also toggles
72    /// its expansion state.  When `false` (the default), only the expand-toggle
73    /// arrow collapses/expands; clicks elsewhere only select.
74    ///
75    /// Set to `true` for file-explorer-style trees (the demo Tree tab).
76    /// Leave `false` for the inspector tree, where clicking selects without
77    /// accidentally collapsing an expanded branch.
78    pub toggle_on_row_click: bool,
79    focused: bool,
80    /// Flat-row index of the row under the cursor.
81    hovered_row: Option<usize>,
82    /// Node index used as the keyboard cursor / shift-click anchor.
83    cursor_node: Option<usize>,
84    /// Active drag gesture.
85    drag: Option<DragState>,
86    /// Current computed drop target.
87    drop_target: Option<DropPosition>,
88
89    // Scrollbar drag
90    hovered_scrollbar: bool,
91    dragging_scrollbar: bool,
92    sb_drag_start_y: f64,
93    sb_drag_start_offset: f64,
94}
95
96// ---------------------------------------------------------------------------
97// Construction
98// ---------------------------------------------------------------------------
99
100impl TreeView {
101    pub fn new(font: Arc<Font>) -> Self {
102        Self {
103            bounds: Rect::default(),
104            row_widgets: Vec::new(),
105            base: WidgetBase::new(),
106            row_metas: Vec::new(),
107            nodes: Vec::new(),
108            scroll_offset: 0.0,
109            content_height: 0.0,
110            row_height: 24.0,
111            indent_width: 16.0,
112            font,
113            font_size: 13.0,
114            drag_enabled: false,
115            toggle_on_row_click: false,
116            focused: false,
117            hovered_row: None,
118            cursor_node: None,
119            drag: None,
120            drop_target: None,
121            hovered_scrollbar: false,
122            dragging_scrollbar: false,
123            sb_drag_start_y: 0.0,
124            sb_drag_start_offset: 0.0,
125        }
126    }
127
128    pub fn with_row_height(mut self, h: f64) -> Self { self.row_height = h; self }
129    pub fn with_indent_width(mut self, w: f64) -> Self { self.indent_width = w; self }
130    pub fn with_font_size(mut self, s: f64) -> Self { self.font_size = s; self }
131    pub fn with_drag_enabled(mut self) -> Self { self.drag_enabled = true; self }
132    pub fn with_toggle_on_row_click(mut self) -> Self { self.toggle_on_row_click = true; self }
133
134    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
135    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
136    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
137    pub fn with_min_size(mut self, s: Size)    -> Self { self.base.min_size = s; self }
138    pub fn with_max_size(mut self, s: Size)    -> Self { self.base.max_size = s; self }
139
140    /// Add a root-level node; returns its index.
141    pub fn add_root(&mut self, label: impl Into<String>, icon: NodeIcon) -> usize {
142        let order = self.nodes.iter().filter(|n| n.parent.is_none()).count() as u32;
143        let idx = self.nodes.len();
144        self.nodes.push(TreeNode::new(label, icon, None, order));
145        idx
146    }
147
148    /// Add a child of `parent_idx`; returns its index.
149    pub fn add_child(
150        &mut self,
151        parent_idx: usize,
152        label: impl Into<String>,
153        icon: NodeIcon,
154    ) -> usize {
155        let order = self.nodes
156            .iter()
157            .filter(|n| n.parent == Some(parent_idx))
158            .count() as u32;
159        let idx = self.nodes.len();
160        self.nodes.push(TreeNode::new(label, icon, Some(parent_idx), order));
161        idx
162    }
163
164    /// Expand the node at `idx`.
165    pub fn expand(&mut self, idx: usize) {
166        if idx < self.nodes.len() { self.nodes[idx].is_expanded = true; }
167    }
168}
169
170// ---------------------------------------------------------------------------
171// Geometry helpers
172// ---------------------------------------------------------------------------
173
174impl TreeView {
175    fn scrollbar_x(&self) -> f64 { self.bounds.width - SCROLLBAR_W }
176
177    fn max_scroll(&self) -> f64 {
178        (self.content_height - self.bounds.height).max(0.0)
179    }
180
181    fn thumb_metrics(&self) -> Option<(f64, f64)> {
182        let h = self.bounds.height;
183        if self.content_height <= h { return None; }
184        let ratio = h / self.content_height;
185        let thumb_h = (h * ratio).max(20.0);
186        let track_h = h - thumb_h;
187        let thumb_y = track_h * (1.0 - self.scroll_offset / self.max_scroll());
188        Some((thumb_y, thumb_h))
189    }
190
191    /// Is `local_pos` in the scrollbar strip?
192    fn in_scrollbar(&self, local_pos: Point) -> bool {
193        local_pos.x >= self.scrollbar_x()
194    }
195
196    /// Returns the flat-row index (into `row_metas`/`row_widgets`) for the row
197    /// under `pos` in TreeView-local coordinates, or `None`.
198    fn row_index_at(&self, pos: Point) -> Option<usize> {
199        for (i, widget) in self.row_widgets.iter().enumerate() {
200            let b = widget.bounds();
201            // Clamp to visible content area — rows scrolled off-screen have b.y < 0
202            // or b.y + b.height > self.bounds.height; exclude those slivers.
203            if pos.y >= b.y.max(0.0)
204                && pos.y < (b.y + b.height).min(self.bounds.height)
205                && pos.x >= 0.0
206                && pos.x < self.bounds.width - SCROLLBAR_W
207            {
208                return Some(i);
209            }
210        }
211        None
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Selection helpers
217// ---------------------------------------------------------------------------
218
219impl TreeView {
220    fn select_single(&mut self, node_idx: usize) {
221        for n in &mut self.nodes { n.is_selected = false; }
222        self.nodes[node_idx].is_selected = true;
223        self.cursor_node = Some(node_idx);
224    }
225
226    fn toggle_select(&mut self, node_idx: usize) {
227        self.nodes[node_idx].is_selected = !self.nodes[node_idx].is_selected;
228        self.cursor_node = Some(node_idx);
229    }
230
231    fn range_select(&mut self, anchor_node: usize, target_node: usize, rows: &[FlatRow]) {
232        let a = rows.iter().position(|r| r.node_idx == anchor_node);
233        let b = rows.iter().position(|r| r.node_idx == target_node);
234        if let (Some(a), Some(b)) = (a, b) {
235            let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
236            for n in &mut self.nodes { n.is_selected = false; }
237            for r in &rows[lo..=hi] {
238                self.nodes[r.node_idx].is_selected = true;
239            }
240        }
241        self.cursor_node = Some(target_node);
242    }
243
244    fn move_cursor(&mut self, delta: i32, rows: &[FlatRow]) {
245        if rows.is_empty() { return; }
246        let cur_flat = self.cursor_node
247            .and_then(|ni| rows.iter().position(|r| r.node_idx == ni))
248            .unwrap_or(0);
249        let new_flat = (cur_flat as i32 + delta)
250            .clamp(0, rows.len() as i32 - 1) as usize;
251        let ni = rows[new_flat].node_idx;
252        self.select_single(ni);
253        // Scroll to keep the new row visible.
254        self.scroll_to_row(new_flat);
255    }
256
257    /// Returns the node index currently under the cursor, or `None`.
258    pub fn hovered_node_idx(&self) -> Option<usize> {
259        self.hovered_row.and_then(|ri| self.row_metas.get(ri).map(|m| m.node_idx))
260    }
261
262    fn scroll_to_row(&mut self, flat_idx: usize) {
263        // `row_widgets` bounds reflect the `scroll_offset` from the last `layout()` call.
264        // The framework calls `layout()` every frame before rendering, so `scroll_offset`
265        // changes here will be reflected before the next mouse hit-test.
266        // Y-up coordinates: y_bottom is the lower edge (smaller Y) and y_top is the upper edge (larger Y).
267        let y_bottom = self.bounds.height
268            - (flat_idx as f64 + 1.0) * self.row_height
269            + self.scroll_offset;
270        let y_top = y_bottom + self.row_height;
271        if y_bottom < 0.0 {
272            self.scroll_offset = (self.scroll_offset - y_bottom).min(self.max_scroll());
273        } else if y_top > self.bounds.height {
274            self.scroll_offset = (self.scroll_offset - (y_top - self.bounds.height)).max(0.0);
275        }
276    }
277}
278
279// ---------------------------------------------------------------------------
280// Widget impl
281// ---------------------------------------------------------------------------
282
283impl Widget for TreeView {
284    fn type_name(&self) -> &'static str { "TreeView" }
285    fn bounds(&self) -> Rect { self.bounds }
286    fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
287    fn children(&self) -> &[Box<dyn Widget>] { &self.row_widgets }
288    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.row_widgets }
289    fn is_focusable(&self) -> bool { true }
290
291    fn margin(&self)   -> Insets  { self.base.margin }
292    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
293    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
294    fn min_size(&self) -> Size    { self.base.min_size }
295    fn max_size(&self) -> Size    { self.base.max_size }
296
297    fn hit_test(&self, local_pos: Point) -> bool {
298        // Capture all events during drags even if cursor leaves bounds.
299        if self.drag.is_some() || self.dragging_scrollbar { return true; }
300        let b = self.bounds();
301        local_pos.x >= 0.0 && local_pos.x <= b.width
302            && local_pos.y >= 0.0 && local_pos.y <= b.height
303    }
304
305    fn layout(&mut self, available: Size) -> Size {
306        let rows = flatten_visible(&self.nodes);
307        self.content_height = rows.len() as f64 * self.row_height;
308        self.scroll_offset = self.scroll_offset.clamp(0.0, self.max_scroll());
309
310        let h         = available.height;
311        let w         = available.width - SCROLLBAR_W;
312        let rh        = self.row_height;
313        let ind       = self.indent_width;
314        let font_size = self.font_size;
315
316        self.row_widgets.clear();
317        self.row_metas.clear();
318
319        for (i, flat) in rows.iter().enumerate() {
320            // Skip the node being dragged — its ghost is painted at the cursor instead.
321            if self.drag.as_ref().map_or(false, |d| d.live && d.node_idx == flat.node_idx) {
322                continue;
323            }
324
325            let node = &self.nodes[flat.node_idx];
326
327            // Y position of this row in TreeView-local (Y-up) coordinates.
328            let y_bot = h - (i as f64 + 1.0) * rh + self.scroll_offset;
329
330            let mut tree_row = TreeRow::new(
331                flat.node_idx,
332                flat.depth,
333                flat.has_children,
334                node.is_expanded,
335                node.is_selected,
336                self.hovered_row == Some(i),
337                self.focused,
338                node.icon,
339                node.label.clone(),
340                Arc::clone(&self.font),
341                font_size,
342                ind,
343                rh,
344            );
345
346            tree_row.layout(Size::new(w, rh));
347            tree_row.set_bounds(Rect::new(0.0, y_bot, w, rh));
348
349            // toggle_rect in TreeView-local coords = row's y_bot + toggle's local y offset.
350            let toggle_rect = if flat.has_children {
351                let tlb = tree_row.toggle_local_bounds;
352                Some(Rect::new(tlb.x, y_bot + tlb.y, tlb.width, tlb.height))
353            } else {
354                None
355            };
356
357            self.row_metas.push(RowMeta { node_idx: flat.node_idx, toggle_rect });
358            self.row_widgets.push(Box::new(tree_row));
359        }
360
361        available
362    }
363
364    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
365        let h = self.bounds.height;
366        let w = self.bounds.width;
367        let content_w = w - SCROLLBAR_W;
368        let v = ctx.visuals().clone();
369
370        // Background — follow the theme's window fill rather than hard-coded white.
371        ctx.set_fill_color(v.window_fill);
372        ctx.begin_path();
373        ctx.rect(0.0, 0.0, w, h);
374        ctx.fill();
375
376        // Scrollbar — theme-aware track and thumb.
377        let sb_x = self.scrollbar_x();
378        if self.content_height > h {
379            ctx.set_fill_color(v.scroll_track);
380            ctx.begin_path();
381            ctx.rect(sb_x, 0.0, SCROLLBAR_W, h);
382            ctx.fill();
383            if let Some((thumb_y, thumb_h)) = self.thumb_metrics() {
384                let thumb_color = if self.dragging_scrollbar {
385                    v.scroll_thumb_dragging
386                } else if self.hovered_scrollbar {
387                    v.scroll_thumb_hovered
388                } else {
389                    v.scroll_thumb
390                };
391                ctx.set_fill_color(thumb_color);
392                ctx.begin_path();
393                ctx.rounded_rect(sb_x + 2.0, thumb_y, SCROLLBAR_W - 4.0, thumb_h, 3.0);
394                ctx.fill();
395            }
396        }
397
398        // Content clip — rows must not bleed into the scrollbar strip.
399        // This clip is active during framework recursion into row_widgets (after paint() returns).
400        ctx.clip_rect(0.0, 0.0, content_w, h);
401
402        // Drop indicator and ghost (drag feedback)
403        let rows = flatten_visible(&self.nodes);
404        if let Some(drop_target) = self.drop_target {
405            if self.drag.as_ref().map_or(false, |d| d.live) {
406                let rh  = self.row_height;
407                let off = self.scroll_offset;
408                let ind = self.indent_width;
409                let ref_node = match drop_target {
410                    DropPosition::Before(ni) | DropPosition::After(ni) | DropPosition::AsChild(ni) => ni,
411                };
412                if let Some(ri) = rows.iter().position(|r| r.node_idx == ref_node) {
413                    let y_bot = h - (ri as f64 + 1.0) * rh + off;
414                    let indent = rows[ri].depth as f64 * ind + EXPAND_W;
415                    match drop_target {
416                        DropPosition::Before(_) => paint_drop_line(ctx, indent, y_bot + rh, content_w - indent),
417                        DropPosition::After(_)  => paint_drop_line(ctx, indent, y_bot, content_w - indent),
418                        DropPosition::AsChild(_) => paint_drop_child_highlight(ctx, y_bot, content_w, rh),
419                    }
420                }
421            }
422        }
423        if let Some(drag) = &self.drag {
424            if drag.live {
425                let label = self.nodes[drag.node_idx].label.clone();
426                let ic    = icon_color(self.nodes[drag.node_idx].icon);
427                let pos   = drag.current_pos;
428                let rh    = self.row_height;
429                let font  = Arc::clone(&self.font);
430                let fs    = self.font_size;
431                paint_ghost(ctx, &label, pos, content_w, rh, &font, fs, ic);
432            }
433        }
434    }
435
436    fn on_event(&mut self, event: &Event) -> EventResult {
437        // Every consumed event in a tree view mutates some visible state —
438        // selection, expansion, scroll offset, hover row, focus ring.  Wrap
439        // the dispatch so a `Consumed` result translates to a repaint
440        // request.  Events that bubble away as `Ignored` do NOT tick,
441        // honouring the "only repaint on real change" contract.
442        let result = match event {
443            Event::FocusGained => { self.focused = true;  EventResult::Consumed }
444            Event::FocusLost   => { self.focused = false; EventResult::Consumed }
445
446            Event::MouseWheel { delta_y, .. } => {
447                // Convention: delta_y > 0 = user scrolled DOWN (wants to see content below).
448                // Increasing scroll_offset shifts content UP → reveals lower rows. ✓
449                self.scroll_offset =
450                    (self.scroll_offset + delta_y * 40.0).clamp(0.0, self.max_scroll());
451                self.hovered_row = None; // stale after layout rebuild on next frame
452                EventResult::Consumed
453            }
454
455            Event::MouseMove { pos } => self.handle_mouse_move(*pos),
456            Event::MouseDown { pos, button: MouseButton::Left, modifiers } => {
457                self.handle_mouse_down(*pos, *modifiers)
458            }
459            Event::MouseUp { button: MouseButton::Left, pos, .. } => {
460                self.handle_mouse_up(*pos)
461            }
462            Event::KeyDown { key, modifiers } => self.handle_key_down(key, *modifiers),
463            _ => EventResult::Ignored,
464        };
465        if result == EventResult::Consumed {
466            crate::animation::request_tick();
467        }
468        result
469    }
470}
471
472// ---------------------------------------------------------------------------
473// Mouse event handlers
474// ---------------------------------------------------------------------------
475
476impl TreeView {
477    fn handle_mouse_move(&mut self, pos: Point) -> EventResult {
478        self.hovered_scrollbar = self.in_scrollbar(pos);
479
480        if self.dragging_scrollbar {
481            if let Some((_, thumb_h)) = self.thumb_metrics() {
482                let h = self.bounds.height;
483                let track_h = (h - thumb_h).max(1.0);
484                let delta_y = self.sb_drag_start_y - pos.y;
485                let spp = self.max_scroll() / track_h;
486                self.scroll_offset =
487                    (self.sb_drag_start_offset + delta_y * spp).clamp(0.0, self.max_scroll());
488            }
489            return EventResult::Consumed;
490        }
491
492        if let Some(drag) = &mut self.drag {
493            let dx = pos.x - drag.current_pos.x;
494            let dy = pos.y - drag.current_pos.y;
495            drag.current_pos = pos;
496            if !drag.live && (dx * dx + dy * dy).sqrt() > DRAG_THRESHOLD {
497                drag.live = true;
498            }
499            if drag.live {
500                let node_idx = drag.node_idx;
501                let rows = flatten_visible(&self.nodes);
502                self.drop_target = compute_drop_target(
503                    pos, &rows, &self.nodes,
504                    self.bounds.height, self.row_height,
505                    self.scroll_offset, self.drag.as_ref().unwrap(),
506                );
507                let _ = node_idx;
508            }
509            return EventResult::Consumed;
510        }
511
512        self.hovered_row = self.row_index_at(pos);
513        EventResult::Ignored
514    }
515
516    fn handle_mouse_down(&mut self, pos: Point, mods: Modifiers) -> EventResult {
517        if self.in_scrollbar(pos) {
518            self.dragging_scrollbar = true;
519            self.sb_drag_start_y = pos.y;
520            self.sb_drag_start_offset = self.scroll_offset;
521            return EventResult::Consumed;
522        }
523
524        let Some(flat_i) = self.row_index_at(pos) else {
525            return EventResult::Ignored;
526        };
527        let meta     = &self.row_metas[flat_i];
528        let node_idx = meta.node_idx;
529
530        // Expand/collapse: any click on a row with children toggles it when
531        // `toggle_on_row_click` is enabled (file-explorer style).  Otherwise
532        // only the expand-toggle arrow triggers expansion so that clicking a
533        // row in the inspector tree selects it without accidentally collapsing
534        // a branch the user was browsing.
535        if self.toggle_on_row_click {
536            if meta.toggle_rect.is_some() {
537                self.nodes[node_idx].is_expanded = !self.nodes[node_idx].is_expanded;
538            }
539        } else if let Some(tr) = meta.toggle_rect {
540            if pos.x >= tr.x && pos.x < tr.x + tr.width
541                && pos.y >= tr.y && pos.y < tr.y + tr.height
542            {
543                self.nodes[node_idx].is_expanded = !self.nodes[node_idx].is_expanded;
544            }
545        }
546
547        // Selection
548        if mods.ctrl {
549            self.toggle_select(node_idx);
550        } else if mods.shift {
551            if let Some(a) = self.cursor_node {
552                let rows2 = flatten_visible(&self.nodes);
553                self.range_select(a, node_idx, &rows2);
554            } else {
555                self.select_single(node_idx);
556            }
557        } else {
558            self.select_single(node_idx);
559            if self.drag_enabled {
560                let y_bot = self.row_widgets[flat_i].bounds().y;
561                self.drag = Some(DragState {
562                    node_idx,
563                    _cursor_row_offset: pos.y - y_bot,
564                    current_pos: pos,
565                    live: false,
566                });
567            }
568        }
569
570        EventResult::Consumed
571    }
572
573    fn handle_mouse_up(&mut self, pos: Point) -> EventResult {
574        // Scrollbar drag end
575        if self.dragging_scrollbar {
576            self.dragging_scrollbar = false;
577            return EventResult::Consumed;
578        }
579
580        // Node drag end
581        if let Some(drag) = self.drag.take() {
582            if drag.live {
583                if let Some(target) = self.drop_target.take() {
584                    apply_drop(&mut self.nodes, drag.node_idx, target);
585                }
586            } else {
587                // Was a click, not a drag — finalize single-select.
588                self.select_single(drag.node_idx);
589            }
590            self.drop_target = None;
591            return EventResult::Consumed;
592        }
593
594        let _ = pos;
595        EventResult::Ignored
596    }
597
598    fn handle_key_down(&mut self, key: &Key, mods: Modifiers) -> EventResult {
599        let rows = flatten_visible(&self.nodes);
600        match key {
601            Key::ArrowDown => { self.move_cursor(1, &rows);  EventResult::Consumed }
602            Key::ArrowUp   => { self.move_cursor(-1, &rows); EventResult::Consumed }
603            Key::ArrowRight => {
604                if let Some(ni) = self.cursor_node {
605                    if !self.nodes[ni].is_expanded
606                        && rows.iter().any(|r| r.node_idx == ni && r.has_children)
607                    {
608                        self.nodes[ni].is_expanded = true;
609                    } else {
610                        // Move to first child
611                        if rows.iter().any(|r| r.node_idx == ni) {
612                            self.move_cursor(1, &rows);
613                        }
614                    }
615                }
616                EventResult::Consumed
617            }
618            Key::ArrowLeft => {
619                if let Some(ni) = self.cursor_node {
620                    if self.nodes[ni].is_expanded {
621                        self.nodes[ni].is_expanded = false;
622                    } else if let Some(parent_idx) = self.nodes[ni].parent {
623                        self.select_single(parent_idx);
624                        if let Some(fi) = rows.iter().position(|r| r.node_idx == parent_idx) {
625                            self.scroll_to_row(fi);
626                        }
627                    }
628                }
629                EventResult::Consumed
630            }
631            Key::Char(' ') | Key::Enter => {
632                if let Some(ni) = self.cursor_node {
633                    if rows.iter().any(|r| r.node_idx == ni && r.has_children) {
634                        self.nodes[ni].is_expanded = !self.nodes[ni].is_expanded;
635                    }
636                }
637                EventResult::Consumed
638            }
639            Key::Tab => EventResult::Ignored, // let App handle focus advancement
640            _ => {
641                let _ = mods;
642                EventResult::Ignored
643            }
644        }
645    }
646}