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