1use facett_core::{Facet, FacetCaps, Semantics, clipboard};
7use std::collections::BTreeSet;
8
9const CELL_MAX: usize = 48;
10
11pub type RowId = String;
15
16pub struct Table {
18 pub title: String,
19 pub columns: Vec<String>,
20 pub rows: Vec<Vec<String>>,
21 selected: BTreeSet<usize>,
24 row_ids: Vec<RowId>,
28 scale: f32,
30}
31
32impl Table {
33 pub fn new(title: impl Into<String>, columns: Vec<String>) -> Self {
34 Self {
35 title: title.into(),
36 columns,
37 rows: Vec::new(),
38 selected: BTreeSet::new(),
39 row_ids: Vec::new(),
40 scale: 1.0,
41 }
42 }
43 pub fn push_row(&mut self, row: Vec<String>) {
44 self.rows.push(row);
45 }
46
47 pub fn with_row_ids(mut self, ids: Vec<RowId>) -> Self {
52 self.row_ids = ids;
53 self
54 }
55
56 fn row_identity(&self, i: usize) -> String {
60 self.row_ids.get(i).cloned().unwrap_or_else(|| i.to_string())
61 }
62
63 fn row_label(&self, i: usize) -> String {
68 format!("row: {}", self.row_identity(i))
69 }
70
71 pub fn select_row(&mut self, i: usize) {
73 if i < self.rows.len() && !self.selected.insert(i) {
74 self.selected.remove(&i);
75 }
76 }
77 pub fn clear_selection(&mut self) {
79 self.selected.clear();
80 }
81 pub fn selected_rows(&self) -> Vec<usize> {
83 self.selected.iter().copied().collect()
84 }
85
86 fn copy_indices(&self) -> Vec<usize> {
88 if self.selected.is_empty() {
89 (0..self.rows.len()).collect()
90 } else {
91 self.selected.iter().copied().filter(|&i| i < self.rows.len()).collect()
92 }
93 }
94}
95
96fn truncate(s: &str) -> String {
97 if s.chars().count() <= CELL_MAX {
98 s.to_string()
99 } else {
100 let head: String = s.chars().take(CELL_MAX - 1).collect();
101 format!("{head}…")
102 }
103}
104
105impl Facet for Table {
106 fn title(&self) -> &str {
107 &self.title
108 }
109 fn ui(&mut self, ui: &mut egui::Ui) {
110 use egui_extras::{Column, TableBuilder};
111 let s = self.scale;
112 ui.label(format!("{} rows × {} cols · {} selected", self.rows.len(), self.columns.len(), self.selected.len()));
113 let ncols = self.columns.len().max(1);
114 let mut tb = TableBuilder::new(ui).striped(true).sense(egui::Sense::click());
115 for _ in 0..ncols {
116 tb = tb.column(Column::auto().at_least(60.0 * s).resizable(true));
117 }
118 let mut toggle: Option<usize> = None;
119 tb.header(20.0 * s, |mut header| {
120 for c in &self.columns {
121 header.col(|ui| {
122 ui.strong(c);
123 });
124 }
125 })
126 .body(|body| {
129 body.rows(18.0 * s, self.rows.len(), |mut row| {
130 let i = row.index();
131 let is_selected = self.selected.contains(&i);
132 row.set_selected(is_selected);
133 for cell in &self.rows[i] {
134 row.col(|ui| {
135 ui.label(truncate(cell));
136 });
137 }
138 let resp = row.response();
139 let sem = Semantics::list_item(self.row_label(i), is_selected);
147 resp.widget_info(|| sem.widget_info());
148 if resp.clicked() {
149 toggle = Some(i);
150 }
151 });
152 });
153 if let Some(i) = toggle {
154 self.select_row(i);
155 }
156 }
157 fn state_json(&self) -> serde_json::Value {
158 serde_json::json!({
159 "columns": self.columns,
160 "rows": self.rows.len(),
161 "selected": self.selected_rows(),
162 "scale": self.scale,
163 })
164 }
165
166 fn caps(&self) -> FacetCaps {
167 FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable().themeable()
170 }
171
172 fn scale(&self) -> f32 {
173 self.scale
174 }
175 fn set_scale(&mut self, scale: f32) {
176 self.scale = scale.clamp(0.25, 4.0);
177 }
178
179 fn selection_json(&self) -> serde_json::Value {
180 serde_json::json!(self.selected_rows())
181 }
182
183 fn copy(&mut self) -> Option<String> {
186 if self.rows.is_empty() {
187 return None;
188 }
189 let idx = self.copy_indices();
190 let rows = idx.into_iter().map(|i| self.rows[i].clone());
191 Some(clipboard::rows_to_tsv(&self.columns, rows))
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn truncate_caps_long_cells() {
201 assert_eq!(truncate("short"), "short");
202 let long = "x".repeat(100);
203 let t = truncate(&long);
204 assert_eq!(t.chars().count(), CELL_MAX);
205 assert!(t.ends_with('…'));
206 }
207
208 #[test]
209 fn state_json_reports_shape() {
210 let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
211 t.push_row(vec!["knut".into(), "0.1".into()]);
212 t.push_row(vec!["korp".into(), "0.1".into()]);
213 let j = t.state_json();
214 assert_eq!(j["rows"], 2);
215 assert_eq!(j["columns"].as_array().unwrap().len(), 2);
216 }
217
218 fn repos() -> Table {
219 let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
220 t.push_row(vec!["knut".into(), "0.1".into()]);
221 t.push_row(vec!["korp".into(), "0.2".into()]);
222 t
223 }
224
225 #[test]
226 fn caps_declares_table_surface() {
227 let c = repos().caps();
228 assert!(c.selectable && c.copyable && c.searchable && c.scalable && c.resizable);
229 assert!(!c.pasteable && !c.cuttable);
230 }
231
232 #[test]
233 fn copy_all_rows_when_nothing_selected() {
234 let mut t = repos();
235 let tsv = t.copy().expect("non-empty table copies");
236 assert_eq!(tsv, "name\tversion\nknut\t0.1\nkorp\t0.2");
237 }
238
239 #[test]
240 fn copy_only_selected_rows() {
241 let mut t = repos();
242 t.select_row(1);
243 let tsv = t.copy().expect("selection copies");
244 assert_eq!(tsv, "name\tversion\nkorp\t0.2");
245 assert_eq!(t.selection_json(), serde_json::json!([1]));
246 }
247
248 #[test]
249 fn row_identity_falls_back_to_index_without_row_ids() {
250 let t = repos();
251 assert_eq!(t.row_identity(0), "0");
253 assert_eq!(t.row_identity(1), "1");
254 assert_eq!(t.row_label(0), "row: 0");
255 }
256
257 #[test]
258 fn with_row_ids_keys_identity_on_stable_id() {
259 let t = repos().with_row_ids(vec!["pkg-knut".into(), "pkg-korp".into()]);
260 assert_eq!(t.row_identity(0), "pkg-knut");
262 assert_eq!(t.row_identity(1), "pkg-korp");
263 assert_eq!(t.row_label(1), "row: pkg-korp");
264 let mut t = t;
266 t.select_row(0);
267 assert_eq!(t.selected_rows(), vec![0]);
268 }
269
270 #[test]
271 fn select_row_toggles() {
272 let mut t = repos();
273 t.select_row(0);
274 assert_eq!(t.selected_rows(), vec![0]);
275 t.select_row(0);
276 assert!(t.selected_rows().is_empty());
277 }
278
279 #[test]
280 fn cut_falls_back_to_copy_for_read_only_table() {
281 let mut t = repos();
282 let cut = t.cut().expect("cut delegates to copy");
284 assert_eq!(cut, "name\tversion\nknut\t0.1\nkorp\t0.2");
285 assert_eq!(t.rows.len(), 2, "cut must not remove rows on a read-only viewer");
286 }
287
288 #[test]
289 fn paste_is_rejected() {
290 let mut t = repos();
291 assert!(!t.paste("anything"), "read-only table does not consume paste");
292 }
293
294 #[test]
295 fn empty_table_copies_nothing() {
296 let mut t = Table::new("empty", vec!["a".into()]);
297 assert_eq!(t.copy(), None);
298 }
299
300 #[test]
301 fn set_scale_clamps() {
302 let mut t = repos();
303 t.set_scale(99.0);
304 assert_eq!(t.scale(), 4.0);
305 t.set_scale(0.001);
306 assert_eq!(t.scale(), 0.25);
307 assert_eq!(t.state_json()["scale"], 0.25);
308 }
309}