From 26d1cb831a4e7da475ecea201028e01a801aee7f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:11:34 +0000 Subject: [PATCH 1/9] feat(desktop-gpui): resize a picked window to a ratio or size, and focus it to record it alone --- .github/workflows/picker-smoke.yml | 6 +- apps/desktop-gpui/Cargo.toml | 1 + apps/desktop-gpui/locales/de.toml | 8 + apps/desktop-gpui/locales/en.toml | 8 + apps/desktop-gpui/locales/es.toml | 8 + apps/desktop-gpui/locales/fr.toml | 8 + apps/desktop-gpui/locales/hi.toml | 8 + apps/desktop-gpui/locales/ja.toml | 8 + apps/desktop-gpui/locales/zh-CN.toml | 8 + apps/desktop-gpui/src/app_windows.rs | 21 + apps/desktop-gpui/src/foreign_window.rs | 314 +++++++++++++ .../src/i18n/settings_surface_tests.rs | 8 + apps/desktop-gpui/src/i18n/tests.rs | 11 + apps/desktop-gpui/src/main.rs | 2 + apps/desktop-gpui/src/picker_probe.rs | 121 +++++- apps/desktop-gpui/src/recording.rs | 9 +- apps/desktop-gpui/src/settings_window.rs | 22 +- apps/desktop-gpui/src/store.rs | 13 + apps/desktop-gpui/src/target_overlay.rs | 411 +++++++++++++++++- apps/desktop-gpui/src/window_arrange.rs | 214 +++++++++ 20 files changed, 1184 insertions(+), 25 deletions(-) create mode 100644 apps/desktop-gpui/src/foreign_window.rs create mode 100644 apps/desktop-gpui/src/window_arrange.rs diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml index 4ad7daceb0..fc6652c944 100644 --- a/.github/workflows/picker-smoke.yml +++ b/.github/workflows/picker-smoke.yml @@ -12,6 +12,8 @@ on: - "apps/desktop-gpui/src/camera_window.rs" - "apps/desktop-gpui/src/picker_probe.rs" - "apps/desktop-gpui/src/main_window.rs" + - "apps/desktop-gpui/src/foreign_window.rs" + - "apps/desktop-gpui/src/window_arrange.rs" - "apps/desktop-gpui/src/platform.rs" - "apps/desktop-gpui/src/platform/**" - "scripts/picker-smoke/**" @@ -105,7 +107,9 @@ jobs: db="/Library/Application Support/com.apple.TCC/TCC.db" now=$(date +%s) for client in "$app" /bin/bash /Users/runner/bin/Runner.Worker /Users/runner/bin/Runner.Listener; do - sudo sqlite3 "$db" "INSERT OR REPLACE INTO access (service,client,client_type,auth_value,auth_reason,auth_version,indirect_object_identifier,flags,last_modified) VALUES ('kTCCServiceScreenCapture','$client',1,2,4,1,'UNUSED',0,$now);" + for service in kTCCServiceScreenCapture kTCCServiceAccessibility; do + sudo sqlite3 "$db" "INSERT OR REPLACE INTO access (service,client,client_type,auth_value,auth_reason,auth_version,indirect_object_identifier,flags,last_modified) VALUES ('$service','$client',1,2,4,1,'UNUSED',0,$now);" + done done sudo killall tccd || true sleep 2 diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 8b9c003a1b..e408a1de58 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -211,6 +211,7 @@ raw-window-handle = "0.6" tray-icon = { version = "0.21.1", default-features = false } windows-sys = { version = "0.59", features = [ "Win32_Foundation", + "Win32_Graphics_Dwm", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml index d1ba2c5ba4..31d87336f5 100644 --- a/apps/desktop-gpui/locales/de.toml +++ b/apps/desktop-gpui/locales/de.toml @@ -159,6 +159,10 @@ label = "Studio Sound für neue Aufnahmen" description = "Was mit dem Hauptfenster passiert, sobald eine Aufnahme beginnt." label = "Hauptfenster beim Aufnahmestart" +[settings.general.recording.window_capture] +description = "Wie eine Fensteraufnahme das gewählte Fenster erfasst. „Fokussiertes Fenster“ nimmt nur dieses Fenster auf, zentriert und folgt ihm, wenn es verschoben wird; „Bildschirmbereich“ nimmt den Bereich des Bildschirms auf, den es abdeckt." +label = "Fensteraufnahme" + [settings.general.recording.post_deletion] description = "Ob das Aufnahmefenster wieder geöffnet werden soll." label = "Nach dem Löschen einer Aufnahme" @@ -351,6 +355,10 @@ ultra = "Ultra" close = "Schließen" minimise = "Minimieren" +[settings.options.window_capture] +focused_window = "Fokussiertes Fenster" +screen_region = "Bildschirmbereich" + [settings.options.post_studio] do_nothing = "Nichts tun" open_editor = "Editor öffnen" diff --git a/apps/desktop-gpui/locales/en.toml b/apps/desktop-gpui/locales/en.toml index b5560c51ac..d4deb434ac 100644 --- a/apps/desktop-gpui/locales/en.toml +++ b/apps/desktop-gpui/locales/en.toml @@ -161,6 +161,10 @@ label = { text = "Studio Sound on new recordings", context = "Studio Sound is th description = "What happens to the main window once a recording begins." label = "Main window when recording starts" +[settings.general.recording.window_capture] +description = "How a window recording captures the window you pick. Focused window records only that window, centred and followed if it moves; Screen region records the area of the screen it covers." +label = "Window capture style" + [settings.general.recording.post_deletion] description = "Whether the recording window should reopen." label = "After deleting a recording" @@ -353,6 +357,10 @@ ultra = "Ultra" close = { text = "Close", max_px = 180, size = 12 } minimise = { text = "Minimise", max_px = 180, size = 12 } +[settings.options.window_capture] +focused_window = { text = "Focused window", max_px = 180, size = 12 } +screen_region = { text = "Screen region", max_px = 180, size = 12 } + [settings.options.post_studio] do_nothing = "Do nothing" open_editor = "Open editor" diff --git a/apps/desktop-gpui/locales/es.toml b/apps/desktop-gpui/locales/es.toml index ef1f98c301..fb293b9b1e 100644 --- a/apps/desktop-gpui/locales/es.toml +++ b/apps/desktop-gpui/locales/es.toml @@ -159,6 +159,10 @@ label = "Studio Sound en las grabaciones nuevas" description = "Qué ocurre con la ventana principal cuando empieza una grabación." label = "Ventana principal al empezar a grabar" +[settings.general.recording.window_capture] +description = "Cómo captura una grabación de ventana la ventana que eliges. Ventana enfocada graba solo esa ventana, centrada y siguiéndola si se mueve; Región de pantalla graba el área de la pantalla que ocupa." +label = "Captura de ventanas" + [settings.general.recording.post_deletion] description = "Si la ventana de grabación debe volver a abrirse." label = "Después de eliminar una grabación" @@ -351,6 +355,10 @@ ultra = "Ultra" close = "Cerrar" minimise = "Minimizar" +[settings.options.window_capture] +focused_window = "Ventana enfocada" +screen_region = "Región de pantalla" + [settings.options.post_studio] do_nothing = "No hacer nada" open_editor = "Abrir el editor" diff --git a/apps/desktop-gpui/locales/fr.toml b/apps/desktop-gpui/locales/fr.toml index 0b981c420d..465b90e17b 100644 --- a/apps/desktop-gpui/locales/fr.toml +++ b/apps/desktop-gpui/locales/fr.toml @@ -159,6 +159,10 @@ label = "Studio Sound sur les nouveaux enregistrements" description = "Ce que devient la fenêtre principale lorsqu'un enregistrement commence." label = "Fenêtre principale au début de l'enregistrement" +[settings.general.recording.window_capture] +description = "La façon dont un enregistrement de fenêtre capture la fenêtre choisie. Fenêtre ciblée enregistre uniquement cette fenêtre, centrée et suivie si elle bouge ; Zone de l’écran enregistre la zone de l’écran qu’elle couvre." +label = "Capture de fenêtre" + [settings.general.recording.post_deletion] description = "Indique si la fenêtre d'enregistrement doit se rouvrir." label = "Après la suppression d'un enregistrement" @@ -351,6 +355,10 @@ ultra = "Ultra" close = "Fermer" minimise = "Réduire" +[settings.options.window_capture] +focused_window = "Fenêtre ciblée" +screen_region = "Zone de l’écran" + [settings.options.post_studio] do_nothing = "Ne rien faire" open_editor = "Ouvrir l'éditeur" diff --git a/apps/desktop-gpui/locales/hi.toml b/apps/desktop-gpui/locales/hi.toml index 8bff040928..68bd4690cc 100644 --- a/apps/desktop-gpui/locales/hi.toml +++ b/apps/desktop-gpui/locales/hi.toml @@ -159,6 +159,10 @@ label = "नई रिकॉर्डिंग में Studio Sound" description = "रिकॉर्डिंग शुरू होने पर मुख्य विंडो के साथ क्या होता है।" label = "रिकॉर्डिंग शुरू होने पर मुख्य विंडो" +[settings.general.recording.window_capture] +description = "विंडो रिकॉर्डिंग आपकी चुनी हुई विंडो को कैसे कैप्चर करती है। फ़ोकस की गई विंडो केवल उसी विंडो को रिकॉर्ड करती है, बीच में रखकर और हिलने पर उसका पीछा करते हुए; स्क्रीन क्षेत्र स्क्रीन का वह हिस्सा रिकॉर्ड करता है जिसे वह ढकती है।" +label = "विंडो कैप्चर शैली" + [settings.general.recording.post_deletion] description = "क्या रिकॉर्डिंग विंडो फिर से खुलनी चाहिए।" label = "रिकॉर्डिंग हटाने के बाद" @@ -351,6 +355,10 @@ ultra = "अल्ट्रा" close = "बंद करें" minimise = "छोटा करें" +[settings.options.window_capture] +focused_window = "फ़ोकस की गई विंडो" +screen_region = "स्क्रीन क्षेत्र" + [settings.options.post_studio] do_nothing = "कुछ न करें" open_editor = "एडिटर खोलें" diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml index c2854b0058..e9197b6a0b 100644 --- a/apps/desktop-gpui/locales/ja.toml +++ b/apps/desktop-gpui/locales/ja.toml @@ -159,6 +159,10 @@ label = "新しい録画でStudio Soundを使用" description = "録画が始まったときのメインウィンドウの動作。" label = "録画開始時のメインウィンドウ" +[settings.general.recording.window_capture] +description = "ウインドウ録画で選んだウインドウをどのようにキャプチャするか。「フォーカスしたウインドウ」はそのウインドウだけを中央に配置し、移動しても追従して録画します。「画面の範囲」はウインドウが覆う画面の範囲を録画します。" +label = "ウインドウのキャプチャ方法" + [settings.general.recording.post_deletion] description = "録画ウィンドウをもう一度開くかどうか。" label = "録画を削除した後" @@ -351,6 +355,10 @@ ultra = "最高" close = "閉じる" minimise = "最小化" +[settings.options.window_capture] +focused_window = "フォーカスしたウインドウ" +screen_region = "画面の範囲" + [settings.options.post_studio] do_nothing = "何もしない" open_editor = "エディタを開く" diff --git a/apps/desktop-gpui/locales/zh-CN.toml b/apps/desktop-gpui/locales/zh-CN.toml index ecc9cadf58..065f81bd13 100644 --- a/apps/desktop-gpui/locales/zh-CN.toml +++ b/apps/desktop-gpui/locales/zh-CN.toml @@ -159,6 +159,10 @@ label = "新录制默认开启 Studio Sound" description = "录制开始后主窗口的处理方式。" label = "开始录制时的主窗口" +[settings.general.recording.window_capture] +description = "窗口录制如何捕获你选择的窗口。“聚焦窗口”只录制该窗口,将其居中并在移动时跟随;“屏幕区域”录制它所覆盖的屏幕区域。" +label = "窗口捕获方式" + [settings.general.recording.post_deletion] description = "是否重新打开录制窗口。" label = "删除录制后" @@ -351,6 +355,10 @@ ultra = "超高" close = "关闭" minimise = "最小化" +[settings.options.window_capture] +focused_window = "聚焦窗口" +screen_region = "屏幕区域" + [settings.options.post_studio] do_nothing = "不执行任何操作" open_editor = "打开编辑器" diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index e6a00c55aa..72638e248b 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -2120,7 +2120,24 @@ fn open_overlays_core(request: OverlayRequest, cx: &mut App) -> bool { /// A "cancelled" dismissal in the Tauri vocabulary, which is the kind that /// reveals the main window again (`dismissalReveals`, /// `new-main/index.tsx:2047-2058`). +/// Put back every window the picker moved or resized. Off the main thread: +/// `SetWindowPos` waits on the target app, which may be busy. +fn restore_arranged_windows(cx: &mut App) { + let arranged = TargetSelect::global(cx).update(cx, |select, _| select.take_arranged()); + if arranged.is_empty() { + return; + } + cx.background_executor() + .spawn(async move { + for window in arranged { + crate::foreign_window::arrange(&window.id, &window.display_id, window.bounds); + } + }) + .detach(); +} + pub fn dismiss_target_overlays(cx: &mut App) { + restore_arranged_windows(cx); if RecordingSession::global(cx).read(cx).phase == Phase::Idle && RecordingSession::global(cx) .read(cx) @@ -2395,6 +2412,10 @@ pub fn start_recording_from_overlay(target: ScreenCaptureTarget, cx: &mut App) { { return; } + // The user shaped the window to record it: it stays where it is. + TargetSelect::global(cx).update(cx, |select, _| { + select.take_arranged(); + }); // Collected before the windows go away: the capture starts a beat after // this, and an overlay that has not finished closing must not end up in // the recording. diff --git a/apps/desktop-gpui/src/foreign_window.rs b/apps/desktop-gpui/src/foreign_window.rs new file mode 100644 index 0000000000..2bde061fb7 --- /dev/null +++ b/apps/desktop-gpui/src/foreign_window.rs @@ -0,0 +1,314 @@ +//! Moving, resizing and raising another app's window for the picker. Frames +//! are relative to a display, in the units `HoveredWindow::bounds` uses there: +//! logical points on macOS and Windows, pixels on X11. + +use scap_targets::{DisplayId, WindowId}; + +use crate::target_overlay::AreaRect; + +/// Whether the picker can shape other apps' windows here. Wayland gives no +/// client that power; macOS needs the Accessibility permission Cap holds. +pub fn can_arrange() -> bool { + imp::can_arrange() +} + +/// Move and resize window `id` to `frame`, relative to display `display`. +/// The app may clamp the result (minimum sizes, grid snapping): re-read the +/// window afterwards rather than trusting `frame`. +pub fn arrange(id: &WindowId, display: &DisplayId, frame: AreaRect) -> bool { + let Some(display) = scap_targets::Display::from_id(display) else { + return false; + }; + imp::arrange(id, &display, frame) +} + +/// Bring window `id` and its app to the front. +pub fn raise(id: &WindowId) -> bool { + imp::raise(id) +} + +#[cfg(target_os = "macos")] +mod imp { + use std::ffi::c_void; + + use core_foundation::array::CFArray; + use core_foundation::base::{CFType, CFTypeRef, TCFType}; + use core_foundation::string::{CFString, CFStringRef}; + + use super::*; + + type AXUIElementRef = *const c_void; + type AXValueRef = *const c_void; + type AXError = i32; + + const AX_SUCCESS: AXError = 0; + const AX_VALUE_CG_POINT: u32 = 1; + const AX_VALUE_CG_SIZE: u32 = 2; + + #[repr(C)] + struct Point { + x: f64, + y: f64, + } + + #[repr(C)] + struct Size { + width: f64, + height: f64, + } + + #[link(name = "ApplicationServices", kind = "framework")] + unsafe extern "C" { + fn AXIsProcessTrusted() -> bool; + fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef; + fn AXUIElementCopyAttributeValue( + element: AXUIElementRef, + attribute: CFStringRef, + value: *mut CFTypeRef, + ) -> AXError; + fn AXUIElementSetAttributeValue( + element: AXUIElementRef, + attribute: CFStringRef, + value: CFTypeRef, + ) -> AXError; + fn AXUIElementPerformAction(element: AXUIElementRef, action: CFStringRef) -> AXError; + fn AXValueCreate(kind: u32, value: *const c_void) -> AXValueRef; + /// Private but stable since 10.6, and the only way to tie an AX + /// window to its CGWindowID. + fn _AXUIElementGetWindow(element: AXUIElementRef, id: *mut u32) -> AXError; + } + + pub fn can_arrange() -> bool { + unsafe { AXIsProcessTrusted() } + } + + /// The AX element of window `id`, owned by the returned `CFType`. + fn ax_window(id: &WindowId) -> Option { + let window = scap_targets::Window::from_id(id)?; + let pid = window.raw_handle().owner_pid()?; + let number: u32 = id.to_string().parse().ok()?; + let app = unsafe { CFType::wrap_under_create_rule(AXUIElementCreateApplication(pid)) }; + let windows = unsafe { + let mut value: CFTypeRef = std::ptr::null(); + let attribute = CFString::from_static_string("AXWindows"); + if AXUIElementCopyAttributeValue( + app.as_CFTypeRef(), + attribute.as_concrete_TypeRef(), + &mut value, + ) != AX_SUCCESS + || value.is_null() + { + return None; + } + CFArray::::wrap_under_create_rule(value as _) + }; + windows.iter().find_map(|element| { + let mut found = 0; + let ok = unsafe { _AXUIElementGetWindow(element.as_CFTypeRef(), &mut found) }; + (ok == AX_SUCCESS && found == number).then(|| element.clone()) + }) + } + + fn set(element: &CFType, attribute: &'static str, kind: u32, value: *const c_void) -> bool { + unsafe { + let value = AXValueCreate(kind, value); + if value.is_null() { + return false; + } + let value = CFType::wrap_under_create_rule(value); + let attribute = CFString::from_static_string(attribute); + AXUIElementSetAttributeValue( + element.as_CFTypeRef(), + attribute.as_concrete_TypeRef(), + value.as_CFTypeRef(), + ) == AX_SUCCESS + } + } + + pub fn arrange(id: &WindowId, display: &scap_targets::Display, frame: AreaRect) -> bool { + let Some(origin) = display.raw_handle().logical_bounds().map(|b| b.position()) else { + return false; + }; + let Some(element) = ax_window(id) else { + tracing::warn!(%id, "arrange window: no accessibility element"); + return false; + }; + let point = Point { + x: origin.x() + f64::from(frame.x), + y: origin.y() + f64::from(frame.y), + }; + let size = Size { + width: f64::from(frame.width), + height: f64::from(frame.height), + }; + let point = &point as *const Point as *const c_void; + let size = &size as *const Size as *const c_void; + // Position first so a growing window is not clamped by the screen + // edge it starts against, then again because some apps re-centre + // after a resize. + let moved = set(&element, "AXPosition", AX_VALUE_CG_POINT, point); + let sized = set(&element, "AXSize", AX_VALUE_CG_SIZE, size); + set(&element, "AXPosition", AX_VALUE_CG_POINT, point); + if !(moved && sized) { + tracing::warn!(%id, moved, sized, "arrange window: the app refused part of the frame"); + } + moved || sized + } + + pub fn raise(id: &WindowId) -> bool { + if let Some(element) = ax_window(id) { + let action = CFString::from_static_string("AXRaise"); + unsafe { + AXUIElementPerformAction(element.as_CFTypeRef(), action.as_concrete_TypeRef()); + } + } + crate::platform::focus_capture_target_window(id) + } +} + +#[cfg(windows)] +mod imp { + use windows_sys::Win32::{ + Foundation::{HWND, RECT}, + Graphics::Dwm::{DWMWA_EXTENDED_FRAME_BOUNDS, DwmGetWindowAttribute}, + UI::WindowsAndMessaging::{ + GetWindowRect, IsZoomed, SW_RESTORE, SWP_NOACTIVATE, SWP_NOZORDER, SetWindowPos, + ShowWindow, + }, + }; + + use super::*; + + pub fn can_arrange() -> bool { + true + } + + fn hwnd(id: &WindowId) -> Option { + let window = scap_targets::Window::from_id(id)?; + let hwnd = window.raw_handle().inner().0 as HWND; + (!hwnd.is_null()).then_some(hwnd) + } + + pub fn arrange(id: &WindowId, display: &scap_targets::Display, frame: AreaRect) -> bool { + let Some(hwnd) = hwnd(id) else { + return false; + }; + let (Some(physical), Some(logical)) = ( + display.raw_handle().physical_bounds(), + display.raw_handle().logical_bounds(), + ) else { + return false; + }; + let scale = physical.size().width() / logical.size().width().max(1.); + let x = physical.position().x() + f64::from(frame.x) * scale; + let y = physical.position().y() + f64::from(frame.y) * scale; + let width = f64::from(frame.width) * scale; + let height = f64::from(frame.height) * scale; + unsafe { + if IsZoomed(hwnd) != 0 { + ShowWindow(hwnd, SW_RESTORE); + } + // `frame` is the visible window (DWM's extended frame bounds, + // what scap-targets reports); SetWindowPos also counts the + // invisible resize borders around it. + let mut outer: RECT = std::mem::zeroed(); + let mut visible: RECT = std::mem::zeroed(); + let (left, top, right, bottom) = if GetWindowRect(hwnd, &mut outer) != 0 + && DwmGetWindowAttribute( + hwnd, + DWMWA_EXTENDED_FRAME_BOUNDS as u32, + &mut visible as *mut RECT as *mut _, + std::mem::size_of::() as u32, + ) == 0 + { + ( + visible.left - outer.left, + visible.top - outer.top, + outer.right - visible.right, + outer.bottom - visible.bottom, + ) + } else { + (0, 0, 0, 0) + }; + SetWindowPos( + hwnd, + std::ptr::null_mut(), + x.round() as i32 - left, + y.round() as i32 - top, + width.round() as i32 + left + right, + height.round() as i32 + top + bottom, + SWP_NOZORDER | SWP_NOACTIVATE, + ) != 0 + } + } + + pub fn raise(id: &WindowId) -> bool { + crate::platform::focus_capture_target_window(id) + } +} + +#[cfg(target_os = "linux")] +mod imp { + use x11rb::connection::Connection as _; + use x11rb::protocol::xproto::{ClientMessageEvent, ConnectionExt as _, EventMask}; + + use super::*; + + pub fn can_arrange() -> bool { + !cap_recording::screenshot::uses_wayland_portal() + } + + fn send(window: u32, message: &[u8], data: [u32; 5]) -> anyhow::Result<()> { + let (connection, screen) = x11rb::connect(None)?; + let root = connection.setup().roots[screen].root; + let atom = connection.intern_atom(false, message)?.reply()?.atom; + let event = ClientMessageEvent::new(32, window, atom, data); + connection + .send_event( + false, + root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + )? + .check()?; + connection.flush()?; + Ok(()) + } + + pub fn arrange(id: &WindowId, display: &scap_targets::Display, frame: AreaRect) -> bool { + if !can_arrange() { + return false; + } + let Ok(window) = id.to_string().parse::() else { + return false; + }; + let Some(origin) = display.raw_handle().physical_bounds().map(|b| b.position()) else { + return false; + }; + // StaticGravity, so x/y place the client area as scap-targets + // measures it; x, y, width and height present; from a pager. + const STATIC_GRAVITY: u32 = 10; + let flags = STATIC_GRAVITY | (0b1111 << 8) | (2 << 12); + let data = [ + flags, + (origin.x() + f64::from(frame.x)).round() as i32 as u32, + (origin.y() + f64::from(frame.y)).round() as i32 as u32, + frame.width.round().max(1.) as u32, + frame.height.round().max(1.) as u32, + ]; + match send(window, b"_NET_MOVERESIZE_WINDOW", data) { + Ok(()) => true, + Err(error) => { + tracing::warn!(%error, %id, "arrange window: the window manager refused"); + false + } + } + } + + pub fn raise(id: &WindowId) -> bool { + let Ok(window) = id.to_string().parse::() else { + return false; + }; + send(window, b"_NET_ACTIVE_WINDOW", [2, 0, 0, 0, 0]).is_ok() + } +} diff --git a/apps/desktop-gpui/src/i18n/settings_surface_tests.rs b/apps/desktop-gpui/src/i18n/settings_surface_tests.rs index 1d1cd608f1..67eadd7717 100644 --- a/apps/desktop-gpui/src/i18n/settings_surface_tests.rs +++ b/apps/desktop-gpui/src/i18n/settings_surface_tests.rs @@ -232,6 +232,14 @@ fn settings_surfaces_match_the_literals_they_replaced() { tr::settings::options::main_window_start::minimise(), "Minimise", ), + ( + tr::settings::options::window_capture::screen_region(), + "Screen region", + ), + ( + tr::settings::options::window_capture::focused_window(), + "Focused window", + ), ( tr::settings::options::post_studio::open_editor(), "Open editor", diff --git a/apps/desktop-gpui/src/i18n/tests.rs b/apps/desktop-gpui/src/i18n/tests.rs index 1d9f313b32..b5a3b9f053 100644 --- a/apps/desktop-gpui/src/i18n/tests.rs +++ b/apps/desktop-gpui/src/i18n/tests.rs @@ -207,6 +207,17 @@ fn english_matches_the_literals_it_replaced() { tr::settings::general::recording::main_window_start::label(), "Main window when recording starts".into(), ), + ( + tr::settings::general::recording::window_capture::description(), + "How a window recording captures the window you pick. Focused window records only \ + that window, centred and followed if it moves; Screen region records the area of \ + the screen it covers." + .into(), + ), + ( + tr::settings::general::recording::window_capture::label(), + "Window capture style".into(), + ), ( tr::settings::general::recording::post_deletion::description(), "Whether the recording window should reopen.".into(), diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 448506de17..184144a4b2 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -99,6 +99,8 @@ mod tray; mod ui; mod updates; mod upload; +mod foreign_window; +mod window_arrange; use gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size}; diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index 97a37e2d7a..389ef2c4b7 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -26,6 +26,7 @@ pub fn run(main: WindowHandle, cx: &mut App) { sleep(cx, 800).await; let mut modes = Vec::new(); + let mut window_arrangement = None; for (label, kind) in [ ("display", TargetType::Display), ("window", TargetType::Window), @@ -58,14 +59,25 @@ pub fn run(main: WindowHandle, cx: &mut App) { sleep(cx, 800).await; modes.push(cx.update(|cx| probe_mode(label, cx))); screenshot(&output, label, cx).await; + if kind == TargetType::Window { + window_arrangement = Some(probe_window_arrangement(&output, cx).await); + } cx.update(app_windows::dismiss_target_overlays); sleep(cx, 400).await; } - let passed = modes - .iter() - .all(|mode| mode["cameraAboveOverlays"].as_bool() == Some(true)); - let report = serde_json::json!({ "passed": passed, "modes": modes }); + let arrangement_passed = window_arrangement + .as_ref() + .is_none_or(|report: &serde_json::Value| report["passed"].as_bool() != Some(false)); + let passed = arrangement_passed + && modes + .iter() + .all(|mode| mode["cameraAboveOverlays"].as_bool() == Some(true)); + let report = serde_json::json!({ + "passed": passed, + "modes": modes, + "windowArrangement": window_arrangement, + }); if let Err(error) = std::fs::write(output, report.to_string()) { tracing::error!(%error, "Could not write picker probe results"); } @@ -79,6 +91,107 @@ struct StackEntry { level: Option, } +fn read_window(id: &scap_targets::WindowId) -> Option { + let window = scap_targets::Window::from_id(id)?; + crate::target_overlay::HoveredWindow::from_window(&window).map(|window| window.bounds) +} + +fn rect_json(rect: Option) -> serde_json::Value { + rect.map_or(serde_json::Value::Null, |rect| { + serde_json::json!([rect.x, rect.y, rect.width, rect.height]) + }) +} + +/// Drive the picked window through the overlay the way its menu and Focus +/// toggle do: 16:9, Focus on, then dismiss. Skipped where Cap cannot shape +/// other apps' windows (Wayland, or macOS without Accessibility). +async fn probe_window_arrangement( + output: &std::ffi::OsStr, + cx: &mut AsyncApp, +) -> serde_json::Value { + if !crate::foreign_window::can_arrange() { + return serde_json::json!({ "skipped": "cannot arrange other apps' windows here" }); + } + let picked = cx.update(|cx| { + crate::target_overlay::TargetSelect::global(cx) + .read(cx) + .active_window() + .cloned() + }); + let Some(picked) = picked else { + return serde_json::json!({ "passed": false, "error": "no window to arrange" }); + }; + let overlay = cx.update(|cx| { + cx.global::() + .overlays + .iter() + .find(|(display, _)| *display == picked.display_id) + .map(|(_, overlay)| *overlay) + }); + let Some(overlay) = overlay else { + return serde_json::json!({ "passed": false, "error": "no overlay on the window's display" }); + }; + let display = scap_targets::Display::from_id(&picked.display_id); + let display_size = display + .as_ref() + .and_then(|display| display.raw_handle().logical_bounds()) + .map(|bounds| (bounds.size().width() as f32, bounds.size().height() as f32)); + let before = Some(picked.bounds); + + // `window_arrange::RATIOS` lists 16:9 second. + overlay + .update(cx, |view, _, cx| view.choose_window_size(1, cx)) + .ok(); + sleep(cx, 1500).await; + let shaped = read_window(&picked.id); + let ratio_ok = + shaped.is_some_and(|rect| (rect.width / rect.height / (16. / 9.) - 1.).abs() < 0.04); + let inside = |rect: Option| { + rect.zip(display_size) + .is_some_and(|(rect, (width, height))| { + rect.x >= -1. + && rect.y >= -1. + && rect.x + rect.width <= width + 1. + && rect.y + rect.height <= height + 1. + }) + }; + screenshot(output, "window-16x9", cx).await; + + overlay + .update(cx, |view, _, cx| view.set_focus(true, cx)) + .ok(); + sleep(cx, 1500).await; + let focused = read_window(&picked.id); + let centred = focused.zip(display_size).is_some_and(|(rect, (width, _))| { + (rect.x + rect.width / 2. - width / 2.).abs() < width * 0.04 + }); + screenshot(output, "window-focus", cx).await; + overlay + .update(cx, |view, _, cx| view.set_focus(false, cx)) + .ok(); + sleep(cx, 600).await; + + cx.update(app_windows::dismiss_target_overlays); + sleep(cx, 1500).await; + let restored = read_window(&picked.id); + let restored_ok = restored.zip(before).is_some_and(|(now, then)| { + (now.x - then.x).abs() < 6. + && (now.y - then.y).abs() < 6. + && (now.width - then.width).abs() < 6. + && (now.height - then.height).abs() < 6. + }); + serde_json::json!({ + "passed": ratio_ok && inside(shaped) && centred && inside(focused) && restored_ok, + "before": rect_json(before), + "sixteenByNine": rect_json(shaped), + "ratioOk": ratio_ok, + "focused": rect_json(focused), + "centred": centred, + "restored": rect_json(restored), + "restoredOk": restored_ok, + }) +} + /// Save what the primary display shows next to the report, for review. async fn screenshot(output: &std::ffi::OsStr, label: &str, cx: &mut AsyncApp) { let Some(directory) = std::path::Path::new(output).parent() else { diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index c9658a5c01..343e0a3a18 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -2497,11 +2497,17 @@ async fn start_attempt_with_upload( excluded }; let mut instant_upload = None; + // Focus in the picker and the window capture style setting are the same + // stored choice; recording reads it here, as it starts. + let window_only = matches!(config.target, ScreenCaptureTarget::Window { .. }) + && crate::store::GeneralSettings::load().window_capture_style + == crate::store::WindowCaptureStyle::FocusedWindow; let handle = match config.mode { RecordingMode::Studio => { let mut builder = defaults.apply_to_studio_builder( studio_recording::Actor::builder(project_dir.clone(), config.target.clone()) - .with_system_audio(config.system_audio), + .with_system_audio(config.system_audio) + .with_window_only(window_only), camera_lock.is_some(), None, ); @@ -2532,6 +2538,7 @@ async fn start_attempt_with_upload( let mut builder = instant_recording::Actor::builder(project_dir.clone(), config.target.clone()) .with_system_audio(config.system_audio) + .with_window_only(window_only) .with_max_output_size(instant_max_resolution); if let Some(gate) = config.start_gate.clone() { builder = builder.with_start_gate(gate); diff --git a/apps/desktop-gpui/src/settings_window.rs b/apps/desktop-gpui/src/settings_window.rs index 6647b570b4..496ad3226d 100644 --- a/apps/desktop-gpui/src/settings_window.rs +++ b/apps/desktop-gpui/src/settings_window.rs @@ -38,7 +38,8 @@ use crate::{ store::{ self, AppTheme, DEFAULT_PROJECT_NAME_TEMPLATE, DEFAULT_SERVER_URL, GENERAL_SETTINGS, GeneralSettings, MainWindowStartBehaviour, PostDeletionBehaviour, RECORDING_START_SAFETY, - SettingsEnum, StudioQuality, UpdateChannel, UpdateDownloadPolicy, WindowExclusion, + SettingsEnum, StudioQuality, UpdateChannel, UpdateDownloadPolicy, WindowCaptureStyle, + WindowExclusion, }, theme::Theme, ui, @@ -195,6 +196,7 @@ impl Page { pub(crate) enum MenuKind { Countdown, MainWindowStart, + WindowCapture, PostDeletion, MaxFps, Language, @@ -1556,6 +1558,7 @@ impl SettingsWindow { MenuKind::MainWindowStart => { enum_items(self.settings.main_window_recording_start_behaviour) } + MenuKind::WindowCapture => enum_items(self.settings.window_capture_style), MenuKind::PostDeletion => enum_items(self.settings.post_deletion_behaviour), MenuKind::AddWindow => self .available_windows() @@ -1601,6 +1604,12 @@ impl SettingsWindow { self.write_enum("mainWindowRecordingStartBehaviour", *value, cx); } } + MenuKind::WindowCapture => { + if let Some(value) = WindowCaptureStyle::ALL.get(index) { + self.settings.window_capture_style = *value; + self.write_enum("windowCaptureStyle", *value, cx); + } + } MenuKind::PostDeletion => { if let Some(value) = PostDeletionBehaviour::ALL.get(index) { self.settings.post_deletion_behaviour = *value; @@ -3749,6 +3758,17 @@ impl SettingsWindow { ) .into_any_element(), ), + self.setting_row( + tr::settings::general::recording::window_capture::label(), + Some(tr::settings::general::recording::window_capture::description()), + self.select( + "window-capture-style", + settings.window_capture_style.label(), + MenuKind::WindowCapture, + cx, + ) + .into_any_element(), + ), // "After a Studio recording" lives on the Shortcuts page, // next to the shortcuts it shapes. There is no // post-capture overlay in this app, so a stored diff --git a/apps/desktop-gpui/src/store.rs b/apps/desktop-gpui/src/store.rs index 782e5fe91b..e530e02811 100644 --- a/apps/desktop-gpui/src/store.rs +++ b/apps/desktop-gpui/src/store.rs @@ -795,6 +795,17 @@ settings_enum! { default = Close } +settings_enum! { + /// How a window recording captures the picked window: the region of the + /// screen it covers, or (Focused) the window by itself, centred by the + /// picker and followed if it moves. + WindowCaptureStyle { + ScreenRegion = "screenRegion", tr::settings::options::window_capture::screen_region(); + FocusedWindow = "focusedWindow", tr::settings::options::window_capture::focused_window(); + } + default = ScreenRegion +} + settings_enum! { /// `PostStudioRecordingBehaviour`. PostStudioBehaviour { @@ -1020,6 +1031,7 @@ pub struct GeneralSettings { pub studio_recording_quality: StudioQuality, pub recording_countdown: Option, pub main_window_recording_start_behaviour: MainWindowStartBehaviour, + pub window_capture_style: WindowCaptureStyle, pub post_studio_recording_behaviour: PostStudioBehaviour, pub post_screenshot_behaviour: PostScreenshotBehaviour, pub post_deletion_behaviour: PostDeletionBehaviour, @@ -1389,6 +1401,7 @@ impl GeneralSettings { general, "mainWindowRecordingStartBehaviour", ), + window_capture_style: enum_at(general, "windowCaptureStyle"), post_studio_recording_behaviour: enum_at(general, "postStudioRecordingBehaviour"), post_screenshot_behaviour: enum_at(general, "postScreenshotBehaviour"), post_deletion_behaviour: enum_at(general, "postDeletionBehaviour"), diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 8c25ab7a8a..cc136519fc 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -300,6 +300,10 @@ pub struct TargetSelect { microphone_fill: f32, /// App icons by window id, fetched once per window. icons: HashMap>, + /// Windows the picker moved or resized, keyed by id, with where they + /// were: dismissing the picker puts them back, starting a recording + /// keeps them where the user shaped them. + arranged: HashMap, _poll: Option>, } @@ -316,6 +320,7 @@ impl TargetSelect { pinned_window: None, microphone_fill: 0., icons: HashMap::new(), + arranged: HashMap::new(), _poll: None, }); cx.set_global(TargetSelectGlobal(select.clone())); @@ -336,6 +341,9 @@ impl TargetSelect { ) { self.recording_mode = recording_mode; self.pinned_window = pinned_window; + if self.mode.is_none() && mode.is_some() { + self.arranged.clear(); + } if self.mode == mode { cx.notify(); return; @@ -369,6 +377,34 @@ impl TargetSelect { self.icons.get(&id.to_string()).cloned() } + /// Remember where `window` was before the picker first moves it. + pub fn remember_arranged(&mut self, window: &HoveredWindow) { + self.arranged + .entry(window.id.to_string()) + .or_insert_with(|| window.clone()); + } + + /// Where the picker found window `id`, if it has moved it since. + pub fn arranged_original(&self, id: &WindowId) -> Option<&HoveredWindow> { + self.arranged.get(&id.to_string()) + } + + pub fn forget_arranged(&mut self, id: &WindowId) { + self.arranged.remove(&id.to_string()); + } + + /// Every window the picker moved, with where it was, forgetting them. + pub fn take_arranged(&mut self) -> Vec { + self.arranged.drain().map(|(_, window)| window).collect() + } + + pub fn set_pinned(&mut self, window: HoveredWindow, cx: &mut Context) { + if self.pinned_window.as_ref() != Some(&window) { + self.pinned_window = Some(window); + cx.notify(); + } + } + /// Lock the highlight onto whatever the cursor is over -- the TSX's /// click-anywhere-to-select behavior. fn pin_hovered(&mut self, cx: &mut Context) { @@ -386,9 +422,12 @@ impl TargetSelect { // Only the window variant needs the (much more expensive) // window probe; the others just want to know which display the // cursor is on. - let Ok(want_window) = - this.read_with(cx, |this, _| this.mode == Some(TargetType::Window)) - else { + let Ok((want_window, pinned)) = this.read_with(cx, |this, _| { + ( + this.mode == Some(TargetType::Window), + this.pinned_window.as_ref().map(|window| window.id.clone()), + ) + }) else { return; }; @@ -402,18 +441,34 @@ impl TargetSelect { .flatten() .as_ref() .and_then(HoveredWindow::from_window); - (display, window) + // The pinned window is re-read too: it may have been + // moved or resized, by the picker or by its app. + let pinned = pinned + .filter(|_| want_window) + .and_then(|id| scap_targets::Window::from_id(&id)) + .as_ref() + .and_then(HoveredWindow::from_window); + (display, window, pinned) }) .await; let Ok((icon_wanted, active, changed, microphone_changed)) = this.update(cx, |this: &mut Self, cx| { - let (display, window) = probe; + let (display, window, pinned) = probe; + let pinned_moved = pinned.as_ref().is_some_and(|pinned| { + this.pinned_window + .as_ref() + .is_some_and(|current| current.id == pinned.id && current != pinned) + }); + if pinned_moved { + this.pinned_window = pinned; + } let microphone_fill = crate::ui::MicrophoneLevel::capture_fill(cx); // The mic level stays out of `changed`: only the overlays // drawing the meter repaint for it, below. - let changed = - this.cursor_display != display || this.hovered_window != window; + let changed = pinned_moved + || this.cursor_display != display + || this.hovered_window != window; let microphone_changed = this.microphone_fill != microphone_fill; this.microphone_fill = microphone_fill; this.cursor_display = display; @@ -882,6 +937,16 @@ pub struct OverlayWindow { /// The selection drawn mid-reshape: where it started and when. `crop` /// already holds where it is going. reshape: Option<(AreaRect, Instant)>, + /// The picked window's size and ratio menu. + window_menu: Option, + /// The window highlight mid-move, the way `reshape` is for an area: + /// where it was drawn and when the window started moving. + window_reshape: Option<(AreaRect, Instant)>, + /// Where the window highlight was last drawn, for a move to start from. + window_drawn: Cell>, + /// Focus: centre the picked window and record it by itself. Mirrors the + /// window capture style setting, which it writes. + focus_window: bool, inline_camera: Option>, inline_camera_subscription: Option, empty_camera_state: crate::store::CameraWindowState, @@ -1035,6 +1100,11 @@ impl OverlayWindow { countdown_menu: None, aspect_menu: None, reshape: None, + window_menu: None, + window_reshape: None, + window_drawn: Cell::new(None), + focus_window: crate::store::GeneralSettings::load().window_capture_style + == crate::store::WindowCaptureStyle::FocusedWindow, inline_camera: None, inline_camera_subscription: None, empty_camera_state: crate::store::load().camera_window.unwrap_or_default(), @@ -1279,6 +1349,19 @@ impl Render for OverlayWindow { cx.stop_propagation(); return; } + if let Some(menu) = &mut this.window_menu { + match menu.on_key(&event.keystroke.key) { + crate::ui::MenuKey::Commit(index) => this.choose_window_size(index, cx), + crate::ui::MenuKey::Dismiss => { + this.window_menu = None; + cx.notify(); + } + crate::ui::MenuKey::Moved => cx.notify(), + crate::ui::MenuKey::Ignored => return, + } + cx.stop_propagation(); + return; + } if let Some(menu) = &mut this.aspect_menu { match menu.on_key(&event.keystroke.key) { crate::ui::MenuKey::Commit(index) => this.choose_aspect(index, cx), @@ -1335,7 +1418,7 @@ impl Render for OverlayWindow { } else { match mode { Some(TargetType::Display) => root.child(self.render_display_variant(window, cx)), - Some(TargetType::Window) => root.child(self.render_window_variant(cx)), + Some(TargetType::Window) => root.child(self.render_window_variant(window, cx)), Some(TargetType::Area) => root.child(self.render_area_variant(window, cx)), Some(TargetType::CameraOnly) => root.child(self.render_camera_variant(cx)), None => root, @@ -1346,6 +1429,7 @@ impl Render for OverlayWindow { root.children(self.render_mode_menu(cx)) .children(self.render_countdown_menu(cx)) .children(self.render_aspect_menu(cx)) + .children(self.render_window_menu(cx)) .children(self.render_device_menu(cx)) .into_any_element() } @@ -1481,6 +1565,7 @@ impl OverlayWindow { self.mode_menu = None; self.countdown_menu = None; self.aspect_menu = None; + self.window_menu = None; self.device_menu = Some(OverlayDeviceMenu { state: crate::ui::MenuState::new(position, &items), items, @@ -1775,7 +1860,11 @@ impl OverlayWindow { /// /// Like the TSX, the whole variant only draws on the display the cursor is /// on -- the other overlays stay empty and transparent. - fn render_window_variant(&self, cx: &mut Context) -> gpui::AnyElement { + fn render_window_variant( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> gpui::AnyElement { let select = self.select.read(cx); let active = select .active_window() @@ -1786,11 +1875,24 @@ impl OverlayWindow { .and_then(|window| select.icon_for(&window.id)); let Some(active) = active else { + self.window_drawn.set(None); return div().size_full().into_any_element(); }; - let bounds = active.bounds.scaled(1. / self.capture_scale); - let width = bounds.width; - let height = bounds.height; + let target = active.bounds.scaled(1. / self.capture_scale); + let bounds = match self.window_reshape { + Some((from, started)) => { + let t = started.elapsed().as_secs_f32() / AREA_RESHAPE.as_secs_f32(); + if t >= 1. { + self.window_reshape = None; + target + } else { + window.request_animation_frame(); + area_between(from, target, t) + } + } + None => target, + }; + self.window_drawn.set(Some(bounds)); div() .size_full() @@ -1800,6 +1902,7 @@ impl OverlayWindow { // Clicking anywhere locks the highlight onto whatever is under the // cursor, exactly as the TSX's two click handlers add up to. .on_click(cx.listener(|this, _, _window, cx| { + this.window_menu = None; this.select.update(cx, |select, cx| select.pin_hovered(cx)); // `setOriginalCameraBounds(null)` on the first click // (`target-select-overlay.tsx:609-617`): committing a pick @@ -1811,8 +1914,8 @@ impl OverlayWindow { .absolute() .left(px(bounds.x)) .top(px(bounds.y)) - .w(px(width)) - .h(px(height)) + .w(px(bounds.width)) + .h(px(bounds.height)) .flex() .flex_col() .justify_center() @@ -1840,16 +1943,285 @@ impl OverlayWindow { .font_weight(FontWeight::BOLD) .child(SharedString::from(active.app_name.clone())), ) - .child(div().mb(px(8.)).text_size(px(12.)).child(format!( - "{}x{}", - active.bounds.width as u32, active.bounds.height as u32 - ))), + .child(self.render_window_size(target, cx)) + .children(self.render_focus_toggle(cx)), ) .child(self.render_controls_cluster(self.target(cx), false, cx)), ) .into_any_element() } + /// Recorded pixels per logical point of this display. + fn pixels_per_point(&self) -> f32 { + self.physical_size + .map(|(width, _)| width as f32 / self.display_size.0.max(1.)) + .unwrap_or(1.) + } + + /// The picked window's size as it will record, which opens the ratio and + /// size menu where Cap can shape other apps' windows. + fn render_window_size(&self, window: AreaRect, cx: &mut Context) -> impl IntoElement { + let theme = self.theme; + let scale = self.pixels_per_point(); + let mut label = format!( + "{} × {}", + (window.width * scale).round(), + (window.height * scale).round() + ); + if let Some((w, h)) = crate::window_arrange::matching_ratio(window) { + label.push_str(&format!(" · {w}:{h}")); + } + let arrangeable = crate::foreign_window::can_arrange(); + let tooltip = if arrangeable { + "Change the window's size" + } else if cfg!(target_os = "macos") { + "Turn on Accessibility for Cap to resize other apps' windows" + } else { + "Resizing other apps' windows isn't possible on Wayland" + }; + div() + .id("window-size") + .mb(px(8.)) + .h(px(28.)) + .px(px(10.)) + .flex() + .items_center() + .gap(px(6.)) + .rounded_full() + .text_size(px(12.)) + .text_color(theme.gray_12) + .bg(gpui::hsla(0., 0., 0., 0.28)) + .tooltip(move |_, cx| crate::ui::Tooltip::new(&theme, tooltip).view(cx)) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(label) + .when(arrangeable, |chip| { + chip.cursor_pointer() + .hover(|style| style.bg(gpui::hsla(0., 0., 0., 0.4))) + .child(svg().path("icons/chevron-down.svg").size(px(12.))) + .on_click(cx.listener(|this, event: &gpui::ClickEvent, _, cx| { + cx.stop_propagation(); + this.open_window_menu(event.position(), cx); + })) + }) + } + + fn render_focus_toggle(&self, cx: &mut Context) -> Option { + if !crate::foreign_window::can_arrange() { + return None; + } + let theme = self.theme; + Some( + div() + .id("window-focus") + .mb(px(8.)) + .h(px(32.)) + .pl(px(12.)) + .pr(px(6.)) + .flex() + .items_center() + .gap(px(10.)) + .rounded_full() + .bg(gpui::hsla(0., 0., 0., 0.28)) + .tooltip(move |_, cx| { + crate::ui::Tooltip::new( + &theme, + "Centre this window and record only it, nothing in front of it", + ) + .view(cx) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + div() + .text_size(px(13.)) + .text_color(theme.gray_12) + .child("Focus"), + ) + .child( + crate::ui::Toggle::glass(&theme, "window-focus-toggle", self.focus_window) + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.set_focus(!this.focus_window, cx); + })), + ), + ) + } + + /// What each row of the window menu does. + fn window_menu_entries(&self, window: AreaRect) -> Vec<(String, bool, AreaRect)> { + use crate::window_arrange::{RATIOS, SIZES, fit_to_ratio, fit_to_size, matching_ratio}; + let work_area = self.controls_work_area; + let scale = self.pixels_per_point(); + let current = matching_ratio(window); + let recorded = ( + (window.width * scale).round() as u32, + (window.height * scale).round() as u32, + ); + let mut entries: Vec<(String, bool, AreaRect)> = RATIOS + .into_iter() + .map(|ratio| { + ( + format!("{}:{}", ratio.0, ratio.1), + current == Some(ratio), + fit_to_ratio(window, ratio, work_area), + ) + }) + .collect(); + entries.extend(SIZES.into_iter().filter_map(|(name, width, height)| { + fit_to_size(window, (width, height), scale, work_area).map(|frame| { + ( + format!("{name} · {width} × {height}"), + recorded == (width, height), + frame, + ) + }) + })); + entries + } + + fn active_window_logical(&self, cx: &App) -> Option { + self.select + .read(cx) + .active_window() + .filter(|window| window.display_id == self.display_id) + .map(|window| window.bounds.scaled(1. / self.capture_scale)) + } + + fn open_window_menu(&mut self, position: Point, cx: &mut Context) { + let Some(window) = self.active_window_logical(cx) else { + return; + }; + let items: Vec = self + .window_menu_entries(window) + .into_iter() + .map(|(label, checked, _)| crate::ui::MenuItem::new(label, checked)) + .collect(); + self.mode_menu = None; + self.countdown_menu = None; + self.device_menu = None; + self.aspect_menu = None; + self.window_menu = Some(crate::ui::MenuState::new(position, &items)); + cx.notify(); + } + + fn render_window_menu(&self, cx: &mut Context) -> Option { + let state = self.window_menu.as_ref()?; + let window = self.active_window_logical(cx)?; + let items = self + .window_menu_entries(window) + .into_iter() + .map(|(label, checked, _)| crate::ui::MenuItem::new(label, checked)) + .collect(); + Some( + crate::ui::Menu::plain(&self.theme, "overlay-window-menu", items, state) + .on_select(cx.listener(|this, index: &usize, _, cx| { + this.choose_window_size(*index, cx); + })) + .on_dismiss(cx.listener(|this, _, _, cx| { + this.window_menu = None; + cx.notify(); + })) + .into_any_element(), + ) + } + + pub(crate) fn choose_window_size(&mut self, index: usize, cx: &mut Context) { + self.window_menu = None; + let frame = self + .active_window_logical(cx) + .and_then(|window| self.window_menu_entries(window).into_iter().nth(index)) + .map(|(_, _, frame)| frame); + match frame { + Some(frame) => self.arrange_active_window(frame, false, cx), + None => cx.notify(), + } + } + + /// Focus on: centre the window, bring it forward, record it by itself. + /// Focus off: put it back where the picker found it, record the region. + pub(crate) fn set_focus(&mut self, focus: bool, cx: &mut Context) { + self.focus_window = focus; + let style = if focus { + crate::store::WindowCaptureStyle::FocusedWindow + } else { + crate::store::WindowCaptureStyle::ScreenRegion + }; + if !crate::store::set_store_setting( + crate::store::GENERAL_SETTINGS, + "windowCaptureStyle", + serde_json::Value::from(crate::store::SettingsEnum::as_json(style)), + ) { + tracing::warn!("could not save the window capture style"); + } + let Some(window) = self.active_window_logical(cx) else { + cx.notify(); + return; + }; + if focus { + let centred = crate::window_arrange::centre(window, self.controls_work_area); + self.arrange_active_window(centred, true, cx); + } else { + let original = self.select.read(cx).active_window().and_then(|active| { + self.select + .read(cx) + .arranged_original(&active.id) + .map(|original| original.bounds.scaled(1. / self.capture_scale)) + }); + match original { + Some(original) => self.arrange_active_window(original, false, cx), + None => cx.notify(), + } + } + } + + /// Move the picked window to `frame` (this overlay's logical points), + /// then follow wherever its app actually put it. + fn arrange_active_window(&mut self, frame: AreaRect, raise: bool, cx: &mut Context) { + let Some(window) = self.select.read(cx).active_window().cloned() else { + return; + }; + let from = self + .window_drawn + .get() + .unwrap_or(window.bounds.scaled(1. / self.capture_scale)); + self.select.update(cx, |select, cx| { + select.remember_arranged(&window); + select.set_pinned(window.clone(), cx); + }); + let frame = frame.scaled(self.capture_scale); + let (id, display) = (window.id.clone(), window.display_id.clone()); + let select = self.select.clone(); + cx.spawn(async move |this, cx| { + let moved = cx + .background_executor() + .spawn(async move { + let arranged = crate::foreign_window::arrange(&id, &display, frame); + if raise { + crate::foreign_window::raise(&id); + } + let window = scap_targets::Window::from_id(&id); + ( + arranged, + window.as_ref().and_then(HoveredWindow::from_window), + ) + }) + .await; + let (arranged, fresh) = moved; + if !arranged { + tracing::warn!("the picked window could not be arranged"); + } + if let Some(fresh) = fresh { + select.update(cx, |select, cx| select.set_pinned(fresh, cx)); + } + this.update(cx, |this, cx| { + this.window_reshape = Some((from, Instant::now())); + cx.notify(); + }) + .ok(); + cx.update(app_windows::refresh_target_overlays); + }) + .detach(); + } + fn render_camera_variant(&self, cx: &mut Context) -> impl IntoElement { let preview = if let Some(camera) = &self.inline_camera { let (width, height) = camera.read(cx).inline_size(self.display_size); @@ -2422,6 +2794,7 @@ impl OverlayWindow { self.mode_menu = None; self.countdown_menu = None; self.device_menu = None; + self.window_menu = None; self.aspect_menu = Some(crate::ui::MenuState::new(position, &items)); cx.notify(); } @@ -2687,6 +3060,7 @@ impl OverlayWindow { this.mode_menu = None; this.device_menu = None; this.aspect_menu = None; + this.window_menu = None; this.countdown_menu = Some(crate::ui::MenuState::new(event.position(), &items)); cx.notify(); @@ -2879,6 +3253,7 @@ impl OverlayWindow { this.device_menu = None; this.countdown_menu = None; this.aspect_menu = None; + this.window_menu = None; this.mode_menu = Some(crate::ui::MenuState::new( event.position, &mode_menu_items(current), diff --git a/apps/desktop-gpui/src/window_arrange.rs b/apps/desktop-gpui/src/window_arrange.rs new file mode 100644 index 0000000000..809acec55d --- /dev/null +++ b/apps/desktop-gpui/src/window_arrange.rs @@ -0,0 +1,214 @@ +//! Where a picked window goes when the picker resizes or focuses it. Pure +//! geometry in one display's logical points; `platform` moves the window. + +use crate::target_overlay::AreaRect; + +/// Kept clear between a moved window and the edge of the usable area. +pub const MARGIN: f32 = 16.; + +/// The smallest window the picker will shape, on either side. +pub const MIN_SIDE: f32 = 160.; + +/// The ratios offered for a window, widest first. +pub const RATIOS: [(u32, u32); 6] = [(21, 9), (16, 9), (16, 10), (4, 3), (1, 1), (9, 16)]; + +/// Exact sizes offered, in pixels as recorded. +pub const SIZES: [(&str, u32, u32); 4] = [ + ("720p", 1280, 720), + ("1080p", 1920, 1080), + ("1440p", 2560, 1440), + ("4K", 3840, 2160), +]; + +fn inset(area: AreaRect) -> AreaRect { + let margin_x = MARGIN.min(area.width / 4.); + let margin_y = MARGIN.min(area.height / 4.); + AreaRect { + x: area.x + margin_x, + y: area.y + margin_y, + width: area.width - margin_x * 2., + height: area.height - margin_y * 2., + } +} + +/// `size` centred on (`cx`, `cy`), then pushed back inside `area`. +fn place(width: f32, height: f32, cx: f32, cy: f32, area: AreaRect) -> AreaRect { + let x = (cx - width / 2.).clamp(area.x, (area.x + area.width - width).max(area.x)); + let y = (cy - height / 2.).clamp(area.y, (area.y + area.height - height).max(area.y)); + AreaRect { + x, + y, + width, + height, + } +} + +/// `width` x `height` scaled down, keeping its ratio, until it fits `area`. +fn fit_inside(width: f32, height: f32, area: AreaRect) -> (f32, f32) { + let scale = (area.width / width).min(area.height / height).min(1.); + (width * scale, height * scale) +} + +/// The window reshaped to `ratio` with about the same area, centred where +/// it was and kept inside the usable area of its display. +pub fn fit_to_ratio(window: AreaRect, ratio: (u32, u32), work_area: AreaRect) -> AreaRect { + let area = inset(work_area); + let ratio = ratio.0 as f32 / ratio.1.max(1) as f32; + let size = (window.width * window.height).max(MIN_SIDE * MIN_SIDE); + let (mut width, mut height) = ((size * ratio).sqrt(), (size / ratio).sqrt()); + let shorter = width.min(height); + if shorter < MIN_SIDE { + width *= MIN_SIDE / shorter; + height *= MIN_SIDE / shorter; + } + let (width, height) = fit_inside(width, height, area); + place( + width, + height, + window.x + window.width / 2., + window.y + window.height / 2., + area, + ) +} + +/// The window resized so it records at `pixels` on a display drawing `scale` +/// pixels per point, or `None` when that does not fit the usable area. +pub fn fit_to_size( + window: AreaRect, + pixels: (u32, u32), + scale: f32, + work_area: AreaRect, +) -> Option { + let area = inset(work_area); + let (width, height) = (pixels.0 as f32 / scale, pixels.1 as f32 / scale); + (width <= area.width + 0.5 && height <= area.height + 0.5).then(|| { + place( + width.min(area.width), + height.min(area.height), + window.x + window.width / 2., + window.y + window.height / 2., + area, + ) + }) +} + +/// The window centred in the usable area, shrunk to fit if it is larger. +pub fn centre(window: AreaRect, work_area: AreaRect) -> AreaRect { + let area = inset(work_area); + let (width, height) = fit_inside(window.width, window.height, area); + place( + width, + height, + area.x + area.width / 2., + area.y + area.height / 2., + area, + ) +} + +/// The ratio `window` is closest to, when within 1%. +pub fn matching_ratio(window: AreaRect) -> Option<(u32, u32)> { + let actual = window.width / window.height.max(1.); + RATIOS.into_iter().find(|(w, h)| { + let ratio = *w as f32 / *h as f32; + (actual / ratio - 1.).abs() < 0.01 + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rect(x: f32, y: f32, width: f32, height: f32) -> AreaRect { + AreaRect { + x, + y, + width, + height, + } + } + + fn inside(window: AreaRect, area: AreaRect) -> bool { + window.x >= area.x - 0.01 + && window.y >= area.y - 0.01 + && window.x + window.width <= area.x + area.width + 0.01 + && window.y + window.height <= area.y + area.height + 0.01 + } + + const WORK: AreaRect = AreaRect { + x: 0., + y: 25., + width: 1512., + height: 892., + }; + + #[test] + fn a_ratio_keeps_the_area_and_the_centre() { + let window = rect(300., 200., 800., 600.); + let shaped = fit_to_ratio(window, (16, 9), WORK); + assert!((shaped.width / shaped.height - 16. / 9.).abs() < 0.001); + assert!((shaped.width * shaped.height - 800. * 600.).abs() < 1.); + assert!((shaped.x + shaped.width / 2. - 700.).abs() < 0.01); + assert!((shaped.y + shaped.height / 2. - 500.).abs() < 0.01); + } + + #[test] + fn a_ratio_near_an_edge_stays_on_screen() { + for window in [ + rect(-200., 40., 900., 700.), + rect(1100., 600., 900., 700.), + rect(0., 25., 1512., 892.), + rect(700., 30., 300., 1000.), + ] { + for ratio in RATIOS { + let shaped = fit_to_ratio(window, ratio, WORK); + assert!( + inside(shaped, inset(WORK)), + "{window:?} {ratio:?} -> {shaped:?}" + ); + let actual = shaped.width / shaped.height; + let wanted = ratio.0 as f32 / ratio.1 as f32; + assert!((actual / wanted - 1.).abs() < 0.001, "{ratio:?}"); + } + } + } + + #[test] + fn a_tiny_window_grows_to_a_usable_size() { + let shaped = fit_to_ratio(rect(500., 400., 60., 40.), (9, 16), WORK); + assert!(shaped.width >= MIN_SIDE - 0.01); + assert!((shaped.width / shaped.height - 9. / 16.).abs() < 0.001); + } + + #[test] + fn exact_sizes_fit_or_are_refused() { + let window = rect(300., 200., 800., 600.); + let hd = fit_to_size(window, (1280, 720), 2., WORK).unwrap(); + assert_eq!((hd.width, hd.height), (640., 360.)); + assert!(inside(hd, inset(WORK))); + let full = fit_to_size(window, (1920, 1080), 1., WORK); + assert!( + full.is_none(), + "1920 points does not fit a 1512 point display" + ); + let retina = fit_to_size(window, (2560, 1440), 2., WORK).unwrap(); + assert_eq!((retina.width, retina.height), (1280., 720.)); + assert!(fit_to_size(window, (3840, 2160), 2., WORK).is_none()); + } + + #[test] + fn centring_is_exact_and_shrinks_an_oversized_window() { + let centred = centre(rect(10., 600., 800., 500.), WORK); + assert!((centred.x + centred.width / 2. - 756.).abs() < 0.01); + assert!((centred.y + centred.height / 2. - (25. + 446.)).abs() < 0.01); + let big = centre(rect(0., 0., 3000., 2000.), WORK); + assert!(inside(big, inset(WORK))); + assert!((big.width / big.height - 1.5).abs() < 0.001); + } + + #[test] + fn a_window_reports_the_ratio_it_already_has() { + assert_eq!(matching_ratio(rect(0., 0., 1600., 900.)), Some((16, 9))); + assert_eq!(matching_ratio(rect(0., 0., 1601., 900.)), Some((16, 9))); + assert_eq!(matching_ratio(rect(0., 0., 1000., 700.)), None); + } +} From 22eead84c90bc8f5e85221ee1072c13aae8697e8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:38:28 +0000 Subject: [PATCH 2/9] refactor(desktop-gpui): drop the unused forget_arranged --- apps/desktop-gpui/src/target_overlay.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index c694e35b93..b4bfc039d1 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -389,10 +389,6 @@ impl TargetSelect { self.arranged.get(&id.to_string()) } - pub fn forget_arranged(&mut self, id: &WindowId) { - self.arranged.remove(&id.to_string()); - } - /// Every window the picker moved, with where it was, forgetting them. pub fn take_arranged(&mut self) -> Vec { self.arranged.drain().map(|(_, window)| window).collect() From 8ebbe516248997917373430469c8f54a72dc6427 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:06:15 +0000 Subject: [PATCH 3/9] fix(desktop-gpui): share Focus across overlays and apply it to picks, pin the size menu's window, ignore stale moves, restore maximized windows --- apps/desktop-gpui/src/app_windows.rs | 2 +- apps/desktop-gpui/src/foreign_window.rs | 67 +++++++++++- apps/desktop-gpui/src/target_overlay.rs | 130 ++++++++++++++++++------ apps/desktop-gpui/src/window_arrange.rs | 6 -- 4 files changed, 164 insertions(+), 41 deletions(-) diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 51eb8fd69f..8485df406c 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -2162,7 +2162,7 @@ fn restore_arranged_windows(cx: &mut App) { cx.background_executor() .spawn(async move { for window in arranged { - crate::foreign_window::arrange(&window.id, &window.display_id, window.bounds); + crate::foreign_window::put_back(&window); } }) .detach(); diff --git a/apps/desktop-gpui/src/foreign_window.rs b/apps/desktop-gpui/src/foreign_window.rs index 2bde061fb7..45a7159ef2 100644 --- a/apps/desktop-gpui/src/foreign_window.rs +++ b/apps/desktop-gpui/src/foreign_window.rs @@ -22,11 +22,29 @@ pub fn arrange(id: &WindowId, display: &DisplayId, frame: AreaRect) -> bool { imp::arrange(id, &display, frame) } -/// Bring window `id` and its app to the front. pub fn raise(id: &WindowId) -> bool { imp::raise(id) } +/// How a window sits beyond its bounds (maximized), where bounds alone +/// cannot put it back. +#[derive(Clone, Copy)] +pub struct Placement(imp::RawPlacement); + +pub fn placement(id: &WindowId) -> Option { + imp::placement(id).map(Placement) +} + +pub fn put_back(original: &crate::target_overlay::ArrangedWindow) -> bool { + let window = &original.window; + if let Some(placement) = original.placement + && imp::restore_placement(&window.id, &placement.0) + { + return true; + } + arrange(&window.id, &window.display_id, window.bounds) +} + #[cfg(target_os = "macos")] mod imp { use std::ffi::c_void; @@ -82,6 +100,16 @@ mod imp { unsafe { AXIsProcessTrusted() } } + pub type RawPlacement = std::convert::Infallible; + + pub fn placement(_: &WindowId) -> Option { + None + } + + pub fn restore_placement(_: &WindowId, placement: &RawPlacement) -> bool { + match *placement {} + } + /// The AX element of window `id`, owned by the returned `CFType`. fn ax_window(id: &WindowId) -> Option { let window = scap_targets::Window::from_id(id)?; @@ -172,8 +200,8 @@ mod imp { Foundation::{HWND, RECT}, Graphics::Dwm::{DWMWA_EXTENDED_FRAME_BOUNDS, DwmGetWindowAttribute}, UI::WindowsAndMessaging::{ - GetWindowRect, IsZoomed, SW_RESTORE, SWP_NOACTIVATE, SWP_NOZORDER, SetWindowPos, - ShowWindow, + GetWindowPlacement, GetWindowRect, IsZoomed, SW_RESTORE, SWP_NOACTIVATE, SWP_NOZORDER, + SetWindowPlacement, SetWindowPos, ShowWindow, WINDOWPLACEMENT, }, }; @@ -183,6 +211,29 @@ mod imp { true } + pub type RawPlacement = WINDOWPLACEMENT; + + /// Only a maximized window needs its placement: resizing restores it, + /// and its bounds then put it back as a large normal window. + pub fn placement(id: &WindowId) -> Option { + let hwnd = hwnd(id)?; + unsafe { + if IsZoomed(hwnd) == 0 { + return None; + } + let mut placement: WINDOWPLACEMENT = std::mem::zeroed(); + placement.length = std::mem::size_of::() as u32; + (GetWindowPlacement(hwnd, &mut placement) != 0).then_some(placement) + } + } + + pub fn restore_placement(id: &WindowId, placement: &RawPlacement) -> bool { + let Some(hwnd) = hwnd(id) else { + return false; + }; + unsafe { SetWindowPlacement(hwnd, placement) != 0 } + } + fn hwnd(id: &WindowId) -> Option { let window = scap_targets::Window::from_id(id)?; let hwnd = window.raw_handle().inner().0 as HWND; @@ -258,6 +309,16 @@ mod imp { !cap_recording::screenshot::uses_wayland_portal() } + pub type RawPlacement = std::convert::Infallible; + + pub fn placement(_: &WindowId) -> Option { + None + } + + pub fn restore_placement(_: &WindowId, placement: &RawPlacement) -> bool { + match *placement {} + } + fn send(window: u32, message: &[u8], data: [u32; 5]) -> anyhow::Result<()> { let (connection, screen) = x11rb::connect(None)?; let root = connection.setup().roots[screen].root; diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 9e970bdb49..3a6dfa61cd 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -303,10 +303,31 @@ pub struct TargetSelect { /// Windows the picker moved or resized, keyed by id, with where they /// were: dismissing the picker puts them back, starting a recording /// keeps them where the user shaped them. - arranged: HashMap, + arranged: HashMap, + /// Focus, shared by every display's overlay because the recording reads + /// the one setting it mirrors. + pub focus_window: bool, _poll: Option>, } +/// A window the picker moved, as the picker found it. +#[derive(Clone)] +pub struct ArrangedWindow { + pub window: HoveredWindow, + /// Set when its bounds alone cannot put it back (a maximized window). + pub placement: Option, +} + +enum Arrangement { + Frame(AreaRect), + PutBack(ArrangedWindow), +} + +fn saved_focus() -> bool { + crate::store::GeneralSettings::load().window_capture_style + == crate::store::WindowCaptureStyle::FocusedWindow +} + struct TargetSelectGlobal(Entity); impl Global for TargetSelectGlobal {} @@ -321,6 +342,7 @@ impl TargetSelect { microphone_fill: 0., icons: HashMap::new(), arranged: HashMap::new(), + focus_window: saved_focus(), _poll: None, }); cx.set_global(TargetSelectGlobal(select.clone())); @@ -343,6 +365,7 @@ impl TargetSelect { self.pinned_window = pinned_window; if self.mode.is_none() && mode.is_some() { self.arranged.clear(); + self.focus_window = saved_focus(); } if self.mode == mode { cx.notify(); @@ -381,16 +404,19 @@ impl TargetSelect { pub fn remember_arranged(&mut self, window: &HoveredWindow) { self.arranged .entry(window.id.to_string()) - .or_insert_with(|| window.clone()); + .or_insert_with(|| ArrangedWindow { + window: window.clone(), + placement: crate::foreign_window::placement(&window.id), + }); } /// Where the picker found window `id`, if it has moved it since. - pub fn arranged_original(&self, id: &WindowId) -> Option<&HoveredWindow> { + pub fn arranged_original(&self, id: &WindowId) -> Option<&ArrangedWindow> { self.arranged.get(&id.to_string()) } /// Every window the picker moved, with where it was, forgetting them. - pub fn take_arranged(&mut self) -> Vec { + pub fn take_arranged(&mut self) -> Vec { self.arranged.drain().map(|(_, window)| window).collect() } @@ -402,14 +428,16 @@ impl TargetSelect { } /// Lock the highlight onto whatever the cursor is over -- the TSX's - /// click-anywhere-to-select behavior. - fn pin_hovered(&mut self, cx: &mut Context) { + /// click-anywhere-to-select behavior. True when that picked a new window. + fn pin_hovered(&mut self, cx: &mut Context) -> bool { if let Some(hovered) = self.hovered_window.clone() && self.pinned_window.as_ref().map(|window| &window.id) != Some(&hovered.id) { self.pinned_window = Some(hovered); cx.notify(); + return true; } + false } fn start_polling(&mut self, cx: &mut Context) { @@ -938,9 +966,6 @@ pub struct OverlayWindow { window_reshape: Option<(AreaRect, Instant)>, /// Where the window highlight was last drawn, for a move to start from. window_drawn: Cell>, - /// Focus: centre the picked window and record it by itself. Mirrors the - /// window capture style setting, which it writes. - focus_window: bool, inline_camera: Option>, inline_camera_subscription: Option, empty_camera_state: crate::store::CameraWindowState, @@ -1098,8 +1123,6 @@ impl OverlayWindow { window_menu: None, window_reshape: None, window_drawn: Cell::new(None), - focus_window: crate::store::GeneralSettings::load().window_capture_style - == crate::store::WindowCaptureStyle::FocusedWindow, inline_camera: None, inline_camera_subscription: None, empty_camera_state: crate::store::load().camera_window.unwrap_or_default(), @@ -1898,7 +1921,10 @@ impl OverlayWindow { // cursor, exactly as the TSX's two click handlers add up to. .on_click(cx.listener(|this, _, _window, cx| { this.window_menu = None; - this.select.update(cx, |select, cx| select.pin_hovered(cx)); + let picked = this.select.update(cx, |select, cx| select.pin_hovered(cx)); + if picked && this.select.read(cx).focus_window { + this.set_focus(true, cx); + } // `setOriginalCameraBounds(null)` on the first click // (`target-select-overlay.tsx:609-617`): committing a pick // leaves the camera bubble parked in the picked window. @@ -2005,6 +2031,7 @@ impl OverlayWindow { return None; } let theme = self.theme; + let focus = self.select.read(cx).focus_window; Some( div() .id("window-focus") @@ -2032,11 +2059,13 @@ impl OverlayWindow { .child("Focus"), ) .child( - crate::ui::Toggle::glass(&theme, "window-focus-toggle", self.focus_window) - .on_click(cx.listener(|this, _, _, cx| { + crate::ui::Toggle::glass(&theme, "window-focus-toggle", focus).on_click( + cx.listener(|this, _, _, cx| { cx.stop_propagation(); - this.set_focus(!this.focus_window, cx); - })), + let focus = this.select.read(cx).focus_window; + this.set_focus(!focus, cx); + }), + ), ), ) } @@ -2085,6 +2114,12 @@ impl OverlayWindow { let Some(window) = self.active_window_logical(cx) else { return; }; + // The menu acts on this window until it closes, whatever the + // pointer passes over on the way to a row. + if let Some(active) = self.select.read(cx).active_window().cloned() { + self.select + .update(cx, |select, cx| select.set_pinned(active, cx)); + } let items: Vec = self .window_menu_entries(window) .into_iter() @@ -2126,7 +2161,7 @@ impl OverlayWindow { .and_then(|window| self.window_menu_entries(window).into_iter().nth(index)) .map(|(_, _, frame)| frame); match frame { - Some(frame) => self.arrange_active_window(frame, false, cx), + Some(frame) => self.arrange_active_window(Arrangement::Frame(frame), false, cx), None => cx.notify(), } } @@ -2134,7 +2169,10 @@ impl OverlayWindow { /// Focus on: centre the window, bring it forward, record it by itself. /// Focus off: put it back where the picker found it, record the region. pub(crate) fn set_focus(&mut self, focus: bool, cx: &mut Context) { - self.focus_window = focus; + self.select.update(cx, |select, cx| { + select.focus_window = focus; + cx.notify(); + }); let style = if focus { crate::store::WindowCaptureStyle::FocusedWindow } else { @@ -2153,24 +2191,31 @@ impl OverlayWindow { }; if focus { let centred = crate::window_arrange::centre(window, self.controls_work_area); - self.arrange_active_window(centred, true, cx); + self.arrange_active_window(Arrangement::Frame(centred), true, cx); } else { - let original = self.select.read(cx).active_window().and_then(|active| { - self.select - .read(cx) - .arranged_original(&active.id) - .map(|original| original.bounds.scaled(1. / self.capture_scale)) - }); + let original = self + .select + .read(cx) + .active_window() + .and_then(|active| self.select.read(cx).arranged_original(&active.id).cloned()); match original { - Some(original) => self.arrange_active_window(original, false, cx), + Some(original) => { + self.arrange_active_window(Arrangement::PutBack(original), false, cx) + } None => cx.notify(), } } } - /// Move the picked window to `frame` (this overlay's logical points), - /// then follow wherever its app actually put it. - fn arrange_active_window(&mut self, frame: AreaRect, raise: bool, cx: &mut Context) { + /// Move the picked window (`Frame` in this overlay's logical points, or + /// back where the picker found it), then follow wherever its app + /// actually put it. + fn arrange_active_window( + &mut self, + arrangement: Arrangement, + raise: bool, + cx: &mut Context, + ) { let Some(window) = self.select.read(cx).active_window().cloned() else { return; }; @@ -2182,14 +2227,24 @@ impl OverlayWindow { select.remember_arranged(&window); select.set_pinned(window.clone(), cx); }); - let frame = frame.scaled(self.capture_scale); + let capture_scale = self.capture_scale; let (id, display) = (window.id.clone(), window.display_id.clone()); + let requested = id.clone(); let select = self.select.clone(); cx.spawn(async move |this, cx| { let moved = cx .background_executor() .spawn(async move { - let arranged = crate::foreign_window::arrange(&id, &display, frame); + let arranged = match arrangement { + Arrangement::Frame(frame) => crate::foreign_window::arrange( + &id, + &display, + frame.scaled(capture_scale), + ), + Arrangement::PutBack(original) => { + crate::foreign_window::put_back(&original) + } + }; if raise { crate::foreign_window::raise(&id); } @@ -2204,6 +2259,19 @@ impl OverlayWindow { if !arranged { tracing::warn!("the picked window could not be arranged"); } + // Only while this picker is still up with the same window picked: + // a slow move must not replace a newer choice. + let still_picked = this + .update(cx, |_, cx| { + select + .read(cx) + .active_window() + .is_some_and(|window| window.id == requested) + }) + .unwrap_or(false); + if !still_picked { + return; + } if let Some(fresh) = fresh { select.update(cx, |select, cx| select.set_pinned(fresh, cx)); } diff --git a/apps/desktop-gpui/src/window_arrange.rs b/apps/desktop-gpui/src/window_arrange.rs index 809acec55d..f790712406 100644 --- a/apps/desktop-gpui/src/window_arrange.rs +++ b/apps/desktop-gpui/src/window_arrange.rs @@ -6,13 +6,10 @@ use crate::target_overlay::AreaRect; /// Kept clear between a moved window and the edge of the usable area. pub const MARGIN: f32 = 16.; -/// The smallest window the picker will shape, on either side. pub const MIN_SIDE: f32 = 160.; -/// The ratios offered for a window, widest first. pub const RATIOS: [(u32, u32); 6] = [(21, 9), (16, 9), (16, 10), (4, 3), (1, 1), (9, 16)]; -/// Exact sizes offered, in pixels as recorded. pub const SIZES: [(&str, u32, u32); 4] = [ ("720p", 1280, 720), ("1080p", 1920, 1080), @@ -31,7 +28,6 @@ fn inset(area: AreaRect) -> AreaRect { } } -/// `size` centred on (`cx`, `cy`), then pushed back inside `area`. fn place(width: f32, height: f32, cx: f32, cy: f32, area: AreaRect) -> AreaRect { let x = (cx - width / 2.).clamp(area.x, (area.x + area.width - width).max(area.x)); let y = (cy - height / 2.).clamp(area.y, (area.y + area.height - height).max(area.y)); @@ -43,7 +39,6 @@ fn place(width: f32, height: f32, cx: f32, cy: f32, area: AreaRect) -> AreaRect } } -/// `width` x `height` scaled down, keeping its ratio, until it fits `area`. fn fit_inside(width: f32, height: f32, area: AreaRect) -> (f32, f32) { let scale = (area.width / width).min(area.height / height).min(1.); (width * scale, height * scale) @@ -92,7 +87,6 @@ pub fn fit_to_size( }) } -/// The window centred in the usable area, shrunk to fit if it is larger. pub fn centre(window: AreaRect, work_area: AreaRect) -> AreaRect { let area = inset(work_area); let (width, height) = fit_inside(window.width, window.height, area); From 6e2ab57612f413e3bc30899a51037c1aec249d05 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:20:30 +0000 Subject: [PATCH 4/9] fix(desktop-gpui): run window moves and restores in order, and pick the smoke's test window outright --- apps/desktop-gpui/src/app_windows.rs | 14 +++---- apps/desktop-gpui/src/foreign_window.rs | 56 +++++++++++++++++++++++++ apps/desktop-gpui/src/picker_probe.rs | 22 ++++++++-- apps/desktop-gpui/src/target_overlay.rs | 39 ++++++++--------- 4 files changed, 98 insertions(+), 33 deletions(-) diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 8485df406c..3bc52cd113 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -2159,13 +2159,13 @@ fn restore_arranged_windows(cx: &mut App) { if arranged.is_empty() { return; } - cx.background_executor() - .spawn(async move { - for window in arranged { - crate::foreign_window::put_back(&window); - } - }) - .detach(); + // Queued behind any move still running, which would otherwise land + // after the restore. The job runs whether or not this is awaited. + let _ = crate::foreign_window::in_order(move || { + for window in arranged { + crate::foreign_window::put_back(&window); + } + }); } pub fn dismiss_target_overlays(cx: &mut App) { diff --git a/apps/desktop-gpui/src/foreign_window.rs b/apps/desktop-gpui/src/foreign_window.rs index 45a7159ef2..ca92db980e 100644 --- a/apps/desktop-gpui/src/foreign_window.rs +++ b/apps/desktop-gpui/src/foreign_window.rs @@ -26,6 +26,39 @@ pub fn raise(id: &WindowId) -> bool { imp::raise(id) } +type Job = Box; + +/// Run `job` after every job queued before it, on one worker thread. Moves +/// and restores go through here so a cancel's restore can never land before +/// a move the user started first. +pub fn in_order( + job: impl FnOnce() -> T + Send + 'static, +) -> impl std::future::Future> { + static QUEUE: std::sync::OnceLock> = std::sync::OnceLock::new(); + let queue = QUEUE.get_or_init(|| { + let (sender, receiver) = flume::unbounded::(); + let spawned = std::thread::Builder::new() + .name("cap-window-arrange".into()) + .spawn(move || { + for job in receiver.iter() { + job(); + } + }); + if let Err(error) = spawned { + tracing::error!(%error, "could not start the window arrange thread"); + } + sender + }); + let (done, result) = flume::bounded(1); + let queued = queue.send(Box::new(move || { + let _ = done.send(job()); + })); + async move { + queued.ok()?; + result.recv_async().await.ok() + } +} + /// How a window sits beyond its bounds (maximized), where bounds alone /// cannot put it back. #[derive(Clone, Copy)] @@ -373,3 +406,26 @@ mod imp { send(window, b"_NET_ACTIVE_WINDOW", [2, 0, 0, 0, 0]).is_ok() } } + +#[cfg(test)] +mod tests { + #[test] + fn queued_jobs_run_in_the_order_they_were_queued() { + let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let slow = order.clone(); + let first = super::in_order(move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + slow.lock().unwrap().push("move"); + }); + let fast = order.clone(); + let second = super::in_order(move || fast.lock().unwrap().push("restore")); + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + runtime.block_on(async { + assert_eq!(second.await, Some(())); + assert_eq!(first.await, Some(())); + }); + assert_eq!(*order.lock().unwrap(), ["move", "restore"]); + } +} diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index d8d68dd0f9..57e6a5d07b 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -128,11 +128,25 @@ async fn probe_window_arrangement( if !crate::foreign_window::can_arrange() { return serde_json::json!({ "skipped": "cannot arrange other apps' windows here" }); } + // The workflow opens one of these; pick it outright rather than relying + // on where the runner left the cursor. + let test_window = scap_targets::Window::list().into_iter().find(|window| { + window.owner_name().is_some_and(|owner| { + let owner = owner.to_lowercase(); + ["textedit", "notepad", "xterm"] + .iter() + .any(|name| owner.contains(name)) + }) + }); let picked = cx.update(|cx| { - crate::target_overlay::TargetSelect::global(cx) - .read(cx) - .active_window() - .cloned() + let select = crate::target_overlay::TargetSelect::global(cx); + if let Some(window) = test_window + .as_ref() + .and_then(crate::target_overlay::HoveredWindow::from_window) + { + select.update(cx, |select, cx| select.set_pinned(window, cx)); + } + select.read(cx).active_window().cloned() }); let Some(picked) = picked else { return serde_json::json!({ "passed": false, "error": "no window to arrange" }); diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 3a6dfa61cd..e5778522d3 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -2232,29 +2232,24 @@ impl OverlayWindow { let requested = id.clone(); let select = self.select.clone(); cx.spawn(async move |this, cx| { - let moved = cx - .background_executor() - .spawn(async move { - let arranged = match arrangement { - Arrangement::Frame(frame) => crate::foreign_window::arrange( - &id, - &display, - frame.scaled(capture_scale), - ), - Arrangement::PutBack(original) => { - crate::foreign_window::put_back(&original) - } - }; - if raise { - crate::foreign_window::raise(&id); + let moved = crate::foreign_window::in_order(move || { + let arranged = match arrangement { + Arrangement::Frame(frame) => { + crate::foreign_window::arrange(&id, &display, frame.scaled(capture_scale)) } - let window = scap_targets::Window::from_id(&id); - ( - arranged, - window.as_ref().and_then(HoveredWindow::from_window), - ) - }) - .await; + Arrangement::PutBack(original) => crate::foreign_window::put_back(&original), + }; + if raise { + crate::foreign_window::raise(&id); + } + let window = scap_targets::Window::from_id(&id); + ( + arranged, + window.as_ref().and_then(HoveredWindow::from_window), + ) + }) + .await + .unwrap_or((false, None)); let (arranged, fresh) = moved; if !arranged { tracing::warn!("the picked window could not be arranged"); From 0fbf152c82ae365c641bd99bb6fea85c96d1f332 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:07:57 +0000 Subject: [PATCH 5/9] fix(desktop-gpui): say when Instant with a camera keeps the screen region, and spawn the restore --- apps/desktop-gpui/src/app_windows.rs | 14 ++++++++------ apps/desktop-gpui/src/target_overlay.rs | 23 ++++++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 3bc52cd113..daf91a1062 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -2160,12 +2160,14 @@ fn restore_arranged_windows(cx: &mut App) { return; } // Queued behind any move still running, which would otherwise land - // after the restore. The job runs whether or not this is awaited. - let _ = crate::foreign_window::in_order(move || { - for window in arranged { - crate::foreign_window::put_back(&window); - } - }); + // after the restore. + cx.background_executor() + .spawn(crate::foreign_window::in_order(move || { + for window in arranged { + crate::foreign_window::put_back(&window); + } + })) + .detach(); } pub fn dismiss_target_overlays(cx: &mut App) { diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 133620c3fe..0231c51146 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -2028,6 +2028,21 @@ impl OverlayWindow { } let theme = self.theme; let focus = self.select.read(cx).focus_window; + // Instant records the camera bubble off the screen, so with a camera + // it keeps the screen region (see `instant_capture_window`). + let camera = cx + .global::() + .main + .read(cx) + .is_ok_and(|view| view.camera_selection().is_some()); + let tooltip = if cfg!(any(target_os = "macos", windows)) + && self.select.read(cx).recording_mode == Mode::Instant + && camera + { + "Centre this window. With a camera, Instant mode still records the screen region, so the camera stays in the video" + } else { + "Centre this window and record only it, nothing in front of it" + }; Some( div() .id("window-focus") @@ -2040,13 +2055,7 @@ impl OverlayWindow { .gap(px(10.)) .rounded_full() .bg(gpui::hsla(0., 0., 0., 0.28)) - .tooltip(move |_, cx| { - crate::ui::Tooltip::new( - &theme, - "Centre this window and record only it, nothing in front of it", - ) - .view(cx) - }) + .tooltip(move |_, cx| crate::ui::Tooltip::new(&theme, tooltip).view(cx)) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child( div() From cbeda162410253d45f72cf5891157d51eae6d15d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:03:21 +0000 Subject: [PATCH 6/9] fix(desktop-gpui): colour the window size chip's chevron --- apps/desktop-gpui/src/target_overlay.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index f1832f98cc..3676949c8a 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -2014,7 +2014,12 @@ impl OverlayWindow { .when(arrangeable, |chip| { chip.cursor_pointer() .hover(|style| style.bg(gpui::hsla(0., 0., 0., 0.4))) - .child(svg().path("icons/chevron-down.svg").size(px(12.))) + .child( + svg() + .path("icons/chevron-down.svg") + .size(px(12.)) + .text_color(theme.gray_12), + ) .on_click(cx.listener(|this, event: &gpui::ClickEvent, _, cx| { cx.stop_propagation(); this.open_window_menu(event.position(), cx); From d6c80f60b648ab3f3ed9705462a60c349b8ec6d3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:20:33 +0000 Subject: [PATCH 7/9] fix(desktop-gpui): wait for pending window moves before recording and put moved windows back on every non-recording exit --- apps/desktop-gpui/src/app_windows.rs | 67 +++++++++++++++++-------- apps/desktop-gpui/src/foreign_window.rs | 18 +++++++ apps/desktop-gpui/src/picker_probe.rs | 36 ++++++++++--- apps/desktop-gpui/src/target_overlay.rs | 8 ++- 4 files changed, 98 insertions(+), 31 deletions(-) diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index daf91a1062..8ee10de1d0 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -916,6 +916,7 @@ pub(crate) fn show_main_window_after_capture_pause(cx: &mut App) { windows.main_hidden_for_picker || !windows.overlays.is_empty() }; if picker_open { + restore_arranged_windows(cx); close_target_overlays(cx); } cx.global_mut::().main_hidden_for_picker = false; @@ -1097,6 +1098,7 @@ pub fn request_close_main(cx: &mut App) { } close_camera_window(cx); + restore_arranged_windows(cx); close_target_overlays(cx); // Closing the main window explicitly is not a picker dismissal; a stale // flag here would reveal the window the user just closed. @@ -2146,30 +2148,27 @@ fn open_overlays_core(request: OverlayRequest, cx: &mut App) -> bool { true } +/// Put back every window the picker moved or resized: the picker closed +/// without recording them. +fn restore_arranged_windows(cx: &mut App) { + let arranged = TargetSelect::global(cx).update(cx, |select, _| select.take_arranged()); + crate::foreign_window::put_back_all(arranged, cx.background_executor()); +} + +/// A recording or screenshot is about to capture the windows the picker +/// shaped, so they stay where they are. +fn keep_arranged_windows(cx: &mut App) { + TargetSelect::global(cx).update(cx, |select, _| { + select.take_arranged(); + }); +} + /// Close the overlays and clear the main window's armed target -- Escape, the /// overlay's own close button, or the main window toggling the mode off. /// /// A "cancelled" dismissal in the Tauri vocabulary, which is the kind that /// reveals the main window again (`dismissalReveals`, /// `new-main/index.tsx:2047-2058`). -/// Put back every window the picker moved or resized. Off the main thread: -/// `SetWindowPos` waits on the target app, which may be busy. -fn restore_arranged_windows(cx: &mut App) { - let arranged = TargetSelect::global(cx).update(cx, |select, _| select.take_arranged()); - if arranged.is_empty() { - return; - } - // Queued behind any move still running, which would otherwise land - // after the restore. - cx.background_executor() - .spawn(crate::foreign_window::in_order(move || { - for window in arranged { - crate::foreign_window::put_back(&window); - } - })) - .detach(); -} - pub fn dismiss_target_overlays(cx: &mut App) { restore_arranged_windows(cx); if RecordingSession::global(cx).read(cx).phase == Phase::Idle @@ -2437,7 +2436,31 @@ pub fn show_instant_sign_in(kind: crate::main_window::TargetType, cx: &mut App) show_main_window(cx); } +/// How long Start waits for the picker's window moves to land. A move waits +/// on the target app, and a hung app must not hold the recording forever. +const ARRANGE_SETTLE_LIMIT: std::time::Duration = std::time::Duration::from_millis(1500); + pub fn start_recording_from_overlay(target: ScreenCaptureTarget, cx: &mut App) { + if !TargetSelect::global(cx).read(cx).has_arranged() { + start_recording_from_overlay_now(target, cx); + return; + } + // A size the user just chose may still be on its way to the window, and + // the capture reads the window's bounds when it starts. + let settled = crate::foreign_window::in_order(|| ()); + cx.spawn(async move |cx| { + let limit = cx.background_executor().timer(ARRANGE_SETTLE_LIMIT); + futures_util::future::select(std::pin::pin!(settled), std::pin::pin!(limit)).await; + cx.update(|cx| { + if !cx.global::().overlays.is_empty() { + start_recording_from_overlay_now(target, cx); + } + }); + }) + .detach(); +} + +fn start_recording_from_overlay_now(target: ScreenCaptureTarget, cx: &mut App) { let main = cx.global::().main; if RecordingSession::global(cx).read(cx).phase != Phase::Idle || main @@ -2446,10 +2469,7 @@ pub fn start_recording_from_overlay(target: ScreenCaptureTarget, cx: &mut App) { { return; } - // The user shaped the window to record it: it stays where it is. - TargetSelect::global(cx).update(cx, |select, _| { - select.take_arranged(); - }); + keep_arranged_windows(cx); // Collected before the windows go away: the capture starts a beat after // this, and an overlay that has not finished closing must not end up in // the recording. @@ -3959,6 +3979,7 @@ fn begin_recording_after_preflight(config: StartConfig, cx: &mut App) { if session.read(cx).phase != Phase::Idle { return; } + keep_arranged_windows(cx); // `start_recording`'s first act (`src-tauri/src/recording.rs:1492-1494`): // an armed editor recording target forces Studio, whatever mode the main @@ -6681,6 +6702,7 @@ pub fn abort_editor_recording_flow(cx: &mut App) { }) else { return; }; + restore_arranged_windows(cx); close_target_overlays(cx); cx.global_mut::().editor_hidden_for_picker = None; cx.global_mut::().main_hidden_for_picker = false; @@ -6871,6 +6893,7 @@ pub fn prepare_for_screenshot_capture(cx: &mut App) -> bool { // The screenshot branch of `onRecordingStart` nulls the saved camera bounds // just like the recording branch does, so a parked bubble stays parked. release_camera_park(cx); + keep_arranged_windows(cx); close_target_overlays(cx); true } diff --git a/apps/desktop-gpui/src/foreign_window.rs b/apps/desktop-gpui/src/foreign_window.rs index ca92db980e..5f267e130e 100644 --- a/apps/desktop-gpui/src/foreign_window.rs +++ b/apps/desktop-gpui/src/foreign_window.rs @@ -68,6 +68,24 @@ pub fn placement(id: &WindowId) -> Option { imp::placement(id).map(Placement) } +/// Put `windows` back off the main thread (a move waits on the target app), +/// queued behind any move still running so it cannot land after them. +pub fn put_back_all( + windows: Vec, + executor: &gpui::BackgroundExecutor, +) { + if windows.is_empty() { + return; + } + executor + .spawn(in_order(move || { + for window in windows { + put_back(&window); + } + })) + .detach(); +} + pub fn put_back(original: &crate::target_overlay::ArrangedWindow) -> bool { let window = &original.window; if let Some(placement) = original.placement diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index 57e6a5d07b..77150e955b 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -196,27 +196,47 @@ async fn probe_window_arrangement( (rect.x + rect.width / 2. - width / 2.).abs() < width * 0.04 }); screenshot(output, "window-focus", cx).await; + let near = |a: Option, b: Option| { + a.zip(b).is_some_and(|(a, b)| { + (a.x - b.x).abs() < 6. + && (a.y - b.y).abs() < 6. + && (a.width - b.width).abs() < 6. + && (a.height - b.height).abs() < 6. + }) + }; overlay .update(cx, |view, _, cx| view.set_focus(false, cx)) .ok(); - sleep(cx, 600).await; + sleep(cx, 1500).await; + let unfocused = read_window(&picked.id); + let unfocused_ok = !near(unfocused, focused) || near(focused, before); + // Dismissed with the window still moved: only the dismissal can put it + // back. + overlay + .update(cx, |view, _, cx| view.set_focus(true, cx)) + .ok(); + sleep(cx, 1500).await; + let moved = !near(read_window(&picked.id), before); cx.update(app_windows::dismiss_target_overlays); sleep(cx, 1500).await; let restored = read_window(&picked.id); - let restored_ok = restored.zip(before).is_some_and(|(now, then)| { - (now.x - then.x).abs() < 6. - && (now.y - then.y).abs() < 6. - && (now.width - then.width).abs() < 6. - && (now.height - then.height).abs() < 6. - }); + let restored_ok = moved && near(restored, before); serde_json::json!({ - "passed": ratio_ok && inside(shaped) && centred && inside(focused) && restored_ok, + "passed": ratio_ok + && inside(shaped) + && centred + && inside(focused) + && unfocused_ok + && restored_ok, "before": rect_json(before), "sixteenByNine": rect_json(shaped), "ratioOk": ratio_ok, "focused": rect_json(focused), "centred": centred, + "unfocused": rect_json(unfocused), + "unfocusedOk": unfocused_ok, + "movedBeforeDismiss": moved, "restored": rect_json(restored), "restoredOk": restored_ok, }) diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 3676949c8a..87e43a103e 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -364,7 +364,9 @@ impl TargetSelect { self.recording_mode = recording_mode; self.pinned_window = pinned_window; if self.mode.is_none() && mode.is_some() { - self.arranged.clear(); + // Left over from a picker that closed some way that neither put + // them back nor recorded them. + crate::foreign_window::put_back_all(self.take_arranged(), cx.background_executor()); self.focus_window = saved_focus(); } if self.mode == mode { @@ -415,6 +417,10 @@ impl TargetSelect { self.arranged.get(&id.to_string()) } + pub fn has_arranged(&self) -> bool { + !self.arranged.is_empty() + } + /// Every window the picker moved, with where it was, forgetting them. pub fn take_arranged(&mut self) -> Vec { self.arranged.drain().map(|(_, window)| window).collect() From a8bcd6c54ed74c019a4d4a8802bdcf80b607a9b2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:37:12 +0000 Subject: [PATCH 8/9] fix(desktop-gpui): tie a waiting Start to its picker, give up on windows still moving, keep moved windows until recording commits --- apps/desktop-gpui/src/app_windows.rs | 47 +++++++++++++++++++++++----- apps/desktop-gpui/src/main_window.rs | 3 ++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 8ee10de1d0..6251daf978 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -2155,6 +2155,15 @@ fn restore_arranged_windows(cx: &mut App) { crate::foreign_window::put_back_all(arranged, cx.background_executor()); } +/// A start the user turned back from (a storage or microphone prompt): the +/// windows the picker shaped go back. Never while a capture runs, which may be +/// recording them. +pub(crate) fn restore_arranged_windows_if_idle(cx: &mut App) { + if RecordingSession::global(cx).read(cx).phase == Phase::Idle { + restore_arranged_windows(cx); + } +} + /// A recording or screenshot is about to capture the windows the picker /// shaped, so they stay where they are. fn keep_arranged_windows(cx: &mut App) { @@ -2406,6 +2415,14 @@ pub fn refresh_microphone_overlays(cx: &mut App) { } /// The overlays' native window numbers, for capture exclusion. +fn overlay_handles(cx: &App) -> Vec { + cx.global::() + .overlays + .iter() + .map(|(_, handle)| (*handle).into()) + .collect() +} + fn overlay_window_ids(cx: &mut App) -> Vec { let handles: Vec<_> = cx .global::() @@ -2437,8 +2454,9 @@ pub fn show_instant_sign_in(kind: crate::main_window::TargetType, cx: &mut App) } /// How long Start waits for the picker's window moves to land. A move waits -/// on the target app, and a hung app must not hold the recording forever. -const ARRANGE_SETTLE_LIMIT: std::time::Duration = std::time::Duration::from_millis(1500); +/// on the target app; one still pending after this does not start a +/// recording of bounds that are about to change. +const ARRANGE_SETTLE_LIMIT: std::time::Duration = std::time::Duration::from_secs(5); pub fn start_recording_from_overlay(target: ScreenCaptureTarget, cx: &mut App) { if !TargetSelect::global(cx).read(cx).has_arranged() { @@ -2446,15 +2464,31 @@ pub fn start_recording_from_overlay(target: ScreenCaptureTarget, cx: &mut App) { return; } // A size the user just chose may still be on its way to the window, and - // the capture reads the window's bounds when it starts. + // the capture reads the window's bounds when it starts. The Start belongs + // to these overlays: dismissing them, or opening a new picker, drops it. + let overlays = overlay_handles(cx); let settled = crate::foreign_window::in_order(|| ()); cx.spawn(async move |cx| { let limit = cx.background_executor().timer(ARRANGE_SETTLE_LIMIT); - futures_util::future::select(std::pin::pin!(settled), std::pin::pin!(limit)).await; + let settled = matches!( + futures_util::future::select(std::pin::pin!(settled), std::pin::pin!(limit)).await, + futures_util::future::Either::Left(_) + ); cx.update(|cx| { - if !cx.global::().overlays.is_empty() { - start_recording_from_overlay_now(target, cx); + let current = overlay_handles(cx); + if current.is_empty() || current != overlays { + return; + } + if !settled { + tracing::warn!("a picked window is still moving; recording not started"); + RecordingSession::global(cx).update(cx, |session, cx| { + session.error = + Some("The window is still being resized. Try starting again.".into()); + cx.notify(); + }); + return; } + start_recording_from_overlay_now(target, cx); }); }) .detach(); @@ -2469,7 +2503,6 @@ fn start_recording_from_overlay_now(target: ScreenCaptureTarget, cx: &mut App) { { return; } - keep_arranged_windows(cx); // Collected before the windows go away: the capture starts a beat after // this, and an overlay that has not finished closing must not end up in // the recording. diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 0d13686274..0238c1b158 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -2458,6 +2458,7 @@ impl MainWindow { fn dismiss_microphone_warning(&mut self, cx: &mut Context) { self.cancel_deep_link_start(); + cx.defer(app_windows::restore_arranged_windows_if_idle); cx.notify(); } @@ -2503,6 +2504,7 @@ impl MainWindow { return; } this.finish_recording_start(&permit); + cx.defer(app_windows::restore_arranged_windows_if_idle); this.session.update(cx, |session, cx| { session.error = Some(tr::main::storage::check_failed(&error)); cx.notify(); @@ -2589,6 +2591,7 @@ impl MainWindow { this.check_storage_before_start(config, true, permit, cx); } else { this.finish_recording_start(&permit); + cx.defer(app_windows::restore_arranged_windows_if_idle); if this.session.read(cx).editor_recording_target().is_some() { cx.defer(app_windows::abort_editor_recording_flow); } From be4fe9de9a9759c26d5481fc9480937464e2f59e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:17:40 +0000 Subject: [PATCH 9/9] fix(desktop-gpui): close the picker and show the main window when Start gives up on a moving window --- apps/desktop-gpui/src/app_windows.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 6251daf978..5ad90c0928 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -2481,6 +2481,10 @@ pub fn start_recording_from_overlay(target: ScreenCaptureTarget, cx: &mut App) { } if !settled { tracing::warn!("a picked window is still moving; recording not started"); + // The picker has nowhere to show the error: close it, which + // puts the windows back, and bring the main window forward. + dismiss_target_overlays(cx); + show_main_window(cx); RecordingSession::global(cx).update(cx, |session, cx| { session.error = Some("The window is still being resized. Try starting again.".into());