From cb422fa7af0de6d63e2ae8b103f7309cafdb64df Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Mon, 13 Jul 2026 00:46:47 +0200 Subject: [PATCH] macos: Fix window move controls are disabled (#60620) Closes https://github.com/zed-industries/zed/issues/60595 cc Anthony-Eid since you did an initial pass on this. # Objective Currenly on macOs there is no way right now after the following regression to use window management short keys to move them around. This worked before, but was regressed initially in https://github.com/zed-industries/zed/pull/59836 that tried fixing a macos 27 clicking delay issue on the titlebar. That accidentally set the `native_window.setMovable_(is_movable as BOOL)` to false. Because of this mocos detects this and disables the window managements actions because it thinks the window can/should not be able to move. ## Solution The macOS 27 click delay actually comes from AppKit's native titlebar dragging, not from `NSWindow.isMovable`. The problem is that `is_movable` is also what enables the Window menu's tiling options, and #59836 used it to turn off dragging, which disabled the tiling menu as a side effect. To fix this, I restored `is_movable` to its real meaning so the tiling menu works again, and added a separate `WindowOptions::app_owns_titlebar_drag` flag for windows that draw their own titlebar and handle dragging via `Window::start_window_move`. On macOS this tells AppKit to stop owning titlebar drags (removing the click delay) without affecting `is_movable`. Zed's main window now sets both `is_movable: true` and `app_owns_titlebar_drag: true`, so the tiling menu stays enabled, the click delay is gone. I also added a `window_movable` gpui example to make this behavior easy to test manually. ## Testing I added a gpui test example that helps testing this bug. You can run the newly added gpui example with 4 different window configurations that should confirm that this change is the right one for all of the window types that we have. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase **After** (note this now shows the window managements entries as they were greyed out.) https://github.com/user-attachments/assets/192cc0e1-15d8-486f-b788-a92bb51fb7d1 **GPUI** (test examples with different window configurations.) https://github.com/user-attachments/assets/c9e0f4d3-606b-4cd7-b4d8-a5bb16508164 --- Release Notes: - Fix macos window management controlls were grayed out/not working --- crates/gpui/Cargo.toml | 4 + crates/gpui/examples/window_movable.rs | 125 +++++++++++++++++++++++++ crates/gpui/src/platform.rs | 30 ++++-- crates/gpui/src/window.rs | 2 + crates/gpui_macos/src/window.rs | 32 +++++++ crates/zed/src/zed.rs | 11 ++- 6 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 crates/gpui/examples/window_movable.rs diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index fa6fcace40f376..89e02aacbcc46e 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -197,6 +197,10 @@ path = "examples/input.rs" name = "on_window_close_quit" path = "examples/on_window_close_quit.rs" +[[example]] +name = "window_movable" +path = "examples/window_movable.rs" + [[example]] name = "opacity" path = "examples/opacity.rs" diff --git a/crates/gpui/examples/window_movable.rs b/crates/gpui/examples/window_movable.rs new file mode 100644 index 00000000000000..587a2dbfea55cc --- /dev/null +++ b/crates/gpui/examples/window_movable.rs @@ -0,0 +1,125 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +use gpui::{ + App, Bounds, Context, FocusHandle, Window, WindowBounds, WindowOptions, div, prelude::*, px, + rgb, size, +}; +use gpui::{SharedString, TitlebarOptions}; +use gpui_platform::application; + +struct ExampleWindow { + label: SharedString, + focus_handle: FocusHandle, +} + +impl Render for ExampleWindow { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .track_focus(&self.focus_handle) + .flex() + .flex_col() + .gap_3() + .bg(rgb(0x2e2e2e)) + .size_full() + .justify_center() + .items_center() + .p_8() + .text_lg() + .text_color(rgb(0xffffff)) + .child(self.label.clone()) + .child( + div() + .text_sm() + .text_color(rgb(0xb0b0b0)) + .child("Try to drag the titlebar, and check the Window menu."), + ) + } +} + +fn open_test_window( + cx: &mut App, + bounds: Bounds, + label: &str, + is_movable: bool, + appears_transparent: bool, + app_owns_titlebar_drag: bool, +) { + let label = SharedString::from(format!( + "{label}\nis_movable: {is_movable}\n\ + appears_transparent: {appears_transparent}\n\ + app_owns_titlebar_drag: {app_owns_titlebar_drag}" + )); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + is_movable, + app_owns_titlebar_drag, + titlebar: Some(TitlebarOptions { + title: Some(label.clone()), + appears_transparent, + ..Default::default() + }), + ..Default::default() + }, + |window, cx| { + cx.new(|cx| { + let focus_handle = cx.focus_handle(); + focus_handle.focus(window, cx); + ExampleWindow { + label, + focus_handle, + } + }) + }, + ) + .unwrap(); +} + +fn run_example() { + application().run(|cx: &mut App| { + let window_size = size(px(420.), px(280.0)); + let base = Bounds::centered(None, window_size, cx); + + // (label, is_movable, appears_transparent, app_owns_titlebar_drag, col, row) + let windows = [ + ("Native titlebar, movable", true, false, false, 0.0, 0.0), + ( + "Native titlebar, NOT movable", + false, + false, + false, + 1.0, + 0.0, + ), + ("Custom titlebar, movable", true, true, false, 0.0, 1.0), + ("Custom titlebar, NOT movable", false, true, false, 1.0, 1.0), + ]; + + for (label, is_movable, appears_transparent, app_owns_titlebar_drag, col, row) in windows { + let mut bounds = base; + bounds.origin.x += window_size.width * col; + bounds.origin.y += window_size.height * row; + open_test_window( + cx, + bounds, + label, + is_movable, + appears_transparent, + app_owns_titlebar_drag, + ); + } + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 5765fd80aff0b5..fff4664928f4cc 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1495,14 +1495,24 @@ pub struct WindowOptions { /// The kind of window to create pub kind: WindowKind, - /// Whether the window should be movable by the user. - /// - /// On macOS 27, custom titlebar windows that implement their own drag behavior - /// with [`Window::start_window_move`] should set this to `false`; otherwise - /// AppKit can treat the titlebar region as system-owned and delay clicks - /// while disambiguating titlebar double-clicks. + /// Whether the window can be moved by the user. When `false`, the user cannot drag + /// the window (on macOS this sets `NSWindow.isMovable`, which also disables the + /// Window-menu tiling items); programmatic moves are still allowed. pub is_movable: bool, + /// Whether the application owns dragging of the (custom) titlebar, rather than + /// AppKit. Only has an effect on macOS. + /// + /// Set this to `true` for windows that draw their own titlebar and move the window + /// themselves via [`Window::start_window_move`]. It marks the whole content view as + /// app-owned titlebar content, so AppKit neither drags the window from the titlebar + /// nor delays titlebar clicks while disambiguating double-clicks (a delay first + /// observed on macOS 27). It is independent of `is_movable`, so such windows stay + /// user-movable (via their own drag) and keep the Window-menu tiling items enabled. + /// + /// Leave this `false` for windows that rely on AppKit's native titlebar dragging. + pub app_owns_titlebar_drag: bool, + /// Whether the window should be resizable by the user pub is_resizable: bool, @@ -1558,6 +1568,13 @@ pub struct WindowParams { #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] pub is_movable: bool, + /// Whether the application owns dragging of the (custom) titlebar (macOS only) + #[cfg_attr( + any(target_os = "linux", target_os = "freebsd", target_os = "windows"), + allow(dead_code) + )] + pub app_owns_titlebar_drag: bool, + /// Whether the window should be resizable by the user #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] pub is_resizable: bool, @@ -1639,6 +1656,7 @@ impl Default for WindowOptions { show: true, kind: WindowKind::Normal, is_movable: true, + app_owns_titlebar_drag: false, is_resizable: true, is_minimizable: true, display_id: None, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 198fab268e8cfb..fd560761c899f8 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1292,6 +1292,7 @@ impl Window { show, kind, is_movable, + app_owns_titlebar_drag, is_resizable, is_minimizable, display_id, @@ -1320,6 +1321,7 @@ impl Window { titlebar, kind, is_movable, + app_owns_titlebar_drag, is_resizable, is_minimizable, focus, diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 938633d4749118..e14b2dc0d3fd8c 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -288,6 +288,12 @@ unsafe fn build_classes() { accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, ); + decl.add_method( + sel!(_opaqueRectForWindowMoveWhenInTitlebar), + opaque_rect_for_window_move_when_in_titlebar + as extern "C" fn(&Object, Sel) -> NSRect, + ); + decl.add_method( sel!(characterIndexForPoint:), character_index_for_point as extern "C" fn(&Object, Sel, NSPoint) -> u64, @@ -512,6 +518,11 @@ struct MacWindowState { external_files_dragged: bool, // Whether the next left-mouse click is also the focusing click. first_mouse: bool, + // When true, the whole content view is reported as app-owned titlebar content via + // `_opaqueRectForWindowMoveWhenInTitlebar`, so AppKit does not drag the window from + // the titlebar or delay titlebar clicks (a delay first observed on macOS 27). Such + // windows draw their own titlebar and move the window via `start_window_move`. + app_owns_titlebar_drag: bool, fullscreen_restore_bounds: Bounds, move_tab_to_new_window_callback: Option>, merge_all_windows_callback: Option>, @@ -743,6 +754,7 @@ impl MacWindow { titlebar, kind, is_movable, + app_owns_titlebar_drag, is_resizable, is_minimizable, focus, @@ -902,6 +914,7 @@ impl MacWindow { do_command_handled: None, external_files_dragged: false, first_mouse: false, + app_owns_titlebar_drag, fullscreen_restore_bounds: Bounds::default(), move_tab_to_new_window_callback: None, merge_all_windows_callback: None, @@ -2851,6 +2864,25 @@ extern "C" fn accepts_first_mouse(this: &Object, _: Sel, _: id) -> BOOL { YES } +// Reports which region of the view AppKit should treat as app-owned titlebar content +// (rather than a system-owned window-move region). When `app_owns_titlebar_drag` is +// true, we claim the entire view so AppKit neither drags the window from the titlebar +// nor waits to disambiguate double-clicks before delivering titlebar clicks (the macOS +// 27 delay); such windows implement dragging themselves via [`Window::start_window_move`]. +// Otherwise we return an empty rect so AppKit's native titlebar dragging keeps working. +// This is independent of `NSWindow.isMovable`, so the Window-menu tiling items stay +// enabled regardless. +extern "C" fn opaque_rect_for_window_move_when_in_titlebar(this: &Object, _: Sel) -> NSRect { + let zero_rect = NSRect::new(NSPoint::new(0., 0.), NSSize::new(0., 0.)); + let window_state = unsafe { get_window_state(this) }; + let app_owns_titlebar_drag = window_state.as_ref().lock().app_owns_titlebar_drag; + if app_owns_titlebar_drag { + unsafe { msg_send![this, bounds] } + } else { + zero_rect + } +} + extern "C" fn character_index_for_point(this: &Object, _: Sel, position: NSPoint) -> u64 { let position = screen_point_to_gpui_point(this, position); with_input_handler(this, |input_handler| { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 7e07e7f0f3fddc..e768a3e1f311cf 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -377,10 +377,13 @@ pub fn build_window_options(display_uuid: Option, cx: &mut App) -> WindowO focus: false, show: false, kind: WindowKind::Normal, - // On macOS, Zed handles window movement itself, so disable AppKit's titlebar dragging. - // On other platforms, `is_movable` gates native window dragging (e.g. Windows' - // `HTCAPTION` hit test), so it must remain `true`. - is_movable: cfg!(not(target_os = "macos")), + is_movable: true, + // Zed draws its own titlebar and moves the window via [`Window::start_window_move`], + // so on macOS AppKit should not own titlebar dragging. This avoids the titlebar + // click delay from AppKit's drag disambiguation (first observed on macOS 27) while + // keeping the window movable and the Window-menu tiling items enabled. No-op on + // other platforms. + app_owns_titlebar_drag: true, display_id: display.map(|display| display.id()), window_background: cx.theme().window_background_appearance(), app_id: Some(app_id.to_owned()),