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