1use crate::color::parse_hex_color;
17use crate::panels::tree::{self, TreeRow};
18use eframe::egui;
19use serde_json::Value;
20use std::collections::{HashMap, HashSet};
21
22pub const MIN_COLUMN_WIDTH: f32 = 28.0;
25
26pub const DEFAULT_COLUMN_WIDTH: f32 = 110.0;
28
29const GRIP: f32 = 5.0;
31
32const CELL_PAD: f32 = 3.0;
34
35const FREEZE_GAP: f32 = 4.0;
37
38#[derive(Debug, Clone, PartialEq)]
40pub enum CellKind {
41 Text,
43 Numeric { step: f64 },
45 Choice { options: Vec<String> },
48 Button { label: String },
51 Actions { label: String },
55 Badges,
62 Toggle,
68 ReadOnly,
71}
72
73#[derive(Debug, Clone, PartialEq)]
75pub struct ColumnSpec {
76 pub key: String,
78 pub label: String,
80 pub kind: CellKind,
81 pub default_width: f32,
83}
84
85impl ColumnSpec {
86 pub fn new(key: impl Into<String>, label: impl Into<String>, kind: CellKind) -> Self {
88 Self {
89 key: key.into(),
90 label: label.into(),
91 kind,
92 default_width: DEFAULT_COLUMN_WIDTH,
93 }
94 }
95
96 pub fn width(mut self, width: f32) -> Self {
97 self.default_width = width;
98 self
99 }
100}
101
102#[derive(Debug, Clone, PartialEq)]
107pub struct RowAction {
108 pub id: String,
110 pub label: String,
112 pub tooltip: String,
115 pub enabled: bool,
118 pub separator_above: bool,
121 pub destructive: bool,
123}
124
125impl RowAction {
126 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
128 Self {
129 id: id.into(),
130 label: label.into(),
131 tooltip: String::new(),
132 enabled: true,
133 separator_above: false,
134 destructive: false,
135 }
136 }
137
138 pub fn tooltip(mut self, text: impl Into<String>) -> Self {
139 self.tooltip = text.into();
140 self
141 }
142
143 pub fn disabled(mut self, why: impl Into<String>) -> Self {
145 self.enabled = false;
146 self.tooltip = why.into();
147 self
148 }
149
150 pub fn separator_above(mut self) -> Self {
151 self.separator_above = true;
152 self
153 }
154
155 pub fn destructive(mut self) -> Self {
156 self.destructive = true;
157 self
158 }
159}
160
161#[derive(Debug, Clone, Default, PartialEq)]
165pub struct RowNode {
166 pub id: String,
168 pub cells: HashMap<String, Value>,
170 pub editable: bool,
174 pub selected: bool,
176 pub expanded: bool,
178 pub actions: Vec<RowAction>,
183 pub children: Vec<RowNode>,
184}
185
186impl RowNode {
187 pub fn new(id: impl Into<String>) -> Self {
188 Self {
189 id: id.into(),
190 editable: true,
191 ..Default::default()
192 }
193 }
194
195 pub fn cell(mut self, key: impl Into<String>, value: Value) -> Self {
197 self.cells.insert(key.into(), value);
198 self
199 }
200
201 pub fn actions(mut self, actions: Vec<RowAction>) -> Self {
203 self.actions = actions;
204 self
205 }
206}
207
208#[derive(Debug, Clone, Default, PartialEq)]
211pub struct ColumnLayout {
212 pub order: Vec<String>,
216 pub hidden: HashSet<String>,
218 pub widths: HashMap<String, f32>,
220 pub sort: Option<(String, bool)>,
222 pub frozen: usize,
227}
228
229#[derive(Debug, Clone, PartialEq)]
231pub struct CellEdit {
232 pub row_id: String,
233 pub column: String,
234 pub value: Value,
235}
236
237#[derive(Debug, Clone, PartialEq)]
239pub struct CellClick {
240 pub row_id: String,
241 pub column: String,
242}
243
244#[derive(Debug, Clone, PartialEq)]
246pub struct RowActionClick {
247 pub row_id: String,
248 pub action: String,
250}
251
252pub struct ColumnTreeSpec<'a> {
255 pub id: &'a str,
259 pub columns: &'a [ColumnSpec],
262 pub root_label: Option<&'a str>,
265 pub root_cells: Option<&'a HashMap<String, Value>>,
267 pub empty_hint: Option<&'a str>,
269 pub hits_prefix: &'a str,
272}
273
274#[derive(Debug, Default, Clone, PartialEq)]
276pub struct ColumnTreeOut {
277 pub edits: Vec<CellEdit>,
280 pub buttons: Vec<CellClick>,
282 pub actions: Vec<RowActionClick>,
284 pub toggled: Option<String>,
287 pub clicked: Option<String>,
289 pub hovered: Option<String>,
293 pub layout_changed: bool,
296}
297
298pub fn column_tree(
314 ui: &mut egui::Ui,
315 spec: &ColumnTreeSpec<'_>,
316 layout: &mut ColumnLayout,
317 rows: &[RowNode],
318 mut hits: Option<&mut HashMap<String, egui::Rect>>,
319) -> ColumnTreeOut {
320 let mut out = ColumnTreeOut::default();
321 let arranged = arranged_columns(spec, layout);
325 let visible: Vec<&ColumnSpec> = arranged
326 .iter()
327 .copied()
328 .filter(|column| !layout.hidden.contains(&column.key))
329 .collect();
330 if visible.is_empty() {
331 ui.label(egui::RichText::new("(every column is hidden)").weak());
332 return out;
333 }
334
335 let widths: Vec<f32> = visible
337 .iter()
338 .map(|column| column_width(layout, column))
339 .collect();
340
341 let mut frozen = arranged
346 .iter()
347 .take(layout.frozen)
348 .filter(|column| !layout.hidden.contains(&column.key))
349 .count();
350 if frozen >= visible.len() {
351 frozen = 0;
352 }
353
354 ui.spacing_mut().item_spacing.y = 2.0;
355 let full = ui.available_rect_before_wrap();
356 let frozen_width: f32 = widths[..frozen]
359 .iter()
360 .sum::<f32>()
361 .min((full.width() - MIN_COLUMN_WIDTH).max(0.0));
362
363 let mut pending: Option<MenuOpen> = None;
365 let mut bounds: Vec<(String, f32, f32)> = Vec::new();
370 let mut bottom = full.top();
371
372 if frozen > 0 {
373 let rect = egui::Rect::from_min_max(
374 full.min,
375 egui::pos2(full.min.x + frozen_width, full.max.y),
376 );
377 let mut pane = ui.new_child(
378 egui::UiBuilder::new()
379 .max_rect(rect)
380 .layout(egui::Layout::top_down(egui::Align::Min))
381 .id_salt((spec.id, "column-tree-frozen")),
382 );
383 pane.set_clip_rect(pane.clip_rect().intersect(egui::Rect::from_x_y_ranges(
386 rect.x_range(),
387 ui.clip_rect().y_range(),
388 )));
389 pane.spacing_mut().item_spacing.y = 2.0;
390 draw_pane(
391 &mut pane,
392 spec,
393 layout,
394 &visible[..frozen],
395 &widths[..frozen],
396 0,
397 frozen_width,
398 rows,
399 &mut hits,
400 &mut out,
401 &mut pending,
402 &mut bounds,
403 );
404 bottom = bottom.max(pane.min_rect().bottom());
405 }
406
407 let scroll_left = full.min.x + if frozen > 0 { frozen_width + FREEZE_GAP } else { 0.0 };
411 let scroll_rect = egui::Rect::from_min_max(egui::pos2(scroll_left, full.min.y), full.max);
412 let viewport = scroll_rect.width();
413 let mut pane = ui.new_child(
414 egui::UiBuilder::new()
415 .max_rect(scroll_rect)
416 .layout(egui::Layout::top_down(egui::Align::Min))
417 .id_salt((spec.id, "column-tree-scrolling")),
418 );
419 let rest: f32 = widths[frozen..].iter().sum();
420 egui::ScrollArea::horizontal()
424 .id_salt((spec.id, "column-tree-hscroll"))
425 .show(&mut pane, |ui: &mut egui::Ui| {
426 ui.spacing_mut().item_spacing.y = 2.0;
427 draw_pane(
428 ui,
429 spec,
430 layout,
431 &visible[frozen..],
432 &widths[frozen..],
433 frozen,
434 rest.max(viewport),
435 rows,
436 &mut hits,
437 &mut out,
438 &mut pending,
439 &mut bounds,
440 );
441 if rest > viewport {
442 let scroll = ui.spacing().scroll;
448 ui.add_space(scroll.bar_width + scroll.bar_inner_margin + scroll.bar_outer_margin);
449 }
450 });
451 bottom = bottom.max(pane.min_rect().bottom());
452
453 let used = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x, bottom));
454 ui.advance_cursor_after_rect(used);
455
456 if frozen > 0 {
458 let x = full.min.x + frozen_width + FREEZE_GAP * 0.5;
459 let divider = egui::Rect::from_min_max(
460 egui::pos2(x - 1.0, used.top()),
461 egui::pos2(x + 1.0, used.bottom()),
462 );
463 ui.painter()
464 .rect_filled(divider, 0.0, ui.visuals().widgets.active.bg_fill);
465 publish(&mut hits, spec, "freeze:divider", divider);
466 }
467
468 finish_reorder(ui, spec, layout, &arranged, &bounds, &mut out);
469 row_action_menu(ui, spec, rows, &mut hits, &mut out, &mut pending);
473 out
474}
475
476#[allow(clippy::too_many_arguments)]
483fn draw_pane(
484 ui: &mut egui::Ui,
485 spec: &ColumnTreeSpec<'_>,
486 layout: &mut ColumnLayout,
487 columns: &[&ColumnSpec],
488 widths: &[f32],
489 offset: usize,
490 band: f32,
491 rows: &[RowNode],
492 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
493 out: &mut ColumnTreeOut,
494 pending: &mut Option<MenuOpen>,
495 bounds: &mut Vec<(String, f32, f32)>,
496) {
497 header(ui, spec, layout, columns, widths, band, hits, out, bounds);
498 ui.separator();
499
500 if let Some(label) = spec.root_label {
501 let mut cells = spec.root_cells.cloned().unwrap_or_default();
502 if offset == 0 {
505 cells.insert(columns[0].key.clone(), Value::String(label.to_string()));
506 }
507 let root = RowNode {
508 id: format!("{}__root", spec.id),
509 cells,
510 editable: false,
511 selected: false,
512 expanded: true,
513 actions: Vec::new(),
514 children: Vec::new(),
515 };
516 draw_row(
517 ui, spec, columns, widths, offset, band, &root, &[], true, true, hits, out, pending,
518 );
519 }
520
521 if rows.is_empty() {
522 if let Some(hint) = spec.empty_hint {
523 if offset == 0 {
524 let guides = tree::child_guides(&[], true);
525 tree::node(ui, TreeRow::leaf(&guides, true, hint), |_| {});
526 } else {
527 ui.allocate_exact_size(
530 egui::vec2(band, ui.spacing().interact_size.y),
531 egui::Sense::hover(),
532 );
533 }
534 }
535 return;
536 }
537
538 let ordered = sorted_siblings(rows, layout);
539 let last = ordered.len();
540 for (index, row) in ordered.iter().enumerate() {
541 draw_subtree(
542 ui,
543 spec,
544 layout,
545 columns,
546 widths,
547 offset,
548 band,
549 row,
550 &[],
551 index + 1 == last,
552 hits,
553 out,
554 pending,
555 );
556 }
557}
558
559fn arranged_columns<'a>(
564 spec: &'a ColumnTreeSpec<'_>,
565 layout: &ColumnLayout,
566) -> Vec<&'a ColumnSpec> {
567 let mut out: Vec<&ColumnSpec> = Vec::new();
568 for key in &layout.order {
569 if let Some(column) = spec.columns.iter().find(|c| &c.key == key) {
570 if !out.iter().any(|c| c.key == column.key) {
571 out.push(column);
572 }
573 }
574 }
575 for column in spec.columns {
576 if !out.iter().any(|c| c.key == column.key) {
577 out.push(column);
578 }
579 }
580 out
581}
582
583fn column_width(layout: &ColumnLayout, column: &ColumnSpec) -> f32 {
584 layout
585 .widths
586 .get(&column.key)
587 .copied()
588 .unwrap_or(column.default_width)
589 .max(MIN_COLUMN_WIDTH)
590}
591
592#[allow(clippy::too_many_arguments)]
597fn header(
598 ui: &mut egui::Ui,
599 spec: &ColumnTreeSpec<'_>,
600 layout: &mut ColumnLayout,
601 visible: &[&ColumnSpec],
602 widths: &[f32],
603 width: f32,
604 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
605 out: &mut ColumnTreeOut,
606 bounds: &mut Vec<(String, f32, f32)>,
607) {
608 let height = ui.spacing().interact_size.y;
609 let (band, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
610 let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
611 let dragging: Option<String> = ui.data(|d| d.get_temp(drag_key));
612
613 let mut x = band.left();
614 let first = bounds.len();
617 for (column, width) in visible.iter().zip(widths) {
618 let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
619 bounds.push((column.key.clone(), cell.left(), cell.right()));
620
621 let resp = ui.interact(
622 cell,
623 ui.id().with(("column-tree-head", spec.id, &column.key)),
624 egui::Sense::click_and_drag(),
625 );
626 let held = dragging.as_deref() == Some(column.key.as_str());
627 let fill = if held {
628 ui.visuals().selection.bg_fill.gamma_multiply(0.45)
629 } else if resp.hovered() {
630 ui.visuals().widgets.hovered.bg_fill
631 } else {
632 ui.visuals().widgets.noninteractive.bg_fill
633 };
634 ui.painter().rect_filled(cell, 0.0, fill);
635 let marker = match &layout.sort {
638 Some((key, true)) if key == &column.key => " \u{25B2}",
639 Some((key, false)) if key == &column.key => " \u{25BC}",
640 _ => "",
641 };
642 let text = format!("{}{marker}", column.label);
643 ui.painter().text(
644 egui::pos2(cell.left() + CELL_PAD, cell.center().y),
645 egui::Align2::LEFT_CENTER,
646 elide(ui, &text, *width - 2.0 * CELL_PAD),
647 egui::TextStyle::Body.resolve(ui.style()),
648 ui.visuals().strong_text_color(),
649 );
650 publish(hits, spec, &format!("col:{}", column.key), cell);
651
652 resp.context_menu(|ui| {
656 ui.label(egui::RichText::new("Columns").strong());
657 for candidate in spec.columns {
658 let mut shown = !layout.hidden.contains(&candidate.key);
659 if ui.checkbox(&mut shown, &candidate.label).changed() {
660 if shown {
661 layout.hidden.remove(&candidate.key);
662 } else {
663 layout.hidden.insert(candidate.key.clone());
664 }
665 out.layout_changed = true;
666 }
667 }
668 });
669
670 if resp.clicked() {
674 layout.sort = match &layout.sort {
675 Some((key, true)) if key == &column.key => Some((column.key.clone(), false)),
676 Some((key, false)) if key == &column.key => None,
677 _ => Some((column.key.clone(), true)),
678 };
679 out.layout_changed = true;
680 }
681 if resp.drag_started() {
682 ui.data_mut(|d| d.insert_temp(drag_key, column.key.clone()));
683 }
684
685 ui.painter().line_segment(
686 [
687 egui::pos2(cell.right(), band.top()),
688 egui::pos2(cell.right(), band.bottom()),
689 ],
690 ui.visuals().widgets.noninteractive.bg_stroke,
691 );
692 x = cell.right();
693 }
694
695 for (column, (_, _, right)) in visible.iter().zip(&bounds[first..]) {
701 let grip_rect = egui::Rect::from_min_max(
702 egui::pos2(right - GRIP * 0.5, band.top()),
703 egui::pos2(right + GRIP * 0.5, band.bottom()),
704 );
705 let grip = ui.interact(
706 grip_rect,
707 ui.id().with(("column-tree-grip", spec.id, &column.key)),
708 egui::Sense::drag(),
709 );
710 if grip.hovered() || grip.dragged() {
711 ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
712 }
713 if grip.dragged() {
714 let next = (column_width(layout, column) + grip.drag_delta().x).max(MIN_COLUMN_WIDTH);
715 layout.widths.insert(column.key.clone(), next);
716 out.layout_changed = true;
717 }
718 publish(hits, spec, &format!("grip:{}", column.key), grip_rect);
719 }
720}
721
722fn finish_reorder(
726 ui: &egui::Ui,
727 spec: &ColumnTreeSpec<'_>,
728 layout: &mut ColumnLayout,
729 arranged: &[&ColumnSpec],
730 bounds: &[(String, f32, f32)],
731 out: &mut ColumnTreeOut,
732) {
733 let drag_key = egui::Id::new((spec.id, "column-tree-drag"));
734 let Some(held) = ui.data(|d| d.get_temp::<String>(drag_key)) else {
735 return;
736 };
737 if ui.input(|i| i.pointer.any_down()) {
738 return;
739 }
740 ui.data_mut(|d| d.remove::<String>(drag_key));
741 let Some(pos) = ui.input(|i| i.pointer.latest_pos()) else {
742 return;
743 };
744 if let Some((target, _, _)) = bounds
745 .iter()
746 .find(|(_, left, right)| pos.x >= *left && pos.x < *right)
747 {
748 if *target != held && move_column(layout, arranged, &held, target) {
749 out.layout_changed = true;
750 }
751 }
752}
753
754fn move_column(
758 layout: &mut ColumnLayout,
759 arranged: &[&ColumnSpec],
760 held: &str,
761 target: &str,
762) -> bool {
763 let mut order: Vec<String> = layout.order.clone();
764 for column in arranged {
767 if !order.iter().any(|key| key == &column.key) {
768 order.push(column.key.clone());
769 }
770 }
771 let Some(from) = order.iter().position(|key| key == held) else {
772 return false;
773 };
774 let key = order.remove(from);
775 let Some(to) = order.iter().position(|k| k == target) else {
776 order.insert(from.min(order.len()), key);
777 return false;
778 };
779 order.insert(to, key);
780 layout.order = order;
781 true
782}
783
784#[allow(clippy::too_many_arguments)]
787fn draw_subtree(
788 ui: &mut egui::Ui,
789 spec: &ColumnTreeSpec<'_>,
790 layout: &ColumnLayout,
791 visible: &[&ColumnSpec],
792 widths: &[f32],
793 offset: usize,
794 band: f32,
795 row: &RowNode,
796 guides: &[bool],
797 is_last: bool,
798 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
799 out: &mut ColumnTreeOut,
800 pending: &mut Option<MenuOpen>,
801) {
802 draw_row(
803 ui, spec, visible, widths, offset, band, row, guides, is_last, false, hits, out, pending,
804 );
805 if !row.expanded || row.children.is_empty() {
806 return;
807 }
808 let child_guides = tree::child_guides(guides, is_last);
809 let ordered = sorted_siblings(&row.children, layout);
810 let last = ordered.len();
811 for (index, child) in ordered.iter().enumerate() {
812 draw_subtree(
813 ui,
814 spec,
815 layout,
816 visible,
817 widths,
818 offset,
819 band,
820 child,
821 &child_guides,
822 index + 1 == last,
823 hits,
824 out,
825 pending,
826 );
827 }
828}
829
830fn sorted_siblings<'a>(rows: &'a [RowNode], layout: &ColumnLayout) -> Vec<&'a RowNode> {
834 let mut out: Vec<&RowNode> = rows.iter().collect();
835 if let Some((key, ascending)) = &layout.sort {
836 out.sort_by(|a, b| {
837 let ordering = compare_cells(a.cells.get(key), b.cells.get(key));
838 if *ascending {
839 ordering
840 } else {
841 ordering.reverse()
842 }
843 });
844 }
845 out
846}
847
848fn compare_cells(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering {
852 use std::cmp::Ordering;
853 let empty = |value: Option<&Value>| match value {
854 None | Some(Value::Null) => true,
855 Some(Value::String(text)) => text.is_empty(),
856 _ => false,
857 };
858 match (empty(a), empty(b)) {
859 (true, true) => return Ordering::Equal,
860 (true, false) => return Ordering::Greater,
861 (false, true) => return Ordering::Less,
862 (false, false) => {}
863 }
864 if let (Some(Value::Number(x)), Some(Value::Number(y))) = (a, b) {
865 if let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) {
866 return x.partial_cmp(&y).unwrap_or(Ordering::Equal);
867 }
868 }
869 display_text(a).to_lowercase().cmp(&display_text(b).to_lowercase())
870}
871
872fn display_text(value: Option<&Value>) -> String {
875 match value {
876 None | Some(Value::Null) => String::new(),
877 Some(Value::String(text)) => text.clone(),
878 Some(other) => other.to_string(),
879 }
880}
881
882#[allow(clippy::too_many_arguments)]
884fn draw_row(
885 ui: &mut egui::Ui,
886 spec: &ColumnTreeSpec<'_>,
887 visible: &[&ColumnSpec],
888 widths: &[f32],
889 offset: usize,
890 band_width: f32,
891 row: &RowNode,
892 guides: &[bool],
893 is_last: bool,
894 root: bool,
895 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
896 out: &mut ColumnTreeOut,
897 pending: &mut Option<MenuOpen>,
898) {
899 let height = ui.spacing().interact_size.y;
900 let (band, _) = ui.allocate_exact_size(egui::vec2(band_width, height), egui::Sense::hover());
901 let clip = ui.clip_rect();
902
903 let over_row = band.intersect(clip);
910 if !root && over_row.is_positive() && ui.rect_contains_pointer(over_row) {
911 out.hovered = Some(row.id.clone());
912 }
913 if !row.actions.is_empty()
914 && over_row.is_positive()
915 && ui.input(|i| i.pointer.secondary_clicked())
916 {
917 if let Some(pos) = ui.ctx().input(|i| i.pointer.interact_pos()) {
918 let above = ui.ctx().layer_id_at(pos);
921 let ours = above.is_none() || above == Some(ui.layer_id());
922 if over_row.contains(pos) && ours {
923 *pending = Some(MenuOpen {
924 row: row.id.clone(),
925 pos,
926 });
927 }
928 }
929 }
930
931 if row.selected && !root {
938 let visible_band = band.intersect(clip);
939 if visible_band.is_positive() {
940 ui.painter().rect_filled(
941 visible_band,
942 0.0,
943 ui.visuals().selection.bg_fill.gamma_multiply(0.35),
944 );
945 }
946 }
947
948 let mut x = band.left();
949 for (index, (column, width)) in visible.iter().zip(widths).enumerate() {
950 let cell = egui::Rect::from_min_size(egui::pos2(x, band.top()), egui::vec2(*width, height));
951 x = cell.right();
952 let Some(cell_clip) = cell.intersect(clip).is_positive().then_some(cell.intersect(clip))
953 else {
954 continue;
955 };
956 let mut child = ui.new_child(
957 egui::UiBuilder::new()
958 .max_rect(cell.shrink2(egui::vec2(CELL_PAD, 0.0)))
959 .layout(egui::Layout::left_to_right(egui::Align::Center))
960 .id_salt(("column-tree-cell", spec.id, &row.id, &column.key)),
961 );
962 child.set_clip_rect(cell_clip);
963
964 if offset + index == 0 {
965 let label = display_text(row.cells.get(&column.key));
968 let expandable = !row.children.is_empty();
969 let mut tree_row = TreeRow {
970 guides,
971 is_last,
972 expandable,
973 expanded: row.expanded,
974 root,
975 glyph: None,
976 label: &label,
977 selected: row.selected,
978 draggable: false,
979 tint: None,
980 };
981 if root {
982 tree_row.expandable = true;
983 tree_row.expanded = true;
984 }
985 let resp = tree::node(&mut child, tree_row, |_| {});
986 publish(hits, spec, &format!("row:{}", row.id), resp.label.rect);
987 publish(hits, spec, &format!("box:{}", row.id), resp.box_rect);
988 if resp.toggled {
989 out.toggled = Some(row.id.clone());
990 }
991 if resp.label.clicked() {
992 out.clicked = Some(row.id.clone());
993 }
994 } else {
995 let rect = cell_editor(&mut child, spec, row, column, out, pending);
996 publish(
997 hits,
998 spec,
999 &format!("cell:{}:{}", row.id, column.key),
1000 rect,
1001 );
1002 if matches!(column.kind, CellKind::Actions { .. }) {
1003 publish(hits, spec, &format!("menu:{}", row.id), rect);
1004 }
1005 }
1006 }
1007}
1008
1009fn cell_editor(
1013 ui: &mut egui::Ui,
1014 spec: &ColumnTreeSpec<'_>,
1015 row: &RowNode,
1016 column: &ColumnSpec,
1017 out: &mut ColumnTreeOut,
1018 pending: &mut Option<MenuOpen>,
1019) -> egui::Rect {
1020 let value = row.cells.get(&column.key);
1021 let width = ui.available_width();
1022 let mut emit = |new_value: Value| {
1023 out.edits.push(CellEdit {
1024 row_id: row.id.clone(),
1025 column: column.key.clone(),
1026 value: new_value,
1027 });
1028 };
1029
1030 match &column.kind {
1031 CellKind::Button { label } => {
1032 let button = ui.add_sized([width, ui.available_height()], egui::Button::new(label));
1033 if button.clicked() {
1034 out.buttons.push(CellClick {
1035 row_id: row.id.clone(),
1036 column: column.key.clone(),
1037 });
1038 }
1039 button.rect
1040 }
1041 CellKind::Actions { label } => {
1042 let offered = !row.actions.is_empty();
1048 let button = ui
1049 .add_enabled_ui(offered, |ui| {
1050 ui.add_sized([width, ui.available_height()], egui::Button::new(label))
1051 })
1052 .inner;
1053 if button.clicked() {
1054 *pending = Some(MenuOpen {
1055 row: row.id.clone(),
1056 pos: button.rect.left_bottom(),
1057 });
1058 }
1059 button.rect
1060 }
1061 CellKind::ReadOnly => ui.add(egui::Label::new(display_text(value)).truncate()).rect,
1062 CellKind::Badges => {
1063 let badges = value.and_then(Value::as_array).cloned().unwrap_or_default();
1064 ui.horizontal(|ui| {
1065 ui.spacing_mut().item_spacing.x = 3.0;
1066 for badge in &badges {
1067 let glyph = badge.get("glyph").and_then(Value::as_str).unwrap_or("");
1068 if glyph.is_empty() {
1069 continue;
1070 }
1071 let color = badge
1072 .get("color")
1073 .and_then(Value::as_str)
1074 .and_then(parse_hex_color)
1075 .unwrap_or_else(|| ui.visuals().text_color());
1076 let label = match crate::icon_text::glyph(ui, glyph, color) {
1080 Some(art) => ui.add(art),
1081 None => ui.label(egui::RichText::new(glyph).color(color)),
1082 };
1083 if let Some(tip) = badge.get("tooltip").and_then(Value::as_str) {
1084 label.on_hover_text(tip);
1085 }
1086 }
1087 })
1088 .response
1089 .rect
1090 }
1091 CellKind::Toggle => {
1092 let mut on = value.and_then(Value::as_bool).unwrap_or(false);
1096 let box_ = ui
1097 .add_enabled_ui(row.editable, |ui| ui.checkbox(&mut on, ""))
1098 .inner;
1099 if box_.changed() {
1100 emit(Value::Bool(on));
1101 }
1102 box_.rect
1103 }
1104 _ if !row.editable => ui
1105 .add(
1106 egui::Label::new(egui::RichText::new(display_text(value)).weak())
1107 .truncate(),
1108 )
1109 .rect,
1110 CellKind::Text | CellKind::Numeric { .. } | CellKind::Choice { .. } => {
1111 let salt = egui::Id::new(("column-tree-cell", spec.id, &row.id, &column.key));
1116 let size = egui::vec2(width, ui.available_height());
1117 let (edited, rect) = value_editor(ui, salt, &column.kind, value, size);
1118 if let Some(new_value) = edited {
1119 emit(new_value);
1120 }
1121 rect
1122 }
1123 }
1124}
1125
1126pub fn value_editor(
1140 ui: &mut egui::Ui,
1141 salt: egui::Id,
1142 kind: &CellKind,
1143 value: Option<&Value>,
1144 size: egui::Vec2,
1145) -> (Option<Value>, egui::Rect) {
1146 match kind {
1147 CellKind::Text => {
1148 let buffer_id = salt.with("text");
1154 let stored = display_text(value);
1155 let mut buffer: String =
1156 ui.data(|d| d.get_temp(buffer_id)).unwrap_or_else(|| stored.clone());
1157 let edit = ui.add_sized(size, egui::TextEdit::singleline(&mut buffer));
1158 let entered = edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
1159 let mut committed = None;
1160 if edit.has_focus() || edit.changed() {
1161 ui.data_mut(|d| d.insert_temp(buffer_id, buffer.clone()));
1162 }
1163 if edit.lost_focus() || entered {
1164 ui.data_mut(|d| d.remove::<String>(buffer_id));
1165 if buffer != stored {
1166 committed = Some(Value::String(buffer));
1167 }
1168 } else if !edit.has_focus() {
1169 ui.data_mut(|d| d.remove::<String>(buffer_id));
1172 }
1173 (committed, edit.rect)
1174 }
1175 CellKind::Numeric { step } => {
1176 let mut number = value.and_then(Value::as_f64).unwrap_or(0.0);
1177 let drag = ui.add_sized(size, egui::DragValue::new(&mut number).speed(*step));
1178 let committed = drag.changed().then(|| serde_json::json!(number));
1179 (committed, drag.rect)
1180 }
1181 CellKind::Choice { options } => {
1182 let current = display_text(value);
1183 let mut chosen = current.clone();
1184 let combo = egui::ComboBox::from_id_salt(salt.with("combo"))
1185 .width(size.x)
1186 .selected_text(if current.is_empty() { "—" } else { ¤t })
1187 .show_ui(ui, |ui| {
1188 ui.selectable_value(&mut chosen, String::new(), "—");
1191 for option in options {
1192 ui.selectable_value(&mut chosen, option.clone(), option);
1193 }
1194 });
1195 let committed = (chosen != current).then(|| Value::String(chosen));
1196 (committed, combo.response.rect)
1197 }
1198 _ => {
1199 let label = ui.add(egui::Label::new(display_text(value)).truncate());
1200 (None, label.rect)
1201 }
1202 }
1203}
1204
1205#[derive(Clone)]
1209struct MenuOpen {
1210 row: String,
1211 pos: egui::Pos2,
1212}
1213
1214fn row_action_menu(
1218 ui: &mut egui::Ui,
1219 spec: &ColumnTreeSpec<'_>,
1220 rows: &[RowNode],
1221 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
1222 out: &mut ColumnTreeOut,
1223 pending: &mut Option<MenuOpen>,
1224) {
1225 let key = egui::Id::new((spec.id, "column-tree-menu"));
1226 let mut open: Option<MenuOpen> = ui.data(|d| d.get_temp(key));
1227 let was_open = open.as_ref().map(|state| state.row.clone());
1228
1229 if let Some(state) = open.clone() {
1230 match find_row(rows, &state.row) {
1232 Some(row) if !row.actions.is_empty() => {
1233 let mut still_open = true;
1234 egui::Popup::new(
1235 key.with("popup"),
1236 ui.ctx().clone(),
1237 egui::PopupAnchor::Position(state.pos),
1238 ui.layer_id(),
1239 )
1240 .open_bool(&mut still_open)
1241 .kind(egui::PopupKind::Menu)
1242 .layout(egui::Layout::top_down_justified(egui::Align::Min))
1243 .width(160.0)
1244 .show(|ui| {
1245 for action in &row.actions {
1246 if action.separator_above {
1247 ui.separator();
1248 }
1249 let color =
1254 action.destructive.then(|| ui.visuals().error_fg_color);
1255 let button =
1256 crate::icon_text::icon_button_colored(ui, &action.label, color);
1257 let entry = ui.add_enabled(action.enabled, button);
1258 publish(
1259 hits,
1260 spec,
1261 &format!("menuitem:{}:{}", row.id, action.id),
1262 entry.rect,
1263 );
1264 if !action.tooltip.is_empty() {
1265 if action.enabled {
1268 entry.clone().on_hover_text(&action.tooltip);
1269 } else {
1270 entry.clone().on_disabled_hover_text(&action.tooltip);
1271 }
1272 }
1273 if entry.clicked() {
1274 out.actions.push(RowActionClick {
1275 row_id: row.id.clone(),
1276 action: action.id.clone(),
1277 });
1278 }
1279 }
1280 });
1281 if !still_open {
1282 open = None;
1283 }
1284 }
1285 _ => open = None,
1286 }
1287 }
1288
1289 if let Some(next) = pending.take() {
1294 let toggled_off = open.is_none() && was_open.as_deref() == Some(next.row.as_str());
1295 open = (!toggled_off).then_some(next);
1296 }
1297 match &open {
1298 Some(state) => ui.data_mut(|d| {
1299 d.insert_temp(key, state.clone());
1300 }),
1301 None => ui.data_mut(|d| d.remove::<MenuOpen>(key)),
1302 }
1303}
1304
1305fn find_row<'a>(rows: &'a [RowNode], id: &str) -> Option<&'a RowNode> {
1307 for row in rows {
1308 if row.id == id {
1309 return Some(row);
1310 }
1311 if let Some(found) = find_row(&row.children, id) {
1312 return Some(found);
1313 }
1314 }
1315 None
1316}
1317
1318fn elide(ui: &egui::Ui, text: &str, width: f32) -> String {
1320 let font = egui::TextStyle::Body.resolve(ui.style());
1321 let measure = |candidate: &str| {
1322 ui.painter()
1323 .layout_no_wrap(candidate.to_string(), font.clone(), egui::Color32::WHITE)
1324 .rect
1325 .width()
1326 };
1327 if width <= 0.0 || measure(text) <= width {
1328 return text.to_string();
1329 }
1330 let mut cut: Vec<char> = text.chars().collect();
1331 while !cut.is_empty() {
1332 cut.pop();
1333 let candidate: String = cut.iter().collect::<String>() + "\u{2026}";
1334 if measure(&candidate) <= width {
1335 return candidate;
1336 }
1337 }
1338 String::new()
1339}
1340
1341fn publish(
1342 hits: &mut Option<&mut HashMap<String, egui::Rect>>,
1343 spec: &ColumnTreeSpec<'_>,
1344 key: &str,
1345 rect: egui::Rect,
1346) {
1347 if let Some(map) = hits.as_deref_mut() {
1348 map.insert(format!("{}{key}", spec.hits_prefix), rect);
1349 }
1350}
1351
1352