diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml
index c47a2d8eb5a..58bdd3eba8d 100644
--- a/.github/workflows/picker-smoke.yml
+++ b/.github/workflows/picker-smoke.yml
@@ -11,6 +11,8 @@ on:
- "apps/desktop-gpui/src/main_window.rs"
- "apps/desktop-gpui/src/foreign_window.rs"
- "apps/desktop-gpui/src/window_arrange.rs"
+ - "apps/desktop-gpui/src/overlay_backdrop.rs"
+ - "apps/desktop-gpui/src/ui/glass.rs"
- "apps/desktop-gpui/src/platform.rs"
- "apps/desktop-gpui/src/platform/**"
- "scripts/picker-smoke/**"
diff --git a/apps/desktop-gpui/assets/icons/hint-arrow-down.svg b/apps/desktop-gpui/assets/icons/hint-arrow-down.svg
new file mode 100644
index 00000000000..1fa74b41f1d
--- /dev/null
+++ b/apps/desktop-gpui/assets/icons/hint-arrow-down.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desktop-gpui/assets/icons/hint-arrow-left.svg b/apps/desktop-gpui/assets/icons/hint-arrow-left.svg
new file mode 100644
index 00000000000..f6ef0d5f75f
--- /dev/null
+++ b/apps/desktop-gpui/assets/icons/hint-arrow-left.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desktop-gpui/assets/icons/hint-arrow-right.svg b/apps/desktop-gpui/assets/icons/hint-arrow-right.svg
new file mode 100644
index 00000000000..27bcf6b552c
--- /dev/null
+++ b/apps/desktop-gpui/assets/icons/hint-arrow-right.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desktop-gpui/assets/icons/hint-arrow-up.svg b/apps/desktop-gpui/assets/icons/hint-arrow-up.svg
new file mode 100644
index 00000000000..29afd2b2059
--- /dev/null
+++ b/apps/desktop-gpui/assets/icons/hint-arrow-up.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs
index 8ee10de1d0b..86b6f1ad779 100644
--- a/apps/desktop-gpui/src/app_windows.rs
+++ b/apps/desktop-gpui/src/app_windows.rs
@@ -4001,6 +4001,13 @@ fn begin_recording_after_preflight(config: StartConfig, cx: &mut App) {
let mut excluded: Vec = config.excluded_windows.clone();
excluded.extend(open_controls(&config, session.clone(), cx));
+ // Focus marks the window it records with a ring for the whole recording.
+ if let ScreenCaptureTarget::Window { id } = &config.target
+ && crate::store::GeneralSettings::load().window_capture_style
+ == crate::store::WindowCaptureStyle::FocusedWindow
+ {
+ excluded.extend(crate::focus_border::open(id, cx));
+ }
// The camera bubble is excluded from studio captures (the camera is its own
// track, composited in the editor) but *included* in instant captures --
// `filter_for_instant_mode` in the Tauri app strips the camera exclusion
@@ -4720,6 +4727,64 @@ fn park_camera_in_window(target: &HoveredWindow, cx: &mut App) -> bool {
true
}
+/// The picker moved the picked window from `before` to `after` (Focus
+/// centring it, or the size menu): a camera bubble sitting on the window goes
+/// with it, keeping its place relative to the window's bottom-right corner,
+/// where a park puts it, and staying inside the window.
+pub fn carry_camera_with_window(before: &HoveredWindow, after: &HoveredWindow, cx: &mut App) {
+ let origin = |display: &DisplayId| {
+ let bounds = scap_targets::Display::from_id(display)?
+ .raw_handle()
+ .logical_bounds()?;
+ Some((bounds.position().x() as f32, bounds.position().y() as f32))
+ };
+ let (Some(from), Some(to)) = (origin(&before.display_id), origin(&after.display_id)) else {
+ return;
+ };
+ let Some(camera) = camera_snapshot(cx) else {
+ return;
+ };
+ let old = AreaRect {
+ x: before.bounds.x + from.0,
+ y: before.bounds.y + from.1,
+ ..before.bounds
+ };
+ let new = AreaRect {
+ x: after.bounds.x + to.0,
+ y: after.bounds.y + to.1,
+ ..after.bounds
+ };
+ let bubble = camera.bounds;
+ let centre = (bubble.x + bubble.width / 2., bubble.y + bubble.height / 2.);
+ let on_window = centre.0 >= old.x
+ && centre.0 <= old.x + old.width
+ && centre.1 >= old.y
+ && centre.1 <= old.y + old.height;
+ if !on_window || old == new {
+ return;
+ }
+ let right_gap = old.x + old.width - (bubble.x + bubble.width);
+ let bottom_gap = old.y + old.height - (bubble.y + bubble.height);
+ let (min_x, min_y) = (new.x + CAMERA_PARK_PADDING, new.y + CAMERA_PARK_PADDING);
+ let max_x = (new.x + new.width - bubble.width - CAMERA_PARK_PADDING).max(min_x);
+ let max_y = (new.y + new.height - bubble.height - CAMERA_PARK_PADDING).max(min_y);
+ let x = (new.x + new.width - bubble.width - right_gap).clamp(min_x, max_x);
+ let y = (new.y + new.height - bubble.height - bottom_gap).clamp(min_y, max_y);
+ cx.global_mut::().camera_park.last_window = Some(after.id.clone());
+ queue_camera_placement(
+ CameraPlacement {
+ camera: camera.handle,
+ bounds: AreaRect {
+ x: x.round(),
+ y: y.round(),
+ ..bubble
+ },
+ size_override: CameraSizeOverride::Preserve,
+ },
+ cx,
+ );
+}
+
fn camera_area_bounds(
display_origin: (f32, f32),
crop: AreaRect,
@@ -7162,6 +7227,7 @@ pub fn refresh_library_after_delete(cx: &mut App) {
}
fn close_controls(session: &Entity, cx: &mut App) {
+ crate::focus_border::close(cx);
if let Some(handle) = cx.global_mut::().controls.take() {
handle
.update(cx, |_, window, _| window.remove_window())
diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs
index 638020d001e..746fccfef48 100644
--- a/apps/desktop-gpui/src/assets.rs
+++ b/apps/desktop-gpui/src/assets.rs
@@ -73,6 +73,10 @@ const ICONS: &[(&str, &[u8])] = assets!("icons":
"folder.svg",
"gauge.svg",
"gear.svg",
+ "hint-arrow-down.svg",
+ "hint-arrow-left.svg",
+ "hint-arrow-right.svg",
+ "hint-arrow-up.svg",
// The config sidebar's field glyphs. Four are Cap's own
// (`packages/ui-solid/icons/{padding,corners,shadow,bg-blur}.svg`); the
// rest of the background section's are Lucide, drawn here from the same
@@ -344,6 +348,7 @@ mod tests {
include_str!("ui/selection_header.rs"),
include_str!("ui/radio_cards.rs"),
include_str!("ui/windows_caption.rs"),
+ include_str!("ui/hint.rs"),
// The onboarding window's welcome cards and permissions surface; the
// per-permission row glyphs are named on `OSPermission::icon`.
include_str!("onboarding_window.rs"),
diff --git a/apps/desktop-gpui/src/focus_border.rs b/apps/desktop-gpui/src/focus_border.rs
new file mode 100644
index 00000000000..e8c6bf89179
--- /dev/null
+++ b/apps/desktop-gpui/src/focus_border.rs
@@ -0,0 +1,236 @@
+//! The Focus ring: a soft outline just outside the window Focus records.
+//!
+//! The picker draws it around the picked window while Focus is on, and a
+//! recording keeps it up in a window of its own: a transparent panel over the
+//! recorded window's display that ignores the mouse and stays out of
+//! captures, following the window if it moves. It marks which window is
+//! being recorded without sitting on any of its pixels.
+
+use std::time::Duration;
+
+use gpui::{
+ App, AppContext as _, Context, Global, IntoElement, ParentElement as _, Render, Styled as _,
+ Task, Window, WindowBounds, WindowHandle, WindowKind, WindowOptions, div, point, px, size,
+};
+use scap_targets::{DisplayId, WindowId};
+
+use crate::{
+ platform,
+ target_overlay::{AreaRect, HoveredWindow},
+ theme::Theme,
+};
+
+/// Recording keeps the ring up only where its window can let clicks through
+/// to the app being recorded. Windows and X11 still need that, so there the
+/// ring shows in the picker alone.
+pub const SUPPORTED: bool = cfg!(target_os = "macos");
+
+/// How far outside the window the ring sits, so it never covers the window.
+const OUTSET: f32 = 4.;
+const WIDTH: f32 = 2.;
+/// How often a recording rereads where its window is.
+const FOLLOW: Duration = Duration::from_millis(120);
+
+/// The corner radius of other apps' windows, which the ring follows.
+const WINDOW_RADIUS: f32 = if cfg!(target_os = "macos") {
+ 16.
+} else if cfg!(windows) {
+ 8.
+} else {
+ 10.
+};
+
+/// The ring around `window`, positioned in the same space as `window`: a
+/// crisp line with two fainter ones outside it for a glow. Borders only, since
+/// gpui paints a box shadow under the whole element and would tint the window.
+pub fn ring(window: AreaRect, theme: &Theme) -> impl IntoElement {
+ let color = gpui::Hsla::from(theme.blue_9);
+ let band = |outset: f32, alpha: f32| {
+ div()
+ .absolute()
+ .left(px(window.x - outset))
+ .top(px(window.y - outset))
+ .w(px(window.width + 2. * outset))
+ .h(px(window.height + 2. * outset))
+ .rounded(px(WINDOW_RADIUS + outset))
+ .border(px(WIDTH))
+ .border_color(color.opacity(alpha))
+ };
+ div()
+ .absolute()
+ .size_full()
+ .child(band(OUTSET + 2. * WIDTH, 0.1))
+ .child(band(OUTSET + WIDTH, 0.28))
+ .child(band(OUTSET, 0.9))
+}
+
+struct FocusBorderWindow(WindowHandle);
+impl Global for FocusBorderWindow {}
+
+pub struct FocusBorder {
+ window: WindowId,
+ display: DisplayId,
+ bounds: Option,
+ theme: Theme,
+ _follow: Task<()>,
+}
+
+impl FocusBorder {
+ fn new(target: HoveredWindow, window: &mut Window, cx: &mut Context) -> Self {
+ crate::theme::bind_window(window, cx);
+ let follow = cx.spawn(async move |this, cx| {
+ loop {
+ cx.background_executor().timer(FOLLOW).await;
+ let Ok(id) = this.read_with(cx, |this, _| this.window.clone()) else {
+ return;
+ };
+ let lookup = id.clone();
+ let found = cx
+ .background_executor()
+ .spawn(async move {
+ scap_targets::Window::from_id(&lookup)
+ .as_ref()
+ .and_then(HoveredWindow::from_window)
+ })
+ .await;
+ let updated = this.update(cx, |this, cx| {
+ if found
+ .as_ref()
+ .is_some_and(|found| found.display_id != this.display)
+ {
+ return true;
+ }
+ let bounds = found.map(|found| found.bounds);
+ if this.bounds != bounds {
+ this.bounds = bounds;
+ cx.notify();
+ }
+ false
+ });
+ match updated {
+ Ok(false) => {}
+ // The window went to another display: the ring's panel
+ // covers one display, so a new one opens over the other.
+ Ok(true) => {
+ cx.update(|cx| cx.defer(move |cx| reopen(&id, cx)));
+ return;
+ }
+ Err(_) => return,
+ }
+ }
+ });
+ Self {
+ window: target.id,
+ display: target.display_id,
+ bounds: Some(target.bounds),
+ theme: Theme::for_window(window, cx, false),
+ _follow: follow,
+ }
+ }
+}
+
+impl Render for FocusBorder {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement {
+ div()
+ .size_full()
+ .relative()
+ .children(self.bounds.map(|bounds| ring(bounds, &self.theme)))
+ }
+}
+
+fn reopen(target: &WindowId, cx: &mut App) {
+ if cx.has_global::() {
+ open(target, cx);
+ }
+}
+
+/// Puts the ring around `target` for the length of a recording. The native
+/// id of the ring's own window comes back, for the capture to leave out.
+pub fn open(target: &WindowId, cx: &mut App) -> Option {
+ if !SUPPORTED {
+ return None;
+ }
+ close(cx);
+ let found = scap_targets::Window::from_id(target)?;
+ let target = HoveredWindow::from_window(&found)?;
+ let display = found.display()?;
+ let logical = display.raw_handle().logical_bounds()?;
+ let (x, y) = (logical.position().x(), logical.position().y());
+ let (width, height) = (logical.size().width(), logical.size().height());
+
+ let handle = cx
+ .open_window(
+ WindowOptions {
+ // Moved onto its display right after opening, as the picker's
+ // overlays are: gpui's origin math cannot say "cover this display".
+ window_bounds: Some(WindowBounds::Windowed(gpui::Bounds {
+ origin: point(px(0.), px(0.)),
+ size: size(px(width as f32), px(height as f32)),
+ })),
+ titlebar: None,
+ kind: WindowKind::PopUp,
+ focus: false,
+ show: false,
+ is_resizable: false,
+ is_minimizable: false,
+ window_background: gpui::WindowBackgroundAppearance::Transparent,
+ ..Default::default()
+ },
+ move |window, cx| cx.new(|cx| FocusBorder::new(target, window, cx)),
+ )
+ .inspect_err(|error| tracing::warn!("focus ring window failed to open: {error:#}"))
+ .ok()?;
+ cx.set_global(FocusBorderWindow(handle));
+
+ let (native, number) = handle
+ .update(cx, |_, window, _| {
+ (
+ platform::native_window(window),
+ platform::window_number(window),
+ )
+ })
+ .ok()?;
+ let native = native?;
+ // Out of every capture, and clicks go straight through to the window it
+ // marks. Without both the ring would be in the video, or in the way.
+ let hidden = platform::set_window_capture_hidden(&native, true);
+ let click_through = platform::set_window_click_through(&native, true);
+ if !click_through {
+ tracing::warn!(
+ hidden,
+ "focus ring could not let clicks through; not showing it"
+ );
+ close(cx);
+ return None;
+ }
+ // AppKit re-enters gpui as it orders the panel in, so that runs from a
+ // fresh turn with nothing borrowed (see `platform::place_overlay_panel`).
+ cx.spawn(async move |cx| {
+ let open = cx.update(|cx| {
+ cx.try_global::()
+ .is_some_and(|current| current.0 == handle)
+ });
+ if open {
+ platform::place_overlay_panel(
+ &native,
+ x,
+ y,
+ width,
+ height,
+ platform::target_overlay_level() - 1,
+ );
+ }
+ })
+ .detach();
+ number?.to_string().parse().ok()
+}
+
+pub fn close(cx: &mut App) {
+ if let Some(FocusBorderWindow(handle)) = cx.try_global::() {
+ let handle = *handle;
+ cx.remove_global::();
+ handle
+ .update(cx, |_, window, _| window.remove_window())
+ .ok();
+ }
+}
diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs
index 3ede16b4231..b0a3ef392f3 100644
--- a/apps/desktop-gpui/src/main.rs
+++ b/apps/desktop-gpui/src/main.rs
@@ -42,6 +42,7 @@ mod editor_tabs;
mod editor_timeline;
mod editor_window;
mod feeds;
+mod focus_border;
mod hotkeys;
mod i18n;
mod import;
@@ -63,6 +64,7 @@ mod mode_select_window;
mod onboarding_audio;
mod onboarding_doodles;
mod onboarding_window;
+mod overlay_backdrop;
mod permission_guide;
mod permissions;
mod permissions_ui;
diff --git a/apps/desktop-gpui/src/overlay_backdrop.rs b/apps/desktop-gpui/src/overlay_backdrop.rs
new file mode 100644
index 00000000000..b896b3981bf
--- /dev/null
+++ b/apps/desktop-gpui/src/overlay_backdrop.rs
@@ -0,0 +1,272 @@
+//! The desktop under a picker overlay, for the overlay's glass to read.
+//!
+//! The overlay is a transparent window, so its glass panes would otherwise
+//! read only the overlay's own dimming. The overlay asks the window server
+//! to leave it out of screen captures for a moment (`NSWindowSharingNone`
+//! on macOS, `WDA_EXCLUDEFROMCAPTURE` on Windows: neither changes what is on
+//! screen), captures its display, and paints that capture beneath its panes
+//! through [`crate::ui::glass::Backdrop`].
+//!
+//! Linux has no way to leave a window out of a capture short of unmapping
+//! it, so its panes keep the plain fallback fill.
+
+use std::{
+ sync::{
+ Arc,
+ atomic::{AtomicBool, AtomicU64, Ordering},
+ },
+ time::Duration,
+};
+
+use gpui::{AsyncApp, RenderImage, Window};
+use scap_targets::DisplayId;
+
+/// A capture older than this is retaken the next time the overlay draws.
+pub const STALE_AFTER: Duration = Duration::from_secs(3);
+
+/// How long the panels must hold still before a stale capture is retaken.
+pub const SETTLE: Duration = Duration::from_millis(400);
+
+/// The longest a capture may take before the panes give up on it.
+const TIMEOUT: Duration = Duration::from_millis(1500);
+
+/// How long the main thread runs between leaving the overlay out of captures
+/// and capturing. The window server only learns of the change once the run
+/// loop turns over: a capture started straight away held the overlay every
+/// time on macOS 27, and painted its dimming back under itself. One frame was
+/// enough there; this leaves margin.
+const EXCLUDE_SETTLE: Duration = Duration::from_millis(40);
+
+pub const SUPPORTED: bool = cfg!(any(target_os = "macos", windows));
+
+/// Set by the picker probe to measure the picker without captures.
+static DISABLED: AtomicBool = AtomicBool::new(false);
+
+/// How long the last capture took, start to GPU-ready image, for the probe.
+static LAST_CAPTURE_MS: AtomicU64 = AtomicU64::new(0);
+
+pub fn enabled() -> bool {
+ SUPPORTED && !DISABLED.load(Ordering::Relaxed)
+}
+
+#[cfg(debug_assertions)]
+pub fn set_disabled(disabled: bool) {
+ DISABLED.store(disabled, Ordering::Relaxed);
+}
+
+#[cfg(debug_assertions)]
+pub fn last_capture_ms() -> u64 {
+ LAST_CAPTURE_MS.load(Ordering::Relaxed)
+}
+
+#[cfg(target_os = "macos")]
+const CAPTURE_HIDDEN: usize = 0;
+#[cfg(windows)]
+const CAPTURE_HIDDEN: usize =
+ windows_sys::Win32::UI::WindowsAndMessaging::WDA_EXCLUDEFROMCAPTURE as usize;
+
+/// Leave `window` out of screen captures, or put it back. True when the
+/// window server now leaves it out: a Windows remote session keeps windows
+/// in captures, and then a capture would hold the overlay itself.
+#[cfg(any(target_os = "macos", windows))]
+fn set_capture_hidden(window: &crate::platform::NativeWindow, hidden: bool) -> bool {
+ let reported = crate::platform::set_window_capture_hidden(window, hidden);
+ !hidden || reported == CAPTURE_HIDDEN
+}
+
+/// Capture `display` with `window` (the overlay on it) left out, at the
+/// display's full resolution, ready for the GPU. `None` when the platform
+/// cannot leave the overlay out or the capture fails.
+///
+/// `above` are Cap windows that sit over the overlay (the camera bubble):
+/// they are left out too, or the capture would keep them where they were
+/// and show them there after they move. One already left out (a Studio
+/// camera is) stays that way.
+pub async fn capture(
+ window: Option,
+ above: Vec,
+ display: DisplayId,
+ cx: &mut AsyncApp,
+) -> Option> {
+ #[cfg(any(target_os = "macos", windows))]
+ {
+ let window = window?;
+ // The overlay must be hidden from captures before one starts and
+ // shown again whatever happens to it, so both run here on the main
+ // thread with no gpui borrow held: AppKit calls back into gpui.
+ if !set_capture_hidden(&window, true) {
+ set_capture_hidden(&window, false);
+ tracing::debug!("overlay backdrop: this session keeps windows in captures");
+ return None;
+ }
+ let above: Vec<_> = above
+ .into_iter()
+ .filter(|window| !crate::platform::window_capture_hidden(window))
+ .collect();
+ for window in &above {
+ set_capture_hidden(window, true);
+ }
+ cx.background_executor().timer(EXCLUDE_SETTLE).await;
+ let started = std::time::Instant::now();
+ let image = capture_bgra(display, cx).await;
+ set_capture_hidden(&window, false);
+ for window in &above {
+ set_capture_hidden(window, false);
+ }
+ let image = Arc::new(RenderImage::new(smallvec::smallvec![image::Frame::new(
+ image?
+ )]));
+ let elapsed_ms = started.elapsed().as_millis() as u64;
+ LAST_CAPTURE_MS.store(elapsed_ms, Ordering::Relaxed);
+ tracing::debug!(elapsed_ms, "overlay backdrop captured");
+ Some(image)
+ }
+ #[cfg(not(any(target_os = "macos", windows)))]
+ {
+ let _ = (window, above, display, cx);
+ None
+ }
+}
+
+/// The display's pixels in gpui's BGRA order. macOS hands them over in
+/// that order already; elsewhere the shared screenshot path is RGB.
+#[cfg(target_os = "macos")]
+async fn capture_bgra(display: DisplayId, cx: &mut AsyncApp) -> Option {
+ let captured = cx
+ .background_executor()
+ .spawn(async move { mac::display_bgra(&display) });
+ let timeout = cx.background_executor().timer(TIMEOUT);
+ match futures_util::future::select(std::pin::pin!(captured), std::pin::pin!(timeout)).await {
+ futures_util::future::Either::Left((image, _)) => image,
+ futures_util::future::Either::Right(_) => {
+ tracing::debug!("overlay backdrop: capture timed out");
+ None
+ }
+ }
+}
+
+#[cfg(windows)]
+async fn capture_bgra(display: DisplayId, cx: &mut AsyncApp) -> Option {
+ let captured = gpui_tokio::Tokio::spawn(cx, async move {
+ tokio::time::timeout(
+ TIMEOUT,
+ cap_recording::screenshot::capture_screenshot(
+ cap_recording::sources::screen_capture::ScreenCaptureTarget::Display {
+ id: display,
+ },
+ ),
+ )
+ .await
+ })
+ .await;
+ let image = match captured {
+ Ok(Ok(Ok(image))) => image,
+ Ok(Ok(Err(error))) => {
+ tracing::debug!("overlay backdrop: capture failed: {error:#}");
+ return None;
+ }
+ Ok(Err(_)) => {
+ tracing::debug!("overlay backdrop: capture timed out");
+ return None;
+ }
+ Err(error) => {
+ tracing::debug!(%error, "overlay backdrop: capture task failed");
+ return None;
+ }
+ };
+ cx.background_executor()
+ .spawn(async move {
+ let mut image = image.into_rgba8();
+ cap_gpui_kernels::frame::swap_red_blue(&mut image);
+ image
+ })
+ .await
+ .into()
+}
+
+#[cfg(target_os = "macos")]
+mod mac {
+ use std::ffi::c_void;
+
+ use core_foundation::base::TCFType;
+ use core_foundation::data::{CFData, CFDataRef};
+ use scap_targets::DisplayId;
+
+ type CGImageRef = *const c_void;
+
+ #[link(name = "CoreGraphics", kind = "framework")]
+ unsafe extern "C" {
+ fn CGDisplayCreateImage(display: u32) -> CGImageRef;
+ fn CGImageGetWidth(image: CGImageRef) -> usize;
+ fn CGImageGetHeight(image: CGImageRef) -> usize;
+ fn CGImageGetBytesPerRow(image: CGImageRef) -> usize;
+ fn CGImageGetBitsPerPixel(image: CGImageRef) -> usize;
+ fn CGImageGetDataProvider(image: CGImageRef) -> *const c_void;
+ fn CGDataProviderCopyData(provider: *const c_void) -> CFDataRef;
+ fn CGImageRelease(image: CGImageRef);
+ }
+
+ /// Display images are 32-bit little-endian with the alpha byte skipped,
+ /// so in memory they are BGRX: one copy per row, and alpha set opaque.
+ pub fn display_bgra(display: &DisplayId) -> Option {
+ let display = scap_targets::Display::from_id(display)?;
+ let image = unsafe { CGDisplayCreateImage(display.raw_handle().inner().id) };
+ if image.is_null() {
+ return None;
+ }
+ let pixels = unsafe {
+ let (width, height) = (CGImageGetWidth(image), CGImageGetHeight(image));
+ let stride = CGImageGetBytesPerRow(image);
+ let data = (CGImageGetBitsPerPixel(image) == 32)
+ .then(|| CGDataProviderCopyData(CGImageGetDataProvider(image)))
+ .filter(|data| !data.is_null())
+ .map(|data| CFData::wrap_under_create_rule(data));
+ CGImageRelease(image);
+ let data = data?;
+ let bytes = data.bytes();
+ if width == 0 || height == 0 || stride < width * 4 || bytes.len() < stride * height {
+ return None;
+ }
+ let mut pixels = Vec::with_capacity(width * height * 4);
+ for row in bytes.chunks_exact(stride).take(height) {
+ pixels.extend_from_slice(&row[..width * 4]);
+ }
+ for pixel in pixels.chunks_exact_mut(4) {
+ pixel[3] = 255;
+ }
+ image::RgbaImage::from_raw(width as u32, height as u32, pixels)
+ };
+ pixels
+ }
+}
+
+/// Whether a capture of `size` pixels can stand in for an overlay drawn at
+/// `logical` points: the same shape, within a pixel's rounding.
+pub fn fits(size: (u32, u32), logical: (f32, f32)) -> bool {
+ if size.0 == 0 || size.1 == 0 || logical.0 <= 0. || logical.1 <= 0. {
+ return false;
+ }
+ let scale_x = size.0 as f32 / logical.0;
+ let scale_y = size.1 as f32 / logical.1;
+ (scale_x / scale_y - 1.).abs() < 0.01
+}
+
+/// The overlay's own window, for [`capture`].
+pub fn native(window: &Window) -> Option {
+ crate::platform::native_window(window)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_capture_fits_an_overlay_of_the_same_shape() {
+ assert!(fits((3024, 1964), (1512., 982.)));
+ assert!(fits((1920, 1080), (1920., 1080.)));
+ assert!(fits((3841, 2160), (1920., 1080.)));
+ assert!(!fits((1920, 1200), (1920., 1080.)));
+ assert!(!fits((0, 1080), (1920., 1080.)));
+ assert!(!fits((1920, 1080), (0., 1080.)));
+ }
+}
diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs
index 77150e955b1..fe639bc23ef 100644
--- a/apps/desktop-gpui/src/picker_probe.rs
+++ b/apps/desktop-gpui/src/picker_probe.rs
@@ -18,11 +18,47 @@ pub fn run(main: WindowHandle, cx: &mut App) {
.and_then(|delay| delay.parse().ok())
.unwrap_or(2000);
sleep(cx, delay).await;
+ // `CAP_PICKER_PROBE_HOLD=window` (or `display`, `area`) arms that
+ // picker and leaves it up, for a script to drive the pointer over it.
+ if let Ok(hold) = std::env::var("CAP_PICKER_PROBE_HOLD") {
+ // `main` leaves the main window as it opened; `settings:` opens
+ // settings page n, for screenshots of the rest of the app.
+ if hold == "main" {
+ return;
+ }
+ if let Some(page) = hold.strip_prefix("settings:") {
+ let page = page.parse::().unwrap_or(0);
+ let page = crate::settings_window::Page::ALL
+ .get(page)
+ .copied()
+ .unwrap_or(crate::settings_window::Page::General);
+ cx.update(|cx| app_windows::open_settings(page, cx));
+ return;
+ }
+ let kind = match hold.as_str() {
+ "display" => TargetType::Display,
+ "area" => TargetType::Area,
+ _ => TargetType::Window,
+ };
+ main.update(cx, |view, _, cx| view.arm_overlay(kind, cx))
+ .ok();
+ sleep(cx, 800).await;
+ cx.update(|cx| {
+ let overlays = cx.global::().overlays.clone();
+ for (_, overlay) in overlays {
+ overlay
+ .update(cx, |_, window, _| window.activate_window())
+ .ok();
+ }
+ });
+ return;
+ }
cx.update(app_windows::open_camera_window);
sleep(cx, 800).await;
let mut modes = Vec::new();
let mut window_arrangement = None;
+ let mut perf = None;
for (label, kind) in [
("display", TargetType::Display),
("window", TargetType::Window),
@@ -69,11 +105,15 @@ pub fn run(main: WindowHandle, cx: &mut App) {
sleep(cx, 800).await;
let mut mode = cx.update(|cx| probe_mode(label, cx));
mode["overlayInactiveBeforeActivation"] = inactive_before.into();
+ let screen = screenshot(&output, label, cx).await;
+ mode["backdrop"] = probe_backdrop(&output, label, screen.as_ref(), cx).await;
modes.push(mode);
- screenshot(&output, label, cx).await;
if kind == TargetType::Window {
window_arrangement = Some(probe_window_arrangement(&output, cx).await);
}
+ if kind == TargetType::Area {
+ perf = Some(probe_perf(cx).await);
+ }
cx.update(app_windows::dismiss_target_overlays);
sleep(cx, 400).await;
}
@@ -82,12 +122,14 @@ pub fn run(main: WindowHandle, cx: &mut App) {
.as_ref()
.is_none_or(|report: &serde_json::Value| report["passed"].as_bool() != Some(false));
let passed = arrangement_passed
- && modes
- .iter()
- .all(|mode| mode["cameraAboveOverlays"].as_bool() == Some(true));
+ && modes.iter().all(|mode| {
+ mode["cameraAboveOverlays"].as_bool() == Some(true)
+ && mode["backdrop"]["passed"].as_bool() != Some(false)
+ });
let report = serde_json::json!({
"modes": modes,
"windowArrangement": window_arrangement,
+ "perf": perf,
});
// The verdict leads so `run.sh` can read it without parsing JSON:
// nested checks carry `passed` keys of their own.
@@ -242,10 +284,12 @@ async fn probe_window_arrangement(
})
}
-async fn screenshot(output: &std::ffi::OsStr, label: &str, cx: &mut AsyncApp) {
- let Some(directory) = std::path::Path::new(output).parent() else {
- return;
- };
+async fn screenshot(
+ output: &std::ffi::OsStr,
+ label: &str,
+ cx: &mut AsyncApp,
+) -> Option {
+ let directory = std::path::Path::new(output).parent()?;
let path = directory.join(format!("{label}.png"));
let target = cap_recording::sources::screen_capture::ScreenCaptureTarget::Display {
id: scap_targets::Display::primary().id(),
@@ -259,10 +303,89 @@ async fn screenshot(output: &std::ffi::OsStr, label: &str, cx: &mut AsyncApp) {
if let Err(error) = image.save(&path) {
tracing::warn!(%error, "picker probe: could not save {label} screenshot");
}
+ Some(image)
+ }
+ Ok(Err(error)) => {
+ tracing::warn!("picker probe: {label} screenshot failed: {error:#}");
+ None
+ }
+ Err(error) => {
+ tracing::warn!(%error, "picker probe: {label} screenshot task failed");
+ None
+ }
+ }
+}
+
+/// The mean per-channel difference between two captures of one display.
+fn mean_difference(
+ backdrop: &[u8],
+ width: u32,
+ height: u32,
+ screen: &image::RgbImage,
+) -> Option {
+ if (screen.width(), screen.height()) != (width, height) {
+ return None;
+ }
+ let (mut sum, mut count) = (0u64, 0u64);
+ for (bgra, rgb) in backdrop.chunks_exact(4).zip(screen.pixels()) {
+ for (a, b) in [bgra[2], bgra[1], bgra[0]].into_iter().zip(rgb.0) {
+ sum += u64::from(a.abs_diff(b));
+ count += 1;
+ }
+ }
+ (count > 0).then(|| sum as f64 / count as f64)
+}
+
+/// Check the capture under the overlay's glass is of the desktop and not of
+/// the overlay: the overlay dims and tints the whole display, so a capture
+/// that left it out differs clearly from a screenshot that has it.
+async fn probe_backdrop(
+ output: &std::ffi::OsStr,
+ label: &str,
+ screen: Option<&image::DynamicImage>,
+ cx: &mut AsyncApp,
+) -> serde_json::Value {
+ if !crate::overlay_backdrop::SUPPORTED {
+ return serde_json::json!({ "skipped": "no capture without the overlay here" });
+ }
+ // The overlay over the display `screen` shows, so the two compare the
+ // same desktop.
+ let primary = scap_targets::Display::primary().id();
+ let image = cx.update(|cx| {
+ let overlay = cx
+ .global::()
+ .overlays
+ .iter()
+ .find(|(display, _)| *display == primary)
+ .map(|(_, overlay)| *overlay)?;
+ overlay
+ .update(cx, |view, _, _| view.backdrop_image())
+ .ok()
+ .flatten()
+ });
+ let Some(image) = image else {
+ return serde_json::json!({ "present": false, "passed": false });
+ };
+ let size = image.size(0);
+ let (width, height) = (size.width.0 as u32, size.height.0 as u32);
+ let bgra = image.as_bytes(0).unwrap_or_default();
+ if let Some(directory) = std::path::Path::new(output).parent() {
+ let rgba: Vec = bgra
+ .chunks_exact(4)
+ .flat_map(|px| [px[2], px[1], px[0], px[3]])
+ .collect();
+ if let Some(image) = image::RgbaImage::from_raw(width, height, rgba) {
+ let _ = image.save(directory.join(format!("{label}-backdrop.png")));
}
- Ok(Err(error)) => tracing::warn!("picker probe: {label} screenshot failed: {error:#}"),
- Err(error) => tracing::warn!(%error, "picker probe: {label} screenshot task failed"),
}
+ let difference =
+ screen.and_then(|screen| mean_difference(bgra, width, height, &screen.to_rgb8()));
+ serde_json::json!({
+ "present": true,
+ "size": [width, height],
+ "meanDifference": difference,
+ "passed": difference.is_some_and(|difference| difference > 6.),
+ })
}
async fn sleep(cx: &mut AsyncApp, millis: u64) {
@@ -451,3 +574,100 @@ fn own_window_stacking(ours: &[isize]) -> Vec {
}
stacking
}
+
+#[cfg(unix)]
+fn cpu_secs() -> f64 {
+ let mut usage = std::mem::MaybeUninit::::zeroed();
+ unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) };
+ let usage = unsafe { usage.assume_init() };
+ let secs = |time: libc::timeval| time.tv_sec as f64 + time.tv_usec as f64 / 1_000_000.0;
+ secs(usage.ru_utime) + secs(usage.ru_stime)
+}
+
+#[cfg(windows)]
+fn cpu_secs() -> f64 {
+ use windows_sys::Win32::{
+ Foundation::FILETIME,
+ System::Threading::{GetCurrentProcess, GetProcessTimes},
+ };
+
+ let empty = FILETIME {
+ dwLowDateTime: 0,
+ dwHighDateTime: 0,
+ };
+ let (mut creation, mut exit, mut kernel, mut user) = (empty, empty, empty, empty);
+ unsafe {
+ GetProcessTimes(
+ GetCurrentProcess(),
+ &mut creation,
+ &mut exit,
+ &mut kernel,
+ &mut user,
+ )
+ };
+ let secs = |time: FILETIME| {
+ ((u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime)) as f64
+ / 10_000_000.0
+ };
+ secs(kernel) + secs(user)
+}
+
+fn cpu_percent_since(cpu: f64, wall: std::time::Instant) -> f64 {
+ (cpu_secs() - cpu) / wall.elapsed().as_secs_f64() * 100.
+}
+
+/// Slide the drawn area back and forth for `millis`, the way a drag moves
+/// the picker's panels, and return the process's CPU use meanwhile, as a
+/// percentage of one core.
+async fn drag_cpu(
+ overlays: &[(
+ scap_targets::DisplayId,
+ WindowHandle,
+ )],
+ base: AreaRect,
+ millis: u64,
+ cx: &mut AsyncApp,
+) -> f64 {
+ let (cpu, wall) = (cpu_secs(), std::time::Instant::now());
+ let mut step = 0u32;
+ while wall.elapsed() < Duration::from_millis(millis) {
+ let offset = 240. * (step as f32 * 0.05).sin();
+ cx.update(|cx| {
+ for (_, overlay) in overlays {
+ overlay
+ .update(cx, |view, _, cx| view.probe_offset_area(base, offset, cx))
+ .ok();
+ }
+ });
+ sleep(cx, 8).await;
+ step += 1;
+ }
+ cpu_percent_since(cpu, wall)
+}
+
+/// 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.
+async fn probe_perf(cx: &mut AsyncApp) -> serde_json::Value {
+ let overlays = cx.update(|cx| cx.global::().overlays.clone());
+ let base = AreaRect {
+ x: 200.,
+ y: 200.,
+ width: 640.,
+ height: 360.,
+ };
+ let (cpu, wall) = (cpu_secs(), std::time::Instant::now());
+ sleep(cx, 3000).await;
+ let idle = cpu_percent_since(cpu, wall);
+ let dragging = drag_cpu(&overlays, base, 6000, cx).await;
+ let capture_ms = crate::overlay_backdrop::last_capture_ms();
+ crate::overlay_backdrop::set_disabled(true);
+ let dragging_without_backdrop = drag_cpu(&overlays, base, 6000, cx).await;
+ crate::overlay_backdrop::set_disabled(false);
+ serde_json::json!({
+ "idleCpuPercent": idle,
+ "dragCpuPercent": dragging,
+ "dragCpuPercentWithoutBackdrop": dragging_without_backdrop,
+ "backdropCaptureMs": capture_ms,
+ })
+}
diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs
index e57f143a56d..3866f052b66 100644
--- a/apps/desktop-gpui/src/platform.rs
+++ b/apps/desktop-gpui/src/platform.rs
@@ -1836,6 +1836,13 @@ mod mac {
/// `setSharingType: None`/`ReadOnly`, so that is what this is.
///
/// Returns the value AppKit reports back, for the probe log.
+ /// Whether the window is already left out of screen captures.
+ pub fn window_capture_hidden(native: &NativeWindow) -> bool {
+ use objc2::msg_send;
+ let sharing: usize = unsafe { msg_send![&*native.0, sharingType] };
+ sharing == NS_WINDOW_SHARING_NONE
+ }
+
pub fn set_window_capture_hidden(native: &NativeWindow, hidden: bool) -> usize {
use objc2::msg_send;
let sharing = if hidden {
@@ -3028,6 +3035,9 @@ mod stub {
pub fn set_window_alpha(_native: &NativeWindow, _alpha: f64) -> f64 {
1.
}
+ pub fn window_capture_hidden(_native: &NativeWindow) -> bool {
+ false
+ }
pub fn set_window_capture_hidden(_native: &NativeWindow, _hidden: bool) -> usize {
0
}
diff --git a/apps/desktop-gpui/src/platform/windows.rs b/apps/desktop-gpui/src/platform/windows.rs
index 5a340c73f8b..bdc3c97932f 100644
--- a/apps/desktop-gpui/src/platform/windows.rs
+++ b/apps/desktop-gpui/src/platform/windows.rs
@@ -151,6 +151,13 @@ pub fn set_window_alpha(_native: &NativeWindow, _alpha: f64) -> f64 {
1.
}
+/// Whether the window is already left out of screen captures.
+pub fn window_capture_hidden(native: &NativeWindow) -> bool {
+ let mut current = WDA_NONE;
+ let read = unsafe { GetWindowDisplayAffinity(native.hwnd(), &mut current) != 0 };
+ read && current == WDA_EXCLUDEFROMCAPTURE
+}
+
pub fn set_window_capture_hidden(native: &NativeWindow, hidden: bool) -> usize {
let streamed = hidden
.then(capture_exclusion::streamed_display_reason)
diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs
index 87e43a103e2..c875b83e068 100644
--- a/apps/desktop-gpui/src/target_overlay.rs
+++ b/apps/desktop-gpui/src/target_overlay.rs
@@ -46,6 +46,7 @@ use crate::{
main_window::{Mode, TargetType},
sign_in::SignIn,
theme::Theme,
+ ui::Hint,
};
/// How often the cursor probe runs. The Tauri loop sleeps 50ms between
@@ -623,6 +624,16 @@ fn more_area_ratios() -> Vec<(u32, u32)> {
const AREA_RESHAPE: Duration = Duration::from_millis(240);
+/// What the picker's glass sits on when there is no capture of the desktop
+/// beneath it: the panel fill the picker had before it had glass.
+fn fallback_fill(theme: &Theme) -> Hsla {
+ if theme.is_dark() {
+ Theme::with_alpha(theme.gray_2, 0.82)
+ } else {
+ Theme::with_alpha(theme.gray_1, 0.82)
+ }
+}
+
fn area_fill(display: (f32, f32), ratio: Option) -> AreaRect {
let (width, height) = match ratio {
Some(ratio) if display.0 / display.1 > ratio => (display.1 * ratio, display.1),
@@ -968,6 +979,17 @@ pub struct OverlayWindow {
window_reshape: Option<(AreaRect, Instant)>,
/// Where the window highlight was last drawn, for a move to start from.
window_drawn: Cell