mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 15:22:08 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user