use core::fmt::{self, Write as _};
use retroglyph_core::color::Style;
use retroglyph_core::frames::FrameStats;
use retroglyph_core::grid::Rect;
use super::FRAME_HISTORY;
use crate::Surface;
use crate::Theme;
pub trait PerfRenderer {
fn render(
&mut self,
stats: &FrameStats<FRAME_HISTORY>,
backend: &str,
area: Rect,
surface: &mut Surface<'_>,
);
}
impl<F> PerfRenderer for F
where
F: FnMut(&FrameStats<FRAME_HISTORY>, &str, Rect, &mut Surface<'_>),
{
fn render(
&mut self,
stats: &FrameStats<FRAME_HISTORY>,
backend: &str,
area: Rect,
surface: &mut Surface<'_>,
) {
self(stats, backend, area, surface);
}
}
struct FixedBuf<const N: usize> {
bytes: [u8; N],
len: usize,
}
impl<const N: usize> FixedBuf<N> {
const fn new() -> Self {
Self {
bytes: [0; N],
len: 0,
}
}
fn as_str(&self) -> &str {
core::str::from_utf8(&self.bytes[..self.len]).unwrap_or("")
}
}
impl<const N: usize> fmt::Write for FixedBuf<N> {
fn write_str(&mut self, s: &str) -> fmt::Result {
let bytes = s.as_bytes();
let end = self.len + bytes.len();
if end > N {
return Err(fmt::Error);
}
self.bytes[self.len..end].copy_from_slice(bytes);
self.len = end;
Ok(())
}
}
fn millis(duration: core::time::Duration) -> f32 {
duration.as_secs_f32() * 1000.0
}
#[derive(Debug, Clone, Copy)]
pub struct DefaultPerfRenderer {
theme: Theme,
}
impl Default for DefaultPerfRenderer {
fn default() -> Self {
Self::new()
}
}
impl DefaultPerfRenderer {
#[must_use]
pub const fn new() -> Self {
Self { theme: Theme::DARK }
}
#[must_use]
pub const fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
}
impl PerfRenderer for DefaultPerfRenderer {
fn render(
&mut self,
stats: &FrameStats<FRAME_HISTORY>,
backend: &str,
area: Rect,
surface: &mut Surface<'_>,
) {
if stats.frame_count() == 0 || area.height() == 0 {
return;
}
let mut text = FixedBuf::<96>::new();
let _ = write!(
text,
" {:>3.0}fps {:>4.1}ms min{:>4.1} max{:>4.1} {backend} ",
stats.fps(),
millis(stats.current()),
millis(stats.min()),
millis(stats.max()),
);
let text = text.as_str();
let width = area.width_usize();
let len = text.chars().count();
if len == 0 || len > width {
return;
}
#[allow(clippy::cast_possible_truncation)]
let len_u16 = len as u16;
let x0 = area.left() + (area.width() - len_u16);
let style = Style::new().fg(self.theme.fg).bg(self.theme.panel_bg);
for (i, ch) in text.chars().enumerate() {
#[allow(clippy::cast_possible_truncation)]
let x = x0 + i as u16;
surface.put((x, area.top()), ch, style);
}
}
}
#[cfg(test)]
mod tests {
use retroglyph_core::grid::{Grid, Pos};
use super::*;
#[test]
fn fixed_buf_formats_without_allocating() {
let mut buf = FixedBuf::<8>::new();
let _ = write!(buf, "{:>3}fps", 62);
assert_eq!(buf.as_str(), " 62fps");
}
#[test]
fn fixed_buf_rejects_writes_past_capacity_and_keeps_what_fit() {
let mut buf = FixedBuf::<4>::new();
assert!(write!(buf, "12345").is_err());
assert_eq!(buf.as_str(), "");
}
#[test]
fn default_impl_matches_new() {
assert_eq!(
DefaultPerfRenderer::default().theme,
DefaultPerfRenderer::new().theme
);
}
#[test]
fn default_perf_renderer_is_a_noop_before_the_first_frame() {
let stats = FrameStats::<FRAME_HISTORY>::new();
let area = Rect::new(0, 0, 40, 1);
let mut grid = Grid::new(40, 1);
DefaultPerfRenderer::new().render(
&stats,
"headless",
area,
&mut Surface::new(&mut grid, area, 0),
);
assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
}
#[test]
fn default_perf_renderer_is_a_noop_when_the_readout_does_not_fit() {
let mut stats = FrameStats::<FRAME_HISTORY>::new();
stats.record(core::time::Duration::from_millis(16));
let area = Rect::new(0, 0, 3, 1);
let mut grid = Grid::new(3, 1);
DefaultPerfRenderer::new().render(
&stats,
"headless",
area,
&mut Surface::new(&mut grid, area, 0),
);
assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
}
#[test]
fn default_perf_renderer_shows_fps_ms_min_max_and_backend_right_aligned() {
use alloc::string::String;
let mut stats = FrameStats::<FRAME_HISTORY>::new();
for _ in 0..5 {
stats.record(core::time::Duration::from_millis(16));
}
let area = Rect::new(0, 0, 60, 1);
let mut grid = Grid::new(60, 1);
DefaultPerfRenderer::new().render(
&stats,
"headless",
area,
&mut Surface::new(&mut grid, area, 0),
);
let row: String = (0..60).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
assert!(row.contains("fps"), "{row}");
assert!(row.contains("ms"), "{row}");
assert!(row.contains("min"), "{row}");
assert!(row.contains("max"), "{row}");
assert!(row.contains("headless"), "{row}");
assert_eq!(grid[Pos::new(59, 0)].glyph(), ' ');
assert_ne!(grid[Pos::new(0, 0)].glyph(), 'h');
}
#[test]
fn theme_overrides_the_default_colors() {
let mut stats = FrameStats::<FRAME_HISTORY>::new();
stats.record(core::time::Duration::from_millis(16));
let area = Rect::new(0, 0, 60, 1);
let mut grid = Grid::new(60, 1);
DefaultPerfRenderer::new().theme(Theme::LIGHT).render(
&stats,
"headless",
area,
&mut Surface::new(&mut grid, area, 0),
);
assert_eq!(grid[Pos::new(59, 0)].style().foreground(), Theme::LIGHT.fg);
assert_eq!(
grid[Pos::new(59, 0)].style().background(),
Theme::LIGHT.panel_bg
);
}
}