225 lines
8.3 KiB
Rust
225 lines
8.3 KiB
Rust
use std::{collections::VecDeque, time::Duration};
|
|
|
|
use gpui::{
|
|
App, Bounds, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
|
Styled, Window, canvas, div, fill, point, size,
|
|
};
|
|
use web_time::Instant;
|
|
|
|
use crate::{
|
|
theme::{ActiveTheme, Radius, TextSize},
|
|
typography::tabular,
|
|
};
|
|
|
|
/// A frame's budget at 60 Hz.
|
|
const BUDGET: Duration = Duration::from_micros(16_667);
|
|
/// How far back frames count.
|
|
const SPAN: Duration = Duration::from_secs(1);
|
|
/// Frames the graph shows.
|
|
const BARS: usize = 60;
|
|
|
|
/// Frames missed before this one came, allowing half a frame of jitter.
|
|
fn missed(interval: Duration) -> u32 {
|
|
(interval.as_secs_f64() / BUDGET.as_secs_f64() - 0.5).max(0.0) as u32
|
|
}
|
|
|
|
/// Frame stamps over the last second and the one before it, oldest first, so a frame that ran into the second counts whole.
|
|
#[derive(Default)]
|
|
struct Frames(VecDeque<Instant>);
|
|
|
|
impl Frames {
|
|
fn record(&mut self, now: Instant) {
|
|
self.0.push_back(now);
|
|
while self
|
|
.0
|
|
.get(1)
|
|
.is_some_and(|second| now.duration_since(*second) >= SPAN)
|
|
{
|
|
self.0.pop_front();
|
|
}
|
|
}
|
|
|
|
fn intervals(&self) -> impl Iterator<Item = Duration> + '_ {
|
|
self.0
|
|
.iter()
|
|
.zip(self.0.iter().skip(1))
|
|
.map(|(before, after)| after.duration_since(*before))
|
|
}
|
|
|
|
/// Frames per second over the stamps kept, once two frames span some time.
|
|
fn fps(&self) -> Option<f64> {
|
|
let (first, last) = (self.0.front()?, self.0.back()?);
|
|
let span = last.duration_since(*first).as_secs_f64();
|
|
(span > 0.0).then(|| (self.0.len() - 1) as f64 / span)
|
|
}
|
|
|
|
fn worst(&self) -> Option<Duration> {
|
|
self.intervals().max()
|
|
}
|
|
}
|
|
|
|
/// Frames per second and the worst frame over the last second, a frame that ran into it counted whole, and a bar per recent frame against the 60 Hz budget: success on time, warning a frame late, danger later. While shown it keeps its view drawing, so it counts the display's frames and holds the window awake; show it only while measuring. Its numbers are readings, so reduced motion leaves them live.
|
|
#[derive(IntoElement)]
|
|
pub struct FpsMeter {
|
|
id: ElementId,
|
|
}
|
|
|
|
impl FpsMeter {
|
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
|
Self { id: id.into() }
|
|
}
|
|
}
|
|
|
|
impl RenderOnce for FpsMeter {
|
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let frames =
|
|
window.use_keyed_state((self.id.clone(), "frames"), cx, |_, _| Frames::default());
|
|
let now = cx.background_executor().now();
|
|
frames.update(cx, |frames, _| frames.record(now));
|
|
window.request_animation_frame();
|
|
let frames = frames.read(cx);
|
|
let fps = frames
|
|
.fps()
|
|
.map_or("—".to_string(), |fps| format!("{fps:.0}"));
|
|
let worst = frames.worst().map_or("—".to_string(), |worst| {
|
|
format!("{:.1}", worst.as_secs_f64() * 1000.0)
|
|
});
|
|
let bars: Vec<Duration> = frames.intervals().collect();
|
|
let bars = bars[bars.len().saturating_sub(BARS)..].to_vec();
|
|
let theme = cx.theme();
|
|
let colors = &theme.colors;
|
|
let graph = theme.tooling().graph;
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1()
|
|
.p_2()
|
|
.rounded(theme.radius(Radius::Md))
|
|
.border_1()
|
|
.border_color(colors.border)
|
|
.bg(colors.overlay)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.flex_wrap()
|
|
.items_baseline()
|
|
.justify_between()
|
|
.gap_x_3()
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_baseline()
|
|
.gap_1()
|
|
.child(
|
|
tabular(div())
|
|
.debug_selector({
|
|
let fps = fps.clone();
|
|
move || format!("fps-{fps}")
|
|
})
|
|
.text_size(theme.text_size(TextSize::Lg))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.text_color(colors.fg)
|
|
.child(fps),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(theme.text_size(TextSize::Xs))
|
|
.text_color(colors.fg_muted)
|
|
.child("fps"),
|
|
),
|
|
)
|
|
.child(
|
|
tabular(div())
|
|
.debug_selector({
|
|
let worst = worst.clone();
|
|
move || format!("fps-worst-{worst}")
|
|
})
|
|
.text_size(theme.text_size(TextSize::Xs))
|
|
.text_color(colors.fg_muted)
|
|
.child(format!("worst {worst} ms")),
|
|
),
|
|
)
|
|
.child(
|
|
canvas(
|
|
|_, _, _| {},
|
|
move |bounds, _, window, cx| {
|
|
let theme = cx.theme();
|
|
let colors = &theme.colors;
|
|
let slot = bounds.size.width / BARS as f32;
|
|
for (ix, bar) in bars.iter().enumerate() {
|
|
let share = (bar.as_secs_f32() / (BUDGET.as_secs_f32() * 3.0)).min(1.0);
|
|
let height = bounds.size.height * share;
|
|
let left = bounds.right() - slot * (bars.len() - ix) as f32;
|
|
let color = match missed(*bar) {
|
|
0 => colors.success,
|
|
1 => colors.warning,
|
|
_ => colors.danger,
|
|
};
|
|
window.paint_quad(fill(
|
|
Bounds::new(
|
|
point(left, bounds.bottom() - height),
|
|
size(slot * 0.7, height),
|
|
),
|
|
color,
|
|
));
|
|
}
|
|
let budget = bounds.bottom() - bounds.size.height / 3.0;
|
|
window.paint_quad(fill(
|
|
Bounds::new(
|
|
point(bounds.left(), budget),
|
|
size(bounds.size.width, theme.chart().hairline),
|
|
),
|
|
colors.border_strong,
|
|
));
|
|
},
|
|
)
|
|
.w(graph.0)
|
|
.h(graph.1),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::time::Duration;
|
|
|
|
use web_time::Instant;
|
|
|
|
use super::{Frames, missed};
|
|
|
|
#[test]
|
|
fn frames_count_the_last_second() {
|
|
let (start, mut frames) = (Instant::now(), Frames::default());
|
|
assert_eq!(frames.fps(), None);
|
|
for n in 0..=100 {
|
|
frames.record(start + Duration::from_millis(20) * n);
|
|
}
|
|
assert_eq!(
|
|
frames.fps(),
|
|
Some(50.0),
|
|
"a frame each 20 ms over the last second"
|
|
);
|
|
assert_eq!(frames.0.len(), 51, "older stamps drop");
|
|
frames.record(start + Duration::from_millis(2100));
|
|
assert_eq!(frames.worst(), Some(Duration::from_millis(100)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_stall_longer_than_the_span_counts_whole() {
|
|
let (start, mut frames) = (Instant::now(), Frames::default());
|
|
for ms in [0, 500, 2000] {
|
|
frames.record(start + Duration::from_millis(ms));
|
|
}
|
|
assert_eq!(frames.worst(), Some(Duration::from_millis(1500)));
|
|
assert_eq!(frames.fps().map(|fps| (fps * 100.0).round()), Some(67.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_is_late_by_whole_frames() {
|
|
let tenths = |tenths: u64| Duration::from_micros(tenths * 100);
|
|
assert_eq!(missed(tenths(169)), 0, "jitter is on time");
|
|
assert_eq!(missed(tenths(333)), 1);
|
|
assert_eq!(missed(tenths(500)), 2);
|
|
}
|
|
}
|