//! Release-safe FPS readout — `/debug fps`, `GROK_FPS` on release builds. //! //! The full frame profiler (`render::frame_metrics`, `GROK_FPS`) is compiled //! only in debug/dev builds because it threads per-phase timings //! through `draw_frame`. This HUD measures the one thing that needs no //! pipeline change — the wall-clock duration of the whole `draw_frame` call //! (render + flush + writer handoff) — so it compiles into release builds //! behind a runtime toggle (the scroll-debug HUD precedent) and profiles the //! production render path with zero fidelity gap. //! //! `GROK_FPS` ownership: in debug/dev builds the env feeds `FrameMetrics` as //! always and this HUD stays toggle-only (no double overlay); on release //! binaries — where that overlay does not exist — the same env enables this //! HUD from startup, so `GROK_FPS=1` is never a silent no-op //! ([`HONORS_GROK_FPS_ENV`]). //! //! "fps" here is render throughput (1 / mean frame cost), not paint //! frequency: the pager draws on demand, so an idle UI paints nothing and a //! busy one is bounded by this number. //! //! Invariant (shared with the scroll HUD): pure observation. Disabled cost //! is one bool check per frame; rendering only paints buffer cells. use super::debug_style; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use std::collections::VecDeque; use std::time::{Duration, Instant}; /// Frame-duration ring buffer capacity (~4s at 30fps). const SAMPLE_CAP: usize = 120; /// Overlay text refresh cadence; avoids re-sorting/formatting every frame. const REFRESH: Duration = Duration::from_millis(250); /// Panel width in cells; each line is padded/truncated to this. const PANEL_WIDTH: u16 = 32; /// Whether this HUD owns the `GROK_FPS` env gate: only where the dev /// `FrameMetrics` overlay is compiled out. In debug/dev builds the env keeps /// feeding that overlay alone. const HONORS_GROK_FPS_ENV: bool = true; /// Runtime state for the FPS HUD. `GROK_FPS` enables it at startup on /// release binaries ([`HONORS_GROK_FPS_ENV`]); `/debug fps` toggles it /// live everywhere. Deliberately NOT a settings-registry entry: it is a /// diagnostic, not a preference to persist. pub struct FpsHud { enabled: bool, samples: VecDeque, /// Cached stats line, rewritten at most every [`REFRESH`]. body: String, last_refresh: Option, } impl Default for FpsHud { fn default() -> Self { Self::new() } } impl FpsHud { pub fn new() -> Self { Self::with_env(std::env::var("GROK_FPS").ok()) } /// `env` is the raw `GROK_FPS` value; the truthiness rule (nonempty and /// not `"0"`) matches `FrameMetrics` and `GROK_SCROLL_DEBUG`. fn with_env(env: Option) -> Self { let env_on = HONORS_GROK_FPS_ENV && env.is_some_and(|v| !v.is_empty() && v != "0"); Self { enabled: env_on, samples: VecDeque::with_capacity(SAMPLE_CAP), body: String::new(), last_refresh: None, } } /// Whether the HUD is currently enabled. pub fn enabled(&self) -> bool { self.enabled } /// `/debug fps` runtime toggle. pub fn toggle(&mut self) { self.enabled = !self.enabled; self.samples.clear(); self.body.clear(); self.last_refresh = None; } /// Rows the overlay occupies when enabled (for stacking overlays). pub fn overlay_height(&self) -> u16 { if self.enabled { 2 } else { 0 } } /// Record one frame's `draw_frame` wall duration. pub fn record(&mut self, frame: Duration) { if !self.enabled { return; } if self.samples.len() >= SAMPLE_CAP { self.samples.pop_front(); } self.samples.push_back(frame); } /// Owned per-frame render params (`None` unless enabled), assembled by /// `AppView::draw` BEFORE the frame closure — the `ScrollDebugPanel` /// pattern. `top_offset` leaves rows for overlays stacked above. pub fn overlay(&mut self, top_offset: u16) -> Option { if !self.enabled { return None; } if self.last_refresh.is_none_or(|at| at.elapsed() >= REFRESH) { self.body = format_stats(&self.samples); self.last_refresh = Some(Instant::now()); } Some(FpsOverlay { body: self.body.clone(), top_offset, }) } } /// Mean/percentile line from the ring buffer; placeholder before samples. fn format_stats(samples: &VecDeque) -> String { if samples.is_empty() { return "fps:- p50:- p95:-".to_string(); } let mut ms: Vec = samples.iter().map(|d| d.as_secs_f64() * 1000.0).collect(); ms.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let mean_ms = ms.iter().sum::() / ms.len() as f64; let fps = if mean_ms > 1e-6 { 1000.0 / mean_ms } else { 0.0 }; let p50 = percentile(&ms, 50.0); let p95 = percentile(&ms, 95.0); format!("fps:{fps:.0} p50:{p50:.1}ms p95:{p95:.1}ms") } /// Linear-interpolation percentile from a sorted slice. fn percentile(sorted: &[f64], pct: f64) -> f64 { if sorted.is_empty() { return 0.0; } let rank = (pct / 100.0) * (sorted.len() - 1) as f64; let lower = rank.floor() as usize; if lower + 1 >= sorted.len() { return sorted[lower]; } let frac = rank - lower as f64; sorted[lower] + (sorted[lower + 1] - sorted[lower]) * frac } /// Owned render params for one frame (title + stats line, top-right). pub struct FpsOverlay { body: String, /// Rows left free for overlays above (the dev `GROK_FPS` line). pub top_offset: u16, } impl FpsOverlay { /// Paint the two-line panel in the top-right corner of `area`, in the /// shared theme-agnostic debug chrome (every cell, padding included). pub fn render(&self, area: Rect, buf: &mut Buffer) { debug_style::render_panel( area, buf, self.top_offset, PANEL_WIDTH, &["fps debug (/debug fps)", self.body.as_str()], ); } } #[cfg(test)] mod tests { use super::*; use ratatui::style::{Color, Modifier, Style}; #[test] fn disabled_by_default_and_toggle_round_trips() { let mut hud = FpsHud::with_env(None); assert!(!hud.enabled()); assert_eq!(hud.overlay_height(), 0); assert!(hud.overlay(0).is_none()); hud.toggle(); assert!(hud.enabled()); assert_eq!(hud.overlay_height(), 2); assert!(hud.overlay(0).is_some()); hud.toggle(); assert!(!hud.enabled()); assert!(hud.overlay(0).is_none()); } /// Default test builds compile without dev instrumentation — release-shaped /// for this gate — so a truthy env must construct enabled. A /// debug/dev test build hands the env to `FrameMetrics` instead; /// asserting against [`HONORS_GROK_FPS_ENV`] keeps the test true under /// both cfgs (the dev half is pinned by the constant's shape, the same /// limitation as the `/debug` visibility test). #[test] fn grok_fps_env_enables_hud_where_dev_overlay_absent() { for truthy in ["1", "full", " "] { assert_eq!( FpsHud::with_env(Some(truthy.into())).enabled(), HONORS_GROK_FPS_ENV, "GROK_FPS={truthy:?} must track the env-gate owner" ); } for falsy in [None, Some(String::new()), Some("0".into())] { assert!(!FpsHud::with_env(falsy).enabled()); } } #[test] fn record_caps_ring_buffer_and_toggle_clears_stale_samples() { let mut hud = FpsHud::with_env(None); hud.toggle(); for _ in 0..SAMPLE_CAP + 30 { hud.record(Duration::from_millis(10)); } assert_eq!(hud.samples.len(), SAMPLE_CAP); hud.toggle(); hud.toggle(); assert!(hud.samples.is_empty()); assert!( hud.overlay(0).unwrap().body.contains("fps:-"), "fresh enablement must show placeholders" ); } #[test] fn stats_line_reports_mean_fps_and_percentiles() { let mut hud = FpsHud::with_env(None); hud.toggle(); for _ in 0..100 { hud.record(Duration::from_millis(10)); } let overlay = hud.overlay(0).expect("enabled"); assert_eq!(overlay.body, "fps:100 p50:10.0ms p95:10.0ms"); } #[test] fn record_is_a_noop_while_disabled() { let mut hud = FpsHud::with_env(None); hud.record(Duration::from_millis(10)); assert!(hud.samples.is_empty()); } /// Every cell of the panel rect — trailing padding included — must carry /// the explicit debug chrome, not the theme style underneath. #[test] fn render_paints_theme_agnostic_style_over_every_panel_cell() { let area = Rect::new(0, 0, 60, 6); let mut buf = Buffer::empty(area); let theme = Style::default() .fg(Color::Rgb(228, 228, 228)) .bg(Color::Rgb(3, 3, 4)) .add_modifier(Modifier::ITALIC); buf.set_style(area, theme); let overlay = FpsOverlay { body: "fps:100 p50:10.0ms p95:10.0ms".to_string(), top_offset: 1, }; overlay.render(area, &mut buf); let x0 = area.width - PANEL_WIDTH; for y in 1..3u16 { for x in x0..area.width { let cell = &buf[(x, y)]; assert_eq!(cell.bg, Color::Black, "cell ({x},{y}) bg"); assert!( cell.fg == Color::White || cell.fg == Color::Yellow, "cell ({x},{y}) fg must be debug chrome, got {:?}", cell.fg ); assert_eq!( cell.modifier, Modifier::empty(), "cell ({x},{y}) must shed themed modifiers" ); } } assert_eq!(buf[(0, 1)].bg, Color::Rgb(3, 3, 4)); assert_eq!(buf[(area.width - 1, 0)].bg, Color::Rgb(3, 3, 4)); } }