diff --git a/apps/desktop-gpui/src/controls_window.rs b/apps/desktop-gpui/src/controls_window.rs index b5460fe54e..6a5da71b3a 100644 --- a/apps/desktop-gpui/src/controls_window.rs +++ b/apps/desktop-gpui/src/controls_window.rs @@ -105,7 +105,9 @@ impl ControlsWindow { let theme = self.theme; div() .id(id) - .size(px(32.)) + .w(px(28.)) + .h(px(32.)) + .flex_shrink_0() .flex() .items_center() .justify_center() @@ -128,7 +130,6 @@ impl ControlsWindow { let stopping = session.phase == Phase::Stopping; let countdown = session.countdown_remaining(); let can_stop = (!starting && !stopping) || countdown.is_some(); - let error = session.error.clone(); let label: SharedString = if let Some(countdown) = countdown { countdown.to_string().into() } else if starting { @@ -142,23 +143,25 @@ impl ControlsWindow { "Pausing…" } .into() - } else if error.is_some() { - "Error".into() } else if session.is_paused() { "Paused".into() + } else if session.error.is_some() { + "Error".into() } else { Self::format_elapsed(session.elapsed()).into() }; div() .id("stop") + .min_w_0() + .flex_1() .flex() .flex_row() .items_center() .gap(px(4.)) .rounded(px(8.)) .py(px(4.)) - .px(px(8.)) + .px(px(6.)) .text_color(theme.red_300) .when(can_stop, |this| { this.hover(|style| style.bg(Theme::with_alpha(theme.red_300, 0.08))) @@ -168,17 +171,17 @@ impl ControlsWindow { })) }) .when(!can_stop, |this| this.opacity(0.6)) - .when_some(error, |this, error| { - this.tooltip(move |_, cx| ui::Tooltip::new(&theme, error.clone()).view(cx)) - }) .child( svg() .path("icons/stop-circle.svg") - .size(px(16.)) + .size(px(20.)) + .flex_shrink_0() .text_color(theme.red_300), ) .child( div() + .min_w_0() + .truncate() .text_size(px(14.)) .font_weight(gpui::FontWeight::MEDIUM) .child(label), @@ -215,7 +218,9 @@ impl ControlsWindow { div() .id("microphone") - .size(px(32.)) + .w(px(28.)) + .h(px(32.)) + .flex_shrink_0() .relative() .flex() .items_center() @@ -352,6 +357,7 @@ impl ControlsWindow { div() .h(px(40.)) + .flex_shrink_0() .w_full() .flex() .flex_row() @@ -373,9 +379,11 @@ impl ControlsWindow { div() .flex() .flex_1() + .min_w_0() .flex_row() .items_center() .justify_between() + .gap(px(4.)) .p(px(4.)) .child(self.render_stop(cx)) .child( @@ -383,7 +391,8 @@ impl ControlsWindow { .flex() .flex_row() .items_center() - .gap(px(4.)) + .gap(px(2.)) + .flex_shrink_0() .child(self.render_microphone(cx)) .child( self.action_button( @@ -440,6 +449,8 @@ impl ControlsWindow { .child( div() .id("drag") + .w(px(24.)) + .flex_shrink_0() .cursor_move() .flex() .items_center() @@ -465,29 +476,67 @@ impl ControlsWindow { impl Render for ControlsWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.theme.refresh(window, cx, false); + let session = self.session.read(cx); + let issue = session.error.clone().or_else(|| { + session + .storage_warning + .then(|| "Low storage. Cap will stop soon to save your recording.".to_owned()) + }); div() .size_full() .flex() .flex_col() .justify_end() - .px(px(12.)) - .pb(px(12.)) + .p(px(12.)) .font_family("Geist") // `body { font-weight: 500 }` (`ui-solid/src/main.css:189-192`). .font_weight(FontWeight::MEDIUM) - .when(self.session.read(cx).storage_warning, |this| { + .when_some(issue, |this, issue| { this.child( div() + .min_h_0() + .w_full() + .flex() + .items_start() + .gap(px(8.)) .mb(px(8.)) - .rounded(px(8.)) - .bg(self.theme.red_2) + .rounded(px(12.)) + .border_1() + .border_color(Theme::with_alpha(self.theme.red_9, 0.4)) + .bg(self.theme.gray_1) .p(px(8.)) - .text_size(px(11.)) + .text_size(px(12.)) .text_color(self.theme.red_11) - .child("Low storage. Cap will stop soon to save your recording."), + .child( + svg() + .path("icons/triangle-alert.svg") + .size(px(20.)) + .flex_shrink_0() + .text_color(self.theme.red_9), + ) + .child(recording_issue_text(issue)), ) }) .child(self.render_bar(cx)) } } + +fn recording_issue_text(error: String) -> impl IntoElement + Styled { + div() + .id("recording-issue-text") + .min_w_0() + .min_h_0() + .flex_1() + .max_h(px(56.)) + .overflow_x_scroll() + .overflow_y_scroll() + .child(if error.contains("Insufficient disk space") + && error.contains("Your recording is still paused") + { + "Not enough disk space to resume. Free up space and try again, or press Stop to save." + .to_owned() + } else { + error + }) +} diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index a7e7ad9b2c..4fc25acb1e 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -59,11 +59,10 @@ use core_foundation::base::TCFType; #[cfg(target_os = "macos")] use core_video::pixel_buffer::{CVPixelBuffer, CVPixelBufferRef}; use gpui::{ - Animation, AnimationExt as _, AppContext as _, Bounds, Context, Entity, FocusHandle, - FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, ParentElement, Pixels, Point, Render, RenderImage, SharedString, - StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription, WeakEntity, Window, - div, point, prelude::FluentBuilder, px, svg, + AppContext as _, Bounds, Context, Entity, FocusHandle, FontWeight, Hsla, InteractiveElement, + IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, + Point, Render, RenderImage, SharedString, StatefulInteractiveElement as _, StyleRefinement, + Styled, Subscription, WeakEntity, Window, div, point, prelude::FluentBuilder, px, svg, }; use crate::{ @@ -75,6 +74,7 @@ use crate::{ }; mod frame; +mod loading; mod scenes; // --------------------------------------------------------------------------- @@ -740,16 +740,24 @@ impl Render for EditorSectionView { // clips sidebar; the config sidebar is hidden, not destroyed // (`Editor.tsx:728-747`). EditorSection::Sidebar => { - if !editor.project_ready() { - return editor.render_preparing_sidebar().into_any_element(); + if !editor.visual_ready() { + return editor.render_preparing_sidebar(!matches!( + editor.state, + LoadState::Failed(_) + )); } - if editor.clips.open { + let sidebar = if editor.clips.open { editor.render_clips_sidebar(cx).into_any_element() } else { editor.with_style_controls(|editor| { editor.render_sidebar(cx).into_any_element() }) - } + }; + loading::reveal( + "editor-sidebar-ready", + sidebar, + editor.render_preparing_sidebar(false), + ) } EditorSection::Timeline => { let viewport_width: f32 = window.viewport_size().width.into(); @@ -8661,12 +8669,8 @@ impl EditorWindow { ) } - fn render_preparing_sidebar(&self) -> impl IntoElement { + fn render_preparing_sidebar(&self, animated: bool) -> gpui::AnyElement { let theme = self.theme; - let project = self - .preparing_seed - .as_ref() - .map_or(&self.project, |seed| &seed.project); div() .size_full() .flex() @@ -8704,33 +8708,8 @@ impl EditorWindow { }), ), ) - .child( - div() - .flex() - .flex_col() - .p(px(16.)) - .gap(px(16.)) - .text_size(px(12.)) - .child( - div() - .font_weight(FontWeight::MEDIUM) - .text_color(Hsla::from(theme.editor.text_1)) - .child("Background"), - ) - .child( - div() - .flex() - .justify_between() - .text_color(Hsla::from(theme.editor.text_3)) - .child("Aspect ratio") - .child(Self::aspect_ratio_label(project.aspect_ratio.as_ref())), - ) - .child( - div() - .text_color(Hsla::from(theme.editor.text_3)) - .child("Editing is available when your recording is ready."), - ), - ) + .child(loading::sidebar(&theme, animated)) + .into_any_element() } /// The player's top row: the stage's own triggers on the left, the @@ -8888,24 +8867,14 @@ impl EditorWindow { let theme = self.theme; let body = match (&self.state, self.latest_frame.is_some()) { (LoadState::Failed(message), _) => self.render_error_state(message).into_any_element(), - (_, true) => self - .preview - .clone() - .cached(StyleRefinement::default().size_full()) - .into_any_element(), - (_, false) => div() - .absolute() - .inset_0() - .flex() - .items_center() - .justify_center() - .child( - svg() - .path("icons/video.svg") - .size(px(48.)) - .text_color(Hsla::from(theme.editor.text_3)), - ) - .into_any_element(), + (_, true) => loading::reveal( + "editor-preview-ready", + self.preview + .clone() + .cached(StyleRefinement::default().size_full()), + loading::preview(&theme, false), + ), + (_, false) => loading::preview(&theme, true), }; div() @@ -8915,17 +8884,6 @@ impl EditorWindow { .overflow_hidden() .bg(Hsla::from(theme.editor.card)) .child(body) - .children(self.latest_frame.is_some().then(|| { - div() - .absolute() - .inset_0() - .bg(Hsla::from(theme.editor.card)) - .with_animation( - "editor-preview-reveal", - Animation::new(Duration::from_millis(300)), - |veil, progress| veil.opacity(1. - progress), - ) - })) // `CanvasElementsOverlay` + `SnapGuidesOverlay` // (`Player.tsx:636-643`), both mounted inside the letterbox // wrapper and only while a frame exists. @@ -9258,6 +9216,14 @@ impl EditorWindow { ) -> impl IntoElement { let theme = self.theme; if !self.project_ready() { + if self + .preparing_presentation + .as_ref() + .and_then(|presentation| presentation.progress.total_duration) + .is_none() + { + return loading::timeline(&theme, !matches!(self.state, LoadState::Failed(_))); + } return div() .size_full() .child(timeline::render_preparing_timeline( @@ -10541,29 +10507,20 @@ impl Render for EditorWindow { })) // The open `KSelect` menu, painted last of all so it is over the // sidebar and the drag layers alike. - .children((!matches!(self.state, LoadState::Failed(_))).then(|| { - let veil = div() - .absolute() - .top(px(HEADER_HEIGHT)) - .bottom_0() - .left_0() - .right_0() - .bg(Hsla::from(theme.editor.window).opacity(0.45)); - if self.visual_ready() { - veil.with_animation( - "editor-controls-reveal", - Animation::new(Duration::from_millis(300)), - |veil, progress| veil.opacity(1. - progress), - ) - .into_any_element() - } else { - veil.occlude() + .children( + (!self.visual_ready() && !matches!(self.state, LoadState::Failed(_))).then(|| { + div() + .absolute() + .top(px(HEADER_HEIGHT)) + .bottom_0() + .left_0() + .right_0() + .occlude() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation()) .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) - .into_any_element() - } - })) + }), + ) .children(self.with_style_controls(|this| this.render_sidebar_menu(cx))) .children(self.render_toolbar_menu(cx)) .children(self.render_frame_controls(window, cx)) diff --git a/apps/desktop-gpui/src/editor_window/loading.rs b/apps/desktop-gpui/src/editor_window/loading.rs new file mode 100644 index 0000000000..ec70ee0e48 --- /dev/null +++ b/apps/desktop-gpui/src/editor_window/loading.rs @@ -0,0 +1,164 @@ +use std::time::Duration; + +use gpui::{ + Animation, AnimationExt, AnyElement, Div, Hsla, IntoElement, ParentElement, Styled, div, px, + relative, +}; + +use crate::theme::Theme; + +fn block(theme: &Theme, width: f32, height: f32) -> Div { + div() + .w(px(width)) + .h(px(height)) + .flex_none() + .rounded(px(5.)) + .bg(Hsla::from(theme.editor.ctl_hover)) +} + +fn pulse(id: &'static str, content: Div, animated: bool) -> AnyElement { + if !animated { + return content.into_any_element(); + } + content + .with_animation( + id, + Animation::new(Duration::from_millis(1800)).repeat(), + |element, progress| { + let opacity = 0.78 + 0.22 * (progress * std::f32::consts::TAU).cos(); + element.opacity(opacity) + }, + ) + .into_any_element() +} + +pub(super) fn preview(theme: &Theme, animated: bool) -> AnyElement { + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .p(px(24.)) + .bg(Hsla::from(theme.editor.card)) + .child(pulse( + "editor-preview-skeleton", + div() + .w_full() + .h_full() + .rounded(px(12.)) + .bg(Hsla::from(theme.editor.ctl)) + .border_1() + .border_color(Hsla::from(theme.editor.line)), + animated, + )) + .into_any_element() +} + +pub(super) fn sidebar(theme: &Theme, animated: bool) -> AnyElement { + pulse( + "editor-sidebar-skeleton", + div() + .flex() + .flex_col() + .p(px(16.)) + .gap(px(20.)) + .child(block(theme, 92., 12.)) + .child( + div() + .flex() + .gap(px(6.)) + .children((0..4).map(|_| block(theme, 64., 28.).flex_1().min_w_0())), + ) + .child( + div() + .flex() + .flex_col() + .gap(px(8.)) + .children((0..2).map(|_| { + div() + .flex() + .gap(px(8.)) + .children((0..4).map(|_| block(theme, 64., 48.).flex_1().min_w_0())) + })), + ) + .child(div().h(px(1.)).w_full().bg(Hsla::from(theme.editor.line))) + .children([72., 88., 64.].into_iter().map(|width| { + div() + .flex() + .flex_col() + .gap(px(12.)) + .child( + div() + .flex() + .justify_between() + .child(block(theme, width, 10.)) + .child(block(theme, 36., 20.)), + ) + .child(block(theme, 0., 5.).w_full()) + })), + animated, + ) +} + +pub(super) fn timeline(theme: &Theme, animated: bool) -> AnyElement { + pulse( + "editor-timeline-skeleton", + div() + .size_full() + .flex() + .flex_col() + .p(px(12.)) + .gap(px(12.)) + .child( + div() + .h(px(24.)) + .flex() + .items_center() + .justify_between() + .children((0..8).map(|_| block(theme, 28., 8.))), + ) + .children([1., 0.72].into_iter().map(|width| { + div() + .flex() + .items_center() + .gap(px(16.)) + .child(block(theme, 24., 24.)) + .child( + div() + .flex_1() + .child(block(theme, 0., 28.).w(relative(width))), + ) + })), + animated, + ) +} + +pub(super) fn reveal( + id: &'static str, + content: impl IntoElement, + placeholder: impl IntoElement, +) -> AnyElement { + div() + .relative() + .size_full() + .child(content) + .child( + div() + .absolute() + .inset_0() + .child(placeholder) + .with_animation( + id, + Animation::new(Duration::from_millis(180)), + |element, progress| { + if progress >= 1. { + div() + } else { + element.opacity((1. - progress).powi(3)) + } + }, + ), + ) + .into_any_element() +} diff --git a/apps/desktop/src/app.tsx b/apps/desktop/src/app.tsx index 44ed6b9725..2a0af6b256 100644 --- a/apps/desktop/src/app.tsx +++ b/apps/desktop/src/app.tsx @@ -35,6 +35,9 @@ const NewMainPage = lazy(() => import("./routes/(window-chrome)/new-main")); const SettingsGeneralPage = lazy( () => import("./routes/(window-chrome)/settings/general"), ); +const SettingsQualityPage = lazy( + () => import("./routes/(window-chrome)/settings/quality"), +); const SettingsRecordingsPage = lazy( () => import("./routes/(window-chrome)/settings/recordings"), ); @@ -198,6 +201,7 @@ function Inner() { + issueMessages().length > 0; @@ -477,13 +481,19 @@ function InProgressRecordingInner() { console.error("Failed to sync recording controls hit area", error); }) .finally(() => { - if (pendingInteractiveBoundsKey === key) + if (pendingInteractiveBoundsKey === key) { pendingInteractiveBoundsKey = ""; + if (lastInteractiveBoundsKey === key) syncInteractiveAreaBounds(); + } }); }; createEffect(() => { - interactiveAreaRef(); + const area = interactiveAreaRef(); + if (!area) return; + const observer = new ResizeObserver(syncInteractiveAreaBounds); + observer.observe(area); + onCleanup(() => observer.disconnect()); queueMicrotask(syncInteractiveAreaBounds); }); @@ -579,7 +589,9 @@ function InProgressRecordingInner() { (state().variant === "recording" || state().variant === "paused") ) { setPauseError( - `Could not ${request.resume ? "resume" : "pause"} recording: ${String(error)}`, + String(error).startsWith("Could not ") + ? String(error) + : `Could not ${request.resume ? "resume" : "pause"} recording: ${String(error)}`, ); } throw error; @@ -874,15 +886,18 @@ function InProgressRecordingInner() { }; return ( -
-
+
+
-
+
-
-
-
+
+
+
- + Starting
} > { if (event.button !== 0) return; @@ -931,8 +955,8 @@ function InProgressRecordingInner() { title="Stop recording" aria-label="Stop recording" > - - + + -
+
{optionsQuery.rawOptions.micName != null ? ( disconnectedInputs.microphone ? ( - + ) : ( <> @@ -1043,7 +1067,7 @@ function InProgressRecordingInner() { > toggleMicMute.mutate()} title={ @@ -1073,32 +1097,6 @@ function InProgressRecordingInner() { )} - - -
- -
-
-
- - {(reason) => ( - -
-
-
- - )} -
- +
@@ -1216,9 +1215,20 @@ function InProgressRecordingInner() { ); } +function recordingIssueMessage(message: string): string { + if ( + message.includes("Insufficient disk space") && + message.includes("Your recording is still paused") + ) { + return "Not enough disk space to resume. Free up space and try again, or press Stop to save."; + } + return message; +} + function RecordingControlTooltip(props: { content: string | undefined; children: JSX.Element; + flexible?: boolean; }) { const [open, setOpen] = createSignal(false); const boundsPrefix = `recording-control-tooltip-${createUniqueId()}`; @@ -1284,7 +1294,10 @@ function RecordingControlTooltip(props: { {props.content} } - childClass="flex h-full items-center" + childClass={cx( + "flex h-full min-w-0 items-center", + props.flexible ? "flex-1" : "shrink-0", + )} placement="top" gutter={6} flip={false} @@ -1294,7 +1307,7 @@ function RecordingControlTooltip(props: { onOpenChange={setOpen} > setOpen(true)} onFocusOut={(event) => { if ( @@ -1312,13 +1325,18 @@ function RecordingControlTooltip(props: { ); } -function RecordingControlButton(props: ComponentProps<"button">) { - const [local, buttonProps] = splitProps(props, ["title"]); +function RecordingControlButton( + props: ComponentProps<"button"> & { flexible?: boolean }, +) { + const [local, buttonProps] = splitProps(props, ["title", "flexible"]); return ( - +