facett-grid — design
Class:
design· Function axis:f0(the whole) +f1ingestion & the columnar data model ·f2virtualization & rendering ·f3multi-column sort ·f4selection & clipboard ·f5conditional formatting & type-aware cells ·f6the Elm/Facet contract & inline editing. Strict: this file states only the shape as built — every claim points at a realfile:lineinfacett-grid/src/. The workflow isgrid-guide.md; the zero-knowledge walkthrough isgrid-for-idiots.md.
f0 — the whole
facett-grid is the flagship facett component: an Arrow-backed data grid, virtualized on both axes, implementing the facett_core::Facet contract (facett-grid/src/lib.rs:1-10). It is composed of four parts that wire together through one struct, Grid (lib.rs:309-338):
GridData(data.rs:180-187) — the columnar input: typedColumns materialised once from an ArrowRecordBatch(arrow.rs:25-34). Never held as Model state.GridState(lib.rs:211-233) — the complete observable, serializable interaction state (FC-1/FC-3): sort spec, selection, the FC-5 stable-id active cell, density/scale/pin/totals/filter, and the inline-edit buffer + committed-edit queue.ColState(lib.rs:183-197, one per column) — per-column display state: width, visibility, an optionalCondFmt, and theprogress/sparklinerender flags.- Derived caches — the visual→source row permutation
order(rebuilt byrebuild_order,lib.rs:638-654), and the ephemeralhover/scroll_x/scroll_yCells (lib.rs:329-337, FC-7: outside the Model on purpose).
Grid implements facett_core::Elm (lib.rs:1660-1677): type Model = GridState, type Msg = Msg, type Effect = Effect (uninhabited, lib.rs:303 — the grid does no I/O). facett_core::impl_facet_via_elm!(Grid, custom_state_json, { … }) (lib.rs:1686) writes the Facet impl in form 3, because the published state_json (dims, column names/kinds, sort, selection, editing) is derived from GridData, not a plain serde(state()).
facett-table (facett-table/src/lib.rs:1-22) is the source-agnostic striped-rows/string-cells viewer used for smaller or generic tabular data (and is what facett-icebergview's row grid is built from, see dataview-design.md); facett-grid is the Arrow-typed, million-row-scale grid described here.
f1 — ingestion & the columnar data model
arrow::grid_data_from_batch (arrow.rs:25) walks a RecordBatch's schema once and maps each Arrow column to a Column via column_from_array (arrow.rs:49): signed/unsigned ints → Column::Int, Float32/64 → Column::Float, Utf8/LargeUtf8 → Column::Text, Boolean → Column::Bool, Date32/Date64 and all four Timestamp(unit, _) variants → Column::Date (ISO-8601 text, via the integer civil-date math in date_from_days, arrow.rs:155, and datetime_from_us, arrow.rs:142 — no chrono dependency). Anything else falls back to blank Text (arrow.rs:132-135); ingestion never fails.
GridData::new (data.rs:193) asserts equal column lengths and computes each column's CellKind via detect_kind (data.rs:254): a Text column is promoted to CellKind::Enum when a ≤2048-row sample finds ≤12 distinct non-null values that are fewer than the sampled row count (data.rs:266-278) — that's what renders as a chip. CellKind::align_right (data.rs:30-33) right-aligns Int/Float; the display string for a cell goes through GridData::cell_string (data.rs:221-232), which groups thousands via group_int/group_float (data.rs:127-175); cell_raw (data.rs:235-250) is the ungrouped form used for search/filter and inline editing. After ingestion, every hot path (sort compare, paint, totals) touches plain Rust vectors inside Column — no per-cell downcast_ref (data.rs:1-6).
Grid::from_record_batch (lib.rs:377-381) and ::from_record_batch_arc (lib.rs:384-388) additionally retain the source batch as Option<Arc<RecordBatch>> (lib.rs:317) so a column copy (f4) can hand out zero-copy Arc-shared arrays instead of re-materialising.
f2 — virtualization & rendering
Rows are uniform height, so the visible window is pure arithmetic: viewport::row_window (viewport.rs:33-43) computes first = floor(scroll_y / row_h) and count = ceil(viewport_h / row_h) + 1, plus an overscan band, clamped to total, and returns a RowWindow { first, count } (viewport.rs:16-19) — O(visible), independent of the total row count (asserted by tests/perf.rs). Columns are few but variable-width: column_x_starts (viewport.rs:47-56) prefix-sums the widths, and column_window (viewport.rs:62-88) finds the first/last visible scrolling column in O(cols), treating the leading pinned columns as a fixed-width inset.
Grid::view (lib.rs:867) is the FC-9 pure paint: it computes row_h from Density::base_row_h() * scale (lib.rs:158-164, :492-494), sizes an empty virtual canvas to total_rows × row_h by total_w inside an egui::ScrollArea::both() (lib.rs:893-911), reads the viewport's scroll offset, calls viewport::row_window(scroll_y, body_h, row_h, total_rows, 2) (lib.rs:915), and paints only that slice. Pinned leading columns are drawn at a fixed x offset by horizontal scroll (lib.rs:942-956); scrolling columns are drawn within col_range (lib.rs:957-966). The header and optional Σ totals footer are positioned outside the scroll area so they stay sticky (lib.rs:871-872, :904-906). Zebra striping and a hover row band are painted per visible row (lib.rs:932-940); hover/scroll_x/scroll_y (lib.rs:329-337) are the only &self-interior mutation view performs (FC-7), driven by an injected clock (ui.input(|i| i.stable_dt), lib.rs:891).
f3 — multi-column sort
SortSpec (sort.rs:37-40) is an ordered Vec<SortKey> (sort.rs:30-34, each a column + Dir::Asc/Desc). SortSpec::click (sort.rs:55-69): a plain click replaces the spec with that column (flipping direction if it was already the sole key); a shift-click (additive) appends/toggles it as a secondary key. sorted_order (sort.rs:99-120) builds the row-index permutation: it sorts 0..nrows by cmp_cell (sort.rs:80-94, nulls always last regardless of direction) across the spec's keys in order, with source index as the final tiebreak — so it is stable and deterministic. The data itself is never moved.
Grid caches this permutation in order (lib.rs:319-321); Msg::ClickHeader/ ClearSort/SetFilter mark order_dirty and call rebuild_order (lib.rs:638-654), which sorts first, then applies the free-text filter (case-insensitive substring over every cell's cell_raw, lib.rs:642-647) as a retain over the sorted order. resync_selection (lib.rs:621-629) runs after every reorder to re-point the FC-5 stable-id active cell at its new visual row (or clear the visual selection if it was filtered out).
f4 — selection & clipboard
Selection (selection.rs:47-53) holds a Vec<CellRange> (selection.rs:14-19, inclusive rectangles in visual post-sort coordinates) plus an anchor (shift-extend pivot) and lead (keyboard caret). click/extend_to/add (selection.rs:66-93) implement plain-click replace, shift-extend, and ctrl-add disjoint ranges; select_row/select_col/select_all (selection.rs:96-122) select whole bands. Selection::to_tsv (selection.rs:170-195) serialises the bounding rectangle of every selected cell: a header row of the involved column names, then tab-joined cell rows, mapping each visual row back through the order permutation to its source row.
Grid's Msgs that address a row (SelectCell/AddCell/ExtendTo, lib.rs:264-270) carry the stable source-row id (FC-5), resolved to the current visual row via visual_of (lib.rs:612-614) inside update (lib.rs:725-742); state.active records the stable (row_id, col) pair separately from the visual selection (lib.rs:216-220). The Facet::copy override (lib.rs:1765-1777) returns Selection::to_tsv, falling back to the whole visible table when nothing is selected. copy_columns (lib.rs:661-683) builds a zero-copy Vec<ArrowColumnRef> from the retained batch: Option<Arc<RecordBatch>> by Arc-cloning the selected columns; CopySource (lib.rs:1790-1815) prefers this typed path (ClipKind::DataColumns) and falls back to the TSV rectangle (ClipKind::Rows) when the grid wasn't built from a batch. PasteTarget/paste_tsv (lib.rs:1817-1868) ingest a TSV/CSV blob at the selection lead, cell by cell, only on editable grids (f6).
f5 — conditional formatting & type-aware cells
CondFmt (condfmt.rs:12-17) is DataBar or ColorScale, attached per-column via Grid::with_cond_fmt (lib.rs:407-413) into ColState.cond; with_progress (lib.rs:414-420) and with_sparkline (lib.rs:421-427) set the matching ColState flags. Both formatting styles normalise a cell's Column::numeric value against the column's cached numeric_range (data.rs:107-123, computed once at Grid::new, lib.rs:344-349) via condfmt::normalize (condfmt.rs:20-25, degenerate range → 0.5, clamped to 0..1). heat_color (condfmt.rs:36-40) lerps between the theme's point and glow colours at low alpha; bar_color (condfmt.rs:43-45) tints from the theme's accent. lerp_color (condfmt.rs:28-32) does the per-channel sRGB interpolation.
Cell alignment and glyph choice follow CellKind (data.rs:14-27): Int/Float right-align with thousands grouping; Enum renders as a tinted chip; Bool as a check/dash glyph; Date dim-coloured. Column::numeric (data.rs:95-105) treats Bool as 0/1 so a boolean column can still drive a heat scale.
f6 — the Elm/Facet contract & inline editing
Msg (lib.rs:259-297) is the complete input vocabulary — header clicks, cell selection (row/add/extend/select-all/clear), the free-text filter, density, totals, scale, the inline-edit lifecycle (BeginEdit/SetEditBuffer/CommitEdit/ CancelEdit), and PasteTsv. Grid::update (lib.rs:711-786) is the single mutation path; Effect is uninhabited (lib.rs:303) — every Msg only mutates in-memory GridState/GridData.
Inline editing is opt-in via Grid::with_editable (lib.rs:437-440), which sets caps().editable().pasteable() (lib.rs:1743-1746). EditState (lib.rs:80-93) holds the live buffer; begin_edit (lib.rs:528-551) loads cell_raw into the buffer on Enter/double-click (refusing CellKind::Bool cells); commit_edit (lib.rs:556-590) validates via validate_input (lib.rs:791-799, type-checked per CellKind) and, on success, calls apply_edit (lib.rs:802-843) to write the typed value into GridData in place and pushes a GridChange (lib.rs:106-116) onto GridState::changes; on failure it sets edit.valid = false without clearing the buffer. cancel_edit (lib.rs:593-595) discards the buffer with no change emitted. Grid::take_changes (lib.rs:469-471) drains the committed-edit queue for a host driving the grid via plain Facet::ui.
state_json (lib.rs:1687-1741) publishes rows/cols/visible_rows/ columns/kinds/sort/density/pinned/totals/scale/selection/filter/ editing/changes/last_change — every value a headless driver (facett_core::harness) needs to assert what the grid is showing after a Msg script.