From d8301e628bc5676ac858b709f94cf4cc2dde2e6a Mon Sep 17 00:00:00 2001 From: dovidmoishe Date: Thu, 15 Oct 2026 05:21:58 +0100 Subject: [PATCH 1/3] feat(desktop-gpui): add editor watch-preview overlay Lift the editor player into a centred rounded card beside Split so recordings can be watched without the rest of the chrome. --- apps/desktop-gpui/assets/icons/minimize.svg | 3 + apps/desktop-gpui/src/assets.rs | 3 + apps/desktop-gpui/src/editor_canvas.rs | 5 +- apps/desktop-gpui/src/editor_crop.rs | 1 + apps/desktop-gpui/src/editor_export.rs | 1 + apps/desktop-gpui/src/editor_watch.rs | 506 ++++++++++++++++++++ apps/desktop-gpui/src/editor_window.rs | 76 ++- apps/desktop-gpui/src/main.rs | 1 + 8 files changed, 591 insertions(+), 5 deletions(-) create mode 100644 apps/desktop-gpui/assets/icons/minimize.svg create mode 100644 apps/desktop-gpui/src/editor_watch.rs diff --git a/apps/desktop-gpui/assets/icons/minimize.svg b/apps/desktop-gpui/assets/icons/minimize.svg new file mode 100644 index 00000000000..665b9e9657e --- /dev/null +++ b/apps/desktop-gpui/assets/icons/minimize.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 73e89942082..179e9e62f0b 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -159,6 +159,7 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "download.svg", "flip-vertical-2.svg", "maximize.svg", + "minimize.svg", "mouse-pointer-2.svg", "mouse-pointer-ban.svg", "move.svg", @@ -324,6 +325,8 @@ mod tests { include_str!("editor_clips.rs"), // The export page's destination cards and share-status glyphs. include_str!("editor_export.rs"), + // The transport's watch-preview toggle. + include_str!("editor_watch.rs"), // The screenshot editor's toolbar, popovers and zoom HUD; the // annotation module names the tool and layer glyphs. include_str!("screenshot_editor.rs"), diff --git a/apps/desktop-gpui/src/editor_canvas.rs b/apps/desktop-gpui/src/editor_canvas.rs index 14618f7bea7..218ee987176 100644 --- a/apps/desktop-gpui/src/editor_canvas.rs +++ b/apps/desktop-gpui/src/editor_canvas.rs @@ -508,7 +508,10 @@ impl EditorWindow { /// `overlayVisible()` (`:238`): only while paused, and only once the /// renderer has reported a layout. pub(crate) fn canvas_overlay_visible(&self) -> bool { - !self.playing && self.frame_layout.is_some() && self.crop.is_none() + !self.playing + && self.frame_layout.is_some() + && self.crop.is_none() + && !self.watch_preview_open } fn scene_mode_at(&self, time: f64) -> SceneMode { diff --git a/apps/desktop-gpui/src/editor_crop.rs b/apps/desktop-gpui/src/editor_crop.rs index 412b8f20c91..c912a3855a1 100644 --- a/apps/desktop-gpui/src/editor_crop.rs +++ b/apps/desktop-gpui/src/editor_crop.rs @@ -1495,6 +1495,7 @@ impl EditorWindow { if self.crop.is_some() { return; } + self.dismiss_watch_preview(cx); let Some(target) = self.display_resolution() else { tracing::warn!("crop: no display recording to crop"); return; diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index abbf6d98e15..42274c07db3 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -478,6 +478,7 @@ impl EditorWindow { } pub(crate) fn open_export(&mut self, window: &mut Window, cx: &mut Context) { + self.dismiss_watch_preview(cx); if self.playing { self.toggle_play_from_crop(cx); } diff --git a/apps/desktop-gpui/src/editor_watch.rs b/apps/desktop-gpui/src/editor_watch.rs new file mode 100644 index 00000000000..0364e80c7a5 --- /dev/null +++ b/apps/desktop-gpui/src/editor_watch.rs @@ -0,0 +1,506 @@ +//! The watch preview: the player's enlarge control lifts the composited frame +//! out of the stage into a centred, rounded card over a dimmed editor, and +//! puts it back on Escape, a click on the wash, or the control itself. +//! +//! The card is a FLIP from the stage's own letterboxed rect, so the picture +//! grows out of where it already was. While the overlay is mounted the stage +//! keeps measuring its rect but stops painting, so there is never a second +//! copy of the frame on screen. +//! +//! The wash is a plain dim: this gpui rev has no backdrop blur. + +use std::time::{Duration, Instant}; + +use gpui::{ + Bounds, Context, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, Render, + SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, div, + prelude::FluentBuilder, px, svg, +}; + +use crate::{ + editor_window::{EditorPreviewFrame, EditorWindow, PREVIEW_RADIUS, ease_out_quint}, + ui, +}; + +pub(crate) const WATCH_OPEN_DURATION: Duration = Duration::from_millis(220); +pub(crate) const WATCH_CLOSE_DURATION: Duration = Duration::from_millis(180); +const WATCH_PADDING: f32 = 40.; +const WATCH_RADIUS: f32 = 16.; +const WATCH_BACKDROP_ALPHA: f32 = 0.62; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum WatchPhase { + Opening, + Closing { from_progress: f32 }, +} + +#[derive(Debug, Clone, Copy)] +struct WatchMotion { + phase: WatchPhase, + started: Instant, + /// The stage's painted frame rect when the motion started, in window + /// coordinates. `None` when the stage has not painted yet, in which case + /// the card only fades. + origin: Option>, +} + +impl WatchMotion { + fn progress(&self, now: Instant, reduce_motion: bool) -> f32 { + let elapsed = now.saturating_duration_since(self.started); + watch_progress(self.phase, elapsed, reduce_motion) + } + + fn settled(&self, now: Instant, reduce_motion: bool) -> bool { + let elapsed = now.saturating_duration_since(self.started); + reduce_motion || elapsed >= phase_duration(self.phase) + } +} + +fn phase_duration(phase: WatchPhase) -> Duration { + match phase { + WatchPhase::Opening => WATCH_OPEN_DURATION, + WatchPhase::Closing { from_progress } => { + WATCH_CLOSE_DURATION.mul_f32(from_progress.clamp(0., 1.)) + } + } +} + +/// How far the card is from the stage (0) to its watch position (1). +fn watch_progress(phase: WatchPhase, elapsed: Duration, reduce_motion: bool) -> f32 { + match phase { + WatchPhase::Opening => { + if reduce_motion { + return 1.; + } + let t = (elapsed.as_secs_f32() / WATCH_OPEN_DURATION.as_secs_f32()).clamp(0., 1.); + ease_out_quint(t) + } + WatchPhase::Closing { from_progress } => { + let duration = phase_duration(phase); + if reduce_motion || duration.is_zero() { + return 0.; + } + let t = (elapsed.as_secs_f32() / duration.as_secs_f32()).clamp(0., 1.); + from_progress * (1. - ease_out_quint(t)) + } + } +} + +/// The card's resting rect: the frame fitted, aspect intact, inside the window +/// less [`WATCH_PADDING`] on every side, and centred. +fn watch_target(viewport: (f32, f32), frame: (f32, f32)) -> Bounds { + let available = ( + (viewport.0 - WATCH_PADDING * 2.).max(0.), + (viewport.1 - WATCH_PADDING * 2.).max(0.), + ); + let (width, height) = contain(available, frame); + Bounds { + origin: gpui::point( + px((viewport.0 - width) / 2.), + px((viewport.1 - height) / 2.), + ), + size: gpui::size(px(width), px(height)), + } +} + +fn contain(available: (f32, f32), frame: (f32, f32)) -> (f32, f32) { + let (width, height) = available; + if width <= 0. || height <= 0. { + return (0., 0.); + } + if frame.0 <= 0. || frame.1 <= 0. { + return (width, height); + } + let aspect = frame.0 / frame.1; + if aspect < width / height { + (height * aspect, height) + } else { + (width, width / aspect) + } +} + +fn lerp(from: f32, to: f32, t: f32) -> f32 { + from + (to - from) * t +} + +fn lerp_bounds(from: Bounds, to: Bounds, t: f32) -> Bounds { + let mix = |a: Pixels, b: Pixels| px(lerp(f32::from(a), f32::from(b), t)); + Bounds { + origin: gpui::point( + mix(from.origin.x, to.origin.x), + mix(from.origin.y, to.origin.y), + ), + size: gpui::size( + mix(from.size.width, to.size.width), + mix(from.size.height, to.size.height), + ), + } +} + +/// The overlay's own view. It is fed every frame the stage gets while it is +/// mounted, so playback keeps moving inside the card without re-rendering the +/// rest of the editor. +pub(crate) struct WatchView { + editor: WeakEntity, + frame: Option, + frame_size: (f32, f32), + motion: Option, +} + +impl WatchView { + pub(crate) fn new(editor: WeakEntity) -> Self { + Self { + editor, + frame: None, + frame_size: (1920., 1080.), + motion: None, + } + } + + fn open( + &mut self, + frame: EditorPreviewFrame, + frame_size: (f32, f32), + origin: Option>, + cx: &mut Context, + ) { + self.frame = Some(frame); + self.frame_size = frame_size; + self.motion = Some(WatchMotion { + phase: WatchPhase::Opening, + started: Instant::now(), + origin, + }); + cx.notify(); + } + + /// Starts the return to the stage and reports how long it will take. + fn close( + &mut self, + origin: Option>, + reduce_motion: bool, + cx: &mut Context, + ) -> Duration { + let now = Instant::now(); + let from_progress = self + .motion + .map_or(1., |motion| motion.progress(now, reduce_motion)); + let phase = WatchPhase::Closing { from_progress }; + self.motion = Some(WatchMotion { + phase, + started: now, + origin: origin.or(self.motion.and_then(|motion| motion.origin)), + }); + cx.notify(); + if reduce_motion { + Duration::ZERO + } else { + phase_duration(phase) + } + } + + pub(crate) fn set_frame( + &mut self, + frame: EditorPreviewFrame, + frame_size: (f32, f32), + cx: &mut Context, + ) { + self.frame = Some(frame); + self.frame_size = frame_size; + cx.notify(); + } + + fn clear(&mut self, cx: &mut Context) { + self.frame = None; + self.motion = None; + cx.notify(); + } +} + +impl Render for WatchView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let (Some(motion), Some(frame)) = (self.motion, self.frame.clone()) else { + return div().into_any_element(); + }; + let reduce_motion = cx.reduce_motion(); + let now = Instant::now(); + let progress = motion.progress(now, reduce_motion); + if !motion.settled(now, reduce_motion) { + window.request_animation_frame(); + } + + let viewport = window.viewport_size(); + let target = watch_target( + (f32::from(viewport.width), f32::from(viewport.height)), + self.frame_size, + ); + let card = lerp_bounds(motion.origin.unwrap_or(target), target, progress); + let card_opacity = if motion.origin.is_some() { + 1. + } else { + progress + }; + let radius = px(lerp(PREVIEW_RADIUS, WATCH_RADIUS, progress)); + let shadow = crate::theme::preview_shadow(); + let editor = self.editor.clone(); + + div() + .absolute() + .inset_0() + .occlude() + .child( + div() + .id("watch-preview-backdrop") + .absolute() + .inset_0() + .occlude() + .bg(gpui::hsla(0., 0., 0., WATCH_BACKDROP_ALPHA * progress)) + .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) + .on_click(move |_, window, cx| { + editor + .update(cx, |editor, cx| editor.close_watch_preview(window, cx)) + .ok(); + }), + ) + .child( + div() + .id("watch-preview-card") + .absolute() + .left(card.origin.x) + .top(card.origin.y) + .w(card.size.width) + .h(card.size.height) + .occlude() + .opacity(card_opacity) + .child( + gpui::canvas( + |bounds, _window, _cx| bounds, + move |_, bounds, window, _cx| { + let corners = gpui::Corners::all(radius); + window.paint_drop_shadows(bounds, corners, &shadow); + window.paint_quad( + gpui::fill(bounds, gpui::black()).corner_radii(radius), + ); + frame.paint(bounds, radius, window); + window.paint_quad(gpui::quad( + bounds, + corners, + gpui::transparent_black(), + gpui::Edges::all(px(1.)), + gpui::hsla(0., 0., 1., 0.08), + gpui::BorderStyle::Solid, + )); + }, + ) + .size_full(), + ), + ) + .into_any_element() + } +} + +impl EditorWindow { + pub(crate) fn watch_preview_expanded(&self) -> bool { + self.watch_preview_open && self.watch_preview_closing.is_none() + } + + pub(crate) fn toggle_watch_preview(&mut self, window: &mut Window, cx: &mut Context) { + if self.watch_preview_expanded() { + self.close_watch_preview(window, cx); + } else if !self.watch_preview_open { + self.open_watch_preview(window, cx); + } + } + + fn open_watch_preview(&mut self, window: &mut Window, cx: &mut Context) { + if self.watch_preview_open || self.crop.is_some() || !self.project_ready() { + return; + } + let Some(frame) = self.latest_frame.clone() else { + return; + }; + let frame_size = self + .frame_layout + .map(|layout| (layout.output_size[0] as f32, layout.output_size[1] as f32)) + .unwrap_or((1920., 1080.)); + let origin = self.player_frame_rect.get().map(|player| player.frame); + + self.end_field_edit(cx); + self.close_color_picker(cx); + self.dismiss_frame_controls(cx); + self.dismiss_transient_menus(); + window.focus(&self.focus, cx); + + self.watch_preview_open = true; + self.watch_preview + .update(cx, |watch, cx| watch.open(frame, frame_size, origin, cx)); + self.set_stage_frame_hidden(true, cx); + cx.notify(); + } + + pub(crate) fn close_watch_preview(&mut self, window: &mut Window, cx: &mut Context) { + if !self.watch_preview_expanded() { + return; + } + let origin = self.player_frame_rect.get().map(|player| player.frame); + let reduce_motion = cx.reduce_motion(); + let remaining = self + .watch_preview + .update(cx, |watch, cx| watch.close(origin, reduce_motion, cx)); + self.watch_preview_closing = Some(cx.spawn_in(window, async move |this, cx| { + if !remaining.is_zero() { + cx.background_executor().timer(remaining).await; + } + this.update(cx, |this, cx| this.finish_watch_preview(cx)) + .ok(); + })); + cx.notify(); + } + + /// Unmounts the overlay without the return motion, for surfaces that + /// replace the player outright. + pub(crate) fn dismiss_watch_preview(&mut self, cx: &mut Context) { + if self.watch_preview_open { + self.finish_watch_preview(cx); + } + } + + fn finish_watch_preview(&mut self, cx: &mut Context) { + self.watch_preview_closing = None; + self.watch_preview_open = false; + self.watch_preview.update(cx, |watch, cx| watch.clear(cx)); + self.set_stage_frame_hidden(false, cx); + cx.notify(); + } + + /// The transport's icon-only toggle, next to Split. + pub(crate) fn render_watch_preview_button(&self, cx: &mut Context) -> impl IntoElement { + let theme = self.theme; + let expanded = self.watch_preview_expanded(); + let enabled = self.project_ready() && self.latest_frame.is_some(); + let (icon, label) = if expanded { + ("icons/minimize.svg", "Exit preview") + } else { + ("icons/enlarge.svg", "Watch preview") + }; + let group = SharedString::from("transport-watch-preview-group"); + div() + .id("transport-watch-preview") + .group(group.clone()) + .flex() + .items_center() + .justify_center() + .size(px(28.)) + .flex_none() + .rounded(px(7.)) + .when(!enabled, |this| this.opacity(0.5)) + .when(enabled, |this| { + this.cursor_pointer() + .hover(|style| style.bg(Hsla::from(theme.editor.ctl_hover))) + .active(|style| style.bg(Hsla::from(theme.editor.ctl_active))) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_watch_preview(window, cx); + })) + }) + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) + .tooltip(move |_window, cx| { + let tooltip = ui::Tooltip::new(&theme, label); + if expanded { + tooltip.keys(["escape"]).view(cx) + } else { + tooltip.view(cx) + } + }) + .child( + svg() + .path(icon) + .size(px(16.)) + .text_color(Hsla::from(theme.editor.text_2)) + .group_hover(group, move |style| { + style.text_color(Hsla::from(theme.editor.text_1)) + }), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rect(bounds: Bounds) -> (f32, f32, f32, f32) { + ( + f32::from(bounds.origin.x), + f32::from(bounds.origin.y), + f32::from(bounds.size.width), + f32::from(bounds.size.height), + ) + } + + #[test] + fn a_wide_frame_fills_the_padded_width_and_centres_vertically() { + let (x, y, w, h) = rect(watch_target((1280., 800.), (1920., 1080.))); + assert_eq!((x, w), (40., 1200.)); + assert!((h - 675.).abs() < 0.01); + assert!((y - (800. - 675.) / 2.).abs() < 0.01); + } + + #[test] + fn a_tall_frame_fills_the_padded_height_and_centres_horizontally() { + let (x, y, w, h) = rect(watch_target((1280., 800.), (1080., 1920.))); + assert_eq!((y, h), (40., 720.)); + assert!((w - 405.).abs() < 0.01); + assert!((x - (1280. - 405.) / 2.).abs() < 0.01); + } + + #[test] + fn a_square_frame_is_bounded_by_the_shorter_side() { + let (x, y, w, h) = rect(watch_target((1280., 800.), (1080., 1080.))); + assert_eq!((w, h), (720., 720.)); + assert_eq!((x, y), (280., 40.)); + } + + #[test] + fn a_window_smaller_than_the_padding_collapses_instead_of_going_negative() { + let (_, _, w, h) = rect(watch_target((60., 60.), (1920., 1080.))); + assert_eq!((w, h), (0., 0.)); + } + + #[test] + fn opening_runs_from_the_stage_to_the_target() { + let opening = WatchPhase::Opening; + assert_eq!(watch_progress(opening, Duration::ZERO, false), 0.); + assert_eq!(watch_progress(opening, WATCH_OPEN_DURATION, false), 1.); + assert_eq!(watch_progress(opening, WATCH_OPEN_DURATION * 2, false), 1.); + assert_eq!(watch_progress(opening, Duration::from_secs(60), false), 1.); + assert_eq!(watch_progress(opening, Duration::ZERO, true), 1.); + } + + #[test] + fn closing_returns_from_wherever_the_open_had_reached() { + let full = WatchPhase::Closing { from_progress: 1. }; + assert_eq!(watch_progress(full, Duration::ZERO, false), 1.); + assert_eq!(watch_progress(full, WATCH_CLOSE_DURATION, false), 0.); + + let partial = WatchPhase::Closing { from_progress: 0.5 }; + assert_eq!(phase_duration(partial), WATCH_CLOSE_DURATION / 2); + assert_eq!(watch_progress(partial, Duration::ZERO, false), 0.5); + assert_eq!(watch_progress(partial, WATCH_CLOSE_DURATION, false), 0.); + + assert_eq!(watch_progress(full, Duration::ZERO, true), 0.); + } + + #[test] + fn bounds_interpolate_between_both_ends() { + let from = Bounds { + origin: gpui::point(px(100.), px(200.)), + size: gpui::size(px(400.), px(225.)), + }; + let to = Bounds { + origin: gpui::point(px(40.), px(62.5)), + size: gpui::size(px(1200.), px(675.)), + }; + assert_eq!(lerp_bounds(from, to, 0.), from); + assert_eq!(lerp_bounds(from, to, 1.), to); + assert_eq!(rect(lerp_bounds(from, to, 0.5)), (70., 131.25, 800., 450.)); + } +} diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 63c57330865..cdcbc115f4a 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -592,7 +592,7 @@ impl EditorPreviewFrame { } /// The stage's rounded preview: radius and the hairline ring around it. -const PREVIEW_RADIUS: f32 = 6.; +pub(crate) const PREVIEW_RADIUS: f32 = 6.; /// The breathing room between the fitted frame and the card. [`letterbox`] -- /// which the crop dialog shares -- already eats `PLAYER_CANVAS_PADDING`, so the @@ -609,6 +609,10 @@ struct PreviewFrameView { /// The stage's lift under the preview. A canvas paints raw quads, so the /// shadow cannot come from a style and is carried here instead. shadow: Vec, + /// Set while the watch overlay is showing the picture: the stage still + /// measures its rect, which is where the overlay animates back to, but + /// paints nothing. + hidden: bool, } impl PreviewFrameView { @@ -620,6 +624,14 @@ impl PreviewFrameView { stats: None, frame_sequence: 0, shadow: crate::theme::preview_shadow(), + hidden: false, + } + } + + fn set_hidden(&mut self, hidden: bool, cx: &mut Context) { + if self.hidden != hidden { + self.hidden = hidden; + cx.notify(); } } @@ -649,6 +661,7 @@ impl Render for PreviewFrameView { let painted = self.stats.clone(); let frame_sequence = self.frame_sequence; let shadow = self.shadow.clone(); + let hidden = self.hidden; gpui::canvas( |bounds, _window, _cx| bounds, @@ -673,6 +686,9 @@ impl Render for PreviewFrameView { container: bounds, frame: fitted, })); + if hidden { + return; + } let radius = px(PREVIEW_RADIUS); window.paint_drop_shadows(fitted, gpui::Corners::all(radius), &shadow); window.paint_quad(gpui::fill(fitted, gpui::black()).corner_radii(radius)); @@ -1375,6 +1391,11 @@ pub struct EditorWindow { preparing_seed: Option>, pub(crate) latest_frame: Option, preview: Entity, + pub(crate) watch_preview: Entity, + /// The watch overlay is mounted, from the open until its return motion + /// lands back on the stage. + pub(crate) watch_preview_open: bool, + pub(crate) watch_preview_closing: Option>, header: Entity, toolbar: Entity, transport_controls: Entity, @@ -1384,7 +1405,7 @@ pub struct EditorWindow { /// Kept alive for the window's lifetime: dropping the last `Arc` is what /// tears the decoders down, and `dispose()` on close does it explicitly. pub(crate) instance: Option>, - focus: FocusHandle, + pub(crate) focus: FocusHandle, // -- Transport ---------------------------------------------------------- /// `editorState.playing`. @@ -1653,7 +1674,7 @@ const ADD_TRACK_TILE_STAGGER_MS: f32 = 24.; const ADD_TRACK_TILE_IN_MS: f32 = 320.; const ADD_TRACK_TILE_SLIDE: f32 = 10.; -fn ease_out_quint(t: f32) -> f32 { +pub(crate) fn ease_out_quint(t: f32) -> f32 { 1. - (1. - t).powi(5) } @@ -2012,6 +2033,10 @@ impl EditorWindow { let player_frame_rect = crate::editor_canvas::canvas_rect_cell(); let preview = cx.new(|_| PreviewFrameView::new(player_frame_rect.clone())); let editor = cx.entity(); + let watch_preview = cx.new({ + let editor = editor.downgrade(); + move |_| crate::editor_watch::WatchView::new(editor) + }); let header = cx.new({ let editor = editor.clone(); move |cx| EditorSectionView::new(&editor, EditorSection::Header, cx) @@ -2059,6 +2084,9 @@ impl EditorWindow { preparing_seed: None, latest_frame: None, preview, + watch_preview, + watch_preview_open: false, + watch_preview_closing: None, header, toolbar, transport_controls, @@ -2630,6 +2658,21 @@ impl EditorWindow { self.instance.is_some() && matches!(&self.state, LoadState::Ready(_)) } + pub(crate) fn set_stage_frame_hidden(&mut self, hidden: bool, cx: &mut Context) { + self.preview + .update(cx, |preview, cx| preview.set_hidden(hidden, cx)); + } + + /// Popovers that would otherwise stay open underneath a full-window + /// overlay. + pub(crate) fn dismiss_transient_menus(&mut self) { + self.sidebar.menu = None; + self.toolbar_menu = None; + self.add_track = None; + self.clip_speed = None; + self.presets_menu = None; + } + #[allow(dead_code)] /// The live project config, for the units that render from it (the config /// sidebar's controls) or serialise it (export). @@ -3340,6 +3383,15 @@ impl EditorWindow { if !self.visual_ready() { return; } + // The overlay covers every surface these shortcuts act on; Space still + // plays because it is taken in the capture phase. + if self.watch_preview_open { + if event.keystroke.key == "escape" && !event.is_held { + self.close_watch_preview(window, cx); + } + cx.stop_propagation(); + return; + } if self.frame_controls.is_open() && event.keystroke.key == "escape" { self.close_frame_controls(window, cx); cx.stop_propagation(); @@ -6336,11 +6388,25 @@ impl EditorWindow { frame.layout.output_size[1] as f32, ); self.latest_frame = Some(frame.frame.clone()); + let watch_frame = self.watch_preview_open.then(|| frame.frame.clone()); let previous = self.preview.update(cx, |preview, cx| { preview.set_frame(frame.frame, frame_size, self.stats.clone(), cx) }); + if let Some(watch_frame) = watch_frame { + self.watch_preview.update(cx, |watch, cx| { + watch.set_frame(watch_frame, frame_size, cx); + }); + } if let Some(EditorPreviewFrame::Image(previous)) = previous { - let _ = window.drop_image(previous); + if self.watch_preview_open { + // The overlay still paints this tile until its next draw; + // dropping it here blanks the card on Windows wgpu. + cx.on_next_frame(window, move |_, window, _| { + let _ = window.drop_image(previous); + }); + } else { + let _ = window.drop_image(previous); + } } if let Some(stats) = &self.stats { stats.presented.fetch_add(1, Ordering::Relaxed); @@ -10124,6 +10190,7 @@ impl EditorWindow { )), ), ) + .child(self.render_watch_preview_button(cx)) .child(self.header_divider()) // `IconCapZoomOut` -> `updateZoom(zoom * 1.1, // playbackTime)`; `IconCapZoomIn` -> `zoom / 1.1` @@ -11636,6 +11703,7 @@ impl Render for EditorWindow { // The clips overlays: import menu, record modal, and the card // drag's window-wide layer with its floating ghost. .children(self.render_clips_overlays(cx)) + .children(self.watch_preview_open.then(|| self.watch_preview.clone())) // Crop mode is a modal: it goes over everything, including the // menu above. .children(self.render_crop_dialog(cx)) diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index ea640335174..44b27be7e51 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -34,6 +34,7 @@ mod editor_preparing; mod editor_sidebar; mod editor_tabs; mod editor_timeline; +mod editor_watch; mod editor_window; mod feeds; mod hotkeys; From 527159448a4dc972827006f1ab1d448e8511b312 Mon Sep 17 00:00:00 2001 From: dovidmoishe Date: Thu, 15 Oct 2026 05:58:23 +0100 Subject: [PATCH 2/3] fix(desktop-gpui): record watch overlay paints in playback stats Count frames on the overlay canvas while the stage is hidden, and drop comments that only narrate the feature. --- apps/desktop-gpui/src/assets.rs | 1 - apps/desktop-gpui/src/editor_watch.rs | 49 ++++++++++++++------------ apps/desktop-gpui/src/editor_window.rs | 13 +++++-- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 179e9e62f0b..488308ff679 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -325,7 +325,6 @@ mod tests { include_str!("editor_clips.rs"), // The export page's destination cards and share-status glyphs. include_str!("editor_export.rs"), - // The transport's watch-preview toggle. include_str!("editor_watch.rs"), // The screenshot editor's toolbar, popovers and zoom HUD; the // annotation module names the tool and layer glyphs. diff --git a/apps/desktop-gpui/src/editor_watch.rs b/apps/desktop-gpui/src/editor_watch.rs index 0364e80c7a5..bc0e1faf674 100644 --- a/apps/desktop-gpui/src/editor_watch.rs +++ b/apps/desktop-gpui/src/editor_watch.rs @@ -1,14 +1,7 @@ -//! The watch preview: the player's enlarge control lifts the composited frame -//! out of the stage into a centred, rounded card over a dimmed editor, and -//! puts it back on Escape, a click on the wash, or the control itself. -//! -//! The card is a FLIP from the stage's own letterboxed rect, so the picture -//! grows out of where it already was. While the overlay is mounted the stage -//! keeps measuring its rect but stops painting, so there is never a second -//! copy of the frame on screen. -//! -//! The wash is a plain dim: this gpui rev has no backdrop blur. +//! This gpui rev has no backdrop blur, so the wash is a dim. The stage keeps +//! measuring its rect while hidden so the card can animate back to it. +use std::sync::Arc; use std::time::{Duration, Instant}; use gpui::{ @@ -18,7 +11,7 @@ use gpui::{ }; use crate::{ - editor_window::{EditorPreviewFrame, EditorWindow, PREVIEW_RADIUS, ease_out_quint}, + editor_window::{EditorPreviewFrame, EditorWindow, PREVIEW_RADIUS, PumpStats, ease_out_quint}, ui, }; @@ -65,7 +58,6 @@ fn phase_duration(phase: WatchPhase) -> Duration { } } -/// How far the card is from the stage (0) to its watch position (1). fn watch_progress(phase: WatchPhase, elapsed: Duration, reduce_motion: bool) -> f32 { match phase { WatchPhase::Opening => { @@ -86,8 +78,6 @@ fn watch_progress(phase: WatchPhase, elapsed: Duration, reduce_motion: bool) -> } } -/// The card's resting rect: the frame fitted, aspect intact, inside the window -/// less [`WATCH_PADDING`] on every side, and centred. fn watch_target(viewport: (f32, f32), frame: (f32, f32)) -> Bounds { let available = ( (viewport.0 - WATCH_PADDING * 2.).max(0.), @@ -137,14 +127,13 @@ fn lerp_bounds(from: Bounds, to: Bounds, t: f32) -> Bounds, frame: Option, frame_size: (f32, f32), motion: Option, + stats: Option>, + frame_sequence: u64, } impl WatchView { @@ -154,6 +143,8 @@ impl WatchView { frame: None, frame_size: (1920., 1080.), motion: None, + stats: None, + frame_sequence: 0, } } @@ -162,10 +153,14 @@ impl WatchView { frame: EditorPreviewFrame, frame_size: (f32, f32), origin: Option>, + stats: Option>, + frame_sequence: u64, cx: &mut Context, ) { self.frame = Some(frame); self.frame_size = frame_size; + self.stats = stats; + self.frame_sequence = frame_sequence; self.motion = Some(WatchMotion { phase: WatchPhase::Opening, started: Instant::now(), @@ -174,7 +169,6 @@ impl WatchView { cx.notify(); } - /// Starts the return to the stage and reports how long it will take. fn close( &mut self, origin: Option>, @@ -203,16 +197,21 @@ impl WatchView { &mut self, frame: EditorPreviewFrame, frame_size: (f32, f32), + stats: Option>, + frame_sequence: u64, cx: &mut Context, ) { self.frame = Some(frame); self.frame_size = frame_size; + self.stats = stats; + self.frame_sequence = frame_sequence; cx.notify(); } fn clear(&mut self, cx: &mut Context) { self.frame = None; self.motion = None; + self.stats = None; cx.notify(); } } @@ -243,6 +242,8 @@ impl Render for WatchView { let radius = px(lerp(PREVIEW_RADIUS, WATCH_RADIUS, progress)); let shadow = crate::theme::preview_shadow(); let editor = self.editor.clone(); + let painted = self.stats.clone(); + let frame_sequence = self.frame_sequence; div() .absolute() @@ -290,6 +291,9 @@ impl Render for WatchView { gpui::hsla(0., 0., 1., 0.08), gpui::BorderStyle::Solid, )); + if let Some(stats) = &painted { + stats.record_paint(frame_sequence); + } }, ) .size_full(), @@ -332,8 +336,10 @@ impl EditorWindow { window.focus(&self.focus, cx); self.watch_preview_open = true; - self.watch_preview - .update(cx, |watch, cx| watch.open(frame, frame_size, origin, cx)); + let (stats, frame_sequence) = self.watch_paint_counters(cx); + self.watch_preview.update(cx, |watch, cx| { + watch.open(frame, frame_size, origin, stats, frame_sequence, cx) + }); self.set_stage_frame_hidden(true, cx); cx.notify(); } @@ -357,8 +363,6 @@ impl EditorWindow { cx.notify(); } - /// Unmounts the overlay without the return motion, for surfaces that - /// replace the player outright. pub(crate) fn dismiss_watch_preview(&mut self, cx: &mut Context) { if self.watch_preview_open { self.finish_watch_preview(cx); @@ -373,7 +377,6 @@ impl EditorWindow { cx.notify(); } - /// The transport's icon-only toggle, next to Split. pub(crate) fn render_watch_preview_button(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; let expanded = self.watch_preview_expanded(); diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index cdcbc115f4a..34d9461a092 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -649,6 +649,10 @@ impl PreviewFrameView { cx.notify(); previous } + + fn paint_counters(&self) -> (Option>, u64) { + (self.stats.clone(), self.frame_sequence) + } } impl Render for PreviewFrameView { @@ -844,7 +848,7 @@ pub struct StatsSnapshot { } impl PumpStats { - fn record_paint(&self, frame_sequence: u64) { + pub(crate) fn record_paint(&self, frame_sequence: u64) { self.painted.fetch_add(1, Ordering::Relaxed); if self .last_painted_sequence @@ -2663,6 +2667,10 @@ impl EditorWindow { .update(cx, |preview, cx| preview.set_hidden(hidden, cx)); } + pub(crate) fn watch_paint_counters(&self, cx: &Context) -> (Option>, u64) { + self.preview.read(cx).paint_counters() + } + /// Popovers that would otherwise stay open underneath a full-window /// overlay. pub(crate) fn dismiss_transient_menus(&mut self) { @@ -6393,8 +6401,9 @@ impl EditorWindow { preview.set_frame(frame.frame, frame_size, self.stats.clone(), cx) }); if let Some(watch_frame) = watch_frame { + let (stats, frame_sequence) = self.watch_paint_counters(cx); self.watch_preview.update(cx, |watch, cx| { - watch.set_frame(watch_frame, frame_size, cx); + watch.set_frame(watch_frame, frame_size, stats, frame_sequence, cx); }); } if let Some(EditorPreviewFrame::Image(previous)) = previous { From f0318260659cb025c275db427807c7b479f5354e Mon Sep 17 00:00:00 2001 From: dovidmoishe Date: Tue, 27 Oct 2026 03:07:32 +0100 Subject: [PATCH 3/3] test(ci): tolerate watch preview duration rounding --- apps/desktop-gpui/src/editor_watch.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop-gpui/src/editor_watch.rs b/apps/desktop-gpui/src/editor_watch.rs index 3d6658cdb17..32aa4d33c2c 100644 --- a/apps/desktop-gpui/src/editor_watch.rs +++ b/apps/desktop-gpui/src/editor_watch.rs @@ -485,7 +485,9 @@ mod tests { assert_eq!(watch_progress(full, WATCH_CLOSE_DURATION, false), 0.); let partial = WatchPhase::Closing { from_progress: 0.5 }; - assert_eq!(phase_duration(partial), WATCH_CLOSE_DURATION / 2); + assert!( + phase_duration(partial).abs_diff(WATCH_CLOSE_DURATION / 2) <= Duration::from_nanos(10) + ); assert_eq!(watch_progress(partial, Duration::ZERO, false), 0.5); assert_eq!(watch_progress(partial, WATCH_CLOSE_DURATION, false), 0.);