From 18fd781113208457642a99148399fb724b5ad605 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:55:01 +0000 Subject: [PATCH 1/6] feat(desktop-gpui): area mode lock, window snapping, click to fit and clearer handles --- apps/desktop-gpui/assets/icons/lock-open.svg | 1 + apps/desktop-gpui/src/assets.rs | 1 + apps/desktop-gpui/src/camera_window.rs | 110 +- apps/desktop-gpui/src/picker_probe.rs | 106 ++ apps/desktop-gpui/src/target_overlay.rs | 1029 +++++++++++++++--- 5 files changed, 1075 insertions(+), 172 deletions(-) create mode 100644 apps/desktop-gpui/assets/icons/lock-open.svg diff --git a/apps/desktop-gpui/assets/icons/lock-open.svg b/apps/desktop-gpui/assets/icons/lock-open.svg new file mode 100644 index 00000000000..5c49a4adb5d --- /dev/null +++ b/apps/desktop-gpui/assets/icons/lock-open.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 28fe176178a..1561d7822e8 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -199,6 +199,7 @@ const ICONS: &[(&str, &[u8])] = assets!("icons": // (gpui's `svg()` keeps only the alpha). "circle-check.svg", "lock.svg", + "lock-open.svg", // The screenshot editor's annotation toolbar, layers panel and header // (`AnnotationTools.tsx`, `LayersPanel.tsx`, `Header.tsx`). Lucide 24x24 // originals, matching the `~icons/lucide/*` imports over there. diff --git a/apps/desktop-gpui/src/camera_window.rs b/apps/desktop-gpui/src/camera_window.rs index defcf1f9fab..6ff82096f63 100644 --- a/apps/desktop-gpui/src/camera_window.rs +++ b/apps/desktop-gpui/src/camera_window.rs @@ -135,6 +135,22 @@ mod inline_preview_tests { ); } + #[test] + fn a_shrunk_preview_fits_its_toolbar_inside_it() { + // Not shrunk: the size-driven scale and every button. + assert_eq!(toolbar_fit(0.85, None), (0.85, 5)); + // Wide enough: unchanged. + assert_eq!(toolbar_fit(0.7, Some(300.)), (0.7, 5)); + for width in [81., 100., 120., 144., 160., 200.] { + let (scale, buttons) = toolbar_fit(1., Some(width)); + let drawn = (42. * buttons as f32 + 4.) * scale + 2.; + assert!(drawn <= width - 8. + 0.01, "{width}: {drawn}"); + assert!(scale >= 0.4); + } + // A portrait sliver keeps close, size and shape. + assert_eq!(toolbar_fit(1., Some(81.)).1, 3); + } + #[test] fn camera_only_wide_preview_preserves_aspect_and_toolbar_space() { let state = CameraWindowState { @@ -157,6 +173,27 @@ fn normalized_size(state: &CameraWindowState) -> f32 { (clamp_size(state.size) - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE) } +/// How the toolbar fits a preview `width` wide (`None` when the picker has +/// not shrunk it): its scale and how many of its buttons show. Each button is +/// a 22px icon in 8px padding, 4px apart, in a 4px-padded strip with a 1px +/// border. A narrow preview drops the preview-only blur, then mirror, buttons +/// before the rest get too small to hit, so the toolbar stays inside it. +fn toolbar_fit(base_scale: f32, width: Option) -> (f32, usize) { + const SMALLEST_COMFORTABLE: f32 = 0.6; + const SMALLEST: f32 = 0.4; + let Some(width) = width else { + return (base_scale, 5); + }; + let fit = |buttons: usize| (width - 10.) / (42. * buttons as f32 + 4.); + for buttons in [5, 4] { + let scale = base_scale.min(fit(buttons)); + if scale >= SMALLEST_COMFORTABLE { + return (scale, buttons); + } + } + (base_scale.min(fit(3)).max(SMALLEST), 3) +} + /// The visible bubble's corner radius. The shipped Tauri experience is the /// legacy webview preview (`enable_native_camera_preview` defaults to false, /// re-forced by the `native_camera_preview_default_rollback_v1` migration, @@ -2202,8 +2239,11 @@ impl CameraWindow { .or(self.retained_aspect) } - fn toolbar_scale(&self) -> f32 { - 0.7 + normalized_size(&self.state) * 0.3 + fn toolbar_fit(&self) -> (f32, usize) { + toolbar_fit( + 0.7 + normalized_size(&self.state) * 0.3, + self.picker_size.map(|(width, _)| width), + ) } /// Pushes the chrome inputs the preview renders with (its own notify is @@ -2454,7 +2494,7 @@ impl CameraWindow { fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { let theme = self.theme; - let scale = self.toolbar_scale(); + let (scale, buttons) = self.toolbar_fit(); #[cfg(target_os = "macos")] let (mirror_failed, blur_failed) = (self.effect_failures.mirror, self.effect_failures.blur); #[cfg(not(target_os = "macos"))] @@ -2533,36 +2573,40 @@ impl CameraWindow { // Preview-only, like both Tauri paths (the WGSL UV flip / // the legacy `scaleX(-1)`); the recorded camera track is // never mirrored by either app. - .child(self.toolbar_button( - "mirror", - "icons/arrows.svg", - self.state.mirrored && !mirror_failed, - scale, - mirror_failed.then_some("!"), - cx, - |this, window, cx| { - this.mutate_state(window, cx, |state| { - state.mirrored = !state.mirrored; - }) - }, - )) - .child(self.toolbar_button( - "blur", - "icons/person-standing.svg", - self.state.background_blur != BlurMode::Off && !blur_failed, - scale, - if blur_failed { - Some("!") - } else { - self.state.background_blur.label() - }, - cx, - |this, window, cx| { - this.mutate_state(window, cx, |state| { - state.background_blur = state.background_blur.cycle(); - }) - }, - )), + .when(buttons >= 4, |this| { + this.child(self.toolbar_button( + "mirror", + "icons/arrows.svg", + self.state.mirrored && !mirror_failed, + scale, + mirror_failed.then_some("!"), + cx, + |this, window, cx| { + this.mutate_state(window, cx, |state| { + state.mirrored = !state.mirrored; + }) + }, + )) + }) + .when(buttons >= 5, |this| { + this.child(self.toolbar_button( + "blur", + "icons/person-standing.svg", + self.state.background_blur != BlurMode::Off && !blur_failed, + scale, + if blur_failed { + Some("!") + } else { + self.state.background_blur.label() + }, + cx, + |this, window, cx| { + this.mutate_state(window, cx, |state| { + state.background_blur = state.background_blur.cycle(); + }) + }, + )) + }), ) } diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index a5244dcd4bb..6e3516c3521 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -668,6 +668,87 @@ async fn drag_cpu( cpu_percent_since(cpu, wall) } +/// The process's memory footprint in MB, as Activity Monitor counts it. +#[cfg(target_os = "macos")] +fn footprint_mb() -> Option { + let mut info = std::mem::MaybeUninit::::zeroed(); + let status = unsafe { + libc::proc_pid_rusage( + std::process::id() as i32, + libc::RUSAGE_INFO_V2, + info.as_mut_ptr().cast(), + ) + }; + (status == 0).then(|| unsafe { info.assume_init() }.ri_phys_footprint as f64 / 1_048_576.) +} + +#[cfg(not(target_os = "macos"))] +fn footprint_mb() -> Option { + None +} + +/// Drag the area through its own mouse handlers (snapping, the camera, the +/// panels following) for `millis`, with mouse events at 250 Hz, grabbing it at +/// `grab`: the middle moves it, a corner resizes it. Reports the CPU use and +/// how evenly frames came: their rate and the longest gaps between them. +async fn drag_frames( + overlay: &WindowHandle, + base: AreaRect, + grab: (f32, f32), + millis: u64, + cx: &mut AsyncApp, +) -> serde_json::Value { + use crate::target_overlay::{ProbeDrag, probe_frames}; + let drag = |step: ProbeDrag, point: (f32, f32), cx: &mut AsyncApp| { + cx.update(|cx| { + overlay + .update(cx, |view, _, cx| view.probe_drag(step, point, cx)) + .ok(); + }); + }; + cx.update(|cx| { + overlay + .update(cx, |view, _, cx| view.probe_offset_area(base, 0., cx)) + .ok(); + }); + sleep(cx, 300).await; + drag(ProbeDrag::Press, grab, cx); + probe_frames::begin(); + let (cpu, wall) = (cpu_secs(), std::time::Instant::now()); + let mut events = 0u32; + while wall.elapsed() < Duration::from_millis(millis) { + let t = events as f32; + let point = ( + grab.0 + 180. * (t * 0.02).sin(), + grab.1 + 90. * (t * 0.013).sin(), + ); + drag(ProbeDrag::Move, point, cx); + sleep(cx, 4).await; + events += 1; + } + let cpu = cpu_percent_since(cpu, wall); + let seconds = wall.elapsed().as_secs_f64(); + let frames = probe_frames::end(); + drag(ProbeDrag::Release, grab, cx); + let mut gaps: Vec = frames + .windows(2) + .map(|pair| (pair[1] - pair[0]).as_secs_f64() * 1000.) + .collect(); + gaps.sort_by(f64::total_cmp); + let percentile = |p: f64| { + gaps.get(((gaps.len() as f64 * p) as usize).min(gaps.len().saturating_sub(1))) + .copied() + }; + serde_json::json!({ + "cpuPercent": cpu, + "fps": frames.len() as f64 / seconds, + "mouseEvents": events, + "medianGapMs": percentile(0.5), + "p99GapMs": percentile(0.99), + "maxGapMs": gaps.last(), + }) +} + /// What the picker costs: the process's CPU use with the area picker left /// alone, and while its panels move, with and without the desktop capture /// under their glass, measured back to back in one process. @@ -687,10 +768,35 @@ async fn probe_perf(cx: &mut AsyncApp) -> serde_json::Value { crate::overlay_backdrop::set_disabled(true); let dragging_without_backdrop = drag_cpu(&overlays, base, 6000, cx).await; crate::overlay_backdrop::set_disabled(false); + + // A real drag through the handlers: moving, then resizing from a corner, + // then a long run of both to show whether memory creeps. + let handler_drags = match overlays.first() { + Some((_, overlay)) => { + let middle = (base.x + base.width / 2., base.y + base.height / 2.); + let corner = (base.x + base.width, base.y + base.height); + let moving = drag_frames(overlay, base, middle, 6000, cx).await; + let resizing = drag_frames(overlay, base, corner, 6000, cx).await; + let warm = footprint_mb(); + for _ in 0..3 { + drag_frames(overlay, base, middle, 5000, cx).await; + drag_frames(overlay, base, corner, 5000, cx).await; + } + let after = footprint_mb(); + serde_json::json!({ + "move": moving, + "resize": resizing, + "footprintMbAfterWarmup": warm, + "footprintMbAfter30sMoreDragging": after, + }) + } + None => serde_json::Value::Null, + }; serde_json::json!({ "idleCpuPercent": idle, "dragCpuPercent": dragging, "dragCpuPercentWithoutBackdrop": dragging_without_backdrop, "backdropCaptureMs": capture_ms, + "handlerDrags": handler_drags, }) } diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 13ba78b025d..da608e99008 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -127,12 +127,11 @@ fn recording_devices_available(cx: &App) -> bool { .is_ok_and(|view| !view.is_preparing_recording()) } -/// How close to an edge or corner counts as grabbing that handle. The TSX's -/// corner buttons are 30px boxes hung 12px outside the crop and its edge -/// buttons are 10px strips straddling the border; this is the same reach -/// expressed as a distance test, because the hit zones are computed in one -/// place here rather than being eight elements with their own listeners. -const AREA_HANDLE_GRAB: f32 = 12.; +/// How close to an edge or corner counts as grabbing that handle: wider than +/// the TSX's 12px so a handle is easy to catch. A distance test, because the +/// hit zones are computed in one place here rather than being eight elements +/// with their own listeners. +const AREA_HANDLE_GRAB: f32 = 18.; /// `w-104` on the controls cluster, and the height its one card comes out at /// (`p-3` + `h-11` + `p-3`, plus `my-2.5` above and below). The TSX measures @@ -224,9 +223,15 @@ fn norm_area_rect(rect: AreaRect, display_size: (f32, f32)) -> NormRect { } } -fn snap_area_rect(rect: AreaRect, display_size: (f32, f32)) -> (AreaRect, Vec) { +/// Snap a moving area to the display's edges and centre lines, and to the +/// edges and centres of `others` (the work area and the windows on screen). +fn snap_area_rect( + rect: AreaRect, + display_size: (f32, f32), + others: &[NormRect], +) -> (AreaRect, Vec) { let rect = rect.clamped(display_size); - let targets = build_snap_targets(&[], None); + let targets = build_snap_targets(others, None); let normalized = norm_area_rect(rect, display_size); let (dx, dy, guides) = snap_moving_rect( normalized, @@ -245,17 +250,158 @@ fn snap_area_rect(rect: AreaRect, display_size: (f32, f32)) -> (AreaRect, Vec ((f32, f32), Vec) { +fn snap_area_point( + point: (f32, f32), + display_size: (f32, f32), + others: &[NormRect], +) -> ((f32, f32), Vec) { let rect = AreaRect { x: point.0, y: point.1, width: 0., height: 0., }; - let (snapped, guides) = snap_area_rect(rect, display_size); + let (snapped, guides) = snap_area_rect(rect, display_size, others); ((snapped.x, snapped.y), guides) } +/// Snap the edges a resize handle moves: only the axes it drags snap, so an +/// edge handle never nudges the other axis. +fn snap_resize_point( + point: (f32, f32), + display_size: (f32, f32), + others: &[NormRect], + horizontal: bool, + vertical: bool, +) -> ((f32, f32), Vec) { + let ((x, y), guides) = snap_area_point(point, display_size, others); + let guides = guides + .into_iter() + .filter(|guide| match guide.axis { + GuideAxis::V => horizontal, + GuideAxis::H => vertical, + }) + .collect(); + ( + ( + if horizontal { x } else { point.0 }, + if vertical { y } else { point.1 }, + ), + guides, + ) +} + +/// The smallest window an area snaps to: anything smaller is a palette or a +/// badge, and its edges would only get in the way. +const AREA_SNAP_WINDOW_MIN: f32 = 48.; + +/// What an area snaps to besides the display: the work area (inside the menu +/// bar, dock or taskbar) and the windows on this display, normalised to it. +fn area_snap_rects( + work_area: AreaRect, + windows: &[AreaRect], + display_size: (f32, f32), +) -> Vec { + let whole = |rect: &AreaRect| { + rect.x.abs() < 1. + && rect.y.abs() < 1. + && (rect.width - display_size.0).abs() < 1. + && (rect.height - display_size.1).abs() < 1. + }; + std::iter::once(work_area) + .chain(windows.iter().copied()) + .filter_map(|rect| rect.intersected(display_size)) + .filter(|rect| { + rect.width >= AREA_SNAP_WINDOW_MIN + && rect.height >= AREA_SNAP_WINDOW_MIN + && !whole(rect) + }) + .map(|rect| norm_area_rect(rect, display_size)) + .collect() +} + +#[cfg(debug_assertions)] +#[derive(Clone, Copy)] +pub(crate) enum ProbeDrag { + Press, + Move, + Release, +} + +/// When each overlay frame rendered while the picker probe is timing a drag. +#[cfg(debug_assertions)] +pub(crate) mod probe_frames { + use std::sync::Mutex; + use std::time::Instant; + + static FRAMES: Mutex>> = Mutex::new(None); + + pub(crate) fn rendered() { + if let Ok(mut frames) = FRAMES.lock() + && let Some(frames) = frames.as_mut() + { + frames.push(Instant::now()); + } + } + + pub(crate) fn begin() { + if let Ok(mut frames) = FRAMES.lock() { + *frames = Some(Vec::with_capacity(4096)); + } + } + + pub(crate) fn end() -> Vec { + FRAMES + .lock() + .ok() + .and_then(|mut frames| frames.take()) + .unwrap_or_default() + } +} + +/// Whether the held modifiers turn area snapping off: Command on macOS, Ctrl +/// elsewhere. +fn snap_off(modifiers: &gpui::Modifiers) -> bool { + if cfg!(target_os = "macos") { + modifiers.platform + } else { + modifiers.control + } +} + +/// A rule-of-thirds grid over the area while it is being drawn or dragged. +fn render_thirds(crop: AreaRect) -> impl IntoElement { + let line = gpui::hsla(0., 0., 1., 0.28); + let mut grid = div() + .absolute() + .left(px(crop.x)) + .top(px(crop.y)) + .w(px(crop.width)) + .h(px(crop.height)); + for third in [1. / 3., 2. / 3.] { + grid = grid + .child( + div() + .absolute() + .top_0() + .left(px((crop.width * third).round())) + .w(px(1.)) + .h_full() + .bg(line), + ) + .child( + div() + .absolute() + .left_0() + .top(px((crop.height * third).round())) + .h(px(1.)) + .w_full() + .bg(line), + ); + } + grid +} + /// The window under the cursor, as the overlays need it. #[derive(Clone, Debug, PartialEq)] pub struct HoveredWindow { @@ -612,6 +758,61 @@ fn store_area_ratio(ratio: Option<(u32, u32)>) { }); } +/// The locked area: the display it was drawn on and its bounds there, so the +/// next picker opens with the same area. +fn stored_locked_area() -> Option<(String, AreaRect)> { + let section = crate::store::store_section(AREA_SELECTION_STORE); + if !section.get("locked")?.as_bool()? { + return None; + } + let display = section.get("screenId")?.as_str()?.to_string(); + let bounds = section.get("bounds")?; + let value = |key: &str| bounds.get(key)?.as_f64().map(|value| value as f32); + let bounds = AreaRect { + x: value("x")?, + y: value("y")?, + width: value("width")?, + height: value("height")?, + }; + [bounds.x, bounds.y, bounds.width, bounds.height] + .iter() + .all(|value| value.is_finite()) + .then_some((display, bounds)) +} + +fn store_locked_area(locked: Option<(&DisplayId, AreaRect)>) { + crate::store::update_store_section(AREA_SELECTION_STORE, |section| { + match locked { + Some((display, bounds)) => { + section.insert("locked".into(), true.into()); + section.insert("screenId".into(), display.to_string().into()); + section.insert( + "bounds".into(), + serde_json::json!({ + "x": bounds.x, + "y": bounds.y, + "width": bounds.width, + "height": bounds.height, + }), + ); + } + None => { + section.insert("locked".into(), false.into()); + } + } + true + }); +} + +/// The locked area for a display, fitted to its current size, if it is still +/// big enough to record. +fn locked_area_for(display: &DisplayId, display_size: (f32, f32)) -> Option { + let (locked_display, bounds) = stored_locked_area()?; + (locked_display == display.to_string()) + .then(|| bounds.clamped(display_size)) + .filter(|bounds| bounds.is_valid_for(AREA_MIN_SIZE)) +} + const QUICK_AREA_RATIOS: [Option<(u32, u32)>; 5] = [ None, Some((16, 9)), @@ -817,6 +1018,18 @@ impl AreaRect { self.width >= min && self.height >= min } + /// The part of this rect on a `display`-sized screen, if any. + fn intersected(self, display: (f32, f32)) -> Option { + let (left, top) = (self.x.max(0.), self.y.max(0.)); + let (right, bottom) = (self.right().min(display.0), self.bottom().min(display.1)); + (right > left && bottom > top).then_some(Self { + x: left, + y: top, + width: right - left, + height: bottom - top, + }) + } + fn clamped(self, display: (f32, f32)) -> Self { let width = self.width.min(display.0); let height = self.height.min(display.1); @@ -969,6 +1182,17 @@ pub struct OverlayWindow { snap_guides: Vec, /// The aspect ratio a drawn or resized area keeps, `None` for free. area_ratio: Option<(u32, u32)>, + /// A locked area is kept for the next picker and holds still: no drag + /// moves, resizes or redraws it until it is unlocked. + area_locked: bool, + /// What an area snaps to besides the display, see [`area_snap_rects`]. + area_snap: Vec, + area_windows_requested: bool, + /// The part of the area under the pointer, while nothing is dragged. + area_hover: Option, + /// The camera still has to follow the area: after a drag step (so it + /// moves once a frame) or a restored area. + area_camera_pending: bool, recording_area: bool, /// The mode menu the start pill's caret drops down -- `menuModes()` /// popped up at the event location (`target-select-overlay.tsx:2064-2090, @@ -1126,6 +1350,7 @@ impl OverlayWindow { height: logical.1, }) .clamped(logical); + let locked_area = locked_area_for(&display.id(), logical); Self { theme, @@ -1142,10 +1367,15 @@ impl OverlayWindow { .map(|size| (size.width() as u32, size.height() as u32)), refresh_rate: display.refresh_rate(), focus: cx.focus_handle(), - crop: None, + crop: locked_area, drag: None, snap_guides: Vec::new(), area_ratio: stored_area_ratio(), + area_locked: locked_area.is_some(), + area_snap: area_snap_rects(controls_work_area, &[], logical), + area_windows_requested: false, + area_hover: None, + area_camera_pending: locked_area.is_some(), recording_area: false, mode_menu: None, countdown_menu: None, @@ -1428,6 +1658,23 @@ impl OverlayWindow { self.backdrop.image() } + /// Drive the area's own mouse handlers, for the picker probe's drag + /// timing: press at `point`, move to it, or release there. + #[cfg(debug_assertions)] + pub(crate) fn probe_drag( + &mut self, + step: ProbeDrag, + point: (f32, f32), + cx: &mut Context, + ) { + let position = gpui::point(px(point.0), px(point.1)); + match step { + ProbeDrag::Press => self.area_mouse_down(position, cx), + ProbeDrag::Move => self.area_mouse_move(position, false, cx), + ProbeDrag::Release => self.area_mouse_up(position, false, cx), + } + } + /// Move the drawn area `offset` points right, the way a drag would, for /// the picker probe's timing. #[cfg(debug_assertions)] @@ -1513,6 +1760,8 @@ impl OverlayWindow { impl Render for OverlayWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + #[cfg(debug_assertions)] + probe_frames::rendered(); self.sync_appearance(window, cx); self.sync_inline_camera(window, cx); self.sync_backdrop(window, cx); @@ -1536,77 +1785,79 @@ impl Render for OverlayWindow { .id("overlay-root") .track_focus(&self.focus) .key_context("TargetSelectOverlay") - .on_key_down( - cx.listener(|this, event: &gpui::KeyDownEvent, _window, cx| { - if let Some(menu) = &mut this.countdown_menu { - match menu.on_key(&event.keystroke.key) { - crate::ui::MenuKey::Commit(index) => this.choose_countdown(index, cx), - crate::ui::MenuKey::Dismiss => { - this.countdown_menu = None; - cx.notify(); - } - crate::ui::MenuKey::Moved => cx.notify(), - crate::ui::MenuKey::Ignored => return, + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| { + if let Some(menu) = &mut this.countdown_menu { + match menu.on_key(&event.keystroke.key) { + crate::ui::MenuKey::Commit(index) => this.choose_countdown(index, cx), + crate::ui::MenuKey::Dismiss => { + this.countdown_menu = None; + cx.notify(); } - cx.stop_propagation(); - return; + crate::ui::MenuKey::Moved => cx.notify(), + crate::ui::MenuKey::Ignored => 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), - crate::ui::MenuKey::Dismiss => { - this.aspect_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.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(); } - cx.stop_propagation(); - return; + crate::ui::MenuKey::Moved => cx.notify(), + crate::ui::MenuKey::Ignored => return, } - if let Some(menu) = &mut this.device_menu { - match menu.state.on_key(&event.keystroke.key) { - crate::ui::MenuKey::Commit(index) => this.choose_device(index, cx), - crate::ui::MenuKey::Dismiss => { - this.device_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), + crate::ui::MenuKey::Dismiss => { + this.aspect_menu = None; + cx.notify(); } - cx.stop_propagation(); - return; + crate::ui::MenuKey::Moved => cx.notify(), + crate::ui::MenuKey::Ignored => return, } - // The Tauri app registers Escape as a *global* shortcut while - // the overlays are up; here it is a plain key handler on the - // overlay that has focus (see the README deviation). - tracing::debug!(key = %event.keystroke.key, "overlay key"); - if event.keystroke.key.as_str() == "escape" { - // An open mode menu takes the Escape before the - // overlay does -- the native menu in the Tauri app - // eats it the same way during menu tracking. - if this.mode_menu.is_some() { - this.mode_menu = None; + cx.stop_propagation(); + return; + } + if let Some(menu) = &mut this.device_menu { + match menu.state.on_key(&event.keystroke.key) { + crate::ui::MenuKey::Commit(index) => this.choose_device(index, cx), + crate::ui::MenuKey::Dismiss => { + this.device_menu = None; cx.notify(); - } else { - this.dismiss(cx); } + crate::ui::MenuKey::Moved => cx.notify(), + crate::ui::MenuKey::Ignored => return, } - }), - ) + cx.stop_propagation(); + return; + } + // The Tauri app registers Escape as a *global* shortcut while + // the overlays are up; here it is a plain key handler on the + // overlay that has focus (see the README deviation). + tracing::debug!(key = %event.keystroke.key, "overlay key"); + if this.area_key(&event.keystroke, window, cx) { + cx.stop_propagation(); + return; + } + if event.keystroke.key.as_str() == "escape" { + // An open mode menu takes the Escape before the + // overlay does -- the native menu in the Tauri app + // eats it the same way during menu tracking. + if this.mode_menu.is_some() { + this.mode_menu = None; + cx.notify(); + } else { + this.dismiss(cx); + } + } + })) .size_full() .relative() .font_family(crate::theme::UI_FONT) @@ -2795,26 +3046,34 @@ impl OverlayWindow { let min = area_min_size(self.select.read(cx).recording_mode); let valid = crop.is_some_and(|crop| crop.is_valid_for(min)); let interactive = !self.recording_area; + let editable = interactive && !self.area_locked; + if interactive && !self.area_windows_requested { + self.load_area_windows(cx); + } + if std::mem::take(&mut self.area_camera_pending) { + self.sync_area_camera(cx); + } let mut root = div() .id("area-root") .size_full() .relative() .when(interactive, |this| { - this.cursor(gpui::CursorStyle::Crosshair) + this.when(editable, |this| this.cursor(gpui::CursorStyle::Crosshair)) .on_mouse_down( MouseButton::Left, cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| { this.area_mouse_down(event.position, cx); }), ) - .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { - this.area_mouse_move(event.position, cx); + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| { + this.area_hover_at(event.position, window, cx); + this.area_mouse_move(event.position, snap_off(&event.modifiers), cx); })) .on_mouse_up( MouseButton::Left, cx.listener(|this, event: &MouseUpEvent, _window, cx| { - this.area_mouse_up(event.position, cx); + this.area_mouse_up(event.position, snap_off(&event.modifiers), cx); }), ) }); @@ -2869,7 +3128,15 @@ impl OverlayWindow { }; if let Some(shown) = shown { - root = root.child(self.render_crop_region(shown, interactive)); + root = root.child(self.render_crop_region(shown, editable)); + if interactive && self.drag.is_some() { + root = root + .child(render_thirds(shown)) + .child(self.render_area_size(shown)); + } + if interactive && self.area_locked { + root = root.child(self.render_locked_badge(shown, cx)); + } } root = root.children(self.render_snap_guides()); @@ -2887,20 +3154,82 @@ impl OverlayWindow { } } - /// `border border-white/50` around the selection, plus the eight resize - /// zones. The zones carry only a cursor: the drag itself is dispatched from - /// the root's mouse-down through [`Self::area_zone_at`], so a grab that - /// starts a pixel outside the handle still resizes. + /// The selection's bounds and handles: a white border with a dark line + /// just outside it, so it shows over light and dark content alike; round + /// knobs on the corners and grips on the edges, each growing under the + /// pointer; and the eight resize zones. The zones carry only a cursor: the + /// drag itself is dispatched from the root's mouse-down through + /// [`Self::area_zone_at`], so a grab that starts a pixel outside the + /// handle still resizes. fn render_crop_region(&self, crop: AreaRect, interactive: bool) -> impl IntoElement { + let hover = self + .area_hover + .filter(|_| interactive && self.drag.is_none()); + let moving = matches!(self.drag, Some(AreaDrag::Move { .. })); + let resizing = match self.drag { + Some(AreaDrag::Resize { handle, .. }) => Some(handle), + _ => None, + }; let mut region = div() .absolute() .left(px(crop.x)) .top(px(crop.y)) .w(px(crop.width)) .h(px(crop.height)) - .border_1() - .border_color(gpui::hsla(0., 0., 1., 0.5)) - .when(interactive, |this| this.cursor(gpui::CursorStyle::OpenHand)); + .border_2() + .border_color(gpui::hsla(0., 0., 1., if interactive { 0.95 } else { 0.7 })) + .when(hover == Some(AreaZone::Inside) || moving, |this| { + this.bg(gpui::hsla(0., 0., 1., 0.06)) + }) + .when(interactive, |this| { + this.cursor(if moving { + gpui::CursorStyle::ClosedHand + } else { + gpui::CursorStyle::OpenHand + }) + }) + .child( + div() + .absolute() + .top(px(-3.)) + .left(px(-3.)) + .right(px(-3.)) + .bottom(px(-3.)) + .border_1() + .border_color(gpui::hsla(0., 0., 0., 0.45)), + ); + + // Under the pointer, before any press: how to move it. + if hover == Some(AreaZone::Inside) && crop.width >= 220. && crop.height >= 120. { + region = region.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + div() + .h(px(28.)) + .px(px(10.)) + .flex() + .items_center() + .gap(px(6.)) + .rounded_full() + .bg(gpui::hsla(0., 0., 0., 0.62)) + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(gpui::white()) + .child( + svg() + .path("icons/move.svg") + .size(px(13.)) + .text_color(gpui::white()), + ) + .child("Drag to move"), + ), + ); + } for handle in AreaHandle::ALL.into_iter().filter(|_| interactive) { let mut zone = div().id(handle.id()).absolute().cursor(handle.cursor()); @@ -2940,42 +3269,34 @@ impl OverlayWindow { } }; - // The corner glyph: the TSX draws an `M0 0 H12 M0 0 V12` stroke in - // white at `stroke-width 4`. gpui's `div` has no paths, so the same - // L is two 4px bars. - if handle.is_corner() { - let bar = |width: f32, height: f32| { - div() - .absolute() - .w(px(width)) - .h(px(height)) - .bg(gpui::white()) - }; - let arm = 18.; - let thickness = 4.; - let mut horizontal = bar(arm, thickness); - let mut vertical = bar(thickness, arm); - horizontal = if handle.west() { - horizontal.left(px(AREA_HANDLE_GRAB)) - } else { - horizontal.right(px(AREA_HANDLE_GRAB)) - }; - vertical = if handle.west() { - vertical.left(px(AREA_HANDLE_GRAB)) + // A knob on each corner, a grip on the middle of each edge (left + // off a side too short to hold one clear of the corners), both + // white with a dark rim and growing while hovered or dragged. + let hot = hover == Some(AreaZone::Handle(handle)) || resizing == Some(handle); + let horizontal = handle.north() || handle.south(); + let side = if horizontal { crop.width } else { crop.height }; + let knob = if handle.is_corner() { + let size = if hot { 18. } else { 14. }; + Some(div().size(px(size))) + } else if side >= 80. { + let (long, short) = if hot { (36., 10.) } else { (28., 8.) }; + let (width, height) = if horizontal { + (long, short) } else { - vertical.right(px(AREA_HANDLE_GRAB)) + (short, long) }; - horizontal = if handle.north() { - horizontal.top(px(AREA_HANDLE_GRAB)) - } else { - horizontal.bottom(px(AREA_HANDLE_GRAB)) - }; - vertical = if handle.north() { - vertical.top(px(AREA_HANDLE_GRAB)) - } else { - vertical.bottom(px(AREA_HANDLE_GRAB)) - }; - zone = zone.child(horizontal).child(vertical); + Some(div().w(px(width)).h(px(height))) + } else { + None + }; + if let Some(knob) = knob { + zone = zone.flex().items_center().justify_center().child( + knob.rounded_full() + .bg(gpui::white()) + .border_1() + .border_color(gpui::hsla(0., 0., 0., 0.35)) + .shadow_md(), + ); } region = region.child(zone); @@ -3118,9 +3439,23 @@ impl OverlayWindow { ) .child( div() - .text_size(px(16.)) - .font_weight(FontWeight::MEDIUM) - .child("Click and drag to select an area"), + .flex() + .flex_col() + .items_center() + .gap(px(6.)) + .child( + div() + .text_size(px(16.)) + .font_weight(FontWeight::MEDIUM) + .child("Click and drag to select an area"), + ) + .child( + div() + .text_size(px(13.)) + .font_weight(FontWeight::NORMAL) + .text_color(gpui::hsla(0., 0., 1., 0.72)) + .child("Or click a window to fit the area to it"), + ), ), ) } @@ -3135,8 +3470,9 @@ impl OverlayWindow { valid: bool, cx: &mut Context, ) -> impl IntoElement { - const WIDTH: f32 = 520.; + const WIDTH: f32 = 600.; let theme = self.theme; + let locked = self.area_locked; let label = match crop.filter(|_| valid) { Some(crop) => format!("{} × {}", crop.width.round(), crop.height.round()), None => "Draw an area".to_string(), @@ -3273,10 +3609,270 @@ impl OverlayWindow { .body("Grow the area to cover the display, keeping its ratio."), cx, ), + ) + .child(divider()) + .child( + self.hinted( + icon_button( + "area-lock", + if locked { + "icons/lock.svg" + } else { + "icons/lock-open.svg" + }, + ) + .when(locked, |button| { + button.bg(Theme::with_alpha(theme.gray_12, 0.12)) + }) + .when(!locked && !valid, |button| button.opacity(0.4)) + .on_click(cx.listener(|this, _, _, cx| this.toggle_area_lock(cx))), + "area-lock", + if locked { + Hint::new("Unlock area") + .body("Let the area be moved, resized or drawn again.") + } else if valid { + Hint::new("Lock area").body( + "Keep this area for next time, and stop a stray drag from changing it.", + ) + } else { + Hint::new("Lock area").body("Draw an area first, then lock it.") + }, + cx, + ), ), ) } + /// Fit the size readout beside the area while it is being drawn or + /// dragged, where the eye already is: under its bottom-right corner, or + /// inside it when the screen runs out. + fn render_area_size(&self, crop: AreaRect) -> impl IntoElement { + const HEIGHT: f32 = 24.; + const GAP: f32 = 8.; + let label = format!("{} × {}", crop.width.round(), crop.height.round()); + let below = crop.bottom() + GAP + HEIGHT <= self.display_size.1; + let top = if below { + crop.bottom() + GAP + } else { + (crop.bottom() - GAP - HEIGHT).max(0.) + }; + div() + .absolute() + .top(px(top)) + .right(px((self.display_size.0 - crop.right()).max(0.))) + .h(px(HEIGHT)) + .px(px(8.)) + .flex() + .items_center() + .rounded(px(6.)) + .bg(gpui::hsla(0., 0., 0., 0.72)) + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(gpui::white()) + .child(label) + } + + /// The badge on a locked area: says why it will not move, and unlocks it. + fn render_locked_badge(&self, crop: AreaRect, cx: &mut Context) -> impl IntoElement { + div() + .absolute() + .left(px(crop.x + 8.)) + .top(px(crop.y + 8.)) + .child( + self.hinted( + div() + .id("area-locked-badge") + .h(px(26.)) + .px(px(9.)) + .flex() + .items_center() + .gap(px(6.)) + .rounded_full() + .cursor_pointer() + .bg(gpui::hsla(0., 0., 0., 0.62)) + .hover(|style| style.bg(gpui::hsla(0., 0., 0., 0.78))) + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(gpui::white()) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(|this, _, _, cx| this.toggle_area_lock(cx))) + .child( + svg() + .path("icons/lock.svg") + .size(px(12.)) + .text_color(gpui::white()), + ) + .child("Locked"), + "area-locked-badge", + Hint::new("Area locked") + .body("Click to unlock it, so it can be moved, resized or drawn again."), + cx, + ), + ) + } + + /// Lock the area (kept for next time, and held still) or unlock it. + fn toggle_area_lock(&mut self, cx: &mut Context) { + if self.area_locked { + self.area_locked = false; + store_locked_area(None); + } else { + let min = area_min_size(self.select.read(cx).recording_mode); + let Some(crop) = self.crop.filter(|crop| crop.is_valid_for(min)) else { + return; + }; + self.area_locked = true; + self.drag = None; + self.snap_guides.clear(); + store_locked_area(Some((&self.display_id, crop))); + } + cx.notify(); + } + + /// Keep a locked area's stored bounds in step with a toolbar change. Only + /// the display the lock belongs to writes, so another display's overlay + /// never takes the lock over. + fn persist_locked_area(&self) { + let Some(crop) = self.crop.filter(|_| self.area_locked) else { + return; + }; + if stored_locked_area().is_some_and(|(display, _)| display == self.display_id.to_string()) { + store_locked_area(Some((&self.display_id, crop))); + } + } + + /// The windows on this display, for the area to snap to. Read once per + /// picker, off the main thread. + fn load_area_windows(&mut self, cx: &mut Context) { + self.area_windows_requested = true; + let display = self.display_id.clone(); + let scale = self.capture_scale; + cx.spawn(async move |this, cx| { + let windows = cx + .background_executor() + .spawn(async move { + crate::devices::list_window_targets() + .into_iter() + .filter_map(|(_, window)| HoveredWindow::from_window(&window)) + .filter(|window| window.display_id == display) + .map(|window| window.bounds.scaled(1. / scale)) + .collect::>() + }) + .await; + this.update(cx, |this, _| { + this.area_snap = + area_snap_rects(this.controls_work_area, &windows, this.display_size); + }) + .ok(); + }) + .detach(); + } + + /// A click with no drag fits the area to the window under it. + fn fit_area_to_window_at(&mut self, point: (f32, f32), cx: &mut Context) { + let display = self.display_id.clone(); + let scale = self.capture_scale; + cx.spawn(async move |this, cx| { + let window = cx + .background_executor() + .spawn(async move { + crate::devices::topmost_foreign_window_at_cursor() + .as_ref() + .and_then(HoveredWindow::from_window) + }) + .await; + let fitted = this + .update(cx, |this, cx| { + let window = window.filter(|window| window.display_id == display)?; + // A drag or a lock since the click wins. + if this.crop.is_some() || this.drag.is_some() || this.area_locked { + return None; + } + let mut bounds = window + .bounds + .scaled(1. / scale) + .intersected(this.display_size)?; + if let Some(ratio) = this.area_ratio { + bounds = area_with_ratio(bounds, ratio_value(ratio), this.display_size); + } + let min = area_min_size(this.select.read(cx).recording_mode); + if !bounds.is_valid_for(min) { + return None; + } + this.crop = Some(AreaRect { + x: point.0, + y: point.1, + width: 0., + height: 0., + }); + this.reshape_area(bounds, cx); + Some(()) + }) + .ok() + .flatten(); + if fitted.is_some() { + cx.update(app_windows::refresh_target_overlays); + } + }) + .detach(); + } + + /// The area's keys: arrows nudge it (Shift for 10 points), Return starts, + /// and Escape mid-drag puts it back. True when the key was used. + fn area_key( + &mut self, + keystroke: &gpui::Keystroke, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.recording_area || self.select.read(cx).mode != Some(TargetType::Area) { + return false; + } + let key = keystroke.key.as_str(); + if key == "escape" + && let Some(drag) = self.drag.take() + { + self.crop = match drag { + AreaDrag::Draw { .. } => None, + AreaDrag::Move { start, .. } | AreaDrag::Resize { start, .. } => Some(start), + }; + self.snap_guides.clear(); + self.sync_area_camera(cx); + cx.notify(); + return true; + } + if key == "enter" && self.drag.is_none() && self.target(cx).is_some() { + self.start_recording(window, cx); + return true; + } + let step = if keystroke.modifiers.shift { 10. } else { 1. }; + let (dx, dy) = match key { + "left" => (-step, 0.), + "right" => (step, 0.), + "up" => (0., -step), + "down" => (0., step), + _ => return false, + }; + let Some(crop) = self + .crop + .filter(|_| !self.area_locked && self.drag.is_none()) + else { + return false; + }; + self.reshape = None; + self.crop = Some( + AreaRect { + x: crop.x + dx, + y: crop.y + dy, + ..crop + } + .clamped(self.display_size), + ); + self.sync_area_camera(cx); + cx.notify(); + true + } + fn open_aspect_menu(&mut self, position: Point, cx: &mut Context) { let items = self.aspect_menu_items(); self.mode_menu = None; @@ -3354,6 +3950,7 @@ impl OverlayWindow { }; self.reshape = Some((from, Instant::now())); self.crop = Some(to.clamped(self.display_size)); + self.persist_locked_area(); self.sync_area_camera(cx); cx.notify(); } @@ -3364,6 +3961,10 @@ impl OverlayWindow { } fn reset_area(&mut self, cx: &mut Context) { + if self.area_locked { + self.area_locked = false; + store_locked_area(None); + } self.reshape = None; self.crop = None; self.sync_area_camera(cx); @@ -3804,12 +4405,30 @@ impl OverlayWindow { } /// Area interaction: which part of the selection a point grabs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AreaZone { Handle(AreaHandle), Inside, } impl OverlayWindow { + /// Track which part of the area the pointer is over, so the handles and + /// the move cue answer before a press. + fn area_hover_at( + &mut self, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let hover = (self.drag.is_none() && !self.area_locked && !self.recording_area) + .then(|| self.area_zone_at((f32::from(position.x), f32::from(position.y)))) + .flatten(); + if hover != self.area_hover { + self.area_hover = hover; + repaint(window, cx); + } + } + fn area_zone_at(&self, point: (f32, f32)) -> Option { let crop = self.crop?; let (x, y) = point; @@ -3843,6 +4462,11 @@ impl OverlayWindow { if self.recording_area { return; } + if self.area_locked { + self.aspect_menu = None; + cx.notify(); + return; + } let point = (f32::from(position.x), f32::from(position.y)); self.snap_guides.clear(); // Mid-reshape, `crop` is where the selection is going, not where it @@ -3886,7 +4510,8 @@ impl OverlayWindow { cx.notify(); } - fn area_mouse_move(&mut self, position: Point, cx: &mut Context) { + /// `free` (Command on macOS, Ctrl elsewhere, held) turns snapping off. + fn area_mouse_move(&mut self, position: Point, free: bool, cx: &mut Context) { if self.recording_area { return; } @@ -3921,7 +4546,11 @@ impl OverlayWindow { Vec::new(), ), (AreaDrag::Draw { anchor }, None) => { - let ((x, y), guides) = snap_area_point((x, y), self.display_size); + let ((x, y), guides) = if free { + ((x, y), Vec::new()) + } else { + snap_area_point((x, y), self.display_size, &self.area_snap) + }; ( AreaRect { x: anchor.0.min(x), @@ -3932,15 +4561,30 @@ impl OverlayWindow { guides, ) } - (AreaDrag::Move { grab, start }, _) => snap_area_rect( - AreaRect { + (AreaDrag::Move { grab, start }, _) => { + let moved = AreaRect { x: start.x + (x - grab.0), y: start.y + (y - grab.1), ..*start - }, - self.display_size, - ), + }; + if free { + (moved.clamped(self.display_size), Vec::new()) + } else { + snap_area_rect(moved, self.display_size, &self.area_snap) + } + } (AreaDrag::Resize { handle, start }, None) => { + let ((x, y), guides) = if free { + ((x, y), Vec::new()) + } else { + snap_resize_point( + (x, y), + self.display_size, + &self.area_snap, + handle.east() || handle.west(), + handle.north() || handle.south(), + ) + }; let mut left = start.x; let mut top = start.y; let mut right = start.right(); @@ -3964,30 +4608,36 @@ impl OverlayWindow { width: (right - left).abs(), height: (bottom - top).abs(), }, - Vec::new(), + guides, ) } }; self.crop = Some(next); self.snap_guides = guides; - self.sync_area_camera(cx); + // Mouse events outrun the display: the camera follows on the next + // frame, once, rather than once per event. + self.area_camera_pending = true; cx.notify(); } - fn area_mouse_up(&mut self, position: Point, cx: &mut Context) { + fn area_mouse_up(&mut self, position: Point, free: bool, cx: &mut Context) { if self.recording_area { return; } - self.area_mouse_move(position, cx); + self.area_mouse_move(position, free, cx); let Some(drag) = self.drag.take() else { return; }; self.snap_guides.clear(); if let Some(crop) = self.crop { - // A click with no drag is not a selection. + // A click with no drag is not a selection: it fits the area to + // the window under it instead. if crop.width < 2. || crop.height < 2. { self.crop = None; + if matches!(drag, AreaDrag::Draw { .. }) { + self.fit_area_to_window_at((f32::from(position.x), f32::from(position.y)), cx); + } } else { self.crop = Some(crop.clamped(self.display_size)); } @@ -4375,7 +5025,7 @@ mod tests { #[test] fn moving_area_snaps_to_display_centres_and_edges() { let display = (1000., 800.); - let (centered, center_guides) = snap_area_rect(rect(446., 180., 100., 120.), display); + let (centered, center_guides) = snap_area_rect(rect(446., 180., 100., 120.), display, &[]); assert_eq!(centered, rect(450., 180., 100., 120.)); assert!( center_guides @@ -4383,7 +5033,7 @@ mod tests { .any(|guide| guide.axis == GuideAxis::V && guide.pos == 0.5) ); - let (edge, edge_guides) = snap_area_rect(rect(894., 180., 100., 120.), display); + let (edge, edge_guides) = snap_area_rect(rect(894., 180., 100., 120.), display, &[]); assert_eq!(edge, rect(900., 180., 100., 120.)); assert!( edge_guides @@ -4395,7 +5045,7 @@ mod tests { #[test] fn drawing_endpoint_snaps_per_axis_within_seven_pixels() { let display = (1000., 800.); - let (snapped, guides) = snap_area_point((496., 404.), display); + let (snapped, guides) = snap_area_point((496., 404.), display, &[]); assert_eq!(snapped, (500., 400.)); assert!( guides @@ -4408,7 +5058,7 @@ mod tests { .any(|guide| guide.axis == GuideAxis::H && guide.pos == 0.5) ); - let (unsnapped, guides) = snap_area_point((490., 390.), display); + let (unsnapped, guides) = snap_area_point((490., 390.), display, &[]); assert_eq!(unsnapped, (490., 390.)); assert!(guides.is_empty()); } @@ -4455,4 +5105,105 @@ mod tests { assert!((selection_drag_progress(0.5) - 1.).abs() < 0.001); assert_eq!(selection_drag_progress(1.), 0.); } + + #[test] + fn an_area_snaps_to_windows_and_the_work_area() { + let display = (1000., 800.); + let window = AreaRect { + x: 200., + y: 150., + width: 400., + height: 300., + }; + let work_area = AreaRect { + x: 0., + y: 25., + width: 1000., + height: 700., + }; + let others = area_snap_rects(work_area, &[window], display); + + // A moving area's left edge 4 points off the window's lands on it. + let (moved, guides) = snap_area_rect( + AreaRect { + x: 204., + y: 500., + width: 120., + height: 90., + }, + display, + &others, + ); + assert_eq!(moved.x, 200.); + assert!(guides.iter().any(|guide| guide.axis == GuideAxis::V)); + + // The dock's edge (the work area's bottom) catches a drawn corner. + let ((_, y), _) = snap_area_point((700., 722.), display, &others); + assert_eq!(y, 725.); + } + + #[test] + fn an_edge_handle_snaps_only_its_own_axis() { + let display = (1000., 800.); + let others = area_snap_rects( + AreaRect { + x: 0., + y: 0., + width: 1000., + height: 800., + }, + &[AreaRect { + x: 300., + y: 300., + width: 200., + height: 200., + }], + display, + ); + // Near both the window's left edge and its top: an east/west handle + // takes the x snap and leaves y alone. + let ((x, y), guides) = snap_resize_point((303., 297.), display, &others, true, false); + assert_eq!((x, y), (300., 297.)); + assert!(guides.iter().all(|guide| guide.axis == GuideAxis::V)); + } + + #[test] + fn snap_rects_drop_tiny_whole_display_and_off_screen_windows() { + let display = (1000., 800.); + let whole = AreaRect { + x: 0., + y: 0., + width: 1000., + height: 800., + }; + let windows = [ + // A badge. + AreaRect { + x: 10., + y: 10., + width: 20., + height: 20., + }, + // Fullscreen. + whole, + // On another display. + AreaRect { + x: 1200., + y: 0., + width: 400., + height: 400., + }, + // Half on this display: kept, cut to it. + AreaRect { + x: 800., + y: 100., + width: 400., + height: 300., + }, + ]; + let rects = area_snap_rects(whole, &windows, display); + assert_eq!(rects.len(), 1); + assert!((rects[0].x - 0.8).abs() < 1e-6); + assert!((rects[0].w - 0.2).abs() < 1e-6); + } } From cc1dd529ec7efd976be9b38b3bbfee9852cb202c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:16:33 +0000 Subject: [PATCH 2/6] fix(desktop-gpui): fit on a real click, find the window where it was clicked, keep small screenshot locks --- apps/desktop-gpui/src/picker_probe.rs | 9 +- apps/desktop-gpui/src/target_overlay.rs | 114 ++++++++++++------------ 2 files changed, 63 insertions(+), 60 deletions(-) diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index 50c296e25cc..e452185dd25 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -710,8 +710,8 @@ async fn drag_frames( cx.update(|cx| { overlay .update(cx, |view, _, cx| view.probe_drag(step, point, cx)) - .ok(); - }); + .unwrap_or(false) + }) }; cx.update(|cx| { overlay @@ -719,7 +719,7 @@ async fn drag_frames( .ok(); }); sleep(cx, 300).await; - drag(ProbeDrag::Press, grab, cx); + let started = drag(ProbeDrag::Press, grab, cx); probe_frames::begin(); let (cpu, wall) = (cpu_secs(), std::time::Instant::now()); let mut events = 0u32; @@ -739,7 +739,7 @@ async fn drag_frames( drag(ProbeDrag::Release, grab, cx); let mut gaps: Vec = frames .windows(2) - .map(|pair| (pair[1] - pair[0]).as_secs_f64() * 1000.) + .map(|pair| pair[1].saturating_duration_since(pair[0]).as_secs_f64() * 1000.) .collect(); gaps.sort_by(f64::total_cmp); let percentile = |p: f64| { @@ -747,6 +747,7 @@ async fn drag_frames( .copied() }; serde_json::json!({ + "dragStarted": started, "cpuPercent": cpu, "fps": frames.len() as f64 / seconds, "mouseEvents": events, diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 0885d064dae..e4954c16136 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -806,11 +806,11 @@ fn store_locked_area(locked: Option<(&DisplayId, AreaRect)>) { /// The locked area for a display, fitted to its current size, if it is still /// big enough to record. -fn locked_area_for(display: &DisplayId, display_size: (f32, f32)) -> Option { +fn locked_area_for(display: &DisplayId, display_size: (f32, f32), min: f32) -> Option { let (locked_display, bounds) = stored_locked_area()?; (locked_display == display.to_string()) .then(|| bounds.clamped(display_size)) - .filter(|bounds| bounds.is_valid_for(AREA_MIN_SIZE)) + .filter(|bounds| bounds.is_valid_for(min)) } const QUICK_AREA_RATIOS: [Option<(u32, u32)>; 5] = [ @@ -1350,7 +1350,11 @@ impl OverlayWindow { height: logical.1, }) .clamped(logical); - let locked_area = locked_area_for(&display.id(), logical); + let locked_area = locked_area_for( + &display.id(), + logical, + area_min_size(select.read(cx).recording_mode), + ); Self { theme, @@ -1666,13 +1670,18 @@ impl OverlayWindow { step: ProbeDrag, point: (f32, f32), cx: &mut Context, - ) { + ) -> bool { let position = gpui::point(px(point.0), px(point.1)); match step { - ProbeDrag::Press => self.area_mouse_down(position, cx), + ProbeDrag::Press => { + // A lock saved on this display would hold the area still. + self.area_locked = false; + self.area_mouse_down(position, cx); + } ProbeDrag::Move => self.area_mouse_move(position, false, cx), ProbeDrag::Release => self.area_mouse_up(position, false, cx), } + self.drag.is_some() } /// Move the drawn area `offset` points right, the way a drag would, for @@ -3706,7 +3715,6 @@ impl OverlayWindow { ) } - /// Lock the area (kept for next time, and held still) or unlock it. fn toggle_area_lock(&mut self, cx: &mut Context) { if self.area_locked { self.area_locked = false; @@ -3763,53 +3771,37 @@ impl OverlayWindow { .detach(); } - /// A click with no drag fits the area to the window under it. fn fit_area_to_window_at(&mut self, point: (f32, f32), cx: &mut Context) { - let display = self.display_id.clone(); - let scale = self.capture_scale; - cx.spawn(async move |this, cx| { - let window = cx - .background_executor() - .spawn(async move { - crate::devices::topmost_foreign_window_at_cursor() - .as_ref() - .and_then(HoveredWindow::from_window) - }) - .await; - let fitted = this - .update(cx, |this, cx| { - let window = window.filter(|window| window.display_id == display)?; - // A drag or a lock since the click wins. - if this.crop.is_some() || this.drag.is_some() || this.area_locked { - return None; - } - let mut bounds = window - .bounds - .scaled(1. / scale) - .intersected(this.display_size)?; - if let Some(ratio) = this.area_ratio { - bounds = area_with_ratio(bounds, ratio_value(ratio), this.display_size); - } - let min = area_min_size(this.select.read(cx).recording_mode); - if !bounds.is_valid_for(min) { - return None; - } - this.crop = Some(AreaRect { - x: point.0, - y: point.1, - width: 0., - height: 0., - }); - this.reshape_area(bounds, cx); - Some(()) - }) - .ok() - .flatten(); - if fitted.is_some() { - cx.update(app_windows::refresh_target_overlays); - } - }) - .detach(); + // Looked up during the release, while the pointer is still where it + // clicked: a lookup that ran later would find whatever window the + // pointer had moved over since. + let Some(window) = crate::devices::topmost_foreign_window_at_cursor() + .as_ref() + .and_then(HoveredWindow::from_window) + .filter(|window| window.display_id == self.display_id) + else { + return; + }; + let Some(mut bounds) = window + .bounds + .scaled(1. / self.capture_scale) + .intersected(self.display_size) + else { + return; + }; + if let Some(ratio) = self.area_ratio { + bounds = area_with_ratio(bounds, ratio_value(ratio), self.display_size); + } + if !bounds.is_valid_for(area_min_size(self.select.read(cx).recording_mode)) { + return; + } + self.crop = Some(AreaRect { + x: point.0, + y: point.1, + width: 0., + height: 0., + }); + self.reshape_area(bounds, cx); } /// The area's keys: arrows nudge it (Shift for 10 points), Return starts, @@ -4407,8 +4399,6 @@ enum AreaZone { } impl OverlayWindow { - /// Track which part of the area the pointer is over, so the handles and - /// the move cue answer before a press. fn area_hover_at( &mut self, position: Point, @@ -4625,12 +4615,21 @@ impl OverlayWindow { return; }; self.snap_guides.clear(); + // Measured from the pointer, not the crop: snapping can stretch a + // stationary click into a few points of selection. + let clicked = match drag { + AreaDrag::Draw { anchor } => { + (f32::from(position.x) - anchor.0).abs() < 3. + && (f32::from(position.y) - anchor.1).abs() < 3. + } + _ => false, + }; if let Some(crop) = self.crop { // A click with no drag is not a selection: it fits the area to // the window under it instead. - if crop.width < 2. || crop.height < 2. { + if clicked || crop.width < 2. || crop.height < 2. { self.crop = None; - if matches!(drag, AreaDrag::Draw { .. }) { + if clicked { self.fit_area_to_window_at((f32::from(position.x), f32::from(position.y)), cx); } } else { @@ -4642,7 +4641,10 @@ impl OverlayWindow { // the screenshot area picker has no confirm step // (`target-select-overlay.tsx`'s area mouse-up). Move/resize grabs // keep the crop for the Start button, same as recording mode. + // A click fitted to a window waits for the button instead, so a stray + // click never takes a screenshot. if matches!(drag, AreaDrag::Draw { .. }) + && !clicked && self.select.read(cx).recording_mode == Mode::Screenshot && let Some(target) = self.target(cx) { From 6c05c6aa5a23c12cc660c7d6f56a36c3ba9ae1f3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:23:42 +0000 Subject: [PATCH 3/6] fix(desktop-gpui): leave Return to the open mode menu --- apps/desktop-gpui/src/target_overlay.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index e4954c16136..80fb61043c5 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -1851,7 +1851,8 @@ impl Render for OverlayWindow { // the overlays are up; here it is a plain key handler on the // overlay that has focus (see the README deviation). tracing::debug!(key = %event.keystroke.key, "overlay key"); - if this.area_key(&event.keystroke, window, cx) { + // The mode menu takes its keys first, like the menus above. + if this.mode_menu.is_none() && this.area_key(&event.keystroke, window, cx) { cx.stop_propagation(); return; } From 9ad696a9e8df5dff48a9715b13027ee61e09d9a5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:03:56 +0000 Subject: [PATCH 4/6] chore(desktop-gpui): keep only the comments that explain why --- apps/desktop-gpui/src/camera_window.rs | 8 ++--- apps/desktop-gpui/src/picker_probe.rs | 7 ---- apps/desktop-gpui/src/target_overlay.rs | 45 ++++--------------------- 3 files changed, 9 insertions(+), 51 deletions(-) diff --git a/apps/desktop-gpui/src/camera_window.rs b/apps/desktop-gpui/src/camera_window.rs index 6ff82096f63..867be17f430 100644 --- a/apps/desktop-gpui/src/camera_window.rs +++ b/apps/desktop-gpui/src/camera_window.rs @@ -173,11 +173,9 @@ fn normalized_size(state: &CameraWindowState) -> f32 { (clamp_size(state.size) - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE) } -/// How the toolbar fits a preview `width` wide (`None` when the picker has -/// not shrunk it): its scale and how many of its buttons show. Each button is -/// a 22px icon in 8px padding, 4px apart, in a 4px-padded strip with a 1px -/// border. A narrow preview drops the preview-only blur, then mirror, buttons -/// before the rest get too small to hit, so the toolbar stays inside it. +/// The fit uses each button's footprint (a 22px icon in 8px padding, 4px +/// apart, in a 4px-padded strip with a 1px border). The preview-only blur, +/// then mirror, buttons go first, before the rest get too small to hit. fn toolbar_fit(base_scale: f32, width: Option) -> (f32, usize) { const SMALLEST_COMFORTABLE: f32 = 0.6; const SMALLEST: f32 = 0.4; diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index e452185dd25..7c02d35f308 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -675,7 +675,6 @@ async fn drag_cpu( cpu_percent_since(cpu, wall) } -/// The process's memory footprint in MB, as Activity Monitor counts it. #[cfg(target_os = "macos")] fn footprint_mb() -> Option { let mut info = std::mem::MaybeUninit::::zeroed(); @@ -694,10 +693,6 @@ fn footprint_mb() -> Option { None } -/// Drag the area through its own mouse handlers (snapping, the camera, the -/// panels following) for `millis`, with mouse events at 250 Hz, grabbing it at -/// `grab`: the middle moves it, a corner resizes it. Reports the CPU use and -/// how evenly frames came: their rate and the longest gaps between them. async fn drag_frames( overlay: &WindowHandle, base: AreaRect, @@ -777,8 +772,6 @@ async fn probe_perf(cx: &mut AsyncApp) -> serde_json::Value { let dragging_without_backdrop = drag_cpu(&overlays, base, 6000, cx).await; crate::overlay_backdrop::set_disabled(false); - // A real drag through the handlers: moving, then resizing from a corner, - // then a long run of both to show whether memory creeps. let handler_drags = match overlays.first() { Some((_, overlay)) => { let middle = (base.x + base.width / 2., base.y + base.height / 2.); diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 80fb61043c5..4082756c56d 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -223,8 +223,6 @@ fn norm_area_rect(rect: AreaRect, display_size: (f32, f32)) -> NormRect { } } -/// Snap a moving area to the display's edges and centre lines, and to the -/// edges and centres of `others` (the work area and the windows on screen). fn snap_area_rect( rect: AreaRect, display_size: (f32, f32), @@ -295,8 +293,6 @@ fn snap_resize_point( /// badge, and its edges would only get in the way. const AREA_SNAP_WINDOW_MIN: f32 = 48.; -/// What an area snaps to besides the display: the work area (inside the menu -/// bar, dock or taskbar) and the windows on this display, normalised to it. fn area_snap_rects( work_area: AreaRect, windows: &[AreaRect], @@ -328,7 +324,6 @@ pub(crate) enum ProbeDrag { Release, } -/// When each overlay frame rendered while the picker probe is timing a drag. #[cfg(debug_assertions)] pub(crate) mod probe_frames { use std::sync::Mutex; @@ -359,8 +354,6 @@ pub(crate) mod probe_frames { } } -/// Whether the held modifiers turn area snapping off: Command on macOS, Ctrl -/// elsewhere. fn snap_off(modifiers: &gpui::Modifiers) -> bool { if cfg!(target_os = "macos") { modifiers.platform @@ -369,7 +362,6 @@ fn snap_off(modifiers: &gpui::Modifiers) -> bool { } } -/// A rule-of-thirds grid over the area while it is being drawn or dragged. fn render_thirds(crop: AreaRect) -> impl IntoElement { let line = gpui::hsla(0., 0., 1., 0.28); let mut grid = div() @@ -758,8 +750,6 @@ fn store_area_ratio(ratio: Option<(u32, u32)>) { }); } -/// The locked area: the display it was drawn on and its bounds there, so the -/// next picker opens with the same area. fn stored_locked_area() -> Option<(String, AreaRect)> { let section = crate::store::store_section(AREA_SELECTION_STORE); if !section.get("locked")?.as_bool()? { @@ -804,8 +794,6 @@ fn store_locked_area(locked: Option<(&DisplayId, AreaRect)>) { }); } -/// The locked area for a display, fitted to its current size, if it is still -/// big enough to record. fn locked_area_for(display: &DisplayId, display_size: (f32, f32), min: f32) -> Option { let (locked_display, bounds) = stored_locked_area()?; (locked_display == display.to_string()) @@ -1018,7 +1006,6 @@ impl AreaRect { self.width >= min && self.height >= min } - /// The part of this rect on a `display`-sized screen, if any. fn intersected(self, display: (f32, f32)) -> Option { let (left, top) = (self.x.max(0.), self.y.max(0.)); let (right, bottom) = (self.right().min(display.0), self.bottom().min(display.1)); @@ -1182,13 +1169,9 @@ pub struct OverlayWindow { snap_guides: Vec, /// The aspect ratio a drawn or resized area keeps, `None` for free. area_ratio: Option<(u32, u32)>, - /// A locked area is kept for the next picker and holds still: no drag - /// moves, resizes or redraws it until it is unlocked. area_locked: bool, - /// What an area snaps to besides the display, see [`area_snap_rects`]. area_snap: Vec, area_windows_requested: bool, - /// The part of the area under the pointer, while nothing is dragged. area_hover: Option, /// The camera still has to follow the area: after a drag step (so it /// moves once a frame) or a restored area. @@ -1662,8 +1645,6 @@ impl OverlayWindow { self.backdrop.image() } - /// Drive the area's own mouse handlers, for the picker probe's drag - /// timing: press at `point`, move to it, or release there. #[cfg(debug_assertions)] pub(crate) fn probe_drag( &mut self, @@ -3159,13 +3140,10 @@ impl OverlayWindow { } } - /// The selection's bounds and handles: a white border with a dark line - /// just outside it, so it shows over light and dark content alike; round - /// knobs on the corners and grips on the edges, each growing under the - /// pointer; and the eight resize zones. The zones carry only a cursor: the - /// drag itself is dispatched from the root's mouse-down through - /// [`Self::area_zone_at`], so a grab that starts a pixel outside the - /// handle still resizes. + /// The zones carry only a cursor: the drag itself is dispatched from the + /// root's mouse-down through [`Self::area_zone_at`], so a grab that starts + /// a pixel outside the handle still resizes. The dark line outside the + /// white border keeps the bounds visible over light content. fn render_crop_region(&self, crop: AreaRect, interactive: bool) -> impl IntoElement { let hover = self .area_hover @@ -3204,7 +3182,6 @@ impl OverlayWindow { .border_color(gpui::hsla(0., 0., 0., 0.45)), ); - // Under the pointer, before any press: how to move it. if hover == Some(AreaZone::Inside) && crop.width >= 220. && crop.height >= 120. { region = region.child( div() @@ -3274,9 +3251,8 @@ impl OverlayWindow { } }; - // A knob on each corner, a grip on the middle of each edge (left - // off a side too short to hold one clear of the corners), both - // white with a dark rim and growing while hovered or dragged. + // Edge grips are left off a side too short to hold one clear of + // the corners. let hot = hover == Some(AreaZone::Handle(handle)) || resizing == Some(handle); let horizontal = handle.north() || handle.south(); let side = if horizontal { crop.width } else { crop.height }; @@ -3648,9 +3624,6 @@ impl OverlayWindow { ) } - /// Fit the size readout beside the area while it is being drawn or - /// dragged, where the eye already is: under its bottom-right corner, or - /// inside it when the screen runs out. fn render_area_size(&self, crop: AreaRect) -> impl IntoElement { const HEIGHT: f32 = 24.; const GAP: f32 = 8.; @@ -3677,7 +3650,6 @@ impl OverlayWindow { .child(label) } - /// The badge on a locked area: says why it will not move, and unlocks it. fn render_locked_badge(&self, crop: AreaRect, cx: &mut Context) -> impl IntoElement { div() .absolute() @@ -3745,8 +3717,6 @@ impl OverlayWindow { } } - /// The windows on this display, for the area to snap to. Read once per - /// picker, off the main thread. fn load_area_windows(&mut self, cx: &mut Context) { self.area_windows_requested = true; let display = self.display_id.clone(); @@ -3805,8 +3775,6 @@ impl OverlayWindow { self.reshape_area(bounds, cx); } - /// The area's keys: arrows nudge it (Shift for 10 points), Return starts, - /// and Escape mid-drag puts it back. True when the key was used. fn area_key( &mut self, keystroke: &gpui::Keystroke, @@ -4496,7 +4464,6 @@ impl OverlayWindow { cx.notify(); } - /// `free` (Command on macOS, Ctrl elsewhere, held) turns snapping off. fn area_mouse_move(&mut self, position: Point, free: bool, cx: &mut Context) { if self.recording_area { return; From f3ee5d1843a52377c181397c85dfec42d54e89d8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:20:38 +0000 Subject: [PATCH 5/6] fix(desktop-gpui): Escape mid-drag puts the area back, one display holds the area lock --- apps/desktop-gpui/src/app_windows.rs | 26 +++++++++- apps/desktop-gpui/src/target_overlay.rs | 66 ++++++++++++++++++------- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 748984e8982..89aad46d8dd 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -581,7 +581,7 @@ pub fn init(main: WindowHandle, session: Entity, c cx.spawn(async move |cx| { while escape.recv_async().await.is_ok() { cx.update(|cx| { - if TargetSelect::global(cx).read(cx).mode.is_some() { + if TargetSelect::global(cx).read(cx).mode.is_some() && !cancel_area_drags(cx) { dismiss_target_overlays(cx); } }); @@ -2554,6 +2554,30 @@ fn start_recording_from_overlay_now(target: ScreenCaptureTarget, cx: &mut App) { } } +/// Escape mid-drag puts the area back rather than closing the picker. True +/// when an overlay had a drag to cancel. +fn cancel_area_drags(cx: &mut App) -> bool { + let overlays = cx.global::().overlays.clone(); + let mut cancelled = false; + for (_, overlay) in overlays { + cancelled |= overlay + .update(cx, |view, _, cx| view.cancel_area_drag(cx)) + .unwrap_or(false); + } + cancelled +} + +pub fn release_other_area_locks(display: &DisplayId, cx: &mut App) { + let overlays = cx.global::().overlays.clone(); + for (id, overlay) in overlays { + if &id != display { + overlay + .update(cx, |view, _, cx| view.release_area_lock(cx)) + .ok(); + } + } +} + /// Seed an area selection on the overlay for a display (harness path -- the /// real one is a mouse drag, and unprivileged synthetic drags are dropped). pub fn seed_area_selection(display: Option, crop: AreaRect, cx: &mut App) -> bool { diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 4082756c56d..77076a635c0 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -1137,6 +1137,8 @@ enum AreaDrag { /// Drawing a fresh selection from an anchor point. Draw { anchor: (f32, f32), + /// The area the press replaced, for Escape to bring back. + previous: Option, }, /// Dragging the whole selection. Move { @@ -3688,10 +3690,33 @@ impl OverlayWindow { ) } + /// Put the area back where the drag found it. False when nothing was + /// being dragged, so Escape can go on to close the picker. + pub fn cancel_area_drag(&mut self, cx: &mut Context) -> bool { + let Some(drag) = self.drag.take() else { + return false; + }; + self.crop = match drag { + AreaDrag::Draw { previous, .. } => previous, + AreaDrag::Move { start, .. } | AreaDrag::Resize { start, .. } => Some(start), + }; + self.snap_guides.clear(); + self.sync_area_camera(cx); + cx.notify(); + true + } + + /// Only the display the saved lock belongs to clears it. + fn unlock_area(&mut self) { + self.area_locked = false; + if stored_locked_area().is_some_and(|(display, _)| display == self.display_id.to_string()) { + store_locked_area(None); + } + } + fn toggle_area_lock(&mut self, cx: &mut Context) { if self.area_locked { - self.area_locked = false; - store_locked_area(None); + self.unlock_area(); } else { let min = area_min_size(self.select.read(cx).recording_mode); let Some(crop) = self.crop.filter(|crop| crop.is_valid_for(min)) else { @@ -3701,10 +3726,20 @@ impl OverlayWindow { self.drag = None; self.snap_guides.clear(); store_locked_area(Some((&self.display_id, crop))); + // One lock is saved: another display's lock is replaced. + let display = self.display_id.clone(); + cx.defer(move |cx| app_windows::release_other_area_locks(&display, cx)); } cx.notify(); } + pub fn release_area_lock(&mut self, cx: &mut Context) { + if self.area_locked { + self.area_locked = false; + cx.notify(); + } + } + /// Keep a locked area's stored bounds in step with a toolbar change. Only /// the display the lock belongs to writes, so another display's overlay /// never takes the lock over. @@ -3785,16 +3820,7 @@ impl OverlayWindow { return false; } let key = keystroke.key.as_str(); - if key == "escape" - && let Some(drag) = self.drag.take() - { - self.crop = match drag { - AreaDrag::Draw { .. } => None, - AreaDrag::Move { start, .. } | AreaDrag::Resize { start, .. } => Some(start), - }; - self.snap_guides.clear(); - self.sync_area_camera(cx); - cx.notify(); + if key == "escape" && self.cancel_area_drag(cx) { return true; } if key == "enter" && self.drag.is_none() && self.target(cx).is_some() { @@ -3918,8 +3944,7 @@ impl OverlayWindow { fn reset_area(&mut self, cx: &mut Context) { if self.area_locked { - self.area_locked = false; - store_locked_area(None); + self.unlock_area(); } self.reshape = None; self.crop = None; @@ -4451,13 +4476,16 @@ impl OverlayWindow { None => { // A press outside the selection starts a fresh one, the way the // Cropper's full-screen "Start selection" button does. - self.crop = Some(AreaRect { + let previous = self.crop.replace(AreaRect { x: point.0, y: point.1, width: 0., height: 0., }); - Some(AreaDrag::Draw { anchor: point }) + Some(AreaDrag::Draw { + anchor: point, + previous, + }) } }; self.sync_area_camera(cx); @@ -4474,7 +4502,7 @@ impl OverlayWindow { let ratio = self.area_ratio.map(ratio_value); let (next, guides) = match (drag, ratio) { - (AreaDrag::Draw { anchor }, Some(ratio)) => ( + (AreaDrag::Draw { anchor, .. }, Some(ratio)) => ( area_from_anchor_with_ratio(*anchor, (x, y), ratio, self.display_size), Vec::new(), ), @@ -4498,7 +4526,7 @@ impl OverlayWindow { }, Vec::new(), ), - (AreaDrag::Draw { anchor }, None) => { + (AreaDrag::Draw { anchor, .. }, None) => { let ((x, y), guides) = if free { ((x, y), Vec::new()) } else { @@ -4586,7 +4614,7 @@ impl OverlayWindow { // Measured from the pointer, not the crop: snapping can stretch a // stationary click into a few points of selection. let clicked = match drag { - AreaDrag::Draw { anchor } => { + AreaDrag::Draw { anchor, .. } => { (f32::from(position.x) - anchor.0).abs() < 3. && (f32::from(position.y) - anchor.1).abs() < 3. } From 391f4389ca16f1ac49382caf8abfa32e263a4021 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:41:57 +0000 Subject: [PATCH 6/6] fix(desktop-gpui): count only the dragged display's frames in the drag probe --- apps/desktop-gpui/src/picker_probe.rs | 13 +++++++------ apps/desktop-gpui/src/target_overlay.rs | 18 ++++++++++++------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index 7c02d35f308..fcc7e77285f 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -694,6 +694,7 @@ fn footprint_mb() -> Option { } async fn drag_frames( + display: &scap_targets::DisplayId, overlay: &WindowHandle, base: AreaRect, grab: (f32, f32), @@ -715,7 +716,7 @@ async fn drag_frames( }); sleep(cx, 300).await; let started = drag(ProbeDrag::Press, grab, cx); - probe_frames::begin(); + probe_frames::begin(display.clone()); let (cpu, wall) = (cpu_secs(), std::time::Instant::now()); let mut events = 0u32; while wall.elapsed() < Duration::from_millis(millis) { @@ -773,15 +774,15 @@ async fn probe_perf(cx: &mut AsyncApp) -> serde_json::Value { crate::overlay_backdrop::set_disabled(false); let handler_drags = match overlays.first() { - Some((_, overlay)) => { + Some((display, overlay)) => { let middle = (base.x + base.width / 2., base.y + base.height / 2.); let corner = (base.x + base.width, base.y + base.height); - let moving = drag_frames(overlay, base, middle, 6000, cx).await; - let resizing = drag_frames(overlay, base, corner, 6000, cx).await; + let moving = drag_frames(display, overlay, base, middle, 6000, cx).await; + let resizing = drag_frames(display, overlay, base, corner, 6000, cx).await; let warm = footprint_mb(); for _ in 0..3 { - drag_frames(overlay, base, middle, 5000, cx).await; - drag_frames(overlay, base, corner, 5000, cx).await; + drag_frames(display, overlay, base, middle, 5000, cx).await; + drag_frames(display, overlay, base, corner, 5000, cx).await; } let after = footprint_mb(); serde_json::json!({ diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index b4c408fe739..f9769cae6a5 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -330,19 +330,24 @@ pub(crate) mod probe_frames { use std::sync::Mutex; use std::time::Instant; - static FRAMES: Mutex>> = Mutex::new(None); + use scap_targets::DisplayId; - pub(crate) fn rendered() { + /// Only the dragged display's overlay counts: another display repainting + /// for the mic meter would add frames it did not draw. + static FRAMES: Mutex)>> = Mutex::new(None); + + pub(crate) fn rendered(display: &DisplayId) { if let Ok(mut frames) = FRAMES.lock() - && let Some(frames) = frames.as_mut() + && let Some((measured, frames)) = frames.as_mut() + && measured == display { frames.push(Instant::now()); } } - pub(crate) fn begin() { + pub(crate) fn begin(display: DisplayId) { if let Ok(mut frames) = FRAMES.lock() { - *frames = Some(Vec::with_capacity(4096)); + *frames = Some((display, Vec::with_capacity(4096))); } } @@ -351,6 +356,7 @@ pub(crate) mod probe_frames { .lock() .ok() .and_then(|mut frames| frames.take()) + .map(|(_, frames)| frames) .unwrap_or_default() } } @@ -1754,7 +1760,7 @@ impl OverlayWindow { impl Render for OverlayWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { #[cfg(debug_assertions)] - probe_frames::rendered(); + probe_frames::rendered(&self.display_id); self.sync_appearance(window, cx); self.sync_inline_camera(window, cx); self.sync_backdrop(window, cx);