use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::effects::FadeTrack;
use facett_core::{Facet, FacetCaps, Semantics, clipboard};
use std::collections::BTreeSet;
const ROW_FADE_SECS: f32 = 0.12;
pub mod warehouse;
pub use warehouse::{PreviewTable, WarehouseTableView};
const CELL_MAX: usize = 48;
pub type RowId = String;
pub struct Table {
pub title: String,
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
selected: BTreeSet<usize>,
row_ids: Vec<RowId>,
scale: f32,
hover_fades: FadeTrack,
sel_fades: FadeTrack,
}
impl Table {
pub fn new(title: impl Into<String>, columns: Vec<String>) -> Self {
Self {
title: title.into(),
columns,
rows: Vec::new(),
selected: BTreeSet::new(),
row_ids: Vec::new(),
scale: 1.0,
hover_fades: FadeTrack::default(),
sel_fades: FadeTrack::default(),
}
}
pub fn push_row(&mut self, row: Vec<String>) {
self.rows.push(row);
}
pub fn with_row_ids(mut self, ids: Vec<RowId>) -> Self {
self.row_ids = ids;
self
}
fn row_identity(&self, i: usize) -> String {
self.row_ids.get(i).cloned().unwrap_or_else(|| i.to_string())
}
fn row_label(&self, i: usize) -> String {
format!("row: {}", self.row_identity(i))
}
pub fn select_row(&mut self, i: usize) {
if i < self.rows.len() && !self.selected.insert(i) {
self.selected.remove(&i);
}
}
pub fn clear_selection(&mut self) {
self.selected.clear();
}
pub fn selected_rows(&self) -> Vec<usize> {
self.selected.iter().copied().collect()
}
fn copy_indices(&self) -> Vec<usize> {
if self.selected.is_empty() {
(0..self.rows.len()).collect()
} else {
self.selected.iter().copied().filter(|&i| i < self.rows.len()).collect()
}
}
}
fn truncate(s: &str) -> String {
if s.chars().count() <= CELL_MAX {
s.to_string()
} else {
let head: String = s.chars().take(CELL_MAX - 1).collect();
format!("{head}…")
}
}
impl Table {
pub fn copy_tsv(&self) -> Option<String> {
if self.rows.is_empty() {
return None;
}
let idx = self.copy_indices();
let rows = idx.into_iter().map(|i| self.rows[i].clone());
Some(clipboard::rows_to_tsv(&self.columns, rows))
}
}
impl CopySource for Table {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Rows, ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
self.copy_tsv().map(ClipPayload::Rows)
}
}
impl Facet for Table {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, ui: &mut egui::Ui) {
use egui_extras::{Column, TableBuilder};
let s = self.scale;
let dt = ui.input(|i| i.stable_dt);
let th = facett_core::theme(ui);
ui.label(format!("{} rows × {} cols · {} selected", self.rows.len(), self.columns.len(), self.selected.len()));
let ncols = self.columns.len().max(1);
let mut row_hi: Vec<(u64, egui::Rect, bool, bool)> = Vec::new();
let mut tb = TableBuilder::new(ui).striped(true).sense(egui::Sense::click());
for _ in 0..ncols {
tb = tb.column(Column::auto().at_least(60.0 * s).resizable(true));
}
let mut toggle: Option<usize> = None;
tb.header(20.0 * s, |mut header| {
for c in &self.columns {
header.col(|ui| {
ui.strong(c);
});
}
})
.body(|body| {
body.rows(18.0 * s, self.rows.len(), |mut row| {
let i = row.index();
let is_selected = self.selected.contains(&i);
row.set_selected(is_selected);
for cell in &self.rows[i] {
row.col(|ui| {
ui.label(truncate(cell));
});
}
let resp = row.response();
let sem = Semantics::list_item(self.row_label(i), is_selected);
resp.widget_info(|| sem.widget_info());
row_hi.push((FadeTrack::key(self.row_label(i)), resp.rect, resp.hovered(), is_selected));
if resp.clicked() {
toggle = Some(i);
}
});
});
self.hover_fades.begin();
self.sel_fades.begin();
for (key, _, hovered, selected) in &row_hi {
if *hovered {
self.hover_fades.lit(*key);
}
if *selected {
self.sel_fades.lit(*key);
}
}
let a = self.hover_fades.advance(dt, ROW_FADE_SECS);
let b = self.sel_fades.advance(dt, ROW_FADE_SECS);
{
let painter = ui.painter();
for (key, rect, _, _) in &row_hi {
let hf = self.hover_fades.factor(*key);
let sf = self.sel_fades.factor(*key);
if hf > 0.001 {
painter.rect_filled(*rect, 2.0, th.accent.linear_multiply((0.10 * hf).min(0.14)));
}
if sf > 0.01 {
painter.rect_stroke(
*rect,
2.0,
egui::Stroke::new(1.0, th.accent.linear_multiply(0.5 * sf)),
egui::StrokeKind::Inside,
);
}
}
}
if a || b {
ui.ctx().request_repaint();
}
if let Some(i) = toggle {
self.select_row(i);
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-table::Table::ui",
"ui_render",
self.selected.iter().all(|&i| i < self.rows.len()) && (self.rows.is_empty() || !self.columns.is_empty()),
&format!("rows={} cols={} selected={}", self.rows.len(), self.columns.len(), self.selected.len()),
);
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"columns": self.columns,
"rows": self.rows.len(),
"selected": self.selected_rows(),
"scale": self.scale,
})
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable().themeable()
}
fn scale(&self) -> f32 {
self.scale
}
fn set_scale(&mut self, scale: f32) {
self.scale = scale.clamp(0.25, 4.0);
}
fn selection_json(&self) -> serde_json::Value {
serde_json::json!(self.selected_rows())
}
fn copy(&mut self) -> Option<String> {
self.copy_tsv()
}
fn kind(&self) -> &'static str {
"table"
}
fn portable_state(&self) -> Option<serde_json::Value> {
Some(serde_json::json!({
"columns": self.columns,
"selected": self.selected_rows(),
"scale": self.scale,
}))
}
fn load_state(&mut self, state: &serde_json::Value) -> bool {
let obj = match state.as_object() {
Some(o) => o,
None => return false,
};
if let Some(cols) = obj.get("columns").and_then(|v| v.as_array()) {
self.columns = cols.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
}
if let Some(sel) = obj.get("selected").and_then(|v| v.as_array()) {
self.selected = sel
.iter()
.filter_map(|v| v.as_u64().map(|n| n as usize))
.filter(|&i| i < self.rows.len())
.collect();
}
if let Some(s) = obj.get("scale").and_then(|v| v.as_f64()) {
self.set_scale(s as f32);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_caps_long_cells() {
assert_eq!(truncate("short"), "short");
let long = "x".repeat(100);
let t = truncate(&long);
assert_eq!(t.chars().count(), CELL_MAX);
assert!(t.ends_with('…'));
}
#[test]
fn state_json_reports_shape() {
let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
t.push_row(vec!["knut".into(), "0.1".into()]);
t.push_row(vec!["korp".into(), "0.1".into()]);
let j = t.state_json();
assert_eq!(j["rows"], 2);
assert_eq!(j["columns"].as_array().unwrap().len(), 2);
}
fn repos() -> Table {
let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
t.push_row(vec!["knut".into(), "0.1".into()]);
t.push_row(vec!["korp".into(), "0.2".into()]);
t
}
#[test]
fn caps_declares_table_surface() {
let c = repos().caps();
assert!(c.selectable && c.copyable && c.searchable && c.scalable && c.resizable);
assert!(!c.pasteable && !c.cuttable);
}
#[test]
fn portable_state_round_trips_columns_selection_scale_between_instances() {
let mut a = Table::new("repos", vec!["version".into(), "name".into()]);
a.push_row(vec!["0.1".into(), "knut".into()]);
a.push_row(vec!["0.2".into(), "korp".into()]);
a.select_row(1);
a.set_scale(1.5);
let env = clipboard::encode_component(a.kind(), &a.portable_state().unwrap());
let (kind, state) = clipboard::decode_component(&env).unwrap();
assert_eq!(kind, "table");
let mut b = repos(); assert_ne!(b.portable_state(), a.portable_state(), "differ before paste");
assert!(b.load_state(&state));
assert_eq!(b.portable_state(), a.portable_state(), "B mirrors A's column order/selection/scale");
assert_eq!(b.columns, vec!["version".to_string(), "name".to_string()]);
assert_eq!(b.selected_rows(), vec![1]);
assert_eq!(b.scale(), 1.5);
}
#[test]
fn paste_component_clamps_a_selection_out_of_range() {
let a_state = serde_json::json!({ "columns": ["name", "version"], "selected": [5], "scale": 1.0 });
let mut b = repos();
assert!(b.load_state(&a_state));
assert!(b.selected_rows().is_empty(), "out-of-range selection is clamped away");
}
#[test]
fn typed_copy_is_the_selection_as_rows() {
use facett_core::clip::{ClipKind, CopySource};
let mut t = repos();
t.select_row(1);
let p = t.copy_payload().expect("selection copies");
assert_eq!(p.kind(), ClipKind::Rows);
assert_eq!(p.as_text(), "name\tversion\nkorp\t0.2");
assert!(Table::new("e", vec!["a".into()]).copy_payload().is_none());
}
#[test]
fn copy_all_rows_when_nothing_selected() {
let mut t = repos();
let tsv = t.copy().expect("non-empty table copies");
assert_eq!(tsv, "name\tversion\nknut\t0.1\nkorp\t0.2");
}
#[test]
fn copy_only_selected_rows() {
let mut t = repos();
t.select_row(1);
let tsv = t.copy().expect("selection copies");
assert_eq!(tsv, "name\tversion\nkorp\t0.2");
assert_eq!(t.selection_json(), serde_json::json!([1]));
}
#[test]
fn row_identity_falls_back_to_index_without_row_ids() {
let t = repos();
assert_eq!(t.row_identity(0), "0");
assert_eq!(t.row_identity(1), "1");
assert_eq!(t.row_label(0), "row: 0");
}
#[test]
fn with_row_ids_keys_identity_on_stable_id() {
let t = repos().with_row_ids(vec!["pkg-knut".into(), "pkg-korp".into()]);
assert_eq!(t.row_identity(0), "pkg-knut");
assert_eq!(t.row_identity(1), "pkg-korp");
assert_eq!(t.row_label(1), "row: pkg-korp");
let mut t = t;
t.select_row(0);
assert_eq!(t.selected_rows(), vec![0]);
}
#[test]
fn select_row_toggles() {
let mut t = repos();
t.select_row(0);
assert_eq!(t.selected_rows(), vec![0]);
t.select_row(0);
assert!(t.selected_rows().is_empty());
}
#[test]
fn cut_falls_back_to_copy_for_read_only_table() {
let mut t = repos();
let cut = t.cut().expect("cut delegates to copy");
assert_eq!(cut, "name\tversion\nknut\t0.1\nkorp\t0.2");
assert_eq!(t.rows.len(), 2, "cut must not remove rows on a read-only viewer");
}
#[test]
fn paste_is_rejected() {
let mut t = repos();
assert!(!t.paste("anything"), "read-only table does not consume paste");
}
#[test]
fn empty_table_copies_nothing() {
let mut t = Table::new("empty", vec!["a".into()]);
assert_eq!(t.copy(), None);
}
#[test]
fn set_scale_clamps() {
let mut t = repos();
t.set_scale(99.0);
assert_eq!(t.scale(), 4.0);
t.set_scale(0.001);
assert_eq!(t.scale(), 0.25);
assert_eq!(t.state_json()["scale"], 0.25);
}
}