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