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}