diff --git a/apps/desktop-gpui/src/controls_window.rs b/apps/desktop-gpui/src/controls_window.rs index 37a723cd7a..5497d755b2 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,64 @@ 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, 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 (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 { + 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; + } + }) + } + fn timer_is_live(&self, cx: &gpui::App) -> bool { matches!( self.session.read(cx).phase, @@ -511,6 +575,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 +593,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..1892bda65d 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -1843,6 +1843,75 @@ 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))) + } + + /// Removes its `NSEvent` monitors when dropped. + pub struct PointerMoveMonitor(Vec>); + + impl Drop for PointerMoveMonitor { + fn drop(&mut self) { + 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. 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; + 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), + addLocalMonitorForEventsMatchingMask: MASK, + handler: &*local + ] + }; + 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 /// hides while the recording controls bar is up. Takes the retained /// handle for the same reason [`place_overlay_panel`] does: ordering a