Skip to main content

teksilo_widgets/
list_view.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ListView — a virtualized, scrollable list backed by a reactive data model.
5//!
6//! `ListView<T>` materializes widget subtrees only for the rows currently
7//! visible in its viewport (plus a configurable buffer). Scrolling and model
8//! changes trigger a localized rebuild that touches only the newly-visible
9//! slice, leaving the rest of the tree untouched. The data source is a
10//! `ListModel<T>` (in-memory, reactive) or any `ListDataSource<Item = T>`
11//! (lazy / external). A delegate closure `(index, &T, selected) -> Box<dyn Widget>`
12//! produces each row widget on demand.
13//!
14//! Row heights come in three modes: **uniform** (`item_height`, the 32 dp
15//! default and fastest path), **exact callback** (`item_height_fn` — pure,
16//! deterministic per-row sizes), and **auto-measured** (`auto_item_height` —
17//! height-for-width measurement with scroll anchoring so content above the
18//! viewport stays put while estimates converge).
19//!
20//! ## When to use
21//!
22//! - Large or dynamically-loaded lists (thousands of rows) — use `ListView`.
23//! - Small, always-all-visible collections — use `Repeater` instead.
24//! - Hierarchical data — use `TreeView`.
25//! - Multi-column tabular data — use `TableView`.
26//!
27//! ## Accessibility
28//!
29//! The widget is `Role::ListBox`; each row is wrapped in
30//! `Role::ListBoxOption` with `set_selected` state. Those are the interactive
31//! ARIA roles — `listbox` / `option` — not the static `list` / `listitem` pair,
32//! because this widget has keyboard navigation and selection.
33//!
34//! Each row publishes its 1-based `position_in_set` **in the model**, and the
35//! container publishes the model's length as `size_of_set`, so a screen reader
36//! says "row 147 of 200" rather than counting the realized window. The count
37//! sits on the container because AccessKit resolves an item's set size by
38//! walking up from it, unlike ARIA's per-item `aria-setsize`.
39//!
40//! The container is the focusable node and rows deliberately are not, so
41//! `set_selected` is the only signal telling assistive technology which row is
42//! current — and the row subtree is kept out of the Tab order, so a control the
43//! delegate puts in a row (the checkbox `StandardListItem` embeds, most often)
44//! never becomes a Tab stop of its own. Such a control publishes a keyboard
45//! toggle instead, which `Space` runs. Full keyboard navigation: arrows, Home,
46//! End, PageUp, PageDown (each moving the selection, or only the cursor when
47//! the accelerator is held), Shift for a range and Ctrl+Shift for an additive
48//! one, Space (checks the row when it carries a checkbox, else select/toggle),
49//! Enter (activate), Ctrl+A / Ctrl+Shift+A (select all /
50//! deselect), Ctrl+Arrow and Ctrl+Space (the disjoint-selection pair),
51//! type-ahead (opt-in via `type_ahead_label`), and Shift+F10 or the Menu key
52//! for the selected row's context menu. On macOS, Cmd+Down opens the focused
53//! row. The chord table and its rationale are in
54//! [docs/data-view-keyboard.md](https://github.com/ferntech-eu/teksilo/blob/main/docs/data-view-keyboard.md).
55//!
56//! ```rust
57//! # use teksilo_widgets::ListView;
58//! # use teksilo_widgets::primitives::TextWidget;
59//! # use teksilo_data::{ListModel, SelectionMode, SelectionModel};
60//! # use teksilo_i18n::lit;
61//! # struct Item { name: String }
62//! # let model: ListModel<Item> = ListModel::from_vec(vec![Item { name: "Alpha".into() }]);
63//! # let sel = SelectionModel::new(SelectionMode::Single);
64//! let _w = ListView::new(model, |_i, item, _selected| {
65//!     Box::new(TextWidget::new(lit!(&item.name)))
66//! })
67//! .item_height(32.0)
68//! .selection(sel);
69//! ```
70
71use std::cell::{Cell, RefCell};
72use std::rc::Rc;
73use std::time::Duration;
74
75use teksilo_canvas::{Point, Rect, Size, SizeProposal};
76use teksilo_tokens::{BorderRole, Easing};
77
78use teksilo_core::DropFeedback;
79use teksilo_core::accessibility::AccessNodeBuilder;
80use teksilo_core::binding::BindingLevel;
81use teksilo_core::drag_payload::DragPayload;
82use teksilo_core::signal::{Prop, Signal};
83use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
84use teksilo_core::widget_builder::HandlerSet;
85use teksilo_core::widget_id::WidgetId;
86
87use teksilo_data::selection_model::SelectionModel;
88use teksilo_data::{ItemKey, KeyedSelectionModel};
89
90use crate::data_views::RowSelection;
91use teksilo_data::{DataChange, DropPosition, DropResponse, ListModel};
92
93// Qualified rather than glob-imported: `data_views::ViewKind` is already in
94// scope here and means something else (which data view a drag came from).
95use crate::common::list_nav;
96use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
97use crate::common::scroll::OverscrollBehavior;
98use crate::data_views::{DragTransferMode, RowDragData, ViewId, ViewKind, flat_insertion_target};
99use crate::list_source::ListSource;
100use crate::scroll_area::ScrollBarMode;
101use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
102
103mod body_pane;
104
105/// Default number of extra items to create above and below the viewport.
106const BUFFER_ITEMS: usize = 5;
107/// Default item height.
108const DEFAULT_ITEM_HEIGHT: f32 = 32.0;
109/// Scrollbar thickness.
110const SCROLLBAR_THICKNESS: f32 = 12.0;
111
112/// A virtualized scrollable list backed by a [`ListModel<T>`](teksilo_data::ListModel) or `ListDataSource`.
113///
114/// See the module-level documentation for the full feature overview.
115pub struct ListView<T: 'static> {
116    source: ListSource<T>,
117    delegate: Rc<dyn Fn(usize, &T, bool) -> Box<dyn Widget>>,
118    /// Per-row tooltip resolvers. Shared with `TreeView`; see
119    /// [`RowTooltips`](crate::data_views::RowTooltips).
120    row_tooltips: crate::data_views::RowTooltips<T>,
121    item_height: f32,
122    spacing: f32,
123    /// Height-mode selection (uniform / exact callback / auto-measure).
124    height_source: HeightSource,
125    /// Row geometry — all virtualization consumers (visible range,
126    /// placement, scrollbar totals, ensure-visible, DnD insertion) go
127    /// through this. Shared handle: cloned into the scroll observer,
128    /// keyboard and DnD closures.
129    metrics: SharedRowMetrics,
130    /// Row selection — index-based [`SelectionModel`] or keyed
131    /// [`KeyedSelectionModel<K>`], unified behind the index-facing facade.
132    row_selection: Option<RowSelection>,
133
134    /// Keyboard-focused item index within the list.
135    focused_index: Rc<Cell<Option<usize>>>,
136
137    /// Shared (model index → row wrapper id) map, written by the body pane at
138    /// the end of every build. Handed out by
139    /// [`realized_row_ids`](Self::realized_row_ids) so a host that keeps focus
140    /// elsewhere — a command palette whose focus stays in its search field —
141    /// can point `active_descendant` at the highlighted row. Mirrors
142    /// `GridView`'s `tile_map`.
143    row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
144
145    /// Type-ahead ("type to jump") label extractor — opt-in via
146    /// [`type_ahead_label`](Self::type_ahead_label). When set, typing a
147    /// printable character jumps the selection to the next row whose label
148    /// starts with the accumulated search term (Qt `keyboardSearch` /
149    /// macOS type-select convention).
150    type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
151    /// Reset window for the type-ahead search term.
152    type_ahead_timeout: Duration,
153    /// Persistent type-ahead buffer — a field (not built in `build`) so the
154    /// accumulated term survives the selection-driven rebuild each
155    /// keystroke triggers.
156    type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
157
158    /// Enable intra-widget drag reordering + keyboard Alt+Arrow.
159    reorderable: bool,
160
161    /// Whether to render an internal vertical scrollbar. When the
162    /// caller wants the scrollbar outside the list — e.g. so it
163    /// survives ListView rebuilds — this is disabled and the caller
164    /// mounts their own, wired through `scroll_y_signal` /
165    /// `max_scroll_y_signal` / `viewport_ratio_y_signal`.
166    show_scrollbar: bool,
167
168    // Persistent state (survives rebuild)
169    scroll_y: Signal<f32>,
170    max_scroll_y: Signal<f32>,
171    /// Scroll-chaining behavior at the boundary (default `Chain`).
172    overscroll_behavior: OverscrollBehavior,
173    viewport_ratio_y: Signal<f32>,
174
175    /// Animate wheel scrolling instead of snapping to the new offset.
176    /// Enabled by default — mirrors `ScrollArea`.
177    smooth_scrolling: bool,
178    /// Duration of the smooth scroll animation.
179    smooth_scroll_duration: Duration,
180
181    /// How the scroll bar is displayed. Defaults to `Permanent` (reserves
182    /// a layout column); `Overlay` / `Thin` float over the content.
183    scroll_bar_style: ScrollBarMode,
184
185    /// Active drop feedback (set by on_drag_hover, cleared by on_drag_leave,
186    /// read by paint). Reactive Signal — bound at `RepaintOnly` so any
187    /// `set(...)` call dirties the ListView for repaint automatically.
188    drop_feedback: Signal<Option<(f32, f32)>>, // (y, width) for insertion line
189    /// Content width (updated during place_children, used by drag feedback).
190    placed_content_width: Rc<Cell<f32>>,
191
192    /// Optional row-activation callback (a click per `activate_on`, or
193    /// Enter/Space on the focused row) — distinct from *selection*, which also
194    /// moves on arrow navigation.
195    on_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
196    /// Whether activation is a single or double click (default `DoubleClick`).
197    activate_on: crate::data_views::ActivateOn,
198
199    /// `true` while the view holds keyboard focus (root's inclusive
200    /// [`BuildContext::view_focus_active`](teksilo_core::BuildContext::view_focus_active) signal). With `focus_visible`, drives
201    /// the **container focus ring** shown when the view is Tab-focused but
202    /// nothing is selected. Bound `RepaintOnly`.
203    view_focused: Signal<bool>,
204    /// Input-modality `:focus-visible` — gates the container ring to keyboard
205    /// navigation. Bound `RepaintOnly`.
206    focus_visible: Signal<bool>,
207
208    /// Root-level **relayout** trigger. The root's own `place_children` owns
209    /// the scrollbar totals (`max_scroll_y`, thumb ratio) and the
210    /// content-width decision, none of which its `build` output depends on —
211    /// so a data change or a pane measurement that moves the content total
212    /// needs a re-place here, not a rebuild. Bumped by the data observer and
213    /// by [`body_pane::ListBodyPane::total_refresh`].
214    layout_refresh: Signal<u64>,
215    /// Root-level **repaint** trigger for the container focus ring, which is
216    /// suppressed as soon as anything is selected. Selection changes rebuild
217    /// the pane (the delegate's `selected` argument) but must not rebuild the
218    /// root — they only change what the root paints.
219    paint_refresh: Signal<u64>,
220
221    /// Pane-local rebuild trigger, owned here so it survives pane rebuilds.
222    /// Bumped by the root's data observer, and by the pane itself on
223    /// scroll-buffer exit, selection change and the post-measure realization
224    /// re-check.
225    pane_version: Signal<u64>,
226    /// Buffered row range materialized by the pane's latest build.
227    pane_built_start: Rc<Cell<usize>>,
228    pane_built_end: Rc<Cell<usize>>,
229
230    // Set during build
231    body_pane_id: Option<WidgetId>,
232    scrollbar_id: Option<WidgetId>,
233    /// Shared so the on_drag_tick closure sees the current viewport
234    /// height when edge-computing its auto-scroll delta. Plain `Cell<f32>`
235    /// clones by value, which would leave the tick closure reading the
236    /// 600 px default forever.
237    viewport_height: Rc<Cell<f32>>,
238    /// The ListView's own absolute (window) bounds, cached from
239    /// `place_children`. The keyboard handler reads it to build the selected
240    /// row's absolute rect and chase it into any *enclosing* scroll area via
241    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
242    /// Rows are not distinct focusable nodes (the view holds focus), so the
243    /// framework's focus-driven follow never reveals the selected row in an
244    /// outer scroller — this closes that gap.
245    viewport_bounds: Rc<Cell<Rect>>,
246
247    /// Stable, kind-tagged ID for this ListView instance (identifies its own
248    /// reorder vs. a foreign drop, even across widget kinds / windows).
249    model_id: ViewId,
250
251    /// Cross-widget export / foreign-receive machinery — the builders
252    /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
253    /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start payload
254    /// build, and the move-out completion, shared by all five data views.
255    export: crate::data_views::RowExport<T>,
256
257    /// Whole-view enabled state, statically or reactively. Forwarded to the
258    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
259    /// time; `enabled_state` is the single source of truth — a disabled
260    /// view greys out and stops accepting focus / selection / keyboard
261    /// input (arena-gated).
262    enabled: Prop<bool>,
263}
264
265impl<T: 'static> ListView<T> {
266    /// Create a new ListView backed by a `ListModel<T>`.
267    ///
268    /// The `delegate` closure receives `(index, &item, selected)` and returns
269    /// a boxed widget for that item.
270    pub fn new(
271        model: ListModel<T>,
272        delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
273    ) -> Self {
274        Self::create(ListSource::from_model(model), delegate)
275    }
276
277    /// Create a ListView backed by a custom `ListDataSource`.
278    ///
279    /// Use this for large or external datasets that cannot fit in memory.
280    /// The source must implement `ListDataSource<Item = T>`.
281    pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>(
282        source: S,
283        delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
284    ) -> Self {
285        Self::create(ListSource::from_data_source(source), delegate)
286    }
287
288    /// Create a ListView backed by a custom `ListDataSource` with **keyed**
289    /// selection. The `KeyedSelectionModel<S::Key>` tracks selection by source
290    /// identity, so it survives reorders, filters, lazy window-slides, and
291    /// stays consistent across two views of the same source. The view stays
292    /// key-less (`ListView<T>`) — the index↔key mapping is captured from the
293    /// concrete source here. Mutually exclusive with
294    /// [`selection`](Self::selection) (the last one set wins).
295    pub fn from_source_keyed<S: teksilo_data::ListDataSource<Item = T>>(
296        source: S,
297        keyed: KeyedSelectionModel<S::Key>,
298        delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
299    ) -> Self
300    where
301        S::Key: ItemKey,
302    {
303        let s = Rc::new(source);
304        let key_at = {
305            let s = s.clone();
306            Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
307        };
308        let len = {
309            let s = s.clone();
310            Rc::new(move || s.len()) as Rc<dyn Fn() -> usize>
311        };
312        // Existence for prune: scan the (cheap, key-only) visible index space —
313        // works for lazy sources too, where keys are known before items load.
314        let contains = {
315            let s = s.clone();
316            Rc::new(move |k: &S::Key| (0..s.len()).any(|i| s.key_at(i).as_ref() == Some(k)))
317                as Rc<dyn Fn(&S::Key) -> bool>
318        };
319        let row_selection = RowSelection::from_keyed(keyed, key_at, len, contains);
320        let mut view = Self::create(ListSource::from_data_source_rc(s), delegate);
321        view.row_selection = Some(row_selection);
322        view
323    }
324
325    /// Create a ListView backed by a pre-built [`ListSource`]. Crate-
326    /// internal entry point for consumers that already own an erased
327    /// source (e.g. `ComboBox`'s `ItemSource` bridged through
328    /// [`ListSource::from_cloning_accessors`]).
329    pub(crate) fn from_list_source(
330        source: ListSource<T>,
331        delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
332    ) -> Self {
333        Self::create(source, delegate)
334    }
335
336    fn create(
337        source: ListSource<T>,
338        delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
339    ) -> Self {
340        let model_id = ViewId::next(ViewKind::List);
341        Self {
342            model_id,
343            export: crate::data_views::RowExport::default(),
344            source,
345            delegate: Rc::new(delegate),
346            row_tooltips: Default::default(),
347            item_height: DEFAULT_ITEM_HEIGHT,
348            spacing: 0.0,
349            height_source: HeightSource::Uniform,
350            metrics: Rc::new(RefCell::new(RowMetrics::uniform(DEFAULT_ITEM_HEIGHT, 0.0))),
351            row_selection: None,
352            focused_index: Rc::new(Cell::new(None)),
353            row_map: Rc::new(RefCell::new(Vec::new())),
354            type_ahead_label: None,
355            type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
356            type_ahead: crate::common::type_ahead::TypeAheadState::new(),
357            reorderable: false,
358            show_scrollbar: true,
359            drop_feedback: Signal::new(None),
360            // Replaced at build with the live tree signals.
361            view_focused: Signal::new(false),
362            focus_visible: Signal::new(false),
363            placed_content_width: Rc::new(Cell::new(0.0)),
364            on_activate: None,
365            activate_on: crate::data_views::ActivateOn::default(),
366            overscroll_behavior: OverscrollBehavior::default(),
367            smooth_scrolling: true,
368            smooth_scroll_duration: Duration::from_millis(150),
369            scroll_bar_style: ScrollBarMode::Permanent,
370            scroll_y: Signal::new_animated(0.0),
371            max_scroll_y: Signal::new(0.0),
372            viewport_ratio_y: Signal::new(1.0),
373            layout_refresh: Signal::new(0_u64),
374            paint_refresh: Signal::new(0_u64),
375            pane_version: Signal::new(0_u64),
376            pane_built_start: Rc::new(Cell::new(0)),
377            pane_built_end: Rc::new(Cell::new(0)),
378            body_pane_id: None,
379            scrollbar_id: None,
380            viewport_height: Rc::new(Cell::new(600.0)),
381            viewport_bounds: Rc::new(Cell::new(Rect::ZERO)),
382            enabled: Prop::Static(true),
383        }
384    }
385
386    /// Enable or disable the whole view. A disabled view greys out and stops
387    /// accepting focus / selection / keyboard input (arena-gated).
388    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
389        self.enabled = enabled.into();
390        self
391    }
392
393    /// Set the scroll-chaining behavior at the boundary (default
394    /// [`OverscrollBehavior::Chain`]; [`Contain`](OverscrollBehavior::Contain)
395    /// disables chaining to an ancestor scrollable).
396    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
397        self.overscroll_behavior = behavior;
398        self
399    }
400
401    /// Enable or disable animated wheel scrolling (enabled by default).
402    pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
403        self.smooth_scrolling = enabled;
404        self
405    }
406
407    /// Duration of the smooth scroll animation (default 150 ms).
408    pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
409        self.smooth_scroll_duration = duration;
410        self
411    }
412
413    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
414    /// and `Thin` float the bar over the content instead of reserving a
415    /// layout column, mirroring `ScrollArea::scroll_bar_style`.
416    pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
417        self.scroll_bar_style = style;
418        self
419    }
420
421    /// Re-materialize `self.metrics` after a height-mode / item-height /
422    /// spacing builder call, keeping the three order-independent.
423    fn remake_metrics(&self) {
424        *self.metrics.borrow_mut() = self
425            .height_source
426            .make_metrics(self.item_height, self.spacing);
427    }
428
429    /// Set the fixed height per item (default 32.0) — the uniform fast
430    /// path. Mutually exclusive with [`item_height_fn`](Self::item_height_fn)
431    /// and [`auto_item_height`](Self::auto_item_height); the last mode
432    /// setter wins.
433    pub fn item_height(mut self, height: f32) -> Self {
434        self.item_height = height;
435        self.height_source = HeightSource::Uniform;
436        self.remake_metrics();
437        self
438    }
439
440    /// Per-item heights from a callback. The callback must be pure (same
441    /// index + same data → same height); it is re-swept from the first
442    /// changed index on every model change. No measurement pass runs —
443    /// this is the deterministic variable-height path.
444    pub fn item_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
445        self.height_source = HeightSource::Exact(Rc::new(f));
446        self.remake_metrics();
447        self
448    }
449
450    /// Auto-measured item heights: each realized row is measured at the
451    /// list's content width (height-for-width), unrealized rows assume
452    /// `estimated`. Scroll anchoring keeps content above the viewport
453    /// stationary as estimates are corrected. `estimated` should be a
454    /// typical row height — a wrong estimate only costs realization
455    /// churn while measurements settle, never incorrect layout.
456    pub fn auto_item_height(mut self, estimated: f32) -> Self {
457        self.height_source = HeightSource::Auto { estimated };
458        self.remake_metrics();
459        self
460    }
461
462    /// Set spacing between items (default 0.0).
463    pub fn spacing(mut self, spacing: f32) -> Self {
464        self.spacing = spacing;
465        self.remake_metrics();
466        self
467    }
468
469    /// Set the index-based selection model (positions). For identity-based
470    /// selection that survives reorder / filter / window-slide, build the view
471    /// with [`from_source_keyed`](Self::from_source_keyed) instead.
472    pub fn selection(mut self, sel: SelectionModel) -> Self {
473        self.row_selection = Some(RowSelection::from_index(sel));
474        self
475    }
476
477    /// Keep the row the keyboard is on inside the realized window.
478    ///
479    /// Only the rows near the viewport are realized, so a current row far from
480    /// the scroll offset frequently has **no widget**. Everything that speaks
481    /// for it then has nothing to speak about: no node carries `selected`,
482    /// [`Self::current_row_widget`] resolves to `None` so no active descendant
483    /// is nominated, and a screen reader is told nothing. The first arrow press
484    /// steps *past* that row as well, because the cursor was somewhere nobody
485    /// was shown.
486    ///
487    /// Two triggers, and the second is not redundant. Revealing only on focus
488    /// misses the common case where the selection is made *from inside* a focus
489    /// handler — a list that lands on "whatever is happening now" the first
490    /// time it is reached does exactly that, so the reveal would run first,
491    /// find nothing selected, and do nothing. Reacting to the selection as well
492    /// covers that, and covers any later programmatic selection too.
493    ///
494    /// `ensure_index_visible` arithmetic rather than `scroll_to_index`: a row
495    /// already on screen must not jump under somebody who can see it.
496    ///
497    /// The handles are cloned into the closures rather than reached through
498    /// `self`, which they cannot borrow. A caller could not do this from
499    /// outside in any case: the handles are private, and
500    /// `with_widget_mut::<ListView<_>>` cannot reach the widget either, since
501    /// this type overrides `as_any` and not `as_any_mut`.
502    fn reveal_current_row_on_focus(&self, ctx: &mut teksilo_core::build_context::BuildContext) {
503        let metrics = self.metrics.clone();
504        let scroll_y = self.scroll_y.clone();
505        let viewport_height = self.viewport_height.clone();
506        let max_scroll_y = self.max_scroll_y.clone();
507        let focused_index = self.focused_index.clone();
508        let selection = self.row_selection.clone();
509
510        let reveal: Rc<dyn Fn()> = Rc::new(move || {
511            let Some(index) = focused_index.get().or_else(|| {
512                selection
513                    .as_ref()
514                    .and_then(|s| s.selected_indices().first().copied())
515            }) else {
516                return;
517            };
518            let current = scroll_y.get();
519            let target = metrics.borrow_mut().scroll_for_ensure_visible(
520                index,
521                current,
522                viewport_height.get(),
523                max_scroll_y.get(),
524            );
525            if (target - current).abs() > f32::EPSILON {
526                scroll_y.set(target);
527            }
528        });
529
530        let on_focus = reveal.clone();
531        ctx.effect(&self.view_focused, move |focused| {
532            if *focused {
533                on_focus();
534            }
535        });
536
537        if let Some(sel) = self.row_selection.as_ref() {
538            let on_select = reveal;
539            let focused = self.view_focused.clone();
540            let handle = sel.observe_for_rebuild(move || {
541                // Only while this view has focus. A selection driven from
542                // elsewhere — a combobox highlighting rows in a list the user
543                // is not in — must not scroll the view under them.
544                if focused.get() {
545                    on_select();
546                }
547            });
548            ctx.own_handle(handle);
549        }
550    }
551
552    /// A shared handle to the live `(model index → row node id)` map of the
553    /// **realized** rows, rewritten at the end of every build.
554    ///
555    /// The id is the row's `Role::ListBoxOption` wrapper — the node an
556    /// `active_descendant` has to point at. Take the handle before moving the
557    /// view into the tree; it is populated on the first build.
558    ///
559    /// This exists for the ARIA combobox / listbox pattern, where keyboard
560    /// focus stays on a *text field* while the arrow keys move a highlight
561    /// through this list (a command palette, a type-ahead picker). The field's
562    /// AT node publishes `active_descendant` pointing here, so a screen reader
563    /// announces each row as the highlight moves without focus ever leaving
564    /// the input.
565    ///
566    /// A `ListView` that holds focus itself does **not** need this handle: it
567    /// publishes its own `active_descendant` from `accessibility()`, pointing
568    /// at whichever row the keyboard is on. It used to publish none, on the
569    /// assumption that holding focus was enough, and that assumption is what
570    /// made every Teksilo list silent to NVDA.
571    ///
572    /// Only realized rows are present — a row scrolled outside the
573    /// virtualization window has no widget, so look-ups for it return `None`.
574    /// Callers should `scroll_to_index` the row they intend to announce.
575    pub fn realized_row_ids(&self) -> Rc<RefCell<Vec<(usize, WidgetId)>>> {
576        self.row_map.clone()
577    }
578
579    /// The realized row the keyboard is on: the navigation cursor when there
580    /// is one, else the first selected row.
581    ///
582    /// `None` when that row is outside the virtualization window, which is the
583    /// honest answer — there is no widget for it, so there is no node to point
584    /// at and nothing on screen for a menu or an announcement to be about.
585    fn current_row_widget(&self) -> Option<WidgetId> {
586        let index = self.focused_index.get().or_else(|| {
587            self.row_selection
588                .as_ref()
589                .and_then(|s| s.selected_indices().first().copied())
590        })?;
591        let map = self.row_map.borrow();
592        map.iter()
593            .find(|(i, _)| *i == index)
594            .map(|(_, widget)| *widget)
595    }
596
597    /// Enable intra-widget drag reordering.
598    ///
599    /// When enabled, rows can be dragged within this ListView to reorder them.
600    /// The move is routed through the source's `accept_drop` — a `ListModel`
601    /// reorders in place, an external source routes the move to its store. The
602    /// hover indicator reflects the source's `can_accept` verdict, so a
603    /// forbidden drop shows no insertion line. Keyboard equivalent:
604    /// Alt+ArrowUp/Down.
605    pub fn reorderable(mut self, enabled: bool) -> Self {
606        self.reorderable = enabled;
607        self
608    }
609
610    /// Make rows **droppable outside this view** — on a
611    /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
612    ///
613    /// A dragged row (or the whole selection, when the pressed row is part of a
614    /// multi-selection) carries clones of its items in a public
615    /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
616    /// them out with `payload.get_typed::<RowDragData<T>>()` /
617    /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
618    /// also makes rows a drag source even without [`reorderable`](Self::reorderable).
619    ///
620    /// `mode` chooses what happens to the origin rows once a *foreign* target
621    /// accepts them: [`DragTransferMode::Move`] removes them (via the source's
622    /// `on_drag_out`, or [`on_rows_transferred_out`](Self::on_rows_transferred_out)),
623    /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
624    /// transfer, so `mode` never affects it. Requires `T: Clone`.
625    ///
626    /// **Move caveats.** The row is removed only when the drop is accepted by an
627    /// in-app target *in the same window* (`DropOutcome::InApp { accepted: true }`)
628    /// or the OS reports a genuine move. Shipped OS backends advertise **copy
629    /// only**, so a drag exported to another application — or to another window
630    /// of the same app — is treated as a *copy*: the origin row is kept and the
631    /// receiver must own its own copy semantics. Also, for a `ListModel`-backed
632    /// view (whose key *is* the row index) the move-out removes by the indices
633    /// captured at drag-start; if a shared handle to the same model is mutated
634    /// while the drag is in flight, those indices can point at different rows —
635    /// use a keyed source, or [`on_rows_transferred_out`](Self::on_rows_transferred_out)
636    /// with your own stable identity, for models that change mid-drag.
637    pub fn exportable(mut self, mode: DragTransferMode) -> Self
638    where
639        T: Clone,
640    {
641        self.export.set_exportable(mode);
642        self
643    }
644
645    /// Additionally advertise the dragged rows as MIME data so they can be
646    /// dropped on a [`DropZone`](crate::DropZone) or exported to another
647    /// application / window via the OS. `f` maps the dragged items to
648    /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
649    /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
650    /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
651    /// `T: Clone`.
652    pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
653    where
654        T: Clone,
655    {
656        self.export.set_export_external(f);
657        self
658    }
659
660    /// Override how rows moved out to a foreign target are removed from this
661    /// view. Receives the dragged rows' indices (descending-safe) and the live
662    /// context. Without this, an [`exportable`](Self::exportable)
663    /// [`Move`](DragTransferMode::Move) drag removes them through the source's
664    /// `on_drag_out` (works out of the box for a `ListModel`).
665    pub fn on_rows_transferred_out(
666        mut self,
667        f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
668    ) -> Self {
669        self.export.set_on_rows_transferred_out(f);
670        self
671    }
672
673    /// Accept exported rows dropped from a **different** view or source without
674    /// writing a custom `ListDataSource`. Pair with
675    /// [`on_rows_received`](Self::on_rows_received), which is handed the dropped
676    /// items and the insertion index. (Same-view reorder is
677    /// [`reorderable`](Self::reorderable); a custom `ListDataSource` can still
678    /// accept foreign drops through its `can_accept`/`accept_drop` instead.)
679    pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
680        self.export.accept_foreign_rows = accept;
681        self
682    }
683
684    /// Handler for rows accepted via [`accept_foreign_rows`](Self::accept_foreign_rows):
685    /// `(items, insertion_index, ctx)`. Insert them into your model at the
686    /// index.
687    pub fn on_rows_received(
688        mut self,
689        f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
690    ) -> Self {
691        self.export.set_on_rows_received(f);
692        self
693    }
694
695    /// Set the row-**activation** handler — invoked with the flat row index and
696    /// the live [`EventContext`](teksilo_core::widget::EventContext) on a click
697    /// (per [`activate_on`](Self::activate_on)) or **Enter** on the focused row.
698    /// The context lets the handler open a modal, toast, or dispatch an intent —
699    /// matching [`TableView::on_row_activate`](crate::TableView::on_row_activate)
700    /// / [`GridView::on_tile_activate`](crate::GridView::on_tile_activate).
701    /// Distinct from *selection*: arrow-key navigation and **Space** move /
702    /// toggle the selection but do **not** activate.
703    pub fn on_activate(
704        mut self,
705        f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
706    ) -> Self {
707        self.on_activate = Some(Rc::new(f));
708        self
709    }
710
711    /// Choose single- vs double-click activation (default
712    /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter activates in
713    /// either mode.
714    pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
715        self.activate_on = mode;
716        self
717    }
718
719    /// Enable **type-ahead** ("type to jump"): with this set, typing a
720    /// printable character while the list has keyboard focus jumps the
721    /// selection to the next row whose label starts with the accumulated
722    /// search term, wrapping around (Qt `keyboardSearch` / macOS &
723    /// Windows type-select). `label(&item)` yields the searchable text for
724    /// a row; matching is ASCII-case-insensitive. A pause longer than the
725    /// [`type_ahead_timeout`](Self::type_ahead_timeout) starts a fresh term.
726    /// Whether a composite row tooltip offers dwell-to-sticky promotion.
727    /// Default `true`.
728    ///
729    /// Turn it off for a read-only row card: with nothing to reach into there
730    /// is nothing to pin, so the countdown indicator would promise an
731    /// interaction that does not exist and the surface would outlive the
732    /// pointer for no reason.
733    pub fn row_tooltip_sticky(mut self, on: bool) -> Self {
734        self.row_tooltips.set_composite_sticky(on);
735        self
736    }
737
738    /// Per-row plain tooltip: one line of text for the row under the pointer.
739    ///
740    /// The resolver receives the row's flat index and its item; returning
741    /// `None` leaves that row without a tip. Mutually exclusive with
742    /// [`row_rich_tooltip`](Self::row_rich_tooltip) and
743    /// [`row_composite_tooltip`](Self::row_composite_tooltip) — last setter
744    /// wins, matching the per-widget tooltip matrix.
745    ///
746    /// Opens to the row's trailing side, never below it: rows stack
747    /// vertically, so a tip below would cover the next row.
748    pub fn row_tooltip(
749        mut self,
750        f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static,
751    ) -> Self {
752        self.row_tooltips.set_plain(f);
753        self
754    }
755
756    /// Per-row rich tooltip — a registry key or inline
757    /// [`TooltipContent`](crate::tooltip::TooltipContent). See
758    /// [`row_tooltip`](Self::row_tooltip) for the shared semantics.
759    pub fn row_rich_tooltip(
760        mut self,
761        f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static,
762    ) -> Self {
763        self.row_tooltips.set_rich(f);
764        self
765    }
766
767    /// Per-row composite tooltip — an arbitrary widget tree describing the row.
768    ///
769    /// The body is built for every **realized** row (the virtualization window)
770    /// and rebuilt with it, so keep the resolver cheap and defer anything
771    /// costly to the body's own first paint, which only runs if the tip is
772    /// actually shown. See [`row_tooltip`](Self::row_tooltip) for the rest.
773    pub fn row_composite_tooltip(
774        mut self,
775        f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static,
776    ) -> Self {
777        self.row_tooltips.set_composite(f);
778        self
779    }
780
781    pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
782        self.type_ahead_label = Some(Rc::new(label));
783        self
784    }
785
786    /// Reset window between keystrokes before the type-ahead search term
787    /// clears (default 500 ms). A zero duration disables type-ahead.
788    pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
789        self.type_ahead_timeout = timeout;
790        self
791    }
792
793    /// Suppress the internal scroll bar. Use when the caller wants to
794    /// mount its own `ScrollBar` outside the ListView (keeping it alive
795    /// across rebuilds so a thumb drag isn't torn down when the visible
796    /// range shifts past the buffer). The caller is expected to wire
797    /// the external bar up to the signals returned by
798    /// [`scroll_y_signal`](Self::scroll_y_signal),
799    /// [`max_scroll_y_signal`](Self::max_scroll_y_signal) and
800    /// [`viewport_ratio_y_signal`](Self::viewport_ratio_y_signal).
801    pub fn show_scrollbar(mut self, show: bool) -> Self {
802        self.show_scrollbar = show;
803        self
804    }
805
806    /// Total content height (all items + spacing).
807    fn total_content_height(&self) -> f32 {
808        self.metrics.borrow_mut().total_height(self.source.len())
809    }
810
811    /// Compute the visible range of model indices for the current scroll and viewport.
812    fn visible_range(&self) -> (usize, usize) {
813        self.metrics.borrow_mut().visible_range(
814            self.scroll_y.get(),
815            self.viewport_height.get(),
816            self.source.len(),
817            BUFFER_ITEMS,
818        )
819    }
820
821    /// The root's children, in the one order `build`, `children` and
822    /// `place_children` all rely on: body pane first, scrollbar second.
823    /// The pane is always mounted (an empty list realizes zero rows inside
824    /// it), so the scrollbar's index only shifts with `show_scrollbar`.
825    fn child_ids(&self) -> Vec<WidgetId> {
826        [self.body_pane_id, self.scrollbar_id]
827            .into_iter()
828            .flatten()
829            .collect()
830    }
831
832    /// Clamp scroll_y to valid range.
833    fn clamp_scroll(&self) {
834        let max = self.max_scroll_y.get();
835        let current = self.scroll_y.get();
836        let clamped = current.clamp(0.0, max);
837        if (clamped - current).abs() > 0.001 {
838            self.scroll_y.set(clamped);
839        }
840    }
841
842    /// Test-only accessor: the reactive drop-feedback signal. `Some((y, w))`
843    /// while a compatible drag hovers, `None` once the drag leaves or ends.
844    #[cfg(test)]
845    pub(crate) fn drop_feedback_signal(&self) -> &Signal<Option<(f32, f32)>> {
846        &self.drop_feedback
847    }
848
849    /// The current vertical scroll offset, in logical pixels. Drives the
850    /// viewport position and the scroll bar thumb. Exposed so external
851    /// logic (e.g. a parent widget implementing custom scroll-into-view)
852    /// can read or drive the scroll directly — prefer
853    /// [`scroll_to_index`](Self::scroll_to_index) /
854    /// [`ensure_index_visible`](Self::ensure_index_visible) when possible.
855    pub fn scroll_y_signal(&self) -> &Signal<f32> {
856        &self.scroll_y
857    }
858
859    /// The maximum scroll offset, `content_height - viewport_height`.
860    /// Updated during layout. Exposed for callers that mount their own
861    /// external scrollbar via [`show_scrollbar(false)`](Self::show_scrollbar).
862    pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
863        &self.max_scroll_y
864    }
865
866    /// The vertical viewport-to-content ratio (0.0..1.0). Drives the
867    /// thumb size on any external scrollbar.
868    pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
869        &self.viewport_ratio_y
870    }
871
872    /// Scroll so the given model index is aligned to the top of the
873    /// viewport. Clamped to the valid scroll range. Safe to call before
874    /// the ListView has been laid out — the clamp will kick in on the
875    /// first layout pass.
876    pub fn scroll_to_index(&self, index: usize) {
877        let target = self.metrics.borrow_mut().row_top(index);
878        let max = self.max_scroll_y.get();
879        self.scroll_y.set(target.clamp(0.0, max));
880    }
881
882    /// Scroll the minimum distance needed to bring the given model
883    /// index fully into the viewport. No-op if already visible.
884    pub fn ensure_index_visible(&self, index: usize) {
885        let scroll = self.scroll_y.get();
886        let new_scroll = self.metrics.borrow_mut().scroll_for_ensure_visible(
887            index,
888            scroll,
889            self.viewport_height.get(),
890            self.max_scroll_y.get(),
891        );
892        if (new_scroll - scroll).abs() > f32::EPSILON {
893            self.scroll_y.set(new_scroll);
894        }
895    }
896}
897
898impl<T: 'static> std::fmt::Debug for ListView<T> {
899    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
900        f.debug_struct("ListView")
901            .field("item_count", &self.source.len())
902            .field("item_height", &self.item_height)
903            .field("scroll_bar_style", &self.scroll_bar_style)
904            .field("scroll_y", &self.scroll_y.get())
905            .finish()
906    }
907}
908
909impl<T: 'static> Widget for ListView<T> {
910    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
911        // The root builds exactly two children — the body pane and the
912        // scrollbar — and neither depends on the data, the selection or the
913        // scroll offset. So it declares no `Rebuild`-level binding at all:
914        // row realization is the pane's job (see `body_pane`'s module docs
915        // for why that separation is load-bearing and not just tidy), and
916        // what the root still owns resolves at `Relayout` / `RepaintOnly`.
917        let self_id = ctx.self_id();
918        ctx.enabled_when(self_id, self.enabled.clone());
919
920        // Scrollbar totals + the content-width decision live in the root's
921        // `place_children`; a data change or a pane measurement that moves
922        // the content total re-places the root through this.
923        self.layout_refresh.bind_to(
924            ctx.self_id(),
925            ctx.binding_registry(),
926            BindingLevel::Relayout,
927        );
928        // Container focus ring: painted only while nothing is selected, so a
929        // selection change has to reach the root's paint — without rebuilding
930        // it and taking the scrollbar down with it.
931        self.paint_refresh.bind_to(
932            ctx.self_id(),
933            ctx.binding_registry(),
934            BindingLevel::RepaintOnly,
935        );
936
937        // Bind scroll_y at Relayout so place_children runs on every scroll
938        // position change (re-clamps and refreshes the thumb) without a
939        // rebuild. The pane holds the matching binding for its rows.
940        self.scroll_y.bind_to(
941            ctx.self_id(),
942            ctx.binding_registry(),
943            BindingLevel::Relayout,
944        );
945
946        // Register animated signal for smooth scrolling. Deliberately the
947        // ROOT and only the root: the scheduler keys an animation to the
948        // widget that registered its signal last and cancels it when that
949        // widget rebuilds, so registering from the pane too would make every
950        // buffer-exit rebuild abort an in-flight fling.
951        ctx.register_animated_signal(&self.scroll_y);
952
953        // Bind drop_feedback at RepaintOnly so `set(...)` calls from
954        // on_drag_hover / on_drag_leave dirty the ListView's paint cache
955        // without triggering a rebuild.
956        self.drop_feedback.bind_to(
957            ctx.self_id(),
958            ctx.binding_registry(),
959            BindingLevel::RepaintOnly,
960        );
961
962        // Focus signals for the container ring (see TreeView). `RepaintOnly` so
963        // focus-in/out redraws; selection-emptiness changes arrive on
964        // `paint_refresh`. `begin_view_focus` keys the scope signal on this root id directly,
965        // independent of the arena focusable flag (not yet wired at this point):
966        // a plain `view_focus_active()` would `find_focusable_at_or_above`
967        // nothing and fall back to the constant-`true` "outside any scope"
968        // signal — lighting the ring whenever ANY other widget takes keyboard
969        // focus. Pop straight back; the real row scope below resolves the same
970        // cached signal.
971        self.view_focused = ctx.begin_view_focus();
972        ctx.end_view_focus();
973        self.focus_visible = ctx.focus_visible();
974        self.reveal_current_row_on_focus(ctx);
975        self.view_focused.bind_to(
976            ctx.self_id(),
977            ctx.binding_registry(),
978            BindingLevel::RepaintOnly,
979        );
980        self.focus_visible.bind_to(
981            ctx.self_id(),
982            ctx.binding_registry(),
983            BindingLevel::RepaintOnly,
984        );
985
986        // --- Observe model changes ---
987        // One observer, root-owned, doing the bookkeeping the pane can't
988        // (metrics divergence, selection shift, keyboard cursor) and then
989        // fanning out: rebuild the pane (row content changed) and re-place
990        // the root (the content total, hence the thumb, changed).
991        let pane_version_for_data = self.pane_version.clone();
992        let layout_refresh_for_data = self.layout_refresh.clone();
993        let data_ver = Rc::new(Cell::new(0_u64));
994        let data_handle = (self.source.observe_fn)(Box::new({
995            let dv = data_ver.clone();
996            let metrics = self.metrics.clone();
997            let len_fn = self.source.len_fn.clone();
998            let first_changed = self.source.first_changed_fn.clone();
999            let row_sel = self.row_selection.clone();
1000            let focused = self.focused_index.clone();
1001            move |change| {
1002                // Keep row metrics in step with the data: rows before
1003                // the first changed index keep their (seeded or
1004                // measured) heights, the rest re-derive.
1005                let divergence = match change {
1006                    DataChange::ItemsInserted { range } | DataChange::ItemsRemoved { range } => {
1007                        Some(range.start)
1008                    }
1009                    DataChange::ItemUpdated { index } => Some(*index),
1010                    DataChange::ItemsMoved { from, to, .. } => Some((*from).min(*to)),
1011                    // A lazy window load makes rows from range.start onward differ.
1012                    DataChange::WindowLoaded { range } => Some(range.start),
1013                    // Reset-emitting proxies (SortFilterListModel) expose
1014                    // their real divergence through the side-channel.
1015                    DataChange::Reset => (first_changed)(),
1016                };
1017                metrics
1018                    .borrow_mut()
1019                    .apply_divergence(divergence, (len_fn)());
1020                // Keep selection in step: index-shift (index model) or prune
1021                // orphaned keys (keyed model).
1022                if let Some(ref rs) = row_sel {
1023                    rs.on_data_change(change);
1024                }
1025                // Keep the keyboard-navigation anchor in step too — otherwise
1026                // it silently points at the wrong row after any insert /
1027                // remove / move (reachable not just from local edits but
1028                // from a live watcher pushing in a peer process's write).
1029                if let Some(current) = focused.get() {
1030                    focused.set(teksilo_data::data_change::adjust_single_index_for_change(
1031                        current, change,
1032                    ));
1033                }
1034                let next = dv.get() + 1;
1035                dv.set(next);
1036                pane_version_for_data.set(next);
1037                layout_refresh_for_data.set(next);
1038            }
1039        }));
1040        ctx.own_handle(data_handle);
1041
1042        // --- Observe selection changes ---
1043        // The pane runs its own selection observer for the delegate's
1044        // `selected` argument; the root only needs its container focus ring
1045        // repainted, since that ring is suppressed once anything is selected.
1046        if let Some(ref rs) = self.row_selection {
1047            let paint_refresh_for_sel = self.paint_refresh.clone();
1048            let sel_ver = Rc::new(Cell::new(0_u64));
1049            let handle = rs.observe_for_rebuild(move || {
1050                let next = sel_ver.get() + 1;
1051                sel_ver.set(next);
1052                paint_refresh_for_sel.set(next);
1053            });
1054            ctx.own_handle(handle);
1055        }
1056
1057        // Scroll-buffer exit is deliberately NOT observed here. It rebuilds
1058        // the body pane and nothing else — the root's own children are
1059        // unaffected by which rows are realized, and a root rebuild during a
1060        // scrollbar thumb drag is exactly the one the framework defers.
1061
1062        // --- Set up scroll event handler + DnD handlers on self ---
1063        let scroll_y = self.scroll_y.clone();
1064        let max_scroll = self.max_scroll_y.clone();
1065        let line_height = self.item_height;
1066        let overscroll_behavior = self.overscroll_behavior;
1067        let smooth_scrolling = self.smooth_scrolling;
1068        let smooth_scroll_duration = self.smooth_scroll_duration;
1069        let mut handlers = HandlerSet::new()
1070            .on_scroll(move |event, _ctx| match event {
1071                teksilo_core::event::WidgetEvent::Scroll { delta, .. } => {
1072                    let dy = match delta {
1073                        teksilo_core::event::ScrollDelta::Lines { y, .. } => y * line_height,
1074                        teksilo_core::event::ScrollDelta::Pixels { y, .. } => *y,
1075                    };
1076                    let current = scroll_y.get();
1077                    let max = max_scroll.get();
1078                    // Base off the animation target so successive notches
1079                    // accumulate instead of restarting mid-animation.
1080                    let base = scroll_y.animation_target().unwrap_or(current);
1081                    let (new_y, moved) = crate::common::scroll::scroll_clamp_axis(base, dy, max);
1082                    if moved {
1083                        if smooth_scrolling {
1084                            scroll_y.animate_to(new_y, smooth_scroll_duration, Easing::EaseOut);
1085                        } else {
1086                            scroll_y.set(new_y);
1087                        }
1088                    }
1089                    // Chain to an ancestor scrollable when fully clamped
1090                    // (unless Contain), otherwise consume.
1091                    crate::common::scroll::scroll_response(
1092                        moved,
1093                        overscroll_behavior == OverscrollBehavior::Contain,
1094                    )
1095                }
1096                _ => teksilo_core::event::EventResponse::Ignored,
1097            })
1098            .clips_children(true)
1099            .focusable(true);
1100
1101        // --- Keyboard navigation + Alt+Arrow reorder ---
1102        {
1103            let len_for_key = self.source.len_fn.clone();
1104            let accept_drop_for_key = self.source.dnd.accept_drop_fn.clone();
1105            let stash_for_key = self.source.dnd.stash_drag_keys_fn.clone();
1106            let view_id_for_key = self.model_id;
1107            let sel_for_key = self.row_selection.clone();
1108            let activate_key = self.on_activate.clone();
1109            let fi = self.focused_index.clone();
1110            let reorderable = self.reorderable;
1111            let scroll_for_nav = self.scroll_y.clone();
1112            let metrics_for_nav = self.metrics.clone();
1113            let max_for_nav = self.max_scroll_y.clone();
1114            let vh_for_nav = self.viewport_height.clone();
1115            let vb_for_nav = self.viewport_bounds.clone();
1116            // Type-ahead state + label resolver (reads row text via the
1117            // source's string accessor, so lazy/unloaded rows are skipped).
1118            let ta_state = self.type_ahead.clone();
1119            // Index → realized row id, so `Space` can ask the row whether it
1120            // publishes a keyboard toggle (a checkbox) before falling back to
1121            // the selection.
1122            let row_map_for_key = self.row_map.clone();
1123            let ta_label = self.type_ahead_label.clone();
1124            let ta_timeout = self.type_ahead_timeout;
1125            let with_item_str = self.source.with_item_str_fn.clone();
1126
1127            handlers = handlers.on_key(move |event, ctx| {
1128                if let teksilo_core::event::WidgetEvent::KeyDown { key, modifiers, .. } = event {
1129                    use teksilo_core::event::Key;
1130                    let count = (len_for_key)();
1131                    if count == 0 {
1132                        return teksilo_core::event::EventResponse::Ignored;
1133                    }
1134
1135                    // Select all — Ctrl+A, ⌘A on macOS (Multi selection only;
1136                    // a no-op for Single / None, matching every list control).
1137                    // With Shift it deselects instead: GTK is the only toolkit
1138                    // that *mandates* Ctrl+Shift+A, but the ARIA listbox and
1139                    // tree patterns both sanction an unselect-all, Windows and
1140                    // Qt simply have none, and adding it takes nothing away.
1141                    if modifiers.command() && matches!(key, Key::A) {
1142                        if let Some(ref sel) = sel_for_key
1143                            && sel.mode() == teksilo_data::SelectionMode::Multi
1144                        {
1145                            if modifiers.shift() {
1146                                sel.clear();
1147                            } else {
1148                                sel.select_all(count);
1149                            }
1150                            return teksilo_core::event::EventResponse::Handled;
1151                        }
1152                        return teksilo_core::event::EventResponse::Ignored;
1153                    }
1154
1155                    // macOS reads a couple of chords in a list that the other
1156                    // desktops spend elsewhere, and both are dead here
1157                    // otherwise. ⌘↓ opens the row — Finder's "Command–Down
1158                    // Arrow: Open the selected item", and what VS Code binds as
1159                    // `list.select`'s macOS secondary. (⌘↑ ascends to the
1160                    // parent, which a flat list has none of; `TreeView` claims
1161                    // it.) Off macOS this resolves to `None` and costs nothing.
1162                    if let Some(alias) = list_nav::mac_alias(*key, *modifiers, ctx.is_rtl()) {
1163                        if alias == list_nav::MacAlias::Activate {
1164                            let row = fi
1165                                .get()
1166                                .or_else(|| {
1167                                    sel_for_key
1168                                        .as_ref()
1169                                        .and_then(|s| s.selected_indices().first().copied())
1170                                })
1171                                .unwrap_or(0)
1172                                .min(count - 1);
1173                            if let Some(ref sel) = sel_for_key {
1174                                sel.select(row);
1175                            }
1176                            if let Some(ref cb) = activate_key {
1177                                cb(row, ctx);
1178                            }
1179                            return teksilo_core::event::EventResponse::Handled;
1180                        }
1181                        return teksilo_core::event::EventResponse::Ignored;
1182                    }
1183
1184                    // Type-ahead: a printable char (no Ctrl/Alt/Super) jumps the
1185                    // selection to the next row whose label starts with the
1186                    // accumulated term. Opt-in via `type_ahead_label`.
1187                    if ta_label.is_some()
1188                        && !modifiers.ctrl()
1189                        && !modifiers.alt()
1190                        && !modifiers.super_key()
1191                        && let Some(c) = key.to_char()
1192                    {
1193                        let current = fi.get().unwrap_or(0).min(count - 1);
1194                        let label = ta_label.as_ref().unwrap();
1195                        if let Some(idx) = ta_state.search(c, current, count, ta_timeout, |i| {
1196                            (with_item_str)(i, &|item| label(item))
1197                        }) {
1198                            fi.set(Some(idx));
1199                            if let Some(ref sel) = sel_for_key {
1200                                sel.select(idx);
1201                            }
1202                            let scroll = scroll_for_nav.get();
1203                            let new_scroll =
1204                                metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1205                                    idx,
1206                                    scroll,
1207                                    vh_for_nav.get(),
1208                                    max_for_nav.get(),
1209                                );
1210                            if (new_scroll - scroll).abs() > f32::EPSILON {
1211                                scroll_for_nav.set(new_scroll);
1212                            }
1213                            crate::common::row_metrics::chase_row_into_outer_view(
1214                                ctx,
1215                                &metrics_for_nav,
1216                                vb_for_nav.get(),
1217                                idx,
1218                                new_scroll,
1219                            );
1220                            return teksilo_core::event::EventResponse::Handled;
1221                        }
1222                        return teksilo_core::event::EventResponse::Ignored;
1223                    }
1224
1225                    // Alt+Arrow: reorder via the source's accept_drop (when
1226                    // reorderable). The move is expressed as a synthetic
1227                    // same-view RowDragData so it travels exactly the same
1228                    // source-owned path as a pointer drop.
1229                    if modifiers.alt() && reorderable {
1230                        let selected_idx = sel_for_key
1231                            .as_ref()
1232                            .and_then(|s| s.selected_indices().first().copied());
1233                        if let Some(idx) = selected_idx {
1234                            let mv = match key {
1235                                teksilo_core::event::Key::ArrowUp if idx > 0 => {
1236                                    Some((idx - 1, DropPosition::Before, idx - 1))
1237                                }
1238                                teksilo_core::event::Key::ArrowDown if idx + 1 < count => {
1239                                    Some((idx + 1, DropPosition::After, idx + 1))
1240                                }
1241                                _ => None,
1242                            };
1243                            if let Some((target, position, dest)) = mv {
1244                                // Synthetic same-view payloads must stash the
1245                                // dragged row's key at construction — the
1246                                // accept path resolves identity from the
1247                                // stash, never from `rows`.
1248                                (stash_for_key)(&[idx]);
1249                                let payload = DragPayload::typed(RowDragData::<T> {
1250                                    source: view_id_for_key,
1251                                    rows: vec![idx],
1252                                    items: None,
1253                                });
1254                                if (accept_drop_for_key)(
1255                                    &payload,
1256                                    target,
1257                                    position,
1258                                    view_id_for_key,
1259                                ) {
1260                                    if let Some(ref sel) = sel_for_key {
1261                                        sel.select(dest);
1262                                    }
1263                                    fi.set(Some(dest));
1264                                    // Reveal the moved row (own viewport first,
1265                                    // then chain to any enclosing scroll area).
1266                                    let scroll = scroll_for_nav.get();
1267                                    let new_scroll =
1268                                        metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1269                                            dest,
1270                                            scroll,
1271                                            vh_for_nav.get(),
1272                                            max_for_nav.get(),
1273                                        );
1274                                    if (new_scroll - scroll).abs() > f32::EPSILON {
1275                                        scroll_for_nav.set(new_scroll);
1276                                    }
1277                                    crate::common::row_metrics::chase_row_into_outer_view(
1278                                        ctx,
1279                                        &metrics_for_nav,
1280                                        vb_for_nav.get(),
1281                                        dest,
1282                                        new_scroll,
1283                                    );
1284                                }
1285                                return teksilo_core::event::EventResponse::Handled;
1286                            }
1287                        }
1288                    }
1289
1290                    // Navigation keys (no modifiers or with Shift for extend)
1291                    //
1292                    // The cursor is `focused_index` once the user has navigated
1293                    // or clicked; failing that it is the current selection — a
1294                    // view can be handed a selected row before it is ever
1295                    // focused (a launcher preselecting the top entry, a dialog
1296                    // restoring the last choice), and the keyboard must continue
1297                    // from what the user can see, not from an invisible zero.
1298                    //
1299                    // `None` ("no cursor yet") is deliberately NOT the same as
1300                    // `Some(0)`: from nothing, Down must land ON the first row
1301                    // and Up on the last one. Stepping to row 1 instead would
1302                    // silently skip row 0 — the row the user was looking at —
1303                    // which is what every toolkit (GTK, Qt, macOS, the ARIA
1304                    // listbox pattern) explicitly avoids.
1305                    let cursor = fi
1306                        .get()
1307                        .or_else(|| {
1308                            sel_for_key
1309                                .as_ref()
1310                                .and_then(|s| s.selected_indices().first().copied())
1311                        })
1312                        .map(|i| i.min(count - 1));
1313                    // Anchor for the keys that need a row to compute *from*
1314                    // (paging, activation) rather than a direction to step in.
1315                    let current = cursor.unwrap_or(0);
1316                    // The edge-and-page family is resolved once, in
1317                    // `common::list_nav`, so the five data views cannot drift
1318                    // apart on it again. A flat list has no row to be scoped
1319                    // to, so `RowFirst` / `RowLast` never arrive here — but a
1320                    // list's row *is* the collection, so they read the same way
1321                    // if the view kind is ever widened.
1322                    let nav = list_nav::nav_chord(*key, *modifiers, list_nav::ViewKind::Linear);
1323                    let new_idx = if let Some(chord) = nav {
1324                        Some(match chord.movement {
1325                            list_nav::NavMove::First | list_nav::NavMove::RowFirst => 0,
1326                            list_nav::NavMove::Last | list_nav::NavMove::RowLast => count - 1,
1327                            // Geometry-driven, so variable and auto-measured
1328                            // heights page by visual distance rather than by a
1329                            // fixed row count; the ensure-visible below then
1330                            // scrolls to follow.
1331                            list_nav::NavMove::Page { down } => {
1332                                let vh = vh_for_nav.get();
1333                                let r = {
1334                                    let mut m = metrics_for_nav.borrow_mut();
1335                                    m.resize(count);
1336                                    let target = if down {
1337                                        m.row_top(current) + vh
1338                                    } else {
1339                                        (m.row_top(current) - vh).max(0.0)
1340                                    };
1341                                    m.row_at(target)
1342                                };
1343                                // Guarantee progress even when one row is
1344                                // taller than the whole viewport.
1345                                if r == current && down {
1346                                    (current + 1).min(count - 1)
1347                                } else if r == current {
1348                                    current.saturating_sub(1)
1349                                } else {
1350                                    r.min(count - 1)
1351                                }
1352                            }
1353                        })
1354                    } else {
1355                        match key {
1356                            Key::ArrowDown => Some(match cursor {
1357                                None => 0,
1358                                Some(c) => (c + 1).min(count - 1),
1359                            }),
1360                            Key::ArrowUp => Some(match cursor {
1361                                None => count - 1,
1362                                Some(c) => c.saturating_sub(1),
1363                            }),
1364                            Key::Enter => {
1365                                // Enter activates the focused row (open / commit).
1366                                if let Some(ref sel) = sel_for_key {
1367                                    sel.select(current);
1368                                }
1369                                if let Some(ref cb) = activate_key {
1370                                    cb(current, ctx);
1371                                }
1372                                return teksilo_core::event::EventResponse::Handled;
1373                            }
1374                            Key::Space if modifiers.ctrl() => {
1375                                // Ctrl+Space toggles the focused row's selection —
1376                                // the keyboard equivalent of Ctrl+click. Distinct
1377                                // from plain Space below: it always toggles (even
1378                                // in Single mode, via `SelectionModel::toggle`'s
1379                                // own Single-mode fallback to `select`), pairing
1380                                // with Ctrl+Arrow's cursor-only move so a user can
1381                                // walk the cursor without disturbing the existing
1382                                // selection, then Ctrl+Space to add rows one at a
1383                                // time.
1384                                //
1385                                // Both halves stay on literal `ctrl()`, macOS
1386                                // included: ⌘Space is Spotlight and never reaches
1387                                // an app, and ⌘↑/⌘↓ already mean something else in
1388                                // a Finder list. This Explorer-style cursor pair
1389                                // has no ⌘ counterpart, so Control keeps it
1390                                // reachable and out of the platform's way.
1391                                if let Some(ref sel) = sel_for_key {
1392                                    sel.toggle(current);
1393                                }
1394                                fi.set(Some(current));
1395                                return teksilo_core::event::EventResponse::Handled;
1396                            }
1397                            Key::Space => {
1398                                // A row carrying a checkbox reads Space as
1399                                // "check this" — what Windows does for a
1400                                // checkbox list view, and what a visible
1401                                // checkbox looks like it should answer to. The
1402                                // row's control is out of the Tab order, so
1403                                // this is its only keyboard route; Ctrl+Space
1404                                // above keeps toggling the *selection*.
1405                                //
1406                                // Rows without a checkbox are unaffected:
1407                                // there is no published toggle, so Space falls
1408                                // through to the selection as before.
1409                                if let Some(row_id) = row_map_for_key
1410                                    .borrow()
1411                                    .iter()
1412                                    .find(|(i, _)| *i == current)
1413                                    .map(|(_, id)| *id)
1414                                {
1415                                    let sel_fallback = sel_for_key.clone();
1416                                    ctx.row_space_activate(
1417                                        row_id,
1418                                        std::rc::Rc::new(move || {
1419                                            if let Some(ref sel) = sel_fallback {
1420                                                if sel.mode() == teksilo_data::SelectionMode::Multi
1421                                                {
1422                                                    sel.toggle(current);
1423                                                } else {
1424                                                    sel.select(current);
1425                                                }
1426                                            }
1427                                        }),
1428                                    );
1429                                    fi.set(Some(current));
1430                                    return teksilo_core::event::EventResponse::Handled;
1431                                }
1432                                // Otherwise Space moves/toggles the selection but
1433                                // does NOT activate — the platform convention
1434                                // (Enter is the activator). Multi: toggle the
1435                                // focused row; Single: select it.
1436                                if let Some(ref sel) = sel_for_key {
1437                                    if sel.mode() == teksilo_data::SelectionMode::Multi {
1438                                        sel.toggle(current);
1439                                    } else {
1440                                        sel.select(current);
1441                                    }
1442                                }
1443                                fi.set(Some(current));
1444                                return teksilo_core::event::EventResponse::Handled;
1445                            }
1446                            _ => None,
1447                        }
1448                    };
1449
1450                    if let Some(idx) = new_idx {
1451                        fi.set(Some(idx));
1452                        // What the chord does to the selection. The edge-and-page
1453                        // keys carry their own answer from `list_nav`, where the
1454                        // accelerator means "move the cursor, leave the selection
1455                        // alone" — the rule GTK4 and Qt both apply to *every*
1456                        // navigation key.
1457                        //
1458                        // The arrows keep reading literal `ctrl()` instead: ⌘↑/⌘↓
1459                        // already mean something else in a Finder list (see the
1460                        // Ctrl+Space arm above), so this pair has no ⌘ counterpart
1461                        // to move to. That asymmetry is deliberate — it is also
1462                        // what leaves ⌘↑/⌘↓ free for the macOS aliases.
1463                        let op = match nav {
1464                            Some(chord) => chord.selection,
1465                            None if modifiers.ctrl()
1466                                && !modifiers.shift()
1467                                && matches!(key, Key::ArrowUp | Key::ArrowDown) =>
1468                            {
1469                                list_nav::SelectionOp::Suppress
1470                            }
1471                            None if modifiers.shift() => list_nav::SelectionOp::Extend,
1472                            None => list_nav::SelectionOp::Replace,
1473                        };
1474                        if let Some(ref sel) = sel_for_key {
1475                            match op {
1476                                list_nav::SelectionOp::Replace => sel.select(idx),
1477                                list_nav::SelectionOp::Suppress => {}
1478                                list_nav::SelectionOp::Extend => sel.extend_to(idx),
1479                                list_nav::SelectionOp::ExtendAdditive => {
1480                                    sel.extend_to_additive(idx)
1481                                }
1482                            }
1483                        }
1484                        // Scroll into view — the ListView's own viewport first,
1485                        // then chain to any enclosing scroll area.
1486                        let scroll = scroll_for_nav.get();
1487                        let new_scroll = metrics_for_nav.borrow_mut().scroll_for_ensure_visible(
1488                            idx,
1489                            scroll,
1490                            vh_for_nav.get(),
1491                            max_for_nav.get(),
1492                        );
1493                        if (new_scroll - scroll).abs() > f32::EPSILON {
1494                            scroll_for_nav.set(new_scroll);
1495                        }
1496                        crate::common::row_metrics::chase_row_into_outer_view(
1497                            ctx,
1498                            &metrics_for_nav,
1499                            vb_for_nav.get(),
1500                            idx,
1501                            new_scroll,
1502                        );
1503                        return teksilo_core::event::EventResponse::Handled;
1504                    }
1505                }
1506                teksilo_core::event::EventResponse::Ignored
1507            });
1508        }
1509
1510        // --- DnD: register self as a drop target when it can reorder OR accept
1511        // foreign rows. The source's `can_accept` decides per-hover whether the
1512        // drop is allowed (and a forbidden verdict shows no insertion line). ---
1513        if self.export.is_drop_target(self.reorderable) {
1514            let metrics_for_hover = self.metrics.clone();
1515            let scroll_for_hover = self.scroll_y.clone();
1516            let len_for_hover = self.source.len_fn.clone();
1517            let can_accept_for_hover = self.source.dnd.can_accept_fn.clone();
1518            let my_view_id = self.model_id;
1519
1520            let feedback_for_hover = self.drop_feedback.clone();
1521            let width_for_hover = self.placed_content_width.clone();
1522            let export_for_hover = self.export.clone();
1523            handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1524                let scroll = scroll_for_hover.get().max(0.0);
1525                let content_y = position.y + scroll;
1526                let len = (len_for_hover)();
1527                let (insertion_y, ins) = {
1528                    let mut m = metrics_for_hover.borrow_mut();
1529                    m.resize(len);
1530                    let ins = m.insertion_index(content_y);
1531                    (m.row_top(ins) - scroll, ins)
1532                };
1533                let line_width = width_for_hover.get();
1534                // Ask the source whether a drop here is allowed; paint the
1535                // insertion line only when it is. A foreign exported row is
1536                // allowed when `accept_foreign_rows` is on even though a bare
1537                // `ListModel`'s `can_accept` rejects the `Foreign` branch.
1538                let allowed = flat_insertion_target(ins, len).is_some_and(|(target, pos)| {
1539                    !matches!(
1540                        (can_accept_for_hover)(payload, target, pos, my_view_id),
1541                        DropResponse::Reject
1542                    ) || export_for_hover.accepts_foreign_export(payload, my_view_id)
1543                });
1544                if allowed {
1545                    feedback_for_hover.set(Some((insertion_y, line_width)));
1546                    DropFeedback::InsertionLine {
1547                        y: insertion_y,
1548                        width: line_width,
1549                    }
1550                } else {
1551                    feedback_for_hover.set(None);
1552                    DropFeedback::NoFeedback
1553                }
1554            });
1555
1556            let len_for_drop = self.source.len_fn.clone();
1557            let accept_drop_for_drop = self.source.dnd.accept_drop_fn.clone();
1558            let drop_view_id = self.model_id;
1559            let scroll_for_drop = self.scroll_y.clone();
1560            let metrics_for_drop = self.metrics.clone();
1561            let export_for_drop = self.export.clone();
1562            let reorderable_for_drop = self.reorderable;
1563
1564            handlers = handlers.on_drop(move |mut payload, position, ctx| {
1565                let scroll = scroll_for_drop.get().max(0.0);
1566                let content_y = position.y + scroll;
1567                let len = (len_for_drop)();
1568                let ins = {
1569                    let mut m = metrics_for_drop.borrow_mut();
1570                    m.resize(len);
1571                    m.insertion_index(content_y)
1572                };
1573                let is_same_view = payload
1574                    .get_typed::<RowDragData<T>>()
1575                    .is_some_and(|rd| rd.source == drop_view_id);
1576                // A same-view reorder only happens when the view is
1577                // `reorderable`; a foreign payload is the source's call (a bare
1578                // ListModel rejects it).
1579                if (reorderable_for_drop || !is_same_view)
1580                    && let Some((target, position_kind)) = flat_insertion_target(ins, len)
1581                    && (accept_drop_for_drop)(&payload, target, position_kind, drop_view_id)
1582                {
1583                    if is_same_view {
1584                        export_for_drop.note_self_reorder();
1585                    }
1586                    return true;
1587                }
1588                // Otherwise, the shared foreign-receive sugar (peek-before-take).
1589                export_for_drop.foreign_receive(&mut payload, drop_view_id, ins, ctx)
1590            });
1591
1592            // Clear the insertion line whenever the drag leaves this
1593            // widget — pointer moves to another target, drop completes,
1594            // Escape cancels, or the source is destroyed.
1595            let feedback_for_leave = self.drop_feedback.clone();
1596            handlers = handlers.on_drag_leave(move |_ctx| {
1597                feedback_for_leave.set(None);
1598            });
1599
1600            // Per-frame auto-scroll when the pointer lingers within
1601            // 32 px of the viewport top or bottom edge during a drag.
1602            // Linear ramp inside the edge zone, capped at ~12 px/frame
1603            // so fast-moving fingers still feel responsive but don't
1604            // rocket past the content.
1605            let scroll_for_tick = self.scroll_y.clone();
1606            let max_scroll_for_tick = self.max_scroll_y.clone();
1607            let viewport_for_tick = self.viewport_height.clone();
1608            handlers = handlers.on_drag_tick(move |pos, _ctx| {
1609                const EDGE: f32 = 32.0;
1610                const MAX_VELOCITY: f32 = 12.0;
1611                let h = viewport_for_tick.get();
1612                let above = (EDGE - pos.y).max(0.0);
1613                let below = (pos.y - (h - EDGE)).max(0.0);
1614                let delta = if above > 0.0 {
1615                    -(above / EDGE) * MAX_VELOCITY
1616                } else if below > 0.0 {
1617                    (below / EDGE) * MAX_VELOCITY
1618                } else {
1619                    0.0
1620                };
1621                if delta.abs() > 0.01 {
1622                    let max = max_scroll_for_tick.get();
1623                    let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
1624                    scroll_for_tick.set(new_y);
1625                }
1626            });
1627        }
1628
1629        // Export completion (move-out): fires on the drag source — this view's
1630        // root id, the stable id start_drag was given.
1631        handlers = self.export.install_completion(handlers);
1632
1633        ctx.apply_self_handlers(handlers);
1634
1635        // --- Body pane ---
1636        // Hoisted into its own widget so that scroll-buffer-exit rebuilds
1637        // (which happen mid-thumb-drag once the user scrolls past the
1638        // buffered range) target a SIBLING of the scrollbar rather than the
1639        // scrollbar's ancestor. Rebuilding the ancestor would be deferred by
1640        // the framework to preserve the captured drag, leaving the list blank
1641        // until the user released the thumb. See `body_pane`'s module docs.
1642        let pane = body_pane::ListBodyPane::<T> {
1643            source: self.source.clone(),
1644            delegate: self.delegate.clone(),
1645            row_tooltips: self.row_tooltips.clone(),
1646            metrics: self.metrics.clone(),
1647            row_selection: self.row_selection.clone(),
1648            focused_index: self.focused_index.clone(),
1649            row_map: self.row_map.clone(),
1650            reorderable: self.reorderable,
1651            export: self.export.clone(),
1652            on_activate: self.on_activate.clone(),
1653            activate_on: self.activate_on,
1654            model_id: self.model_id,
1655            root_id: self_id,
1656            scroll_y: self.scroll_y.clone(),
1657            viewport_height: self.viewport_height.clone(),
1658            placed_content_width: self.placed_content_width.clone(),
1659            version: self.pane_version.clone(),
1660            total_refresh: self.layout_refresh.clone(),
1661            prev_built_start: self.pane_built_start.clone(),
1662            prev_built_end: self.pane_built_end.clone(),
1663            item_entries: Vec::new(),
1664        };
1665        self.body_pane_id = Some(ctx.add(pane));
1666
1667        // --- Create scrollbar ---
1668        // Skipped when the caller opted out via `show_scrollbar(false)`
1669        // — they're expected to mount their own, wired through the
1670        // exposed signal accessors.
1671        if self.show_scrollbar {
1672            let scrollbar = ScrollBar::new(
1673                ScrollBarOrientation::Vertical,
1674                self.scroll_y.clone(),
1675                self.max_scroll_y.clone(),
1676                self.viewport_ratio_y.clone(),
1677            )
1678            .visual(match self.scroll_bar_style {
1679                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
1680                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
1681                ScrollBarMode::Thin => ScrollBarVisual::Thin,
1682            });
1683            let sb_id = ctx.add(scrollbar);
1684            self.scrollbar_id = Some(sb_id);
1685        } else {
1686            self.scrollbar_id = None;
1687        }
1688
1689        self.child_ids()
1690    }
1691
1692    fn layout_response(
1693        &self,
1694        proposal: SizeProposal,
1695        _ctx: &LayoutContext,
1696    ) -> teksilo_core::widget::LayoutResponse {
1697        // The viewport takes whatever the parent offers — but only an
1698        // allocation is cached for the visible-range computation; a
1699        // measurement's fallback is not a viewport (`common::viewport`).
1700        crate::common::viewport::viewport_size(
1701            proposal,
1702            &self.viewport_height,
1703            Size::new(300.0, 200.0),
1704        )
1705        .into()
1706    }
1707
1708    fn place_children(
1709        &self,
1710        bounds: Rect,
1711        _proposal: SizeProposal,
1712        children: &mut [WidgetPlacement],
1713        _ctx: &LayoutContext,
1714    ) {
1715        // Cache our own absolute bounds for the keyboard handler's
1716        // outer-scroll chase (`ensure_visible`). Done before the empty-children
1717        // bail so the rect stays fresh even for an empty list that later fills.
1718        self.viewport_bounds.set(bounds);
1719        // The allocated height is the authoritative viewport: `build` sizes its
1720        // realization window from this, and a stale value there costs a
1721        // permanent rebuild loop (`common::viewport`).
1722        crate::common::viewport::record_viewport_height(&self.viewport_height, bounds.height);
1723
1724        if children.is_empty() {
1725            return;
1726        }
1727
1728        let viewport_height = bounds.height;
1729
1730        // The scrollbar decision uses the pre-measure total: the content
1731        // width must be known before rows can be measured at it. If a
1732        // measurement flips the decision, the next frame corrects it.
1733        let provisional_total = self.total_content_height();
1734        let needs_internal_scrollbar =
1735            self.show_scrollbar && provisional_total > viewport_height + 0.5;
1736        let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
1737        let content_width = if needs_internal_scrollbar && reserves_bar {
1738            (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
1739        } else {
1740            bounds.width
1741        };
1742        self.placed_content_width.set(content_width);
1743
1744        // Totals for the scrollbar. In auto-measure mode these are computed
1745        // BEFORE the pane measures its rows (parent-before-child ordering), so
1746        // the pane pokes `layout_refresh` when a measurement moves the total
1747        // and we re-place next frame with the corrected value.
1748        let total_height = self.total_content_height();
1749        let max_y = (total_height - viewport_height).max(0.0);
1750        self.max_scroll_y.set(max_y);
1751        let ratio = if total_height > 0.0 {
1752            (viewport_height / total_height).clamp(0.0, 1.0)
1753        } else {
1754            1.0
1755        };
1756        self.viewport_ratio_y.set(ratio);
1757        self.clamp_scroll();
1758
1759        // Two children in a fixed order (see `child_ids`): the body pane
1760        // fills the content column and positions its own rows; the scrollbar
1761        // sits alongside it.
1762        let mut next = 0;
1763        if self.body_pane_id.is_some() {
1764            if let Some(child) = children.get_mut(next) {
1765                child.origin = bounds.origin();
1766                child.size = Size::new(content_width, bounds.height);
1767            }
1768            next += 1;
1769        }
1770        if self.scrollbar_id.is_some()
1771            && let Some(sb_child) = children.get_mut(next)
1772        {
1773            if needs_internal_scrollbar {
1774                sb_child.origin =
1775                    Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1776                sb_child.size = Size::new(SCROLLBAR_THICKNESS, bounds.height);
1777            } else {
1778                sb_child.origin = bounds.origin();
1779                sb_child.size = Size::ZERO;
1780            }
1781        }
1782    }
1783
1784    fn paint(
1785        &self,
1786        bounds: Rect,
1787        canvas: &mut teksilo_canvas::Canvas,
1788        ctx: &teksilo_core::widget::PaintContext,
1789    ) {
1790        // Draw insertion line during drag hover. Recipe-driven role +
1791        // thickness — defaults to BorderRole::Accent / 2 dp; a custom
1792        // `ListContainerStyle` installed via the theme slot overrides.
1793        if let Some((y, width)) = self.drop_feedback.get() {
1794            let recipe = ctx
1795                .theme
1796                .style_slots
1797                .list_container
1798                .as_ref()
1799                .map(|s| s.insertion())
1800                .unwrap_or_default();
1801            let color = recipe.role.resolve(&ctx.theme.colors);
1802            let line_y = bounds.y + y;
1803            let line_x = bounds.x;
1804            let half = recipe.thickness * 0.5;
1805            // Own paint isn't covered by `clips_children` — clip so an
1806            // insertion line at the after-last boundary can't bleed
1807            // past the widget's bottom edge.
1808            canvas.set_clip(bounds);
1809            canvas.fill_rect(
1810                Rect::new(line_x, line_y - half, width, recipe.thickness),
1811                color,
1812            );
1813            canvas.clear_clip();
1814        }
1815
1816        // Container focus ring — keyboard focus landed but nothing is selected,
1817        // so no row ring shows; outline the whole view (see TreeView).
1818        let has_selection = self
1819            .row_selection
1820            .as_ref()
1821            .is_some_and(|s| s.has_selection());
1822        if self.view_focused.get() && self.focus_visible.get() && !has_selection {
1823            let color = BorderRole::Focused.resolve(&ctx.theme.colors);
1824            let inset = 1.0_f32;
1825            let rect = Rect::new(
1826                bounds.x + inset,
1827                bounds.y + inset,
1828                (bounds.width - inset * 2.0).max(0.0),
1829                (bounds.height - inset * 2.0).max(0.0),
1830            );
1831            canvas.stroke_rect(rect, color, 1.5);
1832        }
1833    }
1834
1835    /// The context-menu key opens the *current row's* menu, not the list's.
1836    ///
1837    /// A `ListView` is focusable and its rows deliberately are not — the
1838    /// container owns focus and `set_selected` is what tells assistive
1839    /// technology which row is current (see `list_item_a11y`). So the
1840    /// dispatcher's default of "the focused widget" would open the list's own
1841    /// menu, in the widget family where a per-row menu matters most.
1842    ///
1843    /// The row the user means is the keyboard cursor if they have navigated
1844    /// (`focused_index`), else the first selected row. Both are indices into
1845    /// the model, and only realized rows have a widget, so a cursor scrolled
1846    /// outside the virtualization window resolves to nothing and the menu falls
1847    /// back to the list — which is the right answer, since there is no row on
1848    /// screen for it to be about.
1849    fn context_menu_key_target(&self) -> Option<WidgetId> {
1850        self.current_row_widget()
1851    }
1852
1853    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1854        builder.set_role(teksilo_core::accesskit::Role::ListBox);
1855        // Whether the selection takes more than one row. A real property on
1856        // both platforms that have one: UIA's `SelectionCanSelectMultiple`
1857        // and AT-SPI's multiselectable state. Left unset it reads false, so a
1858        // multi-select view was telling every screen reader that one row was
1859        // the most it would ever hold.
1860        //
1861        // Gated on the mode, and the gate matters beyond tidiness:
1862        // `accesskit_windows` picks the event it raises on a selection change
1863        // from this property (`adapter.rs:189-199`), firing
1864        // `ElementAddedToSelection` when it is true and `ElementSelected` when
1865        // it is false. A single-select view publishing `true` would trade the
1866        // right event for the wrong one.
1867        if self
1868            .row_selection
1869            .as_ref()
1870            .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
1871        {
1872            builder.set_multiselectable(true);
1873        }
1874
1875        // The logical row count, not the realized virtualization window: a
1876        // 200-row list announces "of 200" even while twenty rows exist as
1877        // widgets. It belongs here rather than on each row, because
1878        // `size_of_set_from_container` resolves an item's set size by walking
1879        // *up* from it — a size written on a row is read by no adapter.
1880        builder.set_size_of_set(self.source.len());
1881
1882        // The current row, as the container's active descendant.
1883        //
1884        // Keyboard focus stays here, on the list, and the row is marked
1885        // `selected`. On AT-SPI that is the whole story: Orca announces the
1886        // selection change. On Windows it is not, because UIA has no
1887        // active-descendant property at all — what it has is a focused
1888        // element, and for a list box that element is the item.
1889        //
1890        // AccessKit bridges the two in the consumer rather than in each
1891        // adapter: `accesskit_consumer` resolves the focused node as
1892        // `focused.active_descendant().unwrap_or(focused)`
1893        // (`tree.rs:541`) and `accesskit_windows::focus_moved`
1894        // (`adapter.rs:341-345`) raises `UIA_AutomationFocusChangedEventId` on
1895        // whatever comes out. So this one property turns every arrow press
1896        // into the focus change a screen reader announces, and `is_focused`
1897        // (`consumer node.rs:89-105`) moves from this container to the row,
1898        // which is what the ARIA listbox pattern says should happen.
1899        //
1900        // Without it, arrowing through any Teksilo list is silent to NVDA:
1901        // there is no focus change to announce, and the selection event that
1902        // is raised names a row node the pane rebuilt a moment earlier. The
1903        // mouse still reads rows correctly, because hit-testing does not go
1904        // through events at all, which is exactly how this hid for so long.
1905        // Only while this view actually holds focus. A container that does not
1906        // have focus has no active descendant to speak of, and publishing one
1907        // anyway puts a second relation in the tree for a client to follow: the
1908        // combobox pattern (`CommandPalette`) keeps focus on a text field that
1909        // points at a row in *this* list, and two publishers of the same row is
1910        // an ambiguity nobody needs to resolve.
1911        if self.view_focused.get()
1912            && let Some(row) = self.current_row_widget()
1913        {
1914            builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(row));
1915        }
1916    }
1917
1918    fn as_any(&self) -> Option<&dyn std::any::Any> {
1919        Some(self)
1920    }
1921
1922    fn children(&self) -> Vec<WidgetId> {
1923        self.child_ids()
1924    }
1925
1926    fn clips_children(&self) -> bool {
1927        true
1928    }
1929}
1930
1931#[cfg(test)]
1932mod tests {
1933    use super::*;
1934    use teksilo_core::widget_tree::WidgetTree;
1935
1936    /// The realized row wrappers. `ListView`'s own children are the body pane
1937    /// and the scrollbar (see `body_pane`'s module docs for why the rows sit
1938    /// one level down), so every test that used to walk `tree.children(lv_id)`
1939    /// for rows goes through here.
1940    fn row_ids(tree: &WidgetTree, lv: WidgetId) -> Vec<WidgetId> {
1941        let kids = tree.children(lv);
1942        match kids.first() {
1943            Some(&pane) => tree.children(pane),
1944            None => Vec::new(),
1945        }
1946    }
1947
1948    /// The internal scrollbar — always the ListView's last child.
1949    fn scrollbar_of(tree: &WidgetTree, lv: WidgetId) -> WidgetId {
1950        *tree.children(lv).last().expect("ListView has children")
1951    }
1952
1953    #[test]
1954    fn smooth_scroll_survives_a_body_pane_rebuild() {
1955        let (mut tree, lv_id, model) = make_list_view(500, 20.0);
1956        let scroll = {
1957            tree.layout(SizeProposal::exact(400.0, 200.0));
1958            let any = tree.widget_as_any(lv_id).unwrap();
1959            any.downcast_ref::<ListView<usize>>()
1960                .unwrap()
1961                .scroll_y_signal()
1962                .clone()
1963        };
1964        crate::common::thumb_drag_test::assert_fling_survives_pane_rebuild(
1965            &mut tree,
1966            400.0,
1967            200.0,
1968            &scroll,
1969            "ListView",
1970            || model.push(9999),
1971        );
1972    }
1973
1974    #[test]
1975    fn rows_materialize_during_scrollbar_thumb_drag() {
1976        // The reason `ListBodyPane` exists — see
1977        // `common::thumb_drag_test`'s module docs for the invariant.
1978        let (mut tree, lv_id, _model) = make_list_view(500, 20.0);
1979        crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
1980            &mut tree,
1981            lv_id,
1982            400.0,
1983            200.0,
1984            0.0,
1985            "ListView",
1986            |t| {
1987                row_ids(t, lv_id)
1988                    .into_iter()
1989                    .filter(|id| {
1990                        let b = t.bounds(*id);
1991                        b.height > 1.0 && b.y > -b.height && b.y < 200.0
1992                    })
1993                    .count()
1994            },
1995        );
1996    }
1997
1998    #[derive(Debug)]
1999    pub(super) struct FixedLeaf(pub f32, pub f32);
2000    impl Widget for FixedLeaf {
2001        fn layout_response(
2002            &self,
2003            _proposal: SizeProposal,
2004            _ctx: &LayoutContext,
2005        ) -> teksilo_core::widget::LayoutResponse {
2006            Size::new(self.0, self.1).into()
2007        }
2008    }
2009
2010    /// A `ListView` announced neither its position nor its total until now: no
2011    /// row set `position_in_set`, and no container anywhere in the framework
2012    /// set `size_of_set`. A screen-reader user arrowing through a 200-row list
2013    /// heard each row's label and nothing about where they were in it.
2014    ///
2015    /// Asked the way a platform adapter asks it — the position off the row, the
2016    /// total by walking up to the `Role::ListBox` — because that walk is
2017    /// exactly what a node-level assertion would have missed.
2018    #[test]
2019    fn a_row_announces_its_position_out_of_the_whole_model() {
2020        let (mut tree, lv_id, _model) = make_list_view(200, 20.0);
2021        tree.layout(SizeProposal::exact(400.0, 200.0));
2022        let rows = row_ids(&tree, lv_id);
2023        assert!(!rows.is_empty(), "some rows must be realized");
2024
2025        let update = tree.sync_accessibility();
2026        for (i, &row) in rows.iter().enumerate().take(3) {
2027            crate::a11y_set_semantics::assert_announces(
2028                &update,
2029                teksilo_core::accessibility::widget_id_to_node_id(row),
2030                i + 1,
2031                200,
2032                &format!("row {i}"),
2033            );
2034        }
2035    }
2036
2037    /// The number a row announces is its place in the **model**, not in the
2038    /// realized window: scroll to row 150 and it must say 151, not 1.
2039    #[test]
2040    fn a_scrolled_row_announces_its_model_position_not_its_window_position() {
2041        let (mut tree, lv_id, _model) = make_list_view(200, 20.0);
2042        tree.layout(SizeProposal::exact(400.0, 200.0));
2043        {
2044            let any = tree.widget_as_any(lv_id).unwrap();
2045            any.downcast_ref::<ListView<usize>>()
2046                .unwrap()
2047                .scroll_y_signal()
2048                .set(150.0 * 20.0);
2049        }
2050        tree.layout(SizeProposal::exact(400.0, 200.0));
2051
2052        let rows = row_ids(&tree, lv_id);
2053        assert!(
2054            !rows.is_empty(),
2055            "some rows must be realized after scrolling"
2056        );
2057        let update = tree.sync_accessibility();
2058        let (position, size) = crate::a11y_set_semantics::announced_set_position(
2059            &update,
2060            teksilo_core::accessibility::widget_id_to_node_id(rows[0]),
2061        );
2062        assert_eq!(
2063            size,
2064            Some(200),
2065            "the total is the model's, not the window's"
2066        );
2067        assert!(
2068            position.is_some_and(|p| p > 100),
2069            "the first realized row after scrolling to the 150th must announce a \
2070             model position, not a window position; got {position:?}"
2071        );
2072    }
2073
2074    fn make_list_view(count: usize, item_height: f32) -> (WidgetTree, WidgetId, ListModel<usize>) {
2075        let model = ListModel::from_vec((0..count).collect());
2076        let mut tree = WidgetTree::new();
2077        let lv_id = tree.add(
2078            ListView::new(model.clone(), move |_i, _item, _selected| {
2079                Box::new(FixedLeaf(100.0, item_height))
2080            })
2081            .item_height(item_height),
2082        );
2083        (tree, lv_id, model)
2084    }
2085
2086    /// Taking focus reveals the row the keyboard is on, however far down it is.
2087    ///
2088    /// Only the rows near the viewport are realized, so a selection made
2089    /// before the list is looked at — restoring a session, landing on
2090    /// "whatever is happening now" — usually sits outside that window. Nothing
2091    /// then speaks for it: no node carries `selected`, no active descendant is
2092    /// nominated, and a screen reader taking focus here is told nothing. The
2093    /// first arrow press steps past the row as well, because the cursor was
2094    /// somewhere nobody was shown.
2095    ///
2096    /// Asserted on the accessibility tree, since that is what the failure was
2097    /// about: the row has to be a node a platform can name.
2098    #[test]
2099    fn taking_focus_reveals_the_current_row() {
2100        use teksilo_data::{SelectionMode, SelectionModel};
2101
2102        let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
2103        let selection = SelectionModel::new(SelectionMode::Single);
2104        selection.select(150);
2105
2106        let mut tree = WidgetTree::new();
2107        let lv_id = tree.add(
2108            ListView::new(model.clone(), move |_i, _item, _selected| {
2109                Box::new(FixedLeaf(100.0, 20.0))
2110            })
2111            .item_height(20.0)
2112            .selection(selection.clone()),
2113        );
2114        tree.layout(SizeProposal::exact(400.0, 200.0));
2115
2116        let realized_selected = |tree: &mut WidgetTree| -> Vec<usize> {
2117            tree.accessibility_tree_snapshot()
2118                .nodes
2119                .iter()
2120                .filter(|(_, node)| node.is_selected() == Some(true))
2121                .filter_map(|(_, node)| node.position_in_set())
2122                .collect()
2123        };
2124
2125        assert!(
2126            realized_selected(&mut tree).is_empty(),
2127            "row 150 starts far outside the realized window, which is the case \
2128             this is about"
2129        );
2130
2131        tree.focus(lv_id);
2132        tree.layout(SizeProposal::exact(400.0, 200.0));
2133
2134        assert_eq!(
2135            realized_selected(&mut tree),
2136            vec![150],
2137            "taking focus has to bring the current row into the realized window, \
2138             or nothing in the tree can be told about it"
2139        );
2140    }
2141
2142    /// And a row already on screen does not jump.
2143    ///
2144    /// `ensure_index_visible`, not `scroll_to_index`: somebody who can see the
2145    /// list must not have it lurch when they click into it.
2146    #[test]
2147    fn taking_focus_does_not_move_a_row_already_in_view() {
2148        use teksilo_data::{SelectionMode, SelectionModel};
2149
2150        let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
2151        let selection = SelectionModel::new(SelectionMode::Single);
2152        selection.select(2);
2153
2154        let mut tree = WidgetTree::new();
2155        let lv_id = tree.add(
2156            ListView::new(model.clone(), move |_i, _item, _selected| {
2157                Box::new(FixedLeaf(100.0, 20.0))
2158            })
2159            .item_height(20.0)
2160            .selection(selection.clone()),
2161        );
2162        tree.layout(SizeProposal::exact(400.0, 200.0));
2163
2164        let scroll = {
2165            let any = tree.widget_as_any(lv_id).unwrap();
2166            any.downcast_ref::<ListView<usize>>()
2167                .unwrap()
2168                .scroll_y_signal()
2169                .clone()
2170        };
2171        let before = scroll.get();
2172
2173        tree.focus(lv_id);
2174        tree.layout(SizeProposal::exact(400.0, 200.0));
2175
2176        assert_eq!(
2177            scroll.get(),
2178            before,
2179            "row 2 is already visible, so the list must not scroll at all"
2180        );
2181    }
2182
2183    /// The focused node, as a platform adapter resolves it, is the current row.
2184    ///
2185    /// This is the property that decides whether a screen reader says anything
2186    /// when the user presses an arrow. Keyboard focus stays on the list, so
2187    /// there is no focus change for the platform to report unless the list
2188    /// nominates a row as its active descendant; `accesskit_consumer` then
2189    /// resolves the focused node through it (`tree.rs:541`) and
2190    /// `accesskit_windows::focus_moved` raises
2191    /// `UIA_AutomationFocusChangedEventId` on the row (`adapter.rs:341-345`).
2192    ///
2193    /// Asserted through `is_focused`, which is the consumer's own answer and
2194    /// what both adapters go on, rather than through the raw property: the
2195    /// property is the mechanism and this is the meaning.
2196    #[test]
2197    fn the_current_row_is_what_the_platform_calls_focused() {
2198        use teksilo_data::{SelectionMode, SelectionModel};
2199
2200        let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2201        let selection = SelectionModel::new(SelectionMode::Single);
2202        selection.select(3);
2203
2204        let mut tree = WidgetTree::new();
2205        let lv_id = tree.add(
2206            ListView::new(model.clone(), move |_i, _item, _selected| {
2207                Box::new(FixedLeaf(100.0, 20.0))
2208            })
2209            .item_height(20.0)
2210            .selection(selection.clone()),
2211        );
2212        tree.layout(SizeProposal::exact(400.0, 200.0));
2213        tree.focus(lv_id);
2214        tree.layout(SizeProposal::exact(400.0, 200.0));
2215
2216        // The row a screen reader would be told about, by model index.
2217        let focused_row = |tree: &mut WidgetTree| -> Option<usize> {
2218            let snapshot = tree.accessibility_tree_snapshot();
2219            let consumer = accesskit_consumer::Tree::new(snapshot, true);
2220            let state = consumer.state();
2221            let focus = state.focus_id()?;
2222            let focused = state.node_by_id(focus)?;
2223            // Exactly what `accesskit_consumer` does before telling an adapter
2224            // the focus moved (`tree.rs:541`), and what `is_focused` concludes
2225            // (`node.rs:89-105`). `focus_id` alone is the raw value and still
2226            // names the container.
2227            let resolved = focused.active_descendant().unwrap_or(focused);
2228            // Zero-based in the tree, one-based in the ear.
2229            resolved.position_in_set()
2230        };
2231
2232        assert_eq!(
2233            focused_row(&mut tree),
2234            Some(3),
2235            "with the list focused, the platform's focused node must be the \
2236             current row and not the list itself"
2237        );
2238
2239        selection.select(6);
2240        tree.layout(SizeProposal::exact(400.0, 200.0));
2241
2242        assert_eq!(
2243            focused_row(&mut tree),
2244            Some(6),
2245            "and moving the selection must move it, which is the focus change \
2246             NVDA announces; without it an arrow press is silent"
2247        );
2248    }
2249
2250    /// A list with nothing selected nominates nothing.
2251    ///
2252    /// The container stays the focused node, which is correct: there is no row
2253    /// to be on, and pointing at one would make a screen reader announce a row
2254    /// the user has not reached.
2255    #[test]
2256    fn an_unselected_list_nominates_no_row() {
2257        let (mut tree, lv_id, _model) = make_list_view(10, 20.0);
2258        tree.layout(SizeProposal::exact(400.0, 200.0));
2259        tree.focus(lv_id);
2260        tree.layout(SizeProposal::exact(400.0, 200.0));
2261
2262        let snapshot = tree.accessibility_tree_snapshot();
2263        let consumer = accesskit_consumer::Tree::new(snapshot, true);
2264        let state = consumer.state();
2265        let focus = state.focus_id().expect("something has focus");
2266        let focused = state.node_by_id(focus).expect("the focused node exists");
2267        let resolved = focused.active_descendant().unwrap_or(focused);
2268        assert_eq!(
2269            resolved.role(),
2270            teksilo_core::accesskit::Role::ListBox,
2271            "with no selection the list itself is the focused node"
2272        );
2273    }
2274
2275    /// And the row that is now selected says so.
2276    ///
2277    /// The pair matters: keeping the widgets would be easy if the selected flag
2278    /// stopped following the selection, and that would trade a silent screen
2279    /// reader for a lying one.
2280    #[test]
2281    fn the_selected_row_reports_itself_after_a_move() {
2282        use teksilo_data::{SelectionMode, SelectionModel};
2283
2284        let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2285        let selection = SelectionModel::new(SelectionMode::Single);
2286        selection.select(3);
2287
2288        let mut tree = WidgetTree::new();
2289        // The id is not needed: this asserts the `selected` flag, which does
2290        // not depend on the view holding focus.
2291        let _ = tree.add(
2292            ListView::new(model.clone(), move |_i, _item, _selected| {
2293                Box::new(FixedLeaf(100.0, 20.0))
2294            })
2295            .item_height(20.0)
2296            .selection(selection.clone()),
2297        );
2298        tree.layout(SizeProposal::exact(400.0, 200.0));
2299
2300        let selected_rows = |tree: &mut WidgetTree| -> Vec<usize> {
2301            let nodes = tree.accessibility_tree_snapshot().nodes;
2302            nodes
2303                .iter()
2304                .filter(|(_, node)| node.is_selected() == Some(true))
2305                .filter_map(|(_, node)| node.position_in_set())
2306                .collect()
2307        };
2308
2309        // Raw `position_in_set` is zero-based (AccessKit's convention, one
2310        // below the ARIA number the adapters speak), so row 4 reads as 3.
2311        assert_eq!(selected_rows(&mut tree), vec![3]);
2312
2313        selection.select(4);
2314        tree.layout(SizeProposal::exact(400.0, 200.0));
2315
2316        assert_eq!(
2317            selected_rows(&mut tree),
2318            vec![4],
2319            "the flag has to follow the selection, whether or not the widget was \
2320             rebuilt to carry it"
2321        );
2322    }
2323
2324    #[test]
2325    fn arrow_nav_resumes_from_the_clicked_row() {
2326        // Regression: a row click must move the keyboard-navigation cursor
2327        // (`focused_index`) to the clicked row, so the next Arrow step continues
2328        // from there — not from the stale keyboard cursor / index 0.
2329        use teksilo_canvas::Point;
2330        use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
2331        use teksilo_data::{SelectionMode, SelectionModel};
2332
2333        let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2334        let selection = SelectionModel::new(SelectionMode::Single);
2335        let sel = selection.clone();
2336        let mut tree = WidgetTree::new();
2337        let lv_id = tree.add(
2338            ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2339                .item_height(20.0)
2340                .selection(sel),
2341        );
2342        tree.layout(SizeProposal::exact(400.0, 300.0)); // 10 rows × 20px all visible
2343        tree.focus(lv_id);
2344
2345        // Click row 3 (rows are 20px tall, so y≈70; x past any leading control).
2346        tree.dispatch_event(WidgetEvent::PointerDown {
2347            position: Point::new(50.0, 70.0),
2348            button: PointerButton::Primary,
2349            modifiers: Modifiers::NONE,
2350        });
2351        tree.dispatch_event(WidgetEvent::PointerUp {
2352            position: Point::new(50.0, 70.0),
2353            button: PointerButton::Primary,
2354            modifiers: Modifiers::NONE,
2355        });
2356        assert_eq!(
2357            selection.selected_indices(),
2358            vec![3],
2359            "precondition: body click selects row 3"
2360        );
2361
2362        // ArrowDown must step to 4 (from the clicked row), not to 1 (from index 0).
2363        tree.press_key(Key::ArrowDown, Modifiers::NONE);
2364        assert_eq!(
2365            selection.selected_indices(),
2366            vec![4],
2367            "ArrowDown after a click resumes from the clicked row (3 → 4)"
2368        );
2369    }
2370
2371    #[test]
2372    fn focused_index_follows_insert_before_it() {
2373        // Bug repro: `focused_index` (the keyboard-nav anchor) was never
2374        // adjusted on any DataChange, so after a peer/insert shifts the
2375        // rows it silently pointed at the wrong one — the next ArrowDown
2376        // would resume from a stale position instead of the row the user
2377        // was actually on.
2378        use teksilo_canvas::Point;
2379        use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
2380        use teksilo_data::{SelectionMode, SelectionModel};
2381
2382        let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2383        let selection = SelectionModel::new(SelectionMode::Single);
2384        let sel = selection.clone();
2385        let mut tree = WidgetTree::new();
2386        let lv_id = tree.add(
2387            ListView::new(model.clone(), |_i, _item, _sel| {
2388                Box::new(FixedLeaf(100.0, 20.0))
2389            })
2390            .item_height(20.0)
2391            .selection(sel),
2392        );
2393        tree.layout(SizeProposal::exact(400.0, 300.0));
2394        tree.focus(lv_id);
2395
2396        // Click row 3 — sets both selection and the keyboard-nav anchor to 3.
2397        tree.dispatch_event(WidgetEvent::PointerDown {
2398            position: Point::new(50.0, 70.0),
2399            button: PointerButton::Primary,
2400            modifiers: Modifiers::NONE,
2401        });
2402        tree.dispatch_event(WidgetEvent::PointerUp {
2403            position: Point::new(50.0, 70.0),
2404            button: PointerButton::Primary,
2405            modifiers: Modifiers::NONE,
2406        });
2407        assert_eq!(selection.selected_indices(), vec![3], "precondition");
2408
2409        // A peer-driven reload prepends two rows — row 3 is now row 5.
2410        model.insert(0, 100);
2411        model.insert(0, 200);
2412        tree.layout(SizeProposal::exact(400.0, 300.0));
2413        // The selection model itself already index-shifts (existing
2414        // behaviour) — this is just re-confirming the setup, not the fix.
2415        assert_eq!(
2416            selection.selected_indices(),
2417            vec![5],
2418            "precondition: selection shifts with the inserted rows"
2419        );
2420
2421        // If `focused_index` had NOT shifted (the bug), it would still read
2422        // 3, and ArrowDown would resume from there (→ select 4). With the
2423        // fix it follows the insert to 5, so ArrowDown resumes from 5 (→ 6).
2424        tree.press_key(Key::ArrowDown, Modifiers::NONE);
2425        assert_eq!(
2426            selection.selected_indices(),
2427            vec![6],
2428            "ArrowDown after a leading insert resumes from the shifted row (5 → 6), \
2429             not the stale pre-insert one (3 → 4)"
2430        );
2431    }
2432
2433    #[test]
2434    fn focused_index_dropped_when_its_row_is_removed() {
2435        // The focused row itself was removed: the anchor must be cleared,
2436        // not left pointing at whatever now occupies its old slot.
2437        use teksilo_canvas::Point;
2438        use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
2439        use teksilo_data::{SelectionMode, SelectionModel};
2440
2441        let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2442        let selection = SelectionModel::new(SelectionMode::Single);
2443        let sel = selection.clone();
2444        let mut tree = WidgetTree::new();
2445        let lv_id = tree.add(
2446            ListView::new(model.clone(), |_i, _item, _sel| {
2447                Box::new(FixedLeaf(100.0, 20.0))
2448            })
2449            .item_height(20.0)
2450            .selection(sel),
2451        );
2452        tree.layout(SizeProposal::exact(400.0, 300.0));
2453        tree.focus(lv_id);
2454
2455        // Click row 3.
2456        tree.dispatch_event(WidgetEvent::PointerDown {
2457            position: Point::new(50.0, 70.0),
2458            button: PointerButton::Primary,
2459            modifiers: Modifiers::NONE,
2460        });
2461        tree.dispatch_event(WidgetEvent::PointerUp {
2462            position: Point::new(50.0, 70.0),
2463            button: PointerButton::Primary,
2464            modifiers: Modifiers::NONE,
2465        });
2466        assert_eq!(selection.selected_indices(), vec![3], "precondition");
2467
2468        // Row 3 itself is removed from under the focused anchor.
2469        model.remove(3);
2470        tree.layout(SizeProposal::exact(400.0, 300.0));
2471        assert!(
2472            selection.selected_indices().is_empty(),
2473            "precondition: selection drops the removed row"
2474        );
2475
2476        // With `focused_index` cleared (`None`) — and the selection dropped with
2477        // it, so there is no cursor to fall back on either — the next ArrowDown
2478        // lands ON row 0. Left un-cleared (the bug), the stale anchor would
2479        // still read 3 (now clamped to the shrunk list, still in range) and
2480        // ArrowDown would step to 4 instead.
2481        tree.press_key(Key::ArrowDown, Modifiers::NONE);
2482        assert_eq!(
2483            selection.selected_indices(),
2484            vec![0],
2485            "focused_index was cleared, so nav restarts at the top (row 0), \
2486             not from the stale removed row's index (3 → 4)"
2487        );
2488    }
2489
2490    #[test]
2491    fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
2492        // "No cursor yet" is not "cursor on row 0": the very first ArrowDown
2493        // must select the FIRST row, not step past it to row 1 (which would
2494        // make the top row unreachable by keyboard until you arrow back up),
2495        // and the very first ArrowUp must select the LAST row.
2496        use teksilo_core::event::{Key, Modifiers};
2497        use teksilo_data::{SelectionMode, SelectionModel};
2498
2499        for (key, want, what) in [
2500            (
2501                Key::ArrowDown,
2502                0usize,
2503                "first ArrowDown selects the first row",
2504            ),
2505            (Key::ArrowUp, 9usize, "first ArrowUp selects the last row"),
2506        ] {
2507            let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2508            let selection = SelectionModel::new(SelectionMode::Single);
2509            let mut tree = WidgetTree::new();
2510            let lv_id = tree.add(
2511                ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2512                    .item_height(20.0)
2513                    .selection(selection.clone()),
2514            );
2515            tree.layout(SizeProposal::exact(400.0, 300.0));
2516            tree.focus(lv_id);
2517            assert!(
2518                selection.selected_indices().is_empty(),
2519                "precondition: nothing selected, no cursor"
2520            );
2521
2522            tree.press_key(key, Modifiers::NONE);
2523            assert_eq!(selection.selected_indices(), vec![want], "{what}");
2524        }
2525    }
2526
2527    #[test]
2528    fn keyboard_cursor_starts_from_a_preset_selection() {
2529        // A view can be handed a selection before it is ever focused (a
2530        // launcher preselecting the top entry). The first arrow key must
2531        // continue from that visible row rather than from an invisible zero —
2532        // otherwise Down on a preselected row 2 would jump backwards to row 0.
2533        use teksilo_core::event::{Key, Modifiers};
2534        use teksilo_data::{SelectionMode, SelectionModel};
2535
2536        let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
2537        let selection = SelectionModel::new(SelectionMode::Single);
2538        selection.select(2);
2539        let mut tree = WidgetTree::new();
2540        let lv_id = tree.add(
2541            ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
2542                .item_height(20.0)
2543                .selection(selection.clone()),
2544        );
2545        tree.layout(SizeProposal::exact(400.0, 300.0));
2546        tree.focus(lv_id);
2547
2548        tree.press_key(Key::ArrowDown, Modifiers::NONE);
2549        assert_eq!(
2550            selection.selected_indices(),
2551            vec![3],
2552            "Down from a preselected row 2 continues to 3"
2553        );
2554        tree.press_key(Key::ArrowUp, Modifiers::NONE);
2555        tree.press_key(Key::ArrowUp, Modifiers::NONE);
2556        assert_eq!(selection.selected_indices(), vec![1], "and Up walks back");
2557    }
2558
2559    #[test]
2560    fn checkbox_press_does_not_select_row() {
2561        // Regression: pressing an embedded checkbox toggles it but must NOT
2562        // select the row. The row's select-on-press handler yields to the
2563        // checkbox's own tap via `ctx.press_claimed_by_interactive_child()`.
2564        use crate::styles::recipe_standard_item_style as si;
2565        use teksilo_canvas::Point;
2566        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2567        use teksilo_data::{SelectionMode, SelectionModel};
2568        use teksilo_i18n::lit;
2569
2570        let model = ListModel::from_vec(vec!["alpha", "beta", "gamma"]);
2571        let checks: Vec<Signal<bool>> = (0..3).map(|_| Signal::new(false)).collect();
2572        let checks_for_rows = checks.clone();
2573        let selection = SelectionModel::new(SelectionMode::Single);
2574        let sel = selection.clone();
2575        let mut tree = WidgetTree::new();
2576        let lv_id = tree.add(
2577            ListView::new(model, move |i, _item, _selected| {
2578                Box::new(
2579                    crate::StandardListItem::new(lit!(format!("row {i}")))
2580                        .checkbox(checks_for_rows[i].clone()),
2581                ) as Box<dyn Widget>
2582            })
2583            .item_height(40.0)
2584            .selection(sel),
2585        );
2586        tree.layout(SizeProposal::exact(400.0, 300.0));
2587
2588        let rows = row_ids(&tree, lv_id);
2589        let row0 = tree.bounds(rows[0]);
2590        let press = |t: &mut WidgetTree, x: f32, y: f32| {
2591            t.dispatch_event(WidgetEvent::PointerDown {
2592                position: Point::new(x, y),
2593                button: PointerButton::Primary,
2594                modifiers: Modifiers::NONE,
2595            });
2596            t.dispatch_event(WidgetEvent::PointerUp {
2597                position: Point::new(x, y),
2598                button: PointerButton::Primary,
2599                modifiers: Modifiers::NONE,
2600            });
2601        };
2602
2603        // Press the embedded checkbox (leading edge): toggles it, must NOT select.
2604        let cb_x = row0.x
2605            + si::STANDARD_ITEM_BG_HORIZONTAL_INSET
2606            + si::STANDARD_ITEM_PADDING_HORIZONTAL
2607            + 4.0;
2608        let cb_y = row0.y + row0.height * 0.5;
2609        press(&mut tree, cb_x, cb_y);
2610        assert!(checks[0].get(), "checkbox press should toggle the checkbox");
2611        assert!(
2612            selection.selected_indices().is_empty(),
2613            "checkbox press must not select the row (got {:?})",
2614            selection.selected_indices()
2615        );
2616
2617        // Press the row body (far right of the checkbox): selects, no toggle.
2618        let body_x = row0.x + row0.width * 0.7;
2619        press(&mut tree, body_x, cb_y);
2620        assert_eq!(
2621            selection.selected_indices(),
2622            vec![0],
2623            "body press should select row 0"
2624        );
2625        assert!(
2626            checks[0].get(),
2627            "body press must not toggle the checkbox back"
2628        );
2629    }
2630
2631    #[test]
2632    fn virtualization_creates_only_visible_items() {
2633        let (mut tree, lv_id, _model) = make_list_view(10_000, 30.0);
2634        // Viewport: 300px tall, items 30px each = ~10 visible + 2*5 buffer = ~20
2635        tree.layout(SizeProposal::exact(400.0, 300.0));
2636
2637        let children = row_ids(&tree, lv_id);
2638        // children includes items + 1 scrollbar
2639        let item_count = children.len() - 1;
2640        assert!(
2641            item_count < 30,
2642            "Expected fewer than 30 items, got {}",
2643            item_count
2644        );
2645        assert!(
2646            item_count >= 10,
2647            "Expected at least 10 items, got {}",
2648            item_count
2649        );
2650    }
2651
2652    #[test]
2653    fn empty_model_shows_scrollbar_only() {
2654        let (mut tree, lv_id, _model) = make_list_view(0, 30.0);
2655        tree.layout(SizeProposal::exact(400.0, 300.0));
2656
2657        // The pane is mounted even with no data (it is the stable sibling the
2658        // scrollbar needs), and realizes no rows.
2659        assert_eq!(tree.children(lv_id).len(), 2, "body pane + scrollbar");
2660        assert!(
2661            row_ids(&tree, lv_id).is_empty(),
2662            "no rows for an empty model"
2663        );
2664    }
2665
2666    #[test]
2667    fn data_change_triggers_rebuild() {
2668        let (mut tree, lv_id, model) = make_list_view(5, 30.0);
2669        tree.layout(SizeProposal::exact(400.0, 300.0));
2670
2671        let initial_items = row_ids(&tree, lv_id).len(); // minus scrollbar
2672        assert_eq!(initial_items, 5);
2673
2674        model.push(99);
2675        tree.layout(SizeProposal::exact(400.0, 300.0));
2676
2677        let new_items = row_ids(&tree, lv_id).len();
2678        assert_eq!(new_items, 6);
2679    }
2680
2681    #[test]
2682    fn remove_triggers_rebuild() {
2683        let (mut tree, lv_id, model) = make_list_view(5, 30.0);
2684        tree.layout(SizeProposal::exact(400.0, 300.0));
2685        assert_eq!(row_ids(&tree, lv_id).len(), 5);
2686
2687        model.remove(0);
2688        tree.layout(SizeProposal::exact(400.0, 300.0));
2689        assert_eq!(row_ids(&tree, lv_id).len(), 4);
2690    }
2691
2692    #[test]
2693    fn items_positioned_correctly() {
2694        let (mut tree, lv_id, _model) = make_list_view(3, 40.0);
2695        tree.layout(SizeProposal::exact(400.0, 300.0));
2696
2697        let children = row_ids(&tree, lv_id);
2698        // Items should be at y=0, y=40, y=80
2699        let y0 = tree.bounds(children[0]).y;
2700        let y1 = tree.bounds(children[1]).y;
2701        let y2 = tree.bounds(children[2]).y;
2702        assert!((y0 - 0.0).abs() < 0.01);
2703        assert!((y1 - 40.0).abs() < 0.01);
2704        assert!((y2 - 80.0).abs() < 0.01);
2705    }
2706
2707    #[test]
2708    fn items_have_correct_height() {
2709        let (mut tree, lv_id, _model) = make_list_view(3, 40.0);
2710        tree.layout(SizeProposal::exact(400.0, 300.0));
2711
2712        let children = row_ids(&tree, lv_id);
2713        for i in 0..3 {
2714            let h = tree.bounds(children[i]).height;
2715            assert!((h - 40.0).abs() < 0.01, "Item {} height {} != 40.0", i, h);
2716        }
2717    }
2718
2719    #[test]
2720    fn scrollbar_positioned_on_right_edge() {
2721        let (mut tree, lv_id, _model) = make_list_view(100, 30.0);
2722        tree.layout(SizeProposal::exact(400.0, 300.0));
2723
2724        let sb_bounds = tree.bounds(scrollbar_of(&tree, lv_id));
2725        // Scrollbar should be at right edge
2726        assert!(
2727            (sb_bounds.x - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
2728            "Scrollbar x {} != {}",
2729            sb_bounds.x,
2730            400.0 - SCROLLBAR_THICKNESS
2731        );
2732        assert!((sb_bounds.height - 300.0).abs() < 0.01);
2733    }
2734
2735    #[test]
2736    fn small_list_collapses_scrollbar() {
2737        let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2738        // 3 items * 30px = 90px < 300px viewport
2739        tree.layout(SizeProposal::exact(400.0, 300.0));
2740
2741        let sb_bounds = tree.bounds(scrollbar_of(&tree, lv_id));
2742        assert!(
2743            sb_bounds.width < 0.01 && sb_bounds.height < 0.01,
2744            "Scrollbar should be collapsed for small lists"
2745        );
2746    }
2747
2748    #[test]
2749    fn item_width_leaves_room_for_scrollbar() {
2750        let (mut tree, lv_id, _model) = make_list_view(100, 30.0);
2751        tree.layout(SizeProposal::exact(400.0, 300.0));
2752
2753        let children = row_ids(&tree, lv_id);
2754        let item_width = tree.bounds(children[0]).width;
2755        assert!(
2756            (item_width - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
2757            "Item width {} should be {}",
2758            item_width,
2759            400.0 - SCROLLBAR_THICKNESS
2760        );
2761    }
2762
2763    #[test]
2764    fn small_list_items_use_full_width() {
2765        let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
2766        // 3 items * 30px = 90px < 300px viewport — no scrollbar needed
2767        tree.layout(SizeProposal::exact(400.0, 300.0));
2768
2769        let children = row_ids(&tree, lv_id);
2770        let item_width = tree.bounds(children[0]).width;
2771        assert!(
2772            (item_width - 400.0).abs() < 0.01,
2773            "Small list item width {} should be full 400.0 (no scrollbar)",
2774            item_width,
2775        );
2776    }
2777
2778    // --- Selection tests ---
2779
2780    fn make_selectable_list(
2781        count: usize,
2782    ) -> (
2783        WidgetTree,
2784        WidgetId,
2785        ListModel<usize>,
2786        teksilo_data::SelectionModel,
2787    ) {
2788        use teksilo_data::{SelectionMode, SelectionModel};
2789        let model = ListModel::from_vec((0..count).collect());
2790        let selection = SelectionModel::new(SelectionMode::Multi);
2791        let sel_clone = selection.clone();
2792        let mut tree = WidgetTree::new();
2793        let lv_id = tree.add(
2794            ListView::new(model.clone(), move |_i, _item, _selected| {
2795                Box::new(FixedLeaf(100.0, 30.0))
2796            })
2797            .item_height(30.0)
2798            .selection(sel_clone),
2799        );
2800        tree.layout(SizeProposal::exact(400.0, 300.0));
2801        (tree, lv_id, model, selection)
2802    }
2803
2804    #[test]
2805    fn click_selects_item() {
2806        let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2807        // Click the second item (y = 30..60, center at 45)
2808        let children = row_ids(&tree, lv_id);
2809        tree.click(children[1]);
2810        assert!(selection.is_selected(1), "item 1 should be selected");
2811        assert!(!selection.is_selected(0), "item 0 should not be selected");
2812    }
2813
2814    #[test]
2815    fn a_multi_select_list_says_so_and_a_single_select_one_does_not() {
2816        // Left unset the property reads false, so a multi-select list was
2817        // telling every screen reader that one row was the most it would hold.
2818        //
2819        // The negative half is not symmetry for its own sake.
2820        // `accesskit_windows` chooses the event it raises on a selection change
2821        // from this property (`adapter.rs:189-199`): `ElementAddedToSelection`
2822        // when it is true, `ElementSelected` when it is false. A single-select
2823        // list publishing `true` would trade the right event for the wrong one.
2824        use teksilo_data::{SelectionMode, SelectionModel};
2825
2826        let published = |mode: SelectionMode| {
2827            let model = ListModel::from_vec(vec![1, 2, 3]);
2828            let mut tree = WidgetTree::new();
2829            // The view's own id is not needed: the node is found by role,
2830            // which is how an adapter finds it too.
2831            tree.add(
2832                ListView::new(model, move |_i, _item, _selected| {
2833                    Box::new(FixedLeaf(100.0, 30.0))
2834                })
2835                .item_height(30.0)
2836                .selection(SelectionModel::new(mode)),
2837            );
2838            tree.layout(SizeProposal::exact(400.0, 300.0));
2839            // Read off the real node rather than the `AccessibilityInfo`
2840            // summary, which carries no such field: the property only exists
2841            // where an adapter would look for it.
2842            let snapshot = tree.accessibility_tree_snapshot();
2843            let consumer = accesskit_consumer::Tree::new(snapshot, true);
2844            let root = consumer.state().root();
2845            fn listbox<'a>(
2846                node: accesskit_consumer::NodeRef<'a>,
2847            ) -> Option<accesskit_consumer::NodeRef<'a>> {
2848                if node.role() == teksilo_core::accesskit::Role::ListBox {
2849                    return Some(node);
2850                }
2851                node.children().find_map(listbox)
2852            }
2853            listbox(root)
2854                .expect("the view publishes a ListBox")
2855                .is_multiselectable()
2856        };
2857
2858        assert!(
2859            published(SelectionMode::Multi),
2860            "a list that takes more than one row has to say so"
2861        );
2862        assert!(
2863            !published(SelectionMode::Single),
2864            "and one that does not must not, or Windows raises the wrong event"
2865        );
2866    }
2867
2868    #[test]
2869    fn moving_the_selection_keeps_every_row_node() {
2870        // Arrowing down replaced every realized row widget, and with them
2871        // every AccessKit node id in the list. The `active_descendant` the
2872        // container publishes then names a node the screen reader has never
2873        // seen, and the scroll anchor is reset under the user.
2874        //
2875        // Only two rows change when the selection moves: the one that lost it
2876        // and the one that gained it. Every other row is identical, and the
2877        // wrapper nodes must survive even for those two.
2878        let (mut tree, lv_id, _model, selection) = make_selectable_list(5);
2879        selection.select(0);
2880        tree.layout(SizeProposal::exact(400.0, 300.0));
2881        let before = row_ids(&tree, lv_id);
2882        assert_eq!(
2883            before.len(),
2884            5,
2885            "all five rows realize in a 300 px viewport"
2886        );
2887
2888        selection.select(1);
2889        tree.layout(SizeProposal::exact(400.0, 300.0));
2890        let after = row_ids(&tree, lv_id);
2891
2892        assert_eq!(
2893            before, after,
2894            "a selection move must not replace the row nodes"
2895        );
2896    }
2897
2898    #[test]
2899    fn click_replaces_selection() {
2900        let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2901        let children = row_ids(&tree, lv_id);
2902        tree.click(children[0]);
2903        assert!(selection.is_selected(0));
2904
2905        tree.click(children[2]);
2906        assert!(selection.is_selected(2));
2907        assert!(
2908            !selection.is_selected(0),
2909            "previous selection should be cleared"
2910        );
2911    }
2912
2913    #[test]
2914    fn keyed_selection_tracks_identity_not_index() {
2915        // from_source_keyed wires a KeyedSelectionModel<S::Key>: a click stores
2916        // the row's KEY (not its index), proving the index↔key translation.
2917        use std::rc::Rc;
2918        use teksilo_core::ObserverHandle;
2919        use teksilo_data::{KeyedSelectionModel, ListDataSource, SelectionMode};
2920
2921        struct KeyedSource {
2922            items: Vec<(u64, usize)>, // (stable key, value)
2923        }
2924        impl ListDataSource for KeyedSource {
2925            type Item = usize;
2926            type Key = u64;
2927            fn len(&self) -> usize {
2928                self.items.len()
2929            }
2930            fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
2931                self.items.get(i).map(|(_, v)| f(v))
2932            }
2933            fn key_at(&self, i: usize) -> Option<u64> {
2934                self.items.get(i).map(|(k, _)| *k)
2935            }
2936            fn index_of(&self, key: &u64) -> Option<usize> {
2937                self.items.iter().position(|(k, _)| k == key)
2938            }
2939            fn observe_changes(
2940                &self,
2941                _f: impl Fn(&teksilo_data::DataChange) + 'static,
2942            ) -> ObserverHandle {
2943                ObserverHandle::new(Rc::new(()) as Rc<dyn std::any::Any>, 0, Rc::new(|_| {}))
2944            }
2945        }
2946
2947        let keyed = KeyedSelectionModel::<u64>::new(SelectionMode::Single);
2948        let source = KeyedSource {
2949            items: vec![(10, 100), (20, 200), (30, 300)],
2950        };
2951        let mut tree = WidgetTree::new();
2952        let lv_id = tree.add(
2953            ListView::from_source_keyed(source, keyed.clone(), |_i, _v, _sel| {
2954                Box::new(FixedLeaf(100.0, 30.0))
2955            })
2956            .item_height(30.0),
2957        );
2958        tree.layout(SizeProposal::exact(400.0, 300.0));
2959
2960        // Click row 1 → the keyed model stores key 20, not index 1.
2961        let children = row_ids(&tree, lv_id);
2962        tree.click(children[1]);
2963        assert!(keyed.is_selected(&20), "selection is stored by key");
2964        assert_eq!(keyed.selected_keys(), vec![20]);
2965        assert!(!keyed.is_selected(&10));
2966    }
2967
2968    #[test]
2969    fn ctrl_click_toggles() {
2970        use teksilo_core::event::Modifiers;
2971        let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2972        let children = row_ids(&tree, lv_id);
2973
2974        // Select item 0
2975        tree.click(children[0]);
2976        assert!(selection.is_selected(0));
2977
2978        // Ctrl+click item 2 to add it
2979        let center = tree.bounds(children[2]).center();
2980        tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
2981            position: center,
2982            button: teksilo_core::event::PointerButton::Primary,
2983            modifiers: Modifiers::COMMAND,
2984        });
2985        tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerUp {
2986            position: center,
2987            button: teksilo_core::event::PointerButton::Primary,
2988            modifiers: Modifiers::COMMAND,
2989        });
2990
2991        assert!(selection.is_selected(0), "item 0 should still be selected");
2992        assert!(selection.is_selected(2), "item 2 should be toggled on");
2993    }
2994
2995    #[test]
2996    fn shift_click_extends_range() {
2997        use teksilo_core::event::Modifiers;
2998        let (mut tree, lv_id, _, selection) = make_selectable_list(5);
2999        let children = row_ids(&tree, lv_id);
3000
3001        // Select item 1 as anchor
3002        tree.click(children[1]);
3003        assert!(
3004            selection.is_selected(1),
3005            "item 1 should be selected after plain click"
3006        );
3007
3008        // Shift+click item 3 — should extend from anchor (1) to 3
3009        let center = tree.bounds(children[3]).center();
3010        tree.dispatch_event(teksilo_core::event::WidgetEvent::PointerDown {
3011            position: center,
3012            button: teksilo_core::event::PointerButton::Primary,
3013            modifiers: Modifiers::SHIFT,
3014        });
3015
3016        let selected = selection.selected_indices();
3017        assert_eq!(
3018            selected,
3019            vec![1, 2, 3],
3020            "Shift+click should select range 1..=3, got {:?}",
3021            selected
3022        );
3023    }
3024
3025    // --- Scroll boundary tests ---
3026
3027    #[test]
3028    fn scroll_changes_visible_items() {
3029        // 100 items at 30px each. Viewport 300px → ~10 visible at a time.
3030        let model = ListModel::from_vec((0..100).collect());
3031        let mut tree = WidgetTree::new();
3032        let lv_id = tree.add(
3033            ListView::new(model.clone(), move |i, _item, _selected| {
3034                // Encode model index in the leaf width so we can verify which items are visible
3035                Box::new(FixedLeaf(i as f32, 30.0))
3036            })
3037            .item_height(30.0),
3038        );
3039        tree.layout(SizeProposal::exact(400.0, 300.0));
3040
3041        // Initially: items near index 0 should be visible
3042        let children = row_ids(&tree, lv_id);
3043        let first_y = tree.bounds(children[0]).y;
3044        assert!(
3045            first_y.abs() < 30.0,
3046            "First visible item should be near the top, got y={}",
3047            first_y
3048        );
3049
3050        // Scroll down by 1500px (50 items * 30px)
3051        tree.dispatch_event(teksilo_core::event::WidgetEvent::Scroll {
3052            delta: teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 1500.0 },
3053            modifiers: Default::default(),
3054        });
3055        tree.layout(SizeProposal::exact(400.0, 300.0));
3056
3057        // After scroll: the first item's Y should be near 0 (scroll offset applied),
3058        // and crucially it should NOT be the same items as before scroll.
3059        let children_after = row_ids(&tree, lv_id);
3060        let item_count_after = children_after.len() - 1;
3061        assert!(
3062            item_count_after > 0,
3063            "Should have visible items after scroll"
3064        );
3065
3066        // The first visible item after scrolling 1500px should be positioned
3067        // near the top of the viewport. Its model position is ~index 50 (1500/30),
3068        // so its pre-scroll Y would have been 1500. After scroll offset, it's near 0.
3069        let first_y_after = tree.bounds(children_after[0]).y;
3070        assert!(
3071            first_y_after < 300.0,
3072            "First item should be in viewport after scroll, got y={}",
3073            first_y_after
3074        );
3075
3076        // The pre-scroll first item was at y≈0. After scrolling, the first rendered
3077        // item should be at a different content position (not the same item).
3078        // We can verify by checking that the first item's Y is NOT at the same
3079        // content position as before. Before: item index 0 at y=0.
3080        // After: the first rendered item's content Y = first_y_after + 1500 ≈ 1500,
3081        // which corresponds to index ~50. So it's different items.
3082        // More directly: if we had the same items, their Y would be far outside
3083        // the viewport (y = 0 - 1500 = -1500), but we see y < 300.
3084        // This proves the ListView rebuilt with a different visible range.
3085
3086        // Also verify we still have roughly the right count (not all 100)
3087        assert!(
3088            item_count_after < 30,
3089            "Should still be virtualized after scroll, got {} items",
3090            item_count_after
3091        );
3092    }
3093
3094    // --- AccessKit tests ---
3095
3096    #[test]
3097    fn list_item_has_a11y_role() {
3098        let (mut tree, lv_id, _model) = make_list_view(3, 30.0);
3099        tree.layout(SizeProposal::exact(400.0, 300.0));
3100
3101        // The direct children of ListView are ListItemWrappers (+ scrollbar)
3102        let children = row_ids(&tree, lv_id);
3103        let info = tree.accessibility_node(children[0]);
3104        assert_eq!(
3105            info.role(),
3106            teksilo_core::accesskit::Role::ListBoxOption,
3107            "Item wrapper should have ListBoxOption role"
3108        );
3109    }
3110
3111    // --- Alt+Arrow reorder test ---
3112
3113    #[test]
3114    fn alt_arrow_moves_one_step_per_press_across_rebuilds() {
3115        // Regression for the "moves several lines per press" bug: rebuilds
3116        // were accumulating on_key handlers via HandlerSet merge semantics,
3117        // so the Nth Alt+Arrow press fired the reorder N times. Force a few
3118        // rebuilds (by mutating the selection signal) before pressing the
3119        // key, then confirm the item moves exactly one position.
3120        use teksilo_core::event::{Key, Modifiers};
3121        use teksilo_data::{SelectionMode, SelectionModel};
3122
3123        let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3124        let selection = SelectionModel::new(SelectionMode::Single);
3125        let sel_clone = selection.clone();
3126        let model_clone = model.clone();
3127
3128        let mut tree = WidgetTree::new();
3129        let lv_id = tree.add(
3130            ListView::new(model_clone, move |_i, _item, _sel| {
3131                Box::new(FixedLeaf(100.0, 30.0))
3132            })
3133            .item_height(30.0)
3134            .selection(sel_clone)
3135            .reorderable(true),
3136        );
3137        tree.layout(SizeProposal::exact(400.0, 300.0));
3138
3139        // Force several rebuilds by toggling the selection a few times.
3140        // Each rebuild would previously merge a fresh on_key handler onto the
3141        // existing chain.
3142        for i in 0..3 {
3143            selection.select(i);
3144            tree.layout(SizeProposal::exact(400.0, 300.0));
3145        }
3146
3147        selection.select(0);
3148        tree.layout(SizeProposal::exact(400.0, 300.0));
3149
3150        tree.focus(lv_id);
3151        tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
3152            key: Key::ArrowDown,
3153            modifiers: Modifiers::ALT,
3154            text: None,
3155        });
3156
3157        // Expect a single swap: [10,20,30,40,50] → [20,10,30,40,50].
3158        assert_eq!(model.with_item(0, |v| *v), Some(20));
3159        assert_eq!(model.with_item(1, |v| *v), Some(10));
3160        assert_eq!(model.with_item(2, |v| *v), Some(30));
3161    }
3162
3163    /// A 100-row list, ~10 rows to a viewport, focused and ready for keys.
3164    /// Returns the scroll signal too, so a test can assert that a key moved
3165    /// the viewport and not just the selection.
3166    fn keyboard_fixture(
3167        mode: teksilo_data::SelectionMode,
3168    ) -> (
3169        WidgetTree,
3170        teksilo_data::SelectionModel,
3171        Signal<f32>,
3172        SizeProposal,
3173    ) {
3174        use teksilo_data::SelectionModel;
3175        let model = ListModel::from_vec((0..100usize).collect());
3176        let selection = SelectionModel::new(mode);
3177        let sel = selection.clone();
3178        let mut tree = WidgetTree::new();
3179        let view = ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3180            .item_height(20.0)
3181            .selection(sel);
3182        let scroll = view.scroll_y_signal().clone();
3183        let lv = tree.add(view);
3184        let p = SizeProposal::exact(400.0, 200.0);
3185        tree.layout(p);
3186        tree.focus(lv);
3187        (tree, selection, scroll, p)
3188    }
3189
3190    #[test]
3191    fn home_and_end_reach_the_ends_of_the_collection() {
3192        use teksilo_core::event::{Key, Modifiers};
3193        let (mut tree, selection, _scroll, p) =
3194            keyboard_fixture(teksilo_data::SelectionMode::Single);
3195        selection.select(40);
3196
3197        tree.press_key(Key::End, Modifiers::NONE);
3198        tree.layout(p);
3199        assert_eq!(selection.selected_indices(), vec![99]);
3200
3201        tree.press_key(Key::Home, Modifiers::NONE);
3202        tree.layout(p);
3203        assert_eq!(selection.selected_indices(), vec![0]);
3204    }
3205
3206    #[test]
3207    fn home_and_end_scroll_the_row_they_land_on_into_view() {
3208        use teksilo_core::event::{Key, Modifiers};
3209        let (mut tree, selection, scroll, p) =
3210            keyboard_fixture(teksilo_data::SelectionMode::Single);
3211        selection.select(0);
3212        tree.layout(p);
3213
3214        tree.press_key(Key::End, Modifiers::NONE);
3215        tree.layout(p);
3216        // 100 rows of 20 dp in a 200 dp viewport: the last row is only visible
3217        // once the offset reaches the bottom of the content.
3218        let at_end = scroll.get();
3219        assert!(
3220            at_end > 1500.0,
3221            "End should scroll to the bottom of the content, got {at_end}"
3222        );
3223
3224        tree.press_key(Key::Home, Modifiers::NONE);
3225        tree.layout(p);
3226        assert!(scroll.get() < 1.0, "Home should scroll back to the top");
3227    }
3228
3229    #[test]
3230    fn the_accelerator_moves_the_cursor_without_disturbing_the_selection() {
3231        use teksilo_core::event::{Key, Modifiers};
3232        let (mut tree, selection, _scroll, p) =
3233            keyboard_fixture(teksilo_data::SelectionMode::Multi);
3234        selection.select(40);
3235
3236        // Ctrl/⌘+End walks the cursor to the last row and leaves row 40 picked
3237        // — the rule GTK4 and Qt apply to every navigation key, and the one
3238        // Ctrl+Arrow already followed here.
3239        tree.press_key(Key::End, Modifiers::COMMAND);
3240        tree.layout(p);
3241        assert_eq!(selection.selected_indices(), vec![40]);
3242
3243        tree.press_key(Key::Home, Modifiers::COMMAND);
3244        tree.layout(p);
3245        assert_eq!(selection.selected_indices(), vec![40]);
3246
3247        // The cursor really did move, so a plain Home now selects where it is.
3248        tree.press_key(Key::PageDown, Modifiers::COMMAND);
3249        tree.layout(p);
3250        assert_eq!(selection.selected_indices(), vec![40]);
3251    }
3252
3253    #[test]
3254    fn shift_end_then_shift_home_selects_one_range_not_the_whole_list() {
3255        use teksilo_core::event::{Key, Modifiers};
3256        let (mut tree, selection, _scroll, p) =
3257            keyboard_fixture(teksilo_data::SelectionMode::Multi);
3258        selection.select(10);
3259
3260        tree.press_key(Key::End, Modifiers::SHIFT);
3261        tree.layout(p);
3262        assert_eq!(selection.count(), 90, "10..=99");
3263
3264        tree.press_key(Key::Home, Modifiers::SHIFT);
3265        tree.layout(p);
3266        assert_eq!(
3267            selection.selected_indices(),
3268            (0..=10).collect::<Vec<_>>(),
3269            "reversing the gesture must shrink the range, not union with it"
3270        );
3271    }
3272
3273    #[test]
3274    fn ctrl_shift_end_extends_without_losing_an_earlier_pick() {
3275        use teksilo_core::event::{Key, Modifiers};
3276        let (mut tree, selection, _scroll, p) =
3277            keyboard_fixture(teksilo_data::SelectionMode::Multi);
3278        selection.select(0);
3279        selection.toggle(95); // a disjoint pick; anchor moves to 95
3280
3281        tree.press_key(Key::End, Modifiers::COMMAND | Modifiers::SHIFT);
3282        tree.layout(p);
3283        let got = selection.selected_indices();
3284        assert_eq!(got.first().copied(), Some(0), "the earlier pick survives");
3285        assert_eq!(got.last().copied(), Some(99));
3286        assert_eq!(got.len(), 6, "0 plus 95..=99");
3287    }
3288
3289    #[test]
3290    fn ctrl_shift_a_deselects_everything() {
3291        use teksilo_core::event::{Key, Modifiers};
3292        let (mut tree, selection, _scroll, p) =
3293            keyboard_fixture(teksilo_data::SelectionMode::Multi);
3294        tree.press_key(Key::A, Modifiers::COMMAND);
3295        tree.layout(p);
3296        assert_eq!(selection.count(), 100);
3297
3298        tree.press_key(Key::A, Modifiers::COMMAND | Modifiers::SHIFT);
3299        tree.layout(p);
3300        assert_eq!(selection.count(), 0);
3301    }
3302
3303    #[test]
3304    fn page_down_up_moves_selection_by_viewport() {
3305        use teksilo_core::event::{Key, Modifiers};
3306        use teksilo_data::{SelectionMode, SelectionModel};
3307
3308        let model = ListModel::from_vec((0..100usize).collect());
3309        let selection = SelectionModel::new(SelectionMode::Single);
3310        let sel = selection.clone();
3311        let mut tree = WidgetTree::new();
3312        let lv = tree.add(
3313            ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3314                .item_height(20.0)
3315                .selection(sel),
3316        );
3317        let p = SizeProposal::exact(400.0, 200.0); // ~10 rows visible
3318        tree.layout(p);
3319        tree.focus(lv);
3320        selection.select(0);
3321
3322        tree.press_key(Key::PageDown, Modifiers::NONE);
3323        tree.layout(p);
3324        let after_pgdn = selection.selected_indices()[0];
3325        assert!(
3326            after_pgdn >= 8,
3327            "PageDown should advance ~one viewport of rows, got {after_pgdn}"
3328        );
3329        let scroll = with_list_view::<usize, _>(&tree, lv, |v| v.scroll_y_signal().get());
3330        assert!(scroll > 0.0, "PageDown scrolls to follow, got {scroll}");
3331
3332        tree.press_key(Key::PageUp, Modifiers::NONE);
3333        tree.layout(p);
3334        assert!(
3335            selection.selected_indices()[0] < after_pgdn,
3336            "PageUp should move selection back up"
3337        );
3338    }
3339
3340    #[test]
3341    fn space_toggles_selection_enter_activates() {
3342        use std::cell::Cell;
3343        use teksilo_core::event::{Key, Modifiers};
3344        use teksilo_data::{SelectionMode, SelectionModel};
3345
3346        let model = ListModel::from_vec((0..5usize).collect());
3347        let selection = SelectionModel::new(SelectionMode::Multi);
3348        let sel = selection.clone();
3349        let activated = Rc::new(Cell::new(None));
3350        let act = activated.clone();
3351        let mut tree = WidgetTree::new();
3352        let lv = tree.add(
3353            ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3354                .item_height(20.0)
3355                .selection(sel)
3356                .on_activate(move |i, _ctx| act.set(Some(i))),
3357        );
3358        tree.layout(SizeProposal::exact(400.0, 200.0));
3359        tree.focus(lv);
3360
3361        // Move the cursor to row 2: the first Down lands ON row 0 (it does not
3362        // skip it), so it takes three.
3363        tree.press_key(Key::ArrowDown, Modifiers::NONE);
3364        tree.press_key(Key::ArrowDown, Modifiers::NONE);
3365        tree.press_key(Key::ArrowDown, Modifiers::NONE);
3366        assert_eq!(selection.selected_indices(), vec![2]);
3367        assert_eq!(activated.get(), None, "arrows never activate");
3368
3369        // Space toggles the focused row's selection OFF (Multi), no activate.
3370        tree.press_key(Key::Space, Modifiers::NONE);
3371        assert!(
3372            selection.selected_indices().is_empty(),
3373            "Space toggles row 2 off in Multi mode"
3374        );
3375        assert_eq!(activated.get(), None, "Space must NOT activate");
3376
3377        // Enter activates the focused row (and selects it).
3378        tree.press_key(Key::Enter, Modifiers::NONE);
3379        assert_eq!(activated.get(), Some(2), "Enter activates the focused row");
3380    }
3381
3382    #[test]
3383    fn ctrl_a_selects_all_in_multi_mode() {
3384        use teksilo_core::event::{Key, Modifiers};
3385        use teksilo_data::{SelectionMode, SelectionModel};
3386
3387        let model = ListModel::from_vec((0..6usize).collect());
3388        let selection = SelectionModel::new(SelectionMode::Multi);
3389        let sel = selection.clone();
3390        let mut tree = WidgetTree::new();
3391        let lv = tree.add(
3392            ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3393                .item_height(20.0)
3394                .selection(sel),
3395        );
3396        tree.layout(SizeProposal::exact(400.0, 200.0));
3397        tree.focus(lv);
3398        tree.press_key(Key::A, Modifiers::COMMAND);
3399        assert_eq!(selection.selected_indices().len(), 6, "Ctrl+A selects all");
3400    }
3401
3402    #[test]
3403    fn ctrl_arrow_moves_cursor_without_selecting_in_multi_mode() {
3404        use teksilo_core::event::{Key, Modifiers};
3405        use teksilo_data::{SelectionMode, SelectionModel};
3406
3407        let model = ListModel::from_vec((0..6usize).collect());
3408        let selection = SelectionModel::new(SelectionMode::Multi);
3409        let sel = selection.clone();
3410        let mut tree = WidgetTree::new();
3411        let lv = tree.add(
3412            ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3413                .item_height(20.0)
3414                .selection(sel),
3415        );
3416        tree.layout(SizeProposal::exact(400.0, 200.0));
3417        tree.focus(lv);
3418
3419        // Plain Arrow still selects (the first Down lands ON row 0).
3420        tree.press_key(Key::ArrowDown, Modifiers::NONE);
3421        assert_eq!(selection.selected_indices(), vec![0]);
3422
3423        // Ctrl+ArrowDown moves the cursor without touching the selection.
3424        tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3425        assert_eq!(
3426            selection.selected_indices(),
3427            vec![0],
3428            "Ctrl+ArrowDown must leave the selection unchanged"
3429        );
3430        let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
3431        assert_eq!(focused, Some(1), "Ctrl+ArrowDown moves the cursor to row 1");
3432
3433        tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3434        assert_eq!(selection.selected_indices(), vec![0], "still unchanged");
3435        let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
3436        assert_eq!(focused, Some(2));
3437
3438        // Ctrl+Space toggles the now-focused row (row 2) on, adding to —
3439        // not replacing — the existing selection.
3440        tree.press_key(Key::Space, Modifiers::CTRL);
3441        assert_eq!(selection.selected_indices(), vec![0, 2]);
3442
3443        // Ctrl+Space again toggles it back off.
3444        tree.press_key(Key::Space, Modifiers::CTRL);
3445        assert_eq!(selection.selected_indices(), vec![0]);
3446
3447        // Plain Arrow after a Ctrl-cursor move still replaces the
3448        // selection with the new cursor position (select-follow).
3449        tree.press_key(Key::ArrowDown, Modifiers::NONE);
3450        assert_eq!(selection.selected_indices(), vec![3]);
3451    }
3452
3453    #[test]
3454    fn ctrl_arrow_moves_cursor_without_selecting_in_single_mode() {
3455        use teksilo_core::event::{Key, Modifiers};
3456        use teksilo_data::{SelectionMode, SelectionModel};
3457
3458        let model = ListModel::from_vec((0..6usize).collect());
3459        let selection = SelectionModel::new(SelectionMode::Single);
3460        let sel = selection.clone();
3461        let mut tree = WidgetTree::new();
3462        let lv = tree.add(
3463            ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3464                .item_height(20.0)
3465                .selection(sel),
3466        );
3467        tree.layout(SizeProposal::exact(400.0, 200.0));
3468        tree.focus(lv);
3469
3470        tree.press_key(Key::ArrowDown, Modifiers::NONE);
3471        assert_eq!(selection.selected_indices(), vec![0]);
3472
3473        tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3474        assert_eq!(
3475            selection.selected_indices(),
3476            vec![0],
3477            "Ctrl+ArrowDown must not select in Single mode either"
3478        );
3479        let focused = with_list_view::<usize, _>(&tree, lv, |v| v.focused_index.get());
3480        assert_eq!(focused, Some(1));
3481
3482        tree.press_key(Key::ArrowDown, Modifiers::NONE);
3483        assert_eq!(selection.selected_indices(), vec![2]);
3484    }
3485
3486    #[test]
3487    fn type_ahead_jumps_to_matching_row() {
3488        use teksilo_core::event::{Key, Modifiers};
3489        use teksilo_data::{SelectionMode, SelectionModel};
3490
3491        let model = ListModel::from_vec(vec![
3492            "Apple".to_string(),
3493            "Banana".to_string(),
3494            "Cherry".to_string(),
3495            "Cranberry".to_string(),
3496            "Date".to_string(),
3497        ]);
3498        let selection = SelectionModel::new(SelectionMode::Single);
3499        let sel = selection.clone();
3500        let mut tree = WidgetTree::new();
3501        let lv = tree.add(
3502            ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3503                .item_height(20.0)
3504                .selection(sel)
3505                .type_ahead_label(|s: &String| s.clone()),
3506        );
3507        tree.layout(SizeProposal::exact(400.0, 200.0));
3508        tree.focus(lv);
3509        selection.select(0);
3510
3511        // Type 'c' → jumps to "Cherry" (first item after 0 starting with c).
3512        tree.press_key(Key::C, Modifiers::NONE);
3513        assert_eq!(selection.selected_indices(), vec![2], "'c' → Cherry");
3514
3515        // Type 'r' within timeout → buffer "cr" → "Cranberry".
3516        tree.press_key(Key::R, Modifiers::NONE);
3517        assert_eq!(selection.selected_indices(), vec![3], "'cr' → Cranberry");
3518    }
3519
3520    #[test]
3521    fn type_ahead_buffer_survives_rebuild() {
3522        // The persistent-field design under test: each keystroke changes the
3523        // selection, which schedules a rebuild. Force that rebuild between the
3524        // two keystrokes; the accumulated buffer ("c" then "cr") must survive,
3525        // or multi-char search is impossible.
3526        use teksilo_core::event::{Key, Modifiers};
3527        use teksilo_data::{SelectionMode, SelectionModel};
3528
3529        let model = ListModel::from_vec(vec![
3530            "Apple".to_string(),
3531            "Cherry".to_string(),
3532            "Cranberry".to_string(),
3533        ]);
3534        let selection = SelectionModel::new(SelectionMode::Single);
3535        let sel = selection.clone();
3536        let mut tree = WidgetTree::new();
3537        let p = SizeProposal::exact(400.0, 200.0);
3538        let lv = tree.add(
3539            ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3540                .item_height(20.0)
3541                .selection(sel)
3542                .type_ahead_label(|s: &String| s.clone()),
3543        );
3544        tree.layout(p);
3545        tree.focus(lv);
3546        selection.select(0);
3547
3548        tree.press_key(Key::C, Modifiers::NONE); // → Cherry (idx 1)
3549        assert_eq!(selection.selected_indices(), vec![1]);
3550        tree.layout(p); // <-- the rebuild that would reset a build()-local buffer
3551        tree.press_key(Key::R, Modifiers::NONE); // "cr" → Cranberry (idx 2)
3552        assert_eq!(
3553            selection.selected_indices(),
3554            vec![2],
3555            "buffer 'c' must survive the rebuild so 'cr' matches Cranberry"
3556        );
3557    }
3558
3559    #[test]
3560    fn page_down_on_short_list_jumps_to_last_without_panic() {
3561        use teksilo_core::event::{Key, Modifiers};
3562        use teksilo_data::{SelectionMode, SelectionModel};
3563
3564        // 3 rows in a 200px (~10-row) viewport — content shorter than a page.
3565        let model = ListModel::from_vec((0..3usize).collect());
3566        let selection = SelectionModel::new(SelectionMode::Single);
3567        let sel = selection.clone();
3568        let mut tree = WidgetTree::new();
3569        let lv = tree.add(
3570            ListView::new(model, move |_i, _it, _s| Box::new(FixedLeaf(100.0, 20.0)))
3571                .item_height(20.0)
3572                .selection(sel),
3573        );
3574        tree.layout(SizeProposal::exact(400.0, 200.0));
3575        tree.focus(lv);
3576        selection.select(0);
3577        tree.press_key(Key::PageDown, Modifiers::NONE);
3578        assert_eq!(selection.selected_indices(), vec![2], "PageDown → last row");
3579        tree.press_key(Key::PageDown, Modifiers::NONE);
3580        assert_eq!(selection.selected_indices(), vec![2], "stays at last");
3581        tree.press_key(Key::PageUp, Modifiers::NONE);
3582        assert_eq!(selection.selected_indices(), vec![0], "PageUp → first row");
3583    }
3584
3585    #[test]
3586    fn alt_arrow_reorders_item() {
3587        use teksilo_core::event::{Key, Modifiers};
3588        use teksilo_data::{SelectionMode, SelectionModel};
3589
3590        let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3591        let selection = SelectionModel::new(SelectionMode::Single);
3592        let sel_clone = selection.clone();
3593        let model_clone = model.clone();
3594
3595        let mut tree = WidgetTree::new();
3596        let lv_id = tree.add(
3597            ListView::new(model_clone.clone(), move |_i, _item, _sel| {
3598                Box::new(FixedLeaf(100.0, 30.0))
3599            })
3600            .item_height(30.0)
3601            .selection(sel_clone)
3602            .reorderable(true),
3603        );
3604        tree.layout(SizeProposal::exact(400.0, 300.0));
3605
3606        // Select item at index 2 (value 30)
3607        selection.select(2);
3608
3609        // Focus the ListView and press Alt+ArrowDown
3610        tree.focus(lv_id);
3611        tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyDown {
3612            key: Key::ArrowDown,
3613            modifiers: Modifiers::ALT,
3614            text: None,
3615        });
3616
3617        // Item 30 should now be at index 3
3618        assert_eq!(model.with_item(3, |v| *v), Some(30));
3619        assert_eq!(model.with_item(2, |v| *v), Some(40));
3620    }
3621
3622    // --- Drag-and-drop integration tests ---
3623
3624    /// Build a reorderable ListView at the tree root with the given values.
3625    /// Returns (tree, ListView id, model).
3626    fn make_reorderable_list(
3627        values: Vec<usize>,
3628        item_height: f32,
3629    ) -> (WidgetTree, WidgetId, ListModel<usize>) {
3630        let model = ListModel::from_vec(values);
3631        let model_clone = model.clone();
3632        let mut tree = WidgetTree::new();
3633        let lv_id = tree.add(
3634            ListView::new(model_clone, move |_i, _item, _sel| {
3635                Box::new(FixedLeaf(100.0, item_height))
3636            })
3637            .item_height(item_height)
3638            .reorderable(true),
3639        );
3640        (tree, lv_id, model)
3641    }
3642
3643    /// Run a full drag gesture: PointerDown on source, Move to cross threshold,
3644    /// Move to target, Up.
3645    fn drag_item(tree: &mut WidgetTree, from: Point, to: Point) {
3646        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3647        tree.dispatch_event(WidgetEvent::PointerDown {
3648            position: from,
3649            button: PointerButton::Primary,
3650            modifiers: Modifiers::NONE,
3651        });
3652        // Cross drag threshold (default 5px)
3653        tree.dispatch_event(WidgetEvent::PointerMove {
3654            position: Point::new(from.x + 10.0, from.y),
3655        });
3656        tree.dispatch_event(WidgetEvent::PointerMove { position: to });
3657        tree.dispatch_event(WidgetEvent::PointerUp {
3658            position: to,
3659            button: PointerButton::Primary,
3660            modifiers: Modifiers::NONE,
3661        });
3662    }
3663
3664    #[test]
3665    fn drag_reorders_item_downward() {
3666        let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
3667        tree.layout(SizeProposal::exact(400.0, 300.0));
3668
3669        // Source: item 0 (y=0..30, center y=15). Target: between item 3 and 4
3670        // (y=120; insertion index = round((120 + 15) / 30) = 4 → after index-shift = 3).
3671        let children = row_ids(&tree, lv_id);
3672        let from = tree.bounds(children[0]).center();
3673        let to = Point::new(from.x, 120.0);
3674        drag_item(&mut tree, from, to);
3675
3676        // After move: [20, 30, 40, 10, 50]
3677        assert_eq!(model.with_item(0, |v| *v), Some(20));
3678        assert_eq!(model.with_item(3, |v| *v), Some(10));
3679        assert_eq!(model.with_item(4, |v| *v), Some(50));
3680    }
3681
3682    #[test]
3683    fn drag_reorders_item_upward() {
3684        let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
3685        tree.layout(SizeProposal::exact(400.0, 300.0));
3686
3687        // Source: item 3 (value 40, y=90..120, center y=105). Target: y=15 (just
3688        // below top → insertion index 1).
3689        let children = row_ids(&tree, lv_id);
3690        let from = tree.bounds(children[3]).center();
3691        let to = Point::new(from.x, 15.0);
3692        drag_item(&mut tree, from, to);
3693
3694        // After move: [10, 40, 20, 30, 50]
3695        assert_eq!(model.with_item(1, |v| *v), Some(40));
3696        assert_eq!(model.with_item(2, |v| *v), Some(20));
3697        assert_eq!(model.with_item(3, |v| *v), Some(30));
3698    }
3699
3700    #[test]
3701    fn reorderable_drag_routes_to_source_accept_drop() {
3702        // The redesign's core: a reorderable ListView routes the drop to the
3703        // SOURCE's accept_drop. A source can apply the move to its own store
3704        // (`ListModel` does) or, for an externally-owned store, capture it and
3705        // reconcile later. This source captures (from, to) WITHOUT mutating,
3706        // proving the controlled path with no on_reorder hook.
3707        use std::cell::RefCell;
3708        use std::rc::Rc;
3709        use teksilo_core::ObserverHandle;
3710        use teksilo_data::{
3711            DragEligibility, DragSource, DropCommit, DropPosition, DropQuery, DropResponse,
3712            ListDataSource,
3713        };
3714
3715        struct CapturingSource {
3716            items: Vec<usize>,
3717            captured: Rc<RefCell<Vec<(usize, usize)>>>,
3718        }
3719        impl ListDataSource for CapturingSource {
3720            type Item = usize;
3721            type Key = usize;
3722            fn len(&self) -> usize {
3723                self.items.len()
3724            }
3725            fn with_item<R>(&self, i: usize, f: impl FnOnce(&usize) -> R) -> Option<R> {
3726                self.items.get(i).map(f)
3727            }
3728            fn key_at(&self, i: usize) -> Option<usize> {
3729                (i < self.items.len()).then_some(i)
3730            }
3731            fn observe_changes(
3732                &self,
3733                _f: impl Fn(&teksilo_data::DataChange) + 'static,
3734            ) -> ObserverHandle {
3735                let inner: Rc<dyn std::any::Any> = Rc::new(());
3736                ObserverHandle::new(inner, 0, Rc::new(|_| {}))
3737            }
3738            fn drag(&self, _k: &usize) -> DragEligibility {
3739                DragEligibility::CanDrag
3740            }
3741            fn can_accept(&self, q: &DropQuery<'_, usize>) -> DropResponse {
3742                match &q.source {
3743                    DragSource::SameView { .. } if q.position != DropPosition::Into => {
3744                        DropResponse::Accept
3745                    }
3746                    _ => DropResponse::Reject,
3747                }
3748            }
3749            fn accept_drop(&self, c: DropCommit<'_, usize>) -> bool {
3750                let DragSource::SameView { key: from } = c.source else {
3751                    return false;
3752                };
3753                let target = c.target;
3754                let shift = if from < target { 1 } else { 0 };
3755                let to = match c.position {
3756                    DropPosition::Before => target.saturating_sub(shift),
3757                    DropPosition::After => (target + 1).saturating_sub(shift),
3758                    DropPosition::Into => return false,
3759                };
3760                // Controlled: capture the resolved move, do NOT mutate `items`.
3761                self.captured.borrow_mut().push((from, to));
3762                true
3763            }
3764        }
3765
3766        let captured: Rc<RefCell<Vec<(usize, usize)>>> = Rc::new(RefCell::new(Vec::new()));
3767        let source = CapturingSource {
3768            items: vec![10, 20, 30, 40, 50],
3769            captured: captured.clone(),
3770        };
3771        let mut tree = WidgetTree::new();
3772        let lv_id = tree.add(
3773            ListView::from_source(source, move |_i, _item, _sel| {
3774                Box::new(FixedLeaf(100.0, 30.0))
3775            })
3776            .item_height(30.0)
3777            .reorderable(true),
3778        );
3779        tree.layout(SizeProposal::exact(400.0, 300.0));
3780
3781        // Drag item 0 down to y=120 → insertion index 4 → (target 4, Before),
3782        // which the source resolves to the move (from 0, to 3).
3783        let children = row_ids(&tree, lv_id);
3784        let from = tree.bounds(children[0]).center();
3785        let to = Point::new(from.x, 120.0);
3786        drag_item(&mut tree, from, to);
3787
3788        assert_eq!(
3789            *captured.borrow(),
3790            vec![(0, 3)],
3791            "the drop is routed to the source's accept_drop with the resolved move"
3792        );
3793    }
3794
3795    #[test]
3796    fn drag_emits_items_moved_change() {
3797        use std::cell::Cell;
3798        use std::rc::Rc;
3799        use teksilo_data::DataChange;
3800
3801        let (mut tree, lv_id, model) = make_reorderable_list(vec![10, 20, 30, 40, 50], 30.0);
3802        tree.layout(SizeProposal::exact(400.0, 300.0));
3803
3804        let moved = Rc::new(Cell::new(None::<(usize, usize)>));
3805        let moved_clone = moved.clone();
3806        let handle = model.observe_changes(move |change| {
3807            if let DataChange::ItemsMoved { from, to, .. } = change {
3808                moved_clone.set(Some((*from, *to)));
3809            }
3810        });
3811
3812        // Drag item 0 down to index 3
3813        let children = row_ids(&tree, lv_id);
3814        let from = tree.bounds(children[0]).center();
3815        let to = Point::new(from.x, 120.0);
3816        drag_item(&mut tree, from, to);
3817
3818        assert_eq!(moved.get(), Some((0, 3)));
3819        drop(handle);
3820    }
3821
3822    #[test]
3823    fn drag_drop_accounts_for_scroll_offset() {
3824        // 20 items, 30px each (total 600px). Scroll by 60px (2 items) so that
3825        // item 2 sits at tree y=0.
3826        let (mut tree, _lv_id, model) = make_reorderable_list((0..20).collect(), 30.0);
3827        tree.layout(SizeProposal::exact(400.0, 300.0));
3828
3829        // The Scroll event only dispatches to the hovered or focused widget.
3830        // Move the pointer over the ListView so it becomes hovered.
3831        tree.pointer_move(Point::new(50.0, 50.0));
3832        tree.dispatch_event(teksilo_core::event::WidgetEvent::Scroll {
3833            delta: teksilo_core::event::ScrollDelta::Pixels { x: 0.0, y: 60.0 },
3834            modifiers: Default::default(),
3835        });
3836        // Wheel scrolling animates; complete it so the offset is the full
3837        // 60px before the drag math runs.
3838        tree.tick_animations(std::time::Duration::from_millis(200));
3839        tree.layout(SizeProposal::exact(400.0, 300.0));
3840
3841        // Drag from tree y=15 (center of item 2) down to tree y=120 (middle
3842        // of viewport). In the on_drop handler: content_y = 120 + 60 = 180,
3843        // target_index = (180 + 15) / 30 = 6. Source index = 2, from < to, so
3844        // adjusted_to = 5. move_item(2, 5) gives [0, 1, 3, 4, 5, 2, 6, ...].
3845        let from = Point::new(50.0, 15.0);
3846        let to = Point::new(50.0, 120.0);
3847        drag_item(&mut tree, from, to);
3848
3849        assert_eq!(
3850            model.with_item(5, |v| *v),
3851            Some(2),
3852            "Item 2 should land at index 5 after drag with scroll offset"
3853        );
3854        assert_eq!(
3855            model.with_item(2, |v| *v),
3856            Some(3),
3857            "Item 3 should shift up to index 2"
3858        );
3859    }
3860
3861    #[test]
3862    fn click_selects_item_on_reorderable_list_with_selection() {
3863        // Regression — the user reports that after the recent framework
3864        // round they can drag but not select. Reproduce the exact combo:
3865        // a ListView that is BOTH reorderable and selectable, a simple
3866        // click (PointerDown + PointerUp at the same point, no move),
3867        // and assert:
3868        //   1. the SelectionModel signal updates, AND
3869        //   2. a subsequent rebuild re-invokes the delegate with the new
3870        //      `selected` flag so the view actually reflects the change.
3871        use std::cell::Cell;
3872        use std::rc::Rc;
3873        use teksilo_data::{SelectionMode, SelectionModel};
3874
3875        let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3876        let selection = SelectionModel::new(SelectionMode::Single);
3877        let sel_clone = selection.clone();
3878        let model_clone = model.clone();
3879
3880        // Record which indices were delegated as `selected=true` on each
3881        // build pass so we can assert post-click rebuild.
3882        let selected_rebuilds: Rc<std::cell::RefCell<Vec<Vec<usize>>>> =
3883            Rc::new(std::cell::RefCell::new(Vec::new()));
3884        let current_pass: Rc<Cell<Vec<usize>>> = Rc::new(Cell::new(Vec::new()));
3885        let _sr = selected_rebuilds.clone();
3886        let cp = current_pass.clone();
3887
3888        let mut tree = WidgetTree::new();
3889        let lv_id = tree.add(
3890            ListView::new(model_clone, move |index, _item, selected| {
3891                if selected {
3892                    let mut acc = cp.take();
3893                    acc.push(index);
3894                    cp.set(acc);
3895                }
3896                Box::new(FixedLeaf(100.0, 30.0))
3897            })
3898            .item_height(30.0)
3899            .selection(sel_clone)
3900            .reorderable(true),
3901        );
3902        tree.layout(SizeProposal::exact(400.0, 300.0));
3903        selected_rebuilds.borrow_mut().push(current_pass.take());
3904
3905        // Click item 2.
3906        let children = row_ids(&tree, lv_id);
3907        tree.click(children[2]);
3908
3909        // 1. Selection model updated.
3910        assert_eq!(selection.selected_indices(), vec![2]);
3911
3912        // 2. A layout tick after the click must rebuild and deliver the
3913        //    new selection state to the delegate.
3914        tree.layout(SizeProposal::exact(400.0, 300.0));
3915        selected_rebuilds.borrow_mut().push(current_pass.take());
3916
3917        let passes = selected_rebuilds.borrow().clone();
3918        assert_eq!(
3919            passes[0],
3920            Vec::<usize>::new(),
3921            "initial build: nothing selected"
3922        );
3923        assert_eq!(
3924            passes[1],
3925            vec![2],
3926            "post-click rebuild should deliver selected=true for item 2"
3927        );
3928    }
3929
3930    #[test]
3931    fn drag_survives_rebuild_triggered_by_selection() {
3932        // Regression: user clicks a list row (with .selection() set), which
3933        // fires the selection handler → marks the ListView for rebuild. The
3934        // same PointerDown also arms the DragRecognizer on the item wrapper
3935        // and installs pointer capture at that wrapper. When rebuild runs,
3936        // the OLD wrapper is destroyed and NEW wrappers are created. Without
3937        // revalidating `pointer_captured_by`, the next PointerMove is routed
3938        // to the destroyed wrapper id and silently dropped, so the drag
3939        // gesture never progresses past DragRecognizer::Pending and the user
3940        // can select but not drag.
3941        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3942        use teksilo_data::{SelectionMode, SelectionModel};
3943
3944        let model = ListModel::from_vec(vec![10, 20, 30, 40, 50]);
3945        let selection = SelectionModel::new(SelectionMode::Single);
3946        let sel_clone = selection.clone();
3947        let model_clone = model.clone();
3948
3949        let mut tree = WidgetTree::new();
3950        let _lv_id = tree.add(
3951            ListView::new(model_clone, move |_i, _item, _sel| {
3952                Box::new(FixedLeaf(100.0, 30.0))
3953            })
3954            .item_height(30.0)
3955            .selection(sel_clone)
3956            .reorderable(true),
3957        );
3958        tree.layout(SizeProposal::exact(400.0, 300.0));
3959
3960        // Click-and-drag item 0 down to row 3.
3961        //
3962        // PointerDown: fires the selection handler on the wrapper — this
3963        // trips the selection signal, which dirty-marks the ListView for
3964        // rebuild. Bubble reaches the wrapper, arms the gesture arena, and
3965        // captures the pointer at the old wrapper id.
3966        tree.dispatch_event(WidgetEvent::PointerDown {
3967            position: Point::new(50.0, 15.0),
3968            button: PointerButton::Primary,
3969            modifiers: Modifiers::NONE,
3970        });
3971        // Force the rebuild to run *before* the drag progresses — this is
3972        // the ordering the real app hits because layout runs between the
3973        // PointerDown and the first PointerMove. Old wrappers are destroyed
3974        // here; new ones take their place with different widget ids.
3975        tree.layout(SizeProposal::exact(400.0, 300.0));
3976
3977        // Cross drag threshold.
3978        tree.dispatch_event(WidgetEvent::PointerMove {
3979            position: Point::new(60.0, 15.0),
3980        });
3981        // Move to target.
3982        tree.dispatch_event(WidgetEvent::PointerMove {
3983            position: Point::new(60.0, 120.0),
3984        });
3985        tree.dispatch_event(WidgetEvent::PointerUp {
3986            position: Point::new(60.0, 120.0),
3987            button: PointerButton::Primary,
3988            modifiers: Modifiers::NONE,
3989        });
3990
3991        // Item 0 (value 10) should have moved to index 3.
3992        assert_eq!(
3993            model.with_item(3, |v| *v),
3994            Some(10),
3995            "Drag must complete even after the selection-triggered rebuild \
3996             destroyed the originally-captured wrapper"
3997        );
3998    }
3999
4000    #[test]
4001    fn lazy_loading_rows_render_placeholders_and_request_the_window() {
4002        // A windowed source with nothing resident: every visible row is
4003        // `Loading`, so the ListView must render placeholder skeletons (not
4004        // skip the rows) and nudge the source to load the realized window.
4005        use std::cell::RefCell;
4006        use std::ops::Range;
4007        use std::rc::Rc;
4008        use teksilo_core::ObserverHandle;
4009        use teksilo_data::{ListDataSource, RowState};
4010
4011        struct Windowed {
4012            total: usize,
4013            requested: Rc<RefCell<Vec<Range<usize>>>>,
4014        }
4015        impl ListDataSource for Windowed {
4016            type Item = usize;
4017            type Key = usize;
4018            fn len(&self) -> usize {
4019                self.total
4020            }
4021            fn with_item<R>(&self, _i: usize, _f: impl FnOnce(&usize) -> R) -> Option<R> {
4022                None // nothing resident yet
4023            }
4024            fn key_at(&self, i: usize) -> Option<usize> {
4025                (i < self.total).then_some(i)
4026            }
4027            fn row_state(&self, _i: usize) -> RowState {
4028                RowState::Loading
4029            }
4030            fn request_window(&self, range: Range<usize>) {
4031                self.requested.borrow_mut().push(range);
4032            }
4033            fn observe_changes(
4034                &self,
4035                _f: impl Fn(&teksilo_data::DataChange) + 'static,
4036            ) -> ObserverHandle {
4037                let inner: Rc<dyn std::any::Any> = Rc::new(());
4038                ObserverHandle::new(inner, 0, Rc::new(|_| {}))
4039            }
4040        }
4041
4042        let requested = Rc::new(RefCell::new(Vec::new()));
4043        let source = Windowed {
4044            total: 1000,
4045            requested: requested.clone(),
4046        };
4047        let mut tree = WidgetTree::new();
4048        let lv_id = tree.add(
4049            ListView::from_source(source, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4050                .item_height(30.0),
4051        );
4052        tree.layout(SizeProposal::exact(400.0, 300.0));
4053
4054        // 300px / 30px = 10 visible + buffer → the loading rows are realized as
4055        // placeholder child widgets (children minus the scrollbar), NOT skipped.
4056        let placeholder_rows = row_ids(&tree, lv_id).len();
4057        assert!(
4058            placeholder_rows >= 10,
4059            "loading rows must render as placeholders, got {placeholder_rows}"
4060        );
4061        // And the source was asked to load the realized window.
4062        assert!(
4063            !requested.borrow().is_empty(),
4064            "request_window must be called for the visible range"
4065        );
4066    }
4067
4068    /// Helper: borrow the ListView widget at `id` via the downcast hook
4069    /// and run a closure against it.
4070    fn with_list_view<T: 'static, R>(
4071        tree: &WidgetTree,
4072        id: WidgetId,
4073        f: impl FnOnce(&ListView<T>) -> R,
4074    ) -> R {
4075        let any = tree.widget_as_any(id).expect("widget exposes as_any");
4076        let lv = any
4077            .downcast_ref::<ListView<T>>()
4078            .expect("widget is a ListView<T>");
4079        f(lv)
4080    }
4081
4082    #[test]
4083    fn drop_indicator_clears_after_drop() {
4084        // Regression for the "insertion line lingers after drop" bug —
4085        // the ListView's drop_feedback Signal must be None once the
4086        // drag has ended, whether the drop was accepted or not.
4087        let (mut tree, lv_id, _model) = make_reorderable_list(vec![1, 2, 3, 4, 5], 30.0);
4088        tree.layout(SizeProposal::exact(400.0, 300.0));
4089
4090        drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4091
4092        let feedback =
4093            with_list_view::<usize, _>(&tree, lv_id, |lv| lv.drop_feedback_signal().get());
4094        assert!(
4095            feedback.is_none(),
4096            "drop_feedback must be cleared by on_drag_leave after drop, got {:?}",
4097            feedback
4098        );
4099    }
4100
4101    #[test]
4102    fn drag_spawns_preview_overlay_and_cleans_up() {
4103        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
4104
4105        let (mut tree, _lv_id, _model) = make_reorderable_list(vec![1, 2, 3, 4, 5], 30.0);
4106        tree.layout(SizeProposal::exact(400.0, 300.0));
4107
4108        let baseline = tree.overlay_manager().len();
4109
4110        // PointerDown + threshold-crossing PointerMove starts the drag.
4111        tree.dispatch_event(WidgetEvent::PointerDown {
4112            position: Point::new(50.0, 15.0),
4113            button: PointerButton::Primary,
4114            modifiers: Modifiers::NONE,
4115        });
4116        tree.dispatch_event(WidgetEvent::PointerMove {
4117            position: Point::new(60.0, 15.0),
4118        });
4119
4120        assert_eq!(
4121            tree.overlay_manager().len(),
4122            baseline + 1,
4123            "Preview overlay should be live during drag"
4124        );
4125
4126        // Drop — preview should be dismissed.
4127        tree.dispatch_event(WidgetEvent::PointerUp {
4128            position: Point::new(60.0, 15.0),
4129            button: PointerButton::Primary,
4130            modifiers: Modifiers::NONE,
4131        });
4132        assert_eq!(
4133            tree.overlay_manager().len(),
4134            baseline,
4135            "Preview overlay should be dismissed after drop"
4136        );
4137    }
4138
4139    #[test]
4140    fn edge_auto_scroll_advances_scroll_y_during_drag() {
4141        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
4142
4143        // 50 items, 30 px each (1500 px of content) in a 300 px viewport.
4144        let (mut tree, _lv_id, _model) =
4145            make_reorderable_list((0..50).collect::<Vec<usize>>(), 30.0);
4146        tree.layout(SizeProposal::exact(400.0, 300.0));
4147
4148        // Kick off a drag and move the pointer near the BOTTOM edge so
4149        // the on_drag_tick scroll delta is positive.
4150        tree.dispatch_event(WidgetEvent::PointerDown {
4151            position: Point::new(50.0, 15.0),
4152            button: PointerButton::Primary,
4153            modifiers: Modifiers::NONE,
4154        });
4155        tree.dispatch_event(WidgetEvent::PointerMove {
4156            position: Point::new(60.0, 15.0),
4157        });
4158        tree.dispatch_event(WidgetEvent::PointerMove {
4159            position: Point::new(60.0, 290.0), // inside bottom 32 px edge zone
4160        });
4161
4162        // Drive layout a few times to accumulate on_drag_tick fires.
4163        for _ in 0..8 {
4164            tree.layout(SizeProposal::exact(400.0, 300.0));
4165        }
4166        let scroll_y = with_list_view::<usize, _>(&tree, _lv_id, |lv| lv.scroll_y_signal().get());
4167        assert!(
4168            scroll_y > 5.0,
4169            "Edge auto-scroll should have advanced scroll_y; got {scroll_y}"
4170        );
4171
4172        // Clean up the drag.
4173        tree.dispatch_event(WidgetEvent::PointerUp {
4174            position: Point::new(60.0, 290.0),
4175            button: PointerButton::Primary,
4176            modifiers: Modifiers::NONE,
4177        });
4178    }
4179
4180    // -- Boundary scroll chaining -------------------------------------------
4181
4182    /// A ListView (40 × 30px items in a 100px viewport → 1100px of scroll)
4183    /// stacked above a filler inside an outer ScrollArea, so chaining from the
4184    /// inner list to the outer area is observable.
4185    fn nested_list_fixture(inner: OverscrollBehavior) -> (WidgetTree, Signal<f32>, Signal<f32>) {
4186        use crate::ScrollArea;
4187        use crate::primitives::{FixedSize, VStack};
4188        let mut tree = WidgetTree::new();
4189        let model = ListModel::from_vec((0..40_usize).collect());
4190        let lv = ListView::new(model, move |_i, _item, _sel| {
4191            Box::new(FixedLeaf(180.0, 30.0))
4192        })
4193        .item_height(30.0)
4194        .overscroll_behavior(inner);
4195        let inner_y = lv.scroll_y_signal().clone();
4196        let lv_id = tree.add(lv);
4197        let viewport = tree.add(FixedSize::new().width(200.0).height(100.0).child_id(lv_id));
4198        let filler = tree.add(FixedLeaf(200.0, 200.0));
4199        let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
4200        let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
4201        let outer_y = outer.scroll_y_signal().clone();
4202        let _outer = tree.add(outer);
4203        tree.layout(SizeProposal::exact(200.0, 150.0));
4204        (tree, inner_y, outer_y)
4205    }
4206
4207    #[test]
4208    fn nested_list_chains_to_outer_at_boundary() {
4209        use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
4210        let (mut tree, inner_y, outer_y) = nested_list_fixture(OverscrollBehavior::Chain);
4211        tree.pointer_move(Point::new(50.0, 40.0));
4212        tree.dispatch_event(WidgetEvent::Scroll {
4213            delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
4214            modifiers: Modifiers::NONE,
4215        });
4216        tree.layout(SizeProposal::exact(200.0, 150.0));
4217        let inner_bottom = inner_y.get();
4218        assert!(
4219            inner_bottom > 0.0,
4220            "inner list should scroll down; got {inner_bottom}"
4221        );
4222        assert!(
4223            outer_y.get() < 0.01,
4224            "outer must not move while the inner absorbs"
4225        );
4226
4227        tree.pointer_move(Point::new(50.0, 40.0));
4228        tree.dispatch_event(WidgetEvent::Scroll {
4229            delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
4230            modifiers: Modifiers::NONE,
4231        });
4232        tree.layout(SizeProposal::exact(200.0, 150.0));
4233        assert!(
4234            (inner_y.get() - inner_bottom).abs() < 0.01,
4235            "inner stays clamped at bottom"
4236        );
4237        assert!(
4238            outer_y.get() > 0.01,
4239            "outer scrolled because the inner chained the boundary"
4240        );
4241    }
4242
4243    #[test]
4244    fn nested_list_contain_blocks_chaining() {
4245        use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
4246        let (mut tree, _inner_y, outer_y) = nested_list_fixture(OverscrollBehavior::Contain);
4247        tree.pointer_move(Point::new(50.0, 40.0));
4248        tree.dispatch_event(WidgetEvent::Scroll {
4249            delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
4250            modifiers: Modifiers::NONE,
4251        });
4252        tree.layout(SizeProposal::exact(200.0, 150.0));
4253        tree.pointer_move(Point::new(50.0, 40.0));
4254        tree.dispatch_event(WidgetEvent::Scroll {
4255            delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
4256            modifiers: Modifiers::NONE,
4257        });
4258        tree.layout(SizeProposal::exact(200.0, 150.0));
4259        assert!(
4260            outer_y.get() < 0.01,
4261            "Contain must prevent chaining: outer stays put"
4262        );
4263    }
4264
4265    #[test]
4266    fn keyboard_selection_chases_outer_scroll_area() {
4267        // A 200px ListView (20 × 20px rows → scrolls internally) whose lower
4268        // half sits below a 100px outer ScrollArea's fold. Arrow-key selection
4269        // is not a focus change (the list keeps focus, `active_descendant`
4270        // style), so the framework's focus-driven follow never reveals the
4271        // selected row — `ctx.ensure_visible` must.
4272        use crate::ScrollArea;
4273        use crate::primitives::{FixedSize, VStack};
4274        use teksilo_core::event::{Key, Modifiers};
4275        use teksilo_data::{SelectionMode, SelectionModel};
4276
4277        let mut tree = WidgetTree::new();
4278        let model = ListModel::from_vec((0..20_usize).collect());
4279        let selection = SelectionModel::new(SelectionMode::Single);
4280        let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(180.0, 20.0)))
4281            .item_height(20.0)
4282            .selection(selection);
4283        let lv_id = tree.add(lv);
4284        let lv_box = tree.add(FixedSize::new().width(200.0).height(200.0).child_id(lv_id));
4285        let filler = tree.add(FixedLeaf(200.0, 200.0));
4286        let outer_content = tree.add(VStack::new().add_child(lv_box).add_child(filler));
4287        let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
4288        let outer_y = outer.scroll_y_signal().clone();
4289        let _outer = tree.add(outer);
4290        tree.layout(SizeProposal::exact(200.0, 100.0));
4291
4292        // Focus scrolls the outer to reveal the tall list; reset so any further
4293        // scroll is attributable to the row-selection chase.
4294        tree.focus(lv_id);
4295        tree.layout(SizeProposal::exact(200.0, 100.0));
4296        outer_y.set(0.0);
4297        tree.layout(SizeProposal::exact(200.0, 100.0));
4298        assert!(outer_y.get().abs() < 0.01, "reset outer to top");
4299
4300        // Select down toward the bottom rows (below the outer fold).
4301        for _ in 0..20 {
4302            tree.press_key(Key::ArrowDown, Modifiers::NONE);
4303        }
4304        tree.layout(SizeProposal::exact(200.0, 100.0));
4305
4306        assert!(
4307            outer_y.get() > 0.01,
4308            "selecting a row below the outer fold must scroll the enclosing \
4309             ScrollArea (got {})",
4310            outer_y.get()
4311        );
4312    }
4313
4314    // --- Variable row heights ---
4315
4316    /// Collect the (y, height) bounds of the realized item children (the
4317    /// scrollbar is always the last child), sorted by y.
4318    fn item_spans(tree: &WidgetTree, lv_id: WidgetId) -> Vec<(f32, f32)> {
4319        let children = row_ids(tree, lv_id);
4320        let mut spans: Vec<(f32, f32)> = children[..]
4321            .iter()
4322            .map(|c| {
4323                let b = tree.bounds(*c);
4324                (b.y, b.height)
4325            })
4326            .collect();
4327        spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
4328        spans
4329    }
4330
4331    #[test]
4332    fn exact_item_height_fn_positions_rows_at_callback_heights() {
4333        let heights = [100.0_f32, 20.0, 50.0];
4334        let model = ListModel::from_vec(vec![0_usize, 1, 2]);
4335        let mut tree = WidgetTree::new();
4336        let lv_id = tree.add(
4337            ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4338                .item_height_fn(move |i| heights[i]),
4339        );
4340        tree.layout(SizeProposal::exact(400.0, 300.0));
4341
4342        let spans = item_spans(&tree, lv_id);
4343        assert_eq!(spans.len(), 3);
4344        assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 100.0).abs() < 0.01);
4345        assert!((spans[1].0 - 100.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
4346        assert!((spans[2].0 - 120.0).abs() < 0.01 && (spans[2].1 - 50.0).abs() < 0.01);
4347    }
4348
4349    #[test]
4350    fn exact_heights_with_spacing() {
4351        let heights = [100.0_f32, 20.0, 50.0];
4352        let model = ListModel::from_vec(vec![0_usize, 1, 2]);
4353        let mut tree = WidgetTree::new();
4354        let lv_id = tree.add(
4355            ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4356                .item_height_fn(move |i| heights[i])
4357                .spacing(8.0),
4358        );
4359        tree.layout(SizeProposal::exact(400.0, 300.0));
4360
4361        let spans = item_spans(&tree, lv_id);
4362        assert!((spans[1].0 - 108.0).abs() < 0.01);
4363        assert!((spans[2].0 - 136.0).abs() < 0.01);
4364    }
4365
4366    #[test]
4367    fn variable_heights_virtualize() {
4368        let model = ListModel::from_vec((0..10_000).collect::<Vec<usize>>());
4369        let mut tree = WidgetTree::new();
4370        let lv_id = tree.add(
4371            ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4372                .item_height_fn(|i| 20.0 + (i % 5) as f32 * 10.0),
4373        );
4374        tree.layout(SizeProposal::exact(400.0, 300.0));
4375
4376        let item_count = row_ids(&tree, lv_id).len();
4377        assert!(
4378            item_count < 40,
4379            "Expected fewer than 40 realized rows, got {item_count}"
4380        );
4381        assert!(
4382            item_count >= 8,
4383            "Expected at least 8 rows, got {item_count}"
4384        );
4385    }
4386
4387    #[test]
4388    fn auto_measure_corrects_rows_from_estimate() {
4389        // Delegate rows are 30 px tall; the estimate says 50. After the
4390        // measure pass, row 1 must sit at y = 30, not 50.
4391        let model = ListModel::from_vec(vec![0_usize, 1, 2, 3]);
4392        let mut tree = WidgetTree::new();
4393        let lv_id = tree.add(
4394            ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4395                .auto_item_height(50.0),
4396        );
4397        tree.layout(SizeProposal::exact(400.0, 300.0));
4398        tree.layout(SizeProposal::exact(400.0, 300.0));
4399
4400        let spans = item_spans(&tree, lv_id);
4401        assert!(
4402            (spans[1].0 - 30.0).abs() < 0.01,
4403            "row 1 should sit at measured 30, got {}",
4404            spans[1].0
4405        );
4406        assert!((spans[1].1 - 30.0).abs() < 0.01);
4407    }
4408
4409    #[test]
4410    fn auto_measure_under_realization_converges() {
4411        // Estimate 100, actual 20: the first build realizes far too few
4412        // rows for the viewport. The post-measure realization re-check
4413        // must request rebuilds until realized rows tile the viewport.
4414        let model = ListModel::from_vec((0..200).collect::<Vec<usize>>());
4415        let mut tree = WidgetTree::new();
4416        let lv_id = tree.add(
4417            ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 20.0)))
4418                .auto_item_height(100.0),
4419        );
4420        // Let the re-check / rebuild cycle settle.
4421        for _ in 0..6 {
4422            tree.layout(SizeProposal::exact(400.0, 300.0));
4423        }
4424
4425        let spans = item_spans(&tree, lv_id);
4426        // Contiguous tiling from the top…
4427        let mut expected_y = spans[0].0;
4428        for (y, h) in &spans {
4429            assert!(
4430                (y - expected_y).abs() < 0.01,
4431                "rows must tile contiguously: expected y {expected_y}, got {y}"
4432            );
4433            expected_y = y + h;
4434        }
4435        // …and full viewport coverage (no gap at the bottom).
4436        let last_bottom = spans.last().map(|(y, h)| y + h).unwrap();
4437        assert!(
4438            last_bottom >= 300.0,
4439            "realized rows must cover the viewport bottom, got {last_bottom}"
4440        );
4441    }
4442
4443    #[test]
4444    fn auto_measure_append_preserves_measured_prefix() {
4445        let model = ListModel::from_vec((0..4).collect::<Vec<usize>>());
4446        let mut tree = WidgetTree::new();
4447        let lv_id = tree.add(
4448            ListView::new(model.clone(), |_i, _item, _sel| {
4449                Box::new(FixedLeaf(100.0, 30.0))
4450            })
4451            .auto_item_height(50.0),
4452        );
4453        tree.layout(SizeProposal::exact(400.0, 300.0));
4454        tree.layout(SizeProposal::exact(400.0, 300.0));
4455
4456        // Rows measured to 30. Appending must keep that prefix (the
4457        // divergence is the old length) — row 1 stays at 30, it doesn't
4458        // snap back to the 50 px estimate.
4459        model.push(99);
4460        tree.layout(SizeProposal::exact(400.0, 300.0));
4461        let spans = item_spans(&tree, lv_id);
4462        assert_eq!(spans.len(), 5);
4463        assert!(
4464            (spans[1].0 - 30.0).abs() < 0.01,
4465            "measured prefix must survive an append, got y {}",
4466            spans[1].0
4467        );
4468    }
4469
4470    #[test]
4471    fn scrollbar_reservation_self_corrects_after_auto_measure_flips_the_decision() {
4472        // The scrollbar decision (and the content width it drives) is made
4473        // from the PRE-measure estimate, since rows can't be measured at a
4474        // width that itself depends on the decision. When the actual
4475        // measured total flips "fits without a scrollbar" into "needs
4476        // one", the pass that measures it places rows at the stale
4477        // (unreserved) width and leaves the scrollbar collapsed; the NEXT
4478        // pass recomputes `provisional_total` from the now-measured total
4479        // and corrects both. Pins that the mismatch resolves by the very
4480        // next layout pass — see the comment on `provisional_total` in
4481        // `ListView::place_children` — so a refactor can't make the
4482        // one-frame lag persist.
4483        //
4484        // 10 rows at the 20px estimate fit a 300px viewport (no
4485        // scrollbar); the same 10 rows measured at their real 40px
4486        // height (400px total) do not. With every row already realized
4487        // and no scroll-anchor shift, nothing else in this scenario
4488        // dirties the list for another pass — `tree.layout()` short-
4489        // circuits a clean tree (see `WidgetTree::layout_with_ops`'s
4490        // `!proposal_changed && !any_needs_layout()` guard) — so the
4491        // second pass is driven by a `scroll_y` touch, the same
4492        // `Relayout`-bound signal a real scroll/resize event would flip
4493        // in a live app.
4494        let model = ListModel::from_vec((0..10).collect::<Vec<usize>>());
4495        let mut tree = WidgetTree::new();
4496        let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 40.0)))
4497            .auto_item_height(20.0);
4498        let scroll_y = lv.scroll_y_signal().clone();
4499        let lv_id = tree.add(lv);
4500
4501        tree.layout(SizeProposal::exact(400.0, 300.0));
4502        let children = row_ids(&tree, lv_id);
4503        let item0_frame1 = tree.bounds(children[0]).width;
4504        let sb_frame1 = tree.bounds(scrollbar_of(&tree, lv_id)).width;
4505        assert!(
4506            (item0_frame1 - 400.0).abs() < 0.01,
4507            "frame 1 uses the pre-measure (no-scrollbar) decision, got width {item0_frame1}"
4508        );
4509        assert!(
4510            sb_frame1 < 0.01,
4511            "frame 1's scrollbar is still collapsed from the same stale decision, got {sb_frame1}"
4512        );
4513
4514        // `Signal::set` always notifies (no equality skip), so setting the
4515        // same value still marks this list dirty for `Relayout` and forces
4516        // the next `layout()` to re-run `place_children`.
4517        scroll_y.set(0.0);
4518        tree.layout(SizeProposal::exact(400.0, 300.0));
4519        let children = row_ids(&tree, lv_id);
4520        let item0_frame2 = tree.bounds(children[0]).width;
4521        let sb_frame2 = tree.bounds(scrollbar_of(&tree, lv_id)).width;
4522        assert!(
4523            (item0_frame2 - (400.0 - SCROLLBAR_THICKNESS)).abs() < 0.01,
4524            "frame 2 must self-correct to the measured (needs-scrollbar) width, got {item0_frame2}"
4525        );
4526        assert!(
4527            (sb_frame2 - SCROLLBAR_THICKNESS).abs() < 0.01,
4528            "frame 2's scrollbar must appear once the measured total is known, got {sb_frame2}"
4529        );
4530    }
4531
4532    #[test]
4533    fn ensure_index_visible_with_variable_heights() {
4534        let model = ListModel::from_vec((0..100).collect::<Vec<usize>>());
4535        let heights = |i: usize| 20.0 + (i % 3) as f32 * 20.0; // 20/40/60
4536        let mut tree = WidgetTree::new();
4537        let lv = ListView::new(model, |_i, _item, _sel| Box::new(FixedLeaf(100.0, 30.0)))
4538            .item_height_fn(heights);
4539        let scroll = lv.scroll_y_signal().clone();
4540        let lv_id = tree.add(lv);
4541        tree.layout(SizeProposal::exact(400.0, 300.0));
4542
4543        // row_top(20) = sum of heights 0..20 = 6 full cycles (20+40+60) ×
4544        // 6 + 20 + 40 = 720 + 60 = … compute the prefix directly:
4545        let top_20: f32 = (0..20).map(heights).sum();
4546        let bottom_20 = top_20 + heights(20);
4547
4548        tree.widget_as_any(lv_id)
4549            .and_then(|any| any.downcast_ref::<ListView<usize>>())
4550            .expect("ListView exposes itself via as_any")
4551            .ensure_index_visible(20);
4552        // Row 20 was below the viewport → scrolled so its bottom is at
4553        // the viewport bottom.
4554        assert!(
4555            (scroll.get() - (bottom_20 - 300.0)).abs() < 0.5,
4556            "scroll {} != bottom {} - viewport",
4557            scroll.get(),
4558            bottom_20
4559        );
4560    }
4561
4562    #[test]
4563    fn drag_insertion_with_variable_heights() {
4564        // Heights [40, 10, 40, 40, 40]: dropping at y = 35 (lower half of
4565        // the tall row 0) must insert at index 1 — the naive midpoint
4566        // formula would skip past the short row 1.
4567        let model = ListModel::from_vec(vec![10_usize, 20, 30, 40, 50]);
4568        let heights = [40.0_f32, 10.0, 40.0, 40.0, 40.0];
4569        let mut tree = WidgetTree::new();
4570        let lv_id = tree.add(
4571            ListView::new(model.clone(), |_i, _item, _sel| {
4572                Box::new(FixedLeaf(100.0, 30.0))
4573            })
4574            .item_height_fn(move |i| heights.get(i).copied().unwrap_or(40.0))
4575            .reorderable(true),
4576        );
4577        tree.layout(SizeProposal::exact(400.0, 300.0));
4578
4579        // Drag item 4 (value 50) up to y = 35.
4580        let children = row_ids(&tree, lv_id);
4581        let from = tree.bounds(children[4]).center();
4582        drag_item(&mut tree, from, Point::new(from.x, 35.0));
4583
4584        // Insertion before row 1: [10, 50, 20, 30, 40].
4585        assert_eq!(model.with_item(1, |v| *v), Some(50));
4586        assert_eq!(model.with_item(2, |v| *v), Some(20));
4587    }
4588
4589    // --- Cross-widget export drop (RowDragData) integration tests ---
4590
4591    #[allow(clippy::type_complexity)]
4592    type Captured = Rc<RefCell<Option<(Vec<usize>, Option<Vec<usize>>)>>>;
4593
4594    /// Scene: `VStack { FixedSize(120)[ ListView(exportable) ], sink }` where
4595    /// the sink records any `RowDragData<usize>` it receives. Row 0 sits at
4596    /// window y≈15; the sink spans y=120..200 (drop at y≈160).
4597    fn export_scene(
4598        values: Vec<usize>,
4599        mode: DragTransferMode,
4600    ) -> (WidgetTree, ListModel<usize>, SelectionModel, Captured) {
4601        use crate::primitives::{FixedSize, VStack};
4602        use teksilo_core::widget_builder::WidgetBuilder as _;
4603        let model = ListModel::from_vec(values);
4604        let sel = SelectionModel::new(teksilo_data::SelectionMode::Multi);
4605        let cap: Captured = Rc::new(RefCell::new(None));
4606        let cap2 = cap.clone();
4607        let lv = ListView::new(model.clone(), |_i, _item, _s| {
4608            Box::new(FixedLeaf(180.0, 30.0))
4609        })
4610        .item_height(30.0)
4611        .selection(sel.clone())
4612        .exportable(mode);
4613        let sink = FixedLeaf(180.0, 80.0).on_drop(move |mut payload, _pos, _ctx| {
4614            if let Some(rd) = payload.take_typed::<RowDragData<usize>>() {
4615                *cap2.borrow_mut() = Some((rd.rows, rd.items));
4616                true
4617            } else {
4618                false
4619            }
4620        });
4621        let mut tree = WidgetTree::new();
4622        tree.add(
4623            VStack::new()
4624                .spacing(0.0)
4625                .child(FixedSize::new().height(120.0).child(lv))
4626                .child(sink),
4627        );
4628        tree.layout(SizeProposal::exact(200.0, 300.0));
4629        (tree, model, sel, cap)
4630    }
4631
4632    #[test]
4633    fn exportable_row_drops_on_foreign_sink_with_items() {
4634        let (mut tree, _model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Copy);
4635        drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4636        let (rows, items) = cap.borrow().clone().expect("sink received a RowDragData");
4637        assert_eq!(rows, vec![0]);
4638        assert_eq!(items, Some(vec![10]));
4639    }
4640
4641    #[test]
4642    fn exportable_move_removes_source_row_after_foreign_accept() {
4643        let (mut tree, model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Move);
4644        drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4645        assert!(cap.borrow().is_some(), "sink accepted the drop");
4646        // Move: source row 0 (value 10) is removed once accepted elsewhere.
4647        assert_eq!(model.len(), 2);
4648        assert_eq!(model.with_item(0, |v| *v), Some(20));
4649    }
4650
4651    #[test]
4652    fn exportable_copy_leaves_source_intact() {
4653        let (mut tree, model, _sel, cap) = export_scene(vec![10, 20, 30], DragTransferMode::Copy);
4654        drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4655        assert!(cap.borrow().is_some());
4656        assert_eq!(model.len(), 3);
4657        assert_eq!(model.with_item(0, |v| *v), Some(10));
4658    }
4659
4660    #[test]
4661    fn exportable_multi_selection_drags_the_whole_set() {
4662        let (mut tree, _model, sel, cap) =
4663            export_scene(vec![10, 20, 30, 40], DragTransferMode::Copy);
4664        // Select rows 0 and 2, then grab row 0.
4665        sel.select_indices([0_usize, 2_usize], false);
4666        drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4667        let (rows, items) = cap.borrow().clone().expect("received");
4668        assert_eq!(rows, vec![0, 2]);
4669        assert_eq!(items, Some(vec![10, 30]));
4670    }
4671
4672    #[test]
4673    fn reorder_only_view_is_not_exportable() {
4674        // A plain reorderable (non-exportable) view carries `items: None`, so a
4675        // foreign sink gating on `is_export()` gets nothing usable.
4676        use crate::primitives::{FixedSize, VStack};
4677        use teksilo_core::widget_builder::WidgetBuilder as _;
4678        let model: ListModel<usize> = ListModel::from_vec(vec![1, 2, 3]);
4679        let is_export: Rc<Cell<Option<bool>>> = Rc::new(Cell::new(None));
4680        let probe = is_export.clone();
4681        let lv = ListView::new(model.clone(), |_i, _it, _s| {
4682            Box::new(FixedLeaf(180.0, 30.0))
4683        })
4684        .item_height(30.0)
4685        .selection(SelectionModel::new(teksilo_data::SelectionMode::Single))
4686        .reorderable(true);
4687        let sink = FixedLeaf(180.0, 80.0).on_drop(move |payload, _pos, _ctx| {
4688            probe.set(
4689                payload
4690                    .get_typed::<RowDragData<usize>>()
4691                    .map(|rd| rd.is_export()),
4692            );
4693            true
4694        });
4695        let mut tree = WidgetTree::new();
4696        tree.add(
4697            VStack::new()
4698                .spacing(0.0)
4699                .child(FixedSize::new().height(120.0).child(lv))
4700                .child(sink),
4701        );
4702        tree.layout(SizeProposal::exact(200.0, 300.0));
4703        drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 160.0));
4704        // The reorder-only drag reaches the foreign sink, but carries no items,
4705        // so a receiver gating on `is_export()` correctly rejects it.
4706        assert_eq!(
4707            is_export.get(),
4708            Some(false),
4709            "reorder-only payload is not an export"
4710        );
4711    }
4712
4713    /// The same per-row tooltip API as `TreeView`, on the sibling view.
4714    ///
4715    /// Both views build their rows from a delegate the app cannot reach, so
4716    /// both resolve and attach the tip themselves through the shared
4717    /// `RowTooltips`. Porting the API is only half of it — the behaviour has
4718    /// to match, which is what this pins.
4719    #[test]
4720    fn row_composite_tooltip_opens_for_the_hovered_row() {
4721        use crate::primitives::TextWidget;
4722        use std::time::Duration;
4723        use teksilo_i18n::lit;
4724
4725        let model = ListModel::from_vec(vec![
4726            "Alpha".to_string(),
4727            "Beta".to_string(),
4728            "Gamma".to_string(),
4729        ]);
4730        let mut tree = WidgetTree::new().with_text_backend(std::rc::Rc::new(
4731            std::cell::RefCell::new(teksilo_canvas::MockTextBackend::new()),
4732        ));
4733        let lv =
4734            tree.add(
4735                ListView::new(model, |_i, _it, _s| Box::new(FixedLeaf(180.0, 20.0)))
4736                    .item_height(20.0)
4737                    .row_composite_tooltip(|_i, item: &String| {
4738                        Some(Box::new(TextWidget::new(lit!(format!("about {item}"))))
4739                            as Box<dyn Widget>)
4740                    }),
4741            );
4742        tree.layout(SizeProposal::exact(400.0, 200.0));
4743        assert!(tree.active_overlays().is_empty());
4744
4745        // Hover row 1 (20 dp rows → centre at y = 30).
4746        let bounds = tree.bounds(lv);
4747        tree.pointer_move(teksilo_canvas::Point::new(bounds.x + 40.0, bounds.y + 30.0));
4748        tree.advance_time(Duration::from_millis(750));
4749
4750        assert_eq!(tree.active_overlays().len(), 1);
4751        assert!(
4752            tree.find_by_label("about Beta").is_some(),
4753            "the tip must carry the hovered row's own content"
4754        );
4755    }
4756
4757    #[test]
4758    fn accept_foreign_rows_receives_from_another_view() {
4759        use crate::primitives::{FixedSize, VStack};
4760        // Source A (exportable Move) above; receiver B (accept_foreign_rows) below.
4761        let a = ListModel::from_vec(vec![10, 20, 30]);
4762        let b = ListModel::from_vec(vec![100, 200]);
4763        let b_recv = b.clone();
4764        let lv_a = ListView::new(a.clone(), |_i, _it, _s| Box::new(FixedLeaf(180.0, 30.0)))
4765            .item_height(30.0)
4766            .exportable(DragTransferMode::Move);
4767        let lv_b = ListView::new(b.clone(), |_i, _it, _s| Box::new(FixedLeaf(180.0, 30.0)))
4768            .item_height(30.0)
4769            .accept_foreign_rows(true)
4770            .on_rows_received(move |items, at, _ctx| {
4771                for (k, v) in items.into_iter().enumerate() {
4772                    b_recv.insert(at + k, v);
4773                }
4774            });
4775        let mut tree = WidgetTree::new();
4776        tree.add(
4777            VStack::new()
4778                .spacing(0.0)
4779                .child(FixedSize::new().height(90.0).child(lv_a))
4780                .child(FixedSize::new().height(150.0).child(lv_b))
4781                .child(FixedLeaf(180.0, 10.0)),
4782        );
4783        tree.layout(SizeProposal::exact(200.0, 300.0));
4784        // Drag A's row 0 (y≈15) onto B's first row (B spans y=90..240; drop y≈105).
4785        drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4786        // B received value 10; A lost it (Move).
4787        assert_eq!(b.len(), 3, "receiver B gained the dragged row");
4788        assert!(
4789            (0..b.len()).any(|i| b.with_item(i, |v| *v) == Some(10)),
4790            "B contains the moved value 10"
4791        );
4792        assert_eq!(a.len(), 2, "source A removed the moved row");
4793        assert!(
4794            (0..a.len()).all(|i| a.with_item(i, |v| *v) != Some(10)),
4795            "A no longer contains 10"
4796        );
4797    }
4798
4799    #[test]
4800    fn two_views_over_same_model_do_not_spuriously_reorder() {
4801        use crate::primitives::{FixedSize, VStack};
4802        // Two reorderable ListViews sharing ONE model have distinct ViewIds, so
4803        // a drag from A onto B is Foreign (rejected by ListModel), not a
4804        // same-view reorder — proving ids don't collide across instances.
4805        let model = ListModel::from_vec(vec![10, 20, 30]);
4806        let lv_a = ListView::new(model.clone(), |_i, _it, _s| {
4807            Box::new(FixedLeaf(180.0, 30.0))
4808        })
4809        .item_height(30.0)
4810        .reorderable(true);
4811        let lv_b = ListView::new(model.clone(), |_i, _it, _s| {
4812            Box::new(FixedLeaf(180.0, 30.0))
4813        })
4814        .item_height(30.0)
4815        .reorderable(true);
4816        let mut tree = WidgetTree::new();
4817        tree.add(
4818            VStack::new()
4819                .spacing(0.0)
4820                .child(FixedSize::new().height(90.0).child(lv_a))
4821                .child(FixedSize::new().height(150.0).child(lv_b))
4822                .child(FixedLeaf(180.0, 10.0)),
4823        );
4824        tree.layout(SizeProposal::exact(200.0, 300.0));
4825        drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 105.0));
4826        // The shared model is unchanged: B rejected A's foreign row.
4827        assert_eq!(model.with_item(0, |v| *v), Some(10));
4828        assert_eq!(model.with_item(1, |v| *v), Some(20));
4829        assert_eq!(model.with_item(2, |v| *v), Some(30));
4830    }
4831
4832    #[test]
4833    fn exportable_not_reorderable_does_not_reorder_on_same_view_drop() {
4834        use crate::primitives::{FixedSize, VStack};
4835        // A view that is exportable + accepts foreign rows (so it IS a drop
4836        // target) but is NOT reorderable must not reorder itself when its own
4837        // row is dropped back inside it.
4838        let model: ListModel<usize> = ListModel::from_vec(vec![10, 20, 30, 40]);
4839        let lv = ListView::new(model.clone(), |_i, _it, _s| {
4840            Box::new(FixedLeaf(180.0, 30.0))
4841        })
4842        .item_height(30.0)
4843        .exportable(DragTransferMode::Move)
4844        .accept_foreign_rows(true)
4845        .on_rows_received(|_items, _at, _ctx| {});
4846        let mut tree = WidgetTree::new();
4847        tree.add(
4848            VStack::new()
4849                .spacing(0.0)
4850                .child(FixedSize::new().height(200.0).child(lv))
4851                .child(FixedLeaf(180.0, 10.0)),
4852        );
4853        tree.layout(SizeProposal::exact(200.0, 300.0));
4854        // Drag row 0 (y=15) and drop within the view at row 2 (y=75).
4855        drag_item(&mut tree, Point::new(50.0, 15.0), Point::new(50.0, 75.0));
4856        // No reorder happened (reorderable was never enabled).
4857        assert_eq!(model.with_item(0, |v| *v), Some(10));
4858        assert_eq!(model.with_item(3, |v| *v), Some(40));
4859    }
4860
4861    /// A focusable container that never advertises `Action::Focus` cannot be
4862    /// focused by assistive technology: the tree services the action itself,
4863    /// but the AT only ever asks for what a node advertises.
4864    #[test]
4865    fn advertises_focus_so_assistive_tech_can_focus_the_list() {
4866        let (mut tree, lv_id, _model) = make_list_view(20, 20.0);
4867        tree.layout(SizeProposal::exact(400.0, 200.0));
4868
4869        let info = tree.accessibility_node(lv_id);
4870        assert_eq!(info.role(), teksilo_core::accesskit::Role::ListBox);
4871        assert!(
4872            info.actions()
4873                .contains(&teksilo_core::accesskit::Action::Focus),
4874            "ListView must advertise Action::Focus; without it no screen reader \
4875             can move focus into the list"
4876        );
4877
4878        // And the advertised action really lands.
4879        let mut ops = teksilo_core::window::NoopWindowOps;
4880        let handled = tree.dispatch_access_action(
4881            teksilo_core::accessibility::widget_id_to_node_id(lv_id),
4882            teksilo_core::accesskit::Action::Focus,
4883            None,
4884            &mut ops,
4885        );
4886        assert!(handled, "the Focus action must be serviced");
4887        assert_eq!(tree.focused(), Some(lv_id));
4888    }
4889}
4890
4891#[cfg(test)]
4892mod checkbox_keyboard_tests {
4893    use super::tests::FixedLeaf;
4894    use super::*;
4895    use teksilo_core::WidgetTree;
4896    use teksilo_core::event::{Key, Modifiers};
4897    use teksilo_data::{CheckedModel, SelectionMode, SelectionModel};
4898    use teksilo_i18n::lit;
4899
4900    /// A 200-row list showing ~10, every row carrying a checkbox.
4901    fn checked_list() -> (WidgetTree, SelectionModel, CheckedModel, SizeProposal) {
4902        let model = ListModel::from_vec((0..200usize).collect());
4903        let checks = CheckedModel::new();
4904        let selection = SelectionModel::new(SelectionMode::Multi);
4905        let (sel, ck) = (selection.clone(), checks.clone());
4906        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4907        let lv = tree.add(
4908            ListView::new(model, move |i, item, selected| {
4909                Box::new(
4910                    crate::StandardListItem::new(lit!(format!("Row {item}")))
4911                        .selected(selected)
4912                        .checkbox(ck.signal_for(i)),
4913                )
4914            })
4915            .item_height(24.0)
4916            .selection(sel),
4917        );
4918        let p = SizeProposal::exact(400.0, 240.0);
4919        tree.layout(p);
4920        tree.focus(lv);
4921        (tree, selection, checks, p)
4922    }
4923
4924    #[test]
4925    fn a_list_is_one_tab_stop_however_many_rows_are_realized() {
4926        // The row checkboxes used to be Tab stops of their own, which made the
4927        // Tab order a function of the virtualization window: 31 stops in this
4928        // fixture, and a *different* 31 after scrolling. A listbox is one Tab
4929        // stop with a cursor moving inside it.
4930        let (mut tree, _sel, _ck, p) = checked_list();
4931        let mut seen = std::collections::BTreeSet::new();
4932        for _ in 0..40 {
4933            tree.press_key(Key::Tab, Modifiers::NONE);
4934            tree.layout(p);
4935            seen.insert(tree.focused());
4936        }
4937        assert_eq!(
4938            seen.len(),
4939            1,
4940            "Tab must not walk into the rows; got {} distinct stops",
4941            seen.len()
4942        );
4943    }
4944
4945    #[test]
4946    fn space_checks_the_focused_row_and_ctrl_space_still_selects() {
4947        let (mut tree, sel, checks, p) = checked_list();
4948        sel.select(3);
4949        tree.layout(p);
4950
4951        // Space reaches the checkbox — the row's only keyboard route to it,
4952        // now that it is out of the Tab order.
4953        tree.press_key(Key::Space, Modifiers::NONE);
4954        tree.layout(p);
4955        assert!(checks.signal_for(3).get(), "Space checks the focused row");
4956        assert_eq!(sel.selected_indices(), vec![3], "and leaves the selection");
4957
4958        tree.press_key(Key::Space, Modifiers::NONE);
4959        tree.layout(p);
4960        assert!(!checks.signal_for(3).get(), "and unchecks it again");
4961
4962        // Ctrl+Space keeps meaning "toggle the selection".
4963        tree.press_key(Key::Space, Modifiers::CTRL);
4964        tree.layout(p);
4965        assert_eq!(sel.selected_indices(), Vec::<usize>::new());
4966        assert!(!checks.signal_for(3).get(), "the check is untouched");
4967    }
4968
4969    #[test]
4970    fn a_row_without_a_checkbox_keeps_space_on_the_selection() {
4971        let model = ListModel::from_vec((0..20usize).collect());
4972        let selection = SelectionModel::new(SelectionMode::Multi);
4973        let sel = selection.clone();
4974        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4975        let lv = tree.add(
4976            ListView::new(model, move |_i, _item, _s| Box::new(FixedLeaf(100.0, 24.0)))
4977                .item_height(24.0)
4978                .selection(sel),
4979        );
4980        let p = SizeProposal::exact(400.0, 240.0);
4981        tree.layout(p);
4982        tree.focus(lv);
4983        selection.select(2);
4984
4985        tree.press_key(Key::Space, Modifiers::NONE);
4986        tree.layout(p);
4987        assert_eq!(
4988            selection.selected_indices(),
4989            Vec::<usize>::new(),
4990            "no checkbox to publish a toggle, so Space is still the selection"
4991        );
4992    }
4993}