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 638020d001e..bf1fe1b43bd 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",
@@ -334,6 +335,7 @@ mod tests {
include_str!("editor_clips.rs"),
// The export page's destination cards and share-status glyphs.
include_str!("editor_export.rs"),
+ 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 e39582dffa6..8bb84fb0d06 100644
--- a/apps/desktop-gpui/src/editor_canvas.rs
+++ b/apps/desktop-gpui/src/editor_canvas.rs
@@ -506,7 +506,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 ec1f860dad1..4d305bfb661 100644
--- a/apps/desktop-gpui/src/editor_crop.rs
+++ b/apps/desktop-gpui/src/editor_crop.rs
@@ -1509,6 +1509,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 e3c53ce48ab..e0a41d08415 100644
--- a/apps/desktop-gpui/src/editor_export.rs
+++ b/apps/desktop-gpui/src/editor_export.rs
@@ -526,6 +526,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..32aa4d33c2c
--- /dev/null
+++ b/apps/desktop-gpui/src/editor_watch.rs
@@ -0,0 +1,511 @@
+//! 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::{
+ 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, PumpStats, 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.))
+ }
+ }
+}
+
+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))
+ }
+ }
+}
+
+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),
+ ),
+ }
+}
+
+pub(crate) struct WatchView {
+ editor: WeakEntity,
+ frame: Option,
+ frame_size: (f32, f32),
+ motion: Option,
+ stats: Option>,
+ frame_sequence: u64,
+}
+
+impl WatchView {
+ pub(crate) fn new(editor: WeakEntity) -> Self {
+ Self {
+ editor,
+ frame: None,
+ frame_size: (1920., 1080.),
+ motion: None,
+ stats: None,
+ frame_sequence: 0,
+ }
+ }
+
+ fn open(
+ &mut self,
+ 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(),
+ origin,
+ });
+ cx.notify();
+ }
+
+ 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),
+ 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();
+ }
+}
+
+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();
+ let painted = self.stats.clone();
+ let frame_sequence = self.frame_sequence;
+
+ 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,
+ ));
+ if let Some(stats) = &painted {
+ stats.record_paint(frame_sequence);
+ }
+ },
+ )
+ .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_watch_preview_menus();
+ window.focus(&self.focus, cx);
+
+ self.watch_preview_open = true;
+ 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();
+ }
+
+ 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();
+ }
+
+ 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();
+ }
+
+ 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!(
+ 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.);
+
+ 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 7ca98333774..b60b55bc443 100644
--- a/apps/desktop-gpui/src/editor_window.rs
+++ b/apps/desktop-gpui/src/editor_window.rs
@@ -621,7 +621,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
@@ -638,6 +638,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 {
@@ -649,6 +653,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();
}
}
@@ -666,6 +678,10 @@ impl PreviewFrameView {
cx.notify();
previous
}
+
+ fn paint_counters(&self) -> (Option>, u64) {
+ (self.stats.clone(), self.frame_sequence)
+ }
}
impl Render for PreviewFrameView {
@@ -678,6 +694,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,
@@ -702,6 +719,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));
@@ -857,7 +877,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
@@ -1365,6 +1385,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,
@@ -1374,7 +1399,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`.
@@ -1646,7 +1671,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)
}
@@ -2013,6 +2038,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)
@@ -2061,6 +2090,9 @@ impl EditorWindow {
preparing_seed: None,
latest_frame: None,
preview,
+ watch_preview,
+ watch_preview_open: false,
+ watch_preview_closing: None,
header,
toolbar,
transport_controls,
@@ -2699,6 +2731,24 @@ 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));
+ }
+
+ pub(crate) fn watch_paint_counters(&self, cx: &Context) -> (Option>, u64) {
+ self.preview.read(cx).paint_counters()
+ }
+
+ pub(crate) fn dismiss_watch_preview_menus(&mut self) {
+ self.dismiss_transient_menus();
+ 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).
@@ -3489,6 +3539,13 @@ impl EditorWindow {
window: &mut Window,
cx: &mut Context,
) {
+ 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();
@@ -6519,11 +6576,26 @@ 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 {
+ let (stats, frame_sequence) = self.watch_paint_counters(cx);
+ self.watch_preview.update(cx, |watch, cx| {
+ watch.set_frame(watch_frame, frame_size, stats, frame_sequence, 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);
@@ -10547,6 +10619,7 @@ impl EditorWindow {
)),
),
)
+ .child(self.render_watch_preview_button(cx))
.child(self.header_divider())
// `IconCapZoomOut` -> `updateZoom(zoom * 1.1,
// playbackTime)`; `IconCapZoomIn` -> `zoom / 1.1`
@@ -12072,6 +12145,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 db32a0fa617..471e13fcdfc 100644
--- a/apps/desktop-gpui/src/main.rs
+++ b/apps/desktop-gpui/src/main.rs
@@ -40,6 +40,7 @@ mod editor_preparing;
mod editor_sidebar;
mod editor_tabs;
mod editor_timeline;
+mod editor_watch;
mod editor_window;
mod feeds;
mod hotkeys;