Skip to main content

teksilo_widgets/
list_view.rs

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