From 44f3f304a5dcdc217a8e672afbde8eb2dd9acb81 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 13:40:28 +0000 Subject: [PATCH] macOS: dragging a title-bar control no longer moves the window AppKit moves the window when a drag starts anywhere in the title-bar band of a full-size content view, on the application bar's controls too. The title bar now reports whether the pointer is on one of its controls (title_bar::window_movable), and the macOS app sets the window's movable flag from it each frame it changes, through objc2-app-kit's safe methods (the versions muda already uses). Home, the menus, search, the workspace menu, Share and Discord keep their drags; empty bar space still moves the window, and so does the band while a modal covers the bar. --- Cargo.lock | 2 ++ apps/designcraft/Cargo.toml | 3 +++ apps/designcraft/src/mac_window.rs | 31 ++++++++++++++++++++++++++++++ apps/designcraft/src/main.rs | 9 +++++++++ crates/ui-egui/src/chrome.rs | 24 +++++++++++++++++++++++ crates/ui-egui/src/title_bar.rs | 26 +++++++++++++++++++++++-- 6 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 apps/designcraft/src/mac_window.rs diff --git a/Cargo.lock b/Cargo.lock index 484bfa1b8..911f7dea0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,6 +878,8 @@ dependencies = [ "egui", "log", "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "rfd", "serde", "serde_json", diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 416b07406..404534581 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -31,3 +31,6 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } +# The versions muda uses: the window's `movable` flag (see `mac_window`), through safe methods. +objc2 = { version = "0.6", default-features = false, features = ["std"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] } diff --git a/apps/designcraft/src/mac_window.rs b/apps/designcraft/src/mac_window.rs new file mode 100644 index 000000000..b7fd267f1 --- /dev/null +++ b/apps/designcraft/src/mac_window.rs @@ -0,0 +1,31 @@ +//! macOS: AppKit moves the window when a drag starts anywhere in the title-bar band, on the +//! application bar's controls too. The window is made unmovable while the pointer is on one of +//! them (`designcraft_ui_egui::title_bar::window_movable`), so only the bar's empty space moves it. + +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSWindowStyleMask}; + +/// Sets the window's `movable` flag from this frame's title bar. `applied` holds the value last +/// set, so AppKit is only called when it changes. +pub fn sync_movable(ctx: &egui::Context, applied: &mut Option) { + let movable = designcraft_ui_egui::title_bar::window_movable(ctx); + if *applied == Some(movable) { + return; + } + // eframe runs the app on the main thread; anywhere else AppKit is out of reach. + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let mut found = false; + for window in NSApplication::sharedApplication(mtm).windows().to_vec() { + // The main window is the only one with a full-size content view. + if window.styleMask().contains(NSWindowStyleMask::FullSizeContentView) { + window.setMovable(movable); + found = true; + } + } + // Before the window is on screen there is nothing to set yet; try again next frame. + if found { + *applied = Some(movable); + } +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index c30606232..b1233a59d 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -15,6 +15,8 @@ mod control_server; mod gpu; mod logging; #[cfg(target_os = "macos")] +mod mac_window; +#[cfg(target_os = "macos")] mod native_menu; use designcraft_engine::Session; @@ -24,6 +26,9 @@ struct App { app: DesignApp, #[cfg(target_os = "macos")] menu: Option, + /// The window's `movable` flag as last set; see `mac_window`. + #[cfg(target_os = "macos")] + movable: Option, /// The graphics start in progress (`gpu.json`) until a frame has been presented; see `gpu`. startup: Option, /// Frames begun so far. @@ -57,6 +62,8 @@ impl eframe::App for App { } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.app.ui(ui); + #[cfg(target_os = "macos")] + mac_window::sync_movable(ui.ctx(), &mut self.movable); } fn on_exit(&mut self) { // A normal exit before the third frame is not a crash. @@ -332,6 +339,8 @@ fn main() -> eframe::Result { app, #[cfg(target_os = "macos")] menu: None, + #[cfg(target_os = "macos")] + movable: None, startup: gpu, frames: 0, })) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 41f168508..744679cad 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -942,6 +942,30 @@ mod tests { } } + #[test] + fn the_mac_window_is_not_movable_on_the_title_bar_controls() { + use super::{MAC_APP_BAR, MAC_CONTENT_LEFT}; + let mut app = app(); + app.integrated_titlebar = true; + let mut h = test_window::open(app, vec2(1400.0, 900.0)); + let bar = test_window::panel_rect(&h, "app_bar"); + let mid = bar.min.y + MAC_APP_BAR / 2.0; + // The search field is the bar's last control: 125 wide, 4 from the 10-wide right margin. + let search = pos2(bar.max.x - 10.0 - 4.0 - 60.0, mid); + let controls = [rect(&h, "Home").center(), rect(&h, "Share").center(), rect(&h, "File").center(), search]; + let mut movable_at = |at: egui::Pos2| { + h.hover_at(at); + h.run_steps(2); + crate::title_bar::window_movable(&h.ctx) + }; + for at in [pos2(bar.center().x, mid), pos2(bar.min.x + MAC_CONTENT_LEFT - 4.0, bar.min.y + 4.0), pos2(bar.center().x, 500.0)] { + assert!(movable_at(at), "empty title bar or the page at {at:?}"); + } + for at in controls { + assert!(!movable_at(at), "control at {at:?}"); + } + } + #[test] fn a_narrow_window_scrolls_the_app_bar() { let w = 640.0; diff --git a/crates/ui-egui/src/title_bar.rs b/crates/ui-egui/src/title_bar.rs index 0210ddead..a369fa9fb 100644 --- a/crates/ui-egui/src/title_bar.rs +++ b/crates/ui-egui/src/title_bar.rs @@ -2,8 +2,10 @@ //! when dragged and follows the system's title-bar double-click setting. //! //! The window has a full-size content view, so AppKit hands every click in the title-bar band to -//! the app (that is how the bar's own controls work) and neither drags nor zooms the window -//! itself. +//! the app (that is how the bar's own controls work) and does not zoom the window. It does move +//! the window when a drag starts anywhere in the band, on the bar's controls too, unless the +//! window is not movable. [`window_movable`] tells the macOS shell when the pointer is on a +//! control, so it can switch that off before the press. use std::sync::{Once, OnceLock}; @@ -70,6 +72,12 @@ pub fn double_click_command(action: DoubleClick, maximized: bool) -> Option egui::Id { + egui::Id::new("title_bar_window_movable") +} + +/// Whether AppKit may move the window when a drag starts in the title-bar band: false while the +/// pointer is on one of the bar's controls, so pressing and dragging it does not move the window. +/// True when neither this pass nor the one before drew a title bar. +pub fn window_movable(ctx: &egui::Context) -> bool { + match ctx.data(|d| d.get_temp::<(u64, bool)>(movable_id())) { + Some((pass, movable)) if ctx.cumulative_pass_nr().saturating_sub(pass) <= 1 => movable, + _ => true, + } +} + #[cfg(test)] mod tests { use super::*;