vor 0.2.0

Cross-platform performance instrumentation with an in-app egui panel and live system and GPU metrics.
Documentation
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;
/// Smallest window width (fraction of full frame); roughly a 1000×
/// zoom limit. Tight enough to see sub-µs scopes in a 16 ms frame.
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,
) {
    // A range selection turns the chart into a multi-frame timeline
    // spanning the selected frames, sharing the bars' time axis; a
    // single pin shows just that frame.
    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();

    // Absolute-ns span across the chosen frames. puffin scope
    // timestamps are absolute, so blocks from every frame land on one
    // axis without per-frame offsetting.
    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();

    // Frame boundaries, so the multi-frame axis reads as discrete
    // frames lined up in time.
    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);
}

/// puffin frames for an inclusive system-ring index range, oldest
/// first. Indices without a retained puffin frame are skipped (the
/// puffin ring may be shorter than the system ring).
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;
    }
    // Only consume scroll / pinch when the cursor is over the chart;
    // otherwise the parent ScrollArea should still scroll the panel.
    if let Some(pos) = resp.hover_pos() {
        // Steal scroll-wheel + pinch from the parent ScrollArea so they
        // drive the zoom instead of scrolling the panel.
        let (wheel, pinch) = ui.input_mut(|i| {
            let w = i.smooth_scroll_delta.y;
            let p = i.zoom_delta();
            // Zero the delta so the parent ScrollArea sees nothing to
            // scroll. `consume_*` would be ideal but isn't exposed.
            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();
    }
}

/// Map a wheel-delta y to a multiplicative zoom factor. Up = zoom
/// in (>1), down = zoom out (<1). Returns 1.0 when there's no scroll.
fn wheel_zoom_factor(wheel_y: f32) -> f32 {
    if wheel_y.abs() < 1e-3 {
        return 1.0;
    }
    // 1.2× per "click" of wheel, sign by direction. Trackpad scroll
    // emits many small deltas; raising to (wheel/120) gives a smooth
    // response near the wheel-click rate most mice use.
    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,
}

/// Align puffin's ring with the caller's history ring. Both are
/// pushed once per `frame_mark`, so for `pinned = Some(i)`, picking
/// the `i`-th-most-recent puffin frame matches the `i`-th-most-recent
/// history entry.
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()
    }
}