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.
This commit is contained in:
aiongg
2026-10-25 12:09:34 -04:00
committed by GitHub
parent c74116411d
commit 200b68fd6a
7 changed files with 186 additions and 23 deletions
+1 -1
View File
@@ -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]
+40 -1
View File
@@ -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<Vec2> {
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.
+4 -1
View File
@@ -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<P>,
/// Where a native window that follows a bar drag goes.
#[cfg(feature = "native-windows")]
pub(super) cursor: DesktopCursor,
}
pub(super) struct DrawnDivider {
+62 -12
View File
@@ -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<Pos2>),
}
impl DesktopCursor {
fn at(self, ctx: &Context) -> Option<Pos2> {
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<Pos2> {
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<Pos2> {
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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let Some(mut drag) = ui.ctx().data(|d| d.get_temp::<WindowDrag>(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));
+13
View File
@@ -978,6 +978,8 @@ pub struct DockArea<'a, P = ()> {
native_windows: Option<bool>,
#[cfg(feature = "native-windows")]
input_hook: Option<super::render::InputHook>,
#[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));
+51 -1
View File
@@ -140,6 +140,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> 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<P: Clone + Eq + Hash + Debug + Send + Sync + 'static, C: DockContent<P>> 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<P> {
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<P> {
@@ -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<usize>,
pub windows: std::collections::HashMap<egui::ViewportId, HostWindow>,
/// 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<Pos2>,
/// 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<egui::DroppedFileHandle>,
/// Shapes of its last frame.
pub shapes: Vec<ClippedShape>,
/// Desktop positions of its content at the end of recent frames, latest last.
places: Vec<Pos2>,
}
#[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::<Vec<_>, _>(|(.., 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.
+15 -7
View File
@@ -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.