From 9ba1a0116a32e79dbcfe6cddbf9fca3fb9559a47 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 eab072e470..bcf992a531 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -195,6 +195,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 0000000000..587a2dbfea --- /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 52b2f0066a..531145bcaa 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1682,14 +1682,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, @@ -1745,6 +1755,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, @@ -1826,6 +1843,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 35269a09c8..5246964274 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1293,6 +1293,7 @@ impl Window { show, kind, is_movable, + app_owns_titlebar_drag, is_resizable, is_minimizable, display_id, @@ -1321,6 +1322,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 6221b14161..4be73b8ce3 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>, @@ -746,6 +757,7 @@ impl MacWindow { titlebar, kind, is_movable, + app_owns_titlebar_drag, is_resizable, is_minimizable, focus, @@ -909,6 +921,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, @@ -2860,6 +2873,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 2ecd422be6..01f887b6b4 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -381,10 +381,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()),