use std::collections::{BTreeMap, VecDeque};
use egui::{Align2, Color32, FontId, Pos2, Rect, CornerRadius, Sense, Stroke, Ui, Vec2};
use facett_core::{Facet, FacetCaps, theme};
const NODE_W: f32 = 160.0;
const NODE_H: f32 = 56.0;
const COL_GAP: f32 = 220.0;
const ROW_GAP: f32 = 90.0;
const LEFT_PAD: f32 = 30.0;
const TOP_PAD: f32 = 40.0;
#[derive(Clone)]
pub struct DepNode {
pub label: String,
pub sublabel: Option<String>,
pub color: Color32,
}
#[derive(Clone)]
pub struct DepEdge {
pub from: usize,
pub to: usize,
pub via: Vec<String>,
}
pub struct DepGraphView {
pub title: String,
pub nodes: Vec<DepNode>,
pub edges: Vec<DepEdge>,
pub selected: Option<usize>,
}
impl DepGraphView {
pub fn new(nodes: Vec<DepNode>, edges: Vec<DepEdge>) -> Self {
Self { title: "deps".into(), nodes, edges, selected: None }
}
pub fn with_title(mut self, t: impl Into<String>) -> Self {
self.title = t.into();
self
}
pub fn columns(&self) -> Vec<usize> {
let n = self.nodes.len();
let mut indeg = vec![0usize; n];
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
for e in &self.edges {
if e.from < n && e.to < n {
adj[e.to].push(e.from);
indeg[e.from] += 1;
}
}
let mut col = vec![0usize; n];
let mut q: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
while let Some(r) = q.pop_front() {
for &c in &adj[r] {
col[c] = col[c].max(col[r] + 1);
indeg[c] -= 1;
if indeg[c] == 0 {
q.push_back(c);
}
}
}
col
}
pub fn show(&mut self, ui: &mut Ui) {
if self.nodes.is_empty() {
ui.label("no nodes");
return;
}
let col = self.columns();
let mut row_of = vec![0usize; self.nodes.len()];
let mut per_col: BTreeMap<usize, usize> = BTreeMap::new();
for (i, &c) in col.iter().enumerate() {
let r = per_col.entry(c).or_insert(0);
row_of[i] = *r;
*r += 1;
}
let ncols = col.iter().copied().max().unwrap_or(0) + 1;
let nrows = per_col.values().copied().max().unwrap_or(1);
let needed_w = LEFT_PAD + ncols as f32 * COL_GAP + NODE_W;
let needed_h = TOP_PAD + nrows as f32 * ROW_GAP + NODE_H;
let avail = ui.available_size();
let canvas = Vec2::new(avail.x.max(needed_w), avail.y.max(needed_h.max(360.0)));
let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click());
let th = theme(ui);
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, th.bg);
let pos_for = |i: usize| {
Pos2::new(
rect.left() + LEFT_PAD + col[i] as f32 * COL_GAP,
rect.top() + TOP_PAD + row_of[i] as f32 * ROW_GAP,
)
};
for e in &self.edges {
if e.from >= self.nodes.len() || e.to >= self.nodes.len() {
continue;
}
let from = pos_for(e.from) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
let to = pos_for(e.to) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
draw_arrow(&painter, from, to, th.edge);
if !e.via.is_empty() {
let via = e.via.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
let label = if e.via.len() > 2 { format!("{via} +{}", e.via.len() - 2) } else { via };
let mid = Pos2::new((from.x + to.x) / 2.0, (from.y + to.y) / 2.0 - 8.0);
painter.text(mid, Align2::CENTER_BOTTOM, &label, FontId::monospace(10.0), th.text_dim);
}
}
for (i, node) in self.nodes.iter().enumerate() {
let p = pos_for(i);
let nrect = Rect::from_min_size(p, Vec2::new(NODE_W, NODE_H));
let is_sel = self.selected == Some(i);
let fill = if is_sel { th.accent.linear_multiply(0.2) } else { th.node_fill };
painter.rect_filled(nrect, CornerRadius::same(6), fill);
let (bw, bc) = if is_sel { (3.0, th.accent) } else { (2.0, node.color) };
painter.rect_stroke(nrect, CornerRadius::same(6), Stroke::new(bw, bc), egui::StrokeKind::Inside);
painter.text(p + Vec2::new(NODE_W / 2.0, 14.0), Align2::CENTER_TOP, &node.label, FontId::proportional(15.0), th.text);
if let Some(sub) = &node.sublabel {
let short: String = sub.chars().take(12).collect();
painter.text(p + Vec2::new(NODE_W / 2.0, 34.0), Align2::CENTER_TOP, short, FontId::monospace(11.0), th.text_dim);
}
}
if resp.clicked() {
if let Some(pos) = resp.interact_pointer_pos() {
let hit = (0..self.nodes.len()).find(|&i| Rect::from_min_size(pos_for(i), Vec2::new(NODE_W, NODE_H)).contains(pos));
if let Some(i) = hit {
self.selected = if self.selected == Some(i) { None } else { Some(i) };
}
}
}
if let Some(sel) = self.selected {
let deps: Vec<String> = self
.edges
.iter()
.filter(|e| e.from == sel)
.map(|e| format!(" → {} [{}]", self.nodes[e.to].label, e.via.join(", ")))
.collect();
let users: Vec<String> = self
.edges
.iter()
.filter(|e| e.to == sel)
.map(|e| format!(" ← {} [{}]", self.nodes[e.from].label, e.via.join(", ")))
.collect();
let rows = 3 + deps.len() + users.len();
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));
painter.rect_filled(panel, CornerRadius::same(6), th.panel_bg);
painter.rect_stroke(panel, CornerRadius::same(6), Stroke::new(1.0, th.panel_stroke), egui::StrokeKind::Inside);
let mut y = panel.top() + 8.0;
let mut put = |s: &str, c: Color32| {
painter.text(Pos2::new(panel.left() + 10.0, y), Align2::LEFT_TOP, s, FontId::proportional(12.0), c);
y += 16.0;
};
put(&format!("◆ {} (click again to deselect)", self.nodes[sel].label), th.accent);
put(&format!("depends on ({}):", deps.len()), th.text_dim);
for d in &deps {
put(d, th.text);
}
put(&format!("used by ({}):", users.len()), th.text_dim);
for u in &users {
put(u, th.text);
}
}
}
}
pub fn draw_arrow(painter: &egui::Painter, from: Pos2, to: Pos2, color: Color32) {
painter.line_segment([from, to], Stroke::new(2.0, color));
let dir = (to - from).normalized();
let perp = Vec2::new(-dir.y, dir.x);
let tip = to - dir * (NODE_W / 2.0 + 4.0);
let base = tip - dir * 10.0;
painter.add(egui::Shape::convex_polygon(vec![tip, base + perp * 5.0, base - perp * 5.0], color, Stroke::NONE));
}
impl Facet for DepGraphView {
fn title(&self) -> &str {
&self.title
}
fn ui(&mut self, ui: &mut Ui) {
self.show(ui);
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"nodes": self.nodes.len(),
"edges": self.edges.len(),
"columns": self.columns().iter().copied().max().map(|m| m + 1).unwrap_or(0),
"selected": self.selected.map(|i| self.nodes[i].label.clone()),
})
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().selectable().resizable()
}
fn selection_json(&self) -> serde_json::Value {
match self.selected {
Some(i) => serde_json::json!({ "node": self.nodes[i].label }),
None => serde_json::Value::Null,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn node(l: &str) -> DepNode {
DepNode { label: l.into(), sublabel: None, color: Color32::GRAY }
}
#[test]
fn toposort_columns_and_state() {
let nodes = vec![node("a"), node("b"), node("c")];
let edges = vec![
DepEdge { from: 0, to: 1, via: vec!["x".into()] },
DepEdge { from: 1, to: 2, via: vec![] },
];
let view = DepGraphView::new(nodes, edges);
let cols = view.columns();
assert!(cols[2] < cols[1] && cols[1] < cols[0], "deps to the left: {cols:?}");
let j = view.state_json();
assert_eq!(j["nodes"], 3);
assert_eq!(j["edges"], 2);
assert_eq!(j["columns"], 3);
assert!(j["selected"].is_null());
}
}