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