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