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, Semantics, clipboard};
7use std::collections::BTreeSet;
8
9const CELL_MAX: usize = 48;
10
11/// A stable, domain-level identity for a row (FC-5). When present, selection is
12/// keyed on this string instead of the visual `row.index()`, so an insert/delete
13/// that shifts visual positions does **not** silently move the selection.
14pub type RowId = String;
15
16/// A data table: header `columns` + `rows` (each a row of cells).
17pub struct Table {
18    pub title: String,
19    pub columns: Vec<String>,
20    pub rows: Vec<Vec<String>>,
21    /// Selected row indices (click to toggle). Empty = nothing selected, in
22    /// which case `copy()` falls back to copying every row.
23    selected: BTreeSet<usize>,
24    /// Optional stable per-row identities (FC-5). When non-empty (and aligned to
25    /// `rows`), the row's a11y label + identity is keyed on this rather than the
26    /// visual index. Empty = fall back to the visual index (back-compatible).
27    row_ids: Vec<RowId>,
28    /// Uniform scale (drives row height + font); 1.0 = native.
29    scale: f32,
30}
31
32impl Table {
33    pub fn new(title: impl Into<String>, columns: Vec<String>) -> Self {
34        Self {
35            title: title.into(),
36            columns,
37            rows: Vec::new(),
38            selected: BTreeSet::new(),
39            row_ids: Vec::new(),
40            scale: 1.0,
41        }
42    }
43    pub fn push_row(&mut self, row: Vec<String>) {
44        self.rows.push(row);
45    }
46
47    /// Attach **stable row identities** (FC-5), one per row, in row order. When
48    /// set, the a11y node label + identity key derive from the `RowId` instead of
49    /// the visual position, so re-ordering / inserting / deleting rows keeps each
50    /// row's identity stable. Back-compatible: leave unset to key on the index.
51    pub fn with_row_ids(mut self, ids: Vec<RowId>) -> Self {
52        self.row_ids = ids;
53        self
54    }
55
56    /// The stable identity of row `i`: its `RowId` if one is set and aligned,
57    /// otherwise the visual index as a string. This is what the row's AccessKit
58    /// node is labelled + keyed on.
59    fn row_identity(&self, i: usize) -> String {
60        self.row_ids.get(i).cloned().unwrap_or_else(|| i.to_string())
61    }
62
63    /// The unambiguous a11y label for row `i`. Prefixed with `"row: "` so the row
64    /// node never collides with a cell's text (egui_extras already emits a node
65    /// per cell; an unprefixed label would make `get_by_label` ambiguous when a
66    /// cell's text equals the row identity).
67    fn row_label(&self, i: usize) -> String {
68        format!("row: {}", self.row_identity(i))
69    }
70
71    /// Toggle a row's selection (headless-test + click handler entry point).
72    pub fn select_row(&mut self, i: usize) {
73        if i < self.rows.len() && !self.selected.insert(i) {
74            self.selected.remove(&i);
75        }
76    }
77    /// Clear the row selection.
78    pub fn clear_selection(&mut self) {
79        self.selected.clear();
80    }
81    /// The currently-selected row indices, in order.
82    pub fn selected_rows(&self) -> Vec<usize> {
83        self.selected.iter().copied().collect()
84    }
85
86    /// The rows that `copy()` would emit: the selection, or all rows if none.
87    fn copy_indices(&self) -> Vec<usize> {
88        if self.selected.is_empty() {
89            (0..self.rows.len()).collect()
90        } else {
91            self.selected.iter().copied().filter(|&i| i < self.rows.len()).collect()
92        }
93    }
94}
95
96fn truncate(s: &str) -> String {
97    if s.chars().count() <= CELL_MAX {
98        s.to_string()
99    } else {
100        let head: String = s.chars().take(CELL_MAX - 1).collect();
101        format!("{head}…")
102    }
103}
104
105impl Facet for Table {
106    fn title(&self) -> &str {
107        &self.title
108    }
109    fn ui(&mut self, ui: &mut egui::Ui) {
110        use egui_extras::{Column, TableBuilder};
111        let s = self.scale;
112        ui.label(format!("{} rows × {} cols · {} selected", self.rows.len(), self.columns.len(), self.selected.len()));
113        let ncols = self.columns.len().max(1);
114        let mut tb = TableBuilder::new(ui).striped(true).sense(egui::Sense::click());
115        for _ in 0..ncols {
116            tb = tb.column(Column::auto().at_least(60.0 * s).resizable(true));
117        }
118        let mut toggle: Option<usize> = None;
119        tb.header(20.0 * s, |mut header| {
120            for c in &self.columns {
121                header.col(|ui| {
122                    ui.strong(c);
123                });
124            }
125        })
126        // Virtualised: only the visible rows are built, so a million-row Arrow
127        // batch scrolls at 60 fps (render time flat in row count).
128        .body(|body| {
129            body.rows(18.0 * s, self.rows.len(), |mut row| {
130                let i = row.index();
131                let is_selected = self.selected.contains(&i);
132                row.set_selected(is_selected);
133                for cell in &self.rows[i] {
134                    row.col(|ui| {
135                        ui.label(truncate(cell));
136                    });
137                }
138                let resp = row.response();
139                // FC-4 + FC-5: attach a labelled, `selected`-bearing AccessKit
140                // node to the ROW's response (the union of its cells). egui_extras
141                // emits nodes for the *cells*; without this the selected ROW is not
142                // queryable and its toggled state is invisible to a driver / screen
143                // reader. The label carries the row's STABLE identity (RowId when
144                // set, else the index — FC-5) and is prefixed (`row: …`) so it
145                // never collides with a cell's own text node.
146                let sem = Semantics::list_item(self.row_label(i), is_selected);
147                resp.widget_info(|| sem.widget_info());
148                if resp.clicked() {
149                    toggle = Some(i);
150                }
151            });
152        });
153        if let Some(i) = toggle {
154            self.select_row(i);
155        }
156    }
157    fn state_json(&self) -> serde_json::Value {
158        serde_json::json!({
159            "columns": self.columns,
160            "rows": self.rows.len(),
161            "selected": self.selected_rows(),
162            "scale": self.scale,
163        })
164    }
165
166    fn caps(&self) -> FacetCaps {
167        // egui_extras' `TableBuilder` + `ui.label/strong` are standard widgets, so
168        // they follow the active `Theme`'s `Visuals` (set by `set_theme`).
169        FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable().themeable()
170    }
171
172    fn scale(&self) -> f32 {
173        self.scale
174    }
175    fn set_scale(&mut self, scale: f32) {
176        self.scale = scale.clamp(0.25, 4.0);
177    }
178
179    fn selection_json(&self) -> serde_json::Value {
180        serde_json::json!(self.selected_rows())
181    }
182
183    /// TSV: header row + selected rows (or all rows when none selected),
184    /// `\t`-joined cells, `\n` between rows. `None` only for an empty table.
185    fn copy(&mut self) -> Option<String> {
186        if self.rows.is_empty() {
187            return None;
188        }
189        let idx = self.copy_indices();
190        let rows = idx.into_iter().map(|i| self.rows[i].clone());
191        Some(clipboard::rows_to_tsv(&self.columns, rows))
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn truncate_caps_long_cells() {
201        assert_eq!(truncate("short"), "short");
202        let long = "x".repeat(100);
203        let t = truncate(&long);
204        assert_eq!(t.chars().count(), CELL_MAX);
205        assert!(t.ends_with('…'));
206    }
207
208    #[test]
209    fn state_json_reports_shape() {
210        let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
211        t.push_row(vec!["knut".into(), "0.1".into()]);
212        t.push_row(vec!["korp".into(), "0.1".into()]);
213        let j = t.state_json();
214        assert_eq!(j["rows"], 2);
215        assert_eq!(j["columns"].as_array().unwrap().len(), 2);
216    }
217
218    fn repos() -> Table {
219        let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
220        t.push_row(vec!["knut".into(), "0.1".into()]);
221        t.push_row(vec!["korp".into(), "0.2".into()]);
222        t
223    }
224
225    #[test]
226    fn caps_declares_table_surface() {
227        let c = repos().caps();
228        assert!(c.selectable && c.copyable && c.searchable && c.scalable && c.resizable);
229        assert!(!c.pasteable && !c.cuttable);
230    }
231
232    #[test]
233    fn copy_all_rows_when_nothing_selected() {
234        let mut t = repos();
235        let tsv = t.copy().expect("non-empty table copies");
236        assert_eq!(tsv, "name\tversion\nknut\t0.1\nkorp\t0.2");
237    }
238
239    #[test]
240    fn copy_only_selected_rows() {
241        let mut t = repos();
242        t.select_row(1);
243        let tsv = t.copy().expect("selection copies");
244        assert_eq!(tsv, "name\tversion\nkorp\t0.2");
245        assert_eq!(t.selection_json(), serde_json::json!([1]));
246    }
247
248    #[test]
249    fn row_identity_falls_back_to_index_without_row_ids() {
250        let t = repos();
251        // No row_ids set → identity is the visual index; label is prefixed.
252        assert_eq!(t.row_identity(0), "0");
253        assert_eq!(t.row_identity(1), "1");
254        assert_eq!(t.row_label(0), "row: 0");
255    }
256
257    #[test]
258    fn with_row_ids_keys_identity_on_stable_id() {
259        let t = repos().with_row_ids(vec!["pkg-knut".into(), "pkg-korp".into()]);
260        // Identity now derives from the stable RowId, not the visual position.
261        assert_eq!(t.row_identity(0), "pkg-knut");
262        assert_eq!(t.row_identity(1), "pkg-korp");
263        assert_eq!(t.row_label(1), "row: pkg-korp");
264        // Selection/copy semantics are unchanged & back-compatible.
265        let mut t = t;
266        t.select_row(0);
267        assert_eq!(t.selected_rows(), vec![0]);
268    }
269
270    #[test]
271    fn select_row_toggles() {
272        let mut t = repos();
273        t.select_row(0);
274        assert_eq!(t.selected_rows(), vec![0]);
275        t.select_row(0);
276        assert!(t.selected_rows().is_empty());
277    }
278
279    #[test]
280    fn cut_falls_back_to_copy_for_read_only_table() {
281        let mut t = repos();
282        // Table is not cuttable; cut() defaults to copy() (no removal).
283        let cut = t.cut().expect("cut delegates to copy");
284        assert_eq!(cut, "name\tversion\nknut\t0.1\nkorp\t0.2");
285        assert_eq!(t.rows.len(), 2, "cut must not remove rows on a read-only viewer");
286    }
287
288    #[test]
289    fn paste_is_rejected() {
290        let mut t = repos();
291        assert!(!t.paste("anything"), "read-only table does not consume paste");
292    }
293
294    #[test]
295    fn empty_table_copies_nothing() {
296        let mut t = Table::new("empty", vec!["a".into()]);
297        assert_eq!(t.copy(), None);
298    }
299
300    #[test]
301    fn set_scale_clamps() {
302        let mut t = repos();
303        t.set_scale(99.0);
304        assert_eq!(t.scale(), 4.0);
305        t.set_scale(0.001);
306        assert_eq!(t.scale(), 0.25);
307        assert_eq!(t.state_json()["scale"], 0.25);
308    }
309}