use std::cell::RefCell; use eframe::egui; use egui::ScrollArea; use egui_ltreeview::{ Action, DirPosition, DragAndDrop, NodeBuilder, TreeView, TreeViewBuilder, }; use crate::signal::PlottedSignal; use crate::tree::{ decode_plot_node_id, plot_node_id, PLOT_FLAG, ROOT_NODE_ID, SIGNAL_FLAG, }; /// Auto-placement cap. The user can manually drag more signals into a plot. pub const MAX_SIGNALS_PER_PLOT: usize = 5; pub struct PlotGroup { pub id: u64, pub signals: Vec, } pub enum PlotListAction { NewPlot, Move(DragAndDrop), RemovePlot(u64), RemoveSignal(u64), } pub fn remove_plot(plots: &mut Vec, plot_id: u64) { plots.retain(|p| p.id != plot_id); } /// Keep at most one empty plot; drop any further empties (keeps the /// first one encountered so the user's stable reference doesn't vanish). fn dedupe_empty(plots: &mut Vec) { let mut seen_empty = false; plots.retain(|p| { if p.signals.is_empty() { if seen_empty { false } else { seen_empty = true; true } } else { true } }); } /// Most common unit in the plot (first-wins on ties). Returns `None` /// when the plot is empty. pub fn majority_unit(signals: &[PlottedSignal]) -> Option<&str> { let mut counts: Vec<(&str, usize)> = Vec::new(); for sig in signals { if let Some(c) = counts.iter_mut().find(|(u, _)| *u == sig.unit.as_str()) { c.1 += 1; } else { counts.push((sig.unit.as_str(), 1)); } } counts.into_iter().max_by_key(|(_, c)| *c).map(|(u, _)| u) } pub fn contains_signal(plots: &[PlotGroup], signal_id: u64) -> bool { plots .iter() .any(|p| p.signals.iter().any(|s| s.id == signal_id)) } pub fn remove_signal(plots: &mut Vec, signal_id: u64) { for plot in plots.iter_mut() { if let Some(pos) = plot.signals.iter().position(|s| s.id == signal_id) { plot.signals.remove(pos); break; } } dedupe_empty(plots); } /// Place `sig` into the first plot whose majority unit matches and that /// is under the cap; otherwise append a new plot at the end. pub fn add_signal_auto( plots: &mut Vec, next_plot_id: &mut u64, sig: PlottedSignal, ) { let unit = sig.unit.as_str(); // Prefer an existing empty plot, then a unit-matching plot under cap. let slot = plots .iter() .position(|p| p.signals.is_empty()) .or_else(|| { plots.iter().position(|p| { p.signals.len() < MAX_SIGNALS_PER_PLOT && majority_unit(&p.signals) == Some(unit) }) }); if let Some(i) = slot { plots[i].signals.push(sig); } else { plots.push(PlotGroup { id: *next_plot_id, signals: vec![sig], }); *next_plot_id += 1; } } pub fn new_plot(plots: &mut Vec, next_plot_id: &mut u64) { if plots.iter().any(|p| p.signals.is_empty()) { return; } plots.push(PlotGroup { id: *next_plot_id, signals: Vec::new(), }); *next_plot_id += 1; } pub fn show_plot_list( ui: &mut egui::Ui, plots: &[PlotGroup], ) -> Vec { let events: RefCell> = RefCell::new(Vec::new()); if ui.button("+ New plot").clicked() { events.borrow_mut().push(PlotListAction::NewPlot); } ScrollArea::both() .scroll([true, true]) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded) .show(ui, |ui| { let (_resp, actions) = TreeView::::new(ui.make_persistent_id("PlotsTree")) .show(ui, |builder: &mut TreeViewBuilder<'_, u64>| { // Implicit root so the library can produce // Before/After positions for top-level plots. builder.dir(ROOT_NODE_ID, "Plots"); for (i, plot) in plots.iter().enumerate() { let unit = majority_unit(&plot.signals).unwrap_or("empty"); let label = format!("Plot {} [{}]", i + 1, unit); let plot_id = plot.id; let node = NodeBuilder::dir(plot_node_id(plot.id)) .label(label) .activatable(true) .context_menu(|ui| { if ui.button("Remove plot").clicked() { events .borrow_mut() .push(PlotListAction::RemovePlot(plot_id)); ui.close(); } }); builder.node(node); for sig in &plot.signals { let sig_id = sig.id; let node = NodeBuilder::leaf(sig.id) .label(sig.label.as_str()) .context_menu(|ui| { if ui.button("Remove signal").clicked() { events.borrow_mut().push( PlotListAction::RemoveSignal(sig_id), ); ui.close(); } }); builder.node(node); } builder.close_dir(); } builder.close_dir(); }); for action in actions { match action { Action::Move(dd) => { events.borrow_mut().push(PlotListAction::Move(dd)); } Action::Activate(act) => { for id in act.selected { if id & SIGNAL_FLAG != 0 { events .borrow_mut() .push(PlotListAction::RemoveSignal(id)); } else if let Some(plot_id) = decode_plot_node_id(id) { events .borrow_mut() .push(PlotListAction::RemovePlot(plot_id)); } } } _ => {} } } }); events.into_inner() } /// Apply a drag-and-drop move to the plot list. /// /// Sources are either signal leaves (move signal between/within plots) or /// plot dirs (reorder plots). Mixed sources are handled item-by-item. pub fn apply_move(plots: &mut Vec, dd: DragAndDrop) { for source in dd.source { if source & SIGNAL_FLAG != 0 { apply_signal_move(plots, source, dd.target, &dd.position); } else if source & PLOT_FLAG != 0 { apply_plot_move(plots, source & !PLOT_FLAG, dd.target, &dd.position); } } dedupe_empty(plots); } fn apply_signal_move( plots: &mut Vec, signal_id: u64, target: u64, position: &DirPosition, ) { // Resolve the target plot. If dropped on the root or somewhere we // can't interpret, leave the signal where it is. let target_plot_id = if let Some(pid) = decode_plot_node_id(target) { pid } else { return; }; // Detach from its current plot. let detached = plots.iter_mut().find_map(|p| { p.signals .iter() .position(|s| s.id == signal_id) .map(|pos| p.signals.remove(pos)) }); let Some(sig) = detached else { return; }; let Some(target_plot) = plots.iter_mut().find(|p| p.id == target_plot_id) else { return; }; let insert_at = match position { DirPosition::First => 0, DirPosition::Last => target_plot.signals.len(), DirPosition::Before(sibling) => target_plot .signals .iter() .position(|s| s.id == *sibling) .unwrap_or(target_plot.signals.len()), DirPosition::After(sibling) => target_plot .signals .iter() .position(|s| s.id == *sibling) .map(|i| i + 1) .unwrap_or(target_plot.signals.len()), }; target_plot.signals.insert(insert_at, sig); } fn apply_plot_move( plots: &mut Vec, plot_id: u64, target: u64, position: &DirPosition, ) { // Only reordering at the root level is supported. Dropping a plot // onto another plot is treated as "place after that plot". let from = match plots.iter().position(|p| p.id == plot_id) { Some(i) => i, None => return, }; let plot = plots.remove(from); let to = if target == ROOT_NODE_ID { match position { DirPosition::First => 0, DirPosition::Last => plots.len(), DirPosition::Before(sibling) => decode_plot_node_id(*sibling) .and_then(|pid| plots.iter().position(|p| p.id == pid)) .unwrap_or(plots.len()), DirPosition::After(sibling) => decode_plot_node_id(*sibling) .and_then(|pid| plots.iter().position(|p| p.id == pid)) .map(|i| i + 1) .unwrap_or(plots.len()), } } else if let Some(target_plot_id) = decode_plot_node_id(target) { plots .iter() .position(|p| p.id == target_plot_id) .map(|i| i + 1) .unwrap_or(plots.len()) } else { plots.len() }; let to = to.min(plots.len()); plots.insert(to, plot); }