1use 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#[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 #[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 #[must_use]
88 pub const fn backend(mut self, backend: &'a str) -> Self {
89 self.backend = backend;
90 self
91 }
92
93 #[must_use]
95 pub const fn title(mut self, title: &'a str) -> Self {
96 self.title = title;
97 self
98 }
99
100 #[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 #[must_use]
110 pub const fn text_style(mut self, style: Style) -> Self {
111 self.text_style = style;
112 self
113 }
114
115 #[must_use]
124 pub const fn sparkline_style(mut self, style: Style) -> Self {
125 self.sparkline_style = style;
126 self
127 }
128
129 #[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 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 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
228fn millis(duration: core::time::Duration) -> f32 {
231 duration.as_secs_f32() * 1000.0
232}
233
234#[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 #[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 #[must_use]
320 pub const fn backend(mut self, backend: &'a str) -> Self {
321 self.backend = backend;
322 self
323 }
324
325 #[must_use]
327 pub const fn title(mut self, title: &'a str) -> Self {
328 self.title = title;
329 self
330 }
331
332 #[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 #[must_use]
341 pub const fn text_style(mut self, style: Style) -> Self {
342 self.text_style = style;
343 self
344 }
345
346 #[must_use]
348 pub const fn sparkline_style(mut self, style: Style) -> Self {
349 self.sparkline_style = style;
350 self
351 }
352
353 #[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 fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, frame: &Frame) {
372 state.record(frame.delta);
373
374 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 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 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 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}