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.
This commit is contained in:
aiongg
2026-10-11 13:40:28 +00:00
parent 94fa7dc2ad
commit 44f3f304a5
6 changed files with 93 additions and 2 deletions
Generated
+2
View File
@@ -878,6 +878,8 @@ dependencies = [
"egui",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"rfd",
"serde",
"serde_json",
+3
View File
@@ -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"] }
+31
View File
@@ -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<bool>) {
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);
}
}
+9
View File
@@ -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<native_menu::NativeMenu>,
/// The window's `movable` flag as last set; see `mac_window`.
#[cfg(target_os = "macos")]
movable: Option<bool>,
/// The graphics start in progress (`gpu.json`) until a frame has been presented; see `gpu`.
startup: Option<gpu::Startup>,
/// 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,
}))
+24
View File
@@ -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;
+24 -2
View File
@@ -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
pub fn empty_area(ui: &mut egui::Ui, rect: egui::Rect) {
prefetch();
let resp = ui.interact(rect, ui.id().with("title_bar_empty"), egui::Sense::click());
// A control in this layer covers the empty area under the pointer. Another layer on top (a
// modal's backdrop, a tooltip) leaves the window movable, as AppKit's own title bar would be.
let on_control =
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id())) && !resp.hovered();
let pass = ui.ctx().cumulative_pass_nr();
ui.data_mut(|d| d.insert_temp(movable_id(), (pass, !on_control)));
if resp.double_clicked() {
let maximized = ui.input(|i| i.viewport().maximized).unwrap_or(false);
if let Some(cmd) = double_click_command(action(), maximized) {
@@ -91,6 +99,20 @@ pub fn empty_area(ui: &mut egui::Ui, rect: egui::Rect) {
}
}
fn movable_id() -> 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::*;