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

nordisk/facett

.nornir/grid-design.md

facett-grid — design

Class: design · Function axis: f0 (the whole) + f1 ingestion & the columnar data model · f2 virtualization & rendering · f3 multi-column sort · f4 selection & clipboard · f5 conditional formatting & type-aware cells · f6 the Elm/Facet contract & inline editing. Strict: this file states only the shape as built — every claim points at a real file:line in facett-grid/src/. The workflow is grid-guide.md; the zero-knowledge walkthrough is grid-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):

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.