Skip to main content

twrite_gpui/
fps.rs

1//! Frame-rate HUD for interactive performance testing.
2//!
3//! GPUI (at the pinned revision) ships no FPS widget, so this module
4//! provides a minimal one with zero forced repaints:
5//!
6//! - [`FrameStats`] samples one timestamp per rendered frame and reports
7//!   rolling FPS plus average/worst frame times over a short window.
8//! - [`fps_badge`] renders those stats as a color-coded chip hosts can drop
9//!   into any status bar.
10//!
11//! The editor canvas records a sample on every prepaint
12//! ([`crate::Editor::frame_stats`]), so the number reflects real editor
13//! frames: it updates while interacting (typing, selection drags,
14//! scrolling) and freezes when idle — which is correct, since no frames are
15//! being produced then.
16
17use std::collections::VecDeque;
18use std::time::Instant;
19
20use gpui::{IntoElement, ParentElement, Styled, div, rgb};
21
22/// Number of frame timestamps kept; FPS is computed over the whole window
23/// (~2s at 60fps).
24const WINDOW: usize = 120;
25
26/// Rolling frame-time statistics sampled once per rendered frame.
27#[derive(Debug, Clone, Default)]
28pub struct FrameStats {
29    frames: VecDeque<Instant>,
30}
31
32impl FrameStats {
33    /// Creates empty stats (no samples yet).
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    /// Samples the current time as one rendered frame.
39    pub fn record(&mut self) {
40        self.record_at(Instant::now());
41    }
42
43    /// Samples `now` as one rendered frame.
44    ///
45    /// Takes the timestamp explicitly so tests can inject exact intervals
46    /// and hosts can forward a timestamp they already hold.
47    pub fn record_at(&mut self, now: Instant) {
48        // Timestamps must be monotonic: a clock step backwards would produce
49        // a negative span and a bogus spike, so drop regressions.
50        if self.frames.back().is_some_and(|&last| now < last) {
51            return;
52        }
53        if self.frames.len() >= WINDOW {
54            self.frames.pop_front();
55        }
56        self.frames.push_back(now);
57    }
58
59    /// Number of samples currently in the window.
60    pub fn samples(&self) -> usize {
61        self.frames.len()
62    }
63
64    /// Rolling frames-per-second over the window, or `None` with < 2 samples.
65    pub fn fps(&self) -> Option<f32> {
66        let n = self.frames.len();
67        if n < 2 {
68            return None;
69        }
70        let span = self.span_secs()?;
71        if span <= 0.0 {
72            return None;
73        }
74        Some((n - 1) as f32 / span)
75    }
76
77    /// Mean frame interval in milliseconds, or `None` with < 2 samples.
78    pub fn avg_ms(&self) -> Option<f32> {
79        let n = self.frames.len();
80        if n < 2 {
81            return None;
82        }
83        let span = self.span_secs()?;
84        if span <= 0.0 {
85            return None;
86        }
87        Some(span * 1000.0 / (n - 1) as f32)
88    }
89
90    /// Worst single frame interval in the window in milliseconds.
91    ///
92    /// Useful for selection-jank testing: FPS can look fine on average while
93    /// individual frames blow the 16.6ms budget.
94    pub fn worst_ms(&self) -> Option<f32> {
95        if self.frames.len() < 2 {
96            return None;
97        }
98        self.frames
99            .iter()
100            .zip(self.frames.iter().skip(1))
101            .map(|(a, b)| (*b - *a).as_secs_f32() * 1000.0)
102            .reduce(f32::max)
103    }
104
105    /// Drops all samples (e.g. when opening a new document to test).
106    pub fn clear(&mut self) {
107        self.frames.clear();
108    }
109
110    /// Compact one-line summary (`"60 fps · 16.6 ms avg · 18.1 ms worst"`),
111    /// or `"fps: …"` before enough samples exist.
112    pub fn text(&self) -> String {
113        match (self.fps(), self.avg_ms(), self.worst_ms()) {
114            (Some(fps), Some(avg), Some(worst)) => {
115                format!("{fps:.0} fps · {avg:.1} ms avg · {worst:.1} ms worst")
116            }
117            _ => "fps: …".to_string(),
118        }
119    }
120
121    fn span_secs(&self) -> Option<f32> {
122        let (first, last) = (self.frames.front()?, self.frames.back()?);
123        Some((*last - *first).as_secs_f32())
124    }
125}
126
127/// Renders [`FrameStats`] as a color-coded status-bar chip.
128///
129/// Green at ≥ 50fps, amber at ≥ 25fps, red below — so a selection-drag
130/// regression is visible at a glance. Returns a plain `div`, so hosts can
131/// drop it anywhere (status bars, overlays).
132pub fn fps_badge(stats: &FrameStats) -> impl IntoElement {
133    let color = match stats.fps() {
134        Some(fps) if fps >= 50.0 => rgb(0xa6e3a1),
135        Some(fps) if fps >= 25.0 => rgb(0xf9e2af),
136        Some(_) => rgb(0xf38ba8),
137        None => rgb(0x6c7086),
138    };
139    div()
140        .text_xs()
141        .px_2()
142        .py_0p5()
143        .rounded_md()
144        .bg(rgb(0x313244))
145        .text_color(color)
146        .child(stats.text())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::time::Duration;
153
154    fn stats_at(ms: &[u64]) -> FrameStats {
155        let mut s = FrameStats::new();
156        let t0 = Instant::now();
157        for &m in ms {
158            s.record_at(t0 + Duration::from_millis(m));
159        }
160        s
161    }
162
163    #[test]
164    fn empty_and_singleton_yield_no_reading() {
165        let empty = FrameStats::new();
166        assert_eq!(empty.samples(), 0);
167        assert_eq!(empty.fps(), None);
168        assert_eq!(empty.avg_ms(), None);
169        assert_eq!(empty.worst_ms(), None);
170        assert_eq!(empty.text(), "fps: …");
171
172        let mut one = FrameStats::new();
173        one.record();
174        assert_eq!(one.samples(), 1);
175        assert_eq!(one.fps(), None);
176    }
177
178    #[test]
179    fn steady_60fps_reports_60fps() {
180        // Four frames at exact 16ms intervals: 3 intervals / 48ms = 62.5fps.
181        let s = stats_at(&[0, 16, 32, 48]);
182        assert!((s.fps().unwrap() - 62.5).abs() < 0.01);
183        assert!((s.avg_ms().unwrap() - 16.0).abs() < 0.01);
184        assert!((s.worst_ms().unwrap() - 16.0).abs() < 0.01);
185        assert_eq!(s.text(), "62 fps · 16.0 ms avg · 16.0 ms worst");
186    }
187
188    #[test]
189    fn worst_frame_tracks_the_spike() {
190        // Two smooth frames then one 100ms hitch.
191        let s = stats_at(&[0, 16, 32, 132]);
192        assert!((s.worst_ms().unwrap() - 100.0).abs() < 0.01);
193        // Average dilutes the spike: 132ms / 3 intervals = 44ms.
194        assert!((s.avg_ms().unwrap() - 44.0).abs() < 0.01);
195    }
196
197    #[test]
198    fn window_is_bounded() {
199        let t0 = Instant::now();
200        let mut s = FrameStats::new();
201        for i in 0..(WINDOW + 50) {
202            s.record_at(t0 + Duration::from_millis(i as u64));
203        }
204        assert_eq!(s.samples(), WINDOW);
205        assert!(s.fps().unwrap() > 900.0);
206    }
207
208    #[test]
209    fn clock_regression_is_dropped() {
210        let t0 = Instant::now();
211        let mut s = FrameStats::new();
212        s.record_at(t0);
213        s.record_at(t0 + Duration::from_millis(16));
214        s.record_at(t0); // backwards: ignored
215        assert_eq!(s.samples(), 2);
216    }
217
218    #[test]
219    fn clear_resets_to_no_reading() {
220        let mut s = stats_at(&[0, 16, 32]);
221        assert!(s.fps().is_some());
222        s.clear();
223        assert_eq!(s.samples(), 0);
224        assert_eq!(s.fps(), None);
225    }
226}