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//! ## Sections
21//!
22//! [`sections`](ListView::sections) groups the items under full-width header
23//! rows (a song list grouped by artist, a log grouped by day) from the same
24//! provider [`GridView`](crate::GridView) takes, and
25//! [`grouping_sections`](crate::grouping_sections) builds one from runs of
26//! equal keys. The headers belong to the scrolled content: they are
27//! virtualized with the rows, counted in the scroll extent, and laid out in all
28//! three height modes, while being exactly
29//! [`section_header_height`](ListView::section_header_height) tall themselves
30//! (28 dp by default, as in `GridView`).
31//! [`pinned_section_headers`](ListView::pinned_section_headers) keeps the
32//! header of the section at the top of the viewport pinned there, until the
33//! next section's header pushes it out.
34//!
35//! A header is not an item. Selection, activation, type-ahead, keyboard
36//! navigation, `scroll_to_index` and drag and drop all speak indices into the
37//! model, and the keys step over the headers. A drop on a header, or on the
38//! pinned copy of one, inserts at the boundary before its section's first
39//! item; the insertion line shows on which side of the header the dropped
40//! rows land (see [`sections`](ListView::sections)).
41//!
42//! ## Scrolling to an item
43//!
44//! [`handle`](ListView::handle) returns a [`ListViewHandle`], a cheap `Clone`
45//! taken before the view is added and usable from any handler afterwards. Its
46//! [`scroll_to_index`](ListViewHandle::scroll_to_index) and
47//! [`ensure_index_visible`](ListViewHandle::ensure_index_visible) are applied at
48//! the next layout pass, after row measurement and scroll anchoring, so they
49//! may follow a model change in the same turn, or be called before mount:
50//!
51//! ```rust
52//! # use teksilo_widgets::ListView;
53//! # use teksilo_widgets::primitives::TextWidget;
54//! # use teksilo_data::ListModel;
55//! # use teksilo_i18n::lit;
56//! # let model: ListModel<String> = ListModel::from_vec(vec!["Alpha".into()]);
57//! let list = ListView::new(model.clone(), |_i, item, _selected| {
58//!     Box::new(TextWidget::new(lit!(item)))
59//! });
60//! let handle = list.handle();
61//! // … `ctx.add(list)`; then, from a handler:
62//! model.insert(0, "Fresh".into());
63//! handle.ensure_index_visible(0);
64//! ```
65//!
66//! The builder-value methods of the same names compute the offset when they
67//! are called, against the last layout pass.
68//!
69//! ## The keyboard cursor
70//!
71//! The list has one current row, its keyboard cursor: the row the arrow keys
72//! step from and the one it names to assistive technology as its active
73//! descendant while it holds keyboard focus. The same [`ListViewHandle`]
74//! reads and moves it: [`cursor`](ListViewHandle::cursor),
75//! [`set_cursor`](ListViewHandle::set_cursor) (clamped to the last row;
76//! keyboard focus, the selection and the scroll offset are left alone) and
77//! [`cursor_signal`](ListViewHandle::cursor_signal), for an application to
78//! observe. A cursor set before mount is there at the first build.
79//!
80//! In a single selection the cursor follows the selection. In a multiple
81//! selection the two are **separate**: `Ctrl+↓` moves the cursor and leaves
82//! the selection, so after `↓`, `Ctrl+↓`, `Ctrl+↓` the selection is still row 0
83//! while the cursor and the active descendant are on row 2, and a selection
84//! written from outside leaves the cursor where it was. Read the cursor, not
85//! the first selected row, for "the row the user is on".
86//!
87//! ## Pan to scroll
88//!
89//! The view installs [`common::scrollable::ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior),
90//! which gives it the shared wheel arithmetic, a finger's pan and the
91//! `PanClaim` that puts it on a pan's claimant chain. A pan scrolls it, the
92//! release coasts, and a pan it cannot absorb hands the **whole** event to the
93//! container outside — never a residual. Vertical only: this view owns no
94//! horizontal offset, so a horizontal pan is declined and chains outward. A pan
95//! that starts on a row scrolls rather than activating it or collapsing a
96//! multi-selection onto it.
97//!
98//! ## When to use
99//!
100//! - Large or dynamically-loaded lists (thousands of rows) — use `ListView`.
101//! - Small, always-all-visible collections — use `Repeater` instead.
102//! - Hierarchical data — use `TreeView`.
103//! - Multi-column tabular data — use `TableView`.
104//!
105//! ## Accessibility
106//!
107//! The widget is `Role::ListBox`; each row is wrapped in
108//! `Role::ListBoxOption` with `set_selected` state. Those are the interactive
109//! ARIA roles — `listbox` / `option` — not the static `list` / `listitem` pair,
110//! because this widget has keyboard navigation and selection.
111//!
112//! Each row publishes its 1-based `position_in_set` **in the model**, and the
113//! container publishes the model's length as `size_of_set`, so a screen reader
114//! says "row 147 of 200" rather than counting the realized window. The count
115//! sits on the container because AccessKit resolves an item's set size by
116//! walking up from it, unlike ARIA's per-item `aria-setsize`.
117//!
118//! A section header is a `Role::Heading` named by its section's title, placed
119//! just before the section's first realized row in the reading order. It is
120//! not an option, so it takes no position in the set and the rows keep theirs
121//! in the whole model. A header stays realized as long as any row of its
122//! section is, however far above the viewport it has scrolled, so the rows a
123//! screen reader can reach are always read under a heading naming their
124//! section. The pinned copy of a header is hidden from assistive technology,
125//! which reads that in-flow header instead.
126//!
127//! The container is the focusable node and rows deliberately are not. The
128//! container names the cursor's row as its active descendant, which is the row
129//! assistive technology calls current, and each row says through
130//! `set_selected` whether it is selected. In a single selection the two are one
131//! row: the keys move them together, and a selection the application sets
132//! moves the cursor onto it. The row subtree is kept out of the Tab order, so a
133//! control the delegate puts in a row (the checkbox `StandardListItem` embeds,
134//! most often) never becomes a Tab stop of its own. Such a control publishes a
135//! keyboard toggle instead, which `Space` runs. A control in a section header
136//! has no cursor to be reached through, so it is a Tab stop while its header
137//! is on screen. Full
138//! keyboard navigation: arrows, Home, End, PageUp, PageDown (each moving the
139//! selection; in a multiple selection, Ctrl on an arrow or the accelerator on
140//! the others moves only the cursor), Shift for a range and Ctrl+Shift for an
141//! additive one, Space (checks the row when it carries a checkbox, else
142//! select/toggle), Enter (activate), Ctrl+A / Ctrl+Shift+A (select all /
143//! deselect), Ctrl+Arrow and Ctrl+Space (the disjoint-selection pair),
144//! type-ahead (opt-in via `type_ahead_label`), and Shift+F10 or the Menu key
145//! for the selected row's context menu. On macOS, Cmd+Down opens the focused
146//! row. The chord table and its rationale are in
147//! [docs/data-view-keyboard.md](https://github.com/ferntech-eu/teksilo/blob/main/docs/data-view-keyboard.md).
148//!
149//! ```rust
150//! # use teksilo_widgets::ListView;
151//! # use teksilo_widgets::primitives::TextWidget;
152//! # use teksilo_data::{ListModel, SelectionMode, SelectionModel};
153//! # use teksilo_i18n::lit;
154//! # struct Item { name: String }
155//! # let model: ListModel<Item> = ListModel::from_vec(vec![Item { name: "Alpha".into() }]);
156//! # let sel = SelectionModel::new(SelectionMode::Single);
157//! let _w = ListView::new(model, |_i, item, _selected| {
158//!     Box::new(TextWidget::new(lit!(&item.name)))
159//! })
160//! .item_height(32.0)
161//! .selection(sel);
162//! ```
163
164use std::cell::{Cell, RefCell};
165use std::rc::Rc;
166use std::time::Duration;
167
168use teksilo_canvas::{Point, Rect, Size, SizeProposal};
169use teksilo_tokens::{BorderRole, InputTokens, OverscrollStyle, TargetRole};
170
171use teksilo_core::DropFeedback;
172use teksilo_core::accessibility::AccessNodeBuilder;
173use teksilo_core::binding::BindingLevel;
174use teksilo_core::drag_payload::DragPayload;
175use teksilo_core::kinetic::KineticScroller;
176use teksilo_core::pointer::touch_action::PanAxes;
177use teksilo_core::signal::{Prop, Signal};
178use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
179use teksilo_core::widget_builder::HandlerSet;
180use teksilo_core::widget_id::WidgetId;
181
182use teksilo_data::selection_model::SelectionModel;
183use teksilo_data::{ItemKey, KeyedSelectionModel};
184
185use crate::data_views::RowSelection;
186use teksilo_data::{DataChange, DropResponse, ListModel};
187
188// Qualified rather than glob-imported: `data_views::ViewKind` is already in
189// scope here and means something else (which data view a drag came from).
190use crate::common::list_nav;
191use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
192use crate::common::row_reveal::{RevealAlign, RowReveal, SharedRowReveal};
193use crate::common::scroll::OverscrollBehavior;
194use crate::data_views::{DragTransferMode, RowDragData, ViewId, ViewKind, flat_insertion_target};
195use crate::grid_view::sections::SectionProvider;
196use crate::list_source::ListSource;
197use crate::scroll_area::ScrollBarMode;
198use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
199use teksilo_core::styles::density::dp;
200
201mod body_pane;
202mod sections;
203mod widget_impl;
204
205/// Default number of extra items to create above and below the viewport.
206const BUFFER_ITEMS: usize = 5;
207/// Default item height.
208const DEFAULT_ITEM_HEIGHT: f32 = 32.0;
209
210/// [`DEFAULT_ITEM_HEIGHT`] raised to the density's `target_size`
211/// (24 / 32 / 44 dp). The identity at Compact.
212fn default_item_height(tokens: &InputTokens) -> f32 {
213    dp(DEFAULT_ITEM_HEIGHT, TargetRole::Target, tokens)
214}
215/// Scrollbar thickness.
216const SCROLLBAR_THICKNESS: f32 = 12.0;
217
218/// A virtualized scrollable list backed by a [`ListModel<T>`](teksilo_data::ListModel) or `ListDataSource`.
219///
220/// See the module-level documentation for the full feature overview.
221pub struct ListView<T: 'static> {
222    source: ListSource<T>,
223    delegate: Rc<dyn Fn(usize, &T, bool) -> Box<dyn Widget>>,
224    /// Per-row tooltip resolvers. Shared with `TreeView`; see
225    /// [`RowTooltips`](crate::data_views::RowTooltips).
226    row_tooltips: crate::data_views::RowTooltips<T>,
227    item_height: f32,
228    spacing: f32,
229    /// Height-mode selection (uniform / exact callback / auto-measure).
230    height_source: HeightSource,
231    /// Row geometry — all virtualization consumers (visible range,
232    /// placement, scrollbar totals, ensure-visible, DnD insertion) go
233    /// through this. Shared handle: cloned into the scroll observer,
234    /// keyboard and DnD closures.
235    metrics: SharedRowMetrics,
236    /// Row selection — index-based [`SelectionModel`] or keyed
237    /// [`KeyedSelectionModel<K>`], unified behind the index-facing facade.
238    row_selection: Option<RowSelection>,
239
240    /// The keyboard cursor: the model index the arrow keys step from and the
241    /// view names as its active descendant. A signal, bound
242    /// `AccessibilityOnly` by the root, so a write from anywhere (the keys, a
243    /// click, a [`ListViewHandle`]) re-walks the AT tree; shared with every
244    /// handle.
245    focused_index: Signal<Option<usize>>,
246
247    /// Shared (model index → row wrapper id) map, written by the body pane at
248    /// the end of every build. Handed out by
249    /// [`realized_row_ids`](Self::realized_row_ids) so a host that keeps focus
250    /// elsewhere — a command palette whose focus stays in its search field —
251    /// can point `active_descendant` at the highlighted row. Mirrors
252    /// `GridView`'s `tile_map`.
253    row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
254
255    /// Type-ahead ("type to jump") label extractor — opt-in via
256    /// [`type_ahead_label`](Self::type_ahead_label). When set, typing a
257    /// printable character jumps the selection to the next row whose label
258    /// starts with the accumulated search term (Qt `keyboardSearch` /
259    /// macOS type-select convention).
260    type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
261    /// Reset window for the type-ahead search term.
262    type_ahead_timeout: Duration,
263    /// Persistent type-ahead buffer — a field (not built in `build`) so the
264    /// accumulated term survives the selection-driven rebuild each
265    /// keystroke triggers.
266    type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
267
268    /// Enable intra-widget drag reordering + keyboard Alt+Arrow.
269    reorderable: bool,
270
271    /// Whether to render an internal vertical scrollbar. When the
272    /// caller wants the scrollbar outside the list — e.g. so it
273    /// survives ListView rebuilds — this is disabled and the caller
274    /// mounts their own, wired through `scroll_y_signal` /
275    /// `max_scroll_y_signal` / `viewport_ratio_y_signal`.
276    show_scrollbar: bool,
277    /// The view is a virtualizer for a list another widget owns and publishes;
278    /// see [`presentational`](Self::presentational).
279    presentational: bool,
280
281    // Persistent state (survives rebuild)
282    scroll_y: Signal<f32>,
283    max_scroll_y: Signal<f32>,
284    /// Scroll-chaining behavior at the boundary (default `Chain`).
285    overscroll_behavior: OverscrollBehavior,
286    viewport_ratio_y: Signal<f32>,
287
288    /// Animate wheel scrolling instead of snapping to the new offset.
289    /// Enabled by default — mirrors `ScrollArea`.
290    smooth_scrolling: bool,
291    /// Duration of the smooth scroll animation.
292    smooth_scroll_duration: Duration,
293
294    /// How the scroll bar is displayed. Defaults to `Permanent` (reserves
295    /// a layout column); `Overlay` / `Thin` float over the content.
296    scroll_bar_style: ScrollBarMode,
297
298    /// Active drop feedback (set by on_drag_hover, cleared by on_drag_leave,
299    /// read by paint). Reactive Signal — bound at `RepaintOnly` so any
300    /// `set(...)` call dirties the ListView for repaint automatically.
301    drop_feedback: Signal<Option<(f32, f32)>>, // (y, width) for insertion line
302    /// Content width (updated during place_children, used by drag feedback).
303    placed_content_width: Rc<Cell<f32>>,
304
305    /// Optional row-activation callback (a click per `activate_on`, or
306    /// Enter/Space on the focused row) — distinct from *selection*, which also
307    /// moves on arrow navigation.
308    on_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
309    /// Whether activation is a single or double click (default `DoubleClick`).
310    activate_on: crate::data_views::ActivateOn,
311
312    /// `true` while the view holds keyboard focus (root's inclusive
313    /// [`BuildContext::view_focus_active`](teksilo_core::BuildContext::view_focus_active) signal). With `focus_visible`, drives
314    /// the **container focus ring** shown when the view is Tab-focused but
315    /// nothing is selected. Bound `RepaintOnly`.
316    view_focused: Signal<bool>,
317    /// Input-modality `:focus-visible` — gates the container ring to keyboard
318    /// navigation. Bound `RepaintOnly`.
319    focus_visible: Signal<bool>,
320
321    /// Root-level **relayout** trigger. The root's own `place_children` owns
322    /// the scrollbar totals (`max_scroll_y`, thumb ratio) and the
323    /// content-width decision, none of which its `build` output depends on —
324    /// so a data change or a pane measurement that moves the content total
325    /// needs a re-place here, not a rebuild. Bumped by the data observer and
326    /// by [`body_pane::ListBodyPane::total_refresh`].
327    layout_refresh: Signal<u64>,
328    /// Root-level **repaint** trigger for the container focus ring, which is
329    /// suppressed as soon as anything is selected. Selection changes rebuild
330    /// the pane (the delegate's `selected` argument) but must not rebuild the
331    /// root — they only change what the root paints.
332    paint_refresh: Signal<u64>,
333
334    /// Pane-local rebuild trigger, owned here so it survives pane rebuilds.
335    /// Bumped by the root's data observer, and by the pane itself on
336    /// scroll-buffer exit, selection change and the post-measure realization
337    /// re-check.
338    pane_version: Signal<u64>,
339    /// Buffered row range materialized by the pane's latest build.
340    pane_built_start: Rc<Cell<usize>>,
341    pane_built_end: Rc<Cell<usize>>,
342
343    // Set during build
344    body_pane_id: Option<WidgetId>,
345    scrollbar_id: Option<WidgetId>,
346    /// Shared so the on_drag_tick closure sees the current viewport
347    /// height when edge-computing its auto-scroll delta. Plain `Cell<f32>`
348    /// clones by value, which would leave the tick closure reading the
349    /// 600 px default forever.
350    viewport_height: Rc<Cell<f32>>,
351    /// The ListView's own absolute (window) bounds, cached from
352    /// `place_children`. The keyboard handler reads it to build the selected
353    /// row's absolute rect and chase it into any *enclosing* scroll area via
354    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
355    /// Rows are not distinct focusable nodes (the view holds focus), so the
356    /// framework's focus-driven follow never reveals the selected row in an
357    /// outer scroller — this closes that gap.
358    viewport_bounds: Rc<Cell<Rect>>,
359
360    /// This surface's pan physics: the range a finger's pan is clamped to and
361    /// the offset it is currently holding. Owned by the view rather than by
362    /// the [`ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior)
363    /// so it survives a rebuild, and so `place_children` — the only pass that
364    /// knows the viewport extent — can publish into it.
365    scroller: Rc<RefCell<KineticScroller>>,
366
367    /// Stable, kind-tagged ID for this ListView instance (identifies its own
368    /// reorder vs. a foreign drop, even across widget kinds / windows).
369    model_id: ViewId,
370
371    /// Cross-widget export / foreign-receive machinery — the builders
372    /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
373    /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start payload
374    /// build, and the move-out completion, shared by all five data views.
375    export: crate::data_views::RowExport<T>,
376
377    /// Whole-view enabled state, statically or reactively. Forwarded to the
378    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
379    /// time; `enabled_state` is the single source of truth — a disabled
380    /// view greys out and stops accepting focus / selection / keyboard
381    /// input (arena-gated).
382    enabled: Prop<bool>,
383
384    /// The section grouping set by [`sections`](Self::sections). `None` keeps
385    /// the list flat, and every geometric query then answers from `metrics`
386    /// alone, exactly as before sections existed.
387    sections: Option<Rc<sections::SectionTable>>,
388    #[allow(clippy::type_complexity)]
389    section_header_delegate: Option<Rc<dyn Fn(usize, &str) -> Box<dyn Widget>>>,
390    section_header_height: f32,
391    pinned_section_headers: bool,
392    /// The section the pinned slot shows, written by `place_children` from
393    /// the scroll offset.
394    pinned_section: Signal<Option<usize>>,
395    /// Bumped by the data observer so the pinned slot rebuilds when a model
396    /// change retitles the section it shows.
397    pinned_refresh: Signal<u64>,
398    /// The section the pinned slot last built; see
399    /// [`sections::PinnedSectionHeader::built`].
400    pinned_built: Rc<Cell<Option<usize>>>,
401    /// Where `place_children` last put the pinned copy on screen, `None`
402    /// while it is hidden. A drop resolves against it, since a pointer over
403    /// the copy is over what the user sees there and not over the rows
404    /// under it.
405    pinned_shown: Rc<Cell<Option<sections::PinnedShown>>>,
406    pinned_header_id: Option<WidgetId>,
407    /// Section headers the body pane realized in its latest build, so its
408    /// scroll observer can tell when one it lacks comes into view.
409    pane_built_headers: Rc<Cell<(usize, usize)>>,
410    /// The reveal a [`ListViewHandle`] asked for, applied by the body pane
411    /// after its measure pass. Created with the view so a handle taken before
412    /// mount reaches every pane the view builds.
413    reveal: SharedRowReveal,
414}
415
416impl<T: 'static> ListView<T> {
417    /// Create a new ListView backed by a `ListModel<T>`.
418    ///
419    /// The `delegate` closure receives `(index, &item, selected)` and returns
420    /// a boxed widget for that item.
421    pub fn new(
422        model: ListModel<T>,
423        delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
424    ) -> Self {
425        Self::create(ListSource::from_model(model), delegate)
426    }
427
428    /// Create a ListView backed by a custom `ListDataSource`.
429    ///
430    /// Use this for large or external datasets that cannot fit in memory.
431    /// The source must implement `ListDataSource<Item = T>`.
432    pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>(
433        source: S,
434        delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
435    ) -> Self {
436        Self::create(ListSource::from_data_source(source), delegate)
437    }
438
439    /// Create a ListView backed by a custom `ListDataSource` with **keyed**
440    /// selection. The `KeyedSelectionModel<S::Key>` tracks selection by source
441    /// identity, so it survives reorders, filters, lazy window-slides, and
442    /// stays consistent across two views of the same source. The view stays
443    /// key-less (`ListView<T>`) — the index↔key mapping is captured from the
444    /// concrete source here. Mutually exclusive with
445    /// [`selection`](Self::selection) (the last one set wins).
446    pub fn from_source_keyed<S: teksilo_data::ListDataSource<Item = T>>(
447        source: S,
448        keyed: KeyedSelectionModel<S::Key>,
449        delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
450    ) -> Self
451    where
452        S::Key: ItemKey,
453    {
454        let s = Rc::new(source);
455        let key_at = {
456            let s = s.clone();
457            Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
458        };
459        let len = {
460            let s = s.clone();
461            Rc::new(move || s.len()) as Rc<dyn Fn() -> usize>
462        };
463        // Existence for the prune after a removal or a reset is the keys of the
464        // visible index space, which a lazy source knows before its items
465        // load, gathered once per prune rather than scanned once per selected
466        // key.
467        let row_selection = RowSelection::from_keyed_flat(keyed, key_at, len);
468        let mut view = Self::create(ListSource::from_data_source_rc(s), delegate);
469        view.row_selection = Some(row_selection);
470        view
471    }
472
473    /// Create a ListView backed by a pre-built [`ListSource`]. Crate-
474    /// internal entry point for consumers that already own an erased
475    /// source (e.g. `ComboBox`'s `ItemSource` bridged through
476    /// [`ListSource::from_cloning_accessors`]).
477    pub(crate) fn from_list_source(
478        source: ListSource<T>,
479        delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
480    ) -> Self {
481        Self::create(source, delegate)
482    }
483
484    fn create(
485        source: ListSource<T>,
486        delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static,
487    ) -> Self {
488        let model_id = ViewId::next(ViewKind::List);
489        Self {
490            model_id,
491            export: crate::data_views::RowExport::default(),
492            source,
493            delegate: Rc::new(delegate),
494            row_tooltips: Default::default(),
495            item_height: DEFAULT_ITEM_HEIGHT,
496            spacing: 0.0,
497            height_source: HeightSource::Uniform,
498            metrics: Rc::new(RefCell::new(RowMetrics::uniform(DEFAULT_ITEM_HEIGHT, 0.0))),
499            row_selection: None,
500            focused_index: Signal::new(None),
501            row_map: Rc::new(RefCell::new(Vec::new())),
502            type_ahead_label: None,
503            type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
504            type_ahead: crate::common::type_ahead::TypeAheadState::new(),
505            reorderable: false,
506            show_scrollbar: true,
507            presentational: false,
508            drop_feedback: Signal::new(None),
509            // Replaced at build with the live tree signals.
510            view_focused: Signal::new(false),
511            focus_visible: Signal::new(false),
512            placed_content_width: Rc::new(Cell::new(0.0)),
513            on_activate: None,
514            activate_on: crate::data_views::ActivateOn::default(),
515            overscroll_behavior: OverscrollBehavior::default(),
516            smooth_scrolling: true,
517            smooth_scroll_duration: Duration::from_millis(150),
518            scroll_bar_style: ScrollBarMode::Permanent,
519            scroll_y: Signal::new_animated(0.0),
520            max_scroll_y: Signal::new(0.0),
521            viewport_ratio_y: Signal::new(1.0),
522            layout_refresh: Signal::new(0_u64),
523            paint_refresh: Signal::new(0_u64),
524            pane_version: Signal::new(0_u64),
525            pane_built_start: Rc::new(Cell::new(0)),
526            pane_built_end: Rc::new(Cell::new(0)),
527            body_pane_id: None,
528            scrollbar_id: None,
529            viewport_height: Rc::new(Cell::new(600.0)),
530            viewport_bounds: Rc::new(Cell::new(Rect::ZERO)),
531            scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
532            enabled: Prop::Static(true),
533            sections: None,
534            section_header_delegate: None,
535            section_header_height: sections::DEFAULT_SECTION_HEADER_HEIGHT,
536            pinned_section_headers: false,
537            pinned_section: Signal::new(None),
538            pinned_refresh: Signal::new(0),
539            pinned_built: Rc::new(Cell::new(None)),
540            pinned_shown: Rc::new(Cell::new(None)),
541            pinned_header_id: None,
542            pane_built_headers: Rc::new(Cell::new((0, 0))),
543            reveal: RowReveal::new(),
544        }
545    }
546
547    /// Enable or disable the whole view. A disabled view greys out and stops
548    /// accepting focus / selection / keyboard input (arena-gated).
549    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
550        self.enabled = enabled.into();
551        self
552    }
553
554    /// Set the scroll-chaining behavior at the boundary (default
555    /// [`OverscrollBehavior::Chain`]; [`Contain`](OverscrollBehavior::Contain)
556    /// disables chaining to an ancestor scrollable).
557    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
558        self.overscroll_behavior = behavior;
559        self
560    }
561
562    /// Enable or disable animated wheel scrolling (enabled by default).
563    pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
564        self.smooth_scrolling = enabled;
565        self
566    }
567
568    /// Duration of the smooth scroll animation (default 150 ms).
569    pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
570        self.smooth_scroll_duration = duration;
571        self
572    }
573
574    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
575    /// and `Thin` float the bar over the content instead of reserving a
576    /// layout column, mirroring `ScrollArea::scroll_bar_style`.
577    pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
578        self.scroll_bar_style = style;
579        self
580    }
581
582    /// Re-materialize `self.metrics` after a height-mode / item-height /
583    /// spacing builder call, keeping the three order-independent.
584    fn remake_metrics(&self) {
585        *self.metrics.borrow_mut() = self
586            .height_source
587            .make_metrics(self.item_height, self.spacing);
588    }
589
590    /// Set the fixed height per item (default 32.0) — the uniform fast
591    /// path. Mutually exclusive with [`item_height_fn`](Self::item_height_fn)
592    /// and [`auto_item_height`](Self::auto_item_height); the last mode
593    /// setter wins.
594    pub fn item_height(mut self, height: f32) -> Self {
595        self.item_height = height;
596        self.height_source = HeightSource::Uniform;
597        self.remake_metrics();
598        self
599    }
600
601    /// Per-item heights from a callback. The callback must be pure (same
602    /// index + same data → same height); it is re-swept from the first
603    /// changed index on every model change. No measurement pass runs —
604    /// this is the deterministic variable-height path.
605    pub fn item_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
606        self.height_source = HeightSource::Exact(Rc::new(f));
607        self.remake_metrics();
608        self
609    }
610
611    /// Auto-measured item heights: each realized row is measured at the
612    /// list's content width (height-for-width), unrealized rows assume
613    /// `estimated`. Scroll anchoring keeps content above the viewport
614    /// stationary as estimates are corrected. `estimated` should be a
615    /// typical row height — a wrong estimate only costs realization
616    /// churn while measurements settle, never incorrect layout.
617    pub fn auto_item_height(mut self, estimated: f32) -> Self {
618        self.height_source = HeightSource::Auto { estimated };
619        self.remake_metrics();
620        self
621    }
622
623    /// Set spacing between items (default 0.0). With
624    /// [`sections`](Self::sections) it also separates a section's last item
625    /// from the next header; a header sits directly on its first item.
626    pub fn spacing(mut self, spacing: f32) -> Self {
627        self.spacing = spacing;
628        self.remake_metrics();
629        self
630    }
631
632    /// Group the items into sections, with a full-width header row above each
633    /// one. Takes the same provider as [`GridView::sections`](crate::GridView::sections):
634    /// [`grouping_sections`](crate::grouping_sections) partitions runs of
635    /// equal keys, or implement [`SectionProvider`] yourself.
636    ///
637    /// Headers are rows of the scrolled content: they are virtualized with the
638    /// items, counted in the scroll extent, and compose with all three
639    /// row-height modes. They are not items. Every index the view takes or
640    /// reports — selection, [`on_activate`](Self::on_activate), type-ahead,
641    /// [`scroll_to_index`](Self::scroll_to_index), drag and drop — is still an
642    /// index into the model, and the arrow keys step from item to item over
643    /// the headers.
644    ///
645    /// A drop on a header inserts at the boundary before its section's first
646    /// item, as a drop on the lower half of the row above the header does:
647    /// one index, two places on screen. Which section the dropped rows join is
648    /// the provider's decision, made when it next counts. The insertion line
649    /// assumes the counts survive the drop, as they do for
650    /// `grouping_sections` and for any provider that does not read the items.
651    /// Rows of this list dragged down from above the boundary then close the
652    /// section before the header, since the source takes them out before
653    /// putting them back, and the line is drawn above it. Rows dragged up from
654    /// below, and rows dropped from another view, open the section after it,
655    /// and the line is drawn under the header. A provider that groups by the
656    /// items' content, such as the `ByArtist` provider below, puts a moved
657    /// item in the section of its own key wherever it is dropped. With such a
658    /// provider the line at a section boundary shows where in the list the
659    /// rows go, not which section they join.
660    ///
661    /// The view reads the provider's counts again after every change the
662    /// model reports, so a provider that derives them from the live model
663    /// follows inserts, removals and resets. `grouping_sections` groups the
664    /// model as it is when called; for a model that changes, derive the counts
665    /// on demand instead:
666    ///
667    /// ```rust
668    /// # use teksilo_widgets::{ListView, SectionProvider};
669    /// # use teksilo_widgets::primitives::TextWidget;
670    /// # use teksilo_data::ListModel;
671    /// # use teksilo_i18n::lit;
672    /// struct Song { artist: String, title: String }
673    ///
674    /// /// Runs of songs by the same artist, read from the model each time.
675    /// struct ByArtist(ListModel<Song>);
676    ///
677    /// impl ByArtist {
678    ///     fn runs(&self) -> Vec<(String, usize)> {
679    ///         let mut runs: Vec<(String, usize)> = Vec::new();
680    ///         for i in 0..self.0.len() {
681    ///             let artist = self.0.with_item(i, |s| s.artist.clone()).unwrap_or_default();
682    ///             match runs.last_mut() {
683    ///                 Some((last, count)) if *last == artist => *count += 1,
684    ///                 _ => runs.push((artist, 1)),
685    ///             }
686    ///         }
687    ///         runs
688    ///     }
689    /// }
690    ///
691    /// impl SectionProvider for ByArtist {
692    ///     fn section_count(&self) -> usize { self.runs().len() }
693    ///     fn items_in_section(&self, s: usize) -> usize { self.runs()[s].1 }
694    ///     fn section_title(&self, s: usize) -> String { self.runs()[s].0.clone() }
695    ///     fn section_counts(&self) -> Vec<usize> {
696    ///         self.runs().into_iter().map(|(_, count)| count).collect()
697    ///     }
698    /// }
699    ///
700    /// let songs = ListModel::from_vec(vec![
701    ///     Song { artist: "Ada".into(), title: "One".into() },
702    ///     Song { artist: "Ada".into(), title: "Two".into() },
703    ///     Song { artist: "Bea".into(), title: "Three".into() },
704    /// ]);
705    /// let _list = ListView::new(songs.clone(), |_i, song, _selected| {
706    ///     Box::new(TextWidget::new(lit!(&song.title)))
707    /// })
708    /// .sections(ByArtist(songs))
709    /// .pinned_section_headers(true);
710    /// ```
711    pub fn sections<P: SectionProvider>(mut self, provider: P) -> Self {
712        self.sections = Some(Rc::new(sections::SectionTable::new(provider)));
713        self
714    }
715
716    /// Build each section's header row from `(section_index, title)`, the
717    /// title being the provider's. Without it a header is the title as plain
718    /// text. The same signature as
719    /// [`GridView::section_header_delegate`](crate::GridView::section_header_delegate).
720    ///
721    /// Whatever it builds, the row is published to assistive technology as a
722    /// heading named by the title, so a text label in it that repeats the
723    /// title should be hidden
724    /// ([`TextWidget::a11y_hidden`](crate::primitives::TextWidget::a11y_hidden))
725    /// or it is read twice; the default header hides its own.
726    ///
727    /// A control inside it is a Tab stop while the header is on screen. A
728    /// control in a row is not, because the list's cursor reaches it (`Space`
729    /// runs its keyboard toggle), and the cursor steps over headers. Tab then
730    /// goes from the list to the controls of the headers in view, top to
731    /// bottom. The [pinned copy](Self::pinned_section_headers) is a picture
732    /// of the header and takes no Tab stop, and neither does the header it
733    /// covers; arrowing to the section's first item brings that header back
734    /// into view.
735    pub fn section_header_delegate(
736        mut self,
737        f: impl Fn(usize, &str) -> Box<dyn Widget> + 'static,
738    ) -> Self {
739        self.section_header_delegate = Some(Rc::new(f));
740        self
741    }
742
743    /// Height of each section header row (default 28, as in `GridView`).
744    /// Headers are not measured, in any row-height mode: the delegate's
745    /// widget is given exactly this height.
746    pub fn section_header_height(mut self, height: f32) -> Self {
747        self.section_header_height = height.max(0.0);
748        self
749    }
750
751    /// Keep the header of the section holding the top of the viewport pinned
752    /// there while its rows scroll beneath it; the next section's header
753    /// pushes it up as it arrives. Rows revealed by the keyboard, by
754    /// [`ensure_index_visible`](Self::ensure_index_visible) and by
755    /// [`scroll_to_index`](Self::scroll_to_index) stop below it.
756    ///
757    /// The pinned copy is drawn on an opaque surface and is hidden from
758    /// assistive technology, which reads the section's own header instead. A
759    /// drop on it is a drop on that header, and a control in it takes no Tab
760    /// stop (see [`section_header_delegate`](Self::section_header_delegate)).
761    pub fn pinned_section_headers(mut self, enabled: bool) -> Self {
762        self.pinned_section_headers = enabled;
763        self
764    }
765
766    /// The geometry every query of the view goes through: the row metrics,
767    /// with the section headers between the rows when there are any.
768    fn geometry(&self) -> sections::ListGeometry {
769        sections::ListGeometry::new(
770            self.metrics.clone(),
771            self.sections.as_ref().map(|table| sections::SectionLayer {
772                table: table.clone(),
773                header_height: self.section_header_height,
774                spacing: self.spacing,
775                pinned: self.pinned_section_headers,
776            }),
777        )
778    }
779
780    /// The header-widget factory shared by the body pane and the pinned slot:
781    /// the application's delegate, or the title as plain text.
782    ///
783    /// The plain text is hidden from assistive technology: the heading it
784    /// sits in is already named by the same title, and a label under it would
785    /// be read a second time.
786    fn header_factory(&self) -> Option<sections::HeaderFactory> {
787        let table = self.sections.clone()?;
788        let delegate = self.section_header_delegate.clone();
789        Some(Rc::new(move |section| {
790            let title = table.title(section);
791            match &delegate {
792                Some(delegate) => delegate(section, &title),
793                None => Box::new(
794                    crate::primitives::TextWidget::new(teksilo_i18n::lit!(title)).a11y_hidden(),
795                ) as Box<dyn Widget>,
796            }
797        }))
798    }
799
800    /// Set the index-based selection model (positions). For identity-based
801    /// selection that survives reorder / filter / window-slide, build the view
802    /// with [`from_source_keyed`](Self::from_source_keyed) instead.
803    pub fn selection(mut self, sel: SelectionModel) -> Self {
804        self.row_selection = Some(RowSelection::from_index(sel));
805        self
806    }
807
808    /// In a single selection, hand the keyboard cursor back to the selection
809    /// whenever the selection moves without it.
810    ///
811    /// The view's own keys, clicks and actions move the two together. A
812    /// selection written from outside does not — an application landing on a
813    /// row as the list takes focus, a model re-sourcing its selection — and
814    /// `focused_index` stayed on the row the user last reached. The view
815    /// publishes that row as its active descendant, so a screen reader went on
816    /// announcing it as current while every action read the selection, and the
817    /// next arrow press stepped from a row nobody was on.
818    ///
819    /// Cleared rather than overwritten: every reader of `focused_index` falls
820    /// back to the first selected row, so `None` makes the selection the
821    /// cursor. The cursor is left alone when there is no row to hand it to —
822    /// an emptied selection, or a keyed one on a row this view does not show
823    /// (filtered out here, visible in another view sharing the model) — and in
824    /// a multiple selection, where walking the cursor away from the selection
825    /// is how one is assembled.
826    ///
827    /// Registered before [`Self::reveal_current_row_on_focus`], whose own
828    /// selection observer reads the cursor and must find the one resynced here.
829    fn follow_the_selection_in_single_mode(
830        &self,
831        ctx: &mut teksilo_core::build_context::BuildContext,
832    ) {
833        let Some(selection) = self.row_selection.clone() else {
834            return;
835        };
836        if selection.mode() != teksilo_data::SelectionMode::Single {
837            return;
838        }
839        let focused_index = self.focused_index.clone();
840        let observed = selection.clone();
841        let handle = observed.observe_for_rebuild(move || {
842            if let Some(index) = focused_index.get()
843                && !selection.is_selected(index)
844                && !selection.selected_indices().is_empty()
845            {
846                focused_index.set(None);
847            }
848        });
849        ctx.own_handle(handle);
850    }
851
852    /// Keep the row the keyboard is on inside the realized window.
853    ///
854    /// Only the rows near the viewport are realized, so a current row far from
855    /// the scroll offset frequently has **no widget**. Everything that speaks
856    /// for it then has nothing to speak about: no node carries `selected`,
857    /// [`Self::current_row_widget`] resolves to `None` so no active descendant
858    /// is nominated, and a screen reader is told nothing. The first arrow press
859    /// steps *past* that row as well, because the cursor was somewhere nobody
860    /// was shown.
861    ///
862    /// Two triggers, and the second is not redundant. Revealing only on focus
863    /// misses the common case where the selection is made *from inside* a focus
864    /// handler — a list that lands on "whatever is happening now" the first
865    /// time it is reached does exactly that, so the reveal would run first,
866    /// find nothing selected, and do nothing. Reacting to the selection as well
867    /// covers that, and covers any later programmatic selection too.
868    ///
869    /// `ensure_index_visible` arithmetic rather than `scroll_to_index`: a row
870    /// already on screen must not jump under somebody who can see it.
871    ///
872    /// The handles are cloned into the closures rather than reached through
873    /// `self`, which they cannot borrow. A caller could not do this from
874    /// outside in any case: the handles are private, and
875    /// `with_widget_mut::<ListView<_>>` cannot reach the widget either, since
876    /// this type overrides `as_any` and not `as_any_mut`.
877    fn reveal_current_row_on_focus(&self, ctx: &mut teksilo_core::build_context::BuildContext) {
878        let geometry = self.geometry();
879        let len = self.source.len_fn.clone();
880        let scroll_y = self.scroll_y.clone();
881        let viewport_height = self.viewport_height.clone();
882        let max_scroll_y = self.max_scroll_y.clone();
883        let focused_index = self.focused_index.clone();
884        let selection = self.row_selection.clone();
885
886        let reveal: Rc<dyn Fn()> = Rc::new(move || {
887            let Some(index) = focused_index.get().or_else(|| {
888                selection
889                    .as_ref()
890                    .and_then(|s| s.selected_indices().first().copied())
891            }) else {
892                return;
893            };
894            let current = scroll_y.get();
895            let target = geometry.scroll_for_ensure_visible(
896                index,
897                current,
898                viewport_height.get(),
899                max_scroll_y.get(),
900                len(),
901            );
902            if (target - current).abs() > f32::EPSILON {
903                scroll_y.set(target);
904            }
905        });
906
907        let on_focus = reveal.clone();
908        ctx.effect(&self.view_focused, move |focused| {
909            if *focused {
910                on_focus();
911            }
912        });
913
914        if let Some(sel) = self.row_selection.as_ref() {
915            let on_select = reveal;
916            let focused = self.view_focused.clone();
917            let handle = sel.observe_for_rebuild(move || {
918                // Only while this view has focus. A selection driven from
919                // elsewhere — a combobox highlighting rows in a list the user
920                // is not in — must not scroll the view under them.
921                if focused.get() {
922                    on_select();
923                }
924            });
925            ctx.own_handle(handle);
926        }
927    }
928
929    /// A shared handle to the live `(model index → row node id)` map of the
930    /// **realized** rows, rewritten at the end of every build.
931    ///
932    /// The id is the row's `Role::ListBoxOption` wrapper — the node an
933    /// `active_descendant` has to point at. Take the handle before moving the
934    /// view into the tree; it is populated on the first build.
935    ///
936    /// This exists for the ARIA combobox / listbox pattern, where keyboard
937    /// focus stays on a *text field* while the arrow keys move a highlight
938    /// through this list (a command palette, a type-ahead picker). The field's
939    /// AT node publishes `active_descendant` pointing here, so a screen reader
940    /// announces each row as the highlight moves without focus ever leaving
941    /// the input.
942    ///
943    /// A `ListView` that holds focus itself does **not** need this handle: it
944    /// publishes its own `active_descendant` from `accessibility()`, pointing
945    /// at whichever row the keyboard is on. It used to publish none, on the
946    /// assumption that holding focus was enough, and that assumption is what
947    /// made every Teksilo list silent to NVDA.
948    ///
949    /// Only realized rows are present — a row scrolled outside the
950    /// virtualization window has no widget, so look-ups for it return `None`.
951    /// Callers should `scroll_to_index` the row they intend to announce.
952    pub fn realized_row_ids(&self) -> Rc<RefCell<Vec<(usize, WidgetId)>>> {
953        self.row_map.clone()
954    }
955
956    /// The realized row the keyboard is on: the navigation cursor when there
957    /// is one, else the first selected row.
958    ///
959    /// `None` when that row is outside the virtualization window, which is the
960    /// honest answer — there is no widget for it, so there is no node to point
961    /// at and nothing on screen for a menu or an announcement to be about.
962    fn current_row_widget(&self) -> Option<WidgetId> {
963        let index = self.focused_index.get().or_else(|| {
964            self.row_selection
965                .as_ref()
966                .and_then(|s| s.selected_indices().first().copied())
967        })?;
968        let map = self.row_map.borrow();
969        map.iter()
970            .find(|(i, _)| *i == index)
971            .map(|(_, widget)| *widget)
972    }
973
974    /// Enable intra-widget drag reordering.
975    ///
976    /// When enabled, rows can be dragged within this ListView to reorder them.
977    /// The move is routed through the source's `accept_drop` — a `ListModel`
978    /// reorders in place, an external source routes the move to its store. The
979    /// hover indicator reflects the source's `can_accept` verdict, so a
980    /// forbidden drop shows no insertion line. Keyboard equivalent:
981    /// Alt+ArrowUp/Down.
982    pub fn reorderable(mut self, enabled: bool) -> Self {
983        self.reorderable = enabled;
984        self
985    }
986
987    /// Make rows **droppable outside this view** — on a
988    /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
989    ///
990    /// A dragged row (or the whole selection, when the pressed row is part of a
991    /// multi-selection) carries clones of its items in a public
992    /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
993    /// them out with `payload.get_typed::<RowDragData<T>>()` /
994    /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
995    /// also makes rows a drag source even without [`reorderable`](Self::reorderable).
996    ///
997    /// `mode` chooses what happens to the origin rows once a *foreign* target
998    /// accepts them: [`DragTransferMode::Move`] removes them (via the source's
999    /// `on_drag_out`, or [`on_rows_transferred_out`](Self::on_rows_transferred_out)),
1000    /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
1001    /// transfer, so `mode` never affects it. Requires `T: Clone`.
1002    ///
1003    /// **Move caveats.** The row is removed only when the drop is accepted by an
1004    /// in-app target *in the same window* (`DropOutcome::InApp { accepted: true }`)
1005    /// or the OS reports a genuine move. Shipped OS backends advertise **copy
1006    /// only**, so a drag exported to another application — or to another window
1007    /// of the same app — is treated as a *copy*: the origin row is kept and the
1008    /// receiver must own its own copy semantics. Also, for a `ListModel`-backed
1009    /// view (whose key *is* the row index) the move-out removes by the indices
1010    /// captured at drag-start; if a shared handle to the same model is mutated
1011    /// while the drag is in flight, those indices can point at different rows —
1012    /// use a keyed source, or [`on_rows_transferred_out`](Self::on_rows_transferred_out)
1013    /// with your own stable identity, for models that change mid-drag.
1014    pub fn exportable(mut self, mode: DragTransferMode) -> Self
1015    where
1016        T: Clone,
1017    {
1018        self.export.set_exportable(mode);
1019        self
1020    }
1021
1022    /// Additionally advertise the dragged rows as MIME data so they can be
1023    /// dropped on a [`DropZone`](crate::DropZone) or exported to another
1024    /// application / window via the OS. `f` maps the dragged items to
1025    /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
1026    /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
1027    /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
1028    /// `T: Clone`.
1029    pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
1030    where
1031        T: Clone,
1032    {
1033        self.export.set_export_external(f);
1034        self
1035    }
1036
1037    /// Override how rows moved out to a foreign target are removed from this
1038    /// view. Receives the dragged rows' indices (descending-safe) and the live
1039    /// context. Without this, an [`exportable`](Self::exportable)
1040    /// [`Move`](DragTransferMode::Move) drag removes them through the source's
1041    /// `on_drag_out` (works out of the box for a `ListModel`).
1042    pub fn on_rows_transferred_out(
1043        mut self,
1044        f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
1045    ) -> Self {
1046        self.export.set_on_rows_transferred_out(f);
1047        self
1048    }
1049
1050    /// Accept exported rows dropped from a **different** view or source without
1051    /// writing a custom `ListDataSource`. Pair with
1052    /// [`on_rows_received`](Self::on_rows_received), which is handed the dropped
1053    /// items and the insertion index. (Same-view reorder is
1054    /// [`reorderable`](Self::reorderable); a custom `ListDataSource` can still
1055    /// accept foreign drops through its `can_accept`/`accept_drop` instead.)
1056    pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
1057        self.export.accept_foreign_rows = accept;
1058        self
1059    }
1060
1061    /// Handler for rows accepted via [`accept_foreign_rows`](Self::accept_foreign_rows):
1062    /// `(items, insertion_index, ctx)`. Insert them into your model at the
1063    /// index.
1064    pub fn on_rows_received(
1065        mut self,
1066        f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
1067    ) -> Self {
1068        self.export.set_on_rows_received(f);
1069        self
1070    }
1071
1072    /// Set the row-**activation** handler — invoked with the flat row index and
1073    /// the live [`EventContext`](teksilo_core::widget::EventContext) on a click
1074    /// (per [`activate_on`](Self::activate_on)) or **Enter** on the focused row.
1075    /// The context lets the handler open a modal, toast, or dispatch an intent —
1076    /// matching [`TableView::on_row_activate`](crate::TableView::on_row_activate)
1077    /// / [`GridView::on_tile_activate`](crate::GridView::on_tile_activate).
1078    /// Distinct from *selection*: arrow-key navigation and **Space** move /
1079    /// toggle the selection but do **not** activate.
1080    pub fn on_activate(
1081        mut self,
1082        f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
1083    ) -> Self {
1084        self.on_activate = Some(Rc::new(f));
1085        self
1086    }
1087
1088    /// Choose single- vs double-click activation (default
1089    /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter activates in
1090    /// either mode.
1091    pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
1092        self.activate_on = mode;
1093        self
1094    }
1095
1096    /// Enable **type-ahead** ("type to jump"): with this set, typing a
1097    /// printable character while the list has keyboard focus jumps the
1098    /// selection to the next row whose label starts with the accumulated
1099    /// search term, wrapping around (Qt `keyboardSearch` / macOS &
1100    /// Windows type-select). `label(&item)` yields the searchable text for
1101    /// a row; matching is case-insensitive (a Unicode fold, so an accented
1102    /// label is reachable). A pause longer than the
1103    /// [`type_ahead_timeout`](Self::type_ahead_timeout) starts a fresh term.
1104    /// Whether a composite row tooltip offers dwell-to-sticky promotion.
1105    /// Default `true`.
1106    ///
1107    /// Turn it off for a read-only row card: with nothing to reach into there
1108    /// is nothing to pin, so the countdown indicator would promise an
1109    /// interaction that does not exist and the surface would outlive the
1110    /// pointer for no reason.
1111    pub fn row_tooltip_sticky(mut self, on: bool) -> Self {
1112        self.row_tooltips.set_composite_sticky(on);
1113        self
1114    }
1115
1116    /// Per-row plain tooltip: one line of text for the row under the pointer.
1117    ///
1118    /// The resolver receives the row's flat index and its item; returning
1119    /// `None` leaves that row without a tip. Mutually exclusive with
1120    /// [`row_rich_tooltip`](Self::row_rich_tooltip) and
1121    /// [`row_composite_tooltip`](Self::row_composite_tooltip) — last setter
1122    /// wins, matching the per-widget tooltip matrix.
1123    ///
1124    /// Opens to the row's trailing side, never below it: rows stack
1125    /// vertically, so a tip below would cover the next row.
1126    pub fn row_tooltip(
1127        mut self,
1128        f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static,
1129    ) -> Self {
1130        self.row_tooltips.set_plain(f);
1131        self
1132    }
1133
1134    /// Per-row rich tooltip — a registry key or inline
1135    /// [`TooltipContent`](crate::tooltip::TooltipContent). See
1136    /// [`row_tooltip`](Self::row_tooltip) for the shared semantics.
1137    pub fn row_rich_tooltip(
1138        mut self,
1139        f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static,
1140    ) -> Self {
1141        self.row_tooltips.set_rich(f);
1142        self
1143    }
1144
1145    /// Per-row composite tooltip — an arbitrary widget tree describing the row.
1146    ///
1147    /// The body is built for every **realized** row (the virtualization window)
1148    /// and rebuilt with it, so keep the resolver cheap and defer anything
1149    /// costly to the body's own first paint, which only runs if the tip is
1150    /// actually shown. See [`row_tooltip`](Self::row_tooltip) for the rest.
1151    pub fn row_composite_tooltip(
1152        mut self,
1153        f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static,
1154    ) -> Self {
1155        self.row_tooltips.set_composite(f);
1156        self
1157    }
1158
1159    pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
1160        self.type_ahead_label = Some(Rc::new(label));
1161        self
1162    }
1163
1164    /// Reset window between keystrokes before the type-ahead search term
1165    /// clears (default 500 ms). A zero duration disables type-ahead.
1166    pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
1167        self.type_ahead_timeout = timeout;
1168        self
1169    }
1170
1171    /// Suppress the internal scroll bar. Use when the caller wants to
1172    /// mount its own `ScrollBar` outside the ListView (keeping it alive
1173    /// across rebuilds so a thumb drag isn't torn down when the visible
1174    /// range shifts past the buffer). The caller is expected to wire
1175    /// the external bar up to the signals returned by
1176    /// [`scroll_y_signal`](Self::scroll_y_signal),
1177    /// [`max_scroll_y_signal`](Self::max_scroll_y_signal) and
1178    /// [`viewport_ratio_y_signal`](Self::viewport_ratio_y_signal).
1179    pub fn show_scrollbar(mut self, show: bool) -> Self {
1180        self.show_scrollbar = show;
1181        self
1182    }
1183
1184    /// Lay out and virtualize the rows, and publish nothing of the list itself.
1185    ///
1186    /// For a widget that uses the view only to realize a window of rows and
1187    /// owns the list a screen reader walks: the `ComboBox` dropdown, whose
1188    /// panel is the `Role::ListBox` and whose rows are the options. A view
1189    /// publishing its own list box there nested a second list box, a group and
1190    /// one unselected, unnamed option around each of the combo box's options,
1191    /// and AT-SPI's Selection interface then counted the unselected wrappers,
1192    /// so Orca had nothing to read as the selection moved.
1193    ///
1194    /// The root, the body pane and every row wrapper become content-free
1195    /// containers, which the accessibility walk prunes, and the root is not
1196    /// focusable: focus stays wherever the owner keeps it.
1197    pub(crate) fn presentational(mut self, presentational: bool) -> Self {
1198        self.presentational = presentational;
1199        self
1200    }
1201
1202    /// Total content height (all items + spacing, and the section headers).
1203    fn total_content_height(&self) -> f32 {
1204        self.geometry().total_height(self.source.len())
1205    }
1206
1207    /// The root's children, in paint order: the body pane, the pinned section
1208    /// header over it, the scrollbar over both. The pane is always mounted
1209    /// (an empty list realizes zero rows inside it); the pinned header exists
1210    /// only with [`pinned_section_headers`](Self::pinned_section_headers).
1211    fn child_ids(&self) -> Vec<WidgetId> {
1212        [self.body_pane_id, self.pinned_header_id, self.scrollbar_id]
1213            .into_iter()
1214            .flatten()
1215            .collect()
1216    }
1217
1218    /// Clamp scroll_y to valid range.
1219    fn clamp_scroll(&self) {
1220        let max = self.max_scroll_y.get();
1221        let current = self.scroll_y.get();
1222        let clamped = current.clamp(0.0, max);
1223        if (clamped - current).abs() > 0.001 {
1224            self.scroll_y.set(clamped);
1225        }
1226    }
1227
1228    /// Test-only accessor: the reactive drop-feedback signal. `Some((y, w))`
1229    /// while a compatible drag hovers, `None` once the drag leaves or ends.
1230    #[cfg(test)]
1231    pub(crate) fn drop_feedback_signal(&self) -> &Signal<Option<(f32, f32)>> {
1232        &self.drop_feedback
1233    }
1234
1235    /// The current vertical scroll offset, in logical pixels. Drives the
1236    /// viewport position and the scroll bar thumb. Exposed so external
1237    /// logic (e.g. a parent widget implementing custom scroll-into-view)
1238    /// can read or drive the scroll directly — prefer
1239    /// [`scroll_to_index`](Self::scroll_to_index) /
1240    /// [`ensure_index_visible`](Self::ensure_index_visible) when possible.
1241    pub fn scroll_y_signal(&self) -> &Signal<f32> {
1242        &self.scroll_y
1243    }
1244
1245    /// The maximum scroll offset, `content_height - viewport_height`.
1246    /// Updated during layout. Exposed for callers that mount their own
1247    /// external scrollbar via [`show_scrollbar(false)`](Self::show_scrollbar).
1248    pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
1249        &self.max_scroll_y
1250    }
1251
1252    /// The vertical viewport-to-content ratio (0.0..1.0). Drives the
1253    /// thumb size on any external scrollbar.
1254    pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
1255        &self.viewport_ratio_y
1256    }
1257
1258    /// Scroll so the given model index is aligned to the top of the
1259    /// viewport, now, against the scroll range and row offsets of the last
1260    /// layout pass.
1261    ///
1262    /// The offset is computed and clamped when this is called, to the
1263    /// [`max_scroll_y`](Self::max_scroll_y_signal) that pass wrote. Before the
1264    /// view has been laid out that range is `0`, so the call scrolls to the
1265    /// top and does nothing more; after a model change in the same turn the
1266    /// range still describes the old list. On a list that is already mounted,
1267    /// or to scroll before the first layout, use a [`handle`](Self::handle),
1268    /// which defers the computation to the next layout pass.
1269    ///
1270    /// With [`sections`](Self::sections), the first item of a section brings
1271    /// its header to the top with it, and with
1272    /// [`pinned_section_headers`](Self::pinned_section_headers) every item
1273    /// stops below the pinned header.
1274    pub fn scroll_to_index(&self, index: usize) {
1275        let target = self
1276            .geometry()
1277            .scroll_to_index_target(index, self.source.len());
1278        let max = self.max_scroll_y.get();
1279        self.scroll_y.set(target.clamp(0.0, max));
1280    }
1281
1282    /// Scroll the minimum distance needed to bring the given model
1283    /// index fully into view. No-op if already visible. Sections are
1284    /// honoured as in [`scroll_to_index`](Self::scroll_to_index).
1285    ///
1286    /// Like `scroll_to_index`, this computes the offset when it is called,
1287    /// against the viewport and scroll range of the last layout pass; a
1288    /// [`handle`](Self::handle) defers it to the next one.
1289    pub fn ensure_index_visible(&self, index: usize) {
1290        let scroll = self.scroll_y.get();
1291        let new_scroll = self.geometry().scroll_for_ensure_visible(
1292            index,
1293            scroll,
1294            self.viewport_height.get(),
1295            self.max_scroll_y.get(),
1296            self.source.len(),
1297        );
1298        if (new_scroll - scroll).abs() > f32::EPSILON {
1299            self.scroll_y.set(new_scroll);
1300        }
1301    }
1302
1303    /// A handle that scrolls this list once it is mounted.
1304    ///
1305    /// Take it while describing the tree, before the view is handed to
1306    /// `ctx.add(...)`; it stays attached to the view across rebuilds. See
1307    /// [`ListViewHandle`].
1308    pub fn handle(&self) -> ListViewHandle {
1309        ListViewHandle {
1310            reveal: self.reveal.clone(),
1311            cursor: self.focused_index.clone(),
1312            len: self.source.len_fn.clone(),
1313        }
1314    }
1315}
1316
1317/// A handle on a mounted [`ListView`], to scroll it to an item from outside
1318/// the widget — a view-model reacting to a search hit, a "jump to" command.
1319///
1320/// Cheap to clone, valid before the view is mounted, and it survives the
1321/// view's rebuilds. Its methods never compute an offset when they are called:
1322/// they record the request and ask for a layout pass, and the view applies it
1323/// there, after row measurement and scroll anchoring have settled. So a model
1324/// change made in the same turn (rows inserted above, say) is accounted for,
1325/// a request made before mount is applied at the first layout, and a later
1326/// request replaces an earlier pending one. A request on an empty list is
1327/// dropped; an index past the end reveals the last item.
1328///
1329/// The handle also reads and moves the list's **keyboard cursor**, the row
1330/// the arrow keys step from and assistive technology is told is current:
1331/// [`cursor`](Self::cursor), [`set_cursor`](Self::set_cursor) and
1332/// [`cursor_signal`](Self::cursor_signal). In a multiple selection the cursor
1333/// and the selection are separate: the accelerator with an arrow moves the
1334/// cursor alone, and a selection written from outside leaves the cursor where
1335/// it was.
1336///
1337/// ```rust
1338/// # use teksilo_widgets::ListView;
1339/// # use teksilo_widgets::primitives::TextWidget;
1340/// # use teksilo_data::ListModel;
1341/// # use teksilo_i18n::lit;
1342/// # let model: ListModel<String> = ListModel::from_vec(vec!["Alpha".into()]);
1343/// let list = ListView::new(model, |_i, item, _selected| {
1344///     Box::new(TextWidget::new(lit!(item)))
1345/// });
1346/// let handle = list.handle();
1347/// // … `ctx.add(list)`, then later, from any handler:
1348/// handle.scroll_to_index(120);
1349/// ```
1350#[derive(Clone)]
1351pub struct ListViewHandle {
1352    reveal: SharedRowReveal,
1353    /// The list's `focused_index`, the same signal the keyboard writes.
1354    cursor: Signal<Option<usize>>,
1355    /// The list's item count, to clamp a cursor written from here.
1356    len: Rc<dyn Fn() -> usize>,
1357}
1358
1359impl std::fmt::Debug for ListViewHandle {
1360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1361        f.debug_struct("ListViewHandle")
1362            .field("pending", &self.reveal.pending())
1363            .field("cursor", &self.cursor.get())
1364            .finish()
1365    }
1366}
1367
1368impl ListViewHandle {
1369    /// Scroll so the given model index is aligned to the top of the
1370    /// viewport, as far as the scroll range allows, at the next layout pass.
1371    /// Sections are honoured as in [`ListView::scroll_to_index`].
1372    pub fn scroll_to_index(&self, index: usize) {
1373        self.reveal.request(index, RevealAlign::Top);
1374    }
1375
1376    /// Scroll the minimum distance that brings the given model index fully
1377    /// into view, at the next layout pass; no scroll when it already is.
1378    /// Sections are honoured as in [`ListView::ensure_index_visible`].
1379    pub fn ensure_index_visible(&self, index: usize) {
1380        self.reveal.request(index, RevealAlign::Nearest);
1381    }
1382
1383    /// The model index the keyboard cursor is on, or `None` while the list
1384    /// has no cursor: it has not been navigated, clicked or given one by
1385    /// [`set_cursor`](Self::set_cursor). With no cursor the keys start from
1386    /// the first selected row, and from the first or last row when nothing is
1387    /// selected.
1388    ///
1389    /// In a single selection the cursor follows the selection: the keys move
1390    /// them together, and a selection written from outside takes the cursor
1391    /// off any other row (it reads `None` then, and the keys start from the
1392    /// selection). In a multiple selection they are separate: the accelerator
1393    /// with an arrow (`Ctrl+Arrow`) moves the cursor alone, and a selection
1394    /// written from outside leaves the cursor where it was.
1395    pub fn cursor(&self) -> Option<usize> {
1396        self.cursor.get()
1397    }
1398
1399    /// Move the keyboard cursor to model index `index`, or remove it with
1400    /// `None`.
1401    ///
1402    /// An index past the last row is clamped to the last row; on an empty
1403    /// list the cursor is removed. While the list holds keyboard focus it
1404    /// names the row as its active descendant at the next accessibility
1405    /// update, when the row is realized; the next arrow key steps from it.
1406    ///
1407    /// Keyboard focus and the selection are left alone, and the list does
1408    /// not scroll: pair it with
1409    /// [`ensure_index_visible`](Self::ensure_index_visible) to bring the row
1410    /// into view. Usable before the list is mounted (the first build finds
1411    /// the cursor there) and across its rebuilds. In a single selection the
1412    /// next change of selection takes the cursor back to the selected row.
1413    pub fn set_cursor(&self, index: Option<usize>) {
1414        let len = (self.len)();
1415        let index = index.and_then(|i| len.checked_sub(1).map(|last| i.min(last)));
1416        self.cursor.set(index);
1417    }
1418
1419    /// A read-only signal of the cursor, as [`cursor`](Self::cursor) reports
1420    /// it, for an application to observe or bind: a status line naming the
1421    /// current row, actions enabled for it. It changes with every write of
1422    /// the cursor, by the keys, a click, the data shifting it or
1423    /// [`set_cursor`](Self::set_cursor).
1424    pub fn cursor_signal(&self) -> Signal<Option<usize>> {
1425        self.cursor.map(|index| *index)
1426    }
1427}
1428
1429impl<T: 'static> std::fmt::Debug for ListView<T> {
1430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1431        f.debug_struct("ListView")
1432            .field("item_count", &self.source.len())
1433            .field("item_height", &self.item_height)
1434            .field("scroll_bar_style", &self.scroll_bar_style)
1435            .field("scroll_y", &self.scroll_y.get())
1436            .finish()
1437    }
1438}
1439
1440#[cfg(test)]
1441mod tests;
1442
1443#[cfg(test)]
1444mod checkbox_keyboard_tests;
1445
1446#[cfg(test)]
1447mod handle_tests;
1448
1449#[cfg(test)]
1450mod cursor_tests;