diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 07865f8a59..b53d563dab 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -5386,6 +5386,7 @@ fn load_editor_project_candidate( drive_auto_sidebar(handle, cx).await; drive_auto_playback(path, handle, cx).await; drive_auto_export(handle, cx).await; + crate::editor_hover_bench::run(handle, cx).await; }) .detach(); } diff --git a/apps/desktop-gpui/src/editor_hover_bench.rs b/apps/desktop-gpui/src/editor_hover_bench.rs new file mode 100644 index 0000000000..4325410ada --- /dev/null +++ b/apps/desktop-gpui/src/editor_hover_bench.rs @@ -0,0 +1,144 @@ +//! `CAP_GPUI_AUTO_HOVER_BENCH=1` (with `CAP_GPUI_AUTO_EDITOR`): the editor's +//! per-move cost while the pointer glides over a paused timeline. The pointer +//! sweeps the timeline at a fixed speed and moves arrive at +//! `CAP_GPUI_AUTO_HOVER_BENCH_HZ` (default 120); a move that lands while the +//! previous one is still being handled is coalesced into the next, as the OS +//! does. Each move runs the timeline's own hover handler, then one synchronous +//! `Window::draw` (view renders + layout + prepaint + paint) is timed with it. +//! `CAP_GPUI_AUTO_SIDEBAR` picks the sidebar tab that stays open meanwhile. +//! +//! Run: +//! CAP_GPUI_AUTO_EDITOR= CAP_GPUI_AUTO_HOVER_BENCH=1 \ +//! CAP_GPUI_AUTO_SIDEBAR=captions CAP_GPUI_NO_MENUS=1 CAP_GPUI_NO_TRAY=1 \ +//! cargo run --release + +use std::time::{Duration, Instant}; + +use gpui::{App, WindowHandle}; + +use crate::editor_window::EditorWindow; + +const DEFAULT_MOUSE_HZ: u64 = 120; +const DEFAULT_SWEEP_SECS: f32 = 3.; +const WARMUP: Duration = Duration::from_secs(1); +const MEASURED: Duration = Duration::from_secs(6); + +pub async fn run(handle: WindowHandle, cx: &mut gpui::AsyncApp) { + if !std::env::var("CAP_GPUI_AUTO_HOVER_BENCH").is_ok_and(|value| value == "1") { + return; + } + cx.background_executor() + .timer(Duration::from_millis(2500)) + .await; + cx.update(|cx| { + cx.activate(true); + handle + .update(cx, |_, window, _| window.activate_window()) + .ok(); + }); + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + + let mouse_hz = std::env::var("CAP_GPUI_AUTO_HOVER_BENCH_HZ") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|hz| *hz > 0) + .unwrap_or(DEFAULT_MOUSE_HZ); + let interval = Duration::from_micros(1_000_000 / mouse_hz); + let sweep_secs = std::env::var("CAP_GPUI_AUTO_HOVER_BENCH_SWEEP_SECS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|secs| *secs > 0.) + .unwrap_or(DEFAULT_SWEEP_SECS); + let started = Instant::now(); + let mut next_move = started; + let mut move_us = Vec::new(); + let mut frames_at_start = None; + loop { + next_move += interval; + let now = Instant::now(); + if next_move > now { + cx.background_executor() + .timer(next_move.saturating_duration_since(now)) + .await; + } else { + next_move = now; + // An overloaded loop must still give the preview pump and the run + // loop a turn between moves, as real event dispatch does. + tokio::task::yield_now().await; + } + let elapsed = started.elapsed(); + if elapsed >= WARMUP + MEASURED { + break; + } + let measuring = elapsed >= WARMUP; + if measuring && frames_at_start.is_none() { + frames_at_start = cx.update(|cx| frames_presented(handle, cx)); + } + let phase = (elapsed.as_secs_f32() / sweep_secs).fract(); + // Two separate updates: observers of the editor (its section views) + // only hear about the move once the first update flushes its effects, + // and nothing yields to the run loop in between, so the platform + // cannot draw the frame before it is timed here. + let move_started = Instant::now(); + let hovered = cx.update(|cx| { + handle + .update(cx, |view, window, cx| { + let width = f32::from(window.viewport_size().width); + let x = 140. + (width - 180.).max(1.) * phase; + view.timeline_hover(x, window, cx); + }) + .is_ok() + }); + let drawn = cx.update(|cx| draw_editor_window(handle, cx)); + if !hovered || drawn.is_none() { + eprintln!("hover-bench: the editor window went away"); + std::process::exit(1); + } + if measuring { + move_us.push(move_started.elapsed().as_secs_f64() * 1e6); + } + } + let frames = cx + .update(|cx| frames_presented(handle, cx)) + .zip(frames_at_start) + .map(|(end, start)| end.saturating_sub(start)); + + println!( + "hover-bench: sidebar tab {}, pointer at {mouse_hz}Hz crossing the timeline in {sweep_secs}s, {} moves handled and {frames:?} preview frames presented in {:.1}s", + std::env::var("CAP_GPUI_AUTO_SIDEBAR").unwrap_or_else(|_| "default".into()), + move_us.len(), + MEASURED.as_secs_f64(), + ); + report("move", &mut move_us); + std::process::exit(0); +} + +fn frames_presented(handle: WindowHandle, cx: &mut App) -> Option { + handle + .update(cx, |view, _, _| view.preview_frames_presented()) + .ok() + .flatten() +} + +fn report(label: &str, samples: &mut [f64]) { + if samples.is_empty() { + println!("hover-bench: no {label} samples"); + return; + } + samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN draw times")); + let mean = samples.iter().sum::() / samples.len() as f64; + let p50 = samples[samples.len() / 2]; + let p95 = samples[(samples.len() as f64 * 0.95) as usize]; + let max = samples[samples.len() - 1]; + println!("hover-bench: {label} {mean:.1} mean / {p50:.1} p50 / {p95:.1} p95 / {max:.1} max us"); +} + +/// `AnyWindowHandle::update` hands over the root as an unleased `AnyView`; +/// the typed handle would lease the root entity that `draw()` re-renders. +fn draw_editor_window(handle: WindowHandle, cx: &mut App) -> Option<()> { + gpui::AnyWindowHandle::from(handle) + .update(cx, |_, window, cx| window.draw(cx).clear()) + .ok() +} diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index d88e57ac19..dd506e4388 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -3769,9 +3769,11 @@ impl EditorWindow { // scene-mode pane gates on it too. Notifying the parent here would // rebuild every cached section -- header, toolbar, sidebar included -- // at the 60Hz playhead rate, which is the same class of rebuild that - // measured 40ms/frame on the text-panel sidebar. Paused paths (hover, - // seeks) keep the full invalidation: the overlay's element boxes - // appear and disappear as the playhead crosses segments. + // measured 40ms/frame on the text-panel sidebar. A paused playhead + // change keeps the full invalidation: the overlay's element boxes + // appear and disappear as the playhead crosses segments. Pointer hover + // goes through `invalidate_hover_chrome` instead, and an arriving + // paused frame whose layout changed still notifies the whole editor. if !self.playing { cx.notify(); } @@ -3792,10 +3794,16 @@ impl EditorWindow { ); } + pub(crate) fn preview_frames_presented(&self) -> Option { + self.stats + .as_ref() + .map(|stats| stats.presented.load(Ordering::Relaxed)) + } + /// `onMouseMove` on the timeline container (`TL/index.tsx:1170-1188`): /// while paused, the pointer's time becomes `previewTime`; outside the /// content column, and at all times while playing, it is cleared. - fn timeline_hover(&mut self, x: f32, window: &mut Window, cx: &mut Context) { + pub(crate) fn timeline_hover(&mut self, x: f32, window: &mut Window, cx: &mut Context) { // `if (editorState.playing) return;` -- the handler bails *before* it // writes, so a preview time set while paused survives a play rather // than being cleared. The ghost is hidden by the render's own @@ -3808,17 +3816,28 @@ impl EditorWindow { if next != self.view.preview_time { self.view.preview_time = next; self.emit_preview_frame(); - self.invalidate_playback_chrome(window, cx); + self.invalidate_hover_chrome(cx); } } - fn timeline_hover_leave(&mut self, window: &mut Window, cx: &mut Context) { + fn timeline_hover_leave(&mut self, _window: &mut Window, cx: &mut Context) { if self.playing || self.view.preview_time.is_none() { return; } self.view.preview_time = None; self.emit_preview_frame(); - self.invalidate_playback_chrome(window, cx); + self.invalidate_hover_chrome(cx); + } + + /// Pointer-hover state that only the timeline, the transport and the + /// canvas overlay read. Notifying `self` would rebuild the header, toolbar + /// and sidebar on every pointer move, because each section observes the + /// editor. Notifying a child view marks its ancestors dirty instead, so + /// this window and its canvas overlay still re-render while the untouched + /// sections are reused from their cache. + fn invalidate_hover_chrome(&mut self, cx: &mut Context) { + self.timeline_view.update(cx, |_, cx| cx.notify()); + self.transport_controls.update(cx, |_, cx| cx.notify()); } fn set_hovered_track( @@ -5922,7 +5941,7 @@ impl EditorWindow { changed = true; } if changed { - cx.notify(); + self.invalidate_hover_chrome(cx); } } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 0d2ba82f25..98acecf80d 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -28,6 +28,7 @@ mod editor_color; mod editor_crop; mod editor_edits; mod editor_export; +mod editor_hover_bench; #[cfg(target_os = "linux")] mod editor_modal; mod editor_panels;