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;