1use std::collections::{BTreeMap, VecDeque};
12
13use egui::{Align2, Color32, FontId, Pos2, Rect, CornerRadius, Sense, Stroke, Ui, Vec2};
14use facett_core::{Facet, FacetCaps, theme};
15
16const NODE_W: f32 = 160.0;
17const NODE_H: f32 = 56.0;
18const COL_GAP: f32 = 220.0;
19const ROW_GAP: f32 = 90.0;
20const LEFT_PAD: f32 = 30.0;
21const TOP_PAD: f32 = 40.0;
22
23#[derive(Clone)]
26pub struct DepNode {
27 pub label: String,
28 pub sublabel: Option<String>,
29 pub color: Color32,
30}
31
32#[derive(Clone)]
34pub struct DepEdge {
35 pub from: usize,
36 pub to: usize,
37 pub via: Vec<String>,
38}
39
40pub struct DepGraphView {
42 pub title: String,
43 pub nodes: Vec<DepNode>,
44 pub edges: Vec<DepEdge>,
45 pub selected: Option<usize>,
46}
47
48impl DepGraphView {
49 pub fn new(nodes: Vec<DepNode>, edges: Vec<DepEdge>) -> Self {
50 Self { title: "deps".into(), nodes, edges, selected: None }
51 }
52 pub fn with_title(mut self, t: impl Into<String>) -> Self {
53 self.title = t.into();
54 self
55 }
56
57 pub fn columns(&self) -> Vec<usize> {
60 let n = self.nodes.len();
61 let mut indeg = vec![0usize; n];
62 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
63 for e in &self.edges {
64 if e.from < n && e.to < n {
65 adj[e.to].push(e.from);
67 indeg[e.from] += 1;
68 }
69 }
70 let mut col = vec![0usize; n];
71 let mut q: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
72 while let Some(r) = q.pop_front() {
73 for &c in &adj[r] {
74 col[c] = col[c].max(col[r] + 1);
75 indeg[c] -= 1;
76 if indeg[c] == 0 {
77 q.push_back(c);
78 }
79 }
80 }
81 col
82 }
83
84 pub fn show(&mut self, ui: &mut Ui) {
85 if self.nodes.is_empty() {
86 ui.label("no nodes");
87 return;
88 }
89 let col = self.columns();
90 let mut row_of = vec![0usize; self.nodes.len()];
91 let mut per_col: BTreeMap<usize, usize> = BTreeMap::new();
92 for (i, &c) in col.iter().enumerate() {
93 let r = per_col.entry(c).or_insert(0);
94 row_of[i] = *r;
95 *r += 1;
96 }
97 let ncols = col.iter().copied().max().unwrap_or(0) + 1;
98 let nrows = per_col.values().copied().max().unwrap_or(1);
99 let needed_w = LEFT_PAD + ncols as f32 * COL_GAP + NODE_W;
100 let needed_h = TOP_PAD + nrows as f32 * ROW_GAP + NODE_H;
101 let avail = ui.available_size();
102 let canvas = Vec2::new(avail.x.max(needed_w), avail.y.max(needed_h.max(360.0)));
103 let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click());
104 let th = theme(ui);
105 let painter = ui.painter_at(rect);
106 painter.rect_filled(rect, CornerRadius::ZERO, th.bg);
107
108 let pos_for = |i: usize| {
109 Pos2::new(
110 rect.left() + LEFT_PAD + col[i] as f32 * COL_GAP,
111 rect.top() + TOP_PAD + row_of[i] as f32 * ROW_GAP,
112 )
113 };
114
115 for e in &self.edges {
117 if e.from >= self.nodes.len() || e.to >= self.nodes.len() {
118 continue;
119 }
120 let from = pos_for(e.from) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
121 let to = pos_for(e.to) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
122 draw_arrow(&painter, from, to, th.edge);
123 if !e.via.is_empty() {
124 let via = e.via.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
125 let label = if e.via.len() > 2 { format!("{via} +{}", e.via.len() - 2) } else { via };
126 let mid = Pos2::new((from.x + to.x) / 2.0, (from.y + to.y) / 2.0 - 8.0);
127 painter.text(mid, Align2::CENTER_BOTTOM, &label, FontId::monospace(10.0), th.text_dim);
128 }
129 }
130
131 for (i, node) in self.nodes.iter().enumerate() {
133 let p = pos_for(i);
134 let nrect = Rect::from_min_size(p, Vec2::new(NODE_W, NODE_H));
135 let is_sel = self.selected == Some(i);
136 let fill = if is_sel { th.accent.linear_multiply(0.2) } else { th.node_fill };
137 painter.rect_filled(nrect, CornerRadius::same(6), fill);
138 let (bw, bc) = if is_sel { (3.0, th.accent) } else { (2.0, node.color) };
139 painter.rect_stroke(nrect, CornerRadius::same(6), Stroke::new(bw, bc), egui::StrokeKind::Inside);
140 painter.text(p + Vec2::new(NODE_W / 2.0, 14.0), Align2::CENTER_TOP, &node.label, FontId::proportional(15.0), th.text);
141 if let Some(sub) = &node.sublabel {
142 let short: String = sub.chars().take(12).collect();
143 painter.text(p + Vec2::new(NODE_W / 2.0, 34.0), Align2::CENTER_TOP, short, FontId::monospace(11.0), th.text_dim);
144 }
145 }
146
147 if resp.clicked() {
149 if let Some(pos) = resp.interact_pointer_pos() {
150 let hit = (0..self.nodes.len()).find(|&i| Rect::from_min_size(pos_for(i), Vec2::new(NODE_W, NODE_H)).contains(pos));
151 if let Some(i) = hit {
152 self.selected = if self.selected == Some(i) { None } else { Some(i) };
153 }
154 }
155 }
156
157 if let Some(sel) = self.selected {
159 let deps: Vec<String> = self
160 .edges
161 .iter()
162 .filter(|e| e.from == sel)
163 .map(|e| format!(" → {} [{}]", self.nodes[e.to].label, e.via.join(", ")))
164 .collect();
165 let users: Vec<String> = self
166 .edges
167 .iter()
168 .filter(|e| e.to == sel)
169 .map(|e| format!(" ← {} [{}]", self.nodes[e.from].label, e.via.join(", ")))
170 .collect();
171 let rows = 3 + deps.len() + users.len();
172 let panel = Rect::from_min_size(rect.left_top() + Vec2::new(8.0, 8.0), Vec2::new(380.0, rows as f32 * 16.0 + 16.0));
173 painter.rect_filled(panel, CornerRadius::same(6), th.panel_bg);
174 painter.rect_stroke(panel, CornerRadius::same(6), Stroke::new(1.0, th.panel_stroke), egui::StrokeKind::Inside);
175 let mut y = panel.top() + 8.0;
176 let mut put = |s: &str, c: Color32| {
177 painter.text(Pos2::new(panel.left() + 10.0, y), Align2::LEFT_TOP, s, FontId::proportional(12.0), c);
178 y += 16.0;
179 };
180 put(&format!("◆ {} (click again to deselect)", self.nodes[sel].label), th.accent);
181 put(&format!("depends on ({}):", deps.len()), th.text_dim);
182 for d in &deps {
183 put(d, th.text);
184 }
185 put(&format!("used by ({}):", users.len()), th.text_dim);
186 for u in &users {
187 put(u, th.text);
188 }
189 }
190 }
191}
192
193pub fn draw_arrow(painter: &egui::Painter, from: Pos2, to: Pos2, color: Color32) {
195 painter.line_segment([from, to], Stroke::new(2.0, color));
196 let dir = (to - from).normalized();
197 let perp = Vec2::new(-dir.y, dir.x);
198 let tip = to - dir * (NODE_W / 2.0 + 4.0);
199 let base = tip - dir * 10.0;
200 painter.add(egui::Shape::convex_polygon(vec![tip, base + perp * 5.0, base - perp * 5.0], color, Stroke::NONE));
201}
202
203impl Facet for DepGraphView {
204 fn title(&self) -> &str {
205 &self.title
206 }
207 fn ui(&mut self, ui: &mut Ui) {
208 self.show(ui);
209 }
210 fn state_json(&self) -> serde_json::Value {
211 serde_json::json!({
212 "nodes": self.nodes.len(),
213 "edges": self.edges.len(),
214 "columns": self.columns().iter().copied().max().map(|m| m + 1).unwrap_or(0),
215 "selected": self.selected.map(|i| self.nodes[i].label.clone()),
216 })
217 }
218 fn caps(&self) -> FacetCaps {
222 FacetCaps::NONE.themeable().selectable().resizable()
223 }
224 fn selection_json(&self) -> serde_json::Value {
225 match self.selected {
226 Some(i) => serde_json::json!({ "node": self.nodes[i].label }),
227 None => serde_json::Value::Null,
228 }
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 fn node(l: &str) -> DepNode {
237 DepNode { label: l.into(), sublabel: None, color: Color32::GRAY }
238 }
239
240 #[test]
241 fn toposort_columns_and_state() {
242 let nodes = vec![node("a"), node("b"), node("c")];
244 let edges = vec![
245 DepEdge { from: 0, to: 1, via: vec!["x".into()] },
246 DepEdge { from: 1, to: 2, via: vec![] },
247 ];
248 let view = DepGraphView::new(nodes, edges);
249 let cols = view.columns();
250 assert!(cols[2] < cols[1] && cols[1] < cols[0], "deps to the left: {cols:?}");
251 let j = view.state_json();
252 assert_eq!(j["nodes"], 3);
253 assert_eq!(j["edges"], 2);
254 assert_eq!(j["columns"], 3);
255 assert!(j["selected"].is_null());
256 }
257}