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}