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}