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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-28 07:13:12 -04:00
1 parent a0d93eb35f
commit 5f951ea581
14 files changed
+1378 -7

No files matched your search

+5
View File
@@ -204,6 +204,10 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A native view laid over the window takes AppKit's first responder on a press, and gpui's view never asks for it back, so typed text would go to the native view for good. The component hands the keyboard back on every press gpui gets and when the view leaves (`misc::WebView`, through wry's `focus_parent`); `Step::NativeKey` proves it, since gpui's own dispatch skips AppKit.
- A map keeps places in `f64`: at zoom 19 the world is 134 million pixels across. Its wheel pans and Command or Control with the wheel zooms about the pointer, as the canvas's does. Its tiles stay in gpui's asset cache, as every `img()` picture does. Its popup is an overlay that is not modal: it takes focus when it shows, and the frame it stops showing, by any way out, hands focus back. Its layers, lines and areas unwrap across the date line and repeat on every world in view. Clustered pins gather by squares of the world, not of the view, so a pan never regroups them; a zoom may, so it hands a focused pin's or cluster's focus to the map.
- gpui's inspector (debug builds) runs no mouse listener while picking and holds a pick on a press without a redraw, so Ely's panel requests a frame each render while picking (`tooling::install_inspector`). Its panel scrolls, so the box model it draws over the page goes through `primitives::raise`, which paints unclipped.
- A tool that measures redraws cannot move: any animation redraws the view it counts (`tooling::RenderCounter`). `AnyView::cached` renders again only on its own notify or a window refresh. A meter that counts frames keeps its view drawing while shown (`tooling::FpsMeter`).
- A logger of input lays its canvas before what it holds, so its capture listeners run first and see what a child stops (`tooling::EventLogger`).
## Decisions
- 2026-09-24: official gpui 0.2.2 over the `gpui-pre` snapshot. Older API, first-party publisher.
@@ -219,3 +223,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- 2026-09-27: ThemeSwitcher is `settings::ThemeSelector`: `src/theme` sits under every chapter and cannot draw with buttons.
- 2026-09-28: QRCodeScanner proven with `rqrr` 0.11: the host hands in frames as `RenderImage`s, read off the main thread; gpui 0.2.2 has no camera.
- 2026-09-28: WebView proven with `wry` 0.57: a child WKWebView over the window, placed from a canvas in prepaint at its box each frame and hidden once the box leaves the view. It draws above everything gpui paints and gpui's clipping does not reach it; gpui's test windows have no native handle, so the capture proves it.
- 2026-09-28: InspectorOverlay is gpui's inspector drawn by Ely, `tooling::install_inspector`, in debug builds only, as gpui has it.
+2
View File
@@ -98,6 +98,8 @@ fn main() -> Result<()> {
.with_assets(Assets)
.run(move |cx: &mut App| {
ely_gpui_component::init(cx);
#[cfg(debug_assertions)]
ely_gpui_component::tooling::install_inspector(cx);
pages::bind_keys(cx);
pages::install_i18n(cx);
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
@@ -0,0 +1,145 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
forms::{Switch, TextInput},
theme::{ActiveTheme, Radius},
tooling::{EventLogger, FpsMeter, RenderCounter},
};
use gpui::{
AnyElement, AnyView, App, Context, IntoElement, ParentElement, Render, StyleRefinement, Styled,
Window, div, px,
};
use crate::probe::probe;
use crate::ui::section;
/// A view of its own, drawn cached, so only its own notify renders it again.
struct Apart;
impl Render for Apart {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.items_center()
.gap_3()
.child(RenderCounter::new("apart-renders", "This view"))
.child(
Button::new("apart-redraw", "Redraw this view")
.variant(ButtonVariant::Outline)
.on_click(cx.listener(|_, _, _, cx| cx.notify())),
)
}
}
#[cfg(debug_assertions)]
fn inspect() -> AnyElement {
probe(
"inspect-button",
Button::new("inspect", "Inspect").on_click(|_, window, cx| window.toggle_inspector(cx)),
)
.into_any_element()
}
#[cfg(not(debug_assertions))]
fn inspect() -> AnyElement {
div()
.child("Release builds leave gpui's inspector out.")
.into_any_element()
}
pub fn sections(window: &mut Window, cx: &mut App) -> Vec<AnyElement> {
let measuring = window.use_keyed_state("fps-measuring", cx, |_, _| false);
let on = *measuring.read(cx);
let apart = window.use_keyed_state("renders-apart", cx, |_, _| Apart);
let field = window.use_keyed_state("events-field", cx, TextInput::new);
let theme = cx.theme();
let colors = &theme.colors;
let boxed = |element: gpui::Div| {
element
.border_2()
.border_color(colors.border)
.rounded(theme.radius(Radius::Md))
.bg(colors.surface)
};
vec![
section(
"InspectorOverlay",
"gpui's inspector, which debug builds have, drawn in Ely's look. Inspect opens it at the window's right: hovering picks a box and shows its margin, border, padding and size over the page, the wheel steps out to the boxes around it, and a press holds it.",
cx,
)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_4()
.child(inspect())
.child(probe(
"inspect-sample",
div().child(boxed(div().m_3().p_4()).child("A box to pick")),
)),
)
.into_any_element(),
section(
"FPSMeter / PerfOverlay",
"Frames per second over the last second, the worst frame, and a bar per frame against the 60 Hz budget: green on time, amber a frame late, red later. It keeps the window drawing while shown, so Measure shows it only while you look.",
cx,
)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_4()
.child(probe(
"fps-switch",
Switch::new("fps-measure", on)
.label("Measure")
.on_change(move |on, _, cx| {
measuring.update(cx, |measuring, cx| {
*measuring = on;
cx.notify();
})
}),
))
.children(on.then(|| FpsMeter::new("gallery-fps"))),
)
.into_any_element(),
section(
"RenderCounter",
"How often the view that draws it renders. The page's count climbs with every frame the window draws; the view beside it is drawn cached, so it renders only when it redraws itself or the window refreshes.",
cx,
)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_4()
.child(RenderCounter::new("page-renders", "Page"))
.child(probe(
"renders-apart",
div().w(px(320.0)).h(px(36.0)).child(
AnyView::from(apart)
.cached(StyleRefinement::default().w(px(320.0)).h(px(36.0))),
),
)),
)
.into_any_element(),
section(
"EventLogger",
"The input events that reach what it holds, newest first: presses, releases, moves run together while the pointer travels, wheels, keys and modifiers, each timed from the first.",
cx,
)
.child(probe(
"events",
div().w(px(420.0)).child(
EventLogger::new("gallery-events").child(
boxed(div().flex().flex_col().gap_2().p_3())
.child("Move, press or scroll here, or type in the field.")
.child(field),
),
),
))
.into_any_element(),
]
}
+35 -2
View File
@@ -6,6 +6,8 @@ use ely_gpui_component::{
};
use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px};
mod inspection;
use super::Page;
use crate::probe::probe;
use crate::script::Step;
@@ -15,16 +17,46 @@ pub const PAGE: Page = Page {
number: 43,
slug: "tooling",
title: "Library Tooling",
summary: "Tools for building with the library: this gallery, and a playground that edits a component live.",
summary: "Tools for building with the library: this gallery, a playground that edits a component live, and tools that inspect a running window: its boxes, frames, renders and input.",
render,
script: &[
Step::DownAt("button-playground", 472.0, 207.0),
Step::UpAt("button-playground", 472.0, 207.0),
Step::Shot("playground-loading"),
Step::DownAt("inspect-button", 12.0, 12.0),
Step::UpAt("inspect-button", 12.0, 12.0),
Step::HoverAt("inspect-sample", 60.0, 30.0),
Step::Shot("inspector-picking"),
Step::DownAt("inspect-sample", 60.0, 30.0),
Step::UpAt("inspect-sample", 60.0, 30.0),
Step::HoverAt("inspect-button", 12.0, 12.0),
Step::Shot("inspector-held"),
Step::DownAt("inspect-button", 12.0, 12.0),
Step::UpAt("inspect-button", 12.0, 12.0),
Step::DownAt("fps-switch", 14.0, 10.0),
Step::UpAt("fps-switch", 14.0, 10.0),
Step::Wait(1200),
Step::Shot("fps-measuring"),
Step::DownAt("fps-switch", 14.0, 10.0),
Step::UpAt("fps-switch", 14.0, 10.0),
Step::DownAt("renders-apart", 200.0, 18.0),
Step::UpAt("renders-apart", 200.0, 18.0),
Step::DownAt("renders-apart", 200.0, 18.0),
Step::UpAt("renders-apart", 200.0, 18.0),
Step::Shot("renders"),
Step::HoverAt("events", 40.0, 20.0),
Step::HoverAt("events", 120.0, 30.0),
Step::DownAt("events", 120.0, 30.0),
Step::UpAt("events", 120.0, 30.0),
Step::DownAt("events", 60.0, 60.0),
Step::UpAt("events", 60.0, 60.0),
Step::Type("Hi"),
Step::Shot("events"),
],
};
fn render(_: &mut Window, cx: &mut App) -> AnyElement {
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
let inspection = inspection::sections(window, cx);
div()
.child(section(
"ComponentGallery / Storybook",
@@ -98,5 +130,6 @@ fn render(_: &mut Window, cx: &mut App) -> AnyElement {
))),
),
)
.children(inspection)
.into_any_element()
}
+2
View File
@@ -15,6 +15,7 @@ mod platform;
mod project;
mod syntax;
mod tokens;
mod tooling;
#[cfg(all(test, feature = "test-support"))]
mod tests;
@@ -43,6 +44,7 @@ pub use syntax::{SyntaxTheme, syntax_themes};
pub use tokens::{
AvatarSize, ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize,
};
pub use tooling::ToolingSizes;
const FRAME: Duration = Duration::from_millis(8);
+21
View File
@@ -0,0 +1,21 @@
use gpui::Rems;
use super::{Theme, tokens::px_to_rems};
/// The tooling chapter's measures.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ToolingSizes {
/// An FPS meter's graph, across and tall.
pub graph: (Rems, Rems),
/// How tall an event log grows before it scrolls.
pub log: Rems,
}
impl Theme {
pub fn tooling(&self) -> ToolingSizes {
ToolingSizes {
graph: (px_to_rems(160.0), px_to_rems(40.0)),
log: px_to_rems(200.0),
}
}
}
+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,
}
+4 -4
View File
@@ -359,10 +359,10 @@
## T43b Tooling · Inspection
- [ ] InspectorOverlay
- [ ] FPSMeter / PerfOverlay
- [ ] RenderCounter
- [ ] EventLogger
- [x] InspectorOverlay → `tooling::install_inspector`: gpui's inspector, debug builds only, drawn by Ely: hover picks a box, the wheel steps out, a press holds; the box's margin, border, padding and size over the page, its model, source and ids in the panel
- [x] FPSMeter / PerfOverlay → `tooling::FpsMeter`: frames per second over the last second, the worst frame, a bar per frame against the 60 Hz budget
- [x] RenderCounter → `tooling::RenderCounter`: how often the view that draws it has rendered
- [x] EventLogger → `tooling::EventLogger`: the input events that reach what it holds, newest first, moves run together, each timed from the first
## T43c Tooling · Catalogs