1use 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#[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 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 pub fn numeric_columns(&self) -> Vec<usize> {
51 (0..self.columns.len()).filter(|&i| self.col_is_numeric(i)).collect()
52 }
53}
54
55pub struct WarehouseTableView {
57 title: String,
58 tables: Vec<(String, PreviewTable)>,
59 selected: Option<usize>,
60 chart: bool,
62 chart_val: Option<usize>,
63 chart_label: Option<usize>,
64 grid_scroll_x: SmoothScroll,
67 grid_scroll_y: SmoothScroll,
68}
69
70impl WarehouseTableView {
71 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 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 pub fn set_title(&mut self, title: impl Into<String>) {
97 self.title = title.into();
98 }
99
100 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 pub fn table_names(&self) -> Vec<String> {
119 self.tables.iter().map(|(n, _)| n.clone()).collect()
120 }
121
122 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 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 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 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 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 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
335 Some(self)
336 }
337}
338
339fn 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
379fn 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 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 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 #[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 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}