1use 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
13pub trait PerfRenderer {
22 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
48struct 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 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
87fn millis(duration: core::time::Duration) -> f32 {
91 duration.as_secs_f32() * 1000.0
92}
93
94#[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 #[must_use]
115 pub const fn new() -> Self {
116 Self { theme: Theme::DARK }
117 }
118
119 #[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 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 #[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 #[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 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 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 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 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}