use crate::app::{App, Flow, Frame};
use crate::backend::Backend;
use crate::color::Color;
use crate::event::{Event, KeyCode, KeyEventKind};
use crate::frame_stats::FrameStats;
use crate::grid::{Rect, Size};
use crate::style::Style;
use crate::surface::Surface;
use crate::terminal::Terminal;
use alloc::boxed::Box;
use alloc::vec::Vec;
use core::fmt::{self, Write as _};
pub const FRAME_HISTORY: usize = 120;
pub const DEFAULT_LAYER: u8 = 2;
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, Default)]
pub struct DefaultPerfRenderer;
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(Color::Rgb {
r: 0xE6,
g: 0xE6,
b: 0xE6,
})
.bg(Color::Rgb {
r: 0x10,
g: 0x10,
b: 0x14,
});
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);
}
}
}
#[must_use]
pub fn default_is_toggle_key(event: &Event) -> bool {
let Event::Key(key) = event else {
return false;
};
key.kind == KeyEventKind::Press && matches!(key.code, KeyCode::Char('`') | KeyCode::F(1))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum PerfOverlayMode {
Off,
Compact,
Full,
}
impl PerfOverlayMode {
#[must_use]
const fn next(self, has_full: bool) -> Self {
match self {
Self::Off => Self::Compact,
Self::Compact if has_full => Self::Full,
Self::Compact | Self::Full => Self::Off,
}
}
}
struct FullMode {
renderer: Box<dyn PerfRenderer>,
size: Size,
}
pub struct PerfOverlayApp<A, R = DefaultPerfRenderer> {
inner: A,
stats: FrameStats<FRAME_HISTORY>,
backend: &'static str,
renderer: R,
mode: PerfOverlayMode,
full: Option<FullMode>,
layer: u8,
size: Size,
toggle_key: fn(&Event) -> bool,
passthrough: Vec<Event>,
}
impl<A> PerfOverlayApp<A, DefaultPerfRenderer> {
#[must_use]
pub fn new(inner: A, backend: &'static str) -> Self {
Self::with_renderer(inner, backend, DefaultPerfRenderer)
}
}
impl<A, F> PerfOverlayApp<A, F>
where
F: FnMut(&FrameStats<FRAME_HISTORY>, &str, Rect, &mut Surface<'_>),
{
#[must_use]
pub fn with_closure(inner: A, backend: &'static str, renderer: F) -> Self {
Self::with_renderer(inner, backend, renderer)
}
}
impl<A, R: PerfRenderer> PerfOverlayApp<A, R> {
#[must_use]
pub fn with_renderer(inner: A, backend: &'static str, renderer: R) -> Self {
Self {
inner,
stats: FrameStats::new(),
backend,
renderer,
mode: PerfOverlayMode::Compact,
full: None,
layer: DEFAULT_LAYER,
size: Size::new(64, 1),
toggle_key: default_is_toggle_key,
passthrough: Vec::new(),
}
}
#[must_use]
pub fn cycle_with<F>(mut self, size: Size, renderer: F) -> Self
where
F: FnMut(&FrameStats<FRAME_HISTORY>, &str, Rect, &mut Surface<'_>) + 'static,
{
self.full = Some(FullMode {
renderer: Box::new(renderer),
size,
});
self
}
#[must_use]
pub const fn visible(mut self, visible: bool) -> Self {
self.mode = if visible {
PerfOverlayMode::Compact
} else {
PerfOverlayMode::Off
};
self
}
#[must_use]
pub const fn layer(mut self, layer: u8) -> Self {
self.layer = layer;
self
}
#[must_use]
pub const fn size(mut self, size: Size) -> Self {
self.size = size;
self
}
#[must_use]
pub const fn toggle_key(mut self, matches: fn(&Event) -> bool) -> Self {
self.toggle_key = matches;
self
}
#[must_use]
pub const fn stats(&self) -> &FrameStats<FRAME_HISTORY> {
&self.stats
}
#[must_use]
pub const fn is_visible(&self) -> bool {
!matches!(self.mode, PerfOverlayMode::Off)
}
pub const fn set_visible(&mut self, visible: bool) {
self.mode = if visible {
PerfOverlayMode::Compact
} else {
PerfOverlayMode::Off
};
}
#[must_use]
pub const fn mode(&self) -> PerfOverlayMode {
self.mode
}
pub const fn set_mode(&mut self, mode: PerfOverlayMode) {
self.mode = mode;
}
pub const fn toggle(&mut self) {
self.mode = self.mode.next(self.full.is_some());
}
#[must_use]
pub const fn inner(&self) -> &A {
&self.inner
}
pub const fn inner_mut(&mut self) -> &mut A {
&mut self.inner
}
#[must_use]
pub fn into_inner(self) -> A {
self.inner
}
fn area(size: Size, term_width: u16, term_height: u16) -> Rect {
let width = size.width().min(term_width);
let height = size.height().min(term_height);
Rect::new(term_width - width, 0, width, height)
}
}
impl<B, A, R> App<B> for PerfOverlayApp<A, R>
where
B: Backend,
A: App<B>,
R: PerfRenderer,
{
fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow {
self.stats.record(frame.delta);
let mut toggled = false;
self.passthrough.clear();
for event in term.drain_events() {
if (self.toggle_key)(&event) {
toggled = true;
} else {
self.passthrough.push(event);
}
}
if toggled {
self.mode = self.mode.next(self.full.is_some());
}
for event in self.passthrough.drain(..) {
term.backend_mut().push_event(event);
}
let mut flow = self.inner.update(term, frame);
if toggled && flow == Flow::Idle {
flow = Flow::Continue;
}
let drawn = match self.mode {
PerfOverlayMode::Off => None,
PerfOverlayMode::Compact => Some((self.size, None)),
PerfOverlayMode::Full => self.full.as_mut().map(|full| (full.size, Some(full))),
};
if let Some((size, full)) = drawn {
let (term_width, term_height) = {
let surface = term.surface();
(surface.width(), surface.height())
};
let area = Self::area(size, term_width, term_height);
if area.width() > 0 && area.height() > 0 {
let mut base = term.surface();
let mut surface = base.on_layer(self.layer);
match full {
None => self
.renderer
.render(&self.stats, self.backend, area, &mut surface),
Some(full) => {
full.renderer
.render(&self.stats, self.backend, area, &mut surface);
}
}
}
}
flow
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::Headless;
use crate::event::{KeyEvent, KeyModifiers};
struct CountingApp {
updates: u32,
exit_at: u64,
}
impl<B: Backend> App<B> for CountingApp {
fn update(&mut self, _term: &mut Terminal<B>, frame: &Frame) -> Flow {
self.updates += 1;
if frame.frame >= self.exit_at {
Flow::Exit
} else {
Flow::Continue
}
}
}
fn frame(n: u64) -> Frame {
Frame {
delta: core::time::Duration::from_millis(16),
frame: n,
}
}
#[test]
fn default_is_toggle_key_matches_backtick_and_f1_presses_only() {
for code in [KeyCode::Char('`'), KeyCode::F(1)] {
let press = Event::Key(KeyEvent::new(code, KeyModifiers::NONE));
assert!(default_is_toggle_key(&press));
}
let release = Event::Key(KeyEvent::with_kind(
KeyCode::Char('`'),
KeyModifiers::NONE,
KeyEventKind::Release,
));
assert!(!default_is_toggle_key(&release));
assert!(!default_is_toggle_key(&Event::Close));
let other = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(!default_is_toggle_key(&other));
}
#[test]
fn forwards_updates_and_records_frame_stats() {
let mut term = Terminal::new(Headless::new(40, 5));
let mut overlay = PerfOverlayApp::new(
CountingApp {
updates: 0,
exit_at: 2,
},
"headless",
);
for n in 0..3 {
let flow = App::update(&mut overlay, &mut term, &frame(n));
if flow == Flow::Exit {
break;
}
}
assert_eq!(overlay.inner().updates, 3);
assert_eq!(overlay.stats().frame_count(), 3);
}
#[test]
fn swallows_toggle_key_and_passes_other_input_through() {
let mut term = Terminal::new(Headless::new(40, 5));
term.backend_mut().push_event(Event::Key(KeyEvent::new(
KeyCode::Char('`'),
KeyModifiers::NONE,
)));
term.backend_mut().push_event(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::NONE,
)));
let mut overlay = PerfOverlayApp::new(
CountingApp {
updates: 0,
exit_at: 100,
},
"headless",
);
assert!(overlay.is_visible());
let _ = App::update(&mut overlay, &mut term, &frame(0));
assert!(!overlay.is_visible(), "backtick should have toggled off");
let remaining: Vec<Event> = term.drain_events().collect();
assert_eq!(
remaining,
vec![Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::NONE
))]
);
}
#[test]
fn toggling_while_idle_upgrades_flow_to_continue() {
struct AlwaysIdle;
impl<B: Backend> App<B> for AlwaysIdle {
fn update(&mut self, _term: &mut Terminal<B>, _frame: &Frame) -> Flow {
Flow::Idle
}
}
let mut term = Terminal::new(Headless::new(40, 5));
term.backend_mut().push_event(Event::Key(KeyEvent::new(
KeyCode::Char('`'),
KeyModifiers::NONE,
)));
let mut overlay = PerfOverlayApp::new(AlwaysIdle, "headless");
let flow = App::update(&mut overlay, &mut term, &frame(0));
assert_eq!(
flow,
Flow::Continue,
"toggle should upgrade Idle to Continue"
);
let flow = App::update(&mut overlay, &mut term, &frame(1));
assert_eq!(flow, Flow::Idle);
}
#[test]
fn hidden_overlay_draws_nothing() {
let mut term = Terminal::new(Headless::new(40, 5));
let mut overlay = PerfOverlayApp::new(
CountingApp {
updates: 0,
exit_at: 100,
},
"headless",
)
.visible(false);
let _ = App::update(&mut overlay, &mut term, &frame(0));
term.present().expect("present");
assert!(
!term.backend().format_view().contains("fps"),
"hidden overlay should not draw"
);
}
#[test]
fn visible_overlay_draws_a_readout_after_the_first_frame() {
let mut term = Terminal::new(Headless::new(80, 5));
let mut overlay = PerfOverlayApp::new(
CountingApp {
updates: 0,
exit_at: 100,
},
"headless",
);
let _ = App::update(&mut overlay, &mut term, &frame(0));
term.present().expect("present");
let view = term.backend().format_view();
assert!(view.contains("fps"), "{view}");
assert!(view.contains("headless"), "{view}");
}
#[test]
fn custom_renderer_closure_is_used_instead_of_the_default() {
let mut term = Terminal::new(Headless::new(40, 5));
let mut overlay = PerfOverlayApp::with_closure(
CountingApp {
updates: 0,
exit_at: 100,
},
"headless",
|_stats, backend, area, surface| {
surface.print((area.left(), area.top()), backend, Style::new());
},
);
let _ = App::update(&mut overlay, &mut term, &frame(0));
term.present().expect("present");
let view = term.backend().format_view();
assert!(view.contains("headless"), "{view}");
assert!(
!view.contains("fps"),
"custom renderer should replace the default, {view}"
);
}
#[test]
fn without_cycle_with_toggle_only_visits_off_and_compact() {
let mut overlay = PerfOverlayApp::new(
CountingApp {
updates: 0,
exit_at: 100,
},
"headless",
);
assert_eq!(overlay.mode(), PerfOverlayMode::Compact);
overlay.toggle();
assert_eq!(overlay.mode(), PerfOverlayMode::Off);
overlay.toggle();
assert_eq!(overlay.mode(), PerfOverlayMode::Compact);
}
#[test]
fn cycle_with_makes_toggle_visit_off_compact_full() {
let mut overlay = PerfOverlayApp::new(
CountingApp {
updates: 0,
exit_at: 100,
},
"headless",
)
.cycle_with(Size::new(40, 6), |_stats, _backend, _area, _surface| {});
assert_eq!(overlay.mode(), PerfOverlayMode::Compact, "starts visible");
overlay.toggle();
assert_eq!(overlay.mode(), PerfOverlayMode::Full);
overlay.toggle();
assert_eq!(overlay.mode(), PerfOverlayMode::Off);
overlay.toggle();
assert_eq!(overlay.mode(), PerfOverlayMode::Compact, "cycle repeats");
}
#[test]
fn set_mode_jumps_directly_to_full() {
let mut overlay = PerfOverlayApp::new(
CountingApp {
updates: 0,
exit_at: 100,
},
"headless",
)
.cycle_with(Size::new(40, 6), |_stats, _backend, _area, _surface| {});
overlay.set_mode(PerfOverlayMode::Full);
assert_eq!(overlay.mode(), PerfOverlayMode::Full);
assert!(overlay.is_visible());
}
#[test]
fn full_mode_draws_with_its_own_renderer_at_its_own_size() {
let mut term = Terminal::new(Headless::new(60, 10));
let mut overlay = PerfOverlayApp::new(
CountingApp {
updates: 0,
exit_at: 100,
},
"headless",
)
.cycle_with(Size::new(30, 5), |_stats, _backend, area, surface| {
surface.print((area.left(), area.top()), "FULLMODE", Style::new());
});
let _ = App::update(&mut overlay, &mut term, &frame(0));
term.present().expect("present");
let view = term.backend().format_view();
assert!(view.contains("fps"), "{view}");
assert!(!view.contains("FULLMODE"), "{view}");
overlay.set_mode(PerfOverlayMode::Full);
let _ = App::update(&mut overlay, &mut term, &frame(1));
term.present().expect("present");
let view = term.backend().format_view();
assert!(view.contains("FULLMODE"), "{view}");
}
#[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_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 = crate::grid::Grid::new(40, 1);
DefaultPerfRenderer.render(
&stats,
"headless",
area,
&mut Surface::new(&mut grid, area, 0),
);
assert_eq!(grid[crate::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 = crate::grid::Grid::new(3, 1);
DefaultPerfRenderer.render(
&stats,
"headless",
area,
&mut Surface::new(&mut grid, area, 0),
);
assert_eq!(grid[crate::grid::Pos::new(0, 0)].glyph(), ' ');
}
#[test]
fn default_perf_renderer_shows_fps_ms_min_max_and_backend_right_aligned() {
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 = crate::grid::Grid::new(60, 1);
DefaultPerfRenderer.render(
&stats,
"headless",
area,
&mut Surface::new(&mut grid, area, 0),
);
let row: String = (0..60)
.map(|x| grid[crate::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[crate::grid::Pos::new(59, 0)].glyph(), ' ');
assert_ne!(grid[crate::grid::Pos::new(0, 0)].glyph(), 'h');
}
#[test]
fn area_places_the_overlay_flush_against_the_top_right_corner() {
let area =
PerfOverlayApp::<CountingApp, DefaultPerfRenderer>::area(Size::new(20, 3), 80, 24);
assert_eq!(area, Rect::new(60, 0, 20, 3));
}
#[test]
fn area_clamps_to_a_terminal_smaller_than_the_requested_size() {
let area =
PerfOverlayApp::<CountingApp, DefaultPerfRenderer>::area(Size::new(20, 3), 10, 2);
assert_eq!(area, Rect::new(0, 0, 10, 2));
}
#[test]
fn toggle_key_can_be_overridden() {
let mut term = Terminal::new(Headless::new(40, 5));
term.backend_mut().push_event(Event::Key(KeyEvent::new(
KeyCode::Char('p'),
KeyModifiers::NONE,
)));
let mut overlay = PerfOverlayApp::new(
CountingApp {
updates: 0,
exit_at: 100,
},
"headless",
)
.toggle_key(|event| {
matches!(
event,
Event::Key(k) if k.code == KeyCode::Char('p') && k.kind == KeyEventKind::Press
)
});
assert!(overlay.is_visible());
let _ = App::update(&mut overlay, &mut term, &frame(0));
assert!(
!overlay.is_visible(),
"the overridden toggle key ('p') should have cycled the overlay off"
);
term.backend_mut().push_event(Event::Key(KeyEvent::new(
KeyCode::Char('`'),
KeyModifiers::NONE,
)));
let _ = App::update(&mut overlay, &mut term, &frame(1));
assert!(
!overlay.is_visible(),
"backtick should no longer be the toggle key"
);
}
#[test]
fn inner_accessors_and_into_inner_round_trip() {
let mut overlay = PerfOverlayApp::new(
CountingApp {
updates: 3,
exit_at: 100,
},
"headless",
);
assert_eq!(overlay.inner().updates, 3);
overlay.inner_mut().updates = 7;
assert_eq!(overlay.inner().updates, 7);
assert_eq!(overlay.into_inner().updates, 7);
}
}