Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
73cbfd3
feat(desktop-gpui): picker controls in Cap Glass over a capture of th…
richiemcilroy Oct 27, 2026
5992653
feat(desktop-gpui): a glass switch for Focus, with the system switch'…
richiemcilroy Oct 27, 2026
6762184
test(desktop-gpui): measure the picker's CPU idle and mid-drag, with …
richiemcilroy Oct 27, 2026
826ca99
fix(desktop-gpui): capture straight to BGRA on macOS, check the backd…
richiemcilroy Oct 27, 2026
ccfd1eb
perf(desktop-gpui): retake the picker's backdrop once a dragged area …
richiemcilroy Oct 27, 2026
3b5a375
Merge feat/gpui-cap-glass into feat/gpui-picker-glass
richiemcilroy Oct 27, 2026
32c01da
Merge feat/gpui-cap-glass into feat/gpui-picker-glass
richiemcilroy Oct 27, 2026
11733ba
fix(desktop-gpui): leave the camera out of the picker's backdrop and …
richiemcilroy Oct 27, 2026
2bd82bf
Merge feat/gpui-cap-glass into feat/gpui-picker-glass
richiemcilroy Oct 27, 2026
704414e
fix(desktop-gpui): parse window capture affinity check on Windows
richiemcilroy Oct 27, 2026
018c89e
Merge feat/gpui-cap-glass into feat/gpui-picker-glass
richiemcilroy Oct 27, 2026
481a174
fix(desktop-gpui): let the window server hide the picker before captu…
richiemcilroy Oct 27, 2026
25e1dfc
feat(desktop-gpui): anchored picker hints and a Focus ring around the…
richiemcilroy Oct 27, 2026
bc01d38
feat(desktop-gpui): Focus card, hints that swap instantly and can pre…
richiemcilroy Oct 27, 2026
9aa9f60
fix(desktop-gpui): draw the Focus ring as borders so it never tints t…
richiemcilroy Oct 27, 2026
3a4b366
Merge feat/gpui-cap-glass into feat/gpui-picker-glass
richiemcilroy Oct 27, 2026
14a70c8
fix(desktop-gpui): move the camera bubble with a window Focus or the …
richiemcilroy Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/picker-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-gpui/assets/icons/hint-arrow-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/desktop-gpui/assets/icons/hint-arrow-left.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/desktop-gpui/assets/icons/hint-arrow-right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/desktop-gpui/assets/icons/hint-arrow-up.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 67 additions & 0 deletions apps/desktop-gpui/src/app_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3980,6 +3980,13 @@ fn begin_recording_after_preflight(config: StartConfig, cx: &mut App) {

let mut excluded: Vec<scap_targets::WindowId> = 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
Expand Down Expand Up @@ -4699,6 +4706,65 @@ 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 x = (new.x + new.width - bubble.width - right_gap)
.min(new.x + new.width - bubble.width - CAMERA_PARK_PADDING)
.max(new.x + CAMERA_PARK_PADDING);
let y = (new.y + new.height - bubble.height - bottom_gap)
.min(new.y + new.height - bubble.height - CAMERA_PARK_PADDING)
.max(new.y + CAMERA_PARK_PADDING);
cx.global_mut::<AppWindows>().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,
Expand Down Expand Up @@ -7139,6 +7205,7 @@ pub fn refresh_library_after_delete(cx: &mut App) {
}

fn close_controls(session: &Entity<RecordingSession>, cx: &mut App) {
crate::focus_border::close(cx);
if let Some(handle) = cx.global_mut::<AppWindows>().controls.take() {
handle
.update(cx, |_, window, _| window.remove_window())
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop-gpui/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
217 changes: 217 additions & 0 deletions apps/desktop-gpui/src/focus_border.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
//! 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<FocusBorder>);
impl Global for FocusBorderWindow {}

pub struct FocusBorder {
window: WindowId,
display: DisplayId,
bounds: Option<AreaRect>,
theme: Theme,
_follow: Task<()>,
}

impl FocusBorder {
fn new(target: HoveredWindow, window: &mut Window, cx: &mut Context<Self>) -> 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 found = cx
.background_executor()
.spawn(async move {
scap_targets::Window::from_id(&id)
.as_ref()
.and_then(HoveredWindow::from_window)
})
.await;
let updated = this.update(cx, |this, cx| {
let bounds = found
.filter(|found| found.display_id == this.display)
.map(|found| found.bounds);
if this.bounds != bounds {
this.bounds = bounds;
cx.notify();
}
});
if updated.is_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<Self>) -> impl IntoElement {
div()
.size_full()
.relative()
.children(self.bounds.map(|bounds| ring(bounds, &self.theme)))
}
}

/// 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<WindowId> {
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::<FocusBorderWindow>()
.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::<FocusBorderWindow>() {
let handle = *handle;
cx.remove_global::<FocusBorderWindow>();
handle
.update(cx, |_, window, _| window.remove_window())
.ok();
}
}
2 changes: 2 additions & 0 deletions apps/desktop-gpui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ mod editor_tabs;
mod editor_timeline;
mod editor_window;
mod feeds;
mod focus_border;
mod hotkeys;
mod i18n;
mod import;
Expand All @@ -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;
Expand Down
Loading
Loading