teksilo_widgets/list_view/widget_impl.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Widget`] trait implementation for [`ListView`]: build (body pane
5//! and scrollbar assembly, row realization, pointer and drag wiring), layout,
6//! placement, paint and accessibility.
7
8use super::*;
9impl<T: 'static> Widget for ListView<T> {
10 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
11 // The root builds two children — the body pane and the scrollbar —
12 // plus the pinned section header when one is asked for, and none of
13 // them depends on the data, the selection or the scroll offset. So
14 // it declares no `Rebuild`-level binding at all: row realization is
15 // the pane's job (see `body_pane`'s module docs for why that
16 // separation is load-bearing and not just tidy), and what the root
17 // still owns resolves at `Relayout` / `RepaintOnly`.
18 let self_id = ctx.self_id();
19 ctx.enabled_when(self_id, self.enabled.clone());
20
21 // Scrollbar totals + the content-width decision live in the root's
22 // `place_children`; a data change or a pane measurement that moves
23 // the content total re-places the root through this.
24 self.layout_refresh.bind_to(
25 ctx.self_id(),
26 ctx.binding_registry(),
27 BindingLevel::Relayout,
28 );
29 // A handle's reveal is applied by the body pane, which binds the same
30 // request; the root binds it too so the pass also refreshes the
31 // scroll range the pane clamps against.
32 self.reveal.bind(ctx);
33 // Container focus ring: painted only while nothing is selected, so a
34 // selection change has to reach the root's paint — without rebuilding
35 // it and taking the scrollbar down with it.
36 self.paint_refresh.bind_to(
37 ctx.self_id(),
38 ctx.binding_registry(),
39 BindingLevel::RepaintOnly,
40 );
41
42 // The keyboard cursor names the active descendant (`accessibility`),
43 // so any write of it — the keys, a click, a handle's `set_cursor` —
44 // re-walks the AT tree. A cursor-only move (the accelerator with an
45 // arrow in a multiple selection) changes nothing else the walk would
46 // notice. Neither a rebuild nor a relayout: no row is drawn from it.
47 self.focused_index.bind_to(
48 ctx.self_id(),
49 ctx.binding_registry(),
50 BindingLevel::AccessibilityOnly,
51 );
52
53 // Bind scroll_y at Relayout so place_children runs on every scroll
54 // position change (re-clamps and refreshes the thumb) without a
55 // rebuild. The pane holds the matching binding for its rows.
56 self.scroll_y.bind_to(
57 ctx.self_id(),
58 ctx.binding_registry(),
59 BindingLevel::Relayout,
60 );
61
62 // Register animated signal for smooth scrolling. Deliberately the
63 // ROOT and only the root: the scheduler keys an animation to the
64 // widget that registered its signal last and cancels it when that
65 // widget rebuilds, so registering from the pane too would make every
66 // buffer-exit rebuild abort an in-flight fling.
67 ctx.register_animated_signal(&self.scroll_y);
68
69 // Bind drop_feedback at RepaintOnly so `set(...)` calls from
70 // on_drag_hover / on_drag_leave dirty the ListView's paint cache
71 // without triggering a rebuild.
72 self.drop_feedback.bind_to(
73 ctx.self_id(),
74 ctx.binding_registry(),
75 BindingLevel::RepaintOnly,
76 );
77
78 // Focus signals for the container ring (see TreeView). `RepaintOnly` so
79 // focus-in/out redraws; selection-emptiness changes arrive on
80 // `paint_refresh`. `begin_view_focus` keys the scope signal on this root id directly,
81 // independent of the arena focusable flag (not yet wired at this point):
82 // a plain `view_focus_active()` would `find_focusable_at_or_above`
83 // nothing and fall back to the constant-`true` "outside any scope"
84 // signal — lighting the ring whenever ANY other widget takes keyboard
85 // focus. Pop straight back; the real row scope below resolves the same
86 // cached signal.
87 self.view_focused = ctx.begin_view_focus();
88 ctx.end_view_focus();
89 self.focus_visible = ctx.focus_visible();
90 self.follow_the_selection_in_single_mode(ctx);
91 self.reveal_current_row_on_focus(ctx);
92 self.view_focused.bind_to(
93 ctx.self_id(),
94 ctx.binding_registry(),
95 BindingLevel::RepaintOnly,
96 );
97 self.focus_visible.bind_to(
98 ctx.self_id(),
99 ctx.binding_registry(),
100 BindingLevel::RepaintOnly,
101 );
102
103 // --- Observe model changes ---
104 // One observer, root-owned, doing the bookkeeping the pane can't
105 // (metrics divergence, selection shift, keyboard cursor) and then
106 // fanning out: rebuild the pane (row content changed) and re-place
107 // the root (the content total, hence the thumb, changed).
108 let pane_version_for_data = self.pane_version.clone();
109 let layout_refresh_for_data = self.layout_refresh.clone();
110 let pinned_refresh_for_data = self.pinned_refresh.clone();
111 let data_ver = Rc::new(Cell::new(0_u64));
112 let data_handle = (self.source.observe_fn)(Box::new({
113 let dv = data_ver.clone();
114 let metrics = self.metrics.clone();
115 let sections = self.sections.clone();
116 let len_fn = self.source.len_fn.clone();
117 let first_changed = self.source.first_changed_fn.clone();
118 let row_sel = self.row_selection.clone();
119 let focused = self.focused_index.clone();
120 move |change| {
121 // Keep row metrics in step with the data: rows before
122 // the first changed index keep their (seeded or
123 // measured) heights, the rest re-derive.
124 let divergence = match change {
125 DataChange::ItemsInserted { range } | DataChange::ItemsRemoved { range } => {
126 Some(range.start)
127 }
128 DataChange::ItemUpdated { index } => Some(*index),
129 DataChange::ItemsMoved { from, to, .. } => Some((*from).min(*to)),
130 // A lazy window load makes rows from range.start onward differ.
131 DataChange::WindowLoaded { range } => Some(range.start),
132 // Reset-emitting proxies (SortFilterListModel) expose
133 // their real divergence through the side-channel.
134 DataChange::Reset => (first_changed)(),
135 };
136 metrics
137 .borrow_mut()
138 .apply_divergence(divergence, (len_fn)());
139 // The provider's counts follow the model, so they are read
140 // again — lazily, on the next geometric query, by which time
141 // a provider observing the same model has caught up too.
142 // The metrics stay item-indexed, so the measured prefix
143 // above survives a regrouping untouched.
144 if let Some(ref sections) = sections {
145 sections.invalidate();
146 }
147 // Keep selection in step: index-shift (index model) or prune
148 // orphaned keys (keyed model).
149 if let Some(ref rs) = row_sel {
150 rs.on_data_change(change);
151 }
152 // Keep the keyboard-navigation anchor in step too — otherwise
153 // it silently points at the wrong row after any insert /
154 // remove / move (reachable not just from local edits but
155 // from a live watcher pushing in a peer process's write).
156 if let Some(current) = focused.get() {
157 focused.set(teksilo_data::data_change::adjust_single_index_for_change(
158 current, change,
159 ));
160 }
161 let next = dv.get() + 1;
162 dv.set(next);
163 pane_version_for_data.set(next);
164 layout_refresh_for_data.set(next);
165 if sections.is_some() {
166 pinned_refresh_for_data.set(next);
167 }
168 }
169 }));
170 ctx.own_handle(data_handle);
171
172 // --- Observe selection changes ---
173 // The pane runs its own selection observer for the delegate's
174 // `selected` argument; the root only needs its container focus ring
175 // repainted, since that ring is suppressed once anything is selected.
176 if let Some(ref rs) = self.row_selection {
177 let paint_refresh_for_sel = self.paint_refresh.clone();
178 let sel_ver = Rc::new(Cell::new(0_u64));
179 let handle = rs.observe_for_rebuild(move || {
180 let next = sel_ver.get() + 1;
181 sel_ver.set(next);
182 paint_refresh_for_sel.set(next);
183 });
184 ctx.own_handle(handle);
185 }
186
187 // Scroll-buffer exit is deliberately NOT observed here. It rebuilds
188 // the body pane and nothing else — the root's own children are
189 // unaffected by which rows are realized, and a root rebuild during a
190 // scrollbar thumb drag is exactly the one the framework defers.
191
192 // --- Set up scroll event handler + DnD handlers on self ---
193 // The wheel arithmetic, the pan and the claim that puts this node on a
194 // finger's claimant chain all come from `common::scrollable`. A wheel
195 // still takes the path it always did — `handle_scroll_event` branches
196 // on the scroll *source*, not the phase.
197 let mut handlers = HandlerSet::new()
198 .clips_children(true)
199 .focusable(!self.presentational);
200 {
201 let behavior = crate::common::scrollable::ScrollableBehavior::new(
202 crate::common::scrollable::ScrollableAxes::vertical(
203 self.scroll_y.clone(),
204 self.max_scroll_y.clone(),
205 ),
206 )
207 .with_scroller(self.scroller.clone())
208 // Vertical only: this view owns no horizontal offset, so a
209 // horizontal pan is declined and chains outward.
210 .axes(PanAxes::Y)
211 .overscroll(self.overscroll_behavior)
212 .smooth(self.smooth_scrolling)
213 .smooth_duration(self.smooth_scroll_duration)
214 .line_height(self.item_height)
215 .reduced_motion(ctx.prefers_reduced_motion())
216 .physics(ctx.theme().input.scroll_physics);
217 handlers = behavior.install(handlers);
218 }
219
220 // --- The non-drag reorder, all four routes at once ---
221 //
222 // SC 2.5.7 wants the row drag reachable without a drag. One closure
223 // performs the move; the chord below, the row's context menu and the
224 // row's AccessKit custom actions all call it, so the four routes cannot
225 // reach different end states. The move itself travels the bound
226 // source's own drop-accept path — see `common::ordered_move`.
227 let reorder_perform: Option<crate::common::ordered_move::MoveRow> =
228 self.reorderable.then(|| {
229 let mover = std::rc::Rc::new(crate::common::ordered_move::RowMover {
230 len: self.source.len_fn.clone(),
231 stash: self.source.dnd.stash_drag_keys_fn.clone(),
232 payload: {
233 let view = self.model_id;
234 Rc::new(move |from: usize| {
235 DragPayload::typed(RowDragData::<T> {
236 source: view,
237 rows: vec![from],
238 items: None,
239 })
240 })
241 },
242 accept: self.source.dnd.accept_drop_fn.clone(),
243 view: self.model_id,
244 name: {
245 // The type-ahead label resolver, where the application
246 // gave one: the same string that names a row for
247 // find-as-you-type names it in the utterance.
248 let with_item_str = self.source.with_item_str_fn.clone();
249 let label = self.type_ahead_label.clone();
250 Rc::new(move |index: usize| {
251 let label = label.as_ref()?;
252 (with_item_str)(index, &|item| label(item))
253 })
254 },
255 });
256 let sel = self.row_selection.clone();
257 let fi = self.focused_index.clone();
258 let geometry = self.geometry();
259 let len = self.source.len_fn.clone();
260 let scroll = self.scroll_y.clone();
261 let vh = self.viewport_height.clone();
262 let vb = self.viewport_bounds.clone();
263 let max = self.max_scroll_y.clone();
264 std::rc::Rc::new(
265 move |mv: crate::common::ordered_move::OrderedMove,
266 from: usize,
267 ctx: &mut teksilo_core::widget::EventContext| {
268 let Some((dest, utterance)) = mover.commit(mv, from) else {
269 return;
270 };
271 if let Some(ref sel) = sel {
272 sel.select(dest);
273 }
274 fi.set(Some(dest));
275 // Reveal the moved row (own viewport first, then chain
276 // to any enclosing scroll area).
277 let current = scroll.get();
278 let count = len();
279 let new_scroll = geometry.scroll_for_ensure_visible(
280 dest,
281 current,
282 vh.get(),
283 max.get(),
284 count,
285 );
286 if (new_scroll - current).abs() > f32::EPSILON {
287 scroll.set(new_scroll);
288 }
289 geometry.chase_into_outer_view(ctx, vb.get(), dest, new_scroll, count);
290 ctx.announce(utterance);
291 },
292 ) as crate::common::ordered_move::MoveRow
293 });
294
295 // --- Select all, through the rebindable `edit.select_all` ---
296 // Multi selection only; a no-op for Single / None, matching every
297 // list control.
298 {
299 let len = self.source.len_fn.clone();
300 let sel = self.row_selection.clone();
301 crate::common::standard_edit::register_standard_edit_action(
302 ctx,
303 teksilo_core::shortcut::StandardEdit::SelectAll,
304 move |_ctx| {
305 let count = (len)();
306 match sel {
307 Some(ref sel)
308 if count > 0 && sel.mode() == teksilo_data::SelectionMode::Multi =>
309 {
310 sel.select_all(count);
311 true
312 }
313 _ => false,
314 }
315 },
316 );
317 }
318
319 // --- Keyboard navigation + Alt+Arrow reorder ---
320 {
321 let len_for_key = self.source.len_fn.clone();
322 let sel_for_key = self.row_selection.clone();
323 let activate_key = self.on_activate.clone();
324 let fi = self.focused_index.clone();
325 let reorder_key = reorder_perform.clone();
326 let scroll_for_nav = self.scroll_y.clone();
327 let geometry_for_nav = self.geometry();
328 let max_for_nav = self.max_scroll_y.clone();
329 let vh_for_nav = self.viewport_height.clone();
330 let vb_for_nav = self.viewport_bounds.clone();
331 // Type-ahead state + label resolver (reads row text via the
332 // source's string accessor, so lazy/unloaded rows are skipped).
333 let ta_state = self.type_ahead.clone();
334 // Index → realized row id, so `Space` can ask the row whether it
335 // publishes a keyboard toggle (a checkbox) before falling back to
336 // the selection.
337 let row_map_for_key = self.row_map.clone();
338 let ta_label = self.type_ahead_label.clone();
339 let ta_timeout = self.type_ahead_timeout;
340 let with_item_str = self.source.with_item_str_fn.clone();
341
342 handlers = handlers.on_key(move |event, ctx| {
343 if let teksilo_core::event::WidgetEvent::KeyDown { key, modifiers, .. } = event {
344 use teksilo_core::event::Key;
345 let count = (len_for_key)();
346 if count == 0 {
347 return teksilo_core::event::EventResponse::Ignored;
348 }
349
350 // Deselect all — Ctrl+Shift+A, ⇧⌘A on macOS (Multi
351 // selection only). GTK is the only toolkit that
352 // *mandates* it, but the ARIA listbox and tree patterns
353 // both sanction an unselect-all, Windows and Qt simply
354 // have none, and adding it takes nothing away. Select all
355 // is the `edit.select_all` action registered below, so a
356 // plain Ctrl+A only gets here when it is not bound to it.
357 if modifiers.command() && matches!(key, Key::A) {
358 if modifiers.shift()
359 && let Some(ref sel) = sel_for_key
360 && sel.mode() == teksilo_data::SelectionMode::Multi
361 {
362 sel.clear();
363 return teksilo_core::event::EventResponse::Handled;
364 }
365 return teksilo_core::event::EventResponse::Ignored;
366 }
367
368 // macOS reads a couple of chords in a list that the other
369 // desktops spend elsewhere, and both are dead here
370 // otherwise. ⌘↓ opens the row — Finder's "Command–Down
371 // Arrow: Open the selected item", and what VS Code binds as
372 // `list.select`'s macOS secondary. (⌘↑ ascends to the
373 // parent, which a flat list has none of; `TreeView` claims
374 // it.) Off macOS this resolves to `None` and costs nothing.
375 if let Some(alias) = list_nav::mac_alias(*key, *modifiers, ctx.is_rtl()) {
376 if alias == list_nav::MacAlias::Activate {
377 let row = fi
378 .get()
379 .or_else(|| {
380 sel_for_key
381 .as_ref()
382 .and_then(|s| s.selected_indices().first().copied())
383 })
384 .unwrap_or(0)
385 .min(count - 1);
386 if let Some(ref sel) = sel_for_key {
387 sel.select(row);
388 }
389 if let Some(ref cb) = activate_key {
390 cb(row, ctx);
391 }
392 return teksilo_core::event::EventResponse::Handled;
393 }
394 return teksilo_core::event::EventResponse::Ignored;
395 }
396
397 // Type-ahead: a printable char (no Ctrl/Alt/Super) jumps the
398 // selection to the next row whose label starts with the
399 // accumulated term. Opt-in via `type_ahead_label`.
400 if ta_label.is_some()
401 && !modifiers.ctrl()
402 && !modifiers.alt()
403 && !modifiers.super_key()
404 && let Some(c) = key.to_char()
405 {
406 // From the cursor, which is the selection when no key
407 // has moved it — as every other reader of `fi` does.
408 let current = fi
409 .get()
410 .or_else(|| {
411 sel_for_key
412 .as_ref()
413 .and_then(|s| s.selected_indices().first().copied())
414 })
415 .unwrap_or(0)
416 .min(count - 1);
417 let label = ta_label.as_ref().unwrap();
418 if let Some(idx) = ta_state.search(c, current, count, ta_timeout, |i| {
419 (with_item_str)(i, &|item| label(item))
420 }) {
421 fi.set(Some(idx));
422 if let Some(ref sel) = sel_for_key {
423 sel.select(idx);
424 }
425 let scroll = scroll_for_nav.get();
426 let new_scroll = geometry_for_nav.scroll_for_ensure_visible(
427 idx,
428 scroll,
429 vh_for_nav.get(),
430 max_for_nav.get(),
431 count,
432 );
433 if (new_scroll - scroll).abs() > f32::EPSILON {
434 scroll_for_nav.set(new_scroll);
435 }
436 geometry_for_nav.chase_into_outer_view(
437 ctx,
438 vb_for_nav.get(),
439 idx,
440 new_scroll,
441 count,
442 );
443 return teksilo_core::event::EventResponse::Handled;
444 }
445 return teksilo_core::event::EventResponse::Ignored;
446 }
447
448 // Alt+Arrow / Alt+Home / Alt+End: the keyboard route to
449 // the row drag. Decoding and commit are both
450 // `common::ordered_move`, shared with the row's context
451 // menu and its AccessKit custom actions.
452 if let Some(ref perform) = reorder_key
453 && let Some(mv) = crate::common::ordered_move::OrderedMove::from_key(
454 *key,
455 *modifiers,
456 crate::common::ordered_move::MoveAxis::Vertical,
457 ctx.is_rtl(),
458 )
459 {
460 let cursor = sel_for_key
461 .as_ref()
462 .and_then(|s| s.selected_indices().first().copied())
463 .or_else(|| fi.get());
464 if let Some(from) = cursor
465 && mv.destination(from, count).is_some()
466 {
467 perform(mv, from, ctx);
468 return teksilo_core::event::EventResponse::Handled;
469 }
470 }
471
472 // Navigation keys (no modifiers or with Shift for extend)
473 //
474 // The cursor is `focused_index` once the user has navigated
475 // or clicked; failing that it is the current selection — a
476 // view can be handed a selected row before it is ever
477 // focused (a launcher preselecting the top entry, a dialog
478 // restoring the last choice), and the keyboard must continue
479 // from what the user can see, not from an invisible zero.
480 //
481 // `None` ("no cursor yet") is deliberately NOT the same as
482 // `Some(0)`: from nothing, Down must land ON the first row
483 // and Up on the last one. Stepping to row 1 instead would
484 // silently skip row 0 — the row the user was looking at —
485 // which is what every toolkit (GTK, Qt, macOS, the ARIA
486 // listbox pattern) explicitly avoids.
487 let cursor = fi
488 .get()
489 .or_else(|| {
490 sel_for_key
491 .as_ref()
492 .and_then(|s| s.selected_indices().first().copied())
493 })
494 .map(|i| i.min(count - 1));
495 // Anchor for the keys that need a row to compute *from*
496 // (paging, activation) rather than a direction to step in.
497 let current = cursor.unwrap_or(0);
498 // The edge-and-page family is resolved once, in
499 // `common::list_nav`, so the five data views cannot drift
500 // apart on it again. A flat list has no row to be scoped
501 // to, so `RowFirst` / `RowLast` never arrive here — but a
502 // list's row *is* the collection, so they read the same way
503 // if the view kind is ever widened.
504 let nav = list_nav::nav_chord(*key, *modifiers, list_nav::ViewKind::Linear);
505 let new_idx = if let Some(chord) = nav {
506 Some(match chord.movement {
507 list_nav::NavMove::First | list_nav::NavMove::RowFirst => 0,
508 list_nav::NavMove::Last | list_nav::NavMove::RowLast => count - 1,
509 // Geometry-driven, so variable and auto-measured
510 // heights page by visual distance rather than by a
511 // fixed row count; the ensure-visible below then
512 // scrolls to follow.
513 list_nav::NavMove::Page { down } => {
514 let r = geometry_for_nav.page_target(
515 current,
516 down,
517 vh_for_nav.get(),
518 count,
519 );
520 // Guarantee progress even when one row is
521 // taller than the whole viewport.
522 if r == current && down {
523 (current + 1).min(count - 1)
524 } else if r == current {
525 current.saturating_sub(1)
526 } else {
527 r.min(count - 1)
528 }
529 }
530 })
531 } else {
532 match key {
533 Key::ArrowDown => Some(match cursor {
534 None => 0,
535 Some(c) => (c + 1).min(count - 1),
536 }),
537 Key::ArrowUp => Some(match cursor {
538 None => count - 1,
539 Some(c) => c.saturating_sub(1),
540 }),
541 Key::Enter => {
542 // Enter activates the focused row (open / commit).
543 if let Some(ref sel) = sel_for_key {
544 sel.select(current);
545 }
546 if let Some(ref cb) = activate_key {
547 cb(current, ctx);
548 }
549 return teksilo_core::event::EventResponse::Handled;
550 }
551 Key::Space if modifiers.ctrl() => {
552 // Ctrl+Space toggles the focused row's selection —
553 // the keyboard equivalent of Ctrl+click. Distinct
554 // from plain Space below: it always toggles (even
555 // in Single mode, via `SelectionModel::toggle`'s
556 // own Single-mode fallback to `select`). In a
557 // multiple selection it pairs with Ctrl+Arrow's
558 // cursor-only move so a user can walk the cursor
559 // without disturbing the existing selection, then
560 // Ctrl+Space to add rows one at a time.
561 //
562 // Both halves stay on literal `ctrl()`, macOS
563 // included: ⌘Space is Spotlight and never reaches
564 // an app, and ⌘↑/⌘↓ already mean something else in
565 // a Finder list. This Explorer-style cursor pair
566 // has no ⌘ counterpart, so Control keeps it
567 // reachable and out of the platform's way.
568 if let Some(ref sel) = sel_for_key {
569 sel.toggle(current);
570 }
571 fi.set(Some(current));
572 return teksilo_core::event::EventResponse::Handled;
573 }
574 Key::Space => {
575 // A row carrying a checkbox reads Space as
576 // "check this" — what Windows does for a
577 // checkbox list view, and what a visible
578 // checkbox looks like it should answer to. The
579 // row's control is out of the Tab order, so
580 // this is its only keyboard route; Ctrl+Space
581 // above keeps toggling the *selection*.
582 //
583 // Rows without a checkbox are unaffected:
584 // there is no published toggle, so Space falls
585 // through to the selection as before.
586 if let Some(row_id) = row_map_for_key
587 .borrow()
588 .iter()
589 .find(|(i, _)| *i == current)
590 .map(|(_, id)| *id)
591 {
592 let sel_fallback = sel_for_key.clone();
593 ctx.row_space_activate(
594 row_id,
595 std::rc::Rc::new(
596 move |_ctx: &mut teksilo_core::widget::EventContext| {
597 if let Some(ref sel) = sel_fallback {
598 if sel.mode()
599 == teksilo_data::SelectionMode::Multi
600 {
601 sel.toggle(current);
602 } else {
603 sel.select(current);
604 }
605 }
606 },
607 ),
608 );
609 fi.set(Some(current));
610 return teksilo_core::event::EventResponse::Handled;
611 }
612 // Otherwise Space moves/toggles the selection but
613 // does NOT activate — the platform convention
614 // (Enter is the activator). Multi: toggle the
615 // focused row; Single: select it.
616 if let Some(ref sel) = sel_for_key {
617 if sel.mode() == teksilo_data::SelectionMode::Multi {
618 sel.toggle(current);
619 } else {
620 sel.select(current);
621 }
622 }
623 fi.set(Some(current));
624 return teksilo_core::event::EventResponse::Handled;
625 }
626 _ => None,
627 }
628 };
629
630 if let Some(idx) = new_idx {
631 fi.set(Some(idx));
632 // What the chord does to the selection. The edge-and-page
633 // keys carry their own answer from `list_nav`, where the
634 // accelerator means "move the cursor, leave the selection
635 // alone" — the rule GTK4 applies to every navigation key,
636 // and Qt in its multi-selection modes.
637 //
638 // The arrows keep reading literal `ctrl()` instead: ⌘↑/⌘↓
639 // already mean something else in a Finder list (see the
640 // Ctrl+Space arm above), so this pair has no ⌘ counterpart
641 // to move to. That asymmetry is deliberate — it is also
642 // what leaves ⌘↑/⌘↓ free for the macOS aliases.
643 //
644 // Either way a single selection moves with the cursor:
645 // `for_cardinality` takes the cursor-only move away there,
646 // so the row announced is always the row selected.
647 let op = match nav {
648 Some(chord) => chord.selection,
649 None if modifiers.ctrl()
650 && !modifiers.shift()
651 && matches!(key, Key::ArrowUp | Key::ArrowDown) =>
652 {
653 list_nav::SelectionOp::Suppress
654 }
655 None if modifiers.shift() => list_nav::SelectionOp::Extend,
656 None => list_nav::SelectionOp::Replace,
657 };
658 if let Some(ref sel) = sel_for_key {
659 match op.for_cardinality(sel.mode().into()) {
660 list_nav::SelectionOp::Replace => sel.select(idx),
661 list_nav::SelectionOp::Suppress => {}
662 list_nav::SelectionOp::Extend => sel.extend_to(idx),
663 list_nav::SelectionOp::ExtendAdditive => {
664 sel.extend_to_additive(idx)
665 }
666 }
667 }
668 // Scroll into view — the ListView's own viewport first,
669 // then chain to any enclosing scroll area.
670 let scroll = scroll_for_nav.get();
671 let new_scroll = geometry_for_nav.scroll_for_ensure_visible(
672 idx,
673 scroll,
674 vh_for_nav.get(),
675 max_for_nav.get(),
676 count,
677 );
678 if (new_scroll - scroll).abs() > f32::EPSILON {
679 scroll_for_nav.set(new_scroll);
680 }
681 geometry_for_nav.chase_into_outer_view(
682 ctx,
683 vb_for_nav.get(),
684 idx,
685 new_scroll,
686 count,
687 );
688 return teksilo_core::event::EventResponse::Handled;
689 }
690 }
691 teksilo_core::event::EventResponse::Ignored
692 });
693 }
694
695 // --- DnD: register self as a drop target when it can reorder OR accept
696 // foreign rows. The source's `can_accept` decides per-hover whether the
697 // drop is allowed (and a forbidden verdict shows no insertion line). ---
698 if self.export.is_drop_target(self.reorderable) {
699 let geometry_for_hover = self.geometry();
700 let scroll_for_hover = self.scroll_y.clone();
701 let len_for_hover = self.source.len_fn.clone();
702 let can_accept_for_hover = self.source.dnd.can_accept_fn.clone();
703 let my_view_id = self.model_id;
704
705 let feedback_for_hover = self.drop_feedback.clone();
706 let width_for_hover = self.placed_content_width.clone();
707 let export_for_hover = self.export.clone();
708 let pinned_for_hover = self.pinned_shown.clone();
709 handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
710 let scroll = scroll_for_hover.get().max(0.0);
711 let len = (len_for_hover)();
712 // This list's own rows, being moved: with sections they decide
713 // which side of a header they land on.
714 let dragged: &[usize] = payload
715 .get_typed::<RowDragData<T>>()
716 .filter(|rd| rd.source == my_view_id)
717 .map_or(&[], |rd| rd.rows.as_slice());
718 let (ins, insertion_y) = geometry_for_hover.drop_at(
719 position.y,
720 scroll,
721 len,
722 dragged,
723 pinned_for_hover.get(),
724 );
725 let line_width = width_for_hover.get();
726 // Ask the source whether a drop here is allowed; paint the
727 // insertion line only when it is. A foreign exported row is
728 // allowed when `accept_foreign_rows` is on even though a bare
729 // `ListModel`'s `can_accept` rejects the `Foreign` branch.
730 let allowed = flat_insertion_target(ins, len).is_some_and(|(target, pos)| {
731 !matches!(
732 (can_accept_for_hover)(payload, target, pos, my_view_id),
733 DropResponse::Reject
734 ) || export_for_hover.accepts_foreign_export(payload, my_view_id)
735 });
736 if allowed {
737 feedback_for_hover.set(Some((insertion_y, line_width)));
738 DropFeedback::InsertionLine {
739 y: insertion_y,
740 width: line_width,
741 }
742 } else {
743 feedback_for_hover.set(None);
744 DropFeedback::NoFeedback
745 }
746 });
747
748 let len_for_drop = self.source.len_fn.clone();
749 let accept_drop_for_drop = self.source.dnd.accept_drop_fn.clone();
750 let drop_view_id = self.model_id;
751 let scroll_for_drop = self.scroll_y.clone();
752 let geometry_for_drop = self.geometry();
753 let export_for_drop = self.export.clone();
754 let reorderable_for_drop = self.reorderable;
755 let pinned_for_drop = self.pinned_shown.clone();
756
757 handlers = handlers.on_drop(move |mut payload, position, ctx| {
758 let scroll = scroll_for_drop.get().max(0.0);
759 let len = (len_for_drop)();
760 // The index does not depend on the dragged rows, only the line.
761 let (ins, _) =
762 geometry_for_drop.drop_at(position.y, scroll, len, &[], pinned_for_drop.get());
763 let is_same_view = payload
764 .get_typed::<RowDragData<T>>()
765 .is_some_and(|rd| rd.source == drop_view_id);
766 // A same-view reorder only happens when the view is
767 // `reorderable`; a foreign payload is the source's call (a bare
768 // ListModel rejects it).
769 if (reorderable_for_drop || !is_same_view)
770 && let Some((target, position_kind)) = flat_insertion_target(ins, len)
771 && (accept_drop_for_drop)(&payload, target, position_kind, drop_view_id)
772 {
773 if is_same_view {
774 export_for_drop.note_self_reorder();
775 }
776 return true;
777 }
778 // Otherwise, the shared foreign-receive sugar (peek-before-take).
779 export_for_drop.foreign_receive(&mut payload, drop_view_id, ins, ctx)
780 });
781
782 // Clear the insertion line whenever the drag leaves this
783 // widget — pointer moves to another target, drop completes,
784 // Escape cancels, or the source is destroyed.
785 let feedback_for_leave = self.drop_feedback.clone();
786 handlers = handlers.on_drag_leave(move |_ctx| {
787 feedback_for_leave.set(None);
788 });
789
790 // Per-frame auto-scroll when the pointer lingers near the
791 // viewport top or bottom edge during a drag — the band is the
792 // pointer kind's, 32 dp for a mouse or pen and 64 dp for a finger
793 // (`common::drag_autoscroll`).
794 // Linear ramp inside the edge zone, capped at ~12 px/frame
795 // so fast-moving fingers still feel responsive but don't
796 // rocket past the content.
797 let scroll_for_tick = self.scroll_y.clone();
798 let max_scroll_for_tick = self.max_scroll_y.clone();
799 let viewport_for_tick = self.viewport_height.clone();
800 handlers = handlers.on_drag_tick(move |pos, ctx| {
801 let h = viewport_for_tick.get();
802 let band = crate::common::drag_autoscroll::band_for(ctx.pointer_kind());
803 let delta = crate::common::drag_autoscroll::step(pos.y, h, band);
804 if delta.abs() > 0.01 {
805 let max = max_scroll_for_tick.get();
806 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
807 scroll_for_tick.set(new_y);
808 }
809 });
810 }
811
812 // Export completion (move-out): fires on the drag source — this view's
813 // root id, the stable id start_drag was given.
814 handlers = self.export.install_completion(handlers);
815
816 ctx.apply_self_handlers(handlers);
817
818 // --- Body pane ---
819 // Hoisted into its own widget so that scroll-buffer-exit rebuilds
820 // (which happen mid-thumb-drag once the user scrolls past the
821 // buffered range) target a SIBLING of the scrollbar rather than the
822 // scrollbar's ancestor. Rebuilding the ancestor would be deferred by
823 // the framework to preserve the captured drag, leaving the list blank
824 // until the user released the thumb. See `body_pane`'s module docs.
825 let pane = body_pane::ListBodyPane::<T> {
826 source: self.source.clone(),
827 delegate: self.delegate.clone(),
828 row_tooltips: self.row_tooltips.clone(),
829 geometry: self.geometry(),
830 header_factory: self.header_factory(),
831 row_selection: self.row_selection.clone(),
832 focused_index: self.focused_index.clone(),
833 row_map: self.row_map.clone(),
834 reorderable: self.reorderable,
835 reorder_perform: reorder_perform.clone(),
836 export: self.export.clone(),
837 on_activate: self.on_activate.clone(),
838 activate_on: self.activate_on,
839 model_id: self.model_id,
840 root_id: self_id,
841 scroll_y: self.scroll_y.clone(),
842 viewport_height: self.viewport_height.clone(),
843 placed_content_width: self.placed_content_width.clone(),
844 version: self.pane_version.clone(),
845 total_refresh: self.layout_refresh.clone(),
846 prev_built_start: self.pane_built_start.clone(),
847 prev_built_end: self.pane_built_end.clone(),
848 prev_built_headers: self.pane_built_headers.clone(),
849 item_entries: Vec::new(),
850 child_roots: Vec::new(),
851 child_kinds: Vec::new(),
852 header_tab_stops: Vec::new(),
853 presentational: self.presentational,
854 reveal: self.reveal.clone(),
855 };
856 self.body_pane_id = Some(ctx.add(pane));
857
858 // --- Pinned section header ---
859 // Built whenever pinning is asked for, even while the provider has no
860 // section: the slot then holds nothing, and fills in once a model
861 // change gives it one. The root never rebuilds on a data change, so a
862 // slot skipped here could never appear later.
863 self.pinned_header_id = None;
864 if self.pinned_section_headers
865 && let (Some(table), Some(factory)) = (self.sections.clone(), self.header_factory())
866 {
867 self.pinned_header_id = Some(ctx.add(sections::PinnedSectionHeader {
868 section: self.pinned_section.clone(),
869 refresh: self.pinned_refresh.clone(),
870 built: self.pinned_built.clone(),
871 factory,
872 table,
873 len_fn: self.source.len_fn.clone(),
874 child: None,
875 }));
876 // The section is decided as the scroll moves, not only when the
877 // root is next placed: a scroll set by input, an animation or
878 // `scroll_to_index` lands before the frame's rebuilds, so the slot
879 // is rebuilt for the new section in the frame that shows it, and
880 // never spends a frame hidden while it catches up.
881 // `place_children` decides it again, for the offsets written
882 // during layout (the clamp, a measurement's anchor).
883 let geometry = self.geometry();
884 let len = self.source.len_fn.clone();
885 let pinned_section = self.pinned_section.clone();
886 let handle = self.scroll_y.observe(move |y| {
887 let section = geometry.pinned_header(*y, len()).map(|p| p.section);
888 if pinned_section.get() != section {
889 pinned_section.set(section);
890 }
891 });
892 ctx.own_handle(handle);
893 }
894
895 // --- Create scrollbar ---
896 // Skipped when the caller opted out via `show_scrollbar(false)`
897 // — they're expected to mount their own, wired through the
898 // exposed signal accessors.
899 if self.show_scrollbar {
900 let scrollbar = ScrollBar::new(
901 ScrollBarOrientation::Vertical,
902 self.scroll_y.clone(),
903 self.max_scroll_y.clone(),
904 self.viewport_ratio_y.clone(),
905 )
906 .visual(match self.scroll_bar_style {
907 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
908 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
909 ScrollBarMode::Thin => ScrollBarVisual::Thin,
910 });
911 let sb_id = ctx.add(scrollbar);
912 self.scrollbar_id = Some(sb_id);
913 } else {
914 self.scrollbar_id = None;
915 }
916
917 self.child_ids()
918 }
919
920 fn layout_response(
921 &self,
922 proposal: SizeProposal,
923 _ctx: &LayoutContext,
924 ) -> teksilo_core::widget::LayoutResponse {
925 // The viewport takes whatever the parent offers — but only an
926 // allocation is cached for the visible-range computation; a
927 // measurement's fallback is not a viewport (`common::viewport`).
928 crate::common::viewport::viewport_size(
929 proposal,
930 &self.viewport_height,
931 Size::new(300.0, 200.0),
932 )
933 .into()
934 }
935
936 fn place_children(
937 &self,
938 bounds: Rect,
939 _proposal: SizeProposal,
940 children: &mut [WidgetPlacement],
941 _ctx: &LayoutContext,
942 ) {
943 // Cache our own absolute bounds for the keyboard handler's
944 // outer-scroll chase (`ensure_visible`). Done before the empty-children
945 // bail so the rect stays fresh even for an empty list that later fills.
946 self.viewport_bounds.set(bounds);
947 // The allocated height is the authoritative viewport: `build` sizes its
948 // realization window from this, and a stale value there costs a
949 // permanent rebuild loop (`common::viewport`).
950 crate::common::viewport::record_viewport_height(&self.viewport_height, bounds.height);
951 // The rubber band's resistance is a fraction of the viewport. This
952 // view does not band, but the scroller reads the extent either way and
953 // this is the only pass that knows it.
954 self.scroller
955 .borrow_mut()
956 .set_viewport(teksilo_canvas::Vec2::new(bounds.width, bounds.height));
957
958 if children.is_empty() {
959 return;
960 }
961
962 let viewport_height = bounds.height;
963
964 // The scrollbar decision uses the pre-measure total: the content
965 // width must be known before rows can be measured at it. If a
966 // measurement flips the decision, the next frame corrects it.
967 let provisional_total = self.total_content_height();
968 let needs_internal_scrollbar =
969 self.show_scrollbar && provisional_total > viewport_height + 0.5;
970 let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
971 let content_width = if needs_internal_scrollbar && reserves_bar {
972 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
973 } else {
974 bounds.width
975 };
976 self.placed_content_width.set(content_width);
977
978 // Totals for the scrollbar. In auto-measure mode these are computed
979 // BEFORE the pane measures its rows (parent-before-child ordering), so
980 // the pane pokes `layout_refresh` when a measurement moves the total
981 // and we re-place next frame with the corrected value.
982 let total_height = self.total_content_height();
983 let max_y = (total_height - viewport_height).max(0.0);
984 self.max_scroll_y.set(max_y);
985 let ratio = if total_height > 0.0 {
986 (viewport_height / total_height).clamp(0.0, 1.0)
987 } else {
988 1.0
989 };
990 self.viewport_ratio_y.set(ratio);
991 self.clamp_scroll();
992
993 // The pinned header: the current section is decided from the clamped
994 // offset, and handed to the slot when it changed. The scroll observer
995 // in `build` has normally done so already. When it has not (an offset
996 // written during layout) the slot rebuilds next frame, and until it
997 // has, it is not shown, rather than show the previous section's title
998 // over this one's rows.
999 let pinned_shown = self.pinned_header_id.and_then(|_| {
1000 let placement = self
1001 .geometry()
1002 .pinned_header(self.scroll_y.get(), self.source.len());
1003 let section = placement.map(|p| p.section);
1004 if self.pinned_section.get() != section {
1005 self.pinned_section.set(section);
1006 }
1007 let placement = placement?;
1008 (placement.visible && self.pinned_built.get() == Some(placement.section)).then_some(
1009 sections::PinnedShown {
1010 section: placement.section,
1011 top: placement.offset,
1012 bottom: placement.offset + self.section_header_height,
1013 },
1014 )
1015 });
1016 self.pinned_shown.set(pinned_shown);
1017
1018 // The body pane fills the content column and positions its own rows;
1019 // the pinned header lies over its top; the scrollbar sits alongside.
1020 for child in children.iter_mut() {
1021 if Some(child.id) == self.body_pane_id {
1022 child.origin = bounds.origin();
1023 child.size = Size::new(content_width, bounds.height);
1024 } else if Some(child.id) == self.pinned_header_id {
1025 match pinned_shown {
1026 Some(shown) => {
1027 child.origin = Point::new(bounds.x, bounds.y + shown.top);
1028 child.size = Size::new(content_width, self.section_header_height);
1029 }
1030 None => {
1031 child.origin = bounds.origin();
1032 child.size = Size::ZERO;
1033 }
1034 }
1035 } else if Some(child.id) == self.scrollbar_id {
1036 if needs_internal_scrollbar {
1037 child.origin =
1038 Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1039 child.size = Size::new(SCROLLBAR_THICKNESS, bounds.height);
1040 } else {
1041 child.origin = bounds.origin();
1042 child.size = Size::ZERO;
1043 }
1044 }
1045 }
1046 }
1047
1048 fn wants_post_paint(&self) -> bool {
1049 true
1050 }
1051
1052 /// The insertion line and the container focus ring, drawn over the
1053 /// children.
1054 ///
1055 /// Not in `paint`, which runs before them: the pinned section header is
1056 /// an opaque child over the top of the viewport, and would cover the top
1057 /// edge of the ring and a line drawn along or under it. A row that paints
1058 /// its own background covers a line the same way.
1059 fn post_paint(
1060 &self,
1061 bounds: Rect,
1062 canvas: &mut teksilo_canvas::Canvas,
1063 ctx: &teksilo_core::widget::PaintContext,
1064 ) {
1065 // Draw insertion line during drag hover. Recipe-driven role +
1066 // thickness — defaults to BorderRole::Accent / 2 dp; a custom
1067 // `ListContainerStyle` installed via the theme slot overrides.
1068 if let Some((y, width)) = self.drop_feedback.get() {
1069 let recipe = ctx
1070 .theme
1071 .style_slots
1072 .list_container
1073 .as_ref()
1074 .map(|s| s.insertion())
1075 .unwrap_or_default();
1076 let color = recipe.role.resolve(&ctx.theme.colors);
1077 let half = recipe.thickness * 0.5;
1078 // Kept whole inside the viewport rather than clipped at its
1079 // edge: a line at the first or the after-last boundary, or along
1080 // the top edge of the pinned header, would lose half its
1081 // thickness.
1082 let line_y = (bounds.y + y)
1083 .min(bounds.y + bounds.height - half)
1084 .max(bounds.y + half);
1085 canvas.fill_rect(
1086 Rect::new(bounds.x, line_y - half, width, recipe.thickness),
1087 color,
1088 );
1089 }
1090
1091 // Container focus ring — keyboard focus landed but nothing is selected,
1092 // so no row ring shows; outline the whole view (see TreeView).
1093 let has_selection = self
1094 .row_selection
1095 .as_ref()
1096 .is_some_and(|s| s.has_selection());
1097 if self.view_focused.get() && self.focus_visible.get() && !has_selection {
1098 let color = BorderRole::Focused.resolve(&ctx.theme.colors);
1099 let inset = 1.0_f32;
1100 let rect = Rect::new(
1101 bounds.x + inset,
1102 bounds.y + inset,
1103 (bounds.width - inset * 2.0).max(0.0),
1104 (bounds.height - inset * 2.0).max(0.0),
1105 );
1106 canvas.stroke_rect(rect, color, 1.5);
1107 }
1108 }
1109
1110 /// The context-menu key opens the *current row's* menu, not the list's.
1111 ///
1112 /// A `ListView` is focusable and its rows deliberately are not — the
1113 /// container owns focus and `set_selected` is what tells assistive
1114 /// technology which row is current (see `list_item_a11y`). So the
1115 /// dispatcher's default of "the focused widget" would open the list's own
1116 /// menu, in the widget family where a per-row menu matters most.
1117 ///
1118 /// The row the user means is the keyboard cursor if they have navigated
1119 /// (`focused_index`), else the first selected row. Both are indices into
1120 /// the model, and only realized rows have a widget, so a cursor scrolled
1121 /// outside the virtualization window resolves to nothing and the menu falls
1122 /// back to the list — which is the right answer, since there is no row on
1123 /// screen for it to be about.
1124 fn context_menu_key_target(&self) -> Option<WidgetId> {
1125 self.current_row_widget()
1126 }
1127
1128 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1129 if self.presentational {
1130 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
1131 return;
1132 }
1133 builder.set_role(teksilo_core::accesskit::Role::ListBox);
1134 // Whether the selection takes more than one row. A real property on
1135 // both platforms that have one: UIA's `SelectionCanSelectMultiple`
1136 // and AT-SPI's multiselectable state. Left unset it reads false, so a
1137 // multi-select view was telling every screen reader that one row was
1138 // the most it would ever hold.
1139 //
1140 // Gated on the mode, and the gate matters beyond tidiness:
1141 // `accesskit_windows` picks the event it raises on a selection change
1142 // from this property (`adapter.rs:189-199`), firing
1143 // `ElementAddedToSelection` when it is true and `ElementSelected` when
1144 // it is false. A single-select view publishing `true` would trade the
1145 // right event for the wrong one.
1146 if self
1147 .row_selection
1148 .as_ref()
1149 .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
1150 {
1151 builder.set_multiselectable(true);
1152 }
1153
1154 // The logical row count, not the realized virtualization window: a
1155 // 200-row list announces "of 200" even while twenty rows exist as
1156 // widgets. It belongs here rather than on each row, because
1157 // `size_of_set_from_container` resolves an item's set size by walking
1158 // *up* from it — a size written on a row is read by no adapter.
1159 builder.set_size_of_set(self.source.len());
1160
1161 // The current row, as the container's active descendant.
1162 //
1163 // Keyboard focus stays here, on the list, and the row is marked
1164 // `selected`. On AT-SPI that is the whole story: Orca announces the
1165 // selection change. On Windows it is not, because UIA has no
1166 // active-descendant property at all — what it has is a focused
1167 // element, and for a list box that element is the item.
1168 //
1169 // AccessKit bridges the two in the consumer rather than in each
1170 // adapter: `accesskit_consumer` resolves the focused node as
1171 // `focused.active_descendant().unwrap_or(focused)`
1172 // (`tree.rs:541`) and `accesskit_windows::focus_moved`
1173 // (`adapter.rs:341-345`) raises `UIA_AutomationFocusChangedEventId` on
1174 // whatever comes out. So this one property turns every arrow press
1175 // into the focus change a screen reader announces, and `is_focused`
1176 // (`consumer node.rs:89-105`) moves from this container to the row,
1177 // which is what the ARIA listbox pattern says should happen.
1178 //
1179 // Without it, arrowing through any Teksilo list is silent to NVDA:
1180 // there is no focus change to announce, and the selection event that
1181 // is raised names a row node the pane rebuilt a moment earlier. The
1182 // mouse still reads rows correctly, because hit-testing does not go
1183 // through events at all, which is exactly how this hid for so long.
1184 // Only while this view actually holds focus. A container that does not
1185 // have focus has no active descendant to speak of, and publishing one
1186 // anyway puts a second relation in the tree for a client to follow: the
1187 // combobox pattern (`CommandPalette`) keeps focus on a text field that
1188 // points at a row in *this* list, and two publishers of the same row is
1189 // an ambiguity nobody needs to resolve.
1190 if self.view_focused.get()
1191 && let Some(row) = self.current_row_widget()
1192 {
1193 builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(row));
1194 }
1195 }
1196
1197 fn as_any(&self) -> Option<&dyn std::any::Any> {
1198 Some(self)
1199 }
1200
1201 fn children(&self) -> Vec<WidgetId> {
1202 self.child_ids()
1203 }
1204
1205 fn clips_children(&self) -> bool {
1206 true
1207 }
1208}