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

nordisk/facett

facett-table/src/lib.rs

//! **facett-table** — a generic **data-table** viewer: named columns + string
//! rows in a striped grid, with cell truncation, a virtualised row scroll, click
//! **row-selection**, and clickable-header **column sort**. Generalised from
//! nornir's warehouse table ("oslo") — but source-agnostic (any `Vec<String>`
//! rows). A [`Facet`](facett_core::Facet); the consumer formats its cells to strings.
//!
//! ## The canonical Elm split (FC-2 / FC-3 / FC-8 / FC-9)
//! Every interactive input is a [`Msg`] and the **only** mutation path for the
//! control state is [`Table::update`]; [`Table::view`] is a **pure** function of
//! `&self` that paints and *returns* the [`Msg`]s it produced. Row **selection**
//! (keyed on a **stable row id**, FC-5, so a re-sort never silently moves it) and
//! the column **sort** route through the same [`Msg`]s a headless driver
//! ([`facett_core::harness`]) feeds, so live == headless. The
//! [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge (form 3, so the
//! `rows`-count / `selected`-indices `state_json` a deck / render-matrix reads is
//! preserved verbatim) writes the [`Facet`](facett_core::Facet) impl.
//!
//! **Row DATA is host-owned.** The `columns` + `rows` are plain public fields (a
//! host feeds them via [`push_row`](Table::push_row) / builders, and sibling crates
//! read them directly) — they are not driven by [`Msg`]s. The Elm [`Model`](facett_core::Elm::Model)
//! is the interactive *control* surface (selection + sort + scale + the row
//! identities the selection keys on), the part a [`Msg`] actually mutates.

use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::effects::FadeTrack;
use facett_core::{Elm, FacetCaps, Semantics, clipboard};
use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::collections::BTreeSet;

/// Hover / selection cross-fade length (seconds) for a row highlight.
const ROW_FADE_SECS: f32 = 0.12;

/// The **warehouse browser** Facet — a multi-table picker + grid / auto bar-chart,
/// generalised from nornir's `src/viz/warehouse_tab.rs` (`WarehouseBrowser`) with
/// the Iceberg/gRPC source abstracted away. See [`warehouse::WarehouseTableView`].
pub mod warehouse;
pub use warehouse::{PreviewTable, WarehouseTableView};

const CELL_MAX: usize = 48;

/// A stable, domain-level identity for a row (FC-5). When present, selection is
/// keyed on this string instead of the visual `row.index()`, so an insert/delete
/// **or a re-sort** that shifts visual positions does **not** silently move the
/// selection.
pub type RowId = String;

/// A column **sort** (FC-2): which column, and the direction. Numeric-aware — a
/// column whose cells parse as `f64` sorts numerically, otherwise lexically.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Sort {
    /// The column index the rows are ordered by.
    pub col: usize,
    /// `true` = ascending, `false` = descending.
    pub ascending: bool,
}

/// **The interactive control state (FC-1 / FC-3)** of a [`Table`], in one
/// serializable, round-trippable struct — the part a [`Msg`] mutates. Row/column
/// *data* is host-owned (the public `columns`/`rows` fields on [`Table`]); this
/// captures how the table is *arranged*: the row **selection** (keyed on a stable
/// [`RowId`], FC-5), the optional per-row identities that key it, the column
/// **sort**, and the uniform **scale**. [`Table::state`] hands back a `&TableControls`;
/// a headless driver ([`facett_core::harness`]) snapshots it after feeding a `Vec<Msg>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TableControls {
    /// Selected rows, keyed on their **stable identity** (FC-5) — the [`RowId`]
    /// when set, else the visual index as a string. Empty = nothing selected, in
    /// which case `copy()` falls back to copying every row.
    pub selected: BTreeSet<RowId>,
    /// Optional stable per-row identities (FC-5). When non-empty (and aligned to
    /// `rows`), a row's a11y label + selection key derive from this rather than the
    /// visual index. Empty = fall back to the visual index (back-compatible).
    pub row_ids: Vec<RowId>,
    /// The active column sort, or `None` for insertion order.
    pub sort: Option<Sort>,
    /// Uniform scale (drives row height + font); 1.0 = native.
    pub scale: f32,
}

impl Default for TableControls {
    fn default() -> Self {
        Self { selected: BTreeSet::new(), row_ids: Vec::new(), sort: None, scale: 1.0 }
    }
}

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or the in-view header / row clicks) drives the table through.
/// [`Table::update`] applies it; it is the **only** way to mutate a [`TableControls`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Toggle a row's selection, keyed on its **stable identity** (FC-5). The
    /// row-click resolves the visual row to its [`RowId`] before emitting this, so
    /// a re-sort/insert keeps the same row selected.
    ToggleRow(RowId),
    /// Clear the row selection.
    ClearSelection,
    /// Sort by a column. Repeated on the same column cycles ascending → descending
    /// → off (insertion order). The view renders in this order; the underlying
    /// `rows` (and `copy`) stay in host/data order.
    SortBy(usize),
    /// Clear the sort (back to insertion order).
    ClearSort,
    /// Set the uniform scale (row height + font); clamped to `0.25..=4.0`.
    SetScale(f32),
}

/// Side work as data (FC-8). Uninhabited on purpose: the table renders host-fed
/// rows and performs **no** I/O — every [`Msg`] mutates only the in-memory
/// [`TableControls`] — so [`Table::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// A data table: header `columns` + `rows` (each a row of cells).
pub struct Table {
    /// Tab/panel title (the deck keys facets off it). Host-owned.
    pub title: String,
    /// Column headers. Host-owned data (sibling crates read it directly).
    pub columns: Vec<String>,
    /// The rows (each a row of stringified cells). Host-owned data (sibling crates
    /// build + read it directly — e.g. `facett-arrow` / `facett-icebergview`).
    pub rows: Vec<Vec<String>>,
    /// The interactive control state — the serializable Elm [`Model`](facett_core::Elm::Model).
    controls: TableControls,
    /// Per-row hover + selection fades (injected-clock, FC-7). **Ephemeral animation
    /// cache, not observable state** — interior-mutable so the FC-9 pure
    /// [`view`](Self::view) can advance it through a `&self` borrow; kept OUT of the
    /// Model (mirrors `facett-matrix`).
    hover_fades: RefCell<FadeTrack>,
    sel_fades: RefCell<FadeTrack>,
}

impl Table {
    pub fn new(title: impl Into<String>, columns: Vec<String>) -> Self {
        Self {
            title: title.into(),
            columns,
            rows: Vec::new(),
            controls: TableControls::default(),
            hover_fades: RefCell::new(FadeTrack::default()),
            sel_fades: RefCell::new(FadeTrack::default()),
        }
    }
    pub fn push_row(&mut self, row: Vec<String>) {
        self.rows.push(row);
    }

    /// Attach **stable row identities** (FC-5), one per row, in row order. When
    /// set, the a11y node label + the selection key derive from the [`RowId`]
    /// instead of the visual position, so re-ordering / inserting / deleting /
    /// **sorting** rows keeps each row's identity stable. Back-compatible: leave
    /// unset to key on the index.
    pub fn with_row_ids(mut self, ids: Vec<RowId>) -> Self {
        self.controls.row_ids = ids;
        self
    }

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

    /// The stable identity of row `i`: its [`RowId`] if one is set and aligned,
    /// otherwise the visual index as a string. This is what the row's selection +
    /// AccessKit node are keyed on (FC-5).
    fn row_identity(&self, i: usize) -> String {
        self.controls.row_ids.get(i).cloned().unwrap_or_else(|| i.to_string())
    }

    /// The unambiguous a11y label for row `i`. Prefixed with `"row: "` so the row
    /// node never collides with a cell's text (egui_extras already emits a node
    /// per cell; an unprefixed label would make `get_by_label` ambiguous when a
    /// cell's text equals the row identity).
    fn row_label(&self, i: usize) -> String {
        format!("row: {}", self.row_identity(i))
    }

    /// **FC-2 / FC-8** — the single mutation path for the control state. Apply one
    /// [`Msg`]; returns the (always empty) [`Effect`]s the host should run. The
    /// in-view header / row clicks route their [`Msg`]s here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::ToggleRow(id) => {
                if !self.controls.selected.insert(id.clone()) {
                    self.controls.selected.remove(&id);
                }
            }
            Msg::ClearSelection => self.controls.selected.clear(),
            Msg::SortBy(col) => {
                self.controls.sort = match self.controls.sort {
                    // asc → desc → off, on repeat clicks of the same column.
                    Some(Sort { col: c, ascending: true }) if c == col => {
                        Some(Sort { col, ascending: false })
                    }
                    Some(Sort { col: c, ascending: false }) if c == col => None,
                    _ => Some(Sort { col, ascending: true }),
                };
            }
            Msg::ClearSort => self.controls.sort = None,
            Msg::SetScale(s) => self.controls.scale = s.clamp(0.25, 4.0),
        }
        Vec::new()
    }

    /// Toggle a row's selection (headless-test + click handler entry point). Keyed
    /// on the row's **stable identity** (FC-5). No-op if `i` is out of range.
    pub fn select_row(&mut self, i: usize) {
        if i < self.rows.len() {
            let _ = self.update(Msg::ToggleRow(self.row_identity(i)));
        }
    }
    /// Clear the row selection.
    pub fn clear_selection(&mut self) {
        let _ = self.update(Msg::ClearSelection);
    }
    /// The currently-selected row **indices**, in ascending order — resolved from
    /// the stored stable identities against the current rows (FC-5). Identities not
    /// present in the current rows are dropped.
    pub fn selected_rows(&self) -> Vec<usize> {
        let mut out: Vec<usize> = (0..self.rows.len())
            .filter(|&i| self.controls.selected.contains(&self.row_identity(i)))
            .collect();
        out.sort_unstable();
        out
    }

    /// The active sort, if any.
    pub fn sort(&self) -> Option<Sort> {
        self.controls.sort
    }

    /// The row indices in **display order** — the sort applied (numeric-aware), or
    /// insertion order when unsorted. Selection + copy stay keyed on identity/data
    /// order, so this only affects what the view paints.
    fn display_order(&self) -> Vec<usize> {
        let mut idx: Vec<usize> = (0..self.rows.len()).collect();
        if let Some(Sort { col, ascending }) = self.controls.sort {
            // Stable sort so equal keys keep insertion order.
            idx.sort_by(|&a, &b| {
                let ca = self.rows[a].get(col).map(String::as_str).unwrap_or("");
                let cb = self.rows[b].get(col).map(String::as_str).unwrap_or("");
                let ord = match (ca.trim().parse::<f64>(), cb.trim().parse::<f64>()) {
                    (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
                    _ => ca.cmp(cb),
                };
                if ascending { ord } else { ord.reverse() }
            });
        }
        idx
    }

    /// The rows that `copy()` would emit: the selection, or all rows if none — in
    /// host/data order (independent of the display sort).
    fn copy_indices(&self) -> Vec<usize> {
        if self.controls.selected.is_empty() {
            (0..self.rows.len()).collect()
        } else {
            self.selected_rows()
        }
    }
}

fn truncate(s: &str) -> String {
    if s.chars().count() <= CELL_MAX {
        s.to_string()
    } else {
        let head: String = s.chars().take(CELL_MAX - 1).collect();
        format!("{head}…")
    }
}

impl Table {
    /// TSV of the selected rows (or all rows when none selected), header first —
    /// shared by [`Facet::copy`](facett_core::Facet::copy) and [`CopySource`].
    /// `None` for an empty table.
    pub fn copy_tsv(&self) -> Option<String> {
        if self.rows.is_empty() {
            return None;
        }
        let idx = self.copy_indices();
        let rows = idx.into_iter().map(|i| self.rows[i].clone());
        Some(clipboard::rows_to_tsv(&self.columns, rows))
    }
}

// ── typed copy (§16) — the selection (or whole table) as a TSV rectangle ───────
impl CopySource for Table {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Rows, ClipKind::Text]
    }
    fn copy_payload(&self) -> Option<ClipPayload> {
        self.copy_tsv().map(ClipPayload::Rows)
    }
}

impl Table {
    /// **FC-9 pure view** — paints the header (clickable, sort-aware column titles),
    /// the virtualised striped grid (rows in display/sort order), and the
    /// injected-clock hover/selection highlight, and **returns** the [`Msg`]s this
    /// frame's interactions produced. It does **not** mutate the [`TableControls`];
    /// the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies
    /// the returned messages through [`update`](Self::update), so a headless driver
    /// feeding the same [`Msg`]s reproduces the live behaviour exactly. The only
    /// `&self`-interior mutation is the ephemeral hover/selection fade cache (FC-7),
    /// which is deliberately outside the observable Model.
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        use egui_extras::{Column, TableBuilder};
        let mut msgs: Vec<Msg> = Vec::new();
        let s = self.controls.scale;
        // A UNIFORM scale: the column widths, header and row heights below all multiply
        // by `s`, but the TEXT did not follow — zooming the table only spread the same
        // tiny glyphs further apart. Every sibling facet with this knob (facett-card,
        // -security, -console, -filedialog, -git, -grid) scales its type; this one was
        // the outlier. Found by `scale_is_applied_to_the_drawn_table_not_just_stored`,
        // which measures glyph ink area and read a flat 1.00x across a 2x scale.
        if (s - 1.0).abs() > f32::EPSILON {
            ui.style_mut().text_styles.iter_mut().for_each(|(_, f)| f.size *= s);
        }
        let dt = ui.input(|i| i.stable_dt);
        let th = facett_core::theme(ui);
        ui.label(format!(
            "{} rows × {} cols · {} selected",
            self.rows.len(),
            self.columns.len(),
            self.controls.selected.len()
        ));
        let ncols = self.columns.len().max(1);
        let order = self.display_order();
        // Collect each visible row's rect + hover/selection so we can paint an
        // injected-clock (FC-7) hover-fill + selection-glow over the rows after the
        // (virtualised) table lays out. `(key, rect, hovered, selected)`.
        let mut row_hi: Vec<(u64, egui::Rect, bool, bool)> = Vec::new();
        let mut tb = TableBuilder::new(ui).striped(true).sense(egui::Sense::click());
        for _ in 0..ncols {
            tb = tb.column(Column::auto().at_least(60.0 * s).resizable(true));
        }
        let mut toggle: Option<String> = None;
        let mut sort_click: Option<usize> = None;
        tb.header(20.0 * s, |mut header| {
            for (ci, c) in self.columns.iter().enumerate() {
                header.col(|ui| {
                    // Clickable, sort-aware header title: a frameless button so a
                    // click emits `SortBy(ci)` (FC-2) with a ▲/▼ direction marker.
                    let marker = match self.controls.sort {
                        Some(Sort { col, ascending: true }) if col == ci => " ▲",
                        Some(Sort { col, ascending: false }) if col == ci => " ▼",
                        _ => "",
                    };
                    let btn = egui::Button::new(egui::RichText::new(format!("{c}{marker}")).strong())
                        .frame(false);
                    if ui.add(btn).on_hover_text("click to sort by this column").clicked() {
                        sort_click = Some(ci);
                    }
                });
            }
        })
        // Virtualised: only the visible rows are built, so a million-row Arrow
        // batch scrolls at 60 fps (render time flat in row count).
        .body(|body| {
            body.rows(18.0 * s, self.rows.len(), |mut row| {
                let vi = row.index();
                // Map the visual position to the underlying data row via the sort.
                let di = order.get(vi).copied().unwrap_or(vi);
                let identity = self.row_identity(di);
                let is_selected = self.controls.selected.contains(&identity);
                row.set_selected(is_selected);
                for cell in &self.rows[di] {
                    row.col(|ui| {
                        ui.label(truncate(cell));
                    });
                }
                let resp = row.response();
                // FC-4 + FC-5: attach a labelled, `selected`-bearing AccessKit node
                // to the ROW's response (the union of its cells), keyed on the row's
                // STABLE identity (RowId when set, else the index) and prefixed
                // (`row: …`) so it never collides with a cell's own text node.
                let sem = Semantics::list_item(self.row_label(di), is_selected);
                resp.widget_info(|| sem.widget_info());
                // Note the row for the post-table highlight pass (stable key on the
                // row's identity, FC-5, so the fade follows a row across re-sorts).
                row_hi.push((FadeTrack::key(self.row_label(di)), resp.rect, resp.hovered(), is_selected));
                if resp.clicked() {
                    toggle = Some(identity);
                }
            });
        });

        // Whole-row highlight pass: advance the hover/selection fades from this
        // frame's set, then paint a faded accent fill + selection-glow outline over
        // each row. Interior-mutable (FC-7), ephemeral, outside the Model.
        {
            let mut hover_fades = self.hover_fades.borrow_mut();
            let mut sel_fades = self.sel_fades.borrow_mut();
            hover_fades.begin();
            sel_fades.begin();
            for (key, _, hovered, selected) in &row_hi {
                if *hovered {
                    hover_fades.lit(*key);
                }
                if *selected {
                    sel_fades.lit(*key);
                }
            }
            let a = hover_fades.advance(dt, ROW_FADE_SECS);
            let b = sel_fades.advance(dt, ROW_FADE_SECS);
            let painter = ui.painter();
            for (key, rect, _, _) in &row_hi {
                let hf = hover_fades.factor(*key);
                let sf = sel_fades.factor(*key);
                if hf > 0.001 {
                    painter.rect_filled(*rect, 2.0, th.accent.linear_multiply((0.10 * hf).min(0.14)));
                }
                if sf > 0.01 {
                    painter.rect_stroke(
                        *rect,
                        2.0,
                        egui::Stroke::new(1.0_f32, th.accent.linear_multiply(0.5 * sf)),
                        egui::StrokeKind::Inside,
                    );
                }
            }
            if a || b {
                ui.ctx().request_repaint();
            }
        }

        // FC-9: emit the interactions as `Msg`s instead of mutating `self`.
        if let Some(col) = sort_click {
            msgs.push(Msg::SortBy(col));
        }
        if let Some(id) = toggle {
            msgs.push(Msg::ToggleRow(id));
        }

        // ── render-lane emit: this view path RAN ──────────────────────────────
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-table::Table::view",
            "ui_render",
            // OK = the view drew the geometry it declares: every selected identity is
            // a real row, and a non-empty table has columns to render into.
            self.selected_rows().iter().all(|&i| i < self.rows.len())
                && (self.rows.is_empty() || !self.columns.is_empty()),
            &format!(
                "rows={} cols={} selected={} sorted={}",
                self.rows.len(),
                self.columns.len(),
                self.controls.selected.len(),
                self.controls.sort.is_some()
            ),
        );

        msgs
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
//
// The Model/Msg/Effect/pure-view split lives on the inherent methods; the `Elm`
// impl delegates to them verbatim.
impl Elm for Table {
    type Model = TableControls;
    type Msg = Msg;
    type Effect = Effect;

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

// The bridge macro writes `impl Facet for Table` 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 table publishes a RICHER
// `state_json` than a plain `serde(state())`: it reports `rows` as a COUNT (not the
// full cell matrix) and `selected` as visual INDICES (resolved from the stable
// identities) — the exact shape a deck / render-matrix reads — so the macro's
// default serde `state_json` is suppressed and this one supplied.
facett_core::impl_facet_via_elm!(Table, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "columns": self.columns,
            "rows": self.rows.len(),
            "selected": self.selected_rows(),
            "scale": self.controls.scale,
            "sort": self.controls.sort.map(|s| serde_json::json!({ "col": s.col, "ascending": s.ascending })),
        })
    }

    fn caps(&self) -> FacetCaps {
        // egui_extras' `TableBuilder` + `ui.label/strong` are standard widgets, so
        // they follow the active `Theme`'s `Visuals` (set by `set_theme`).
        FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable().themeable()
    }

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

    fn selection_json(&self) -> serde_json::Value {
        serde_json::json!(self.selected_rows())
    }

    /// TSV: header row + selected rows (or all rows when none selected),
    /// `\t`-joined cells, `\n` between rows. `None` only for an empty table.
    fn copy(&mut self) -> Option<String> {
        self.copy_tsv()
    }

    fn kind(&self) -> &'static str {
        "table"
    }

    /// The portable arrangement: the **column order** (the header sequence), the
    /// **row selection** (as indices), and the uniform **scale**. (The row *data*
    /// belongs to each table's own source; this clones how the table is
    /// arranged/selected so a sibling over the same shape mirrors it.)
    fn portable_state(&self) -> Option<serde_json::Value> {
        Some(serde_json::json!({
            "columns": self.columns,
            "selected": self.selected_rows(),
            "scale": self.controls.scale,
        }))
    }

    fn load_state(&mut self, state: &serde_json::Value) -> bool {
        let obj = match state.as_object() {
            Some(o) => o,
            None => return false,
        };
        if let Some(cols) = obj.get("columns").and_then(|v| v.as_array()) {
            self.columns = cols.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
        }
        if let Some(sel) = obj.get("selected").and_then(|v| v.as_array()) {
            // Only adopt indices that exist in THIS table's rows (a sibling may be
            // shorter); each is resolved to that row's STABLE identity (FC-5), so the
            // selection is clamped, never out of range.
            self.controls.selected = sel
                .iter()
                .filter_map(|v| v.as_u64().map(|n| n as usize))
                .filter(|&i| i < self.rows.len())
                .map(|i| self.row_identity(i))
                .collect();
        }
        if let Some(s) = obj.get("scale").and_then(|v| v.as_f64()) {
            let _ = self.update(Msg::SetScale(s as f32));
        }
        true
    }
});

#[cfg(test)]
mod tests {
    use super::*;
    use facett_core::{Facet, harness};

    #[test]
    fn truncate_caps_long_cells() {
        assert_eq!(truncate("short"), "short");
        let long = "x".repeat(100);
        let t = truncate(&long);
        assert_eq!(t.chars().count(), CELL_MAX);
        assert!(t.ends_with('…'));
    }

    #[test]
    fn state_json_reports_shape() {
        let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
        t.push_row(vec!["knut".into(), "0.1".into()]);
        t.push_row(vec!["korp".into(), "0.1".into()]);
        let j = Facet::state_json(&t);
        // FC contract: `rows` is a COUNT (u64), `columns` an array — the shape a
        // deck / render-matrix reads.
        assert_eq!(j["rows"], 2);
        assert_eq!(j["columns"].as_array().unwrap().len(), 2);
    }

    fn repos() -> Table {
        let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
        t.push_row(vec!["knut".into(), "0.1".into()]);
        t.push_row(vec!["korp".into(), "0.2".into()]);
        t
    }

    #[test]
    fn caps_declares_table_surface() {
        let c = repos().caps();
        assert!(c.selectable && c.copyable && c.searchable && c.scalable && c.resizable);
        assert!(!c.pasteable && !c.cuttable);
    }

    #[test]
    fn portable_state_round_trips_columns_selection_scale_between_instances() {
        // A: reordered columns (version first), a row selected, scaled up.
        let mut a = Table::new("repos", vec!["version".into(), "name".into()]);
        a.push_row(vec!["0.1".into(), "knut".into()]);
        a.push_row(vec!["0.2".into(), "korp".into()]);
        a.select_row(1);
        a.set_scale(1.5);

        let env = clipboard::encode_component(a.kind(), &a.portable_state().unwrap());
        let (kind, state) = clipboard::decode_component(&env).unwrap();
        assert_eq!(kind, "table");

        // B starts default-arranged with the same number of rows.
        let mut b = repos(); // 2 rows, columns name/version, no selection, scale 1.0
        assert_ne!(b.portable_state(), a.portable_state(), "differ before paste");
        assert!(b.load_state(&state));
        assert_eq!(b.portable_state(), a.portable_state(), "B mirrors A's column order/selection/scale");
        assert_eq!(b.columns, vec!["version".to_string(), "name".to_string()]);
        assert_eq!(b.selected_rows(), vec![1]);
        assert_eq!(b.scale(), 1.5);
    }

    #[test]
    fn paste_component_clamps_a_selection_out_of_range() {
        // A selects row 5; B has only 2 rows → the stray index is dropped, no panic.
        let a_state = serde_json::json!({ "columns": ["name", "version"], "selected": [5], "scale": 1.0 });
        let mut b = repos();
        assert!(b.load_state(&a_state));
        assert!(b.selected_rows().is_empty(), "out-of-range selection is clamped away");
    }

    #[test]
    fn typed_copy_is_the_selection_as_rows() {
        use facett_core::clip::{ClipKind, CopySource};
        let mut t = repos();
        t.select_row(1);
        let p = t.copy_payload().expect("selection copies");
        assert_eq!(p.kind(), ClipKind::Rows);
        assert_eq!(p.as_text(), "name\tversion\nkorp\t0.2");
        // Empty table offers nothing.
        assert!(Table::new("e", vec!["a".into()]).copy_payload().is_none());
    }

    #[test]
    fn copy_all_rows_when_nothing_selected() {
        let mut t = repos();
        let tsv = t.copy().expect("non-empty table copies");
        assert_eq!(tsv, "name\tversion\nknut\t0.1\nkorp\t0.2");
    }

    #[test]
    fn copy_only_selected_rows() {
        let mut t = repos();
        t.select_row(1);
        let tsv = t.copy().expect("selection copies");
        assert_eq!(tsv, "name\tversion\nkorp\t0.2");
        assert_eq!(t.selection_json(), serde_json::json!([1]));
    }

    #[test]
    fn row_identity_falls_back_to_index_without_row_ids() {
        let t = repos();
        // No row_ids set → identity is the visual index; label is prefixed.
        assert_eq!(t.row_identity(0), "0");
        assert_eq!(t.row_identity(1), "1");
        assert_eq!(t.row_label(0), "row: 0");
    }

    #[test]
    fn with_row_ids_keys_identity_on_stable_id() {
        let t = repos().with_row_ids(vec!["pkg-knut".into(), "pkg-korp".into()]);
        // Identity now derives from the stable RowId, not the visual position.
        assert_eq!(t.row_identity(0), "pkg-knut");
        assert_eq!(t.row_identity(1), "pkg-korp");
        assert_eq!(t.row_label(1), "row: pkg-korp");
        // Selection/copy semantics are unchanged & back-compatible.
        let mut t = t;
        t.select_row(0);
        assert_eq!(t.selected_rows(), vec![0]);
    }

    #[test]
    fn select_row_toggles() {
        let mut t = repos();
        t.select_row(0);
        assert_eq!(t.selected_rows(), vec![0]);
        t.select_row(0);
        assert!(t.selected_rows().is_empty());
    }

    #[test]
    fn set_scale_clamps() {
        let mut t = repos();
        t.set_scale(99.0);
        assert_eq!(t.scale(), 4.0);
        t.set_scale(0.001);
        assert_eq!(t.scale(), 0.25);
        assert_eq!(Facet::state_json(&t)["scale"], 0.25);
    }

    /// **RED-when-broken: the uniform scale knob reaches the PIXELS.**
    ///
    /// The clamp test above is a state round-trip. Measured: deleting the scale
    /// application from the painter outright left this whole crate's suite green, and
    /// `RenderReport::drew()` cannot see it either (a glyph is four vertices at 6pt and
    /// at 60pt). This asserts APPLIED geometry: the area of the glyph triangles the
    /// painter really emitted, which must grow roughly 4x when the scale doubles.
    #[test]
    fn scale_is_applied_to_the_drawn_table_not_just_stored() {
        use facett_core::harness;
        let ink = |s: f32| {
            let mut v = repos();
            <_ as facett_core::Facet>::set_scale(&mut v, s);
            harness::headless_render(&mut v).textured_ink_area
        };
        let (small, big) = (ink(1.0), ink(2.0));
        assert!(small > 0.0, "the facet draws text at 1.0x ({small})");
        assert!(
            big > small * 2.0,
            "doubling the scale must roughly QUADRUPLE the glyph ink, got {small:.0} -> \
             {big:.0} pt^2 ({:.2}x). A ratio near 1.0 means the knob is stored, clamped \
             and serialised but never applied to anything drawn.",
            big / small,
        );
    }

    #[test]
    fn cut_falls_back_to_copy_for_read_only_table() {
        let mut t = repos();
        // Table is not cuttable; cut() defaults to copy() (no removal).
        let cut = t.cut().expect("cut delegates to copy");
        assert_eq!(cut, "name\tversion\nknut\t0.1\nkorp\t0.2");
        assert_eq!(t.rows.len(), 2, "cut must not remove rows on a read-only viewer");
    }

    #[test]
    fn paste_is_rejected() {
        let mut t = repos();
        assert!(!t.paste("anything"), "read-only table does not consume paste");
    }

    #[test]
    fn empty_table_copies_nothing() {
        let mut t = Table::new("empty", vec!["a".into()]);
        assert_eq!(t.copy(), None);
    }

    // ── FC-5: stable-id selection survives a re-sort ─────────────────────────────
    //
    // The whole point of keying selection on a stable RowId (FC-5): re-sorting the
    // table reshuffles the *visual* positions, but the SELECTED row follows its
    // identity — its `state_json`/`selected_rows()` index tracks the row's new data
    // position, never a stale visual slot.

    #[test]
    fn selection_keys_on_stable_id_not_volatile_index() {
        // Numeric column so a sort actually reorders. Rows given stable ids.
        let mut t = Table::new("bench", vec!["name".into(), "ms".into()])
            .with_row_ids(vec!["a".into(), "b".into(), "c".into()]);
        t.push_row(vec!["a".into(), "30".into()]);
        t.push_row(vec!["b".into(), "10".into()]);
        t.push_row(vec!["c".into(), "20".into()]);
        // Select row "b" (data index 1) by its stable identity.
        let _ = t.update(Msg::ToggleRow("b".into()));
        assert_eq!(t.selected_rows(), vec![1], "b is at data index 1");
        // Sorting by `ms` ascending → display order becomes b(10), c(20), a(30);
        // but selection is keyed on the identity "b", so the *data* index is stable.
        let _ = t.update(Msg::SortBy(1));
        assert_eq!(t.sort(), Some(Sort { col: 1, ascending: true }));
        assert_eq!(t.selected_rows(), vec![1], "selection still points at row b (data index 1), not a shifted slot");
        // The stored identity is the stable id, never the visual position.
        assert!(t.state().selected.contains("b"));
    }

    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
    //    harness and assert the resulting `TableControls` — no egui, no GPU. ───────

    #[test]
    fn harness_select_toggle_transitions() {
        let mut t = repos();
        // ToggleRow selects; a second ToggleRow on the same id clears it.
        let snap = harness::snapshot(&mut t, [Msg::ToggleRow("0".into())]);
        assert!(snap.selected.contains("0"));
        assert_eq!(t.selected_rows(), vec![0]);
        let snap = harness::snapshot(&mut t, [Msg::ToggleRow("0".into())]);
        assert!(snap.selected.is_empty(), "re-toggling clears the selection");
        // ClearSelection wipes a multi-selection.
        harness::drive(&mut t, [Msg::ToggleRow("0".into()), Msg::ToggleRow("1".into())]);
        assert_eq!(t.selected_rows(), vec![0, 1]);
        let snap = harness::snapshot(&mut t, [Msg::ClearSelection]);
        assert!(snap.selected.is_empty());
    }

    #[test]
    fn harness_sort_cycles_asc_desc_off() {
        let mut t = repos();
        assert_eq!(t.state().sort, None);
        // asc → desc → off on repeat clicks of the same column.
        let snap = harness::snapshot(&mut t, [Msg::SortBy(0)]);
        assert_eq!(snap.sort, Some(Sort { col: 0, ascending: true }));
        let snap = harness::snapshot(&mut t, [Msg::SortBy(0)]);
        assert_eq!(snap.sort, Some(Sort { col: 0, ascending: false }));
        let snap = harness::snapshot(&mut t, [Msg::SortBy(0)]);
        assert_eq!(snap.sort, None, "third click on the same column clears the sort");
        // Sorting a different column starts fresh ascending; ClearSort resets.
        let snap = harness::snapshot(&mut t, [Msg::SortBy(1)]);
        assert_eq!(snap.sort, Some(Sort { col: 1, ascending: true }));
        let snap = harness::snapshot(&mut t, [Msg::ClearSort]);
        assert_eq!(snap.sort, None);
    }

    #[test]
    fn display_order_is_numeric_aware() {
        let mut t = Table::new("bench", vec!["name".into(), "ms".into()]);
        t.push_row(vec!["a".into(), "30".into()]);
        t.push_row(vec!["b".into(), "10".into()]);
        t.push_row(vec!["c".into(), "20".into()]);
        // Unsorted = insertion order.
        assert_eq!(t.display_order(), vec![0, 1, 2]);
        // Numeric ascending on `ms`: 10, 20, 30 → data indices 1, 2, 0.
        let _ = t.update(Msg::SortBy(1));
        assert_eq!(t.display_order(), vec![1, 2, 0]);
        // Descending: 30, 20, 10 → 0, 2, 1.
        let _ = t.update(Msg::SortBy(1));
        assert_eq!(t.display_order(), vec![0, 2, 1]);
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        let mut t = repos();
        // FC-8: the table does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut t, [Msg::ToggleRow("1".into()), Msg::SortBy(0), Msg::SetScale(1.25)]);
        assert!(effects.is_empty(), "FC-8: table emits no Effects");
        // FC-3: the observable control Model round-trips through serde.
        let json = serde_json::to_value(t.state()).unwrap();
        assert_eq!(json["scale"], 1.25);
        let back: TableControls = serde_json::from_value(json).unwrap();
        assert_eq!(&back, t.state(), "serde(state) -> state round-trips");
    }

    #[test]
    fn headless_render_reports_state() {
        let mut t = repos();
        t.select_row(1);
        let r = harness::headless_render(&mut t);
        assert_eq!(r.title, "repos");
        assert_eq!(r.state["rows"], 2);
        assert_eq!(r.state["selected"], serde_json::json!([1]));
        assert!(r.drew(), "a 2-row table tessellates to vertices");
    }
}