use std::collections::HashMap;
use std::sync::Arc;
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, Vec2, pos2};
use puffin::{FrameData, FrameView, Reader, ScopeDetails, ScopeId, Stream};
use crate::viz::color::hash_color;
use crate::viz::state::FlameView;
const ROW_H: f32 = 22.0;
const MIN_VIEW_WIDTH: f32 = 1e-3;
pub(super) fn flame_chart<R>(
ui: &mut egui::Ui,
view: &FrameView,
history_len: usize,
pinned: Option<usize>,
selection: Option<(usize, usize)>,
flame_view: &mut FlameView,
) {
let multi = matches!(selection, Some((lo, hi)) if hi > lo);
ui.horizontal(|ui| {
let header = if multi {
"Flame · selected range · scroll = zoom · drag = pan"
} else {
"Flame · pinned / latest frame · scroll = zoom · drag = pan"
};
ui.label(egui::RichText::new(header).strong());
if flame_view.width() < 1.0 - 1e-6 && ui.small_button("⤺ reset zoom").clicked() {
*flame_view = FlameView::FULL;
}
});
let recent: Vec<Arc<FrameData>> = view.recent_frames().cloned().collect();
let frames = match selection {
Some((lo, hi)) if hi > lo => frames_for_range(&recent, history_len, lo, hi),
_ => pick_frame::<R>(&recent, history_len, pinned)
.into_iter()
.collect(),
};
let (Some(first), Some(last)) = (frames.first(), frames.last()) else {
ui.label("(no scopes captured yet)");
return;
};
let names: HashMap<ScopeId, Arc<ScopeDetails>> = view
.scope_collection()
.scopes_by_id()
.iter()
.map(|(k, v)| (*k, v.clone()))
.collect();
let span_start = first.range_ns().0;
let span_dur = (last.range_ns().1 - span_start).max(1) as f32;
let mut blocks: Vec<Block> = Vec::new();
for frame in &frames {
let unpacked = frame.unpacked().unwrap();
for stream_info in unpacked.thread_streams.values() {
collect(
Reader::from_start(&stream_info.stream),
&stream_info.stream,
&names,
0,
&mut blocks,
);
}
}
if blocks.is_empty() {
ui.label("(no scopes — add #[vor::profile] to a hot fn)");
return;
}
let max_depth = blocks.iter().map(|b| b.depth).max().unwrap_or(0);
let height = ROW_H * (max_depth as f32 + 1.0);
let (rect, resp) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), height),
Sense::click_and_drag(),
);
handle_zoom_pan(ui, &resp, rect, flame_view);
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 2.0, Color32::from_gray(20));
let win_w = flame_view.width().max(MIN_VIEW_WIDTH);
let frac_to_x = |frac: f32| rect.left() + ((frac - flame_view.start) / win_w) * rect.width();
if frames.len() > 1 {
for frame in &frames {
let frac = (frame.range_ns().0 - span_start) as f32 / span_dur;
if frac <= flame_view.start || frac >= flame_view.end {
continue;
}
painter.vline(
frac_to_x(frac),
rect.y_range(),
Stroke::new(1.0, Color32::from_gray(38)),
);
}
}
for Block {
depth,
name,
start_ns,
dur_ns,
} in &blocks
{
let frac_start = (*start_ns - span_start) as f32 / span_dur;
let frac_end = frac_start + *dur_ns as f32 / span_dur;
if frac_end <= flame_view.start || frac_start >= flame_view.end {
continue;
}
let x0 = frac_to_x(frac_start).max(rect.left());
let x1 = frac_to_x(frac_end).min(rect.right());
let y = rect.top() + *depth as f32 * ROW_H;
let block = Rect::from_min_max(pos2(x0, y + 1.0), pos2(x1, y + ROW_H - 1.0));
painter.rect_filled(block, 2.0, hash_color(name));
if block.width() >= 28.0 {
painter.text(
block.left_top() + Vec2::new(4.0, ROW_H * 0.5 - 7.0),
Align2::LEFT_TOP,
truncate(name, ((block.width() - 6.0) / 6.5).max(0.0) as usize),
FontId::monospace(11.0),
Color32::from_gray(20),
);
}
}
draw_zoom_axis(&painter, rect, span_dur, *flame_view);
}
fn frames_for_range(
frames: &[Arc<FrameData>],
history_len: usize,
lo: usize,
hi: usize,
) -> Vec<Arc<FrameData>> {
(lo..=hi)
.filter_map(|idx| {
let from_end = history_len.saturating_sub(idx + 1);
let puffin_idx = frames.len().checked_sub(from_end + 1)?;
frames.get(puffin_idx).cloned()
})
.collect()
}
fn handle_zoom_pan(ui: &egui::Ui, resp: &egui::Response, rect: Rect, flame_view: &mut FlameView) {
if resp.double_clicked() {
*flame_view = FlameView::FULL;
return;
}
if let Some(pos) = resp.hover_pos() {
let (wheel, pinch) = ui.input_mut(|i| {
let w = i.smooth_scroll_delta.y;
let p = i.zoom_delta();
i.smooth_scroll_delta = egui::Vec2::ZERO;
(w, p)
});
let zoom = wheel_zoom_factor(wheel) * pinch;
if (zoom - 1.0).abs() > 1e-6 {
let fx = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
apply_zoom(flame_view, fx, zoom);
}
}
if resp.dragged() {
let pan = -(resp.drag_delta().x / rect.width()) * flame_view.width();
let new_start = (flame_view.start + pan).clamp(0.0, 1.0 - flame_view.width());
flame_view.start = new_start;
flame_view.end = new_start + flame_view.width();
}
}
fn wheel_zoom_factor(wheel_y: f32) -> f32 {
if wheel_y.abs() < 1e-3 {
return 1.0;
}
1.2_f32.powf(wheel_y / 120.0)
}
fn apply_zoom(flame_view: &mut FlameView, fx: f32, zoom: f32) {
let anchor = flame_view.start + fx * flame_view.width();
let new_w = (flame_view.width() / zoom).clamp(MIN_VIEW_WIDTH, 1.0);
let mut new_start = anchor - fx * new_w;
let mut new_end = new_start + new_w;
if new_start < 0.0 {
new_end -= new_start;
new_start = 0.0;
}
if new_end > 1.0 {
new_start -= new_end - 1.0;
new_end = 1.0;
}
flame_view.start = new_start.max(0.0);
flame_view.end = new_end.min(1.0);
}
fn draw_zoom_axis(painter: &egui::Painter, rect: Rect, frame_dur_ns: f32, view: FlameView) {
if view.width() >= 1.0 - 1e-6 {
return;
}
let win_ms = view.width() as f64 * frame_dur_ns as f64 / 1e6;
let start_ms = view.start as f64 * frame_dur_ns as f64 / 1e6;
painter.text(
pos2(rect.left() + 4.0, rect.top() + 2.0),
Align2::LEFT_TOP,
format!(
"[{start_ms:.3}–{:.3} ms · width {win_ms:.3} ms]",
start_ms + win_ms
),
FontId::monospace(10.0),
Color32::from_gray(180),
);
}
struct Block {
depth: u32,
name: String,
start_ns: i64,
dur_ns: i64,
}
fn pick_frame<R>(
frames: &[Arc<FrameData>],
history_len: usize,
pinned: Option<usize>,
) -> Option<Arc<FrameData>> {
let _ = std::marker::PhantomData::<R>;
match pinned {
Some(idx) => {
let from_end = history_len.saturating_sub(idx + 1);
let puffin_idx = frames.len().checked_sub(from_end + 1)?;
frames.get(puffin_idx).cloned()
}
None => frames.last().cloned(),
}
}
fn collect(
reader: Reader<'_>,
stream: &Stream,
names: &HashMap<ScopeId, Arc<ScopeDetails>>,
depth: u32,
out: &mut Vec<Block>,
) {
for scope in reader.flatten() {
let name = match names.get(&scope.id) {
Some(d) => d.name().to_string(),
None => format!("scope#{}", scope.id.0.get()),
};
out.push(Block {
depth,
name,
start_ns: scope.record.start_ns,
dur_ns: scope.record.duration_ns,
});
if scope.child_begin_position < scope.child_end_position {
let child = Reader::with_offset(stream, scope.child_begin_position).unwrap();
collect(child, stream, names, depth + 1, out);
}
}
}
fn truncate(s: &str, max_chars: usize) -> String {
if max_chars == 0 {
String::new()
} else if s.len() <= max_chars {
s.to_string()
} else {
s.chars().take(max_chars).collect()
}
}