Skip to main content

facett_table/
lib.rs

1//! **facett-table** — a generic **data-table** viewer: named columns + string
2//! rows in a striped grid, with cell truncation + a row scroll. Generalised from
3//! nornir's warehouse table ("oslo") — but source-agnostic (any `Vec<String>`
4//! rows). A [`Facet`]; the consumer formats its cells to strings.
5
6use facett_core::{Facet, FacetCaps, clipboard};
7use std::collections::BTreeSet;
8
9const CELL_MAX: usize = 48;
10
11/// A data table: header `columns` + `rows` (each a row of cells).
12pub struct Table {
13    pub title: String,
14    pub columns: Vec<String>,
15    pub rows: Vec<Vec<String>>,
16    /// Selected row indices (click to toggle). Empty = nothing selected, in
17    /// which case `copy()` falls back to copying every row.
18    selected: BTreeSet<usize>,
19    /// Uniform scale (drives row height + font); 1.0 = native.
20    scale: f32,
21}
22
23impl Table {
24    pub fn new(title: impl Into<String>, columns: Vec<String>) -> Self {
25        Self { title: title.into(), columns, rows: Vec::new(), selected: BTreeSet::new(), scale: 1.0 }
26    }
27    pub fn push_row(&mut self, row: Vec<String>) {
28        self.rows.push(row);
29    }
30
31    /// Toggle a row's selection (headless-test + click handler entry point).
32    pub fn select_row(&mut self, i: usize) {
33        if i < self.rows.len() && !self.selected.insert(i) {
34            self.selected.remove(&i);
35        }
36    }
37    /// Clear the row selection.
38    pub fn clear_selection(&mut self) {
39        self.selected.clear();
40    }
41    /// The currently-selected row indices, in order.
42    pub fn selected_rows(&self) -> Vec<usize> {
43        self.selected.iter().copied().collect()
44    }
45
46    /// The rows that `copy()` would emit: the selection, or all rows if none.
47    fn copy_indices(&self) -> Vec<usize> {
48        if self.selected.is_empty() {
49            (0..self.rows.len()).collect()
50        } else {
51            self.selected.iter().copied().filter(|&i| i < self.rows.len()).collect()
52        }
53    }
54}
55
56fn truncate(s: &str) -> String {
57    if s.chars().count() <= CELL_MAX {
58        s.to_string()
59    } else {
60        let head: String = s.chars().take(CELL_MAX - 1).collect();
61        format!("{head}…")
62    }
63}
64
65impl Facet for Table {
66    fn title(&self) -> &str {
67        &self.title
68    }
69    fn ui(&mut self, ui: &mut egui::Ui) {
70        use egui_extras::{Column, TableBuilder};
71        let s = self.scale;
72        ui.label(format!("{} rows × {} cols · {} selected", self.rows.len(), self.columns.len(), self.selected.len()));
73        let ncols = self.columns.len().max(1);
74        let mut tb = TableBuilder::new(ui).striped(true).sense(egui::Sense::click());
75        for _ in 0..ncols {
76            tb = tb.column(Column::auto().at_least(60.0 * s).resizable(true));
77        }
78        let mut toggle: Option<usize> = None;
79        tb.header(20.0 * s, |mut header| {
80            for c in &self.columns {
81                header.col(|ui| {
82                    ui.strong(c);
83                });
84            }
85        })
86        // Virtualised: only the visible rows are built, so a million-row Arrow
87        // batch scrolls at 60 fps (render time flat in row count).
88        .body(|body| {
89            body.rows(18.0 * s, self.rows.len(), |mut row| {
90                let i = row.index();
91                row.set_selected(self.selected.contains(&i));
92                for cell in &self.rows[i] {
93                    row.col(|ui| {
94                        ui.label(truncate(cell));
95                    });
96                }
97                if row.response().clicked() {
98                    toggle = Some(i);
99                }
100            });
101        });
102        if let Some(i) = toggle {
103            self.select_row(i);
104        }
105    }
106    fn state_json(&self) -> serde_json::Value {
107        serde_json::json!({
108            "columns": self.columns,
109            "rows": self.rows.len(),
110            "selected": self.selected_rows(),
111            "scale": self.scale,
112        })
113    }
114
115    fn caps(&self) -> FacetCaps {
116        // egui_extras' `TableBuilder` + `ui.label/strong` are standard widgets, so
117        // they follow the active `Theme`'s `Visuals` (set by `set_theme`).
118        FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable().themeable()
119    }
120
121    fn scale(&self) -> f32 {
122        self.scale
123    }
124    fn set_scale(&mut self, scale: f32) {
125        self.scale = scale.clamp(0.25, 4.0);
126    }
127
128    fn selection_json(&self) -> serde_json::Value {
129        serde_json::json!(self.selected_rows())
130    }
131
132    /// TSV: header row + selected rows (or all rows when none selected),
133    /// `\t`-joined cells, `\n` between rows. `None` only for an empty table.
134    fn copy(&mut self) -> Option<String> {
135        if self.rows.is_empty() {
136            return None;
137        }
138        let idx = self.copy_indices();
139        let rows = idx.into_iter().map(|i| self.rows[i].clone());
140        Some(clipboard::rows_to_tsv(&self.columns, rows))
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn truncate_caps_long_cells() {
150        assert_eq!(truncate("short"), "short");
151        let long = "x".repeat(100);
152        let t = truncate(&long);
153        assert_eq!(t.chars().count(), CELL_MAX);
154        assert!(t.ends_with('…'));
155    }
156
157    #[test]
158    fn state_json_reports_shape() {
159        let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
160        t.push_row(vec!["knut".into(), "0.1".into()]);
161        t.push_row(vec!["korp".into(), "0.1".into()]);
162        let j = t.state_json();
163        assert_eq!(j["rows"], 2);
164        assert_eq!(j["columns"].as_array().unwrap().len(), 2);
165    }
166
167    fn repos() -> Table {
168        let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
169        t.push_row(vec!["knut".into(), "0.1".into()]);
170        t.push_row(vec!["korp".into(), "0.2".into()]);
171        t
172    }
173
174    #[test]
175    fn caps_declares_table_surface() {
176        let c = repos().caps();
177        assert!(c.selectable && c.copyable && c.searchable && c.scalable && c.resizable);
178        assert!(!c.pasteable && !c.cuttable);
179    }
180
181    #[test]
182    fn copy_all_rows_when_nothing_selected() {
183        let mut t = repos();
184        let tsv = t.copy().expect("non-empty table copies");
185        assert_eq!(tsv, "name\tversion\nknut\t0.1\nkorp\t0.2");
186    }
187
188    #[test]
189    fn copy_only_selected_rows() {
190        let mut t = repos();
191        t.select_row(1);
192        let tsv = t.copy().expect("selection copies");
193        assert_eq!(tsv, "name\tversion\nkorp\t0.2");
194        assert_eq!(t.selection_json(), serde_json::json!([1]));
195    }
196
197    #[test]
198    fn select_row_toggles() {
199        let mut t = repos();
200        t.select_row(0);
201        assert_eq!(t.selected_rows(), vec![0]);
202        t.select_row(0);
203        assert!(t.selected_rows().is_empty());
204    }
205
206    #[test]
207    fn cut_falls_back_to_copy_for_read_only_table() {
208        let mut t = repos();
209        // Table is not cuttable; cut() defaults to copy() (no removal).
210        let cut = t.cut().expect("cut delegates to copy");
211        assert_eq!(cut, "name\tversion\nknut\t0.1\nkorp\t0.2");
212        assert_eq!(t.rows.len(), 2, "cut must not remove rows on a read-only viewer");
213    }
214
215    #[test]
216    fn paste_is_rejected() {
217        let mut t = repos();
218        assert!(!t.paste("anything"), "read-only table does not consume paste");
219    }
220
221    #[test]
222    fn empty_table_copies_nothing() {
223        let mut t = Table::new("empty", vec!["a".into()]);
224        assert_eq!(t.copy(), None);
225    }
226
227    #[test]
228    fn set_scale_clamps() {
229        let mut t = repos();
230        t.set_scale(99.0);
231        assert_eq!(t.scale(), 4.0);
232        t.set_scale(0.001);
233        assert_eq!(t.scale(), 0.25);
234        assert_eq!(t.state_json()["scale"], 0.25);
235    }
236}