facett-table 0.1.6

facett — generic data-table viewer (columns + string rows, striped grid)
Documentation
//! **facett-table** — a generic **data-table** viewer: named columns + string
//! rows in a striped grid, with cell truncation + a row scroll. Generalised from
//! nornir's warehouse table ("oslo") — but source-agnostic (any `Vec<String>`
//! rows). A [`Facet`]; the consumer formats its cells to strings.

use facett_core::{Facet, FacetCaps, Semantics, clipboard};
use std::collections::BTreeSet;

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
/// that shifts visual positions does **not** silently move the selection.
pub type RowId = String;

/// A data table: header `columns` + `rows` (each a row of cells).
pub struct Table {
    pub title: String,
    pub columns: Vec<String>,
    pub rows: Vec<Vec<String>>,
    /// Selected row indices (click to toggle). Empty = nothing selected, in
    /// which case `copy()` falls back to copying every row.
    selected: BTreeSet<usize>,
    /// Optional stable per-row identities (FC-5). When non-empty (and aligned to
    /// `rows`), the row's a11y label + identity is keyed on this rather than the
    /// visual index. Empty = fall back to the visual index (back-compatible).
    row_ids: Vec<RowId>,
    /// Uniform scale (drives row height + font); 1.0 = native.
    scale: f32,
}

impl Table {
    pub fn new(title: impl Into<String>, columns: Vec<String>) -> Self {
        Self {
            title: title.into(),
            columns,
            rows: Vec::new(),
            selected: BTreeSet::new(),
            row_ids: Vec::new(),
            scale: 1.0,
        }
    }
    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 + identity key derive from the `RowId` instead of
    /// the visual position, so re-ordering / inserting / deleting 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.row_ids = ids;
        self
    }

    /// 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 AccessKit
    /// node is labelled + keyed on.
    fn row_identity(&self, i: usize) -> String {
        self.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))
    }

    /// Toggle a row's selection (headless-test + click handler entry point).
    pub fn select_row(&mut self, i: usize) {
        if i < self.rows.len() && !self.selected.insert(i) {
            self.selected.remove(&i);
        }
    }
    /// Clear the row selection.
    pub fn clear_selection(&mut self) {
        self.selected.clear();
    }
    /// The currently-selected row indices, in order.
    pub fn selected_rows(&self) -> Vec<usize> {
        self.selected.iter().copied().collect()
    }

    /// The rows that `copy()` would emit: the selection, or all rows if none.
    fn copy_indices(&self) -> Vec<usize> {
        if self.selected.is_empty() {
            (0..self.rows.len()).collect()
        } else {
            self.selected.iter().copied().filter(|&i| i < self.rows.len()).collect()
        }
    }
}

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 Facet for Table {
    fn title(&self) -> &str {
        &self.title
    }
    fn ui(&mut self, ui: &mut egui::Ui) {
        use egui_extras::{Column, TableBuilder};
        let s = self.scale;
        ui.label(format!("{} rows × {} cols · {} selected", self.rows.len(), self.columns.len(), self.selected.len()));
        let ncols = self.columns.len().max(1);
        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<usize> = None;
        tb.header(20.0 * s, |mut header| {
            for c in &self.columns {
                header.col(|ui| {
                    ui.strong(c);
                });
            }
        })
        // 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 i = row.index();
                let is_selected = self.selected.contains(&i);
                row.set_selected(is_selected);
                for cell in &self.rows[i] {
                    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). egui_extras
                // emits nodes for the *cells*; without this the selected ROW is not
                // queryable and its toggled state is invisible to a driver / screen
                // reader. The label carries the row's STABLE identity (RowId when
                // set, else the index — FC-5) and is prefixed (`row: …`) so it
                // never collides with a cell's own text node.
                let sem = Semantics::list_item(self.row_label(i), is_selected);
                resp.widget_info(|| sem.widget_info());
                if resp.clicked() {
                    toggle = Some(i);
                }
            });
        });
        if let Some(i) = toggle {
            self.select_row(i);
        }
    }
    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "columns": self.columns,
            "rows": self.rows.len(),
            "selected": self.selected_rows(),
            "scale": self.scale,
        })
    }

    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.scale
    }
    fn set_scale(&mut self, scale: f32) {
        self.scale = scale.clamp(0.25, 4.0);
    }

    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> {
        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))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[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 = t.state_json();
        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 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 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);
    }

    #[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!(t.state_json()["scale"], 0.25);
    }
}