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