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