use alloc::format;
use retroglyph_core::app::Frame;
use retroglyph_core::color::Style;
use retroglyph_core::frames::FrameStats;
use retroglyph_core::grid::Rect;
use super::{AnimatedWidget, Panel, Sparkline, Text, Widget};
use crate::Surface;
use crate::theme::Theme;
#[derive(Clone, Copy, Debug)]
pub struct PerfOverlay<'a, const N: usize = 120> {
stats: &'a FrameStats<N>,
backend: &'a str,
title: &'a str,
metrics: &'a [(&'a str, &'a str)],
border_style: Style,
fill_style: Style,
text_style: Style,
sparkline_style: Style,
}
impl<'a, const N: usize> PerfOverlay<'a, N> {
#[must_use]
pub fn new(stats: &'a FrameStats<N>) -> Self {
Self {
stats,
backend: "",
title: "perf",
metrics: &[],
border_style: Style::new(),
fill_style: Style::new(),
text_style: Style::new(),
sparkline_style: Style::new(),
}
.theme(Theme::DARK)
}
#[must_use]
pub const fn backend(mut self, backend: &'a str) -> Self {
self.backend = backend;
self
}
#[must_use]
pub const fn title(mut self, title: &'a str) -> Self {
self.title = title;
self
}
#[must_use]
pub const fn metrics(mut self, metrics: &'a [(&'a str, &'a str)]) -> Self {
self.metrics = metrics;
self
}
#[must_use]
pub const fn text_style(mut self, style: Style) -> Self {
self.text_style = style;
self
}
#[must_use]
pub const fn sparkline_style(mut self, style: Style) -> Self {
self.sparkline_style = style;
self
}
#[must_use]
pub fn theme(mut self, theme: Theme) -> Self {
self.border_style = Style::new().fg(theme.border).bg(theme.title_bg);
self.fill_style = Style::new().bg(theme.panel_bg);
self.text_style = Style::new().fg(theme.fg).bg(theme.panel_bg);
self.sparkline_style = Style::new().fg(theme.accent);
self
}
}
impl<const N: usize> Widget for PerfOverlay<'_, N> {
fn render(&self, surface: &mut Surface<'_>) {
let area = surface.area();
if area.width() < 4 || area.height() < 3 {
return;
}
Panel::new()
.title(self.title)
.border_style(self.border_style)
.fill_style(self.fill_style)
.render(surface);
let inner = Rect::new(
area.left() + 1,
area.top() + 1,
area.width() - 2,
area.height() - 2,
);
let mut y = inner.top();
let row = |y: u16| Rect::new(inner.left(), y, inner.width(), 1);
if y < inner.bottom() {
let readout = if self.backend.is_empty() {
format!(
"{:>3.0}fps {:>4.1}ms min {:>4.1} max {:>4.1}",
self.stats.fps(),
millis(self.stats.current()),
millis(self.stats.min()),
millis(self.stats.max()),
)
} else {
format!(
"{:>3.0}fps {:>4.1}ms min {:>4.1} max {:>4.1} {}",
self.stats.fps(),
millis(self.stats.current()),
millis(self.stats.min()),
millis(self.stats.max()),
self.backend,
)
};
Text::new(&readout)
.style(self.text_style)
.render(&mut surface.scope(row(y)));
y += 1;
}
for (label, value) in self.metrics {
if y >= inner.bottom() {
break;
}
let line = format!("{label}: {value}");
Text::new(&line)
.style(self.text_style)
.render(&mut surface.scope(row(y)));
y += 1;
}
if y < inner.bottom() {
let mut samples = [0.0f32; N];
let mut len = 0;
for duration in self.stats.samples() {
if len >= N {
break;
}
samples[len] = millis(duration);
len += 1;
}
Sparkline::new(&samples[..len])
.style(self.sparkline_style)
.render(&mut surface.scope(row(y)));
}
}
}
fn millis(duration: core::time::Duration) -> f32 {
duration.as_secs_f32() * 1000.0
}
#[derive(Clone, Copy, Debug)]
pub struct AnimatedPerfOverlay<'a, const N: usize = 120> {
backend: &'a str,
title: &'a str,
metrics: &'a [(&'a str, &'a str)],
border_style: Style,
fill_style: Style,
text_style: Style,
sparkline_style: Style,
}
impl<const N: usize> Default for AnimatedPerfOverlay<'_, N> {
fn default() -> Self {
Self::new()
}
}
impl<'a, const N: usize> AnimatedPerfOverlay<'a, N> {
#[must_use]
pub fn new() -> Self {
Self {
backend: "",
title: "perf",
metrics: &[],
border_style: Style::new(),
fill_style: Style::new(),
text_style: Style::new(),
sparkline_style: Style::new(),
}
.theme(Theme::DARK)
}
#[must_use]
pub const fn backend(mut self, backend: &'a str) -> Self {
self.backend = backend;
self
}
#[must_use]
pub const fn title(mut self, title: &'a str) -> Self {
self.title = title;
self
}
#[must_use]
pub const fn metrics(mut self, metrics: &'a [(&'a str, &'a str)]) -> Self {
self.metrics = metrics;
self
}
#[must_use]
pub const fn text_style(mut self, style: Style) -> Self {
self.text_style = style;
self
}
#[must_use]
pub const fn sparkline_style(mut self, style: Style) -> Self {
self.sparkline_style = style;
self
}
#[must_use]
pub fn theme(mut self, theme: Theme) -> Self {
self.border_style = Style::new().fg(theme.border).bg(theme.title_bg);
self.fill_style = Style::new().bg(theme.panel_bg);
self.text_style = Style::new().fg(theme.fg).bg(theme.panel_bg);
self.sparkline_style = Style::new().fg(theme.accent);
self
}
}
impl<const N: usize> AnimatedWidget for AnimatedPerfOverlay<'_, N> {
type State = FrameStats<N>;
fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, frame: &Frame) {
state.record(frame.delta);
let overlay = PerfOverlay {
stats: &*state,
backend: self.backend,
title: self.title,
metrics: self.metrics,
border_style: self.border_style,
fill_style: self.fill_style,
text_style: self.text_style,
sparkline_style: self.sparkline_style,
};
Widget::render(&overlay, surface);
}
}
#[cfg(test)]
mod tests {
use alloc::string::String;
use core::time::Duration;
use retroglyph_core::grid::{Grid, Pos};
use super::*;
fn settled<const N: usize>(millis: u64, frames: usize) -> FrameStats<N> {
let mut stats = FrameStats::new();
for _ in 0..frames {
stats.record(Duration::from_millis(millis));
}
stats
}
#[test]
fn draws_a_readout_border_and_title() {
let stats = settled::<120>(16, 5);
let area = Rect::new(0, 0, 40, 5);
let mut grid = Grid::new(40, 5);
PerfOverlay::new(&stats).render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].glyph(), '┌');
let title_row: String = (0..40).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
assert!(title_row.contains("perf"));
let readout_row: String = (0..40).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
assert!(readout_row.contains("fps"));
assert!(readout_row.contains("ms"));
assert!(readout_row.contains("min"));
}
#[test]
fn backend_label_is_appended_when_set() {
let stats = settled::<120>(16, 5);
let area = Rect::new(0, 0, 70, 5);
let mut grid = Grid::new(70, 5);
PerfOverlay::new(&stats)
.backend("software")
.render(&mut Surface::new(&mut grid, area, 0));
let readout_row: String = (0..70).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
assert!(readout_row.contains("software"));
}
#[test]
fn backend_label_omitted_when_empty() {
let stats = settled::<120>(16, 5);
let area = Rect::new(0, 0, 40, 5);
let mut grid = Grid::new(40, 5);
PerfOverlay::new(&stats).render(&mut Surface::new(&mut grid, area, 0));
let readout_row: String = (0..40).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
assert!(!readout_row.contains("softw"));
}
#[test]
fn extra_metric_rows_are_drawn_below_the_readout() {
let stats = settled::<120>(16, 5);
let area = Rect::new(0, 0, 40, 6);
let mut grid = Grid::new(40, 6);
PerfOverlay::new(&stats)
.metrics(&[("res", "80x24"), ("vsync", "on")])
.render(&mut Surface::new(&mut grid, area, 0));
let row2: String = (0..40).map(|x| grid[Pos::new(x, 2)].glyph()).collect();
let row3: String = (0..40).map(|x| grid[Pos::new(x, 3)].glyph()).collect();
assert!(row2.contains("res") && row2.contains("80x24"));
assert!(row3.contains("vsync") && row3.contains("on"));
}
#[test]
fn metrics_beyond_available_height_are_dropped_not_overflowed() {
let stats = settled::<120>(16, 5);
let area = Rect::new(0, 0, 40, 3);
let mut grid = Grid::new(40, 3);
PerfOverlay::new(&stats)
.metrics(&[("res", "80x24")])
.render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 2)].glyph(), 'â””', "bottom border intact");
}
#[test]
fn too_small_is_a_no_op() {
let stats = settled::<120>(16, 5);
let area = Rect::new(0, 0, 2, 2);
let mut grid = Grid::new(2, 2);
PerfOverlay::new(&stats).render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
}
#[test]
fn theme_maps_named_roles_onto_border_fill_and_text() {
let stats = settled::<120>(16, 5);
let area = Rect::new(0, 0, 40, 5);
let mut grid = Grid::new(40, 5);
PerfOverlay::new(&stats)
.theme(Theme::DARK)
.render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(
grid[Pos::new(0, 0)].style().foreground(),
Theme::DARK.border
);
assert_eq!(grid[Pos::new(1, 1)].style().foreground(), Theme::DARK.fg);
}
fn frame(delta_ms: u64) -> Frame {
Frame::new(Duration::from_millis(delta_ms), 0)
}
#[test]
fn animated_render_records_before_drawing() {
let area = Rect::new(0, 0, 40, 5);
let mut grid = Grid::new(40, 5);
let mut stats = FrameStats::<120>::new();
assert_eq!(stats.frame_count(), 0, "nothing recorded yet");
AnimatedPerfOverlay::new().render(
&mut Surface::new(&mut grid, area, 0),
&mut stats,
&frame(16),
);
assert_eq!(
stats.frame_count(),
1,
"render should have recorded a sample"
);
let readout_row: String = (0..40).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
assert!(readout_row.contains("fps"), "{readout_row}");
}
#[test]
fn animated_render_matches_perf_overlay_drawn_from_the_same_post_record_stats() {
let area = Rect::new(0, 0, 60, 6);
let mut animated_grid = Grid::new(60, 6);
let mut stats = FrameStats::<120>::new();
AnimatedPerfOverlay::new()
.backend("software")
.metrics(&[("res", "80x24")])
.render(
&mut Surface::new(&mut animated_grid, area, 0),
&mut stats,
&frame(16),
);
let mut expected_grid = Grid::new(60, 6);
PerfOverlay::new(&stats)
.backend("software")
.metrics(&[("res", "80x24")])
.render(&mut Surface::new(&mut expected_grid, area, 0));
for y in 0..6 {
let animated_row: String = (0..60)
.map(|x| animated_grid[Pos::new(x, y)].glyph())
.collect();
let expected_row: String = (0..60)
.map(|x| expected_grid[Pos::new(x, y)].glyph())
.collect();
assert_eq!(animated_row, expected_row, "row {y}");
}
}
#[test]
fn animated_render_records_a_sample_every_call() {
let area = Rect::new(0, 0, 40, 5);
let mut grid = Grid::new(40, 5);
let mut stats = FrameStats::<120>::new();
for _ in 0..5 {
AnimatedPerfOverlay::new().render(
&mut Surface::new(&mut grid, area, 0),
&mut stats,
&frame(16),
);
}
assert_eq!(stats.frame_count(), 5);
}
}