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