From 9a8a8a02adf39853f147023632dfd0f44b461364 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 12 Aug 2026 17:57:20 +0200 Subject: [PATCH] Fix window snapping when crossing between displays Signed-off-by: Hans Kokx --- .../xcshareddata/swiftpm/Package.resolved | 59 +++++ .../xcshareddata/swiftpm/Package.resolved | 59 +++++ lib/main.dart | 211 ++++++++++++------ lib/src/docking/dock_controller.dart | 68 +++++- lib/src/docking/dock_logic.dart | 12 +- macos/Runner/AppDelegate.swift | 25 ++- 6 files changed, 345 insertions(+), 89 deletions(-) create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/lib/main.dart b/lib/main.dart index 2bbf712..be677e9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,8 @@ import "src/windows/window_args.dart"; const Size _mainSize = Size(275, 116); const Size _shadeSize = Size(275, 14); +// Toggle to enable extra drag diagnostics (kept off by default). +const bool _dragDiagnostics = false; Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); @@ -151,6 +153,13 @@ class _WinampRootState extends State { /// when it reports `subReady`. final Map _pendingSubBounds = {}; + /// The latest desired top-left per window, coalesced by [_flushMoves]. + final Map _pendingMoves = {}; + Timer? _moveFlushTimer; + + /// How often pending window moves are flushed to the native side. + static const Duration _moveFlushInterval = Duration(milliseconds: 16); + /// Saved top-left positions of the sub-windows, loaded at startup. final Map _savedSubPositions = {}; @@ -200,6 +209,7 @@ class _WinampRootState extends State { @override void dispose() { + _moveFlushTimer?.cancel(); _windowsSubscription?.cancel(); _player.removeListener(_onPlayerChanged); _skins.removeListener(_onSkinChanged); @@ -635,19 +645,34 @@ class _WinampRootState extends State { /// Applies a window rect natively: the main window positions itself; /// sub-windows are moved over their control channel. + /// + /// Moves are coalesced: each call only records the latest position and a + /// single flush applies them up to [_moveFlushInterval] later. A fast drag + /// emits hundreds of positions per second; sending them all would saturate + /// the platform thread (a sub-window move crosses three channel hops) and + /// the windows would lag the cursor and catch up in a cascade of jumps. void _moveWindow(DockWindow window, Rect rect) { - if (window == DockWindow.main) { - windowManager.setPosition(rect.topLeft); - return; + _pendingMoves[window] = rect.topLeft; + _moveFlushTimer ??= Timer(_moveFlushInterval, _flushMoves); + } + + void _flushMoves() { + _moveFlushTimer = null; + final moves = Map.of(_pendingMoves); + _pendingMoves.clear(); + for (final entry in moves.entries) { + final window = entry.key; + final topLeft = entry.value; + if (window == DockWindow.main) { + windowManager.setPosition(topLeft); + } else { + _invokeSub( + window, + "setBounds", + {"x": topLeft.dx, "y": topLeft.dy}, + ); + } } - _invokeSub( - window, - "setBounds", - { - "x": rect.left, - "y": rect.top, - }, - ); } void _persistRects(Map rects) { @@ -817,6 +842,7 @@ class _MainWindowDragController implements WindowDragController { bool _begun = false; Offset? _last; Offset? _relGrab; + bool? _useNative; @override void start(ui.Offset globalPosition) { @@ -825,6 +851,7 @@ class _MainWindowDragController implements WindowDragController { _begun = false; _last = null; _relGrab = globalPosition; + _useNative = null; // undecided until first fetch debugPrint( "main drag start (window-local: " "${globalPosition.dx.toStringAsFixed(1)}," @@ -840,61 +867,96 @@ class _MainWindowDragController implements WindowDragController { Future _fetch(ui.Offset localPoint) async { final seq = ++_seq; - try { - final result = await winampChannel.invokeMethod>( - "getMouseLocation", - ); - if (!_active || seq != _seq || result == null) return; - final point = Offset( - (result["x"] as num).toDouble(), - (result["y"] as num).toDouble(), - ); - if (!_begun) { - _begun = true; - _state._dock.beginMainDrag(point); - debugPrint( - "main drag begun at " - "${point.dx.toStringAsFixed(1)},${point.dy.toStringAsFixed(1)}", + // Decide once whether to use native absolute coordinates or the local + // relative fallback for the whole drag. This avoids mid-drag source + // switching that causes visible jumps when the native channel is + // intermittently available. + if (_useNative == null) { + try { + final result = await winampChannel.invokeMethod>( + "getMouseLocation", ); - } - if (point == _last) return; - _last = point; - _state._dock.mainDragTo(point); - debugPrint( - "main drag to " - "${point.dx.toStringAsFixed(1)},${point.dy.toStringAsFixed(1)}", - ); - } catch (_) { - // Relative fallback. The pointer's window-local coordinate shifts as the - // window follows the cursor, so grabbing the delta since the drag began - // and applying it to the current position tracks the cursor exactly. - if (!_active || seq != _seq) return; - final rect = _state._dock.rectOf(DockWindow.main); - final relGrab = _relGrab; - if (rect == null || relGrab == null) return; - if (!_begun) { - // Grab = pointer − window top-left = the window-local grab offset. - _begun = true; - _state._dock.beginMainDrag(rect.topLeft + relGrab); - debugPrint( - "main drag begun (relative) at " - "${rect.topLeft.dx + relGrab.dx}," - "${rect.topLeft.dy + relGrab.dy}", + if (!_active || seq != _seq || result == null) return; + _useNative = true; + final point = Offset( + (result["x"] as num).toDouble(), + (result["y"] as num).toDouble(), ); + if (!_begun) { + _begun = true; + _state._dock.beginMainDrag(point); + if (_dragDiagnostics) + debugPrint("main drag begun at $point (native)"); + } + if (point != _last) { + _last = point; + _state._dock.mainDragTo(point); + if (_dragDiagnostics) debugPrint("main drag to $point (native)"); + } + return; + } catch (_) { + _useNative = false; + if (_dragDiagnostics) + debugPrint("main drag: native unavailable, using relative mode"); + // fall through to relative path below } - _state._dock.moveAttached( - DockWindow.main, - DockLogic.moveBy( - rect, - localPoint.dx - relGrab.dx, - localPoint.dy - relGrab.dy, - ), - ); - debugPrint( - "main drag to (relative) " - "${localPoint.dx - relGrab.dx},${localPoint.dy - relGrab.dy}", - ); } + + if (_useNative == true) { + // Native already chosen; attempt to fetch latest but ignore errors. + try { + final result = await winampChannel.invokeMethod>( + "getMouseLocation", + ); + if (!_active || seq != _seq || result == null) return; + final point = Offset( + (result["x"] as num).toDouble(), + (result["y"] as num).toDouble(), + ); + if (!_begun) { + _begun = true; + _state._dock.beginMainDrag(point); + if (_dragDiagnostics) + debugPrint("main drag begun at $point (native)"); + } + if (point == _last) return; + _last = point; + _state._dock.mainDragTo(point); + if (_dragDiagnostics) debugPrint("main drag to $point (native)"); + } catch (_) { + // Ignore transient failures once native was chosen; avoid switching + // to relative mid-drag to prevent jumps. + } + return; + } + + // Relative fallback path (chosen because native was unavailable at drag + // start). Use the window-local grab offset consistently for the whole + // drag so coordinates don't oscillate. + if (!_active || seq != _seq) return; + final rect = _state._dock.rectOf(DockWindow.main); + final relGrab = _relGrab; + if (rect == null || relGrab == null) return; + if (!_begun) { + _begun = true; + _state._dock.beginMainDrag(rect.topLeft + relGrab); + if (_dragDiagnostics) + debugPrint( + "main drag begun (relative) at ${rect.topLeft.dx + relGrab.dx},${rect.topLeft.dy + relGrab.dy}", + ); + } + _state._dock.moveAttached( + DockWindow.main, + DockLogic.moveBy( + rect, + localPoint.dx - relGrab.dx, + localPoint.dy - relGrab.dy, + ), + ); + if (_dragDiagnostics) + debugPrint( + "main drag to (relative) ${localPoint.dx - relGrab.dx},${localPoint.dy - relGrab.dy}", + ); } @override @@ -908,17 +970,22 @@ class _MainWindowDragController implements WindowDragController { /// [_active] is false), then ends the drag. Future _finalize() async { try { - final result = await winampChannel.invokeMethod>( - "getMouseLocation", - ); - if (result != null) { - final point = Offset( - (result["x"] as num).toDouble(), - (result["y"] as num).toDouble(), + // Only query native if we chose native mode for this drag. If we + // started in relative mode, skip the native fetch to avoid switching + // sources at the last moment. + if (_useNative != false) { + final result = await winampChannel.invokeMethod>( + "getMouseLocation", ); - if (point != _last) { - _last = point; - _state._dock.mainDragTo(point); + if (result != null) { + final point = Offset( + (result["x"] as num).toDouble(), + (result["y"] as num).toDouble(), + ); + if (point != _last) { + _last = point; + _state._dock.mainDragTo(point); + } } } } catch (_) { diff --git a/lib/src/docking/dock_controller.dart b/lib/src/docking/dock_controller.dart index f8708d4..a571cb9 100644 --- a/lib/src/docking/dock_controller.dart +++ b/lib/src/docking/dock_controller.dart @@ -1,5 +1,8 @@ +import "dart:async"; import "dart:ui"; +import "package:flutter/foundation.dart"; + import "dock_logic.dart"; /// The skin sizes (in unscaled logical pixels) used to compute default window @@ -80,6 +83,7 @@ class DockController { Offset? _grab; Set? _attached; + Timer? _settleTimer; /// Records the grab offset between the pointer and the main window's /// top-left for a main-window drag (Ui.cpp `do_titlebar` `case 1`). @@ -91,12 +95,21 @@ class DockController { /// [pointer] is the cursor's global screen position in the same coordinate /// space as the window rects, so the grab stays constant while dragging. void beginMainDrag(Offset pointer) { + // Cancel any pending post-drag settle when a new drag begins. + _settleTimer?.cancel(); + _settleTimer = null; final rect = _rects[DockWindow.main]; if (rect == null) return; _grab = pointer - rect.topLeft; _attached = DockLogic.dockedBelow( rects: _rects, dragged: DockWindow.main, + snapLen: snapLen, + ); + debugPrint( + "dock beginMainDrag ptr=${pointer.dx.toStringAsFixed(1)}," + "${pointer.dy.toStringAsFixed(1)} main=${rect.left.toStringAsFixed(1)}," + "${rect.top.toStringAsFixed(1)} attached=${_attached!}", ); } @@ -115,7 +128,8 @@ class DockController { Set? attached, }) { attached = - attached ?? DockLogic.dockedBelow(rects: _rects, dragged: driven); + attached ?? + DockLogic.dockedBelow(rects: _rects, dragged: driven, snapLen: snapLen); final prevDriven = _rects[driven]; if (prevDriven == null) { _rects[driven] = target; @@ -148,10 +162,19 @@ class DockController { return; } + // Always snap the driven window when snapping is enabled; however, when + // actively dragging we avoid the attached-group snap/clamp propagation + // which can fight the OS during cross-display transitions and cause + // temporary pinning. if (snapEnabled) { mwr = DockLogic.snapToScreen(mwr, screenRect, snapLen: snapLen); } - mwr = DockLogic.clampVisible(mwr, screenRect); + final dragging = _grab != null; + + // During an active main-drag allow the window to be moved more freely + // across display boundaries by reducing the clamp's minVisible amount. + final minVisible = dragging ? 1.0 : DockLogic.defaultMinVisible; + mwr = DockLogic.clampVisible(mwr, screenRect, minVisible: minVisible); final delta = mwr.topLeft - prevDriven.topLeft; for (final window in attached) { if (window == driven) continue; @@ -162,7 +185,13 @@ class DockController { ); } - if (snapEnabled) { + final doAttachedSnap = snapEnabled && !dragging; + if (dragging && snapEnabled) { + debugPrint( + "dock moveAttached: skipping attached-window snap while dragging", + ); + } + if (doAttachedSnap) { // Keep the attached windows on screen, propagating any shift to the // whole group (the original `config_keeponscreen` behavior). for (final window in attached) { @@ -201,7 +230,23 @@ class DockController { } } + final overTop = screenRect.top - mwr.top; + if (overTop > 0) { + mwr = Rect.fromLTWH(mwr.left, screenRect.top, mwr.width, mwr.height); + for (final window in attached) { + if (window == driven) continue; + moved[window] = DockLogic.moveBy(moved[window]!, 0, overTop); + } + } + final updates = {driven: mwr, ...moved}; + debugPrint( + "dock moveAttached $driven target=${target.left.toStringAsFixed(1)}," + "${target.top.toStringAsFixed(1)} prev=${prevDriven.left.toStringAsFixed(1)}," + "${prevDriven.top.toStringAsFixed(1)} mwr=${mwr.left.toStringAsFixed(1)}," + "${mwr.top.toStringAsFixed(1)} eq=${updates[DockWindow.eq]?.top.toStringAsFixed(1)} " + "pl=${updates[DockWindow.pl]?.top.toStringAsFixed(1)}", + ); for (final entry in updates.entries) { final old = _rects[entry.key]; _rects[entry.key] = entry.value; @@ -233,6 +278,20 @@ class DockController { _grab = null; _attached = null; persistRects(_rects); + // Schedule a short delayed settle so attached-window snapping/keep-on- + // screen propagation can run after the OS finishes any cross-display + // transition. This restores the canonical snapped positions without + // interfering with the interactive drag. + _settleTimer?.cancel(); + _settleTimer = Timer(const Duration(milliseconds: 120), () { + _settleTimer = null; + final mainRect = _rects[DockWindow.main]; + if (mainRect == null) return; + // Re-run the attached move with snapping enabled and no active grab so + // propagation and keep-on-screen behavior apply now. + moveAttached(DockWindow.main, mainRect, attached: null); + persistRects(_rects); + }); } // --- Sub-window drag (reported by the sub-window engine) ------------------- @@ -282,7 +341,8 @@ class DockController { if (window == changed) continue; final oldW = oldRects[window]; if (oldW == null) continue; - if (oldW.top != oldR.bottom || !_horizontalOverlap(oldW, oldR)) { + if ((oldW.top - oldR.bottom).abs() > snapLen || + !_horizontalOverlap(oldW, oldR)) { continue; } final newW = Rect.fromLTWH( diff --git a/lib/src/docking/dock_logic.dart b/lib/src/docking/dock_logic.dart index a2369cc..998e891 100644 --- a/lib/src/docking/dock_logic.dart +++ b/lib/src/docking/dock_logic.dart @@ -21,7 +21,8 @@ abstract final class DockLogic { /// horizontal overlap of the two rects. static int windowAttached(Rect a, Rect b) { var r = 0; - if (b.right == a.left || b.left == a.right) { + if (_closeTo(b.right, a.left, defaultSnapLen) || + _closeTo(b.left, a.right, defaultSnapLen)) { if (_inRange(a.top, b.top, b.bottom) || _inRange(a.bottom, b.top, b.bottom) || _inRange(b.top, a.top, a.bottom) || @@ -29,7 +30,8 @@ abstract final class DockLogic { r |= 1; } } - if (b.bottom == a.top || b.top == a.bottom) { + if (_closeTo(b.bottom, a.top, defaultSnapLen) || + _closeTo(b.top, a.bottom, defaultSnapLen)) { if (_inRange(b.left, a.left, a.right) || _inRange(b.right, a.left, a.right) || _inRange(a.left, b.left, b.right) || @@ -229,6 +231,7 @@ abstract final class DockLogic { static Set dockedBelow({ required Map rects, required DockWindow dragged, + double snapLen = defaultSnapLen, }) { final attached = {dragged}; var changed = true; @@ -239,7 +242,7 @@ abstract final class DockLogic { for (final other in rects.entries) { if (other.key == entry.key) continue; if (attached.contains(other.key)) continue; - if (other.value.top == entry.value.bottom && + if (_closeTo(other.value.top, entry.value.bottom, snapLen) && _horizontalOverlap(other.value, entry.value)) { attached.add(other.key); changed = true; @@ -269,7 +272,8 @@ abstract final class DockLogic { if (w == changed) continue; if (!oldRects.containsKey(w)) continue; final oldW = oldRects[w]!; - if (oldW.top != oldR.bottom || !_horizontalOverlap(oldW, oldR)) { + if (!_closeTo(oldW.top, oldR.bottom, defaultSnapLen) || + !_horizontalOverlap(oldW, oldR)) { continue; } final newW = Rect.fromLTWH( diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index e7c0e61..502ccf6 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -143,16 +143,23 @@ class AppDelegate: FlutterAppDelegate { } result(nil) case "getScreenBounds": - // The visible frame of the primary screen (menu bar and dock excluded), - // converted to window_manager's top-left origin coordinates. - if let screen = NSScreen.screens.first { - let f = screen.frame - let v = screen.visibleFrame + // The union of every screen's visible frame (menu bar and dock + // excluded), converted to window_manager's top-left origin coordinates. + // The primary screen's full frame is the origin reference, so displays + // arranged above it map to negative Y and displays below it to Y beyond + // the primary's height — the drag clamp/snap then span the whole desktop. + let screens = NSScreen.screens + if !screens.isEmpty { + let f = screens[0].frame + var union = screens[0].visibleFrame + for screen in screens.dropFirst() { + union = union.union(screen.visibleFrame) + } result([ - "x": v.origin.x, - "y": f.height - v.origin.y - v.height, - "width": v.width, - "height": v.height, + "x": union.origin.x, + "y": f.height - union.origin.y - union.height, + "width": union.width, + "height": union.height, ]) } else { result(nil)