use std::collections::BTreeSet;
use eframe::egui::{
self, Align2, Color32, FontId, Pos2, Rect, CornerRadius, Sense, Stroke, Ui, Vec2,
};
use uuid::Uuid;
use super::depgraph_layout::{DepGraphLayout, EdgeClass};
use super::model::Timeline;
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;
const DIRECT_COLOR: Color32 = Color32::from_rgb(190, 140, 230);
const TRANSITIVE_COLOR: Color32 = Color32::from_rgb(90, 120, 165);
#[derive(Default)]
pub struct DepGraphView {
pub deep: bool,
pub pan: Vec2,
pub zoom: f32,
pub collapsed: BTreeSet<String>,
}
impl DepGraphView {
pub fn new() -> Self {
Self { deep: false, pan: Vec2::ZERO, zoom: 1.0, collapsed: BTreeSet::new() }
}
pub fn state_json_for(&self, tl: &Timeline, selected_release: Option<Uuid>) -> serde_json::Value {
self.layout_for(tl, selected_release)
.map(|l| l.state_json())
.unwrap_or(serde_json::Value::Null)
}
fn layout_for(&self, tl: &Timeline, selected_release: Option<Uuid>) -> Option<DepGraphLayout> {
let release = selected_release.or_else(|| tl.release_order.last().copied())?;
let snapshot = tl.snapshot_for(&release)?;
let repos: Vec<String> = tl.lanes.iter().map(|l| l.repo.clone()).collect();
Some(DepGraphLayout::build(&repos, &snapshot.edges, self.deep, &self.collapsed))
}
}
pub fn draw_dep_graph(
ui: &mut Ui,
tl: &Timeline,
selected_release: Option<Uuid>,
selected_repo: &mut Option<String>,
view: &mut DepGraphView,
) {
if view.zoom <= 0.0 {
view.zoom = 1.0;
}
ui.horizontal(|ui| {
let deep_label = if view.deep { "🌳 deep: ON" } else { "🌳 deep: off" };
if ui
.selectable_label(view.deep, deep_label)
.on_hover_text("explode to the FULL transitive closure (C5)")
.clicked()
{
view.deep = !view.deep;
}
ui.separator();
if ui.button("➖").on_hover_text("zoom out").clicked() {
view.zoom = (view.zoom * 0.9).max(0.25);
}
ui.label(format!("{:.0}%", view.zoom * 100.0));
if ui.button("➕").on_hover_text("zoom in").clicked() {
view.zoom = (view.zoom * 1.1).min(4.0);
}
if ui.button("⟲ reset view").on_hover_text("reset pan + zoom").clicked() {
view.pan = Vec2::ZERO;
view.zoom = 1.0;
}
if !view.collapsed.is_empty()
&& ui.button(format!("expand all ({})", view.collapsed.len()))
.on_hover_text("re-expand every collapsed node")
.clicked()
{
view.collapsed.clear();
}
ui.separator();
legend_swatch(ui, DIRECT_COLOR, "direct dep");
legend_swatch(ui, TRANSITIVE_COLOR, "transitive dep");
});
let Some(layout) = view.layout_for(tl, selected_release) else {
ui.label("no dependency-graph snapshot for this release");
return;
};
let n_cols = layout.nodes.iter().map(|n| n.col).max().unwrap_or(0) + 1;
let n_rows = layout.nodes.iter().map(|n| n.row).max().unwrap_or(0) + 1;
let available = ui.available_size();
let content_w = LEFT_PAD * 2.0 + n_cols as f32 * COL_GAP + NODE_W;
let content_h = TOP_PAD * 2.0 + n_rows as f32 * ROW_GAP + NODE_H;
let canvas = Vec2::new(available.x.max(400.0), available.y.max(360.0));
let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click_and_drag());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(18, 18, 24));
if resp.dragged() {
view.pan += resp.drag_delta();
}
let hovered = resp.hovered();
if hovered {
ui.input(|i| {
let scroll = i.raw_scroll_delta;
if i.modifiers.command || i.modifiers.ctrl {
let factor = (1.0 + scroll.y * 0.0015).clamp(0.5, 2.0);
view.zoom = (view.zoom * factor).clamp(0.25, 4.0);
} else if scroll != Vec2::ZERO {
view.pan += scroll;
}
});
}
let base = rect.left_top().to_vec2() + Vec2::new(LEFT_PAD, TOP_PAD);
let z = view.zoom;
let offset = base + view.pan + Vec2::new(
((rect.width() - content_w * z).max(0.0)) / 2.0,
((rect.height() - content_h * z).max(0.0)) / 2.0,
);
let node_w = NODE_W * z;
let node_h = NODE_H * z;
let pos_of = |col: usize, row: usize| -> Pos2 {
(offset + Vec2::new(col as f32 * COL_GAP * z, row as f32 * ROW_GAP * z)).to_pos2()
};
let node_pos = |repo: &str| -> Option<Pos2> {
layout.position(repo).map(|(c, r)| pos_of(c, r))
};
let node_rect = |repo: &str| -> Option<Rect> {
node_pos(repo).map(|p| Rect::from_min_size(p, Vec2::new(node_w, node_h)))
};
for e in &layout.edges {
let (Some(fr), Some(tr)) = (node_rect(&e.from), node_rect(&e.to)) else { continue };
let from = fr.center();
let to = tr.center();
let color = match e.class {
EdgeClass::Direct => DIRECT_COLOR,
EdgeClass::Transitive => TRANSITIVE_COLOR,
};
let dashed = e.class == EdgeClass::Transitive;
draw_arrow(&painter, from, to, color, node_w, dashed, z);
let mid = Pos2::new((from.x + to.x) / 2.0, (from.y + to.y) / 2.0 - 8.0 * z);
let label = match e.class {
EdgeClass::Direct => {
let via = e.via.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
if e.via.len() > 2 { format!("{via} +{}", e.via.len() - 2) } else { via }
}
EdgeClass::Transitive => format!("⤳ {} hops", e.hops),
};
if z > 0.45 && !label.is_empty() {
painter.text(
mid,
Align2::CENTER_BOTTOM,
&label,
FontId::monospace(10.0 * z.min(1.4)),
if dashed { Color32::from_rgb(140, 160, 195) } else { Color32::from_rgb(190, 190, 210) },
);
}
}
for node in &layout.nodes {
let Some(nrect) = node_rect(&node.repo) else { continue };
let lane = tl.lanes.iter().find(|l| l.repo == node.repo);
let release = selected_release.or_else(|| tl.release_order.last().copied());
let status = lane
.and_then(|l| release.and_then(|rid| l.nodes.iter().find(|n| n.release_id == rid)))
.map(|n| status_color(&n.gate_status))
.unwrap_or(Color32::from_rgb(80, 80, 100));
let is_sel = selected_repo.as_deref() == Some(node.repo.as_str());
let fill = if is_sel { Color32::from_rgb(28, 46, 72) } else { Color32::from_rgb(30, 30, 40) };
painter.rect_filled(nrect, CornerRadius::same(6), fill);
let (bw, bc) = if is_sel {
(3.0, Color32::from_rgb(120, 210, 255))
} else if node.collapsed {
(2.5, Color32::from_rgb(230, 180, 90))
} else {
(2.0, status)
};
painter.rect_stroke(nrect, CornerRadius::same(6), Stroke::new(bw, bc), egui::StrokeKind::Inside);
if z > 0.5 {
painter.text(
nrect.left_top() + Vec2::new(6.0, 4.0),
Align2::LEFT_TOP,
if node.collapsed { "▸" } else { "▾" },
FontId::monospace(12.0),
Color32::from_rgb(200, 200, 210),
);
}
painter.text(
nrect.center_top() + Vec2::new(0.0, 12.0 * z),
Align2::CENTER_TOP,
&node.repo,
FontId::proportional(15.0 * z.clamp(0.6, 1.4)),
Color32::WHITE,
);
if z > 0.55 {
if let Some(n) = lane.and_then(|l| {
release.and_then(|rid| l.nodes.iter().find(|n| n.release_id == rid))
}) {
painter.text(
nrect.center_top() + Vec2::new(0.0, 32.0 * z),
Align2::CENTER_TOP,
&n.sha[..n.sha.len().min(12)],
FontId::monospace(11.0 * z.clamp(0.6, 1.4)),
Color32::from_rgb(200, 200, 210),
);
}
}
}
if resp.clicked() {
if let Some(pos) = resp.interact_pointer_pos() {
let hit = layout
.nodes
.iter()
.map(|n| n.repo.clone())
.find(|r| node_rect(r).is_some_and(|rc| rc.contains(pos)));
if let Some(repo) = hit {
*selected_repo = Some(repo.clone());
if view.collapsed.contains(&repo) {
view.collapsed.remove(&repo);
} else {
view.collapsed.insert(repo);
}
} else {
*selected_repo = None;
}
}
}
if let (Some(repo), Some(release)) = (
selected_repo.clone(),
selected_release.or_else(|| tl.release_order.last().copied()),
) {
if let Some(snap) = tl.snapshot_for(&release) {
draw_drilldown(&painter, rect, &repo, snap, &layout);
}
}
}
fn legend_swatch(ui: &mut Ui, color: Color32, label: &str) {
let (rect, _) = ui.allocate_exact_size(Vec2::new(22.0, 12.0), Sense::hover());
ui.painter().rect_filled(rect, CornerRadius::same(2), color);
ui.label(label);
}
fn draw_drilldown(
painter: &egui::Painter,
rect: Rect,
repo: &str,
snap: &crate::warehouse::dep_graph::DepGraphSnapshot,
layout: &DepGraphLayout,
) {
let deps: Vec<String> = snap
.edges
.iter()
.filter(|e| e.from == repo)
.map(|e| format!(" → {} [{}]", e.to, e.via.iter().cloned().collect::<Vec<_>>().join(", ")))
.collect();
let users: Vec<String> = snap
.edges
.iter()
.filter(|e| e.to == repo)
.map(|e| format!(" ← {} [{}]", e.from, e.via.iter().cloned().collect::<Vec<_>>().join(", ")))
.collect();
let trans = DepGraphLayout::transitive_closure(repo, &snap.edges);
let rows = 5 + deps.len() + users.len();
let panel = Rect::from_min_size(
rect.left_top() + Vec2::new(8.0, 8.0),
Vec2::new(390.0, rows as f32 * 16.0 + 16.0),
);
painter.rect_filled(panel, CornerRadius::same(6), Color32::from_rgba_unmultiplied(16, 26, 42, 236));
painter.rect_stroke(panel, CornerRadius::same(6), Stroke::new(1.0, Color32::from_rgb(80, 130, 180)), 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;
};
let collapsed = layout.nodes.iter().any(|n| n.repo == repo && n.collapsed);
put(
&format!("◆ {repo} (click node to {})", if collapsed { "expand" } else { "collapse" }),
Color32::from_rgb(120, 210, 255),
);
put(&format!("direct deps ({}):", deps.len()), DIRECT_COLOR);
for d in &deps {
put(d, Color32::from_rgb(205, 215, 235));
}
put(
&format!("transitive closure ({}): {}", trans.len(), trans.iter().cloned().collect::<Vec<_>>().join(", ")),
TRANSITIVE_COLOR,
);
put(&format!("used by ({}):", users.len()), Color32::from_rgb(150, 185, 215));
for u in &users {
put(u, Color32::from_rgb(205, 215, 235));
}
}
fn status_color(status: &str) -> Color32 {
match status {
s if s.starts_with("succeeded_dry_run") => Color32::from_rgb(200, 160, 60),
s if s.starts_with("succeeded") => Color32::from_rgb(80, 180, 120),
"failed_test" => Color32::from_rgb(220, 80, 80),
"failed_bench" => Color32::from_rgb(220, 130, 60),
_ => Color32::from_rgb(140, 140, 160),
}
}
fn draw_arrow(
painter: &egui::Painter,
from: Pos2,
to: Pos2,
color: Color32,
node_w: f32,
dashed: bool,
z: f32,
) {
let dir = (to - from).normalized();
let tip = to - dir * (node_w / 2.0 + 4.0);
let stroke = Stroke::new(2.0 * z.clamp(0.6, 1.5), color);
if dashed {
let total = (tip - from).length();
let step = 10.0 * z;
let mut t = 0.0;
while t < total {
let a = from + dir * t;
let b = from + dir * (t + step * 0.6).min(total);
painter.line_segment([a, b], stroke);
t += step;
}
} else {
painter.line_segment([from, tip], stroke);
}
let perp = Vec2::new(-dir.y, dir.x);
let base = tip - dir * 10.0 * z;
let a = base + perp * 5.0 * z;
let b = base - perp * 5.0 * z;
painter.add(egui::Shape::convex_polygon(vec![tip, a, b], color, Stroke::NONE));
}