use eframe::egui::{
self, Align, Align2, FontId, Layout, Pos2, Rect, CornerRadius, Sense, Stroke, Vec2,
};
use uuid::Uuid;
use super::facett_theme::{Theme, AMBER};
use super::graph::{draw_dep_graph, DepGraphView};
use super::model::{BenchHistory, Timeline};
pub struct TimeTravelState {
pub idx: usize,
pub playing: bool,
pub speed_secs: f32,
pub last_tick: std::time::Instant,
pub zoom: Option<u8>,
pub graph: bool,
pub dep_view: DepGraphView,
pub dep_selected_repo: Option<String>,
last_traced: Option<(usize, bool)>,
theme: Theme,
}
impl Default for TimeTravelState {
fn default() -> Self {
Self {
idx: 0,
playing: false,
speed_secs: 1.5,
last_tick: std::time::Instant::now(),
zoom: None,
graph: false,
dep_view: DepGraphView::new(),
dep_selected_repo: None,
last_traced: None,
theme: Theme::default(),
}
}
}
impl TimeTravelState {
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
self.dep_view.set_palette(t);
}
pub fn clamp(&mut self, n: usize) {
if n == 0 {
self.idx = 0;
} else if self.idx >= n {
self.idx = n - 1;
}
}
pub fn pinned_release(&self, tl: &Timeline) -> Option<Uuid> {
tl.release_order.get(self.idx).copied()
}
pub fn state_json(&self, tl: &Timeline) -> serde_json::Value {
let release = self.pinned_release(tl);
let graph = if self.graph {
self.dep_view.state_json_for(tl, release)
} else {
serde_json::Value::Null
};
serde_json::json!({
"idx": self.idx,
"release_count": tl.release_order.len(),
"pinned_release": release.map(|r| r.to_string()),
"playing": self.playing,
"view": if self.graph { "graph" } else { "text" },
"graph": graph,
"palette": self.theme.name,
})
}
pub fn trace_if_changed(&mut self, tl: &Timeline) {
let key = (self.idx, self.graph);
if self.last_traced == Some(key) {
return;
}
self.last_traced = Some(key);
let release = self.pinned_release(tl);
super::trace::emit_in(
"timetravel.render",
&serde_json::json!({
"idx": self.idx,
"view": if self.graph { "graph" } else { "text" },
"pinned_release": release.map(|r| r.to_string()),
}),
);
let graph = self.state_json(tl);
super::trace::emit_out("timetravel.render", &graph);
super::trace::emit_end(
"timetravel.render",
&serde_json::json!({
"view": graph["view"],
"node_count": graph["graph"]["node_count"],
"direct_edges": graph["graph"]["direct_edges"],
"transitive_edges": graph["graph"]["transitive_edges"],
}),
);
}
}
pub fn draw_timetravel(
ui: &mut egui::Ui,
tl: &Timeline,
state: &mut TimeTravelState,
) -> Option<Uuid> {
let theme = state.theme;
let n = tl.release_order.len();
if n == 0 {
let max_bench = tl
.bench_history
.values()
.map(|h| h.points.len())
.max()
.unwrap_or(0);
if max_bench == 0 {
ui.label("No releases or bench runs yet.");
return None;
}
if state.idx >= max_bench { state.idx = max_bench - 1; }
if state.playing && state.last_tick.elapsed().as_secs_f32() >= state.speed_secs {
state.idx = (state.idx + 1) % max_bench;
state.last_tick = std::time::Instant::now();
}
if state.playing {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(120));
}
let max_idx = max_bench.saturating_sub(1);
ui.horizontal(|ui| {
if ui.button("⏮").clicked() { state.idx = 0; }
if ui.button("◀").clicked() { state.idx = state.idx.saturating_sub(1); }
if ui.button(if state.playing { "⏸ pause" } else { "▶ play" }).clicked() {
state.playing = !state.playing;
state.last_tick = std::time::Instant::now();
}
if ui.button("▶").clicked() { if state.idx + 1 < max_bench { state.idx += 1; } }
if ui.button("⏭").clicked() { state.idx = max_idx; }
ui.separator();
ui.label(format!("bench {} of {}", state.idx + 1, max_bench));
ui.add(egui::Slider::new(&mut state.idx, 0..=max_idx).text("scrub"));
ui.add(egui::Slider::new(&mut state.speed_secs, 0.25..=5.0)
.text("s/step").logarithmic(true));
});
ui.separator();
ui.colored_label(
theme.info(),
"ℹ no release_lineage in this warehouse — showing bench history only",
);
reel_benches(ui, &theme, tl, state.idx);
return None;
}
state.clamp(n);
if state.playing && state.last_tick.elapsed().as_secs_f32() >= state.speed_secs {
state.idx = (state.idx + 1) % n;
state.last_tick = std::time::Instant::now();
}
if state.playing {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(120));
}
let max_idx = n.saturating_sub(1);
ui.horizontal(|ui| {
if ui.button("⏮").on_hover_text("first").clicked() {
state.idx = 0;
}
if ui.button("◀").on_hover_text("previous").clicked() {
state.idx = state.idx.saturating_sub(1);
}
if ui
.button(if state.playing { "⏸ pause" } else { "▶ play" })
.clicked()
{
state.playing = !state.playing;
state.last_tick = std::time::Instant::now();
}
if ui.button("▶").on_hover_text("next").clicked() {
if state.idx + 1 < n {
state.idx += 1;
}
}
if ui.button("⏭").on_hover_text("latest").clicked() {
state.idx = max_idx;
}
ui.separator();
ui.label(format!("release {} of {}", state.idx + 1, n));
ui.separator();
ui.add(egui::Slider::new(&mut state.idx, 0..=max_idx).text("scrub"));
ui.separator();
ui.add(
egui::Slider::new(&mut state.speed_secs, 0.25..=5.0)
.text("s/step")
.logarithmic(true),
);
});
let rid = tl.release_order[state.idx];
state.trace_if_changed(tl);
ui.separator();
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("⏱ pinned to release {rid}"))
.monospace()
.color(theme.text),
);
ui.separator();
let toggle = if state.graph { "🔗 deps: graph" } else { "📄 deps: text" };
if ui
.selectable_label(state.graph, toggle)
.on_hover_text("toggle the dependencies reel between the text edge-list and the laid-out dependency graph (C4)")
.clicked()
{
state.graph = !state.graph;
super::trace::emit_event(
"timetravel.view",
&serde_json::json!({
"view": if state.graph { "graph" } else { "text" },
"release": rid.to_string(),
}),
);
}
});
ui.add_space(8.0);
let idx = state.idx;
let graph_mode = state.graph;
let dep_view = &mut state.dep_view;
let dep_sel = &mut state.dep_selected_repo;
let mut render = |ui: &mut egui::Ui, k: u8| match k {
0 => reel_versions(ui, &theme, tl, rid),
1 => reel_dependencies(ui, &theme, tl, rid, graph_mode, dep_view, dep_sel),
_ => reel_benches(ui, &theme, tl, idx),
};
if let Some(k) = state.zoom {
if ui
.button(egui::RichText::new("⬅ GO BACK").size(15.0).strong())
.on_hover_text("back to all reels")
.clicked()
{
state.zoom = None;
}
ui.separator();
let full = ui.available_size();
ui.allocate_ui_with_layout(
Vec2::new(full.x, full.y - 8.0),
Layout::top_down(Align::Min),
|ui| render(ui, k),
);
} else {
let avail = ui.available_size();
let col_w = (avail.x - 24.0) / 3.0;
ui.horizontal_top(|ui| {
for (i, (k, title)) in [(0u8, "📦 versions"), (1, "🔗 dependencies"), (2, "📈 benches")]
.iter()
.enumerate()
{
ui.allocate_ui_with_layout(
Vec2::new(col_w, avail.y - 40.0),
Layout::top_down(Align::Min),
|ui| {
if ui
.button(format!("⛶ {title}"))
.on_hover_text("click to zoom this diagram fullscreen")
.clicked()
{
state.zoom = Some(*k);
}
render(ui, *k);
},
);
if i < 2 {
ui.separator();
}
}
});
}
Some(rid)
}
fn reel_versions(ui: &mut egui::Ui, theme: &Theme, tl: &Timeline, rid: Uuid) {
ui.heading("📦 Component versions");
ui.separator();
egui::ScrollArea::vertical()
.id_salt("reel-versions")
.show(ui, |ui| {
for lane in &tl.lanes {
let Some(node) = lane.nodes.iter().find(|n| n.release_id == rid) else {
ui.colored_label(
theme.text_dim,
format!("{}: (not part of this release)", lane.repo),
);
continue;
};
ui.group(|ui| {
ui.horizontal(|ui| {
ui.strong(&lane.repo);
ui.label(
egui::RichText::new(format!(
"@{}",
&node.sha[..node.sha.len().min(10)]
))
.monospace()
.color(theme.accent),
);
if node.dirty {
ui.colored_label(AMBER, "✱ dirty");
}
});
ui.label(format!("branch: {} • {}", node.branch, node.gate_status));
if node.published_versions.is_empty() {
ui.colored_label(theme.text_dim, "(no published crates)");
} else {
ui.label("published:");
for (c, v) in &node.published_versions {
ui.monospace(format!(" {c} @ {v}"));
}
}
});
ui.add_space(4.0);
}
});
}
fn reel_dependencies(
ui: &mut egui::Ui,
theme: &Theme,
tl: &Timeline,
rid: Uuid,
graph: bool,
dep_view: &mut DepGraphView,
dep_selected_repo: &mut Option<String>,
) {
ui.heading("🔗 Dependencies");
ui.separator();
if graph {
draw_dep_graph(ui, tl, Some(rid), dep_selected_repo, dep_view);
return;
}
let Some(snap) = tl.snapshot_for(&rid) else {
ui.colored_label(theme.text_dim, "(no snapshot for this release)");
return;
};
ui.label(format!("snapshot: {}", snap.snapshot_id));
ui.label(format!("{} cross-repo edge(s)", snap.edges.len()));
ui.separator();
egui::ScrollArea::vertical()
.id_salt("reel-deps")
.show(ui, |ui| {
if snap.edges.is_empty() {
ui.colored_label(theme.text_dim, "(no edges)");
}
for edge in &snap.edges {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.strong(&edge.from);
ui.label("→");
ui.strong(&edge.to);
});
let names = edge
.via
.iter()
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(", ");
ui.monospace(format!("via: {names}"));
});
ui.add_space(2.0);
}
});
}
fn reel_benches(ui: &mut egui::Ui, theme: &Theme, tl: &Timeline, release_idx: usize) {
ui.heading("⚡ Benchmarks");
ui.separator();
egui::ScrollArea::vertical()
.id_salt("reel-benches")
.show(ui, |ui| {
for lane in &tl.lanes {
let Some(hist) = tl.bench_history.get(&lane.repo) else {
continue;
};
ui.group(|ui| {
ui.horizontal(|ui| {
ui.strong(&lane.repo);
ui.label(format!("({} runs)", hist.points.len()));
});
draw_sparkline(ui, theme, hist, release_idx);
});
ui.add_space(4.0);
}
});
}
fn draw_sparkline(ui: &mut egui::Ui, theme: &Theme, hist: &BenchHistory, release_idx: usize) {
let n = hist.points.len();
if n == 0 {
ui.colored_label(theme.text_dim, "(no runs)");
return;
}
let (mn, mx) = hist.min_max(None).unwrap_or((0.0, 1.0));
let span = (mx - mn).max(1e-9);
let (rect, _resp) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 70.0), Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::same(3), theme.bg);
let pad = 6.0;
let inner = rect.shrink(pad);
let xs = if n == 1 {
vec![inner.center().x]
} else {
(0..n)
.map(|i| inner.left() + inner.width() * (i as f32) / ((n - 1) as f32))
.collect()
};
let mut prev: Option<Pos2> = None;
for (i, p) in hist.points.iter().enumerate() {
let t = ((p.primary_metric_value - mn) / span) as f32;
let y = inner.bottom() - t * inner.height();
let pt = Pos2::new(xs[i], y);
if let Some(pp) = prev {
painter.line_segment(
[pp, pt],
Stroke::new(1.6, theme.point),
);
}
painter.circle_filled(pt, 2.5, theme.point);
prev = Some(pt);
}
let cursor_i = if n == 1 || release_idx == 0 {
0
} else {
((release_idx as f32) * ((n - 1) as f32)
/ ((release_idx.max(n - 1)) as f32))
.round() as usize
}
.min(n - 1);
let cx = xs[cursor_i];
painter.line_segment(
[Pos2::new(cx, inner.top()), Pos2::new(cx, inner.bottom())],
Stroke::new(1.5, AMBER),
);
let pt = &hist.points[cursor_i];
painter.text(
Pos2::new(inner.left() + 2.0, inner.top() - 2.0),
Align2::LEFT_BOTTOM,
format!(
"{} = {:.2} ({}/{}/{})",
pt.primary_metric_name,
pt.primary_metric_value,
pt.version,
pt.machine,
pt.timestamp.format("%Y-%m-%d")
),
FontId::proportional(11.0),
theme.text,
);
let _ = Rect::NOTHING; }