1use facett_core::clip::{ClipKind, ClipPayload, CopySource};
7use facett_core::effects::FadeTrack;
8use facett_core::{Facet, FacetCaps, Semantics, clipboard};
9use std::collections::BTreeSet;
10
11const ROW_FADE_SECS: f32 = 0.12;
13
14pub mod warehouse;
18pub use warehouse::{PreviewTable, WarehouseTableView};
19
20const CELL_MAX: usize = 48;
21
22pub type RowId = String;
26
27pub struct Table {
29 pub title: String,
30 pub columns: Vec<String>,
31 pub rows: Vec<Vec<String>>,
32 selected: BTreeSet<usize>,
35 row_ids: Vec<RowId>,
39 scale: f32,
41 hover_fades: FadeTrack,
44 sel_fades: FadeTrack,
45}
46
47impl Table {
48 pub fn new(title: impl Into<String>, columns: Vec<String>) -> Self {
49 Self {
50 title: title.into(),
51 columns,
52 rows: Vec::new(),
53 selected: BTreeSet::new(),
54 row_ids: Vec::new(),
55 scale: 1.0,
56 hover_fades: FadeTrack::default(),
57 sel_fades: FadeTrack::default(),
58 }
59 }
60 pub fn push_row(&mut self, row: Vec<String>) {
61 self.rows.push(row);
62 }
63
64 pub fn with_row_ids(mut self, ids: Vec<RowId>) -> Self {
69 self.row_ids = ids;
70 self
71 }
72
73 fn row_identity(&self, i: usize) -> String {
77 self.row_ids.get(i).cloned().unwrap_or_else(|| i.to_string())
78 }
79
80 fn row_label(&self, i: usize) -> String {
85 format!("row: {}", self.row_identity(i))
86 }
87
88 pub fn select_row(&mut self, i: usize) {
90 if i < self.rows.len() && !self.selected.insert(i) {
91 self.selected.remove(&i);
92 }
93 }
94 pub fn clear_selection(&mut self) {
96 self.selected.clear();
97 }
98 pub fn selected_rows(&self) -> Vec<usize> {
100 self.selected.iter().copied().collect()
101 }
102
103 fn copy_indices(&self) -> Vec<usize> {
105 if self.selected.is_empty() {
106 (0..self.rows.len()).collect()
107 } else {
108 self.selected.iter().copied().filter(|&i| i < self.rows.len()).collect()
109 }
110 }
111}
112
113fn truncate(s: &str) -> String {
114 if s.chars().count() <= CELL_MAX {
115 s.to_string()
116 } else {
117 let head: String = s.chars().take(CELL_MAX - 1).collect();
118 format!("{head}…")
119 }
120}
121
122impl Table {
123 pub fn copy_tsv(&self) -> Option<String> {
126 if self.rows.is_empty() {
127 return None;
128 }
129 let idx = self.copy_indices();
130 let rows = idx.into_iter().map(|i| self.rows[i].clone());
131 Some(clipboard::rows_to_tsv(&self.columns, rows))
132 }
133}
134
135impl CopySource for Table {
137 fn copy_kinds(&self) -> &[ClipKind] {
138 &[ClipKind::Rows, ClipKind::Text]
139 }
140 fn copy_payload(&self) -> Option<ClipPayload> {
141 self.copy_tsv().map(ClipPayload::Rows)
142 }
143}
144
145impl Facet for Table {
146 fn title(&self) -> &str {
147 &self.title
148 }
149 fn ui(&mut self, ui: &mut egui::Ui) {
150 use egui_extras::{Column, TableBuilder};
151 let s = self.scale;
152 let dt = ui.input(|i| i.stable_dt);
153 let th = facett_core::theme(ui);
154 ui.label(format!("{} rows × {} cols · {} selected", self.rows.len(), self.columns.len(), self.selected.len()));
155 let ncols = self.columns.len().max(1);
156 let mut row_hi: Vec<(u64, egui::Rect, bool, bool)> = Vec::new();
160 let mut tb = TableBuilder::new(ui).striped(true).sense(egui::Sense::click());
161 for _ in 0..ncols {
162 tb = tb.column(Column::auto().at_least(60.0 * s).resizable(true));
163 }
164 let mut toggle: Option<usize> = None;
165 tb.header(20.0 * s, |mut header| {
166 for c in &self.columns {
167 header.col(|ui| {
168 ui.strong(c);
169 });
170 }
171 })
172 .body(|body| {
175 body.rows(18.0 * s, self.rows.len(), |mut row| {
176 let i = row.index();
177 let is_selected = self.selected.contains(&i);
178 row.set_selected(is_selected);
179 for cell in &self.rows[i] {
180 row.col(|ui| {
181 ui.label(truncate(cell));
182 });
183 }
184 let resp = row.response();
185 let sem = Semantics::list_item(self.row_label(i), is_selected);
193 resp.widget_info(|| sem.widget_info());
194 row_hi.push((FadeTrack::key(self.row_label(i)), resp.rect, resp.hovered(), is_selected));
197 if resp.clicked() {
198 toggle = Some(i);
199 }
200 });
201 });
202
203 self.hover_fades.begin();
208 self.sel_fades.begin();
209 for (key, _, hovered, selected) in &row_hi {
210 if *hovered {
211 self.hover_fades.lit(*key);
212 }
213 if *selected {
214 self.sel_fades.lit(*key);
215 }
216 }
217 let a = self.hover_fades.advance(dt, ROW_FADE_SECS);
218 let b = self.sel_fades.advance(dt, ROW_FADE_SECS);
219 {
220 let painter = ui.painter();
221 for (key, rect, _, _) in &row_hi {
222 let hf = self.hover_fades.factor(*key);
223 let sf = self.sel_fades.factor(*key);
224 if hf > 0.001 {
225 painter.rect_filled(*rect, 2.0, th.accent.linear_multiply((0.10 * hf).min(0.14)));
226 }
227 if sf > 0.01 {
228 painter.rect_stroke(
229 *rect,
230 2.0,
231 egui::Stroke::new(1.0, th.accent.linear_multiply(0.5 * sf)),
232 egui::StrokeKind::Inside,
233 );
234 }
235 }
236 }
237 if a || b {
238 ui.ctx().request_repaint();
239 }
240
241 if let Some(i) = toggle {
242 self.select_row(i);
243 }
244
245 #[cfg(feature = "testmatrix")]
247 facett_core::testmatrix::emit(
248 "facett-table::Table::ui",
249 "ui_render",
250 self.selected.iter().all(|&i| i < self.rows.len()) && (self.rows.is_empty() || !self.columns.is_empty()),
253 &format!("rows={} cols={} selected={}", self.rows.len(), self.columns.len(), self.selected.len()),
254 );
255 }
256 fn state_json(&self) -> serde_json::Value {
257 serde_json::json!({
258 "columns": self.columns,
259 "rows": self.rows.len(),
260 "selected": self.selected_rows(),
261 "scale": self.scale,
262 })
263 }
264
265 fn caps(&self) -> FacetCaps {
266 FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable().themeable()
269 }
270
271 fn scale(&self) -> f32 {
272 self.scale
273 }
274 fn set_scale(&mut self, scale: f32) {
275 self.scale = scale.clamp(0.25, 4.0);
276 }
277
278 fn selection_json(&self) -> serde_json::Value {
279 serde_json::json!(self.selected_rows())
280 }
281
282 fn copy(&mut self) -> Option<String> {
285 self.copy_tsv()
286 }
287
288 fn kind(&self) -> &'static str {
291 "table"
292 }
293
294 fn portable_state(&self) -> Option<serde_json::Value> {
299 Some(serde_json::json!({
300 "columns": self.columns,
301 "selected": self.selected_rows(),
302 "scale": self.scale,
303 }))
304 }
305
306 fn load_state(&mut self, state: &serde_json::Value) -> bool {
307 let obj = match state.as_object() {
308 Some(o) => o,
309 None => return false,
310 };
311 if let Some(cols) = obj.get("columns").and_then(|v| v.as_array()) {
312 self.columns = cols.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
313 }
314 if let Some(sel) = obj.get("selected").and_then(|v| v.as_array()) {
315 self.selected = sel
318 .iter()
319 .filter_map(|v| v.as_u64().map(|n| n as usize))
320 .filter(|&i| i < self.rows.len())
321 .collect();
322 }
323 if let Some(s) = obj.get("scale").and_then(|v| v.as_f64()) {
324 self.set_scale(s as f32);
325 }
326 true
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn truncate_caps_long_cells() {
336 assert_eq!(truncate("short"), "short");
337 let long = "x".repeat(100);
338 let t = truncate(&long);
339 assert_eq!(t.chars().count(), CELL_MAX);
340 assert!(t.ends_with('…'));
341 }
342
343 #[test]
344 fn state_json_reports_shape() {
345 let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
346 t.push_row(vec!["knut".into(), "0.1".into()]);
347 t.push_row(vec!["korp".into(), "0.1".into()]);
348 let j = t.state_json();
349 assert_eq!(j["rows"], 2);
350 assert_eq!(j["columns"].as_array().unwrap().len(), 2);
351 }
352
353 fn repos() -> Table {
354 let mut t = Table::new("repos", vec!["name".into(), "version".into()]);
355 t.push_row(vec!["knut".into(), "0.1".into()]);
356 t.push_row(vec!["korp".into(), "0.2".into()]);
357 t
358 }
359
360 #[test]
361 fn caps_declares_table_surface() {
362 let c = repos().caps();
363 assert!(c.selectable && c.copyable && c.searchable && c.scalable && c.resizable);
364 assert!(!c.pasteable && !c.cuttable);
365 }
366
367 #[test]
368 fn portable_state_round_trips_columns_selection_scale_between_instances() {
369 let mut a = Table::new("repos", vec!["version".into(), "name".into()]);
371 a.push_row(vec!["0.1".into(), "knut".into()]);
372 a.push_row(vec!["0.2".into(), "korp".into()]);
373 a.select_row(1);
374 a.set_scale(1.5);
375
376 let env = clipboard::encode_component(a.kind(), &a.portable_state().unwrap());
377 let (kind, state) = clipboard::decode_component(&env).unwrap();
378 assert_eq!(kind, "table");
379
380 let mut b = repos(); assert_ne!(b.portable_state(), a.portable_state(), "differ before paste");
383 assert!(b.load_state(&state));
384 assert_eq!(b.portable_state(), a.portable_state(), "B mirrors A's column order/selection/scale");
385 assert_eq!(b.columns, vec!["version".to_string(), "name".to_string()]);
386 assert_eq!(b.selected_rows(), vec![1]);
387 assert_eq!(b.scale(), 1.5);
388 }
389
390 #[test]
391 fn paste_component_clamps_a_selection_out_of_range() {
392 let a_state = serde_json::json!({ "columns": ["name", "version"], "selected": [5], "scale": 1.0 });
394 let mut b = repos();
395 assert!(b.load_state(&a_state));
396 assert!(b.selected_rows().is_empty(), "out-of-range selection is clamped away");
397 }
398
399 #[test]
400 fn typed_copy_is_the_selection_as_rows() {
401 use facett_core::clip::{ClipKind, CopySource};
402 let mut t = repos();
403 t.select_row(1);
404 let p = t.copy_payload().expect("selection copies");
405 assert_eq!(p.kind(), ClipKind::Rows);
406 assert_eq!(p.as_text(), "name\tversion\nkorp\t0.2");
407 assert!(Table::new("e", vec!["a".into()]).copy_payload().is_none());
409 }
410
411 #[test]
412 fn copy_all_rows_when_nothing_selected() {
413 let mut t = repos();
414 let tsv = t.copy().expect("non-empty table copies");
415 assert_eq!(tsv, "name\tversion\nknut\t0.1\nkorp\t0.2");
416 }
417
418 #[test]
419 fn copy_only_selected_rows() {
420 let mut t = repos();
421 t.select_row(1);
422 let tsv = t.copy().expect("selection copies");
423 assert_eq!(tsv, "name\tversion\nkorp\t0.2");
424 assert_eq!(t.selection_json(), serde_json::json!([1]));
425 }
426
427 #[test]
428 fn row_identity_falls_back_to_index_without_row_ids() {
429 let t = repos();
430 assert_eq!(t.row_identity(0), "0");
432 assert_eq!(t.row_identity(1), "1");
433 assert_eq!(t.row_label(0), "row: 0");
434 }
435
436 #[test]
437 fn with_row_ids_keys_identity_on_stable_id() {
438 let t = repos().with_row_ids(vec!["pkg-knut".into(), "pkg-korp".into()]);
439 assert_eq!(t.row_identity(0), "pkg-knut");
441 assert_eq!(t.row_identity(1), "pkg-korp");
442 assert_eq!(t.row_label(1), "row: pkg-korp");
443 let mut t = t;
445 t.select_row(0);
446 assert_eq!(t.selected_rows(), vec![0]);
447 }
448
449 #[test]
450 fn select_row_toggles() {
451 let mut t = repos();
452 t.select_row(0);
453 assert_eq!(t.selected_rows(), vec![0]);
454 t.select_row(0);
455 assert!(t.selected_rows().is_empty());
456 }
457
458 #[test]
459 fn cut_falls_back_to_copy_for_read_only_table() {
460 let mut t = repos();
461 let cut = t.cut().expect("cut delegates to copy");
463 assert_eq!(cut, "name\tversion\nknut\t0.1\nkorp\t0.2");
464 assert_eq!(t.rows.len(), 2, "cut must not remove rows on a read-only viewer");
465 }
466
467 #[test]
468 fn paste_is_rejected() {
469 let mut t = repos();
470 assert!(!t.paste("anything"), "read-only table does not consume paste");
471 }
472
473 #[test]
474 fn empty_table_copies_nothing() {
475 let mut t = Table::new("empty", vec!["a".into()]);
476 assert_eq!(t.copy(), None);
477 }
478
479 #[test]
480 fn set_scale_clamps() {
481 let mut t = repos();
482 t.set_scale(99.0);
483 assert_eq!(t.scale(), 4.0);
484 t.set_scale(0.001);
485 assert_eq!(t.scale(), 0.25);
486 assert_eq!(t.state_json()["scale"], 0.25);
487 }
488}