Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop-gpui/src/app_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
144 changes: 144 additions & 0 deletions apps/desktop-gpui/src/editor_hover_bench.rs
Original file line number Diff line number Diff line change
@@ -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=<project.cap> 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<EditorWindow>, 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::<u64>().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::<f32>().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<EditorWindow>, cx: &mut App) -> Option<u64> {
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::<f64>() / 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<EditorWindow>, cx: &mut App) -> Option<()> {
gpui::AnyWindowHandle::from(handle)
.update(cx, |_, window, cx| window.draw(cx).clear())
.ok()
}
35 changes: 27 additions & 8 deletions apps/desktop-gpui/src/editor_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -3792,10 +3794,16 @@ impl EditorWindow {
);
}

pub(crate) fn preview_frames_presented(&self) -> Option<u64> {
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<Self>) {
pub(crate) fn timeline_hover(&mut self, x: f32, window: &mut Window, cx: &mut Context<Self>) {
// `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
Expand All @@ -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<Self>) {
fn timeline_hover_leave(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
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>) {
self.timeline_view.update(cx, |_, cx| cx.notify());
self.transport_controls.update(cx, |_, cx| cx.notify());
}

fn set_hovered_track(
Expand Down Expand Up @@ -5922,7 +5941,7 @@ impl EditorWindow {
changed = true;
}
if changed {
cx.notify();
self.invalidate_hover_chrome(cx);
}
}

Expand Down
1 change: 1 addition & 0 deletions apps/desktop-gpui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading