diff --git a/.github/workflows/sync-tests.yml b/.github/workflows/sync-tests.yml index f42c258c6c3..b6f6731801d 100644 --- a/.github/workflows/sync-tests.yml +++ b/.github/workflows/sync-tests.yml @@ -137,6 +137,7 @@ jobs: shell: bash run: | cargo test --locked -p cap-timestamp -p cap-enc-ffmpeg + cargo test --locked -p cap-camera-effects --lib 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. diff --git a/Cargo.lock b/Cargo.lock index 6f98ac0d924..c395bf64716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1247,6 +1247,7 @@ dependencies = [ "tokio-util", "toml_edit 0.23.10+spec-1.0.0", "tracing", + "tracing-appender", "tracing-subscriber", "url", "uuid", @@ -1342,6 +1343,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytemuck", + "libloading 0.9.0", "ndarray 0.16.1", "ort", "tracing", @@ -1457,6 +1459,7 @@ dependencies = [ "async-stream", "axum", "base64 0.22.1", + "block2 0.6.1", "bytemuck", "bytes", "cap-audio", @@ -1585,6 +1588,7 @@ dependencies = [ "cap-media-info", "cap-project", "cap-rendering", + "cap-utils", "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=6013cb5f8bd3)", "ffmpeg-next", "flume", @@ -1593,6 +1597,7 @@ dependencies = [ "ringbuf", "sentry", "serde", + "serde_json", "specta", "tempfile", "tokio", @@ -2036,6 +2041,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "tracing-appender", "uuid", "windows 0.58.0", "windows-sys 0.52.0", @@ -9005,10 +9011,14 @@ dependencies = [ "core-graphics 0.24.0", "image 0.24.9", "objc", + "rustix 1.1.2", "serde", "specta", "tokio", "tracing", + "uuid", + "wayland-client", + "wayland-protocols", "windows 0.60.0", "workspace-hack", "x11rb", @@ -9584,6 +9594,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -12041,6 +12057,7 @@ dependencies = [ "getrandom 0.3.3", "js-sys", "serde", + "sha1_smol", "wasm-bindgen", ] diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index 73f5de61d7f..b66b0c7aee0 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -42,6 +42,7 @@ toml_edit = "0.23.7" url = "2.5.7" tracing.workspace = true tracing-subscriber = "0.3.19" +tracing-appender = "0.2.3" cpal = { workspace = true } winit = "0.30" softbuffer = "0.4" diff --git a/apps/cli/build.rs b/apps/cli/build.rs index b99bd3ea57d..77ca169f7b7 100644 --- a/apps/cli/build.rs +++ b/apps/cli/build.rs @@ -1,3 +1,6 @@ +#[path = "../../scripts/diagnostic-build.rs"] +mod diagnostic_build; + use std::path::Path; // The cap-demo skill directory is embedded wholesale into the `cap` binary via @@ -9,6 +12,7 @@ use std::path::Path; // including playwright-core — bloating the shipped CLI and making builds // non-reproducible. Guard against that here, loudly, at build time. fn main() { + diagnostic_build::emit(); let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); let node_modules = Path::new(&manifest_dir) .join("skill") diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 8c0a790f72d..00f1635d151 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -484,6 +484,30 @@ fn main() { ) .init(); + let _diagnostic_guard = if std::env::var_os("CAP_DIAGNOSTIC_PARENT").is_some() { + let (writer, guard) = tracing_appender::non_blocking(std::io::stderr()); + let queue_errors = writer.error_counter(); + cap_utils::operation_diagnostics::install_queue_loss_counter(move || { + queue_errors.dropped_lines() + }); + cap_utils::operation_diagnostics::install_sink( + cap_utils::operation_diagnostics::AppInfo { + flavor: "export_worker", + version: env!("CARGO_PKG_VERSION"), + source_revision: option_env!("CAP_BUILD_REVISION"), + debug_build: cfg!(debug_assertions), + source_dirty: option_env!("CAP_BUILD_DIRTY").map(|value| value == "true"), + }, + move |bytes| { + use std::io::Write; + let _ = writer.clone().write_all(bytes); + }, + ); + Some(guard) + } else { + None + }; + let exit_after_success = cli.exit_after_success(); // The self-test opens a window, which AppKit requires to live on the real @@ -516,7 +540,11 @@ fn main() { .build() .map_err(|e| format!("Failed to build Tokio runtime: {e}"))?; + if _diagnostic_guard.is_some() { + drop(runtime.spawn(cap_utils::operation_diagnostics::run_checkpoints())); + } let result = runtime.block_on(run(cli)); + drop(_diagnostic_guard); if exit_after_success && result.is_ok() { // Successful export/preview workers have already written their output by here. // Exiting directly avoids Windows GPU/MediaFoundation teardown crashes in the diff --git a/apps/cli/src/selftest/playback.rs b/apps/cli/src/selftest/playback.rs index 83845d08aa7..6612739e7a7 100644 --- a/apps/cli/src/selftest/playback.rs +++ b/apps/cli/src/selftest/playback.rs @@ -907,6 +907,8 @@ mod fixture { transitions: Vec::new(), zoom_segments: Vec::new(), scene_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), mask_segments: Vec::new(), text_segments: Vec::new(), caption_segments: Vec::new(), diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 504a5feeaff..d36888cc560 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1451,6 +1451,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytemuck", + "libloading 0.9.0", "ndarray 0.16.1", "ort", "tracing", @@ -1592,6 +1593,7 @@ dependencies = [ "tracing-subscriber", "tray-icon", "unicode-segmentation", + "wayland-client", "wgpu 25.0.2", "whisper-rs", "windows-sys 0.59.0", @@ -1608,6 +1610,7 @@ dependencies = [ "cap-media-info", "cap-project", "cap-rendering", + "cap-utils", "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=6013cb5f8bd3)", "ffmpeg-next", "flume 0.11.1", @@ -4552,6 +4555,7 @@ dependencies = [ "util_macros", "uuid", "waker-fn", + "wayland-client", "web-time", "windows 0.61.3", "zed-font-kit", @@ -9293,10 +9297,14 @@ dependencies = [ "core-graphics 0.24.0", "image 0.24.9", "objc", + "rustix 1.1.4", "serde", "specta", "tokio", "tracing", + "uuid", + "wayland-client", + "wayland-protocols", "windows 0.60.0", "workspace-hack", "x11rb", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 623a243cad7..83aa68e187b 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -23,7 +23,7 @@ name = "Cap GPUI" identifier = "so.cap.desktop.gpui" icon = ["assets/dock-icon.png"] category = "public.app-category.productivity" -osx_minimum_system_version = "11.0" +osx_minimum_system_version = "12.3" osx_info_plist_exts = ["resources/Info.plist"] osx_url_schemes = ["cap-desktop"] @@ -188,6 +188,7 @@ windows-sys = { version = "0.59", features = [ [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.11", default-features = false, features = ["tokio"] } +wayland-client = "0.31" [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] raw-window-handle = "0.6" diff --git a/apps/desktop-gpui/build.rs b/apps/desktop-gpui/build.rs index 202980b601e..4faa84b72b7 100644 --- a/apps/desktop-gpui/build.rs +++ b/apps/desktop-gpui/build.rs @@ -1,4 +1,8 @@ +#[path = "../../scripts/diagnostic-build.rs"] +mod diagnostic_build; + fn main() { + diagnostic_build::emit(); if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } diff --git a/apps/desktop-gpui/patches/zed-linux.patch b/apps/desktop-gpui/patches/zed-linux.patch index e84a49f5d16..8229cb96232 100644 --- a/apps/desktop-gpui/patches/zed-linux.patch +++ b/apps/desktop-gpui/patches/zed-linux.patch @@ -1,5 +1,57 @@ +diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml +--- a/crates/gpui/Cargo.toml ++++ b/crates/gpui/Cargo.toml +@@ -29,7 +29,7 @@ + bench = ["test-support", "dep:criterion", "dep:hdrhistogram"] + inspector = ["gpui_macros/inspector"] + leak-detection = ["backtrace"] +-wayland = [] ++wayland = ["dep:wayland-client"] + x11 = [ + "scap?/x11", + ] +@@ -110,6 +110,9 @@ + uuid = { workspace = true, features = ["js"] } + + ++[target.'cfg(target_os = "linux")'.dependencies] ++wayland-client = { version = "0.31.11", optional = true } ++ + [target.'cfg(target_os = "macos")'.dependencies] + block = "0.1" + cocoa.workspace = true +diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs +--- a/crates/gpui/src/platform.rs ++++ b/crates/gpui/src/platform.rs +@@ -795,6 +795,10 @@ + + #[expect(missing_docs)] + pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { ++ #[cfg(all(target_os = "linux", feature = "wayland"))] ++ fn wayland_surface(&self) -> Option { ++ None ++ } + fn bounds(&self) -> Bounds; + fn is_maximized(&self) -> bool; + fn window_bounds(&self) -> WindowBounds; +diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs +--- a/crates/gpui/src/window.rs ++++ b/crates/gpui/src/window.rs +@@ -1931,6 +1931,13 @@ + self.handle + } + ++ /// Returns the native Wayland proxy, which tracks destruction without borrowing a raw pointer. ++ #[cfg(all(target_os = "linux", feature = "wayland"))] ++ pub fn wayland_surface(&self) -> Option { ++ self.platform_window.wayland_surface() ++ } ++ ++ + /// Mark the window as dirty, scheduling it to be redrawn on the next frame. + pub fn refresh(&mut self) { + if self.invalidator.not_drawing() { diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs -index 876f931663fa..062af4e8c9b9 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -87,6 +87,9 @@ @@ -12,22 +64,46 @@ index 876f931663fa..062af4e8c9b9 100644 fn read_from_primary(&self) -> Option; fn read_from_clipboard(&self) -> Option; fn active_window(&self) -> Option; -@@ -735,6 +738,10 @@ - self.inner.write_to_clipboard(item) - } +@@ -733,6 +736,10 @@ -+ fn try_write_to_clipboard(&self, item: ClipboardItem) -> anyhow::Result<()> { -+ self.inner.try_write_to_clipboard(item) + fn write_to_clipboard(&self, item: ClipboardItem) { + self.inner.write_to_clipboard(item) + } + - fn read_from_primary(&self) -> Option { - self.inner.read_from_primary() ++ fn try_write_to_clipboard(&self, item: ClipboardItem) -> anyhow::Result<()> { ++ self.inner.try_write_to_clipboard(item) } + + fn read_from_primary(&self) -> Option { diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs -index fdc40a3de87e..c41fa8d6aebb 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs -@@ -1121,6 +1121,30 @@ +@@ -200,6 +200,7 @@ + + #[derive(Clone)] + pub struct Globals { ++ pub registry: wl_registry::WlRegistry, + pub qh: QueueHandle, + pub activation: Option, + pub compositor: wl_compositor::WlCompositor, +@@ -232,6 +233,7 @@ + ) -> Self { + let dialog_v = XdgWmDialogV1::interface().version; + Globals { ++ registry: globals.registry().clone(), + activation: globals.bind(&qh, 1..=1, ()).ok(), + compositor: globals + .bind( +@@ -662,7 +664,7 @@ + global.name, + wl_output_version(global.version), + &qh, +- (), ++ global.name, + ); + in_progress_outputs.insert(output.id(), InProgressOutput::default()); + wl_outputs.insert(output.id(), output); +@@ -1121,6 +1123,30 @@ } } @@ -58,7 +134,16 @@ index fdc40a3de87e..c41fa8d6aebb 100644 fn read_from_primary(&self) -> Option { self.0.borrow_mut().clipboard.read_primary() } -@@ -1301,6 +1325,25 @@ +@@ -1262,7 +1288,7 @@ + name, + wl_output_version(version), + qh, +- (), ++ name, + ); + + state +@@ -1301,6 +1327,25 @@ delegate_noop!(WaylandClientStatePtr: ignore wp_viewporter::WpViewporter); delegate_noop!(WaylandClientStatePtr: ignore wp_viewport::WpViewport); @@ -84,8 +169,22 @@ index fdc40a3de87e..c41fa8d6aebb 100644 impl Dispatch for WaylandClientStatePtr { fn event( state: &mut WaylandClientStatePtr, +@@ -1353,12 +1398,12 @@ + } + } + +-impl Dispatch for WaylandClientStatePtr { ++impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + output: &wl_output::WlOutput, + event: ::Event, +- _: &(), ++ _: &u32, + _: &Connection, + _: &QueueHandle, + ) { diff --git a/crates/gpui_linux/src/linux/wayland/clipboard.rs b/crates/gpui_linux/src/linux/wayland/clipboard.rs -index dfedf53f6dfa..fb40a8b391f1 100644 --- a/crates/gpui_linux/src/linux/wayland/clipboard.rs +++ b/crates/gpui_linux/src/linux/wayland/clipboard.rs @@ -1,6 +1,6 @@ @@ -117,6 +216,17 @@ index dfedf53f6dfa..fb40a8b391f1 100644 - pub fn send(&self, _mime_type: String, fd: OwnedFd) { - if let Some(text) = self.contents.as_ref().and_then(|contents| contents.text()) { - self.send_internal(fd, text.as_bytes().to_owned()); +- } +- } +- +- pub fn send_primary(&self, _mime_type: String, fd: OwnedFd) { +- if let Some(text) = self +- .primary_contents +- .as_ref() +- .and_then(|contents| contents.text()) +- { +- self.send_internal(fd, text.as_bytes().to_owned()); +- } + pub(crate) fn mime_types(&self, item: &ClipboardItem) -> anyhow::Result> { + let has_text = item + .entries() @@ -182,16 +292,9 @@ index dfedf53f6dfa..fb40a8b391f1 100644 + Err(error) => { + log::error!("Failed to prepare primary clipboard MIME type {mime_type}: {error}") + } - } - } - -- pub fn send_primary(&self, _mime_type: String, fd: OwnedFd) { -- if let Some(text) = self -- .primary_contents -- .as_ref() -- .and_then(|contents| contents.text()) -- { -- self.send_internal(fd, text.as_bytes().to_owned()); ++ } ++ } ++ + fn bytes_for_mime(item: &ClipboardItem, mime_type: &str) -> anyhow::Result>> { + if TEXT_MIME_TYPES.contains(&mime_type) { + return Ok(text_bytes(item)); @@ -203,7 +306,7 @@ index dfedf53f6dfa..fb40a8b391f1 100644 + return Ok(Some( + format!("copy\n{}", file_uris(item)?.join("\n")).into_bytes(), + )); - } ++ } + for entry in item.entries() { + if let ClipboardEntry::Image(image) = entry + && image.format().mime_type() == mime_type @@ -378,28 +481,38 @@ index dfedf53f6dfa..fb40a8b391f1 100644 + .unwrap() ) - .unwrap(); +- } +-} + .unwrap(), + "copy\nfile:///tmp/one.txt\nfile:///tmp/two.txt" + ); - } - } ++ } ++} diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs -index 993b2ff3fadc..e8916c11025f 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs -@@ -7,7 +7,7 @@ +@@ -7,15 +7,15 @@ }; - + use collections::{FxHashMap, HashMap}; -use futures::channel::oneshot::Receiver; +use futures::{FutureExt, channel::oneshot::Receiver}; - + use raw_window_handle as rwh; use wayland_backend::client::ObjectId; +-use wayland_client::WEnum; + use wayland_client::{ + Proxy, + protocol::{wl_output, wl_seat, wl_surface}, + }; ++use wayland_client::{WEnum, globals::GlobalListContents}; + use wayland_protocols::wp::viewporter::client::wp_viewport; + use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; + use wayland_protocols::xdg::shell::client::xdg_popup; @@ -93,7 +93,126 @@ tiling: Tiling, } - + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum VisibilityPhase { + Visible, @@ -523,7 +636,11 @@ index 993b2ff3fadc..e8916c11025f 100644 surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, parent: Option, -@@ -110,6 +229,8 @@ +@@ -107,9 +226,12 @@ + viewport: Option, + outputs: HashMap, + display: Option<(ObjectId, Output)>, ++ preferred_output: Option, globals: Globals, renderer: WgpuRenderer, bounds: Bounds, @@ -532,9 +649,44 @@ index 993b2ff3fadc..e8916c11025f 100644 scale: f32, input_handler: Option, decorations: WindowDecorations, -@@ -575,7 +696,11 @@ +@@ -539,6 +661,26 @@ + } + + impl WaylandWindowState { ++ fn fullscreen_output(&self) -> Option<&wl_output::WlOutput> { ++ self.preferred_output.as_ref().filter(|output| { ++ let Some(global_name) = output.data::() else { ++ return false; ++ }; ++ output.is_alive() ++ && self ++ .globals ++ .registry ++ .data::() ++ .is_some_and(|globals| { ++ globals.with_list(|list| { ++ list.iter().any(|global| { ++ global.name == *global_name && global.interface == "wl_output" ++ }) ++ }) ++ }) ++ }) ++ } ++ + pub(crate) fn new( + handle: AnyWindowHandle, + surface: wl_surface::WlSurface, +@@ -551,6 +693,7 @@ + compositor_gpu: Option, + options: WindowParams, + parent: Option, ++ preferred_output: Option, + ) -> anyhow::Result { + let renderer = { + let raw_window = RawWindow { +@@ -575,7 +718,11 @@ }; - + if let WaylandSurfaceState::Xdg(ref xdg_state) = surface_state { - if let Some(title) = options.titlebar.and_then(|titlebar| titlebar.title) { + if let Some(title) = options @@ -544,11 +696,11 @@ index 993b2ff3fadc..e8916c11025f 100644 + { xdg_state.toplevel.set_title(title.to_string()); } - -@@ -591,7 +716,16 @@ + +@@ -591,7 +738,16 @@ .set_max_size(max_texture_size, max_texture_size); } - + - Ok(Self { + let mut state = Self { + visibility: RetainedVisibility::default(), @@ -563,8 +715,11 @@ index 993b2ff3fadc..e8916c11025f 100644 surface_state, acknowledged_first_configure: false, parent, -@@ -605,6 +739,8 @@ +@@ -603,8 +759,11 @@ + globals, + outputs: HashMap::default(), display: None, ++ preferred_output, renderer, bounds: options.bounds, + fixed_outer_size: (!options.is_resizable).then_some(options.bounds.size), @@ -572,7 +727,7 @@ index 993b2ff3fadc..e8916c11025f 100644 scale: 1.0, input_handler: None, decorations: WindowDecorations::Client, -@@ -626,7 +762,30 @@ +@@ -626,7 +785,30 @@ window_controls: WindowControls::default(), client_inset: None, accesskit_adapter: None, @@ -602,9 +757,9 @@ index 993b2ff3fadc..e8916c11025f 100644 + self.fixed_geometry_size = Some(geometry_size); + } } - + pub fn is_transparent(&self) -> bool { -@@ -680,6 +839,34 @@ +@@ -680,6 +862,34 @@ impl Drop for WaylandWindow { fn drop(&mut self) { let mut state = self.0.state.borrow_mut(); @@ -639,19 +794,36 @@ index 993b2ff3fadc..e8916c11025f 100644 let surface_id = state.surface.id(); if let Some(parent) = state.parent.as_ref() { parent.state.borrow_mut().children.remove(&surface_id); -@@ -713,7 +900,7 @@ +@@ -713,7 +923,7 @@ // The wl_surface itself should always be destroyed last. state.surface.destroy(); - + - let state_ptr = self.0.clone(); + let state_ptr = self.clone(); state .globals .executor -@@ -838,8 +1025,153 @@ +@@ -754,7 +964,7 @@ + ¶ms, + parent.clone(), + popup_grab, +- target_output, ++ target_output.clone(), + )?; + + if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { +@@ -779,6 +989,7 @@ + compositor_gpu, + params, + parent, ++ target_output, + )?)), + callbacks: Rc::new(RefCell::new(Callbacks::default())), + }); +@@ -838,8 +1049,153 @@ state.children.values().any(|&blocking| blocking) } - + + fn visibility_supported(&self) -> bool { + let state = self.state.borrow(); + matches!(&state.surface_state, WaylandSurfaceState::Xdg(xdg) if xdg.dialog.is_none()) @@ -761,7 +933,7 @@ index 993b2ff3fadc..e8916c11025f 100644 + xdg.toplevel.set_maximized(); + } + if state.fullscreen { -+ xdg.toplevel.set_fullscreen(None); ++ xdg.toplevel.set_fullscreen(state.fullscreen_output()); + } + if let Some(decoration) = &xdg.decoration { + decoration.set_mode(state.decorations.to_xdg()); @@ -802,7 +974,7 @@ index 993b2ff3fadc..e8916c11025f 100644 state.surface.frame(&state.globals.qh, state.surface.id()); state.resize_throttle = false; let force_render = state.force_render_after_recovery; -@@ -882,6 +1214,17 @@ +@@ -882,6 +1238,17 @@ pub fn handle_xdg_surface_event(&self, event: xdg_surface::Event) { if let xdg_surface::Event::Configure { serial } = event { { @@ -820,7 +992,7 @@ index 993b2ff3fadc..e8916c11025f 100644 let mut state = self.state.borrow_mut(); if let Some(window_controls) = state.in_progress_window_controls.take() { state.window_controls = window_controls; -@@ -901,6 +1244,7 @@ +@@ -901,6 +1268,7 @@ state.fullscreen = configure.fullscreen; state.maximized = configure.maximized; state.tiling = configure.tiling; @@ -828,10 +1000,10 @@ index 993b2ff3fadc..e8916c11025f 100644 // Limit interactive resizes to once per vblank if configure.resizing && state.resize_throttle { state.surface_state.ack_configure(serial); -@@ -945,6 +1289,43 @@ +@@ -945,6 +1313,43 @@ window_geometry.size.height, ); - + + if matches!(state.visibility.phase, VisibilityPhase::Configuring(_)) + && !state.acknowledged_first_configure + { @@ -872,7 +1044,7 @@ index 993b2ff3fadc..e8916c11025f 100644 let request_frame_callback = !state.acknowledged_first_configure; if request_frame_callback { state.acknowledged_first_configure = true; -@@ -958,7 +1339,11 @@ +@@ -958,7 +1363,11 @@ if let zxdg_toplevel_decoration_v1::Event::Configure { mode } = event { match mode { WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ServerSide) => { @@ -885,7 +1057,7 @@ index 993b2ff3fadc..e8916c11025f 100644 let callback = self.callbacks.borrow_mut().appearance_changed.take(); if let Some(mut fun) = callback { fun(); -@@ -966,7 +1351,11 @@ +@@ -966,7 +1375,11 @@ } } WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ClientSide) => { @@ -898,7 +1070,7 @@ index 993b2ff3fadc..e8916c11025f 100644 // Update background to be transparent let callback = self.callbacks.borrow_mut().appearance_changed.take(); if let Some(mut fun) = callback { -@@ -1271,6 +1660,7 @@ +@@ -1271,6 +1684,7 @@ } if let Some(scale) = scale { state.scale = scale; @@ -906,19 +1078,30 @@ index 993b2ff3fadc..e8916c11025f 100644 } let device_bounds = state.bounds.to_device_pixels(state.scale); state.renderer.update_drawable_size(device_bounds.size); -@@ -1466,7 +1856,7 @@ +@@ -1428,6 +1842,10 @@ + } + + impl PlatformWindow for WaylandWindow { ++ fn wayland_surface(&self) -> Option { ++ Some(self.0.surface()) ++ } ++ + fn bounds(&self) -> Bounds { + self.borrow().bounds + } +@@ -1466,7 +1884,7 @@ } - + fn resize(&mut self, size: Size) { - let state = self.borrow(); + let mut state = self.borrow_mut(); let state_ptr = self.0.clone(); - + // A popup's placement is the compositor's, so a resize re-runs the positioner and the -@@ -1486,6 +1876,11 @@ +@@ -1486,6 +1904,11 @@ return; } - + + if state.fixed_outer_size.is_some() { + state.fixed_outer_size = Some(size); + state.update_size_constraints(); @@ -927,7 +1110,7 @@ index 993b2ff3fadc..e8916c11025f 100644 // Keep window geometry consistent with configure handling. On Wayland, window geometry is // surface-local: resizing should not attempt to translate the window; the compositor // controls placement. We also account for client-side decoration insets and tiling. -@@ -1566,6 +1961,20 @@ +@@ -1566,6 +1989,20 @@ _answers: &[PromptButton], ) -> Option> { None @@ -946,11 +1129,11 @@ index 993b2ff3fadc..e8916c11025f 100644 + ) -> anyhow::Result>> { + self.0.request_visibility(visible) } - + fn activate(&self) { -@@ -1596,7 +2005,9 @@ +@@ -1596,7 +2033,9 @@ } - + fn set_title(&mut self, title: &str) { - if let Some(toplevel) = self.borrow().surface_state.toplevel() { + let mut state = self.borrow_mut(); @@ -959,8 +1142,17 @@ index 993b2ff3fadc..e8916c11025f 100644 toplevel.set_title(title.to_string()); } } -@@ -1706,6 +2117,13 @@ - +@@ -1650,7 +2089,7 @@ + let state = self.borrow(); + if let Some(toplevel) = state.surface_state.toplevel() { + if !state.fullscreen { +- toplevel.set_fullscreen(None); ++ toplevel.set_fullscreen(state.fullscreen_output()); + } else { + toplevel.unset_fullscreen(); + } +@@ -1706,6 +2145,13 @@ + fn draw(&self, scene: &Scene) { let mut state = self.borrow_mut(); + if !state @@ -970,12 +1162,12 @@ index 993b2ff3fadc..e8916c11025f 100644 + { + return; + } - + if state.renderer.device_lost() { let raw_window = RawWindow { -@@ -1730,6 +2148,12 @@ +@@ -1730,6 +2176,12 @@ } - + state.renderer_presented = state.renderer.draw(scene); + if state.renderer_presented + && let VisibilityPhase::Configuring(transition) = state.visibility.phase @@ -983,11 +1175,11 @@ index 993b2ff3fadc..e8916c11025f 100644 + state.visibility.phase = VisibilityPhase::Remapping(transition); + WaylandWindowStatePtr::visibility_sync(&state, transition, true); + } - + if state.renderer.needs_redraw() { state.force_render_after_recovery = true; -@@ -1738,6 +2162,13 @@ - +@@ -1738,6 +2190,13 @@ + fn completed_frame(&self) { let mut state = self.borrow_mut(); + if !state @@ -997,11 +1189,11 @@ index 993b2ff3fadc..e8916c11025f 100644 + { + return; + } - + // Work around a bug in old versions of wlroots where committing without a buffer attached // can cause invalid synchronization that leads to graphical corruption. -@@ -1776,6 +2207,9 @@ - +@@ -1776,6 +2235,9 @@ + fn start_window_resize(&self, edge: gpui::ResizeEdge) { let state = self.borrow(); + if state.fixed_outer_size.is_some() { @@ -1010,7 +1202,7 @@ index 993b2ff3fadc..e8916c11025f 100644 if let Some(toplevel) = state.surface_state.toplevel() { toplevel.resize( &state.globals.seat, -@@ -1835,7 +2269,13 @@ +@@ -1835,7 +2297,13 @@ // Commit so the new input region applies immediately. Otherwise it // waits for the next frame, which could be the very click we want to // allow passing through. @@ -1023,9 +1215,9 @@ index 993b2ff3fadc..e8916c11025f 100644 + state.surface.commit(); + } } - + fn window_decorations(&self) -> Decorations { -@@ -1854,6 +2294,7 @@ +@@ -1854,6 +2322,7 @@ Some(decoration) => { decoration.set_mode(decorations.to_xdg()); state.decorations = decorations; @@ -1033,7 +1225,7 @@ index 993b2ff3fadc..e8916c11025f 100644 update_window(state); } None => { -@@ -1863,6 +2304,7 @@ +@@ -1863,6 +2332,7 @@ ); } state.decorations = WindowDecorations::Client; @@ -1041,7 +1233,7 @@ index 993b2ff3fadc..e8916c11025f 100644 update_window(state); } } -@@ -1876,6 +2318,7 @@ +@@ -1876,6 +2346,7 @@ let mut state = self.borrow_mut(); if Some(inset) != state.client_inset { state.client_inset = Some(inset); @@ -1049,8 +1241,8 @@ index 993b2ff3fadc..e8916c11025f 100644 update_window(state); } } -@@ -2082,3 +2525,337 @@ - +@@ -2082,3 +2553,337 @@ + bounds } + @@ -1388,7 +1580,6 @@ index 993b2ff3fadc..e8916c11025f 100644 + } +} diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs -index 0c8c9b4e20b8..701ae28fe440 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -811,11 +811,12 @@ @@ -1454,7 +1645,6 @@ index 0c8c9b4e20b8..701ae28fe440 100644 fn active_window(&self) -> Option { diff --git a/crates/gpui_linux/src/linux/x11/clipboard.rs b/crates/gpui_linux/src/linux/x11/clipboard.rs -index 706e7b096588..591535333fa2 100644 --- a/crates/gpui_linux/src/linux/x11/clipboard.rs +++ b/crates/gpui_linux/src/linux/x11/clipboard.rs @@ -22,6 +22,7 @@ @@ -1545,10 +1735,12 @@ index 706e7b096588..591535333fa2 100644 }]; self.inner.write(data, selection, wait) } -@@ -1138,6 +1182,66 @@ +@@ -1136,6 +1180,66 @@ + Error::Unknown { + description: error.to_string(), } - } - ++} ++ +fn file_uri_list_to_clipboard_data(paths: &[PathBuf], atoms: Atoms) -> Result> { + if paths.is_empty() { + return Err(Error::unknown("clipboard file list is empty")); @@ -1607,28 +1799,167 @@ index 706e7b096588..591535333fa2 100644 + format: atoms.NAUTILUS_FILE_LIST, + }, + ]) -+} -+ + } + /// Clipboard selection - /// - /// Linux has a concept of clipboard "selections" which tend to be used in different contexts. This diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs -@@ -1013,2 +1013,2 @@ +@@ -1010,10 +1010,14 @@ + + impl X11WindowStatePtr { + pub fn should_close(&self) -> bool { - let mut cb = self.callbacks.borrow_mut(); - if let Some(mut should_close) = cb.should_close.take() { + let should_close = self.callbacks.borrow_mut().should_close.take(); + if let Some(mut should_close) = should_close { -@@ -1016 +1016,5 @@ + let result = (should_close)(); - cb.should_close = Some(should_close); + let mut callbacks = self.callbacks.borrow_mut(); + if callbacks.should_close.is_none() { + callbacks.should_close = Some(should_close); + } + drop(callbacks); -@@ -1131,2 +1135,2 @@ + result + } else { + true +@@ -1128,8 +1132,8 @@ + } + } + - let mut callbacks = self.callbacks.borrow_mut(); - if let Some(fun) = callbacks.close.take() { + let close = self.callbacks.borrow_mut().close.take(); + if let Some(fun) = close { + fun() + } + } +diff --git a/crates/gpui_wgpu/src/gpui_wgpu.rs b/crates/gpui_wgpu/src/gpui_wgpu.rs +--- a/crates/gpui_wgpu/src/gpui_wgpu.rs ++++ b/crates/gpui_wgpu/src/gpui_wgpu.rs +@@ -1,4 +1,5 @@ + mod cosmic_text_system; ++mod surface_frame; + mod wgpu_atlas; + mod wgpu_context; + mod wgpu_renderer; +diff --git a/crates/gpui_wgpu/src/surface_frame.rs b/crates/gpui_wgpu/src/surface_frame.rs +new file mode 100644 +--- /dev/null ++++ b/crates/gpui_wgpu/src/surface_frame.rs +@@ -0,0 +1,34 @@ ++pub(super) struct SurfaceFrame { ++ frame: Option, ++ texture: TextureCleanup, ++} ++ ++impl SurfaceFrame { ++ pub(super) fn new(frame: wgpu::SurfaceTexture) -> Self { ++ Self { ++ texture: TextureCleanup(frame.texture.clone()), ++ frame: Some(frame), ++ } ++ } ++ ++ pub(super) fn texture(&self) -> &wgpu::Texture { ++ &self.texture.0 ++ } ++ ++ pub(super) fn present(mut self) { ++ if let Some(frame) = self.frame.take() { ++ frame.present(); ++ } ++ } ++} ++ ++struct TextureCleanup(wgpu::Texture); ++ ++impl Drop for TextureCleanup { ++ fn drop(&mut self) { ++ // wgpu 29 can retain an acquired texture after device loss. Release its raw ++ // resources after present/discard, before the owning surface is destroyed. ++ // https://github.com/gfx-rs/wgpu/issues/9277 ++ self.0.destroy(); ++ } ++} +diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs +--- a/crates/gpui_wgpu/src/wgpu_renderer.rs ++++ b/crates/gpui_wgpu/src/wgpu_renderer.rs +@@ -1,3 +1,4 @@ ++use crate::surface_frame::SurfaceFrame; + use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext}; + use bytemuck::{Pod, Zeroable}; + use gpui::{ +@@ -1118,24 +1119,15 @@ + + self.atlas.before_frame(); + +- let frame = match self ++ let (frame, suboptimal) = match self + .resources() + .surface + .as_ref() + .expect("Configured surface missing") + .get_current_texture() + { +- wgpu::CurrentSurfaceTexture::Success(frame) => frame, +- wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { +- // Textures must be destroyed before the surface can be reconfigured. +- drop(frame); +- let surface_config = self.surface_config.clone(); +- let resources = self.resources_mut(); +- if let Some(surface) = &resources.surface { +- surface.configure(&resources.device, &surface_config); +- } +- return false; +- } ++ wgpu::CurrentSurfaceTexture::Success(frame) => (SurfaceFrame::new(frame), false), ++ wgpu::CurrentSurfaceTexture::Suboptimal(frame) => (SurfaceFrame::new(frame), true), + wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { + let surface_config = self.surface_config.clone(); + let resources = self.resources_mut(); +@@ -1158,7 +1150,7 @@ + self.ensure_intermediate_textures(); + + let frame_view = frame +- .texture ++ .texture() + .create_view(&wgpu::TextureViewDescriptor::default()); + + let gamma_params = GammaParams { +@@ -1330,8 +1322,7 @@ + "instance buffer size grew too large: {}", + self.instance_buffer_capacity + ); +- frame.present(); +- return true; ++ return self.present_frame(frame, suboptimal); + } + self.grow_instance_buffer(); + continue; +@@ -1340,9 +1331,22 @@ + self.resources() + .queue + .submit(std::iter::once(encoder.finish())); +- frame.present(); +- return true; +- } ++ return self.present_frame(frame, suboptimal); ++ } ++ } ++ ++ fn present_frame(&mut self, frame: SurfaceFrame, suboptimal: bool) -> bool { ++ frame.present(); ++ if self.device_lost() { ++ return false; ++ } ++ if suboptimal { ++ let resources = self.resources(); ++ if let Some(surface) = &resources.surface { ++ surface.configure(&resources.device, &self.surface_config); ++ } ++ } ++ true + } + + fn draw_quads( diff --git a/apps/desktop-gpui/resources/Info.plist b/apps/desktop-gpui/resources/Info.plist index dd8348c74d9..b9ee3ed15d1 100644 --- a/apps/desktop-gpui/resources/Info.plist +++ b/apps/desktop-gpui/resources/Info.plist @@ -53,7 +53,7 @@ LSApplicationCategoryType public.app-category.productivity LSMinimumSystemVersion - 11.0 + 12.3 NSHighResolutionCapable NSCameraUsageDescription diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 65b0204fce4..5665a04bc29 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -15,8 +15,8 @@ use std::{ use cap_recording::sources::screen_capture::ScreenCaptureTarget; use gpui::{ - App, AppContext as _, Bounds, Entity, Global, WindowBounds, WindowHandle, WindowKind, - WindowOptions, point, px, size, + App, AppContext as _, Bounds, Entity, Global, Pixels, Size, WindowBounds, WindowHandle, + WindowKind, WindowOptions, point, px, size, }; use scap_targets::DisplayId; @@ -44,6 +44,151 @@ pub const CONTROLS_HEIGHT: f32 = 150.; const CONTROLS_BOTTOM_OFFSET: f64 = 120.; const TARGET_CONTROLS_OFFSET_Y: f64 = 48.; +pub(crate) fn display_work_area( + target: Option<&scap_targets::Display>, + cx: &App, +) -> Option> { + #[cfg(target_os = "macos")] + let display = target + .and_then(|target| target.id().to_string().parse::().ok()) + .and_then(|id| cx.find_display(gpui::DisplayId::new(id))); + #[cfg(not(target_os = "macos"))] + let display = target.and_then(|target| platform_display_for_capture(target, cx)); + #[cfg(target_os = "linux")] + if uses_wayland() { + return target.and_then(capture_display_bounds); + } + let display = display.or_else(|| cx.primary_display())?; + let available = display.visible_bounds(); + #[cfg(target_os = "macos")] + { + let id = u64::from(display.id()).to_string().parse().ok()?; + let bounds = scap_targets::Display::from_id(&id) + .as_ref() + .and_then(capture_display_bounds)?; + let primary_height = cx.primary_display()?.bounds().size.height; + Some(global_macos_work_area(available, bounds, primary_height)) + } + #[cfg(not(target_os = "macos"))] + Some(available) +} + +#[cfg(target_os = "linux")] +fn uses_wayland() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some() + && (std::env::var_os("DISPLAY").is_none() + || std::env::var("XDG_SESSION_TYPE") + .is_ok_and(|session| session.eq_ignore_ascii_case("wayland"))) +} + +#[cfg(not(target_os = "macos"))] +fn platform_display_for_capture( + target: &scap_targets::Display, + cx: &App, +) -> Option> { + #[cfg(target_os = "linux")] + if let Some(uuid) = target.raw_handle().wayland_uuid() { + return cx + .displays() + .into_iter() + .find(|display| display.uuid().is_ok_and(|candidate| candidate == uuid)); + } + let bounds = capture_display_bounds(target)?; + let mut matching = cx + .displays() + .into_iter() + .filter(|display| display.bounds().contains(&bounds.center())); + let display = matching.next()?; + #[cfg(target_os = "linux")] + if uses_wayland() && matching.next().is_some() { + return None; + } + Some(display) +} + +fn capture_display_bounds(display: &scap_targets::Display) -> Option> { + let bounds = display.raw_handle().logical_bounds()?; + Some(Bounds { + origin: point( + px(bounds.position().x() as f32), + px(bounds.position().y() as f32), + ), + size: size( + px(bounds.size().width() as f32), + px(bounds.size().height() as f32), + ), + }) +} + +#[cfg(target_os = "macos")] +fn global_macos_work_area( + mut available: Bounds, + display: Bounds, + primary_height: Pixels, +) -> Bounds { + // GPUI's macOS work area uses local x but includes the AppKit screen y. + // Windows opened without a display ID need primary-display coordinates. + let appkit_origin_y = primary_height - display.origin.y - display.size.height; + available.origin.x += display.origin.x; + available.origin.y += display.origin.y - appkit_origin_y; + available +} + +fn inset_work_area(available: Bounds) -> Bounds { + let inset = point( + px(16.).min((available.size.width - px(1.)).max(px(0.)) / 2.), + px(16.).min((available.size.height - px(1.)).max(px(0.)) / 2.), + ); + Bounds { + origin: available.origin + inset, + size: size( + (available.size.width - inset.x * 2.).max(px(1.)), + (available.size.height - inset.y * 2.).max(px(1.)), + ), + } +} + +fn fit_window_bounds(bounds: Bounds, available: Bounds) -> Bounds { + let size = size( + bounds.size.width.min(available.size.width).max(px(1.)), + bounds.size.height.min(available.size.height).max(px(1.)), + ); + Bounds { + origin: point( + bounds.origin.x.clamp( + available.origin.x, + available.origin.x + (available.size.width - size.width).max(px(0.)), + ), + bounds.origin.y.clamp( + available.origin.y, + available.origin.y + (available.size.height - size.height).max(px(0.)), + ), + ), + size, + } +} + +fn opening_window_bounds(preferred: Size, cx: &App) -> Bounds { + let target = scap_targets::Display::get_containing_cursor(); + match display_work_area(target.as_ref(), cx) { + Some(available) => { + let available = inset_work_area(available); + fit_window_bounds( + Bounds::centered_at(available.center(), preferred), + available, + ) + } + None => Bounds::centered(None, preferred, cx), + } +} + +fn fitted_window_min_size(preferred: Size, bounds: Bounds) -> Size { + size( + preferred.width.min(bounds.size.width), + preferred.height.min(bounds.size.height), + ) +} + pub struct AppWindows { pub main: WindowHandle, pub controls: Option>, @@ -887,7 +1032,7 @@ pub fn open_quality_settings(mode: Mode, cx: &mut App) { if defer_window_until_capture_safe(cx) { return; } - open_settings(Page::General, cx); + open_settings(Page::Quality, cx); if let Some(handle) = cx.global::().settings { handle .update(cx, |view, window, cx| { @@ -929,8 +1074,7 @@ pub fn open_settings(page: Page, cx: &mut App) { return; } - let bounds = Bounds::centered( - None, + let bounds = opening_window_bounds( size( px(settings_window::SETTINGS_WIDTH), px(settings_window::SETTINGS_HEIGHT), @@ -962,9 +1106,12 @@ pub fn open_settings(page: Page, cx: &mut App) { // `.resizable(true).maximized(false)`, and `min_inner_size`. is_resizable: true, is_minimizable: true, - window_min_size: Some(size( - px(settings_window::SETTINGS_MIN_WIDTH), - px(settings_window::SETTINGS_MIN_HEIGHT), + window_min_size: Some(fitted_window_min_size( + size( + px(settings_window::SETTINGS_MIN_WIDTH), + px(settings_window::SETTINGS_MIN_HEIGHT), + ), + bounds, )), // `builder.transparent(true)` on macOS -- the panes paint, the // material shows through the gap. @@ -1079,27 +1226,19 @@ pub fn open_onboarding(cx: &mut App) { } }) .detach(); - hide_main_window(cx); + hide_main_and_park_camera_preview(cx); return; } - let cursor_display = scap_targets::Display::get_containing_cursor() - .and_then(|display| display.raw_handle().logical_bounds()); - let display = cursor_display - .and_then(|bounds| { - let center = point( - px((bounds.position().x() + bounds.size().width() / 2.) as f32), - px((bounds.position().y() + bounds.size().height() / 2.) as f32), - ); - cx.displays() - .into_iter() - .find(|display| display.bounds().contains(¢er)) - }) - .or_else(|| cx.primary_display()); - let bounds = match display { - Some(display) => { - let available = display.visible_bounds(); - let width = (f32::from(display.bounds().size.width) * 0.58) + let display = scap_targets::Display::get_containing_cursor(); + let bounds = match display_work_area(display.as_ref(), cx) { + Some(available) => { + let display_width = display + .as_ref() + .and_then(capture_display_bounds) + .map(|bounds| bounds.size.width) + .unwrap_or(available.size.width); + let width = (f32::from(display_width) * 0.58) .clamp(onboarding_window::ONBOARDING_WIDTH, 1080.) .min((f32::from(available.size.width) - 32.).max(1.)); let height = (width * 0.72) @@ -1163,7 +1302,7 @@ pub fn open_onboarding(cx: &mut App) { } }) .detach(); - hide_main_window(cx); + hide_main_and_park_camera_preview(cx); crate::tray::refresh_menu(cx); } @@ -1258,8 +1397,7 @@ pub fn open_mode_select(cx: &mut App) -> bool { return true; } - let bounds = Bounds::centered( - None, + let bounds = opening_window_bounds( size( px(mode_select_window::MODE_SELECT_WIDTH), px(mode_select_window::MODE_SELECT_HEIGHT), @@ -1378,8 +1516,7 @@ pub fn open_teleprompter(cx: &mut App) { return; } - let bounds = Bounds::centered( - None, + let bounds = opening_window_bounds( size( px(teleprompter_window::TELEPROMPTER_WIDTH), px(teleprompter_window::TELEPROMPTER_HEIGHT), @@ -1407,9 +1544,12 @@ pub fn open_teleprompter(cx: &mut App) { // `resizable: true`, `minWidth: 420, minHeight: 220`. is_resizable: true, is_minimizable: true, - window_min_size: Some(size( - px(teleprompter_window::TELEPROMPTER_MIN_WIDTH), - px(teleprompter_window::TELEPROMPTER_MIN_HEIGHT), + window_min_size: Some(fitted_window_min_size( + size( + px(teleprompter_window::TELEPROMPTER_MIN_WIDTH), + px(teleprompter_window::TELEPROMPTER_MIN_HEIGHT), + ), + bounds, )), // `transparent: true`, `shadow: true`: the shell paints a tint and // the material shows through. @@ -1781,6 +1921,10 @@ fn open_overlays_core(request: OverlayRequest, cx: &mut App) -> bool { // (`target_select_overlay.rs:595-617`): with the main window hidden below // and the overlays non-activating, a plain key handler has nothing to be // delivered to. + if cx.global::().overlays.is_empty() { + disarm_target_selection(cx); + return false; + } platform::register_escape_hotkey(); true } @@ -2060,8 +2204,6 @@ pub fn start_recording_from_overlay(target: ScreenCaptureTarget, cx: &mut App) { } else { release_camera_park(cx); close_target_overlays(cx); - cx.global_mut::().main_hidden_for_picker = false; - cx.global_mut::().editor_hidden_for_picker = None; } let preparing = main @@ -2070,8 +2212,11 @@ pub fn start_recording_from_overlay(target: ScreenCaptureTarget, cx: &mut App) { view.is_preparing_recording() }) .unwrap_or(false); - if retained_area && !preparing && RecordingSession::global(cx).read(cx).phase == Phase::Idle { + if !preparing && RecordingSession::global(cx).read(cx).phase == Phase::Idle { dismiss_target_overlays(cx); + } else if !retained_area { + cx.global_mut::().main_hidden_for_picker = false; + cx.global_mut::().editor_hidden_for_picker = None; } } @@ -2119,6 +2264,30 @@ fn open_overlay( }; let width = bounds.size().width(); let height = bounds.size().height(); + let window_bounds = WindowBounds::Windowed(Bounds { + origin: point(px(0.), px(0.)), + size: size(px(width as f32), px(height as f32)), + }); + #[cfg(target_os = "linux")] + let overlay_display = if uses_wayland() { + let Some(matched) = platform_display_for_capture(display, cx) else { + let capture_display_id = display.id(); + tracing::warn!(%capture_display_id, "could not match capture display to a Wayland output"); + return; + }; + Some(matched) + } else { + None + }; + #[cfg(target_os = "linux")] + let window_bounds = if overlay_display.is_some() { + WindowBounds::Fullscreen(Bounds { + origin: point(px(0.), px(0.)), + size: size(px(width as f32), px(height as f32)), + }) + } else { + window_bounds + }; let handle = cx.open_window( WindowOptions { @@ -2126,10 +2295,9 @@ fn open_overlay( // window-origin math cannot express "cover this display" (see // `platform::set_window_frame_cg`). The size is honoured, and it is // the size the renderer is built for. - window_bounds: Some(WindowBounds::Windowed(Bounds { - origin: point(px(0.), px(0.)), - size: size(px(width as f32), px(height as f32)), - })), + window_bounds: Some(window_bounds), + #[cfg(target_os = "linux")] + display_id: overlay_display.as_ref().map(|display| display.id()), titlebar: None, // `NSWindowStyleMaskNonActivatingPanel` in windows.rs: the overlay // takes clicks without activating the app over the one being @@ -2351,14 +2519,13 @@ fn excluded_own_windows(rules: &[crate::store::WindowExclusion]) -> Vec Vec { excluded_own_windows(rules) .into_iter() .filter(|kind| *kind != OwnWindow::Camera) + // SCK excludes the controls by window ID. NSWindowSharingNone also hides + // them from Screen Sharing, leaving remote users without recording controls. + .filter(|kind| !cfg!(target_os = "macos") || *kind != OwnWindow::Controls) .collect() } @@ -3595,6 +3762,7 @@ pub fn open_camera_window(cx: &mut App) { shadow: false, }, ); + #[cfg(not(target_os = "macos"))] if !inline { platform::show_window_without_focus(window); } @@ -3603,6 +3771,9 @@ pub fn open_camera_window(cx: &mut App) { .ok() .flatten(); remove_popup_window_chrome(native, cx); + #[cfg(target_os = "macos")] + update_camera_presentation(!inline, cx); + #[cfg(not(target_os = "macos"))] sync_camera_presentation(cx); sync_opened_camera_with_picker(cx); refresh_target_overlays(cx); @@ -4349,11 +4520,7 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { return; } - // `cursor_monitor.center_position(1275.0, 800.0)` in the Tauri arm; gpui - // centres on the active display, which is the same one in every - // single-pointer case. - let bounds = Bounds::centered( - None, + let bounds = opening_window_bounds( size( px(editor_window::EDITOR_WIDTH), px(editor_window::EDITOR_HEIGHT), @@ -4379,12 +4546,14 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { kind: WindowKind::Normal, focus: true, show: true, - // `.maximizable(true)` with `min_inner_size == inner_size`. is_resizable: true, is_minimizable: true, - window_min_size: Some(size( - px(editor_window::EDITOR_WIDTH), - px(editor_window::EDITOR_HEIGHT), + window_min_size: Some(fitted_window_min_size( + size( + px(editor_window::EDITOR_WIDTH), + px(editor_window::EDITOR_HEIGHT), + ), + bounds, )), // Opaque, and no native material: `is_transparent()` // (`windows.rs:1069-1082`) does not list Editor, and @@ -5566,11 +5735,11 @@ fn controls_origin(config: &StartConfig) -> (f64, f64) { return (x, y); } - let display = match &config.target { - ScreenCaptureTarget::Display { id } => scap_targets::Display::from_id(id), - _ => scap_targets::Display::get_containing_cursor(), - } - .unwrap_or_else(scap_targets::Display::primary); + let display = config + .target + .display() + .or_else(scap_targets::Display::get_containing_cursor) + .unwrap_or_else(scap_targets::Display::primary); match display.raw_handle().logical_bounds() { Some(bounds) => ( @@ -5592,14 +5761,22 @@ fn open_controls( return None; } let (x, y) = controls_origin(config); + let bounds = Bounds { + origin: point(px(x as f32), px(y as f32)), + size: size(px(CONTROLS_WIDTH), px(CONTROLS_HEIGHT)), + }; + let display = config + .target + .display() + .or_else(scap_targets::Display::get_containing_cursor); + let bounds = display_work_area(display.as_ref(), cx) + .map(|available| fit_window_bounds(bounds, inset_work_area(available))) + .unwrap_or(bounds); let has_microphone = config.microphone.is_some(); let handle = cx.open_window( WindowOptions { - window_bounds: Some(WindowBounds::Windowed(Bounds { - origin: point(px(x as f32), px(y as f32)), - size: size(px(CONTROLS_WIDTH), px(CONTROLS_HEIGHT)), - })), + window_bounds: Some(WindowBounds::Windowed(bounds)), // No titlebar at all: with one, the panel still draws standard // window buttons floating in the transparent top of the window. titlebar: None, @@ -5742,8 +5919,7 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { return; } - let bounds = Bounds::centered( - None, + let bounds = opening_window_bounds( size( px(screenshot_editor::SCREENSHOT_EDITOR_WIDTH), px(screenshot_editor::SCREENSHOT_EDITOR_HEIGHT), @@ -5764,9 +5940,12 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { show: true, is_resizable: true, is_minimizable: true, - window_min_size: Some(size( - px(screenshot_editor::SCREENSHOT_EDITOR_MIN_WIDTH), - px(screenshot_editor::SCREENSHOT_EDITOR_MIN_HEIGHT), + window_min_size: Some(fitted_window_min_size( + size( + px(screenshot_editor::SCREENSHOT_EDITOR_MIN_WIDTH), + px(screenshot_editor::SCREENSHOT_EDITOR_MIN_HEIGHT), + ), + bounds, )), ..Default::default() }, @@ -5899,6 +6078,114 @@ mod tests { use super::*; use crate::store::{DEFAULT_EXCLUDED_WINDOW_TITLES, WindowExclusion, default_excluded_windows}; + #[test] + fn small_display_keeps_editor_and_minimum_size_inside_the_work_area() { + let available = inset_work_area(Bounds { + origin: point(px(0.), px(25.)), + size: size(px(1024.), px(684.)), + }); + let preferred = size(px(1275.), px(800.)); + let bounds = fit_window_bounds( + Bounds::centered_at(available.center(), preferred), + available, + ); + assert_eq!(bounds.origin, point(px(16.), px(41.))); + assert_eq!(bounds.size, size(px(992.), px(652.))); + assert_eq!(fitted_window_min_size(preferred, bounds), bounds.size); + } + + #[test] + fn spacious_display_preserves_preferred_editor_size() { + let available = inset_work_area(Bounds { + origin: point(px(0.), px(25.)), + size: size(px(1920.), px(995.)), + }); + let preferred = size(px(1275.), px(800.)); + let centered = Bounds::centered_at(available.center(), preferred); + assert_eq!(fit_window_bounds(centered, available), centered); + assert_eq!(fitted_window_min_size(preferred, centered), preferred); + } + + #[test] + fn controls_for_an_offscreen_target_stay_on_its_negative_origin_display() { + let available = inset_work_area(Bounds { + origin: point(px(-1440.), px(-180.)), + size: size(px(1440.), px(850.)), + }); + for origin in [point(px(-2200.), px(-500.)), point(px(20.), px(800.))] { + let bounds = fit_window_bounds( + Bounds { + origin, + size: size(px(CONTROLS_WIDTH), px(CONTROLS_HEIGHT)), + }, + available, + ); + assert_eq!(bounds.size, size(px(CONTROLS_WIDTH), px(CONTROLS_HEIGHT))); + assert!(bounds.origin.x >= available.origin.x); + assert!(bounds.origin.y >= available.origin.y); + assert!(bounds.right() <= available.right()); + assert!(bounds.bottom() <= available.bottom()); + } + } + + #[test] + fn scaled_work_areas_fit_without_changing_logical_pixel_sizes() { + for logical_size in [size(px(1280.), px(650.)), size(px(853.), px(455.))] { + let available = inset_work_area(Bounds { + origin: point(px(0.), px(0.)), + size: logical_size, + }); + let bounds = fit_window_bounds( + Bounds::centered_at(available.center(), size(px(782.), px(775.))), + available, + ); + assert_eq!(bounds.size.width, px(782.)); + assert_eq!(bounds.size.height, logical_size.height - px(32.)); + let minimum = fitted_window_min_size(size(px(780.), px(560.)), bounds); + assert!(minimum.width <= bounds.size.width); + assert!(minimum.height <= bounds.size.height); + } + } + + #[test] + fn tiny_work_area_cannot_produce_an_inverted_clamp_range() { + let available = inset_work_area(Bounds { + origin: point(px(10.), px(20.)), + size: size(px(1.), px(1.)), + }); + let bounds = fit_window_bounds( + Bounds::centered_at(available.center(), size(px(782.), px(775.))), + available, + ); + assert_eq!(bounds, available); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_work_area_preserves_horizontal_and_vertical_display_origins() { + for (display_origin, appkit_y) in [ + (point(px(-1440.), px(0.)), 180.), + (point(px(1920.), px(0.)), 180.), + (point(px(0.), px(-900.)), 1080.), + (point(px(0.), px(1080.)), -900.), + ] { + let display = Bounds { + origin: display_origin, + size: size(px(1440.), px(900.)), + }; + let available = global_macos_work_area( + Bounds { + origin: point(px(0.), px(appkit_y + 25.)), + size: size(px(1440.), px(825.)), + }, + display, + px(1080.), + ); + assert_eq!(available.origin, display_origin + point(px(0.), px(25.))); + assert_eq!(available.size, size(px(1440.), px(825.))); + } + } + fn area_target(display: &str, x: f64, y: f64, width: f64, height: f64) -> ScreenCaptureTarget { ScreenCaptureTarget::Area { screen: display.parse().unwrap(), @@ -6876,9 +7163,6 @@ mod tests { assert!(excluded_own_windows(&by_identity).is_empty()); } - /// `apply_content_protection` walks the same rules but skips the camera - /// window outright (`windows.rs:3393-3398`); the camera's protection is the - /// mode's business instead (`recording.rs:1617-1624`). #[test] fn content_protection_skips_the_camera_and_follows_the_mode() { let studio = own_window_exclusion_rules(default_excluded_windows(), RecordingMode::Studio); @@ -6887,6 +7171,7 @@ mod tests { vec![ OwnWindow::Main, OwnWindow::Settings, + #[cfg(not(target_os = "macos"))] OwnWindow::Controls, OwnWindow::ModeSelect, OwnWindow::Teleprompter, diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 935e2c5919d..9b592ca7e87 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -84,7 +84,6 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "bg-blur.svg", "laptop.svg", "wind.svg", - "image-off.svg", "shuffle.svg", "gift.svg", "history.svg", @@ -162,27 +161,18 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": "align-right.svg", "arrow-left-right.svg", "download.svg", - "ease-curve.svg", "flip-vertical-2.svg", - "grid.svg", - "grip.svg", "italic.svg", "maximize.svg", - "moon.svg", "mouse-pointer-2.svg", - "mouse-pointer-click.svg", "move.svg", "move-right.svg", "palette.svg", - "rabbit.svg", "ratio.svg", "refresh-cw.svg", "rotate-ccw.svg", "rotate-cw.svg", - "sliders-horizontal.svg", - "sparkles.svg", "timer.svg", - "volume-2.svg", "volume-x.svg", "diamond.svg", "x-mark.svg", @@ -239,22 +229,6 @@ const IMAGES: &[(&str, &[u8])] = assets!("images": "dark.jpg", ); -/// The background-source tiles' fallback art, copied from -/// `apps/desktop/src/assets/illustrations/`. Full-colour, so `img()` not -/// `svg()`, and webp because that is what the app ships -- 4 KB for the pair, -/// against 25 MB if the wallpapers themselves were embedded (see the README). -/// -/// **Two of `BACKGROUND_ICONS`' four are dead in the shipping app.** -/// `renderBackgroundSourceIcon` returns a live swatch for `color` and a live -/// gradient for `gradient` before it ever reaches the map -/// (`ConfigSidebar.tsx:2076-2089`), so `colorBg` and `gradientBg` are imported -/// and never drawn; only `imageBg` (desktop and wallpaper) and -/// `transparentBg` (image) are. -const ILLUSTRATIONS: &[(&str, &[u8])] = assets!("illustrations": - "image.webp", - "transparent.webp", -); - const ONBOARDING: &[(&str, &[u8])] = &[ ("onboarding/cloud-1.png", include_bytes!("../../desktop/src/assets/illustrations/cloud-1.png")), ("onboarding/cloud-2.png", include_bytes!("../../desktop/src/assets/illustrations/cloud-2.png")), @@ -276,7 +250,6 @@ impl Assets { .iter() .chain(ICONS.iter()) .chain(IMAGES.iter()) - .chain(ILLUSTRATIONS.iter()) .chain(ONBOARDING.iter()) } } @@ -333,6 +306,7 @@ mod tests { include_str!("teleprompter_window.rs"), include_str!("editor_window.rs"), include_str!("editor_window/frame.rs"), + include_str!("editor_window/scenes.rs"), // The timeline's nine track glyphs and its scene-mode icons are named // in the strip's own module, not in the window that hosts it. include_str!("editor_timeline.rs"), @@ -453,42 +427,6 @@ mod tests { ); } - /// And for the background-source tiles' art, which resolves through the - /// same `AssetSource` and fails just as silently. - #[test] - fn every_referenced_illustration_is_embedded_and_vice_versa() { - let source = ICON_SOURCES.concat(); - let source = source.as_str(); - - let referenced: Vec<&str> = source - .match_indices("\"illustrations/") - .filter_map(|(start, _)| source[start + 1..].split('"').next()) - .collect(); - assert!( - !referenced.is_empty(), - "found no illustration references to check" - ); - - let missing: Vec<&str> = referenced - .into_iter() - .filter(|path| Assets.load(path).unwrap().is_none()) - .collect(); - assert!( - missing.is_empty(), - "illustrations referenced but not embedded: {missing:?}" - ); - - let unused: Vec<&str> = ILLUSTRATIONS - .iter() - .map(|(path, _)| *path) - .filter(|path| !source.contains(path)) - .collect(); - assert!( - unused.is_empty(), - "illustrations embedded but never drawn: {unused:?}" - ); - } - #[test] fn fonts_and_icons_resolve() { assert!(Assets.load("fonts/Geist.ttf").unwrap().is_some()); @@ -498,10 +436,5 @@ mod tests { assert_eq!(Assets.list("fonts").unwrap().len(), FONTS.len()); assert_eq!(Assets.list("icons").unwrap().len(), ICONS.len()); assert_eq!(Assets.list("images").unwrap().len(), IMAGES.len()); - assert!(Assets.load("illustrations/image.webp").unwrap().is_some()); - assert_eq!( - Assets.list("illustrations").unwrap().len(), - ILLUSTRATIONS.len() - ); } } diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs index a40220a8df9..072cdab5cd5 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -9,6 +9,8 @@ use serde_json::{Value, json}; use crate::store::{self, DEFAULT_SERVER_URL}; +pub const PRICING_URL: &str = "https://cap.so/pricing?ref=desktop"; + const CALLBACK_HTML: &str = r#" diff --git a/apps/desktop-gpui/src/camera_blur.rs b/apps/desktop-gpui/src/camera_blur.rs index d4a4b08eb55..a5adde3da16 100644 --- a/apps/desktop-gpui/src/camera_blur.rs +++ b/apps/desktop-gpui/src/camera_blur.rs @@ -275,9 +275,8 @@ impl Worker { .context("blur output missing")?; self.frame_number = self.frame_number.wrapping_add(1); - let pending = self - .converter - .encode( + let pending = runtime + .block_on(self.converter.encode( &self.device, &mut encoder, output, @@ -285,7 +284,7 @@ impl Worker { height, self.frame_number, 30, - ) + )) .map_err(|error| anyhow!("{error}"))?; self.queue.submit(std::iter::once(encoder.finish())); diff --git a/apps/desktop-gpui/src/camera_window.rs b/apps/desktop-gpui/src/camera_window.rs index a5d1b9f9fc0..9e448a4ef66 100644 --- a/apps/desktop-gpui/src/camera_window.rs +++ b/apps/desktop-gpui/src/camera_window.rs @@ -306,6 +306,69 @@ fn linux_camera_recording_snapshot( mod frame { use cidre::{arc, cf, cv, vt}; + type CreateRotationSession = unsafe extern "C-unwind" fn( + Option<&cf::Allocator>, + *mut Option>, + ) -> cidre::os::Status; + type RotateImage = unsafe extern "C-unwind" fn( + &vt::PixelRotationSession, + &cv::PixelBuf, + &mut cv::PixelBuf, + ) -> cidre::os::Status; + + struct FlipSession { + session: arc::R, + rotate_image: RotateImage, + } + + impl FlipSession { + fn new() -> Option { + // These APIs are macOS 13+. Direct cidre calls create strong imports that + // make dyld terminate the entire app on macOS 12 before any OS guard runs. + let create = unsafe { + libc::dlsym(libc::RTLD_DEFAULT, c"VTPixelRotationSessionCreate".as_ptr()) + }; + let rotate = unsafe { + libc::dlsym( + libc::RTLD_DEFAULT, + c"VTPixelRotationSessionRotateImage".as_ptr(), + ) + }; + let key = unsafe { + libc::dlsym( + libc::RTLD_DEFAULT, + c"kVTPixelRotationPropertyKey_FlipHorizontalOrientation".as_ptr(), + ) + }; + if create.is_null() || rotate.is_null() || key.is_null() { + return None; + } + let create = + unsafe { std::mem::transmute::<*mut libc::c_void, CreateRotationSession>(create) }; + let rotate_image = + unsafe { std::mem::transmute::<*mut libc::c_void, RotateImage>(rotate) }; + let key = unsafe { key.cast::<*const cf::String>().read().as_ref()? }; + let mut session = None; + unsafe { create(None, &mut session).result().ok()? }; + let mut session = session?; + session + .set_prop(key, Some(cf::Boolean::value_true().as_ref())) + .ok()?; + Some(Self { + session, + rotate_image, + }) + } + + fn rotate( + &self, + source: &cv::PixelBuf, + destination: &mut cv::PixelBuf, + ) -> cidre::os::Result { + unsafe { (self.rotate_image)(&self.session, source, destination).result() } + } + } + /// A converted preview frame: the BGRA IOSurface pixel buffer to paint or /// blur, its dimensions, and the ring generation (bumped on every ring /// rebuild so the blur worker's imported-texture cache can never alias a @@ -314,6 +377,7 @@ mod frame { pub buffer: arc::R, pub dims: (usize, usize), pub generation: u64, + pub mirrored: bool, } /// Converts camera frames (typically `420v`) into BGRA IOSurface-backed @@ -342,7 +406,7 @@ mod frame { session: arc::R, /// `None` when unmirrored, or when the rotation session could not be /// created (the preview then degrades to unmirrored, logged once). - flip_session: Option>, + flip_session: Option, ring: Vec>, mirror_ring: Vec>, next: usize, @@ -402,11 +466,7 @@ mod frame { session.set_realtime(true).ok()?; let flip_session = if mirrored { - let flip = vt::PixelRotationSession::new() - .ok() - .and_then(|mut session| { - session.set_horizontal_flip(true).ok().map(|_| session) - }); + let flip = FlipSession::new(); if flip.is_none() { tracing::warn!( "VTPixelRotationSession unavailable; camera preview mirroring disabled" @@ -464,8 +524,14 @@ mod frame { converter.session.transfer(src, &dst).ok()?; let out = if let Some(flip) = &converter.flip_session { let mut flipped = converter.mirror_ring[converter.next].clone(); - flip.rotate(&dst, &mut flipped).ok()?; - flipped + match flip.rotate(&dst, &mut flipped) { + Ok(()) => flipped, + Err(error) => { + tracing::warn!(?error, "camera preview mirroring failed"); + converter.flip_session = None; + dst + } + } } else { dst }; @@ -474,6 +540,7 @@ mod frame { buffer: out, dims: converter.dst_dims, generation: converter.generation, + mirrored: converter.flip_session.is_some(), }) } } @@ -640,6 +707,127 @@ fn camera_issue(error: &str) -> (&'static str, &'static str) { ("Camera unavailable", message) } +#[cfg(target_os = "macos")] +#[derive(Clone, Copy, Default, PartialEq, Eq)] +struct PreviewEffectFailures { + mirror: bool, + blur: bool, +} + +#[cfg(target_os = "macos")] +impl PreviewEffectFailures { + fn issue(self) -> Option<(&'static str, &'static str)> { + match (self.mirror, self.blur) { + (false, false) => None, + (true, false) => Some(("Mirror unavailable", "Your camera preview is not mirrored.")), + (false, true) => Some(("Background blur unavailable", "Your camera is unblurred.")), + (true, true) => Some(( + "Camera effects unavailable", + "Your camera is unblurred and not mirrored.", + )), + } + } + + fn reset_changed(&mut self, before: CameraWindowState, after: CameraWindowState) { + if before.mirrored != after.mirrored { + self.mirror = false; + } + if before.background_blur != after.background_blur { + self.blur = false; + } + } +} + +#[cfg(all(test, target_os = "macos"))] +mod preview_effect_failure_tests { + use super::*; + + #[test] + fn each_failed_effect_has_explicit_feedback() { + assert!(PreviewEffectFailures::default().issue().is_none()); + assert!( + PreviewEffectFailures { + mirror: true, + blur: false + } + .issue() + .unwrap() + .1 + .contains("not mirrored") + ); + assert!( + PreviewEffectFailures { + mirror: false, + blur: true + } + .issue() + .unwrap() + .1 + .contains("unblurred") + ); + assert!( + PreviewEffectFailures { + mirror: true, + blur: true + } + .issue() + .unwrap() + .1 + .contains("unblurred and not mirrored") + ); + } + + #[test] + fn unrelated_changes_preserve_failure_and_requested_recording_blur() { + let before = CameraWindowState { + mirrored: true, + background_blur: BlurMode::Heavy, + ..Default::default() + }; + let after = CameraWindowState { + size: 400., + ..before + }; + let mut failures = PreviewEffectFailures { + mirror: true, + blur: true, + }; + failures.reset_changed(before, after); + assert!(failures.mirror && failures.blur); + assert_eq!(after.background_blur, BlurMode::Heavy); + } + + #[test] + fn toggle_retry_resets_only_the_changed_effect() { + let before = CameraWindowState { + mirrored: true, + background_blur: BlurMode::Heavy, + ..Default::default() + }; + let mut failures = PreviewEffectFailures { + mirror: true, + blur: true, + }; + failures.reset_changed( + before, + CameraWindowState { + mirrored: false, + ..before + }, + ); + assert!(!failures.mirror && failures.blur); + failures.reset_changed( + before, + CameraWindowState { + background_blur: BlurMode::Off, + ..before + }, + ); + assert!(!failures.mirror && !failures.blur); + assert!(failures.issue().is_none()); + } +} + /// The per-frame half of the window: owns the latest converted (or blurred) /// frame and is the only entity notified at camera rate. Chrome invalidation /// goes through the parent [`CameraWindow`] instead, so a frame draw reuses @@ -662,6 +850,7 @@ struct CameraPreviewView { /// ~20Hz whenever a microphone is selected, and each of those would /// repaint the whole preview for a message that did not change. camera_error: Option, + effect_issue: Option<(&'static str, &'static str)>, _feeds_subscription: Subscription, } @@ -693,6 +882,7 @@ impl CameraPreviewView { frame_dims: None, paints, camera_error, + effect_issue: None, _feeds_subscription: feeds_subscription, } } @@ -807,8 +997,12 @@ impl Render for CameraPreviewView { // the preview with a centred, size-scaled title + message. The // `backdrop-blur-xs` behind it has no per-element hook in this gpui // rev (the recording overlay documents the same gap). - if let Some(error) = self.camera_error.clone() { - let (title, message) = camera_issue(&error); + if let Some((title, message)) = self + .camera_error + .as_deref() + .map(camera_issue) + .or(self.effect_issue) + { let metrics = overlay_metrics(self.size); container = container.child( div() @@ -912,11 +1106,8 @@ pub struct CameraWindow { converter: Option, #[cfg(target_os = "macos")] blur: Option, - /// Latched when the worker dies (device/ONNX bring-up failed); cleared - /// when the blur mode changes, which is the retry point -- the - /// `blur_processor_init_attempted` shape (`camera.rs:1500-1518`). #[cfg(target_os = "macos")] - blur_failed: bool, + effect_failures: PreviewEffectFailures, preview: Entity, toolbar: Entity, frame_dims: Option<(usize, usize)>, @@ -984,7 +1175,13 @@ impl CameraWindow { platform::ForcedAppearance::Dark, cx.foreground_executor(), ); - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + platform::apply_window_theme_deferred( + window, + platform::ForcedAppearance::Dark, + cx.foreground_executor(), + ); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] platform::apply_window_theme(window, platform::ForcedAppearance::Dark); let theme = Theme::dark(); let state = store::load().camera_window.unwrap_or_default(); @@ -1024,7 +1221,7 @@ impl CameraWindow { #[cfg(target_os = "macos")] blur: None, #[cfg(target_os = "macos")] - blur_failed: false, + effect_failures: PreviewEffectFailures::default(), preview, toolbar, frame_dims: None, @@ -1103,6 +1300,10 @@ impl CameraWindow { use core_foundation::base::TCFType as _; use core_video::pixel_buffer::{CVPixelBuffer, CVPixelBufferRef}; + let failures_before = self.effect_failures; + if self.state.background_blur != BlurMode::Off && camera_blur::is_low_spec_preview() { + self.effect_failures.blur = true; + } let blur_mode = self.active_blur_mode(); let max_dims = blur_mode.is_some().then_some(camera_blur::BLUR_MAX_DIMS); if let Some(converted) = frame::FrameConverter::convert( @@ -1111,6 +1312,7 @@ impl CameraWindow { max_dims, self.state.mirrored, ) { + self.effect_failures.mirror = self.state.mirrored && !converted.mirrored; let first_frame = self.frame_dims.is_none(); let dims = converted.dims; let dims_changed = self.frame_dims != Some(dims); @@ -1140,7 +1342,7 @@ impl CameraWindow { "camera blur worker unavailable; preview continues unblurred" ); self.blur = None; - self.blur_failed = true; + self.effect_failures.blur = true; paint_raw = true; } } @@ -1163,6 +1365,9 @@ impl CameraWindow { } else if self.frame_dims.is_none() && self.frames_in_window == 0 { tracing::warn!("camera frame could not be converted for preview"); } + if failures_before != self.effect_failures { + self.sync_effect_feedback(cx); + } } #[cfg(not(target_os = "macos"))] { @@ -1205,13 +1410,21 @@ impl CameraWindow { } } - /// The blur mode frames should be processed with right now: `None` when - /// off, latched off after a worker failure, and always `None` on low-spec - /// machines (`ensure_blur_processor`'s early return, `camera.rs:1491-1498` - /// -- the toggle still cycles and persists there too). + #[cfg(target_os = "macos")] + fn sync_effect_feedback(&self, cx: &mut Context) { + let issue = self.effect_failures.issue(); + self.preview.update(cx, |preview, cx| { + if preview.effect_issue != issue { + preview.effect_issue = issue; + cx.notify(); + } + }); + cx.notify(); + } + #[cfg(target_os = "macos")] fn active_blur_mode(&self) -> Option { - if self.blur_failed || camera_blur::is_low_spec_preview() { + if self.effect_failures.blur || camera_blur::is_low_spec_preview() { return None; } match self.state.background_blur { @@ -1317,7 +1530,7 @@ impl CameraWindow { mutate: impl FnOnce(&mut CameraWindowState), ) { #[cfg(target_os = "macos")] - let blur_before = self.state.background_blur; + let before = self.state; self.picker_size = None; mutate(&mut self.state); self.state.size = clamp_size(self.state.size); @@ -1327,11 +1540,11 @@ impl CameraWindow { }); #[cfg(target_os = "macos")] { - if self.state.background_blur != blur_before { - // Changing the mode is the retry point after a failed - // bring-up. - self.blur_failed = false; + self.effect_failures.reset_changed(before, self.state); + if before.mirrored != self.state.mirrored { + self.converter = None; } + self.sync_effect_feedback(cx); if self.state.background_blur == BlurMode::Off { // Ends the worker thread, dropping the ONNX session and every // GPU texture -- `release_blur_resources` @@ -1550,6 +1763,10 @@ impl CameraWindow { fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; let scale = self.toolbar_scale(); + #[cfg(target_os = "macos")] + let (mirror_failed, blur_failed) = (self.effect_failures.mirror, self.effect_failures.blur); + #[cfg(not(target_os = "macos"))] + let (mirror_failed, blur_failed) = (false, false); let shape_icon = match self.state.shape { CameraShape::Round => "icons/circle.svg", CameraShape::Square => "icons/square.svg", @@ -1627,9 +1844,9 @@ impl CameraWindow { .child(self.toolbar_button( "mirror", "icons/arrows.svg", - self.state.mirrored, + self.state.mirrored && !mirror_failed, scale, - None, + mirror_failed.then_some("!"), cx, |this, window, cx| { this.mutate_state(window, cx, |state| { @@ -1640,9 +1857,13 @@ impl CameraWindow { .child(self.toolbar_button( "blur", "icons/person-standing.svg", - self.state.background_blur != BlurMode::Off, + self.state.background_blur != BlurMode::Off && !blur_failed, scale, - self.state.background_blur.label(), + if blur_failed { + Some("!") + } else { + self.state.background_blur.label() + }, cx, |this, window, cx| { this.mutate_state(window, cx, |state| { diff --git a/apps/desktop-gpui/src/controls_window.rs b/apps/desktop-gpui/src/controls_window.rs index 44777d71384..b5460fe54e3 100644 --- a/apps/desktop-gpui/src/controls_window.rs +++ b/apps/desktop-gpui/src/controls_window.rs @@ -27,6 +27,8 @@ pub struct ControlsWindow { session: Entity, theme: Theme, has_microphone: bool, + #[cfg(target_os = "linux")] + confirmation_pending: bool, /// Repaints the timer. An inactive window is repainted lazily by the /// platform, so the tick both notifies and asks for a frame explicitly. _tick: gpui::Task<()>, @@ -38,6 +40,15 @@ enum DestructiveAction { Delete, } +#[cfg(target_os = "linux")] +fn controls_owner_is_current(owner: gpui::WindowId, window: &Window, cx: &gpui::App) -> bool { + window.window_handle().window_id() == owner + && cx + .try_global::() + .and_then(|windows| windows.controls) + .is_some_and(|controls| controls.window_id() == owner) +} + impl ControlsWindow { pub fn new( session: Entity, @@ -73,6 +84,8 @@ impl ControlsWindow { session, theme, has_microphone, + #[cfg(target_os = "linux")] + confirmation_pending: false, _tick: tick, } } @@ -260,6 +273,47 @@ impl ControlsWindow { ), }; + #[cfg(target_os = "linux")] + { + let owner = window.window_handle().window_id(); + if self.confirmation_pending + || window.has_active_prompt() + || !controls_owner_is_current(owner, window, cx) + { + return; + } + let session_id = self.session.entity_id(); + let Some(ticket) = self.session.read(cx).confirmation_ticket() else { + return; + }; + self.confirmation_pending = true; + let response = + crate::editor_modal::confirm_action(title, message, accept, "Cancel", window, cx); + cx.spawn_in(window, async move |this, cx| { + let confirmed = response.await; + let _ = this.update_in(cx, |this, window, cx| { + this.confirmation_pending = false; + if !confirmed + || !controls_owner_is_current(owner, window, cx) + || this.session.entity_id() != session_id + { + return; + } + this.session.update(cx, |session, cx| { + if !session.confirmation_is_current(&ticket) { + return; + } + match action { + DestructiveAction::Restart => session.restart(cx), + DestructiveAction::Delete => session.delete(cx), + } + }); + }); + }) + .detach(); + } + + #[cfg(not(target_os = "linux"))] cx.spawn_in(window, async move |this, cx| { if !crate::platform::confirm_dialog(title, message, accept, "Cancel", true) { return; diff --git a/apps/desktop-gpui/src/dev_restore.rs b/apps/desktop-gpui/src/dev_restore.rs index 3cd18b1ef57..2c1c2bd885b 100644 --- a/apps/desktop-gpui/src/dev_restore.rs +++ b/apps/desktop-gpui/src/dev_restore.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; -use gpui::{App, Window}; +use gpui::{App, Bounds, Pixels, Point, Size, Window, point, px}; use serde::{Deserialize, Serialize}; use crate::app_windows::{self, AppWindows}; @@ -74,26 +74,86 @@ fn state_path() -> Option { .map(PathBuf::from) } -pub fn init(cx: &mut App) { - let Some(path) = state_path() else { - return; - }; - match std::fs::read_to_string(&path) { +pub struct DevRestore { + path: PathBuf, + state: Option, +} + +pub fn load() -> Option { + let path = state_path()?; + let state = match std::fs::read_to_string(&path) { Ok(raw) => match serde_json::from_str::(&raw) { - Ok(state) => restore(state, cx), + Ok(state) => Some(state), Err(error) => { tracing::warn!(%error, "dev-restore state unreadable; starting fresh"); + None } }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => tracing::warn!(%error, "dev-restore state unreadable; starting fresh"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + tracing::warn!(%error, "dev-restore state unreadable; starting fresh"); + None + } + }; + Some(DevRestore { path, state }) +} + +impl DevRestore { + /// Where the main window opens: the saved origin under the app's own + /// collapsed size, so it never moves after its first frame. + /// + /// Only the origin is restored. The size is the app's to decide, and + /// re-asserting a saved frame over it once shrank the window under its + /// content (a 396pt frame from an older layout against the 432pt + /// collapsed size): the body scrolled, and the poller kept re-saving the + /// shrunken frame, so every relaunch inherited it. Bottom-left anchoring + /// matches `setContentSize:`, which is what `window.resize` uses on + /// macOS, so an expanded restore grows back into the exact saved frame. + pub fn main_window_bounds(&self, size: Size, cx: &App) -> Option> { + let (x, y, width, height) = self.state.as_ref()?.main.frame?; + if !platform::frame_is_on_screen(x, y, width, height) { + tracing::info!( + x, + y, + width, + height, + "saved main window frame is off every connected display; opening centred" + ); + return None; + } + let primary_height = cx.primary_display()?.bounds().size.height; + Some(Bounds { + origin: opening_origin((x, y), size, primary_height), + size, + }) + } + + pub fn init(self, cx: &mut App) { + if let Some(state) = self.state { + restore(state, cx); + } + spawn_poller(self.path, cx); } - spawn_poller(path, cx); +} + +/// gpui's macOS open path takes a top-left origin measured down from the top +/// of the primary display (`MacWindow::open` adds the primary display height +/// and calls `setFrameTopLeftPoint:`); an AppKit frame origin is the +/// bottom-left, measured up from that display's bottom. +fn opening_origin( + appkit_origin: (f64, f64), + size: Size, + primary_height: Pixels, +) -> Point { + let (x, y) = appkit_origin; + let top = px(y as f32) + size.height; + point(px(x as f32), primary_height - top) } fn restore(state: DevState, cx: &mut App) { tracing::info!( expanded = state.main.expanded, + main_frame = ?state.main.frame, settings = state.settings.as_ref().map(|s| s.page.as_str()), editors = state.editors.len(), teleprompter = state.teleprompter, @@ -131,8 +191,8 @@ fn restore(state: DevState, cx: &mut App) { } cx.spawn(async move |cx| { - // Let the centred first frames and the expand animation land before - // the frames are re-asserted. + // Let the first frames and the expand animation land before the + // frames are re-asserted. cx.background_executor() .timer(Duration::from_millis(600)) .await; @@ -155,11 +215,13 @@ fn restore(state: DevState, cx: &mut App) { } }; cx.update(|cx| { + // The main window already opened at its saved origin + // (`DevRestore::main_window_bounds`); only its ordering is left. collect( main.update(cx, |_, window, _| platform::native_window(window)) .ok() .flatten(), - state.main.frame, + None, state.main.visible, ); if let (Some(handle), Some(saved)) = (settings, &state.settings) { @@ -351,3 +413,23 @@ fn write_atomic(path: &Path, contents: &str) -> bool { } result.is_ok() } + +#[cfg(test)] +mod tests { + use super::opening_origin; + use gpui::{point, px, size}; + + #[test] + fn saved_origin_is_kept_under_the_current_collapsed_size() { + let origin = opening_origin((1035., 857.), size(px(330.), px(432.)), px(1440.)); + assert_eq!(origin, point(px(1035.), px(151.))); + } + + #[test] + fn expanded_frame_restores_to_the_same_bottom_left() { + let collapsed = opening_origin((1035., 857.), size(px(330.), px(432.)), px(1440.)); + let expanded = opening_origin((1035., 857.), size(px(600.), px(672.)), px(1440.)); + assert_eq!(collapsed.x, expanded.x); + assert_eq!(collapsed.y - expanded.y, px(672. - 432.)); + } +} diff --git a/apps/desktop-gpui/src/devices.rs b/apps/desktop-gpui/src/devices.rs index 21976bf7bff..8b7be80e821 100644 --- a/apps/desktop-gpui/src/devices.rs +++ b/apps/desktop-gpui/src/devices.rs @@ -135,6 +135,23 @@ pub struct DeviceSnapshot { } impl DeviceSnapshot { + pub fn update_camera_formats( + &mut self, + queried: &CameraOption, + formats: Vec, + ) -> bool { + let Some(camera) = self + .cameras + .iter_mut() + .find(|camera| camera.same_device(queried)) + else { + return false; + }; + camera.best_format = formats.first().copied(); + camera.formats = formats; + true + } + /// Enumerate everything. This blocks — AVFoundation camera discovery and the /// window-server queries are both slow enough to drop frames — so callers /// should run it on the background executor, never inside `render`. @@ -148,6 +165,267 @@ impl DeviceSnapshot { } } +#[derive(Clone, Default)] +pub struct InputEnumerationGate(std::sync::Arc); + +pub struct InputEnumerationPermit(std::sync::Arc); + +impl InputEnumerationGate { + pub fn try_enter(&self) -> Option { + self.0 + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .ok() + .map(|_| InputEnumerationPermit(self.0.clone())) + } +} + +impl Drop for InputEnumerationPermit { + fn drop(&mut self) { + self.0.store(false, std::sync::atomic::Ordering::Release); + } +} + +pub enum InputSnapshot { + Cameras(Vec), + Microphones(Vec), +} + +impl InputSnapshot { + pub fn cameras(previous: &[CameraOption]) -> Self { + Self::Cameras(list_cameras_with_previous(previous)) + } + + pub fn microphones() -> Self { + Self::Microphones(list_microphones()) + } + + pub fn install(self, snapshot: &mut DeviceSnapshot) -> bool { + match self { + Self::Cameras(mut cameras) => { + for camera in &mut cameras { + if camera.formats.is_empty() + && let Some(current) = snapshot.cameras.iter().find(|current| { + current.same_device(camera) && !current.formats.is_empty() + }) + { + camera.best_format = current.best_format; + camera.formats = current.formats.clone(); + } + } + if snapshot.cameras == cameras { + return false; + } + snapshot.cameras = cameras; + } + Self::Microphones(microphones) if snapshot.microphones != microphones => { + snapshot.microphones = microphones; + } + _ => return false, + } + true + } +} + +#[cfg(test)] +mod input_enumeration_tests { + use super::*; + + #[test] + fn cancelled_refresh_keeps_its_permit_until_enumeration_finishes() { + let gate = InputEnumerationGate::default(); + let pending = gate.try_enter().expect("first refresh"); + assert!(gate.clone().try_enter().is_none()); + drop(pending); + assert!(gate.try_enter().is_some()); + } + + #[test] + fn enumeration_unwind_releases_the_gate() { + let gate = InputEnumerationGate::default(); + let worker_gate = gate.clone(); + let result = std::panic::catch_unwind(move || { + let _permit = worker_gate.try_enter().expect("first refresh"); + panic!("enumeration fixture"); + }); + assert!(result.is_err()); + assert!(gate.try_enter().is_some()); + } + + #[test] + fn camera_refresh_preserves_other_device_lists() { + let microphone = MicrophoneOption { + name: "Selected microphone".into(), + sample_rate: Some(48_000), + channels: Some(2), + }; + let mut snapshot = DeviceSnapshot { + microphones: vec![microphone.clone()], + ..Default::default() + }; + let camera = CameraOption { + device_id: "new-camera".into(), + model_id: None, + label: "Connected camera".into(), + best_format: None, + formats: Vec::new(), + }; + assert!(InputSnapshot::Cameras(vec![camera.clone()]).install(&mut snapshot)); + assert_eq!(snapshot.cameras, vec![camera.clone()]); + assert_eq!(snapshot.microphones, vec![microphone]); + assert!(!InputSnapshot::Cameras(vec![camera.clone()]).install(&mut snapshot)); + assert!(InputSnapshot::Microphones(Vec::new()).install(&mut snapshot)); + assert_eq!(snapshot.cameras, vec![camera]); + } + fn camera_fixture(id: &str) -> CameraOption { + CameraOption { + device_id: id.into(), + model_id: None, + label: "Camera".into(), + best_format: None, + formats: Vec::new(), + } + } + + fn formats_fixture() -> Vec { + vec![CameraFormat { + width: 1920, + height: 1080, + frame_rate: 30.0, + }] + } + + #[test] + fn completed_query_updates_matching_camera_capabilities() { + let queried = camera_fixture("camera-a"); + let mut snapshot = DeviceSnapshot { + cameras: vec![queried.clone()], + ..Default::default() + }; + assert!(snapshot.update_camera_formats(&queried, formats_fixture())); + assert_eq!(snapshot.cameras[0].formats, formats_fixture()); + assert_eq!( + snapshot.cameras[0].best_format, + formats_fixture().first().copied() + ); + } + + #[test] + fn completed_query_cannot_overwrite_replacement_camera_capabilities() { + let queried = camera_fixture("camera-a"); + let mut changed_id = queried.clone(); + changed_id.device_id = "camera-b".into(); + let mut changed_model = queried.clone(); + changed_model.model_id = + Some(cap_camera::ModelID::try_from("046d:08e5".to_string()).unwrap()); + let mut changed_label = queried.clone(); + changed_label.label = "Replacement camera".into(); + for replacement in [changed_id, changed_model, changed_label] { + let mut snapshot = DeviceSnapshot { + cameras: vec![replacement.clone()], + ..Default::default() + }; + assert!(!snapshot.update_camera_formats(&queried, formats_fixture())); + assert_eq!(snapshot.cameras, vec![replacement]); + } + } + + #[test] + fn completed_query_cannot_restore_disconnected_camera() { + let mut snapshot = DeviceSnapshot::default(); + assert!(!snapshot.update_camera_formats(&camera_fixture("camera-a"), formats_fixture())); + assert!(snapshot.cameras.is_empty()); + } + + #[test] + fn repeated_camera_refresh_does_not_reopen_unchanged_devices() { + let mut previous = vec![camera_fixture("camera-a").with_formats(&[], formats_fixture)]; + for _ in 0..1000 { + let refreshed = camera_fixture("camera-a").with_formats(&previous, || { + panic!("unchanged camera must not reopen its driver") + }); + assert_eq!(refreshed, previous[0]); + previous = vec![refreshed]; + } + } + + #[test] + fn new_camera_does_not_inherit_another_devices_formats() { + let previous = vec![camera_fixture("camera-a").with_formats(&[], formats_fixture)]; + let mut probes = 0; + let refreshed = camera_fixture("camera-b").with_formats(&previous, || { + probes += 1; + Vec::new() + }); + assert_eq!(probes, 1); + assert!(refreshed.formats.is_empty()); + assert!(refreshed.best_format.is_none()); + } + + #[test] + fn reconnect_refreshes_formats_after_camera_disappears() { + let connected = camera_fixture("camera-a").with_formats(&[], formats_fixture); + let after_disconnect = Vec::new(); + let changed_format = CameraFormat { + width: 1280, + height: 720, + frame_rate: 60.0, + }; + let reconnected = + camera_fixture("camera-a").with_formats(&after_disconnect, || vec![changed_format]); + assert_ne!(reconnected.formats, connected.formats); + assert_eq!(reconnected.best_format, Some(changed_format)); + } + + #[test] + fn renamed_camera_refreshes_capabilities() { + let previous = vec![camera_fixture("camera-a").with_formats(&[], formats_fixture)]; + let mut camera = camera_fixture("camera-a"); + camera.label = "Reconfigured virtual camera".into(); + let mut probes = 0; + camera.with_formats(&previous, || { + probes += 1; + Vec::new() + }); + assert_eq!(probes, 1); + } + #[test] + fn failed_probe_is_not_retried_by_background_refresh() { + let previous = vec![camera_fixture("camera-a").with_formats(&[], Vec::new)]; + let refreshed = camera_fixture("camera-a").with_formats(&previous, || { + panic!("background refresh must not retry a native probe") + }); + assert!(refreshed.formats.is_empty()); + } + + #[test] + fn late_refresh_does_not_erase_successful_explicit_retry() { + let refreshed = vec![camera_fixture("camera-a")]; + let mut snapshot = DeviceSnapshot { + cameras: vec![camera_fixture("camera-a").with_formats(&[], formats_fixture)], + ..Default::default() + }; + assert!(!InputSnapshot::Cameras(refreshed).install(&mut snapshot)); + assert_eq!(snapshot.cameras[0].formats, formats_fixture()); + } + + #[test] + fn changed_device_does_not_inherit_previous_capabilities() { + let mut changed = camera_fixture("camera-a"); + changed.label = "Changed virtual camera".into(); + let mut snapshot = DeviceSnapshot { + cameras: vec![camera_fixture("camera-a").with_formats(&[], formats_fixture)], + ..Default::default() + }; + assert!(InputSnapshot::Cameras(vec![changed]).install(&mut snapshot)); + assert!(snapshot.cameras[0].formats.is_empty()); + } +} + /// Just the capture targets. /// /// The Tauri app keeps these on their own queries (`listScreens` / @@ -174,48 +452,126 @@ impl TargetSnapshot { } fn list_cameras() -> Vec { + list_cameras_with_previous(&[]) +} + +fn list_cameras_with_previous(previous: &[CameraOption]) -> Vec { cap_camera::list_cameras() .map(|camera| { - let mut formats = camera - .formats() - .unwrap_or_default() - .into_iter() - .map(|format| CameraFormat { - width: format.width(), - height: format.height(), - frame_rate: format.frame_rate(), - }) - .collect::>(); - let mut seen = std::collections::HashSet::new(); - formats.retain(|format| { - seen.insert(( - format.width, - format.height, - format.frame_rate.round() as u32, - )) - }); - formats.sort_by(|a, b| { - (b.width * b.height) - .cmp(&(a.width * a.height)) - .then(b.frame_rate.total_cmp(&a.frame_rate)) - }); - let best_format = formats.first().copied(); - CameraOption { device_id: camera.device_id().to_string(), model_id: camera.model_id().cloned(), label: camera.display_name().to_string(), - best_format, - formats, + best_format: None, + formats: Vec::new(), } + .with_formats(previous, || { + camera + .formats() + .unwrap_or_default() + .into_iter() + .map(|format| CameraFormat { + width: format.width(), + height: format.height(), + frame_rate: format.frame_rate(), + }) + .collect() + }) }) .collect() } +impl CameraOption { + fn same_device(&self, other: &Self) -> bool { + self.device_id == other.device_id + && self.model_id == other.model_id + && self.label == other.label + } + + fn with_formats( + mut self, + previous: &[Self], + load_formats: impl FnOnce() -> Vec, + ) -> Self { + // Windows format probing opens native camera sources; polling an unchanged + // device must reuse metadata rather than repeatedly activating its driver. + if let Some(previous) = previous.iter().find(|previous| previous.same_device(&self)) { + self.best_format = previous.best_format; + self.formats = previous.formats.clone(); + return self; + } + + let mut formats = load_formats(); + let mut seen = std::collections::HashSet::new(); + formats.retain(|format| { + seen.insert(( + format.width, + format.height, + format.frame_rate.round() as u32, + )) + }); + formats.sort_by(|a, b| { + (u64::from(b.width) * u64::from(b.height)) + .cmp(&(u64::from(a.width) * u64::from(a.height))) + .then(b.frame_rate.total_cmp(&a.frame_rate)) + }); + self.best_format = formats.first().copied(); + self.formats = formats; + self + } +} + +pub fn camera_formats(device_id: &str) -> Result, String> { + let camera = cap_camera::list_cameras() + .find(|camera| camera.device_id() == device_id) + .ok_or_else(|| "Camera is no longer available. Reconnect it and try again.".to_string())?; + let formats = camera.formats().ok_or_else(|| { + "Could not read camera formats. Check camera access and try again.".to_string() + })?; + if formats.is_empty() { + return Err( + "No camera formats are available. Check camera access and try again.".to_string(), + ); + } + Ok(CameraOption { + device_id: camera.device_id().to_string(), + model_id: camera.model_id().cloned(), + label: camera.display_name().to_string(), + best_format: None, + formats: Vec::new(), + } + .with_formats(&[], || { + formats + .into_iter() + .map(|format| CameraFormat { + width: format.width(), + height: format.height(), + frame_rate: format.frame_rate(), + }) + .collect() + }) + .formats) +} + /// Mirrors `MicrophoneFeed::list_with_settings`: the default input device is /// inserted first so it heads the list, then every other input device is /// appended, deduped by name. fn list_microphones() -> Vec { + // CPAL's configuration lookup opens an input AudioUnit and can prompt for consent. + #[cfg(target_os = "macos")] + if !crate::permissions::check_raw().is_some_and(|permissions| { + permissions.microphone == crate::permissions::MediaAuthorization::Authorized + }) { + return cap_recording::feeds::microphone::MicrophoneFeed::list_names() + .into_iter() + .map(|name| MicrophoneOption { + name, + sample_rate: None, + channels: None, + }) + .collect(); + } + let host = cpal::default_host(); let mut mics: Vec = Vec::new(); diff --git a/apps/desktop-gpui/src/diagnostics.rs b/apps/desktop-gpui/src/diagnostics.rs index f4bfb11f863..9636fdfcbec 100644 --- a/apps/desktop-gpui/src/diagnostics.rs +++ b/apps/desktop-gpui/src/diagnostics.rs @@ -38,7 +38,6 @@ use crate::{permissions, store}; pub const LOG_FILE_PREFIX: &str = "cap-gpui.log"; /// `MAX_SIZE` in `src-tauri/src/logging.rs`. -const MAX_LOG_UPLOAD_BYTES: usize = 1024 * 1024; /// How much of the self-test's stderr is kept for a failure message. const STDERR_TAIL_BYTES: usize = 8 * 1024; @@ -92,70 +91,8 @@ pub fn logs_dir() -> PathBuf { } } -/// `get_latest_log_file` in `src-tauri/src/logging.rs`: the daily appender -/// names files `.`, so the newest by modification time is the -/// one being written right now. -fn latest_log_file(dir: &Path) -> Option { - let mut files: Vec<_> = std::fs::read_dir(dir) - .ok()? - .filter_map(|entry| { - let entry = entry.ok()?; - let path = entry.path(); - if !path.is_file() || !path.file_name()?.to_str()?.contains(LOG_FILE_PREFIX) { - return None; - } - let modified = std::fs::metadata(&path).ok()?.modified().ok()?; - Some((path, modified)) - }) - .collect(); - files.sort_by_key(|(_, modified)| std::cmp::Reverse(*modified)); - files.into_iter().next().map(|(path, _)| path) -} - -/// The last ~1MB of the newest log file, with the Tauri app's truncation -/// header. Split out from the IO so the byte arithmetic is testable. -fn log_tail_from(content: &str, file_size: u64, max_bytes: usize) -> String { - if file_size as usize <= max_bytes { - return content.to_string(); - } - let header = - format!("⚠️ Log file truncated (original size: {file_size} bytes, showing last ~1MB)\n\n"); - let Some(max_content) = max_bytes.checked_sub(header.len()) else { - return header; - }; - if content.len() <= max_content { - return content.to_string(); - } - - let mut start = content.len() - max_content; - // The cut lands at an arbitrary byte; walk forward to a char boundary - // before slicing, then forward again to the next line so the upload never - // opens mid-record. - while start < content.len() && !content.is_char_boundary(start) { - start += 1; - } - let start = match content[start..].find('\n') { - Some(offset) => start + offset + 1, - None => start, - }; - format!("{header}{}", &content[start..]) -} - -/// The log text to upload. A missing log file is not an error: the upload is -/// still worth making for its diagnostics, so a placeholder goes up instead. -pub fn log_tail() -> String { - let dir = logs_dir(); - let Some(path) = latest_log_file(&dir) else { - return format!( - "No log file was found in {}. This build may not have written one yet.", - dir.display() - ); - }; - let size = std::fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0); - match std::fs::read_to_string(&path) { - Ok(content) => log_tail_from(&content, size, MAX_LOG_UPLOAD_BYTES), - Err(error) => format!("Failed to read {}: {error}", path.display()), - } +pub fn log_tail() -> cap_utils::log_upload::LogBundle { + cap_utils::log_upload::collect(&logs_dir(), LOG_FILE_PREFIX) } // --------------------------------------------------------------------------- @@ -917,25 +854,49 @@ fn write_report_into(dir: &Path, report: &Value) -> Result { pub async fn upload_report( server_url: String, token: Option, - log: String, + log: cap_utils::log_upload::LogBundle, report: Option, diagnostics: Option, ) -> Result<(), String> { + let context = serde_json::json!({ + "schemaVersion": 1, + "app": { + "flavor": "gpui", + "version": env!("CARGO_PKG_VERSION"), + "os": std::env::consts::OS, + "binaryArchitecture": std::env::consts::ARCH, + "debugBuild": cfg!(debug_assertions), + "sourceRevision": option_env!("CAP_BUILD_REVISION"), + "sourceDirty": option_env!("CAP_BUILD_DIRTY").and_then(|value| value.parse::().ok()), + }, + "operations": cap_utils::operation_diagnostics::snapshot(), + "logCoverage": &log, + "environmentCollectedAt": "upload_time", + "mediaIncluded": false, + }); // Everything leaving the machine goes through the redactor. Logs record // whole URLs on failure (reqwest's Display and Debug both append the URL), // and an upload failure logs a presigned S3 PUT, whose query string is a // live write credential for up to an hour. use cap_recording::log_redaction::scrub_log_text; + let upload = cap_utils::log_upload::prepare_upload( + log, + context, + diagnostics.as_deref(), + report.as_deref(), + scrub_log_text, + ); let mut form = reqwest::multipart::Form::new() - .text("log", scrub_log_text(&log)) + .text("log", upload.log) .text("os", std::env::consts::OS) - .text("version", env!("CARGO_PKG_VERSION")); - if let Some(report) = report { - form = form.text("report", scrub_log_text(&report)); + .text("version", env!("CARGO_PKG_VERSION")) + .text("context", upload.context); + if let Some(report) = upload.report { + form = form.text("report", report); } - if let Some(diagnostics) = diagnostics { - form = form.text("diagnostics", scrub_log_text(&diagnostics)); + if let Some(diagnostics) = upload.diagnostics { + form = form.text("diagnostics", diagnostics); } let mut request = reqwest::Client::new() @@ -1285,37 +1246,6 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - #[test] - fn a_short_log_is_uploaded_whole() { - let content = "line one\nline two\n"; - assert_eq!(log_tail_from(content, content.len() as u64, 1024), content); - } - - /// Over the cap, the tail keeps the header, stays under it, and opens on a - /// record boundary rather than mid-line. - #[test] - fn a_long_log_is_truncated_to_its_tail_on_a_line_boundary() { - let content: String = (0..500).map(|index| format!("line {index}\n")).collect(); - let tail = log_tail_from(&content, content.len() as u64, 200); - - assert!(tail.starts_with("⚠️ Log file truncated (original size:")); - assert!(tail.len() <= 200, "tail was {} bytes", tail.len()); - assert!(tail.ends_with("line 499\n")); - let body = tail.split("\n\n").nth(1).unwrap(); - assert!( - body.starts_with("line "), - "the tail opened mid-record: {body:?}" - ); - } - - /// A multi-byte character straddling the cut must not panic the slice. - #[test] - fn truncation_survives_a_cut_inside_a_character() { - let content: String = (0..200).map(|index| format!("café {index} ✅\n")).collect(); - let tail = log_tail_from(&content, content.len() as u64, 300); - assert!(tail.ends_with("✅\n")); - } - #[test] fn stage_labels_read_as_sentences() { assert_eq!( diff --git a/apps/desktop-gpui/src/editor_canvas.rs b/apps/desktop-gpui/src/editor_canvas.rs index 88d6876cc65..eaa364b547a 100644 --- a/apps/desktop-gpui/src/editor_canvas.rs +++ b/apps/desktop-gpui/src/editor_canvas.rs @@ -44,7 +44,7 @@ use std::cell::Cell; use std::rc::Rc; use crate::{editor_timeline::TrackKind, editor_window::EditorWindow}; -use cap_project::{CameraXPosition, CameraYPosition, SceneMode, XY}; +use cap_project::{CameraXPosition, CameraYPosition, OverlayTrackKind, SceneMode, XY}; use cap_rendering::FrameLayout; use gpui::{ AnyElement, Bounds, Context, CursorStyle, FontWeight, Hsla, InteractiveElement, IntoElement, @@ -447,6 +447,7 @@ pub enum CanvasSelection { Camera, Mask(usize), Text(usize), + Image(usize), } impl CanvasSelection { @@ -456,6 +457,7 @@ impl CanvasSelection { Self::Camera => "Camera".into(), Self::Mask(_) => "Mask".into(), Self::Text(_) => "Text".into(), + Self::Image(_) => "Image".into(), } } @@ -465,6 +467,7 @@ impl CanvasSelection { Self::Camera => "canvas-camera".into(), Self::Mask(index) => format!("canvas-mask-{index}").into(), Self::Text(index) => format!("canvas-text-{index}").into(), + Self::Image(index) => format!("canvas-image-{index}").into(), } } @@ -472,6 +475,7 @@ impl CanvasSelection { match self { Self::Mask(index) => Some((TrackKind::Mask, index)), Self::Text(index) => Some((TrackKind::Text, index)), + Self::Image(index) => Some((TrackKind::Image, index)), _ => None, } } @@ -597,6 +601,24 @@ impl EditorWindow { } let t = self.preview_or_playhead(); if let Some(timeline) = self.project.timeline.as_ref() { + for (index, segment) in timeline.image_segments.iter().enumerate() { + if exclude == CanvasSelection::Image(index) + || !segment.is_active_at(t) + || segment.opacity <= 0. + { + continue; + } + if let Some(rect) = self.element_rect(CanvasSelection::Image(index)) { + rects.push(image_axis_bounds( + rect, + ( + f64::from(layout.output_size[0]), + f64::from(layout.output_size[1]), + ), + f64::from(segment.rotation), + )); + } + } for (index, segment) in timeline.text_segments.iter().enumerate() { if exclude == CanvasSelection::Text(index) { continue; @@ -651,12 +673,17 @@ impl EditorWindow { cx, ); } else if self.canvas_selection != Some(element) { + if self.selected_style_index().is_none() { + self.set_selection(None, cx); + } self.canvas_selection = Some(element); - self.set_selection(None, cx); } let draggable = match element { CanvasSelection::Display => self.display_draggable(), - CanvasSelection::Camera | CanvasSelection::Mask(_) | CanvasSelection::Text(_) => true, + CanvasSelection::Camera + | CanvasSelection::Mask(_) + | CanvasSelection::Text(_) + | CanvasSelection::Image(_) => true, }; if !draggable { cx.notify(); @@ -690,7 +717,7 @@ impl EditorWindow { let resizable = match element { CanvasSelection::Display => self.display_draggable(), CanvasSelection::Camera => self.camera_resizable(), - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => true, + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => true, }; if !resizable { return; @@ -702,8 +729,10 @@ impl EditorWindow { cx, ); } else if self.canvas_selection != Some(element) { + if self.selected_style_index().is_none() { + self.set_selection(None, cx); + } self.canvas_selection = Some(element); - self.set_selection(None, cx); } let Some(rect) = self.element_rect(element) else { return; @@ -711,8 +740,12 @@ impl EditorWindow { let Some(layout) = self.frame_layout else { return; }; + let effective = self + .project + .style_at(self.preview_or_playhead()) + .into_owned(); let (max_width, padding_scale) = - display_resize_scales(rect, &layout, self.project.aspect_ratio.is_some()); + display_resize_scales(rect, &layout, effective.aspect_ratio.is_some()); self.history.pause(); self.canvas_drag = Some(CanvasDrag { element, @@ -725,9 +758,9 @@ impl EditorWindow { dir_y, output_width: f64::from(layout.output_size[0]), output_height: f64::from(layout.output_size[1]), - camera_manual: self.project.camera.manual_position, - camera_x: self.project.camera.position.x.clone(), - camera_y: self.project.camera.position.y.clone(), + camera_manual: effective.camera.manual_position, + camera_x: effective.camera.position.x.clone(), + camera_y: effective.camera.position.y.clone(), max_width, padding_scale, }), @@ -820,10 +853,17 @@ impl EditorWindow { ); self.snap_guides = guides; self.canvas_drag_rect = Some(rect); - if (self.project.background.padding - padding).abs() > 1e-6 { - self.project.background.padding = padding; - self.project_changed_live(cx); - } + self.write_canvas_style( + crate::editor_sidebar::StyleGroup::Background, + |project| { + if (project.background.padding - padding).abs() <= 1e-6 { + return false; + } + project.background.padding = padding; + true + }, + cx, + ); } CanvasSelection::Camera => { let (rect, size_pct, guides) = camera_resize_rect( @@ -841,14 +881,42 @@ impl EditorWindow { ); self.snap_guides = guides; self.canvas_drag_camera_rect = Some(rect); - if (f64::from(self.project.camera.size) - size_pct).abs() > 1e-6 { - self.project.camera.size = size_pct as f32; - self.project_changed_live(cx); - } + self.write_canvas_style( + crate::editor_sidebar::StyleGroup::Camera, + |project| { + if (f64::from(project.camera.size) - size_pct).abs() <= 1e-6 { + return false; + } + project.camera.size = size_pct as f32; + true + }, + cx, + ); } - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => { - let (rect, guides) = - overlay_resize_rect(start, size, delta, dir_x, dir_y, &targets, shift); + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { + let (rect, guides) = if let CanvasSelection::Image(index) = element { + let Some(segment) = self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.image_segments.get(index)) + else { + return; + }; + ( + image_resize_rect( + start, + size, + delta, + (dir_x, dir_y), + f64::from(segment.rotation), + segment.lock_aspect, + ), + Vec::new(), + ) + } else { + overlay_resize_rect(start, size, delta, dir_x, dir_y, &targets, shift) + }; self.snap_guides = guides; self.canvas_overlay_rect = Some(rect); self.write_overlay_rect(element, rect, cx); @@ -860,8 +928,19 @@ impl EditorWindow { let (center, guides) = match element { CanvasSelection::Display => display_drag_center(start, size, delta, &targets, shift), CanvasSelection::Camera => camera_drag_center(start, size, delta, &targets, shift), - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => { - overlay_drag_center(start, size, delta, &targets, shift) + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { + let bounds = if let CanvasSelection::Image(index) = element { + let rotation = self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.image_segments.get(index)) + .map_or(0., |segment| f64::from(segment.rotation)); + image_axis_bounds(start, size, rotation) + } else { + start + }; + overlay_drag_center(bounds, size, delta, &targets, shift) } }; self.snap_guides = guides; @@ -879,7 +958,7 @@ impl EditorWindow { self.canvas_drag_camera_rect = Some(optimistic); self.write_camera_position(center, cx); } - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => { + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { self.canvas_overlay_rect = Some(optimistic); self.write_overlay_rect(element, optimistic, cx); } @@ -920,6 +999,9 @@ impl EditorWindow { CanvasSelection::Text(index) => { tracing::info!(index, "canvas text drag"); } + CanvasSelection::Image(index) => { + tracing::info!(index, "canvas image drag"); + } } } cx.notify(); @@ -939,6 +1021,18 @@ impl EditorWindow { if !self.canvas_overlay_visible() { return false; } + if let CanvasSelection::Image(index) = selected + && !self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.image_segments.get(index)) + .is_some_and(|segment| { + segment.is_active_at(self.preview_or_playhead()) && segment.opacity > 0. + }) + { + return false; + } let (Some(canvas), Some(rect)) = (self.canvas_bounds(), self.element_rect(selected)) else { return false; }; @@ -957,7 +1051,7 @@ impl EditorWindow { let center = match selected { CanvasSelection::Display => display_nudge_center(rect, size, direction, shift), CanvasSelection::Camera => camera_nudge_center(rect, size, direction, shift), - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => { + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { overlay_nudge_center(rect, direction, shift) } }; @@ -975,35 +1069,107 @@ impl EditorWindow { self.canvas_drag_camera_rect = Some(optimistic); self.write_camera_position(center, cx); } - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => { + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { self.canvas_overlay_rect = Some(optimistic); self.write_overlay_rect(selected, optimistic, cx); } } - let _ = window; + self.schedule_save(window, cx); true } fn write_display_position(&mut self, center: XY, cx: &mut Context) { - if self.project.background.display_position == Some(center) { - return; - } - self.project.background.display_position = Some(center); - self.project_changed_live(cx); + self.write_canvas_style( + crate::editor_sidebar::StyleGroup::Background, + |project| { + if project.background.display_position == Some(center) { + return false; + } + project.background.display_position = Some(center); + true + }, + cx, + ); } fn write_camera_position(&mut self, center: XY, cx: &mut Context) { - if self.project.camera.manual_position == Some(center) { - return; + self.write_canvas_style( + crate::editor_sidebar::StyleGroup::Camera, + |project| { + if project.camera.manual_position == Some(center) { + return false; + } + project.camera.manual_position = Some(center); + true + }, + cx, + ); + } + + fn write_canvas_style( + &mut self, + group: crate::editor_sidebar::StyleGroup, + change: impl FnOnce(&mut cap_project::ProjectConfiguration) -> bool, + cx: &mut Context, + ) { + use crate::editor_sidebar::StyleGroup; + let time = self.preview_or_playhead(); + let target = self.project.timeline.as_ref().and_then(|timeline| { + timeline + .style_segments + .iter() + .enumerate() + .filter(|(_, segment)| { + segment.is_active_at(time) + && match group { + StyleGroup::Background => segment.overrides.background.is_some(), + StyleGroup::Camera => segment.overrides.camera.is_some(), + StyleGroup::Cursor => segment.overrides.cursor.is_some(), + } + }) + .max_by(|(ai, a), (bi, b)| { + a.track + .cmp(&b.track) + .then(a.start.total_cmp(&b.start)) + .then(ai.cmp(bi)) + }) + .map(|(index, _)| index) + }); + let changed = match self.selected_style_index().or(target) { + Some(index) => crate::editor_sidebar::apply_style_control_change( + &mut self.project, + index, + group, + change, + ), + None => change(&mut self.project), + }; + if changed { + self.project_changed_live(cx); } - self.project.camera.manual_position = Some(center); - self.project_changed_live(cx); } fn element_rect(&self, element: CanvasSelection) -> Option { match element { CanvasSelection::Display => self.display_rect(), CanvasSelection::Camera => self.camera_rect(), + CanvasSelection::Image(index) => { + if self + .canvas_drag + .as_ref() + .is_some_and(|drag| drag.element == element) + && let Some(rect) = self.canvas_overlay_rect + { + return Some(rect); + } + let segment = self.project.timeline.as_ref()?.image_segments.get(index)?; + Some(NormRect { + x: segment.center.x - segment.size.x / 2., + y: segment.center.y - segment.size.y / 2., + w: segment.size.x, + h: segment.size.y, + }) + } CanvasSelection::Mask(index) => { if self .canvas_drag @@ -1055,6 +1221,13 @@ impl EditorWindow { return; }; match element { + CanvasSelection::Image(index) => { + let Some(segment) = timeline.image_segments.get_mut(index) else { + return; + }; + segment.center = center; + segment.size = size; + } CanvasSelection::Mask(index) => { let Some(segment) = timeline.mask_segments.get_mut(index) else { return; @@ -1132,7 +1305,7 @@ impl EditorWindow { .h(player.frame.size.height) .overflow_hidden(); - if self.canvas_selection.is_some() { + if self.canvas_selection.is_some() || self.selection.is_some() { layer = layer.child( div() .id("canvas-deselect") @@ -1141,6 +1314,7 @@ impl EditorWindow { .on_mouse_down( MouseButton::Left, cx.listener(|this, _, window, cx| { + this.set_selection(None, cx); this.canvas_selection = None; cx.notify(); window.refresh(); @@ -1162,27 +1336,73 @@ impl EditorWindow { layer.child(self.render_element_box(CanvasSelection::Camera, rect, (cw, ch), cx)); } let time = self.preview_or_playhead(); + let overlay_order = self.project.overlay_tracks(); if let Some(timeline) = self.project.timeline.as_ref() { - for (index, segment) in timeline.mask_segments.iter().enumerate() { - if !(time >= segment.start && time < segment.end) { - continue; - } - let element = CanvasSelection::Mask(index); - if let Some(rect) = self.element_rect(element) { - layer = layer.child(self.render_element_box(element, rect, (cw, ch), cx)); - } - } - for (index, segment) in timeline.text_segments.iter().enumerate() { - if !(time >= segment.start && time < segment.end && segment.enabled) { - continue; - } - let element = CanvasSelection::Text(index); - if let Some(rect) = self.element_rect(element) { - layer = layer.child(self.render_element_box(element, rect, (cw, ch), cx)); + for track in overlay_order.iter().rev() { + match track.kind { + OverlayTrackKind::Mask => { + for (index, segment) in timeline + .mask_segments + .iter() + .enumerate() + .filter(|(_, segment)| segment.track == track.track) + { + if !(segment.enabled && time >= segment.start && time < segment.end) { + continue; + } + let element = CanvasSelection::Mask(index); + if let Some(rect) = self.element_rect(element) { + layer = layer.child(self.render_element_box( + element, + rect, + (cw, ch), + cx, + )); + } + } + } + OverlayTrackKind::Image => { + for (index, _) in + timeline + .image_segments + .iter() + .enumerate() + .filter(|(_, segment)| { + segment.track == track.track + && segment.is_active_at(time) + && segment.opacity > 0. + }) + { + if let Some(rect) = self.element_rect(CanvasSelection::Image(index)) { + layer = + layer.child(self.render_image_box(index, rect, (cw, ch), cx)); + } + } + } + OverlayTrackKind::Text => { + for (index, segment) in timeline + .text_segments + .iter() + .enumerate() + .filter(|(_, segment)| segment.track == track.track) + { + if !(time >= segment.start && time < segment.end && segment.enabled) { + continue; + } + let element = CanvasSelection::Text(index); + if let Some(rect) = self.element_rect(element) { + layer = layer.child(self.render_element_box( + element, + rect, + (cw, ch), + cx, + )); + } + } + } } } } - for guide in &self.snap_guides { let color = gpui::rgb(0xFF3B6B); layer = layer.child(match guide.axis { @@ -1232,7 +1452,9 @@ impl EditorWindow { self.camera_resizable(), (!self.camera_resizable()).then_some("Camera size follows the zoom — drag to move"), ), - CanvasSelection::Mask(_) | CanvasSelection::Text(_) => (true, true, None), + CanvasSelection::Mask(_) | CanvasSelection::Text(_) | CanvasSelection::Image(_) => { + (true, true, None) + } }; let left = rect.x as f32 * canvas.0; @@ -1278,7 +1500,7 @@ impl EditorWindow { // the stop the display would hijack the drag one event later. .on_mouse_down( MouseButton::Left, - cx.listener(move |this, event, window, cx| { + cx.listener(move |this, event: &MouseDownEvent, window, cx| { cx.stop_propagation(); this.begin_canvas_move(element, event, window, cx); }), @@ -1403,7 +1625,7 @@ impl EditorWindow { ) .on_mouse_down( MouseButton::Left, - cx.listener(move |this, event, window, cx| { + cx.listener(move |this, event: &MouseDownEvent, window, cx| { cx.stop_propagation(); this.begin_canvas_resize(element, dir_x, dir_y, event, window, cx); }), @@ -1919,3 +2141,256 @@ mod tests { assert!((guide.end - 0.7).abs() < 1e-9); } } + +fn rotate_point(point: (f64, f64), degrees: f64) -> (f64, f64) { + let (sin, cos) = degrees.to_radians().sin_cos(); + (point.0 * cos - point.1 * sin, point.0 * sin + point.1 * cos) +} + +fn image_corners(rect: NormRect, canvas: (f64, f64), rotation: f64) -> [(f64, f64); 4] { + [(-1., -1.), (1., -1.), (1., 1.), (-1., 1.)].map(|(x, y)| { + let offset = rotate_point( + (x * rect.w * canvas.0 / 2., y * rect.h * canvas.1 / 2.), + rotation, + ); + ( + (rect.x + rect.w / 2.) * canvas.0 + offset.0, + (rect.y + rect.h / 2.) * canvas.1 + offset.1, + ) + }) +} + +fn image_axis_bounds(rect: NormRect, canvas: (f64, f64), rotation: f64) -> NormRect { + let corners = image_corners(rect, canvas, rotation); + let min_x = corners + .iter() + .map(|point| point.0) + .fold(f64::INFINITY, f64::min); + let min_y = corners + .iter() + .map(|point| point.1) + .fold(f64::INFINITY, f64::min); + let max_x = corners + .iter() + .map(|point| point.0) + .fold(f64::NEG_INFINITY, f64::max); + let max_y = corners + .iter() + .map(|point| point.1) + .fold(f64::NEG_INFINITY, f64::max); + NormRect { + x: min_x / canvas.0, + y: min_y / canvas.1, + w: (max_x - min_x) / canvas.0, + h: (max_y - min_y) / canvas.1, + } +} + +fn image_hit(rect: NormRect, canvas: (f64, f64), rotation: f64, point: (f64, f64)) -> bool { + let local = rotate_point( + ( + point.0 - (rect.x + rect.w / 2.) * canvas.0, + point.1 - (rect.y + rect.h / 2.) * canvas.1, + ), + -rotation, + ); + local.0.abs() <= rect.w * canvas.0 / 2. && local.1.abs() <= rect.h * canvas.1 / 2. +} + +fn image_resize_rect( + start: NormRect, + canvas: (f64, f64), + delta: (f64, f64), + direction: (i8, i8), + rotation: f64, + lock_aspect: bool, +) -> NormRect { + let delta = rotate_point(delta, -rotation); + let width = start.w * canvas.0; + let height = start.h * canvas.1; + let mut next_width = (width + delta.0 * f64::from(direction.0)).max(canvas.0 * 0.01); + let mut next_height = (height + delta.1 * f64::from(direction.1)).max(canvas.1 * 0.01); + if lock_aspect && width > 0. && height > 0. { + let sx = next_width / width; + let sy = next_height / height; + let scale = if (sx - 1.).abs() > (sy - 1.).abs() { + sx + } else { + sy + }; + let scale = scale + .max(canvas.0 * 0.01 / width) + .max(canvas.1 * 0.01 / height); + next_width = width * scale; + next_height = height * scale; + } + let offset = rotate_point( + ( + (next_width - width) * f64::from(direction.0) / 2., + (next_height - height) * f64::from(direction.1) / 2., + ), + rotation, + ); + let w = next_width / canvas.0; + let h = next_height / canvas.1; + NormRect { + x: start.x + start.w / 2. + offset.0 / canvas.0 - w / 2., + y: start.y + start.h / 2. + offset.1 / canvas.1 - h / 2., + w, + h, + } +} + +impl EditorWindow { + fn render_image_box( + &self, + index: usize, + rect: NormRect, + canvas: (f32, f32), + cx: &mut Context, + ) -> AnyElement { + let element = CanvasSelection::Image(index); + let rotation = self + .project + .timeline + .as_ref() + .and_then(|timeline| timeline.image_segments.get(index)) + .map_or(0., |segment| f64::from(segment.rotation)); + let size = (f64::from(canvas.0), f64::from(canvas.1)); + let corners = image_corners(rect, size, rotation); + let show = self.canvas_selection == Some(element) || self.hovered_canvas == Some(element); + let color = Hsla::from(self.theme.blue_9); + let mut layer = div() + .id(element.element_id()) + .absolute() + .inset_0() + .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _, cx| { + let Some(bounds) = this.canvas_bounds() else { + return; + }; + let point = ( + f64::from(f32::from(event.position.x - bounds.origin.x)), + f64::from(f32::from(event.position.y - bounds.origin.y)), + ); + this.set_canvas_hover(element, image_hit(rect, size, rotation, point), cx); + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + let Some(bounds) = this.canvas_bounds() else { + return; + }; + let point = ( + f64::from(f32::from(event.position.x - bounds.origin.x)), + f64::from(f32::from(event.position.y - bounds.origin.y)), + ); + if image_hit(rect, size, rotation, point) { + cx.stop_propagation(); + this.begin_canvas_move(element, event, window, cx); + } + }), + ); + if show { + layer = layer.child( + gpui::canvas( + |bounds, _, _| bounds, + move |_, bounds, window, _| { + let mut path = gpui::PathBuilder::stroke(px(2.)); + for (index, (x, y)) in corners.into_iter().enumerate() { + let point = gpui::point( + bounds.origin.x + px(x as f32), + bounds.origin.y + px(y as f32), + ); + if index == 0 { + path.move_to(point); + } else { + path.line_to(point); + } + } + path.close(); + if let Ok(path) = path.build() { + window.paint_path(path, color); + } + }, + ) + .absolute() + .inset_0(), + ); + for ((x, y), (dx, dy)) in corners + .into_iter() + .zip([(-1, -1), (1, -1), (1, 1), (-1, 1)]) + { + layer = layer.child( + div() + .id(SharedString::from(format!( + "image-handle-{index}-{dx}-{dy}" + ))) + .absolute() + .left(px(x as f32 - 6.)) + .top(px(y as f32 - 6.)) + .size(px(12.)) + .rounded_full() + .border_1() + .border_color(gpui::white()) + .bg(color) + .cursor(CursorStyle::ResizeUpLeftDownRight) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.begin_canvas_resize(element, dx, dy, event, window, cx); + }), + ), + ); + } + } + layer.into_any_element() + } +} + +#[cfg(test)] +mod style_image_tests { + use super::*; + + #[test] + fn style_image_rotated_hit_excludes_empty_bounding_box_corners() { + let rect = NormRect { + x: 0.3, + y: 0.35, + w: 0.4, + h: 0.3, + }; + let canvas = (1200., 600.); + let bounds = image_axis_bounds(rect, canvas, 45.); + assert!(image_hit(rect, canvas, 45., (600., 300.))); + assert!(!image_hit( + rect, + canvas, + 45., + (bounds.x * canvas.0, bounds.y * canvas.1) + )); + } + + #[test] + fn style_image_rotated_resize_keeps_opposite_corner_and_aspect() { + let start = NormRect { + x: 0.3, + y: 0.35, + w: 0.4, + h: 0.3, + }; + let canvas = (1200., 600.); + for rotation in [-135., 0., 35., 90.] { + for (corner, direction) in [(0, (-1, -1)), (1, (1, -1)), (2, (1, 1)), (3, (-1, 1))] { + let next = image_resize_rect(start, canvas, (80., 45.), direction, rotation, true); + assert!((next.w / next.h - start.w / start.h).abs() < 1e-9); + let opposite = (corner + 2) % 4; + let before = image_corners(start, canvas, rotation)[opposite]; + let after = image_corners(next, canvas, rotation)[opposite]; + assert!((before.0 - after.0).abs() < 1e-8); + assert!((before.1 - after.1).abs() < 1e-8); + assert!(next.w > 0. && next.h > 0.); + } + } + } +} diff --git a/apps/desktop-gpui/src/editor_clips.rs b/apps/desktop-gpui/src/editor_clips.rs index 1f16e2946ec..814c372b176 100644 --- a/apps/desktop-gpui/src/editor_clips.rs +++ b/apps/desktop-gpui/src/editor_clips.rs @@ -242,6 +242,8 @@ pub(crate) fn move_clip( timeline .transitions .retain(|candidate| candidate.segment_index != transition.segment_index); + ripple_track(&mut timeline.style_segments, boundary, effective.duration); + ripple_track(&mut timeline.image_segments, boundary, effective.duration); ripple_track(&mut timeline.zoom_segments, boundary, effective.duration); ripple_track(&mut timeline.scene_segments, boundary, effective.duration); ripple_track(&mut timeline.mask_segments, boundary, effective.duration); @@ -412,19 +414,17 @@ impl EditorWindow { /// The Clips toggle (`Header.tsx:173-187`): `Button variant={open ? /// "white" : "gray"}` at `flex gap-1.5 justify-center h-[40px]`, clearing /// the timeline selection on every press. - pub(crate) fn render_clips_pill(&self, cx: &mut Context) -> impl IntoElement { - let variant = if self.clips.open { - ui::ButtonVariant::White - } else { - ui::ButtonVariant::Gray - }; - ui::Button::plain(&self.theme, "clips-pill", variant, ui::ButtonSize::Md) - .icon("icons/clapperboard.svg") - .label("Clips") + pub(crate) fn render_clips_pill( + &self, + compact: bool, + cx: &mut Context, + ) -> impl IntoElement { + ui::EditorButton::plain(&self.theme, "clips-pill") + .left_icon("icons/clapperboard.svg") + .when(!compact, |button| button.label("Clips")) + .tooltip(&self.theme, "Clips") + .pressed(self.clips.open) .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))) } @@ -727,21 +727,16 @@ impl EditorWindow { // -- The sidebar ------------------------------------------------------------ /// The whole clips column, drawn in the config sidebar's slot while the - /// mode is open. Same `ml-2 w-104` wrapper the config sidebar carries - /// (`Editor.tsx:728`); the card itself is `flex flex-col flex-1 min-h-0 - /// rounded-xl border bg-gray-1 dark:bg-gray-2 border-gray-3 - /// overflow-hidden` (`ClipsSidebar.tsx:791-797`). + /// mode is open. Same `ml-2 w-104` wrapper the config sidebar carries, and + /// the same card. pub(crate) fn render_clips_sidebar(&mut self, cx: &mut Context) -> impl IntoElement { self.request_clip_thumbnails(cx); - let theme = self.theme; div() - .ml(px(8.)) .w(px(crate::editor_window::SIDEBAR_WIDTH)) .flex_none() .flex() .min_h_0() - .overflow_hidden() .child( div() .flex() @@ -751,8 +746,9 @@ impl EditorWindow { .overflow_hidden() .rounded(px(12.)) .border_1() - .border_color(Hsla::from(theme.gray_3)) + .border_color(self.card_line()) .bg(self.panel_bg()) + .shadow(self.theme.editor.card_shadow()) .child(self.render_clips_back_header(cx)) .child(self.render_clips_body(cx)), ) @@ -769,22 +765,23 @@ impl EditorWindow { .flex_row() .items_center() .gap(px(8.)) - .px(px(16.)) + .px(px(12.)) .w_full() - .h(px(64.)) + .h(px(crate::editor_window::SIDEBAR_TAB_BAR_HEIGHT)) .rounded_t(px(11.)) .border_b_1() - .border_color(Hsla::from(theme.gray_3)) - .text_size(px(14.)) + .border_color(Hsla::from(theme.editor.line)) + .text_size(px(13.)) .font_weight(FontWeight::MEDIUM) - .text_color(Hsla::from(theme.gray_12)) - .hover(move |style| style.bg(Hsla::from(theme.gray_3))) + .text_color(Hsla::from(theme.editor.text_1)) + .cursor_pointer() + .hover(move |style| style.bg(Hsla::from(theme.editor.ctl))) .child( svg() .path("icons/move-left.svg") .size(px(16.)) .flex_shrink_0() - .text_color(Hsla::from(theme.gray_11)), + .text_color(Hsla::from(theme.editor.text_2)), ) .child("Back to editor") .on_click(cx.listener(|this, _, window, cx| this.close_clips(window, cx))) @@ -1492,7 +1489,7 @@ impl EditorWindow { cx.spawn_in(window, async move |this, cx| { // Blocking modal, so from a spawned task with no borrow held -- // the `save_file_panel` rule. - let Some(path) = pick_existing_recording_path() else { + let Some(path) = pick_existing_recording_path(cx).await else { return; }; this.update_in(cx, |this, window, cx| { @@ -1510,7 +1507,10 @@ impl EditorWindow { cx.spawn_in(window, async move |this, cx| { #[cfg(target_os = "macos")] let source = crate::platform::open_image_panel(&["mp4"]); - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + let source = + crate::platform::open_file_panel_async(&[("MP4 Video", &["mp4"])], None, cx).await; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] let source = rfd::FileDialog::new() .add_filter("MP4 Video", &["mp4"]) .pick_file(); @@ -1998,12 +1998,21 @@ impl PreparedMp4Import { /// a `.cap` filter on macOS (bundles are packages there), a directory picker /// on Windows, both rooted at the recordings directory where the dialog /// supports one. -fn pick_existing_recording_path() -> Option { +async fn pick_existing_recording_path(_cx: &mut gpui::AsyncWindowContext) -> Option { #[cfg(target_os = "macos")] { crate::platform::open_image_panel(&["cap"]) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + crate::platform::open_file_panel_async( + &[("Cap Recording", &["cap"])], + Some(crate::recording::recordings_dir()), + _cx, + ) + .await + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { rfd::FileDialog::new() .set_directory(crate::recording::recordings_dir()) @@ -2378,6 +2387,8 @@ fn ensure_project_timeline<'a>( keyboard_segments: Vec::new(), audio_segments: Vec::new(), camera3d_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), }); } @@ -3804,6 +3815,17 @@ mod tests { edge_snap_ratio: 0.25, }]; + config.style_segments.push(cap_project::StyleSegment { + start: 15., + end: 18., + ..Default::default() + }); + config.image_segments.push(cap_project::ImageSegment { + start: 15., + end: 18., + path: "content/images/retained.png".into(), + ..Default::default() + }); config.text_segments = serde_json::from_value(serde_json::json!([{ "start": 10.0, "end": 12.0, @@ -3838,6 +3860,14 @@ mod tests { assert!(config.transitions.is_empty()); assert_eq!(config.zoom_segments[0].start, 16.0); assert_eq!(config.zoom_segments[0].end, 19.0); + assert_eq!( + (config.style_segments[0].start, config.style_segments[0].end), + (16., 19.) + ); + assert_eq!( + (config.image_segments[0].start, config.image_segments[0].end), + (16., 19.) + ); assert_eq!( ( config.keyboard_segments[0].start, diff --git a/apps/desktop-gpui/src/editor_color.rs b/apps/desktop-gpui/src/editor_color.rs index 60a7525ebcb..13393aee32d 100644 --- a/apps/desktop-gpui/src/editor_color.rs +++ b/apps/desktop-gpui/src/editor_color.rs @@ -41,7 +41,7 @@ use cap_project::{ColorCorrection, ProjectConfiguration}; use gpui::{ AnyElement, Context, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, RenderImage, SharedString, StatefulInteractiveElement, Styled, StyledImage, Window, div, img, - prelude::FluentBuilder, px, svg, + prelude::FluentBuilder, px, }; use crate::{editor_window::EditorWindow, ui}; @@ -827,38 +827,29 @@ impl EditorWindow { let mut section = div() .flex() .flex_col() - .gap(px(24.)) - .child( - ui::Field::plain(&theme, "Color Correction") - .icon("icons/sliders-horizontal.svg") - .child(tiles), - ) - .child( - ui::Field::plain(&theme, "Grain") - .icon("icons/grip.svg") - .child(self.slider( - crate::editor_sidebar::SliderKey::Grade(target, GradeSlider::Grain), - "%", - cx, - )), - ); + .gap(px(14.)) + .child(ui::Field::section(&theme, "Color Correction").child(tiles)) + .child(self.slider_field( + "Grain", + crate::editor_sidebar::SliderKey::Grade(target, GradeSlider::Grain), + "%", + cx, + )); // `` (`:151`). if target == GradeTarget::Screen { let grade_cursor = self.project.color_correction.grade_cursor; section = section.child( - ui::Field::plain(&theme, "Apply to cursor") - .icon("icons/mouse-pointer-2.svg") - .value( - ui::Toggle::plain(&theme, "grade-cursor", grade_cursor) - .on_click(cx.listener(move |this, _, window, cx| { - this.edit_project("grade-cursor", window, cx, |project| { - project.color_correction.grade_cursor = !grade_cursor; - true - }); - })) - .into_any_element(), - ), + ui::Field::inline(&theme, "Apply to cursor").value( + ui::Toggle::plain(&theme, "grade-cursor", grade_cursor) + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_project("grade-cursor", window, cx, |project| { + project.color_correction.grade_cursor = !grade_cursor; + true + }); + })) + .into_any_element(), + ), ); } @@ -869,53 +860,32 @@ impl EditorWindow { .w_full() .flex() .flex_col() - .child( - div() - .id(SharedString::from(format!("grade-adjust-{}", target.key()))) - .flex() - .flex_row() - .gap(px(4.)) - .items_center() - .w_full() - .text_size(px(14.)) - .font_weight(FontWeight::MEDIUM) - .text_color(Hsla::from(theme.gray_12)) - .cursor_pointer() - .child("Fine-tune colors") - .child( - // `group-data-expanded:rotate-180` -- no - // rotation in this rev, so the glyph swaps. - svg() - .path(if open.is_open() { - "icons/chevron-down.svg" - } else { - "icons/chevron-right.svg" - }) - .size(px(20.)) - .text_color(Hsla::from(theme.gray_12)), - ) - .on_click(cx.listener(move |this, _, window, cx| { - let next = !this.sidebar.grade_open(target).is_open(); - this.sidebar.set_grade_open(target, next); - this.animate_collapsibles(window, cx); - })), - ) + .child(crate::editor_sidebar::disclosure_row( + &theme, + SharedString::from(format!("grade-adjust-{}", target.key())), + "Fine-tune colors", + open.is_open(), + cx.listener(move |this, _, window, cx| { + let next = !this.sidebar.grade_open(target).is_open(); + this.sidebar.set_grade_open(target, next); + this.animate_collapsibles(window, cx); + }), + )) .child(crate::editor_sidebar::collapsible( open, div() .flex() .flex_col() - .gap(px(24.)) // `mt-4 space-y-6` - .pt(px(16.)) + .pt(px(4.)) .children(GradeSlider::ADJUST.map(|slider| { - ui::Field::plain(&theme, slider.label()) - .child(self.slider( - crate::editor_sidebar::SliderKey::Grade(target, slider), - "%", - cx, - )) - .into_any_element() + self.slider_field( + slider.label(), + crate::editor_sidebar::SliderKey::Grade(target, slider), + "%", + cx, + ) + .into_any_element() })) .into_any_element(), )), diff --git a/apps/desktop-gpui/src/editor_crop.rs b/apps/desktop-gpui/src/editor_crop.rs index 553de486f3c..84b8ff276d2 100644 --- a/apps/desktop-gpui/src/editor_crop.rs +++ b/apps/desktop-gpui/src/editor_crop.rs @@ -1041,6 +1041,8 @@ pub fn is_nudge_key(key: &str) -> Option<&'static str> { /// Everything the open dialog owns. `None` on [`EditorWindow`] means the /// dialog is closed, which is `dialog().type !== "crop"`. pub struct CropState { + style_target: Option, + error: Option, /// `targetSize` -- `recordings.segments[0].display`, the raw recording /// resolution and the space `background.crop` is written in. pub target: (u32, u32), @@ -1086,6 +1088,8 @@ impl CropState { /// viewport gives. pub fn new(target: (u32, u32), box_size: (f32, f32), initial: CropBounds) -> Self { let mut state = Self { + style_target: None, + error: None, target, box_size, // Seeded from the border inset so the first frame is already @@ -1448,9 +1452,31 @@ impl EditorWindow { tracing::warn!("crop: no display recording to crop"); return; }; + self.end_field_edit(cx); + self.close_color_picker(cx); + self.dismiss_frame_controls(cx); self.stop_playback_for_crop(cx); - let initial = match &self.project.background.crop { + let style_target = self.selected_style_index().and_then(|index| { + let segment = self.project.timeline.as_ref()?.style_segments.get(index)?; + Some(StyleCropTarget { + index, + fingerprint: serde_json::to_string(segment).ok()?, + background: segment + .overrides + .background + .clone() + .unwrap_or_else(|| self.project.background.clone()), + opting_in: segment.overrides.background.is_none(), + time: self + .preview_or_playhead() + .clamp(segment.start, (segment.end - 0.001).max(segment.start)), + }) + }); + let background = style_target + .as_ref() + .map_or(&self.project.background, |target| &target.background); + let initial = match &background.crop { Some(crop) => CropBounds::new( f64::from(crop.position.x), f64::from(crop.position.y), @@ -1462,7 +1488,11 @@ impl EditorWindow { let viewport = window.viewport_size(); let container = crop_box_size((viewport.width.into(), viewport.height.into()), target); - let state = CropState::new(target, container, initial); + let mut state = CropState::new(target, container, initial); + state.style_target = style_target; + if let Some(target) = &state.style_target { + self.seek_to_time(target.time, cx); + } tracing::info!( target = format!("{}x{}", target.0, target.1), container = format!("{}x{}", container.0, container.1), @@ -1541,9 +1571,18 @@ impl EditorWindow { /// The footer's Save (`Editor.tsx:1414-1432`): **one** `setProject` call, /// so **one** history entry for the whole session, then close. pub(crate) fn save_crop(&mut self, window: &mut Window, cx: &mut Context) { - let Some(state) = self.crop.take() else { + let Some(mut state) = self.crop.take() else { return; }; + if let Some(target) = &state.style_target + && !target.matches(&self.project) + { + state.error = Some("This Style changed while cropping. Cancel and reopen Crop.".into()); + self.crop = Some(state); + self.publish_project(); + cx.notify(); + return; + } let bounds = state.real(); let crop = Crop { position: XY::new(bounds.x.max(0.) as u32, bounds.y.max(0.) as u32), @@ -1556,7 +1595,13 @@ impl EditorWindow { ), "crop saved" ); - self.project.background.crop = Some(crop); + if let Some(target) = state.style_target { + if !target.apply(&mut self.project, crop) { + return; + } + } else { + self.project.background.crop = Some(crop); + } self.project_changed(window, cx); window.refresh(); } @@ -1571,15 +1616,26 @@ impl EditorWindow { return; }; let mut config = self.project.clone(); + let mut time = self.preview_or_playhead(); if let Some(state) = &self.crop { let bounds = state.real(); - config.background.crop = Some(Crop { + let crop = Crop { position: XY::new(bounds.x.max(0.) as u32, bounds.y.max(0.) as u32), - size: XY::new(bounds.width.max(0.) as u32, bounds.height.max(0.) as u32), - }); + size: XY::new(bounds.width.max(1.) as u32, bounds.height.max(1.) as u32), + }; + if let Some(target) = &state.style_target { + if !target.apply(&mut config, crop) { + return; + } + time = target.time; + } else { + config.background.crop = Some(crop); + if let Some(timeline) = config.timeline.as_mut() { + timeline.style_segments.clear(); + } + } } instance.project_config.0.send(config).ok(); - let time = self.preview_or_playhead(); crate::editor_window::request_frame( instance, (time * f64::from(EDITOR_PREVIEW_FPS)).floor() as u32, @@ -2248,6 +2304,8 @@ impl EditorWindow { .border_color(Hsla::from(theme.gray_3)) .bg(Hsla::from(theme.gray_1)) .overflow_hidden() + .children(state.style_target.as_ref().map(|target| div().px(px(20.)).pt(px(16.)).text_size(px(12.)).child(if target.opting_in { format!("Style {} only · Saving enables its background override. Global settings stay unchanged.",target.index+1) } else { format!("Editing Style {} only · Global settings stay unchanged.",target.index+1) }))) + .children(state.error.as_ref().map(|error| div().p(px(16.)).child(error.clone()))) .child(self.render_crop_header(state, cx)) .child(self.render_crop_body(state, cx)) .child(self.render_crop_footer(cx)), @@ -2653,7 +2711,7 @@ impl EditorWindow { Some(frame) => Some( gpui::canvas( |bounds, _window, _cx| bounds, - move |_, bounds, window, _cx| frame.paint(bounds, window), + move |_, bounds, window, _cx| frame.paint(bounds, px(0.), window), ) .w(px(fitted_w)) .h(px(fitted_h)) @@ -2701,7 +2759,7 @@ impl EditorWindow { let menu = state.menu.as_ref()?; let items = crop_menu_items(state.aspect, self.crop_snap_to_ratio); Some( - ui::Menu::plain(&self.theme, "crop-menu", items, menu) + ui::Menu::editor(&self.theme, "crop-menu", items, menu) .on_select(cx.listener(|this, index: &usize, window, cx| { this.choose_crop_menu(*index, window, cx); })) @@ -3425,3 +3483,100 @@ mod tests { assert!(crop_menu_choice(10).is_none()); } } + +struct StyleCropTarget { + index: usize, + fingerprint: String, + background: cap_project::BackgroundConfiguration, + opting_in: bool, + time: f64, +} + +impl StyleCropTarget { + fn apply(&self, project: &mut cap_project::ProjectConfiguration, crop: Crop) -> bool { + if !self.matches(project) { + return false; + } + let Some(segment) = project + .timeline + .as_mut() + .and_then(|timeline| timeline.style_segments.get_mut(self.index)) + else { + return false; + }; + let mut background = self.background.clone(); + background.crop = Some(crop); + segment.overrides.background = Some(background); + true + } + + fn matches(&self, project: &cap_project::ProjectConfiguration) -> bool { + project + .timeline + .as_ref() + .and_then(|timeline| timeline.style_segments.get(self.index)) + .and_then(|segment| serde_json::to_string(segment).ok()) + .is_some_and(|value| value == self.fingerprint) + } +} + +#[cfg(test)] +mod style_image_tests { + use super::*; + + #[test] + fn style_image_crop_preview_and_save_preserve_base_and_guard_reordering() { + let mut project: cap_project::ProjectConfiguration = serde_json::from_value(serde_json::json!({"timeline":{"zoomSegments":[],"segments":[],"styleSegments":[{"start":1,"end":5,"name":"A"},{"start":6,"end":9,"name":"B"}]}})).unwrap(); + let before = serde_json::to_value(&project).unwrap(); + let target = StyleCropTarget { + index: 0, + fingerprint: serde_json::to_string( + &project.timeline.as_ref().unwrap().style_segments[0], + ) + .unwrap(), + background: project.background.clone(), + opting_in: true, + time: 2., + }; + let crop = Crop { + position: XY::new(100, 50), + size: XY::new(800, 600), + }; + let mut preview = project.clone(); + assert!(target.apply(&mut preview, crop.clone())); + assert_eq!(serde_json::to_value(&project).unwrap(), before); + assert_eq!( + serde_json::to_value(&preview.background).unwrap(), + serde_json::to_value(&project.background).unwrap() + ); + assert_eq!( + serde_json::to_value(&preview.style_at(2.).background.crop).unwrap(), + serde_json::to_value(&Some(crop.clone())).unwrap() + ); + assert!(target.apply(&mut project, crop)); + assert_eq!( + serde_json::to_value(&project.background).unwrap(), + serde_json::to_value(&preview.background).unwrap() + ); + assert!( + project.timeline.as_ref().unwrap().style_segments[1] + .overrides + .background + .is_none() + ); + project.timeline.as_mut().unwrap().style_segments.swap(0, 1); + assert!(!target.apply( + &mut project, + Crop { + position: XY::new(0, 0), + size: XY::new(10, 10) + } + )); + assert!( + project.timeline.as_ref().unwrap().style_segments[0] + .overrides + .background + .is_none() + ); + } +} diff --git a/apps/desktop-gpui/src/editor_edits.rs b/apps/desktop-gpui/src/editor_edits.rs index 5cf07c93377..ca9f0c598c7 100644 --- a/apps/desktop-gpui/src/editor_edits.rs +++ b/apps/desktop-gpui/src/editor_edits.rs @@ -28,8 +28,8 @@ use cap_project::{ AudioTrackSegment, Camera3DProperties, Camera3DSegment, CaptionTrackSegment, ClipSpeedAudioMode, CursorClickEvent, GlideDirection, KeyboardTrackSegment, MaskKind, - MaskSegment, ProjectConfiguration, SceneMode, SceneSegment, TextSegment, TimelineConfiguration, - TimelineSegment, XY, ZoomMode, ZoomSegment, mask_effect_contract, + MaskSegment, OverlayTrack, ProjectConfiguration, SceneMode, SceneSegment, TextSegment, + TimelineConfiguration, TimelineSegment, XY, ZoomMode, ZoomSegment, mask_effect_contract, }; use crate::editor_timeline::{self, Segment, TrackKind}; @@ -358,7 +358,7 @@ pub fn min_segment_duration(kind: TrackKind, secs_per_pixel: f64) -> f64 { TrackKind::Zoom => (1., 40.), TrackKind::Scene => (1., 80.), TrackKind::ThreeD => (1., 40.), - TrackKind::Text => (1., 80.), + TrackKind::Text | TrackKind::Style | TrackKind::Image => (1., 80.), TrackKind::Mask => (1., 80.), TrackKind::Audio => (0.5, 60.), TrackKind::Caption => (0.5, 40.), @@ -512,6 +512,8 @@ impl_track_segment!(SceneSegment); impl_track_segment!(Camera3DSegment); impl_track_segment!(MaskSegment, lane: track); impl_track_segment!(TextSegment, lane: track); +impl_track_segment!(cap_project::StyleSegment, lane: track); +impl_track_segment!(cap_project::ImageSegment, lane: track); impl TrackSegmentOps for CaptionTrackSegment { fn start(&self) -> f64 { @@ -704,6 +706,14 @@ macro_rules! with_track { let $segments = &mut $timeline.camera3d_segments; $body } + TrackKind::Style => { + let $segments = &mut $timeline.style_segments; + $body + } + TrackKind::Image => { + let $segments = &mut $timeline.image_segments; + $body + } TrackKind::Text => { let $segments = &mut $timeline.text_segments; $body @@ -737,6 +747,8 @@ pub fn segment_count(timeline: &TimelineConfiguration, kind: TrackKind) -> usize TrackKind::Zoom => timeline.zoom_segments.len(), TrackKind::Scene => timeline.scene_segments.len(), TrackKind::ThreeD => timeline.camera3d_segments.len(), + TrackKind::Style => timeline.style_segments.len(), + TrackKind::Image => timeline.image_segments.len(), TrackKind::Text => timeline.text_segments.len(), TrackKind::Mask => timeline.mask_segments.len(), TrackKind::Audio => timeline.audio_segments.len(), @@ -763,7 +775,9 @@ pub fn set_segment_start( return false; } segment.set_start(start); - sort_track(segments); + if !matches!(kind, TrackKind::Style | TrackKind::Image) { + sort_track(segments); + } true }) } @@ -783,7 +797,9 @@ pub fn set_segment_end( return false; } segment.set_end(end); - sort_track(segments); + if !matches!(kind, TrackKind::Style | TrackKind::Image) { + sort_track(segments); + } true }) } @@ -812,8 +828,8 @@ pub fn move_segment( }) } -/// `delete*Segments(indices)` for the eight non-clip tracks. The three -/// multi-lane ones renormalise their lanes afterwards; the others do not +/// `delete*Segments(indices)` for the eight non-clip tracks. Style and audio +/// renormalise their lanes afterwards; the others do not /// (`ED/context.ts:781-799` vs `:623-639`). pub fn delete_segments( timeline: &mut TimelineConfiguration, @@ -821,20 +837,16 @@ pub fn delete_segments( indices: &[usize], ) -> bool { match kind { - TrackKind::Mask => { - let deleted = delete_indices(&mut timeline.mask_segments, indices); - normalize_track(&mut timeline.mask_segments, |segment, lane| { - segment.track = lane - }); - deleted - } - TrackKind::Text => { - let deleted = delete_indices(&mut timeline.text_segments, indices); - normalize_track(&mut timeline.text_segments, |segment, lane| { + TrackKind::Image => delete_indices(&mut timeline.image_segments, indices), + TrackKind::Style => { + let deleted = delete_indices(&mut timeline.style_segments, indices); + normalize_track(&mut timeline.style_segments, |segment, lane| { segment.track = lane }); deleted } + TrackKind::Mask => delete_indices(&mut timeline.mask_segments, indices), + TrackKind::Text => delete_indices(&mut timeline.text_segments, indices), TrackKind::Audio => { let deleted = delete_indices(&mut timeline.audio_segments, indices); normalize_track(&mut timeline.audio_segments, |segment, lane| { @@ -873,6 +885,18 @@ pub fn delete_track_lane(timeline: &mut TimelineConfiguration, kind: TrackKind, changed } match kind { + TrackKind::Style => apply( + &mut timeline.style_segments, + lane, + |segment| segment.track, + |segment, value| segment.track = value, + ), + TrackKind::Image => apply( + &mut timeline.image_segments, + lane, + |segment| segment.track, + |segment, value| segment.track = value, + ), TrackKind::Text => apply( &mut timeline.text_segments, lane, @@ -895,6 +919,107 @@ pub fn delete_track_lane(timeline: &mut TimelineConfiguration, kind: TrackKind, } } +pub fn delete_track_lane_and_order( + project: &mut ProjectConfiguration, + available: &[OverlayTrack], + kind: TrackKind, + lane: u32, +) -> bool { + let next_order = kind.overlay_track(lane).map(|deleted| { + project + .resolved_overlay_order(available) + .into_iter() + .filter(|track| *track != deleted) + .map(|mut track| { + if track.kind == deleted.kind && track.track > deleted.track { + track.track -= 1; + } + track + }) + .collect::>() + }); + let timeline_changed = project + .timeline + .as_mut() + .is_some_and(|timeline| delete_track_lane(timeline, kind, lane)); + let order_changed = next_order.is_some_and(|order| { + if project.overlay_order == order { + false + } else { + project.overlay_order = order; + true + } + }); + timeline_changed || order_changed +} + +pub fn reorder_overlay_track( + project: &mut ProjectConfiguration, + available: &[OverlayTrack], + from: OverlayTrack, + target_index: usize, +) -> bool { + let mut order = project.resolved_overlay_order(available); + let Some(from_index) = order.iter().position(|track| *track == from) else { + return false; + }; + let moved = order.remove(from_index); + let target_index = target_index.min(order.len()); + order.insert(target_index, moved); + if order == project.resolved_overlay_order(available) { + return false; + } + project.overlay_order = order; + true +} + +pub fn reorder_track_lane( + timeline: &mut TimelineConfiguration, + kind: TrackKind, + from: u32, + to: u32, +) -> bool { + if from == to { + return false; + } + + fn apply( + segments: &mut [T], + from: u32, + to: u32, + set: impl Fn(&mut T, u32), + ) -> bool { + let mut changed = false; + for segment in segments { + let lane = segment.lane(); + let next = if lane == from { + to + } else if from < to && lane > from && lane <= to { + lane - 1 + } else if from > to && lane >= to && lane < from { + lane + 1 + } else { + lane + }; + if next != lane { + set(segment, next); + changed = true; + } + } + changed + } + + match kind { + TrackKind::Style => apply(&mut timeline.style_segments, from, to, |segment, lane| { + segment.track = lane + }), + TrackKind::Audio => apply(&mut timeline.audio_segments, from, to, |segment, lane| { + segment.track = lane + }), + _ => false, + } +} + /// `deleteClipSegment` (`ED/context.ts:581-600`), including the guard that /// makes it the one track a selection cannot empty: **the last clip cannot be /// deleted**. The Delete binding sorts descending and deletes one at a time @@ -1261,6 +1386,8 @@ fn ripple_delete_output_tracks( cut_end: f64, shift: f64, ) { + ripple_delete_track(&mut timeline.style_segments, cut_start, cut_end, shift); + ripple_delete_track(&mut timeline.image_segments, cut_start, cut_end, shift); ripple_delete_track(&mut timeline.zoom_segments, cut_start, cut_end, shift); ripple_delete_track(&mut timeline.scene_segments, cut_start, cut_end, shift); ripple_delete_camera3d_track(&mut timeline.camera3d_segments, cut_start, cut_end, shift); @@ -1638,6 +1765,8 @@ pub fn ensure_timeline(project: &mut ProjectConfiguration, clip_display_duration keyboard_segments: Vec::new(), audio_segments: Vec::new(), camera3d_segments: Vec::new(), + style_segments: Vec::new(), + image_segments: Vec::new(), }); true } @@ -2058,6 +2187,18 @@ pub fn snap_split_time( .iter() .map(|segment| (segment.start, segment.end)), ) + .chain( + timeline + .style_segments + .iter() + .map(|segment| (segment.start, segment.end)), + ) + .chain( + timeline + .image_segments + .iter() + .map(|segment| (segment.start, segment.end)), + ) .collect::>() { consider(start); @@ -2171,6 +2312,14 @@ pub fn set_clip_segment_timescale( ) }; + for segment in &mut timeline.style_segments { + segment.start += shift(segment.start); + segment.end += shift(segment.end); + } + for segment in &mut timeline.image_segments { + segment.start += shift(segment.start); + segment.end += shift(segment.end); + } for segment in &mut timeline.zoom_segments { segment.start += shift(segment.start); segment.end += shift(segment.end); @@ -3338,7 +3487,7 @@ mod tests { } })); let timeline = config.timeline.as_mut().unwrap(); - assert!(delete_segments(timeline, TrackKind::Mask, &[0])); + assert!(delete_track_lane(timeline, TrackKind::Mask, 0)); assert_eq!(timeline.mask_segments.len(), 1); assert_eq!( timeline.mask_segments[0].track, 0, @@ -3475,3 +3624,309 @@ mod tests { )); } } + +pub fn insert_style_segment( + timeline: &mut TimelineConfiguration, + segment: cap_project::StyleSegment, +) -> usize { + let start = segment.start; + let track = segment.track; + timeline.style_segments.push(segment); + sort_lane_segments(&mut timeline.style_segments); + timeline + .style_segments + .iter() + .rposition(|item| item.start == start && item.track == track) + .unwrap_or(0) +} + +pub fn insert_image_segment( + timeline: &mut TimelineConfiguration, + segment: cap_project::ImageSegment, +) -> usize { + let start = segment.start; + let track = segment.track; + timeline.image_segments.push(segment); + sort_lane_segments(&mut timeline.image_segments); + timeline + .image_segments + .iter() + .rposition(|item| item.start == start && item.track == track) + .unwrap_or(0) +} + +#[cfg(test)] +mod style_image_tests { + use super::*; + + fn project() -> ProjectConfiguration { + serde_json::from_value(serde_json::json!({"timeline": {"zoomSegments":[], + "segments": [{"start":0,"end":20,"timescale":1}], + "styleSegments": [{"start":2,"end":8,"track":0,"name":"First"}, {"start":1,"end":6,"track":1,"name":"Second"}], + "imageSegments": [{"start":2,"end":8,"track":0,"path":"content/images/retained.png","rotation":35,"flipX":true}] + }})).unwrap() + } + + #[test] + fn style_image_edit_split_delete_and_history_preserve_assets_and_overrides() { + let mut project = project(); + let mut history = ProjectHistory::new(project.clone()); + history.pause(); + for kind in [TrackKind::Style, TrackKind::Image] { + let timeline = project.timeline.as_mut().unwrap(); + assert!(move_segment(timeline, kind, 0, 3., 9.)); + assert!(set_segment_start(timeline, kind, 0, 4.)); + assert!(set_segment_end(timeline, kind, 0, 10.)); + history.record(&project); + } + history.resume(&project); + assert_eq!(history.depth(), 2); + assert_eq!( + history + .undo() + .unwrap() + .timeline + .as_ref() + .unwrap() + .image_segments[0] + .start, + 2. + ); + project = history.redo().unwrap().clone(); + let timeline = project.timeline.as_mut().unwrap(); + for kind in [TrackKind::Style, TrackKind::Image] { + assert!(split_segment(timeline, kind, 0, 3.)); + assert!(!split_segment(timeline, kind, 0, 0.1)); + } + assert_eq!(timeline.style_segments[1].end, 10.); + assert_eq!(timeline.style_segments[2].name, "Second"); + assert!(timeline.style_segments[0].overrides.background.is_none()); + assert_eq!( + timeline.image_segments[1].path, + "content/images/retained.png" + ); + assert_eq!(timeline.image_segments[1].rotation, 35.); + assert!(timeline.image_segments[1].flip_x); + history.record(&project); + assert!(delete_segments( + project.timeline.as_mut().unwrap(), + TrackKind::Image, + &[0, 1] + )); + history.record(&project); + let restored = history.undo().unwrap().timeline.as_ref().unwrap(); + assert_eq!(restored.image_segments.len(), 2); + assert_eq!( + restored.image_segments[0].path, + "content/images/retained.png" + ); + } + + #[test] + fn style_image_trim_keeps_unsorted_loaded_indices_and_delete_normalizes_lanes() { + let mut project = project(); + let timeline = project.timeline.as_mut().unwrap(); + timeline.style_segments.swap(0, 1); + assert!(set_segment_start(timeline, TrackKind::Style, 0, 1.5)); + assert_eq!(timeline.style_segments[0].name, "Second"); + assert!(delete_track_lane(timeline, TrackKind::Style, 0)); + assert_eq!(timeline.style_segments[0].track, 0); + assert_eq!(timeline.style_segments[0].name, "Second"); + } + + #[test] + fn lane_reorder_moves_segments_without_changing_config_indices() { + let mut project = project(); + let timeline = project.timeline.as_mut().unwrap(); + timeline.style_segments.push(cap_project::StyleSegment { + start: 0., + end: 10., + track: 2, + ..Default::default() + }); + let names = timeline + .style_segments + .iter() + .map(|segment| segment.name.clone()) + .collect::>(); + + assert!(reorder_track_lane(timeline, TrackKind::Style, 2, 0)); + assert_eq!( + timeline + .style_segments + .iter() + .map(|segment| segment.track) + .collect::>(), + vec![1, 2, 0] + ); + assert_eq!( + timeline + .style_segments + .iter() + .map(|segment| segment.name.clone()) + .collect::>(), + names + ); + } + + #[test] + fn lane_reorder_from_empty_lane_still_reports_sibling_shift() { + let mut project = project(); + let timeline = project.timeline.as_mut().unwrap(); + + assert!(reorder_track_lane(timeline, TrackKind::Style, 2, 0)); + assert_eq!( + timeline + .style_segments + .iter() + .map(|segment| segment.track) + .collect::>(), + vec![1, 2] + ); + } + + #[test] + fn visual_segment_delete_preserves_lane_identity() { + let mut project = project(); + let timeline = project.timeline.as_mut().unwrap(); + let mut second = timeline.image_segments[0].clone(); + second.track = 2; + timeline.image_segments.push(second); + assert!(delete_segments(timeline, TrackKind::Image, &[0])); + assert_eq!(timeline.image_segments[0].track, 2); + } + + #[test] + fn overlay_reorder_and_lane_delete_update_only_saved_order() { + let mut project = project(); + let text = cap_project::OverlayTrack { + kind: cap_project::OverlayTrackKind::Text, + track: 0, + }; + let image = cap_project::OverlayTrack { + kind: cap_project::OverlayTrackKind::Image, + track: 2, + }; + let mask = cap_project::OverlayTrack { + kind: cap_project::OverlayTrackKind::Mask, + track: 0, + }; + let available = [text, image, mask]; + project.timeline.as_mut().unwrap().image_segments[0].track = 2; + + assert!(reorder_overlay_track(&mut project, &available, image, 0)); + assert_eq!(project.overlay_order, [image, text, mask]); + assert!(delete_track_lane_and_order( + &mut project, + &available, + TrackKind::Image, + 1, + )); + assert_eq!( + project.timeline.as_ref().unwrap().image_segments[0].track, + 1 + ); + assert_eq!( + project.overlay_order, + [ + cap_project::OverlayTrack { + kind: cap_project::OverlayTrackKind::Image, + track: 1, + }, + text, + mask + ] + ); + } + + #[test] + fn style_image_speed_ripple_and_serialization_keep_both_tracks() { + let mut project = project(); + let timeline = project.timeline.as_mut().unwrap(); + assert!(set_clip_segment_timescale(timeline, 0, 2.)); + assert_eq!( + ( + timeline.style_segments[0].start, + timeline.style_segments[0].end + ), + (1., 4.) + ); + assert_eq!( + ( + timeline.image_segments[0].start, + timeline.image_segments[0].end + ), + (1., 4.) + ); + let json = serde_json::to_value(&project).unwrap(); + assert!(json["timeline"]["styleSegments"][0]["overrides"]["cameraOnlyPadding"].is_null()); + assert_eq!(json["timeline"]["imageSegments"][0]["lockAspect"], true); + let restored: ProjectConfiguration = serde_json::from_value(json).unwrap(); + assert_eq!( + restored.timeline.unwrap().image_segments[0].path, + "content/images/retained.png" + ); + } +} + +pub(crate) fn replace_image_asset( + project: &mut ProjectConfiguration, + index: usize, + fingerprint: &str, + path: String, + name: String, +) -> bool { + let Some(segment) = project + .timeline + .as_mut() + .and_then(|timeline| timeline.image_segments.get_mut(index)) + else { + return false; + }; + if serde_json::to_string(segment).ok().as_deref() != Some(fingerprint) { + return false; + } + segment.path = path; + segment.name = name; + true +} + +#[cfg(test)] +mod style_image_replacement_tests { + use super::*; + #[test] + fn style_image_replace_preserves_geometry_and_history_rejects_stale_target() { + let mut project: ProjectConfiguration = serde_json::from_value(serde_json::json!({"timeline":{"zoomSegments":[],"segments":[],"imageSegments":[{"start":2,"end":8,"track":3,"path":"content/images/old.png","name":"Old","center":{"x":0.3,"y":0.7},"size":{"x":0.2,"y":0.4},"rotation":35,"flipX":true,"opacity":0.6}]}})).unwrap(); + let before = serde_json::to_value(&project).unwrap(); + let mut history = ProjectHistory::new(project.clone()); + let fingerprint = + serde_json::to_string(&project.timeline.as_ref().unwrap().image_segments[0]).unwrap(); + assert!(replace_image_asset( + &mut project, + 0, + &fingerprint, + "content/images/new.gif".into(), + "New".into() + )); + history.record(&project); + let mut expected = before.clone(); + expected["timeline"]["imageSegments"][0]["path"] = "content/images/new.gif".into(); + expected["timeline"]["imageSegments"][0]["name"] = "New".into(); + assert_eq!(serde_json::to_value(&project).unwrap(), expected); + assert!(!replace_image_asset( + &mut project, + 0, + &fingerprint, + "stale.png".into(), + "Stale".into() + )); + assert_eq!( + serde_json::to_value(history.undo().unwrap()).unwrap(), + before + ); + assert_eq!( + serde_json::to_value(history.redo().unwrap()).unwrap(), + expected + ); + } +} diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 908904dd8a9..677caf82558 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -8,7 +8,7 @@ use std::time::Duration; use cap_export::gif::GifExportSettings; use cap_export::mov::MovExportSettings; use cap_export::mp4::{ExportCompression, Mp4ExportSettings}; -use cap_export::preview::{ExportPreviewSettings, render_preview}; +use cap_export::preview::{ExportPreviewSettings, render_preview_with_config}; use cap_export::{ExporterBase, make_cursor_only_project}; use cap_project::{BackgroundSource, RecordingMeta, XY}; use gpui::{ @@ -176,6 +176,7 @@ pub struct ExportUi { pub preview_stats: Option, pub preview_error: Option, pub preview_task: Option>, + preview_request: Arc<()>, pub phase: ExportPhase, close_requested: bool, pub rendered: u32, @@ -231,6 +232,7 @@ impl ExportUi { preview_stats: None, preview_error: None, preview_task: None, + preview_request: Arc::new(()), phase: ExportPhase::Idle, close_requested: false, rendered: 0, @@ -248,6 +250,14 @@ impl ExportUi { } } + fn update_preview(&mut self, request: &Arc<()>, update: impl FnOnce(&mut Self)) -> bool { + if !Arc::ptr_eq(&self.preview_request, request) { + return false; + } + update(self); + true + } + fn persist(&self) { let prefs = ExportPrefs { format: self.format.slug().to_string(), @@ -472,6 +482,7 @@ impl EditorWindow { let Some(ui) = self.export.as_mut() else { return; }; + let project = self.project.clone(); let (width, height) = ui.resolution.size(); let settings = ExportPreviewSettings { fps: ui.fps, @@ -482,12 +493,14 @@ impl EditorWindow { // Match Windows editor playback: a fresh Media Foundation preview seek can return black. let force = cfg!(target_os = "windows") || ui.force_ffmpeg; ui.preview_error = None; + let request = Arc::new(()); + ui.preview_request = request.clone(); ui.preview_task = Some(cx.spawn_in(window, async move |this, cx| { cx.background_executor() .timer(Duration::from_millis(120)) .await; let result = gpui_tokio::Tokio::spawn(cx, async move { - render_preview(path, time, settings, force).await + render_preview_with_config(path, project, time, settings, force).await }) .await .ok(); @@ -495,7 +508,7 @@ impl EditorWindow { let Some(ui) = this.export.as_mut() else { return; }; - match result { + let updated = ui.update_preview(&request, |ui| match result { Some(Ok(preview)) => { let bytes = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, @@ -522,6 +535,9 @@ impl EditorWindow { None => { ui.preview_error = Some("Preview unavailable".into()); } + }); + if !updated { + return; } cx.notify(); window.refresh(); @@ -590,12 +606,24 @@ impl EditorWindow { "mp4" }; let default = format!("{pretty_name}.{ext}"); - let chosen = std::env::var_os("CAP_GPUI_AUTO_EXPORT") - .map(PathBuf::from) - .or_else(|| platform::save_file_panel(&default, &[ext])); - if chosen.is_none() { + let chosen: Result, String> = match std::env::var_os("CAP_GPUI_AUTO_EXPORT") { + Some(path) => Ok(Some(PathBuf::from(path))), + None => { + #[cfg(target_os = "macos")] + { + platform::try_save_file_panel(&default, &[ext]) + } + #[cfg(not(target_os = "macos"))] + { + Ok(platform::save_file_panel_async(&default, &[ext], cx).await) + } + } + }; + if matches!(chosen, Ok(None)) { let _ = this.update(cx, |this, cx| { - if let Some(ui) = this.export.as_mut() { + if let Some(ui) = this.export.as_mut() + && Arc::ptr_eq(&ui.cancel, &cancel) + { ui.phase = ExportPhase::Idle; } cx.notify(); @@ -605,15 +633,24 @@ impl EditorWindow { }); return; } - chosen + match chosen { + Ok(path) => path, + Err(error) => { + tracing::warn!(error, "Save dialog unavailable; keeping the export in its project output folder"); + None + } + } } else { None }; - let started = this.update(cx, |this, cx| { + let started = this.update_in(cx, |this, _, cx| { let Some(ui) = this.export.as_mut() else { return false; }; + if !Arc::ptr_eq(&ui.cancel, &cancel) { + return false; + } if cancel.load(Ordering::Relaxed) { ui.phase = ExportPhase::Idle; cx.notify(); @@ -820,7 +857,7 @@ impl EditorWindow { let upgraded = store::auth_snapshot().is_upgraded(); if !upgraded && duration >= 300.0 { - cx.open_url(&format!("{}/pricing", crate::auth::server_url())); + cx.open_url(crate::auth::PRICING_URL); return; } @@ -983,7 +1020,7 @@ impl EditorWindow { cx.notify(); }); let _ = this.update(cx, |_, cx| { - cx.open_url(&format!("{}/pricing", crate::auth::server_url())); + cx.open_url(crate::auth::PRICING_URL); }); platform::alert_dialog( "Upgrade required", @@ -1982,6 +2019,26 @@ impl EditorWindow { return; }; let cancel = ui.cancel.clone(); + #[cfg(target_os = "linux")] + { + if !ui.phase.is_busy() { + return; + } + let response = crate::editor_modal::confirm_cancel_export(window, cx); + cx.spawn_in(window, async move |this, cx| { + let confirmed = response.await; + let _ = this.update_in(cx, |this, _, cx| { + if let Some(ui) = this.export.as_mut() + && ui.phase.is_busy() + { + cancel_matching_export(&ui.cancel, &cancel, confirmed); + } + cx.notify(); + }); + }) + .detach(); + } + #[cfg(not(target_os = "linux"))] cx.spawn_in(window, async move |this, cx| { let confirmed = platform::confirm_dialog( "Cancel export?", @@ -2263,6 +2320,21 @@ mod tests { }) } + #[test] + fn stale_preview_results_cannot_replace_the_latest_or_reopened_preview() { + let mut ui = clipboard_export(); + let previous = ui.preview_request.clone(); + let current = Arc::new(()); + ui.preview_request = current.clone(); + assert!(ui.update_preview(¤t, |ui| ui.preview_error = Some("Latest".into()))); + assert!(!ui.update_preview(&previous, |ui| ui.preview_error = Some("Stale".into()))); + assert_eq!(ui.preview_error.as_deref(), Some("Latest")); + + let mut reopened = clipboard_export(); + assert!(!reopened.update_preview(¤t, |ui| ui.preview_error = Some("Closed".into()))); + assert!(reopened.preview_error.is_none()); + } + #[test] fn failed_clipboard_copy_retries_the_completed_file() { let mut ui = clipboard_export(); diff --git a/apps/desktop-gpui/src/editor_modal.rs b/apps/desktop-gpui/src/editor_modal.rs new file mode 100644 index 00000000000..245bbef6254 --- /dev/null +++ b/apps/desktop-gpui/src/editor_modal.rs @@ -0,0 +1,347 @@ +use std::future::Future; + +use gpui::{ + App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, FontWeight, Hsla, + InteractiveElement, IntoElement, KeystrokeEvent, MouseButton, ParentElement, PromptButton, + PromptResponse, Render, ScrollHandle, StatefulInteractiveElement, Styled, Subscription, Window, + div, prelude::FluentBuilder, px, +}; + +use crate::{theme::Theme, ui}; + +#[derive(Clone, Copy)] +enum MessageKind { + Retained, + Confirmation, +} + +pub(crate) fn retained_alert( + message: &str, + window: &mut Window, + cx: &mut App, +) -> impl Future + use<> { + let response = informational_alert("Recording retained", message, window, cx); + async move { + let _ = response.await; + } +} + +pub(crate) fn informational_alert( + title: &str, + message: &str, + window: &mut Window, + cx: &mut App, +) -> impl Future + use<> { + let response = request( + MessageKind::Retained, + title, + message, + vec![PromptButton::ok("OK")], + window, + cx, + ); + async move { response.await == Some(0) } +} + +pub(crate) fn confirm_cancel_export( + window: &mut Window, + cx: &mut App, +) -> impl Future + use<> { + confirm_action( + "Cancel export?", + "Are you sure you want to cancel the export?", + "Cancel export", + "Keep exporting", + window, + cx, + ) +} + +pub(crate) fn confirm_action( + title: &str, + message: &str, + accept: &str, + cancel: &str, + window: &mut Window, + cx: &mut App, +) -> impl Future + use<> { + let response = request( + MessageKind::Confirmation, + title, + message, + vec![ + PromptButton::ok(accept.to_string()), + PromptButton::cancel(cancel.to_string()), + ], + window, + cx, + ); + async move { response.await == Some(0) } +} + +fn request( + kind: MessageKind, + message: &str, + detail: &str, + actions: Vec, + window: &mut Window, + cx: &mut App, +) -> impl Future> + use<> { + let response = if window.has_active_prompt() { + None + } else { + // Cap has no other custom prompt builder. Restore Default before returning; + // this App borrow must not await, reenter, or invoke another prompt. + cx.set_prompt_builder(move |_, message, detail, actions, handle, window, cx| { + let owner = window.window_handle().window_id(); + let modal = cx.new(|cx: &mut Context| { + let weak = cx.entity().downgrade(); + let keyboard = cx.intercept_keystrokes(move |event, window, cx| { + if window.window_handle().window_id() == owner { + let _ = weak.update(cx, |modal, cx| modal.intercept(event, window, cx)); + } + }); + let interaction = Interaction::new(kind); + let scroll = ScrollHandle::new(); + EditorModal { + message: message.to_string(), + detail: detail.unwrap_or_default().to_string(), + actions: actions.to_vec(), + focus: cx.focus_handle(), + interaction, + scroll, + _keyboard: keyboard, + } + }); + handle.with_view(modal, window, cx) + }); + let response = window.prompt( + gpui::PromptLevel::Warning, + message, + Some(detail), + &actions, + cx, + ); + cx.reset_prompt_builder(); + Some(response) + }; + async move { + match response { + Some(response) => response.await.ok(), + None => None, + } + } +} + +struct Interaction { + selected: usize, + cancel: usize, + count: usize, + completed: bool, +} + +impl Interaction { + fn new(kind: MessageKind) -> Self { + let cancel = usize::from(matches!(kind, MessageKind::Confirmation)); + Self { + selected: cancel, + cancel, + count: cancel + 1, + completed: false, + } + } + + fn select(&mut self, index: usize) -> Option { + if self.completed || index >= self.count { + return None; + } + self.completed = true; + Some(index) + } + + fn key(&mut self, key: &str, shift: bool) -> Option { + if self.completed { + return None; + } + match key { + "escape" => self.select(self.cancel), + "enter" | "space" => self.select(self.selected), + "tab" => { + self.selected = if shift { + (self.selected + self.count - 1) % self.count + } else { + (self.selected + 1) % self.count + }; + None + } + _ => None, + } + } +} + +struct EditorModal { + message: String, + detail: String, + actions: Vec, + focus: FocusHandle, + interaction: Interaction, + scroll: ScrollHandle, + _keyboard: Subscription, +} + +impl EditorModal { + fn intercept(&mut self, event: &KeystrokeEvent, window: &mut Window, cx: &mut Context) { + if self.interaction.completed + || !(self.focus.is_focused(window) || self.focus.contains_focused(window, cx)) + { + return; + } + // GPUI dispatches key bindings before element key handlers. The owner-scoped + // interceptor consumes the keystroke before an editor or global action can run. + window.prevent_default(); + cx.stop_propagation(); + let modifiers = event.keystroke.modifiers; + if !modifiers.control && !modifiers.alt && !modifiers.platform && !modifiers.function { + if let Some(index) = self.interaction.key(&event.keystroke.key, modifiers.shift) { + cx.emit(PromptResponse(index)); + } + cx.notify(); + } + } +} + +impl Render for EditorModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::for_window(window, cx, false); + let viewport = window.viewport_size(); + let width = (f32::from(viewport.width) - 24.).clamp(0., 440.); + let height = (f32::from(viewport.height) - 24.).max(0.); + let padding = (width / 10.).min(if height < 200. { 8. } else { 16. }); + + div() + .id("editor-modal-backdrop") + .absolute() + .top_0() + .left_0() + .size_full() + .occlude() + .track_focus(&self.focus) + .flex() + .items_center() + .justify_center() + .bg(gpui::hsla(0., 0., 0., 0.5)) + .on_any_mouse_down(|_, _, cx| cx.stop_propagation()) + .map(|this| { + MouseButton::all().into_iter().fold(this, |this, button| { + this.on_mouse_up(button, |_, _, cx| cx.stop_propagation()) + }) + }) + .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) + .on_key_down(|_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .child( + div() + .id("editor-modal-card") + .occlude() + .w(px(width)) + .max_h(px(height)) + .min_w(px(0.)) + .overflow_hidden() + .on_any_mouse_down(|_, _, cx| cx.stop_propagation()) + .map(|this| { + MouseButton::all().into_iter().fold(this, |this, button| { + this.on_mouse_up(button, |_, _, cx| cx.stop_propagation()) + }) + }) + .flex() + .flex_col() + .gap(px(padding.min(12.))) + .p(px(padding)) + .rounded(px(12.)) + .border_1() + .border_color(Hsla::from(theme.gray_3)) + .bg(Hsla::from(theme.gray_1)) + .text_color(Hsla::from(theme.gray_12)) + .shadow_lg() + .child( + div() + .id("editor-modal-message") + .min_h(px(0.)) + .flex_shrink_1() + .overflow_y_scroll() + .track_scroll(&self.scroll) + .flex() + .flex_col() + .gap(px(padding.min(12.))) + .child( + div() + .flex_shrink_0() + .text_size(px(15.)) + .font_weight(FontWeight::SEMIBOLD) + .child(self.message.clone()), + ) + .child( + div() + .flex_shrink_0() + .text_size(px(13.)) + .text_color(Hsla::from(theme.gray_11)) + .child(self.detail.clone()), + ), + ) + .child( + div() + .id("editor-modal-actions") + .flex() + .flex_shrink_0() + .gap(px(8.)) + .children(self.actions.iter().enumerate().map(|(index, action)| { + div() + .id(("editor-modal-action", index)) + .flex_1() + .min_w(px(0.)) + .rounded(px(10.)) + .p(px(2.)) + .border_1() + .border_color(Hsla::from(theme.gray_3)) + .when(self.interaction.selected == index, |this| { + this.border_color(Hsla::from(theme.gray_12)) + }) + .child( + ui::Button::plain( + &theme, + ("editor-modal-button", index), + if action.is_cancel() { + ui::ButtonVariant::Gray + } else { + ui::ButtonVariant::Primary + }, + ui::ButtonSize::Md, + ) + .label(action.label().clone()) + .radius(px(8.)) + .full_width() + .on_click( + cx.listener(move |modal, _, _, cx| { + if let Some(index) = modal.interaction.select(index) + { + cx.emit(PromptResponse(index)); + } + cx.stop_propagation(); + }), + ), + ) + })), + ), + ) + } +} + +impl EventEmitter for EditorModal {} + +impl Focusable for EditorModal { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} diff --git a/apps/desktop-gpui/src/editor_panels.rs b/apps/desktop-gpui/src/editor_panels.rs index abafeb5b1c4..71a3951cb69 100644 --- a/apps/desktop-gpui/src/editor_panels.rs +++ b/apps/desktop-gpui/src/editor_panels.rs @@ -1762,6 +1762,8 @@ pub fn mask_effect_amount(segment: &MaskSegment) -> f64 { /// panel per segment and each row needs its own track rect. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PanelSlider { + Image(ImageProperty), + StyleCameraOnlyPadding, ZoomAmount, /// The multi-zoom panel's single Amount slider, which writes every selected /// segment at once. @@ -1804,6 +1806,9 @@ pub enum PanelSlider { /// Window` and the sidebar's render chain is threaded with `&self`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FieldKey { + StyleName(usize), + ImageName(usize), + StyleCrop(usize, u8), /// `HexColorInput`s, which live on the sidebar's `ColorTarget` map and are /// listed here only so a tab can name one. CaptionColor, @@ -1912,6 +1917,28 @@ impl EditorWindow { } let timeline = self.project.timeline.as_ref()?; Some(match key { + FieldKey::StyleName(index) => timeline.style_segments.get(index)?.name.clone(), + FieldKey::ImageName(index) => timeline.image_segments.get(index)?.name.clone(), + FieldKey::StyleCrop(index, axis) => { + let background = timeline + .style_segments + .get(index)? + .overrides + .background + .as_ref()?; + let (width, height) = self.display_resolution()?; + let crop = background.crop.clone().unwrap_or(cap_project::Crop { + position: XY::new(0, 0), + size: XY::new(width, height), + }); + match axis { + 0 => crop.position.x, + 1 => crop.position.y, + 2 => crop.size.x, + _ => crop.size.y, + } + .to_string() + } FieldKey::TextContent(index) => timeline.text_segments.get(index)?.content.clone(), FieldKey::CaptionText(index) => timeline.caption_segments.get(index)?.text.clone(), FieldKey::AudioName(index) => timeline @@ -2031,6 +2058,71 @@ impl EditorWindow { }; let text = input.read(cx).text().to_string(); match key { + FieldKey::StyleName(index) | FieldKey::ImageName(index) => { + let style = matches!(key, FieldKey::StyleName(_)); + self.edit_project("segment-name", window, cx, move |project| { + let Some(timeline) = project.timeline.as_mut() else { + return false; + }; + let name = if style { + timeline + .style_segments + .get_mut(index) + .map(|segment| &mut segment.name) + } else { + timeline + .image_segments + .get_mut(index) + .map(|segment| &mut segment.name) + }; + let Some(name) = name else { + return false; + }; + if *name == text { + return false; + } + *name = text; + true + }); + } + FieldKey::StyleCrop(index, axis) => { + if !final_commit { + return; + } + let Some(value) = ui::parse_number(&text).filter(|value| value.is_finite()) else { + return; + }; + let Some((width, height)) = self.display_resolution() else { + return; + }; + self.edit_style_segment("style-crop", index, window, cx, move |segment| { + let Some(background) = segment.overrides.background.as_mut() else { + return false; + }; + let crop = background.crop.get_or_insert(cap_project::Crop { + position: XY::new(0, 0), + size: XY::new(width, height), + }); + let value = value.max(0.) as u32; + match axis { + 0 => crop.position.x = value.min(width.saturating_sub(1)), + 1 => crop.position.y = value.min(height.saturating_sub(1)), + 2 => crop.size.x = value.max(1), + _ => crop.size.y = value.max(1), + } + crop.size.x = crop + .size + .x + .min(width.saturating_sub(crop.position.x)) + .max(1); + crop.size.y = crop + .size + .y + .min(height.saturating_sub(crop.position.y)) + .max(1); + true + }); + } // `onRawValueChange={(v) => cropperRef?.setCropProperty(field, v)}` // -- per keystroke, straight into the cropper, no project write // and so no history entry (`Editor.tsx:1186`). @@ -2225,6 +2317,16 @@ macro_rules! segment_editor { } segment_editor!(edit_text_segment, text_segments, TextSegment); +segment_editor!( + edit_style_segment, + style_segments, + cap_project::StyleSegment +); +segment_editor!( + edit_image_segment, + image_segments, + cap_project::ImageSegment +); segment_editor!(edit_audio_segment, audio_segments, AudioTrackSegment); impl EditorWindow { @@ -2285,6 +2387,8 @@ impl EditorWindow { pub(crate) fn panel_slider_limits(&self, slider: PanelSlider, index: usize) -> (f32, f32, f32) { match slider { + PanelSlider::Image(property) => property.limits(), + PanelSlider::StyleCameraOnlyPadding => (0., 40., 1.), // `minValue={1} maxValue={4.5} step={0.001}` (`:5601-5603`). PanelSlider::ZoomAmount | PanelSlider::ZoomAmountAll => (1., 4.5, 0.001), PanelSlider::TextLayoutTransition => (0.1, 1.5, 0.05), @@ -2326,6 +2430,15 @@ impl EditorWindow { return 0.; }; match slider { + PanelSlider::Image(property) => timeline + .image_segments + .get(index) + .map_or(0., |segment| property.read(segment)), + PanelSlider::StyleCameraOnlyPadding => timeline + .style_segments + .get(index) + .and_then(|segment| segment.overrides.camera_only_padding) + .unwrap_or(0.) as f32, PanelSlider::ZoomAmount => timeline .zoom_segments .get(index) @@ -2448,6 +2561,18 @@ impl EditorWindow { cx: &mut Context, ) { match slider { + PanelSlider::Image(property) => { + self.edit_image_segment("image-transform", index, window, cx, move |segment| { + property.write(segment, value); + true + }) + } + PanelSlider::StyleCameraOnlyPadding => { + self.edit_style_segment("camera-only-padding", index, window, cx, move |segment| { + segment.overrides.camera_only_padding = Some(f64::from(value.clamp(0., 40.))); + true + }) + } PanelSlider::ZoomAmount => { self.edit_zoom_segment("zoom-amount", index, window, cx, move |segment| { segment.amount = f64::from(value); @@ -2986,6 +3111,20 @@ impl EditorWindow { }; let body: AnyElement = match selection.track { + TrackKind::Style => self.stacked_panel( + "style", + "style", + count(timeline.style_segments.len()), + cx, + |this, index, cx| this.render_style_panel(index, cx), + ), + TrackKind::Image => self.stacked_panel( + "image", + "image", + count(timeline.image_segments.len()), + cx, + |this, index, cx| this.render_image_panel(index, cx), + ), TrackKind::Zoom => { let indices = count(timeline.zoom_segments.len()); let total = timeline.zoom_segments.len(); @@ -3103,9 +3242,11 @@ impl EditorWindow { .min_h_0() .overflow_y_scroll() .track_scroll(&self.sidebar.scroll) - .p(px(16.)) - .gap(px(16.)) - .text_size(px(14.)) + .pt(px(14.)) + .px(px(16.)) + .pb(px(16.)) + .gap(px(14.)) + .text_size(px(13.)) .child(body) .into_any_element() } @@ -3135,7 +3276,7 @@ impl EditorWindow { .p(px(16.)) .rounded(px(8.)) .border_1() - .border_color(Hsla::from(self.theme.gray_200_legacy)) + .border_color(Hsla::from(self.theme.editor.line)) .child(content) .into_any_element() } @@ -3180,32 +3321,29 @@ impl EditorWindow { div() .flex() .flex_col() - .gap(px(24.)) - .child( - ui::Field::plain(&theme, SharedString::from(format!("Zoom {}", index + 1))) - .icon("icons/search.svg") - .child(self.slider(SliderKey::Panel(PanelSlider::ZoomAmount, index), "x", cx)), - ) + .gap(px(14.)) + .child(self.slider_field( + SharedString::from(format!("Zoom {}", index + 1)), + SliderKey::Panel(PanelSlider::ZoomAmount, index), + "x", + cx, + )) .child( - ui::Field::plain(&theme, "Zoom Mode") - .icon("icons/settings.svg") - .child( - div() - .flex() - .flex_col() - .gap(px(24.)) - .child(self.zoom_mode_tabs( - manual, - cx, - move |this, want_manual, window, cx| { - this.set_zoom_mode(index, want_manual, window, cx); - }, - )) - .child(self.zoom_mode_helper(manual, cx)) - .children( - manual.then(|| self.render_pad(PadKey::ZoomManual(index), cx)), - ), - ), + ui::Field::section(&theme, "Zoom Mode").child( + div() + .flex() + .flex_col() + .gap(px(14.)) + .child(self.zoom_mode_tabs( + manual, + cx, + move |this, want_manual, window, cx| { + this.set_zoom_mode(index, want_manual, window, cx); + }, + )) + .child(self.zoom_mode_helper(manual, cx)) + .children(manual.then(|| self.render_pad(PadKey::ZoomManual(index), cx))), + ), ) .into_any_element() } @@ -3273,61 +3411,56 @@ impl EditorWindow { div() .flex() .flex_col() - .gap(px(24.)) + .gap(px(14.)) .p(px(16.)) .rounded(px(8.)) .border_1() - .border_color(Hsla::from(theme.gray_200_legacy)) + .border_color(Hsla::from(theme.editor.line)) .child({ - let mut field = ui::Field::plain(&theme, "Zoom Amount") - .icon("icons/search.svg") - .child(self.slider( - SliderKey::Panel(PanelSlider::ZoomAmountAll, 0), - "x", - cx, - )); + let mut field = self.slider_field( + "Zoom Amount", + SliderKey::Panel(PanelSlider::ZoomAmountAll, 0), + "x", + cx, + ); if mixed_amount { field = field.value(mixed_badge("Mixed")); } field }) .child({ - let mut field = ui::Field::plain(&theme, "Zoom Mode") - .icon("icons/settings.svg") - .child( - div() - .flex() - .flex_col() - .gap(px(16.)) - .child(self.zoom_mode_tabs( - manual && !mixed_mode, - cx, - |this, want_manual, window, cx| { - this.set_all_zoom_modes(want_manual, window, cx); - }, - )) - .children( - (!mixed_mode).then(|| self.zoom_mode_helper(manual, cx)), - ) - .children((manual && !mixed_mode).then(|| { - div() - .flex() - .flex_col() - .gap(px(6.)) - .child(self.render_pad(PadKey::ZoomMulti, cx)) - .children(positions_mixed.then(|| { - div() - .text_size(px(12.)) - .text_color(Hsla::from(theme.gray_10)) - .child( - "Segments zoom into different spots. Drag \ + let mut field = ui::Field::section(&theme, "Zoom Mode").child( + div() + .flex() + .flex_col() + .gap(px(16.)) + .child(self.zoom_mode_tabs( + manual && !mixed_mode, + cx, + |this, want_manual, window, cx| { + this.set_all_zoom_modes(want_manual, window, cx); + }, + )) + .children((!mixed_mode).then(|| self.zoom_mode_helper(manual, cx))) + .children((manual && !mixed_mode).then(|| { + div() + .flex() + .flex_col() + .gap(px(6.)) + .child(self.render_pad(PadKey::ZoomMulti, cx)) + .children(positions_mixed.then(|| { + div() + .text_size(px(12.)) + .text_color(Hsla::from(theme.gray_10)) + .child( + "Segments zoom into different spots. Drag \ to move them all to the same one.", - ) - .into_any_element() - })) - .into_any_element() - })), - ); + ) + .into_any_element() + })) + .into_any_element() + })), + ); if mixed_mode { field = field.value(mixed_badge("Mixed")); } @@ -3699,7 +3832,7 @@ impl EditorWindow { .height(px(36.)) .text_size(px(14.)) .bg(Hsla::from(theme.gray_2)) - .border(Hsla::from(theme.gray_3)), + .border(Hsla::from(theme.editor.line)), ), ); } @@ -3719,7 +3852,7 @@ impl EditorWindow { .padding_x(px(12.)) .text_size(px(14.)) .bg(Hsla::from(theme.gray_2)) - .border(Hsla::from(theme.gray_3)); + .border(Hsla::from(theme.editor.line)); // A `