diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml index 3128f0b8de..c47a2d8eb5 100644 --- a/.github/workflows/picker-smoke.yml +++ b/.github/workflows/picker-smoke.yml @@ -9,6 +9,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/**" @@ -136,7 +138,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 cb71a925e6..264f0a1f46 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 a131b7e53b..7ff6bc5433 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 a7f14bdb95..9e0d655831 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 fc0ba2fb48..ba96b90cbd 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 cb8580cf65..9394e830a3 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 b399668f13..b06a92cb0c 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 74f089d808..e444014c30 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 593798e39c..5ad90c0928 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,6 +2148,30 @@ 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 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) { + 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. /// @@ -2153,6 +2179,7 @@ fn open_overlays_core(request: OverlayRequest, cx: &mut App) -> bool { /// reveals the main window again (`dismissalReveals`, /// `new-main/index.tsx:2047-2058`). 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) @@ -2388,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::() @@ -2418,7 +2453,52 @@ 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; 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() { + 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. 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); + let settled = matches!( + futures_util::future::select(std::pin::pin!(settled), std::pin::pin!(limit)).await, + futures_util::future::Either::Left(_) + ); + cx.update(|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"); + // 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()); + cx.notify(); + }); + return; + } + 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 @@ -3936,6 +4016,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 @@ -6658,6 +6739,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; @@ -6848,6 +6930,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 new file mode 100644 index 0000000000..5f267e130e --- /dev/null +++ b/apps/desktop-gpui/src/foreign_window.rs @@ -0,0 +1,449 @@ +//! 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) +} + +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)] +pub struct Placement(imp::RawPlacement); + +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 + && 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; + + 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() } + } + + 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)?; + 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::{ + GetWindowPlacement, GetWindowRect, IsZoomed, SW_RESTORE, SWP_NOACTIVATE, SWP_NOZORDER, + SetWindowPlacement, SetWindowPos, ShowWindow, WINDOWPLACEMENT, + }, + }; + + use super::*; + + pub fn can_arrange() -> bool { + 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; + (!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() + } + + 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; + 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() + } +} + +#[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/i18n/settings_surface_tests.rs b/apps/desktop-gpui/src/i18n/settings_surface_tests.rs index 75b4ccc4e3..4cae02da1d 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/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); } diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index 49a960459a..77150e955b 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -22,6 +22,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), @@ -70,14 +71,24 @@ pub fn run(main: WindowHandle, cx: &mut App) { mode["overlayInactiveBeforeActivation"] = inactive_before.into(); modes.push(mode); 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!({ "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!({ + "modes": modes, + "windowArrangement": window_arrangement, + }); // The verdict leads so `run.sh` can read it without parsing JSON: // nested checks carry `passed` keys of their own. if let Err(error) = std::fs::write( @@ -96,6 +107,141 @@ 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" }); + } + // 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| { + 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" }); + }; + 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; + 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, 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 = moved && near(restored, before); + serde_json::json!({ + "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, + }) +} + async fn screenshot(output: &std::ffi::OsStr, label: &str, cx: &mut AsyncApp) { let Some(directory) = std::path::Path::new(output).parent() else { return; 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 d2b83d7c0d..f5024bc239 100644 --- a/apps/desktop-gpui/src/store.rs +++ b/apps/desktop-gpui/src/store.rs @@ -797,6 +797,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 { @@ -1027,6 +1038,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, @@ -1435,6 +1447,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 3816d12ed0..87e43a103e 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -300,9 +300,34 @@ 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, + /// 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 {} @@ -316,6 +341,8 @@ impl TargetSelect { pinned_window: None, microphone_fill: 0., icons: HashMap::new(), + arranged: HashMap::new(), + focus_window: saved_focus(), _poll: None, }); cx.set_global(TargetSelectGlobal(select.clone())); @@ -336,6 +363,12 @@ impl TargetSelect { ) { self.recording_mode = recording_mode; self.pinned_window = pinned_window; + if self.mode.is_none() && mode.is_some() { + // 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 { cx.notify(); return; @@ -369,15 +402,48 @@ 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(|| 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<&ArrangedWindow> { + 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() + } + + 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) { + /// 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) { @@ -386,9 +452,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 +471,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; @@ -876,6 +961,13 @@ 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>, inline_camera: Option>, inline_camera_subscription: Option, empty_camera_state: crate::store::CameraWindowState, @@ -1030,6 +1122,9 @@ impl OverlayWindow { countdown_menu: None, aspect_menu: None, reshape: None, + window_menu: None, + window_reshape: None, + window_drawn: Cell::new(None), inline_camera: None, inline_camera_subscription: None, empty_camera_state: crate::store::load().camera_window.unwrap_or_default(), @@ -1274,6 +1369,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), @@ -1330,7 +1438,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, @@ -1341,6 +1449,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() } @@ -1476,6 +1585,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, @@ -1770,7 +1880,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() @@ -1781,11 +1895,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() @@ -1795,7 +1922,11 @@ 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.select.update(cx, |select, cx| select.pin_hovered(cx)); + this.window_menu = None; + 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. @@ -1806,8 +1937,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() @@ -1835,16 +1966,336 @@ 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.)) + .text_color(theme.gray_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; + 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") + .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, tooltip).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", focus).on_click( + cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + let focus = this.select.read(cx).focus_window; + this.set_focus(!focus, 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; + }; + // 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() + .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(Arrangement::Frame(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.select.update(cx, |select, cx| { + select.focus_window = focus; + cx.notify(); + }); + 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(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).cloned()); + match original { + Some(original) => { + self.arrange_active_window(Arrangement::PutBack(original), false, cx) + } + None => cx.notify(), + } + } + } + + /// 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; + }; + 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 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 = crate::foreign_window::in_order(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 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"); + } + // 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)); + } + 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); @@ -2418,6 +2869,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(); } @@ -2681,6 +3133,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(); @@ -2873,6 +3326,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..f790712406 --- /dev/null +++ b/apps/desktop-gpui/src/window_arrange.rs @@ -0,0 +1,208 @@ +//! 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.; + +pub const MIN_SIDE: f32 = 160.; + +pub const RATIOS: [(u32, u32); 6] = [(21, 9), (16, 9), (16, 10), (4, 3), (1, 1), (9, 16)]; + +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., + } +} + +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, + } +} + +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, + ) + }) +} + +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); + } +}