diff --git a/.github/workflows/sync-tests.yml b/.github/workflows/sync-tests.yml index 7568140562a..f42c258c6c3 100644 --- a/.github/workflows/sync-tests.yml +++ b/.github/workflows/sync-tests.yml @@ -24,6 +24,7 @@ on: - "crates/timestamp/**" - "crates/rendering/**" - "crates/editor/**" + - "crates/export/**" - "crates/audio/**" - "crates/media-info/**" - "crates/project/**" @@ -139,11 +140,14 @@ jobs: cargo test --locked -p cap-recording --lib -- --test-threads=1 # --nocapture so a WARP-adapter notch skip prints instead of # looking identical to a pass in the CI log. - cargo test --locked -p cap-rendering -- --nocapture + cargo test --locked -p cap-rendering -- --nocapture --skip zoom_spring::tests::precompute_cost_is_bounded_for_long_projects + cargo test --locked -p cap-rendering --lib zoom_spring::tests::precompute_cost_is_bounded_for_long_projects -- --exact --nocapture --test-threads=1 - name: Editor audio playback and export regressions shell: bash run: | + cargo test --locked -p cap-audio --lib + cargo test --locked -p cap-export --lib cargo test --locked -p cap-editor --lib audio::tests:: cargo test --locked -p cap-editor --lib audio_output::tests:: cargo test --locked -p cap-editor --lib playback::tests:: diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index b5979bc0f1a..65b0204fce4 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -4463,10 +4463,7 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m ); log_timeline_model(&summary.timeline); let recordings = summary.recordings.clone(); - if handle - .update(cx, |view, window, cx| view.set_summary(summary, window, cx)) - .is_err() - { + if handle.update(cx, |_, _, _| ()).is_err() { return; } @@ -4548,8 +4545,35 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m }; tracing::info!(path = %path.display(), "editor instance ready"); + let (total, config) = { + let config = instance.project_config.1.borrow().clone(); + let total = config + .timeline + .as_ref() + .map_or(0.0, |timeline| timeline.duration()); + (total, config) + }; + let has_camera = instance + .recordings + .segments + .iter() + .any(|segment| segment.camera.is_some()); + let multiple_clips = instance.recordings.segments.len() > 1; + log_timeline_model(&editor_timeline::TimelineModel::build( + &config, + has_camera, + multiple_clips, + )); if handle - .update(cx, |view, _window, _cx| view.set_instance(instance.clone())) + .update(cx, |view, window, cx| { + // Loading controls can queue a save before the engine is ready. + // Publish the loaded config and instance together so those edits + // cannot replace the saved project with the initial defaults. + view.pending_save().borrow_mut().discard(); + view.set_summary(summary, window, cx); + view.set_project(config, window, cx); + view.set_instance(instance.clone()); + }) .is_err() { instance.dispose().await; @@ -4659,44 +4683,6 @@ fn load_editor_project(path: PathBuf, handle: WindowHandle, cx: &m }) .detach(); - // `totalDuration()` (`context.ts:1374-1380`). Read off the instance - // rather than the pre-flight, because `EditorInstance::new` - // synthesises a timeline for a raw bundle -- and `timeline.duration()` - // is exactly what the playback engine stops at - // (`playback.rs:560-570`). - // - // The whole track model comes from the same read: the config the - // instance actually loaded is the one being rendered, holds, clip - // offsets and all. E4 hands the window the config itself rather than - // the derived model, because it is what every edit mutates and what - // the debounced save writes back. - let (total, config) = { - let config = instance.project_config.1.borrow().clone(); - let total = config - .timeline - .as_ref() - .map_or(0.0, |timeline| timeline.duration()); - (total, config) - }; - { - let has_camera = instance - .recordings - .segments - .iter() - .any(|segment| segment.camera.is_some()); - let multiple_clips = instance.recordings.segments.len() > 1; - log_timeline_model(&editor_timeline::TimelineModel::build( - &config, - has_camera, - multiple_clips, - )); - } - if handle - .update(cx, |view, window, cx| view.set_project(config, window, cx)) - .is_err() - { - return; - } load_editor_waveforms(instance.clone(), handle, cx); if handle @@ -4805,14 +4791,7 @@ fn load_editor_waveforms( (&segment.audio, &mut mic), (&segment.system_audio, &mut system), ] { - match loader.get().await { - Ok(Some(audio)) => { - out.push((audio.samples().to_vec(), audio.channels())) - } - // A failed track is an empty waveform; playback and - // export surface the actual error. - _ => out.push((Vec::new(), 1)), - } + out.push(loader.get().await.ok().flatten()); } } (mic, system) @@ -4824,15 +4803,21 @@ fn load_editor_waveforms( let peaks = cx .background_executor() .spawn(async move { - let extract = |tracks: Vec<(Vec, u16)>| { + let [mic, system] = [mic, system].map(|tracks| { tracks .into_iter() - .map(|(samples, channels)| { - Arc::new(editor_timeline::waveform_peaks(&samples, channels)) + .map(|audio| { + Arc::new(match audio { + Some(audio) => editor_timeline::waveform_peaks( + audio.samples(), + audio.channels(), + ), + None => Vec::new(), + }) }) .collect::>() - }; - (extract(mic), extract(system)) + }); + (mic, system) }) .await; let _ = handle.update(cx, |view, window, cx| { diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 7ed9d7be489..935e2c5919d 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -360,6 +360,7 @@ mod tests { include_str!("screenshot_annotations.rs"), // `ui::SelectionHeader` names the check and the trash itself. include_str!("ui/selection_header.rs"), + include_str!("ui/radio_cards.rs"), // The onboarding window's welcome cards and permissions surface; the // per-permission row glyphs are named on `OSPermission::icon`. include_str!("onboarding_window.rs"), diff --git a/apps/desktop-gpui/src/devices.rs b/apps/desktop-gpui/src/devices.rs index 3d967566f02..21976bf7bff 100644 --- a/apps/desktop-gpui/src/devices.rs +++ b/apps/desktop-gpui/src/devices.rs @@ -380,6 +380,11 @@ pub fn list_window_targets() -> Vec<(WindowOption, Window)> { Window::list() .into_iter() .filter_map(|window| { + #[cfg(target_os = "windows")] + if !window.raw_handle().is_valid() || !window.raw_handle().is_on_screen() { + return None; + } + let label = window.name().filter(|name| !name.trim().is_empty())?; let app = window.owner_name()?; diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index 0ce3cf575a4..d8cc2448c62 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -408,12 +408,17 @@ impl EditorWindow { ui::Button::plain(&self.theme, "clips-pill", variant, ui::ButtonSize::Md) .icon("icons/clapperboard.svg") .label("Clips") + .disabled(!self.project_ready()) .height(px(40.)) + .radius(px(12.)) .font_weight(FontWeight::MEDIUM) .on_click(cx.listener(|this, _, window, cx| this.toggle_clips(window, cx))) } pub(crate) fn toggle_clips(&mut self, window: &mut Window, cx: &mut Context) { + if !self.project_ready() { + return; + } self.set_selection(None, cx); if self.clips.open { self.close_clips(window, cx); @@ -754,6 +759,7 @@ impl EditorWindow { .px(px(16.)) .w_full() .h(px(64.)) + .rounded_t(px(11.)) .border_b_1() .border_color(Hsla::from(theme.gray_3)) .text_size(px(14.)) @@ -838,12 +844,13 @@ impl EditorWindow { .gap(px(8.)) .font_weight(FontWeight::MEDIUM) .disabled(self.clips.importing) - .on_click(cx.listener( - |this, event: &gpui::ClickEvent, _window, cx| { + .on_open(cx.listener( + |this, bounds: &Bounds, _window, cx| { if this.clips.importing { return; } - this.clips.import_menu = Some(event.position()); + this.clips.import_menu = + Some(bounds.bottom_left() + gpui::point(px(0.), px(4.))); cx.notify(); }, )), @@ -1200,7 +1207,7 @@ impl EditorWindow { } fn begin_editor_recording(&mut self, cx: &mut Context) -> bool { - if self.clips.importing { + if !self.project_ready() || self.clips.importing { return false; } let session = RecordingSession::global(cx); @@ -1252,6 +1259,13 @@ impl EditorWindow { _window: &mut Window, cx: &mut Context, ) { + if !self.project_ready() { + tracing::warn!( + recording = %recording_dir.display(), + "the editor is not ready; leaving the recording in the library" + ); + return; + } if self.clips.importing { // A concurrent import owns the bundle merge; the capture stays in // the library and can be pulled in through "Existing recording". @@ -1353,6 +1367,7 @@ impl EditorWindow { .child( div() .id("clips-import-backdrop") + .occlude() .absolute() .top_0() .left_0() @@ -1506,7 +1521,7 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { - if self.clips.importing { + if !self.project_ready() || self.clips.importing { return; } if self.playing { diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 05d0623237d..908904dd8a9 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -2171,8 +2171,22 @@ async fn run_export( builder = builder.with_output_path(path); } - let base = builder.build().await.map_err(|error| error.to_string())?; - let total = base.total_frames(fps); + enum PreparedBase { + Mp4(cap_export::Mp4ExporterBase), + Other(ExporterBase), + } + let (base, total) = if !cursor_only && format != ExportFormatKind::Gif { + let base = builder + .build_for_mp4(cancel.clone()) + .await + .map_err(|error| error.to_string())?; + let total = base.total_frames(fps); + (PreparedBase::Mp4(base), total) + } else { + let base = builder.build().await.map_err(|error| error.to_string())?; + let total = base.total_frames(fps); + (PreparedBase::Other(base), total) + }; let _ = progress_tx.send((0, total)); let progress = { @@ -2189,33 +2203,37 @@ async fn run_export( }; let resolution = XY::new(width, height); - if cursor_only { - MovExportSettings { - fps, - resolution_base: resolution, - cursor_only: true, + match base { + PreparedBase::Other(base) if cursor_only => { + MovExportSettings { + fps, + resolution_base: resolution, + cursor_only: true, + } + .export(base, progress) + .await } - .export(base, progress) - .await - } else if format == ExportFormatKind::Gif { - GifExportSettings { - fps, - resolution_base: resolution, - quality: None, + PreparedBase::Other(base) => { + GifExportSettings { + fps, + resolution_base: resolution, + quality: None, + } + .export(base, progress) + .await } - .export(base, progress) - .await - } else { - Mp4ExportSettings { - fps, - resolution_base: resolution, - compression, - custom_bpp, - force_ffmpeg_decoder: force, - optimize_filesize: optimize, + PreparedBase::Mp4(base) => { + Mp4ExportSettings { + fps, + resolution_base: resolution, + compression, + custom_bpp, + force_ffmpeg_decoder: force, + optimize_filesize: optimize, + } + .export_prepared(base, progress) + .await } - .export(base, progress) - .await } } diff --git a/apps/desktop-gpui/src/editor_panels.rs b/apps/desktop-gpui/src/editor_panels.rs index b50c9765e38..abafeb5b1c4 100644 --- a/apps/desktop-gpui/src/editor_panels.rs +++ b/apps/desktop-gpui/src/editor_panels.rs @@ -2694,6 +2694,17 @@ impl EditorWindow { .map(|(mode, label)| ui::MenuItem::new(*label, *mode == current)) .collect() } + SidebarMenu::Camera3DEasing(_) => { + let current = timeline + .camera3d_segments + .get(index) + .map_or(0, motion_easing); + MOTION_EASINGS + .iter() + .enumerate() + .map(|(index, (_, label, _, _))| ui::MenuItem::new(*label, index == current)) + .collect() + } _ => Vec::new(), } } @@ -2759,6 +2770,9 @@ impl EditorWindow { true }); } + SidebarMenu::Camera3DEasing(_) => { + self.set_camera3d_easing(segment, index, window, cx); + } _ => {} } } @@ -5598,6 +5612,7 @@ impl EditorWindow { .child( ui::EditorButton::plain(&theme, "camera3d-swap") .left_icon("icons/arrow-left-right.svg") + .tooltip(&theme, "Swap start and end") .on_click(cx.listener(move |this, _, window, cx| { this.swap_camera3d_poses(index, window, cx); })), @@ -5613,6 +5628,7 @@ impl EditorWindow { .child( ui::EditorButton::plain(&theme, "camera3d-flip-h") .left_icon("icons/flip-horizontal-2.svg") + .tooltip(&theme, "Flip horizontal") .on_click(cx.listener(move |this, _, window, cx| { this.flip_camera3d(index, true, window, cx); })), @@ -5620,6 +5636,7 @@ impl EditorWindow { .child( ui::EditorButton::plain(&theme, "camera3d-flip-v") .left_icon("icons/flip-vertical-2.svg") + .tooltip(&theme, "Flip vertical") .on_click(cx.listener(move |this, _, window, cx| { this.flip_camera3d(index, false, window, cx); })), @@ -5958,17 +5975,12 @@ impl EditorWindow { } fn easing_select(&self, index: usize, current: usize, cx: &mut Context) -> AnyElement { - let theme = self.theme; - // Four options, and `ui::Menu` draws at the pointer without flipping; - // the corner-style select already established the two-option toggle, - // and this one cycles for the same reason. - ui::Select::plain(&theme, "camera3d-easing", MOTION_EASINGS[current].1) - .stretch_label() - .on_click(cx.listener(move |this, _, window, cx| { - let next = (current + 1) % MOTION_EASINGS.len(); - this.set_camera3d_easing(index, next, window, cx); - })) - .into_any_element() + self.menu_select( + SidebarMenu::Camera3DEasing(index), + "camera3d-easing", + MOTION_EASINGS[current].1, + cx, + ) } /// `selectPose` (`:4933-4937`): flip the card **and** park the playhead on diff --git a/apps/desktop-gpui/src/editor_sidebar.rs b/apps/desktop-gpui/src/editor_sidebar.rs index 9bb5c454dc0..3d2ac8a3b33 100644 --- a/apps/desktop-gpui/src/editor_sidebar.rs +++ b/apps/desktop-gpui/src/editor_sidebar.rs @@ -1043,6 +1043,9 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { + if !self.project_ready() { + return; + } // An edit that is not the open colour panel's closes its bracket // first: the panel is a system window and stays up while the user // does other things, and an unrelated change must not be swallowed @@ -1070,6 +1073,9 @@ impl EditorWindow { cx: &mut Context, change: impl FnOnce(&mut ProjectConfiguration) -> bool, ) { + if !self.project_ready() { + return; + } self.end_color_history(); if !change(&mut self.project) { return; @@ -3512,29 +3518,12 @@ impl EditorWindow { .text_color(Hsla::from(theme.gray_11)) .child("CORNER STYLE"), ) - .child( - ui::Select::plain(&theme, "corner-style", label) - .stretch_label() - .on_click(cx.listener(move |this, _, window, cx| { - // Two options: the trigger toggles between them rather - // than opening a two-row menu. `ui::Menu` draws at the - // pointer and this select is the only one in the tab; - // a real menu arrives with the tabs that have several. - let next = match this.project.background.rounding_type { - CornerStyle::Squircle => CornerStyle::Rounded, - CornerStyle::Rounded => CornerStyle::Squircle, - }; - this.edit_background( - "rounding-type", - |project| { - project.background.rounding_type = next; - true - }, - window, - cx, - ); - })), - ) + .child(self.menu_select( + crate::editor_tabs::SidebarMenu::BackgroundCornerStyle, + "corner-style", + label, + cx, + )) } fn render_border_field(&self, cx: &mut Context) -> impl IntoElement { diff --git a/apps/desktop-gpui/src/editor_tabs.rs b/apps/desktop-gpui/src/editor_tabs.rs index eb7c660a0a2..a61daa1c11c 100644 --- a/apps/desktop-gpui/src/editor_tabs.rs +++ b/apps/desktop-gpui/src/editor_tabs.rs @@ -13,15 +13,6 @@ //! [`EditorWindow::edit_project`], which is the same fan-out a timeline edit or //! a background slider takes. //! -//! Two things in this file are not the project's: the **menus** (`KSelect` has -//! no gpui equivalent, so every select opens `ui::Menu` at the pointer, and the -//! open menu's identity lives in the sidebar state) and the **transcription -//! flow** on the Captions tab, which drives [`crate::transcription`] -- the -//! in-process port of the Tauri binary's caption commands -- rather than -//! invoking them over IPC. The chosen model/language persist in the shared -//! store's `gpui` section, this app's stand-in for the webview's -//! `localStorage` keys. - use std::{ collections::HashSet, sync::{LazyLock, Mutex}, @@ -36,8 +27,8 @@ use cap_project::{ KeyboardData, KeyboardSettings, ProjectConfiguration, ShadowConfiguration, StereoMode, }; use gpui::{ - AnyElement, Context, EntityId, FontWeight, Hsla, InteractiveElement, IntoElement, - ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, div, + AnyElement, Bounds, Context, EntityId, FontWeight, Hsla, InteractiveElement, IntoElement, + ParentElement, Pixels, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, relative, svg, }; use serde_json::Value; @@ -566,11 +557,9 @@ fn with_keyboard_settings( // Menus // --------------------------------------------------------------------------- -/// Every `KSelect` in the sidebar. `ui::Menu` draws at the pointer, so one -/// open-menu slot on the sidebar state serves all of them -- the settings -/// window's `Menu.popup()` stand-in, transcribed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SidebarMenu { + BackgroundCornerStyle, CameraBlur, CameraShape, CameraCornerStyle, @@ -596,6 +585,7 @@ pub enum SidebarMenu { TextAnimationIn(usize), TextAnimationOut(usize), Camera3DBlurMode(usize), + Camera3DEasing(usize), } pub struct OpenMenu { @@ -610,6 +600,12 @@ impl EditorWindow { let captions = caption_settings(project); let keyboard = keyboard_settings(project); match kind { + SidebarMenu::BackgroundCornerStyle => CORNER_STYLES + .iter() + .map(|(style, label)| { + ui::MenuItem::new(*label, *style == project.background.rounding_type) + }) + .collect(), SidebarMenu::CameraBlur => CAMERA_BLUR_MODES .iter() .map(|(mode, label)| { @@ -695,14 +691,15 @@ impl EditorWindow { | SidebarMenu::TextWeight(index) | SidebarMenu::TextAnimationIn(index) | SidebarMenu::TextAnimationOut(index) - | SidebarMenu::Camera3DBlurMode(index) => self.panel_menu_items(kind, index), + | SidebarMenu::Camera3DBlurMode(index) + | SidebarMenu::Camera3DEasing(index) => self.panel_menu_items(kind, index), } } pub(crate) fn open_sidebar_menu( &mut self, kind: SidebarMenu, - origin: gpui::Point, + trigger_bounds: Bounds, window: &mut Window, cx: &mut Context, ) { @@ -715,7 +712,7 @@ impl EditorWindow { let items = self.sidebar_menu_items(kind); self.sidebar.menu = Some(OpenMenu { kind, - state: ui::MenuState::new(origin, &items), + state: ui::MenuState::anchored(trigger_bounds, &items), }); cx.notify(); } @@ -777,6 +774,19 @@ impl EditorWindow { ) { self.sidebar.menu = None; match kind { + SidebarMenu::BackgroundCornerStyle => { + let Some((style, _)) = CORNER_STYLES.get(index) else { + return; + }; + let style = *style; + self.edit_project("rounding-type", window, cx, move |project| { + if project.background.rounding_type == style { + return false; + } + project.background.rounding_type = style; + true + }); + } SidebarMenu::CameraBlur => { let Some((mode, _)) = CAMERA_BLUR_MODES.get(index) else { return; @@ -932,7 +942,8 @@ impl EditorWindow { | SidebarMenu::TextWeight(segment) | SidebarMenu::TextAnimationIn(segment) | SidebarMenu::TextAnimationOut(segment) - | SidebarMenu::Camera3DBlurMode(segment) => { + | SidebarMenu::Camera3DBlurMode(segment) + | SidebarMenu::Camera3DEasing(segment) => { self.choose_panel_menu(kind, segment, index, window, cx) } } @@ -1154,7 +1165,6 @@ impl EditorWindow { .into_any_element() } - /// A `KSelect.Trigger` -- `ui::Select` opening `ui::Menu` at the pointer. pub(crate) fn menu_select( &self, kind: SidebarMenu, @@ -1164,10 +1174,9 @@ impl EditorWindow { ) -> AnyElement { ui::Select::plain(&self.theme, id, label) .stretch_label() - .on_click( - cx.listener(move |this, event: &gpui::ClickEvent, window, cx| { - let origin = event.position(); - this.open_sidebar_menu(kind, origin, window, cx); + .on_open( + cx.listener(move |this, bounds: &Bounds, window, cx| { + this.open_sidebar_menu(kind, *bounds, window, cx); }), ) .into_any_element() @@ -1184,10 +1193,9 @@ impl EditorWindow { ) -> AnyElement { ui::Select::plain(&self.theme, id, label) .stretch_label() - .on_click( - cx.listener(move |this, event: &gpui::ClickEvent, window, cx| { - let origin = event.position(); - this.open_sidebar_menu(kind, origin, window, cx); + .on_open( + cx.listener(move |this, bounds: &Bounds, window, cx| { + this.open_sidebar_menu(kind, *bounds, window, cx); }), ) .into_any_element() @@ -2262,10 +2270,13 @@ impl EditorWindow { .tooltip({ let model_name = SharedString::new_static(model.model_name); move |_window, cx| ui::Tooltip::new(&theme, model_name.clone()).view(cx) - }) - .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| { - this.open_sidebar_menu(SidebarMenu::CaptionModel, event.position(), window, cx); - })); + }); + let model_trigger = ui::Menu::trigger( + model_trigger, + cx.listener(|this, bounds: &Bounds, window, cx| { + this.open_sidebar_menu(SidebarMenu::CaptionModel, *bounds, window, cx); + }), + ); // The download / generate column (`CaptionsTab.tsx:936-1032`). let action = if model_downloaded { @@ -2667,17 +2678,12 @@ impl EditorWindow { ), ); + // An extra flex ancestor here repeats intrinsic layout while scrolling. ui::Field::plain(&theme, "Captions") .icon("icons/message-bubble.svg") .badge("Beta") - .child( - div() - .flex() - .flex_col() - .gap(px(24.)) - .child(transcription) - .child(style), - ) + .child(transcription) + .child(style.mt(px(8.))) .into_any_element() } diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index b5579e57be5..465f718f77b 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -59,7 +59,7 @@ use core_foundation::base::TCFType; #[cfg(target_os = "macos")] use core_video::pixel_buffer::{CVPixelBuffer, CVPixelBufferRef}; use gpui::{ - AppContext as _, Context, Entity, FocusHandle, FontWeight, Hsla, InteractiveElement, + AppContext as _, Bounds, Context, Entity, FocusHandle, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render, RenderImage, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage as _, Subscription, WeakEntity, Window, div, point, prelude::FluentBuilder, @@ -652,25 +652,30 @@ impl Render for EditorSectionView { let Some(editor) = self.editor.upgrade() else { return div().into_any_element(); }; - editor.update(cx, |editor, cx| match self.section { - EditorSection::Header => editor.render_header(window, cx).into_any_element(), - EditorSection::Toolbar => editor.render_player_toolbar(cx).into_any_element(), - EditorSection::Transport => editor.render_transport(cx).into_any_element(), - // The Clips layout mode swaps the config sidebar's column for the - // clips sidebar; the config sidebar is hidden, not destroyed - // (`Editor.tsx:728-747`). - EditorSection::Sidebar => { - if editor.clips.open { - editor.render_clips_sidebar(cx).into_any_element() - } else { - editor.render_sidebar(cx).into_any_element() - } + editor.update(cx, |editor, cx| { + if !editor.project_ready() && !matches!(self.section, EditorSection::Header) { + return div().size_full().into_any_element(); } - EditorSection::Timeline => { - let viewport_width: f32 = window.viewport_size().width.into(); - editor - .render_timeline(viewport_width, cx) - .into_any_element() + match self.section { + EditorSection::Header => editor.render_header(window, cx).into_any_element(), + EditorSection::Toolbar => editor.render_player_toolbar(cx).into_any_element(), + EditorSection::Transport => editor.render_transport(cx).into_any_element(), + // The Clips layout mode swaps the config sidebar's column for the + // clips sidebar; the config sidebar is hidden, not destroyed + // (`Editor.tsx:728-747`). + EditorSection::Sidebar => { + if editor.clips.open { + editor.render_clips_sidebar(cx).into_any_element() + } else { + editor.render_sidebar(cx).into_any_element() + } + } + EditorSection::Timeline => { + let viewport_width: f32 = window.viewport_size().width.into(); + editor + .render_timeline(viewport_width, cx) + .into_any_element() + } } }) } @@ -1570,7 +1575,11 @@ impl EditorWindow { }) .detach(); - let name_input = cx.new(|cx| ui::TextInputState::single_line(window, cx)); + let name_input = cx.new(|cx| { + let mut input = ui::TextInputState::single_line(window, cx); + input.set_disabled(true, cx); + input + }); let hex_targets = [ crate::editor_sidebar::ColorTarget::BackgroundColor, crate::editor_sidebar::ColorTarget::GradientFrom, @@ -1822,7 +1831,8 @@ impl EditorWindow { // the timeline's width. self.view.transform = Transform::initial(summary.duration); self.name_input.update(cx, |input, cx| { - input.set_text(summary.pretty_name.clone(), cx) + input.set_text(summary.pretty_name.clone(), cx); + input.set_disabled(false, cx); }); self.state = LoadState::Ready(Box::new(summary)); cx.notify(); @@ -1858,6 +1868,7 @@ impl EditorWindow { /// the waveforms arrive separately and later, so whatever has landed is /// carried across. fn rebuild_timeline(&mut self) { + dismiss_indexed_sidebar_menu(&mut self.sidebar.menu); let mic = std::mem::take(&mut self.timeline.mic_waveforms); let system = std::mem::take(&mut self.timeline.system_waveforms); self.timeline = TimelineModel::build_with_lanes( @@ -1966,6 +1977,9 @@ impl EditorWindow { } pub(crate) fn project_changed(&mut self, window: &mut Window, cx: &mut Context) { + if !self.project_ready() { + return; + } // Before `history.record`, so the re-projected caption track is part // of the same undo entry as the edit that moved it. self.rederive_caption_track(); @@ -1978,6 +1992,9 @@ impl EditorWindow { } pub(crate) fn project_changed_live(&mut self, cx: &mut Context) { + if !self.project_ready() { + return; + } self.publish_project(); cx.notify(); } @@ -2058,6 +2075,9 @@ impl EditorWindow { /// the re-render is skipped while playing exactly as `emitRenderFrame`'s /// `if (!editorState.playing)` gate does (`:493`). pub(crate) fn publish_project(&self) { + if !self.project_ready() { + return; + } let Some(instance) = &self.instance else { return; }; @@ -2076,6 +2096,9 @@ impl EditorWindow { /// executor. A later edit drops this task, which is `clearTimeout` plus a /// fresh `setTimeout`. pub(crate) fn schedule_save(&mut self, window: &mut Window, cx: &mut Context) { + if !self.project_ready() { + return; + } self.pending_save.borrow_mut().config = Some(self.project.clone()); let pending = self.pending_save.clone(); self.save_task = Some(cx.spawn_in(window, async move |_, cx| { @@ -2164,6 +2187,10 @@ impl EditorWindow { self.selection.as_ref() } + pub(crate) fn project_ready(&self) -> bool { + self.instance.is_some() && matches!(&self.state, LoadState::Ready(_)) + } + #[allow(dead_code)] /// The live project config, for the units that render from it (the config /// sidebar's controls) or serialise it (export). @@ -2446,7 +2473,11 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { - if !is_playback_shortcut(&event.keystroke, ui::text_input_has_focus(window, cx)) { + if !is_playback_shortcut( + &event.keystroke, + ui::text_input_has_focus(window, cx), + self.sidebar.menu.is_some() || self.toolbar_menu.is_some(), + ) { return; } // Focused GPUI buttons arm a second click on key-up unless Space is @@ -2464,6 +2495,9 @@ impl EditorWindow { /// (`useEditorShortcuts.ts:10`) and `e.repeat` is ignored there /// (`:42`) as `is_held` is here. fn on_key(&mut self, event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut Context) { + if !self.project_ready() { + return; + } if self.frame_controls.is_open() && event.keystroke.key == "escape" { self.close_frame_controls(window, cx); cx.stop_propagation(); @@ -3033,6 +3067,7 @@ impl EditorWindow { /// `setEditorState("timeline", "selection", ...)`. pub(crate) fn set_selection(&mut self, selection: Option, cx: &mut Context) { if self.selection != selection { + dismiss_indexed_sidebar_menu(&mut self.sidebar.menu); self.selection = selection; cx.notify(); } @@ -5330,10 +5365,11 @@ impl EditorWindow { } else { self.history.can_redo() }; - let enabled = can || self.selection.is_some(); + let enabled = self.project_ready() && (can || self.selection.is_some()); ui::EditorButton::plain(&theme, id) .left_icon(icon) .disabled(!enabled) + .tooltip(&theme, if undo { "Undo" } else { "Redo" }) .on_click(cx.listener(move |this, _, window, cx| { if !(this.history.can_undo() || this.history.can_redo() || this.selection.is_some()) { @@ -5395,7 +5431,7 @@ impl EditorWindow { fn open_toolbar_menu( &mut self, kind: ToolbarMenu, - origin: gpui::Point, + trigger_bounds: Bounds, window: &mut Window, cx: &mut Context, ) { @@ -5405,7 +5441,7 @@ impl EditorWindow { let items = self.toolbar_menu_items(kind); self.toolbar_menu = Some(OpenToolbarMenu { kind, - state: ui::MenuState::new(origin, &items), + state: ui::MenuState::anchored(trigger_bounds, &items), }); cx.notify(); } @@ -5615,6 +5651,9 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { + if !self.project_ready() { + return; + } if self.presets_menu.is_some() { self.presets_menu = None; cx.notify(); @@ -7279,10 +7318,11 @@ impl EditorWindow { ui::EditorButton::plain(&theme, "presets") .left_icon("icons/presets.svg") .label("Presets") + .disabled(!self.project_ready()) .right_icon("icons/chevron-down.svg") .pressed(self.presets_menu.is_some()) - .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| { - this.open_presets_menu(event.position(), window, cx); + .on_open(cx.listener(|this, bounds: &Bounds, window, cx| { + this.open_presets_menu(bounds.bottom_left(), window, cx); })), ), ) @@ -7399,7 +7439,8 @@ impl EditorWindow { .h(px(40.)) .flex_none() .rounded(px(12.)) - .cursor_pointer() + .when(self.project_ready(), |button| button.cursor_pointer()) + .when(!self.project_ready(), |button| button.opacity(0.5)) .bg(gpui::linear_gradient( 180., gpui::linear_color_stop(gpui::rgb(0x3b82f6), 0.), @@ -7430,7 +7471,13 @@ impl EditorWindow { .text_color(gpui::white()), ) .child("Export") - .on_click(cx.listener(|this, _, window, cx| this.open_export(window, cx))) + .when(self.project_ready(), |button| { + button.on_click(cx.listener(|this, _, window, cx| { + if this.project_ready() { + this.open_export(window, cx); + } + })) + }) } // -- Player -------------------------------------------------------------- @@ -7482,10 +7529,10 @@ impl EditorWindow { .as_ref() .is_some_and(|menu| menu.kind == ToolbarMenu::AspectRatio), ) - .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| { + .on_open(cx.listener(|this, bounds: &Bounds, window, cx| { this.open_toolbar_menu( ToolbarMenu::AspectRatio, - event.position(), + *bounds, window, cx, ); @@ -7497,6 +7544,7 @@ impl EditorWindow { ui::EditorButton::plain(&theme, "crop") .left_icon("icons/crop.svg") .label("Crop") + .tooltip(&theme, "Crop Video") .pressed(self.crop.is_some()) .on_click(cx.listener(|this, _, window, cx| { this.open_crop(window, cx); @@ -7521,10 +7569,10 @@ impl EditorWindow { .child( ui::Select::plain(&theme, "preview-quality", self.preview_quality.label()) .stretch_label() - .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| { + .on_open(cx.listener(|this, bounds: &Bounds, window, cx| { this.open_toolbar_menu( ToolbarMenu::PreviewQuality, - event.position(), + *bounds, window, cx, ); @@ -7651,6 +7699,16 @@ impl EditorWindow { cx: &mut Context, ) -> impl IntoElement { let theme = self.theme; + let (label, key) = if factor > 1. { + ("Zoom out", "-") + } else { + ("Zoom in", "+") + }; + let modifier = if cfg!(target_os = "macos") { + "meta" + } else { + "ctrl" + }; div() .id(id) .flex() @@ -7658,6 +7716,12 @@ impl EditorWindow { .justify_center() .cursor_pointer() .hover(|this| this.opacity(0.7)) + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) + .tooltip(move |_window, cx| { + ui::Tooltip::new(&theme, label) + .keys([modifier, key]) + .view(cx) + }) .child( svg() .path(icon) @@ -7775,18 +7839,29 @@ impl EditorWindow { // `rounded-full border border-gray-300 bg-gray-3 size-9` // with `hover:bg-gray-4` -- [`ui::IconButton`]. .child( - ui::IconButton::new("transport-play", icon) - .size(px(36.)) - .icon_size(px(12.)) - .color(Hsla::from(theme.gray_12)) - .filled( - Hsla::from(theme.gray_3), - Some(Hsla::from(theme.gray_5)), - ) - .hover_bg(Hsla::from(theme.gray_4)) - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_play(window, cx); - })), + div() + .id("transport-play-tooltip") + .flex() + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) + .tooltip(move |_window, cx| { + ui::Tooltip::new(&theme, "Play/Pause video") + .keys(["Space"]) + .view(cx) + }) + .child( + ui::IconButton::new("transport-play", icon) + .size(px(36.)) + .icon_size(px(12.)) + .color(Hsla::from(theme.gray_12)) + .filled( + Hsla::from(theme.gray_3), + Some(Hsla::from(theme.gray_5)), + ) + .hover_bg(Hsla::from(theme.gray_4)) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_play(window, cx); + })), + ), ) .child( div() @@ -7826,6 +7901,12 @@ impl EditorWindow { div() .id("transport-split") .tab_index(0) + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) + .tooltip(move |_window, cx| { + ui::Tooltip::new(&theme, "Toggle Split") + .keys(["S"]) + .view(cx) + }) .flex() .flex_row() .items_center() @@ -8543,8 +8624,43 @@ fn playhead_extrapolation(playing: bool, epoch_has_sample: bool, since_last_samp since_last_sample.clamp(0.0, MAX_PLAYHEAD_EXTRAPOLATION) } -fn is_playback_shortcut(keystroke: &gpui::Keystroke, text_input_focused: bool) -> bool { - keystroke.key == "space" && !keystroke.modifiers.modified() && !text_input_focused +fn dismiss_indexed_sidebar_menu(menu: &mut Option) { + use crate::editor_tabs::SidebarMenu; + + let indexed = menu.as_ref().is_some_and(|menu| match menu.kind { + SidebarMenu::TextFontFamily(_) + | SidebarMenu::TextWeight(_) + | SidebarMenu::TextAnimationIn(_) + | SidebarMenu::TextAnimationOut(_) + | SidebarMenu::Camera3DBlurMode(_) + | SidebarMenu::Camera3DEasing(_) => true, + SidebarMenu::BackgroundCornerStyle + | SidebarMenu::CameraBlur + | SidebarMenu::CameraShape + | SidebarMenu::CameraCornerStyle + | SidebarMenu::AudioStereo + | SidebarMenu::CaptionModel + | SidebarMenu::CaptionLanguage + | SidebarMenu::CaptionFont + | SidebarMenu::CaptionHighlightStyle + | SidebarMenu::CaptionPosition + | SidebarMenu::CaptionAnimation + | SidebarMenu::CaptionWeight + | SidebarMenu::KeyboardFont + | SidebarMenu::KeyboardPosition + | SidebarMenu::KeyboardWeight => false, + }); + if indexed { + *menu = None; + } +} + +fn is_playback_shortcut( + keystroke: &gpui::Keystroke, + text_input_focused: bool, + menu_open: bool, +) -> bool { + keystroke.key == "space" && !keystroke.modifiers.modified() && !text_input_focused && !menu_open } impl Render for EditorWindow { @@ -8591,6 +8707,18 @@ impl Render for EditorWindow { .child(self.render_export_page(window, cx)); } + let timeline_drag_cursor = self + .drag + .map(|drag| match drag.kind { + DragKind::Move { .. } => gpui::CursorStyle::ClosedHand, + DragKind::TrimStart { .. } + | DragKind::TrimEnd { .. } + | DragKind::ClipTrimStart { .. } + | DragKind::ClipTrimEnd { .. } => gpui::CursorStyle::ResizeLeftRight, + DragKind::CreateZoom { .. } => gpui::CursorStyle::Arrow, + }) + .or((self.scrub == Some(Scrub::Ruler)).then_some(gpui::CursorStyle::ResizeLeftRight)); + div() .size_full() .flex() @@ -8818,6 +8946,13 @@ impl Render for EditorWindow { // over everything -- the same shape the settings window's sliders // use, because gpui has no pointer capture and a 96px row would // otherwise lose the drag the moment the pointer left it. + .children(timeline_drag_cursor.map(|cursor| { + div() + .id("timeline-active-drag-cursor") + .absolute() + .inset_0() + .cursor(cursor) + })) .children(self.timeline_resize.is_some().then(|| { ui::Slider::drag_layer( "timeline-height-drag", @@ -8836,6 +8971,7 @@ impl Render for EditorWindow { cx.notify(); }), ) + .cursor(gpui::CursorStyle::ResizeRow) })) .children(self.zoom_slider_drag.then(|| { ui::Slider::drag_layer( @@ -8890,12 +9026,20 @@ impl Render for EditorWindow { this.pad_mouse_up(cx); }), ) + .cursor(gpui::CursorStyle::Crosshair) })) // The canvas display drag: the source installs `mousemove` / // `mouseup` on `window` for the duration (`CEO.tsx:611-618`), so // a drag that leaves the letterboxed rect keeps tracking and the // release closes the undo bracket wherever it happens. - .children(self.canvas_drag.is_some().then(|| { + .children(self.canvas_drag.as_ref().map(|drag| { + let cursor = drag.resize.as_ref().map_or(gpui::CursorStyle::ClosedHand, |resize| { + if resize.dir_x == resize.dir_y { + gpui::CursorStyle::ResizeUpLeftDownRight + } else { + gpui::CursorStyle::ResizeUpRightDownLeft + } + }); ui::Slider::drag_layer( "canvas-display-drag", cx.listener(|this, event: &MouseMoveEvent, window, cx| { @@ -8905,6 +9049,7 @@ impl Render for EditorWindow { this.canvas_mouse_up(window, cx); }), ) + .cursor(cursor) })) // The open `KSelect` menu, painted last of all so it is over the // sidebar and the drag layers alike. @@ -8928,8 +9073,8 @@ impl Render for EditorWindow { .children( self.crop .as_ref() - .is_some_and(|state| state.drag.is_some()) - .then(|| { + .and_then(|state| state.drag.as_ref()) + .map(|drag| { ui::Slider::drag_layer( "crop-drag", cx.listener(|this, event: &MouseMoveEvent, window, cx| { @@ -8939,6 +9084,7 @@ impl Render for EditorWindow { this.crop_mouse_up(window, cx); }), ) + .cursor(drag.cursor()) }), ) } @@ -9102,6 +9248,132 @@ fn hex_to_color(rgba: [u8; 4]) -> cap_project::Color { mod tests { use super::*; + fn open_sidebar_menu_for_test( + kind: crate::editor_tabs::SidebarMenu, + ) -> Option { + Some(crate::editor_tabs::OpenMenu { + kind, + state: ui::MenuState::new( + point(px(12.), px(24.)), + &[ + ui::MenuItem::new("First", true), + ui::MenuItem::new("Second", false), + ], + ), + }) + } + + #[test] + fn indexed_sidebar_menus_are_dismissed_when_their_target_can_change() { + use crate::editor_tabs::SidebarMenu; + + for kind in [ + SidebarMenu::TextFontFamily(0), + SidebarMenu::TextWeight(1), + SidebarMenu::TextAnimationIn(2), + SidebarMenu::TextAnimationOut(3), + SidebarMenu::Camera3DBlurMode(4), + SidebarMenu::Camera3DEasing(5), + ] { + let mut menu = open_sidebar_menu_for_test(kind); + dismiss_indexed_sidebar_menu(&mut menu); + assert!(menu.is_none(), "{kind:?}"); + } + } + + #[test] + fn indexed_sidebar_menu_invalidation_preserves_global_menu_navigation() { + use crate::editor_tabs::SidebarMenu; + + for kind in [ + SidebarMenu::BackgroundCornerStyle, + SidebarMenu::CameraBlur, + SidebarMenu::CameraShape, + SidebarMenu::CameraCornerStyle, + SidebarMenu::AudioStereo, + SidebarMenu::CaptionModel, + SidebarMenu::CaptionLanguage, + SidebarMenu::CaptionFont, + SidebarMenu::CaptionHighlightStyle, + SidebarMenu::CaptionPosition, + SidebarMenu::CaptionAnimation, + SidebarMenu::CaptionWeight, + SidebarMenu::KeyboardFont, + SidebarMenu::KeyboardPosition, + SidebarMenu::KeyboardWeight, + ] { + let mut menu = open_sidebar_menu_for_test(kind); + let state = &mut menu.as_mut().unwrap().state; + assert_eq!(state.on_key("down"), ui::MenuKey::Moved); + let expected = state.clone(); + dismiss_indexed_sidebar_menu(&mut menu); + let remaining = menu.as_mut().unwrap(); + assert_eq!(remaining.kind, kind); + assert_eq!(remaining.state, expected); + assert_eq!(remaining.state.on_key("enter"), ui::MenuKey::Commit(1)); + } + } + + #[test] + fn indexed_sidebar_menu_cannot_retarget_after_delete_or_history_change() { + use crate::editor_tabs::SidebarMenu; + + let mut project = ProjectConfiguration { + timeline: Some(TimelineConfiguration { + segments: Vec::new(), + transitions: Vec::new(), + zoom_segments: Vec::new(), + scene_segments: Vec::new(), + mask_segments: Vec::new(), + text_segments: Vec::new(), + caption_segments: Vec::new(), + keyboard_segments: Vec::new(), + audio_segments: Vec::new(), + camera3d_segments: vec![ + edits::default_camera3d_segment(0.0, 2.0), + edits::default_camera3d_segment(2.0, 4.0), + ], + }), + ..Default::default() + }; + let mut history = ProjectHistory::new(project.clone()); + let mut menu = open_sidebar_menu_for_test(SidebarMenu::Camera3DEasing(0)); + assert_eq!( + menu.as_mut().unwrap().state.on_key("backspace"), + ui::MenuKey::Ignored + ); + assert!(edits::delete_segments( + project.timeline.as_mut().unwrap(), + TrackKind::ThreeD, + &[0], + )); + assert_eq!( + project.timeline.as_ref().unwrap().camera3d_segments[0].start, + 2.0 + ); + dismiss_indexed_sidebar_menu(&mut menu); + assert!(menu.is_none()); + history.record(&project); + + menu = open_sidebar_menu_for_test(SidebarMenu::Camera3DEasing(0)); + project = history.undo().unwrap().clone(); + assert_eq!( + project.timeline.as_ref().unwrap().camera3d_segments[0].start, + 0.0 + ); + dismiss_indexed_sidebar_menu(&mut menu); + assert!(menu.is_none()); + + menu = open_sidebar_menu_for_test(SidebarMenu::Camera3DBlurMode(1)); + project = history.redo().unwrap().clone(); + assert_eq!( + project.timeline.as_ref().unwrap().camera3d_segments.len(), + 1 + ); + dismiss_indexed_sidebar_menu(&mut menu); + assert!(menu.is_none()); + } + #[test] fn failed_predelete_save_keeps_the_pending_edit_for_retry() { let root = std::env::temp_dir().join(format!( @@ -9136,10 +9408,11 @@ mod tests { } #[test] - fn playback_shortcut_is_reserved_for_bare_space_outside_text_fields() { + fn playback_shortcut_is_reserved_for_bare_space_outside_text_fields_and_menus() { let space = gpui::Keystroke::parse("space").unwrap(); - assert!(is_playback_shortcut(&space, false)); - assert!(!is_playback_shortcut(&space, true)); + assert!(is_playback_shortcut(&space, false, false)); + assert!(!is_playback_shortcut(&space, true, false)); + assert!(!is_playback_shortcut(&space, false, true)); for key in [ "enter", "s", @@ -9149,7 +9422,7 @@ mod tests { "alt-space", ] { let keystroke = gpui::Keystroke::parse(key).unwrap(); - assert!(!is_playback_shortcut(&keystroke, false), "{key}"); + assert!(!is_playback_shortcut(&keystroke, false, false), "{key}"); } } diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 11bd2e252ae..80ec9cd418b 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -2665,6 +2665,9 @@ impl MainWindow { .id("microphone-warning") .absolute() .inset_0() + .when(cfg!(target_os = "windows"), |overlay| { + overlay.top(px(HEADER_HEIGHT)) + }) .rounded(px(16.)) .occlude() .flex() @@ -3383,6 +3386,9 @@ impl MainWindow { div() .absolute() .inset_0() + .when(cfg!(target_os = "windows"), |overlay| { + overlay.top(px(HEADER_HEIGHT)) + }) .rounded(px(16.)) .flex() .flex_col() diff --git a/apps/desktop-gpui/src/onboarding_window.rs b/apps/desktop-gpui/src/onboarding_window.rs index 9154b926757..36265a54172 100644 --- a/apps/desktop-gpui/src/onboarding_window.rs +++ b/apps/desktop-gpui/src/onboarding_window.rs @@ -2520,7 +2520,7 @@ impl Render for OnboardingWindow { theme, window.is_window_active(), window.is_maximized(), - false, + true, false, )); #[cfg(not(target_os = "windows"))] diff --git a/apps/desktop-gpui/src/screenshot_annotations.rs b/apps/desktop-gpui/src/screenshot_annotations.rs index 79a81909734..22865294400 100644 --- a/apps/desktop-gpui/src/screenshot_annotations.rs +++ b/apps/desktop-gpui/src/screenshot_annotations.rs @@ -2964,6 +2964,7 @@ impl ScreenshotEditorWindow { .child( div() .id("screenshot-annotation-color-backdrop") + .occlude() .absolute() .top_0() .left_0() @@ -2975,6 +2976,7 @@ impl ScreenshotEditorWindow { ) .child( div() + .occlude() .absolute() .left(px(left)) .top(px(top)) diff --git a/apps/desktop-gpui/src/screenshot_crop.rs b/apps/desktop-gpui/src/screenshot_crop.rs index 21e6f1c7125..a789563dcf2 100644 --- a/apps/desktop-gpui/src/screenshot_crop.rs +++ b/apps/desktop-gpui/src/screenshot_crop.rs @@ -664,6 +664,7 @@ impl ScreenshotEditorWindow { .child( div() .id("screenshot-crop-backdrop") + .occlude() .absolute() .inset_0() .bg(gpui::hsla(0., 0., 0., 0.8)) diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index 0b8f6db0811..f059bf6e745 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -3909,6 +3909,7 @@ impl ScreenshotEditorWindow { .child( div() .id("screenshot-popover-backdrop") + .occlude() .absolute() .top_0() .left_0() @@ -3920,6 +3921,7 @@ impl ScreenshotEditorWindow { ) .child( div() + .occlude() .absolute() .left(px(left)) .top(px(top)) @@ -4505,6 +4507,7 @@ fn kbd_tooltip( .id(gpui::SharedString::from(format!("{label}-tooltip"))) .flex_shrink_0() .child(child) + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) .tooltip(move |_window, cx| ui::Tooltip::new(&theme, label).keys(keys).view(cx)) } @@ -4538,6 +4541,7 @@ fn tool_button( } else { theme.gray_11 })) + .tooltip_show_delay(ui::TOOLTIP_SHOW_DELAY) .tooltip(move |_window, cx| { ui::Tooltip::new(&theme, label.clone()) .keys([shortcut.clone()]) diff --git a/apps/desktop-gpui/src/screenshot_export.rs b/apps/desktop-gpui/src/screenshot_export.rs index 0352004d949..8d30ab6b4a8 100644 --- a/apps/desktop-gpui/src/screenshot_export.rs +++ b/apps/desktop-gpui/src/screenshot_export.rs @@ -131,6 +131,16 @@ pub fn export_bounds(scaled: &[Annotation], canvas: (u32, u32)) -> ExportBounds pub fn composite(raw: &RawFrame, config: &ProjectConfiguration) -> Composited { let width = raw.width.max(1); let height = raw.height.max(1); + if config.annotations.is_empty() + && raw.rgba.len() == width as usize * height as usize * 4 + && is_opaque(&raw.rgba) + { + return Composited { + rgba: raw.rgba.clone(), + width, + height, + }; + } let scale_x = f64::from(width) / f64::from(raw.base_width.max(1)); let scale_y = f64::from(height) / f64::from(raw.base_height.max(1)); let scaled = scale_annotations(&config.annotations, scale_x, scale_y); @@ -201,16 +211,30 @@ pub fn needs_transparency(out: &Composited, config: &ProjectConfiguration) -> bo .any(|&alpha| alpha != 255) } +fn is_opaque(rgba: &[u8]) -> bool { + const ALPHA_MASK: u128 = 0xff000000ff000000ff000000ff000000; + let mut blocks = rgba.chunks_exact(16); + blocks + .by_ref() + .all(|block| u128::from_le_bytes(block.try_into().unwrap()) & ALPHA_MASK == ALPHA_MASK) + && blocks + .remainder() + .iter() + .skip(3) + .step_by(4) + .all(|&alpha| alpha == 255) +} + /// `withWhiteBackground` (`useScreenshotExport.ts:18-28`). pub fn flatten_onto_white(out: &Composited) -> Composited { - let mut rgba = vec![255u8; out.rgba.len()]; - blit_over_offset( - &mut rgba, - (out.width, out.height), - &out.rgba, - (out.width, out.height), - (0, 0), - ); + let mut rgba = out.rgba.clone(); + for pixel in rgba.chunks_exact_mut(4) { + let alpha = u32::from(pixel[3]); + for channel in &mut pixel[..3] { + *channel = ((u32::from(*channel) * alpha + 255 * (255 - alpha) + 127) / 255) as u8; + } + pixel[3] = 255; + } Composited { rgba, width: out.width, @@ -293,7 +317,7 @@ pub fn encode_for_share( /// Copy: always PNG, composited over white when transparency is not needed /// (`:183-198` -- `withWhiteBackground` only on the clipboard path). pub fn encode_for_copy(out: &Composited, config: &ProjectConfiguration) -> Result, String> { - if needs_transparency(out, config) { + if has_no_visible_background(&config.background.source) || is_opaque(&out.rgba) { encode_png(out) } else { encode_png(&flatten_onto_white(out)) @@ -969,6 +993,186 @@ mod tests { assert!(pixel[1] >= 127 && pixel[1] <= 128, "{pixel:?}"); } + fn reference_flatten(out: &Composited) -> Composited { + let mut rgba = vec![255; out.rgba.len()]; + blit_over_offset( + &mut rgba, + (out.width, out.height), + &out.rgba, + (out.width, out.height), + (0, 0), + ); + Composited { + rgba, + width: out.width, + height: out.height, + } + } + + fn reference_unannotated_composite( + raw: &RawFrame, + config: &ProjectConfiguration, + ) -> Composited { + let width = raw.width.max(1); + let height = raw.height.max(1); + let mut canvas = raw.rgba.clone(); + draw_annotations_onto(&mut canvas, width, height, &[]); + let mut rgba = vec![0; width as usize * height as usize * 4]; + if !has_no_visible_background(&config.background.source) { + rgba.fill(255); + } + blit_over_offset(&mut rgba, (width, height), &canvas, (width, height), (0, 0)); + Composited { + rgba, + width, + height, + } + } + + #[test] + fn white_flatten_matches_source_over_for_every_channel_and_alpha() { + let rgba = (0..=255u8) + .flat_map(|alpha| { + (0..=255u8).flat_map(move |channel| { + [channel, 255 - channel, channel.wrapping_mul(17), alpha] + }) + }) + .collect(); + let out = Composited { + rgba, + width: 256, + height: 256, + }; + assert_eq!(flatten_onto_white(&out).rgba, reference_flatten(&out).rgba); + } + + #[test] + fn opacity_check_matches_individual_alpha_bytes_at_block_boundaries() { + for length in 0..130 { + let opaque = vec![255; length]; + assert!(is_opaque(&opaque)); + for changed_byte in 0..length { + let mut rgba = opaque.clone(); + rgba[changed_byte] = 127; + assert_eq!( + is_opaque(&rgba), + rgba.iter().skip(3).step_by(4).all(|&alpha| alpha == 255), + ); + } + } + } + + #[test] + fn unannotated_composite_and_copy_preserve_all_alpha_values() { + for opaque in [false, true] { + let raw = RawFrame { + rgba: (0..=255u8) + .flat_map(|value| { + [ + value, + 255 - value, + value.wrapping_mul(17), + if opaque { 255 } else { value }, + ] + }) + .collect(), + width: 16, + height: 16, + base_width: 16, + base_height: 16, + }; + for invisible in [false, true] { + let mut config = ProjectConfiguration::default(); + if invisible { + invisible_background(&mut config); + } + let out = composite(&raw, &config); + let reference = reference_unannotated_composite(&raw, &config); + assert_eq!(out.rgba, reference.rgba); + let expected = if needs_transparency(&reference, &config) { + reference + } else { + reference_flatten(&reference) + }; + let png = encode_for_copy(&out, &config).unwrap(); + let decoded = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(decoded.dimensions(), (16, 16)); + assert_eq!(decoded.as_raw(), &expected.rgba); + } + } + } + + #[test] + fn unannotated_composite_preserves_extra_pixel_handling() { + let raw = RawFrame { + rgba: vec![10, 20, 30, 255, 40, 50, 60, 255], + width: 1, + height: 1, + base_width: 1, + base_height: 1, + }; + let config = ProjectConfiguration::default(); + assert_eq!( + composite(&raw, &config).rgba, + reference_unannotated_composite(&raw, &config).rgba, + ); + } + + #[test] + #[ignore] + fn benchmark_screenshot_copy_preparation() { + use std::{hint::black_box, time::Instant}; + for (width, height) in [(1920, 1080), (3840, 2160)] { + for opaque in [false, true] { + let raw = RawFrame { + rgba: (0..width * height) + .flat_map(|i| { + [ + (i % 251) as u8, + ((i / width) % 253) as u8, + (i % 247) as u8, + if opaque { 255 } else { (i % 256) as u8 }, + ] + }) + .collect(), + width, + height, + base_width: width, + base_height: height, + }; + let config = ProjectConfiguration::default(); + let mut before = Vec::new(); + let mut after = Vec::new(); + for iteration in 0..8 { + let start = Instant::now(); + let reference = reference_unannotated_composite(black_box(&raw), &config); + let reference = reference_flatten(&reference); + let baseline_ms = start.elapsed().as_secs_f64() * 1000.0; + let start = Instant::now(); + let result = composite(black_box(&raw), &config); + let result = if is_opaque(&result.rgba) { + result + } else { + flatten_onto_white(&result) + }; + let candidate_ms = start.elapsed().as_secs_f64() * 1000.0; + assert_eq!(result.rgba, reference.rgba); + black_box(result); + if iteration > 0 { + before.push(baseline_ms); + after.push(candidate_ms); + } + } + before.sort_by(f64::total_cmp); + after.sort_by(f64::total_cmp); + println!( + "{}", + serde_json::json!({"width":width,"height":height,"opaque":opaque,"baseline_ms":before[3],"candidate_ms":after[3],"pixels_equal":true,"scope":"CPU composite and clipboard flatten; excludes GPU, PNG and native pasteboard"}) + ); + } + } + } + /// `withWhiteBackground` leaves an opaque canvas untouched. #[test] fn flattening_an_opaque_canvas_is_the_identity() { diff --git a/apps/desktop-gpui/src/settings_pages.rs b/apps/desktop-gpui/src/settings_pages.rs index 3cbd3d9e65d..47fe8483391 100644 --- a/apps/desktop-gpui/src/settings_pages.rs +++ b/apps/desktop-gpui/src/settings_pages.rs @@ -2262,9 +2262,8 @@ impl SettingsWindow { let white = gpui::white(); let mut column = div() .absolute() - .top_0() - .left_0() - .size_full() + .inset_0() + .when(cfg!(target_os = "windows"), |overlay| overlay.top(px(36.))) // Nothing behind the takeover is clickable while it runs. .occlude() .flex() @@ -2468,26 +2467,15 @@ impl SettingsWindow { (Ok(()), ClassicTarget::DevSupervisor) => { tracing::info!("handing back to the classic app; waiting for the dev build"); self.pages.switch_back = Some(SwitchBack::WaitingForClassic); + self.pages.switch_back_ticker = None; cx.notify(); - // Occupies the ticker slot so starting or cancelling another - // sequence drops this waiter with it. - self.pages.switch_back_ticker = Some(cx.spawn(async move |this, cx| { + // A committed handoff must finish even if its settings window closes. + cx.spawn(async move |this, cx| { let started = std::time::Instant::now(); loop { cx.background_executor() .timer(Duration::from_millis(500)) .await; - let waiting = this - .update(cx, |this, _| { - matches!( - this.pages.switch_back, - Some(SwitchBack::WaitingForClassic) - ) - }) - .unwrap_or(false); - if !waiting { - break; - } if !store::classic_pending_path().exists() { tracing::info!("classic app is up; quitting"); cx.update(quit_after_flushing_editors); @@ -2506,7 +2494,8 @@ impl SettingsWindow { break; } } - })); + }) + .detach(); } (Err(message), _) => { tracing::error!("{message}"); diff --git a/apps/desktop-gpui/src/ui/button.rs b/apps/desktop-gpui/src/ui/button.rs index ec8f750a53f..048cadefad7 100644 --- a/apps/desktop-gpui/src/ui/button.rs +++ b/apps/desktop-gpui/src/ui/button.rs @@ -8,6 +8,7 @@ use gpui::{ prelude::FluentBuilder, px, svg, }; +use super::menu::OpenHandler; use crate::theme::Theme; /// The click handler every component takes. `cx.listener(..)` produces exactly @@ -130,6 +131,7 @@ pub struct Button { /// than the settings surface's repaint. dim_disabled: bool, on_click: Option, + on_open: Option, } impl Button { @@ -157,6 +159,7 @@ impl Button { height: None, dim_disabled: false, on_click: None, + on_open: None, } } @@ -318,6 +321,14 @@ impl Button { self.on_click = Some(Box::new(handler)); self } + + pub fn on_open( + mut self, + handler: impl Fn(&gpui::Bounds, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Box::new(handler)); + self + } } /// The Radix fills for one variant, before any material remap. @@ -414,6 +425,7 @@ impl RenderOnce for Button { full_width, height, on_click, + on_open, } = self; let icon_color = paint.text; @@ -478,6 +490,7 @@ impl RenderOnce for Button { .when_some(on_click.filter(|_| !disabled), |this, handler| { this.on_click(move |event, window, cx| handler(event, window, cx)) }) + .when_some(on_open.filter(|_| !disabled), crate::ui::Menu::trigger) } } diff --git a/apps/desktop-gpui/src/ui/editor_button.rs b/apps/desktop-gpui/src/ui/editor_button.rs index ff9224554a9..8881f737c20 100644 --- a/apps/desktop-gpui/src/ui/editor_button.rs +++ b/apps/desktop-gpui/src/ui/editor_button.rs @@ -14,11 +14,6 @@ //! //! Disabled is `opacity-50 text-gray-11` on both. //! -//! The polymorphic `as={KSelect.Trigger}` half has no gpui equivalent -- there -//! is no element to become -- so a call site that needs this button to open a -//! menu opens one from its own `on_click`, which is what `ui::Select` already -//! does. - use gpui::{ App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, @@ -27,7 +22,7 @@ use gpui::{ use crate::theme::Theme; -use super::{ClickHandler, Tooltip}; +use super::{ClickHandler, Tooltip, menu::OpenHandler}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EditorButtonVariant { @@ -59,6 +54,7 @@ pub struct EditorButton { pressed_text: Hsla, tooltip: Option<(Theme, SharedString)>, on_click: Option, + on_open: Option, } impl EditorButton { @@ -82,6 +78,7 @@ impl EditorButton { pressed_text: Hsla::from(theme.gray_12), tooltip: None, on_click: None, + on_open: None, } } @@ -146,6 +143,14 @@ impl EditorButton { self.on_click = Some(Box::new(handler)); self } + + pub fn on_open( + mut self, + handler: impl Fn(&gpui::Bounds, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Box::new(handler)); + self + } } impl RenderOnce for EditorButton { @@ -169,6 +174,7 @@ impl RenderOnce for EditorButton { pressed_text, tooltip, on_click, + on_open, } = self; let foreground = if disabled { @@ -216,10 +222,12 @@ impl RenderOnce for EditorButton { .text_color(foreground) })) .when_some(tooltip, |this, (theme, label)| { - this.tooltip(move |_window, cx| Tooltip::new(&theme, label.clone()).view(cx)) + this.tooltip_show_delay(crate::ui::TOOLTIP_SHOW_DELAY) + .tooltip(move |_window, cx| Tooltip::new(&theme, label.clone()).view(cx)) }) .when_some(on_click.filter(|_| !disabled), |this, handler| { this.on_click(move |event, window, cx| handler(event, window, cx)) }) + .when_some(on_open.filter(|_| !disabled), crate::ui::Menu::trigger) } } diff --git a/apps/desktop-gpui/src/ui/menu.rs b/apps/desktop-gpui/src/ui/menu.rs index c18c7552d5f..f19ed5b21e3 100644 --- a/apps/desktop-gpui/src/ui/menu.rs +++ b/apps/desktop-gpui/src/ui/menu.rs @@ -10,14 +10,18 @@ //! The state machine is a plain struct so it can be tested without a window; //! [`Menu`] is the element that draws it. +use std::{cell::Cell, rc::Rc}; + use gpui::{ - App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, - Point, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, - prelude::FluentBuilder, px, svg, + Anchor, App, Bounds, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, + ParentElement, Pixels, Point, RenderOnce, SharedString, Size, StatefulInteractiveElement, + Styled, Window, div, point, prelude::FluentBuilder, px, svg, }; use crate::theme::Theme; +pub(crate) type OpenHandler = Box, &mut Window, &mut App) + 'static>; + /// One row: a label and whether it is the value currently in force. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MenuItem { @@ -52,6 +56,7 @@ pub enum MenuKey { #[derive(Debug, Clone, PartialEq)] pub struct MenuState { pub origin: Point, + pub trigger_bounds: Option>, pub len: usize, pub highlighted: Option, /// Whether the highlight is *drawn*. A menu opened by pointer shows the @@ -69,12 +74,20 @@ impl MenuState { pub fn new(origin: Point, items: &[MenuItem]) -> Self { Self { origin, + trigger_bounds: None, len: items.len(), highlighted: items.iter().position(|item| item.checked), highlight_visible: false, } } + pub fn anchored(trigger_bounds: Bounds, items: &[MenuItem]) -> Self { + Self { + trigger_bounds: Some(trigger_bounds), + ..Self::new(trigger_bounds.bottom_left(), items) + } + } + /// The index to paint highlighted, if any. pub fn visible_highlight(&self) -> Option { self.highlighted.filter(|_| self.highlight_visible) @@ -139,6 +152,7 @@ pub struct Menu { id: ElementId, items: Vec, origin: Point, + trigger_bounds: Option>, highlighted: Option, min_width: Pixels, max_height: Pixels, @@ -166,6 +180,7 @@ impl Menu { id: id.into(), items, origin: state.origin, + trigger_bounds: state.trigger_bounds, highlighted: state.visible_highlight(), min_width: px(180.), max_height: px(320.), @@ -200,6 +215,30 @@ impl Menu { self } + pub fn trigger( + element: gpui::Stateful, + handler: impl Fn(&Bounds, &mut Window, &mut App) + 'static, + ) -> gpui::Stateful { + let bounds = Rc::new(Cell::new(None)); + let measured_bounds = bounds.clone(); + element + .tab_index(0) + .relative() + .on_click(move |_, window, cx| { + if let Some(bounds) = bounds.get() { + handler(&bounds, window, cx); + } + }) + .child( + gpui::canvas( + move |bounds, _, _| measured_bounds.set(Some(bounds)), + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + } + pub fn on_select(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self { self.on_select = Some(Box::new(handler)); self @@ -214,12 +253,39 @@ impl Menu { } } +fn anchored_placement( + trigger: Bounds, + item_count: usize, + max_height: Pixels, + viewport: Size, +) -> (Point, Anchor, Pixels) { + let gap = px(4.); + let margin = px(12.); + let below = (viewport.height - margin - trigger.bottom() - gap).max(px(0.)); + let above = (trigger.top() - gap - margin).max(px(0.)); + let height = (px(item_count as f32 * 24. + 10.)).min(max_height); + if height <= below || below >= above { + ( + trigger.bottom_left() + point(px(0.), gap), + Anchor::TopLeft, + max_height.min(below), + ) + } else { + ( + trigger.origin - point(px(0.), gap), + Anchor::BottomLeft, + max_height.min(above), + ) + } +} + impl RenderOnce for Menu { fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { let Menu { id, items, origin, + trigger_bounds, highlighted, min_width, max_height, @@ -239,6 +305,11 @@ impl RenderOnce for Menu { let viewport = window.viewport_size(); let max_width = (viewport.width - px(24.)).max(px(0.)); let max_height = max_height.min((viewport.height - px(24.)).max(px(0.))); + let min_width = trigger_bounds.map_or(min_width, |bounds| min_width.max(bounds.size.width)); + let (origin, anchor, max_height) = trigger_bounds + .map_or((origin, Anchor::TopLeft, max_height), |bounds| { + anchored_placement(bounds, items.len(), max_height, viewport) + }); div() .absolute() @@ -249,6 +320,7 @@ impl RenderOnce for Menu { // Click-away dismiss, the way a native menu closes. div() .id(SharedString::from(format!("{prefix}-backdrop"))) + .occlude() .absolute() .top_0() .left_0() @@ -260,6 +332,7 @@ impl RenderOnce for Menu { .child( gpui::anchored() .position(origin) + .anchor(anchor) .snap_to_window_with_margin(px(12.)) .child( div() @@ -327,6 +400,45 @@ mod tests { MenuState::new(point(px(0.), px(0.)), &items(checked)) } + #[test] + fn a_select_menu_uses_the_trigger_bounds_and_current_value() { + let bounds = Bounds::new(point(px(100.), px(80.)), gpui::size(px(160.), px(36.))); + let menu = MenuState::anchored(bounds, &items(Some(2))); + assert_eq!(menu.trigger_bounds, Some(bounds)); + assert_eq!(menu.origin, bounds.bottom_left()); + assert_eq!(menu.highlighted, Some(2)); + } + + #[test] + fn a_select_menu_opens_below_the_button_when_it_fits() { + let bounds = Bounds::new(point(px(100.), px(80.)), gpui::size(px(160.), px(36.))); + let (origin, anchor, height) = + anchored_placement(bounds, 4, px(320.), gpui::size(px(800.), px(600.))); + assert_eq!(origin, point(px(100.), px(120.))); + assert_eq!(anchor, Anchor::TopLeft); + assert_eq!(height, px(320.)); + } + + #[test] + fn a_select_menu_flips_above_a_button_near_the_bottom() { + let bounds = Bounds::new(point(px(100.), px(540.)), gpui::size(px(160.), px(36.))); + let (origin, anchor, height) = + anchored_placement(bounds, 4, px(320.), gpui::size(px(800.), px(600.))); + assert_eq!(origin, point(px(100.), px(536.))); + assert_eq!(anchor, Anchor::BottomLeft); + assert_eq!(height, px(320.)); + } + + #[test] + fn a_long_select_menu_is_limited_to_the_larger_side_of_the_button() { + let bounds = Bounds::new(point(px(100.), px(220.)), gpui::size(px(160.), px(36.))); + let (origin, anchor, height) = + anchored_placement(bounds, 40, px(320.), gpui::size(px(800.), px(420.))); + assert_eq!(origin, point(px(100.), px(216.))); + assert_eq!(anchor, Anchor::BottomLeft); + assert_eq!(height, px(204.)); + } + #[test] fn a_menu_opens_on_the_value_it_currently_holds() { assert_eq!(state(Some(2)).highlighted, Some(2)); diff --git a/apps/desktop-gpui/src/ui/radio_cards.rs b/apps/desktop-gpui/src/ui/radio_cards.rs index d6c3d31cbb4..f97047eacb6 100644 --- a/apps/desktop-gpui/src/ui/radio_cards.rs +++ b/apps/desktop-gpui/src/ui/radio_cards.rs @@ -16,7 +16,7 @@ use gpui::{ App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, svg, }; use crate::theme::Theme; @@ -145,10 +145,20 @@ impl RenderOnce for RadioCards { .mt(px(4.)) .size(px(16.)) .flex_none() + .flex() + .items_center() + .justify_center() .rounded_full() .border_1() .border_color(if checked { dot_fill } else { dot_border }) - .when(checked, |this| this.bg(dot_fill)), + .when(checked, |this| { + this.bg(dot_fill).child( + svg() + .path("icons/check.svg") + .size(px(12.)) + .text_color(Hsla::from(gpui::rgb(0xffffff))), + ) + }), ) .child( div() diff --git a/apps/desktop-gpui/src/ui/select.rs b/apps/desktop-gpui/src/ui/select.rs index c42f0fdb3cb..07db73057f9 100644 --- a/apps/desktop-gpui/src/ui/select.rs +++ b/apps/desktop-gpui/src/ui/select.rs @@ -7,11 +7,12 @@ //! that leaves the camera bubble's mirror button disabled). use gpui::{ - App, ClickEvent, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, - Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, - prelude::FluentBuilder, px, svg, + App, Bounds, ClickEvent, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, + ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, + div, prelude::FluentBuilder, px, svg, }; +use super::menu::OpenHandler; use crate::theme::Theme; #[derive(IntoElement)] @@ -38,6 +39,7 @@ pub struct Select { stretch: bool, disabled: bool, on_click: Option, + on_open: Option, } impl Select { @@ -68,6 +70,7 @@ impl Select { stretch: false, disabled: false, on_click: None, + on_open: None, } } @@ -117,6 +120,14 @@ impl Select { self.on_click = Some(Box::new(handler)); self } + + pub fn on_open( + mut self, + handler: impl Fn(&Bounds, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Box::new(handler)); + self + } } impl RenderOnce for Select { @@ -139,6 +150,7 @@ impl RenderOnce for Select { stretch, disabled, on_click, + on_open, } = self; div() @@ -175,5 +187,6 @@ impl RenderOnce for Select { .when_some(on_click.filter(|_| !disabled), |this, handler| { this.on_click(move |event, window, cx| handler(event, window, cx)) }) + .when_some(on_open.filter(|_| !disabled), crate::ui::Menu::trigger) } } diff --git a/apps/desktop/src/components/CapErrorBoundary.tsx b/apps/desktop/src/components/CapErrorBoundary.tsx index 2dca68d1d47..b7f3801a90f 100644 --- a/apps/desktop/src/components/CapErrorBoundary.tsx +++ b/apps/desktop/src/components/CapErrorBoundary.tsx @@ -1,55 +1,72 @@ import { Button } from "@cap/ui-solid"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +import { type as ostype } from "@tauri-apps/plugin-os"; import { ErrorBoundary, type ParentProps } from "solid-js"; +import Titlebar from "./titlebar/Titlebar"; export function CapErrorBoundary(props: ParentProps) { return ( { console.error(e); + const windowLabel = getCurrentWebviewWindow().label; + const showTitlebar = + ostype() === "windows" && + ([ + "main", + "settings", + "upgrade", + "mode-select", + "onboarding", + "teleprompter", + ].includes(windowLabel) || + /^(editor|screenshot-editor)-\d+$/.test(windowLabel)); return ( -
- -

- An Error Occured -

-

- We're very sorry, but something has gone wrong. -

-
- - - -
- - {import.meta.env.DEV && ( -