From 517d23f78f1926fef6b85b31ce97f848cfc0b72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 28 Sep 2026 07:54:54 -0400 Subject: [PATCH] =?UTF-8?q?Tooling=20=C2=B7=20Inspection:=20stalls=20count?= =?UTF-8?q?=20whole,=20the=20panel=20owns=20Tab,=20bound=20keys=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FpsMeter keeps the stamp before the last second, so a frame longer than the span shows as the worst and a bar. The inspector's panel, a second root out of the app's scope, sits in a FocusScope of its own, so Tab goes on from Pick. EventLogger takes key downs through an interceptor, which runs before bindings, and marks its box with a key context to know focus is inside, so a field's Backspace and Enter log. Tests hold the stall, Tab both ways through Pick, bound keys in a field, a key while focus is outside, newest first and a bare border. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- examples/gallery/pages/tooling/mod.rs | 1 + src/tooling/events.rs | 39 ++++--- src/tooling/inspector.rs | 20 +++- src/tooling/perf.rs | 18 +++- src/tooling/tests/inspection.rs | 140 ++++++++++++++++++++++---- 6 files changed, 182 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b11ff7f..91d2463 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,9 +204,9 @@ 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. +- 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. The panel is a second root, out of the app's scope, so it owns Tab through a `FocusScope` of its own. - 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`). +- A logger of input lays its canvas before what it holds, so its capture listeners run first and see what a child stops. Key downs come through `App::intercept_keystrokes`, which runs before bindings, and a key context on its box tells it focus is inside (`tooling::EventLogger`). ## Decisions diff --git a/examples/gallery/pages/tooling/mod.rs b/examples/gallery/pages/tooling/mod.rs index 34e9014..a29748e 100644 --- a/examples/gallery/pages/tooling/mod.rs +++ b/examples/gallery/pages/tooling/mod.rs @@ -51,6 +51,7 @@ pub const PAGE: Page = Page { Step::DownAt("events", 60.0, 60.0), Step::UpAt("events", 60.0, 60.0), Step::Type("Hi"), + Step::Key("backspace"), Step::Shot("events"), ], }; diff --git a/src/tooling/events.rs b/src/tooling/events.rs index 1390317..f15332f 100644 --- a/src/tooling/events.rs +++ b/src/tooling/events.rs @@ -6,10 +6,10 @@ use std::{ }; 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, + AnyElement, App, DispatchPhase, ElementId, InteractiveElement, IntoElement, KeyContext, + Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, + Point, RenderOnce, ScrollDelta, ScrollWheelEvent, StatefulInteractiveElement, Styled, Window, + canvas, div, prelude::FluentBuilder, }; use smallvec::SmallVec; @@ -30,7 +30,7 @@ enum Seen { /// Where the pointer is, and how many moves ran together to get there. Move(Point, usize), Wheel(ScrollDelta), - KeyDown(String, bool), + KeyDown(String), KeyUp(String), Modifiers(Modifiers), } @@ -59,8 +59,7 @@ impl fmt::Display for Seen { 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::KeyDown(key) => write!(f, "key down {key}"), Seen::KeyUp(key) => write!(f, "key up {key}"), Seen::Modifiers(held) => { let names = [ @@ -109,7 +108,7 @@ impl Log { type Record = Rc; -/// 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. +/// 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, a key a binding takes too. It keeps the last fifty. #[derive(IntoElement)] pub struct EventLogger { id: ElementId, @@ -149,14 +148,30 @@ impl RenderOnce for EventLogger { }); } }); + // Bindings run before key listeners, so key downs come through an interceptor. + let mark = id.to_string(); + window.use_keyed_state((id.clone(), "keys"), cx, { + let (down, mark) = (record.clone(), mark.clone()); + move |_, cx| { + cx.intercept_keystrokes(move |event, _, cx| { + let inside = event + .context_stack + .iter() + .any(|context| context.get("logger").is_some_and(|logger| *logger == mark)); + if inside { + down(Seen::KeyDown(event.keystroke.unparse()), cx) + } + }) + } + }); + let mut context = KeyContext::default(); + context.set("logger", mark); let theme = cx.theme(); let colors = &theme.colors; - let (down, up, held) = (record.clone(), record.clone(), record.clone()); + let (up, held) = (record.clone(), record.clone()); let watched = div() .relative() - .capture_key_down(move |event, _, cx| { - down(Seen::KeyDown(event.keystroke.unparse(), event.is_held), cx) - }) + .key_context(context) .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. diff --git a/src/tooling/inspector.rs b/src/tooling/inspector.rs index e84cd1b..13cb27c 100644 --- a/src/tooling/inspector.rs +++ b/src/tooling/inspector.rs @@ -9,7 +9,7 @@ use gpui::{ use crate::{ buttons::{Button, ButtonVariant}, - primitives::raise, + primitives::{FocusScope, raise}, theme::{ActiveTheme, ControlSize, Radius, TextSize}, typography::{Ellipsis, LEADING, literal, tabular}, }; @@ -305,6 +305,10 @@ fn panel( ) }); let states = inspector.render_inspector_states(window, cx); + let focus = window + .use_keyed_state("ely-inspector-focus", cx, |_, cx| cx.focus_handle()) + .read(cx) + .clone(); let owner = cx.entity(); let theme = cx.theme(); let colors = &theme.colors; @@ -314,7 +318,7 @@ fn panel( } false => "Held. Pick chooses another box.", }; - div() + let body = div() .id("ely-inspector") .size_full() .flex() @@ -365,7 +369,11 @@ fn panel( .child(Ellipsis::new(ids)), ) })) - .children(states) + .children(states); + // A second root, out of the app's scope: it owns Tab itself. + FocusScope::new(&focus) + .size_full() + .child(body) .into_any_element() } @@ -394,6 +402,12 @@ mod tests { ); assert_eq!(written.padding, [Side::Px(8.0); 4]); assert_eq!(written.border, [Side::Px(2.0); 4]); + let bare = layers(&StyleRefinement::default(), px(16.0)); + assert_eq!( + bare.border, + [Side::Px(0.0); 4], + "no border written, none read" + ); let mut share = StyleRefinement::default(); share.padding.left = Some(relative(0.25)); let shown = layers(&share, px(16.0)) diff --git a/src/tooling/perf.rs b/src/tooling/perf.rs index afe0068..ff87871 100644 --- a/src/tooling/perf.rs +++ b/src/tooling/perf.rs @@ -25,7 +25,7 @@ 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. +/// 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); @@ -34,8 +34,8 @@ impl Frames { self.0.push_back(now); while self .0 - .front() - .is_some_and(|first| now.duration_since(*first) > SPAN) + .get(1) + .is_some_and(|second| now.duration_since(*second) >= SPAN) { self.0.pop_front(); } @@ -60,7 +60,7 @@ impl Frames { } } -/// 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. +/// 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, @@ -204,6 +204,16 @@ mod tests { 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); diff --git a/src/tooling/tests/inspection.rs b/src/tooling/tests/inspection.rs index e953248..82d3d19 100644 --- a/src/tooling/tests/inspection.rs +++ b/src/tooling/tests/inspection.rs @@ -1,13 +1,15 @@ 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, + AppContext, Context, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, + KeyBinding, KeyUpEvent, Keystroke, Modifiers, ParentElement, Render, ScrollDelta, + ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext, Window, div, point, + px, }; use crate::{ - primitives::FocusScope, + forms::{self, TextInput}, + primitives::{FocusNext, FocusPrev, FocusScope}, theme::Theme, tooling::{EventLogger, FpsMeter, RenderCounter, install_inspector}, }; @@ -73,6 +75,75 @@ fn the_inspector_shows_the_picked_box(cx: &mut TestAppContext) { ); } +struct Stops { + root: FocusHandle, + first: FocusHandle, + second: FocusHandle, +} + +impl Render for Stops { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + FocusScope::new(&self.root) + .root() + .size_full() + .child(div().track_focus(&self.first).size(px(40.0))) + .child(div().track_focus(&self.second).size(px(40.0))) + } +} + +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + cx.run_until_parked(); +} + +/// Tab reaches the panel's Pick and goes on from it, both ways. +#[gpui::test] +fn tab_passes_through_the_panel(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + install_inspector(cx); + cx.bind_keys([ + KeyBinding::new("tab", FocusNext, None), + KeyBinding::new("shift-tab", FocusPrev, None), + ]); + }); + let (view, cx) = cx.add_window_view(|_, cx| Stops { + root: cx.focus_handle(), + first: cx.focus_handle().tab_stop(true), + second: cx.focus_handle().tab_stop(true), + }); + let (root, first, second) = view.read_with(cx, |stops, _| { + ( + stops.root.clone(), + stops.first.clone(), + stops.second.clone(), + ) + }); + cx.update(|window, cx| { + window.focus(&root); + window.toggle_inspector(cx); + }); + cx.run_until_parked(); + cx.simulate_click(point(px(20.0), px(20.0)), Modifiers::none()); + redraw(cx); + assert!(shown("inspector-pick", cx), "a press holds the box"); + let focused = |cx: &mut VisualTestContext| cx.update(|window, cx| window.focused(cx)); + for _ in 0..3 { + press("tab", cx); + } + let pick = focused(cx).expect("Pick holds focus"); + assert!( + ![&root, &first, &second].contains(&&pick), + "Tab reached Pick" + ); + press("tab", cx); + assert_eq!(focused(cx), Some(first.clone()), "Tab went on from Pick"); + press("shift-tab", cx); + assert_eq!(focused(cx), Some(pick), "Shift-Tab came back"); + press("shift-tab", cx); + assert_eq!(focused(cx), Some(second)); +} + struct Meter; impl Render for Meter { @@ -120,6 +191,7 @@ fn the_counter_counts_renders(cx: &mut TestAppContext) { struct Logged { root: FocusHandle, target: FocusHandle, + field: Entity, } impl Render for Logged { @@ -129,24 +201,30 @@ impl Render for Logged { .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()), - ), + EventLogger::new("logger") + .child( + div() + .id("target") + .track_focus(&self.target) + .h(px(100.0)) + .capture_any_mouse_down(|_, _, cx| cx.stop_propagation()), + ) + .child(self.field.clone()), ) } } -/// 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. +/// Pointer events inside the box, even ones a child stops as they arrive, and keys while focus is inside, bound ones too, list newest first 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 { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + }); + let (view, cx) = cx.add_window_view(|window, cx| Logged { root: cx.focus_handle(), target: cx.focus_handle(), + field: cx.new(|cx| TextInput::new(window, cx)), }); let target = view.read_with(cx, |logged, _| logged.target.clone()); cx.update(|window, _| window.focus(&target)); @@ -157,11 +235,13 @@ fn the_logger_lists_events(cx: &mut TestAppContext) { 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)); + let pressed = cx + .debug_bounds("event-press left at 20, 30") + .expect("a press the child stops"); + let released = cx + .debug_bounds("event-release left at 20, 30") + .expect("its release"); + assert!(released.top() < pressed.top(), "newest first"); cx.simulate_event(ScrollWheelEvent { position: point(px(30.0), px(40.0)), delta: ScrollDelta::Pixels(point(px(0.0), px(-24.0))), @@ -177,6 +257,28 @@ fn the_logger_lists_events(cx: &mut TestAppContext) { assert!(shown("event-key up a", cx)); cx.simulate_modifiers_change(Modifiers::shift()); assert!(shown("event-modifiers shift", cx)); + let (field, root) = view.read_with(cx, |logged, cx| { + (logged.field.focus_handle(cx), logged.root.clone()) + }); + cx.update(|window, _| window.focus(&field)); + for key in ["b", "c", "backspace", "left", "enter"] { + press(key, cx); + } + let text = view.read_with(cx, |logged, cx| logged.field.read(cx).text().to_string()); + assert_eq!(text, "b", "the field took Backspace"); + for row in [ + "event-key down backspace", + "event-key down left", + "event-key down enter", + ] { + assert!(shown(row, cx), "{row}: a key the field's bindings take"); + } + cx.update(|window, _| window.focus(&root)); + press("z", cx); + assert!( + !shown("event-key down z", cx), + "a key while focus is outside" + ); cx.simulate_click(point(px(30.0), px(410.0)), Modifiers::none()); assert!( !shown("event-press left at 20, 400", cx),