Skip to main content

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}