vor 0.2.0

Cross-platform performance instrumentation with an in-app egui panel and live system and GPU metrics.
Documentation
use std::collections::VecDeque;

use egui::{Color32, Pos2, Sense, Shape, Stroke, Vec2, pos2};

use crate::viz::color::hash_color;
use crate::viz::layout::PlotLayout;

/// Descriptor for one line plot.
///
/// `R` is the caller's per-frame record type. `get(&R)` extracts the
/// scalar to plot. `integer` switches the readout format between
/// `{:.2}` and `{}`. `description`, if set via [`describe`](Self::describe),
/// shows as a hover tooltip on the row.
pub struct Metric<R> {
    pub name: &'static str,
    pub unit: &'static str,
    pub get: fn(&R) -> f64,
    pub integer: bool,
    pub description: &'static str,
}

// Manual Copy/Clone — the derived ones would imply `R: Copy + Clone`
// even though `R` is only used in a function pointer.
impl<R> Copy for Metric<R> {}
impl<R> Clone for Metric<R> {
    fn clone(&self) -> Self {
        *self
    }
}

impl<R> Metric<R> {
    pub const fn new(name: &'static str, get: fn(&R) -> f64, unit: &'static str) -> Self {
        Self {
            name,
            unit,
            get,
            integer: false,
            description: "",
        }
    }

    pub const fn as_integer(mut self) -> Self {
        self.integer = true;
        self
    }

    /// Attach a one-line explanation shown when the row is hovered.
    pub const fn describe(mut self, description: &'static str) -> Self {
        self.description = description;
        self
    }
}

pub(super) const ROW_H: f32 = 32.0;
pub(super) const LABEL_COL_W: f32 = 96.0;
pub(super) const STAT_COL_W: f32 = 96.0;

/// Per-render context shared by every row in one [`show`] pass.
///
/// [`show`]: super::show::show
#[derive(Clone, Copy)]
pub(super) struct RowView {
    pub pinned: Option<usize>,
    pub selection: Option<(usize, usize)>,
    pub plot_w: f32,
    pub history_capacity: usize,
}

/// Name + unit + formatting for one plotted row. Borrowed, so the
/// replay viewer can pass runtime strings a [`Metric`]'s `'static`
/// fields can't carry.
pub(super) struct RowSpec<'a> {
    pub name: &'a str,
    pub unit: &'a str,
    pub integer: bool,
    pub description: &'a str,
}

pub(super) fn metric_row<R>(
    ui: &mut egui::Ui,
    m: &Metric<R>,
    history: &VecDeque<R>,
    view: RowView,
    contribution: Option<f64>,
) {
    let samples: Vec<f64> = history.iter().map(m.get).collect();
    let spec = RowSpec {
        name: m.name,
        unit: m.unit,
        integer: m.integer,
        description: m.description,
    };
    plot_series(ui, &spec, &samples, view, contribution);
}

/// Draw one labeled line-plot row: name (with optional hover
/// description), the plot with cursor, and the trailing stat. Shared by
/// the live panel ([`metric_row`]) and the replay viewer.
pub(super) fn plot_series(
    ui: &mut egui::Ui,
    spec: &RowSpec,
    samples: &[f64],
    view: RowView,
    contribution: Option<f64>,
) {
    let RowSpec {
        name,
        unit,
        integer,
        description,
    } = *spec;
    let RowView {
        pinned,
        selection,
        plot_w,
        history_capacity,
    } = view;
    ui.horizontal(|ui| {
        let label = ui.add_sized(
            Vec2::new(LABEL_COL_W, ROW_H),
            egui::Label::new(name).sense(Sense::hover()),
        );
        if !description.is_empty() {
            label.on_hover_text(description);
        }
        let (rect, _) = ui.allocate_exact_size(Vec2::new(plot_w, ROW_H), Sense::hover());
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, 1.0, Color32::from_gray(22));

        if samples.is_empty() {
            ui.add_sized(
                Vec2::new(STAT_COL_W, ROW_H),
                egui::Label::new(format!("{unit}")),
            );
            return;
        }

        let layout = PlotLayout::new(rect, samples.len(), selection, history_capacity);
        draw_line_plot(&painter, rect, samples, &layout, hash_color(name));
        draw_cursor(&painter, rect, &layout, pinned);

        let mut label = stat_label(unit, integer, samples, pinned, selection);
        if let Some(c) = contribution {
            label.push_str(&format!("  (+{c:.2})"));
        }
        ui.add_sized(Vec2::new(STAT_COL_W, ROW_H), egui::Label::new(label));
    });
}

fn draw_line_plot(
    painter: &egui::Painter,
    rect: egui::Rect,
    samples: &[f64],
    layout: &PlotLayout,
    color: Color32,
) {
    let max = samples[layout.range.clone()]
        .iter()
        .copied()
        .fold(0.0_f64, f64::max)
        .max(1e-9) as f32;
    let usable_h = rect.height() - 4.0;
    let pts: Vec<Pos2> = layout
        .range
        .clone()
        .map(|i| {
            let x = layout.base_x
                + (i - layout.range.start) as f32 * layout.slot_w
                + layout.slot_w * 0.5;
            let y = rect.bottom() - 2.0 - (samples[i] as f32 / max) * usable_h;
            pos2(x, y)
        })
        .collect();
    painter.add(Shape::line(pts, Stroke::new(1.4, color)));
}

fn draw_cursor(
    painter: &egui::Painter,
    rect: egui::Rect,
    layout: &PlotLayout,
    pinned: Option<usize>,
) {
    let Some(idx) = pinned else {
        return;
    };
    let Some(x) = layout.cursor_x(idx) else {
        return;
    };
    painter.line_segment(
        [pos2(x, rect.top()), pos2(x, rect.bottom())],
        Stroke::new(1.0, Color32::WHITE),
    );
}

fn stat_label(
    unit: &str,
    integer: bool,
    samples: &[f64],
    pinned: Option<usize>,
    selection: Option<(usize, usize)>,
) -> String {
    // Selection wins over pin: show the range's mean + max so the
    // user gets a summary statistic while the cursor pinpoints the
    // slowest frame for the flame chart.
    if let Some((lo, hi)) = selection {
        let hi = hi.min(samples.len().saturating_sub(1));
        if lo > hi {
            return format!("{unit}");
        }
        let n = (hi - lo + 1).max(1);
        let mean: f64 = samples[lo..=hi].iter().sum::<f64>() / n as f64;
        let peak: f64 = samples[lo..=hi].iter().copied().fold(f64::MIN, f64::max);
        return if integer {
            format!("μ {}  · max {}  {unit}", mean as u64, peak as u64)
        } else {
            format!("μ {mean:.2}  · max {peak:.2}  {unit}")
        };
    }
    let v = match pinned.and_then(|i| samples.get(i)) {
        Some(v) => *v,
        None => *samples.last().unwrap(),
    };
    if integer {
        format!("{}  {unit}", v as u64)
    } else {
        format!("{v:.2}  {unit}")
    }
}