Skip to main content

agg_gui/widgets/
performance.rs

1//! `PerformanceView` — Mean CPU usage label + frame-time sparkline.
2//!
3//! Apps wire a [`SharedFrameHistory`] from their main loop into the
4//! widget's constructor, then push each completed frame's wall time via
5//! [`FrameHistory::push`].  The widget renders the rolling mean as
6//! "Mean CPU usage: X.XX ms / frame" plus a sparkline graph below — same
7//! presentation used in the agg-gui demo's Backend panel and the egui
8//! reference's `frame_history` widget.
9//!
10//! This is intentionally a minimal, self-contained widget (one Label
11//! child for glyph caching, one direct paint pass for the sparkline) so
12//! it drops into any container — a side panel, a popup window, a
13//! collapsing header — without extra plumbing.
14
15use std::cell::{Cell, RefCell};
16use std::rc::Rc;
17use std::sync::Arc;
18
19use crate::color::Color;
20use crate::draw_ctx::DrawCtx;
21use crate::event::{Event, EventResult};
22use crate::geometry::{Rect, Size};
23use crate::text::Font;
24use crate::widget::Widget;
25use crate::widgets::Label;
26
27mod run_mode;
28pub use run_mode::{shared_run_mode, RunMode, RunModeDesc, RunModeRow};
29
30// ── Frame history (rolling sample buffer) ─────────────────────────────────────
31
32/// Rolling buffer of recent frame times in milliseconds.  Apps push from
33/// the main loop; widgets read for display.  Sized for ~1 second at
34/// 60 fps (matches the egui reference and the prior `demo_ui` copy).
35pub struct FrameHistory {
36    times: Vec<f32>,
37    head: usize,
38    len: usize,
39    /// Monotonic change counter bumped by every [`push`].  Widgets use
40    /// this to know when the data changed since their last paint and
41    /// can request exactly one redraw instead of polling forever.
42    revision: u64,
43}
44
45impl FrameHistory {
46    /// Number of samples retained.  Tuned for a 1-second window at the
47    /// 60 fps target — short enough to surface a transient hitch on the
48    /// graph, long enough that a single slow frame doesn't dominate the
49    /// "Mean CPU usage" readout.
50    pub const CAP: usize = 60;
51
52    pub fn new() -> Self {
53        Self {
54            times: vec![0.0; Self::CAP],
55            head: 0,
56            len: 0,
57            revision: 0,
58        }
59    }
60
61    /// Append `frame_ms`, dropping the oldest sample once the buffer is full.
62    pub fn push(&mut self, frame_ms: f32) {
63        self.times[self.head] = frame_ms;
64        self.head = (self.head + 1) % Self::CAP;
65        if self.len < Self::CAP {
66            self.len += 1;
67        }
68        self.revision = self.revision.wrapping_add(1);
69    }
70
71    /// Incremented every time a frame sample is appended.
72    pub fn revision(&self) -> u64 {
73        self.revision
74    }
75
76    /// Average of all retained samples (0.0 when empty).
77    pub fn mean_ms(&self) -> f32 {
78        if self.len == 0 {
79            return 0.0;
80        }
81        self.times[..self.len].iter().sum::<f32>() / self.len as f32
82    }
83
84    /// Convenience: 1000 / mean_ms (or 0.0 for an empty / zero buffer).
85    pub fn fps(&self) -> f32 {
86        let m = self.mean_ms();
87        if m < 0.001 {
88            0.0
89        } else {
90            1000.0 / m
91        }
92    }
93
94    /// Number of valid samples currently held.
95    pub fn len(&self) -> usize {
96        self.len
97    }
98
99    pub fn is_empty(&self) -> bool {
100        self.len == 0
101    }
102
103    /// Iterate samples from oldest to newest (sparkline-friendly order).
104    pub fn samples(&self) -> impl Iterator<Item = f32> + '_ {
105        let cap = Self::CAP;
106        (0..self.len).map(move |i| {
107            let idx = (self.head + cap - self.len + i) % cap;
108            self.times[idx]
109        })
110    }
111}
112
113impl Default for FrameHistory {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119/// Shared handle to a [`FrameHistory`] — passed to the widget at
120/// construction and to the platform shell so it can push samples.
121pub type SharedFrameHistory = Rc<RefCell<FrameHistory>>;
122
123/// Convenience: heap-allocate a fresh shared history.  Equivalent to
124/// `Rc::new(RefCell::new(FrameHistory::new()))`.
125pub fn shared_frame_history() -> SharedFrameHistory {
126    Rc::new(RefCell::new(FrameHistory::new()))
127}
128
129// ── PerformanceView widget ────────────────────────────────────────────────────
130
131/// "Mean CPU usage" label stacked above a frame-time sparkline.
132///
133/// Composition (Y-up: top of widget = high local Y):
134///
135/// ```text
136/// ┌────────────────────────────────────────┐ ← top
137/// │ Mean CPU usage: 4.12 ms / frame        │   label_height
138/// ├────────────────────────────────────────┤
139/// │                                        │
140/// │  (sparkline of the last N frame times) │   sparkline_height
141/// │                                        │
142/// └────────────────────────────────────────┘ ← bottom (y = 0)
143/// ```
144///
145/// The horizontal orange line on the sparkline marks the 16.7 ms / 60 fps
146/// reference budget — same convention as the egui reference panel.
147pub struct PerformanceView {
148    bounds: Rect,
149    /// Children stored so the framework's tree walk recurses into them
150    /// (glyph caches, hover, hit-test).  Indices:
151    ///   `mean_idx`            — "Mean CPU usage" label (always present)
152    ///   `selector_idx..`      — optional "Mode" label + RunModeRow +
153    ///                           RunModeDesc (only when a run-mode cell
154    ///                           is wired via `with_run_mode_selector`)
155    children: Vec<Box<dyn Widget>>,
156    history: SharedFrameHistory,
157    sparkline_height: f64,
158    label_height: f64,
159    padding: f64,
160    show_background: bool,
161    live_redraw: bool,
162    redraw_on_history_change: bool,
163    last_painted_revision: Cell<u64>,
164    font: Arc<Font>,
165    /// Layout offsets for the optional selector section.  Populated by
166    /// [`Self::with_run_mode_selector`]; zero when no selector is shown.
167    selector: Option<SelectorLayout>,
168    run_mode: Option<Rc<Cell<RunMode>>>,
169}
170
171/// Layout constants for the optional Reactive/Continuous selector group.
172/// Indices point into `PerformanceView::children`.
173struct SelectorLayout {
174    mode_label_idx: usize,
175    mode_row_idx: usize,
176    desc_idx: usize,
177    mode_label_height: f64,
178    row_height: f64,
179    desc_height: f64,
180    inner_gap: f64,
181    /// Separator stroke between selector group and CPU readout.  Drawn
182    /// directly in `paint()` rather than as a child widget — a plain
183    /// 1-px line doesn't need glyph caching or hit-testing.
184    separator_pad: f64,
185}
186
187impl PerformanceView {
188    /// Build a new view bound to `history`.  `font` is used for the
189    /// "Mean CPU usage" label and (if enabled) the run-mode selector
190    /// labels.
191    pub fn new(font: Arc<Font>, history: SharedFrameHistory) -> Self {
192        let mut label =
193            Label::new("Mean CPU usage: 0.00 ms / frame", Arc::clone(&font)).with_font_size(11.0);
194        // Live counter — value changes every frame, so caching the
195        // glyph bitmap to a backbuffer would invalidate every frame
196        // anyway.  Direct rasterisation is cheaper here.
197        label.buffered = false;
198        Self {
199            bounds: Rect::default(),
200            children: vec![Box::new(label)],
201            history,
202            sparkline_height: 56.0,
203            label_height: 18.0,
204            padding: 12.0,
205            show_background: false,
206            live_redraw: false,
207            redraw_on_history_change: false,
208            last_painted_revision: Cell::new(0),
209            font,
210            selector: None,
211            run_mode: None,
212        }
213    }
214
215    /// Mount a Reactive / Continuous selector at the top of the widget.
216    /// The two buttons read and write through `run_mode`, and a dynamic
217    /// description label below them mirrors the current mode (and shows
218    /// FPS in Continuous mode).  The host's main loop is expected to
219    /// read the same cell to decide whether to pump frames.
220    pub fn with_run_mode_selector(mut self, run_mode: Rc<Cell<RunMode>>) -> Self {
221        // Reuse the existing "Mean CPU usage" Label as child[0]; append
222        // the selector widgets so the visible order (top-down in Y-up)
223        // is: Mode label, button row, description, [separator], mean
224        // label, sparkline.
225        let mode_label = Label::new("Mode", Arc::clone(&self.font)).with_font_size(11.0);
226        let mode_row = RunModeRow::new(Arc::clone(&self.font), Rc::clone(&run_mode));
227        let desc = RunModeDesc::new(
228            Arc::clone(&self.font),
229            Rc::clone(&run_mode),
230            Rc::clone(&self.history),
231        );
232
233        let mean_idx = 0;
234        let _ = mean_idx; // reserved for clarity
235        let mode_label_idx = self.children.len();
236        self.children.push(Box::new(mode_label));
237        let mode_row_idx = self.children.len();
238        self.children.push(Box::new(mode_row));
239        let desc_idx = self.children.len();
240        self.children.push(Box::new(desc));
241
242        self.selector = Some(SelectorLayout {
243            mode_label_idx,
244            mode_row_idx,
245            desc_idx,
246            mode_label_height: 16.0,
247            row_height: RunModeRow::ROW_HEIGHT,
248            desc_height: 18.0,
249            inner_gap: 4.0,
250            separator_pad: 6.0,
251        });
252        self.run_mode = Some(run_mode);
253        self
254    }
255
256    /// Read the live run-mode cell, if a selector is wired.
257    pub fn run_mode(&self) -> Option<Rc<Cell<RunMode>>> {
258        self.run_mode.clone()
259    }
260
261    pub fn with_sparkline_height(mut self, h: f64) -> Self {
262        self.sparkline_height = h.max(8.0);
263        self
264    }
265
266    pub fn with_padding(mut self, p: f64) -> Self {
267        self.padding = p.max(0.0);
268        self
269    }
270
271    /// Paint a panel-fill background behind the widget.  Off by default
272    /// (lets the host pick — the demo's Backend panel already paints
273    /// its own background; a popup window paints its own panel fill).
274    pub fn with_background(mut self, on: bool) -> Self {
275        self.show_background = on;
276        self
277    }
278
279    /// When `true`, claim a redraw every frame so the rolling mean +
280    /// sparkline always show live values.  Off by default — the demo's
281    /// Backend panel relies on continuous-mode repaints (or unrelated
282    /// dirty events) to refresh the readout, and a default-on flag
283    /// would prevent the host from going idle in reactive mode.
284    /// Opt in for popup-window hosts that exist specifically to show
285    /// live performance numbers (Solitaire's Debug → Performance
286    /// Window).
287    pub fn with_live_redraw(mut self, on: bool) -> Self {
288        self.live_redraw = on;
289        self
290    }
291
292    /// When `true`, the view invalidates itself once for each new
293    /// [`FrameHistory`] revision it has not painted yet.
294    ///
295    /// Off by default because the agg-gui demo's Backend panel pushes a
296    /// frame-history sample after each paint; enabling this there would
297    /// make Reactive mode behave like Continuous mode.  Opt in for a
298    /// dedicated popup / overlay whose whole job is to keep the graph
299    /// visually caught up with samples generated by unrelated UI draws.
300    pub fn with_history_redraw(mut self, on: bool) -> Self {
301        self.redraw_on_history_change = on;
302        self
303    }
304
305    fn total_height(&self) -> f64 {
306        let base = self.label_height + self.sparkline_height + self.padding * 3.0;
307        match &self.selector {
308            Some(s) => {
309                base + s.mode_label_height
310                    + s.row_height
311                    + s.desc_height
312                    + s.inner_gap * 3.0
313                    + s.separator_pad * 2.0
314            }
315            None => base,
316        }
317    }
318}
319
320impl Widget for PerformanceView {
321    fn type_name(&self) -> &'static str {
322        "PerformanceView"
323    }
324    fn bounds(&self) -> Rect {
325        self.bounds
326    }
327    fn set_bounds(&mut self, bounds: Rect) {
328        self.bounds = bounds;
329    }
330    fn children(&self) -> &[Box<dyn Widget>] {
331        &self.children
332    }
333    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
334        &mut self.children
335    }
336
337    fn layout(&mut self, available: Size) -> Size {
338        let w = available.width.max(1.0);
339        let h = self.total_height();
340        self.bounds = Rect::new(0.0, 0.0, w, h);
341        let inner_w = (w - self.padding * 2.0).max(1.0);
342
343        // Selector group sits at the top in Y-up (high local Y), in
344        // visual order: "Mode" label, RunModeRow, RunModeDesc.  When the
345        // selector is absent we fall straight through to the mean-label
346        // placement and the geometry matches the pre-selector layout
347        // exactly.
348        let mut cursor_top = h - self.padding;
349        if let Some(s) = &self.selector {
350            // "Mode" label.
351            let row_top = cursor_top;
352            let row_bottom = row_top - s.mode_label_height;
353            let label_size =
354                self.children[s.mode_label_idx].layout(Size::new(inner_w, s.mode_label_height));
355            let label_y = row_bottom + (s.mode_label_height - label_size.height) * 0.5;
356            self.children[s.mode_label_idx].set_bounds(Rect::new(
357                self.padding,
358                label_y,
359                label_size.width,
360                label_size.height,
361            ));
362            cursor_top = row_bottom - s.inner_gap;
363
364            // RunModeRow — full-width segmented control.
365            let row_bottom = cursor_top - s.row_height;
366            self.children[s.mode_row_idx].layout(Size::new(inner_w, s.row_height));
367            self.children[s.mode_row_idx].set_bounds(Rect::new(
368                self.padding,
369                row_bottom,
370                inner_w,
371                s.row_height,
372            ));
373            cursor_top = row_bottom - s.inner_gap;
374
375            // Description.  Let the desc widget self-size for wrap.
376            let desc_size = self.children[s.desc_idx].layout(Size::new(inner_w, s.desc_height));
377            let desc_h = desc_size.height.max(s.desc_height);
378            let desc_bottom = cursor_top - desc_h;
379            self.children[s.desc_idx].set_bounds(Rect::new(
380                self.padding,
381                desc_bottom,
382                inner_w,
383                desc_h,
384            ));
385            cursor_top = desc_bottom - s.separator_pad * 2.0 - s.inner_gap;
386        }
387
388        // Mean-CPU label sits below the optional selector group, above
389        // the sparkline.  Without a selector this lands at exactly the
390        // original position (top of widget, padded).
391        let mean_row_top = cursor_top;
392        let mean_row_bottom = mean_row_top - self.label_height;
393        let mean_size = self.children[0].layout(Size::new(inner_w, self.label_height));
394        let mean_y = mean_row_bottom + (self.label_height - mean_size.height) * 0.5;
395        self.children[0].set_bounds(Rect::new(
396            self.padding,
397            mean_y,
398            mean_size.width,
399            mean_size.height,
400        ));
401
402        Size::new(w, h)
403    }
404
405    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
406        let v = ctx.visuals();
407        let w = self.bounds.width;
408        let h = self.bounds.height;
409
410        // Optional panel-fill background (off by default; hosts that
411        // already draw a panel under us — Backend sidebar, Window — set
412        // this to false).
413        if self.show_background {
414            ctx.set_fill_color(v.panel_fill);
415            ctx.begin_path();
416            ctx.rect(0.0, 0.0, w, h);
417            ctx.fill();
418        }
419
420        // Refresh the label text via the trait setters so the framework
421        // tree walk paints the (potentially-new) glyph string.
422        let (mean, revision) = {
423            let hist = self.history.borrow();
424            (hist.mean_ms(), hist.revision())
425        };
426        let text = format!("Mean CPU usage: {mean:.2} ms / frame");
427        self.children[0].set_label_text(&text);
428        self.children[0].set_label_color(v.text_dim);
429
430        // Faint horizontal separator between the selector group and the
431        // mean-CPU readout, mirroring the divider in the backend panel.
432        if let Some(s) = &self.selector {
433            let desc_bottom = self.children[s.desc_idx].bounds().y;
434            let sep_y = desc_bottom - s.separator_pad;
435            if sep_y > self.padding {
436                ctx.set_stroke_color(v.separator);
437                ctx.set_line_width(1.0);
438                ctx.begin_path();
439                ctx.move_to(self.padding, sep_y);
440                ctx.line_to(w - self.padding, sep_y);
441                ctx.stroke();
442            }
443        }
444        if let Some(s) = &self.selector {
445            // Re-tint the "Mode" label each frame so a theme switch
446            // doesn't leave stale text colour on screen.
447            self.children[s.mode_label_idx].set_label_color(v.text_dim);
448        }
449
450        // Sparkline area sits below the mean label.  Pin it to the bottom
451        // (Y-up: low local Y) so it stays at the foot of the widget
452        // regardless of whether the selector takes up vertical space.
453        let sx = self.padding;
454        let sy = self.padding;
455        let sw = (w - self.padding * 2.0).max(1.0);
456        let sh = self.sparkline_height;
457        paint_sparkline(ctx, &self.history, sx, sy, sw, sh);
458        self.last_painted_revision.set(revision);
459    }
460
461    fn on_event(&mut self, _event: &Event) -> EventResult {
462        EventResult::Ignored
463    }
464
465    fn needs_draw(&self) -> bool {
466        // Reactive run-mode wins, period.  Hosts that wire the
467        // selector explicitly opt in to "the user gets to decide
468        // whether we loop"; in Reactive that means the widget must
469        // NOT claim redraws of its own — otherwise the shell's
470        // per-paint sample push turns `with_history_redraw(true)`
471        // into an infinite loop and AtomArtist (which defaults to
472        // Reactive) ends up painting continuously despite the user
473        // explicitly picking Reactive.  In Continuous the host loop
474        // pumps every frame anyway, so the internal claims here are
475        // redundant but harmless.
476        if let Some(rm) = &self.run_mode {
477            if rm.get() == RunMode::Reactive {
478                return false;
479            }
480        }
481        // Default: passive. The agg-gui demo pushes a sample after each
482        // paint, so making revision changes dirty by default would
483        // turn Reactive mode into an accidental continuous loop.
484        //
485        // Dedicated performance overlays can opt into revision-driven
486        // invalidation with `with_history_redraw(true)`, which redraws
487        // exactly once when a pushed sample has not yet been painted.
488        self.live_redraw
489            || (self.redraw_on_history_change
490                && self.history.borrow().revision() != self.last_painted_revision.get())
491    }
492}
493
494// ── Sparkline painting (free function, shared by hosts that want it) ──────────
495
496/// Paint a frame-time sparkline at `(x, y, w, h)` in the active
497/// `DrawCtx`'s coordinate space.  Reads from `history` for samples and
498/// draws an orange 16.7 ms (60 fps) reference line.  Exposed in case a
499/// caller wants the graph without the surrounding label / padding.
500pub fn paint_sparkline(
501    ctx: &mut dyn DrawCtx,
502    history: &SharedFrameHistory,
503    x: f64,
504    y: f64,
505    w: f64,
506    h: f64,
507) {
508    let v = ctx.visuals();
509    let hist = history.borrow();
510
511    // Background.
512    ctx.set_fill_color(v.track_bg);
513    ctx.begin_path();
514    ctx.rounded_rect(x, y, w, h, 4.0);
515    ctx.fill();
516
517    if hist.len() < 2 {
518        return;
519    }
520    let samples: Vec<f32> = hist.samples().collect();
521    // 60 fps reference (16.7 ms) is the floor for the Y axis range so a
522    // run of fast frames doesn't auto-zoom and exaggerate noise.
523    let max_ms = samples.iter().cloned().fold(0.1_f32, f32::max).max(16.7);
524
525    // Line chart.  Mapping: smaller ms -> higher y (Y-up: top of strip).
526    ctx.set_stroke_color(v.accent);
527    ctx.set_line_width(1.5);
528    ctx.begin_path();
529    let n = samples.len();
530    for (i, &ms) in samples.iter().enumerate() {
531        let px = x + i as f64 / (n - 1) as f64 * w;
532        let py = y + (1.0 - ms as f64 / max_ms as f64) * (h - 4.0) + 2.0;
533        if i == 0 {
534            ctx.move_to(px, py);
535        } else {
536            ctx.line_to(px, py);
537        }
538    }
539    ctx.stroke();
540
541    // 60 fps reference line.
542    let ref_y = y + (1.0 - 16.7 / max_ms as f64) * (h - 4.0) + 2.0;
543    if ref_y >= y + 2.0 && ref_y <= y + h - 2.0 {
544        ctx.set_stroke_color(Color::rgba(1.0, 0.6, 0.0, 0.7));
545        ctx.set_line_width(1.0);
546        ctx.begin_path();
547        ctx.move_to(x, ref_y);
548        ctx.line_to(x + w, ref_y);
549        ctx.stroke();
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn frame_history_mean_with_no_samples_is_zero() {
559        let h = FrameHistory::new();
560        assert_eq!(h.mean_ms(), 0.0);
561        assert_eq!(h.fps(), 0.0);
562        assert!(h.is_empty());
563    }
564
565    #[test]
566    fn frame_history_mean_averages_recent_samples() {
567        let mut h = FrameHistory::new();
568        h.push(10.0);
569        h.push(20.0);
570        h.push(30.0);
571        assert!((h.mean_ms() - 20.0).abs() < 0.001);
572        assert_eq!(h.len(), 3);
573    }
574
575    #[test]
576    fn frame_history_revision_increments_on_push() {
577        let mut h = FrameHistory::new();
578        assert_eq!(h.revision(), 0);
579        h.push(10.0);
580        assert_eq!(h.revision(), 1);
581        h.push(20.0);
582        assert_eq!(h.revision(), 2);
583    }
584
585    #[test]
586    fn frame_history_wraps_at_capacity() {
587        let mut h = FrameHistory::new();
588        // Fill twice past capacity; only the most recent CAP samples
589        // should contribute to the mean.
590        for i in 0..(FrameHistory::CAP * 2) {
591            h.push(i as f32);
592        }
593        assert_eq!(h.len(), FrameHistory::CAP);
594        // The most recent CAP samples are CAP..2*CAP-1; their mean is
595        // (CAP + (2*CAP - 1)) / 2.
596        let cap = FrameHistory::CAP as f32;
597        let expected = (cap + (2.0 * cap - 1.0)) / 2.0;
598        assert!((h.mean_ms() - expected).abs() < 0.01);
599    }
600
601    #[test]
602    fn frame_history_samples_yield_oldest_first() {
603        let mut h = FrameHistory::new();
604        h.push(1.0);
605        h.push(2.0);
606        h.push(3.0);
607        let collected: Vec<f32> = h.samples().collect();
608        assert_eq!(collected, vec![1.0, 2.0, 3.0]);
609    }
610}