Fix window snapping when crossing between displays

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-08-12 17:57:20 +02:00
parent 223726d873
commit 9a8a8a02ad
6 changed files with 345 additions and 89 deletions
@@ -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
}
@@ -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
}
+139 -72
View File
@@ -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<void> main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
@@ -151,6 +153,13 @@ class _WinampRootState extends State<WinampRoot> {
/// when it reports `subReady`.
final Map<DockWindow, Rect> _pendingSubBounds = {};
/// The latest desired top-left per window, coalesced by [_flushMoves].
final Map<DockWindow, Offset> _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<SubWindowType, (double, double)> _savedSubPositions = {};
@@ -200,6 +209,7 @@ class _WinampRootState extends State<WinampRoot> {
@override
void dispose() {
_moveFlushTimer?.cancel();
_windowsSubscription?.cancel();
_player.removeListener(_onPlayerChanged);
_skins.removeListener(_onSkinChanged);
@@ -635,19 +645,34 @@ class _WinampRootState extends State<WinampRoot> {
/// 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<DockWindow, Rect> 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<void> _fetch(ui.Offset localPoint) async {
final seq = ++_seq;
try {
final result = await winampChannel.invokeMethod<Map<dynamic, dynamic>>(
"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<Map<dynamic, dynamic>>(
"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<Map<dynamic, dynamic>>(
"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<void> _finalize() async {
try {
final result = await winampChannel.invokeMethod<Map<dynamic, dynamic>>(
"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<Map<dynamic, dynamic>>(
"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 (_) {
+64 -4
View File
@@ -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<DockWindow>? _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<DockWindow>? 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 = <DockWindow, Rect>{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(
+8 -4
View File
@@ -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<DockWindow> dockedBelow({
required Map<DockWindow, Rect> rects,
required DockWindow dragged,
double snapLen = defaultSnapLen,
}) {
final attached = <DockWindow>{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(
+16 -9
View File
@@ -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)