Skip to main content

retroglyph_ui/perf/
renderer.rs

1//! [`PerfRenderer`] and the built-in [`DefaultPerfRenderer`].
2
3use core::fmt::{self, Write as _};
4
5use retroglyph_core::color::Style;
6use retroglyph_core::frames::FrameStats;
7use retroglyph_core::grid::Rect;
8
9use super::FRAME_HISTORY;
10use crate::Surface;
11use crate::Theme;
12
13/// Draws a [`super::PerfOverlayApp`]'s stats into a rectangular area of a [`Surface`].
14///
15/// Implemented for any `FnMut(&FrameStats<FRAME_HISTORY>, &str, Rect, &mut Surface<'_>)` (pass
16/// such a closure to [`PerfOverlayApp::with_closure`](super::PerfOverlayApp::with_closure); see
17/// its docs for why that constructor exists instead of just accepting `impl PerfRenderer`
18/// everywhere), so a plain closure is enough for a custom overlay; see the [module
19/// docs](super) for composing one out of this crate's widgets. [`DefaultPerfRenderer`] is the
20/// built-in implementation, used by [`PerfOverlayApp::new`](super::PerfOverlayApp::new).
21pub trait PerfRenderer {
22    /// Draws `stats` (and the caller-supplied `backend` label) into `area`, via `surface` scoped
23    /// to it.
24    fn render(
25        &mut self,
26        stats: &FrameStats<FRAME_HISTORY>,
27        backend: &str,
28        area: Rect,
29        surface: &mut Surface<'_>,
30    );
31}
32
33impl<F> PerfRenderer for F
34where
35    F: FnMut(&FrameStats<FRAME_HISTORY>, &str, Rect, &mut Surface<'_>),
36{
37    fn render(
38        &mut self,
39        stats: &FrameStats<FRAME_HISTORY>,
40        backend: &str,
41        area: Rect,
42        surface: &mut Surface<'_>,
43    ) {
44        self(stats, backend, area, surface);
45    }
46}
47
48/// A fixed-capacity, stack-allocated [`fmt::Write`] sink, so [`DefaultPerfRenderer`] can format
49/// its readout without heap-allocating a `String` every frame. Overflowing writes are rejected
50/// (matching `core::fmt`'s own "stop, don't panic" policy); [`FixedBuf::as_str`] then simply
51/// returns whatever was successfully written before the overflow.
52struct FixedBuf<const N: usize> {
53    bytes: [u8; N],
54    len: usize,
55}
56
57impl<const N: usize> FixedBuf<N> {
58    const fn new() -> Self {
59        Self {
60            bytes: [0; N],
61            len: 0,
62        }
63    }
64
65    /// Only ASCII is ever written into this buffer by [`DefaultPerfRenderer`] (digits, spaces,
66    /// and the caller's `backend` label, which is expected to be a short ASCII identifier), so
67    /// `len` bytes are always valid UTF-8; this falls back to `""` rather than panicking if that
68    /// invariant is ever broken by a future caller.
69    fn as_str(&self) -> &str {
70        core::str::from_utf8(&self.bytes[..self.len]).unwrap_or("")
71    }
72}
73
74impl<const N: usize> fmt::Write for FixedBuf<N> {
75    fn write_str(&mut self, s: &str) -> fmt::Result {
76        let bytes = s.as_bytes();
77        let end = self.len + bytes.len();
78        if end > N {
79            return Err(fmt::Error);
80        }
81        self.bytes[self.len..end].copy_from_slice(bytes);
82        self.len = end;
83        Ok(())
84    }
85}
86
87/// `duration` in whole milliseconds, for display. Precision loss below a millisecond is
88/// immaterial to a live readout; [`FrameStats`] itself stays full [`core::time::Duration`]
89/// precision, this is purely a formatting concern of [`DefaultPerfRenderer`].
90fn millis(duration: core::time::Duration) -> f32 {
91    duration.as_secs_f32() * 1000.0
92}
93
94/// The built-in [`PerfRenderer`], used by [`PerfOverlayApp::new`](super::PerfOverlayApp::new).
95///
96/// A single-row `NNNfps MM.Mms minMM.M maxMM.M <backend>` readout, right-aligned within its area,
97/// on a solid background. Colored from a [`Theme`] (see [`DefaultPerfRenderer::theme`]), so a
98/// caller matching a [`Theme`]-driven UI elsewhere doesn't get a hardcoded, unrelated palette here.
99///
100/// A no-op before the first frame is recorded, or if the readout doesn't fit `area`'s width.
101#[derive(Debug, Clone, Copy)]
102pub struct DefaultPerfRenderer {
103    theme: Theme,
104}
105
106impl Default for DefaultPerfRenderer {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl DefaultPerfRenderer {
113    /// A renderer styled from [`Theme::DARK`].
114    #[must_use]
115    pub const fn new() -> Self {
116        Self { theme: Theme::DARK }
117    }
118
119    /// Sets the [`Theme`] the readout's text/background colors come from: `theme.fg` on
120    /// `theme.panel_bg`. Defaults to [`Theme::DARK`].
121    #[must_use]
122    pub const fn theme(mut self, theme: Theme) -> Self {
123        self.theme = theme;
124        self
125    }
126}
127
128impl PerfRenderer for DefaultPerfRenderer {
129    fn render(
130        &mut self,
131        stats: &FrameStats<FRAME_HISTORY>,
132        backend: &str,
133        area: Rect,
134        surface: &mut Surface<'_>,
135    ) {
136        if stats.frame_count() == 0 || area.height() == 0 {
137            return;
138        }
139        // 96 bytes: ~34 for the fixed ` NNNfps NN.Nms minNN.N maxNN.N ` scaffold (numeric fields
140        // widened by the padding specs) plus ~60 for the `{backend}` label. FixedBuf rejects an
141        // overflowing write and keeps only what fit (see its docs), so a backend label long
142        // enough to exceed this budget truncates the readout rather than allocating or panicking;
143        // that is acceptable for a debug HUD but is why the label is documented as "short"
144        // wherever it is passed in.
145        let mut text = FixedBuf::<96>::new();
146        let _ = write!(
147            text,
148            " {:>3.0}fps {:>4.1}ms min{:>4.1} max{:>4.1} {backend} ",
149            stats.fps(),
150            millis(stats.current()),
151            millis(stats.min()),
152            millis(stats.max()),
153        );
154        let text = text.as_str();
155
156        let width = area.width_usize();
157        let len = text.chars().count();
158        if len == 0 || len > width {
159            return;
160        }
161        // `len` is bounded by `width`, itself widened from `area`'s own `u16` width, so
162        // narrowing it back is always exact.
163        #[allow(clippy::cast_possible_truncation)]
164        let len_u16 = len as u16;
165        let x0 = area.left() + (area.width() - len_u16);
166
167        let style = Style::new().fg(self.theme.fg).bg(self.theme.panel_bg);
168        for (i, ch) in text.chars().enumerate() {
169            // `i` ranges over `0..len`, and `len <= width <= area.width()` (a `u16`), so
170            // narrowing it back is always exact.
171            #[allow(clippy::cast_possible_truncation)]
172            let x = x0 + i as u16;
173            surface.put((x, area.top()), ch, style);
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use retroglyph_core::grid::{Grid, Pos};
181
182    use super::*;
183
184    #[test]
185    fn fixed_buf_formats_without_allocating() {
186        let mut buf = FixedBuf::<8>::new();
187        let _ = write!(buf, "{:>3}fps", 62);
188        assert_eq!(buf.as_str(), " 62fps");
189    }
190
191    #[test]
192    fn fixed_buf_rejects_writes_past_capacity_and_keeps_what_fit() {
193        let mut buf = FixedBuf::<4>::new();
194        // "12345" (5 bytes) doesn't fit in a 4-byte buffer; the write errors out and only
195        // whatever was written before the overflow (nothing, here, since it overflows on the
196        // very first `write_str` call) is kept.
197        assert!(write!(buf, "12345").is_err());
198        assert_eq!(buf.as_str(), "");
199    }
200
201    #[test]
202    fn default_impl_matches_new() {
203        assert_eq!(
204            DefaultPerfRenderer::default().theme,
205            DefaultPerfRenderer::new().theme
206        );
207    }
208
209    #[test]
210    fn default_perf_renderer_is_a_noop_before_the_first_frame() {
211        let stats = FrameStats::<FRAME_HISTORY>::new();
212        let area = Rect::new(0, 0, 40, 1);
213        let mut grid = Grid::new(40, 1);
214        DefaultPerfRenderer::new().render(
215            &stats,
216            "headless",
217            area,
218            &mut Surface::new(&mut grid, area, 0),
219        );
220        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
221    }
222
223    #[test]
224    fn default_perf_renderer_is_a_noop_when_the_readout_does_not_fit() {
225        let mut stats = FrameStats::<FRAME_HISTORY>::new();
226        stats.record(core::time::Duration::from_millis(16));
227        // A handful of columns can never fit "NNNfps ...": too narrow, not zero, so this exercises
228        // the `len > width` guard rather than the `area.height() == 0` one.
229        let area = Rect::new(0, 0, 3, 1);
230        let mut grid = Grid::new(3, 1);
231        DefaultPerfRenderer::new().render(
232            &stats,
233            "headless",
234            area,
235            &mut Surface::new(&mut grid, area, 0),
236        );
237        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
238    }
239
240    #[test]
241    fn default_perf_renderer_shows_fps_ms_min_max_and_backend_right_aligned() {
242        use alloc::string::String;
243
244        let mut stats = FrameStats::<FRAME_HISTORY>::new();
245        for _ in 0..5 {
246            stats.record(core::time::Duration::from_millis(16));
247        }
248        let area = Rect::new(0, 0, 60, 1);
249        let mut grid = Grid::new(60, 1);
250        DefaultPerfRenderer::new().render(
251            &stats,
252            "headless",
253            area,
254            &mut Surface::new(&mut grid, area, 0),
255        );
256        let row: String = (0..60).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
257        assert!(row.contains("fps"), "{row}");
258        assert!(row.contains("ms"), "{row}");
259        assert!(row.contains("min"), "{row}");
260        assert!(row.contains("max"), "{row}");
261        assert!(row.contains("headless"), "{row}");
262        // Right-aligned: the last character of the readout (the trailing space) sits in the
263        // area's last column, not floating somewhere in the middle.
264        assert_eq!(grid[Pos::new(59, 0)].glyph(), ' ');
265        assert_ne!(grid[Pos::new(0, 0)].glyph(), 'h');
266    }
267
268    #[test]
269    fn theme_overrides_the_default_colors() {
270        let mut stats = FrameStats::<FRAME_HISTORY>::new();
271        stats.record(core::time::Duration::from_millis(16));
272        let area = Rect::new(0, 0, 60, 1);
273        let mut grid = Grid::new(60, 1);
274        DefaultPerfRenderer::new().theme(Theme::LIGHT).render(
275            &stats,
276            "headless",
277            area,
278            &mut Surface::new(&mut grid, area, 0),
279        );
280        // Column 59 (the trailing space) is always painted, regardless of the readout's exact
281        // length, unlike column 0 which may sit left of a right-aligned short readout.
282        assert_eq!(grid[Pos::new(59, 0)].style().foreground(), Theme::LIGHT.fg);
283        assert_eq!(
284            grid[Pos::new(59, 0)].style().background(),
285            Theme::LIGHT.panel_bg
286        );
287    }
288}