use facett_core::{Facet, FacetCaps, clipboard};
use std::collections::BTreeSet;
const CELL_MAX: usize = 48;
pub struct Table {
pub title: String,
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
selected: BTreeSet<usize>,
scale: f32,
}
impl Table {
pub fn new(title: impl Into<String>, columns: Vec<String>) -> Self {
Self { title: title.into(), columns, rows: Vec::new(), selected: BTreeSet::new(), scale: 1.0 }
}
pub fn push_row(&mut self, row: Vec<String>) {
self.rows.push(row);
}
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 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;
ui.label(format!("{} rows × {} cols · {} selected", self.rows.len(), self.columns.len(), self.selected.len()));
let ncols = self.columns.len().max(1);
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();
row.set_selected(self.selected.contains(&i));
for cell in &self.rows[i] {
row.col(|ui| {
ui.label(truncate(cell));
});
}
if row.response().clicked() {
toggle = Some(i);
}
});
});
if let Some(i) = toggle {
self.select_row(i);
}
}
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> {
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))
}
}
#[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 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 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);
}
}