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;