Skip to main content

facett_table/
warehouse.rs

1//! **WarehouseTableView** — a generic warehouse-style data browser: a left
2//! **table picker**, and for the selected table either a striped grid OR (when a
3//! numeric column exists) a hand-painted horizontal **bar chart**, with combo
4//! pickers for the value/label columns. Generalised from nornir's
5//! `src/viz/warehouse_tab.rs` (`WarehouseBrowser`).
6//!
7//! The nornir original couples this to an Iceberg warehouse + a `Warehouse.Scan`
8//! gRPC; THIS is the reusable UI half — the source is just an in-memory map of
9//! `table name → `[`PreviewTable`]`. A host that has Iceberg/RPC builds those
10//! previews and feeds them in; this crate keeps no I/O. A canonical [`Facet`].
11
12use facett_core::scroll_engine::{SmoothScroll, smooth_scroll_area};
13use facett_core::{Facet, FacetCaps, Semantics};
14use serde::{Deserialize, Serialize};
15
16const CELL_MAX_CHARS: usize = 80;
17
18/// A previewed table: named `columns` + stringified `rows`. The shape nornir's
19/// `IcebergWarehouse::scan_preview` produces — but source-agnostic.
20#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
21pub struct PreviewTable {
22    pub columns: Vec<String>,
23    pub rows: Vec<Vec<String>>,
24}
25
26impl PreviewTable {
27    pub fn new(columns: Vec<String>, rows: Vec<Vec<String>>) -> Self {
28        Self { columns, rows }
29    }
30
31    /// True if at least half of the column's non-empty cells parse as `f64` (and
32    /// at least one does) — the test for offering it as a chart value column.
33    /// Direct port of nornir's `col_is_numeric`.
34    fn col_is_numeric(&self, col: usize) -> bool {
35        let (mut seen, mut ok) = (0usize, 0usize);
36        for row in &self.rows {
37            let cell = row.get(col).map(String::as_str).unwrap_or("");
38            if cell.is_empty() {
39                continue;
40            }
41            seen += 1;
42            if cell.trim().parse::<f64>().is_ok() {
43                ok += 1;
44            }
45        }
46        ok > 0 && ok * 2 >= seen
47    }
48
49    /// The indices of every numeric (chart-able) column.
50    pub fn numeric_columns(&self) -> Vec<usize> {
51        (0..self.columns.len()).filter(|&i| self.col_is_numeric(i)).collect()
52    }
53}
54
55/// A warehouse-style browser over a set of named [`PreviewTable`]s.
56pub struct WarehouseTableView {
57    title: String,
58    tables: Vec<(String, PreviewTable)>,
59    selected: Option<usize>,
60    /// 📊 chart mode: render a numeric column as horizontal bars instead of the grid.
61    chart: bool,
62    chart_val: Option<usize>,
63    chart_label: Option<usize>,
64    /// Smooth scroll (both axes) for the central data grid (SCRL-3): eased,
65    /// injected-clock (FC-7) — state lives here, not in egui memory.
66    grid_scroll_x: SmoothScroll,
67    grid_scroll_y: SmoothScroll,
68}
69
70impl WarehouseTableView {
71    /// An empty browser titled `title`. Add tables with [`with_table`](Self::with_table).
72    pub fn new(title: impl Into<String>) -> Self {
73        Self {
74            title: title.into(),
75            tables: Vec::new(),
76            selected: None,
77            chart: false,
78            chart_val: None,
79            chart_label: None,
80            grid_scroll_x: SmoothScroll::default(),
81            grid_scroll_y: SmoothScroll::default(),
82        }
83    }
84
85    /// Add a named preview table (builder form). The first added becomes the
86    /// default selection.
87    pub fn with_table(mut self, name: impl Into<String>, table: PreviewTable) -> Self {
88        if self.tables.is_empty() {
89            self.selected = Some(0);
90        }
91        self.tables.push((name.into(), table));
92        self
93    }
94
95    /// Re-title the view (the deck keys facets off [`Facet::title`]).
96    pub fn set_title(&mut self, title: impl Into<String>) {
97        self.title = title.into();
98    }
99
100    /// **Mutator**: set (or replace) the named table's preview in place — the
101    /// host-driven counterpart of the [`with_table`](Self::with_table) builder, used
102    /// by a live feed that grows a table's rows each tick. If a table with `name`
103    /// already exists its preview is swapped (selection preserved); otherwise the
104    /// table is appended (and becomes the default selection if it's the first one).
105    pub fn set_table(&mut self, name: impl Into<String>, table: PreviewTable) {
106        let name = name.into();
107        if let Some(slot) = self.tables.iter_mut().find(|(n, _)| *n == name) {
108            slot.1 = table;
109        } else {
110            if self.tables.is_empty() {
111                self.selected = Some(0);
112            }
113            self.tables.push((name, table));
114        }
115    }
116
117    /// The names of every loaded table (the left-panel list).
118    pub fn table_names(&self) -> Vec<String> {
119        self.tables.iter().map(|(n, _)| n.clone()).collect()
120    }
121
122    /// The name of the currently-selected table, if any.
123    pub fn selected_name(&self) -> Option<&str> {
124        self.selected.and_then(|i| self.tables.get(i)).map(|(n, _)| n.as_str())
125    }
126
127    /// Select a table by name (the headless equivalent of clicking it); clears any
128    /// stale chart-column choices. Returns `true` if such a table exists.
129    pub fn select(&mut self, name: &str) -> bool {
130        match self.tables.iter().position(|(n, _)| n == name) {
131            Some(i) => {
132                self.selected = Some(i);
133                self.chart_val = None;
134                self.chart_label = None;
135                true
136            }
137            None => false,
138        }
139    }
140
141    fn current(&self) -> Option<&PreviewTable> {
142        self.selected.and_then(|i| self.tables.get(i)).map(|(_, t)| t)
143    }
144
145    /// **Discovery-contract `local()`** — seeds two realistic tables (one numeric,
146    /// chart-able; one text) so the browser renders with no host attached.
147    pub fn local() -> Self {
148        Self::new("Warehouse")
149            .with_table(
150                "bench_runs",
151                PreviewTable::new(
152                    vec!["bench".into(), "ms".into()],
153                    vec![
154                        vec!["xml_parse".into(), "12.4".into()],
155                        vec!["json_parse".into(), "8.1".into()],
156                        vec!["zstd_decode".into(), "31.7".into()],
157                    ],
158                ),
159            )
160            .with_table(
161                "repos",
162                PreviewTable::new(
163                    vec!["name".into(), "lang".into()],
164                    vec![vec!["nornir".into(), "rust".into()], vec!["facett".into(), "rust".into()]],
165                ),
166            )
167    }
168
169    /// **Discovery-contract `remote()`** — same surface, re-titled (the variant a
170    /// host builds from a server scan; no transport here).
171    pub fn remote() -> Self {
172        let mut v = Self::local();
173        v.title = "Warehouse (remote)".into();
174        v
175    }
176}
177
178const ROW_H: f32 = 18.0;
179const LABEL_W: f32 = 230.0;
180
181impl Facet for WarehouseTableView {
182    fn title(&self) -> &str {
183        &self.title
184    }
185
186    fn ui(&mut self, ui: &mut egui::Ui) {
187        egui::Panel::left("wh_tables").default_size(220.0).show_inside(ui, |ui| {
188            ui.heading(format!("tables ({})", self.tables.len()));
189            ui.separator();
190            egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
191                let names: Vec<String> = self.table_names();
192                for name in &names {
193                    let selected = self.selected_name() == Some(name.as_str());
194                    if ui.selectable_label(selected, name).clicked() {
195                        self.select(name);
196                    }
197                }
198            });
199        });
200
201        egui::CentralPanel::default().show_inside(ui, |ui| {
202            let th = facett_core::theme(ui);
203            let Some(p) = self.current().cloned() else {
204                ui.label("select a table on the left to view its rows");
205                return;
206            };
207            let table_name = self.selected_name().unwrap_or("").to_string();
208            let ncols = p.columns.len();
209            let numeric = p.numeric_columns();
210            let mut chart = self.chart;
211            let mut chart_val = self.chart_val.filter(|&i| i < ncols);
212            let mut chart_label = self.chart_label.filter(|&i| i < ncols);
213
214            ui.horizontal(|ui| {
215                ui.heading(&table_name);
216                ui.label(format!("· {} rows · {} columns", p.rows.len(), ncols));
217                if !numeric.is_empty() {
218                    ui.separator();
219                    ui.checkbox(&mut chart, "📊 chart");
220                }
221            });
222
223            if chart && !numeric.is_empty() {
224                if chart_val.map(|i| !numeric.contains(&i)).unwrap_or(true) {
225                    chart_val = numeric.first().copied();
226                }
227                if chart_label.is_none() {
228                    chart_label = (0..ncols).find(|i| !numeric.contains(i));
229                }
230                ui.horizontal(|ui| {
231                    ui.label("value:");
232                    egui::ComboBox::from_id_salt("wh_chart_val")
233                        .selected_text(chart_val.map(|i| p.columns[i].clone()).unwrap_or_default())
234                        .show_ui(ui, |ui| {
235                            for &i in &numeric {
236                                ui.selectable_value(&mut chart_val, Some(i), &p.columns[i]);
237                            }
238                        });
239                    ui.label("label:");
240                    egui::ComboBox::from_id_salt("wh_chart_label")
241                        .selected_text(chart_label.map(|i| p.columns[i].clone()).unwrap_or("(row #)".into()))
242                        .show_ui(ui, |ui| {
243                            ui.selectable_value(&mut chart_label, None, "(row #)");
244                            for i in 0..ncols {
245                                ui.selectable_value(&mut chart_label, Some(i), &p.columns[i]);
246                            }
247                        });
248                });
249                ui.separator();
250                if let Some(v) = chart_val {
251                    draw_bar_chart(ui, &p, v, chart_label, &th);
252                }
253            } else {
254                ui.separator();
255                let base_id = ui.id().with("wh-grid");
256                // Smooth-scroll both axes (SCRL-3) from this frame's `stable_dt`
257                // (FC-7); `SmoothScroll` is `Copy`, so work on copies + write back.
258                let dt = ui.input(|i| i.stable_dt);
259                let (mut gx, mut gy) = (self.grid_scroll_x, self.grid_scroll_y);
260                smooth_scroll_area(
261                    ui,
262                    egui::ScrollArea::both().auto_shrink([false, false]),
263                    dt,
264                    &mut gy,
265                    Some(&mut gx),
266                    |ui| {
267                    egui::Grid::new("wh_grid").striped(true).num_columns(ncols.max(1)).show(ui, |ui| {
268                        for c in &p.columns {
269                            ui.strong(c);
270                        }
271                        ui.end_row();
272                        for (ri, row) in p.rows.iter().enumerate() {
273                            for cell in row {
274                                let resp = ui
275                                    .push_id(facett_core::stable_id(base_id, &ri.to_string()), |ui| ui.label(truncate(cell)))
276                                    .inner;
277                                let txt = truncate(cell);
278                                resp.widget_info(move || Semantics::new(egui::WidgetType::Label, txt.clone()).widget_info());
279                            }
280                            ui.end_row();
281                        }
282                    });
283                    },
284                );
285                self.grid_scroll_x = gx;
286                self.grid_scroll_y = gy;
287            }
288
289            self.chart = chart;
290            self.chart_val = chart_val;
291            self.chart_label = chart_label;
292        });
293
294        #[cfg(feature = "testmatrix")]
295        facett_core::testmatrix::emit(
296            "facett-table::WarehouseTableView::ui",
297            "ui_render",
298            true,
299            &format!("tables={} selected={:?} chart={}", self.tables.len(), self.selected_name(), self.chart),
300        );
301    }
302
303    fn state_json(&self) -> serde_json::Value {
304        let preview = match self.current() {
305            None => serde_json::json!({ "loaded": false }),
306            Some(p) => serde_json::json!({
307                "loaded": true,
308                "columns": p.columns,
309                "rows": p.rows.len(),
310                "numeric_columns": p.numeric_columns(),
311            }),
312        };
313        serde_json::json!({
314            "title": self.title,
315            "tables": self.table_names(),
316            "table_count": self.tables.len(),
317            "selected": self.selected_name(),
318            "chart": { "on": self.chart, "value_col": self.chart_val, "label_col": self.chart_label },
319            "preview": preview,
320        })
321    }
322
323    fn caps(&self) -> FacetCaps {
324        FacetCaps::NONE.selectable().searchable().resizable().themeable()
325    }
326
327    fn selection_json(&self) -> serde_json::Value {
328        serde_json::json!(self.selected_name())
329    }
330
331    /// Opt into typed downcast so a host can drive this view's mutators
332    /// ([`set_table`](Self::set_table) / [`select`](Self::select)) when it lives boxed
333    /// inside a `FacetDeck` (e.g. a live sim feeding a growing table each tick).
334    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
335        Some(self)
336    }
337}
338
339/// Hand-painted horizontal bar chart over the preview rows (no plotting dep —
340/// consistent with the nornir original). `val` is the numeric column; `label`
341/// the optional label column (row index when `None`).
342fn draw_bar_chart(ui: &mut egui::Ui, p: &PreviewTable, val: usize, label: Option<usize>, th: &facett_core::theme::Theme) {
343    let red = egui::Color32::from_rgb(224, 90, 90);
344    let rows: Vec<(String, f64)> = p
345        .rows
346        .iter()
347        .enumerate()
348        .filter_map(|(i, row)| {
349            let v: f64 = row.get(val)?.trim().parse().ok()?;
350            let l = match label {
351                Some(li) => truncate(row.get(li).map(String::as_str).unwrap_or("")),
352                None => format!("#{i}"),
353            };
354            Some((l, v))
355        })
356        .take(120)
357        .collect();
358    if rows.is_empty() {
359        ui.label("no numeric values to chart in this column");
360        return;
361    }
362    let max = rows.iter().map(|(_, v)| v.abs()).fold(f64::EPSILON, f64::max);
363    egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
364        let h = ROW_H * rows.len() as f32 + 8.0;
365        let (resp, painter) = ui.allocate_painter(egui::Vec2::new(ui.available_width(), h), egui::Sense::hover());
366        let rect = resp.rect;
367        let bar_w = (rect.width() - LABEL_W - 90.0).max(40.0);
368        for (i, (l, v)) in rows.iter().enumerate() {
369            let y = rect.top() + 4.0 + i as f32 * ROW_H;
370            painter.text(egui::Pos2::new(rect.left() + 4.0, y + ROW_H / 2.0), egui::Align2::LEFT_CENTER, l, egui::FontId::monospace(11.0), th.text_dim);
371            let w = ((v.abs() / max) as f32 * bar_w).max(1.0);
372            let bar = egui::Rect::from_min_size(egui::Pos2::new(rect.left() + LABEL_W, y + 2.0), egui::Vec2::new(w, ROW_H - 5.0));
373            painter.rect_filled(bar, 2.0, if *v < 0.0 { red } else { th.accent });
374            painter.text(bar.right_center() + egui::Vec2::new(6.0, 0.0), egui::Align2::LEFT_CENTER, format!("{v}"), egui::FontId::monospace(11.0), th.text_dim);
375        }
376    });
377}
378
379/// Char-safe truncation (never splits a multibyte char) for grid/chart cells.
380fn truncate(s: &str) -> String {
381    if s.chars().count() > CELL_MAX_CHARS {
382        let head: String = s.chars().take(CELL_MAX_CHARS).collect();
383        format!("{head}…")
384    } else {
385        s.to_string()
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn numeric_column_detection() {
395        let p = PreviewTable::new(
396            vec!["name".into(), "ms".into(), "mixed".into()],
397            vec![
398                vec!["a".into(), "1.0".into(), "1".into()],
399                vec!["b".into(), "2.5".into(), "x".into()],
400                vec!["c".into(), "3".into(), "y".into()],
401            ],
402        );
403        // col 1 is fully numeric; col 0 not; col 2 only 1/3 numeric (< half).
404        assert_eq!(p.numeric_columns(), vec![1]);
405    }
406
407    #[test]
408    fn select_switches_table_and_clears_chart_cols() {
409        let mut v = WarehouseTableView::local();
410        // default selection is the first added table.
411        assert_eq!(v.selected_name(), Some("bench_runs"));
412        v.chart_val = Some(1);
413        assert!(v.select("repos"));
414        assert_eq!(v.selected_name(), Some("repos"));
415        assert_eq!(v.chart_val, None, "switching tables clears stale chart cols");
416        assert!(!v.select("nope"));
417    }
418
419    /// inject-assert (LAW 6): feed real tables, assert `state_json` reports the
420    /// list, the selection, and the selected preview's shape + numeric columns.
421    #[test]
422    fn state_json_reports_tables_selection_and_preview() {
423        let v = WarehouseTableView::local();
424        let j = v.state_json();
425        assert_eq!(j["table_count"], 2);
426        assert_eq!(j["tables"], serde_json::json!(["bench_runs", "repos"]));
427        assert_eq!(j["selected"], "bench_runs");
428        assert_eq!(j["preview"]["loaded"], true);
429        assert_eq!(j["preview"]["rows"], 3);
430        assert_eq!(j["preview"]["columns"], serde_json::json!(["bench", "ms"]));
431        // the `ms` column (index 1) is detected numeric → chart-able.
432        assert_eq!(j["preview"]["numeric_columns"], serde_json::json!([1]));
433    }
434
435    #[test]
436    fn empty_view_reports_no_preview() {
437        let v = WarehouseTableView::new("Empty");
438        let j = v.state_json();
439        assert_eq!(j["table_count"], 0);
440        assert_eq!(j["selected"], serde_json::Value::Null);
441        assert_eq!(j["preview"]["loaded"], false);
442    }
443
444    #[test]
445    fn local_remote_titles() {
446        assert_eq!(<WarehouseTableView as Facet>::title(&WarehouseTableView::local()), "Warehouse");
447        assert_eq!(<WarehouseTableView as Facet>::title(&WarehouseTableView::remote()), "Warehouse (remote)");
448    }
449}