use facett_core::scroll_engine::{SmoothScroll, smooth_scroll_area};
use facett_core::{Facet, FacetCaps, Semantics};
use serde::{Deserialize, Serialize};
const CELL_MAX_CHARS: usize = 80;
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct PreviewTable {
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
}
impl PreviewTable {
pub fn new(columns: Vec<String>, rows: Vec<Vec<String>>) -> Self {
Self { columns, rows }
}
fn col_is_numeric(&self, col: usize) -> bool {
let (mut seen, mut ok) = (0usize, 0usize);
for row in &self.rows {
let cell = row.get(col).map(String::as_str).unwrap_or("");
if cell.is_empty() {
continue;
}
seen += 1;
if cell.trim().parse::<f64>().is_ok() {
ok += 1;
}
}
ok > 0 && ok * 2 >= seen
}
pub fn numeric_columns(&self) -> Vec<usize> {
(0..self.columns.len()).filter(|&i| self.col_is_numeric(i)).collect()
}
}
pub struct WarehouseTableView {
title: String,
tables: Vec<(String, PreviewTable)>,
selected: Option<usize>,
chart: bool,
chart_val: Option<usize>,
chart_label: Option<usize>,
grid_scroll_x: SmoothScroll,
grid_scroll_y: SmoothScroll,
}
impl WarehouseTableView {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
tables: Vec::new(),
selected: None,
chart: false,
chart_val: None,
chart_label: None,
grid_scroll_x: SmoothScroll::default(),
grid_scroll_y: SmoothScroll::default(),
}
}
pub fn with_table(mut self, name: impl Into<String>, table: PreviewTable) -> Self {
if self.tables.is_empty() {
self.selected = Some(0);
}
self.tables.push((name.into(), table));
self
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
}
pub fn set_table(&mut self, name: impl Into<String>, table: PreviewTable) {
let name = name.into();
if let Some(slot) = self.tables.iter_mut().find(|(n, _)| *n == name) {
slot.1 = table;
} else {
if self.tables.is_empty() {
self.selected = Some(0);
}
self.tables.push((name, table));
}
}
pub fn table_names(&self) -> Vec<String> {
self.tables.iter().map(|(n, _)| n.clone()).collect()
}
pub fn selected_name(&self) -> Option<&str> {
self.selected.and_then(|i| self.tables.get(i)).map(|(n, _)| n.as_str())
}
pub fn select(&mut self, name: &str) -> bool {
match self.tables.iter().position(|(n, _)| n == name) {
Some(i) => {
self.selected = Some(i);
self.chart_val = None;
self.chart_label = None;
true
}
None => false,
}
}
fn current(&self) -> Option<&PreviewTable> {
self.selected.and_then(|i| self.tables.get(i)).map(|(_, t)| t)
}
pub fn local() -> Self {
Self::new("Warehouse")
.with_table(
"bench_runs",
PreviewTable::new(
vec!["bench".into(), "ms".into()],
vec![
vec!["xml_parse".into(), "12.4".into()],
vec!["json_parse".into(), "8.1".into()],
vec!["zstd_decode".into(), "31.7".into()],
],
),
)
.with_table(
"repos",
PreviewTable::new(
vec!["name".into(), "lang".into()],
vec![vec!["nornir".into(), "rust".into()], vec!["facett".into(), "rust".into()]],
),
)
}
pub fn remote() -> Self {
let mut v = Self::local();
v.title = "Warehouse (remote)".into();
v
}
}
const ROW_H: f32 = 18.0;
const LABEL_W: f32 = 230.0;
impl Facet for WarehouseTableView {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, ui: &mut egui::Ui) {
egui::Panel::left("wh_tables").default_size(220.0).show_inside(ui, |ui| {
ui.heading(format!("tables ({})", self.tables.len()));
ui.separator();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
let names: Vec<String> = self.table_names();
for name in &names {
let selected = self.selected_name() == Some(name.as_str());
if ui.selectable_label(selected, name).clicked() {
self.select(name);
}
}
});
});
egui::CentralPanel::default().show_inside(ui, |ui| {
let th = facett_core::theme(ui);
let Some(p) = self.current().cloned() else {
ui.label("select a table on the left to view its rows");
return;
};
let table_name = self.selected_name().unwrap_or("").to_string();
let ncols = p.columns.len();
let numeric = p.numeric_columns();
let mut chart = self.chart;
let mut chart_val = self.chart_val.filter(|&i| i < ncols);
let mut chart_label = self.chart_label.filter(|&i| i < ncols);
ui.horizontal(|ui| {
ui.heading(&table_name);
ui.label(format!("· {} rows · {} columns", p.rows.len(), ncols));
if !numeric.is_empty() {
ui.separator();
ui.checkbox(&mut chart, "📊 chart");
}
});
if chart && !numeric.is_empty() {
if chart_val.map(|i| !numeric.contains(&i)).unwrap_or(true) {
chart_val = numeric.first().copied();
}
if chart_label.is_none() {
chart_label = (0..ncols).find(|i| !numeric.contains(i));
}
ui.horizontal(|ui| {
ui.label("value:");
egui::ComboBox::from_id_salt("wh_chart_val")
.selected_text(chart_val.map(|i| p.columns[i].clone()).unwrap_or_default())
.show_ui(ui, |ui| {
for &i in &numeric {
ui.selectable_value(&mut chart_val, Some(i), &p.columns[i]);
}
});
ui.label("label:");
egui::ComboBox::from_id_salt("wh_chart_label")
.selected_text(chart_label.map(|i| p.columns[i].clone()).unwrap_or("(row #)".into()))
.show_ui(ui, |ui| {
ui.selectable_value(&mut chart_label, None, "(row #)");
for i in 0..ncols {
ui.selectable_value(&mut chart_label, Some(i), &p.columns[i]);
}
});
});
ui.separator();
if let Some(v) = chart_val {
draw_bar_chart(ui, &p, v, chart_label, &th);
}
} else {
ui.separator();
let base_id = ui.id().with("wh-grid");
let dt = ui.input(|i| i.stable_dt);
let (mut gx, mut gy) = (self.grid_scroll_x, self.grid_scroll_y);
smooth_scroll_area(
ui,
egui::ScrollArea::both().auto_shrink([false, false]),
dt,
&mut gy,
Some(&mut gx),
|ui| {
egui::Grid::new("wh_grid").striped(true).num_columns(ncols.max(1)).show(ui, |ui| {
for c in &p.columns {
ui.strong(c);
}
ui.end_row();
for (ri, row) in p.rows.iter().enumerate() {
for cell in row {
let resp = ui
.push_id(facett_core::stable_id(base_id, &ri.to_string()), |ui| ui.label(truncate(cell)))
.inner;
let txt = truncate(cell);
resp.widget_info(move || Semantics::new(egui::WidgetType::Label, txt.clone()).widget_info());
}
ui.end_row();
}
});
},
);
self.grid_scroll_x = gx;
self.grid_scroll_y = gy;
}
self.chart = chart;
self.chart_val = chart_val;
self.chart_label = chart_label;
});
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-table::WarehouseTableView::ui",
"ui_render",
true,
&format!("tables={} selected={:?} chart={}", self.tables.len(), self.selected_name(), self.chart),
);
}
fn state_json(&self) -> serde_json::Value {
let preview = match self.current() {
None => serde_json::json!({ "loaded": false }),
Some(p) => serde_json::json!({
"loaded": true,
"columns": p.columns,
"rows": p.rows.len(),
"numeric_columns": p.numeric_columns(),
}),
};
serde_json::json!({
"title": self.title,
"tables": self.table_names(),
"table_count": self.tables.len(),
"selected": self.selected_name(),
"chart": { "on": self.chart, "value_col": self.chart_val, "label_col": self.chart_label },
"preview": preview,
})
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.selectable().searchable().resizable().themeable()
}
fn selection_json(&self) -> serde_json::Value {
serde_json::json!(self.selected_name())
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
fn draw_bar_chart(ui: &mut egui::Ui, p: &PreviewTable, val: usize, label: Option<usize>, th: &facett_core::theme::Theme) {
let red = egui::Color32::from_rgb(224, 90, 90);
let rows: Vec<(String, f64)> = p
.rows
.iter()
.enumerate()
.filter_map(|(i, row)| {
let v: f64 = row.get(val)?.trim().parse().ok()?;
let l = match label {
Some(li) => truncate(row.get(li).map(String::as_str).unwrap_or("")),
None => format!("#{i}"),
};
Some((l, v))
})
.take(120)
.collect();
if rows.is_empty() {
ui.label("no numeric values to chart in this column");
return;
}
let max = rows.iter().map(|(_, v)| v.abs()).fold(f64::EPSILON, f64::max);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
let h = ROW_H * rows.len() as f32 + 8.0;
let (resp, painter) = ui.allocate_painter(egui::Vec2::new(ui.available_width(), h), egui::Sense::hover());
let rect = resp.rect;
let bar_w = (rect.width() - LABEL_W - 90.0).max(40.0);
for (i, (l, v)) in rows.iter().enumerate() {
let y = rect.top() + 4.0 + i as f32 * ROW_H;
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);
let w = ((v.abs() / max) as f32 * bar_w).max(1.0);
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));
painter.rect_filled(bar, 2.0, if *v < 0.0 { red } else { th.accent });
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);
}
});
}
fn truncate(s: &str) -> String {
if s.chars().count() > CELL_MAX_CHARS {
let head: String = s.chars().take(CELL_MAX_CHARS).collect();
format!("{head}…")
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numeric_column_detection() {
let p = PreviewTable::new(
vec!["name".into(), "ms".into(), "mixed".into()],
vec![
vec!["a".into(), "1.0".into(), "1".into()],
vec!["b".into(), "2.5".into(), "x".into()],
vec!["c".into(), "3".into(), "y".into()],
],
);
assert_eq!(p.numeric_columns(), vec![1]);
}
#[test]
fn select_switches_table_and_clears_chart_cols() {
let mut v = WarehouseTableView::local();
assert_eq!(v.selected_name(), Some("bench_runs"));
v.chart_val = Some(1);
assert!(v.select("repos"));
assert_eq!(v.selected_name(), Some("repos"));
assert_eq!(v.chart_val, None, "switching tables clears stale chart cols");
assert!(!v.select("nope"));
}
#[test]
fn state_json_reports_tables_selection_and_preview() {
let v = WarehouseTableView::local();
let j = v.state_json();
assert_eq!(j["table_count"], 2);
assert_eq!(j["tables"], serde_json::json!(["bench_runs", "repos"]));
assert_eq!(j["selected"], "bench_runs");
assert_eq!(j["preview"]["loaded"], true);
assert_eq!(j["preview"]["rows"], 3);
assert_eq!(j["preview"]["columns"], serde_json::json!(["bench", "ms"]));
assert_eq!(j["preview"]["numeric_columns"], serde_json::json!([1]));
}
#[test]
fn empty_view_reports_no_preview() {
let v = WarehouseTableView::new("Empty");
let j = v.state_json();
assert_eq!(j["table_count"], 0);
assert_eq!(j["selected"], serde_json::Value::Null);
assert_eq!(j["preview"]["loaded"], false);
}
#[test]
fn local_remote_titles() {
assert_eq!(<WarehouseTableView as Facet>::title(&WarehouseTableView::local()), "Warehouse");
assert_eq!(<WarehouseTableView as Facet>::title(&WarehouseTableView::remote()), "Warehouse (remote)");
}
}