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:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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), (_) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user