1use crate::panels::tree::{self, TreeRow};
85use eframe::egui;
86use serde_json::Value;
87use std::collections::{HashMap, HashSet};
88
89pub const MIN_COLUMN_WIDTH: f32 = 28.0;
92
93pub const DEFAULT_COLUMN_WIDTH: f32 = 110.0;
95
96const GRIP: f32 = 5.0;
98
99const CELL_PAD: f32 = 3.0;
101
102const FREEZE_GAP: f32 = 4.0;
104
105#[derive(Debug, Clone, PartialEq)]
107pub enum CellKind {
108 Text,
110 Numeric { step: f64 },
112 Choice { options: Vec<String> },
115 Button { label: String },
118 Actions { label: String },
122 Badges,
129 Toggle,
135 ReadOnly,
138}
139
140#[derive(Debug, Clone, PartialEq)]
142pub struct ColumnSpec {
143 pub key: String,
145 pub label: String,
147 pub kind: CellKind,
148 pub default_width: f32,
150}
151
152impl ColumnSpec {
153 pub fn new(key: impl Into<String>, label: impl Into<String>, kind: CellKind) -> Self {
155 Self {
156 key: key.into(),
157 label: label.into(),
158 kind,
159 default_width: DEFAULT_COLUMN_WIDTH,
160 }
161 }
162
163 pub fn width(mut self, width: f32) -> Self {
164 self.default_width = width;
165 self
166 }
167}
168
169#[derive(Debug, Clone, PartialEq)]
174pub struct RowAction {
175 pub id: String,
177 pub label: String,
179 pub tooltip: String,
182 pub enabled: bool,
185 pub separator_above: bool,
188 pub destructive: bool,
190}
191
192impl RowAction {
193 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
195 Self {
196 id: id.into(),
197 label: label.into(),
198 tooltip: String::new(),
199 enabled: true,
200 separator_above: false,
201 destructive: false,
202 }
203 }
204
205 pub fn tooltip(mut self, text: impl Into<String>) -> Self {
206 self.tooltip = text.into();
207 self
208 }
209
210 pub fn disabled(mut self, why: impl Into<String>) -> Self {
212 self.enabled = false;
213 self.tooltip = why.into();
214 self
215 }
216
217 pub fn separator_above(mut self) -> Self {
218 self.separator_above = true;
219 self
220 }
221
222 pub fn destructive(mut self) -> Self {
223 self.destructive = true;
224 self
225 }
226}
227
228#[derive(Debug, Clone, Default, PartialEq)]
232pub struct RowNode {
233 pub id: String,
235 pub cells: HashMap<String, Value>,
237 pub editable: bool,
241 pub selected: bool,
243 pub expanded: bool,
245 pub actions: Vec<RowAction>,
250 pub children: Vec<RowNode>,
251}
252
253impl RowNode {
254 pub fn new(id: impl Into<String>) -> Self {
255 Self {
256 id: id.into(),
257 editable: true,
258 ..Default::default()
259 }
260 }
261
262 pub fn cell(mut self, key: impl Into<String>, value: Value) -> Self {
264 self.cells.insert(key.into(), value);
265 self
266 }
267
268 pub fn actions(mut self, actions: Vec<RowAction>) -> Self {
270 self.actions = actions;
271 self
272 }
273}
274
275#[derive(Debug, Clone, Default, PartialEq)]
278pub struct ColumnLayout {
279 pub order: Vec<String>,
283 pub hidden: HashSet<String>,
285 pub widths: HashMap<String, f32>,
287 pub sort: Option<(String, bool)>,
289 pub frozen: usize,
294}
295
296#[derive(Debug, Clone, PartialEq)]
298pub struct CellEdit {
299 pub row_id: String,
300 pub column: String,
301 pub value: Value,
302}
303
304#[derive(Debug, Clone, PartialEq)]
306pub struct CellClick {
307 pub row_id: String,
308 pub column: String,
309}
310
311#[derive(Debug, Clone, PartialEq)]
313pub struct RowActionClick {
314 pub row_id: String,
315 pub action: String,
317}
318
319pub struct ColumnTreeSpec<'a> {
322 pub id: &'a str,
326 pub columns: &'a [ColumnSpec],
329 pub root_label: Option<&'a str>,
332 pub root_cells: Option<&'a HashMap<String, Value>>,
334 pub empty_hint: Option<&'a str>,
336 pub hits_prefix: &'a str,
339}
340
341#[derive(Debug, Default, Clone, PartialEq)]
343pub struct ColumnTreeOut {
344 pub edits: Vec<CellEdit>,
347 pub buttons: Vec<CellClick>,
349 pub actions: Vec<RowActionClick>,
351 pub toggled: Option<String>,
354 pub clicked: Option<String>,
356 pub layout_changed: bool,
359}
360
361fn parse_hex_color(hex: &str) -> Option<egui::Color32> {
364 let digits = hex.strip_prefix('#')?;
365 if digits.len() != 6 {
366 return None;
367 }
368 let byte = |at: usize| u8::from_str_radix(&digits[at..at + 2], 16).ok();
369 Some(egui::Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?))
370}
371
372pub fn column_tree(
388 ui: &mut egui::Ui,
389 spec: &ColumnTreeSpec<'_>,
390 layout: &mut ColumnLayout,
391 rows: &[RowNode],
392 mut hits: Option<&mut HashMap<String, egui::Rect>>,
393) -> ColumnTreeOut {
394 let mut out = ColumnTreeOut::default();
395 let arranged = arranged_columns(spec, layout);
399 let visible: Vec<&ColumnSpec> = arranged
400 .iter()
401 .copied()
402 .filter(|column| !layout.hidden.contains(&column.key))
403 .collect();
404 if visible.is_empty() {
405 ui.label(egui::RichText::new("(every column is hidden)").weak());
406 return out;
407 }
408
409 let widths: Vec<f32> = visible
411 .iter()
412 .map(|column| column_width(layout, column))
413 .collect();
414
415 let mut frozen = arranged
420 .iter()
421 .take(layout.frozen)
422 .filter(|column| !layout.hidden.contains(&column.key))
423 .count();
424 if frozen >= visible.len() {
425 frozen = 0;
426 }
427
428 ui.spacing_mut().item_spacing.y = 2.0;
429 let full = ui.available_rect_before_wrap();
430 let frozen_width: f32 = widths[..frozen]
433 .iter()
434 .sum::<f32>()
435 .min((full.width() - MIN_COLUMN_WIDTH).max(0.0));
436
437 let mut pending: Option<MenuOpen> = None;
439 let mut bounds: Vec<(String, f32, f32)> = Vec::new();
444 let mut bottom = full.top();
445
446 if frozen > 0 {
447 let rect = egui::Rect::from_min_max(
448 full.min,
449 egui::pos2(full.min.x + frozen_width, full.max.y),
450 );
451 let mut pane = ui.new_child(
452 egui::UiBuilder::new()
453 .max_rect(rect)
454 .layout(egui::Layout::top_down(egui::Align::Min))
455 .id_salt((spec.id, "column-tree-frozen")),
456 );
457 pane.set_clip_rect(pane.clip_rect().intersect(egui::Rect::from_x_y_ranges(
460 rect.x_range(),
461 ui.clip_rect().y_range(),
462 )));
463 pane.spacing_mut().item_spacing.y = 2.0;
464 draw_pane(
465 &mut pane,
466 spec,
467 layout,
468 &visible[..frozen],
469 &widths[..frozen],
470 0,
471 frozen_width,
472 rows,
473 &mut hits,
474 &mut out,
475 &mut pending,
476 &mut bounds,
477 );
478 bottom = bottom.max(pane.min_rect().bottom());
479 }
480
481 let scroll_left = full.min.x + if frozen > 0 { frozen_width + FREEZE_GAP } else { 0.0 };
485 let scroll_rect = egui::Rect::from_min_max(egui::pos2(scroll_left, full.min.y), full.max);
486 let viewport = scroll_rect.width();
487 let mut pane = ui.new_child(
488 egui::UiBuilder::new()
489 .max_rect(scroll_rect)
490 .layout(egui::Layout::top_down(egui::Align::Min))
491 .id_salt((spec.id, "column-tree-scrolling")),
492 );
493 let rest: f32 = widths[frozen..].iter().sum();
494 egui::ScrollArea::horizontal()
498 .id_salt((spec.id, "column-tree-hscroll"))
499 .show(&mut pane, |ui: &mut egui::Ui| {
500 ui.spacing_mut().item_spacing.y = 2.0;
501 draw_pane(
502 ui,
503 spec,
504 layout,
505 &visible[frozen..],
506 &widths[frozen..],
507 frozen,
508 rest.max(viewport),
509 rows,
510 &mut hits,
511 &mut out,
512 &mut pending,
513 &mut bounds,
514 );
515 if rest > viewport {
516 let scroll = ui.spacing().scroll;
522 ui.add_space(scroll.bar_width + scroll.bar_inner_margin + scroll.bar_outer_margin);
523 }
524 });
525 bottom = bottom.max(pane.min_rect().bottom());
526
527 let used = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x, bottom));
528 ui.advance_cursor_after_rect(used);
529
530 if frozen > 0 {
532 let x = full.min.x + frozen_width + FREEZE_GAP * 0.5;
533 let divider = egui::Rect::from_min_max(
534 egui::pos2(x - 1.0, used.top()),
535 egui::pos2(x + 1.0, used.bottom()),
536 );
537 ui.painter()
538 .rect_filled(divider, 0.0, ui.visuals().widgets.active.bg_fill);
539 publish(&mut hits, spec, "freeze:divider", divider);
540 }
541
542 finish_reorder(ui, spec, layout, &arranged, &bounds, &mut out);
543 row_action_menu(ui, spec, rows, &mut hits, &mut out, &mut pending);
547 out
548}
549
550#[allow(clippy::too_many_arguments)]
557fn draw_pane(
558 ui: &mut egui::Ui,
559 spec: &ColumnTreeSpec<'_>,
560 layout: &mut ColumnLayout,
561 columns: &[&ColumnSpec],
562 widths: &[f32],
563 offset: usize,
564 band: f32,
565 rows: &[RowNode],
566 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
567 out: &mut ColumnTreeOut,
568 pending: &mut Option<MenuOpen>,
569 bounds: &mut Vec<(String, f32, f32)>,
570) {
571 header(ui, spec, layout, columns, widths, band, hits, out, bounds);
572 ui.separator();
573
574 if let Some(label) = spec.root_label {
575 let mut cells = spec.root_cells.cloned().unwrap_or_default();
576 if offset == 0 {
579 cells.insert(columns[0].key.clone(), Value::String(label.to_string()));
580 }
581 let root = RowNode {
582 id: format!("{}__root", spec.id),
583 cells,
584 editable: false,
585 selected: false,
586 expanded: true,
587 actions: Vec::new(),
588 children: Vec::new(),
589 };
590 draw_row(
591 ui, spec, columns, widths, offset, band, &root, &[], true, true, hits, out, pending,
592 );
593 }
594
595 if rows.is_empty() {
596 if let Some(hint) = spec.empty_hint {
597 if offset == 0 {
598 let guides = tree::child_guides(&[], true);
599 tree::node(ui, TreeRow::leaf(&guides, true, hint), |_| {});
600 } else {
601 ui.allocate_exact_size(
604 egui::vec2(band, ui.spacing().interact_size.y),
605 egui::Sense::hover(),
606 );
607 }
608 }
609 return;
610 }
611
612 let ordered = sorted_siblings(rows, layout);
613 let last = ordered.len();
614 for (index, row) in ordered.iter().enumerate() {
615 draw_subtree(
616 ui,
617 spec,
618 layout,
619 columns,
620 widths,
621 offset,
622 band,
623 row,
624 &[],
625 index + 1 == last,
626 hits,
627 out,
628 pending,
629 );
630 }
631}
632
633fn arranged_columns<'a>(
638 spec: &'a ColumnTreeSpec<'_>,
639 layout: &ColumnLayout,
640) -> Vec<&'a ColumnSpec> {
641 let mut out: Vec<&ColumnSpec> = Vec::new();
642 for key in &layout.order {
643 if let Some(column) = spec.columns.iter().find(|c| &c.key == key) {
644 if !out.iter().any(|c| c.key == column.key) {
645 out.push(column);
646 }
647 }
648 }
649 for column in spec.columns {
650 if !out.iter().any(|c| c.key == column.key) {
651 out.push(column);
652 }
653 }
654 out
655}
656
657fn column_width(layout: &ColumnLayout, column: &ColumnSpec) -> f32 {
658 layout
659 .widths
660 .get(&column.key)
661 .copied()
662 .unwrap_or(column.default_width)
663 .max(MIN_COLUMN_WIDTH)
664}
665
666#[allow(clippy::too_many_arguments)]
671fn header(
672 ui: &mut egui::Ui,
673 spec: &ColumnTreeSpec<'_>,
674 layout: &mut ColumnLayout,
675 visible: &[&ColumnSpec],
676 widths: &[f32],
677 width: f32,
678 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
679 out: &mut ColumnTreeOut,
680 bounds: &mut Vec<(String, f32, f32)>,
681) {
682 let height = ui.spacing().interact_size.y;
683 let (band, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
684 let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
685 let dragging: Option<String> = ui.data(|d| d.get_temp(drag_key));
686
687 let mut x = band.left();
688 let first = bounds.len();
691 for (column, width) in visible.iter().zip(widths) {
692 let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
693 bounds.push((column.key.clone(), cell.left(), cell.right()));
694
695 let resp = ui.interact(
696 cell,
697 ui.id().with(("column-tree-head", spec.id, &column.key)),
698 egui::Sense::click_and_drag(),
699 );
700 let held = dragging.as_deref() == Some(column.key.as_str());
701 let fill = if held {
702 ui.visuals().selection.bg_fill.gamma_multiply(0.45)
703 } else if resp.hovered() {
704 ui.visuals().widgets.hovered.bg_fill
705 } else {
706 ui.visuals().widgets.noninteractive.bg_fill
707 };
708 ui.painter().rect_filled(cell, 0.0, fill);
709 let marker = match &layout.sort {
712 Some((key, true)) if key == &column.key => " \u{25B2}",
713 Some((key, false)) if key == &column.key => " \u{25BC}",
714 _ => "",
715 };
716 let text = format!("{}{marker}", column.label);
717 ui.painter().text(
718 egui::pos2(cell.left() + CELL_PAD, cell.center().y),
719 egui::Align2::LEFT_CENTER,
720 elide(ui, &text, *width - 2.0 * CELL_PAD),
721 egui::TextStyle::Body.resolve(ui.style()),
722 ui.visuals().strong_text_color(),
723 );
724 publish(hits, spec, &format!("col:{}", column.key), cell);
725
726 resp.context_menu(|ui| {
730 ui.label(egui::RichText::new("Columns").strong());
731 for candidate in spec.columns {
732 let mut shown = !layout.hidden.contains(&candidate.key);
733 if ui.checkbox(&mut shown, &candidate.label).changed() {
734 if shown {
735 layout.hidden.remove(&candidate.key);
736 } else {
737 layout.hidden.insert(candidate.key.clone());
738 }
739 out.layout_changed = true;
740 }
741 }
742 });
743
744 if resp.clicked() {
748 layout.sort = match &layout.sort {
749 Some((key, true)) if key == &column.key => Some((column.key.clone(), false)),
750 Some((key, false)) if key == &column.key => None,
751 _ => Some((column.key.clone(), true)),
752 };
753 out.layout_changed = true;
754 }
755 if resp.drag_started() {
756 ui.data_mut(|d| d.insert_temp(drag_key, column.key.clone()));
757 }
758
759 ui.painter().line_segment(
760 [
761 egui::pos2(cell.right(), band.top()),
762 egui::pos2(cell.right(), band.bottom()),
763 ],
764 ui.visuals().widgets.noninteractive.bg_stroke,
765 );
766 x = cell.right();
767 }
768
769 for (column, (_, _, right)) in visible.iter().zip(&bounds[first..]) {
775 let grip_rect = egui::Rect::from_min_max(
776 egui::pos2(right - GRIP * 0.5, band.top()),
777 egui::pos2(right + GRIP * 0.5, band.bottom()),
778 );
779 let grip = ui.interact(
780 grip_rect,
781 ui.id().with(("column-tree-grip", spec.id, &column.key)),
782 egui::Sense::drag(),
783 );
784 if grip.hovered() || grip.dragged() {
785 ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
786 }
787 if grip.dragged() {
788 let next = (column_width(layout, column) + grip.drag_delta().x).max(MIN_COLUMN_WIDTH);
789 layout.widths.insert(column.key.clone(), next);
790 out.layout_changed = true;
791 }
792 publish(hits, spec, &format!("grip:{}", column.key), grip_rect);
793 }
794}
795
796fn finish_reorder(
800 ui: &egui::Ui,
801 spec: &ColumnTreeSpec<'_>,
802 layout: &mut ColumnLayout,
803 arranged: &[&ColumnSpec],
804 bounds: &[(String, f32, f32)],
805 out: &mut ColumnTreeOut,
806) {
807 let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
808 let Some(held) = ui.data(|d| d.get_temp::<String>(drag_key)) else {
809 return;
810 };
811 if ui.input(|i| i.pointer.any_down()) {
812 return;
813 }
814 ui.data_mut(|d| d.remove::<String>(drag_key));
815 let Some(pos) = ui.input(|i| i.pointer.latest_pos()) else {
816 return;
817 };
818 if let Some((target, _, _)) = bounds
819 .iter()
820 .find(|(_, left, right)| pos.x >= *left && pos.x < *right)
821 {
822 if *target != held && move_column(layout, arranged, &held, target) {
823 out.layout_changed = true;
824 }
825 }
826}
827
828fn move_column(
832 layout: &mut ColumnLayout,
833 arranged: &[&ColumnSpec],
834 held: &str,
835 target: &str,
836) -> bool {
837 let mut order: Vec<String> = layout.order.clone();
838 for column in arranged {
841 if !order.iter().any(|key| key == &column.key) {
842 order.push(column.key.clone());
843 }
844 }
845 let Some(from) = order.iter().position(|key| key == held) else {
846 return false;
847 };
848 let key = order.remove(from);
849 let Some(to) = order.iter().position(|k| k == target) else {
850 order.insert(from.min(order.len()), key);
851 return false;
852 };
853 order.insert(to, key);
854 layout.order = order;
855 true
856}
857
858#[allow(clippy::too_many_arguments)]
861fn draw_subtree(
862 ui: &mut egui::Ui,
863 spec: &ColumnTreeSpec<'_>,
864 layout: &ColumnLayout,
865 visible: &[&ColumnSpec],
866 widths: &[f32],
867 offset: usize,
868 band: f32,
869 row: &RowNode,
870 guides: &[bool],
871 is_last: bool,
872 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
873 out: &mut ColumnTreeOut,
874 pending: &mut Option<MenuOpen>,
875) {
876 draw_row(
877 ui, spec, visible, widths, offset, band, row, guides, is_last, false, hits, out, pending,
878 );
879 if !row.expanded || row.children.is_empty() {
880 return;
881 }
882 let child_guides = tree::child_guides(guides, is_last);
883 let ordered = sorted_siblings(&row.children, layout);
884 let last = ordered.len();
885 for (index, child) in ordered.iter().enumerate() {
886 draw_subtree(
887 ui,
888 spec,
889 layout,
890 visible,
891 widths,
892 offset,
893 band,
894 child,
895 &child_guides,
896 index + 1 == last,
897 hits,
898 out,
899 pending,
900 );
901 }
902}
903
904fn sorted_siblings<'a>(rows: &'a [RowNode], layout: &ColumnLayout) -> Vec<&'a RowNode> {
908 let mut out: Vec<&RowNode> = rows.iter().collect();
909 if let Some((key, ascending)) = &layout.sort {
910 out.sort_by(|a, b| {
911 let ordering = compare_cells(a.cells.get(key), b.cells.get(key));
912 if *ascending {
913 ordering
914 } else {
915 ordering.reverse()
916 }
917 });
918 }
919 out
920}
921
922fn compare_cells(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering {
926 use std::cmp::Ordering;
927 let empty = |value: Option<&Value>| match value {
928 None | Some(Value::Null) => true,
929 Some(Value::String(text)) => text.is_empty(),
930 _ => false,
931 };
932 match (empty(a), empty(b)) {
933 (true, true) => return Ordering::Equal,
934 (true, false) => return Ordering::Greater,
935 (false, true) => return Ordering::Less,
936 (false, false) => {}
937 }
938 if let (Some(Value::Number(x)), Some(Value::Number(y))) = (a, b) {
939 if let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) {
940 return x.partial_cmp(&y).unwrap_or(Ordering::Equal);
941 }
942 }
943 display_text(a).to_lowercase().cmp(&display_text(b).to_lowercase())
944}
945
946fn display_text(value: Option<&Value>) -> String {
949 match value {
950 None | Some(Value::Null) => String::new(),
951 Some(Value::String(text)) => text.clone(),
952 Some(other) => other.to_string(),
953 }
954}
955
956#[allow(clippy::too_many_arguments)]
958fn draw_row(
959 ui: &mut egui::Ui,
960 spec: &ColumnTreeSpec<'_>,
961 visible: &[&ColumnSpec],
962 widths: &[f32],
963 offset: usize,
964 band_width: f32,
965 row: &RowNode,
966 guides: &[bool],
967 is_last: bool,
968 root: bool,
969 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
970 out: &mut ColumnTreeOut,
971 pending: &mut Option<MenuOpen>,
972) {
973 let height = ui.spacing().interact_size.y;
974 let (band, _) = ui.allocate_exact_size(egui::vec2(band_width, height), egui::Sense::hover());
975 let clip = ui.clip_rect();
976
977 let over_row = band.intersect(clip);
984 if !row.actions.is_empty()
985 && over_row.is_positive()
986 && ui.input(|i| i.pointer.secondary_clicked())
987 {
988 if let Some(pos) = ui.ctx().input(|i| i.pointer.interact_pos()) {
989 let above = ui.ctx().layer_id_at(pos);
992 let ours = above.is_none() || above == Some(ui.layer_id());
993 if over_row.contains(pos) && ours {
994 *pending = Some(MenuOpen {
995 row: row.id.clone(),
996 pos,
997 });
998 }
999 }
1000 }
1001
1002 if row.selected && !root {
1009 let visible_band = band.intersect(clip);
1010 if visible_band.is_positive() {
1011 ui.painter().rect_filled(
1012 visible_band,
1013 0.0,
1014 ui.visuals().selection.bg_fill.gamma_multiply(0.35),
1015 );
1016 }
1017 }
1018
1019 let mut x = band.left();
1020 for (index, (column, width)) in visible.iter().zip(widths).enumerate() {
1021 let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
1022 x = cell.right();
1023 let Some(cell_clip) = cell.intersect(clip).is_positive().then_some(cell.intersect(clip))
1024 else {
1025 continue;
1026 };
1027 let mut child = ui.new_child(
1028 egui::UiBuilder::new()
1029 .max_rect(cell.shrink2(egui::vec2(CELL_PAD, 0.0)))
1030 .layout(egui::Layout::left_to_right(egui::Align::Center))
1031 .id_salt(("column-tree-cell", spec.id, &row.id, &column.key)),
1032 );
1033 child.set_clip_rect(cell_clip);
1034
1035 if offset + index == 0 {
1036 let label = display_text(row.cells.get(&column.key));
1039 let expandable = !row.children.is_empty();
1040 let mut tree_row = TreeRow {
1041 guides,
1042 is_last,
1043 expandable,
1044 expanded: row.expanded,
1045 root,
1046 glyph: None,
1047 label: &label,
1048 selected: row.selected,
1049 draggable: false,
1050 };
1051 if root {
1052 tree_row.expandable = true;
1053 tree_row.expanded = true;
1054 }
1055 let resp = tree::node(&mut child, tree_row, |_| {});
1056 publish(hits, spec, &format!("row:{}", row.id), resp.label.rect);
1057 publish(hits, spec, &format!("box:{}", row.id), resp.box_rect);
1058 if resp.toggled {
1059 out.toggled = Some(row.id.clone());
1060 }
1061 if resp.label.clicked() {
1062 out.clicked = Some(row.id.clone());
1063 }
1064 } else {
1065 let rect = cell_editor(&mut child, spec, row, column, out, pending);
1066 publish(
1067 hits,
1068 spec,
1069 &format!("cell:{}:{}", row.id, column.key),
1070 rect,
1071 );
1072 if matches!(column.kind, CellKind::Actions { .. }) {
1073 publish(hits, spec, &format!("menu:{}", row.id), rect);
1074 }
1075 }
1076 }
1077}
1078
1079fn cell_editor(
1083 ui: &mut egui::Ui,
1084 spec: &ColumnTreeSpec<'_>,
1085 row: &RowNode,
1086 column: &ColumnSpec,
1087 out: &mut ColumnTreeOut,
1088 pending: &mut Option<MenuOpen>,
1089) -> egui::Rect {
1090 let value = row.cells.get(&column.key);
1091 let width = ui.available_width();
1092 let mut emit = |new_value: Value| {
1093 out.edits.push(CellEdit {
1094 row_id: row.id.clone(),
1095 column: column.key.clone(),
1096 value: new_value,
1097 });
1098 };
1099
1100 match &column.kind {
1101 CellKind::Button { label } => {
1102 let button = ui.add_sized([width, ui.available_height()], egui::Button::new(label));
1103 if button.clicked() {
1104 out.buttons.push(CellClick {
1105 row_id: row.id.clone(),
1106 column: column.key.clone(),
1107 });
1108 }
1109 button.rect
1110 }
1111 CellKind::Actions { label } => {
1112 let offered = !row.actions.is_empty();
1118 let button = ui
1119 .add_enabled_ui(offered, |ui| {
1120 ui.add_sized([width, ui.available_height()], egui::Button::new(label))
1121 })
1122 .inner;
1123 if button.clicked() {
1124 *pending = Some(MenuOpen {
1125 row: row.id.clone(),
1126 pos: button.rect.left_bottom(),
1127 });
1128 }
1129 button.rect
1130 }
1131 CellKind::ReadOnly => ui.add(egui::Label::new(display_text(value)).truncate()).rect,
1132 CellKind::Badges => {
1133 let badges = value.and_then(Value::as_array).cloned().unwrap_or_default();
1134 ui.horizontal(|ui| {
1135 ui.spacing_mut().item_spacing.x = 3.0;
1136 for badge in &badges {
1137 let glyph = badge.get("glyph").and_then(Value::as_str).unwrap_or("");
1138 if glyph.is_empty() {
1139 continue;
1140 }
1141 let color = badge
1142 .get("color")
1143 .and_then(Value::as_str)
1144 .and_then(parse_hex_color)
1145 .unwrap_or_else(|| ui.visuals().text_color());
1146 let label = match crate::icon_text::glyph(ui, glyph, color) {
1150 Some(art) => ui.add(art),
1151 None => ui.label(egui::RichText::new(glyph).color(color)),
1152 };
1153 if let Some(tip) = badge.get("tooltip").and_then(Value::as_str) {
1154 label.on_hover_text(tip);
1155 }
1156 }
1157 })
1158 .response
1159 .rect
1160 }
1161 CellKind::Toggle => {
1162 let mut on = value.and_then(Value::as_bool).unwrap_or(false);
1166 let box_ = ui
1167 .add_enabled_ui(row.editable, |ui| ui.checkbox(&mut on, ""))
1168 .inner;
1169 if box_.changed() {
1170 emit(Value::Bool(on));
1171 }
1172 box_.rect
1173 }
1174 _ if !row.editable => ui
1175 .add(
1176 egui::Label::new(egui::RichText::new(display_text(value)).weak())
1177 .truncate(),
1178 )
1179 .rect,
1180 CellKind::Text => {
1181 let buffer_id = egui::Id::new(("column-tree-text", spec.id, &row.id, &column.key));
1187 let stored = display_text(value);
1188 let mut buffer: String =
1189 ui.data(|d| d.get_temp(buffer_id)).unwrap_or_else(|| stored.clone());
1190 let edit = ui.add_sized(
1191 [width, ui.available_height()],
1192 egui::TextEdit::singleline(&mut buffer),
1193 );
1194 let entered = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1195 if edit.has_focus() || edit.changed() {
1196 ui.data_mut(|d| d.insert_temp(buffer_id, buffer.clone()));
1197 }
1198 if edit.lost_focus() || entered {
1199 ui.data_mut(|d| d.remove::<String>(buffer_id));
1200 if buffer != stored {
1201 emit(Value::String(buffer));
1202 }
1203 } else if !edit.has_focus() {
1204 ui.data_mut(|d| d.remove::<String>(buffer_id));
1207 }
1208 edit.rect
1209 }
1210 CellKind::Numeric { step } => {
1211 let mut number = value.and_then(Value::as_f64).unwrap_or(0.0);
1212 let drag = ui.add_sized(
1213 [width, ui.available_height()],
1214 egui::DragValue::new(&mut number).speed(*step),
1215 );
1216 if drag.changed() {
1217 emit(serde_json::json!(number));
1218 }
1219 drag.rect
1220 }
1221 CellKind::Choice { options } => {
1222 let current = display_text(value);
1223 let mut chosen = current.clone();
1224 let combo = egui::ComboBox::from_id_salt((
1225 "column-tree-combo",
1226 spec.id,
1227 &row.id,
1228 &column.key,
1229 ))
1230 .width(width)
1231 .selected_text(if current.is_empty() { "—" } else { ¤t })
1232 .show_ui(ui, |ui| {
1233 ui.selectable_value(&mut chosen, String::new(), "—");
1236 for option in options {
1237 ui.selectable_value(&mut chosen, option.clone(), option);
1238 }
1239 });
1240 if chosen != current {
1241 emit(Value::String(chosen));
1242 }
1243 combo.response.rect
1244 }
1245 }
1246}
1247
1248#[derive(Clone)]
1252struct MenuOpen {
1253 row: String,
1254 pos: egui::Pos2,
1255}
1256
1257fn row_action_menu(
1261 ui: &mut egui::Ui,
1262 spec: &ColumnTreeSpec<'_>,
1263 rows: &[RowNode],
1264 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
1265 out: &mut ColumnTreeOut,
1266 pending: &mut Option<MenuOpen>,
1267) {
1268 let key = egui::Id::new((spec.id, "column-tree-menu"));
1269 let mut open: Option<MenuOpen> = ui.data(|d| d.get_temp(key));
1270 let was_open = open.as_ref().map(|state| state.row.clone());
1271
1272 if let Some(state) = open.clone() {
1273 match find_row(rows, &state.row) {
1275 Some(row) if !row.actions.is_empty() => {
1276 let mut still_open = true;
1277 egui::Popup::new(
1278 key.with("popup"),
1279 ui.ctx().clone(),
1280 egui::PopupAnchor::Position(state.pos),
1281 ui.layer_id(),
1282 )
1283 .open_bool(&mut still_open)
1284 .kind(egui::PopupKind::Menu)
1285 .layout(egui::Layout::top_down_justified(egui::Align::Min))
1286 .width(160.0)
1287 .show(|ui| {
1288 for action in &row.actions {
1289 if action.separator_above {
1290 ui.separator();
1291 }
1292 let color =
1297 action.destructive.then(|| ui.visuals().error_fg_color);
1298 let button =
1299 crate::icon_text::icon_button_colored(ui, &action.label, color);
1300 let entry = ui.add_enabled(action.enabled, button);
1301 publish(
1302 hits,
1303 spec,
1304 &format!("menuitem:{}:{}", row.id, action.id),
1305 entry.rect,
1306 );
1307 if !action.tooltip.is_empty() {
1308 if action.enabled {
1311 entry.clone().on_hover_text(&action.tooltip);
1312 } else {
1313 entry.clone().on_disabled_hover_text(&action.tooltip);
1314 }
1315 }
1316 if entry.clicked() {
1317 out.actions.push(RowActionClick {
1318 row_id: row.id.clone(),
1319 action: action.id.clone(),
1320 });
1321 }
1322 }
1323 });
1324 if !still_open {
1325 open = None;
1326 }
1327 }
1328 _ => open = None,
1329 }
1330 }
1331
1332 if let Some(next) = pending.take() {
1337 let toggled_off = open.is_none() && was_open.as_deref() == Some(next.row.as_str());
1338 open = (!toggled_off).then_some(next);
1339 }
1340 match &open {
1341 Some(state) => ui.data_mut(|d| {
1342 d.insert_temp(key, state.clone());
1343 }),
1344 None => ui.data_mut(|d| d.remove::<MenuOpen>(key)),
1345 }
1346}
1347
1348fn find_row<'a>(rows: &'a [RowNode], id: &str) -> Option<&'a RowNode> {
1350 for row in rows {
1351 if row.id == id {
1352 return Some(row);
1353 }
1354 if let Some(found) = find_row(&row.children, id) {
1355 return Some(found);
1356 }
1357 }
1358 None
1359}
1360
1361fn elide(ui: &egui::Ui, text: &str, width: f32) -> String {
1363 let font = egui::TextStyle::Body.resolve(ui.style());
1364 let measure = |candidate: &str| {
1365 ui.painter()
1366 .layout_no_wrap(candidate.to_string(), font.clone(), egui::Color32::WHITE)
1367 .rect
1368 .width()
1369 };
1370 if width <= 0.0 || measure(text) <= width {
1371 return text.to_string();
1372 }
1373 let mut cut: Vec<char> = text.chars().collect();
1374 while !cut.is_empty() {
1375 cut.pop();
1376 let candidate: String = cut.iter().collect::<String>() + "\u{2026}";
1377 if measure(&candidate) <= width {
1378 return candidate;
1379 }
1380 }
1381 String::new()
1382}
1383
1384fn publish(
1385 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
1386 spec: &ColumnTreeSpec<'_>,
1387 key: &str,
1388 rect: egui::Rect,
1389) {
1390 if let Some(map) = hits.as_deref_mut() {
1391 map.insert(format!("{}{key}", spec.hits_prefix), rect);
1392 }
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397 use super::*;
1401
1402 fn columns() -> Vec<ColumnSpec> {
1403 vec![
1404 ColumnSpec::new("name", "Name", CellKind::Text).width(120.0),
1405 ColumnSpec::new("qty", "Qty", CellKind::ReadOnly).width(40.0),
1406 ColumnSpec::new("note", "Note", CellKind::Text),
1407 ColumnSpec::new(
1408 "grade",
1409 "Grade",
1410 CellKind::Choice {
1411 options: vec!["A".into(), "B".into()],
1412 },
1413 ),
1414 ColumnSpec::new("del", "", CellKind::Button { label: "x".into() }).width(30.0),
1415 ColumnSpec::new("on", "", CellKind::Toggle).width(26.0),
1416 ColumnSpec::new("flags", "", CellKind::Badges).width(52.0),
1417 ColumnSpec::new(
1418 "act",
1419 "",
1420 CellKind::Actions {
1421 label: "\u{22EF}".into(),
1422 },
1423 )
1424 .width(30.0),
1425 ]
1426 }
1427
1428 fn actions(open_allowed: bool) -> Vec<RowAction> {
1431 vec![
1432 RowAction::new("open", "Open").tooltip("Open it"),
1433 RowAction::new("rename", "Rename").tooltip("Rename it"),
1434 RowAction::new("drop", "Drop")
1435 .separator_above()
1436 .destructive(),
1437 ]
1438 .into_iter()
1439 .map(|action| {
1440 if action.id == "open" && !open_allowed {
1441 action.disabled("This one has nothing to open")
1442 } else {
1443 action
1444 }
1445 })
1446 .collect()
1447 }
1448
1449 fn rows() -> Vec<RowNode> {
1452 vec![
1453 RowNode::new("J1")
1454 .cell("name", Value::String("J1".into()))
1455 .cell("qty", serde_json::json!(2))
1456 .cell("note", Value::String("main".into()))
1457 .cell("on", Value::Bool(true))
1458 .cell(
1459 "flags",
1460 serde_json::json!([
1461 { "glyph": "A", "color": "#ff9f0a", "tooltip": "amber" },
1462 { "glyph": "B" }
1463 ]),
1464 )
1465 .actions(actions(true)),
1466 {
1467 let mut parent = RowNode::new("J2")
1468 .cell("name", Value::String("J2".into()))
1469 .cell("qty", serde_json::json!(1))
1470 .actions(actions(false));
1471 parent.expanded = true;
1472 parent.children = vec![
1473 RowNode::new("J2-P2").cell("name", Value::String("pin2".into())),
1475 RowNode::new("J2-P1").cell("name", Value::String("pin1".into())),
1476 ];
1477 parent
1478 },
1479 ]
1480 }
1481
1482 fn spec<'a>(columns: &'a [ColumnSpec]) -> ColumnTreeSpec<'a> {
1483 ColumnTreeSpec {
1484 id: "test-tree",
1485 columns,
1486 root_label: None,
1487 root_cells: None,
1488 empty_hint: Some("(nothing)"),
1489 hits_prefix: "",
1490 }
1491 }
1492
1493 fn frame(
1495 ctx: &egui::Context,
1496 layout: &mut ColumnLayout,
1497 rows: &[RowNode],
1498 events: Vec<egui::Event>,
1499 ) -> (ColumnTreeOut, HashMap<String, egui::Rect>) {
1500 let cols = columns();
1501 let spec = spec(&cols);
1502 let mut hits = HashMap::new();
1503 let mut out = ColumnTreeOut::default();
1504 let raw = egui::RawInput {
1505 screen_rect: Some(egui::Rect::from_min_size(
1506 egui::pos2(0.0, 0.0),
1507 egui::vec2(700.0, 500.0),
1508 )),
1509 events,
1510 ..Default::default()
1511 };
1512 let _ = ctx.run_ui(raw, |ui| {
1513 out = column_tree(ui, &spec, layout, rows, Some(&mut hits));
1514 });
1515 (out, hits)
1516 }
1517
1518 fn click_at(
1521 ctx: &egui::Context,
1522 layout: &mut ColumnLayout,
1523 rows: &[RowNode],
1524 pos: egui::Pos2,
1525 button: egui::PointerButton,
1526 ) -> ColumnTreeOut {
1527 frame(
1528 ctx,
1529 layout,
1530 rows,
1531 vec![
1532 egui::Event::PointerMoved(pos),
1533 egui::Event::PointerButton {
1534 pos,
1535 button,
1536 pressed: true,
1537 modifiers: egui::Modifiers::default(),
1538 },
1539 ],
1540 );
1541 frame(
1542 ctx,
1543 layout,
1544 rows,
1545 vec![egui::Event::PointerButton {
1546 pos,
1547 button,
1548 pressed: false,
1549 modifiers: egui::Modifiers::default(),
1550 }],
1551 )
1552 .0
1553 }
1554
1555 fn right_click_at(
1557 ctx: &egui::Context,
1558 layout: &mut ColumnLayout,
1559 rows: &[RowNode],
1560 pos: egui::Pos2,
1561 ) -> ColumnTreeOut {
1562 click_at(ctx, layout, rows, pos, egui::PointerButton::Secondary)
1563 }
1564
1565 fn settle(ctx: &egui::Context, layout: &mut ColumnLayout, rows: &[RowNode])
1569 -> HashMap<String, egui::Rect>
1570 {
1571 frame(ctx, layout, rows, vec![]);
1572 frame(ctx, layout, rows, vec![]).1
1573 }
1574
1575 fn menu_entries(hits: &HashMap<String, egui::Rect>, row: &str) -> Vec<String> {
1577 let prefix = format!("menuitem:{row}:");
1578 let mut keys: Vec<String> = hits
1579 .keys()
1580 .filter_map(|key| key.strip_prefix(&prefix).map(str::to_string))
1581 .collect();
1582 keys.sort();
1583 keys
1584 }
1585
1586 #[test]
1589 fn every_column_and_every_row_cell_is_drawn() {
1590 let ctx = egui::Context::default();
1591 let mut layout = ColumnLayout::default();
1592 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1593 for key in ["name", "qty", "note", "grade", "del", "act"] {
1594 assert!(hits.contains_key(&format!("col:{key}")), "heading {key}");
1595 }
1596 for row in ["J1", "J2", "J2-P1", "J2-P2"] {
1597 assert!(hits.contains_key(&format!("row:{row}")), "tree cell {row}");
1598 assert!(
1599 hits.contains_key(&format!("cell:{row}:note")),
1600 "editor cell {row}"
1601 );
1602 }
1603 assert!(
1606 hits["row:J2-P1"].left() > hits["row:J2"].left(),
1607 "a child indents"
1608 );
1609 }
1610
1611 #[test]
1614 fn heading_click_cycles_sort_and_sorts_within_each_level() {
1615 let ctx = egui::Context::default();
1616 let mut layout = ColumnLayout::default();
1617 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1618 let head = hits["col:name"].center();
1619
1620 let out = click_at(&ctx, &mut layout, &rows(), head, egui::PointerButton::Primary);
1621 assert!(out.layout_changed, "a sort click is a layout change");
1622 assert_eq!(layout.sort, Some(("name".into(), true)));
1623 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1626 assert!(hits["row:J2-P1"].top() < hits["row:J2-P2"].top(), "sorted");
1627 assert!(hits["row:J2"].top() < hits["row:J2-P1"].top(), "still nested");
1628
1629 click_at(&ctx, &mut layout, &rows(), head, egui::PointerButton::Primary);
1630 assert_eq!(layout.sort, Some(("name".into(), false)), "then descending");
1631 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1632 assert!(hits["row:J2-P2"].top() < hits["row:J2-P1"].top(), "reversed");
1633
1634 click_at(&ctx, &mut layout, &rows(), head, egui::PointerButton::Primary);
1635 assert_eq!(layout.sort, None, "a third click clears the sort");
1636 }
1637
1638 #[test]
1641 fn empty_cells_sort_last_and_numbers_compare_numerically() {
1642 use std::cmp::Ordering;
1643 let ten = serde_json::json!(10);
1644 let nine = serde_json::json!(9);
1645 assert_eq!(compare_cells(Some(&ten), Some(&nine)), Ordering::Greater);
1646 let ten_text = Value::String("10".into());
1648 let nine_text = Value::String("9".into());
1649 assert_eq!(
1650 compare_cells(Some(&ten_text), Some(&nine_text)),
1651 Ordering::Less
1652 );
1653 let filled = Value::String("a".into());
1654 assert_eq!(compare_cells(None, Some(&filled)), Ordering::Greater);
1655 assert_eq!(compare_cells(Some(&Value::Null), Some(&filled)), Ordering::Greater);
1656 assert_eq!(compare_cells(None, None), Ordering::Equal);
1657 }
1658
1659 #[test]
1661 fn dragging_a_divider_resizes_the_column() {
1662 let ctx = egui::Context::default();
1663 let mut layout = ColumnLayout::default();
1664 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1665 let grip = hits["grip:name"].center();
1666 frame(
1667 &ctx,
1668 &mut layout,
1669 &rows(),
1670 vec![
1671 egui::Event::PointerMoved(grip),
1672 egui::Event::PointerButton {
1673 pos: grip,
1674 button: egui::PointerButton::Primary,
1675 pressed: true,
1676 modifiers: egui::Modifiers::default(),
1677 },
1678 ],
1679 );
1680 let mut out = ColumnTreeOut::default();
1681 for step in 1..=4 {
1682 out = frame(
1683 &ctx,
1684 &mut layout,
1685 &rows(),
1686 vec![egui::Event::PointerMoved(egui::pos2(
1687 grip.x + 10.0 * step as f32,
1688 grip.y,
1689 ))],
1690 )
1691 .0;
1692 }
1693 assert!(out.layout_changed, "a resize is a layout change");
1694 assert!(
1695 layout.widths["name"] > 120.0,
1696 "widened past its default: {:?}",
1697 layout.widths
1698 );
1699 layout.widths.insert("name".into(), 1.0);
1701 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1702 assert!(hits["col:name"].width() >= MIN_COLUMN_WIDTH);
1703 }
1704
1705 #[test]
1707 fn a_hidden_column_disappears_and_the_rest_close_up() {
1708 let ctx = egui::Context::default();
1709 let mut layout = ColumnLayout::default();
1710 let (_, before) = frame(&ctx, &mut layout, &rows(), vec![]);
1711 let note_left = before["col:note"].left();
1712 layout.hidden.insert("qty".into());
1713 let (_, after) = frame(&ctx, &mut layout, &rows(), vec![]);
1714 assert!(!after.contains_key("col:qty"), "no heading");
1715 assert!(!after.contains_key("cell:J1:qty"), "no cell");
1716 assert!(
1717 after["col:note"].left() < note_left,
1718 "the columns to its right close up"
1719 );
1720 }
1721
1722 #[test]
1726 fn layout_order_reorders_and_unnamed_columns_still_appear() {
1727 let ctx = egui::Context::default();
1728 let mut layout = ColumnLayout {
1729 order: vec!["note".into(), "name".into()],
1730 ..Default::default()
1731 };
1732 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1733 assert!(hits["col:note"].left() < hits["col:name"].left(), "reordered");
1734 for key in ["qty", "grade", "del"] {
1735 assert!(
1736 hits.contains_key(&format!("col:{key}")),
1737 "unnamed column {key} still drawn"
1738 );
1739 }
1740 assert!(hits.contains_key("row:J1"), "the tree cell moved with it");
1743 }
1744
1745 #[test]
1748 fn move_column_seeds_from_the_drawn_order() {
1749 let cols = columns();
1750 let visible: Vec<&ColumnSpec> = cols.iter().collect();
1751 let mut layout = ColumnLayout::default();
1752 assert!(move_column(&mut layout, &visible, "grade", "name"));
1753 assert_eq!(
1754 layout.order,
1755 vec!["grade", "name", "qty", "note", "del", "on", "flags", "act"],
1756 "the held column takes the target's slot"
1757 );
1758 }
1759
1760 #[test]
1763 fn a_button_cell_reports_the_click_and_edits_nothing() {
1764 let ctx = egui::Context::default();
1765 let mut layout = ColumnLayout::default();
1766 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1767 let out = click_at(
1768 &ctx,
1769 &mut layout,
1770 &rows(),
1771 hits["cell:J1:del"].center(),
1772 egui::PointerButton::Primary,
1773 );
1774 assert_eq!(
1775 out.buttons,
1776 vec![CellClick {
1777 row_id: "J1".into(),
1778 column: "del".into()
1779 }]
1780 );
1781 assert!(out.edits.is_empty(), "a button never writes a cell");
1782 }
1783
1784 #[test]
1787 fn a_toggle_cell_reports_the_flipped_boolean() {
1788 let ctx = egui::Context::default();
1789 let mut layout = ColumnLayout::default();
1790 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1791 let out = click_at(
1792 &ctx,
1793 &mut layout,
1794 &rows(),
1795 hits["cell:J1:on"].center(),
1796 egui::PointerButton::Primary,
1797 );
1798 assert_eq!(
1799 out.edits,
1800 vec![CellEdit {
1801 row_id: "J1".into(),
1802 column: "on".into(),
1803 value: Value::Bool(false),
1804 }],
1805 "the cell was true, so the click writes false"
1806 );
1807 }
1808
1809 #[test]
1812 fn a_toggle_on_a_read_only_row_draws_but_does_not_write() {
1813 let ctx = egui::Context::default();
1814 let mut layout = ColumnLayout::default();
1815 let mut rows = rows();
1816 rows[0].editable = false;
1817 let (_, hits) = frame(&ctx, &mut layout, &rows, vec![]);
1818 let cell = *hits.get("cell:J1:on").expect("the box is still DRAWN");
1819 let out = click_at(&ctx, &mut layout, &rows, cell.center(), egui::PointerButton::Primary);
1820 assert!(out.edits.is_empty(), "but it does not write");
1821 }
1822
1823 #[test]
1826 fn a_badges_cell_draws_glyphs_and_never_edits() {
1827 let ctx = egui::Context::default();
1828 let mut layout = ColumnLayout::default();
1829 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1830 let cell = *hits.get("cell:J1:flags").expect("a badges rect");
1831 let out = click_at(&ctx, &mut layout, &rows(), cell.center(), egui::PointerButton::Primary);
1832 assert!(out.edits.is_empty(), "badges are read-only");
1833 assert!(out.buttons.is_empty());
1834 assert_eq!(parse_hex_color("#ff9f0a"), Some(egui::Color32::from_rgb(0xff, 0x9f, 0x0a)));
1835 assert_eq!(parse_hex_color("nonsense"), None, "ignored, not guessed");
1836 assert_eq!(parse_hex_color("#fff"), None, "a short form is not a colour");
1837 }
1838
1839 #[test]
1842 fn a_collapse_box_click_reports_a_toggle_not_a_state_change() {
1843 let ctx = egui::Context::default();
1844 let mut layout = ColumnLayout::default();
1845 let mut data = rows();
1846 let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
1847 let out = click_at(
1848 &ctx,
1849 &mut layout,
1850 &data,
1851 hits["box:J2"].center(),
1852 egui::PointerButton::Primary,
1853 );
1854 assert_eq!(out.toggled.as_deref(), Some("J2"));
1855 assert!(data[1].expanded, "the widget did not touch the caller's state");
1856 data[1].expanded = false;
1858 let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
1859 assert!(!hits.contains_key("row:J2-P1"), "collapsed children are gone");
1860 }
1861
1862 #[test]
1866 fn a_text_cell_commits_on_focus_loss_not_per_keystroke() {
1867 let ctx = egui::Context::default();
1868 let mut layout = ColumnLayout::default();
1869 let data = rows();
1870 let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
1871 let cell = hits["cell:J1:note"].center();
1872 click_at(&ctx, &mut layout, &data, cell, egui::PointerButton::Primary);
1873
1874 let out = frame(
1875 &ctx,
1876 &mut layout,
1877 &data,
1878 vec![egui::Event::Text("XY".into())],
1879 )
1880 .0;
1881 assert!(out.edits.is_empty(), "typing alone commits nothing");
1882
1883 let out = frame(
1885 &ctx,
1886 &mut layout,
1887 &data,
1888 vec![
1889 egui::Event::Key {
1890 key: egui::Key::Enter,
1891 physical_key: None,
1892 pressed: true,
1893 repeat: false,
1894 modifiers: egui::Modifiers::default(),
1895 },
1896 egui::Event::Key {
1897 key: egui::Key::Enter,
1898 physical_key: None,
1899 pressed: false,
1900 repeat: false,
1901 modifiers: egui::Modifiers::default(),
1902 },
1903 ],
1904 )
1905 .0;
1906 assert_eq!(
1907 out.edits,
1908 vec![CellEdit {
1909 row_id: "J1".into(),
1910 column: "note".into(),
1911 value: Value::String("mainXY".into()),
1912 }],
1913 "one commit carrying the finished text"
1914 );
1915 }
1916
1917 #[test]
1921 fn a_read_only_row_shows_values_but_takes_no_edit() {
1922 let ctx = egui::Context::default();
1923 let mut layout = ColumnLayout::default();
1924 let mut data = rows();
1925 data[0].editable = false;
1926 let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
1927 assert!(hits.contains_key("cell:J1:note"), "the cell is still drawn");
1928 let cell = hits["cell:J1:note"].center();
1929 click_at(&ctx, &mut layout, &data, cell, egui::PointerButton::Primary);
1930 let out = frame(&ctx, &mut layout, &data, vec![egui::Event::Text("Z".into())]).0;
1931 assert!(out.edits.is_empty(), "a read-only row takes no text");
1932 let out = click_at(
1934 &ctx,
1935 &mut layout,
1936 &data,
1937 hits["cell:J1:del"].center(),
1938 egui::PointerButton::Primary,
1939 );
1940 assert_eq!(out.buttons.len(), 1, "buttons stay live on a read-only row");
1941 }
1942
1943 #[test]
1945 fn an_empty_tree_draws_the_hint() {
1946 let ctx = egui::Context::default();
1947 let mut layout = ColumnLayout::default();
1948 let (out, hits) = frame(&ctx, &mut layout, &[], vec![]);
1949 assert!(hits.contains_key("col:name"), "the header still stands");
1950 assert!(out.edits.is_empty());
1951 }
1952
1953 #[test]
1956 fn hiding_every_column_says_so() {
1957 let ctx = egui::Context::default();
1958 let mut layout = ColumnLayout::default();
1959 for column in columns() {
1960 layout.hidden.insert(column.key);
1961 }
1962 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
1963 assert!(hits.is_empty(), "nothing to publish, and no panic");
1964 }
1965
1966 #[test]
1974 fn a_right_click_on_a_text_cell_opens_the_row_menu() {
1975 let ctx = egui::Context::default();
1976 let mut layout = ColumnLayout::default();
1977 let data = rows();
1978 let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
1979 let cell = hits["cell:J1:note"].center();
1980
1981 let out = right_click_at(&ctx, &mut layout, &data, cell);
1982 assert!(out.clicked.is_none(), "a right-click never selects");
1983 assert_eq!(layout.sort, None, "and never sorts");
1984 assert!(!out.layout_changed);
1985
1986 let hits = settle(&ctx, &mut layout, &data);
1987 assert_eq!(
1988 menu_entries(&hits, "J1"),
1989 vec!["drop", "open", "rename"],
1990 "the row's declared entries, from a right-click over a text cell"
1991 );
1992 }
1993
1994 #[test]
1997 fn both_triggers_open_one_and_the_same_menu() {
1998 let ctx = egui::Context::default();
1999 let mut layout = ColumnLayout::default();
2000 let data = rows();
2001 let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
2002
2003 click_at(
2004 &ctx,
2005 &mut layout,
2006 &data,
2007 hits["menu:J1"].center(),
2008 egui::PointerButton::Primary,
2009 );
2010 let from_cell = settle(&ctx, &mut layout, &data);
2011 let by_cell = menu_entries(&from_cell, "J1");
2012 assert_eq!(by_cell, vec!["drop", "open", "rename"]);
2013
2014 click_at(
2016 &ctx,
2017 &mut layout,
2018 &data,
2019 hits["menu:J1"].center(),
2020 egui::PointerButton::Primary,
2021 );
2022 let closed = settle(&ctx, &mut layout, &data);
2023 assert!(
2024 menu_entries(&closed, "J1").is_empty(),
2025 "a second click on the trigger shuts it"
2026 );
2027
2028 right_click_at(&ctx, &mut layout, &data, hits["row:J1"].center());
2029 let from_row = settle(&ctx, &mut layout, &data);
2030 assert_eq!(menu_entries(&from_row, "J1"), by_cell, "the same menu");
2031 }
2032
2033 #[test]
2037 fn an_entry_reports_its_row_and_a_disabled_one_reports_nothing() {
2038 let ctx = egui::Context::default();
2039 let mut layout = ColumnLayout::default();
2040 let data = rows();
2041 let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
2042
2043 click_at(
2045 &ctx,
2046 &mut layout,
2047 &data,
2048 hits["menu:J2"].center(),
2049 egui::PointerButton::Primary,
2050 );
2051 let open = settle(&ctx, &mut layout, &data);
2052 assert_eq!(
2053 menu_entries(&open, "J2"),
2054 vec!["drop", "open", "rename"],
2055 "a refused entry is GREYED, not hidden"
2056 );
2057 let out = click_at(
2058 &ctx,
2059 &mut layout,
2060 &data,
2061 open["menuitem:J2:open"].center(),
2062 egui::PointerButton::Primary,
2063 );
2064 assert!(out.actions.is_empty(), "a disabled entry fires nothing");
2065
2066 click_at(
2070 &ctx,
2071 &mut layout,
2072 &data,
2073 hits["menu:J2"].center(),
2074 egui::PointerButton::Primary,
2075 );
2076 let open = settle(&ctx, &mut layout, &data);
2077 let out = click_at(
2078 &ctx,
2079 &mut layout,
2080 &data,
2081 open["menuitem:J2:rename"].center(),
2082 egui::PointerButton::Primary,
2083 );
2084 assert_eq!(
2085 out.actions,
2086 vec![RowActionClick {
2087 row_id: "J2".into(),
2088 action: "rename".into()
2089 }]
2090 );
2091 assert!(out.edits.is_empty(), "a menu never writes a cell");
2092 let after = settle(&ctx, &mut layout, &data);
2093 assert!(
2094 menu_entries(&after, "J2").is_empty(),
2095 "choosing an entry closes the menu"
2096 );
2097 }
2098
2099 #[test]
2102 fn a_row_that_declares_nothing_opens_nothing() {
2103 let ctx = egui::Context::default();
2104 let mut layout = ColumnLayout::default();
2105 let data = rows();
2106 let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
2107 assert!(hits.contains_key("menu:J2-P1"), "the trigger cell is drawn");
2108
2109 click_at(
2110 &ctx,
2111 &mut layout,
2112 &data,
2113 hits["menu:J2-P1"].center(),
2114 egui::PointerButton::Primary,
2115 );
2116 right_click_at(&ctx, &mut layout, &data, hits["cell:J2-P1:note"].center());
2117 let after = settle(&ctx, &mut layout, &data);
2118 assert!(
2119 after.keys().all(|key| !key.starts_with("menuitem:")),
2120 "no menu from either trigger: {:?}",
2121 after.keys().collect::<Vec<_>>()
2122 );
2123 }
2124
2125 #[test]
2132 fn frozen_columns_hold_still_while_the_rest_scroll() {
2133 let ctx = egui::Context::default();
2134 let mut layout = ColumnLayout {
2135 frozen: 2,
2136 ..Default::default()
2137 };
2138 layout.widths.insert("note".into(), 400.0);
2140 layout.widths.insert("grade".into(), 400.0);
2141 let data = rows();
2142 let (_, before) = frame(&ctx, &mut layout, &data, vec![]);
2143 assert!(before.contains_key("freeze:divider"), "the boundary is drawn");
2144 let divider = before["freeze:divider"];
2145 assert!(before["col:qty"].right() <= divider.left() + 1.0, "qty is pinned");
2146 assert!(before["col:note"].left() >= divider.left(), "note scrolls");
2147
2148 let over = before["col:note"].center();
2150 for _ in 0..8 {
2151 frame(
2152 &ctx,
2153 &mut layout,
2154 &data,
2155 vec![
2156 egui::Event::PointerMoved(over),
2157 egui::Event::MouseWheel {
2158 unit: egui::MouseWheelUnit::Point,
2159 delta: egui::vec2(-60.0, 0.0),
2160 phase: egui::TouchPhase::Move,
2161 modifiers: egui::Modifiers::default(),
2162 },
2163 ],
2164 );
2165 }
2166 let (_, after) = frame(&ctx, &mut layout, &data, vec![]);
2167 assert_eq!(
2168 after["col:name"], before["col:name"],
2169 "a frozen heading does not move"
2170 );
2171 assert_eq!(after["col:qty"], before["col:qty"], "nor the second one");
2172 assert_eq!(after["row:J1"], before["row:J1"], "nor the frozen tree cell");
2173 assert!(
2174 after["col:note"].left() < before["col:note"].left() - 20.0,
2175 "and the scrolling side did scroll: {:?} -> {:?}",
2176 before["col:note"],
2177 after["col:note"]
2178 );
2179 }
2180
2181 #[test]
2185 fn freezing_every_column_reads_as_none() {
2186 let ctx = egui::Context::default();
2187 let mut layout = ColumnLayout {
2188 frozen: 99,
2189 ..Default::default()
2190 };
2191 let (_, hits) = frame(&ctx, &mut layout, &rows(), vec![]);
2192 assert!(!hits.contains_key("freeze:divider"), "no boundary is drawn");
2193 assert!(hits.contains_key("col:act"), "and the last column is still there");
2194 }
2195
2196 #[test]
2199 fn hiding_a_frozen_column_does_not_promote_the_next_one() {
2200 let ctx = egui::Context::default();
2201 let mut layout = ColumnLayout {
2202 frozen: 2,
2203 ..Default::default()
2204 };
2205 layout.widths.insert("note".into(), 400.0);
2206 layout.widths.insert("grade".into(), 400.0);
2207 let (_, before) = frame(&ctx, &mut layout, &rows(), vec![]);
2208 assert!(before["col:note"].left() >= before["freeze:divider"].left());
2209
2210 layout.hidden.insert("qty".into());
2211 let (_, after) = frame(&ctx, &mut layout, &rows(), vec![]);
2212 assert!(
2213 after["col:note"].left() >= after["freeze:divider"].left(),
2214 "note stayed on the scrolling side rather than being promoted"
2215 );
2216 assert!(
2217 after["col:name"].right() <= after["freeze:divider"].left() + 1.0,
2218 "and the surviving frozen column is still frozen"
2219 );
2220 }
2221
2222 #[test]
2228 fn the_horizontal_scrollbar_does_not_eat_the_last_rows_clicks() {
2229 let ctx = egui::Context::default();
2230 let mut layout = ColumnLayout::default();
2231 layout.widths.insert("note".into(), 300.0);
2233 layout.widths.insert("grade".into(), 300.0);
2234 let mut data = rows();
2235 data[1].expanded = false; let mut hits = frame(&ctx, &mut layout, &data, vec![]).1;
2239 for _ in 0..12 {
2240 hits = frame(
2241 &ctx,
2242 &mut layout,
2243 &data,
2244 vec![
2245 egui::Event::PointerMoved(egui::pos2(350.0, 40.0)),
2246 egui::Event::MouseWheel {
2247 unit: egui::MouseWheelUnit::Point,
2248 delta: egui::vec2(-60.0, 0.0),
2249 phase: egui::TouchPhase::Move,
2250 modifiers: egui::Modifiers::default(),
2251 },
2252 ],
2253 )
2254 .1;
2255 }
2256 let trigger = *hits
2257 .get("menu:J2")
2258 .expect("the trailing action column scrolled into view");
2259
2260 click_at(
2261 &ctx,
2262 &mut layout,
2263 &data,
2264 trigger.center(),
2265 egui::PointerButton::Primary,
2266 );
2267 let hits = settle(&ctx, &mut layout, &data);
2268 assert!(
2269 !menu_entries(&hits, "J2").is_empty(),
2270 "the CENTRE of the last row's trigger opened nothing: {trigger:?}"
2271 );
2272 }
2273
2274 #[test]
2279 fn it_survives_being_nested_in_a_vertical_scroll_area() {
2280 let ctx = egui::Context::default();
2281 let cols = columns();
2282 let spec = spec(&cols);
2283 let data = rows();
2284 let mut layout = ColumnLayout {
2287 frozen: 2,
2288 ..Default::default()
2289 };
2290
2291 let mut draw = |events: Vec<egui::Event>| {
2292 let mut hits = HashMap::new();
2293 let raw = egui::RawInput {
2294 screen_rect: Some(egui::Rect::from_min_size(
2295 egui::pos2(0.0, 0.0),
2296 egui::vec2(700.0, 500.0),
2297 )),
2298 events,
2299 ..Default::default()
2300 };
2301 let _ = ctx.run_ui(raw, |ui| {
2302 egui::ScrollArea::vertical()
2303 .auto_shrink([false, false])
2304 .show(ui, |ui| {
2305 column_tree(ui, &spec, &mut layout, &data, Some(&mut hits));
2306 });
2307 });
2308 hits
2309 };
2310
2311 let hits = draw(vec![]);
2312 for (key, rect) in &hits {
2313 assert!(rect.is_finite(), "{key} has an infinite rect {rect:?}");
2317 if key.starts_with("row:")
2320 || key.starts_with("col:")
2321 || key.starts_with("menu")
2322 || key.starts_with("freeze:")
2323 {
2324 assert!(rect.is_positive(), "{key} is unclickable: {rect:?}");
2325 }
2326 }
2327 assert!(hits.contains_key("freeze:divider"), "still frozen in there");
2328 assert!(hits["row:J1"].top() < 500.0, "and drawn on screen");
2329
2330 let trigger = hits["menu:J1"].center();
2332 draw(vec![
2333 egui::Event::PointerMoved(trigger),
2334 egui::Event::PointerButton {
2335 pos: trigger,
2336 button: egui::PointerButton::Primary,
2337 pressed: true,
2338 modifiers: egui::Modifiers::default(),
2339 },
2340 ]);
2341 draw(vec![egui::Event::PointerButton {
2342 pos: trigger,
2343 button: egui::PointerButton::Primary,
2344 pressed: false,
2345 modifiers: egui::Modifiers::default(),
2346 }]);
2347 draw(vec![]);
2348 let hits = draw(vec![]);
2349 assert_eq!(menu_entries(&hits, "J1"), vec!["drop", "open", "rename"]);
2350 }
2351
2352 #[test]
2356 fn a_heading_drags_across_the_freeze_boundary() {
2357 let ctx = egui::Context::default();
2358 let mut layout = ColumnLayout {
2359 order: vec![
2360 "name".into(),
2361 "qty".into(),
2362 "note".into(),
2363 "grade".into(),
2364 "del".into(),
2365 "act".into(),
2366 ],
2367 frozen: 2,
2368 ..Default::default()
2369 };
2370 let data = rows();
2371 let (_, hits) = frame(&ctx, &mut layout, &data, vec![]);
2372 let from = hits["col:note"].center();
2373 let onto = hits["col:name"].center();
2374
2375 frame(
2376 &ctx,
2377 &mut layout,
2378 &data,
2379 vec![
2380 egui::Event::PointerMoved(from),
2381 egui::Event::PointerButton {
2382 pos: from,
2383 button: egui::PointerButton::Primary,
2384 pressed: true,
2385 modifiers: egui::Modifiers::default(),
2386 },
2387 ],
2388 );
2389 for step in 1..=4 {
2390 let at = egui::pos2(from.x + (onto.x - from.x) * step as f32 / 4.0, from.y);
2391 frame(&ctx, &mut layout, &data, vec![egui::Event::PointerMoved(at)]);
2392 }
2393 let out = frame(
2394 &ctx,
2395 &mut layout,
2396 &data,
2397 vec![egui::Event::PointerButton {
2398 pos: onto,
2399 button: egui::PointerButton::Primary,
2400 pressed: false,
2401 modifiers: egui::Modifiers::default(),
2402 }],
2403 )
2404 .0;
2405 assert!(out.layout_changed, "the drop is a layout change");
2406 assert_eq!(
2407 layout.order.first().map(String::as_str),
2408 Some("note"),
2409 "the scrolling column took the frozen one's slot: {:?}",
2410 layout.order
2411 );
2412 }
2413}