Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-grid/src/lib.rs

//! **facett-grid** — the flagship facett component: a data **grid** that is
//! better-looking *and* smoother than a spreadsheet. Arrow-backed, virtualized on
//! **both** axes (render only visible cells; smooth over a million+ rows), with a
//! frozen sticky header, pinned leading columns, an optional totals footer,
//! multi-column sort, Excel-style cell selection → TSV, type-aware cell rendering
//! (grouped numbers, dates, enum chips, booleans, in-cell progress bars +
//! sparklines), conditional formatting (data bars / colour-scale heat), and full
//! facett theming (zebra stripes, hover row+col cross-highlight, selection
//! accent). A [`Facet`]: `caps()` = selectable · copyable · searchable · scalable
//! · resizable · themeable · editable (when opted in via [`Grid::with_editable`]).
//!
//! Build one from an Arrow `RecordBatch`:
//! ```no_run
//! # use arrow_array::RecordBatch;
//! # fn b() -> RecordBatch { unimplemented!() }
//! let batch: RecordBatch = b();
//! let grid = facett_grid::Grid::from_record_batch("repos", &batch)
//!     .pin_columns(1)            // freeze the leading column
//!     .with_totals(true);        // sticky sum footer for numeric cols
//! ```
//! See `.nornir/facett-grid.md` for the design (virtualization model, ingestion,
//! selection/clipboard, theming, the Facet API) and the snapshot-test guide.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`GridState`]** — the complete observable, serializable, round-trippable
//!   *interaction* state: the multi-sort spec, the selection (+ its FC-5 stable-id
//!   active cell), density/scale/pin/totals/filter and the inline-edit buffer +
//!   committed-edit queue. [`Grid::state`] hands back a `&GridState`. The bulk Arrow
//!   data is the *input* (held on [`Grid`], not in the Model — it is not the state a
//!   headless driver mutates), and the sort/filter row permutation is a *derived
//!   cache* rebuilt by [`update`](Grid::update); neither belongs in the Model.
//! - **[`Msg`] + [`Grid::update`]** — the single mutation path (FC-2): every input
//!   (header click / sort, cell select-extend-add, select-all, filter, density,
//!   totals, scale, the inline-edit lifecycle, TSV paste) is a `Msg`; a headless
//!   driver ([`facett_core::harness`]) reaches the identical transitions the canvas
//!   gestures drive.
//! - **[`Grid::view`]** — a **pure** paint (FC-9): it reads `&self`, paints the
//!   virtualized grid and *returns* the [`Msg`]s the interactions produced; the
//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them via
//!   [`update`](Grid::update). The only `&self`-interior mutation is the ephemeral
//!   scroll/hover and derived sum caches (FC-7), deliberately outside the Model.
//! - **FC-5 stable-id selection** — the active cell is addressed and observed by its
//!   **source-row id** (stable across a re-sort), not the volatile *visual* row. A
//!   re-sort remaps the highlight so it follows the data row (see [`Grid::update`]).
//! - **Rich `state_json`** — the grid dims + column names/kinds a driver reads are
//!   *derived* from the Arrow data, so the macro is invoked in **form 3**
//!   (`custom_state_json`) to publish those keys instead of a plain `serde(state())`.

use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;

use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};

use arrow_array::RecordBatch;
use facett_core::clip::{ArrowColumnRef, ClipKind, ClipPayload, CopySource, PasteTarget};
use facett_core::scroll_engine::SmoothScroll;
use facett_core::{FacetCaps, theme};
use serde::{Deserialize, Serialize};

pub mod arrow;
pub mod condfmt;
pub mod data;
pub mod selection;
pub mod sort;
pub mod viewport;

pub use condfmt::CondFmt;
pub use data::{CellKind, Column, GridData};
pub use selection::{CellRange, Selection};
pub use sort::{Dir, SortKey, SortSpec};
pub use viewport::{RowWindow, row_window};

// ── Inline editing ────────────────────────────────────────────────────────────

/// The live editing state for the active inline cell editor. Part of the
/// observable [`GridState`] (serializable) — a host/driver can read what is being
/// typed and whether it validates.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EditState {
    /// Whether the editor is currently active.
    pub active: bool,
    /// Visual row (post-sort) of the cell being edited.
    pub row: usize,
    /// Source column index of the cell being edited.
    pub col: usize,
    /// The text buffer shown in the `TextEdit`.
    pub buffer: String,
    /// Whether `buffer` parses as a valid value for the column's type.
    pub valid: bool,
    /// The original cell value (for cancel and old_value_str in GridChange).
    pub(crate) original: String,
}

impl EditState {
    fn clear(&mut self) {
        self.active = false;
        self.buffer.clear();
        self.valid = true;
        self.original.clear();
    }
}

/// One committed cell edit — recorded in the observable [`GridState::changes`]
/// queue when the user presses Enter or Tab to confirm a change.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GridChange {
    /// Visual row index at the time of the edit.
    pub row: usize,
    /// Stable source-row identity, even if the edit reorders or filters out the row.
    /// `None` only for changes deserialized from older saved states.
    #[serde(default)]
    pub source_row: Option<usize>,
    /// Source column index.
    pub col: usize,
    /// The display string of the cell before the edit.
    pub old_value_str: String,
    /// The committed text (already validated as the correct type).
    pub new_value_str: String,
}

/// A per-frame collection of committed edits — retained as a public type for API
/// stability. The Elm view path folds committed edits into [`GridState::changes`]
/// (drainable via [`Grid::take_changes`]) directly.
#[derive(Clone, Debug, Default)]
pub struct GridResponse {
    pub changes: Vec<GridChange>,
}

impl GridResponse {
    pub fn is_empty(&self) -> bool {
        self.changes.is_empty()
    }
}

impl GridChange {
    /// JSON form of a committed change (folded into `state_json`).
    fn to_json(&self) -> serde_json::Value {
        serde_json::json!({
            "row": self.row,
            "source_row": self.source_row,
            "col": self.col,
            "old": self.old_value_str,
            "new": self.new_value_str,
        })
    }
}

// ── Row-height density ────────────────────────────────────────────────────────

/// Row-height presets — Excel's "row height" affordance.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Density {
    /// Tight rows for dense data.
    Compact,
    #[default]
    Comfortable,
    /// Roomy rows.
    Spacious,
}

impl Density {
    pub fn base_row_h(self) -> f32 {
        match self {
            Density::Compact => 18.0,
            Density::Comfortable => 24.0,
            Density::Spacious => 32.0,
        }
    }
    pub fn as_str(self) -> &'static str {
        match self {
            Density::Compact => "compact",
            Density::Comfortable => "comfortable",
            Density::Spacious => "spacious",
        }
    }
    pub fn cycle(self) -> Density {
        match self {
            Density::Compact => Density::Comfortable,
            Density::Comfortable => Density::Spacious,
            Density::Spacious => Density::Compact,
        }
    }
}

/// Per-column display state (width, visibility, override kind, conditional fmt).
#[derive(Clone, Debug)]
struct ColState {
    width: f32,
    visible: bool,
    cond: Option<CondFmt>,
    /// Optional kind override (e.g. force a Float to render a progress bar).
    progress: bool,
    /// Render a sparkline from this column's numeric values across nearby rows.
    sparkline: bool,
}

impl Default for ColState {
    fn default() -> Self {
        Self { width: 120.0, visible: true, cond: None, progress: false, sparkline: false }
    }
}

// ── FC-1 / FC-3: the observable Model ─────────────────────────────────────────

/// **The complete observable interaction state (FC-1 / FC-3)** of a [`Grid`], in
/// one serializable, round-trippable struct. Everything a [`Msg`] can change lives
/// here; [`Grid::state`] hands back a `&GridState` and a headless driver
/// ([`facett_core::harness`]) snapshots it after feeding a `Vec<Msg>`.
///
/// **Not** in the Model, on purpose: the bulk Arrow [`GridData`] (the *input* the
/// grid renders, not state the driver mutates — and not serde-friendly at a million
/// rows), the derived sort/filter row permutation (a cache rebuilt from
/// `sort`+`filter`+data), and the ephemeral smooth-scroll / hover animation (FC-7).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GridState {
    /// The active multi-column sort spec.
    pub sort: SortSpec,
    /// The Excel-style rectangular selection, in **visual** (post-sort) coords — the
    /// render/TSV surface. Its FC-5 durable identity is [`active`](Self::active).
    pub selection: Selection,
    /// **FC-5** — the active cell as a **stable `(source_row_id, col)`**, invariant
    /// across a re-sort (whereas [`selection`](Self::selection)'s visual lead moves).
    /// `None` when nothing is selected.
    pub active: Option<(usize, usize)>,
    /// Row-height density preset.
    pub density: Density,
    /// Zoom (also the [`Facet`] scale).
    pub scale: f32,
    /// Whether the sticky totals footer is shown.
    pub show_totals: bool,
    /// Free-text filter (searchable cap). Empty = no filter.
    pub filter: String,
    /// The inline cell editor (active or idle).
    pub edit: EditState,
    /// Committed inline edits not yet drained by the host, in commit order.
    pub changes: Vec<GridChange>,
}

impl Default for GridState {
    fn default() -> Self {
        Self {
            sort: SortSpec::default(),
            selection: Selection::default(),
            active: None,
            density: Density::default(),
            scale: 1.0,
            show_totals: false,
            filter: String::new(),
            edit: EditState::default(),
            changes: Vec::new(),
        }
    }
}

// ── FC-2: the input vocabulary ────────────────────────────────────────────────

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the grid through, the same transition each canvas
/// gesture / key chord produces. Applied by [`Grid::update`], the **only** mutation
/// path. Rows are addressed by their **stable source-row id** (FC-5), not the
/// volatile visual index.
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Click a column header to sort by it (`additive` = shift-click, multi-key).
    ClickHeader { col: usize, additive: bool },
    /// Clear all sorting (back to source order) — emitted by the toolbar's
    /// "Clear sort" button, which is offered only while a sort is active.
    ClearSort,
    /// Plain click / keyboard move: select a single cell. `row` is the **source-row
    /// id** (FC-5), `col` the source column index.
    SelectCell { row: usize, col: usize },
    /// Ctrl-click: add a disjoint single-cell range. `row` = source-row id.
    AddCell { row: usize, col: usize },
    /// Shift-click / shift-arrow: extend the active range to a cell. `row` = source id.
    ExtendTo { row: usize, col: usize },
    /// Select the whole grid.
    SelectAll,
    /// Clear the selection.
    ClearSelection,
    /// Set the free-text filter (case-insensitive substring over all cells) —
    /// emitted by the toolbar's 🔍 field, one message per keystroke. Large searches
    /// advance through `ContinueFilter`; `set_filter` and `order` finish synchronously.
    SetFilter(String),
    /// Continue a large search; obsolete generations are ignored.
    ContinueFilter { generation: u64 },
    /// Set the row-height density preset.
    SetDensity(Density),
    /// Cycle the density preset — emitted by the toolbar's "Density: …" button.
    CycleDensity,
    /// Show / hide the sticky totals footer — emitted by the toolbar's "Σ totals"
    /// checkbox.
    SetTotals(bool),
    /// Set the zoom / Facet scale (clamped 0.25..4.0).
    SetScale(f32),
    /// Begin editing the selected lead cell (Enter / double-click). No-op unless the
    /// grid is editable and the lead cell is text-editable.
    BeginEdit,
    /// Replace the live edit buffer (each keystroke in the inline editor).
    SetEditBuffer(String),
    /// Commit the current edit buffer (Enter / Tab). Valid input applies + queues a
    /// [`GridChange`]; invalid input sets `edit.valid = false` and keeps editing.
    CommitEdit,
    /// Cancel the current edit (Escape) — buffer discarded, no change.
    CancelEdit,
    /// Ingest a TSV/CSV blob at the selection lead (editable grids only).
    PasteTsv(String),
}

/// Side work as data (FC-8). The grid does no I/O — every [`Msg`] mutates only the
/// in-memory [`GridState`]/data — so this is uninhabited on purpose: the
/// type-checked statement that [`Grid::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// The grid component. Owns the columnar [`GridData`] (the input), the observable
/// interaction [`GridState`], the per-column display state, and the derived
/// order/scroll caches; wires them to egui + the [`Facet`]/[`Elm`](facett_core::Elm)
/// contracts.
pub struct Grid {
    title: String,
    /// All observable interaction state (FC-1 / FC-3).
    state: GridState,
    data: GridData,
    /// The source Arrow batch (when built from one), retained so a column copy
    /// can hand out **zero-copy** `Arc`-shared arrays sliced over the selection
    /// (PERF-1, GRID-4) instead of re-materialising a `Vec` per copy.
    batch: Option<Arc<RecordBatch>>,
    cols: Vec<ColState>,
    /// Cached visual→source row permutation; rebuilt when `sort`/`filter` changes.
    order: Arc<Vec<usize>>,
    /// Unfiltered order, shared with `order` while no filter is active.
    sorted: Arc<Vec<usize>>,
    pending_filter: Option<FilterJob>,
    filter_generation: u64,
    order_dirty: bool,
    pinned: usize,
    /// Derived sums, invalidated on data/filter/order changes.
    sums: RefCell<BTreeMap<usize, Option<f64>>>,
    /// Numeric ranges per column, cached for conditional formatting.
    ranges: BTreeMap<usize, (f64, f64)>,
    /// Whether inline cell editing is enabled (opt-in via [`with_editable`]).
    editable: bool,
    /// Last-known hovered cell (visual coords) for cross-highlight. **Ephemeral
    /// render cache, not observable state** — interior-mutable so the FC-9 pure
    /// [`view`](Self::view) can update it through a `&self` borrow (FC-7).
    hover: Cell<Option<(usize, usize)>>,
    /// Smooth scroll (both axes) for the virtualized body (SCRL-3): eased,
    /// injected-clock (FC-7). Same story as `hover`: interior-mutable ephemeral
    /// animation cache, deliberately kept OUT of [`GridState`].
    scroll_x: Cell<SmoothScroll>,
    scroll_y: Cell<SmoothScroll>,
    /// What the virtualized body actually PAINTED this frame — the row window and
    /// the content origin the painter used. Same FC-7 ephemeral-cache story as
    /// `hover`; it exists so [`emit_a11y`](Self::emit_a11y) can put an AccessKit
    /// node over the *same* rect the cell was painted into, using the painter's
    /// own arithmetic rather than a second copy of it (LAW 5).
    body_view: Cell<Option<BodyView>>,
}

/// Cooperative search keeps both native and wasm event loops responsive.
struct FilterJob {
    generation: u64,
    cursor: usize,
    matches: Vec<usize>,
    scratch: String,
}

/// The body's painted geometry for one frame (see [`Grid::body_view`]).
#[derive(Clone, Copy, Debug)]
struct BodyView {
    win: viewport::RowWindow,
    origin: egui::Pos2,
}

impl Grid {
    /// Build from a prepared [`GridData`].
    pub fn new(title: impl Into<String>, data: GridData) -> Self {
        let ncols = data.ncols();
        let mut ranges = BTreeMap::new();
        for c in 0..ncols {
            if let Some(r) = data.columns[c].numeric_range() {
                ranges.insert(c, r);
            }
        }
        let cols = (0..ncols)
            .map(|c| ColState {
                // Auto-fit a sensible starting width from the header + a sample.
                width: autofit_width(&data, c, 64).clamp(56.0, 360.0),
                ..ColState::default()
            })
            .collect();
        let sorted = Arc::new((0..data.nrows()).collect());
        Self {
            title: title.into(),
            order: Arc::clone(&sorted),
            sorted,
            pending_filter: None,
            filter_generation: 0,
            state: GridState::default(),
            data,
            batch: None,
            cols,
            order_dirty: false,
            pinned: 0,
            sums: RefCell::default(),
            ranges,
            editable: false,
            hover: Cell::new(None),
            scroll_x: Cell::new(SmoothScroll::default()),
            scroll_y: Cell::new(SmoothScroll::default()),
            body_view: Cell::new(None),
        }
    }

    /// Build straight from an Arrow [`RecordBatch`] (the common path). The batch is
    /// retained (`Arc`-shared) so column copy stays zero-copy (GRID-4 / PERF-1).
    pub fn from_record_batch(title: impl Into<String>, batch: &arrow_array::RecordBatch) -> Self {
        let mut g = Self::new(title, arrow::grid_data_from_batch(batch));
        g.batch = Some(Arc::new(batch.clone())); // RecordBatch::clone is Arc-of-buffers, zero-copy
        g
    }

    /// Build from an already-`Arc`-shared batch — no buffer clone at all.
    pub fn from_record_batch_arc(title: impl Into<String>, batch: Arc<arrow_array::RecordBatch>) -> Self {
        let mut g = Self::new(title, arrow::grid_data_from_batch(&batch));
        g.batch = Some(batch);
        g
    }

    // ── builders ─────────────────────────────────────────────────────────────

    /// Freeze the leading `n` columns (they don't scroll horizontally).
    pub fn pin_columns(mut self, n: usize) -> Self {
        self.pinned = n.min(self.data.ncols());
        self
    }
    /// Show a sticky totals (sum) footer for numeric columns.
    pub fn with_totals(mut self, on: bool) -> Self {
        self.state.show_totals = on;
        self
    }
    /// Start with a density preset.
    pub fn with_density(mut self, d: Density) -> Self {
        self.state.density = d;
        self
    }
    /// Attach a conditional-format rule to a column.
    pub fn with_cond_fmt(mut self, col: usize, fmt: CondFmt) -> Self {
        if let Some(c) = self.cols.get_mut(col) {
            c.cond = Some(fmt);
        }
        self
    }
    /// Render a numeric column as in-cell progress bars (expects 0..1 or 0..100).
    pub fn with_progress(mut self, col: usize) -> Self {
        if let Some(c) = self.cols.get_mut(col) {
            c.progress = true;
        }
        self
    }
    /// Render a numeric column as a tiny per-cell sparkline (windowed over rows).
    pub fn with_sparkline(mut self, col: usize) -> Self {
        if let Some(c) = self.cols.get_mut(col) {
            c.sparkline = true;
        }
        self
    }
    /// Hide a column.
    pub fn hide_column(mut self, col: usize) -> Self {
        if let Some(c) = self.cols.get_mut(col) {
            c.visible = false;
        }
        self
    }
    /// Enable inline cell editing (Enter/double-click → edit, Enter/Tab → commit,
    /// Escape → cancel). Adds the `editable` capability flag.
    pub fn with_editable(mut self) -> Self {
        self.editable = true;
        self
    }

    // ── public accessors / actions (also the headless-test entry points) ──────

    /// **FC-3** — read the complete observable interaction state at any frame boundary.
    pub fn state(&self) -> &GridState {
        &self.state
    }

    pub fn data(&self) -> &GridData {
        &self.data
    }
    pub fn sort_spec(&self) -> &SortSpec {
        &self.state.sort
    }
    pub fn selection(&self) -> &Selection {
        &self.state.selection
    }
    pub fn density(&self) -> Density {
        self.state.density
    }
    pub fn edit_state(&self) -> &EditState {
        &self.state.edit
    }

    /// Drain the committed inline-edits accumulated since the last call (in commit
    /// order). This is the public/GUI observation surface for edits: a host that
    /// drives the grid through [`Facet::ui`] (which discards any per-frame response)
    /// calls this each frame to consume what the user committed.
    pub fn take_changes(&mut self) -> Vec<GridChange> {
        std::mem::take(&mut self.state.changes)
    }

    /// Peek at the most recent committed change without draining (also surfaced in
    /// `state_json` as `last_change`). `None` until an edit is committed.
    pub fn last_change(&self) -> Option<&GridChange> {
        self.state.changes.last()
    }

    /// The current visual→source row order, completing any pending filter.
    pub fn order(&mut self) -> &[usize] {
        self.ensure_order();
        self.finish_filter();
        &self.order
    }

    /// The number of visible rows, completing any pending filter.
    pub fn visible_rows(&mut self) -> usize {
        self.ensure_order();
        self.finish_filter();
        self.order.len()
    }

    /// The current scaled row height.
    pub fn row_h(&self) -> f32 {
        self.state.density.base_row_h() * self.state.scale
    }

    /// Apply a header click (sort). `additive` = shift-click (multi-key). Routes
    /// through the FC-2 [`update`](Self::update) mutation path.
    pub fn click_header(&mut self, col: usize, additive: bool) {
        let _ = self.update(Msg::ClickHeader { col, additive });
    }
    /// Clear all sorting.
    pub fn clear_sort(&mut self) {
        let _ = self.update(Msg::ClearSort);
    }

    /// Set the filter synchronously. UI messages search large grids incrementally.
    pub fn set_filter(&mut self, q: impl Into<String>) {
        let _ = self.update(Msg::SetFilter(q.into()));
        self.finish_filter();
    }

    /// Select a single cell (visual coords) — test/click entry point. Resolves the
    /// visual row to its stable source id and routes through [`update`](Self::update).
    pub fn select_cell(&mut self, r: usize, c: usize) {
        self.ensure_order();
        let row = self.order.get(r).copied().unwrap_or(r);
        let _ = self.update(Msg::SelectCell { row, col: c });
    }
    /// Extend the active selection range to (r, c) — shift-drag entry point.
    pub fn extend_selection(&mut self, r: usize, c: usize) {
        self.ensure_order();
        let row = self.order.get(r).copied().unwrap_or(r);
        let _ = self.update(Msg::ExtendTo { row, col: c });
    }

    /// Trigger edit mode on the selected lead cell (as if Enter was pressed).
    /// Loads the current cell value into the edit buffer.
    /// Returns `true` if edit mode was activated.
    pub fn begin_edit(&mut self) -> bool {
        if !self.editable {
            return false;
        }
        let Some((vr, col)) = self.state.selection.lead else { return false };
        let nrows = self.order.len();
        if vr >= nrows || col >= self.data.ncols() {
            return false;
        }
        let src = self.order[vr];
        // Bool and progress/sparkline columns are not text-editable.
        let kind = self.data.kinds.get(col).copied().unwrap_or(CellKind::Text);
        if kind == CellKind::Bool {
            return false;
        }
        let current = self.data.cell_raw(src, col);
        self.state.edit.active = true;
        self.state.edit.row = vr;
        self.state.edit.col = col;
        self.state.edit.buffer = current.clone();
        self.state.edit.original = current;
        self.state.edit.valid = true;
        true
    }

    /// Commit the current edit buffer. Validates the type; if valid, applies the
    /// change and returns a `GridChange`. If invalid, sets `edit.valid = false`
    /// and returns `None`.
    pub fn commit_edit(&mut self) -> Option<GridChange> {
        if !self.state.edit.active {
            return None;
        }
        let vr = self.state.edit.row;
        let col = self.state.edit.col;
        let nrows = self.order.len();
        if vr >= nrows || col >= self.data.ncols() {
            self.state.edit.clear();
            return None;
        }
        let src = self.order[vr];
        let kind = self.data.kinds.get(col).copied().unwrap_or(CellKind::Text);
        let buf = self.state.edit.buffer.trim().to_string();

        // Validate the input against the column type.
        let valid = validate_cell_input(&buf, kind, &self.data.columns[col]);
        if !valid {
            self.state.edit.valid = false;
            return None;
        }

        let old = self.state.edit.original.clone();
        // Apply the edit to the underlying data.
        apply_edit(&mut self.data, src, col, &buf);

        let change = GridChange {
            row: vr,
            source_row: Some(src),
            col,
            old_value_str: old,
            new_value_str: buf,
        };
        self.state.edit.clear();
        self.data_changed(&[col]);
        Some(change)
    }

    /// Cancel the current edit — no change emitted, buffer discarded.
    pub fn cancel_edit(&mut self) {
        self.state.edit.clear();
    }

    /// Compute the visible row window for a given scroll/viewport — exposed so a
    /// host (and the perf test) can assert virtualization is O(visible).
    pub fn row_window_at(&mut self, scroll_y: f32, viewport_h: f32) -> RowWindow {
        let total = self.visible_rows();
        viewport::row_window(scroll_y, viewport_h, self.row_h(), total, 2)
    }

    /// The indices of currently-visible columns (respecting hide/show).
    fn visible_cols(&self) -> Vec<usize> {
        (0..self.cols.len()).filter(|&c| self.cols[c].visible).collect()
    }

    /// The current visual row of a **stable source-row id** (FC-5), if it is visible
    /// under the active sort+filter. Used to translate a `Msg`'s stable id back into
    /// the visual coords the `Selection` renders in.
    fn visual_of(&self, source_id: usize) -> Option<usize> {
        self.order.iter().position(|&s| s == source_id)
    }

    /// After the row order is rebuilt (a re-sort or filter change), re-point the
    /// visual selection at the FC-5 [`active`](GridState::active) source row so the
    /// highlight **follows the data row** instead of staying at a stale screen
    /// position. Collapses a multi-range selection to the active cell (Excel keeps a
    /// dragged rectangle in place on re-sort; FC-5 makes selection track identity).
    fn resync_selection(&mut self) {
        let Some((rid, col)) = self.state.active else { return };
        match self.visual_of(rid) {
            Some(vr) => self.state.selection.click(vr, col),
            // The active row was filtered out of view: drop the visual highlight but
            // keep the stable id so a later filter-clear can restore it.
            None => self.state.selection.clear(),
        }
    }

    /// Rebuild the order cache if the sort/filter changed.
    fn ensure_order(&mut self) {
        if self.order_dirty {
            self.rebuild_order();
        }
    }

    fn rebuild_order(&mut self) {
        if self.order_dirty {
            self.sorted = Arc::new(sort::sorted_order(&self.data, &self.state.sort));
            self.order_dirty = false;
        }
        self.filter_generation = self.filter_generation.wrapping_add(1);
        self.pending_filter = None;
        if self.state.filter.trim().is_empty() {
            self.publish_order(Arc::clone(&self.sorted));
        } else {
            self.pending_filter = Some(FilterJob {
                generation: self.filter_generation,
                cursor: 0,
                matches: Vec::new(),
                scratch: String::new(),
            });
            if self.data.nrows() <= self.filter_step_rows() {
                self.finish_filter();
            }
        }
    }

    /// Whether the displayed rows are awaiting the current filter's result.
    pub fn filter_pending(&self) -> bool {
        self.pending_filter.is_some()
    }

    fn filter_step_rows(&self) -> usize {
        (65_536 / self.data.ncols().max(1)).clamp(1, 16_384)
    }

    fn continue_filter(&mut self, generation: u64) {
        // Bound cells as well as rows for wide tables. No threads or host I/O.
        let rows = self.filter_step_rows();
        let Some(job) = self.pending_filter.as_mut() else {
            return;
        };
        if job.generation != generation {
            return;
        }
        let end = (job.cursor + rows).min(self.sorted.len());
        for &row in &self.sorted[job.cursor..end] {
            if self
                .data
                .row_matches(row, &self.state.filter, &mut job.scratch)
            {
                job.matches.push(row);
            }
        }
        job.cursor = end;
        if end == self.sorted.len() {
            let job = self.pending_filter.take().expect("active search");
            self.publish_order(Arc::new(job.matches));
        }
    }

    fn finish_filter(&mut self) {
        while let Some(job) = &self.pending_filter {
            self.continue_filter(job.generation);
        }
    }

    fn publish_order(&mut self, order: Arc<Vec<usize>>) {
        if Arc::ptr_eq(&self.order, &order) || self.order == order {
            self.order = order;
            return;
        }
        let editing = self
            .state
            .edit
            .active
            .then(|| self.order.get(self.state.edit.row).copied())
            .flatten();
        self.order = order;
        if let Some(source) = editing {
            if let Some(row) = self.visual_of(source) {
                self.state.edit.row = row;
            } else {
                self.state.edit.clear();
            }
        }
        self.sums.get_mut().clear();
        self.resync_selection();
    }

    fn data_changed(&mut self, columns: &[usize]) {
        // Typed clipboard data must not expose the unedited source batch.
        self.batch = None;
        self.sums.get_mut().clear();
        for &col in columns {
            if let Some(range) = self.data.columns[col].numeric_range() {
                self.ranges.insert(col, range);
            } else {
                self.ranges.remove(&col);
            }
        }
        self.order_dirty = self
            .state
            .sort
            .keys
            .iter()
            .any(|key| columns.contains(&key.col));
        self.rebuild_order();
    }

    /// Build a **zero-copy** typed payload for the current selection (GRID-4): the
    /// selected columns as `Arc`-shared Arrow arrays. Requires the grid was built
    /// from a `RecordBatch` (else `None` — the TSV path still works). The arrays
    /// are the *whole* source columns shared by `Arc` (no buffer copy); the TSV
    /// mirror in [`copy`](Facet::copy) carries the exact selected rectangle.
    pub fn copy_columns(&self) -> Option<Vec<ArrowColumnRef>> {
        let batch = self.batch.as_ref()?;
        let (_, cols) = self.state.selection.cells();
        let col_ix: Vec<usize> = if cols.is_empty() {
            (0..self.data.ncols()).collect()
        } else {
            cols.into_iter().filter(|&c| c < batch.num_columns()).collect()
        };
        if col_ix.is_empty() {
            return None;
        }
        let schema = batch.schema();
        Some(
            col_ix
                .into_iter()
                .map(|c| ArrowColumnRef {
                    name: schema.field(c).name().clone(),
                    // Arc clone of the column = share the same buffer (zero-copy).
                    array: Arc::clone(batch.column(c)),
                })
                .collect(),
        )
    }

    /// Column sum for the totals footer (numeric columns only), over the visible
    /// rows. One sequential pass over the column in source-row order
    /// ([`Column::fold_values`]); a `Column::numeric` read per visible row cost
    /// PR #2's first frame +1.3 ms at 100k rows. Source order also keeps Σ the
    /// same across re-sorts: float addition is not associative.
    fn column_sum(&self, col: usize) -> Option<f64> {
        if let Some(sum) = self.sums.borrow().get(&col) {
            return *sum;
        }
        let c = self.data.columns.get(col)?;
        if !matches!(c.kind(), CellKind::Int | CellKind::Float) {
            return None;
        }
        let add = |(acc, any): (f64, bool), v: Option<data::Value<'_>>| match v.and_then(data::Value::numeric) {
            Some(x) => (acc + x, true),
            None => (acc, any),
        };
        // `order` never repeats a row, so at full length it holds every row.
        let (acc, any) = if self.order.len() == self.data.nrows() {
            c.fold_values((0.0, false), |sum, _, v| add(sum, v))
        } else {
            let mut visible = vec![false; self.data.nrows()];
            for &row in self.order.iter() {
                visible[row] = true;
            }
            c.fold_values((0.0, false), |sum, row, v| if visible[row] { add(sum, v) } else { sum })
        };
        let sum = any.then_some(acc);
        self.sums.borrow_mut().insert(col, sum);
        sum
    }
}

// ── FC-2 / FC-8: the single mutation path ─────────────────────────────────────

impl Grid {
    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. Every canvas gesture and key
    /// chord routes its `Msg` here too, so live == headless. Row-addressing `Msg`s
    /// carry a **stable source-row id** (FC-5), resolved to the current visual coords
    /// here via [`visual_of`](Self::visual_of).
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::ClickHeader { col, additive } => {
                self.state.sort.click(col, additive);
                self.order_dirty = true;
                self.rebuild_order();
                self.resync_selection();
            }
            Msg::ClearSort => {
                self.state.sort.clear();
                self.order_dirty = true;
                self.rebuild_order();
                self.resync_selection();
            }
            Msg::SelectCell { row, col } => {
                self.ensure_order();
                let vr = self.visual_of(row).unwrap_or(row);
                self.state.selection.click(vr, col);
                self.state.active = Some((row, col));
            }
            Msg::AddCell { row, col } => {
                self.ensure_order();
                let vr = self.visual_of(row).unwrap_or(row);
                self.state.selection.add(vr, col);
                self.state.active = Some((row, col));
            }
            Msg::ExtendTo { row, col } => {
                self.ensure_order();
                let vr = self.visual_of(row).unwrap_or(row);
                self.state.selection.extend_to(vr, col);
                self.state.active = Some((row, col));
            }
            Msg::SelectAll => {
                self.ensure_order();
                let nrows = self.order.len();
                let ncols = self.data.ncols();
                self.state.selection.select_all(nrows, ncols);
                // The select-all lead is visual (0,0); its stable id is order[0].
                self.state.active = self.order.first().map(|&s| (s, 0));
            }
            Msg::ClearSelection => {
                self.state.selection.clear();
                self.state.active = None;
            }
            Msg::SetFilter(q) => {
                if self.state.filter == q {
                    return Vec::new();
                }
                self.state.filter = q;
                self.rebuild_order();
                self.resync_selection();
            }
            Msg::ContinueFilter { generation } => self.continue_filter(generation),
            Msg::SetDensity(d) => self.state.density = d,
            Msg::CycleDensity => self.state.density = self.state.density.cycle(),
            Msg::SetTotals(on) => self.state.show_totals = on,
            Msg::SetScale(s) => self.state.scale = s.clamp(0.25, 4.0),
            Msg::BeginEdit => {
                self.begin_edit();
            }
            Msg::SetEditBuffer(b) => {
                if self.state.edit.active {
                    let kind = self
                        .data
                        .kinds
                        .get(self.state.edit.col)
                        .copied()
                        .unwrap_or(CellKind::Text);
                    self.state.edit.valid = validate_cell_input(
                        b.trim(),
                        kind,
                        &self.data.columns[self.state.edit.col],
                    );
                    self.state.edit.buffer = b;
                }
            }
            Msg::CommitEdit => {
                if let Some(change) = self.commit_edit() {
                    self.state.changes.push(change);
                }
            }
            Msg::CancelEdit => self.cancel_edit(),
            Msg::PasteTsv(t) => {
                self.paste_tsv(&t);
            }
        }
        Vec::new()
    }
}

/// Validate a text input against a `CellKind` — returns `true` if the value
/// can be committed without a type error.
fn validate_input(s: &str, kind: CellKind) -> bool {
    match kind {
        CellKind::Int => s.parse::<i64>().is_ok(),
        CellKind::Float => s.parse::<f64>().is_ok(),
        CellKind::Bool => matches!(s.to_ascii_lowercase().as_str(), "true" | "false" | "1" | "0"),
        // Text, Enum, Date: any string is valid.
        _ => true,
    }
}

fn validate_cell_input(s: &str, kind: CellKind, column: &Column) -> bool {
    if matches!(column, Column::Arrow(c) if c.is_unsigned()) {
        s.parse::<u64>().is_ok()
    } else {
        validate_input(s, kind)
    }
}

/// Apply a validated edit to `GridData` in-place.
fn apply_edit(data: &mut GridData, src: usize, col: usize, value: &str) {
    let Some(column) = data.columns.get_mut(col) else { return };
    match column {
        Column::Arrow(c) => c.edit(src, value),
        Column::Int { v, nulls } => {
            if let Ok(x) = value.parse::<i64>() {
                if let Some(cell) = v.get_mut(src) {
                    *cell = x;
                }
                if let Some(n) = nulls.get_mut(src) {
                    *n = false;
                }
            }
        }
        Column::Float { v, nulls } => {
            if let Ok(x) = value.parse::<f64>() {
                if let Some(cell) = v.get_mut(src) {
                    *cell = x;
                }
                if let Some(n) = nulls.get_mut(src) {
                    *n = false;
                }
            }
        }
        Column::Text { v, nulls } | Column::Date { v, nulls } => {
            if let Some(cell) = v.get_mut(src) {
                *cell = value.to_string();
            }
            if let Some(n) = nulls.get_mut(src) {
                *n = false;
            }
        }
        Column::Bool { v, nulls } => {
            let b = matches!(value.to_ascii_lowercase().as_str(), "true" | "1");
            if let Some(cell) = v.get_mut(src) {
                *cell = b;
            }
            if let Some(n) = nulls.get_mut(src) {
                *n = false;
            }
        }
    }
}

/// Estimate a good column width from the header + a sample of cells.
fn autofit_width(data: &GridData, col: usize, sample: usize) -> f32 {
    let char_w = 7.2; // approx for the default proportional font at 14px
    let header = data.names.get(col).map(|s| s.chars().count()).unwrap_or(0);
    let mut max = header;
    let n = data.nrows().min(sample);
    for r in 0..n {
        max = max.max(data.cell_string(r, col).chars().count());
    }
    24.0 + (max as f32) * char_w
}

// ── rendering (FC-9 pure view) ─────────────────────────────────────────────────

impl Grid {
    /// **The toolbar** — the grid's own control strip, and the *only* place several
    /// [`Msg`]s reach a human: row/col counts, "Clear sort" ([`Msg::ClearSort`]),
    /// the density cycle ([`Msg::CycleDensity`]), the Σ-totals toggle
    /// ([`Msg::SetTotals`]), the free-text filter ([`Msg::SetFilter`]) and the
    /// live selected-cell count.
    ///
    /// FC-9 pure: `&self`, so every control reads the Model and **emits** — a
    /// button pushes its `Msg`, the checkbox and the text field edit a local copy
    /// and push only when it actually differs from the Model. Nothing here mutates
    /// [`GridState`]; the [`Elm`](facett_core::Elm) bridge applies what is returned.
    ///
    /// (Deleted by the Elm migration `4b92e16` and rebuilt 2026-07-23. For three
    /// weeks `CycleDensity` / `ClearSort` / `SetFilter` were reachable only from the
    /// programmatic API — unit-tested, but emitted by no UI at all. The screenshot
    /// proof that should have caught it was overwriting its own golden.)
    fn view_toolbar(&self, ui: &mut Ui, msgs: &mut Vec<Msg>) {
        ui.horizontal(|ui| {
            // The dimensions the grid is actually showing: `order` is the cached
            // visual→source permutation, so its length is the post-filter row count.
            ui.label(format!("{} rows × {} cols", self.order.len(), self.data.ncols()));

            // Only offered when there is a sort to clear (as the header ▲1/▲2
            // indicators are the only other way to see one is active).
            if !self.state.sort.is_empty() && ui.button("Clear sort").clicked() {
                msgs.push(Msg::ClearSort);
            }

            // One button, one preset forward: compact → comfortable → spacious → …
            if ui.button(format!("Density: {}", self.state.density.as_str())).clicked() {
                msgs.push(Msg::CycleDensity);
            }

            // Pure view: the checkbox drives a COPY of the flag and emits the
            // transition, it never writes `self.state.show_totals`.
            let mut totals = self.state.show_totals;
            if ui.checkbox(&mut totals, "Σ totals").changed() {
                msgs.push(Msg::SetTotals(totals));
            }

            // Same story for the filter: edit a clone, emit when it differs.
            ui.label("🔍");
            let mut buf = self.state.filter.clone();
            ui.add(egui::TextEdit::singleline(&mut buf).hint_text("filter…").desired_width(140.0));
            if buf != self.state.filter {
                msgs.push(Msg::SetFilter(buf));
            }

            if self.filter_pending() {
                ui.label("Searching…");
            }

            // What the Excel-style selection currently covers. `usize::MAX` is
            // `Selection::cell_count`'s "too large to enumerate" sentinel.
            let sel = self.state.selection.cell_count();
            if sel > 0 {
                let s = if sel == usize::MAX { ">1M".to_string() } else { sel.to_string() };
                ui.label(format!("· {s} cells selected"));
            }

            // Inline-edit status, so an invalid buffer is visible outside the cell.
            if self.editable && self.state.edit.active {
                ui.label(if self.state.edit.valid { "editing…" } else { "⚠ invalid type" });
            }
        });
        ui.separator();
    }

    /// **FC-9 render** — a **pure** function of `&self`: it paints the toolbar, the
    /// virtualized grid (header, body cells, inline editor, footer, a11y layer) and
    /// *returns* the [`Msg`]s the interactions produced. It MUST NOT mutate the
    /// [`GridState`]; the
    /// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
    /// returned messages through [`update`](Self::update). The only `&self`-interior
    /// mutation is the ephemeral scroll/hover and derived sum caches (FC-7), which
    /// is deliberately outside the observable Model.
    pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        let mut msgs: Vec<Msg> = Vec::new();
        self.view_toolbar(ui, &mut msgs);
        if let Some(job) = &self.pending_filter {
            msgs.push(Msg::ContinueFilter {
                generation: job.generation,
            });
            ui.ctx().request_repaint();
        }
        let th = theme(ui);
        let row_h = self.row_h();
        let header_h = (row_h + 4.0).max(22.0);
        let footer_h = if self.state.show_totals { row_h + 2.0 } else { 0.0 };
        let font = FontId::proportional(13.0 * self.state.scale);

        let vis_cols = self.visible_cols();
        let widths: Vec<f32> = vis_cols.iter().map(|&c| self.cols[c].width).collect();
        let x_starts = viewport::column_x_starts(&widths);
        let total_w = *x_starts.last().unwrap_or(&0.0);
        let total_rows = self.order.len();

        let full = ui.available_rect_before_wrap();
        let body_top = full.top() + header_h;
        let body_h = (full.height() - header_h - footer_h).max(0.0);

        // The scroll area: we drive both scrollbars manually and read offsets.
        // Smooth-scroll both axes (SCRL-3): force egui's displayed offset to our
        // eased offset, follow its resolved offset as the target, advance by
        // `stable_dt` (FC-7). `SmoothScroll` is `Copy`, so we work on copies pulled
        // from the interior-mutable cache (the FC-9 view is `&self`) and write them
        // back afterwards.
        let dt = ui.input(|i| i.stable_dt);
        let (mut gsx, mut gsy) = (self.scroll_x.get(), self.scroll_y.get());
        let mut scroll = egui::ScrollArea::both()
            .auto_shrink([false, false])
            .vertical_scroll_offset(gsy.offset)
            .horizontal_scroll_offset(gsx.offset);
        // Keep the header/footer outside the scrolled content by sizing the inner
        // virtual canvas and painting only visible cells.
        scroll = scroll.id_salt(("facett_grid", self.title.as_str()));

        // We paint header first (sticky), then the scrollable body, then footer.
        let body_rect = Rect::from_min_size(pos2(full.left(), body_top), vec2(full.width(), body_h));

        // Header background.
        let painter = ui.painter_at(full);
        painter.rect_filled(Rect::from_min_size(full.min, vec2(full.width(), header_h)), 0.0, th.panel_bg);

        // Run the scroll area constrained to the body rect.
        let out = ui.scope_builder(egui::UiBuilder::new().max_rect(body_rect), |ui| {
            scroll.show_viewport(ui, |ui, viewport| {
                ui.set_height(total_rows as f32 * row_h);
                ui.set_width(total_w);
                let scroll_y = viewport.min.y;
                let scroll_x = viewport.min.x;
                let win = viewport::row_window(scroll_y, body_h, row_h, total_rows, 2);
                let origin = ui.min_rect().min; // top-left of the virtual content
                let painter = ui.painter_at(ui.clip_rect());

                // Visible scrolling-column range (pinned drawn separately).
                let pinned_vis = self.pinned.min(vis_cols.len());
                let col_range = viewport::column_window(&x_starts, scroll_x, body_rect.width(), pinned_vis);

                // Hover detection (for cross-highlight).
                let pointer = ui.input(|i| i.pointer.hover_pos());
                let mut hover_cell: Option<(usize, usize)> = None;

                // Paint visible body cells.
                for vr in win.range() {
                    let src = self.order[vr];
                    let y = origin.y + vr as f32 * row_h;
                    let row_rect = Rect::from_min_size(pos2(origin.x, y), vec2(total_w, row_h));
                    // Zebra striping.
                    if vr % 2 == 1 {
                        painter.rect_filled(row_rect, 0.0, th.node_fill.linear_multiply(0.5));
                    }
                    // Hover row band.
                    if let Some(p) = pointer
                        && p.y >= y && p.y < y + row_h {
                            painter.rect_filled(row_rect, 0.0, th.accent.linear_multiply(0.06));
                        }

                    // Draw pinned columns at fixed x (no horizontal scroll).
                    for pc in 0..pinned_vis {
                        let col = vis_cols[pc];
                        let cx = origin.x + scroll_x + x_starts[pc];
                        let cell = Rect::from_min_size(pos2(cx, y), vec2(widths[pc], row_h));
                        // Skip painter path for the actively-edited cell (editor overlay below).
                        if !(self.state.edit.active && self.state.edit.row == vr && self.state.edit.col == col) {
                            self.draw_cell(&painter, &th, &font, cell, vr, src, col, pointer, &mut hover_cell);
                        }
                        // pinned divider
                        painter.line_segment(
                            [pos2(cx + widths[pc], y), pos2(cx + widths[pc], y + row_h)],
                            Stroke::new(1.0_f32, th.panel_stroke.linear_multiply(0.4)),
                        );
                    }
                    // Scrolling columns.
                    for vc in col_range.clone() {
                        let col = vis_cols[vc];
                        let cx = origin.x + x_starts[vc];
                        let cell = Rect::from_min_size(pos2(cx, y), vec2(widths[vc], row_h));
                        // Skip painter path for the actively-edited cell (editor overlay below).
                        if !(self.state.edit.active && self.state.edit.row == vr && self.state.edit.col == col) {
                            self.draw_cell(&painter, &th, &font, cell, vr, src, col, pointer, &mut hover_cell);
                        }
                    }
                }

                // ── Inline editor overlay ──────────────────────────────────
                // If we're editing a cell that is currently visible, place an
                // interactive TextEdit widget at the cell's rect using `ui.put`.
                // FC-9: the editor edits a LOCAL buffer and emits `Msg`s — it never
                // mutates `self.state.edit` directly.
                if self.state.edit.active {
                    let edit_vr = self.state.edit.row;
                    let edit_col = self.state.edit.col;
                    // Only render the overlay if the row is in the visible window.
                    if win.contains(edit_vr) {
                        // Find the cell rect.
                        let y = origin.y + edit_vr as f32 * row_h;
                        let cell_rect_opt: Option<Rect> = {
                            // Check pinned columns.
                            let mut found = None;
                            for pc in 0..pinned_vis {
                                if vis_cols[pc] == edit_col {
                                    let cx = origin.x + scroll_x + x_starts[pc];
                                    found = Some(Rect::from_min_size(pos2(cx, y), vec2(widths[pc], row_h)));
                                    break;
                                }
                            }
                            if found.is_none() {
                                for vc in pinned_vis..vis_cols.len() {
                                    if vis_cols[vc] == edit_col {
                                        let cx = origin.x + x_starts[vc];
                                        found = Some(Rect::from_min_size(pos2(cx, y), vec2(widths[vc], row_h)));
                                        break;
                                    }
                                }
                            }
                            found
                        };

                        if let Some(cell_rect) = cell_rect_opt {
                            // Draw a selection highlight + accent border behind the editor.
                            painter.rect_filled(cell_rect, 0.0, th.accent.linear_multiply(0.18));
                            painter.rect_stroke(cell_rect, 0.0, Stroke::new(2.0_f32, th.accent), StrokeKind::Inside);

                            // Live validity of the (possibly-edited) buffer.
                            let mut buf = self.state.edit.buffer.clone();
                            let kind = self
                                .data
                                .kinds
                                .get(edit_col)
                                .copied()
                                .unwrap_or(CellKind::Text);
                            let valid =
                                validate_cell_input(buf.trim(), kind, &self.data.columns[edit_col]);
                            // Show validity feedback: red tint when invalid.
                            if !valid {
                                painter.rect_filled(cell_rect, 0.0, Color32::from_rgba_unmultiplied(220, 60, 60, 40));
                            }

                            // Place the TextEdit widget at the cell rect (edits a local copy).
                            let te_resp = ui.put(
                                cell_rect.shrink2(vec2(3.0, 2.0)),
                                egui::TextEdit::singleline(&mut buf)
                                    .font(font.clone())
                                    .frame(egui::Frame::new().inner_margin(egui::Margin::ZERO)),
                            );
                            // Request focus on the first frame.
                            te_resp.request_focus();

                            // Emit a buffer update when the user typed (FC-9: no self-mutation).
                            if buf != self.state.edit.buffer {
                                msgs.push(Msg::SetEditBuffer(buf));
                            }

                            // Commit on Enter or Tab; cancel on Escape.
                            let (enter, tab, esc) = ui.input(|i| {
                                (
                                    i.key_pressed(egui::Key::Enter),
                                    i.key_pressed(egui::Key::Tab),
                                    i.key_pressed(egui::Key::Escape),
                                )
                            });
                            if enter || tab {
                                msgs.push(Msg::CommitEdit);
                            } else if esc {
                                msgs.push(Msg::CancelEdit);
                            }
                        }
                    }
                }

                // Gridlines (light) between visible rows.
                for vr in win.range() {
                    let y = origin.y + (vr + 1) as f32 * row_h;
                    painter.line_segment(
                        [pos2(origin.x, y), pos2(origin.x + total_w, y)],
                        Stroke::new(0.5_f32, th.edge.linear_multiply(0.5)),
                    );
                }

                // Ephemeral hover cache (FC-7) — interior-mutable, outside the Model.
                self.hover.set(hover_cell);
                // What was painted, for the a11y layer below (FC-7 cache).
                self.body_view.set(Some(BodyView { win, origin }));
                self.handle_body_input(ui, &x_starts, &vis_cols, origin, row_h, scroll_x, pinned_vis, &mut msgs);
                viewport
            })
        });
        // Ease the displayed offset toward egui's resolved offset (after this
        // frame's wheel) and repaint while it glides. State written back to the
        // interior-mutable scroll cache (FC-7).
        let sa = &out.inner;
        let max_y = (sa.content_size.y - sa.inner_rect.height()).max(0.0);
        let max_x = (sa.content_size.x - sa.inner_rect.width()).max(0.0);
        let mut animating = gsy.follow(max_y, sa.state.offset.y, dt);
        animating |= gsx.follow(max_x, sa.state.offset.x, dt);
        if animating {
            ui.ctx().request_repaint();
        }
        self.scroll_x.set(gsx);
        self.scroll_y.set(gsy);
        let scroll_x = out.inner.inner.min.x;

        // ── sticky header (painted over the top inset) ──
        self.draw_header(&painter, &th, &font, full, header_h, &vis_cols, &x_starts, scroll_x, total_w);

        // ── sticky totals footer ──
        if self.state.show_totals {
            let fy = full.bottom() - footer_h;
            let frect = Rect::from_min_size(pos2(full.left(), fy), vec2(full.width(), footer_h));
            painter.rect_filled(frect, 0.0, th.panel_bg);
            painter.line_segment([pos2(full.left(), fy), pos2(full.right(), fy)], Stroke::new(1.0_f32, th.panel_stroke));
            let pinned_vis = self.pinned.min(vis_cols.len());
            let pinned_w = x_starts[pinned_vis];
            // Scrolling totals, clipped to the area right of the pinned inset.
            let fclip = Rect::from_min_size(pos2(full.left() + pinned_w, fy), vec2(full.width() - pinned_w, footer_h));
            let fp = painter.with_clip_rect(fclip);
            for vc in pinned_vis..vis_cols.len() {
                let col = vis_cols[vc];
                let cx = full.left() + x_starts[vc] - scroll_x;
                if cx + widths[vc] < fclip.left() || cx > full.right() {
                    continue;
                }
                self.draw_total(&fp, &th, &font, cx, fy, widths[vc], footer_h, col, false);
            }
            // Pinned totals over the top (fixed x).
            for pc in 0..pinned_vis {
                let col = vis_cols[pc];
                let cx = full.left() + x_starts[pc];
                self.draw_total(&painter, &th, &font, cx, fy, widths[pc], footer_h, col, pc == 0);
            }
        }

        // Keyboard handling (nav + select-all + edit trigger) — emits `Msg`s.
        self.handle_keys(ui, &vis_cols, &mut msgs);

        // ── FC-4 AccessKit: the grid is custom-painted, so nothing above this
        // point lands in the accessibility tree. Ride semantic nodes over the
        // paint: a Table-role region for the whole grid, a Button node per
        // *visible* column header (carrying the column name + sort state), and a
        // node per *painted* body cell (carrying its column, row and value).
        self.emit_a11y(ui, full, header_h, &vis_cols, &x_starts, scroll_x);

        // ── render-lane emit: this pure `view` path RAN ───────────────────────
        #[cfg(feature = "testmatrix")]
        {
            let vr = self.order.len();
            facett_core::testmatrix::emit(
                "facett-grid::Grid::view",
                "ui_render",
                // OK = the grid drew its declared geometry: the visible-row set never
                // exceeds the backing data's row count.
                vr <= self.data.nrows(),
                &format!(
                    "rows={} cols={} visible={} cells={}",
                    self.data.nrows(),
                    self.data.ncols(),
                    vr,
                    self.data.nrows().saturating_mul(self.data.ncols()),
                ),
            );
        }

        msgs
    }

    /// Attach the AccessKit semantic layer over the painted grid (FC-4 / FC-5).
    /// Headers, the whole-grid summary and **every painted body cell** become
    /// labelled, addressable nodes keyed on stable domain keys (the **column name**
    /// and the **source row id**, never a loop index).
    #[allow(clippy::too_many_arguments)]
    fn emit_a11y(&self, ui: &Ui, full: Rect, header_h: f32, vis_cols: &[usize], x_starts: &[f32], scroll_x: f32) {
        let base = ui.id().with(("facett_grid_a11y", self.title.as_str()));

        // Whole-grid Table-role region: title + dimensions as a value-bearing node.
        let table_label = format!("{} — {} rows × {} cols", self.title, self.data.nrows(), self.data.ncols());
        let table_resp = ui.interact(full, base.with("table"), egui::Sense::hover());
        table_resp.widget_info(|| {
            facett_core::Semantics::new(egui::WidgetType::Other, table_label.clone())
                .value(self.data.nrows() as f64)
                .widget_info()
        });

        // Per visible column header → a Button node keyed on the column name.
        let pinned_vis = self.pinned.min(vis_cols.len());
        for (vc, &col) in vis_cols.iter().enumerate() {
            let name = self.data.names.get(col).cloned().unwrap_or_default();
            if name.is_empty() {
                continue;
            }
            let w = self.cols[col].width;
            let cx = if vc < pinned_vis { full.left() + x_starts[vc] } else { full.left() + x_starts[vc] - scroll_x };
            let hrect = Rect::from_min_size(pos2(cx, full.top()), vec2(w, header_h));
            if hrect.right() < full.left() || hrect.left() > full.right() {
                continue;
            }
            let sorted = self.state.sort.rank_of(col);
            let label = match sorted {
                Some((rank, dir)) if self.state.sort.keys.len() > 1 => format!("{name} {} {rank}", dir.arrow()),
                Some((_, dir)) => format!("{name} {}", dir.arrow()),
                None => name.clone(),
            };
            let resp = ui.interact(hrect, base.with(("hdr", name.as_str())), egui::Sense::hover());
            resp.widget_info(|| {
                facett_core::Semantics::new(egui::WidgetType::Button, label.clone())
                    .selected(sorted.is_some())
                    .widget_info()
            });
        }

        // ── BODY CELLS. Every cell value is painted with a raw `Painter::text`,
        // which builds no widget, so before this loop **no cell value reached
        // AccessKit at all** — headers and the `N rows × M cols` summary did, and
        // nothing else. That is STINKY-19 R0b, and its consequence was fleet-wide:
        // any robot test asserting grid CONTENT through the accessibility tree
        // (`get_by_label`, `label_contains`) could only ever match a header or the
        // summary, and passed identically **with every cell in the grid blanked**.
        //
        // Cost is bounded by the VIEWPORT, not the dataset: only the rows the
        // virtualizer painted this frame are emitted (a 600 px body is ~25 rows over
        // 10 rows or over 10 M — `tests/perf.rs`), and columns scrolled off the pane
        // are skipped by the same rect test the painter uses.
        let mut lead_emitted = false;
        if let Some(bv) = self.body_view.get() {
            let row_h = self.row_h();
            let pinned_vis = self.pinned.min(vis_cols.len());
            let body_top = full.top() + header_h;
            for vr in bv.win.range() {
                let Some(&src) = self.order.get(vr) else { continue };
                // The painter's own y for this row — one arithmetic, not a copy.
                let y = bv.origin.y + vr as f32 * row_h;
                if y + row_h < body_top || y > full.bottom() {
                    continue;
                }
                for (vc, &col) in vis_cols.iter().enumerate() {
                    let w = self.cols[col].width;
                    let cx = if vc < pinned_vis {
                        bv.origin.x + scroll_x + x_starts[vc]
                    } else {
                        bv.origin.x + x_starts[vc]
                    };
                    if cx + w < full.left() || cx > full.right() {
                        continue;
                    }
                    let selected = self.state.selection.lead == Some((vr, col));
                    lead_emitted |= selected;
                    self.emit_cell_node(ui, base, Rect::from_min_size(pos2(cx, y), vec2(w, row_h)), vr, src, col, selected);
                }
            }
        }
        // The lead cell is the one cell a driver addresses BY NAME, so it rides in
        // even when the body has scrolled it out of the painted window.
        if !lead_emitted
            && let Some((vr, col)) = self.state.selection.lead
            && let Some(&src) = self.order.get(vr)
        {
            let rect = self
                .lead_cell_rect(full, header_h, vis_cols, x_starts, scroll_x, vr, col)
                .unwrap_or_else(|| Rect::from_min_size(full.min, vec2(1.0, 1.0)));
            self.emit_cell_node(ui, base, rect, vr, src, col, true);
        }
    }

    /// **ONE writer** for a body cell's AccessKit node: its label, its numeric value
    /// and whether it is the selection lead. The painted-window loop and the
    /// off-screen-lead fallback both go through here, so the label a robot driver
    /// queries cannot drift between the two paths (LAW 5).
    ///
    /// The label is `"{column} row {visual_row}: {value}"` — the same spelling the
    /// lead cell has carried since FC-4, so existing queries keep working.
    #[allow(clippy::too_many_arguments)]
    fn emit_cell_node(&self, ui: &Ui, base: egui::Id, rect: Rect, vr: usize, src: usize, col: usize, selected: bool) {
        let colname = self.data.names.get(col).cloned().unwrap_or_default();
        let value = self.data.cell_string(src, col);
        let label = format!("{colname} row {vr}: {value}");
        let numeric = value.trim().parse::<f64>().ok();
        let resp = ui.interact(rect, base.with(("cell", src, col)), egui::Sense::hover());
        resp.widget_info(move || {
            let mut sem = facett_core::Semantics::new(egui::WidgetType::Other, label.clone()).selected(selected);
            if let Some(v) = numeric {
                sem = sem.value(v);
            }
            sem.widget_info()
        });
    }

    /// On-screen rect of the lead cell `(visual_row, col)`, if currently visible.
    /// Pure geometry from the same column layout the painter uses.
    #[allow(clippy::too_many_arguments)]
    fn lead_cell_rect(
        &self,
        full: Rect,
        header_h: f32,
        vis_cols: &[usize],
        x_starts: &[f32],
        scroll_x: f32,
        vr: usize,
        col: usize,
    ) -> Option<Rect> {
        let row_h = self.row_h();
        let vc = vis_cols.iter().position(|&c| c == col)?;
        let pinned_vis = self.pinned.min(vis_cols.len());
        let w = self.cols[col].width;
        let cx = if vc < pinned_vis { full.left() + x_starts[vc] } else { full.left() + x_starts[vc] - scroll_x };
        let body_top = full.top() + header_h;
        let cy = body_top + vr as f32 * row_h; // approximate (un-scrolled) — good enough as an addressable anchor
        Some(Rect::from_min_size(pos2(cx.max(full.left()), cy), vec2(w, row_h)))
    }

    #[allow(clippy::too_many_arguments)]
    fn draw_cell(
        &self,
        painter: &egui::Painter,
        th: &facett_core::Theme,
        font: &FontId,
        cell: Rect,
        vr: usize,
        src: usize,
        col: usize,
        pointer: Option<egui::Pos2>,
        hover_cell: &mut Option<(usize, usize)>,
    ) -> bool {
        let kind = self.data.kinds.get(col).copied().unwrap_or(CellKind::Text);
        let cs = &self.cols[col];
        let pad = 4.0;
        let mut hovered = false;
        if let Some(p) = pointer
            && cell.contains(p) {
                *hover_cell = Some((vr, col));
                hovered = true;
            }

        // Conditional formatting background.
        if let (Some(cond), Some(&(lo, hi))) = (cs.cond, self.ranges.get(&col))
            && let Some(x) = self.data.columns[col].numeric(src) {
                let t = condfmt::normalize(x, lo, hi);
                match cond {
                    CondFmt::ColorScale => {
                        painter.rect_filled(cell, 0.0, condfmt::heat_color(th, t));
                    }
                    CondFmt::DataBar => {
                        let bw = (cell.width() - 2.0 * pad) * t;
                        let bar = Rect::from_min_size(cell.min + vec2(pad, pad), vec2(bw, cell.height() - 2.0 * pad));
                        painter.rect_filled(bar, 2.0, condfmt::bar_color(th));
                    }
                }
            }

        // Selection highlight (drawn above cond-fmt, under text).
        if self.state.selection.contains(vr, col) {
            painter.rect_filled(cell, 0.0, th.accent.linear_multiply(0.22));
            painter.rect_stroke(cell, 0.0, Stroke::new(1.0_f32, th.accent.linear_multiply(0.5)), StrokeKind::Inside);
        }
        // Lead cursor.
        if self.state.selection.lead == Some((vr, col)) {
            painter.rect_stroke(cell, 0.0, Stroke::new(1.5_f32, th.accent), StrokeKind::Inside);
        }

        // Cross-highlight on the hovered column.
        if hovered {
            painter.rect_filled(cell, 0.0, th.accent.linear_multiply(0.05));
        }

        // The cell content by kind.
        if self.data.columns[col].is_null(src) {
            painter.text(cell.left_center() + vec2(pad, 0.0), Align2::LEFT_CENTER, "—", font.clone(), th.text_dim);
            return hovered;
        }

        match kind {
            _ if cs.progress => {
                let frac = self.progress_fraction(src, col);
                draw_progress(painter, th, cell.shrink2(vec2(pad, 4.0)), frac, font);
            }
            _ if cs.sparkline => {
                self.draw_sparkline(painter, th, cell.shrink2(vec2(pad, 3.0)), vr, col);
            }
            CellKind::Bool => {
                let b = self.data.columns[col].numeric(src) == Some(1.0);
                let (glyph, color) = if b { ("✔", th.point) } else { ("–", th.text_dim) };
                painter.text(cell.center(), Align2::CENTER_CENTER, glyph, font.clone(), color);
            }
            CellKind::Enum => {
                draw_chip(painter, th, cell, &self.data.cell_string(src, col), font, pad);
            }
            CellKind::Date => {
                painter.text(cell.left_center() + vec2(pad, 0.0), Align2::LEFT_CENTER, self.data.cell_string(src, col), font.clone(), th.text_dim);
            }
            _ => {
                let s = self.data.cell_string(src, col);
                let s = elide(&s, cell.width() - 2.0 * pad);
                if kind.align_right() {
                    painter.text(cell.right_center() - vec2(pad, 0.0), Align2::RIGHT_CENTER, s, font.clone(), th.text);
                } else {
                    painter.text(cell.left_center() + vec2(pad, 0.0), Align2::LEFT_CENTER, s, font.clone(), th.text);
                }
            }
        }
        hovered
    }

    fn progress_fraction(&self, src: usize, col: usize) -> f32 {
        let Some(x) = self.data.columns[col].numeric(src) else { return 0.0 };
        // Treat 0..1 directly; otherwise normalise over the column range.
        if (0.0..=1.0).contains(&x) {
            x as f32
        } else if let Some(&(lo, hi)) = self.ranges.get(&col) {
            condfmt::normalize(x, lo.min(0.0), hi)
        } else {
            (x as f32 / 100.0).clamp(0.0, 1.0)
        }
    }

    fn draw_sparkline(&self, painter: &egui::Painter, th: &facett_core::Theme, rect: Rect, vr: usize, col: usize) {
        // Window the column's numeric values around this visual row.
        let n = self.order.len();
        let span = 12usize;
        let lo = vr.saturating_sub(span / 2);
        let hi = (lo + span).min(n);
        let vals: Vec<f32> = (lo..hi).filter_map(|i| self.data.columns[col].numeric(self.order[i]).map(|x| x as f32)).collect();
        if vals.len() < 2 {
            return;
        }
        let (mn, mx) = vals.iter().fold((f32::MAX, f32::MIN), |(a, b), &v| (a.min(v), b.max(v)));
        let range = (mx - mn).max(1e-6);
        let pts: Vec<egui::Pos2> = vals
            .iter()
            .enumerate()
            .map(|(i, &v)| {
                let x = rect.left() + rect.width() * (i as f32 / (vals.len() - 1) as f32);
                let y = rect.bottom() - rect.height() * ((v - mn) / range);
                pos2(x, y)
            })
            .collect();
        for w in pts.windows(2) {
            painter.line_segment([w[0], w[1]], Stroke::new(1.2_f32, th.accent));
        }
    }

    #[allow(clippy::too_many_arguments)]
    fn draw_header(
        &self,
        painter: &egui::Painter,
        th: &facett_core::Theme,
        font: &FontId,
        full: Rect,
        header_h: f32,
        vis_cols: &[usize],
        x_starts: &[f32],
        scroll_x: f32,
        _total_w: f32,
    ) {
        let pinned_vis = self.pinned.min(vis_cols.len());
        let pinned_w = x_starts[pinned_vis];
        let draw_one = |vc: usize, cx: f32| {
            let col = vis_cols[vc];
            let w = self.cols[col].width;
            let rect = Rect::from_min_size(pos2(cx, full.top()), vec2(w, header_h));
            painter.line_segment([rect.right_top(), rect.right_bottom()], Stroke::new(1.0_f32, th.panel_stroke.linear_multiply(0.5)));
            let mut label = self.data.names.get(col).cloned().unwrap_or_default();
            if let Some((rank, dir)) = self.state.sort.rank_of(col) {
                label = format!("{label} {}{}", dir.arrow(), if self.state.sort.keys.len() > 1 { rank.to_string() } else { String::new() });
            }
            painter.text(rect.left_center() + vec2(6.0, 0.0), Align2::LEFT_CENTER, elide(&label, w - 12.0), font.clone(), th.text);
        };
        // scrolling headers first (clipped under the pinned inset), then pinned over them.
        let clip = Rect::from_min_size(pos2(full.left() + pinned_w, full.top()), vec2(full.width() - pinned_w, header_h));
        let hp = painter.with_clip_rect(clip);
        for vc in pinned_vis..vis_cols.len() {
            let cx = full.left() + x_starts[vc] - scroll_x;
            let col = vis_cols[vc];
            let w = self.cols[col].width;
            if cx + w < clip.left() || cx > full.right() {
                continue;
            }
            let rect = Rect::from_min_size(pos2(cx, full.top()), vec2(w, header_h));
            hp.line_segment([rect.right_top(), rect.right_bottom()], Stroke::new(1.0_f32, th.panel_stroke.linear_multiply(0.5)));
            let mut label = self.data.names.get(col).cloned().unwrap_or_default();
            if let Some((rank, dir)) = self.state.sort.rank_of(col) {
                label = format!("{label} {}{}", dir.arrow(), if self.state.sort.keys.len() > 1 { rank.to_string() } else { String::new() });
            }
            hp.text(rect.left_center() + vec2(6.0, 0.0), Align2::LEFT_CENTER, elide(&label, w - 12.0), font.clone(), th.text);
        }
        for (vc, &xs) in x_starts.iter().enumerate().take(pinned_vis) {
            draw_one(vc, full.left() + xs);
        }
        // header baseline
        painter.line_segment([pos2(full.left(), full.top() + header_h), pos2(full.right(), full.top() + header_h)], Stroke::new(1.2_f32, th.panel_stroke));
    }

    #[allow(clippy::too_many_arguments)]
    fn draw_total(&self, painter: &egui::Painter, th: &facett_core::Theme, font: &FontId, cx: f32, fy: f32, w: f32, h: f32, col: usize, first: bool) {
        let rect = Rect::from_min_size(pos2(cx, fy), vec2(w, h));
        if first {
            painter.text(rect.left_center() + vec2(6.0, 0.0), Align2::LEFT_CENTER, "Σ", font.clone(), th.accent);
        }
        if let Some(sum) = self.column_sum(col) {
            let s = data::group_float(sum);
            painter.text(rect.right_center() - vec2(6.0, 0.0), Align2::RIGHT_CENTER, s, font.clone(), th.accent);
        }
    }

    /// Map a pointer position over the body to a (visual row, column).
    #[allow(clippy::too_many_arguments)]
    fn pointer_to_cell(
        &self,
        p: egui::Pos2,
        x_starts: &[f32],
        vis_cols: &[usize],
        origin: egui::Pos2,
        row_h: f32,
        scroll_x: f32,
        pinned_vis: usize,
    ) -> Option<(usize, usize)> {
        if row_h <= 0.0 {
            return None;
        }
        let vr = ((p.y - origin.y) / row_h).floor();
        if vr < 0.0 || vr as usize >= self.order.len() {
            return None;
        }
        let vr = vr as usize;
        let local_x = p.x - origin.x;
        let pinned_w = x_starts[pinned_vis];
        // Pinned columns are painted at origin.x + scroll_x + x_starts[pc], so in
        // screen space they occupy [origin.x+scroll_x, origin.x+scroll_x+pinned_w).
        let pin_screen_lo = scroll_x;
        let pin_screen_hi = scroll_x + pinned_w;
        if pinned_vis > 0 && local_x >= pin_screen_lo && local_x < pin_screen_hi {
            let content_x = local_x - scroll_x;
            for vc in 0..pinned_vis {
                if content_x >= x_starts[vc] && content_x < x_starts[vc + 1] {
                    return Some((vr, vc));
                }
            }
        }
        // Scrolling columns are painted at origin.x + x_starts[vc].
        for vc in pinned_vis..vis_cols.len() {
            if local_x >= x_starts[vc] && local_x < x_starts[vc + 1] {
                return Some((vr, vc));
            }
        }
        None
    }

    /// Read pointer interaction over the body and **emit** the selection/edit `Msg`s
    /// it produces (FC-9 pure — no `self` mutation). Rows are emitted as stable
    /// source-row ids (FC-5).
    #[allow(clippy::too_many_arguments)]
    fn handle_body_input(
        &self,
        ui: &mut Ui,
        x_starts: &[f32],
        vis_cols: &[usize],
        origin: egui::Pos2,
        row_h: f32,
        scroll_x: f32,
        pinned_vis: usize,
        msgs: &mut Vec<Msg>,
    ) {
        let resp = ui.interact(ui.min_rect(), ui.id().with("grid_body"), Sense::click_and_drag());
        let (cmd, shift) = ui.input(|i| (i.modifiers.command, i.modifiers.shift));

        // Double-click triggers edit mode.
        if self.editable && resp.double_clicked() {
            if let Some(p) = resp.interact_pointer_pos()
                && let Some((vr, vc)) = self.pointer_to_cell(p, x_starts, vis_cols, origin, row_h, scroll_x, pinned_vis)
            {
                let col = vis_cols[vc];
                let row = self.order[vr];
                msgs.push(Msg::SelectCell { row, col });
                msgs.push(Msg::BeginEdit);
            }
            return;
        }

        if let Some(p) = resp.interact_pointer_pos()
            && let Some((vr, vc)) = self.pointer_to_cell(p, x_starts, vis_cols, origin, row_h, scroll_x, pinned_vis) {
                let col = vis_cols[vc];
                let row = self.order[vr];
                if resp.drag_started() || (resp.clicked() && shift) {
                    if shift {
                        msgs.push(Msg::ExtendTo { row, col });
                    } else if cmd {
                        msgs.push(Msg::AddCell { row, col });
                    } else {
                        msgs.push(Msg::SelectCell { row, col });
                    }
                } else if resp.clicked() {
                    if cmd {
                        msgs.push(Msg::AddCell { row, col });
                    } else {
                        msgs.push(Msg::SelectCell { row, col });
                    }
                } else if resp.dragged() {
                    msgs.push(Msg::ExtendTo { row, col });
                }
            }
    }

    /// Resolve keyboard chords through the shared KeyMap and **emit** the resulting
    /// nav/select/edit `Msg`s (FC-9 pure). Targets are emitted as stable source-row
    /// ids (FC-5).
    fn handle_keys(&self, ui: &mut Ui, vis_cols: &[usize], msgs: &mut Vec<Msg>) {
        let nrows = self.order.len();
        let ncols = self.data.ncols();
        if nrows == 0 || ncols == 0 {
            return;
        }

        // If the editor is active, all key handling is done in the overlay.
        if self.state.edit.active {
            return;
        }

        // Resolve every navigation chord through the shared KeyMap (COH-2 / GRID-2):
        // the grid never inspects raw `Key`/`Modifiers` — it consumes Actions.
        use facett_core::look::{Action, keymap};
        let km = keymap(ui);
        let act = |a: Action, ui: &mut Ui| km.consume(a, ui);

        if act(Action::SelectAll, ui) {
            msgs.push(Msg::SelectAll);
            return;
        }
        if self.editable && act(Action::Confirm, ui) {
            msgs.push(Msg::BeginEdit);
            return;
        }

        let Some((lr, lc)) = self.state.selection.lead else { return };
        let cur_vc = vis_cols.iter().position(|&c| c == lc).unwrap_or(0);
        let page = 20i64;
        let mut nr = lr as i64;
        let mut nvc = cur_vc as i64;
        // Movement actions. Shift-extend variants set `extend`.
        let mut extend = false;
        if act(Action::Up, ui) {
            nr -= 1;
        }
        if act(Action::Down, ui) {
            nr += 1;
        }
        if act(Action::ExtendUp, ui) {
            nr -= 1;
            extend = true;
        }
        if act(Action::ExtendDown, ui) {
            nr += 1;
            extend = true;
        }
        if act(Action::PageUp, ui) {
            nr -= page;
        }
        if act(Action::PageDown, ui) {
            nr += page;
        }
        if act(Action::Left, ui) {
            nvc -= 1;
        }
        if act(Action::Right, ui) {
            nvc += 1;
        }
        if act(Action::ExtendLeft, ui) {
            nvc -= 1;
            extend = true;
        }
        if act(Action::ExtendRight, ui) {
            nvc += 1;
            extend = true;
        }
        if act(Action::LineStart, ui) {
            nvc = 0;
        }
        if act(Action::LineEnd, ui) {
            nvc = vis_cols.len() as i64 - 1;
        }
        if act(Action::DocStart, ui) {
            nr = 0;
        }
        if act(Action::DocEnd, ui) {
            nr = nrows as i64 - 1;
        }
        if nr == lr as i64 && nvc == cur_vc as i64 {
            return;
        }
        let nr = nr.clamp(0, nrows as i64 - 1) as usize;
        let nvc = nvc.clamp(0, vis_cols.len() as i64 - 1) as usize;
        let ncol = vis_cols[nvc];
        let row = self.order[nr];
        if extend {
            msgs.push(Msg::ExtendTo { row, col: ncol });
        } else {
            msgs.push(Msg::SelectCell { row, col: ncol });
        }
        ui.ctx().request_repaint();
    }
}

// ── small paint helpers ──────────────────────────────────────────────────────

fn draw_progress(painter: &egui::Painter, th: &facett_core::Theme, rect: Rect, frac: f32, font: &FontId) {
    let frac = frac.clamp(0.0, 1.0);
    painter.rect_filled(rect, 3.0, th.node_fill);
    let fill = Rect::from_min_size(rect.min, vec2(rect.width() * frac, rect.height()));
    painter.rect_filled(fill, 3.0, condfmt::bar_color(th));
    painter.rect_stroke(rect, 3.0, Stroke::new(0.8_f32, th.panel_stroke.linear_multiply(0.6)), StrokeKind::Inside);
    let pct = format!("{:.0}%", frac * 100.0);
    painter.text(rect.center(), Align2::CENTER_CENTER, pct, font.clone(), th.text);
}

fn draw_chip(painter: &egui::Painter, th: &facett_core::Theme, cell: Rect, text: &str, font: &FontId, pad: f32) {
    let color = facett_core::hash_color(text);
    let galley = painter.layout_no_wrap(text.to_string(), font.clone(), th.text);
    let cw = (galley.size().x + 12.0).min(cell.width() - 2.0 * pad);
    let ch = (galley.size().y + 4.0).min(cell.height() - 4.0);
    let rect = Rect::from_min_size(cell.left_center() + vec2(pad, -ch / 2.0), vec2(cw, ch));
    let fill = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 46);
    painter.rect_filled(rect, 5.0, fill);
    painter.rect_stroke(rect, 5.0, Stroke::new(1.0_f32, color), StrokeKind::Inside);
    painter.galley(rect.left_top() + vec2(6.0, 2.0), galley, th.text);
}

/// Elide a string to fit `max_w` pixels (rough char-width estimate).
fn elide(s: &str, max_w: f32) -> String {
    let char_w = 7.0;
    let max = (max_w / char_w).floor().max(1.0) as usize;
    if s.chars().count() <= max {
        s.to_string()
    } else if max <= 1 {
        "…".to_string()
    } else {
        let head: String = s.chars().take(max - 1).collect();
        format!("{head}…")
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────

impl facett_core::Elm for Grid {
    type Model = GridState;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &GridState {
        &self.state
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        Grid::update(self, msg)
    }
    fn view(&self, ui: &mut Ui) -> Vec<Msg> {
        Grid::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for Grid` from the `Elm` impl: `title`, the
// FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides
// below. **Form 3** (`custom_state_json`) because the grid publishes a RICHER
// `state_json` than a plain `serde(state())`: the grid dims + per-column names/kinds
// (`rows`, `cols`, `columns`, `kinds`, `visible_rows`) that hosts read are *derived*
// from the Arrow `GridData`, not the interaction Model — so the default `state_json`
// is suppressed and this one supplied, preserving the exact pre-migration keys.
facett_core::impl_facet_via_elm!(Grid, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        // Note: state is a read-only snapshot; the order/visible-range are computed
        // from the cached `order` (so call after a ui/update pass for post-sort truth).
        let order_len = if self.order_dirty { self.data.nrows() } else { self.order.len() };
        let sort: Vec<serde_json::Value> = self
            .state
            .sort
            .keys
            .iter()
            .map(|k| serde_json::json!({ "col": self.data.names.get(k.col), "dir": match k.dir { Dir::Asc => "asc", Dir::Desc => "desc" } }))
            .collect();
        let (rows, cols) = self.state.selection.cells();
        let editing = if self.state.edit.active {
            serde_json::json!({
                "row": self.state.edit.row,
                "col": self.state.edit.col,
                "value": self.state.edit.buffer,
                "valid": self.state.edit.valid,
            })
        } else {
            serde_json::json!({
                "row": serde_json::Value::Null,
                "col": serde_json::Value::Null,
                "value": "",
                "valid": true,
            })
        };
        serde_json::json!({
            "title": self.title,
            "rows": self.data.nrows(),
            "cols": self.data.ncols(),
            "visible_rows": order_len,
            "columns": self.data.names,
            "kinds": self.data.kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>(),
            "sort": sort,
            "density": self.state.density.as_str(),
            "pinned": self.pinned,
            "totals": self.state.show_totals,
            "scale": self.state.scale,
            "selection": {
                "ranges": self.state.selection.ranges.len(),
                "rows": rows.iter().copied().collect::<Vec<_>>(),
                "cols": cols.iter().copied().collect::<Vec<_>>(),
                "lead": self.state.selection.lead.map(|(r, c)| serde_json::json!([r, c])),
                // FC-5: the stable source-row-id identity of the active cell.
                "active": self.state.active.map(|(r, c)| serde_json::json!([r, c])),
            },
            "filter": self.state.filter,
            "filter_pending": self.filter_pending(),
            "editing": editing,
            // Observable committed-edit surface (drained via `take_changes`): the
            // count still pending + the most recent committed change (or null).
            "changes": self.state.changes.len(),
            "last_change": self.state.changes.last().map(|c| c.to_json()).unwrap_or(serde_json::Value::Null),
        })
    }

    fn caps(&self) -> FacetCaps {
        let base = FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable().themeable();
        if self.editable { base.editable().pasteable() } else { base }
    }

    fn scale(&self) -> f32 {
        self.state.scale
    }
    fn set_scale(&mut self, scale: f32) {
        let _ = self.update(Msg::SetScale(scale));
    }

    fn selection_json(&self) -> serde_json::Value {
        let (rows, cols) = self.state.selection.cells();
        serde_json::json!({
            "rows": rows.iter().copied().collect::<Vec<_>>(),
            "cols": cols.iter().copied().collect::<Vec<_>>(),
        })
    }

    /// Copy the current selection as TSV (the spreadsheet bounding rectangle).
    /// Falls back to the full table when nothing is selected.
    fn copy(&mut self) -> Option<String> {
        self.ensure_order();
        self.finish_filter();
        if self.state.selection.is_empty() {
            if self.data.is_empty() {
                return None;
            }
            // whole visible table
            let mut sel = Selection::default();
            sel.select_all(self.order.len(), self.data.ncols());
            return sel.to_tsv(&self.data, &self.order);
        }
        self.state.selection.to_tsv(&self.data, &self.order)
    }

    /// Accept pasted TSV/CSV text into the selection region (editable grids only).
    fn paste(&mut self, text: &str) -> bool {
        if !self.editable {
            return false;
        }
        self.paste_tsv(text)
    }
});

// ── typed cross-facet copy/paste (§16, CLIP-6 / GRID-4) ─────────────────────────

impl CopySource for Grid {
    fn copy_kinds(&self) -> &[ClipKind] {
        // Richest first: typed columns, then the TSV rows mirror, then plain text.
        &[ClipKind::DataColumns, ClipKind::Rows, ClipKind::Text]
    }

    fn copy_payload(&self) -> Option<ClipPayload> {
        // Prefer zero-copy typed columns; fall back to the TSV rectangle.
        if let Some(cols) = self.copy_columns() {
            return Some(ClipPayload::DataColumns(cols));
        }
        // No source batch → TSV of the selection (or whole table).
        let order = &self.order;
        let tsv = if self.state.selection.is_empty() {
            let mut sel = Selection::default();
            sel.select_all(order.len(), self.data.ncols());
            sel.to_tsv(&self.data, order)
        } else {
            self.state.selection.to_tsv(&self.data, order)
        };
        tsv.map(ClipPayload::Rows)
    }
}

impl PasteTarget for Grid {
    fn accepts(&self, k: ClipKind) -> bool {
        // The grid ingests tabular text/TSV when editable; typed columns map to
        // TSV via the universal fallback. Non-editable grids accept nothing.
        self.editable && matches!(k, ClipKind::Text | ClipKind::Rows | ClipKind::DataColumns)
    }

    fn paste_payload(&mut self, p: &ClipPayload) {
        if !self.editable {
            return;
        }
        // Ingest the text/TSV view into the active cell region.
        let text = p.as_text();
        self.paste_tsv(&text);
    }
}

impl Grid {
    /// Ingest a TSV/CSV blob at the selection lead, row by row, cell by cell,
    /// committing each as a typed edit (validated by column kind). Used by
    /// [`PasteTarget`], the Facet `paste`, and [`Msg::PasteTsv`].
    fn paste_tsv(&mut self, text: &str) -> bool {
        self.ensure_order();
        let Some((lead_vr, lead_col)) = self.state.selection.lead.or(Some((0, 0))) else { return false };
        let mut changed = BTreeSet::new();
        for (dr, line) in text.lines().enumerate() {
            let vr = lead_vr + dr;
            if vr >= self.order.len() {
                break;
            }
            for (dc, cell) in line.split('\t').enumerate() {
                let col = lead_col + dc;
                if col >= self.data.ncols() {
                    break;
                }
                let src = self.order[vr];
                let kind = self.data.kinds.get(col).copied().unwrap_or(CellKind::Text);
                if validate_cell_input(cell.trim(), kind, &self.data.columns[col]) {
                    let old = self.data.cell_string(src, col);
                    apply_edit(&mut self.data, src, col, cell.trim());
                    self.state.changes.push(GridChange {
                        row: vr,
                        source_row: Some(src),
                        col,
                        old_value_str: old,
                        new_value_str: cell.trim().to_string(),
                    });
                    changed.insert(col);
                }
            }
        }
        if changed.is_empty() {
            return false;
        }
        self.data_changed(&changed.into_iter().collect::<Vec<_>>());
        true
    }
}

#[cfg(test)]
mod tests;