diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 58d6b1618..03dbfc445 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -39,9 +39,10 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"] } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } -# The input context, to drop the IME's marked text when the Type tool ends a composition. +# The input context, to drop the IME's marked text when the Type tool ends a composition; the +# window's `movable` flag (see `mac_window`). The versions muda uses, through safe methods. objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSTextInputContext", "NSWindow"] } # Finder double-clicks, Open With, Dock drops and `open -a DesignCraft brochure.idml` arrive as a # kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by PhotoCraft, # PdfCraft and WordCraft) registers the handler behind a safe API (Objective-C interop needs unsafe, 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 0a56b79c7..1c2163554 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -19,6 +19,8 @@ mod control_server; mod gpu; mod logging; #[cfg(target_os = "macos")] +mod mac_window; +#[cfg(target_os = "macos")] mod native_menu; mod profile; @@ -34,6 +36,9 @@ struct App { /// Documents and quit requests from Finder, the Dock and Open With. #[cfg(target_os = "macos")] apple_events: fmv_macos_events::Inbox, + /// The window's `movable` flag as last set; see `mac_window`. + #[cfg(target_os = "macos")] + movable: Option, /// Frames begun so far. frames: u32, } @@ -74,6 +79,8 @@ impl eframe::App for App { if self.app.take_ime_discard() { discard_marked_text(); } + #[cfg(target_os = "macos")] + mac_window::sync_movable(ui.ctx(), &mut self.movable); self.startup.leave(); } fn on_exit(&mut self) { @@ -461,6 +468,8 @@ fn main() -> std::process::ExitCode { startup: created, #[cfg(target_os = "macos")] apple_events: events.connect(&cc.egui_ctx), + #[cfg(target_os = "macos")] + movable: None, frames: 0, })) }), diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 451c933d1..3a1d282fd 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -983,6 +983,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::*;