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::clip::{ClipKind, ClipPayload, CopySource};
7use facett_core::effects::FadeTrack;
8use facett_core::{Facet, FacetCaps, Semantics, clipboard};
9use std::collections::BTreeSet;
10
11/// Hover / selection cross-fade length (seconds) for a row highlight.
12const ROW_FADE_SECS: f32 = 0.12;
13
14/// The **warehouse browser** Facet — a multi-table picker + grid / auto bar-chart,
15/// generalised from nornir's `src/viz/warehouse_tab.rs` (`WarehouseBrowser`) with
16/// the Iceberg/gRPC source abstracted away. See [`warehouse::WarehouseTableView`].
17pub mod warehouse;
18pub use warehouse::{PreviewTable, WarehouseTableView};
19
20const CELL_MAX: usize = 48;
21
22/// A stable, domain-level identity for a row (FC-5). When present, selection is
23/// keyed on this string instead of the visual `row.index()`, so an insert/delete
24/// that shifts visual positions does **not** silently move the selection.
25pub type RowId = String;
26
27/// A data table: header `columns` + `rows` (each a row of cells).
28pub struct Table {
29    pub title: String,
30    pub columns: Vec<String>,
31    pub rows: Vec<Vec<String>>,
32    /// Selected row indices (click to toggle). Empty = nothing selected, in
33    /// which case `copy()` falls back to copying every row.
34    selected: BTreeSet<usize>,
35    /// Optional stable per-row identities (FC-5). When non-empty (and aligned to
36    /// `rows`), the row's a11y label + identity is keyed on this rather than the
37    /// visual index. Empty = fall back to the visual index (back-compatible).
38    row_ids: Vec<RowId>,
39    /// Uniform scale (drives row height + font); 1.0 = native.
40    scale: f32,
41    /// Per-row hover + selection fades (injected-clock, FC-7). Painted as a faded
42    /// accent fill + selection-glow outline over the egui_extras rows.
43    hover_fades: FadeTrack,
44    sel_fades: FadeTrack,
45}
46
47impl Table {
48    pub fn new(title: impl Into<String>, columns: Vec<String>) -> Self {
49        Self {
50            title: title.into(),
51            columns,
52            rows: Vec::new(),
53            selected: BTreeSet::new(),
54            row_ids: Vec::new(),
55            scale: 1.0,
56            hover_fades: FadeTrack::default(),
57            sel_fades: FadeTrack::default(),
58        }
59    }
60    pub fn push_row(&mut self, row: Vec<String>) {
61        self.rows.push(row);
62    }
63
64    /// Attach **stable row identities** (FC-5), one per row, in row order. When
65    /// set, the a11y node label + identity key derive from the `RowId` instead of
66    /// the visual position, so re-ordering / inserting / deleting rows keeps each
67    /// row's identity stable. Back-compatible: leave unset to key on the index.
68    pub fn with_row_ids(mut self, ids: Vec<RowId>) -> Self {
69        self.row_ids = ids;
70        self
71    }
72
73    /// The stable identity of row `i`: its `RowId` if one is set and aligned,
74    /// otherwise the visual index as a string. This is what the row's AccessKit
75    /// node is labelled + keyed on.
76    fn row_identity(&self, i: usize) -> String {
77        self.row_ids.get(i).cloned().unwrap_or_else(|| i.to_string())
78    }
79
80    /// The unambiguous a11y label for row `i`. Prefixed with `"row: "` so the row
81    /// node never collides with a cell's text (egui_extras already emits a node
82    /// per cell; an unprefixed label would make `get_by_label` ambiguous when a
83    /// cell's text equals the row identity).
84    fn row_label(&self, i: usize) -> String {
85        format!("row: {}", self.row_identity(i))
86    }
87
88    /// Toggle a row's selection (headless-test + click handler entry point).
89    pub fn select_row(&mut self, i: usize) {
90        if i < self.rows.len() && !self.selected.insert(i) {
91            self.selected.remove(&i);
92        }
93    }
94    /// Clear the row selection.
95    pub fn clear_selection(&mut self) {
96        self.selected.clear();
97    }
98    /// The currently-selected row indices, in order.
99    pub fn selected_rows(&self) -> Vec<usize> {
100        self.selected.iter().copied().collect()
101    }
102
103    /// The rows that `copy()` would emit: the selection, or all rows if none.
104    fn copy_indices(&self) -> Vec<usize> {
105        if self.selected.is_empty() {
106            (0..self.rows.len()).collect()
107        } else {
108            self.selected.iter().copied().filter(|&i| i < self.rows.len()).collect()
109        }
110    }
111}
112
113fn truncate(s: &str) -> String {
114    if s.chars().count() <= CELL_MAX {
115        s.to_string()
116    } else {
117        let head: String = s.chars().take(CELL_MAX - 1).collect();
118        format!("{head}…")
119    }
120}
121
122impl Table {
123    /// TSV of the selected rows (or all rows when none selected), header first —
124    /// shared by [`Facet::copy`] and [`CopySource`]. `None` for an empty table.
125    pub fn copy_tsv(&self) -> Option<String> {
126        if self.rows.is_empty() {
127            return None;
128        }
129        let idx = self.copy_indices();
130        let rows = idx.into_iter().map(|i| self.rows[i].clone());
131        Some(clipboard::rows_to_tsv(&self.columns, rows))
132    }
133}
134
135// ── typed copy (§16) — the selection (or whole table) as a TSV rectangle ───────
136impl CopySource for Table {
137    fn copy_kinds(&self) -> &[ClipKind] {
138        &[ClipKind::Rows, ClipKind::Text]
139    }
140    fn copy_payload(&self) -> Option<ClipPayload> {
141        self.copy_tsv().map(ClipPayload::Rows)
142    }
143}
144
145impl Facet for Table {
146    fn title(&self) -> &str {
147        &self.title
148    }
149    fn ui(&mut self, ui: &mut egui::Ui) {
150        use egui_extras::{Column, TableBuilder};
151        let s = self.scale;
152        let dt = ui.input(|i| i.stable_dt);
153        let th = facett_core::theme(ui);
154        ui.label(format!("{} rows × {} cols · {} selected", self.rows.len(), self.columns.len(), self.selected.len()));
155        let ncols = self.columns.len().max(1);
156        // Collect each visible row's rect + hover/selection so we can paint an
157        // injected-clock (FC-7) hover-fill + selection-glow over the rows after the
158        // (virtualised) table lays out. `(key, rect, hovered, selected)`.
159        let mut row_hi: Vec<(u64, egui::Rect, bool, bool)> = Vec::new();
160        let mut tb = TableBuilder::new(ui).striped(true).sense(egui::Sense::click());
161        for _ in 0..ncols {
162            tb = tb.column(Column::auto().at_least(60.0 * s).resizable(true));
163        }
164        let mut toggle: Option<usize> = None;
165        tb.header(20.0 * s, |mut header| {
166            for c in &self.columns {
167                header.col(|ui| {
168                    ui.strong(c);
169                });
170            }
171        })
172        // Virtualised: only the visible rows are built, so a million-row Arrow
173        // batch scrolls at 60 fps (render time flat in row count).
174        .body(|body| {
175            body.rows(18.0 * s, self.rows.len(), |mut row| {
176                let i = row.index();
177                let is_selected = self.selected.contains(&i);
178                row.set_selected(is_selected);
179                for cell in &self.rows[i] {
180                    row.col(|ui| {
181                        ui.label(truncate(cell));
182                    });
183                }
184                let resp = row.response();
185                // FC-4 + FC-5: attach a labelled, `selected`-bearing AccessKit
186                // node to the ROW's response (the union of its cells). egui_extras
187                // emits nodes for the *cells*; without this the selected ROW is not
188                // queryable and its toggled state is invisible to a driver / screen
189                // reader. The label carries the row's STABLE identity (RowId when
190                // set, else the index — FC-5) and is prefixed (`row: …`) so it
191                // never collides with a cell's own text node.
192                let sem = Semantics::list_item(self.row_label(i), is_selected);
193                resp.widget_info(|| sem.widget_info());
194                // Note the row for the post-table highlight pass (stable key on the
195                // row's identity, FC-5, so the fade follows a row across re-sorts).
196                row_hi.push((FadeTrack::key(self.row_label(i)), resp.rect, resp.hovered(), is_selected));
197                if resp.clicked() {
198                    toggle = Some(i);
199                }
200            });
201        });
202
203        // Whole-row highlight pass: advance the hover/selection fades from this
204        // frame's set, then paint a faded accent fill + selection-glow outline over
205        // each row. (egui_extras owns the row body, so the highlight rides on top at
206        // a low alpha — text stays legible.)
207        self.hover_fades.begin();
208        self.sel_fades.begin();
209        for (key, _, hovered, selected) in &row_hi {
210            if *hovered {
211                self.hover_fades.lit(*key);
212            }
213            if *selected {
214                self.sel_fades.lit(*key);
215            }
216        }
217        let a = self.hover_fades.advance(dt, ROW_FADE_SECS);
218        let b = self.sel_fades.advance(dt, ROW_FADE_SECS);
219        {
220            let painter = ui.painter();
221            for (key, rect, _, _) in &row_hi {
222                let hf = self.hover_fades.factor(*key);
223                let sf = self.sel_fades.factor(*key);
224                if hf > 0.001 {
225                    painter.rect_filled(*rect, 2.0, th.accent.linear_multiply((0.10 * hf).min(0.14)));
226                }
227                if sf > 0.01 {
228                    painter.rect_stroke(
229                        *rect,
230                        2.0,
231                        egui::Stroke::new(1.0, th.accent.linear_multiply(0.5 * sf)),
232                        egui::StrokeKind::Inside,
233                    );
234                }
235            }
236        }
237        if a || b {
238            ui.ctx().request_repaint();
239        }
240
241        if let Some(i) = toggle {
242            self.select_row(i);
243        }
244
245        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
246        #[cfg(feature = "testmatrix")]
247        facett_core::testmatrix::emit(
248            "facett-table::Table::ui",
249            "ui_render",
250            // OK = the view drew the geometry it declares: every selected index is a
251            // real row, and a non-empty table has columns to render into.
252            self.selected.iter().all(|&i| i < self.rows.len()) && (self.rows.is_empty() || !self.columns.is_empty()),
253            &format!("rows={} cols={} selected={}", self.rows.len(), self.columns.len(), self.selected.len()),
254        );
255    }
256    fn state_json(&self) -> serde_json::Value {
257        serde_json::json!({
258            "columns": self.columns,
259            "rows": self.rows.len(),
260            "selected": self.selected_rows(),
261            "scale": self.scale,
262        })
263    }
264
265    fn caps(&self) -> FacetCaps {
266        // egui_extras' `TableBuilder` + `ui.label/strong` are standard widgets, so
267        // they follow the active `Theme`'s `Visuals` (set by `set_theme`).
268        FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable().themeable()
269    }
270
271    fn scale(&self) -> f32 {
272        self.scale
273    }
274    fn set_scale(&mut self, scale: f32) {
275        self.scale = scale.clamp(0.25, 4.0);
276    }
277
278    fn selection_json(&self) -> serde_json::Value {
279        serde_json::json!(self.selected_rows())
280    }
281
282    /// TSV: header row + selected rows (or all rows when none selected),
283    /// `\t`-joined cells, `\n` between rows. `None` only for an empty table.
284    fn copy(&mut self) -> Option<String> {
285        self.copy_tsv()
286    }
287
288    // ── cross-instance clone (copy/paste BETWEEN two Tables) ──────────────────
289
290    fn kind(&self) -> &'static str {
291        "table"
292    }
293
294    /// The portable arrangement: the **column order** (the header sequence), the
295    /// **row selection**, and the uniform **scale**. (The row *data* belongs to
296    /// each table's own source; this clones how the table is arranged/selected so
297    /// a sibling over the same shape mirrors it.)
298    fn portable_state(&self) -> Option<serde_json::Value> {
299        Some(serde_json::json!({
300            "columns": self.columns,
301            "selected": self.selected_rows(),
302            "scale": self.scale,
303        }))
304    }
305
306    fn load_state(&mut self, state: &serde_json::Value) -> bool {
307        let obj = match state.as_object() {
308            Some(o) => o,
309            None => return false,
310        };
311        if let Some(cols) = obj.get("columns").and_then(|v| v.as_array()) {
312            self.columns = cols.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
313        }
314        if let Some(sel) = obj.get("selected").and_then(|v| v.as_array()) {
315            // Only adopt indices that exist in THIS table's rows (a sibling may be
316            // shorter); selection is clamped, never out of range.
317            self.selected = sel
318                .iter()
319                .filter_map(|v| v.as_u64().map(|n| n as usize))
320                .filter(|&i| i < self.rows.len())
321                .collect();
322        }
323        if let Some(s) = obj.get("scale").and_then(|v| v.as_f64()) {
324            self.set_scale(s as f32);
325        }
326        true
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn truncate_caps_long_cells() {
336        assert_eq!(truncate("short"), "short");
337        let long = "x".repeat(100);
338        let t = truncate(&long);
339        assert_eq!(t.chars().count(), CELL_MAX);
340        assert!(t.ends_with('…'));
341    }
342
343    #[test]
344    fn state_json_reports_shape() {
345        let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
346        t.push_row(vec!["knut".into(), "0.1".into()]);
347        t.push_row(vec!["korp".into(), "0.1".into()]);
348        let j = t.state_json();
349        assert_eq!(j["rows"], 2);
350        assert_eq!(j["columns"].as_array().unwrap().len(), 2);
351    }
352
353    fn repos() -> Table {
354        let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
355        t.push_row(vec!["knut".into(), "0.1".into()]);
356        t.push_row(vec!["korp".into(), "0.2".into()]);
357        t
358    }
359
360    #[test]
361    fn caps_declares_table_surface() {
362        let c = repos().caps();
363        assert!(c.selectable && c.copyable && c.searchable && c.scalable && c.resizable);
364        assert!(!c.pasteable && !c.cuttable);
365    }
366
367    #[test]
368    fn portable_state_round_trips_columns_selection_scale_between_instances() {
369        // A: reordered columns (version first), a row selected, scaled up.
370        let mut a = Table::new("repos", vec!["version".into(), "name".into()]);
371        a.push_row(vec!["0.1".into(), "knut".into()]);
372        a.push_row(vec!["0.2".into(), "korp".into()]);
373        a.select_row(1);
374        a.set_scale(1.5);
375
376        let env = clipboard::encode_component(a.kind(), &a.portable_state().unwrap());
377        let (kind, state) = clipboard::decode_component(&env).unwrap();
378        assert_eq!(kind, "table");
379
380        // B starts default-arranged with the same number of rows.
381        let mut b = repos(); // 2 rows, columns name/version, no selection, scale 1.0
382        assert_ne!(b.portable_state(), a.portable_state(), "differ before paste");
383        assert!(b.load_state(&state));
384        assert_eq!(b.portable_state(), a.portable_state(), "B mirrors A's column order/selection/scale");
385        assert_eq!(b.columns, vec!["version".to_string(), "name".to_string()]);
386        assert_eq!(b.selected_rows(), vec![1]);
387        assert_eq!(b.scale(), 1.5);
388    }
389
390    #[test]
391    fn paste_component_clamps_a_selection_out_of_range() {
392        // A selects row 5; B has only 2 rows → the stray index is dropped, no panic.
393        let a_state = serde_json::json!({ "columns": ["name", "version"], "selected": [5], "scale": 1.0 });
394        let mut b = repos();
395        assert!(b.load_state(&a_state));
396        assert!(b.selected_rows().is_empty(), "out-of-range selection is clamped away");
397    }
398
399    #[test]
400    fn typed_copy_is_the_selection_as_rows() {
401        use facett_core::clip::{ClipKind, CopySource};
402        let mut t = repos();
403        t.select_row(1);
404        let p = t.copy_payload().expect("selection copies");
405        assert_eq!(p.kind(), ClipKind::Rows);
406        assert_eq!(p.as_text(), "name\tversion\nkorp\t0.2");
407        // Empty table offers nothing.
408        assert!(Table::new("e", vec!["a".into()]).copy_payload().is_none());
409    }
410
411    #[test]
412    fn copy_all_rows_when_nothing_selected() {
413        let mut t = repos();
414        let tsv = t.copy().expect("non-empty table copies");
415        assert_eq!(tsv, "name\tversion\nknut\t0.1\nkorp\t0.2");
416    }
417
418    #[test]
419    fn copy_only_selected_rows() {
420        let mut t = repos();
421        t.select_row(1);
422        let tsv = t.copy().expect("selection copies");
423        assert_eq!(tsv, "name\tversion\nkorp\t0.2");
424        assert_eq!(t.selection_json(), serde_json::json!([1]));
425    }
426
427    #[test]
428    fn row_identity_falls_back_to_index_without_row_ids() {
429        let t = repos();
430        // No row_ids set → identity is the visual index; label is prefixed.
431        assert_eq!(t.row_identity(0), "0");
432        assert_eq!(t.row_identity(1), "1");
433        assert_eq!(t.row_label(0), "row: 0");
434    }
435
436    #[test]
437    fn with_row_ids_keys_identity_on_stable_id() {
438        let t = repos().with_row_ids(vec!["pkg-knut".into(), "pkg-korp".into()]);
439        // Identity now derives from the stable RowId, not the visual position.
440        assert_eq!(t.row_identity(0), "pkg-knut");
441        assert_eq!(t.row_identity(1), "pkg-korp");
442        assert_eq!(t.row_label(1), "row: pkg-korp");
443        // Selection/copy semantics are unchanged & back-compatible.
444        let mut t = t;
445        t.select_row(0);
446        assert_eq!(t.selected_rows(), vec![0]);
447    }
448
449    #[test]
450    fn select_row_toggles() {
451        let mut t = repos();
452        t.select_row(0);
453        assert_eq!(t.selected_rows(), vec![0]);
454        t.select_row(0);
455        assert!(t.selected_rows().is_empty());
456    }
457
458    #[test]
459    fn cut_falls_back_to_copy_for_read_only_table() {
460        let mut t = repos();
461        // Table is not cuttable; cut() defaults to copy() (no removal).
462        let cut = t.cut().expect("cut delegates to copy");
463        assert_eq!(cut, "name\tversion\nknut\t0.1\nkorp\t0.2");
464        assert_eq!(t.rows.len(), 2, "cut must not remove rows on a read-only viewer");
465    }
466
467    #[test]
468    fn paste_is_rejected() {
469        let mut t = repos();
470        assert!(!t.paste("anything"), "read-only table does not consume paste");
471    }
472
473    #[test]
474    fn empty_table_copies_nothing() {
475        let mut t = Table::new("empty", vec!["a".into()]);
476        assert_eq!(t.copy(), None);
477    }
478
479    #[test]
480    fn set_scale_clamps() {
481        let mut t = repos();
482        t.set_scale(99.0);
483        assert_eq!(t.scale(), 4.0);
484        t.set_scale(0.001);
485        assert_eq!(t.scale(), 0.25);
486        assert_eq!(t.state_json()["scale"], 0.25);
487    }
488}