From 200b68fd6abf0094f15bb6f42872a135a6f48716 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:09:34 -0400 Subject: [PATCH] docking: a macOS window bar drag follows AppKit's cursor (#325) AppKit reports a native window's new place as soon as a move is commanded, but measures the pointer events it sends against where the window server had the window, which can be a move behind. Placing the window from those events made it overshoot and step back while the cursor moved smoothly. On macOS the window now goes to NSEvent::mouseLocation, flipped against the primary screen's height and divided by egui's zoom factor, less the pressed point; drop targets are hit at the same point. Other platforms keep placing the window from the frame's pointer move. The cursor source is a DesktopCursor the test harness fixes. NativeHost can tell the cursor (tells_cursor) and measure a window's pointer events against its place some frames back (stale), through window_pointer. --- crates/ui/Cargo.toml | 2 +- crates/ui/src/docking/native_tests.rs | 41 +++++++++++++- crates/ui/src/docking/render/mod.rs | 5 +- crates/ui/src/docking/render/native.rs | 74 +++++++++++++++++++++----- crates/ui/src/docking/surface.rs | 13 +++++ crates/ui/src/docking/testing.rs | 52 +++++++++++++++++- docs/ui-docking.md | 22 +++++--- 7 files changed, 186 insertions(+), 23 deletions(-) diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index b35b71a3..a27c9d56 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -18,7 +18,7 @@ serde = { version = "1", features = ["derive"], optional = true } # Fading a native window while it is dragged (`NSWindow.alphaValue`); winit already uses these. [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSResponder", "NSWindow"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSEvent", "NSResponder", "NSScreen", "NSWindow"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSString"] } [features] diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index ff49703b..380c8d45 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -1,7 +1,7 @@ //! Floating windows in their own OS windows (feature `native-windows`), drawn by the driver's //! stand-in for OS windows. -use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, epaint::ClippedShape, pos2, vec2}; +use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, Vec2, ViewportCommand, epaint::ClippedShape, pos2, vec2}; use super::render::{ APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, drag_opacity, floats_shown, resize_bands, wayland, @@ -1195,6 +1195,45 @@ fn a_bar_drag_follows_the_pointer_when_moves_land_late() { assert_eq!(window(&d, 5).native.map(|r| [r[0], r[1]]), Some([to.x, to.y])); } +/// A bar drag at a steady pointer speed under AppKit's timing: the window's reported place is +/// fresh, while pointer events are measured against where the window was a frame back on some +/// frames. Returns the window's step each frame. Released, the layout keeps where it ended. +fn bar_drag_with_stale_pointer(tells_cursor: bool) -> Vec { + let mut d = driver(layout(true)); + d.host().tells_cursor = tells_cursor; + d.host().stale = vec![1, 0, 1, 1, 0]; + let id = id_of(&d); + let viewport = d.viewport(id); + let grab = pos2(40.0, 9.0); + let mut pointer = d.host().windows[&viewport].rect.min + grab.to_vec2(); + d.window_step(id, vec![Event::PointerMoved(grab), button(grab, true)]); + let mut place = d.host().windows[&viewport].rect.min; + let mut steps = Vec::new(); + // On to the desktop right of the main window, where the release docks nothing. + for _ in 0..24 { + pointer += vec2(20.0, 5.0); + d.window_pointer(id, pointer); + let now = d.host().windows[&viewport].rect.min; + steps.push(now - place); + place = now; + } + let local = pointer - place.to_vec2(); + d.window_step(id, vec![button(local, false)]); + d.settle(); + assert_eq!(window(&d, 5).native.map(|r| pos2(r[0], r[1])), Some(d.host().windows[&viewport].rect.min)); + steps +} + +#[test] +fn a_bar_drag_follows_the_cursor_where_pointer_events_trail_the_window() { + // Without the cursor the window overshoots and steps back. + let steps = bar_drag_with_stale_pointer(false); + assert!(steps.iter().any(|s| s.x < 0.0), "{steps:?}"); + // With it, every frame moves the window by the pointer's step. + let steps = bar_drag_with_stale_pointer(true); + assert!(steps.iter().all(|s| *s == vec2(20.0, 5.0)), "{steps:?}"); +} + #[test] fn a_native_windows_content_is_its_origin_where_its_frame_differs() { // A title bar's height between the window's outer position and its content. diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index eafc74c7..f0495e3a 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -29,7 +29,7 @@ pub(in crate::docking) use body_drag::held_events; #[cfg(feature = "native-windows")] pub(in crate::docking) use body_drag::{cancel_body_route, mark_app_drag}; #[cfg(feature = "native-windows")] -pub(in crate::docking) use native::{InputHook, set_input_hook}; +pub(in crate::docking) use native::{DesktopCursor, InputHook, set_input_hook}; /// How long after blocking starts a native window that still has the keyboard hands it to the /// main window: an OS dialog the app opens with the modal (a file dialog) takes focus a little @@ -133,6 +133,9 @@ pub(super) struct Renderer<'a, P> { /// The other OS windows a drag may cross into. #[cfg(feature = "native-windows")] pub(super) cross: cross::Cross

, + /// Where a native window that follows a bar drag goes. + #[cfg(feature = "native-windows")] + pub(super) cursor: DesktopCursor, } pub(super) struct DrawnDivider { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 06839e87..d42b1a8a 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -126,6 +126,48 @@ fn set_window_opacity(title: &str, main: Option<&str>, opacity: f64) { } } +/// Where the cursor is on the desktop, in egui's desktop points, for a native window that +/// follows a bar drag. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(in crate::docking) enum DesktopCursor { + /// The window system's: AppKit's on macOS, none elsewhere. + #[default] + System, + /// A stand-in window system's (`docking::testing::NativeHost`), or none. + #[cfg(any(test, feature = "testing"))] + Fixed(Option), +} + +impl DesktopCursor { + fn at(self, ctx: &Context) -> Option { + match self { + Self::System => system_cursor(ctx), + #[cfg(any(test, feature = "testing"))] + Self::Fixed(at) => at, + } + .filter(|p| p.is_finite()) + } +} + +/// AppKit's cursor, in the desktop points egui reports window positions in. AppKit measures +/// from the bottom-left of the primary screen (the one with the menu bar, first in +/// `NSScreen::screens`), y up; winit flips window positions against that screen's height, +/// whichever screen a window is on, so the cursor is flipped the same way. egui's points are +/// AppKit's divided by the zoom factor. +#[cfg(target_os = "macos")] +fn system_cursor(ctx: &Context) -> Option { + let mtm = objc2::MainThreadMarker::new()?; + let primary = objc2_app_kit::NSScreen::screens(mtm).firstObject()?; + let at = objc2_app_kit::NSEvent::mouseLocation(); + let height = primary.frame().size.height; + Some(pos2(at.x as f32, (height - at.y) as f32) / ctx.zoom_factor()) +} + +#[cfg(not(target_os = "macos"))] +fn system_cursor(_: &Context) -> Option { + None +} + /// Whether native windows outline their ×'s hit area, and on macOS AppKit's resize areas: /// `CRAFT_DOCK_DEBUG_CLOSE=1`, read once. fn debug_close() -> bool { @@ -748,21 +790,29 @@ impl Renderer<'_, P> { let Some(mut drag) = ui.ctx().data(|d| d.get_temp::(key)).filter(|d| d.window == window) else { return false }; let content = ui.input(|i| content_of(i.viewport())); let frame = content.map_or(Vec2::ZERO, |(_, frame)| frame); - // A pointer move of this frame is relative to where the window was when the window - // system sent it, which is where it reports the window now: a move commanded earlier - // may not have landed yet. Without one the window stays, since the last position egui - // keeps (button events included) was measured against an older place. - let moved = ui.input(|i| i.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None })); - let mut step = None; - if let (Some((origin, _)), Some(local)) = (content, moved.filter(|p| p.is_finite())) { - let to = origin + (local.to_vec2() - drag.grab); - if to.distance(drag.to) > 0.0 { - (drag.to, step) = (to, Some(to)); - ui.ctx().data_mut(|d| d.insert_temp(key, drag)); + // Where the window system tells the cursor on the desktop (macOS), the window goes + // there: AppKit reports the window's place as soon as a move is commanded, but measures + // the pointer events it sends against where the window server had the window, which + // can be a move behind. Elsewhere a pointer move of this frame is relative to where the + // window was when the window system sent it, which is where it reports the window now: + // a move commanded earlier may not have landed yet. Without one the window stays, since + // the last position egui keeps (button events included) was measured against an older + // place. + let to = match self.cursor.at(ui.ctx()) { + Some(cursor) => Some(cursor - drag.grab), + None => { + let moved = ui.input(|i| i.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None })); + content.zip(moved.filter(|p| p.is_finite())).map(|((origin, _), local)| origin + (local.to_vec2() - drag.grab)) } + }; + let mut step = None; + if let Some(to) = to.filter(|to| to.distance(drag.to) > 0.0) { + (drag.to, step) = (to, Some(to)); + ui.ctx().data_mut(|d| d.insert_temp(key, drag)); } // The pointer on the desktop is the pressed point of the window where it was last - // put, so the origin is that place, less the local pointer. + // put (the cursor, where the window system tells it), so the origin is that place, + // less the local pointer. Targets are hit there. let local = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.is_finite()); let origin = local.map(|local| drag.to + drag.grab - local.to_vec2()).or(content.map(|(origin, _)| origin)); let pointer = self.record_pointer(ui.ctx(), Some(window), origin, Some(&drag)); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 98b53a74..70a6fb9c 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -978,6 +978,8 @@ pub struct DockArea<'a, P = ()> { native_windows: Option, #[cfg(feature = "native-windows")] input_hook: Option, + #[cfg(feature = "native-windows")] + cursor: super::render::DesktopCursor, } impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { @@ -996,6 +998,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { native_windows: None, #[cfg(feature = "native-windows")] input_hook: None, + #[cfg(feature = "native-windows")] + cursor: Default::default(), } } @@ -1053,6 +1057,13 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { self } + /// Where native windows that follow a bar drag go: a stand-in window system's cursor. + #[cfg(all(feature = "native-windows", any(test, feature = "testing")))] + pub(in crate::docking) fn desktop_cursor(mut self, cursor: super::render::DesktopCursor) -> Self { + self.cursor = cursor; + self + } + /// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press /// outside it. Off, it stays open until its icon or another icon is clicked. pub fn auto_collapse_iconic(mut self, on: bool) -> Self { @@ -1229,6 +1240,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { native_windows: self.native_windows, #[cfg(feature = "native-windows")] cross: Default::default(), + #[cfg(feature = "native-windows")] + cursor: self.cursor, }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new(), (root, rect)); diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 95a53d65..991e5f76 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -140,6 +140,8 @@ impl> Do let monitors = self.monitors.clone(); #[cfg(feature = "native-windows")] let input_hook = self.input_hook.clone(); + #[cfg(feature = "native-windows")] + let cursor = self.host.as_ref().and_then(|host| host.borrow().cursor); let (style_fn, maximized, raise, auto, bounds, locked, native, blocked) = ( &self.style, self.maximized.clone(), @@ -170,6 +172,10 @@ impl> Do dock = dock.float_bounds(bounds); } #[cfg(feature = "native-windows")] + { + dock = dock.desktop_cursor(super::render::DesktopCursor::Fixed(cursor)); + } + #[cfg(feature = "native-windows")] if let Some(hook) = input_hook.clone() { dock = dock.native_input_hook(move |ctx, input| hook(ctx, input)); } @@ -249,6 +255,26 @@ impl> Do self.window_step(window, vec![Event::PointerMoved(to)]) } + /// Move the pointer to the desktop point `point` in the native window of `window`, and + /// step. The window gets the point in its coordinates, measured against its place + /// [`NativeHost::stale`] picks; with [`NativeHost::tells_cursor`] the window system also + /// tells the point as the cursor's. + #[cfg(feature = "native-windows")] + pub fn window_pointer(&mut self, window: u64, point: Pos2) -> DockOutput

{ + let viewport = self.viewport(window); + { + let mut host = self.host(); + let behind = host.pointer_moves.checked_rem(host.stale.len()).and_then(|i| host.stale.get(i)).copied().unwrap_or(0); + host.pointer_moves += 1; + host.cursor = host.tells_cursor.then_some(point); + if let Some(target) = host.windows.get_mut(&viewport) { + let place = target.places.iter().rev().nth(behind).or(target.places.first()).copied().unwrap_or(target.rect.min); + target.events.push(Event::PointerMoved(point - place.to_vec2())); + } + } + self.step(Vec::new()) + } + /// Release at `pos` in a native window, in its coordinates. #[cfg(feature = "native-windows")] pub fn window_release(&mut self, window: u64, pos: Pos2) -> DockOutput

{ @@ -400,11 +426,21 @@ pub struct NativeHost { /// `OuterPosition` lands a frame late, at the end of the frame after the command. That /// frame still reports the old place, and gets pointer events relative to it. pub lag: bool, + /// The window system tells where the cursor is on the desktop, as AppKit does: where + /// [`DockDriver::window_pointer`] last put it. + pub tells_cursor: bool, + /// [`DockDriver::window_pointer`] measures a window's pointer events against its place + /// this many frames back, one entry per call in turn, as AppKit does while the window + /// server catches up with a move it reported at once. Empty: its place now. + pub stale: Vec, pub windows: std::collections::HashMap, /// Every command sent to a native window or the main window, in order. pub commands: Vec<(egui::ViewportId, egui::ViewportCommand)>, /// Moves waiting to land under `lag`: window, content position, frame sent. late: Vec<(egui::ViewportId, Pos2, usize)>, + cursor: Option, + /// Calls of [`DockDriver::window_pointer`], for [`Self::stale`]. + pointer_moves: usize, frames: usize, /// The driver's modifiers: the keyboard's, whichever window has focus. modifiers: Modifiers, @@ -434,6 +470,8 @@ pub struct HostWindow { pub dropped_files: Vec, /// Shapes of its last frame. pub shapes: Vec, + /// Desktop positions of its content at the end of recent frames, latest last. + places: Vec, } #[cfg(feature = "native-windows")] @@ -446,9 +484,13 @@ impl NativeHost { no_position: false, frame: egui::Vec2::ZERO, lag: false, + tells_cursor: false, + stale: Vec::new(), windows: Default::default(), commands: Vec::new(), late: Vec::new(), + cursor: None, + pointer_moves: 0, frames: 0, modifiers: Modifiers::NONE, screen: Rect::NOTHING, @@ -492,6 +534,7 @@ impl NativeHost { hovered_files: Vec::new(), dropped_files: Vec::new(), shapes: Vec::new(), + places: Vec::new(), }); let mut viewports = host.infos(); let (time, focused, modifiers) = (host.time, host.focus == Some(id), host.modifiers); @@ -524,8 +567,10 @@ impl NativeHost { } } - /// End a frame: moves sent in an earlier one land. + /// End a frame: moves sent in an earlier one land, and each window keeps its place. fn land(&mut self) { + /// How many frames back [`Self::stale`] can reach. + const PLACES: usize = 8; let frames = self.frames; let (landed, late) = std::mem::take(&mut self.late).into_iter().partition::, _>(|(.., sent)| *sent < frames); self.late = late; @@ -534,6 +579,11 @@ impl NativeHost { window.rect = Rect::from_min_size(pos, window.rect.size()); } } + for window in self.windows.values_mut() { + window.places.push(window.rect.min); + let excess = window.places.len().saturating_sub(PLACES); + window.places.drain(..excess); + } } /// Carry out the commands a frame sent to native windows. diff --git a/docs/ui-docking.md b/docs/ui-docking.md index ec09cf92..5dbfba79 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -627,12 +627,17 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho (the window's top-left is the pointer minus the grab offset, less the bar's height). A Ctrl/Cmd-drag floats there wherever it is released. - *Moving a window.* Dragging the bar, the only tab of a window, or the drag area of its only - group moves the window, so the pressed point stays under the pointer. Each move is worked - out from a pointer move of that frame and the window's reported place, which the pointer's - position is relative to; a move commanded earlier may land late. A frame with no pointer - move leaves the window. X11 sends no move whose position in the window repeats the last - one, so while the pointer keeps exact pace with the window the window can trail it by one - move until the next. Released over a target in another window, the whole window docks or + group moves the window, so the pressed point stays under the pointer. On macOS each frame + puts the window at the cursor's desktop position (AppKit's `NSEvent::mouseLocation`) less + the pressed point, and targets are hit at that position. AppKit reports a window's new place + as soon as a move is commanded, but measures the pointer events it sends against where the + window server had the window, which can be a move behind; placing the window from those + events would overshoot and step back. Elsewhere each move is worked out from a pointer move + of that frame and the window's reported place, which the pointer's position is relative to; + a move commanded earlier may land late. A frame with no pointer move leaves the window. X11 + sends no move whose position in the window repeats the last one, so while the pointer keeps + exact pace with the window the window can trail it by one move until the next. Released over + a target in another window, the whole window docks or joins there as its groups (`Action::MoveWindow`). The bar docks only with `DockStyle::title_bar_docks`; the only tab or group docks either way, since it is the way back in with that flag off. Targets: in the @@ -732,7 +737,10 @@ OS windows: the main window's content sits at `origin` on the desktop, and each runs its own frame with its own input and desktop rect (`host()`, `window_step`, `window_click`). `NativeHost` can report a monitor size (`monitor_size`), give windows a frame between their outer position and content (`frame`), land `OuterPosition` a frame late (`lag`), -and report no positions (`no_position`, as on Wayland). A `HostWindow` takes files dragged +and report no positions (`no_position`, as on Wayland). `window_pointer` moves the pointer to a +desktop point in a native window; the host can tell that point as the cursor's (`tells_cursor`, +as AppKit does) and measure the window's pointer events against its place some frames back +(`stale`, a pattern taken one entry per call). A `HostWindow` takes files dragged over it (`hovered_files`) or dropped on it (`dropped_files`), and `with_native_input_hook` passes a hook to `DockArea::native_input_hook` every frame.