1use facett_core::{Facet, FacetCaps, clipboard};
7use std::collections::BTreeSet;
8
9const CELL_MAX: usize = 48;
10
11pub struct Table {
13 pub title: String,
14 pub columns: Vec<String>,
15 pub rows: Vec<Vec<String>>,
16 selected: BTreeSet<usize>,
19 scale: f32,
21}
22
23impl Table {
24 pub fn new(title: impl Into<String>, columns: Vec<String>) -> Self {
25 Self { title: title.into(), columns, rows: Vec::new(), selected: BTreeSet::new(), scale: 1.0 }
26 }
27 pub fn push_row(&mut self, row: Vec<String>) {
28 self.rows.push(row);
29 }
30
31 pub fn select_row(&mut self, i: usize) {
33 if i < self.rows.len() && !self.selected.insert(i) {
34 self.selected.remove(&i);
35 }
36 }
37 pub fn clear_selection(&mut self) {
39 self.selected.clear();
40 }
41 pub fn selected_rows(&self) -> Vec<usize> {
43 self.selected.iter().copied().collect()
44 }
45
46 fn copy_indices(&self) -> Vec<usize> {
48 if self.selected.is_empty() {
49 (0..self.rows.len()).collect()
50 } else {
51 self.selected.iter().copied().filter(|&i| i < self.rows.len()).collect()
52 }
53 }
54}
55
56fn truncate(s: &str) -> String {
57 if s.chars().count() <= CELL_MAX {
58 s.to_string()
59 } else {
60 let head: String = s.chars().take(CELL_MAX - 1).collect();
61 format!("{head}…")
62 }
63}
64
65impl Facet for Table {
66 fn title(&self) -> &str {
67 &self.title
68 }
69 fn ui(&mut self, ui: &mut egui::Ui) {
70 use egui_extras::{Column, TableBuilder};
71 let s = self.scale;
72 ui.label(format!("{} rows × {} cols · {} selected", self.rows.len(), self.columns.len(), self.selected.len()));
73 let ncols = self.columns.len().max(1);
74 let mut tb = TableBuilder::new(ui).striped(true).sense(egui::Sense::click());
75 for _ in 0..ncols {
76 tb = tb.column(Column::auto().at_least(60.0 * s).resizable(true));
77 }
78 let mut toggle: Option<usize> = None;
79 tb.header(20.0 * s, |mut header| {
80 for c in &self.columns {
81 header.col(|ui| {
82 ui.strong(c);
83 });
84 }
85 })
86 .body(|body| {
89 body.rows(18.0 * s, self.rows.len(), |mut row| {
90 let i = row.index();
91 row.set_selected(self.selected.contains(&i));
92 for cell in &self.rows[i] {
93 row.col(|ui| {
94 ui.label(truncate(cell));
95 });
96 }
97 if row.response().clicked() {
98 toggle = Some(i);
99 }
100 });
101 });
102 if let Some(i) = toggle {
103 self.select_row(i);
104 }
105 }
106 fn state_json(&self) -> serde_json::Value {
107 serde_json::json!({
108 "columns": self.columns,
109 "rows": self.rows.len(),
110 "selected": self.selected_rows(),
111 "scale": self.scale,
112 })
113 }
114
115 fn caps(&self) -> FacetCaps {
116 FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable().themeable()
119 }
120
121 fn scale(&self) -> f32 {
122 self.scale
123 }
124 fn set_scale(&mut self, scale: f32) {
125 self.scale = scale.clamp(0.25, 4.0);
126 }
127
128 fn selection_json(&self) -> serde_json::Value {
129 serde_json::json!(self.selected_rows())
130 }
131
132 fn copy(&mut self) -> Option<String> {
135 if self.rows.is_empty() {
136 return None;
137 }
138 let idx = self.copy_indices();
139 let rows = idx.into_iter().map(|i| self.rows[i].clone());
140 Some(clipboard::rows_to_tsv(&self.columns, rows))
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn truncate_caps_long_cells() {
150 assert_eq!(truncate("short"), "short");
151 let long = "x".repeat(100);
152 let t = truncate(&long);
153 assert_eq!(t.chars().count(), CELL_MAX);
154 assert!(t.ends_with('…'));
155 }
156
157 #[test]
158 fn state_json_reports_shape() {
159 let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
160 t.push_row(vec!["knut".into(), "0.1".into()]);
161 t.push_row(vec!["korp".into(), "0.1".into()]);
162 let j = t.state_json();
163 assert_eq!(j["rows"], 2);
164 assert_eq!(j["columns"].as_array().unwrap().len(), 2);
165 }
166
167 fn repos() -> Table {
168 let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
169 t.push_row(vec!["knut".into(), "0.1".into()]);
170 t.push_row(vec!["korp".into(), "0.2".into()]);
171 t
172 }
173
174 #[test]
175 fn caps_declares_table_surface() {
176 let c = repos().caps();
177 assert!(c.selectable && c.copyable && c.searchable && c.scalable && c.resizable);
178 assert!(!c.pasteable && !c.cuttable);
179 }
180
181 #[test]
182 fn copy_all_rows_when_nothing_selected() {
183 let mut t = repos();
184 let tsv = t.copy().expect("non-empty table copies");
185 assert_eq!(tsv, "name\tversion\nknut\t0.1\nkorp\t0.2");
186 }
187
188 #[test]
189 fn copy_only_selected_rows() {
190 let mut t = repos();
191 t.select_row(1);
192 let tsv = t.copy().expect("selection copies");
193 assert_eq!(tsv, "name\tversion\nkorp\t0.2");
194 assert_eq!(t.selection_json(), serde_json::json!([1]));
195 }
196
197 #[test]
198 fn select_row_toggles() {
199 let mut t = repos();
200 t.select_row(0);
201 assert_eq!(t.selected_rows(), vec![0]);
202 t.select_row(0);
203 assert!(t.selected_rows().is_empty());
204 }
205
206 #[test]
207 fn cut_falls_back_to_copy_for_read_only_table() {
208 let mut t = repos();
209 let cut = t.cut().expect("cut delegates to copy");
211 assert_eq!(cut, "name\tversion\nknut\t0.1\nkorp\t0.2");
212 assert_eq!(t.rows.len(), 2, "cut must not remove rows on a read-only viewer");
213 }
214
215 #[test]
216 fn paste_is_rejected() {
217 let mut t = repos();
218 assert!(!t.paste("anything"), "read-only table does not consume paste");
219 }
220
221 #[test]
222 fn empty_table_copies_nothing() {
223 let mut t = Table::new("empty", vec!["a".into()]);
224 assert_eq!(t.copy(), None);
225 }
226
227 #[test]
228 fn set_scale_clamps() {
229 let mut t = repos();
230 t.set_scale(99.0);
231 assert_eq!(t.scale(), 4.0);
232 t.set_scale(0.001);
233 assert_eq!(t.scale(), 0.25);
234 assert_eq!(t.state_json()["scale"], 0.25);
235 }
236}