Skip to main content

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}