oriel_iced/grid.rs
1//! The grid widget — [`VirtualGrid`], everything it emits and accepts, and
2//! its public style defaults.
3//!
4//! New here? Start at the [crate root](crate) for the runnable quickstart,
5//! the wiring contract, and the "where do I…?" map. This module holds the
6//! working surface: [`Message`] (forward every one to
7//! [`VirtualGrid::update`] and run the returned tasks), the imperative
8//! methods (ingest, specs, scrolling, navigation, selection), the chrome
9//! knobs, and the style hooks whose defaults are public `default_*`
10//! functions — wrap them or replace them.
11//!
12//! # Fetch traffic (what a custom store should expect)
13//!
14//! The grid keeps one contiguous **fetch window** of up to 256 rows
15//! (visible slice + margin), refetched when scrolling approaches the
16//! window's edge, after every [`transact`](VirtualGrid::transact), and on
17//! every spec change. Results are generation-guarded: only the newest
18//! in-flight fetch may fill the window; superseded results are discarded.
19//! While a fetch is pending, affected rows render as dimmed placeholders.
20//! There is no built-in debounce — a store behind a slow query should
21//! cache or coalesce on its side.
22//!
23//! # Design notes
24//!
25//! A virtualized, multi-column grid over the [`DataSource`] seam: a
26//! resizable, scroll-synced header, store-executed sort/filter,
27//! identity-keyed selection/focus with keyboard nav, and imperative
28//! transactional ingest with tail-follow/freeze.
29//!
30//! Fetches rows **through the trait** and materializes only the visible slice.
31//! Columns render via the [`Column`] seam ([`oriel_core::columns`]), laid out
32//! by a shared [`ColumnLayout`]; the header scrollable is kept horizontally
33//! aligned with the body via programmatic `scroll_to`.
34//!
35//! Ingest goes through [`VirtualGrid::transact`]: the app mutates the store
36//! imperatively (append/drop/upsert — store semantics, not seam surface) and
37//! the grid reacts — count, tail-follow or re-anchor **by row identity**,
38//! window refetch. The per-frame rebuild never touches more than the
39//! visible slice, no matter the ingest rate.
40//!
41//! The visible slice is keyed by a **`RowId`-derived hash**: on
42//! count-changing rebuilds (ingest appends/drops), `keyed`'s key-driven
43//! splice hands each surviving row its own retained widget-tree state
44//! instead of whichever slot's state happens to line up. Equal-length
45//! rebuilds still diff positionally regardless of key (an upstream iced
46//! behavior, pinned by canary tests) — which is why per-row *derived*
47//! state stays data-side.
48
49use std::cell::Cell;
50use std::collections::{BTreeSet, HashMap};
51use std::rc::Rc;
52use std::time::{Duration, Instant};
53
54use iced::alignment::Vertical;
55use iced::widget::{column, container, keyed_column, mouse_area, row, scrollable, space, text};
56use iced::widget::scrollable::{AbsoluteOffset, Direction, Scrollbar, Viewport};
57use iced::{Color, Element, Length, Padding, Task};
58use oriel_core::columns::{Column, ColumnId, ColumnLayout};
59use oriel_core::data::DataSource;
60use oriel_core::query::{QuerySpec, SortDirection, SortSpec};
61use oriel_core::selection::Selection;
62use oriel_core::viewport::{RowVirtualizer, ScrollAnchor};
63
64/// ODD on purpose (a measured calibration, not arithmetic): digit/cap ink at 13 px is 9 px tall
65/// (odd), and only an odd row height can center odd ink with integer gaps —
66/// see `column.rs`'s optical text box. Even heights put every centered cell on
67/// a half pixel. (With a [row divider](VirtualGrid::set_row_divider), pick an
68/// EVEN height instead — the divider takes the extra pixel.)
69pub const DEFAULT_ROW_HEIGHT: f32 = 25.0;
70/// Default header-band height.
71pub const DEFAULT_HEADER_HEIGHT: f32 = 28.0;
72/// Default footer-band height.
73pub const DEFAULT_FOOTER_HEIGHT: f32 = 28.0;
74/// Width of the resize grab strip on the right of each resizable header cell.
75const DEFAULT_HANDLE_WIDTH: f32 = 6.0;
76/// Horizontal padding inside each cell.
77pub const DEFAULT_CELL_PAD: f32 = 6.0;
78/// Scrollbar clearance. Bottom: reserved past the last row so
79/// fully-scrolled content clears iced's OVERLAY horizontal bar (its
80/// embedded mode is only honored for single-direction scrollables — the
81/// `Direction::Both` layout branch ignores `Scrollbar::spacing`,
82/// iced_widget-0.14.2/src/scrollable.rs:475-503). Right: the strip hosting
83/// the grid's OWN vertical bar ([`crate::scrollbar::VScrollbar`] — iced's
84/// has a hardcoded 2 px minimum thumb, useless at data-grid scale; the
85/// scrollable's vertical bar is hidden and this one drives it). Matches the
86/// default `Scrollbar` breadth (width 10, margin 0).
87const SCROLLBAR_GUTTER: f32 = 10.0;
88/// Default reorder-flight duration (ag-grid v30 parity again: `animateRows`
89/// defaults on there too). `Duration::ZERO` opts out.
90const DEFAULT_REORDER_ANIMATION: Duration = Duration::from_millis(250);
91const FETCH_ROWS: usize = 256;
92const FETCH_MARGIN: usize = 32;
93const MAX_VISIBLE_ROWS: usize = 128;
94const EDGE: usize = 8;
95
96/// The (owned) element type every column in this grid produces.
97///
98/// Cells render **display content** of arbitrary richness, but the element
99/// is closed over the grid's own [`Message`] type: a cell cannot host a
100/// widget that emits *your application's* messages (in-cell buttons/links
101/// are deliberately unshipped until a consumer exists). The supported
102/// pattern for row-level actions is the row click: match
103/// [`Message::RowPressed`] in your `update` before forwarding.
104///
105/// Cell box contract: each cell is laid out in a fixed box — the column's
106/// current width by the grid's row height (minus 1 px when a row divider
107/// is set) — vertically centered, **clipped** (grid content never wraps or
108/// overflows), with the grid's horizontal `cell_padding` applied outside
109/// your element. Content taller than the row is clipped top/bottom. For
110/// auto-size, width is measured through
111/// [`Column::measure`] — content that `Fill`s has no natural width and
112/// opts the column out.
113pub type CellElement<Row> = Element<'static, Message<Row>>;
114/// A boxed column for this grid — all columns share one `Element` and `Row`.
115pub type BoxedColumn<Row> = Box<dyn Column<Element = CellElement<Row>, Row = Row>>;
116
117/// Everything a style hook may key chrome off for one row: its position, its
118/// data (`None` = placeholder, not fetched yet), and its interaction state.
119/// `Copy`, so hooks can stash it in per-frame closures freely.
120#[derive(Debug)]
121pub struct RowContext<'a, Row> {
122 /// The row's position in the current order.
123 pub index: usize,
124 /// The row's data — `None` while it is a placeholder.
125 pub row: Option<&'a Row>,
126 /// Whether the row is selected (identity-keyed).
127 pub is_selected: bool,
128 /// Whether the row holds the keyboard-nav focus (identity-keyed).
129 pub is_focused: bool,
130 /// Whether the cursor is over the row (positional, transient).
131 pub is_hovered: bool,
132}
133
134impl<Row> Clone for RowContext<'_, Row> {
135 fn clone(&self) -> Self {
136 *self
137 }
138}
139impl<Row> Copy for RowContext<'_, Row> {}
140
141/// A row-chrome hook: full `container` styling per row (background, border,
142/// text color). Return a [`Border`](iced::Border) to build row dividers the
143/// grid doesn't ship — no fork needed.
144pub type RowStyleFn<Row> =
145 Box<dyn for<'a> Fn(&iced::Theme, &RowContext<'a, Row>) -> iced::widget::container::Style>;
146/// A cell-chrome hook — same power per `(row, column)`; vertical dividers and
147/// per-column tinting live here.
148pub type CellStyleFn<Row> = Box<
149 dyn for<'a> Fn(
150 &iced::Theme,
151 &RowContext<'a, Row>,
152 ColumnId,
153 ) -> iced::widget::container::Style,
154>;
155/// The header-band hook.
156pub type HeaderStyleFn = Box<dyn Fn(&iced::Theme) -> iced::widget::container::Style>;
157/// The sort-indicator hook: the element rendered beside the sorted column's
158/// header label. Direction in, element out — swap in any widget.
159pub type SortIndicatorFn<Row> =
160 Box<dyn Fn(SortDirection) -> Element<'static, Message<Row>>>;
161
162/// The built-in row chrome — a **public default** (no privileged internals):
163/// wrap it and adjust, or replace it outright via
164/// [`VirtualGrid::set_row_style`]. Precedence: selected (`primary.weak`) >
165/// focused > hovered > zebra stripe, all derived from the theme palette.
166pub fn default_row_style<Row>(
167 theme: &iced::Theme,
168 row: &RowContext<'_, Row>,
169) -> iced::widget::container::Style {
170 let palette = theme.extended_palette();
171 let background = if row.is_selected {
172 Some(palette.primary.weak.color.into())
173 } else if row.is_focused {
174 Some(Color { a: 0.45, ..palette.background.strong.color }.into())
175 } else if row.is_hovered {
176 Some(Color { a: 0.60, ..palette.background.weak.color }.into())
177 } else if row.index % 2 == 1 {
178 Some(Color { a: 0.30, ..palette.background.weak.color }.into())
179 } else {
180 None
181 };
182 iced::widget::container::Style {
183 background,
184 ..iced::widget::container::Style::default()
185 }
186}
187
188/// The built-in cell chrome: none — rows carry the default look. Override via
189/// [`VirtualGrid::set_cell_style`] for vertical dividers or column tints.
190pub fn default_cell_style<Row>(
191 _theme: &iced::Theme,
192 _row: &RowContext<'_, Row>,
193 _column: ColumnId,
194) -> iced::widget::container::Style {
195 iced::widget::container::Style::default()
196}
197
198/// The built-in header band: a `background.weak` fill. Override via
199/// [`VirtualGrid::set_header_style`].
200pub fn default_header_style(theme: &iced::Theme) -> iced::widget::container::Style {
201 iced::widget::container::Style {
202 background: Some(theme.extended_palette().background.weak.color.into()),
203 ..iced::widget::container::Style::default()
204 }
205}
206
207/// The built-in footer band — same fill as the header, bookending the body.
208/// Override via [`VirtualGrid::set_footer_style`].
209pub fn default_footer_style(theme: &iced::Theme) -> iced::widget::container::Style {
210 default_header_style(theme)
211}
212
213/// The built-in sort indicator — ag-grid's chevron-headed arrow
214/// ([`SortArrow`](crate::arrow::SortArrow), quad-drawn) in the inherited
215/// text color, so it always matches the header label beside it. Public
216/// like the other chrome defaults: wrap or replace via
217/// [`VirtualGrid::set_sort_indicator`].
218pub fn default_sort_indicator<Row: 'static>(
219 direction: SortDirection,
220) -> Element<'static, Message<Row>> {
221 match direction {
222 SortDirection::Ascending => crate::arrow::SortArrow::up(),
223 SortDirection::Descending => crate::arrow::SortArrow::down(),
224 }
225 .into()
226}
227
228/// Messages the grid emits and consumes.
229///
230/// `#[non_exhaustive]`: new capabilities add variants, so match with a
231/// wildcard arm. Most integrations never match at all — they forward
232/// everything to [`VirtualGrid::update`].
233#[derive(Debug, Clone)]
234#[non_exhaustive]
235pub enum Message<Row> {
236 /// The body scrollable moved; carries its absolute offset (x and y).
237 BodyScrolled(AbsoluteOffset),
238 /// A fetch resolved: `rows` start at index `start`.
239 Fetched {
240 /// The fetch generation this result belongs to. Results from a
241 /// superseded generation (an older scroll position or an older spec)
242 /// are ignored instead of clobbering the window — the stale-fetch
243 /// guard (a degenerate form of request coalescing).
244 generation: u64,
245 /// Index of the first fetched row.
246 start: usize,
247 /// The fetched rows.
248 rows: Vec<Row>,
249 },
250 /// A body row was clicked; `index` is its position in the current order.
251 ///
252 /// On [`update`](VirtualGrid::update), a click on a fetched row
253 /// **single-selects** it ([`Selection`] holds exactly that row) and
254 /// moves keyboard focus to it; a click on a not-yet-fetched placeholder
255 /// row does nothing. That is the whole v1 gesture policy — ctrl/shift
256 /// multi-select gestures are unshipped (the underlying [`Selection`] is
257 /// a set; drive multi-selection programmatically via
258 /// [`selection_mut`](VirtualGrid::selection_mut)). To react to clicks
259 /// yourself (master-detail panes), match this variant in your `update`
260 /// before forwarding, or read
261 /// [`selection`](VirtualGrid::selection)/[`focus`](VirtualGrid::focus)
262 /// after forwarding.
263 RowPressed {
264 /// Positional index of the clicked row.
265 index: usize,
266 },
267 /// The cursor entered a body row (drives the hover highlight).
268 RowHovered {
269 /// Positional index of the hovered row.
270 index: usize,
271 },
272 /// The cursor left a body row.
273 RowUnhovered {
274 /// Positional index of the row the cursor left.
275 index: usize,
276 },
277 /// A column is being resized: `delta` px from the drag origin.
278 ColumnResizing {
279 /// Positional index of the column.
280 index: usize,
281 /// Pixel delta from the drag origin.
282 delta: f32,
283 },
284 /// A column resize ended; commit the width.
285 ColumnResized {
286 /// Positional index of the column.
287 index: usize,
288 },
289 /// A resize grip was double-clicked: auto-size the column to its content.
290 ColumnAutoSizeRequested {
291 /// Positional index of the column.
292 index: usize,
293 },
294 /// The grid's own vertical scrollbar (the min-thumb replacement for the
295 /// scrollable's hidden one) was dragged or rail-clicked to an absolute
296 /// y offset.
297 ScrollbarDragged(f32),
298 /// The phantom measurer reported a column's natural content width (the
299 /// widest of its header — including the sort indicator when sorted —
300 /// visible cells, and footer, without cell padding).
301 ColumnMeasured {
302 /// Positional index of the column.
303 index: usize,
304 /// Measured natural width, px.
305 width: f32,
306 },
307 /// A sortable column's header was clicked; toggle sorting by it.
308 HeaderPressed {
309 /// Positional index of the column.
310 index: usize,
311 },
312}
313
314/// A virtualized, multi-column grid over a [`DataSource`].
315pub struct VirtualGrid<S: DataSource> {
316 store: S,
317 columns: Vec<BoxedColumn<S::Row>>,
318 layout: ColumnLayout,
319 virt: RowVirtualizer,
320 /// The current sort/filter spec — core-owned view state; the
321 /// store executes it. Get/set via [`spec`](Self::spec)/[`set_spec`](Self::set_spec).
322 spec: QuerySpec,
323 /// Selected rows, by identity — survives re-sort/re-virtualization by
324 /// construction. Settable from outside via
325 /// [`selection_mut`](Self::selection_mut).
326 selection: Selection<S::RowId>,
327 /// The keyboard-nav cursor, by **identity** ("state lives on the
328 /// `RowId`"), so re-sorting never leaves the cursor highlighting whatever
329 /// row happens to occupy the old slot, and nav continues *from the focused
330 /// row* in the new order when it is still within the fetch window. Its
331 /// position is resolved opportunistically against the window
332 /// ([`focus_index`](Self::focus_index)); when unresolvable, nav re-anchors
333 /// at the top visible row — unless the app opts into off-window
334 /// restoration ([`set_focus_restoration`](Self::set_focus_restoration)).
335 focus: Option<S::RowId>,
336 /// Opt-in: resolve an off-window focus through
337 /// [`DataSource::index_of`] instead of re-anchoring nav at the top visible
338 /// row. An **app choice**, never automatic — it makes nav jump the
339 /// viewport to wherever the focused row went.
340 focus_restoration: bool,
341 total_rows: usize,
342 /// Columns (positional indices) awaiting an auto-size measurement. For
343 /// each pending column the view carries a phantom
344 /// [`Measure`](crate::measure) over its header (with sort indicator
345 /// when sorted) + visible cells + footer; each report commits through
346 /// `ColumnLayout::set_width` and clears its entry. A grip double-click
347 /// queues one column; [`auto_size_all`](Self::auto_size_all) queues
348 /// every column — all measured in a single layout pass.
349 auto_size: BTreeSet<usize>,
350 /// The hovered row's position — deliberately **positional and transient**
351 /// (it follows the cursor, not the data; a stale index self-corrects on
352 /// the next cursor event). Never part of durable view state.
353 hovered: Option<usize>,
354 scroll_offset: f32,
355 /// The last offset the body scrollable *reported* (as opposed to
356 /// [`scroll_offset`](Self::scroll_offset), which is what the grid last
357 /// commanded/synced). Reports are not only user scrolls — see the
358 /// receding-report logic in `BodyScrolled`.
359 last_scroll_report: f32,
360 /// Accumulated upward drift across consecutive receding reports —
361 /// precision touchpads recede in sub-pixel steps that a per-report
362 /// 1 px test never sees. Reset by any non-receding
363 /// report.
364 recession: f32,
365 /// The fetch generation whose result currently fills
366 /// [`window_rows`](Self::window_rows). Flight scheduling waits until
367 /// this catches [`fetch_generation`](Self::fetch_generation) — with a
368 /// genuinely async store, scheduling against the pre-mutation window
369 /// would compute every delta from stale indices.
370 window_generation: u64,
371 /// Bumped on every auto-size request; stamps the phantom measurers so a
372 /// re-queue that lands in the same message batch as a drain still
373 /// re-publishes (the one-shot flag alone needed an idle frame between —
374 /// a wedge: the fit would stay pending forever).
375 auto_size_epoch: u64,
376 /// Tail-follow: when `true`, every [`transact`](Self::transact)
377 /// auto-scrolls to the newest rows. Freeze = `set_follow(false)` — it halts
378 /// the auto-scroll, never the ingest. A manual scroll away from the bottom
379 /// freezes automatically (the grid must not fight the user for the scroll
380 /// position); re-engaging is always explicit.
381 follow: bool,
382 /// Last body-viewport height observed during layout; drives page-nav and
383 /// ensure-visible math. Interior-mutable because `view` is `&self`.
384 viewport_height: Cell<f32>,
385 /// Monotonic fetch generation — see [`Message::Fetched::generation`].
386 fetch_generation: u64,
387 window_start: usize,
388 window_rows: Vec<S::Row>,
389 header_id: iced::widget::Id,
390 body_id: iced::widget::Id,
391 footer_id: iced::widget::Id,
392 /// Whether any column provides a footer — structural, checked once at
393 /// construction; the footer strip renders (and scroll-syncs) only then.
394 has_footer: bool,
395 // Chrome knobs: geometry values plus the style hooks. The
396 // hooks default to the public `default_*_style` functions — integrators
397 // wrap or replace them (dividers, row tints, custom palettes) without
398 // touching the grid.
399 header_height: f32,
400 footer_height: f32,
401 cell_padding: f32,
402 handle_width: f32,
403 row_style: RowStyleFn<S::Row>,
404 cell_style: CellStyleFn<S::Row>,
405 header_style: HeaderStyleFn,
406 footer_style: HeaderStyleFn,
407 sort_indicator: SortIndicatorFn<S::Row>,
408 /// A 1 px line under every row, carved OUT of the row height (pitch
409 /// unchanged) — see [`set_row_divider`](Self::set_row_divider).
410 row_divider: Option<Color>,
411 /// A 1 px line at each body cell's right edge (except the last column),
412 /// carved out of the cell width — see
413 /// [`set_column_divider`](Self::set_column_divider).
414 column_divider: Option<Color>,
415 /// The frame rules' color (the 1 px separators under the header and
416 /// above the footer). `None` = the ambient theme's rule style. This is
417 /// what a [`GridTheme`](crate::theme::GridTheme)'s `border` drives.
418 rule_color: Option<Color>,
419 /// Reorder-flight duration; `ZERO` = off (see
420 /// [`set_reorder_animation`](Self::set_reorder_animation)).
421 reorder_animation: Duration,
422 /// Live flights by row identity — screen-space offsets easing to zero.
423 flights: HashMap<S::RowId, crate::slide::Flight>,
424 /// Live enter-fades by row identity — rows that newly appeared in the
425 /// viewport during an upheaval materialize from the background (only the
426 /// timing of the [`Flight`](crate::slide::Flight) is used).
427 fades: HashMap<S::RowId, crate::slide::Flight>,
428 /// The flight easing curve (default: normalized low-pass; see
429 /// [`set_reorder_easing`](Self::set_reorder_easing)).
430 reorder_easing: crate::slide::Easing,
431 /// Old on-screen positions captured before an upheaval, waiting for the
432 /// re-derived window; tagged with the fetch generation that will fill it
433 /// (a superseding fetch discards them).
434 pending_flights: Option<(u64, Vec<(S::RowId, f32)>)>,
435}
436
437impl<S: DataSource> VirtualGrid<S> {
438 /// Creates a grid over `store` rendered by `columns`, plus the initial fetch
439 /// [`Task`]. Column widths/order come from the columns' sizing policies.
440 pub fn new(store: S, columns: Vec<BoxedColumn<S::Row>>) -> (Self, Task<Message<S::Row>>) {
441 let total_rows = store.row_count().lower_bound();
442 let layout = ColumnLayout::new(columns.iter().map(|c| (c.id(), c.sizing())));
443 let has_footer = columns.iter().any(|c| c.footer().is_some());
444 let mut grid = Self {
445 store,
446 columns,
447 layout,
448 virt: RowVirtualizer::new(DEFAULT_ROW_HEIGHT),
449 spec: QuerySpec::default(),
450 selection: Selection::new(),
451 focus: None,
452 focus_restoration: false,
453 total_rows,
454 auto_size: BTreeSet::new(),
455 hovered: None,
456 scroll_offset: 0.0,
457 last_scroll_report: 0.0,
458 recession: 0.0,
459 window_generation: 0,
460 auto_size_epoch: 0,
461 follow: false,
462 viewport_height: Cell::new(0.0),
463 fetch_generation: 0,
464 window_start: 0,
465 window_rows: Vec::new(),
466 header_id: iced::widget::Id::unique(),
467 body_id: iced::widget::Id::unique(),
468 footer_id: iced::widget::Id::unique(),
469 has_footer,
470 header_height: DEFAULT_HEADER_HEIGHT,
471 footer_height: DEFAULT_FOOTER_HEIGHT,
472 cell_padding: DEFAULT_CELL_PAD,
473 handle_width: DEFAULT_HANDLE_WIDTH,
474 row_style: Box::new(default_row_style),
475 cell_style: Box::new(default_cell_style),
476 header_style: Box::new(default_header_style),
477 footer_style: Box::new(default_footer_style),
478 sort_indicator: Box::new(default_sort_indicator),
479 row_divider: None,
480 column_divider: None,
481 rule_color: None,
482 reorder_animation: DEFAULT_REORDER_ANIMATION,
483 flights: HashMap::new(),
484 fades: HashMap::new(),
485 reorder_easing: Rc::new(crate::slide::low_pass_easing),
486 pending_flights: None,
487 };
488 let init = grid.fetch_task(0);
489 (grid, init)
490 }
491
492 /// The current selection (by row identity).
493 #[must_use]
494 pub fn selection(&self) -> &Selection<S::RowId> {
495 &self.selection
496 }
497
498 /// Mutable access to the selection — the programmatic entry point (linked
499 /// selection, presets, "select these rows from outside"). The next render
500 /// reflects whatever is set here.
501 pub fn selection_mut(&mut self) -> &mut Selection<S::RowId> {
502 &mut self.selection
503 }
504
505 /// Read access to the underlying store — rows for a master-detail pane,
506 /// `index_of` for app-side policies. All **mutation** goes through
507 /// [`transact`](Self::transact), so the grid always observes the change.
508 #[must_use]
509 pub fn store(&self) -> &S {
510 &self.store
511 }
512
513 /// Whether the grid is tail-following (auto-scrolling to the newest rows
514 /// on every transaction).
515 #[must_use]
516 pub fn follow(&self) -> bool {
517 self.follow
518 }
519
520 /// Enables or disables tail-follow. The **freeze toggle** is
521 /// `set_follow(false)`: it halts the auto-scroll only — ingest keeps
522 /// flowing through [`transact`](Self::transact) untouched. Enabling jumps
523 /// to the tail immediately. The grid also freezes itself when the user
524 /// scrolls away from the bottom (it never fights for the scroll position);
525 /// scrolling back down does **not** re-engage — re-enabling is explicit,
526 /// so watch [`follow`](Self::follow) to keep a toggle widget honest.
527 pub fn set_follow(&mut self, follow: bool) -> Task<Message<S::Row>> {
528 self.follow = follow;
529 if follow {
530 self.jump_to_tail()
531 } else {
532 Task::none()
533 }
534 }
535
536 /// Applies an imperative transaction to the store — **the ingest entry
537 /// point**, and the change-notification path:
538 /// the grid owns the store, so routing every mutation through here is what
539 /// lets it react without any subscription machinery.
540 ///
541 /// The closure gets `&mut S` and calls whatever transaction methods the
542 /// store exposes (append/drop/upsert/clear — store semantics, not seam
543 /// surface). The store must uphold the seam's post-mutation consistency
544 /// contract (see [`DataSource`]); the grid then:
545 ///
546 /// - re-reads the row count;
547 /// - if following, scrolls to the tail; otherwise **re-anchors by row
548 /// identity**: the first previously-visible row that still
549 /// exists keeps its on-screen position, so appends/drops above the
550 /// viewport never make visible content jump. When no visible row
551 /// survived (or the store's `index_of` can't answer), it degrades to the
552 /// old offset, clamped — which is continuous in the ring-drain case,
553 /// where the anchor index was already sliding toward zero;
554 /// - refetches the window — synchronously when the store's future is
555 /// already ready (resident stores: no placeholder flicker at ingest
556 /// rates), falling back to the async task path otherwise.
557 ///
558 /// Selection and focus are **not** pruned: both are identity-keyed, so ids
559 /// of departed rows simply stop resolving (and resolve again if the rows
560 /// return). Pruning is app policy, built from the exposed primitives:
561 /// `grid.selection_mut().retain(|id| grid.store().index_of(id).is_some())`.
562 ///
563 /// # Example
564 ///
565 /// ```
566 /// # use oriel_core::{columns::ColumnId, data::VecStore};
567 /// # use oriel_iced::grid::{BoxedColumn, VirtualGrid};
568 /// # use oriel_iced::column::TextColumn;
569 /// # #[derive(Clone, Debug)] struct Row { id: u64, name: String }
570 /// # fn key(r: &Row) -> u64 { r.id }
571 /// # let store = VecStore::new(
572 /// # (0..100u64).map(|id| Row { id, name: format!("row {id}") }).collect(),
573 /// # key as fn(&Row) -> u64,
574 /// # );
575 /// # let columns: Vec<BoxedColumn<Row>> =
576 /// # vec![Box::new(TextColumn::new(ColumnId(1), "Name", 200.0, |r: &Row| r.name.clone()))];
577 /// # let (mut grid, _init) = VirtualGrid::new(store, columns);
578 /// // One ingest tick = ONE transaction; mutate the store through its own
579 /// // API (the grid never learns mutation shapes):
580 /// let task = grid.transact(|store| {
581 /// store.append(vec![Row { id: 100, name: "fresh".into() }]);
582 /// store.drop_front(1); // ring semantics, if the app wants them
583 /// });
584 /// // …return `task` (mapped into your message type) from `update`.
585 /// # assert_eq!(grid.total_rows(), 100);
586 /// ```
587 pub fn transact(&mut self, mutate: impl FnOnce(&mut S)) -> Task<Message<S::Row>> {
588 let snapshot = self.flight_snapshot();
589 let task = self.transact_inner(mutate);
590 self.pending_flights = snapshot.map(|s| (self.fetch_generation, s));
591 self.try_schedule_flights();
592 task
593 }
594
595 fn transact_inner(&mut self, mutate: impl FnOnce(&mut S)) -> Task<Message<S::Row>> {
596 // Capture the anchor BEFORE the mutation: the top visible row's
597 // identity, plus the rows visible below it as fallback candidates.
598 let anchor = self.virt.anchor_at(self.scroll_offset);
599 let candidates: Vec<S::RowId> = if self.follow {
600 Vec::new()
601 } else {
602 let visible = self.virt.rows_spanned(self.viewport_height.get());
603 (anchor.row..anchor.row.saturating_add(visible))
604 .filter_map(|i| self.cached_row(i).map(|row| self.store.row_id(row)))
605 .collect()
606 };
607
608 mutate(&mut self.store);
609 self.total_rows = self.store.row_count().lower_bound();
610
611 if self.follow {
612 return self.jump_to_tail();
613 }
614
615 let new_offset = candidates
616 .iter()
617 .find_map(|id| self.store.index_of(id))
618 .map(|index| {
619 self.virt.flat_offset_of(ScrollAnchor { row: index, offset_px: anchor.offset_px })
620 })
621 .unwrap_or(self.scroll_offset)
622 .clamp(0.0, self.max_scroll());
623 let scroll = (new_offset != self.scroll_offset).then(|| {
624 self.scroll_offset = new_offset;
625 iced::widget::operation::scroll_to(
626 self.body_id.clone(),
627 AbsoluteOffset { x: self.layout.h_scroll(), y: new_offset },
628 )
629 });
630 // Unconditional refetch: even at an unchanged anchor the rows under
631 // the window may have changed (updates, shifted indices).
632 let fetch = self.fetch_now(self.virt.anchor_at(new_offset).row);
633 match scroll {
634 Some(scroll) => Task::batch([scroll, fetch]),
635 None => fetch,
636 }
637 }
638
639 /// The keyboard-nav cursor — the focused row's identity, if any.
640 #[must_use]
641 pub fn focus(&self) -> Option<&S::RowId> {
642 self.focus.as_ref()
643 }
644
645 /// The focused row's position in the current order, resolved against the
646 /// fetch window. `None` means the cursor exists but its row is currently
647 /// beyond the window (e.g. sorted far away) — nav will re-anchor at the
648 /// top visible row. With
649 /// [`set_focus_restoration(true)`](Self::set_focus_restoration) the miss
650 /// falls back to [`DataSource::index_of`], so an off-window focus still
651 /// resolves (and nav continues from it) whenever the store can answer.
652 #[must_use]
653 pub fn focus_index(&self) -> Option<usize> {
654 let focus = self.focus.as_ref()?;
655 self.window_rows
656 .iter()
657 .position(|row| self.store.row_id(row) == *focus)
658 .map(|offset| self.window_start + offset)
659 .or_else(|| self.focus_restoration.then(|| self.store.index_of(focus)).flatten())
660 }
661
662 /// Opts into **focus restoration** — "the viewport chases the focused
663 /// row" (default: off). When enabled:
664 ///
665 /// - a focus that lies outside the fetch window (sorted far away, slid
666 /// along by ingest) still resolves through [`DataSource::index_of`],
667 /// and keyboard nav continues *from it*, jumping the viewport to
668 /// wherever the row went (instead of re-anchoring at the top visible
669 /// row);
670 /// - a spec change keeps a surviving focused row in view across the
671 /// reorder (instead of the default leave-the-scrollbar-alone).
672 ///
673 /// This is an integrator/app **choice**, never automatic: the
674 /// chase is right for master-detail-style flows and wrong for others, and
675 /// for stores whose `index_of` stays the tolerant default `None` it
676 /// simply never fires.
677 pub fn set_focus_restoration(&mut self, enabled: bool) {
678 self.focus_restoration = enabled;
679 }
680
681 /// Whether off-window focus restoration is enabled.
682 #[must_use]
683 pub fn focus_restoration(&self) -> bool {
684 self.focus_restoration
685 }
686
687 // --- chrome knobs (a knob for everything that makes sense;
688 // the style hooks are the no-cliffs escape hatch — dividers, row tints,
689 // custom palettes all land integrator-side without forking the grid) ---
690
691 /// Sets the row height, logical px (default 25 — deliberately **ODD**;
692 /// see `DEFAULT_ROW_HEIGHT`'s parity note. Themes with a row divider use
693 /// EVEN heights: the 1 px line is carved out, leaving an odd content
694 /// box). The viewport **re-anchors by row** across the change: the rows
695 /// on screen stay the rows on screen — a density change must never
696 /// teleport the user (the row-space anchor is the position of truth;
697 /// the pixel offset is derived). While tail-following, the tail stays
698 /// pinned instead. Returns the follow-up [`Task`] — feed it to iced.
699 pub fn set_row_height(&mut self, px: f32) -> Task<Message<S::Row>> {
700 if px == self.virt.row_height() {
701 return Task::none();
702 }
703 let anchor = self.scroll_anchor();
704 // The sub-row offset is in OLD-height pixels; scale it so the
705 // anchor row keeps its on-screen fraction.
706 let scale = px / self.virt.row_height();
707 self.virt = RowVirtualizer::new(px);
708 if self.follow {
709 return self.jump_to_tail();
710 }
711 let target = ScrollAnchor { row: anchor.row, offset_px: anchor.offset_px * scale };
712 let offset = self.virt.flat_offset_of(target).clamp(0.0, self.max_scroll());
713 let scroll = (offset != self.scroll_offset).then(|| {
714 self.scroll_offset = offset;
715 iced::widget::operation::scroll_to(
716 self.body_id.clone(),
717 AbsoluteOffset { x: self.layout.h_scroll(), y: offset },
718 )
719 });
720 let fetch = self.fetch_now(self.virt.anchor_at(offset).row);
721 match scroll {
722 Some(scroll) => Task::batch([scroll, fetch]),
723 None => fetch,
724 }
725 }
726
727 /// Sets the header height, logical px (default 28).
728 pub fn set_header_height(&mut self, px: f32) {
729 self.header_height = px;
730 }
731
732 /// Sets the footer height, logical px (default 28; only rendered when a
733 /// column provides a footer).
734 pub fn set_footer_height(&mut self, px: f32) {
735 self.footer_height = px;
736 }
737
738 /// Sets the horizontal cell padding, logical px (default 6).
739 pub fn set_cell_padding(&mut self, px: f32) {
740 self.cell_padding = px;
741 }
742
743 /// Sets the resize grab-strip width, logical px (default 6).
744 pub fn set_handle_width(&mut self, px: f32) {
745 self.handle_width = px;
746 }
747
748 /// Replaces the row-chrome hook (default: [`default_row_style`]). Wrap
749 /// the default to keep the built-in precedence and adjust on top, or
750 /// replace it outright — this is the no-cliffs escape for any row look
751 /// the grid doesn't ship.
752 ///
753 /// # Example — a data-driven tint over the default chrome
754 ///
755 /// ```
756 /// # use oriel_core::{columns::ColumnId, data::VecStore};
757 /// # use oriel_iced::grid::{self, BoxedColumn, VirtualGrid};
758 /// # use oriel_iced::column::TextColumn;
759 /// # #[derive(Clone, Debug)] struct Row { id: u64, alarming: bool }
760 /// # fn key(r: &Row) -> u64 { r.id }
761 /// # let store = VecStore::new(
762 /// # (0..100u64).map(|id| Row { id, alarming: id % 7 == 0 }).collect(),
763 /// # key as fn(&Row) -> u64,
764 /// # );
765 /// # let columns: Vec<BoxedColumn<Row>> =
766 /// # vec![Box::new(TextColumn::new(ColumnId(1), "#", 80.0, |r: &Row| r.id.to_string()))];
767 /// # let (mut grid, _init) = VirtualGrid::new(store, columns);
768 /// grid.set_row_style(|theme, ctx| {
769 /// // Keep selection/focus/hover/zebra precedence, tint on top.
770 /// let mut style = grid::default_row_style(theme, ctx);
771 /// if !ctx.is_selected && ctx.row.is_some_and(|r| r.alarming) {
772 /// let danger = theme.extended_palette().danger.base.color;
773 /// style.background = Some(iced::Color { a: 0.08, ..danger }.into());
774 /// }
775 /// style
776 /// });
777 /// ```
778 pub fn set_row_style(
779 &mut self,
780 style: impl for<'a> Fn(&iced::Theme, &RowContext<'a, S::Row>) -> iced::widget::container::Style
781 + 'static,
782 ) {
783 self.row_style = Box::new(style);
784 }
785
786 /// Replaces the cell-chrome hook (default: [`default_cell_style`], which
787 /// is no chrome at all). Vertical dividers and per-column tints go here.
788 pub fn set_cell_style(
789 &mut self,
790 style: impl for<'a> Fn(
791 &iced::Theme,
792 &RowContext<'a, S::Row>,
793 ColumnId,
794 ) -> iced::widget::container::Style
795 + 'static,
796 ) {
797 self.cell_style = Box::new(style);
798 }
799
800 /// Replaces the header-band hook (default: [`default_header_style`]).
801 pub fn set_header_style(
802 &mut self,
803 style: impl Fn(&iced::Theme) -> iced::widget::container::Style + 'static,
804 ) {
805 self.header_style = Box::new(style);
806 }
807
808 /// Replaces the footer-band hook (default: [`default_footer_style`]).
809 pub fn set_footer_style(
810 &mut self,
811 style: impl Fn(&iced::Theme) -> iced::widget::container::Style + 'static,
812 ) {
813 self.footer_style = Box::new(style);
814 }
815
816 /// Replaces the sort-indicator hook (default: [`default_sort_indicator`]).
817 /// The returned element is placed right of the sorted column's header
818 /// label, vertically centered.
819 pub fn set_sort_indicator(
820 &mut self,
821 indicator: impl Fn(SortDirection) -> Element<'static, Message<S::Row>> + 'static,
822 ) {
823 self.sort_indicator = Box::new(indicator);
824 }
825
826 /// Sets the horizontal **row divider**: a single 1 px line under every
827 /// row (default: `None` — no line; the built-in look separates rows by
828 /// zebra stripes instead). The line is carved OUT of the row height, so
829 /// the row pitch — and all virtualization math — is unchanged. Parity
830 /// note: with a divider, pick an **even** row height so the remaining
831 /// content box stays odd and cell ink centers on integer pixels (the
832 /// same chain that makes the default height odd).
833 pub fn set_row_divider(&mut self, color: Option<Color>) {
834 self.row_divider = color;
835 }
836
837 /// Sets the vertical **column divider**: a single 1 px line at the right
838 /// edge of every body cell except the last column's (default: `None`).
839 /// Carved out of the cell's width, so column positions stay aligned with
840 /// the header. Full spreadsheet-style gridlines = both dividers set.
841 pub fn set_column_divider(&mut self, color: Option<Color>) {
842 self.column_divider = color;
843 }
844
845 /// Auto-sizes **every** column to its content (ag-grid's
846 /// `autoSizeAllColumns`): queues a measurement per column; all are
847 /// measured in the next layout pass and commit through the same
848 /// policy-clamped path as a grip double-click. Columns whose cells ask
849 /// to `Fill` have no natural width and are left alone; fixed columns
850 /// clamp back to their fixed width. [`GridTheme`](crate::theme::GridTheme)
851 /// application calls this — a theme changes the metrics columns were
852 /// fitted under (padding, density), so the fit re-derives.
853 pub fn auto_size_all(&mut self) {
854 self.auto_size.extend(0..self.columns.len());
855 self.auto_size_epoch += 1;
856 }
857
858 /// Sets the color of the grid's **frame rules** — the 1 px separators
859 /// under the header band and above the footer strip (default: `None`,
860 /// the ambient theme's rule style). Themes drive this from their
861 /// structural border color.
862 pub fn set_rule_color(&mut self, color: Option<Color>) {
863 self.rule_color = color;
864 }
865
866 /// The current row height, logical px.
867 #[must_use]
868 pub fn row_height(&self) -> f32 {
869 self.virt.row_height()
870 }
871
872 /// The current header-band height, logical px.
873 #[must_use]
874 pub fn header_height(&self) -> f32 {
875 self.header_height
876 }
877
878 /// The current footer-strip height, logical px.
879 #[must_use]
880 pub fn footer_height(&self) -> f32 {
881 self.footer_height
882 }
883
884 /// The current horizontal cell padding, logical px.
885 #[must_use]
886 pub fn cell_padding(&self) -> f32 {
887 self.cell_padding
888 }
889
890 /// The current resize grab-strip width, logical px.
891 #[must_use]
892 pub fn handle_width(&self) -> f32 {
893 self.handle_width
894 }
895
896 /// Sets the row-animation duration (ag-grid's `animateRows` pair):
897 /// rows visible before AND after an upheaval **glide** from their old
898 /// on-screen position; rows that newly entered the viewport **fade in**
899 /// from the background. **Default: 250 ms, on** (v30 parity);
900 /// `Duration::ZERO` disables both.
901 ///
902 /// Flights are computed in **screen space** against a resting viewport:
903 /// frozen-anchor ingest holds rows at constant screen positions (zero
904 /// delta — nothing animates, even at 60 Hz), and while **tail-following,
905 /// flights are suppressed** — there the viewport itself moves with the
906 /// stream, and animating that would smear every visible row every tick.
907 /// The enter-fade is motion-free, so it stays on under follow: appended
908 /// rows materialize at the tail.
909 pub fn set_reorder_animation(&mut self, duration: Duration) {
910 self.reorder_animation = duration;
911 if duration.is_zero() {
912 self.flights.clear();
913 self.fades.clear();
914 self.pending_flights = None;
915 }
916 }
917
918 /// Replaces the flight easing curve — any `f(t)` with `f(0) = 0` and
919 /// `f(1) = 1` (the deadline contract: every flight lands at exactly
920 /// `t[N]`, whatever the curve does before it). Default:
921 /// [`slide::low_pass_easing`](crate::slide::low_pass_easing) — a
922 /// normalized first-order low-pass response;
923 /// [`slide::ease_out_cubic`](crate::slide::ease_out_cubic) is provided
924 /// as an alternative.
925 pub fn set_reorder_easing(&mut self, easing: impl Fn(f32) -> f32 + 'static) {
926 self.reorder_easing = Rc::new(easing);
927 }
928
929 /// Whether any row animation (flight or enter-fade) is in progress.
930 #[must_use]
931 pub fn animating(&self) -> bool {
932 let now = Instant::now();
933 self.flights.values().any(|f| f.is_live(now))
934 || self.fades.values().any(|f| f.is_live(now))
935 }
936
937 /// Captures visible rows' current on-screen y — the "before" half of
938 /// row-animation scheduling. `None` when animation is off or nothing is
939 /// cached (a boot fill therefore never animates: there is no prior
940 /// visible state to animate FROM).
941 fn flight_snapshot(&self) -> Option<Vec<(S::RowId, f32)>> {
942 if self.reorder_animation.is_zero() {
943 return None;
944 }
945 let anchor = self.virt.anchor_at(self.scroll_offset);
946 let visible = self.virt.rows_spanned(self.viewport_height.get());
947 let mut snapshot = Vec::new();
948 for i in anchor.row..anchor.row.saturating_add(visible) {
949 if let Some(row) = self.cached_row(i) {
950 snapshot.push((self.store.row_id(row), self.virt.screen_y(i, self.scroll_offset)));
951 }
952 }
953 (!snapshot.is_empty()).then_some(snapshot)
954 }
955
956 /// The "after" half: once the re-derived window is filled (synchronously
957 /// by a transaction, or by the matching [`Message::Fetched`] for spec
958 /// changes; a superseding fetch, e.g. a scroll, discards the capture):
959 ///
960 /// - rows present on BOTH sides get a **flight** from their old screen
961 /// position — unless tail-following, where the viewport itself moves
962 /// with the stream and per-tick re-flighting would smear every row
963 /// (observed on-screen with feed + follow both live);
964 /// - rows that newly ENTERED the viewport get an **enter-fade**,
965 /// materializing from the background — motion-free, so it composes
966 /// with a moving viewport: the tail's soft edge under feed+follow.
967 fn try_schedule_flights(&mut self) {
968 let Some((generation, _)) = self.pending_flights.as_ref() else {
969 return;
970 };
971 if *generation != self.fetch_generation {
972 self.pending_flights = None;
973 return;
974 }
975 if self.window_generation != self.fetch_generation {
976 // The fill for THIS generation hasn't landed (async store, or a
977 // spec change awaiting its fetch) — scheduling now would compute
978 // every delta from the stale pre-mutation window. The matching
979 // `Fetched` re-enters here.
980 return;
981 }
982 let Some((_, old)) = self.pending_flights.take() else {
983 return;
984 };
985 let old: HashMap<S::RowId, f32> = old.into_iter().collect();
986 let now = Instant::now();
987 let duration = self.reorder_animation;
988 let anchor = self.virt.anchor_at(self.scroll_offset);
989 let visible = self.virt.rows_spanned(self.viewport_height.get());
990 self.flights.retain(|_, f| f.is_live(now));
991 self.fades.retain(|_, f| f.is_live(now));
992 for i in anchor.row..anchor.row.saturating_add(visible) {
993 if let Some(row) = self.cached_row(i) {
994 let id = self.store.row_id(row);
995 match old.get(&id) {
996 Some(old_y) if !self.follow => {
997 let delta = old_y - self.virt.screen_y(i, self.scroll_offset);
998 if delta.abs() >= 0.5 {
999 self.flights.insert(
1000 id,
1001 crate::slide::Flight { from: delta, started: now, duration },
1002 );
1003 }
1004 }
1005 Some(_) => {} // both-sides row while following: no motion
1006 None => {
1007 // Newly visible: materialize from the background.
1008 self.fades.insert(
1009 id,
1010 crate::slide::Flight { from: 0.0, started: now, duration },
1011 );
1012 }
1013 }
1014 }
1015 }
1016 }
1017
1018 /// The current fetch generation (matches [`Message::Fetched::generation`]
1019 /// for the newest in-flight fetch). Exposed for integrations and tests
1020 /// that construct `Fetched` messages themselves.
1021 #[must_use]
1022 pub fn fetch_generation(&self) -> u64 {
1023 self.fetch_generation
1024 }
1025
1026 /// The contiguous range of row indices currently held in the fetch window.
1027 #[must_use]
1028 pub fn window_range(&self) -> core::ops::Range<usize> {
1029 self.window_start..self.window_start + self.window_rows.len()
1030 }
1031
1032 /// The current sort/filter spec — the value presets get/persist. This
1033 /// **includes header-click sorts**, so it is also the observation point
1034 /// for user-driven sort changes: compare `spec().sort` across `update`
1035 /// calls, or match [`Message::HeaderPressed`] before forwarding and
1036 /// predict the outcome with
1037 /// [`QuerySpec::cycled_sort`](oriel_core::query::QuerySpec::cycled_sort).
1038 #[must_use]
1039 pub fn spec(&self) -> &QuerySpec {
1040 &self.spec
1041 }
1042
1043 /// The current (post-filter) row count.
1044 #[must_use]
1045 pub fn total_rows(&self) -> usize {
1046 self.total_rows
1047 }
1048
1049 /// The column layout — widths, order, horizontal scroll — the
1050 /// persistable value. Read it to persist; restore through
1051 /// [`column_layout_mut`](Self::column_layout_mut).
1052 ///
1053 /// Note: the shipped grid **owns** its layout — two grids cannot yet
1054 /// share one handle (held open for split views); until then, keep
1055 /// sibling grids in lockstep by forwarding each one's
1056 /// [`ColumnResizing`](Message::ColumnResizing)/[`ColumnResized`](Message::ColumnResized)
1057 /// messages to the other.
1058 #[must_use]
1059 pub fn column_layout(&self) -> &ColumnLayout {
1060 &self.layout
1061 }
1062
1063 /// Mutable access to the column layout — the programmatic entry point
1064 /// for **restoring persisted widths**
1065 /// ([`ColumnLayout::set_width`] is policy-clamped per column) or
1066 /// driving widths from outside. The next render reflects whatever is
1067 /// set here.
1068 ///
1069 /// ```
1070 /// # use oriel_core::{columns::ColumnId, data::VecStore};
1071 /// # use oriel_iced::grid::{BoxedColumn, VirtualGrid};
1072 /// # use oriel_iced::column::TextColumn;
1073 /// # #[derive(Clone, Debug)] struct Row { id: u64 }
1074 /// # fn key(r: &Row) -> u64 { r.id }
1075 /// # let store = VecStore::new((0..10).map(|id| Row { id }).collect(), key as fn(&Row) -> u64);
1076 /// # let columns: Vec<BoxedColumn<Row>> =
1077 /// # vec![Box::new(TextColumn::new(ColumnId(1), "#", 80.0, |r: &Row| r.id.to_string()))];
1078 /// # let (mut grid, _init) = VirtualGrid::new(store, columns);
1079 /// // Persist…
1080 /// let widths: Vec<f32> = grid.column_layout().placements().iter().map(|p| p.width).collect();
1081 /// // …restore (e.g. next session):
1082 /// for (index, width) in widths.iter().enumerate() {
1083 /// grid.column_layout_mut().set_width(index, *width);
1084 /// }
1085 /// ```
1086 pub fn column_layout_mut(&mut self) -> &mut ColumnLayout {
1087 &mut self.layout
1088 }
1089
1090 /// The scroll position of truth, in row space: the first
1091 /// row the viewport's top edge touches plus the sub-row pixel offset.
1092 /// Exposed state — hold it to observe anchoring, persist it and restore
1093 /// via [`scroll_to_anchor`](Self::scroll_to_anchor).
1094 ///
1095 /// # Example — a bookmark that survives churn
1096 ///
1097 /// ```
1098 /// # use oriel_core::{columns::ColumnId, data::VecStore};
1099 /// # use oriel_iced::grid::{BoxedColumn, VirtualGrid};
1100 /// # use oriel_iced::column::TextColumn;
1101 /// # #[derive(Clone, Debug)] struct Row { id: u64, name: String }
1102 /// # fn key(r: &Row) -> u64 { r.id }
1103 /// # let store = VecStore::new(
1104 /// # (0..1000u64).map(|id| Row { id, name: format!("row {id}") }).collect(),
1105 /// # key as fn(&Row) -> u64,
1106 /// # );
1107 /// # let columns: Vec<BoxedColumn<Row>> =
1108 /// # vec![Box::new(TextColumn::new(ColumnId(1), "Name", 200.0, |r: &Row| r.name.clone()))];
1109 /// # let (mut grid, _init) = VirtualGrid::new(store, columns);
1110 /// let bookmark = grid.scroll_anchor(); // ROW-space, not pixels
1111 /// // …the stream appends thousands of rows, the user wanders off…
1112 /// let task = grid.scroll_to_anchor(bookmark); // back to the same row
1113 /// # let _ = task;
1114 /// ```
1115 #[must_use]
1116 pub fn scroll_anchor(&self) -> ScrollAnchor {
1117 self.virt.anchor_at(self.scroll_offset)
1118 }
1119
1120 /// Jumps the viewport so `anchor`'s row sits at the top edge with its
1121 /// sub-row offset — the **restore** half of scroll persistence
1122 /// ([`scroll_anchor`](Self::scroll_anchor) is the read half), and the
1123 /// "jump to row" primitive in general. Clamped to the scrollable range;
1124 /// refetches the window (synchronously when the store is ready). Freezes
1125 /// tail-follow: an explicit jump is the app taking the viewport
1126 /// somewhere, and the grid never fights that. Returns the follow-up
1127 /// [`Task`].
1128 pub fn scroll_to_anchor(&mut self, anchor: ScrollAnchor) -> Task<Message<S::Row>> {
1129 self.follow = false;
1130 let offset = self.virt.flat_offset_of(anchor).clamp(0.0, self.max_scroll());
1131 let scroll = (offset != self.scroll_offset).then(|| {
1132 self.scroll_offset = offset;
1133 iced::widget::operation::scroll_to(
1134 self.body_id.clone(),
1135 AbsoluteOffset { x: self.layout.h_scroll(), y: offset },
1136 )
1137 });
1138 let fetch = self.fetch_now(self.virt.anchor_at(offset).row);
1139 match scroll {
1140 Some(scroll) => Task::batch([scroll, fetch]),
1141 None => fetch,
1142 }
1143 }
1144
1145 /// Jumps so the row at `index` sits at the viewport top — see
1146 /// [`scroll_to_anchor`](Self::scroll_to_anchor).
1147 pub fn scroll_to_row(&mut self, index: usize) -> Task<Message<S::Row>> {
1148 self.scroll_to_anchor(ScrollAnchor { row: index, offset_px: 0.0 })
1149 }
1150
1151 /// Sets the sort/filter spec: the store executes it and the row window is
1152 /// invalidated and refetched. Viewport policy:
1153 ///
1154 /// - **default — the scrollbar does not move.** A re-sort/re-filter
1155 /// changes the rows, not the user's place; the offset is clamped only
1156 /// when the new row set ends above it.
1157 /// - while tail-following, the view follows the tail of the NEW row set
1158 /// (a filter edit mid-stream must not silently stop the follow);
1159 /// - with [`set_focus_restoration(true)`](Self::set_focus_restoration), a
1160 /// focused row that survives the spec change is kept in view across the
1161 /// reorder — the same focus-chase the opt-in buys keyboard nav.
1162 ///
1163 /// **Selection/focus policy** (v1 default, prune-on-filter): a change to
1164 /// the **filter** clears the selection and the focus — through the async
1165 /// seam the grid cannot cheaply ask which selected ids survive an
1166 /// arbitrary predicate, so pruning degenerates to clearing. A
1167 /// **sort-only** change keeps both: identity survives reordering.
1168 /// (Contrast [`transact`](Self::transact), which never prunes.)
1169 ///
1170 /// Returns the follow-up [`Task`] — feed it to iced.
1171 ///
1172 /// # Example — a filter box that preserves header-click sorting
1173 ///
1174 /// ```
1175 /// # use oriel_core::{columns::ColumnId, data::VecStore};
1176 /// # use oriel_iced::grid::{BoxedColumn, VirtualGrid};
1177 /// # use oriel_iced::column::TextColumn;
1178 /// # use oriel_core::query::FilterValue;
1179 /// # #[derive(Clone, Debug)] struct Row { id: u64, name: String }
1180 /// # fn key(r: &Row) -> u64 { r.id }
1181 /// # let store = VecStore::new(
1182 /// # (0..100u64).map(|id| Row { id, name: format!("row {id}") }).collect(),
1183 /// # key as fn(&Row) -> u64,
1184 /// # ).with_fields(|r, col| match col {
1185 /// # ColumnId(1) => Some(FilterValue::Text(r.name.clone())),
1186 /// # _ => None,
1187 /// # });
1188 /// # let columns: Vec<BoxedColumn<Row>> =
1189 /// # vec![Box::new(TextColumn::new(ColumnId(1), "Name", 200.0, |r: &Row| r.name.clone()))];
1190 /// # let (mut grid, _init) = VirtualGrid::new(store, columns);
1191 /// use oriel_core::query::{FilterSpec, Predicate};
1192 ///
1193 /// // The app owns the filter; header clicks own the sort — compose by
1194 /// // reading the CURRENT spec instead of building one from scratch:
1195 /// let mut spec = grid.spec().clone();
1196 /// spec.filter = FilterSpec {
1197 /// clauses: vec![Predicate::Contains { column: ColumnId(1), needle: "7".into() }],
1198 /// };
1199 /// let task = grid.set_spec(spec); // the scrollbar stays put (clamp only)
1200 /// # let _ = task;
1201 /// # assert_eq!(grid.total_rows(), 19);
1202 /// ```
1203 pub fn set_spec(&mut self, spec: QuerySpec) -> Task<Message<S::Row>> {
1204 let snapshot = self.flight_snapshot();
1205 let task = self.set_spec_inner(spec);
1206 self.pending_flights = snapshot.map(|s| (self.fetch_generation, s));
1207 self.try_schedule_flights();
1208 task
1209 }
1210
1211 fn set_spec_inner(&mut self, spec: QuerySpec) -> Task<Message<S::Row>> {
1212 // Prune-on-filter (the accepted default). Through the async seam
1213 // the grid cannot cheaply ask which selected ids survive an arbitrary
1214 // predicate, so pruning degenerates to clearing when the *filter*
1215 // changes. A sort change keeps the selection — identity survives
1216 // reordering by construction.
1217 if spec.filter != self.spec.filter {
1218 self.selection.clear();
1219 self.focus = None;
1220 }
1221 self.store.apply_spec(&spec);
1222 self.spec = spec;
1223 self.total_rows = self.store.row_count().lower_bound();
1224 self.window_start = 0;
1225 self.window_rows.clear();
1226 if self.follow {
1227 return self.jump_to_tail();
1228 }
1229 // Opt-in focus chase: keep a surviving focused row in view across the
1230 // reorder (minimal scroll — no movement at all if it's still visible).
1231 if self.focus_restoration {
1232 if let Some(index) = self.focus.as_ref().and_then(|id| self.store.index_of(id)) {
1233 return self.reveal_after_invalidate(index);
1234 }
1235 }
1236 // Default: the scrollbar does not move — a spec change swaps the rows
1237 // under the viewport, it doesn't take the user anywhere. Clamp only
1238 // if the new row set ends above the current offset, and refetch the
1239 // window around the kept position (async, like the scroll path).
1240 let offset = self.scroll_offset.clamp(0.0, self.max_scroll());
1241 let scroll = (offset != self.scroll_offset).then(|| {
1242 self.scroll_offset = offset;
1243 iced::widget::operation::scroll_to(
1244 self.body_id.clone(),
1245 AbsoluteOffset { x: self.layout.h_scroll(), y: offset },
1246 )
1247 });
1248 let fetch = self.fetch_task(self.virt.anchor_at(offset).row);
1249 match scroll {
1250 Some(scroll) => Task::batch([scroll, fetch]),
1251 None => fetch,
1252 }
1253 }
1254
1255 /// Scrolls just enough to keep row `target` in view (nothing if it
1256 /// already is) and refetches around it — the spec-change variant of
1257 /// [`ensure_visible`](Self::ensure_visible): the window has just been
1258 /// invalidated, so the refetch is unconditional (and synchronous when the
1259 /// store is ready — no placeholder flash at the revealed position).
1260 fn reveal_after_invalidate(&mut self, target: usize) -> Task<Message<S::Row>> {
1261 let offset = self
1262 .virt
1263 .reveal_offset(self.scroll_offset, self.viewport_height.get(), target)
1264 .unwrap_or(self.scroll_offset)
1265 .clamp(0.0, self.max_scroll());
1266 let scroll = (offset != self.scroll_offset).then(|| {
1267 self.scroll_offset = offset;
1268 iced::widget::operation::scroll_to(
1269 self.body_id.clone(),
1270 AbsoluteOffset { x: self.layout.h_scroll(), y: offset },
1271 )
1272 });
1273 let fetch = self.fetch_now(self.virt.anchor_at(offset).row);
1274 match scroll {
1275 Some(scroll) => Task::batch([scroll, fetch]),
1276 None => fetch,
1277 }
1278 }
1279
1280 /// The spec that results from clicking `column`'s header — the core's
1281 /// three-state cycle ([`QuerySpec::cycled_sort`]).
1282 fn toggled_sort(&self, column: ColumnId) -> Option<SortSpec> {
1283 self.spec.cycled_sort(column)
1284 }
1285
1286 fn fetch_task(&mut self, anchor_row: usize) -> Task<Message<S::Row>> {
1287 self.fetch_generation += 1;
1288 let generation = self.fetch_generation;
1289 let start = anchor_row.saturating_sub(FETCH_MARGIN);
1290 let end = anchor_row.saturating_add(FETCH_ROWS).min(self.total_rows).max(start);
1291 Task::perform(self.store.fetch(start..end), move |rows| Message::Fetched {
1292 generation,
1293 start,
1294 rows,
1295 })
1296 }
1297
1298 /// Like [`fetch_task`](Self::fetch_task), but fills the window
1299 /// **synchronously** when the store's future is already ready (resident
1300 /// stores) — used on the transaction paths, where the async round-trip
1301 /// would flash a placeholder frame at the tail on every ingest tick. A
1302 /// pending future falls back to the async task path unchanged. One
1303 /// non-blocking poll: the grid still spawns nothing and blocks nowhere.
1304 fn fetch_now(&mut self, anchor_row: usize) -> Task<Message<S::Row>> {
1305 use core::future::Future;
1306 use core::task::{Context, Poll, Waker};
1307 self.fetch_generation += 1;
1308 let generation = self.fetch_generation;
1309 let start = anchor_row.saturating_sub(FETCH_MARGIN);
1310 let end = anchor_row.saturating_add(FETCH_ROWS).min(self.total_rows).max(start);
1311 let mut future = Box::pin(self.store.fetch(start..end));
1312 match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
1313 Poll::Ready(rows) => {
1314 self.window_start = start;
1315 self.window_rows = rows;
1316 self.window_generation = generation;
1317 Task::none()
1318 }
1319 // Re-polling from a fresh context is within the `Future` contract
1320 // (poll must re-register its waker each call), so the once-polled
1321 // future can be handed to the executor as usual.
1322 Poll::Pending => Task::perform(future, move |rows| Message::Fetched {
1323 generation,
1324 start,
1325 rows,
1326 }),
1327 }
1328 }
1329
1330 /// The largest meaningful scroll offset: content height minus the viewport
1331 /// (0 before the first layout measures one — the scrollable clamps its own
1332 /// state, and the next real scroll event corrects ours).
1333 fn max_scroll(&self) -> f32 {
1334 (self.virt.total_height(self.total_rows) - self.viewport_height.get()).max(0.0)
1335 }
1336
1337 /// Scrolls to the very bottom (the newest rows) and refetches around it.
1338 fn jump_to_tail(&mut self) -> Task<Message<S::Row>> {
1339 let offset = self.max_scroll();
1340 let moved = offset != self.scroll_offset;
1341 self.scroll_offset = offset;
1342 let fetch = self.fetch_now(self.virt.anchor_at(offset).row);
1343 if moved {
1344 let scroll = iced::widget::operation::scroll_to(
1345 self.body_id.clone(),
1346 AbsoluteOffset { x: self.layout.h_scroll(), y: offset },
1347 );
1348 Task::batch([scroll, fetch])
1349 } else {
1350 fetch
1351 }
1352 }
1353
1354 /// The cached row at position `index` in the current order, if fetched.
1355 fn cached_row(&self, index: usize) -> Option<&S::Row> {
1356 index
1357 .checked_sub(self.window_start)
1358 .and_then(|offset| self.window_rows.get(offset))
1359 }
1360
1361 // --- keyboard navigation (public *actions*; the integrator binds keys —
1362 // any input source can drive these, which is the no-cliffs seam) ---
1363
1364 /// Moves the nav cursor down one row (single-selecting it) and keeps it
1365 /// visible. Returns the follow-up [`Task`].
1366 pub fn focus_next(&mut self) -> Task<Message<S::Row>> {
1367 self.move_focus(1)
1368 }
1369
1370 /// Moves the nav cursor up one row (single-selecting it) and keeps it visible.
1371 pub fn focus_prev(&mut self) -> Task<Message<S::Row>> {
1372 self.move_focus(-1)
1373 }
1374
1375 /// Moves the nav cursor down a page — the number of *fully visible* rows
1376 /// (partial rows don't count).
1377 pub fn page_down(&mut self) -> Task<Message<S::Row>> {
1378 self.move_focus(self.virt.page_len(self.viewport_height.get()) as isize)
1379 }
1380
1381 /// Moves the nav cursor up a page.
1382 pub fn page_up(&mut self) -> Task<Message<S::Row>> {
1383 self.move_focus(-(self.virt.page_len(self.viewport_height.get()) as isize))
1384 }
1385
1386 fn move_focus(&mut self, delta: isize) -> Task<Message<S::Row>> {
1387 if self.total_rows == 0 {
1388 return Task::none();
1389 }
1390 let last = self.total_rows - 1;
1391 let target = match self.focus_index() {
1392 // Continue from the focused row's position in the current order
1393 // (with focus restoration on, that may be an off-window position
1394 // resolved through the store).
1395 Some(index) => {
1396 (index as isize + delta).clamp(0, last as isize) as usize
1397 }
1398 // No cursor yet, or its row is beyond the fetch window and
1399 // restoration is off (or unanswerable): re-anchor at the top
1400 // visible row instead of pretending a stale position means
1401 // something.
1402 None => self.virt.anchor_at(self.scroll_offset).row.min(last),
1403 };
1404 // Scroll/refetch FIRST — the refetch is synchronous for resident
1405 // stores, so a target just outside the old window is selectable on
1406 // this very press. Only a genuinely pending fetch leaves selection to
1407 // catch up on the next press.
1408 let task = self.ensure_visible(target);
1409 if let Some(row) = self.cached_row(target) {
1410 let id = self.store.row_id(row);
1411 self.selection.replace(id.clone());
1412 self.focus = Some(id);
1413 }
1414 task
1415 }
1416
1417 /// Scrolls just enough to bring row `target` fully into view (no-op when it
1418 /// already is), then refetches if the window no longer covers the anchor.
1419 fn ensure_visible(&mut self, target: usize) -> Task<Message<S::Row>> {
1420 let Some(offset) =
1421 self.virt.reveal_offset(self.scroll_offset, self.viewport_height.get(), target)
1422 else {
1423 return Task::none();
1424 };
1425 // Nav that scrolls UP is the user leaving the tail — freeze, same as a
1426 // manual upward scroll (tail-follow must never fight the user).
1427 if offset < self.scroll_offset {
1428 self.follow = false;
1429 }
1430 self.scroll_offset = offset;
1431 let scroll = iced::widget::operation::scroll_to(
1432 self.body_id.clone(),
1433 AbsoluteOffset { x: self.layout.h_scroll(), y: offset },
1434 );
1435 let anchor = self.virt.anchor_at(offset);
1436 if self.needs_fetch(anchor.row) {
1437 // Nav is a transaction-like path: fill synchronously when the
1438 // store is ready (see `fetch_now`) so the row lands this press.
1439 let fetch = self.fetch_now(anchor.row);
1440 Task::batch([scroll, fetch])
1441 } else {
1442 scroll
1443 }
1444 }
1445
1446 fn needs_fetch(&self, anchor_row: usize) -> bool {
1447 if self.window_rows.is_empty() {
1448 return true;
1449 }
1450 let window_end = self.window_start + self.window_rows.len();
1451 let at_top = self.window_start > 0 && anchor_row < self.window_start + EDGE;
1452 let at_bottom =
1453 window_end < self.total_rows && anchor_row + MAX_VISIBLE_ROWS + EDGE > window_end;
1454 at_top || at_bottom
1455 }
1456
1457 /// Handles a [`Message`]; returns the follow-up [`Task`].
1458 pub fn update(&mut self, message: Message<S::Row>) -> Task<Message<S::Row>> {
1459 match message {
1460 Message::BodyScrolled(offset) => {
1461 // A manual scroll away from the tail freezes tail-follow —
1462 // but `on_scroll` reports are NOT only user scrolls. While
1463 // `scroll_to` operations never publish (no `Shell` in
1464 // `operate`, iced_widget-0.14.2/src/scrollable.rs:539),
1465 // `notify_viewport` fires on every RedrawRequested whose
1466 // viewport differs from the last report — INCLUDING when only
1467 // the content bounds changed (scrollable.rs:1084, 1618). So
1468 // while ingest grows the content under a pinned absolute
1469 // offset, the scrollable streams stale "above the bottom"
1470 // reports with no user involved (the failure this prevents:
1471 // follow
1472 // unticked itself during the ring's fill phase, because the
1473 // commanded scroll lands a frame behind the growth).
1474 //
1475 // Growth echoes are non-decreasing, so user intent is a
1476 // RECEDING report: below both the previous report and the
1477 // grid's own commanded offset. (The second bound keeps a
1478 // commanded shrink — clear(), viewport resize — from
1479 // reading as a user scroll.) Recession ACCUMULATES across
1480 // consecutive receding reports: precision touchpads recede
1481 // in sub-pixel steps a per-report 1 px test never sees.
1482 if offset.y < self.last_scroll_report {
1483 self.recession += self.last_scroll_report - offset.y;
1484 } else {
1485 self.recession = 0.0;
1486 }
1487 // The commanded bound is strict WITHOUT a margin: genuine
1488 // recession sits strictly below the adopted offset (which
1489 // trails the reports by one step), while command echoes
1490 // land exactly ON it — a margin here would swallow
1491 // sub-pixel steps the accumulator exists to catch.
1492 let receding = self.recession >= 1.0 && offset.y < self.scroll_offset;
1493 if self.follow && receding {
1494 self.follow = false;
1495 }
1496 self.last_scroll_report = offset.y;
1497 self.scroll_offset = offset.y;
1498 self.layout.set_h_scroll(offset.x);
1499 // Keep the header (and footer strip, if any) aligned with the
1500 // body's horizontal offset.
1501 let mut tasks = vec![iced::widget::operation::scroll_to(
1502 self.header_id.clone(),
1503 AbsoluteOffset { y: 0.0, ..offset },
1504 )];
1505 if self.has_footer {
1506 tasks.push(iced::widget::operation::scroll_to(
1507 self.footer_id.clone(),
1508 AbsoluteOffset { y: 0.0, ..offset },
1509 ));
1510 }
1511 let anchor = self.virt.anchor_at(offset.y);
1512 if self.needs_fetch(anchor.row) {
1513 tasks.push(self.fetch_task(anchor.row));
1514 }
1515 Task::batch(tasks)
1516 }
1517 Message::Fetched { generation, start, rows } => {
1518 // Stale-fetch guard: only the newest generation may fill the
1519 // window; superseded results (older scroll position or older
1520 // spec) would show wrong rows until the next fetch landed.
1521 if generation == self.fetch_generation {
1522 self.window_start = start;
1523 self.window_rows = rows;
1524 self.window_generation = generation;
1525 // An async fill (set_spec's path, or a transact over a
1526 // genuinely async store) may complete a pending flight
1527 // capture.
1528 self.try_schedule_flights();
1529 }
1530 Task::none()
1531 }
1532 Message::RowPressed { index } => {
1533 // Selection and focus both need the row's identity; a click on
1534 // a placeholder (not yet fetched) row changes nothing. v1
1535 // single-select: the click replaces the selection (multi-select
1536 // gestures are held open — the underlying Selection is a set).
1537 if let Some(row) = self.cached_row(index) {
1538 let id = self.store.row_id(row);
1539 self.selection.replace(id.clone());
1540 self.focus = Some(id);
1541 }
1542 Task::none()
1543 }
1544 Message::RowHovered { index } => {
1545 self.hovered = Some(index);
1546 Task::none()
1547 }
1548 Message::RowUnhovered { index } => {
1549 // Adjacent rows' enter/exit can arrive in either order within
1550 // one frame — only clear if nothing newer took over.
1551 if self.hovered == Some(index) {
1552 self.hovered = None;
1553 }
1554 Task::none()
1555 }
1556 Message::ColumnResizing { index, delta } => {
1557 self.layout.preview_resize(index, delta);
1558 Task::none()
1559 }
1560 Message::ColumnResized { index } => {
1561 self.layout.commit_resize(index);
1562 Task::none()
1563 }
1564 Message::ScrollbarDragged(offset) => {
1565 // Same semantics as any programmatic jump: freeze follow,
1566 // clamp, command the scrollable, refetch (the bar is a user
1567 // gesture — dragging away from the tail must stick).
1568 let anchor = self.virt.anchor_at(offset.max(0.0));
1569 self.scroll_to_anchor(anchor)
1570 }
1571 Message::ColumnAutoSizeRequested { index } => {
1572 self.auto_size.insert(index);
1573 self.auto_size_epoch += 1;
1574 Task::none()
1575 }
1576 Message::ColumnMeasured { index, width } => {
1577 if self.auto_size.remove(&index)
1578 && width < crate::measure::MEASURE_CAP - 0.5
1579 {
1580 // Natural content + the cell's horizontal padding (the
1581 // header's grip allowance is folded into the measurement
1582 // itself); the policy clamp has final say. A width AT the
1583 // measuring cap means some cell asked to Fill — it has no
1584 // natural width, so auto-size leaves the column alone
1585 // (committing would slam it to the policy maximum, which
1586 // reads as broken when a theme switch fits every column).
1587 self.layout.set_width(index, width + self.cell_padding * 2.0);
1588 }
1589 Task::none()
1590 }
1591 Message::HeaderPressed { index } => match self.layout.id(index) {
1592 Some(column) => {
1593 let spec = QuerySpec {
1594 sort: self.toggled_sort(column),
1595 filter: self.spec.filter.clone(),
1596 };
1597 self.set_spec(spec)
1598 }
1599 None => Task::none(),
1600 },
1601 }
1602 }
1603
1604 /// Renders the whole grid — header band, virtualized body, footer strip
1605 /// (when any column provides one), and scrollbars.
1606 ///
1607 /// The element **fills all the space its parent gives it, on both
1608 /// axes** — size the grid by constraining the parent, exactly like any
1609 /// other iced widget: drop it in a `column!` beside a toolbar and it
1610 /// takes the rest, or cap it with
1611 /// `container(grid.view().map(Msg::Grid)).height(400)`. It needs a
1612 /// bounded height (any layout that isn't infinitely tall) — the visible
1613 /// slice is computed against it.
1614 pub fn view(&self) -> Element<'_, Message<S::Row>>
1615 where
1616 S::Row: Clone,
1617 {
1618 let columns = &self.columns;
1619 let layout = &self.layout;
1620 let virt = self.virt;
1621 let spec_sort = self.spec.sort;
1622 let scroll_offset = self.scroll_offset;
1623 let total_rows = self.total_rows;
1624 let window_start = self.window_start;
1625 let window_rows = &self.window_rows;
1626 let store = &self.store;
1627 let selection = &self.selection;
1628 let focus = self.focus.as_ref();
1629 let hovered = self.hovered;
1630 let auto_size = &self.auto_size;
1631 let viewport_height = &self.viewport_height;
1632 let header_id = self.header_id.clone();
1633 let body_id = self.body_id.clone();
1634 let header_height = self.header_height;
1635 let footer_height = self.footer_height;
1636 let has_footer = self.has_footer;
1637 let handle_width = self.handle_width;
1638 let cell_padding = self.cell_padding;
1639 let row_style = &self.row_style;
1640 let cell_style = &self.cell_style;
1641 let header_style = &self.header_style;
1642 let footer_style = &self.footer_style;
1643 let sort_indicator = &self.sort_indicator;
1644 let row_divider = self.row_divider;
1645 let column_divider = self.column_divider;
1646 let rule_color = self.rule_color;
1647 let auto_size_epoch = self.auto_size_epoch;
1648 let footer_id = self.footer_id.clone();
1649 let flights = &self.flights;
1650 let fades = &self.fades;
1651 let reorder_easing = &self.reorder_easing;
1652 let fade_cover: crate::slide::CoverFn<iced::Theme> =
1653 Rc::new(crate::slide::theme_background);
1654
1655 iced::widget::responsive(move |size| {
1656 let placements = layout.placements();
1657 let total_width = layout.total_width();
1658 let row_height = virt.row_height();
1659 // Header, 1 px rule, the body, and — when any column provides a
1660 // footer — another rule plus the footer strip.
1661 let footer_space = if has_footer { footer_height + 1.0 } else { 0.0 };
1662 let body_viewport = (size.height - header_height - 1.0 - footer_space).max(0.0);
1663 let rows_height = virt.total_height(total_rows);
1664 // Overlay-scrollbar gutters, reserved ONLY when the bar can
1665 // actually appear — an unconditional gutter reads as a dead gap
1666 // (e.g. between the last row and a docked footer). Adding one
1667 // bar's gutter can push the other axis into overflow, hence the
1668 // second pass.
1669 let v_over = rows_height > body_viewport;
1670 let h_over =
1671 total_width + if v_over { SCROLLBAR_GUTTER } else { 0.0 } > size.width;
1672 let bottom_gutter = if h_over { SCROLLBAR_GUTTER } else { 0.0 };
1673 let v_over = rows_height + bottom_gutter > body_viewport;
1674 let right_gutter = if v_over { SCROLLBAR_GUTTER } else { 0.0 };
1675 // With a footer, a body shorter than its viewport DOCKS the
1676 // footer to the content instead of stranding the totals across an
1677 // empty gap at the pane bottom — pinning only matters once
1678 // there's something to scroll.
1679 let body_natural = rows_height + bottom_gutter;
1680 let docked = has_footer && body_natural < body_viewport;
1681 let body_view_h = if docked { body_natural } else { body_viewport };
1682 let body_height = if docked {
1683 Length::Fixed(body_natural)
1684 } else {
1685 Length::Fill
1686 };
1687 // Remember the EFFECTIVE viewport (minus the horizontal
1688 // scrollbar's overlay band) for page-nav / ensure-visible /
1689 // max-scroll math: with the bottom gutter in the content, the
1690 // true scroll maximum is `total_height - (viewport - gutter)`,
1691 // and a row is only "fully visible" above the bar. (`view` is
1692 // `&self`, hence the Cell.) Materialization below still uses the
1693 // full band height — rows under the translucent bar must exist.
1694 viewport_height.set((body_view_h - bottom_gutter).max(0.0));
1695 let window = virt.visible(scroll_offset, body_view_h, total_rows);
1696 let cache_end = window_start + window_rows.len();
1697 let cell_pad = Padding {
1698 top: 0.0,
1699 right: cell_padding,
1700 bottom: 0.0,
1701 left: cell_padding,
1702 };
1703
1704 // --- header row (sort mouse_area INSIDE the resize handle, so a
1705 // press on the grab strip is consumed before the sort area ever
1706 // sees it; the handle also captures the event) ---
1707 let header_cells = placements.iter().map(|p| {
1708 let col = &columns[p.index];
1709
1710 let mut label_content: Element<'_, Message<S::Row>> = col.header();
1711 if let Some(sort) = spec_sort.filter(|s| s.column == p.id) {
1712 label_content = row![label_content, (sort_indicator)(sort.direction)]
1713 .spacing(4)
1714 .align_y(Vertical::Center)
1715 .into();
1716 }
1717 let label: Element<'_, Message<S::Row>> = container(label_content)
1718 .width(Length::Fill)
1719 .height(Length::Fixed(header_height))
1720 .align_y(Vertical::Center)
1721 .padding(cell_pad)
1722 .into();
1723 let label: Element<'_, Message<S::Row>> = if col.sortable() {
1724 let index = p.index;
1725 mouse_area(label)
1726 .on_press(Message::HeaderPressed { index })
1727 .into()
1728 } else {
1729 label
1730 };
1731 let content: Element<'_, Message<S::Row>> = if col.sizing().is_resizable() {
1732 let index = p.index;
1733 crate::resize::ResizeHandle::new(
1734 label,
1735 handle_width,
1736 move |delta| Message::ColumnResizing { index, delta },
1737 Message::ColumnResized { index },
1738 )
1739 .on_auto_size(Message::ColumnAutoSizeRequested { index })
1740 .into()
1741 } else {
1742 label
1743 };
1744 container(content)
1745 .width(Length::Fixed(p.width))
1746 .height(Length::Fixed(header_height))
1747 .into()
1748 });
1749 // The header sits on its own band (a step off the body background)
1750 // with a 1 px rule below — colors via the header-style hook.
1751 // A corner spacer mirrors the body's external scrollbar strip, so
1752 // header and body share one horizontal viewport width (and thus
1753 // one scroll maximum) and stay aligned at the far right.
1754 let header = container(
1755 row![
1756 scrollable(row(header_cells).width(Length::Fixed(total_width)))
1757 .id(header_id.clone())
1758 .direction(Direction::Horizontal(hidden_scrollbar()))
1759 .width(Length::Fill)
1760 .height(Length::Fixed(header_height)),
1761 space().width(Length::Fixed(right_gutter)),
1762 ],
1763 )
1764 .style(move |theme: &iced::Theme| (header_style)(theme));
1765
1766 // --- body rows ---
1767 // INVARIANT: every keyed child — data row or placeholder — must be
1768 // the SAME top-level widget type (a `row` of per-column cells).
1769 // `keyed_column` pairs equal-length children positionally WITHOUT a
1770 // tag check (iced_widget keyed/column.rs:224-245 — canary-pinned),
1771 // and `container` is *transparent* in the state tree (its
1772 // tag/state delegate to its content, iced_widget container.rs:236),
1773 // so a bare `container(text)` placeholder carries `text`'s stateful
1774 // tag while a data row carries `row`'s stateless one. Swapping the
1775 // two under keyed's untag-checked zip hands `text::layout` a
1776 // stateless node → "Downcast on stateless state" (the startup
1777 // crash; regression-tested in tests/lifecycle.rs). One level down,
1778 // all diffs are tag-checked, so per-cell heterogeneity is fine.
1779 let body_rows = keyed_column(window.range().map(|i| {
1780 let data = (i >= window_start && i < cache_end)
1781 .then(|| &window_rows[i - window_start]);
1782 let id = data.map(|d| store.row_id(d));
1783 // Keyed key: a `RowId`-derived hash, so retained
1784 // widget-tree state follows the ROW across count-changing
1785 // rebuilds. `keyed` needs `Key: Copy` but `RowId` is only
1786 // `Clone` — the 64-bit hash is the bridge (a collision merely
1787 // mispairs one diff, which the uniform-row rule makes benign).
1788 // Placeholders have no identity yet: a tagged positional hash.
1789 let key = match &id {
1790 Some(id) => keyed_hash(0, id),
1791 None => keyed_hash(1, &i),
1792 };
1793
1794 // The style hooks' input: selection and focus by IDENTITY
1795 // (neither ever decorates whatever row occupies an old slot
1796 // after a re-sort); hover positional and transient; the row
1797 // data itself for data-driven chrome (TX tints etc.).
1798 let ctx = RowContext {
1799 index: i,
1800 row: data,
1801 is_selected: id.as_ref().is_some_and(|id| selection.contains(id)),
1802 is_focused: id.as_ref().zip(focus).is_some_and(|(id, f)| id == f),
1803 is_hovered: hovered == Some(i),
1804 };
1805
1806 // The row divider is carved OUT of the row height (pitch
1807 // unchanged — virtualization math never sees dividers), and
1808 // the column divider out of each cell's width (column
1809 // positions stay header-aligned).
1810 let cell_h = row_height - if row_divider.is_some() { 1.0 } else { 0.0 };
1811 let last = placements.len().saturating_sub(1);
1812 let mut cells: Vec<Element<'_, Message<S::Row>>> =
1813 Vec::with_capacity(placements.len() * 2);
1814 for (k, p) in placements.iter().enumerate() {
1815 let content: Element<'_, Message<S::Row>> = match data {
1816 Some(data) => columns[p.index].cell(data),
1817 // Placeholders render dim — clearly "not data yet".
1818 None => text("…")
1819 .size(13.0)
1820 .style(|theme: &iced::Theme| iced::widget::text::Style {
1821 color: Some(theme.extended_palette().background.strong.color),
1822 })
1823 .into(),
1824 };
1825 let column_id = p.id;
1826 let vband = column_divider.filter(|_| k < last);
1827 let shave = if vband.is_some() { 1.0 } else { 0.0 };
1828 cells.push(
1829 container(content)
1830 .width(Length::Fixed(p.width - shave))
1831 .height(Length::Fixed(cell_h))
1832 .align_y(Vertical::Center)
1833 .padding(cell_pad)
1834 .clip(true)
1835 .style(move |theme: &iced::Theme| {
1836 (cell_style)(theme, &ctx, column_id)
1837 })
1838 .into(),
1839 );
1840 if let Some(color) = vband {
1841 cells.push(
1842 container(space())
1843 .width(Length::Fixed(1.0))
1844 .height(Length::Fixed(cell_h))
1845 .style(move |_theme: &iced::Theme| {
1846 iced::widget::container::Style {
1847 background: Some(color.into()),
1848 ..iced::widget::container::Style::default()
1849 }
1850 })
1851 .into(),
1852 );
1853 }
1854 }
1855
1856 // Row chrome via the row-style hook (default: selected >
1857 // focused > hovered > zebra, theme-derived), wrapped in a
1858 // Slide carrying any reorder flight. The wrapper chain is the
1859 // SAME for data and placeholder rows (uniform top-level type
1860 // — see the INVARIANT above):
1861 // Slide(mouse_area(container(row))).
1862 let flight = id.as_ref().and_then(|id| flights.get(id)).copied();
1863 let fade = id.as_ref().and_then(|id| fades.get(id)).copied();
1864 // With a row divider, the row is lanes + a 1 px band; the
1865 // band paints OVER the row chrome (selection tint etc.), as
1866 // market grids do. Same structure for data and placeholder
1867 // rows (the uniform-type invariant holds at the Slide level;
1868 // one level down diffs are tag-checked, so the row/column
1869 // content switch on a divider toggle rebuilds cleanly).
1870 let lanes = row(cells).width(Length::Fixed(total_width));
1871 let row_body: Element<'_, Message<S::Row>> = match row_divider {
1872 Some(color) => column![
1873 lanes,
1874 container(space())
1875 .width(Length::Fixed(total_width))
1876 .height(Length::Fixed(1.0))
1877 .style(move |_theme: &iced::Theme| {
1878 iced::widget::container::Style {
1879 background: Some(color.into()),
1880 ..iced::widget::container::Style::default()
1881 }
1882 }),
1883 ]
1884 .into(),
1885 None => lanes.into(),
1886 };
1887 let row_el: Element<'_, Message<S::Row>> = crate::slide::Slide::new(
1888 mouse_area(
1889 container(row_body)
1890 .style(move |theme: &iced::Theme| (row_style)(theme, &ctx)),
1891 )
1892 .on_press(Message::RowPressed { index: i })
1893 .on_enter(Message::RowHovered { index: i })
1894 .on_exit(Message::RowUnhovered { index: i }),
1895 flight,
1896 fade,
1897 Rc::clone(reorder_easing),
1898 Rc::clone(&fade_cover),
1899 )
1900 .into();
1901 (key, row_el)
1902 }))
1903 .width(Length::Fixed(total_width));
1904
1905 // The content carries the scrollbar gutters itself: extra width
1906 // past the last column and extra height past the last row, so
1907 // fully-scrolled content clears the overlay bars.
1908 // The scrollable's own vertical bar is HIDDEN (its minimum thumb
1909 // is a hardcoded 2 px — see SCROLLBAR_GUTTER); wheel/keyboard/
1910 // programmatic scrolling still run through it, while the grid's
1911 // VScrollbar beside it renders the thumb and handles dragging.
1912 let body = scrollable(
1913 column![
1914 space().height(Length::Fixed(window.space_before)),
1915 body_rows,
1916 space().height(Length::Fixed(window.space_after + bottom_gutter)),
1917 ]
1918 .width(Length::Fixed(total_width)),
1919 )
1920 .id(body_id.clone())
1921 .on_scroll(|viewport: Viewport| Message::BodyScrolled(viewport.absolute_offset()))
1922 .direction(Direction::Both {
1923 vertical: hidden_scrollbar(),
1924 horizontal: Scrollbar::new(),
1925 })
1926 .width(Length::Fill)
1927 .height(if v_over { Length::Fill } else { body_height });
1928 let body_pane: Element<'_, Message<S::Row>> = if v_over {
1929 row![
1930 body,
1931 crate::scrollbar::VScrollbar::new(
1932 scroll_offset,
1933 body_view_h,
1934 rows_height + bottom_gutter,
1935 Message::ScrollbarDragged,
1936 ),
1937 ]
1938 .height(body_height)
1939 .into()
1940 } else {
1941 body.into()
1942 };
1943
1944 // The auto-size measurers: one zero-size phantom PER COLUMN
1945 // (uniform tree shape every frame), each idle and empty unless
1946 // its column has a pending measurement. A pending one lays out
1947 // fresh copies of the column's header + visible cells against
1948 // generous limits and publishes the natural maximum — so an
1949 // `auto_size_all` fits every column in a single layout pass.
1950 let measurers = (0..columns.len()).map(|index| {
1951 let mut measure_children: Vec<Element<'_, Message<S::Row>>> = Vec::new();
1952 if auto_size.contains(&index) {
1953 let col = &columns[index];
1954 // Cells measure through `Column::measure` — the
1955 // measurement-friendly rendering. A `None` means the
1956 // column has NO natural width (bars); publish a
1957 // cap-hitting report so the pending entry drains through
1958 // the decline path and the width stays put.
1959 let mut cells: Vec<Element<'_, Message<S::Row>>> = Vec::new();
1960 let mut unmeasurable = false;
1961 for i in window.range() {
1962 if i >= window_start && i < cache_end {
1963 match col.measure(&window_rows[i - window_start]) {
1964 Some(element) => cells.push(element),
1965 None => {
1966 unmeasurable = true;
1967 break;
1968 }
1969 }
1970 }
1971 }
1972 if unmeasurable {
1973 measure_children.push(
1974 space().width(Length::Fixed(crate::measure::MEASURE_CAP)).into(),
1975 );
1976 }
1977 // A fit with NOTHING to measure would collapse the column
1978 // to its header width (empty store at boot, a grid
1979 // filtered down to nothing) — stay pending instead; the
1980 // fit self-arms on the first frame that has rows.
1981 else if !cells.is_empty() {
1982 // The header is measured AS RENDERED: the sorted
1983 // column carries its sort indicator (+ the 4 px
1984 // gap), so a fitted width never clips the arrow.
1985 let mut header_el: Element<'_, Message<S::Row>> = col.header();
1986 if let Some(sort) = spec_sort.filter(|s| s.column == col.id()) {
1987 header_el = row![header_el, (sort_indicator)(sort.direction)]
1988 .spacing(4)
1989 .align_y(Vertical::Center)
1990 .into();
1991 }
1992 // The header is measured WITH its grip allowance (the
1993 // ResizeHandle reserves the header cell's right
1994 // edge); body cells are measured bare. Folding the
1995 // allowance into the max here — rather than padding
1996 // the committed width — keeps cell-dominated columns
1997 // symmetric around their content (measured on-screen: a
1998 // blanket +grip read as 5 px left / 11 px right
1999 // inside the dividers).
2000 measure_children.push(
2001 container(header_el)
2002 .padding(Padding {
2003 top: 0.0,
2004 right: handle_width,
2005 bottom: 0.0,
2006 left: 0.0,
2007 })
2008 .into(),
2009 );
2010 measure_children.append(&mut cells);
2011 // The footer must fit too — a total is often wider
2012 // than any cell (`None` = nothing to measure). The
2013 // rendered footer cell loses 1 px to its boundary
2014 // tick, so the measurement carries the same
2015 // allowance (like the header's grip) — without it a
2016 // footer-dominated fit lands exactly 1 px short and
2017 // the total wraps.
2018 if let Some(footer_el) = col.measure_footer() {
2019 measure_children.push(
2020 container(footer_el)
2021 .padding(Padding {
2022 top: 0.0,
2023 right: 1.0,
2024 bottom: 0.0,
2025 left: 0.0,
2026 })
2027 .into(),
2028 );
2029 }
2030 }
2031 }
2032 crate::measure::Measure::new(measure_children, auto_size_epoch, move |width| {
2033 Message::ColumnMeasured { index, width }
2034 })
2035 });
2036
2037 // The frame rules: themable via `set_rule_color` (a GridTheme's
2038 // structural border), ambient rule style otherwise.
2039 let frame_rule = || -> Element<'_, Message<S::Row>> {
2040 match rule_color {
2041 Some(color) => iced::widget::rule::horizontal(1)
2042 .style(move |theme: &iced::Theme| iced::widget::rule::Style {
2043 color,
2044 ..iced::widget::rule::default(theme)
2045 })
2046 .into(),
2047 None => iced::widget::rule::horizontal(1).into(),
2048 }
2049 };
2050 let mut children: Vec<Element<'_, Message<S::Row>>> =
2051 vec![header.into(), frame_rule(), body_pane];
2052 if has_footer {
2053 // The footer strip: one cell per column (empty where a column
2054 // has no footer, so widths stay aligned), horizontally synced
2055 // with the body like the header.
2056 let footer_cells = placements.iter().map(|p| {
2057 let content: Element<'_, Message<S::Row>> = columns[p.index]
2058 .footer()
2059 .unwrap_or_else(|| space().into());
2060 let cell = container(content)
2061 .width(Length::Fill)
2062 .height(Length::Fill)
2063 .align_y(Vertical::Center)
2064 .padding(cell_pad)
2065 .clip(true);
2066 // A column-boundary tick after every footer cell —
2067 // totals float ambiguously without one (and it rhymes
2068 // with the header's resize ticks).
2069 let tick = iced::widget::rule::vertical(1).style(|theme: &iced::Theme| {
2070 iced::widget::rule::Style {
2071 fill_mode: iced::widget::rule::FillMode::Percent(50.0),
2072 ..iced::widget::rule::default(theme)
2073 }
2074 });
2075 container(row![cell, tick])
2076 .width(Length::Fixed(p.width))
2077 .height(Length::Fixed(footer_height))
2078 .into()
2079 });
2080 let footer = container(
2081 row![
2082 scrollable(row(footer_cells).width(Length::Fixed(total_width)))
2083 .id(footer_id.clone())
2084 .direction(Direction::Horizontal(hidden_scrollbar()))
2085 .width(Length::Fill)
2086 .height(Length::Fixed(footer_height)),
2087 space().width(Length::Fixed(right_gutter)),
2088 ],
2089 )
2090 .style(move |theme: &iced::Theme| (footer_style)(theme));
2091 children.push(frame_rule());
2092 children.push(footer.into());
2093 }
2094 children.extend(measurers.map(Into::into));
2095
2096 column(children).width(Length::Fill).height(Length::Fill).into()
2097 })
2098 .into()
2099 }
2100}
2101
2102/// A zero-size scrollbar — the header scrolls only programmatically (via
2103/// `scroll_to`), never by the user.
2104fn hidden_scrollbar() -> Scrollbar {
2105 Scrollbar::new().width(0).margin(0).scroller_width(0)
2106}
2107
2108/// The keyed-diff key bridge: a stable 64-bit hash of a `RowId` (tag 0) or of a
2109/// placeholder's position (tag 1). `DefaultHasher::new()` is fixed-key
2110/// SipHash, so keys are stable across frames — which is all keyed diffing
2111/// compares.
2112fn keyed_hash(tag: u8, value: &impl std::hash::Hash) -> u64 {
2113 use std::hash::{DefaultHasher, Hash, Hasher};
2114 let mut hasher = DefaultHasher::new();
2115 tag.hash(&mut hasher);
2116 value.hash(&mut hasher);
2117 hasher.finish()
2118}