mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
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:
Generated
+2
@@ -878,6 +878,8 @@ dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user