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