Skip to main content

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}