Merge mac-titlebar update: macOS: dragging a title-bar control no longer moves the window

Conflict: apps/designcraft/Cargo.toml: one objc2/objc2-app-kit entry with next's NSTextInputContext and the branch's NSApplication/NSResponder/NSWindow features; next's fmv-macos-events kept
Conflict: apps/designcraft/src/main.rs: next's App fields, GPU startup, IME discard and Apple events kept; the branch's `movable` field and `mac_window::sync_movable` call added before `startup.leave()`
This commit is contained in:
aiongg
2026-10-11 14:10:18 +00:00
5 changed files with 91 additions and 4 deletions
+3 -2
View File
@@ -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,
+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
@@ -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<bool>,
/// 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,
}))
}),
+24
View File
@@ -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;
+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::*;