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

+286
View File
@@ -0,0 +1,286 @@
use std::{
collections::VecDeque,
fmt,
rc::Rc,
time::{Duration, Instant},
};
use gpui::{
AnyElement, App, DispatchPhase, ElementId, InteractiveElement, IntoElement, Modifiers,
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point,
RenderOnce, ScrollDelta, ScrollWheelEvent, StatefulInteractiveElement, Styled, Window, canvas,
div, prelude::FluentBuilder,
};
use smallvec::SmallVec;
use crate::{
layout::on_axis,
theme::{ActiveTheme, TextSize},
typography::{Ellipsis, tabular},
};
/// Events a logger keeps.
const KEPT: usize = 50;
/// An input event and what it carried; pointer places are in the logger's box.
#[derive(Clone, Debug)]
enum Seen {
Press(MouseButton, usize, Point<Pixels>),
Release(MouseButton, Point<Pixels>),
/// Where the pointer is, and how many moves ran together to get there.
Move(Point<Pixels>, usize),
Wheel(ScrollDelta),
KeyDown(String, bool),
KeyUp(String),
Modifiers(Modifiers),
}
fn at(point: Point<Pixels>) -> String {
format!("{:.0}, {:.0}", f32::from(point.x), f32::from(point.y))
}
fn button(button: MouseButton) -> String {
format!("{button:?}").to_lowercase()
}
impl fmt::Display for Seen {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Seen::Press(which, 1, place) => write!(f, "press {} at {}", button(*which), at(*place)),
Seen::Press(which, clicks, place) => {
write!(f, "press {} ×{clicks} at {}", button(*which), at(*place))
}
Seen::Release(which, place) => {
write!(f, "release {} at {}", button(*which), at(*place))
}
Seen::Move(place, 1) => write!(f, "move to {}", at(*place)),
Seen::Move(place, moves) => write!(f, "move to {} ×{moves}", at(*place)),
Seen::Wheel(ScrollDelta::Pixels(delta)) => write!(f, "wheel {} px", at(*delta)),
Seen::Wheel(ScrollDelta::Lines(delta)) => {
write!(f, "wheel {}, {} lines", delta.x, delta.y)
}
Seen::KeyDown(key, false) => write!(f, "key down {key}"),
Seen::KeyDown(key, true) => write!(f, "key held {key}"),
Seen::KeyUp(key) => write!(f, "key up {key}"),
Seen::Modifiers(held) => {
let names = [
(held.control, "ctrl"),
(held.alt, "alt"),
(held.shift, "shift"),
(held.platform, "cmd"),
(held.function, "fn"),
]
.into_iter()
.filter_map(|(on, name)| on.then_some(name))
.collect::<Vec<_>>();
match names.is_empty() {
true => write!(f, "modifiers none"),
false => write!(f, "modifiers {}", names.join(" ")),
}
}
}
}
}
/// The events seen, oldest first, each timed from the first.
#[derive(Default)]
struct Log {
start: Option<Instant>,
entries: VecDeque<(Duration, Seen)>,
}
impl Log {
/// Adds an event; a move after a move joins it.
fn push(&mut self, now: Instant, seen: Seen) {
let start = *self.start.get_or_insert(now);
let when = now.duration_since(start);
if let (Seen::Move(place, _), Some((last, Seen::Move(was, moves)))) =
(&seen, self.entries.back_mut())
{
(*last, *was, *moves) = (when, *place, *moves + 1);
return;
}
self.entries.push_back((when, seen));
if self.entries.len() > KEPT {
self.entries.pop_front();
}
}
}
type Record = Rc<dyn Fn(Seen, &mut App)>;
/// The input events that reach what it holds, newest first below it: presses, releases, moves run together while the pointer travels, wheels, keys and modifiers, each timed from the first. Pointer events count inside its box, even where a child stops them; keys count while focus is inside. It keeps the last fifty.
#[derive(IntoElement)]
pub struct EventLogger {
id: ElementId,
children: SmallVec<[AnyElement; 2]>,
}
impl EventLogger {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
children: SmallVec::new(),
}
}
}
impl ParentElement for EventLogger {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for EventLogger {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let log = window.use_keyed_state((id.clone(), "log"), cx, |_, _| Log::default());
let entries: Vec<(Duration, Seen)> = log.read(cx).entries.iter().rev().cloned().collect();
let record: Record = Rc::new({
let id = id.clone();
move |seen, cx| {
if !matches!(seen, Seen::Move(..)) {
log::debug!("event logger {id}: {seen}");
}
let now = cx.background_executor().now();
log.update(cx, |log, cx| {
log.push(now, seen);
cx.notify();
});
}
});
let theme = cx.theme();
let colors = &theme.colors;
let (down, up, held) = (record.clone(), record.clone(), record.clone());
let watched = div()
.relative()
.capture_key_down(move |event, _, cx| {
down(Seen::KeyDown(event.keystroke.unparse(), event.is_held), cx)
})
.capture_key_up(move |event, _, cx| up(Seen::KeyUp(event.keystroke.unparse()), cx))
.on_modifiers_changed(move |event, _, cx| held(Seen::Modifiers(event.modifiers), cx))
// Laid first, so its capture listeners run before any child's.
.child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let inside = move |place: Point<Pixels>, phase: DispatchPhase| {
(phase == DispatchPhase::Capture && bounds.contains(&place))
.then(|| place - bounds.origin)
};
let pressed = record.clone();
window.on_mouse_event(move |event: &MouseDownEvent, phase, _, cx| {
if let Some(place) = inside(event.position, phase) {
pressed(Seen::Press(event.button, event.click_count, place), cx)
}
});
let released = record.clone();
window.on_mouse_event(move |event: &MouseUpEvent, phase, _, cx| {
if let Some(place) = inside(event.position, phase) {
released(Seen::Release(event.button, place), cx)
}
});
let moved = record.clone();
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
if let Some(place) = inside(event.position, phase) {
moved(Seen::Move(place, 1), cx)
}
});
let wheeled = record.clone();
window.on_mouse_event(move |event: &ScrollWheelEvent, phase, _, cx| {
if inside(event.position, phase).is_some() {
wheeled(Seen::Wheel(event.delta), cx)
}
});
},
)
.absolute()
.inset_0(),
)
.children(self.children);
let rows = entries.into_iter().map(|(when, seen)| {
let text = seen.to_string();
div()
.debug_selector({
let text = text.clone();
move || format!("event-{text}")
})
.flex()
.items_center()
.gap_3()
.child(
tabular(div())
.flex_none()
.text_color(colors.fg_muted)
.child(format!("+{:.2} s", when.as_secs_f64())),
)
.child(div().flex_1().min_w_0().child(Ellipsis::new(text)))
});
let empty = rows.len() == 0;
div().flex().flex_col().gap_3().child(watched).child(
on_axis(
div()
.id((id, "log"))
.flex()
.flex_col()
.gap_1()
.max_h(theme.tooling().log)
.overflow_y_scroll()
.text_size(theme.text_size(TextSize::Xs)),
)
.when(empty, |log| {
log.child(
div()
.debug_selector(|| "event-logger-empty".into())
.text_color(colors.fg_muted)
.child("Events inside the box show here."),
)
})
.children(rows),
)
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use gpui::{Modifiers, MouseButton, point, px};
use super::{KEPT, Log, Seen};
#[test]
fn moves_run_together_and_the_log_keeps_fifty() {
let (start, mut log) = (Instant::now(), Log::default());
log.push(start, Seen::Move(point(px(1.0), px(2.0)), 1));
log.push(
start + Duration::from_millis(250),
Seen::Move(point(px(5.0), px(6.0)), 1),
);
let (when, seen) = log.entries.back().expect("an entry");
assert_eq!(log.entries.len(), 1);
assert_eq!(*when, Duration::from_millis(250));
assert_eq!(seen.to_string(), "move to 5, 6 ×2");
for _ in 0..KEPT {
log.push(
start,
Seen::Press(MouseButton::Left, 2, point(px(0.0), px(0.0))),
);
}
assert_eq!(log.entries.len(), KEPT);
assert_eq!(
log.entries.front().expect("an entry").1.to_string(),
"press left ×2 at 0, 0"
);
let held = Modifiers {
shift: true,
platform: true,
..Modifiers::default()
};
assert_eq!(Seen::Modifiers(held).to_string(), "modifiers shift cmd");
assert_eq!(
Seen::Modifiers(Modifiers::default()).to_string(),
"modifiers none"
);
}
}
+405
View File
@@ -0,0 +1,405 @@
use std::fmt;
use gpui::{
AbsoluteLength, AnyElement, App, BorderStyle, Bounds, Context, Corners, DefiniteLength,
DivInspectorState, Edges, FontWeight, Inspector, InteractiveElement, IntoElement, Length,
ParentElement, Pixels, StatefulInteractiveElement, StyleRefinement, Styled, TextRun, Window,
canvas, div, fill, point, quad, size, transparent_black,
};
use crate::{
buttons::{Button, ButtonVariant},
primitives::raise,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::{Ellipsis, LEADING, literal, tabular},
};
// Chart hues Rust, Ochre, Green and Blue, as browsers color a box's layers.
const MARGIN: usize = 6;
const BORDER: usize = 2;
const PADDING: usize = 5;
const CONTENT: usize = 0;
/// One side of a box as written: pixels, `auto`, or a share of the parent's width.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Side {
Px(f32),
Auto,
Share(f32),
}
impl Side {
/// What the overlay draws: `auto` and shares only layout knows, so they draw nothing.
fn drawn(self) -> Pixels {
match self {
Side::Px(px) => Pixels::from(px),
Side::Auto | Side::Share(_) => Pixels::ZERO,
}
}
}
fn number(value: f32) -> String {
((value * 10.0).round() / 10.0).to_string()
}
impl fmt::Display for Side {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Side::Px(px) => write!(f, "{}", number(*px)),
Side::Auto => write!(f, "auto"),
Side::Share(share) => write!(f, "{}%", number(share * 100.0)),
}
}
}
/// A box's margin, border and padding, each top, right, bottom, left.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Layers {
margin: [Side; 4],
border: [Side; 4],
padding: [Side; 4],
}
/// The layers as the style writes them, rems at `rem`.
fn layers(style: &StyleRefinement, rem: Pixels) -> Layers {
let definite = |length: DefiniteLength| match length {
DefiniteLength::Absolute(length) => Side::Px(length.to_pixels(rem).into()),
DefiniteLength::Fraction(share) => Side::Share(share),
};
let margin = |length: Option<Length>| match length {
None => Side::Px(0.0),
Some(Length::Auto) => Side::Auto,
Some(Length::Definite(length)) => definite(length),
};
let padding = |length: Option<DefiniteLength>| length.map_or(Side::Px(0.0), definite);
let border = |length: Option<AbsoluteLength>| {
Side::Px(length.map_or(0.0, |length| length.to_pixels(rem).into()))
};
let (m, b, p) = (&style.margin, &style.border_widths, &style.padding);
Layers {
margin: [m.top, m.right, m.bottom, m.left].map(margin),
border: [b.top, b.right, b.bottom, b.left].map(border),
padding: [p.top, p.right, p.bottom, p.left].map(padding),
}
}
fn edges(sides: [Side; 4]) -> Edges<Pixels> {
let [top, right, bottom, left] = sides.map(Side::drawn);
Edges {
top,
right,
bottom,
left,
}
}
fn shrink(bounds: Bounds<Pixels>, by: Edges<Pixels>) -> Bounds<Pixels> {
let inner = bounds.extend(Edges {
top: -by.top,
right: -by.right,
bottom: -by.bottom,
left: -by.left,
});
Bounds::new(
inner.origin,
inner.size.max(&size(Pixels::ZERO, Pixels::ZERO)),
)
}
/// Paints the layers over the page at the box's bounds, with a tag that gives its size.
fn paint_layers(bounds: Bounds<Pixels>, layers: Layers, window: &mut Window, cx: &mut App) {
let theme = cx.theme();
let colors = &theme.colors;
let hue = |hue| colors.hue(hue, "inspector");
let band = |bounds, widths, hue| {
quad(
bounds,
Corners::default(),
transparent_black(),
widths,
colors.hue(hue, "inspector").alpha(0.4),
BorderStyle::Solid,
)
};
let (margin, border, padding) = (
edges(layers.margin),
edges(layers.border),
edges(layers.padding),
);
let (outer, inner) = (bounds.extend(margin), shrink(bounds, border));
window.paint_quad(band(outer, margin, MARGIN));
window.paint_quad(band(bounds, border, BORDER));
window.paint_quad(band(inner, padding, PADDING));
window.paint_quad(fill(shrink(inner, padding), hue(CONTENT).alpha(0.25)));
let text = theme.text_size(TextSize::Xs).to_pixels(window.rem_size());
let label = format!(
"{} × {}",
number(bounds.size.width.into()),
number(bounds.size.height.into())
);
let run = TextRun {
len: label.len(),
font: window.text_style().font(),
color: colors.bg,
background_color: None,
underline: None,
strikethrough: None,
};
let line = window
.text_system()
.shape_line(label.into(), text, &[run], None);
let (height, pad) = (text * LEADING, text / 2.0);
let top = (outer.top() - height).max(Pixels::ZERO);
let tag = Bounds::new(
point(outer.left(), top),
size(line.width + pad * 2.0, height),
);
let radius = theme.radius(Radius::Sm).to_pixels(window.rem_size());
window.paint_quad(fill(tag, colors.fg).corner_radii(radius));
if let Err(error) = line.paint(point(tag.left() + pad, tag.top()), height, window, cx) {
log::error!("inspector: size tag did not paint: {error:#}");
}
}
/// One layer of the box model: its name, its four sides around what it holds.
fn ring(
name: &'static str,
sides: [Side; 4],
hue: usize,
inside: AnyElement,
cx: &App,
) -> AnyElement {
let theme = cx.theme();
let color = theme.colors.hue(hue, "inspector");
let value = |side: Side| tabular(div()).child(side.to_string());
let shown = sides.map(|side| side.to_string()).join("-");
div()
.debug_selector(move || format!("inspector-{name}-{shown}"))
.relative()
.flex()
.flex_col()
.items_center()
.gap_1()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(color)
.bg(color.alpha(0.12))
.child(
div()
.absolute()
.top_1()
.left_2()
.text_color(theme.colors.fg_muted)
.child(name),
)
.child(value(sides[0]))
.child(
div()
.flex()
.items_center()
.gap_2()
.child(value(sides[3]))
.child(inside)
.child(value(sides[1])),
)
.child(value(sides[2]))
.into_any_element()
}
/// A picked box's model, sizes and place, and its layers drawn over the page.
fn box_model(state: &DivInspectorState, window: &mut Window, cx: &mut App) -> AnyElement {
let (bounds, children) = (state.bounds, state.content_size);
let layers = layers(&state.base_style, window.rem_size());
let content = shrink(shrink(bounds, edges(layers.border)), edges(layers.padding)).size;
let pair = |w: Pixels, h: Pixels| format!("{} × {}", number(w.into()), number(h.into()));
let theme = cx.theme();
let fact = |name: &'static str, value: String| {
div()
.flex()
.justify_between()
.gap_2()
.child(div().text_color(theme.colors.fg_muted).child(name))
.child(tabular(div()).child(value))
};
let sized = format!(
"{}x{}",
number(bounds.size.width.into()),
number(bounds.size.height.into())
);
let core = div()
.debug_selector(move || {
format!(
"inspector-content-{}x{}",
number(content.width.into()),
number(content.height.into())
)
})
.px_2()
.py_1()
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(theme.colors.hue(CONTENT, "inspector"))
.bg(theme.colors.hue(CONTENT, "inspector").alpha(0.12))
.child(tabular(div()).child(pair(content.width, content.height)))
.into_any_element();
let padded = ring("padding", layers.padding, PADDING, core, cx);
let bordered = ring("border", layers.border, BORDER, padded, cx);
let model = ring("margin", layers.margin, MARGIN, bordered, cx);
let theme = cx.theme();
div()
.flex()
.flex_col()
.gap_3()
.text_size(theme.text_size(TextSize::Xs))
// Raised, so the panel's scroll box does not clip it off the page.
.child(raise(
"ely-inspector-overlay",
canvas(
|_, _, _| {},
move |_, _, window, cx| paint_layers(bounds, layers, window, cx),
)
.absolute()
.top_0()
.left_0(),
))
.child(model)
.child(
div()
.debug_selector(move || format!("inspector-size-{sized}"))
.flex()
.flex_col()
.gap_1()
.child(fact(
"Place",
format!(
"{}, {}",
number(bounds.origin.x.into()),
number(bounds.origin.y.into())
),
))
.child(fact("Size", pair(bounds.size.width, bounds.size.height)))
.child(fact("Children", pair(children.width, children.height))),
)
.into_any_element()
}
/// The inspector's panel: how to pick, the picked box's source and ids, and its model.
fn panel(
inspector: &mut Inspector,
window: &mut Window,
cx: &mut Context<Inspector>,
) -> AnyElement {
let picking = inspector.is_picking();
if picking {
// gpui holds a pick on a press without a redraw, so the panel draws each frame while picking.
window.request_animation_frame();
}
let place = inspector.active_element_id().map(|id| {
let location = id.path.source_location;
let ids: Vec<String> = id.path.global_id.iter().map(ToString::to_string).collect();
(
format!("{}:{}", location.file(), location.line()),
ids[ids.len().saturating_sub(3)..].join(" › "),
)
});
let states = inspector.render_inspector_states(window, cx);
let owner = cx.entity();
let theme = cx.theme();
let colors = &theme.colors;
let hint = match picking {
true => {
"Hover picks a box, the wheel steps out to the boxes around it, and a press holds it."
}
false => "Held. Pick chooses another box.",
};
div()
.id("ely-inspector")
.size_full()
.flex()
.flex_col()
.gap_4()
.p_4()
.overflow_y_scroll()
.bg(colors.surface)
.border_l_1()
.border_color(colors.border)
.text_color(colors.fg)
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.child(div().font_weight(FontWeight::SEMIBOLD).child("Inspector"))
.children((!picking).then(|| {
div().debug_selector(|| "inspector-pick".into()).child(
Button::new("ely-inspector-pick", "Pick")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
owner.update(cx, |inspector, _| inspector.start_picking());
log::info!("inspector: picking");
window.refresh();
}),
)
})),
)
.child(
div()
.debug_selector(move || format!("inspector-picking-{picking}"))
.text_color(colors.fg_muted)
.child(hint),
)
.children(place.map(|(source, ids)| {
div()
.flex()
.flex_col()
.gap_1()
.child(literal(div()).child(Ellipsis::new(source)))
.child(
literal(div())
.text_color(colors.fg_muted)
.child(Ellipsis::new(ids)),
)
}))
.children(states)
.into_any_element()
}
/// Draws gpui's inspector, which debug builds have, in Ely's look. `window.toggle_inspector` opens it at the window's right, and the page gives up its width. Hovering picks the box under the pointer, the wheel steps out to the boxes around it, and a press holds it; the picked box shows its margin, border, padding and size over the page, and its model in the panel.
pub fn install_inspector(cx: &mut App) {
cx.set_inspector_renderer(Box::new(panel));
cx.register_inspector_element(|_, state: &DivInspectorState, window, cx| {
box_model(state, window, cx)
});
log::info!("inspector: installed");
}
#[cfg(test)]
mod tests {
use gpui::{StyleRefinement, Styled, div, px, relative, rems};
use super::{Side, layers};
#[test]
fn layers_read_as_written() {
let mut style = div().mt(px(4.0)).mx_auto().p(rems(0.5)).border_2();
let written = layers(style.style(), px(16.0));
assert_eq!(
written.margin,
[Side::Px(4.0), Side::Auto, Side::Px(0.0), Side::Auto]
);
assert_eq!(written.padding, [Side::Px(8.0); 4]);
assert_eq!(written.border, [Side::Px(2.0); 4]);
let mut share = StyleRefinement::default();
share.padding.left = Some(relative(0.25));
let shown = layers(&share, px(16.0))
.padding
.map(|side| side.to_string());
assert_eq!(shown, ["0", "0", "0", "25%"]);
assert_eq!(Side::Share(0.25).drawn(), px(0.0), "a share draws nothing");
}
}
+10
View File
@@ -1,5 +1,15 @@
mod events;
#[cfg(debug_assertions)]
mod inspector;
mod perf;
mod playground;
mod renders;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use events::EventLogger;
#[cfg(debug_assertions)]
pub use inspector::install_inspector;
pub use perf::FpsMeter;
pub use playground::{Knob, Playground, Setting, Settings};
pub use renders::RenderCounter;
+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);
}
}
+61
View File
@@ -0,0 +1,61 @@
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use crate::{
theme::{ActiveTheme, TextSize},
typography::tabular,
};
/// How many times the view that draws it has rendered, beside a label: place it in a view's render to see how often that view redraws. It shows the count and nothing moves, since any motion would redraw the view it counts.
#[derive(IntoElement)]
pub struct RenderCounter {
id: ElementId,
label: SharedString,
}
impl RenderCounter {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
}
}
}
impl RenderOnce for RenderCounter {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let renders = window.use_keyed_state((self.id.clone(), "renders"), cx, |_, _| 0u64);
let count = renders.update(cx, |renders, _| {
*renders += 1;
*renders
});
let theme = cx.theme();
let colors = &theme.colors;
let label = self.label;
div()
.debug_selector({
let label = label.clone();
move || format!("renders-{label}-{count}")
})
.flex()
.flex_none()
.items_center()
.gap_1p5()
.px_2()
.py_0p5()
.rounded_full()
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.text_size(theme.text_size(TextSize::Xs))
.child(div().text_color(colors.fg_muted).child(label))
.child(
tabular(div())
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(count.to_string()),
)
}
}
+185
View File
@@ -0,0 +1,185 @@
use std::time::Duration;
use gpui::{
Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, Modifiers,
ParentElement, Render, ScrollDelta, ScrollWheelEvent, Styled, TestAppContext, TouchPhase,
VisualTestContext, Window, div, point, px,
};
use crate::{
primitives::FocusScope,
theme::Theme,
tooling::{EventLogger, FpsMeter, RenderCounter, install_inspector},
};
fn shown(selector: &'static str, cx: &mut VisualTestContext) -> bool {
cx.debug_bounds(selector).is_some()
}
fn redraw(cx: &mut VisualTestContext) {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
struct Sample;
impl Render for Sample {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(
div()
.id("sample")
.mt(px(4.0))
.ml(px(4.0))
.p(px(8.0))
.border_2()
.w(px(120.0))
.h(px(40.0))
.child("Sample"),
)
}
}
/// Hovering picks a box and the panel gives its size and layers; a press holds it, and Pick picks again.
#[gpui::test]
fn the_inspector_shows_the_picked_box(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
install_inspector(cx);
});
let (_, cx) = cx.add_window_view(|_, _| Sample);
cx.update(|window, cx| window.toggle_inspector(cx));
cx.run_until_parked();
assert!(shown("inspector-picking-true", cx));
cx.simulate_mouse_move(point(px(60.0), px(24.0)), None, Modifiers::none());
assert!(shown("inspector-size-120x40", cx), "the hovered box");
assert!(shown("inspector-margin-4-0-0-4", cx));
assert!(shown("inspector-border-2-2-2-2", cx));
assert!(shown("inspector-padding-8-8-8-8", cx));
assert!(
shown("inspector-content-100x20", cx),
"less border and padding"
);
cx.simulate_click(point(px(60.0), px(24.0)), Modifiers::none());
// gpui holds a pick without a redraw; the panel's own frame requests do not run in tests.
redraw(cx);
assert!(shown("inspector-picking-false", cx), "a press holds it");
let pick = cx
.debug_bounds("inspector-pick")
.expect("Pick shows once held");
cx.simulate_click(pick.center(), Modifiers::none());
assert!(
cx.update(|window, cx| window.is_inspector_picking(cx)),
"Pick picks again"
);
}
struct Meter;
impl Render for Meter {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().child(FpsMeter::new("meter"))
}
}
/// The meter counts a frame per draw on the executor's clock, and names the worst.
#[gpui::test]
fn the_meter_counts_frames(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Meter);
for _ in 0..60 {
cx.executor().advance_clock(Duration::from_millis(20));
redraw(cx);
}
assert!(shown("fps-50", cx), "a frame each 20 ms");
assert!(shown("fps-worst-20.0", cx));
cx.executor().advance_clock(Duration::from_millis(100));
redraw(cx);
assert!(shown("fps-worst-100.0", cx), "one slow frame");
}
struct Counted;
impl Render for Counted {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().child(RenderCounter::new("counter", "Page"))
}
}
/// Each render of the view that draws it counts once.
#[gpui::test]
fn the_counter_counts_renders(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Counted);
cx.run_until_parked();
assert!(shown("renders-Page-1", cx), "the first frame");
redraw(cx);
redraw(cx);
assert!(shown("renders-Page-3", cx));
}
struct Logged {
root: FocusHandle,
target: FocusHandle,
}
impl Render for Logged {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
FocusScope::new(&self.root)
.root()
.size_full()
.p(px(10.0))
.child(
EventLogger::new("logger").child(
div()
.id("target")
.track_focus(&self.target)
.h(px(100.0))
.capture_any_mouse_down(|_, _, cx| cx.stop_propagation()),
),
)
}
}
/// Pointer events inside the box, even ones a child stops as they arrive, and keys while focus is inside, list with what they carried, placed in the box.
#[gpui::test]
fn the_logger_lists_events(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (view, cx) = cx.add_window_view(|_, cx| Logged {
root: cx.focus_handle(),
target: cx.focus_handle(),
});
let target = view.read_with(cx, |logged, _| logged.target.clone());
cx.update(|window, _| window.focus(&target));
cx.run_until_parked();
assert!(shown("event-logger-empty", cx));
cx.simulate_mouse_move(point(px(20.0), px(20.0)), None, Modifiers::none());
assert!(shown("event-move to 10, 10", cx));
cx.simulate_mouse_move(point(px(30.0), px(40.0)), None, Modifiers::none());
assert!(shown("event-move to 20, 30 ×2", cx), "moves run together");
cx.simulate_click(point(px(30.0), px(40.0)), Modifiers::none());
assert!(
shown("event-press left at 20, 30", cx),
"a press the child stops"
);
assert!(shown("event-release left at 20, 30", cx));
cx.simulate_event(ScrollWheelEvent {
position: point(px(30.0), px(40.0)),
delta: ScrollDelta::Pixels(point(px(0.0), px(-24.0))),
modifiers: Modifiers::none(),
touch_phase: TouchPhase::Moved,
});
assert!(shown("event-wheel 0, -24 px", cx));
cx.simulate_keystrokes("a");
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse("a").expect("a key"),
});
assert!(shown("event-key down a", cx));
assert!(shown("event-key up a", cx));
cx.simulate_modifiers_change(Modifiers::shift());
assert!(shown("event-modifiers shift", cx));
cx.simulate_click(point(px(30.0), px(410.0)), Modifiers::none());
assert!(
!shown("event-press left at 20, 400", cx),
"a press outside the box"
);
}
@@ -5,13 +5,15 @@ use gpui::{
use std::cell::Cell;
use super::{Knob, Playground};
use crate::{
forms,
primitives::{FocusNext, FocusPrev, FocusScope},
theme::Theme,
tooling::{Knob, Playground},
};
mod inspection;
struct Stage {
root: FocusHandle,
}