facett-table 0.1.1

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, clipboard};
use std::collections::BTreeSet;

const CELL_MAX: usize = 48;

/// 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>,
    /// 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(), scale: 1.0 }
    }
    pub fn push_row(&mut self, row: Vec<String>) {
        self.rows.push(row);
    }

    /// 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();
                row.set_selected(self.selected.contains(&i));
                for cell in &self.rows[i] {
                    row.col(|ui| {
                        ui.label(truncate(cell));
                    });
                }
                if row.response().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 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);
    }
}