feat: Implement window dragging and docking functionality

- Added WindowDragController to manage custom dragging of sub-windows.
- Integrated dragging logic into EqWindow and PlWindow, allowing for smooth window movement.
- Introduced docking logic to snap windows together and maintain their positions relative to each other.
- Implemented persistence of window positions using SharedPreferences.
- Enhanced AppDelegate to support screen bounds and mouse location retrieval for accurate window positioning.
- Updated pubspec.yaml to include screen_retriever dependency.
- Added comprehensive tests for dock controller and dock logic to ensure correct behavior during window interactions.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-08-10 15:53:29 +02:00
parent f338752e75
commit 212f8e16d2
13 changed files with 1748 additions and 38 deletions
+19 -16
View File
@@ -69,25 +69,28 @@ items `[x]` as done.
### B1. Plumbing
- Persist per-window positions (`winamp.window_<name>_x/y` in PlayerState);
sub-windows spawn at saved positions instead of `center()`.
- Sub-windows report bounds over `winamp.subwindows`; main holds the
authoritative rect map; main -> sub pushes on demand.
- Add `getScreenBounds` to AppDelegate's `winamp` channel.
- Replace `DragSurface.startDragging()` with a custom drag loop: pointer-down
records start pos + grab; move computes delta -> snap -> `setPosition`;
dragged window moves attached windows via relayed delta; drag-end persists
positions + recomputes attachments.
- [x] Persist per-window positions (`winamp.window_<name>_x/y` in PlayerState);
sub-windows spawn at saved positions instead of `center()`.
- [x] Sub-windows report bounds over `winamp.subwindows` (`subReady`/`subBounds`);
main holds the authoritative rect map; main -> sub pushes on demand
(`setDockContext`/`setBounds` on each sub-window's control channel).
- [x] Add `getScreenBounds` to AppDelegate's `winamp` channel.
- [x] Replace `DragSurface.startDragging()` with a custom drag loop
(`WindowDragSurface`/`WindowDragController`): pointer-down records start
pos + grab; move computes delta -> snap -> `setPosition`; dragged window
moves attached windows via relayed delta; drag-end persists positions +
recomputes attachments.
- [x] Unit tests for `dock_logic.dart` + `dock_controller.dart` (31 tests).
### B2. Dock logic (task 1) — port from DOCK.cpp / Set.cpp / ui.cpp
- `IsWindowAttached` (dynamic geometry), `SnapWindowToWindow`, `SnapToScreen`,
`AdjustSnap`, `FixOverlaps`.
- Shade-toggle re-pin (`doMyDirtyShitholeDockingShit`) when main/EQ/PL sizes
change; docking severs automatically on window close.
- Verify vs original: stacked main->PL->EQ moves together; moving a docked
window undocks it; shade toggle keeps below-windows pinned; closing middle
PL severs the chain.
- [x] `IsWindowAttached` (dynamic geometry), `SnapWindowToWindow`, `SnapToScreen`,
`AdjustSnap`, `FixOverlaps` (`lib/src/docking/dock_logic.dart`).
- [x] Shade-toggle re-pin (`doMyDirtyShitholeDockingShit`) when main/EQ/PL sizes
change; docking severs automatically on window close.
- [ ] Verify vs original: stacked main->PL->EQ moves together; moving a docked
window undocks it; shade toggle keeps below-windows pinned; closing middle
PL severs the chain.
### B3. Playlist resize from bottom-right (task 6)
+354 -5
View File
@@ -1,5 +1,6 @@
import "dart:async";
import "dart:io";
import "dart:ui" as ui;
import "package:dartamp_skin/dartamp_skin.dart";
import "package:desktop_multi_window/desktop_multi_window.dart";
@@ -9,6 +10,8 @@ import "package:flutter/services.dart";
import "package:window_manager/window_manager.dart";
import "src/common_widgets.dart";
import "src/docking/dock_controller.dart";
import "src/docking/dock_logic.dart";
import "src/main_window/main_window.dart";
import "src/player/player_state.dart";
import "src/skin/skin_cursors.dart";
@@ -131,6 +134,8 @@ class WinampRoot extends StatefulWidget {
class _WinampRootState extends State<WinampRoot> {
late final PlayerState _player;
late final SkinManager _skins;
late final DockController _dock;
late final _MainWindowDragController _mainDrag;
bool _windowReady = false;
bool _wasShade = false;
double _wasScale = 1.0;
@@ -142,6 +147,13 @@ class _WinampRootState extends State<WinampRoot> {
WindowController? _plController;
StreamSubscription<void>? _windowsSubscription;
/// Initial rects queued for a sub-window that has not booted yet; pushed
/// when it reports `subReady`.
final Map<DockWindow, Rect> _pendingSubBounds = {};
/// Saved top-left positions of the sub-windows, loaded at startup.
final Map<SubWindowType, (double, double)> _savedSubPositions = {};
@override
void initState() {
super.initState();
@@ -153,6 +165,11 @@ class _WinampRootState extends State<WinampRoot> {
uiScale: widget.initialUiScale,
);
_skins = widget.skinManager;
_dock = DockController(
moveWindow: _moveWindow,
persistRects: _persistRects,
);
_mainDrag = _MainWindowDragController(this);
_wasShade = widget.initialWindowShade;
_wasScale = widget.initialUiScale;
_wasAlwaysOnTop = widget.initialAlwaysOnTop;
@@ -197,7 +214,42 @@ class _WinampRootState extends State<WinampRoot> {
await windowManager.setResizable(false);
await windowManager.setAlwaysOnTop(_player.alwaysOnTop);
await windowManager.setTitle("Winamp");
await _loadScreenBounds();
await windowManager.setSize(_scaledSize());
final savedMain = await PlayerState.loadWindowPos("main");
if (savedMain != null) {
final saved = _clampToScreen(
Rect.fromLTWH(
savedMain.$1,
savedMain.$2,
_scaledSize().width,
_scaledSize().height,
),
_scaledSize(),
);
await windowManager.setPosition(saved.topLeft);
} else {
// No saved position: pick a centered-on-screen default so the window
// always starts fully visible (no off-screen sliver that breaks pointer
// hit-testing against the drag surface).
final screen = _dock.screenRect;
final size = _scaledSize();
if (screen != Rect.zero) {
final x = (screen.width - size.width) / 2;
final y = (screen.height - size.height) / 2;
await windowManager.setPosition(Offset(x, y));
}
}
final mainBounds = await windowManager.getBounds();
_dock.setRect(
DockWindow.main,
Rect.fromLTWH(
mainBounds.left,
mainBounds.top,
mainBounds.width,
mainBounds.height,
),
);
_windowReady = true;
} catch (_) {
// The native window may not be ready yet on macOS; defer to the next
@@ -208,10 +260,53 @@ class _WinampRootState extends State<WinampRoot> {
return;
}
unawaited(_syncMenuToNative());
final savedEq = await PlayerState.loadWindowPos("eq");
final savedPl = await PlayerState.loadWindowPos("pl");
if (savedEq != null) _savedSubPositions[SubWindowType.eq] = savedEq;
if (savedPl != null) _savedSubPositions[SubWindowType.pl] = savedPl;
if (_player.eqOpen) unawaited(_openSubWindow(SubWindowType.eq));
if (_player.plOpen) unawaited(_openSubWindow(SubWindowType.pl));
}
/// Clamps [rect] so its top-left stays within the visible screen, keeping at
/// least the window's top-left corner on screen. Returns [rect] unchanged
/// when the screen bounds are not known yet (`screenRect` is zero).
Rect _clampToScreen(Rect rect, Size size) {
final screen = _dock.screenRect;
if (screen == Rect.zero) return rect;
final maxX = screen.width < size.width
? screen.left
: screen.right - size.width;
final maxY = screen.height < size.height
? screen.top
: screen.bottom - size.height;
return Rect.fromLTWH(
rect.left.clamp(screen.left, maxX).toDouble(),
rect.top.clamp(screen.top, maxY).toDouble(),
size.width,
size.height,
);
}
Future<void> _loadScreenBounds() async {
try {
final result = await winampChannel.invokeMethod<Map<dynamic, dynamic>>(
"getScreenBounds",
);
if (result != null) {
_dock.screenRect = Rect.fromLTWH(
(result["x"] as num).toDouble(),
(result["y"] as num).toDouble(),
(result["width"] as num).toDouble(),
(result["height"] as num).toDouble(),
);
}
} catch (_) {
// ignore: screen bounds are best-effort; snapping is disabled without
// them because screenRect stays Rect.zero
}
}
Size _scaledSize() {
final base = _player.windowShade ? _shadeSize : _mainSize;
final scale = _player.uiScale;
@@ -226,10 +321,23 @@ class _WinampRootState extends State<WinampRoot> {
_wasShade = _player.windowShade;
_wasScale = _player.uiScale;
windowManager.setSize(_scaledSize());
final mainRect = _dock.rectOf(DockWindow.main);
if (mainRect != null) {
_dock.repinResized(
DockWindow.main,
Rect.fromLTWH(
mainRect.left,
mainRect.top,
_scaledSize().width,
_scaledSize().height,
),
);
}
}
if (scaleChanged) {
_eqController?.invokeMethod("setUiScale", _player.uiScale);
_plController?.invokeMethod("setUiScale", _player.uiScale);
unawaited(_pushDockContext());
}
windowManager.setAlwaysOnTop(_player.alwaysOnTop);
if (_player.alwaysOnTop != _wasAlwaysOnTop) {
@@ -295,6 +403,7 @@ class _WinampRootState extends State<WinampRoot> {
var controller = isEq ? _eqController : _plController;
if (controller != null) {
await controller.show();
_setInitialSubRect(type);
return;
}
try {
@@ -318,7 +427,9 @@ class _WinampRootState extends State<WinampRoot> {
}
return;
}
// The sub-window shows itself once configured.
// The sub-window shows itself once configured; queue its initial position
// (restored or default-docked) so it spawns docked, not centered.
_setInitialSubRect(type);
if (isEq) {
_eqController = controller;
} else {
@@ -326,16 +437,161 @@ class _WinampRootState extends State<WinampRoot> {
}
}
/// Positions a sub-window at its saved position or docked to the main window
/// (EQ to the right, PL below), clamped on screen, and queues the rect for
/// when it boots.
void _setInitialSubRect(SubWindowType type) {
final window = type == SubWindowType.eq ? DockWindow.eq : DockWindow.pl;
final mainRect = _dock.rectOf(DockWindow.main);
if (mainRect == null) return;
final shade = type == SubWindowType.eq ? _player.eqShade : _player.plShade;
final size = DockController.scaledSize(window, shade, _player.uiScale);
var rect = _defaultSubRect(type, mainRect, size);
final saved = _savedSubPositions[type];
if (saved != null) {
rect = Rect.fromLTWH(saved.$1, saved.$2, size.width, size.height);
} else if (_pendingSubBounds.containsKey(window)) {
rect = _pendingSubBounds[window]!;
}
rect = _clampToScreen(rect, size);
_pendingSubBounds[window] = rect;
_dock.setRect(window, rect);
_pushPendingSubBounds(window);
}
Rect _defaultSubRect(
SubWindowType type,
Rect mainRect,
Size size,
) {
if (type == SubWindowType.eq) {
return Rect.fromLTWH(
mainRect.right,
mainRect.top,
size.width,
size.height,
);
}
return Rect.fromLTWH(
mainRect.left,
mainRect.bottom,
size.width,
size.height,
);
}
/// Pushes a queued initial bounds to a sub-window that has booted.
void _pushPendingSubBounds(DockWindow window) {
final rect = _pendingSubBounds[window];
if (rect == null) return;
final controller = window == DockWindow.eq ? _eqController : _plController;
if (controller == null) return;
controller.invokeMethod("setBounds", {
"x": rect.left,
"y": rect.top,
});
_pendingSubBounds.remove(window);
}
Future<dynamic> _onSubWindowCall(MethodCall call) async {
final args = call.arguments is Map ? call.arguments as Map : const {};
switch (call.method) {
case "toggleEqShade":
_player.toggleEqShade();
case "togglePlShade":
_player.togglePlShade();
case "eqClose":
_dock.removeWindow(DockWindow.eq);
_player.setEqOpen(false);
case "plClose":
_dock.removeWindow(DockWindow.pl);
_player.setPlOpen(false);
case "subReady":
final window = _subWindowFromArgs(args);
if (window == null) return;
_pushPendingSubBounds(window);
await _pushDockContext();
case "subBounds":
final window = _subWindowFromArgs(args);
if (window == null) return;
final rect = Rect.fromLTWH(
(args["x"] as num).toDouble(),
(args["y"] as num).toDouble(),
(args["w"] as num).toDouble(),
(args["h"] as num).toDouble(),
);
_dock.subBounds(window, rect);
await _pushDockContext();
}
}
DockWindow? _subWindowFromArgs(Map args) {
final name = args["type"] as String?;
if (name == "eq") return DockWindow.eq;
if (name == "pl") return DockWindow.pl;
return null;
}
/// Pushes the current screen rect, snap settings and all window rects to the
/// open sub-windows so they can snap locally during their own drags.
Future<void> _pushDockContext() async {
final rectsJson = _dock.rects.map(
(window, rect) => MapEntry(
window.name,
{
"x": rect.left,
"y": rect.top,
"w": rect.width,
"h": rect.height,
},
),
);
final args = <String, dynamic>{
"screen": {
"x": _dock.screenRect.left,
"y": _dock.screenRect.top,
"w": _dock.screenRect.width,
"h": _dock.screenRect.height,
},
"snap": _dock.snapEnabled,
"snapLen": _dock.snapLen,
"rects": rectsJson,
};
unawaited(_eqController?.invokeMethod("setDockContext", args));
unawaited(_plController?.invokeMethod("setDockContext", args));
}
/// Applies a window rect natively: the main window positions itself;
/// sub-windows are moved over their control channel.
void _moveWindow(DockWindow window, Rect rect) {
if (window == DockWindow.main) {
windowManager.setPosition(rect.topLeft);
return;
}
final controller = window == DockWindow.eq ? _eqController : _plController;
controller?.invokeMethod("setBounds", {
"x": rect.left,
"y": rect.top,
});
}
void _persistRects(Map<DockWindow, Rect> rects) {
for (final entry in rects.entries) {
final (name, type) = switch (entry.key) {
DockWindow.main => ("main", null),
DockWindow.eq => ("eq", SubWindowType.eq),
DockWindow.pl => ("pl", SubWindowType.pl),
};
if (type != null) {
_savedSubPositions[type] = (entry.value.left, entry.value.top);
}
unawaited(
PlayerState.saveWindowPos(
name,
entry.value.left,
entry.value.top,
),
);
}
}
@@ -354,8 +610,17 @@ class _WinampRootState extends State<WinampRoot> {
}
_eqController = eq;
_plController = pl;
if (eq == null && _player.eqOpen) _player.setEqOpen(false);
if (pl == null && _player.plOpen) _player.setPlOpen(false);
if (eq == null && _player.eqOpen) {
_dock.removeWindow(DockWindow.eq);
_player.setEqOpen(false);
}
if (pl == null && _player.plOpen) {
_dock.removeWindow(DockWindow.pl);
_player.setPlOpen(false);
}
if (eq == null || pl == null) {
unawaited(_pushDockContext());
}
}
Future<dynamic> _handleMethodCall(MethodCall call) async {
@@ -419,8 +684,8 @@ class _WinampRootState extends State<WinampRoot> {
color: const Color(0xFF000000),
child: SkinCursorsGate(
skin: skin,
child: DragSurface(
draggable: _windowReady,
child: WindowDragSurface(
dragController: _windowReady ? _mainDrag : null,
onWindowShadeToggle: _player.toggleWindowShade,
child: SizedBox(
width: _mainSize.width * scale,
@@ -442,3 +707,87 @@ class _WinampRootState extends State<WinampRoot> {
);
}
}
/// Drives the main window's custom drag loop through the [DockController].
///
/// Flutter's pointer positions are window-local (they shift as the window
/// follows the cursor), so the cursor's global screen position is fetched from
/// the native side on every update. Absolute positions make the window track
/// the cursor exactly with no feedback loop or accumulated error.
class _MainWindowDragController implements WindowDragController {
_MainWindowDragController(this._state);
final _WinampRootState _state;
bool _active = false;
int _seq = 0;
bool _begun = false;
Offset? _last;
@override
void start(ui.Offset globalPosition) {
_active = true;
_seq = 0;
_begun = false;
_last = null;
_fetch();
}
@override
void update(ui.Offset globalPosition) {
if (_active) _fetch();
}
Future<void> _fetch() 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);
}
if (point == _last) return;
_last = point;
_state._dock.mainDragTo(point);
} catch (_) {
// ignore: the drag loop resumes on the next pointer update
}
}
@override
void end() {
_active = false;
unawaited(_finalize());
}
/// Applies one last cursor position so the window settles exactly where the
/// pointer was released (the in-flight [update] fetches are dropped once
/// [_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(),
);
if (point != _last) {
_last = point;
_state._dock.mainDragTo(point);
}
}
} catch (_) {
// ignore: end the drag with the position already applied
}
_state._dock.endMainDrag();
}
}
+28 -7
View File
@@ -3,7 +3,6 @@ import "dart:ui" as ui;
import "package:dartamp_skin/dartamp_skin.dart";
import "package:flutter/material.dart";
import "package:flutter/widgets.dart";
import "package:window_manager/window_manager.dart";
/// A button that shows a pressed state when tapped, using skin images.
class SkinPressButton extends StatefulWidget {
@@ -43,6 +42,20 @@ class _SkinPressButtonState extends State<SkinPressButton> {
}
}
/// Reports a custom window drag loop to a [WindowDragController].
///
/// The native `window_manager.startDragging()` opens a modal loop that yields
/// no move callbacks, so it can't run the docking/snapping logic mid-drag.
/// Instead this reports the pointer's global position on down/update/up and the
/// controller follows with `setPosition`, so attached windows can move along.
abstract class WindowDragController {
void start(ui.Offset globalPosition);
void update(ui.Offset globalPosition);
void end();
}
/// Makes the whole surface draggable so a borderless window can be moved by
/// clicking and dragging anywhere on it.
///
@@ -51,23 +64,31 @@ class _SkinPressButtonState extends State<SkinPressButton> {
/// not move the window. A double-tap on the surface toggles window-shade mode;
/// [GestureDetector.onDoubleTapDown] fires on the second press so the shade
/// toggle is immediate instead of waiting out the double-tap timeout.
class DragSurface extends StatelessWidget {
const DragSurface({
class WindowDragSurface extends StatelessWidget {
const WindowDragSurface({
required this.child,
super.key,
this.draggable = true,
this.dragController,
this.onWindowShadeToggle,
super.key,
});
final bool draggable;
final WindowDragController? dragController;
final GestureTapCallback? onWindowShadeToggle;
final Widget child;
@override
Widget build(BuildContext context) {
final controller = dragController;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: draggable ? (_) => windowManager.startDragging() : null,
onPanStart: controller == null
? null
: (d) => controller.start(d.globalPosition),
onPanUpdate: controller == null
? null
: (d) => controller.update(d.globalPosition),
onPanEnd: controller == null ? null : (_) => controller.end(),
onPanCancel: controller == null ? null : () => controller.end(),
onDoubleTapDown: onWindowShadeToggle == null
? null
: (_) => onWindowShadeToggle!(),
+289
View File
@@ -0,0 +1,289 @@
import "dart:ui";
import "dock_logic.dart";
/// The skin sizes (in unscaled logical pixels) used to compute default window
/// rects when no saved position exists.
const Map<DockWindow, Size> _mainSizes = {
DockWindow.main: Size(275, 116),
DockWindow.eq: Size(275, 116),
DockWindow.pl: Size(280, 290),
};
const Map<DockWindow, Size> _shadeSizes = {
DockWindow.main: Size(275, 14),
DockWindow.eq: Size(275, 14),
DockWindow.pl: Size(280, 14),
};
/// How a moved window rect is applied natively.
typedef MoveWindowFn = void Function(DockWindow window, Rect rect);
/// Persists the current window rects (called after drags, re-pins and closes).
typedef PersistRectsFn = void Function(Map<DockWindow, Rect> rects);
/// Drives the docking/snapping logic for the main, EQ and PL windows.
///
/// Holds the authoritative rect map (the main window owns it), computes the
/// drag/snap math from the pure [DockLogic] helpers, and applies moves through
/// the injected [MoveWindowFn] (main window `setPosition`, sub-windows pushed
/// over their control channel).
class DockController {
DockController({
required this.moveWindow,
required this.persistRects,
this.snapEnabled = true,
this.snapLen = DockLogic.defaultSnapLen,
});
/// The visible frame of the screen (top-left coordinates) for snapping.
Rect screenRect = Rect.zero;
/// Whether windows snap to each other and the screen edges.
bool snapEnabled;
/// The snap distance in logical pixels (`config_snaplen`).
double snapLen;
final MoveWindowFn moveWindow;
final PersistRectsFn persistRects;
final Map<DockWindow, Rect> _rects = {};
/// The authoritative window rects. The main window is always present;
/// closed sub-windows are absent.
Map<DockWindow, Rect> get rects => Map.unmodifiable(_rects);
Rect? rectOf(DockWindow window) => _rects[window];
/// The unscaled size of [window] in its current shade state.
static Size baseSize(DockWindow window, bool shade) =>
(shade ? _shadeSizes[window] : _mainSizes[window])!;
/// The scaled size of [window] at the current shade state and UI scale.
static Size scaledSize(DockWindow window, bool shade, double scale) {
final base = baseSize(window, shade);
return Size(base.width * scale, base.height * scale);
}
/// Registers the rect for a window about to open (or already open).
void setRect(DockWindow window, Rect rect) {
_rects[window] = rect;
}
/// Forgets a closed window so attachments are recomputed without it.
void removeWindow(DockWindow window) {
_rects.remove(window);
}
// --- Main window drag -----------------------------------------------------
Offset? _grab;
Set<DockWindow>? _attached;
/// 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`).
///
/// [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) {
final rect = _rects[DockWindow.main];
if (rect == null) return;
_grab = pointer - rect.topLeft;
_attached = DockLogic.attachedTo(rects: _rects, dragged: DockWindow.main);
}
/// Moves the main window so its grab offset to the cursor is preserved, then
/// moves the whole attached group (Ui.cpp `do_titlebar` `case 0`).
///
/// [pointer] is the cursor's global screen position; the window follows it
/// absolutely, so repeated calls never accumulate error.
void mainDragTo(Offset pointer) {
final grab = _grab;
final attached = _attached;
if (grab == null || attached == null) return;
final prevMain = _rects[DockWindow.main];
if (prevMain == null) return;
var mwr = Rect.fromLTWH(
pointer.dx - grab.dx,
pointer.dy - grab.dy,
prevMain.width,
prevMain.height,
);
final moved = <DockWindow, Rect>{};
if (snapEnabled) {
mwr = DockLogic.snapToScreen(mwr, screenRect, snapLen: snapLen);
for (final entry in _rects.entries) {
if (entry.key == DockWindow.main || attached.contains(entry.key)) {
continue;
}
mwr = DockLogic.snapWindowToWindow(
mwr,
entry.value,
snapLen: snapLen,
);
}
}
mwr = DockLogic.clampVisible(mwr, screenRect);
final mainDelta = mwr.topLeft - prevMain.topLeft;
for (final window in attached) {
if (window == DockWindow.main) continue;
moved[window] = DockLogic.moveBy(
_rects[window]!,
mainDelta.dx,
mainDelta.dy,
);
}
if (snapEnabled) {
// Dock the attached windows to the screen, propagating any shift.
for (final window in attached) {
if (window == DockWindow.main) continue;
final old = moved[window]!;
moved[window] = DockLogic.snapToScreen(
old,
screenRect,
snapLen: snapLen,
);
final shift = moved[window]!.topLeft - old.topLeft;
if (shift != Offset.zero) {
mwr = DockLogic.moveBy(mwr, shift.dx, shift.dy);
for (final other in attached) {
if (other == DockWindow.main || other == window) continue;
moved[other] = DockLogic.moveBy(
moved[other]!,
shift.dx,
shift.dy,
);
}
}
}
final oldMain = mwr;
mwr = DockLogic.snapToScreen(mwr, screenRect, snapLen: snapLen);
final shift = mwr.topLeft - oldMain.topLeft;
if (shift != Offset.zero) {
for (final window in attached) {
if (window == DockWindow.main) continue;
moved[window] = DockLogic.moveBy(
moved[window]!,
shift.dx,
shift.dy,
);
}
}
// Dock the attached windows to the non-attached windows.
for (final window in attached) {
if (window == DockWindow.main) continue;
final old = moved[window]!;
for (final entry in _rects.entries) {
if (attached.contains(entry.key)) continue;
moved[window] = DockLogic.snapWindowToWindow(
moved[window]!,
entry.value,
snapLen: snapLen,
);
}
final deltaShift = moved[window]!.topLeft - old.topLeft;
if (deltaShift != Offset.zero) {
mwr = DockLogic.moveBy(mwr, deltaShift.dx, deltaShift.dy);
for (final other in attached) {
if (other == DockWindow.main || other == window) continue;
moved[other] = DockLogic.moveBy(
moved[other]!,
deltaShift.dx,
deltaShift.dy,
);
}
}
}
}
final updates = <DockWindow, Rect>{DockWindow.main: mwr, ...moved};
for (final entry in updates.entries) {
final old = _rects[entry.key];
_rects[entry.key] = entry.value;
if (old != entry.value) {
moveWindow(entry.key, entry.value);
}
}
}
void endMainDrag() {
_grab = null;
_attached = null;
persistRects(_rects);
}
// --- Sub-window drag (reported by the sub-window engine) -------------------
/// Updates the map with a sub-window's final bounds after it dragged itself.
///
/// A size change means the sub-window was resized (shade/scale toggle), so
/// windows docked against its new edges are re-pinned.
void subBounds(DockWindow window, Rect rect) {
final old = _rects[window];
final oldRects = Map<DockWindow, Rect>.of(_rects);
_rects[window] = rect;
if (old != null && (old.width != rect.width || old.height != rect.height)) {
_repin(window, rect, oldRects);
}
persistRects(_rects);
}
// --- Re-pin after a window changes size (Set.cpp doMyDirtyShitholeDockingShit)
/// Re-pins windows docked below/right of [changed] when it resizes to
/// [newRect] (shade toggle, UI scale change, or playlist resize).
void repinResized(DockWindow changed, Rect newRect) {
final oldRects = Map<DockWindow, Rect>.of(_rects);
_rects[changed] = newRect;
_repin(changed, newRect, oldRects);
}
void _repin(
DockWindow changed,
Rect newRect,
Map<DockWindow, Rect> oldRects, {
int? edge,
}) {
final oldR = oldRects[changed]!;
final edges = edge == null ? const [0, 1] : [edge];
for (final e in edges) {
final diff = e == 0
? newRect.bottom - oldR.bottom
: newRect.right - oldR.right;
if (diff == 0) continue;
for (final window in DockWindow.values) {
if (window == changed) continue;
final oldW = oldRects[window];
if (oldW == null) continue;
final isDocked = e == 0
? oldW.top == oldR.bottom && _horizontalOverlap(oldW, oldR)
: oldW.left == oldR.right && _verticalOverlap(oldW, oldR);
if (!isDocked) continue;
final newW = e == 0
? Rect.fromLTWH(oldW.left, newRect.bottom, oldW.width, oldW.height)
: Rect.fromLTWH(newRect.right, oldW.top, oldW.width, oldW.height);
_rects[window] = newW;
moveWindow(window, newW);
_repin(window, newW, oldRects, edge: e);
}
}
persistRects(_rects);
}
static bool _horizontalOverlap(Rect a, Rect b) =>
(a.left >= b.left && a.left < b.right) ||
(a.right > b.left && a.right <= b.right) ||
(b.left >= a.left && b.left < a.right) ||
(b.right > a.left && b.right <= a.right);
static bool _verticalOverlap(Rect a, Rect b) =>
(a.top >= b.top && a.top < b.bottom) ||
(a.bottom > b.top && a.bottom <= b.bottom) ||
(b.top >= a.top && b.top < a.bottom) ||
(b.bottom > a.top && b.bottom <= a.bottom);
}
+331
View File
@@ -0,0 +1,331 @@
import "dart:ui";
/// Identifies the dockable Winamp windows.
enum DockWindow { main, eq, pl }
/// A pure Dart port of the original Winamp docking geometry
/// (`original/Src/Winamp/DOCK.cpp`, `embwnd.cpp`, `Ui.cpp`).
///
/// All rects use top-left logical-pixel coordinates matching `window_manager`
/// bounds on macOS. Functions are side-effect free and return new rects.
abstract final class DockLogic {
/// The default snap distance (the original `config_snaplen` default).
static const double defaultSnapLen = 10;
static bool _inRange(double x, double l, double h) => x >= l && x <= h;
/// Port of `IsWindowAttached` (DOCK.cpp:89).
///
/// Returns 1 when [a] and [b] share a vertical seam, 2 for a horizontal
/// seam, or 3 when both, based on exact edge equality plus vertical or
/// 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 (_inRange(a.top, b.top, b.bottom) ||
_inRange(a.bottom, b.top, b.bottom) ||
_inRange(b.top, a.top, a.bottom) ||
_inRange(b.bottom, a.top, a.bottom)) {
r |= 1;
}
}
if (b.bottom == a.top || b.top == a.bottom) {
if (_inRange(b.left, a.left, a.right) ||
_inRange(b.right, a.left, a.right) ||
_inRange(a.left, b.left, b.right) ||
_inRange(a.right, b.left, b.right)) {
r |= 2;
}
}
return r;
}
static bool _overlaps1(double l1, double r1, double l2, double r2) =>
_inRange(l1, l2, r2) ||
_inRange(r1, l2, r2) ||
_inRange(l2, l1, r1) ||
_inRange(r2, l1, r1);
static bool _closeTo(double x, double t, double snapLen) =>
_inRange(x, t - snapLen, t + snapLen);
/// Port of `SnapWindowToWindow` (DOCK.cpp:109). Returns [src] snapped to
/// [dest] when an edge comes within [snapLen] pixels, otherwise [src]
/// unchanged.
static Rect snapWindowToWindow(
Rect src,
Rect dest, {
double snapLen = defaultSnapLen,
}) {
var out = src;
if (_overlaps1(dest.left, dest.right, out.left, out.right)) {
if (_closeTo(out.top, dest.bottom, snapLen)) {
out = Rect.fromLTWH(
out.left,
dest.bottom,
out.width,
dest.bottom + out.height - out.top,
);
} else if (_closeTo(out.bottom, dest.top, snapLen)) {
out = Rect.fromLTWH(
out.left,
dest.top - out.height,
out.width,
out.height,
);
}
}
if (_overlaps1(dest.top, dest.bottom, out.top, out.bottom)) {
if (_closeTo(out.right, dest.left, snapLen)) {
out = Rect.fromLTWH(
dest.left - out.width,
out.top,
out.width,
out.height,
);
} else if (_closeTo(out.left, dest.right, snapLen)) {
out = Rect.fromLTWH(dest.right, out.top, out.width, out.height);
}
}
if (out.right == dest.left || out.left == dest.right) {
if (_closeTo(out.top, dest.top, snapLen)) {
out = Rect.fromLTWH(
out.left,
dest.top,
out.width,
dest.top + out.height - out.top,
);
} else if (_closeTo(out.bottom, dest.bottom, snapLen)) {
out = Rect.fromLTWH(
out.left,
dest.bottom - out.height,
out.width,
out.height,
);
}
}
if (out.bottom == dest.top || out.top == dest.bottom) {
if (_closeTo(out.left, dest.left, snapLen)) {
out = Rect.fromLTWH(dest.left, out.top, out.width, out.height);
} else if (_closeTo(out.right, dest.right, snapLen)) {
out = Rect.fromLTWH(
dest.right - out.width,
out.top,
out.width,
out.height,
);
}
}
return out;
}
/// Port of `SnapToScreen` (embwnd.cpp:855). Snaps [rect] to the edges of
/// [screen] when within [snapLen].
static Rect snapToScreen(
Rect rect,
Rect screen, {
double snapLen = defaultSnapLen,
}) {
var out = rect;
if (out.left < screen.left + snapLen && out.left > screen.left - snapLen) {
out = Rect.fromLTWH(screen.left, out.top, out.width, out.height);
}
if (out.top < screen.top + snapLen && out.top > screen.top - snapLen) {
out = Rect.fromLTWH(out.left, screen.top, out.width, out.height);
}
if (out.right > screen.right - snapLen &&
out.right < screen.right + snapLen) {
out = Rect.fromLTWH(
screen.right - out.width,
out.top,
out.width,
out.height,
);
}
if (out.bottom > screen.bottom - snapLen &&
out.bottom < screen.bottom + snapLen) {
out = Rect.fromLTWH(
out.left,
screen.bottom - out.height,
out.width,
out.height,
);
}
return out;
}
/// The minimum window area that must stay visible while dragging, so a
/// window can never be pushed fully off-screen and become unrecoverable.
static const double defaultMinVisible = 32;
/// Hard-clamps [rect] so at least [minVisible] pixels of it remain inside
/// [screen] along each axis. Unlike [snapToScreen] (which only magnetizes
/// near edges), this bounds a window dragged far off-screen. Returns [rect]
/// unchanged when [screen] is empty or zero-sized.
static Rect clampVisible(
Rect rect,
Rect screen, {
double minVisible = defaultMinVisible,
}) {
if (screen.isEmpty) return rect;
var left = rect.left;
var top = rect.top;
final maxLeft = screen.right - minVisible;
if (left > maxLeft) left = maxLeft;
final minLeft = screen.left + minVisible - rect.width;
if (left < minLeft) left = minLeft;
final maxTop = screen.bottom - minVisible;
if (top > maxTop) top = maxTop;
final minTop = screen.top + minVisible - rect.height;
if (top < minTop) top = minTop;
return Rect.fromLTWH(left, top, rect.width, rect.height);
}
/// Port of `MoveRect` (DOCK.cpp:81).
static Rect moveBy(Rect r, double dx, double dy) =>
Rect.fromLTWH(r.left + dx, r.top + dy, r.width, r.height);
/// Port of `IsPointInRect` (DOCK.cpp:205).
static bool pointInRect(Offset p, Rect r) =>
p.dx >= r.left && p.dx < r.right && p.dy >= r.top && p.dy < r.bottom;
/// Port of `FixOverlaps` (DOCK.cpp:212). If [r1] overlaps [r2]'s top-left
/// corner, pushes [r2] out of [r1] along the shorter axis. Returns the
/// possibly-moved pair, preserving argument order.
static (Rect, Rect) fixOverlaps(Rect r1, Rect r2) {
final Rect a;
final Rect b;
if (r1.left >= r2.left) {
a = r2;
b = r1;
} else {
a = r1;
b = r2;
}
if (pointInRect(b.topLeft, a)) {
if (a.right - b.left < a.bottom - b.top) {
return (
r1,
Rect.fromLTWH(b.left + (a.right - b.left), b.top, b.width, b.height),
);
}
return (
r1,
Rect.fromLTWH(b.left, b.top + (a.bottom - b.top), b.width, b.height),
);
}
return (r1, r2);
}
/// Port of `AdjustSnap` (DOCK.cpp:175). Called when a window is resized to
/// keep windows that were docked against it pinned to the new edge. The
/// original uses a half-open range check (`INREG`), unlike [windowAttached].
static (Rect, Rect) adjustSnap(
Rect old1,
Rect old2,
Rect new1,
Rect new2, {
double snapLen = defaultSnapLen,
}) {
bool inReg(double x, double l, double h) => x >= l && x < h;
var n1 = new1;
var n2 = new2;
if (inReg(old1.top, old2.top, old2.bottom) ||
inReg(old2.top, old1.top, old1.bottom)) {
if (old1.right >= old2.left && old1.left < old2.right) {
n1 = moveBy(n1, (n2.left - n1.width) - n1.left, 0);
} else if (old2.right >= old1.left && old2.left < old1.right) {
n2 = moveBy(n2, (n1.left - n2.width) - n2.left, 0);
}
}
if (inReg(old1.left, old2.left, old2.right) ||
inReg(old2.left, old1.left, old1.right)) {
if (old1.bottom >= old2.top && old1.top < old2.bottom) {
n1 = moveBy(n1, 0, (n2.top - n1.height) - n1.top);
} else if (old2.bottom >= old1.top && old2.top < old1.bottom) {
n2 = moveBy(n2, 0, (n1.top - n2.height) - n2.top);
}
}
return (n1, n2);
}
/// Port of the transitive attachment closure in `do_titlebar` (Ui.cpp:280).
///
/// Returns the set of windows docked (directly or transitively) to
/// [dragged], including [dragged] itself, considering only windows present
/// in [rects].
static Set<DockWindow> attachedTo({
required Map<DockWindow, Rect> rects,
required DockWindow dragged,
}) {
final attached = <DockWindow>{dragged};
var changed = true;
while (changed) {
changed = false;
for (final entry in rects.entries) {
if (!attached.contains(entry.key)) continue;
for (final other in rects.entries) {
if (other.key == entry.key) continue;
if (attached.contains(other.key)) continue;
if (windowAttached(entry.value, other.value) != 0) {
attached.add(other.key);
changed = true;
}
}
}
}
return attached;
}
/// Port of `doMyDirtyShitholeDockingShit` (Set.cpp:94) for the resize
/// re-pin: when [changed]'s rect grows/shrinks to [newRect], any window
/// docked against its bottom or right edge is moved to stay pinned to the
/// new edge, recursively. [oldRects] is the pre-resize snapshot used for the
/// docking checks. Returns the new rects for the windows that moved.
static Map<DockWindow, Rect> repinResized(
DockWindow changed,
Rect newRect,
Map<DockWindow, Rect> oldRects, {
int? edge,
}) {
final result = Map<DockWindow, Rect>.from(oldRects);
final oldR = oldRects[changed]!;
final edges = edge == null ? const [0, 1] : [edge];
for (final e in edges) {
final diff = e == 0
? newRect.bottom - oldR.bottom
: newRect.right - oldR.right;
if (diff == 0) continue;
for (final w in DockWindow.values) {
if (w == changed) continue;
if (!oldRects.containsKey(w)) continue;
final oldW = oldRects[w]!;
final isDocked = e == 0
? oldW.top == oldR.bottom && _horizontalOverlap(oldW, oldR)
: oldW.left == oldR.right && _verticalOverlap(oldW, oldR);
if (!isDocked) continue;
final newW = e == 0
? Rect.fromLTWH(oldW.left, newRect.bottom, oldW.width, oldW.height)
: Rect.fromLTWH(newRect.right, oldW.top, oldW.width, oldW.height);
result[w] = newW;
final nested = repinResized(w, newW, oldRects, edge: e);
for (final nestedEntry in nested.entries) {
if (nestedEntry.key != w) result[nestedEntry.key] = nestedEntry.value;
}
}
}
return result;
}
static bool _horizontalOverlap(Rect a, Rect b) =>
(a.left >= b.left && a.left < b.right) ||
(a.right > b.left && a.right <= b.right) ||
(b.left >= a.left && b.left < a.right) ||
(b.right > a.left && b.right <= a.right);
static bool _verticalOverlap(Rect a, Rect b) =>
(a.top >= b.top && a.top < b.bottom) ||
(a.bottom > b.top && a.bottom <= b.bottom) ||
(b.top >= a.top && b.top < a.bottom) ||
(b.bottom > a.top && b.bottom <= a.bottom);
}
+9 -3
View File
@@ -19,10 +19,16 @@ const Size _eqShadeSize = Size(275, 14);
/// presets buttons, the response graph, and 11 draggable sliders (preamp + 10
/// bands) drawn on top. Windowshade mode uses `eq_ex.bmp`.
class EqWindow extends StatelessWidget {
const EqWindow({required this.player, this.skinFuture, super.key});
const EqWindow({
required this.player,
this.skinFuture,
this.dragController,
super.key,
});
final PlayerState player;
final Future<ClassicSkin>? skinFuture;
final WindowDragController? dragController;
@override
Widget build(BuildContext context) {
@@ -40,8 +46,8 @@ class EqWindow extends StatelessWidget {
}
return SkinCursorsGate(
skin: snapshot.data!,
child: DragSurface(
draggable: true,
child: WindowDragSurface(
dragController: dragController,
onWindowShadeToggle: player.toggleEqShade,
child: _EqWindowView(skin: snapshot.data!, player: player),
),
+9 -3
View File
@@ -150,10 +150,16 @@ class _KeyedFont {
/// Lives in its own OS window (its own Flutter engine); the parent
/// [SubWindowRoot] manages native window sizing and cross-window messaging.
class PlWindow extends StatelessWidget {
const PlWindow({required this.player, this.skinFuture, super.key});
const PlWindow({
required this.player,
this.skinFuture,
this.dragController,
super.key,
});
final PlayerState player;
final Future<ClassicSkin>? skinFuture;
final WindowDragController? dragController;
@override
Widget build(BuildContext context) {
@@ -171,8 +177,8 @@ class PlWindow extends StatelessWidget {
}
return SkinCursorsGate(
skin: snapshot.data!,
child: DragSurface(
draggable: true,
child: WindowDragSurface(
dragController: dragController,
onWindowShadeToggle: player.togglePlShade,
child: _PlWindowView(skin: snapshot.data!, player: player),
),
+21
View File
@@ -12,6 +12,10 @@ const String _kPlShadeKey = "winamp.pl_shade";
const String _kUiScaleKey = "winamp.ui_scale";
const String _kActiveSkinKey = "winamp.active_skin";
/// The `winamp.window_<name>_x/y` preference keys for a window position.
String _windowPosKey(String window, String axis) =>
"winamp.window_${window}_$axis";
/// Simulated playback/UI state for the player, mirroring the flags tracked by
/// the original Winamp main window. No audio yet; the transport simply drives
/// the display, sliders, and windowshade.
@@ -308,6 +312,23 @@ class PlayerState extends ChangeNotifier {
}
}
/// Loads the saved top-left position of the window named [window]
/// (`"main"`, `"eq"`, or `"pl"`), or `null` when never moved.
static Future<(double, double)?> loadWindowPos(String window) async {
final prefs = await SharedPreferences.getInstance();
final x = prefs.getDouble(_windowPosKey(window, "x"));
final y = prefs.getDouble(_windowPosKey(window, "y"));
if (x == null || y == null) return null;
return (x, y);
}
/// Persists the top-left position of the window named [window].
static Future<void> saveWindowPos(String window, double x, double y) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_windowPosKey(window, "x"), x);
await prefs.setDouble(_windowPosKey(window, "y"), y);
}
void _startTicker() {
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
+267 -3
View File
@@ -1,10 +1,14 @@
import "dart:async";
import "dart:ui" as ui;
import "package:dartamp_skin/dartamp_skin.dart";
import "package:desktop_multi_window/desktop_multi_window.dart";
import "package:flutter/material.dart";
import "package:screen_retriever/screen_retriever.dart";
import "package:window_manager/window_manager.dart";
import "../common_widgets.dart";
import "../docking/dock_logic.dart";
import "../main_window/eq_window.dart";
import "../main_window/playlist_window.dart";
import "../player/player_state.dart";
@@ -47,12 +51,23 @@ class SubWindowRoot extends StatefulWidget {
class _SubWindowRootState extends State<SubWindowRoot> {
late final PlayerState _player;
late Future<ClassicSkin> _skinFuture;
late final _SubWindowDragController _drag;
bool _windowReady = false;
bool _wasShade = false;
double _wasScale = 1.0;
/// This window's current bounds, kept in sync with native so the custom
/// drag loop can compute grab offsets without async round-trips.
Rect? _windowBounds;
/// The snapshot of the other windows' rects and the screen pushed by the
/// main window, used to snap this window during its own drags.
_SubDockContext? _dockContext;
bool get _isEq => widget.type == SubWindowType.eq;
DockWindow get _dockWindow => _isEq ? DockWindow.eq : DockWindow.pl;
Size get _mainSize => _isEq ? _eqMainSize : _plMainSize;
Size get _shadeSize => _isEq ? _eqShadeSize : _plShadeSize;
@@ -66,6 +81,7 @@ class _SubWindowRootState extends State<SubWindowRoot> {
uiScale: widget.initialUiScale,
);
_skinFuture = loadAppSkin(widget.initialSkinName);
_drag = _SubWindowDragController(this);
_wasShade = widget.initialShade;
_wasScale = widget.initialUiScale;
_player.addListener(_onPlayerChanged);
@@ -102,6 +118,21 @@ class _SubWindowRootState extends State<SubWindowRoot> {
_skinFuture = loadAppSkin(call.arguments as String);
});
}
case "setBounds":
final args = call.arguments;
if (args is Map) {
final bounds = _windowBounds;
final size = bounds?.size ?? _scaledSize();
final x = (args["x"] as num).toDouble();
final y = (args["y"] as num).toDouble();
_windowBounds = Rect.fromLTWH(x, y, size.width, size.height);
await windowManager.setPosition(Offset(x, y));
}
case "setDockContext":
final args = call.arguments;
if (args is Map) {
_dockContext = _SubDockContext.decode(args);
}
}
});
} catch (_) {
@@ -121,10 +152,20 @@ class _SubWindowRootState extends State<SubWindowRoot> {
await windowManager.setAsFrameless();
await windowManager.setBackgroundColor(const Color(0xFF000000));
await windowManager.setSize(_scaledSize());
await windowManager.center();
if (_windowBounds != null) {
await windowManager.setPosition(_windowBounds!.topLeft);
} else {
_windowBounds = await windowManager.getBounds();
}
_windowReady = true;
final controller = await WindowController.fromCurrentEngine();
await controller.show();
unawaited(
subWindowChannel.invokeMethod(
"subReady",
{"type": _isEq ? "eq" : "pl"},
),
);
} catch (_) {
// The native window may not be ready yet; defer to the next frame so the
// window still shows once it is.
@@ -155,10 +196,36 @@ class _SubWindowRootState extends State<SubWindowRoot> {
if (shadeChanged || scaleChanged) {
_wasScale = _player.uiScale;
windowManager.setSize(_scaledSize());
final bounds = _windowBounds;
if (bounds != null) {
final size = _scaledSize();
_windowBounds = Rect.fromLTWH(
bounds.left,
bounds.top,
size.width,
size.height,
);
_reportSubBounds(_windowBounds!);
}
}
windowManager.setAlwaysOnTop(_player.alwaysOnTop);
}
void _reportSubBounds(Rect bounds) {
unawaited(
subWindowChannel.invokeMethod(
"subBounds",
{
"type": _isEq ? "eq" : "pl",
"x": bounds.left,
"y": bounds.top,
"w": bounds.width,
"h": bounds.height,
},
),
);
}
@override
Widget build(BuildContext context) {
return ColoredBox(
@@ -178,8 +245,16 @@ class _SubWindowRootState extends State<SubWindowRoot> {
alignment: Alignment.topLeft,
filterQuality: FilterQuality.none,
child: _isEq
? EqWindow(player: _player, skinFuture: _skinFuture)
: PlWindow(player: _player, skinFuture: _skinFuture),
? EqWindow(
player: _player,
skinFuture: _skinFuture,
dragController: _drag,
)
: PlWindow(
player: _player,
skinFuture: _skinFuture,
dragController: _drag,
),
),
);
},
@@ -187,3 +262,192 @@ class _SubWindowRootState extends State<SubWindowRoot> {
);
}
}
/// A snapshot of the screen and other windows' rects pushed by the main
/// window, used to snap this sub-window during its own drags.
class _SubDockContext {
const _SubDockContext({
required this.screen,
required this.snap,
required this.snapLen,
required this.rects,
});
final Rect screen;
final bool snap;
final double snapLen;
final Map<DockWindow, Rect> rects;
factory _SubDockContext.decode(Map args) {
final screenMap = args["screen"] is Map ? args["screen"] as Map : null;
final rects = <DockWindow, Rect>{};
final rectsMap = args["rects"] is Map ? args["rects"] as Map : null;
if (rectsMap != null) {
for (final entry in rectsMap.entries) {
final window = DockWindow.values
.where((w) => w.name == entry.key)
.firstOrNull;
final value = entry.value;
if (window == null || value is! Map) continue;
rects[window] = Rect.fromLTWH(
(value["x"] as num).toDouble(),
(value["y"] as num).toDouble(),
(value["w"] as num).toDouble(),
(value["h"] as num).toDouble(),
);
}
}
return _SubDockContext(
screen: screenMap == null
? Rect.zero
: Rect.fromLTWH(
(screenMap["x"] as num).toDouble(),
(screenMap["y"] as num).toDouble(),
(screenMap["w"] as num).toDouble(),
(screenMap["h"] as num).toDouble(),
),
snap: args["snap"] == true,
snapLen: (args["snapLen"] as num?)?.toDouble() ?? 10,
rects: rects,
);
}
}
/// Drives this sub-window's custom drag loop: moves the window with
/// `setPosition`, snaps locally against the pushed dock context, and reports
/// the final bounds to the main window on release.
///
/// Flutter's pointer positions are window-local (they shift as the window
/// follows the cursor), so the cursor's global screen position is fetched from
/// the native side on every update. Absolute positions make the window track
/// the cursor exactly with no feedback loop or accumulated error.
class _SubWindowDragController implements WindowDragController {
_SubWindowDragController(this._state);
final _SubWindowRootState _state;
bool _active = false;
int _seq = 0;
Offset? _grab;
Offset? _last;
@override
void start(ui.Offset globalPosition) {
_active = true;
_seq = 0;
_grab = null;
_last = null;
_fetch();
}
@override
void update(ui.Offset globalPosition) {
if (_active) _fetch();
}
Future<void> _fetch() async {
final seq = ++_seq;
try {
final point = await screenRetriever.getCursorScreenPoint();
if (!_active || seq != _seq) return;
if (point == _last) return;
_last = point;
var bounds = _state._windowBounds;
if (bounds == null) {
bounds = await windowManager.getBounds();
_state._windowBounds = bounds;
}
final grab = _grab ?? (point - bounds.topLeft);
_grab = grab;
var candidate = Rect.fromLTWH(
point.dx - grab.dx,
point.dy - grab.dy,
bounds.width,
bounds.height,
);
final context = _state._dockContext;
if (context != null && context.snap) {
candidate = DockLogic.snapToScreen(
candidate,
context.screen,
snapLen: context.snapLen,
);
for (final entry in context.rects.entries) {
if (entry.key == _state._dockWindow) continue;
candidate = DockLogic.snapWindowToWindow(
candidate,
entry.value,
snapLen: context.snapLen,
);
}
}
candidate = DockLogic.clampVisible(
candidate,
context?.screen ?? Rect.zero,
);
_state._windowBounds = candidate;
if (candidate != bounds) {
unawaited(windowManager.setPosition(candidate.topLeft));
}
} catch (_) {
// ignore: native channel unavailable
}
}
@override
void end() {
_active = false;
unawaited(_finalize());
}
/// Applies one last cursor position so the window settles where the pointer
/// was released (in-flight [update] fetches are dropped once [_active] is
/// false), then reports the final bounds to the main window.
Future<void> _finalize() async {
final context = _state._dockContext;
var grab = _grab;
try {
final point = await screenRetriever.getCursorScreenPoint();
if (point != _last) {
_last = point;
var bounds = _state._windowBounds;
bounds ??= await windowManager.getBounds();
grab ??= point - bounds.topLeft;
_grab = grab;
var candidate = Rect.fromLTWH(
point.dx - grab.dx,
point.dy - grab.dy,
bounds.width,
bounds.height,
);
if (context != null && context.snap) {
candidate = DockLogic.snapToScreen(
candidate,
context.screen,
snapLen: context.snapLen,
);
for (final entry in context.rects.entries) {
if (entry.key == _state._dockWindow) continue;
candidate = DockLogic.snapWindowToWindow(
candidate,
entry.value,
snapLen: context.snapLen,
);
}
}
candidate = DockLogic.clampVisible(
candidate,
context?.screen ?? Rect.zero,
);
_state._windowBounds = candidate;
await windowManager.setPosition(candidate.topLeft);
}
} catch (_) {
// ignore: report the position already applied
}
final bounds = _state._windowBounds;
if (bounds != null) {
_state._reportSubBounds(bounds);
}
}
}
+24 -1
View File
@@ -10,7 +10,7 @@ class AppDelegate: FlutterAppDelegate {
}
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return true
return false
}
override func applicationWillFinishLaunching(_ notification: Notification) {
@@ -84,6 +84,29 @@ class AppDelegate: FlutterAppDelegate {
setUiScaleMenuState(scale)
}
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
result([
"x": v.origin.x,
"y": f.height - v.origin.y - v.height,
"width": v.width,
"height": v.height,
])
} else {
result(nil)
}
case "getMouseLocation":
// The global cursor position, flipped into window_manager's top-left
// origin space (same convention as getScreenBounds and setPosition), so
// the custom drag loop can position windows from absolute screen points
// instead of window-local pointer coordinates.
let mouse = NSEvent.mouseLocation
let screenHeight = NSScreen.screens.first?.frame.height ?? mouse.y
result(["x": mouse.x, "y": screenHeight - mouse.y])
case "registerCursor":
if let args = call.arguments as? [String: Any],
let name = args["name"] as? String,
+1
View File
@@ -14,6 +14,7 @@ dependencies:
flutter:
sdk: flutter
path_provider: ^2.1.5
screen_retriever: ^0.2.2
shared_preferences: ^2.5.5
window_manager: ^0.5.2
+112
View File
@@ -0,0 +1,112 @@
import "dart:ui";
import "package:dartamp/src/docking/dock_controller.dart";
import "package:dartamp/src/docking/dock_logic.dart";
import "package:flutter_test/flutter_test.dart";
const screen = Rect.fromLTWH(0, 0, 1440, 900);
void main() {
late List<(DockWindow, Rect)> moved;
late Map<DockWindow, Rect>? persisted;
late DockController dock;
setUp(() {
moved = [];
persisted = null;
dock = DockController(
moveWindow: (window, rect) => moved.add((window, rect)),
persistRects: (rects) => persisted = rects,
)..screenRect = screen;
});
test("setRect and removeWindow maintain the rect map", () {
dock.setRect(DockWindow.eq, const Rect.fromLTWH(275, 0, 275, 116));
expect(dock.rectOf(DockWindow.eq)!.topLeft, const Offset(275, 0));
expect(dock.rects.length, 1);
dock.removeWindow(DockWindow.eq);
expect(dock.rectOf(DockWindow.eq), isNull);
});
test("a main-window drag moves the attached group by the same delta", () {
dock.setRect(DockWindow.main, const Rect.fromLTWH(500, 300, 275, 116));
dock.setRect(DockWindow.eq, const Rect.fromLTWH(775, 300, 275, 116));
dock.beginMainDrag(const Offset(600, 340));
dock.mainDragTo(const Offset(700, 420));
dock.endMainDrag();
final mainRect = dock.rectOf(DockWindow.main)!;
final eqRect = dock.rectOf(DockWindow.eq)!;
expect(mainRect.topLeft, const Offset(600, 380));
expect(eqRect.topLeft, const Offset(875, 380));
expect(moved, contains((DockWindow.main, mainRect)));
expect(moved, contains((DockWindow.eq, eqRect)));
expect(persisted, isNotNull);
});
test("movement tracks the cumulative pointer delta", () {
dock.setRect(DockWindow.main, const Rect.fromLTWH(500, 300, 275, 116));
dock.setRect(DockWindow.eq, const Rect.fromLTWH(775, 300, 275, 116));
dock.beginMainDrag(const Offset(600, 340));
dock.mainDragTo(const Offset(700, 420));
dock.mainDragTo(const Offset(800, 500));
expect(dock.rectOf(DockWindow.main)!.topLeft, const Offset(700, 460));
expect(dock.rectOf(DockWindow.eq)!.topLeft, const Offset(975, 460));
});
test("dragging to a screen corner snaps the whole group", () {
dock.setRect(DockWindow.main, const Rect.fromLTWH(10, 10, 275, 116));
dock.setRect(DockWindow.eq, const Rect.fromLTWH(285, 10, 275, 116));
dock.beginMainDrag(const Offset(100, 100));
dock.mainDragTo(const Offset(95, 95));
expect(dock.rectOf(DockWindow.main)!.topLeft, Offset.zero);
expect(dock.rectOf(DockWindow.eq)!.topLeft, const Offset(275, 0));
});
test("an unattached window stays put while the group moves", () {
dock.setRect(DockWindow.main, const Rect.fromLTWH(500, 300, 275, 116));
dock.setRect(DockWindow.eq, const Rect.fromLTWH(800, 500, 275, 116));
dock.beginMainDrag(const Offset(600, 340));
dock.mainDragTo(const Offset(650, 400));
expect(dock.rectOf(DockWindow.main)!.topLeft, const Offset(550, 360));
expect(dock.rectOf(DockWindow.eq)!.topLeft, const Offset(800, 500));
});
test("a sub-window resizing re-pins windows docked to its new edge", () {
dock.setRect(DockWindow.main, const Rect.fromLTWH(0, 0, 275, 116));
dock.setRect(DockWindow.eq, const Rect.fromLTWH(275, 0, 275, 116));
dock.setRect(DockWindow.pl, const Rect.fromLTWH(0, 116, 280, 290));
dock.repinResized(DockWindow.main, const Rect.fromLTWH(0, 0, 330, 139.2));
expect(dock.rectOf(DockWindow.eq)!.topLeft, const Offset(330, 0));
expect(dock.rectOf(DockWindow.pl)!.topLeft, const Offset(0, 139.2));
});
test("subBounds re-pins the chain docked below the resized window", () {
dock.setRect(DockWindow.main, const Rect.fromLTWH(0, 0, 275, 116));
dock.setRect(DockWindow.pl, const Rect.fromLTWH(0, 116, 280, 290));
dock.setRect(DockWindow.eq, const Rect.fromLTWH(0, 406, 275, 116));
dock.subBounds(DockWindow.pl, const Rect.fromLTWH(0, 116, 280, 14));
expect(dock.rectOf(DockWindow.eq)!.topLeft, const Offset(0, 130));
});
test("repinResized keeps everything pinned when the edge does not move", () {
dock.setRect(DockWindow.main, const Rect.fromLTWH(0, 0, 275, 116));
dock.setRect(DockWindow.eq, const Rect.fromLTWH(275, 0, 275, 116));
dock.repinResized(DockWindow.main, const Rect.fromLTWH(0, 0, 275, 14));
expect(dock.rectOf(DockWindow.eq)!.topLeft, const Offset(275, 0));
});
test("scaledSize accounts for shade state and UI scale", () {
expect(
DockController.scaledSize(DockWindow.main, false, 1.0),
const Size(275, 116),
);
expect(
DockController.scaledSize(DockWindow.eq, true, 1.0),
const Size(275, 14),
);
expect(
DockController.scaledSize(DockWindow.pl, false, 1.2),
const Size(336, 348),
);
});
}
+284
View File
@@ -0,0 +1,284 @@
import "dart:ui";
import "package:dartamp/src/docking/dock_logic.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
const snap = DockLogic.defaultSnapLen;
group("windowAttached", () {
test("returns 1 for a vertical seam with vertical overlap", () {
const a = Rect.fromLTWH(0, 0, 275, 116);
const b = Rect.fromLTWH(275, 10, 275, 116);
expect(DockLogic.windowAttached(a, b), 1);
});
test("returns 2 for a horizontal seam with horizontal overlap", () {
const a = Rect.fromLTWH(0, 0, 275, 116);
const b = Rect.fromLTWH(50, 116, 280, 290);
expect(DockLogic.windowAttached(a, b), 2);
});
test("returns 3 when both seams meet", () {
const a = Rect.fromLTWH(0, 0, 275, 116);
const b = Rect.fromLTWH(275, 116, 275, 116);
expect(DockLogic.windowAttached(a, b), 3);
});
test("returns 0 for disjoint windows", () {
const a = Rect.fromLTWH(0, 0, 275, 116);
const b = Rect.fromLTWH(500, 500, 275, 116);
expect(DockLogic.windowAttached(a, b), 0);
});
});
group("snapWindowToWindow", () {
const dest = Rect.fromLTWH(0, 0, 275, 116);
test("snaps src above dest to dest.top", () {
const src = Rect.fromLTWH(50, 116 - snap + 3, 275, 116);
final out = DockLogic.snapWindowToWindow(src, dest);
expect(out.top, dest.bottom);
expect(out.left, src.left);
});
test("snaps src below dest to dest.bottom", () {
final src = Rect.fromLTWH(50, dest.bottom + snap - 3, 275, 116);
final out = DockLogic.snapWindowToWindow(src, dest);
expect(out.top, dest.bottom);
});
test("snaps src left of dest to dest.left", () {
const src = Rect.fromLTWH(275 - snap + 3, 20, 275, 116);
final out = DockLogic.snapWindowToWindow(src, dest);
expect(out.left, dest.right);
expect(out.top, src.top);
});
test("snaps src right of dest to dest.right", () {
const src = Rect.fromLTWH(275 + snap - 3, 20, 275, 116);
final out = DockLogic.snapWindowToWindow(src, dest);
expect(out.left, dest.right);
});
test("leaves src unchanged outside snap range", () {
const src = Rect.fromLTWH(400, 20, 275, 116);
expect(DockLogic.snapWindowToWindow(src, dest), src);
});
});
group("snapToScreen", () {
const screen = Rect.fromLTWH(0, 0, 1440, 900);
test("snaps to the left and top edges", () {
const src = Rect.fromLTWH(-5, -5, 275, 116);
final out = DockLogic.snapToScreen(src, screen);
expect(out.left, 0);
expect(out.top, 0);
});
test("snaps to the right and bottom edges", () {
const src = Rect.fromLTWH(1440 - 275 + 5, 900 - 116 + 5, 275, 116);
final out = DockLogic.snapToScreen(src, screen);
expect(out.right, 1440);
expect(out.bottom, 900);
});
test("leaves rect unchanged away from the edges", () {
const src = Rect.fromLTWH(400, 300, 275, 116);
expect(DockLogic.snapToScreen(src, screen), src);
});
});
group("clampVisible", () {
const screen = Rect.fromLTWH(0, 0, 1440, 900);
const minVisible = DockLogic.defaultMinVisible;
test(
"keeps at least minVisible on-screen when dragged far off the left",
() {
const src = Rect.fromLTWH(-500, 300, 275, 116);
final out = DockLogic.clampVisible(src, screen);
expect(out.left, screen.left + minVisible - src.width);
expect(out.top, src.top);
},
);
test(
"keeps at least minVisible on-screen when dragged far off the top",
() {
const src = Rect.fromLTWH(300, -500, 275, 116);
final out = DockLogic.clampVisible(src, screen);
expect(out.left, src.left);
expect(out.top, screen.top + minVisible - src.height);
},
);
test(
"keeps at least minVisible on-screen when dragged far off the right",
() {
const src = Rect.fromLTWH(2000, 300, 275, 116);
final out = DockLogic.clampVisible(src, screen);
expect(out.left, screen.right - minVisible);
expect(out.top, src.top);
},
);
test(
"keeps at least minVisible on-screen when dragged far off the bottom",
() {
const src = Rect.fromLTWH(300, 2000, 275, 116);
final out = DockLogic.clampVisible(src, screen);
expect(out.left, src.left);
expect(out.top, screen.bottom - minVisible);
},
);
test("leaves a fully on-screen rect unchanged", () {
const src = Rect.fromLTWH(400, 300, 275, 116);
expect(DockLogic.clampVisible(src, screen), src);
});
test("returns the rect unchanged for an empty screen", () {
const src = Rect.fromLTWH(-500, 300, 275, 116);
expect(DockLogic.clampVisible(src, Rect.zero), src);
});
test(
"a slightly off-screen rect stays where the window still has minVisible "
"on-screen",
() {
const src = Rect.fromLTWH(-20, 100, 275, 116);
expect(DockLogic.clampVisible(src, screen), src);
},
);
});
group("moveBy and pointInRect", () {
test("moveBy shifts the top-left", () {
final out = DockLogic.moveBy(const Rect.fromLTWH(10, 20, 5, 6), 3, -2);
expect(out.left, 13);
expect(out.top, 18);
expect(out.width, 5);
expect(out.height, 6);
});
test("pointInRect respects the half-open bounds", () {
const r = Rect.fromLTWH(0, 0, 10, 10);
expect(DockLogic.pointInRect(const Offset(5, 5), r), isTrue);
expect(DockLogic.pointInRect(const Offset(10, 5), r), isFalse);
expect(DockLogic.pointInRect(const Offset(5, 10), r), isFalse);
});
});
group("fixOverlaps", () {
test("pushes r2 right when the horizontal gap is smaller", () {
const r1 = Rect.fromLTWH(0, 0, 50, 200);
const r2 = Rect.fromLTWH(40, 10, 50, 50);
final (_, out) = DockLogic.fixOverlaps(r1, r2);
expect(out.left, r1.right);
expect(out.top, r2.top);
});
test("pushes r2 down when the vertical gap is smaller", () {
const r1 = Rect.fromLTWH(0, 0, 200, 50);
const r2 = Rect.fromLTWH(10, 40, 50, 50);
final (_, out) = DockLogic.fixOverlaps(r1, r2);
expect(out.left, r2.left);
expect(out.top, r1.bottom);
});
test("leaves disjoint rects alone", () {
const r1 = Rect.fromLTWH(0, 0, 50, 50);
const r2 = Rect.fromLTWH(100, 100, 50, 50);
final out = DockLogic.fixOverlaps(r1, r2);
expect(out.$1, r1);
expect(out.$2, r2);
});
});
group("adjustSnap", () {
test("re-pins the left window's right edge to the right window's left", () {
const old1 = Rect.fromLTWH(0, 0, 275, 116);
const old2 = Rect.fromLTWH(275, 0, 275, 116);
const new1 = Rect.fromLTWH(0, 0, 330, 116);
const new2 = Rect.fromLTWH(275, 0, 275, 116);
final out = DockLogic.adjustSnap(old1, old2, new1, new2);
expect(out.$1, const Rect.fromLTWH(-55, 0, 330, 116));
expect(out.$2, new2);
});
});
group("attachedTo", () {
test("includes only the dragged window when nothing is docked", () {
final rects = {
DockWindow.main: const Rect.fromLTWH(0, 0, 275, 116),
DockWindow.eq: const Rect.fromLTWH(500, 500, 275, 116),
};
final attached = DockLogic.attachedTo(
rects: rects,
dragged: DockWindow.main,
);
expect(attached, {DockWindow.main});
});
test("walks the transitive closure of attachments", () {
final rects = {
DockWindow.main: const Rect.fromLTWH(0, 0, 275, 116),
DockWindow.eq: const Rect.fromLTWH(275, 0, 275, 116),
DockWindow.pl: const Rect.fromLTWH(275, 116, 280, 290),
};
final attached = DockLogic.attachedTo(
rects: rects,
dragged: DockWindow.main,
);
expect(attached, {DockWindow.main, DockWindow.eq, DockWindow.pl});
});
});
group("repinResized", () {
test("moves a window docked below when the edge grows", () {
final oldRects = {
DockWindow.main: const Rect.fromLTWH(0, 0, 275, 116),
DockWindow.pl: const Rect.fromLTWH(0, 116, 280, 290),
};
const newRect = Rect.fromLTWH(0, 0, 275, 139.2);
final out = DockLogic.repinResized(
DockWindow.main,
newRect,
oldRects,
);
expect(out[DockWindow.pl]!.top, 139.2);
});
test("recursively re-pins a chain docked below", () {
final oldRects = {
DockWindow.main: const Rect.fromLTWH(0, 0, 275, 116),
DockWindow.pl: const Rect.fromLTWH(0, 116, 280, 290),
DockWindow.eq: const Rect.fromLTWH(0, 406, 275, 116),
};
const newRect = Rect.fromLTWH(0, 0, 275, 139.2);
final out = DockLogic.repinResized(
DockWindow.main,
newRect,
oldRects,
);
expect(out[DockWindow.pl]!.top, 139.2);
expect(out[DockWindow.eq]!.top, 429.2);
});
test("moves a window docked to the right when the edge grows", () {
final oldRects = {
DockWindow.main: const Rect.fromLTWH(0, 0, 275, 116),
DockWindow.eq: const Rect.fromLTWH(275, 0, 275, 116),
};
const newRect = Rect.fromLTWH(0, 0, 330, 116);
final out = DockLogic.repinResized(
DockWindow.main,
newRect,
oldRects,
);
expect(out[DockWindow.eq]!.left, 330);
});
});
}