From 4eceec81d261f54627b04271e5eed14216d13b6d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:22:54 +0000 Subject: [PATCH 1/3] fix(desktop-gpui): let clicks through the transparent part of the recording controls on macOS --- apps/desktop-gpui/src/controls_window.rs | 125 ++++++++++++++++++----- apps/desktop-gpui/src/platform.rs | 15 +++ 2 files changed, 114 insertions(+), 26 deletions(-) diff --git a/apps/desktop-gpui/src/controls_window.rs b/apps/desktop-gpui/src/controls_window.rs index 37a723cd7a..e5bd0ff35b 100644 --- a/apps/desktop-gpui/src/controls_window.rs +++ b/apps/desktop-gpui/src/controls_window.rs @@ -36,8 +36,20 @@ pub struct ControlsWindow { _tick: gpui::Task<()>, ticking_live: bool, level_bar: Entity, + /// The banner and bar, in window points. On macOS the rest of the window + /// passes clicks through to the app underneath. + visible_region: std::rc::Rc>>>, + #[cfg(target_os = "macos")] + _click_through: gpui::Task<()>, } +#[cfg(target_os = "macos")] +const POINTER_POLL_INTERVAL: Duration = Duration::from_millis(30); +/// Starts taking clicks just before the pointer reaches the bar, so a quick +/// move-and-click on Stop is not lost to the poll interval. +#[cfg(target_os = "macos")] +const POINTER_HALO: gpui::Pixels = px(4.); + #[derive(Clone, Copy)] enum DestructiveAction { Restart, @@ -90,12 +102,56 @@ impl ControlsWindow { _tick: gpui::Task::ready(()), ticking_live: false, level_bar, + visible_region: Default::default(), + #[cfg(target_os = "macos")] + _click_through: gpui::Task::ready(()), }; this.ticking_live = this.timer_is_live(cx); this._tick = Self::spawn_tick(window, cx); + #[cfg(target_os = "macos")] + { + this._click_through = Self::spawn_click_through(window, cx); + } this } + /// The window is taller than the bar so the issue banner and tooltips + /// have room above it. A transparent window still takes every click in + /// its frame, so it ignores mouse events unless the pointer is over the + /// banner or the bar. An ignoring window gets no mouse moves, hence the + /// poll of the global pointer. + #[cfg(target_os = "macos")] + fn spawn_click_through(window: &mut Window, cx: &mut Context) -> gpui::Task<()> { + cx.spawn_in(window, async move |this, cx| { + let mut native = None; + let mut click_through = None; + loop { + let Ok(region) = this.update_in(cx, |this, window, _| { + if native.is_none() { + native = crate::platform::native_window(window); + } + this.visible_region.get() + }) else { + return; + }; + if let Some(native) = native.as_ref() { + let wanted = !region.is_some_and(|region| { + crate::platform::pointer_in_window_region( + native, + region.dilate(POINTER_HALO), + ) + }); + if click_through != Some(wanted) + && crate::platform::set_window_click_through(native, wanted) + { + click_through = Some(wanted); + } + } + cx.background_executor().timer(POINTER_POLL_INTERVAL).await; + } + }) + } + fn timer_is_live(&self, cx: &gpui::App) -> bool { matches!( self.session.read(cx).phase, @@ -511,6 +567,7 @@ impl ControlsWindow { impl Render for ControlsWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.theme.refresh(window, cx, false); + let region = self.visible_region.clone(); let session = self.session.read(cx); let issue = session.error.clone().or_else(|| { session @@ -528,33 +585,49 @@ impl Render for ControlsWindow { .locale_font() // `body { font-weight: 500 }` (`ui-solid/src/main.css:189-192`). .font_weight(FontWeight::MEDIUM) - .when_some(issue, |this, issue| { - this.child( - div() - .min_h_0() - .w_full() - .flex() - .items_start() - .gap(px(8.)) - .mb(px(8.)) - .rounded(px(12.)) - .border_1() - .border_color(Theme::with_alpha(self.theme.red_9, 0.4)) - .bg(self.theme.gray_1) - .p(px(8.)) - .text_size(px(12.)) - .text_color(self.theme.red_11) - .child( - svg() - .path("icons/triangle-alert.svg") - .size(px(20.)) - .flex_shrink_0() - .text_color(self.theme.red_9), + .child( + div() + .relative() + .min_h_0() + .w_full() + .flex() + .flex_col() + .when_some(issue, |this, issue| { + this.child( + div() + .min_h_0() + .w_full() + .flex() + .items_start() + .gap(px(8.)) + .mb(px(8.)) + .rounded(px(12.)) + .border_1() + .border_color(Theme::with_alpha(self.theme.red_9, 0.4)) + .bg(self.theme.gray_1) + .p(px(8.)) + .text_size(px(12.)) + .text_color(self.theme.red_11) + .child( + svg() + .path("icons/triangle-alert.svg") + .size(px(20.)) + .flex_shrink_0() + .text_color(self.theme.red_9), + ) + .child(recording_issue_text(issue)), ) - .child(recording_issue_text(issue)), - ) - }) - .child(self.render_bar(cx)) + }) + .child(self.render_bar(cx)) + .child( + gpui::canvas( + move |bounds, _, _| region.set(Some(bounds)), + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ), + ) } } diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 6549f0b522..ef8f39b52f 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -1843,6 +1843,21 @@ mod mac { native.0.ignoresMouseEvents() == click_through } + /// Whether the pointer is inside `region`, given in the window's own + /// top-left points. Reads the global pointer, so it keeps working while + /// the window ignores mouse events. + pub fn pointer_in_window_region( + native: &NativeWindow, + region: gpui::Bounds, + ) -> bool { + let frame = native.0.frame(); + let pointer: objc2_foundation::NSPoint = + unsafe { objc2::msg_send![objc2::class!(NSEvent), mouseLocation] }; + let x = pointer.x - frame.origin.x; + let y = frame.origin.y + frame.size.height - pointer.y; + region.contains(&gpui::point(gpui::px(x as f32), gpui::px(y as f32))) + } + /// `orderOut:` -- hide without closing, the way the Tauri main window /// hides while the recording controls bar is up. Takes the retained /// handle for the same reason [`place_overlay_panel`] does: ordering a From b1e1a8121dccebf8465e9a69a651c54a407d5a47 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:08:58 +0000 Subject: [PATCH 2/3] fix(desktop-gpui): re-check controls click-through as each pointer move is delivered --- apps/desktop-gpui/src/controls_window.rs | 52 ++++++++++++++---------- apps/desktop-gpui/src/platform.rs | 34 ++++++++++++++++ 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/apps/desktop-gpui/src/controls_window.rs b/apps/desktop-gpui/src/controls_window.rs index e5bd0ff35b..5497d755b2 100644 --- a/apps/desktop-gpui/src/controls_window.rs +++ b/apps/desktop-gpui/src/controls_window.rs @@ -118,35 +118,43 @@ impl ControlsWindow { /// The window is taller than the bar so the issue banner and tooltips /// have room above it. A transparent window still takes every click in /// its frame, so it ignores mouse events unless the pointer is over the - /// banner or the bar. An ignoring window gets no mouse moves, hence the - /// poll of the global pointer. + /// banner or the bar. An ignoring window gets no mouse moves, so a global + /// move monitor re-checks as AppKit delivers each move, before a click + /// that follows it, and a poll catches the pointer leaving the bar. #[cfg(target_os = "macos")] fn spawn_click_through(window: &mut Window, cx: &mut Context) -> gpui::Task<()> { cx.spawn_in(window, async move |this, cx| { - let mut native = None; - let mut click_through = None; - loop { - let Ok(region) = this.update_in(cx, |this, window, _| { - if native.is_none() { - native = crate::platform::native_window(window); - } - this.visible_region.get() + let (native, region) = loop { + let Ok((native, region)) = this.update_in(cx, |this, window, _| { + ( + crate::platform::native_window(window), + this.visible_region.clone(), + ) }) else { return; }; - if let Some(native) = native.as_ref() { - let wanted = !region.is_some_and(|region| { - crate::platform::pointer_in_window_region( - native, - region.dilate(POINTER_HALO), - ) - }); - if click_through != Some(wanted) - && crate::platform::set_window_click_through(native, wanted) - { - click_through = Some(wanted); - } + if let Some(native) = native { + break (native, region); + } + cx.background_executor().timer(POINTER_POLL_INTERVAL).await; + }; + let click_through = std::cell::Cell::new(None); + let apply = std::rc::Rc::new(move || { + let wanted = !region.get().is_some_and(|region| { + crate::platform::pointer_in_window_region(&native, region.dilate(POINTER_HALO)) + }); + if click_through.get() != Some(wanted) + && crate::platform::set_window_click_through(&native, wanted) + { + click_through.set(Some(wanted)); } + }); + let _monitor = crate::platform::monitor_pointer_moves({ + let apply = apply.clone(); + move || apply() + }); + while this.update(cx, |_, _| ()).is_ok() { + apply(); cx.background_executor().timer(POINTER_POLL_INTERVAL).await; } }) diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index ef8f39b52f..19dcb02803 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -1858,6 +1858,40 @@ mod mac { region.contains(&gpui::point(gpui::px(x as f32), gpui::px(y as f32))) } + /// Removes its `NSEvent` monitor when dropped. + pub struct PointerMoveMonitor(Retained); + + impl Drop for PointerMoveMonitor { + fn drop(&mut self) { + unsafe { + let _: () = objc2::msg_send![objc2::class!(NSEvent), removeMonitor: &*self.0]; + } + } + } + + /// Calls `on_move` on the main thread each time the pointer moves over + /// another app's windows, which includes a window of ours that ignores + /// mouse events. AppKit runs it while delivering the move, before any + /// click that follows. Mouse monitors need no accessibility permission. + pub fn monitor_pointer_moves(on_move: impl Fn() + 'static) -> Option { + const MOUSE_MOVED: u64 = 1 << 5; + const LEFT_MOUSE_DRAGGED: u64 = 1 << 6; + const RIGHT_MOUSE_DRAGGED: u64 = 1 << 7; + const OTHER_MOUSE_DRAGGED: u64 = 1 << 27; + let block = block2::RcBlock::new(move |_: std::ptr::NonNull| on_move()); + let monitor: Option> = unsafe { + objc2::msg_send![ + objc2::class!(NSEvent), + addGlobalMonitorForEventsMatchingMask: MOUSE_MOVED + | LEFT_MOUSE_DRAGGED + | RIGHT_MOUSE_DRAGGED + | OTHER_MOUSE_DRAGGED, + handler: &*block + ] + }; + monitor.map(PointerMoveMonitor) + } + /// `orderOut:` -- hide without closing, the way the Tauri main window /// hides while the recording controls bar is up. Takes the retained /// handle for the same reason [`place_overlay_panel`] does: ordering a From 2665ad80e13b28efbb18348cc35ac6e4509d4f8d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:00:14 +0000 Subject: [PATCH 3/3] fix(desktop-gpui): re-check click-through on pointer moves sent to Cap's own windows --- apps/desktop-gpui/src/platform.rs | 52 +++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 19dcb02803..1892bda65d 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -1858,38 +1858,58 @@ mod mac { region.contains(&gpui::point(gpui::px(x as f32), gpui::px(y as f32))) } - /// Removes its `NSEvent` monitor when dropped. - pub struct PointerMoveMonitor(Retained); + /// Removes its `NSEvent` monitors when dropped. + pub struct PointerMoveMonitor(Vec>); impl Drop for PointerMoveMonitor { fn drop(&mut self) { - unsafe { - let _: () = objc2::msg_send![objc2::class!(NSEvent), removeMonitor: &*self.0]; + for monitor in &self.0 { + unsafe { + let _: () = objc2::msg_send![objc2::class!(NSEvent), removeMonitor: &**monitor]; + } } } } - /// Calls `on_move` on the main thread each time the pointer moves over - /// another app's windows, which includes a window of ours that ignores - /// mouse events. AppKit runs it while delivering the move, before any - /// click that follows. Mouse monitors need no accessibility permission. + /// Calls `on_move` on the main thread each time the pointer moves. The + /// global monitor sees moves AppKit sends to other apps, which includes a + /// window of ours that ignores mouse events; the local one sees moves sent + /// to Cap's own windows, such as a camera bubble under the bar or the + /// controls window itself. AppKit runs both while delivering the move, + /// before any click that follows. Mouse monitors need no accessibility + /// permission. pub fn monitor_pointer_moves(on_move: impl Fn() + 'static) -> Option { const MOUSE_MOVED: u64 = 1 << 5; const LEFT_MOUSE_DRAGGED: u64 = 1 << 6; const RIGHT_MOUSE_DRAGGED: u64 = 1 << 7; const OTHER_MOUSE_DRAGGED: u64 = 1 << 27; - let block = block2::RcBlock::new(move |_: std::ptr::NonNull| on_move()); - let monitor: Option> = unsafe { + const MASK: u64 = + MOUSE_MOVED | LEFT_MOUSE_DRAGGED | RIGHT_MOUSE_DRAGGED | OTHER_MOUSE_DRAGGED; + let on_move = std::rc::Rc::new(on_move); + let global = block2::RcBlock::new({ + let on_move = on_move.clone(); + move |_: std::ptr::NonNull| on_move() + }); + let local = block2::RcBlock::new(move |event: std::ptr::NonNull| { + on_move(); + event.as_ptr() + }); + let global: Option> = unsafe { + objc2::msg_send![ + objc2::class!(NSEvent), + addGlobalMonitorForEventsMatchingMask: MASK, + handler: &*global + ] + }; + let local: Option> = unsafe { objc2::msg_send![ objc2::class!(NSEvent), - addGlobalMonitorForEventsMatchingMask: MOUSE_MOVED - | LEFT_MOUSE_DRAGGED - | RIGHT_MOUSE_DRAGGED - | OTHER_MOUSE_DRAGGED, - handler: &*block + addLocalMonitorForEventsMatchingMask: MASK, + handler: &*local ] }; - monitor.map(PointerMoveMonitor) + let monitors = global.into_iter().chain(local).collect::>(); + (!monitors.is_empty()).then_some(PointerMoveMonitor(monitors)) } /// `orderOut:` -- hide without closing, the way the Tauri main window