Skip to main content

teksilo_widgets/tree_view/
widget_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Widget`] trait implementation for [`TreeView`]: build,
5//! layout, placement, paint, and accessibility.
6
7use super::*;
8
9use crate::common::{list_nav, tree_expand};
10
11impl<T: 'static> TreeView<T> {
12    /// The realized row the keyboard is on: the navigation cursor when there
13    /// is one, else the first selected row.
14    ///
15    /// `None` when that row is outside the virtualization window, which is the
16    /// honest answer: there is no widget for it, so there is no node to point
17    /// at and nothing on screen for a menu or an announcement to be about.
18    ///
19    /// The index is the **flat** (visible) row index, the same coordinate
20    /// `focused_index` and `row_map` are keyed on, so a collapsed branch's
21    /// descendants simply are not in it.
22    fn current_row_widget(&self) -> Option<WidgetId> {
23        let index = self.focused_index.get().or_else(|| {
24            self.row_selection
25                .as_ref()
26                .and_then(|s| s.selected_indices().first().copied())
27        })?;
28        let map = self.row_map.borrow();
29        map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
30    }
31
32    /// In a single selection, keep the keyboard cursor seated on the selected
33    /// row, whoever moved the selection — see `ListView`'s method of the same
34    /// name for why the two must not part.
35    ///
36    /// Seated rather than cleared, as `ListView` does, because a tree needs
37    /// the cursor's identity anchor: it is what the source version effect
38    /// resolves after a structural change, and what carries an index
39    /// selection along with its row there (see that effect). Seated once at
40    /// build as well, so a selection made before the view existed has an
41    /// anchor from the start.
42    fn follow_the_selection_in_single_mode(
43        &self,
44        ctx: &mut teksilo_core::build_context::BuildContext,
45    ) {
46        let Some(selection) = self.row_selection.clone() else {
47            return;
48        };
49        if selection.mode() != teksilo_data::SelectionMode::Single {
50            return;
51        }
52        seat_the_cursor_on_the_selection(
53            &selection,
54            &self.source,
55            &self.focused_index,
56            &self.focused_anchor,
57        );
58        let source = self.source.clone();
59        let focused_index = self.focused_index.clone();
60        let focused_anchor = self.focused_anchor.clone();
61        let observed = selection.clone();
62        let handle = observed.observe_for_rebuild(move || {
63            seat_the_cursor_on_the_selection(&selection, &source, &focused_index, &focused_anchor);
64        });
65        ctx.own_handle(handle);
66    }
67
68    /// Scroll the row the keyboard is on into view when this tree takes focus.
69    ///
70    /// Only the rows near the viewport are realized, so on a tree taller than
71    /// the window the current row frequently has no widget. Everything that
72    /// speaks for it then has nothing to speak about: no node carries
73    /// `selected`, [`Self::current_row_widget`] resolves to `None` so no active
74    /// descendant is nominated, and a screen reader taking focus here is told
75    /// nothing at all. Worse, the first arrow press steps *past* that row,
76    /// because the cursor was somewhere the user was never shown.
77    ///
78    /// `ensure_index_visible` rather than `scroll_to_index`: a row already on
79    /// screen must not jump under somebody who can see it.
80    ///
81    /// The handles are cloned into the effect rather than reaching through
82    /// `self`, which the closure cannot borrow.
83    fn reveal_current_row_on_focus(&self, ctx: &mut teksilo_core::build_context::BuildContext) {
84        let metrics = self.metrics.clone();
85        let scroll_y = self.scroll_y.clone();
86        let viewport_height = self.viewport_height.clone();
87        let max_scroll_y = self.max_scroll_y.clone();
88        let focused_index = self.focused_index.clone();
89        let selection = self.row_selection.clone();
90
91        ctx.effect(&self.view_focused, move |focused| {
92            if !*focused {
93                return;
94            }
95            let Some(index) = focused_index.get().or_else(|| {
96                selection
97                    .as_ref()
98                    .and_then(|s| s.selected_indices().first().copied())
99            }) else {
100                return;
101            };
102            let current = scroll_y.get();
103            let target = metrics.borrow_mut().scroll_for_ensure_visible(
104                index,
105                current,
106                viewport_height.get(),
107                max_scroll_y.get(),
108            );
109            if (target - current).abs() > f32::EPSILON {
110                scroll_y.set(target);
111            }
112        });
113    }
114}
115
116/// In a single selection, put the keyboard cursor on the selected row, with
117/// the identity anchor that lets it follow that row through a structural
118/// change.
119///
120/// A selection with no row this view shows — emptied, or keyed on a node
121/// inside a collapsed branch — leaves the cursor alone: there is no row to
122/// put it on.
123fn seat_the_cursor_on_the_selection<T: 'static>(
124    selection: &crate::data_views::RowSelection,
125    source: &crate::tree_source::TreeSource<T>,
126    focused_index: &Signal<Option<usize>>,
127    focused_anchor: &RefCell<Option<crate::data_views::RowAnchor>>,
128) {
129    let Some(&selected) = selection.selected_indices().first() else {
130        return;
131    };
132    if focused_index.get() != Some(selected) {
133        focused_index.set(Some(selected));
134        *focused_anchor.borrow_mut() = Some(source.anchor(selected));
135    }
136}
137
138impl<T: 'static> Widget for TreeView<T> {
139    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
140        let self_id = ctx.self_id();
141        ctx.enabled_when(self_id, self.enabled.clone());
142
143        // The root builds exactly two children — the body pane and the
144        // scrollbar — and neither depends on the source, the selection or the
145        // scroll offset. So it declares no `Rebuild`-level binding at all:
146        // row realization is the pane's job (see `body_pane`'s module docs for
147        // why that separation is load-bearing), and what the root still owns
148        // resolves at `Relayout` / `RepaintOnly`.
149
150        // Scrollbar totals + the content-width decision live in the root's
151        // `place_children`; a source change or a pane measurement that moves
152        // the content total re-places the root through this.
153        self.layout_refresh.bind_to(
154            ctx.self_id(),
155            ctx.binding_registry(),
156            BindingLevel::Relayout,
157        );
158        // A handle's reveal is applied by the body pane, which binds the same
159        // request; the root binds it too so the pass also refreshes the
160        // scroll range the pane clamps against.
161        self.reveal.bind(ctx);
162        // Container focus ring: painted only while nothing is selected, so a
163        // selection change has to reach the root's paint — without rebuilding
164        // it and taking the scrollbar down with it.
165        self.paint_refresh.bind_to(
166            ctx.self_id(),
167            ctx.binding_registry(),
168            BindingLevel::RepaintOnly,
169        );
170
171        // The keyboard cursor names the active descendant (`accessibility`),
172        // so any write of it — the keys, a click, a handle's `set_cursor` —
173        // re-walks the AT tree. A cursor-only move (the accelerator with an
174        // arrow in a multiple selection) changes nothing else the walk would
175        // notice. Neither a rebuild nor a relayout: no row is drawn from it.
176        self.focused_index.bind_to(
177            ctx.self_id(),
178            ctx.binding_registry(),
179            BindingLevel::AccessibilityOnly,
180        );
181
182        // Bind scroll_y at Relayout so place_children runs on every scroll
183        // position change (re-clamps and refreshes the thumb) without a
184        // rebuild. The pane holds the matching binding for its rows.
185        self.scroll_y.bind_to(
186            ctx.self_id(),
187            ctx.binding_registry(),
188            BindingLevel::Relayout,
189        );
190
191        // Register the animated signal for smooth scrolling on the ROOT and
192        // only the root: the scheduler keys an animation to the widget that
193        // registered its signal last and cancels it when that widget rebuilds,
194        // so registering from the pane too would make every buffer-exit
195        // rebuild abort an in-flight fling.
196        ctx.register_animated_signal(&self.scroll_y);
197
198        // Bind drop_feedback at RepaintOnly so `set(...)` calls from
199        // on_drag_hover / on_drag_leave dirty the TreeView's paint cache
200        // without triggering a rebuild.
201        self.drop_feedback.bind_to(
202            ctx.self_id(),
203            ctx.binding_registry(),
204            BindingLevel::RepaintOnly,
205        );
206
207        // Focus signals for the container ring. `begin_view_focus` keys the
208        // scope signal on this root id directly (independent of the arena
209        // focusable flag, not yet wired here): a plain `view_focus_active()`
210        // would find no focusable ancestor and fall back to the constant-`true`
211        // "outside any scope" signal — lighting the ring whenever ANY other
212        // widget takes keyboard focus. Pop straight back; the real row scope
213        // below resolves the same cached signal. `focus_visible` is the
214        // keyboard/pointer modality. Bound `RepaintOnly` so focus-in/out
215        // redraws the ring. (Selection-emptiness changes arrive on
216        // `paint_refresh`, so paint re-reads the selection without extra
217        // binding.)
218        self.view_focused = ctx.begin_view_focus();
219        ctx.end_view_focus();
220        self.focus_visible = ctx.focus_visible();
221        self.follow_the_selection_in_single_mode(ctx);
222        self.reveal_current_row_on_focus(ctx);
223        self.view_focused.bind_to(
224            ctx.self_id(),
225            ctx.binding_registry(),
226            BindingLevel::RepaintOnly,
227        );
228        self.focus_visible.bind_to(
229            ctx.self_id(),
230            ctx.binding_registry(),
231            BindingLevel::RepaintOnly,
232        );
233
234        // --- Observe source version (covers both data mutations and expand/collapse) ---
235        // One observer, root-owned, doing the bookkeeping the pane can't
236        // (metrics divergence, selection prune, keyboard cursor) and then
237        // fanning out: rebuild the pane (row content changed) and re-place the
238        // root (the content total, hence the thumb, changed).
239        let source_version = self.source.version_signal();
240        let pane_version_for_data = self.pane_version.clone();
241        let layout_refresh_for_data = self.layout_refresh.clone();
242        let data_ver = Rc::new(Cell::new(0_u64));
243        ctx.effect(&source_version, {
244            let dv = data_ver.clone();
245            let ver = pane_version_for_data.clone();
246            let layout = layout_refresh_for_data.clone();
247            let metrics = self.metrics.clone();
248            let source = self.source.clone();
249            let row_sel = self.row_selection.clone();
250            let focused = self.focused_index.clone();
251            let focused_anchor = self.focused_anchor.clone();
252            move |_| {
253                // Read before anything below moves either of them: in a single
254                // selection, was the cursor on the selected row?
255                let single_selection = row_sel
256                    .as_ref()
257                    .filter(|rs| rs.mode() == teksilo_data::SelectionMode::Single);
258                let was_on_selection = single_selection
259                    .is_some_and(|rs| focused.get().is_some_and(|i| rs.is_selected(i)));
260                // Source version observers fire synchronously per reflatten, so
261                // `first_changed_index()` describes exactly this change:
262                // heights of flat rows before it (e.g. above an
263                // expand/collapse point) stay valid.
264                metrics
265                    .borrow_mut()
266                    .apply_divergence(source.first_changed_index(), source.visible_count());
267                // Drop any keyed selection whose node was deleted (no-op for
268                // the index model). A collapse does not delete, so a collapsed
269                // node's selection survives.
270                if let Some(ref rs) = row_sel {
271                    rs.prune();
272                    // Index-based selection has no identity to track by, so
273                    // it cannot follow a moved row — but it must not keep
274                    // pointing past the shrunk end either.
275                    rs.prune_out_of_range(source.visible_count());
276                }
277                // The keyboard cursor: a version bump carries no `DataChange`
278                // delta to shift it by (it covers expand/collapse too, which
279                // has none), so it is tracked by identity instead. The anchor
280                // captured the last time `focused_index` moved is resolved
281                // against the now-current source and the cursor rewritten to
282                // wherever that row landed, or dropped if the row is gone —
283                // the same dance `reconcile_editing_row` runs for
284                // `TableView`'s `editing_cell`.
285                // Snapshot-then-drop the borrow before the `None` arm below
286                // takes it mutably — an `if let focused_anchor.borrow()...`
287                // scrutinee keeps the immutable `Ref` alive for the whole
288                // block (temporary lifetime extension), which would panic
289                // on that `borrow_mut()`.
290                let anchor_snapshot = focused_anchor.borrow().clone();
291                if let Some(anchor) = anchor_snapshot {
292                    match anchor.index() {
293                        Some(idx) => {
294                            if focused.get() != Some(idx) {
295                                focused.set(Some(idx));
296                            }
297                        }
298                        None => {
299                            focused.set(None);
300                            *focused_anchor.borrow_mut() = None;
301                        }
302                    }
303                }
304                // In a single selection the selection follows its row too.
305                // The cursor has just followed the row by identity. A keyed
306                // selection did the same on its own; an index selection has
307                // no identity and kept its position, now some other row. So a
308                // cursor that was on the selection and is off it now takes
309                // the selection with it — the row the user was on stays the
310                // row selected, as it would in a `ListView`, whose index
311                // selection shifts with an insert. A row that is gone leaves
312                // the selection where it is, and the cursor is seated there.
313                if let Some(rs) = single_selection {
314                    if was_on_selection
315                        && let Some(index) = focused.get()
316                        && !rs.is_selected(index)
317                    {
318                        rs.select(index);
319                    }
320                    seat_the_cursor_on_the_selection(rs, &source, &focused, &focused_anchor);
321                }
322
323                let next = dv.get() + 1;
324                dv.set(next);
325                ver.set(next);
326                layout.set(next);
327            }
328        });
329
330        // --- Observe selection changes ---
331        // The pane runs its own selection observer for the delegate's
332        // `selected` argument; the root only needs its container focus ring
333        // repainted, since that ring is suppressed once anything is selected.
334        if let Some(ref rs) = self.row_selection {
335            let paint_refresh_for_sel = self.paint_refresh.clone();
336            let sel_ver = Rc::new(Cell::new(0_u64));
337            let handle = rs.observe_for_rebuild(move || {
338                let next = sel_ver.get() + 1;
339                sel_ver.set(next);
340                paint_refresh_for_sel.set(next);
341            });
342            ctx.own_handle(handle);
343        }
344
345        // Scroll-buffer exit is deliberately NOT observed here. It rebuilds
346        // the body pane and nothing else — the root's own children are
347        // unaffected by which rows are realized, and a root rebuild during a
348        // scrollbar thumb drag is exactly the one the framework defers.
349
350        // --- Scroll event handler + DnD ---
351        // The wheel arithmetic, the pan and the claim that puts this node on a
352        // finger's claimant chain all come from `common::scrollable`. A wheel
353        // still takes the path it always did — `handle_scroll_event` branches
354        // on the scroll *source*, not the phase.
355        let mut handlers = HandlerSet::new().clips_children(true).focusable(true);
356        {
357            let behavior = crate::common::scrollable::ScrollableBehavior::new(
358                crate::common::scrollable::ScrollableAxes::vertical(
359                    self.scroll_y.clone(),
360                    self.max_scroll_y.clone(),
361                ),
362            )
363            .with_scroller(self.scroller.clone())
364            // Vertical only: this view owns no horizontal offset, so a
365            // horizontal pan is declined and chains outward.
366            .axes(PanAxes::Y)
367            .overscroll(self.overscroll_behavior)
368            .smooth(self.smooth_scrolling)
369            .smooth_duration(self.smooth_scroll_duration)
370            .line_height(self.item_height)
371            .reduced_motion(ctx.prefers_reduced_motion())
372            .physics(ctx.theme().input.scroll_physics);
373            handlers = behavior.install(handlers);
374        }
375
376        // --- The non-drag reorder, all four routes at once ---
377        //
378        // The row drag can do two things here: move a row among its siblings
379        // (`OrderedMove`) and reparent it (`TreeMove`, the drag's
380        // `DropPosition::Into`). Both are committed through the source's own
381        // `accept_drop`, and both closures are shared by the chord below, the
382        // row's context menu and the row's AccessKit custom actions — see
383        // `common::ordered_move`.
384        let (reorder_perform, reparent_perform) = if self.reorderable {
385            let follow = {
386                let sel = self.row_selection.clone();
387                let fi = self.focused_index.clone();
388                let fi_anchor = self.focused_anchor.clone();
389                let source = self.source.clone();
390                let metrics = self.metrics.clone();
391                let scroll = self.scroll_y.clone();
392                let vh = self.viewport_height.clone();
393                let vb = self.viewport_bounds.clone();
394                let max = self.max_scroll_y.clone();
395                Rc::new(
396                    move |new_flat: usize, ctx: &mut teksilo_core::widget::EventContext| {
397                        fi.set(Some(new_flat));
398                        *fi_anchor.borrow_mut() = Some(source.anchor(new_flat));
399                        if let Some(ref sel) = sel {
400                            sel.select(new_flat);
401                        }
402                        let current = scroll.get();
403                        let new_scroll = metrics.borrow_mut().scroll_for_ensure_visible(
404                            new_flat,
405                            current,
406                            vh.get(),
407                            max.get(),
408                        );
409                        if (new_scroll - current).abs() > f32::EPSILON {
410                            scroll.set(new_scroll);
411                        }
412                        crate::common::row_metrics::chase_row_into_outer_view(
413                            ctx,
414                            &metrics,
415                            vb.get(),
416                            new_flat,
417                            new_scroll,
418                        );
419                    },
420                )
421            };
422            // The row's name for the utterance, from the same resolver
423            // find-as-you-type uses. `None` where the application gave none.
424            let name_of: Rc<dyn Fn(usize) -> Option<String>> = {
425                let source = self.source.clone();
426                let label = self.type_ahead_label.clone();
427                Rc::new(move |index: usize| {
428                    let label = label.as_ref()?;
429                    source.with_row_str(index, &|item| label(item))
430                })
431            };
432            let sibling = {
433                let source = self.source.clone();
434                let follow = follow.clone();
435                let name_of = name_of.clone();
436                Rc::new(
437                    move |mv: crate::common::ordered_move::OrderedMove,
438                          flat: usize,
439                          ctx: &mut teksilo_core::widget::EventContext| {
440                        let name = (name_of)(flat);
441                        let Some(new_flat) = source.sibling_move(flat, mv) else {
442                            return;
443                        };
444                        follow(new_flat, ctx);
445                        let (pos, size) = source.sibling_position(new_flat);
446                        ctx.announce(crate::common::ordered_move::move_announcement(
447                            name.as_deref(),
448                            pos.saturating_sub(1),
449                            size,
450                        ));
451                    },
452                ) as crate::common::ordered_move::MoveRow
453            };
454            let reparent = {
455                let source = self.source.clone();
456                let follow = follow.clone();
457                Rc::new(
458                    move |mv: crate::common::ordered_move::TreeMove,
459                          flat: usize,
460                          ctx: &mut teksilo_core::widget::EventContext| {
461                        let name = (name_of)(flat);
462                        let Some(new_flat) = source.reparent(flat, mv) else {
463                            return;
464                        };
465                        follow(new_flat, ctx);
466                        // `set_level` is 1-based, and so is what an adapter
467                        // announces, so the depth becomes a level here.
468                        let level = source.meta(new_flat).map_or(1, |m| m.depth + 1);
469                        ctx.announce(crate::common::ordered_move::reparent_announcement(
470                            name.as_deref(),
471                            level,
472                        ));
473                    },
474                ) as crate::common::ordered_move::TreeReparentRow
475            };
476            (Some(sibling), Some(reparent))
477        } else {
478            (None, None)
479        };
480
481        // --- Select all visible rows, through the rebindable
482        // `edit.select_all` (Multi only) ---
483        {
484            let source = self.source.clone();
485            let sel = self.row_selection.clone();
486            crate::common::standard_edit::register_standard_edit_action(
487                ctx,
488                teksilo_core::shortcut::StandardEdit::SelectAll,
489                move |_ctx| {
490                    let visible_count = source.visible_count();
491                    match sel {
492                        Some(ref sel)
493                            if visible_count > 0
494                                && sel.mode() == teksilo_data::SelectionMode::Multi =>
495                        {
496                            sel.select_all(visible_count);
497                            true
498                        }
499                        _ => false,
500                    }
501                },
502            );
503        }
504
505        // --- Keyboard navigation + expand/collapse + Alt+Arrow reorder ---
506        {
507            let source = self.source.clone();
508            let sel_for_key = self.row_selection.clone();
509            let activate_key = self.on_activate.clone();
510            let fi = self.focused_index.clone();
511            let fi_anchor = self.focused_anchor.clone();
512            let reorder_key = reorder_perform.clone();
513            let reparent_key = reparent_perform.clone();
514            let scroll_for_nav = self.scroll_y.clone();
515            let metrics_for_nav = self.metrics.clone();
516            let max_for_nav = self.max_scroll_y.clone();
517            let vh_for_nav = self.viewport_height.clone();
518            let vb_for_nav = self.viewport_bounds.clone();
519            let ta_state = self.type_ahead.clone();
520            // Visible index → realized row id, so `Space` can ask the row
521            // whether it publishes a keyboard toggle.
522            let row_map_for_key = self.row_map.clone();
523            let ta_label = self.type_ahead_label.clone();
524            let ta_timeout = self.type_ahead_timeout;
525
526            handlers = handlers.on_key(move |event, ctx| {
527                if let teksilo_core::event::WidgetEvent::KeyDown { key, modifiers, .. } = event {
528                    use teksilo_core::event::Key;
529                    let visible_count = source.visible_count();
530                    if visible_count == 0 {
531                        return teksilo_core::event::EventResponse::Ignored;
532                    }
533
534                    // The keyboard cursor: `focused_index` once the user has
535                    // navigated or clicked, else the current selection (a tree
536                    // can be handed a selected row before it is ever focused).
537                    // `None` = "no cursor yet", which is NOT "cursor on row 0" —
538                    // see the arrow keys below.
539                    let cursor = fi
540                        .get()
541                        .or_else(|| {
542                            sel_for_key
543                                .as_ref()
544                                .and_then(|s| s.selected_indices().first().copied())
545                        })
546                        .map(|i| i.min(visible_count - 1));
547                    // Anchor for the keys that compute *from* a row (expand /
548                    // collapse / paging / activation) rather than step in a
549                    // direction.
550                    let current = cursor.unwrap_or(0);
551
552                    // Move the keyboard cursor AND refresh the `RowAnchor` it
553                    // resolves through on the next structural change — every
554                    // site below that moves `fi` must go through this, or the
555                    // cursor silently stops following its row (see the
556                    // `source_version` effect in `build`).
557                    let set_focus = |idx: usize| {
558                        fi.set(Some(idx));
559                        *fi_anchor.borrow_mut() = Some(source.anchor(idx));
560                    };
561
562                    // Helper: scroll so flat row `idx` is visible in the tree's
563                    // OWN viewport; returns the resulting scroll offset so the
564                    // caller can chain the reveal to enclosing scroll areas.
565                    let ensure_visible = |idx: usize| -> f32 {
566                        let scroll = scroll_for_nav.get();
567                        let new_scroll = metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
568                            idx,
569                            scroll,
570                            vh_for_nav.get(),
571                            max_for_nav.get(),
572                        );
573                        if (new_scroll - scroll).abs() > f32::EPSILON {
574                            scroll_for_nav.set(new_scroll);
575                        }
576                        new_scroll
577                    };
578
579                    // Deselect all — GTK's Ctrl+Shift+A, which no other
580                    // toolkit spends on anything else (Multi only). Select all
581                    // is the `edit.select_all` action registered below, so a
582                    // plain Ctrl+A only gets here when it is not bound to it.
583                    if modifiers.command() && matches!(key, Key::A) {
584                        if modifiers.shift()
585                            && let Some(ref sel) = sel_for_key
586                            && sel.mode() == teksilo_data::SelectionMode::Multi
587                        {
588                            sel.clear();
589                            return teksilo_core::event::EventResponse::Handled;
590                        }
591                        return teksilo_core::event::EventResponse::Ignored;
592                    }
593
594                    // The three recursive / one-level expand chords, read
595                    // *before* type-ahead: `Key::to_char` answers for `*`, `+`
596                    // and `-`, so a view that searched first would swallow them
597                    // and hunt for a row named "*".
598                    if let Some(chord) = list_nav::tree_chord(*key, *modifiers) {
599                        let handled = with_subtree_ops(&source, |ops| match chord {
600                            list_nav::TreeChord::ExpandSubtree => {
601                                tree_expand::expand_subtree(ops, current)
602                            }
603                            list_nav::TreeChord::CollapseSubtree => {
604                                tree_expand::collapse_subtree(ops, current)
605                            }
606                            list_nav::TreeChord::ExpandOne => match source.meta(current) {
607                                Some(m) if m.has_children && !m.is_expanded => {
608                                    source.set_expanded_at(current, true);
609                                    true
610                                }
611                                _ => false,
612                            },
613                            list_nav::TreeChord::CollapseOne => match source.meta(current) {
614                                Some(m) if m.is_expanded => {
615                                    source.set_expanded_at(current, false);
616                                    true
617                                }
618                                _ => false,
619                            },
620                        });
621                        return if handled {
622                            teksilo_core::event::EventResponse::Handled
623                        } else {
624                            teksilo_core::event::EventResponse::Ignored
625                        };
626                    }
627
628                    // Type-ahead: a printable char (no Ctrl/Alt/Super) jumps the
629                    // selection to the next visible row whose label starts with
630                    // the accumulated term. Opt-in via `type_ahead_label`.
631                    if ta_label.is_some()
632                        && !modifiers.ctrl()
633                        && !modifiers.alt()
634                        && !modifiers.super_key()
635                        && let Some(c) = key.to_char()
636                    {
637                        let label = ta_label.as_ref().unwrap();
638                        let source_ref = &source;
639                        if let Some(idx) =
640                            ta_state.search(c, current, visible_count, ta_timeout, |i| {
641                                source_ref.with_row_str(i, &|item| label(item))
642                            })
643                        {
644                            set_focus(idx);
645                            if let Some(ref sel) = sel_for_key {
646                                sel.select(idx);
647                            }
648                            let new_scroll = ensure_visible(idx);
649                            crate::common::row_metrics::chase_row_into_outer_view(
650                                ctx,
651                                &metrics_for_nav,
652                                vb_for_nav.get(),
653                                idx,
654                                new_scroll,
655                            );
656                            return teksilo_core::event::EventResponse::Handled;
657                        }
658                        return teksilo_core::event::EventResponse::Ignored;
659                    }
660
661                    // The keyboard route to the row drag. `Alt` plus the
662                    // vertical arrows and the ends move the row among its
663                    // siblings; the accelerator plus `]` / `[` — and, off macOS,
664                    // `Alt` plus the horizontal arrows — indent and outdent,
665                    // which is the same drag's reparenting drop. Decoding and
666                    // commit are both `common::ordered_move`, shared with the
667                    // row's context menu and its AccessKit custom actions.
668                    if (modifiers.alt() || modifiers.command())
669                        && let Some(ref sibling) = reorder_key
670                        && let Some(ref reparent) = reparent_key
671                    {
672                        let flat_idx = sel_for_key
673                            .as_ref()
674                            .and_then(|s| s.selected_indices().first().copied())
675                            .or(fi.get())
676                            .unwrap_or(current);
677                        if let Some(mv) = crate::common::ordered_move::OrderedMove::from_key(
678                            *key,
679                            *modifiers,
680                            crate::common::ordered_move::MoveAxis::Vertical,
681                            ctx.is_rtl(),
682                        ) {
683                            sibling(mv, flat_idx, ctx);
684                            return teksilo_core::event::EventResponse::Handled;
685                        }
686                        if let Some(mv) = crate::common::ordered_move::TreeMove::from_key(
687                            *key,
688                            *modifiers,
689                            ctx.is_rtl(),
690                        ) {
691                            reparent(mv, flat_idx, ctx);
692                            return teksilo_core::event::EventResponse::Handled;
693                        }
694                        // Falls THROUGH rather than swallowing: on macOS `⌥→` /
695                        // `⌥←` are the subtree expand pair
696                        // (`list_nav::mac_alias`), and the reorder decoders
697                        // deliberately decline them there. Returning `Ignored`
698                        // here made those two dead in any reorderable tree.
699                    }
700
701                    // Move the cursor to `to`, selecting and revealing it the
702                    // way every other focus-moving key does.
703                    let move_to = |to: usize, ctx: &mut teksilo_core::widget::EventContext| {
704                        set_focus(to);
705                        if let Some(ref sel) = sel_for_key {
706                            sel.select(to);
707                        }
708                        let new_scroll = ensure_visible(to);
709                        crate::common::row_metrics::chase_row_into_outer_view(
710                            ctx,
711                            &metrics_for_nav,
712                            vb_for_nav.get(),
713                            to,
714                            new_scroll,
715                        );
716                    };
717
718                    // Expand a closed node; on one already open, move into its
719                    // first child. Both halves are required by the ARIA tree
720                    // pattern, and Windows documents the same two-stage rule
721                    // ("display the current selection, or select the first
722                    // subfolder") — so descending takes two presses, which is
723                    // what makes the first press safe on a large subtree.
724                    let expand_or_descend = |ctx: &mut teksilo_core::widget::EventContext| {
725                        let Some(meta) = source.meta(current) else {
726                            return false;
727                        };
728                        if meta.has_children && !meta.is_expanded {
729                            source.set_expanded_at(current, true);
730                            return true;
731                        }
732                        match with_subtree_ops(&source, |ops| {
733                            crate::common::tree_expand::first_child(ops, current)
734                        }) {
735                            Some(child) => {
736                                move_to(child, ctx);
737                                true
738                            }
739                            None => false,
740                        }
741                    };
742
743                    // Collapse an open node; on a leaf or a closed one, ascend
744                    // to the parent. Shared with the macOS ⌘↑ alias below,
745                    // which means exactly this.
746                    let collapse_or_ascend = |ctx: &mut teksilo_core::widget::EventContext| {
747                        let Some(meta) = source.meta(current) else {
748                            return false;
749                        };
750                        if meta.is_expanded {
751                            source.set_expanded_at(current, false);
752                            return true;
753                        }
754                        match source.parent_index(current) {
755                            Some(parent) => {
756                                move_to(parent, ctx);
757                                true
758                            }
759                            None => false,
760                        }
761                    };
762
763                    // The chevron points along the reading direction, so the
764                    // two arrows swap under RTL — `TreeTableView` has read
765                    // `is_rtl()` here since it shipped; this one did not, so a
766                    // right-to-left tree collapsed on the wrong key.
767                    //
768                    // Only the unmodified arrows expand: with Shift held the
769                    // key belongs to the range extension below, and with the
770                    // accelerator to the cursor-only move. ⌘ is excluded too,
771                    // or a macOS ⌘←/⌘→ — which the platform spends on history,
772                    // never on an outline — would silently open and close rows.
773                    if !modifiers.shift()
774                        && !modifiers.ctrl()
775                        && !modifiers.alt()
776                        && !modifiers.super_key()
777                    {
778                        let rtl = ctx.is_rtl();
779                        let expand_key = if rtl { Key::ArrowLeft } else { Key::ArrowRight };
780                        let collapse_key = if rtl { Key::ArrowRight } else { Key::ArrowLeft };
781                        if *key == expand_key && expand_or_descend(ctx) {
782                            return teksilo_core::event::EventResponse::Handled;
783                        }
784                        if *key == collapse_key && collapse_or_ascend(ctx) {
785                            return teksilo_core::event::EventResponse::Handled;
786                        }
787                    }
788
789                    // macOS spends a few chords on a tree that the other
790                    // desktops spend elsewhere, and all of them are dead here
791                    // otherwise: ⌘↓ opens the row, ⌘↑ ascends to the parent
792                    // (Finder's "enclosing folder", VS Code's `list.collapse`),
793                    // and ⌥→/⌥← expand or collapse a whole subtree — AppKit's
794                    // own `expandItem:expandChildren:`, documented as the
795                    // Option-click twin. Off macOS this is `None` and free.
796                    if let Some(alias) = list_nav::mac_alias(*key, *modifiers, ctx.is_rtl()) {
797                        let handled = match alias {
798                            list_nav::MacAlias::Activate => {
799                                if let Some(ref sel) = sel_for_key {
800                                    sel.select(current);
801                                }
802                                if let Some(ref cb) = activate_key {
803                                    cb(current, ctx);
804                                }
805                                true
806                            }
807                            list_nav::MacAlias::CollapseOrParent => collapse_or_ascend(ctx),
808                            list_nav::MacAlias::ExpandSubtree => with_subtree_ops(&source, |ops| {
809                                tree_expand::expand_subtree(ops, current)
810                            }),
811                            list_nav::MacAlias::CollapseSubtree => {
812                                with_subtree_ops(&source, |ops| {
813                                    tree_expand::collapse_subtree(ops, current)
814                                })
815                            }
816                        };
817                        return if handled {
818                            teksilo_core::event::EventResponse::Handled
819                        } else {
820                            teksilo_core::event::EventResponse::Ignored
821                        };
822                    }
823
824                    // Navigation keys. With no cursor yet, the first Down lands ON
825                    // the first row and the first Up on the last one — stepping
826                    // to row 1 would silently skip the row the user is looking at
827                    // (see `ListView`, same rule).
828                    // The edge-and-page family, resolved once in
829                    // `common::list_nav`. `End` is the last **visible** row,
830                    // not the model's last leaf — the ARIA tree pattern states
831                    // it outright ("the last node that is focusable without
832                    // opening a node"), and Qt and GTK both scan the current
833                    // flattening rather than the model.
834                    let nav = list_nav::nav_chord(*key, *modifiers, list_nav::ViewKind::Linear);
835                    let new_idx = if let Some(chord) = nav {
836                        Some(match chord.movement {
837                            list_nav::NavMove::First | list_nav::NavMove::RowFirst => 0,
838                            list_nav::NavMove::Last | list_nav::NavMove::RowLast => {
839                                visible_count - 1
840                            }
841                            list_nav::NavMove::Page { down } => {
842                                let vh = vh_for_nav.get();
843                                let r = {
844                                    let mut m = metrics_for_nav.borrow_mut();
845                                    m.resize(visible_count);
846                                    let target = if down {
847                                        m.row_top(current) + vh
848                                    } else {
849                                        (m.row_top(current) - vh).max(0.0)
850                                    };
851                                    m.row_at(target)
852                                };
853                                if r == current && down {
854                                    (current + 1).min(visible_count - 1)
855                                } else if r == current {
856                                    current.saturating_sub(1)
857                                } else {
858                                    r.min(visible_count - 1)
859                                }
860                            }
861                        })
862                    } else {
863                        match key {
864                            Key::ArrowDown => Some(match cursor {
865                                None => 0,
866                                Some(c) => (c + 1).min(visible_count - 1),
867                            }),
868                            Key::ArrowUp => Some(match cursor {
869                                None => visible_count - 1,
870                                Some(c) => c.saturating_sub(1),
871                            }),
872                            Key::Enter => {
873                                // Enter activates the focused row (open / commit).
874                                if let Some(ref sel) = sel_for_key {
875                                    sel.select(current);
876                                }
877                                if let Some(ref cb) = activate_key {
878                                    cb(current, ctx);
879                                }
880                                return teksilo_core::event::EventResponse::Handled;
881                            }
882                            Key::Space if modifiers.ctrl() => {
883                                // Ctrl+Space toggles the focused row's selection —
884                                // the keyboard equivalent of Ctrl+click. In a
885                                // multiple selection it pairs with Ctrl+Arrow's
886                                // cursor-only move so a user can
887                                // walk the cursor without disturbing the existing
888                                // selection, then Ctrl+Space to add rows one at a
889                                // time.
890                                //
891                                // Both halves stay on literal `ctrl()`, macOS
892                                // included: ⌘Space is Spotlight and never reaches
893                                // an app, and ⌘↑/⌘↓ already mean something else in
894                                // a Finder list. This Explorer-style cursor pair
895                                // has no ⌘ counterpart, so Control keeps it
896                                // reachable and out of the platform's way.
897                                if let Some(ref sel) = sel_for_key {
898                                    sel.toggle(current);
899                                }
900                                set_focus(current);
901                                return teksilo_core::event::EventResponse::Handled;
902                            }
903                            Key::Space => {
904                                // A row carrying a checkbox reads Space as
905                                // "check this" — see the `ListView` sibling for
906                                // why. Ctrl+Space above keeps toggling the
907                                // selection, and a row without a checkbox
908                                // publishes no toggle, so it falls through to
909                                // the selection exactly as before.
910                                if let Some(row_id) = row_map_for_key
911                                    .borrow()
912                                    .iter()
913                                    .find(|(i, _)| *i == current)
914                                    .map(|(_, id)| *id)
915                                {
916                                    let sel_fallback = sel_for_key.clone();
917                                    ctx.row_space_activate(
918                                        row_id,
919                                        std::rc::Rc::new(
920                                            move |_ctx: &mut teksilo_core::widget::EventContext| {
921                                                if let Some(ref sel) = sel_fallback {
922                                                    if sel.mode()
923                                                        == teksilo_data::SelectionMode::Multi
924                                                    {
925                                                        sel.toggle(current);
926                                                    } else {
927                                                        sel.select(current);
928                                                    }
929                                                }
930                                            },
931                                        ),
932                                    );
933                                    set_focus(current);
934                                    return teksilo_core::event::EventResponse::Handled;
935                                }
936                                if let Some(ref sel) = sel_for_key {
937                                    if sel.mode() == teksilo_data::SelectionMode::Multi {
938                                        sel.toggle(current);
939                                    } else {
940                                        sel.select(current);
941                                    }
942                                }
943                                set_focus(current);
944                                return teksilo_core::event::EventResponse::Handled;
945                            }
946                            _ => None,
947                        }
948                    };
949
950                    if let Some(idx) = new_idx {
951                        set_focus(idx);
952                        // What the chord does to the selection — see the
953                        // `ListView` sibling for the full rationale. The
954                        // edge-and-page keys carry their own answer from
955                        // `list_nav`; the arrows keep reading literal `ctrl()`,
956                        // which is what leaves ⌘↑/⌘↓ free for the macOS
957                        // aliases above. A single selection moves with the
958                        // cursor either way (`for_cardinality`).
959                        let op = match nav {
960                            Some(chord) => chord.selection,
961                            None if modifiers.ctrl()
962                                && !modifiers.shift()
963                                && matches!(key, Key::ArrowUp | Key::ArrowDown) =>
964                            {
965                                list_nav::SelectionOp::Suppress
966                            }
967                            None if modifiers.shift() => list_nav::SelectionOp::Extend,
968                            None => list_nav::SelectionOp::Replace,
969                        };
970                        if let Some(ref sel) = sel_for_key {
971                            match op.for_cardinality(sel.mode().into()) {
972                                list_nav::SelectionOp::Replace => sel.select(idx),
973                                list_nav::SelectionOp::Suppress => {}
974                                list_nav::SelectionOp::Extend => sel.extend_to(idx),
975                                list_nav::SelectionOp::ExtendAdditive => {
976                                    sel.extend_to_additive(idx)
977                                }
978                            }
979                        }
980                        let new_scroll = ensure_visible(idx);
981                        crate::common::row_metrics::chase_row_into_outer_view(
982                            ctx,
983                            &metrics_for_nav,
984                            vb_for_nav.get(),
985                            idx,
986                            new_scroll,
987                        );
988                        return teksilo_core::event::EventResponse::Handled;
989                    }
990                }
991                teksilo_core::event::EventResponse::Ignored
992            });
993        }
994
995        // --- DnD: register as drop target when reorderable OR accept foreign
996        // rows. The source's `can_accept` decides per-hover whether the drop is
997        // allowed (and a forbidden verdict shows no insertion line / highlight);
998        // a foreign exported row that the source itself rejects can still be
999        // accepted via the `accept_foreign_rows` sugar (shown as a plain
1000        // between-rows insertion — a foreign source has no Into/reparent
1001        // semantics). ---
1002        if self.export.is_drop_target(self.reorderable) {
1003            let my_view_id = self.tree_id;
1004
1005            // Shared across hover / tick / leave: the visible row index under the
1006            // pointer + when first seen, for spring-loaded folder expansion.
1007            // Reset whenever the hovered row changes or the drag leaves.
1008            let hovered_row: Rc<Cell<Option<(usize, std::time::Instant)>>> =
1009                Rc::new(Cell::new(None));
1010
1011            // ----- hover: geometry → (target, position) → source.can_accept -----
1012            let metrics_for_hover = self.metrics.clone();
1013            let scroll_for_hover = self.scroll_y.clone();
1014            let source_for_hover = self.source.clone();
1015            let feedback_for_hover = self.drop_feedback.clone();
1016            let width_for_hover = self.placed_content_width.clone();
1017            let hr_for_hover = hovered_row.clone();
1018            let export_for_hover = self.export.clone();
1019            handlers = handlers.on_drag_hover(move |payload, position, ctx| {
1020                let line_width = width_for_hover.get();
1021                let vc = source_for_hover.visible_count();
1022                if vc == 0 {
1023                    feedback_for_hover.set(None);
1024                    hr_for_hover.set(None);
1025                    return DropFeedback::NoFeedback;
1026                }
1027                let scroll = scroll_for_hover.get().max(0.0);
1028                let content_y = position.y + scroll;
1029                let (insertion_top, row_idx, row_top, row_h) = {
1030                    let mut m = metrics_for_hover.borrow_mut();
1031                    m.resize(vc);
1032                    let ins = m.insertion_index(content_y);
1033                    let r = m.row_at(content_y);
1034                    let insertion_top = m.row_top(ins);
1035                    let row_top = m.row_top(r);
1036                    let row_h = m.row_height(r);
1037                    (insertion_top, r, row_top, row_h)
1038                };
1039                // Spring-load tracking (dwell-to-expand the hovered branch).
1040                match hr_for_hover.get() {
1041                    Some((p, t)) if p == row_idx => hr_for_hover.set(Some((row_idx, t))),
1042                    _ => hr_for_hover.set(Some((row_idx, std::time::Instant::now()))),
1043                }
1044                // Drop position from Y within the row (top third Before / middle
1045                // Into / bottom After). The source's `can_accept` is the verdict
1046                // — a Reject shows NO line (the pre-commit forbidden affordance).
1047                // Before / Into / After from the y within the row. The bands
1048                // are plain thirds for a cursor and widen at the edges for a
1049                // finger — `common::drop_bands` owns the rule, and the hover
1050                // affordance and the drop itself both read it, so the line the
1051                // user sees cannot promise a position the drop does not take.
1052                let y_in_row = content_y - row_top;
1053                let drop_pos = crate::common::drop_bands::drop_position_in_row(
1054                    y_in_row,
1055                    row_h,
1056                    ctx.pointer_kind(),
1057                );
1058                // The source's verdict decides the *effective* position: a
1059                // `Redirect` (e.g. Into-a-leaf → After) overrides the raw zone.
1060                // `depth` rides along so `paint` can indent the affordance to
1061                // the level the dropped row actually lands at — `Before` /
1062                // `After` are documented as *siblings* of the target, so both
1063                // take the target's own depth, and so does the `Into` box,
1064                // which frames that very row.
1065                let (effective, depth) = match (source_for_hover.dnd.can_accept_fn)(
1066                    payload, row_idx, drop_pos, my_view_id,
1067                ) {
1068                    DropResponse::Reject => {
1069                        // The source itself won't take this drop — fall back to
1070                        // the foreign-export sugar, shown as a plain between-rows
1071                        // insertion (a foreign source has no Into/reparent
1072                        // semantics to honor). It lands at a flat index with no
1073                        // nesting the view can promise, so it claims none:
1074                        // depth 0.
1075                        let foreign_ok =
1076                            export_for_hover.accepts_foreign_export(payload, my_view_id);
1077                        if !foreign_ok {
1078                            feedback_for_hover.set(None);
1079                            return DropFeedback::NoFeedback;
1080                        }
1081                        (DropPosition::Before, 0)
1082                    }
1083                    DropResponse::Accept => (drop_pos, source_for_hover.depth(row_idx)),
1084                    DropResponse::Redirect(p) => (p, source_for_hover.depth(row_idx)),
1085                };
1086                if effective == DropPosition::Into {
1087                    // Drop *into* the hovered container → highlight its whole row.
1088                    let top = row_top - scroll;
1089                    feedback_for_hover.set(Some(DropViz::Rect {
1090                        top,
1091                        height: row_h,
1092                        width: line_width,
1093                        depth,
1094                    }));
1095                    DropFeedback::HighlightRect {
1096                        rect: Rect::new(0.0, top, line_width, row_h),
1097                        color: teksilo_tokens::Color::from_rgba(0.25, 0.47, 0.85, 0.25),
1098                    }
1099                } else {
1100                    let insertion_y = insertion_top - scroll;
1101                    feedback_for_hover.set(Some(DropViz::Line {
1102                        y: insertion_y,
1103                        width: line_width,
1104                        depth,
1105                    }));
1106                    DropFeedback::InsertionLine {
1107                        y: insertion_y,
1108                        width: line_width,
1109                    }
1110                }
1111            });
1112
1113            // ----- drop: re-derive (target, position), route to accept_drop -----
1114            let metrics_for_drop = self.metrics.clone();
1115            let scroll_for_drop = self.scroll_y.clone();
1116            let source_for_drop = self.source.clone();
1117            let feedback_for_drop = self.drop_feedback.clone();
1118            let export_for_drop = self.export.clone();
1119            let reorderable_for_drop = self.reorderable;
1120            handlers = handlers.on_drop(move |mut payload, position, ctx| {
1121                feedback_for_drop.set(None);
1122                let vc = source_for_drop.visible_count();
1123                if vc == 0 {
1124                    return false;
1125                }
1126                let scroll = scroll_for_drop.get().max(0.0);
1127                let content_y = position.y + scroll;
1128                let (row_idx, row_top, row_h, ins) = {
1129                    let mut m = metrics_for_drop.borrow_mut();
1130                    m.resize(vc);
1131                    let r = m.row_at(content_y);
1132                    let ins = m.insertion_index(content_y);
1133                    (r, m.row_top(r), m.row_height(r), ins)
1134                };
1135                // Before / Into / After from the y within the row. The bands
1136                // are plain thirds for a cursor and widen at the edges for a
1137                // finger — `common::drop_bands` owns the rule, and the hover
1138                // affordance and the drop itself both read it, so the line the
1139                // user sees cannot promise a position the drop does not take.
1140                let y_in_row = content_y - row_top;
1141                let drop_pos = crate::common::drop_bands::drop_position_in_row(
1142                    y_in_row,
1143                    row_h,
1144                    ctx.pointer_kind(),
1145                );
1146                let is_same_view = payload
1147                    .get_typed::<RowDragData<T>>()
1148                    .is_some_and(|rd| rd.source == my_view_id);
1149                // Route the drop to the source's accept_drop first. A same-view
1150                // reorder/reparent only happens when the view is `reorderable`;
1151                // a foreign payload the source itself recognises is the
1152                // source's call.
1153                if (reorderable_for_drop || !is_same_view)
1154                    && (source_for_drop.dnd.accept_drop_fn)(&payload, row_idx, drop_pos, my_view_id)
1155                {
1156                    // Only suppress our OWN move-out for a genuine same-view drop.
1157                    if is_same_view {
1158                        export_for_drop.note_self_reorder();
1159                    }
1160                    return true;
1161                }
1162                // Otherwise, the shared foreign-receive sugar (peek-before-take):
1163                // accept exported rows from a different view/source without a
1164                // custom TreeDataSource, at the flat insertion index.
1165                export_for_drop.foreign_receive(&mut payload, my_view_id, ins, ctx)
1166            });
1167
1168            // Clear insertion line + spring-load timer whenever the drag leaves.
1169            let feedback_for_leave = self.drop_feedback.clone();
1170            let hr_for_leave = hovered_row.clone();
1171            handlers = handlers.on_drag_leave(move |_ctx| {
1172                feedback_for_leave.set(None);
1173                hr_for_leave.set(None);
1174            });
1175
1176            // Per-frame tick: viewport-edge auto-scroll plus spring-loaded
1177            // folders. The tick fires regardless of pointer movement, so
1178            // edge-scroll and spring-open still progress when the hand is
1179            // stationary.
1180            let scroll_for_tick = self.scroll_y.clone();
1181            let max_scroll_for_tick = self.max_scroll_y.clone();
1182            let viewport_for_tick = self.viewport_height.clone();
1183            let hr_for_tick = hovered_row.clone();
1184            let source_for_tick = self.source.clone();
1185            const SPRING_DELAY_MS: u64 = 700;
1186            handlers = handlers.on_drag_tick(move |pos, ctx| {
1187                // --- 1. Edge auto-scroll ---
1188                let h = viewport_for_tick.get();
1189                let band = crate::common::drag_autoscroll::band_for(ctx.pointer_kind());
1190                let delta = crate::common::drag_autoscroll::step(pos.y, h, band);
1191                if delta.abs() > 0.01 {
1192                    let max = max_scroll_for_tick.get();
1193                    let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
1194                    scroll_for_tick.set(new_y);
1195                }
1196
1197                // --- 2. Spring-loaded folders ---
1198                if let Some((row_idx, first_seen)) = hr_for_tick.get() {
1199                    let elapsed_ms = first_seen.elapsed().as_millis() as u64;
1200                    let has_children = source_for_tick
1201                        .meta(row_idx)
1202                        .map(|m| m.has_children)
1203                        .unwrap_or(false);
1204                    if elapsed_ms >= SPRING_DELAY_MS
1205                        && has_children
1206                        && !source_for_tick.is_expanded_at(row_idx)
1207                    {
1208                        source_for_tick.set_expanded_at(row_idx, true);
1209                        // Reset so we don't keep re-firing on the same row.
1210                        hr_for_tick.set(None);
1211                    }
1212                }
1213            });
1214        }
1215
1216        // --- Export completion: remove rows moved out to a FOREIGN target. The
1217        // handler fires on the drag source (this view's root id, the stable id
1218        // start_drag was given). A same-view reorder called
1219        // `export.note_self_reorder()`, so it is skipped here (already applied).
1220        //
1221        // FIXED (was a known limitation): move-out no longer resolves the
1222        // dragged rows from flat indices at completion time. `build_payload`
1223        // captures a stable-key removal thunk via `source.dnd.snapshot_out_fn`
1224        // at drag-start, so a Move that dwelled over a collapsing/expanding
1225        // folder mid-drag (spring-load auto-expand reshuffling flat indices)
1226        // still removes the correct node.
1227        handlers = self.export.install_completion(handlers);
1228
1229        ctx.apply_self_handlers(handlers);
1230
1231        // --- Body pane ---
1232        // Hoisted into its own widget so that scroll-buffer-exit rebuilds
1233        // (which happen mid-thumb-drag once the user scrolls past the buffered
1234        // range) target a SIBLING of the scrollbar rather than the scrollbar's
1235        // ancestor. Rebuilding the ancestor would be deferred by the framework
1236        // to preserve the captured drag, leaving the tree blank until the user
1237        // released the thumb. See `body_pane`'s module docs.
1238        let pane = super::body_pane::TreeViewBodyPane::<T> {
1239            source: self.source.clone(),
1240            row_delegate: self.row_delegate.clone(),
1241            row_tooltips: self.row_tooltips.clone(),
1242            metrics: self.metrics.clone(),
1243            row_selection: self.row_selection.clone(),
1244            focused_index: self.focused_index.clone(),
1245            focused_anchor: self.focused_anchor.clone(),
1246            reorderable: self.reorderable,
1247            reorder_perform: reorder_perform.clone(),
1248            reparent_perform: reparent_perform.clone(),
1249            row_click_expands: self.row_click_expands,
1250            export: self.export.clone(),
1251            on_activate: self.on_activate.clone(),
1252            activate_on: self.activate_on,
1253            tree_id: self.tree_id,
1254            root_id: self_id,
1255            scroll_y: self.scroll_y.clone(),
1256            viewport_height: self.viewport_height.clone(),
1257            version: self.pane_version.clone(),
1258            total_refresh: self.layout_refresh.clone(),
1259            prev_built_start: self.pane_built_start.clone(),
1260            prev_built_end: self.pane_built_end.clone(),
1261            item_entries: Vec::new(),
1262            row_roots: Vec::new(),
1263            row_map: self.row_map.clone(),
1264            reveal: self.reveal.clone(),
1265        };
1266        self.body_pane_id = Some(ctx.add(pane));
1267
1268        // --- Scrollbar ---
1269        let scrollbar = ScrollBar::new(
1270            ScrollBarOrientation::Vertical,
1271            self.scroll_y.clone(),
1272            self.max_scroll_y.clone(),
1273            self.viewport_ratio_y.clone(),
1274        )
1275        .visual(match self.scroll_bar_style {
1276            ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
1277            ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
1278            ScrollBarMode::Thin => ScrollBarVisual::Thin,
1279        });
1280        self.scrollbar_id = Some(ctx.add(scrollbar));
1281
1282        self.child_ids()
1283    }
1284
1285    fn layout_response(
1286        &self,
1287        proposal: SizeProposal,
1288        _ctx: &LayoutContext,
1289    ) -> teksilo_core::widget::LayoutResponse {
1290        // Only an allocation may seed the cached viewport — see
1291        // `common::viewport` for what a measurement pass does to `build`'s
1292        // realization window otherwise.
1293        crate::common::viewport::viewport_size(
1294            proposal,
1295            &self.viewport_height,
1296            Size::new(300.0, 200.0),
1297        )
1298        .into()
1299    }
1300
1301    fn place_children(
1302        &self,
1303        bounds: Rect,
1304        _proposal: SizeProposal,
1305        children: &mut [WidgetPlacement],
1306        _ctx: &LayoutContext,
1307    ) {
1308        // Cache our own absolute bounds for the keyboard handler's
1309        // outer-scroll chase (`ensure_visible`), before the empty-children bail.
1310        self.viewport_bounds.set(bounds);
1311        // The allocated height is the authoritative viewport: `build` sizes its
1312        // realization window from this, and a stale value there costs a
1313        // permanent rebuild loop (`common::viewport`).
1314        crate::common::viewport::record_viewport_height(&self.viewport_height, bounds.height);
1315        // The rubber band's resistance is a fraction of the viewport. This
1316        // view does not band, but the scroller reads the extent either way and
1317        // this is the only pass that knows it.
1318        self.scroller
1319            .borrow_mut()
1320            .set_viewport(teksilo_canvas::Vec2::new(bounds.width, bounds.height));
1321
1322        if children.is_empty() {
1323            return;
1324        }
1325
1326        let viewport_height = bounds.height;
1327        // Permanent reserves a column for the bar; Overlay / Thin float
1328        // over the content, so rows span the full width.
1329        let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
1330        let content_width = if reserves_bar {
1331            (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
1332        } else {
1333            bounds.width
1334        };
1335        self.placed_content_width.set(content_width);
1336
1337        // Totals for the scrollbar. In auto-measure mode these are computed
1338        // BEFORE the pane measures its rows (parent-before-child ordering), so
1339        // the pane pokes `layout_refresh` when a measurement moves the total
1340        // and we re-place next frame with the corrected value.
1341        let total_height = self.total_content_height();
1342        let max_y = (total_height - viewport_height).max(0.0);
1343        self.max_scroll_y.set(max_y);
1344        let ratio = if total_height > 0.0 {
1345            (viewport_height / total_height).clamp(0.0, 1.0)
1346        } else {
1347            1.0
1348        };
1349        self.viewport_ratio_y.set(ratio);
1350        self.clamp_scroll();
1351
1352        // Two children in a fixed order (see `child_ids`): the body pane fills
1353        // the content column and positions its own rows; the scrollbar sits
1354        // alongside it.
1355        let mut next = 0;
1356        if self.body_pane_id.is_some() {
1357            if let Some(child) = children.get_mut(next) {
1358                child.origin = bounds.origin();
1359                child.size = Size::new(content_width, bounds.height);
1360            }
1361            next += 1;
1362        }
1363        if self.scrollbar_id.is_some()
1364            && let Some(sb_child) = children.get_mut(next)
1365        {
1366            let needs_scrollbar = total_height > viewport_height + 0.5;
1367            if needs_scrollbar {
1368                sb_child.origin =
1369                    Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1370                sb_child.size = Size::new(SCROLLBAR_THICKNESS, bounds.height);
1371            } else {
1372                sb_child.origin = bounds.origin();
1373                sb_child.size = Size::ZERO;
1374            }
1375        }
1376    }
1377
1378    fn paint(
1379        &self,
1380        bounds: Rect,
1381        canvas: &mut teksilo_canvas::Canvas,
1382        ctx: &teksilo_core::widget::PaintContext,
1383    ) {
1384        // Draw the drop affordance during drag hover — recipe-driven role +
1385        // thickness via `ListContainerStyle::insertion()` / `drop_into()`.
1386        if let Some(viz) = self.drop_feedback.get() {
1387            let slot = ctx.theme.style_slots.list_container.as_ref();
1388            let recipe = slot.map(|s| s.insertion()).unwrap_or_default();
1389            let color = recipe.role.resolve(&ctx.theme.colors);
1390            // Own paint isn't covered by `clips_children` — clip so feedback at
1391            // the after-last boundary can't bleed past the widget's bottom edge.
1392            canvas.set_clip(bounds);
1393            match viz {
1394                DropViz::Line { y, width, depth } => {
1395                    let line_y = bounds.y + y;
1396                    let half = recipe.thickness * 0.5;
1397                    let indent = (depth as f32 * recipe.indent_step).min(width);
1398                    canvas.fill_rect(
1399                        Rect::new(
1400                            bounds.x + indent,
1401                            line_y - half,
1402                            width - indent,
1403                            recipe.thickness,
1404                        ),
1405                        color,
1406                    );
1407                }
1408                DropViz::Rect {
1409                    top,
1410                    height,
1411                    width,
1412                    depth,
1413                } => {
1414                    // Into-container highlight. Inset on every side — see
1415                    // `ListDropIntoRecipe::inset`: flush to the row, its top and
1416                    // bottom edges would be the very pixels a Before / After
1417                    // line occupies, and the affordance would stop saying
1418                    // anything the line doesn't.
1419                    let into = slot.map(|s| s.drop_into()).unwrap_or_default();
1420                    let color = into.role.resolve(&ctx.theme.colors);
1421                    let indent = (depth as f32 * recipe.indent_step).min(width);
1422                    let rect = Rect::new(
1423                        bounds.x + indent + into.inset,
1424                        bounds.y + top + into.inset,
1425                        (width - indent - into.inset * 2.0).max(0.0),
1426                        (height - into.inset * 2.0).max(0.0),
1427                    );
1428                    let radius = teksilo_tokens::CornerRadius::uniform(into.corner_radius);
1429                    canvas.fill_rounded_rect(rect, radius, color.with_alpha(into.fill_alpha));
1430                    canvas.stroke_rounded_rect(rect, radius, color, into.thickness);
1431                }
1432            }
1433            canvas.clear_clip();
1434        }
1435
1436        // Container focus ring. When the view is Tab-focused (keyboard modality)
1437        // but nothing is selected, no row paints a ring — so outline the whole
1438        // view, giving the user a visible focus landing point before they arrow.
1439        // Once a row is selected its own ring takes over and this clears.
1440        let has_selection = self
1441            .row_selection
1442            .as_ref()
1443            .is_some_and(|s| s.has_selection());
1444        if self.view_focused.get() && self.focus_visible.get() && !has_selection {
1445            let color = BorderRole::Focused.resolve(&ctx.theme.colors);
1446            let inset = 1.0_f32;
1447            let rect = Rect::new(
1448                bounds.x + inset,
1449                bounds.y + inset,
1450                (bounds.width - inset * 2.0).max(0.0),
1451                (bounds.height - inset * 2.0).max(0.0),
1452            );
1453            canvas.stroke_rect(rect, color, 1.5);
1454        }
1455    }
1456
1457    /// The context-menu key opens the *current row's* menu, not the tree's.
1458    ///
1459    /// A `TreeView` is focusable and its rows deliberately are not — the
1460    /// container owns focus and `set_selected` is what tells assistive
1461    /// technology which row is current (see `list_item_a11y`, which says so
1462    /// explicitly). So the dispatcher's default of "the focused widget" would
1463    /// open the tree's own menu, in the widget family where a per-row menu
1464    /// matters most.
1465    ///
1466    /// The row the user means is the keyboard cursor if they have navigated,
1467    /// else the first selected row. Only realized rows have a widget, so a
1468    /// cursor scrolled outside the virtualization window resolves to nothing
1469    /// and the menu falls back to the tree — right, because there is no row on
1470    /// screen for it to be about.
1471    fn context_menu_key_target(&self) -> Option<WidgetId> {
1472        self.current_row_widget()
1473    }
1474
1475    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1476        builder.set_role(teksilo_core::accesskit::Role::Tree);
1477        // Whether the selection takes more than one row. A real property on
1478        // both platforms that have one: UIA's `SelectionCanSelectMultiple`
1479        // and AT-SPI's multiselectable state. Left unset it reads false, so a
1480        // multi-select view was telling every screen reader that one row was
1481        // the most it would ever hold.
1482        //
1483        // Gated on the mode, and the gate matters beyond tidiness:
1484        // `accesskit_windows` picks the event it raises on a selection change
1485        // from this property (`adapter.rs:189-199`), firing
1486        // `ElementAddedToSelection` when it is true and `ElementSelected` when
1487        // it is false. A single-select view publishing `true` would trade the
1488        // right event for the wrong one.
1489        if self
1490            .row_selection
1491            .as_ref()
1492            .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
1493        {
1494            builder.set_multiselectable(true);
1495        }
1496
1497        // The role above is left exactly as it was found. It is not what makes
1498        // the nomination below work: `focus_id` is the raw stored value
1499        // (`accesskit_consumer-0.39.0/src/tree.rs:534-536`) and neither it nor
1500        // the `active_descendant` resolution beneath it consults `common_filter`
1501        // at all, so a container the filter drops can still be the node whose
1502        // active descendant an adapter reads.
1503        //
1504        // No `size_of_set` here, deliberately. A flattened tree cannot express
1505        // "the 2nd of 5 siblings" from a single container value, and the reason
1506        // is argued in full at `list_item_a11y.rs:278-291`: AccessKit resolves
1507        // an item's set size by walking *up* from it, so the only number this
1508        // node could carry is one shared by every row at every depth. Doing it
1509        // correctly needs a real `Role::Group` per expanded branch. Writing the
1510        // number anyway would make a missing feature look like a working one.
1511
1512        // The current row, as the container's active descendant.
1513        //
1514        // Keyboard focus stays here, on the tree, and the row is marked
1515        // `selected`. On AT-SPI that is the whole story: Orca announces the
1516        // selection change. On Windows it is not, because UIA has no
1517        // active-descendant property at all. What it has is a focused element,
1518        // and for a tree that element is the item.
1519        //
1520        // AccessKit bridges the two in the consumer rather than in each
1521        // adapter: `accesskit_consumer` resolves the focused node as
1522        // `focused.active_descendant().unwrap_or(focused)` (`tree.rs:541`) and
1523        // `accesskit_windows::focus_moved` (`adapter.rs:341-345`) raises
1524        // `UIA_AutomationFocusChangedEventId` on whatever comes out. So this
1525        // one property turns every arrow press into the focus change a screen
1526        // reader announces, and `is_focused` (`consumer node.rs:89-105`) moves
1527        // from this container to the row, which is what the ARIA tree pattern
1528        // says should happen.
1529        //
1530        // Without it, arrowing through any Teksilo tree is silent to NVDA:
1531        // there is no focus change to announce. The mouse still reads rows
1532        // correctly, because hit-testing does not go through events at all,
1533        // which is exactly how this hid for so long.
1534        //
1535        // Only while this view actually holds focus. A container that does not
1536        // have focus has no active descendant to speak of, and publishing one
1537        // anyway puts a second relation in the tree for a client to follow.
1538        if self.view_focused.get()
1539            && let Some(row) = self.current_row_widget()
1540        {
1541            builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(row));
1542        }
1543    }
1544
1545    fn as_any(&self) -> Option<&dyn std::any::Any> {
1546        Some(self)
1547    }
1548
1549    fn children(&self) -> Vec<WidgetId> {
1550        self.child_ids()
1551    }
1552
1553    fn clips_children(&self) -> bool {
1554        true
1555    }
1556}
1557
1558/// Run `f` against a [`SubtreeOps`](crate::common::tree_expand::SubtreeOps) view of `source`.
1559///
1560/// The recursive expand walks the *live* flattening, so it needs the three
1561/// closures rebuilt against this source rather than a snapshot — expanding a
1562/// row changes what the next read returns, which is the whole point.
1563fn with_subtree_ops<T: 'static, R>(
1564    source: &std::rc::Rc<crate::tree_source::TreeSource<T>>,
1565    f: impl FnOnce(&crate::common::tree_expand::SubtreeOps) -> R,
1566) -> R {
1567    let count = || source.visible_count();
1568    let row = |i: usize| {
1569        source
1570            .meta(i)
1571            .map(|m| (m.depth, m.has_children, m.is_expanded))
1572    };
1573    let set = |i: usize, on: bool| source.set_expanded_at(i, on);
1574    f(&crate::common::tree_expand::SubtreeOps {
1575        visible_count: &count,
1576        row: &row,
1577        set_expanded: &set,
1578    })
1579}