retroglyph_ui/widget/perf_overlay.rs
1//! [`PerfOverlay`]: a bordered live frame-time/FPS panel with a sparkline.
2use alloc::format;
3
4use retroglyph_core::app::Frame;
5use retroglyph_core::color::Style;
6use retroglyph_core::frames::FrameStats;
7use retroglyph_core::grid::Rect;
8
9use super::{AnimatedWidget, Panel, Sparkline, Text, Widget};
10use crate::Surface;
11use crate::Theme;
12
13/// A bordered panel showing live [`FrameStats`].
14///
15/// An `NNNfps MM.Mms minMM.M maxMM.M <backend>` readout, any extra caller-supplied
16/// metric rows (`VSync` state, resolution, render backend details, ...), and a scrolling
17/// frame-time [`Sparkline`].
18///
19/// The richer counterpart to [`DefaultPerfRenderer`](crate::DefaultPerfRenderer): composed
20/// entirely from existing widgets ([`Panel`], [`Text`], [`Sparkline`]), it draws through
21/// [`Surface`] like every other widget in this crate, so it already works on every backend. Hand
22/// it to [`PerfOverlayApp::with_closure`](crate::PerfOverlayApp::with_closure) as a closure to use
23/// it instead of the built-in renderer:
24///
25/// ```
26/// # #[cfg(feature = "std")]
27/// # {
28/// use retroglyph_core::app::{App, Flow, Frame};
29/// use retroglyph_core::backend::{Backend, Headless};
30/// use retroglyph_core::grid::Size;
31/// use retroglyph_core::terminal::Terminal;
32/// use retroglyph_ui::{PerfOverlay, PerfOverlayApp, Widget};
33///
34/// struct MyGame;
35/// impl<B: Backend> App<B> for MyGame {
36/// fn update(&mut self, _term: &mut Terminal<B>, frame: &Frame) -> Flow {
37/// if frame.frame >= 1 { Flow::Exit } else { Flow::Continue }
38/// }
39/// }
40///
41/// let term = Terminal::new(Headless::new(60, 12));
42/// let app = PerfOverlayApp::with_closure(MyGame, "software", |stats, backend, area, surface| {
43/// PerfOverlay::new(stats)
44/// .backend(backend)
45/// .metrics(&[("res", "1920x1080"), ("vsync", "on")])
46/// .render(&mut surface.scope(area));
47/// })
48/// .size(Size::new(34, 8));
49/// retroglyph_core::app::run_blocking(term, app).expect("run_blocking");
50/// # } // `run_blocking` is `std`-only; a no-op under `--no-default-features`.
51/// ```
52///
53/// `N` must match the [`FrameStats`] window it's built from; this crate's
54/// [`PerfOverlayApp`](crate::PerfOverlayApp) always uses 120 samples
55/// ([`FRAME_HISTORY`](crate::FRAME_HISTORY)), the default here too.
56///
57/// # As an [`AnimatedWidget`]
58///
59/// This type is read-only: it borrows an already-updated [`FrameStats`] (`new(stats)`), which is
60/// exactly what [`Widget`] rendering wants but is the wrong shape for [`AnimatedWidget`], whose
61/// `state: &mut FrameStats` argument needs to *record into* the same data a draw call reads --
62/// an immutable borrow baked into `self` and a mutable one for `state` can't coexist. Use
63/// [`AnimatedPerfOverlay`] instead for a call site that owns one [`FrameStats`] field and wants to
64/// record and draw in a single call, with no [`PerfOverlayApp`](crate::PerfOverlayApp) decorator
65/// wrapping the app.
66///
67/// Rows beyond the panel's available interior height are silently dropped: the readout row
68/// draws first, then one row per [`metrics`](Self::metrics) entry, then the sparkline, each only
69/// if there's still room, so a caller that under-sizes the area loses the least important rows
70/// first rather than panicking or overflowing the border.
71#[derive(Clone, Copy, Debug)]
72pub struct PerfOverlay<'a, const N: usize = 120> {
73 stats: &'a FrameStats<N>,
74 backend: &'a str,
75 title: &'a str,
76 metrics: &'a [(&'a str, &'a str)],
77 border_style: Style,
78 fill_style: Style,
79 text_style: Style,
80 sparkline_style: Style,
81}
82
83impl<'a, const N: usize> PerfOverlay<'a, N> {
84 /// A perf overlay reading `stats`, titled `"perf"`, with no backend label and no extra
85 /// metrics, styled from [`Theme::DARK`] (as if [`PerfOverlay::theme`] had been called).
86 #[must_use]
87 pub fn new(stats: &'a FrameStats<N>) -> Self {
88 Self {
89 stats,
90 backend: "",
91 title: "perf",
92 metrics: &[],
93 border_style: Style::new(),
94 fill_style: Style::new(),
95 text_style: Style::new(),
96 sparkline_style: Style::new(),
97 }
98 .theme(Theme::DARK)
99 }
100
101 /// Sets the backend label appended to the readout row (e.g. `"crossterm"`, `"software"`).
102 /// Omitted entirely if left empty (the default).
103 #[must_use]
104 pub const fn backend(mut self, backend: &'a str) -> Self {
105 self.backend = backend;
106 self
107 }
108
109 /// Sets the panel's title. Defaults to `"perf"`.
110 #[must_use]
111 pub const fn title(mut self, title: &'a str) -> Self {
112 self.title = title;
113 self
114 }
115
116 /// Sets extra `(label, value)` metric rows drawn below the readout: resolution, `VSync`
117 /// state, render backend details, or anything else an app wants visible. Defaults to none.
118 #[must_use]
119 pub const fn metrics(mut self, metrics: &'a [(&'a str, &'a str)]) -> Self {
120 self.metrics = metrics;
121 self
122 }
123
124 /// Sets the readout/metric text's style. Defaults to [`Theme::DARK`]'s `fg` role.
125 #[must_use]
126 pub const fn text_style(mut self, style: Style) -> Self {
127 self.text_style = style;
128 self
129 }
130
131 /// Sets the frame-time sparkline's bar color (every bar, uniformly; see
132 /// [`Sparkline::style`]).
133 ///
134 /// Defaults to a fixed accent color, not [`Sparkline`]'s own green-to-red ramp: the sparkline
135 /// scrolls, so "tallest bar in the visible window" isn't the same thing as "a slow frame":
136 /// the same absolute frame time reads as short one moment and tall the next as the window's
137 /// own max shifts. A ramp keyed to that relative height would tell a story the data doesn't
138 /// support; one fixed color makes height the only signal, which is the honest one.
139 #[must_use]
140 pub const fn sparkline_style(mut self, style: Style) -> Self {
141 self.sparkline_style = style;
142 self
143 }
144
145 /// Applies `theme`'s named roles: `border_style`/`fill_style` map the same way as
146 /// [`Panel::theme`], and `text_style` becomes `theme.fg` on `theme.panel_bg`.
147 ///
148 /// Call before any manual style override you want to keep.
149 #[must_use]
150 pub fn theme(mut self, theme: Theme) -> Self {
151 self.border_style = Style::new().fg(theme.border).bg(theme.title_bg);
152 self.fill_style = Style::new().bg(theme.panel_bg);
153 self.text_style = Style::new().fg(theme.fg).bg(theme.panel_bg);
154 self.sparkline_style = Style::new().fg(theme.accent);
155 self
156 }
157}
158
159impl<const N: usize> Widget for PerfOverlay<'_, N> {
160 fn render(&self, surface: &mut Surface<'_>) {
161 let area = surface.area();
162 // 2 rows go to the top and bottom border, so height 3 is the smallest that leaves a
163 // readout row: a hard bound derived from `inner`'s `area.height() - 2` below, matching
164 // the height half of this guard exactly. The width 4 floor is looser than the true hard
165 // bound: `inner`'s `area.width() - 2` only actually underflows and panics at width 1
166 // (verified: width 2 and 3 both render without panicking, just with a squeezed 0- or
167 // 1-column-wide interior). 4 is a conservative floor above that width-2 minimum, picked
168 // by eye to avoid drawing a readout so narrow it clips to nothing useful.
169 if area.width() < 4 || area.height() < 3 {
170 return;
171 }
172
173 Panel::new()
174 .title(self.title)
175 .border_style(self.border_style)
176 .fill_style(self.fill_style)
177 .render(surface);
178
179 // Must match the 1-cell border inset the `Panel` above draws (the same rect
180 // `Panel::inner` computes for a zero-padding panel). Kept inline rather than routed
181 // through `Panel::inner` because the panel is built and dropped in the same statement.
182 let inner = Rect::new(
183 area.left() + 1,
184 area.top() + 1,
185 area.width() - 2,
186 area.height() - 2,
187 );
188 let mut y = inner.top();
189 let row = |y: u16| Rect::new(inner.left(), y, inner.width(), 1);
190
191 if y < inner.bottom() {
192 let readout = if self.backend.is_empty() {
193 format!(
194 "{:>3.0}fps {:>4.1}ms min {:>4.1} max {:>4.1}",
195 self.stats.fps(),
196 millis(self.stats.current()),
197 millis(self.stats.min()),
198 millis(self.stats.max()),
199 )
200 } else {
201 format!(
202 "{:>3.0}fps {:>4.1}ms min {:>4.1} max {:>4.1} {}",
203 self.stats.fps(),
204 millis(self.stats.current()),
205 millis(self.stats.min()),
206 millis(self.stats.max()),
207 self.backend,
208 )
209 };
210 Text::new(&readout)
211 .style(self.text_style)
212 .render(&mut surface.scope(row(y)));
213 y += 1;
214 }
215
216 for (label, value) in self.metrics {
217 if y >= inner.bottom() {
218 break;
219 }
220 let line = format!("{label}: {value}");
221 Text::new(&line)
222 .style(self.text_style)
223 .render(&mut surface.scope(row(y)));
224 y += 1;
225 }
226
227 if y < inner.bottom() {
228 let mut samples = [0.0f32; N];
229 let mut len = 0;
230 for duration in self.stats.samples() {
231 if len >= N {
232 break;
233 }
234 samples[len] = millis(duration);
235 len += 1;
236 }
237 Sparkline::new(&samples[..len])
238 .style(self.sparkline_style)
239 .render(&mut surface.scope(row(y)));
240 }
241 }
242}
243
244/// `duration` in whole milliseconds, for display. [`FrameStats`] itself stays full
245/// [`core::time::Duration`] precision; this is purely a formatting concern of this widget.
246fn millis(duration: core::time::Duration) -> f32 {
247 duration.as_secs_f32() * 1000.0
248}
249
250/// [`PerfOverlay`]'s [`AnimatedWidget`] counterpart.
251///
252/// The same readout, extra metric rows, and frame-time sparkline, but reading its [`FrameStats`]
253/// from `state` at render time instead of borrowing one up front.
254///
255/// [`PerfOverlay::new`] takes `stats: &'a FrameStats<N>`, which [`Widget`] rendering (a pure read
256/// of already-current data) wants but [`AnimatedWidget::render`] can't offer: its `state: &mut
257/// FrameStats<N>` needs to record a fresh sample into the same data a draw call then reads, and an
258/// immutable borrow baked into `self` can't coexist with a mutable one passed as `state` in the
259/// same call. `AnimatedPerfOverlay` holds no stats reference of its own, only the same
260/// backend/title/metrics/style knobs [`PerfOverlay`] has, so there's nothing to alias.
261///
262/// Replaces routing a `Duration` through [`PerfOverlayApp`](crate::PerfOverlayApp) just to reach
263/// this widget: an app that owns one [`FrameStats`] field can record into it and draw in a single
264/// call, no decorator wrapping the app at all. [`PerfOverlayApp`](crate::PerfOverlayApp) remains
265/// the right choice for an app that also wants its toggle-key handling, mode cycling, and event
266/// draining done generically, across any wrapped [`App`](retroglyph_core::app::App). This is only for
267/// the (now unblocked) case that doesn't need any of that.
268///
269/// # Examples
270///
271/// ```
272/// # #[cfg(feature = "std")]
273/// # {
274/// use retroglyph_core::app::{App, Flow, Frame};
275/// use retroglyph_core::backend::{Backend, Headless};
276/// use retroglyph_core::frames::FrameStats;
277/// use retroglyph_core::grid::Rect;
278/// use retroglyph_core::terminal::Terminal;
279/// use retroglyph_ui::{AnimatedPerfOverlay, AnimatedWidget};
280///
281/// struct MyGame {
282/// stats: FrameStats,
283/// }
284///
285/// impl<B: Backend> App<B> for MyGame {
286/// fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow {
287/// let area = Rect::new(0, 0, 34, 8);
288/// let mut surface = term.surface();
289/// AnimatedPerfOverlay::new()
290/// .backend("software")
291/// .render(&mut surface.scope(area), &mut self.stats, frame);
292/// if frame.frame >= 1 { Flow::Exit } else { Flow::Continue }
293/// }
294/// }
295///
296/// let term = Terminal::new(Headless::new(60, 12));
297/// let app = MyGame { stats: FrameStats::new() };
298/// retroglyph_core::app::run_blocking(term, app).expect("run_blocking");
299/// # } // `run_blocking` is `std`-only; a no-op under `--no-default-features`.
300/// ```
301#[derive(Clone, Copy, Debug)]
302pub struct AnimatedPerfOverlay<'a, const N: usize = 120> {
303 backend: &'a str,
304 title: &'a str,
305 metrics: &'a [(&'a str, &'a str)],
306 border_style: Style,
307 fill_style: Style,
308 text_style: Style,
309 sparkline_style: Style,
310}
311
312impl<const N: usize> Default for AnimatedPerfOverlay<'_, N> {
313 fn default() -> Self {
314 Self::new()
315 }
316}
317
318impl<'a, const N: usize> AnimatedPerfOverlay<'a, N> {
319 /// An animated perf overlay titled `"perf"`, with no backend label and no extra metrics:
320 /// the same defaults as [`PerfOverlay::new`], styled from [`Theme::DARK`] (as if
321 /// [`AnimatedPerfOverlay::theme`] had been called). `N` must match the [`FrameStats`] window
322 /// this is driven by; the default, 120, matches [`PerfOverlay`]'s and
323 /// [`FrameStats`]'s own defaults.
324 #[must_use]
325 pub fn new() -> Self {
326 Self {
327 backend: "",
328 title: "perf",
329 metrics: &[],
330 border_style: Style::new(),
331 fill_style: Style::new(),
332 text_style: Style::new(),
333 sparkline_style: Style::new(),
334 }
335 .theme(Theme::DARK)
336 }
337
338 /// See [`PerfOverlay::backend`].
339 #[must_use]
340 pub const fn backend(mut self, backend: &'a str) -> Self {
341 self.backend = backend;
342 self
343 }
344
345 /// See [`PerfOverlay::title`].
346 #[must_use]
347 pub const fn title(mut self, title: &'a str) -> Self {
348 self.title = title;
349 self
350 }
351
352 /// See [`PerfOverlay::metrics`].
353 #[must_use]
354 pub const fn metrics(mut self, metrics: &'a [(&'a str, &'a str)]) -> Self {
355 self.metrics = metrics;
356 self
357 }
358
359 /// See [`PerfOverlay::text_style`].
360 #[must_use]
361 pub const fn text_style(mut self, style: Style) -> Self {
362 self.text_style = style;
363 self
364 }
365
366 /// See [`PerfOverlay::sparkline_style`].
367 #[must_use]
368 pub const fn sparkline_style(mut self, style: Style) -> Self {
369 self.sparkline_style = style;
370 self
371 }
372
373 /// See [`PerfOverlay::theme`].
374 #[must_use]
375 pub fn theme(mut self, theme: Theme) -> Self {
376 self.border_style = Style::new().fg(theme.border).bg(theme.title_bg);
377 self.fill_style = Style::new().bg(theme.panel_bg);
378 self.text_style = Style::new().fg(theme.fg).bg(theme.panel_bg);
379 self.sparkline_style = Style::new().fg(theme.accent);
380 self
381 }
382}
383
384impl<const N: usize> AnimatedWidget for AnimatedPerfOverlay<'_, N> {
385 type State = FrameStats<N>;
386
387 /// Records a sample into `state` via [`FrameStats::record`], then draws [`PerfOverlay::new`]
388 /// built from the result (plus this type's own backend/title/metrics/style knobs), both in
389 /// one call, so there's exactly one place, not two independently ordered ones, where the
390 /// stats window advances.
391 fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, frame: &Frame) {
392 state.record(frame.delta);
393
394 // A direct struct literal, not the public builder chain: `PerfOverlay` has no public
395 // `border_style`/`fill_style` setters of its own (only `theme()` sets them together), but
396 // both types live in this module, so their private fields are visible to each other here.
397 let overlay = PerfOverlay {
398 stats: &*state,
399 backend: self.backend,
400 title: self.title,
401 metrics: self.metrics,
402 border_style: self.border_style,
403 fill_style: self.fill_style,
404 text_style: self.text_style,
405 sparkline_style: self.sparkline_style,
406 };
407 Widget::render(&overlay, surface);
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use alloc::string::String;
414 use core::time::Duration;
415
416 use retroglyph_core::grid::{Grid, Pos};
417
418 use super::*;
419
420 fn settled<const N: usize>(millis: u64, frames: usize) -> FrameStats<N> {
421 let mut stats = FrameStats::new();
422 for _ in 0..frames {
423 stats.record(Duration::from_millis(millis));
424 }
425 stats
426 }
427
428 #[test]
429 fn draws_a_readout_border_and_title() {
430 let stats = settled::<120>(16, 5);
431 let area = Rect::new(0, 0, 40, 5);
432 let mut grid = Grid::new(40, 5);
433 PerfOverlay::new(&stats).render(&mut Surface::new(&mut grid, area, 0));
434
435 assert_eq!(grid[Pos::new(0, 0)].glyph(), '┌');
436 let title_row: String = (0..40).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
437 assert!(title_row.contains("perf"));
438 let readout_row: String = (0..40).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
439 assert!(readout_row.contains("fps"));
440 assert!(readout_row.contains("ms"));
441 assert!(readout_row.contains("min"));
442 }
443
444 #[test]
445 fn backend_label_is_appended_when_set() {
446 let stats = settled::<120>(16, 5);
447 let area = Rect::new(0, 0, 70, 5);
448 let mut grid = Grid::new(70, 5);
449 PerfOverlay::new(&stats)
450 .backend("software")
451 .render(&mut Surface::new(&mut grid, area, 0));
452
453 let readout_row: String = (0..70).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
454 assert!(readout_row.contains("software"));
455 }
456
457 #[test]
458 fn backend_label_omitted_when_empty() {
459 let stats = settled::<120>(16, 5);
460 let area = Rect::new(0, 0, 40, 5);
461 let mut grid = Grid::new(40, 5);
462 PerfOverlay::new(&stats).render(&mut Surface::new(&mut grid, area, 0));
463
464 let readout_row: String = (0..40).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
465 assert!(!readout_row.contains("softw"));
466 }
467
468 #[test]
469 fn extra_metric_rows_are_drawn_below_the_readout() {
470 let stats = settled::<120>(16, 5);
471 let area = Rect::new(0, 0, 40, 6);
472 let mut grid = Grid::new(40, 6);
473 PerfOverlay::new(&stats)
474 .metrics(&[("res", "80x24"), ("vsync", "on")])
475 .render(&mut Surface::new(&mut grid, area, 0));
476
477 let row2: String = (0..40).map(|x| grid[Pos::new(x, 2)].glyph()).collect();
478 let row3: String = (0..40).map(|x| grid[Pos::new(x, 3)].glyph()).collect();
479 assert!(row2.contains("res") && row2.contains("80x24"));
480 assert!(row3.contains("vsync") && row3.contains("on"));
481 }
482
483 #[test]
484 fn metrics_beyond_available_height_are_dropped_not_overflowed() {
485 let stats = settled::<120>(16, 5);
486 // Interior is 1 row tall (area height 3 - 2 border rows): only the readout row fits.
487 let area = Rect::new(0, 0, 40, 3);
488 let mut grid = Grid::new(40, 3);
489 PerfOverlay::new(&stats)
490 .metrics(&[("res", "80x24")])
491 .render(&mut Surface::new(&mut grid, area, 0));
492
493 assert_eq!(grid[Pos::new(0, 2)].glyph(), '└', "bottom border intact");
494 }
495
496 #[test]
497 fn too_small_is_a_no_op() {
498 let stats = settled::<120>(16, 5);
499 let area = Rect::new(0, 0, 2, 2);
500 let mut grid = Grid::new(2, 2);
501 PerfOverlay::new(&stats).render(&mut Surface::new(&mut grid, area, 0));
502 assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
503 }
504
505 #[test]
506 fn theme_maps_named_roles_onto_border_fill_and_text() {
507 let stats = settled::<120>(16, 5);
508 let area = Rect::new(0, 0, 40, 5);
509 let mut grid = Grid::new(40, 5);
510 PerfOverlay::new(&stats)
511 .theme(Theme::DARK)
512 .render(&mut Surface::new(&mut grid, area, 0));
513
514 assert_eq!(
515 grid[Pos::new(0, 0)].style().foreground(),
516 Theme::DARK.border
517 );
518 assert_eq!(grid[Pos::new(1, 1)].style().foreground(), Theme::DARK.fg);
519 }
520
521 fn frame(delta_ms: u64) -> Frame {
522 Frame {
523 delta: Duration::from_millis(delta_ms),
524 frame: 0,
525 }
526 }
527
528 #[test]
529 fn animated_render_records_before_drawing() {
530 let area = Rect::new(0, 0, 40, 5);
531 let mut grid = Grid::new(40, 5);
532 let mut stats = FrameStats::<120>::new();
533 assert_eq!(stats.frame_count(), 0, "nothing recorded yet");
534
535 AnimatedPerfOverlay::new().render(
536 &mut Surface::new(&mut grid, area, 0),
537 &mut stats,
538 &frame(16),
539 );
540
541 assert_eq!(
542 stats.frame_count(),
543 1,
544 "render should have recorded a sample"
545 );
546 // Drawn from the *post-record* stats, not a stale empty window: the readout row shows an
547 // fps/ms reading rather than the "no frames yet" no-op PerfOverlay::render otherwise
548 // takes (see too_small_is_a_no_op).
549 let readout_row: String = (0..40).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
550 assert!(readout_row.contains("fps"), "{readout_row}");
551 }
552
553 #[test]
554 fn animated_render_matches_perf_overlay_drawn_from_the_same_post_record_stats() {
555 let area = Rect::new(0, 0, 60, 6);
556
557 let mut animated_grid = Grid::new(60, 6);
558 let mut stats = FrameStats::<120>::new();
559 AnimatedPerfOverlay::new()
560 .backend("software")
561 .metrics(&[("res", "80x24")])
562 .render(
563 &mut Surface::new(&mut animated_grid, area, 0),
564 &mut stats,
565 &frame(16),
566 );
567
568 // `stats` now holds the one recorded sample; a plain `PerfOverlay` built from it (with
569 // the same knobs) should draw byte-for-byte identically.
570 let mut expected_grid = Grid::new(60, 6);
571 PerfOverlay::new(&stats)
572 .backend("software")
573 .metrics(&[("res", "80x24")])
574 .render(&mut Surface::new(&mut expected_grid, area, 0));
575
576 for y in 0..6 {
577 let animated_row: String = (0..60)
578 .map(|x| animated_grid[Pos::new(x, y)].glyph())
579 .collect();
580 let expected_row: String = (0..60)
581 .map(|x| expected_grid[Pos::new(x, y)].glyph())
582 .collect();
583 assert_eq!(animated_row, expected_row, "row {y}");
584 }
585 }
586
587 #[test]
588 fn animated_render_records_a_sample_every_call() {
589 let area = Rect::new(0, 0, 40, 5);
590 let mut grid = Grid::new(40, 5);
591 let mut stats = FrameStats::<120>::new();
592
593 for _ in 0..5 {
594 AnimatedPerfOverlay::new().render(
595 &mut Surface::new(&mut grid, area, 0),
596 &mut stats,
597 &frame(16),
598 );
599 }
600
601 assert_eq!(stats.frame_count(), 5);
602 }
603}