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 0000000000..5c49a4adb5
--- /dev/null
+++ b/apps/desktop-gpui/assets/icons/lock-open.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desktop-gpui/locales/de.toml b/apps/desktop-gpui/locales/de.toml
index 8e743ec65d..ce5ec3eb70 100644
--- a/apps/desktop-gpui/locales/de.toml
+++ b/apps/desktop-gpui/locales/de.toml
@@ -1482,6 +1482,16 @@ reset_title = "Auswahl zurücksetzen"
reset_body = "Den Bereich löschen, um einen neuen aufzuziehen."
fill_title = "Bildschirm füllen"
fill_body = "Den Bereich auf den ganzen Bildschirm vergrößern und dabei das Seitenverhältnis beibehalten."
+fit_window_hint = "Oder klicken Sie auf ein Fenster, um den Bereich daran anzupassen"
+drag_to_move = "Zum Verschieben ziehen"
+locked = "Gesperrt"
+lock_title = "Bereich sperren"
+lock_body = "Diesen Bereich für das nächste Mal behalten und vor versehentlichem Ziehen schützen."
+lock_needs_area = "Ziehen Sie zuerst einen Bereich auf und sperren Sie ihn dann."
+unlock_title = "Bereich entsperren"
+unlock_body = "Den Bereich wieder verschieben, in der Größe ändern oder neu aufziehen lassen."
+locked_title = "Bereich gesperrt"
+locked_body = "Klicken, um ihn zu entsperren, damit er wieder verschoben, in der Größe geändert oder neu aufgezogen werden kann."
[overlay.camera_only]
description = "Nur mit Kamera und Mikrofon aufnehmen"
diff --git a/apps/desktop-gpui/locales/en.toml b/apps/desktop-gpui/locales/en.toml
index 6a86a71b39..2b82792336 100644
--- a/apps/desktop-gpui/locales/en.toml
+++ b/apps/desktop-gpui/locales/en.toml
@@ -1484,6 +1484,16 @@ reset_title = "Reset selection"
reset_body = "Clear the area so you can draw a new one."
fill_title = "Fill display"
fill_body = "Grow the area to cover the display, keeping its ratio."
+fit_window_hint = "Or click a window to fit the area to it"
+drag_to_move = { text = "Drag to move", context = "Pill in the middle of the selected area while the pointer is over it." }
+locked = { text = "Locked", context = "Badge on a locked area, about 60px wide; clicking it unlocks the area." }
+lock_title = "Lock area"
+lock_body = "Keep this area for next time, and stop a stray drag from changing it."
+lock_needs_area = "Draw an area first, then lock it."
+unlock_title = "Unlock area"
+unlock_body = "Let the area be moved, resized or drawn again."
+locked_title = "Area locked"
+locked_body = "Click to unlock it, so it can be moved, resized or drawn again."
[overlay.camera_only]
description = "Record using only your camera and microphone"
diff --git a/apps/desktop-gpui/locales/es.toml b/apps/desktop-gpui/locales/es.toml
index dc5f30591c..a3f3b8ca77 100644
--- a/apps/desktop-gpui/locales/es.toml
+++ b/apps/desktop-gpui/locales/es.toml
@@ -1482,6 +1482,16 @@ reset_title = "Restablecer la selección"
reset_body = "Borra el área para dibujar una nueva."
fill_title = "Llenar la pantalla"
fill_body = "Amplía el área hasta cubrir la pantalla manteniendo su proporción."
+fit_window_hint = "O haz clic en una ventana para ajustar el área a ella"
+drag_to_move = "Arrastra para mover"
+locked = "Bloqueada"
+lock_title = "Bloquear el área"
+lock_body = "Guarda esta área para la próxima vez y evita que un arrastre accidental la cambie."
+lock_needs_area = "Primero dibuja un área y luego bloquéala."
+unlock_title = "Desbloquear el área"
+unlock_body = "Permite volver a mover, redimensionar o dibujar el área."
+locked_title = "Área bloqueada"
+locked_body = "Haz clic para desbloquearla y poder moverla, redimensionarla o dibujarla de nuevo."
[overlay.camera_only]
description = "Graba solo con tu cámara y tu micrófono"
diff --git a/apps/desktop-gpui/locales/fr.toml b/apps/desktop-gpui/locales/fr.toml
index 0c2cb1be62..878edca9d3 100644
--- a/apps/desktop-gpui/locales/fr.toml
+++ b/apps/desktop-gpui/locales/fr.toml
@@ -1482,6 +1482,16 @@ reset_title = "Réinitialiser la sélection"
reset_body = "Effacer la zone pour en dessiner une nouvelle."
fill_title = "Remplir l'écran"
fill_body = "Agrandir la zone jusqu'à couvrir l'écran en conservant son format."
+fit_window_hint = "Ou cliquez sur une fenêtre pour y ajuster la zone"
+drag_to_move = "Faites glisser pour déplacer"
+locked = "Verrouillée"
+lock_title = "Verrouiller la zone"
+lock_body = "Conserver cette zone pour la prochaine fois et éviter qu'un glissement accidentel ne la modifie."
+lock_needs_area = "Dessinez d'abord une zone, puis verrouillez-la."
+unlock_title = "Déverrouiller la zone"
+unlock_body = "Permettre de déplacer, redimensionner ou redessiner la zone."
+locked_title = "Zone verrouillée"
+locked_body = "Cliquez pour la déverrouiller afin de pouvoir la déplacer, la redimensionner ou la redessiner."
[overlay.camera_only]
description = "Enregistrez uniquement avec votre caméra et votre micro"
diff --git a/apps/desktop-gpui/locales/hi.toml b/apps/desktop-gpui/locales/hi.toml
index 11f8b00e79..5a2dd1f812 100644
--- a/apps/desktop-gpui/locales/hi.toml
+++ b/apps/desktop-gpui/locales/hi.toml
@@ -1482,6 +1482,16 @@ reset_title = "चयन रीसेट करें"
reset_body = "क्षेत्र हटाएँ ताकि आप नया बना सकें।"
fill_title = "पूरा डिस्प्ले भरें"
fill_body = "अनुपात बनाए रखते हुए क्षेत्र को पूरे डिस्प्ले तक बढ़ाएँ।"
+fit_window_hint = "या क्षेत्र को किसी विंडो के बराबर करने के लिए उस विंडो पर क्लिक करें"
+drag_to_move = "खिसकाने के लिए खींचें"
+locked = "लॉक है"
+lock_title = "क्षेत्र लॉक करें"
+lock_body = "इस क्षेत्र को अगली बार के लिए रखें, और गलती से खींचने पर इसे बदलने से रोकें।"
+lock_needs_area = "पहले एक क्षेत्र बनाएँ, फिर उसे लॉक करें।"
+unlock_title = "क्षेत्र अनलॉक करें"
+unlock_body = "क्षेत्र को फिर से खिसकाने, उसका साइज़ बदलने या नया बनाने दें।"
+locked_title = "क्षेत्र लॉक है"
+locked_body = "अनलॉक करने के लिए क्लिक करें, ताकि इसे खिसकाया, इसका साइज़ बदला या फिर से बनाया जा सके।"
[overlay.camera_only]
description = "सिर्फ़ अपने कैमरा और माइक्रोफ़ोन से रिकॉर्ड करें"
diff --git a/apps/desktop-gpui/locales/ja.toml b/apps/desktop-gpui/locales/ja.toml
index 6ec028088d..578ed3f994 100644
--- a/apps/desktop-gpui/locales/ja.toml
+++ b/apps/desktop-gpui/locales/ja.toml
@@ -1482,6 +1482,16 @@ reset_title = "選択範囲をリセット"
reset_body = "範囲を消去して、新しく描き直せるようにします。"
fill_title = "ディスプレイ全体に広げる"
fill_body = "比率を保ったまま、範囲をディスプレイ全体に広げます。"
+fit_window_hint = "またはウィンドウをクリックして、範囲をそのウィンドウに合わせます"
+drag_to_move = "ドラッグして移動"
+locked = "ロック中"
+lock_title = "範囲をロック"
+lock_body = "この範囲を次回のために保持し、誤ってドラッグしても変わらないようにします。"
+lock_needs_area = "先に範囲を描いてから、ロックしてください。"
+unlock_title = "範囲のロックを解除"
+unlock_body = "範囲を再び移動、サイズ変更、描き直しできるようにします。"
+locked_title = "範囲はロックされています"
+locked_body = "クリックしてロックを解除すると、移動、サイズ変更、描き直しができます。"
[overlay.camera_only]
description = "カメラとマイクだけで録画します"
diff --git a/apps/desktop-gpui/locales/zh-CN.toml b/apps/desktop-gpui/locales/zh-CN.toml
index 2d4353d3c3..d8a7fb4ba7 100644
--- a/apps/desktop-gpui/locales/zh-CN.toml
+++ b/apps/desktop-gpui/locales/zh-CN.toml
@@ -1482,6 +1482,16 @@ reset_title = "重置选区"
reset_body = "清除该区域,以便重新绘制。"
fill_title = "填满屏幕"
fill_body = "保持比例,将区域扩大到整个屏幕。"
+fit_window_hint = "或点击一个窗口,让区域与其贴合"
+drag_to_move = "拖动以移动"
+locked = "已锁定"
+lock_title = "锁定区域"
+lock_body = "保留此区域供下次使用,并防止误拖动将其改变。"
+lock_needs_area = "请先绘制一个区域,然后再锁定。"
+unlock_title = "解锁区域"
+unlock_body = "允许再次移动、调整大小或重新绘制该区域。"
+locked_title = "区域已锁定"
+locked_body = "点击即可解锁,以便移动、调整大小或重新绘制。"
[overlay.camera_only]
description = "仅使用摄像头和麦克风录制"
diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs
index 3791fbe498..885f0cb7b0 100644
--- a/apps/desktop-gpui/src/app_windows.rs
+++ b/apps/desktop-gpui/src/app_windows.rs
@@ -582,7 +582,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);
}
});
@@ -2553,6 +2553,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/assets.rs b/apps/desktop-gpui/src/assets.rs
index b407b61d0a..572e287db0 100644
--- a/apps/desktop-gpui/src/assets.rs
+++ b/apps/desktop-gpui/src/assets.rs
@@ -200,6 +200,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 defcf1f9fa..867be17f43 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,25 @@ fn normalized_size(state: &CameraWindowState) -> f32 {
(clamp_size(state.size) - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE)
}
+/// 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;
+ 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 +2237,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 +2492,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 +2571,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/i18n/dialogs_surface_tests.rs b/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs
index 3f6d4b3066..c8f881b9bb 100644
--- a/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs
+++ b/apps/desktop-gpui/src/i18n/dialogs_surface_tests.rs
@@ -352,6 +352,31 @@ fn dialog_surfaces_match_the_literals_they_replaced() {
tr::overlay::area::fill_body(),
"Grow the area to cover the display, keeping its ratio.",
),
+ (
+ tr::overlay::area::fit_window_hint(),
+ "Or click a window to fit the area to it",
+ ),
+ (tr::overlay::area::drag_to_move(), "Drag to move"),
+ (tr::overlay::area::locked(), "Locked"),
+ (tr::overlay::area::lock_title(), "Lock area"),
+ (
+ tr::overlay::area::lock_body(),
+ "Keep this area for next time, and stop a stray drag from changing it.",
+ ),
+ (
+ tr::overlay::area::lock_needs_area(),
+ "Draw an area first, then lock it.",
+ ),
+ (tr::overlay::area::unlock_title(), "Unlock area"),
+ (
+ tr::overlay::area::unlock_body(),
+ "Let the area be moved, resized or drawn again.",
+ ),
+ (tr::overlay::area::locked_title(), "Area locked"),
+ (
+ tr::overlay::area::locked_body(),
+ "Click to unlock it, so it can be moved, resized or drawn again.",
+ ),
(tr::overlay::window::size_title(), "Change window size"),
(
tr::overlay::window::size_body(),
@@ -374,7 +399,10 @@ fn dialog_surfaces_match_the_literals_they_replaced() {
"Wayland doesn't let apps resize other apps' windows.",
),
(tr::overlay::window::focus_title(), "Focus mode"),
- (tr::overlay::window::focus_on(), "Recording only this window"),
+ (
+ tr::overlay::window::focus_on(),
+ "Recording only this window",
+ ),
(tr::overlay::window::focus_off(), "Record only this window"),
(tr::overlay::window::focus_enable(), "Enable Focus mode"),
(tr::overlay::window::focus_disable(), "Turn off Focus mode"),
diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs
index 66f9aa3aef..fcc7e77285 100644
--- a/apps/desktop-gpui/src/picker_probe.rs
+++ b/apps/desktop-gpui/src/picker_probe.rs
@@ -675,6 +675,84 @@ async fn drag_cpu(
cpu_percent_since(cpu, wall)
}
+#[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
+}
+
+async fn drag_frames(
+ display: &scap_targets::DisplayId,
+ 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))
+ .unwrap_or(false)
+ })
+ };
+ cx.update(|cx| {
+ overlay
+ .update(cx, |view, _, cx| view.probe_offset_area(base, 0., cx))
+ .ok();
+ });
+ sleep(cx, 300).await;
+ let started = drag(ProbeDrag::Press, grab, cx);
+ 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) {
+ 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].saturating_duration_since(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!({
+ "dragStarted": started,
+ "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.
@@ -694,10 +772,33 @@ 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);
+
+ let handler_drags = match overlays.first() {
+ 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(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(display, overlay, base, middle, 5000, cx).await;
+ drag_frames(display, 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 2b4159296b..f9769cae6a 100644
--- a/apps/desktop-gpui/src/target_overlay.rs
+++ b/apps/desktop-gpui/src/target_overlay.rs
@@ -128,12 +128,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
@@ -225,9 +224,13 @@ fn norm_area_rect(rect: AreaRect, display_size: (f32, f32)) -> NormRect {
}
}
-fn snap_area_rect(rect: AreaRect, display_size: (f32, f32)) -> (AreaRect, Vec) {
+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,
@@ -246,17 +249,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.;
+
+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,
+}
+
+#[cfg(debug_assertions)]
+pub(crate) mod probe_frames {
+ use std::sync::Mutex;
+ use std::time::Instant;
+
+ use scap_targets::DisplayId;
+
+ /// Only the dragged display's overlay counts: another display repainting
+ /// for the mic meter would add frames it did not draw.
+ static FRAMES: Mutex