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