Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
133 changes: 107 additions & 26 deletions apps/desktop-gpui/src/controls_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,20 @@ pub struct ControlsWindow {
_tick: gpui::Task<()>,
ticking_live: bool,
level_bar: Entity<ui::MicrophoneLevel>,
/// 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<std::cell::Cell<Option<gpui::Bounds<gpui::Pixels>>>>,
#[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,
Expand Down Expand Up @@ -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<Self>) -> 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;
Comment thread
richiemcilroy marked this conversation as resolved.
};
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,
Expand Down Expand Up @@ -511,6 +575,7 @@ impl ControlsWindow {
impl Render for ControlsWindow {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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
Expand All @@ -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(),
),
)
}
}

Expand Down
69 changes: 69 additions & 0 deletions apps/desktop-gpui/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<gpui::Pixels>,
) -> 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<Retained<AnyObject>>);

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<PointerMoveMonitor> {
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<AnyObject>| on_move()
});
let local = block2::RcBlock::new(move |event: std::ptr::NonNull<AnyObject>| {
on_move();
event.as_ptr()
});
let global: Option<Retained<AnyObject>> = unsafe {
objc2::msg_send![
objc2::class!(NSEvent),
addGlobalMonitorForEventsMatchingMask: MASK,
handler: &*global
]
};
let local: Option<Retained<AnyObject>> = unsafe {
objc2::msg_send![
objc2::class!(NSEvent),
addLocalMonitorForEventsMatchingMask: MASK,
handler: &*local
]
};
let monitors = global.into_iter().chain(local).collect::<Vec<_>>();
(!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
Expand Down
Loading