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