Tooling · Inspection: inspector, FPS meter, render counter and event logger

install_inspector draws gpui's debug inspector in Ely's look: a panel
with the picked box's source, ids and box model, and its margin,
border, padding and size drawn over the page through raise, since the
panel scrolls. FpsMeter counts frames on the executor's clock and
colors each by the frames it missed. RenderCounter counts the renders
of the view that draws it. EventLogger lists the input that reaches
what it holds, its canvas laid first so a child's stop hides nothing.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-28 07:13:12 -04:00
1 parent 274089a8f6
commit 462e47b854
14 files changed
+1378 -7

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
use std::{
collections::VecDeque,
time::{Duration, Instant},
};
use gpui::{
App, Bounds, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
Styled, Window, canvas, div, fill, point, size,
};
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, oldest first.
#[derive(Default)]
struct Frames(VecDeque<Instant>);
impl Frames {
fn record(&mut self, now: Instant) {
self.0.push_back(now);
while self
.0
.front()
.is_some_and(|first| now.duration_since(*first) > 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 over the last second, the worst frame, 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, 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_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);
}
}