From a7568591720bda613922d7b26def05df71194c0f Mon Sep 17 00:00:00 2001 From: Kiizon Date: Tue, 13 Oct 2026 07:49:14 -0400 Subject: [PATCH] Move window to the display under the cursor (#1908) Show the cursor screen detection toggle as "Move window to display where cursor is" under Across Display Settings, instead of only when the hidden default is already on. Test helpers now pin the cursor screen detection setting, so the display cycle tests no longer depend on the machine's own preferences. --- Rectangle/Localizable.xcstrings | 7 + .../SettingsWindow/BehaviorSettingsView.swift | 10 +- .../BehaviorSettingsViewModel.swift | 1 - Rectangle/ShortcutManager.swift | 10 + Rectangle/WindowHistory.swift | 11 + Rectangle/WindowManager.swift | 97 +++-- RectangleTests/RectangleTests.swift | 384 +++++++++++++++++- 7 files changed, 486 insertions(+), 34 deletions(-) diff --git a/Rectangle/Localizable.xcstrings b/Rectangle/Localizable.xcstrings index 8df27cfb7..6cbfb7eb7 100644 --- a/Rectangle/Localizable.xcstrings +++ b/Rectangle/Localizable.xcstrings @@ -2147,6 +2147,9 @@ } } } + }, + "All move commands will move the focused window to its target position on the display where the cursor currently resides, leaving the cursor where it is." : { + }, "Allow" : { "comment" : "Text for the \"Allow\" button in an alert.", @@ -20966,6 +20969,9 @@ } } } + }, + "Move window to display where cursor is" : { + }, "New" : { "extractionState" : "manual", @@ -33970,6 +33976,7 @@ } }, "Use cursor screen detection" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Rectangle/SettingsWindow/BehaviorSettingsView.swift b/Rectangle/SettingsWindow/BehaviorSettingsView.swift index 255505df0..9b0901788 100644 --- a/Rectangle/SettingsWindow/BehaviorSettingsView.swift +++ b/Rectangle/SettingsWindow/BehaviorSettingsView.swift @@ -118,10 +118,6 @@ struct BehaviorSettingsView: View { CustomDisclosureGroup { VStack(alignment: .leading, spacing: 12) { Divider() - if viewModel.showCursorScreenDetection { - Toggle("Use cursor screen detection", isOn: $viewModel.useCursorScreenDetection) - } - Toggle("Double-click window title bar to maximize/restore", isOn: $viewModel.doubleClickTitleBar) Toggle("Repeated maximize restores the previous size and position", isOn: $viewModel.repeatedMaximizeRestoresPrevious) @@ -144,6 +140,12 @@ struct BehaviorSettingsView: View { CustomDisclosureGroup { VStack(alignment: .leading, spacing: 12) { Divider() + VStack(alignment: .leading, spacing: 2) { + Toggle("Move window to display where cursor is", isOn: $viewModel.useCursorScreenDetection) + Text("All move commands will move the focused window to its target position on the display where the cursor currently resides, leaving the cursor where it is.") + .font(.caption) + .foregroundColor(.secondary) + } Toggle("Move cursor along with window across displays", isOn: $viewModel.moveCursorAcrossDisplays) Toggle("Preserve maximize state when moving across displays", isOn: $viewModel.autoMaximize) VStack(alignment: .leading, spacing: 2) { diff --git a/Rectangle/SettingsWindow/BehaviorSettingsViewModel.swift b/Rectangle/SettingsWindow/BehaviorSettingsViewModel.swift index 2ec0ff385..b933c8edb 100644 --- a/Rectangle/SettingsWindow/BehaviorSettingsViewModel.swift +++ b/Rectangle/SettingsWindow/BehaviorSettingsViewModel.swift @@ -176,7 +176,6 @@ final class BehaviorSettingsViewModel { // MARK: - UI Conditional Flags var showCooperativeCornerResize: Bool { Defaults.cooperativeCornerResize.enabled } - var showCursorScreenDetection: Bool { Defaults.useCursorScreenDetection.enabled } var showCombinedDisplayMode: Bool { !NSScreen.screensHaveSeparateSpaces } var stageCapable: Bool { StageUtil.stageCapable } diff --git a/Rectangle/ShortcutManager.swift b/Rectangle/ShortcutManager.swift index c67dc390f..82fb108d8 100644 --- a/Rectangle/ShortcutManager.swift +++ b/Rectangle/ShortcutManager.swift @@ -253,6 +253,7 @@ class ShortcutManager { } if isRepeatAction(parameters: parameters, windowElement: windowElement, windowId: windowId), + !cursorChangedDisplay(windowId: windowId), RepeatedMaximizeRestore.restoreRect(for: parameters.action, windowId: windowId, windowRect: windowManager.logicalFrame(for: windowElement)) == nil { if let screen = screenDetection.detectScreens(using: windowElement)?.adjacentScreens?.next{ parameters = ExecutionParameters(parameters.action, updateRestoreRect: parameters.updateRestoreRect, screen: screen, windowElement: windowElement, windowId: windowId, source: parameters.source, preservesFocus: parameters.preservesFocus, completion: parameters.completion, cancellation: parameters.cancellation) @@ -374,6 +375,15 @@ class ShortcutManager { return false } + /// Pointing at another display with cursor screen detection makes a press + /// a first press there, so it mustn't cycle the window on from its display. + private func cursorChangedDisplay(windowId: CGWindowID) -> Bool { + guard Defaults.useCursorScreenDetection.enabled, + let cursorScreen = screenDetection.detectScreensAtCursor()?.currentScreen + else { return false } + return AppDelegate.windowHistory.cursorChangedDisplay(for: windowId, cursorScreenFrame: cursorScreen.frame) + } + private func subscribe(notification: WindowAction, selector: Selector) { notificationCenter.addObserver(self, selector: selector, name: notification.notificationName, object: nil) } diff --git a/Rectangle/WindowHistory.swift b/Rectangle/WindowHistory.swift index 52d725cdf..1e53db8f4 100644 --- a/Rectangle/WindowHistory.swift +++ b/Rectangle/WindowHistory.swift @@ -10,4 +10,15 @@ class WindowHistory { var preMaximizeRects = [CGWindowID: CGRect]() // the normalized window frame right before this app last maximized / almost maximized it + var cursorScreenFrames = [CGWindowID: CGRect]() // the frame of the display under the cursor when this app last acted on the window with cursor screen detection + + var cursorActions = [CGWindowID: WindowAction]() // the command pressed when this app last acted on the window with cursor screen detection, which can differ from the action it resulted in + + /// With cursor screen detection, a command is a fresh one on the cursor's + /// display unless the cursor stayed on the display it was on last time. + func cursorChangedDisplay(for windowId: CGWindowID?, cursorScreenFrame: CGRect) -> Bool { + guard let windowId, let lastFrame = cursorScreenFrames[windowId] else { return true } + return lastFrame != cursorScreenFrame + } + } diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index 2dc830ca8..3a30f50e0 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -101,6 +101,20 @@ class WindowManager { return } + let pendingDestination = windowAnimator.destination(for: frontmostWindowElement) + let currentWindowRect = pendingDestination ?? frontmostWindowElement.frame + + var lastRectangleAction = windowId.flatMap { AppDelegate.windowHistory.lastRectangleActions[$0] } + + let windowMovedExternally = currentWindowRect != lastRectangleAction?.rect + + if windowMovedExternally { + lastRectangleAction = nil + if let windowId { + AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) + } + } + // An explicit screen (display cycling) or the cursor screen controls the // calculation, but neither necessarily contains the window before it moves. let sourceScreens = screenDetection.detectScreens(using: frontmostWindowElement) @@ -109,10 +123,37 @@ class WindowManager { screens = parameters.source == .titleBar ? screenDetection.detectScreens(at: screen) : UsableScreens(currentScreen: screen, numScreens: 1) + } else if Defaults.useCursorScreenDetection.enabled, + action.classification != .display, + let cursorScreens = screenDetection.detectScreensAtCursor() { + let cursorScreenFrame = cursorScreens.currentScreen.frame + let cursorChangedDisplay = AppDelegate.windowHistory.cursorChangedDisplay(for: windowId, cursorScreenFrame: cursorScreenFrame) + let isSameCommandAgain = windowId.map { AppDelegate.windowHistory.cursorActions[$0] == action } ?? false + if !cursorChangedDisplay && isSameCommandAgain { + // Until the cursor points elsewhere, pressing the same command again continues + // from wherever the last press left the window, so repeats that cross displays + // keep working. This mustn't depend on the last action, which is forgotten + // whenever the app adjusts the frame Rectangle set. + screens = sourceScreens + } else { + // Pointing at another display, or pressing another command, is a first press + // on the cursor's display. The last action there doesn't count as a repeat if + // the cursor moved or repeats walked the window off that display. + if cursorChangedDisplay || sourceScreens?.currentScreen != cursorScreens.currentScreen { + lastRectangleAction = nil + if let windowId { + AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) + } + } + screens = cursorScreens + } + if let windowId { + AppDelegate.windowHistory.cursorScreenFrames[windowId] = cursorScreenFrame + AppDelegate.windowHistory.cursorActions[windowId] = action + } } else { - screens = Defaults.useCursorScreenDetection.enabled - ? screenDetection.detectScreensAtCursor() - : sourceScreens + // Display actions count from the window's display, even with cursor screen detection. + screens = sourceScreens } guard let usableScreens = screens, let sourceScreens else { @@ -121,20 +162,6 @@ class WindowManager { return } - let pendingDestination = windowAnimator.destination(for: frontmostWindowElement) - let currentWindowRect = pendingDestination ?? frontmostWindowElement.frame - - var lastRectangleAction = windowId.flatMap { AppDelegate.windowHistory.lastRectangleActions[$0] } - - let windowMovedExternally = currentWindowRect != lastRectangleAction?.rect - - if windowMovedExternally { - lastRectangleAction = nil - if let windowId { - AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) - } - } - if parameters.updateRestoreRect, let windowId { if AppDelegate.windowHistory.restoreRects[windowId] == nil || windowMovedExternally { @@ -278,14 +305,16 @@ class WindowManager { defer { parameters.completion?() } guard let self, self.executionID == currentExecutionID else { return } let finalRect = self.apply(result: resultParameters) - self.windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: finalRect, + self.windowMovedAcrossDisplays(windowElement: frontmostWindowElement, windowId: windowId, + resultingRect: finalRect, destination: calcResult.screen, preservesFocus: parameters.preservesFocus) self.postProcess(result: resultParameters, resultingRect: finalRect, incrementCount: !animated) } return } } - windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: resultingRect, + windowMovedAcrossDisplays(windowElement: frontmostWindowElement, windowId: windowId, + resultingRect: resultingRect, destination: calcResult.screen, preservesFocus: parameters.preservesFocus) } @@ -351,16 +380,38 @@ class WindowManager { } } - func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect, preservesFocus: Bool) { + func windowMovedAcrossDisplays(windowElement: AccessibilityElement, windowId: CGWindowID?, + resultingRect: CGRect, destination: NSScreen, preservesFocus: Bool) { if !preservesFocus { windowElement.bringToFront(force: true) } - if Defaults.moveCursorAcrossDisplays.userEnabled { - CGWarpMouseCursorPosition(resultingRect.centerPoint) + if Defaults.moveCursorAcrossDisplays.userEnabled, !windowCameToCursor(on: destination) { + moveCursor(to: resultingRect.centerPoint, on: destination, windowId: windowId) + } + } + + /// With cursor screen detection, a window moved to the cursor's display was sent + /// there by the cursor, so moving the cursor again would only pull it away from + /// where the user put it. + private func windowCameToCursor(on destination: NSScreen) -> Bool { + Defaults.useCursorScreenDetection.enabled + && screenDetection.detectScreensAtCursor()?.currentScreen == destination + } + + private func moveCursor(to point: CGPoint, on screen: NSScreen, windowId: CGWindowID?) { + warpCursor(to: point) + // Rectangle moving the cursor isn't the user pointing at another display, + // so the next press of the same command still counts as a repeat. + if Defaults.useCursorScreenDetection.enabled, let windowId { + AppDelegate.windowHistory.cursorScreenFrames[windowId] = screen.frame } } + func warpCursor(to point: CGPoint) { + CGWarpMouseCursorPosition(point) + } + func postProcess(result: ResultParameters, resultingRect: CGRect, incrementCount: Bool = true) { let calcResult = result.calcResult @@ -369,7 +420,7 @@ class WindowManager { } if Defaults.moveCursor.userEnabled, result.source == .keyboardShortcut { - CGWarpMouseCursorPosition(resultingRect.centerPoint) + moveCursor(to: resultingRect.centerPoint, on: calcResult.screen, windowId: result.windowId) } recordAction(windowId: result.windowId, resultingRect: resultingRect, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction, incrementCount: incrementCount) diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index eb60e9bde..327b60327 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -5955,17 +5955,37 @@ class ShortcutManagerSessionTests: XCTestCase { } } + private final class CursorScreenDetection: ScreenDetection { + let screen: NSScreen + + init(screen: NSScreen) { + self.screen = screen + } + + override func detectScreens(using frontmostWindowElement: AccessibilityElement?) -> UsableScreens? { + UsableScreens(currentScreen: screen, adjacentScreens: AdjacentScreens(prev: screen, next: screen), numScreens: 2) + } + + override func detectScreensAtCursor() -> UsableScreens? { + UsableScreens(currentScreen: screen, numScreens: 2) + } + } + /// Runs `body` with the settings that decide how a mouse button press /// executes, putting the user's back afterwards. private func withExecutionSettings(subsequentExecutionMode: SubsequentExecutionMode = .none, - pointerTargeting: Bool, _ body: () throws -> Void) rethrows { + pointerTargeting: Bool, cursorDetection: Bool = false, + _ body: () throws -> Void) rethrows { let savedMode = Defaults.subsequentExecutionMode.toCodable() let savedPointerTargeting = Defaults.mouseButtonShortcutsUseWindowUnderCursor.enabled + let savedCursorDetection = Defaults.useCursorScreenDetection.enabled Defaults.subsequentExecutionMode.load(from: CodableDefault(int: subsequentExecutionMode.rawValue)) Defaults.mouseButtonShortcutsUseWindowUnderCursor.enabled = pointerTargeting + Defaults.useCursorScreenDetection.enabled = cursorDetection defer { Defaults.subsequentExecutionMode.load(from: savedMode) Defaults.mouseButtonShortcutsUseWindowUnderCursor.enabled = savedPointerTargeting + Defaults.useCursorScreenDetection.enabled = savedCursorDetection } try body() } @@ -6063,6 +6083,30 @@ class ShortcutManagerSessionTests: XCTestCase { } } + func testDisplayCycleStartsFreshWhenCursorPointsAtAnotherDisplay() throws { + defer { AppDelegate.windowHistory.cursorScreenFrames.removeValue(forKey: 987_004) } + try withExecutionSettings(subsequentExecutionMode: .cycleMonitor, pointerTargeting: false, cursorDetection: true) { + let window = TestWindow(windowId: 987_004) + defer { AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: 987_004) } + let screen = try XCTUnwrap(NSScreen.screens.first) + let windowManager = CapturingWindowManager() + let harness = makeHarness(windowManager: windowManager, screenDetection: CursorScreenDetection(screen: screen)) + AppDelegate.windowHistory.lastRectangleActions[987_004] = RectangleAction(action: .leftHalf, rect: window.frame) + + // The last press happened with the cursor on another display. + AppDelegate.windowHistory.cursorScreenFrames[987_004] = screen.frame.offsetBy(dx: screen.frame.width, dy: 0) + harness.notificationCenter.post(name: WindowAction.leftHalf.notificationName, + object: ExecutionParameters(.leftHalf, windowElement: window, windowId: 987_004)) + XCTAssertNil(try XCTUnwrap(windowManager.executedParameters.last).screen) + + // Pressing again with the cursor where it was last time still cycles. + AppDelegate.windowHistory.cursorScreenFrames[987_004] = screen.frame + harness.notificationCenter.post(name: WindowAction.leftHalf.notificationName, + object: ExecutionParameters(.leftHalf, windowElement: window, windowId: 987_004)) + XCTAssertTrue(try XCTUnwrap(windowManager.executedParameters.last).screen === screen) + } + } + func testMouseButtonShortcutsBindToTheMouseButtonStoreOnly() { let mouseButton = mouseButtonShortcut(3) let harness = makeHarness(shortcuts: [.leftHalf: shortcut(1), .rightHalf: mouseButton]) @@ -6555,17 +6599,21 @@ class DisplayMoveFocusTests: XCTestCase { } } - func testMovingAcrossDisplaysBringsTheWindowForwardUnlessFocusIsPreserved() { + func testMovingAcrossDisplaysBringsTheWindowForwardUnlessFocusIsPreserved() throws { let previousMoveCursor = Defaults.moveCursorAcrossDisplays.enabled Defaults.moveCursorAcrossDisplays.enabled = false defer { Defaults.moveCursorAcrossDisplays.enabled = previousMoveCursor } let manager = WindowManager() let window = FocusTrackingElement() - manager.windowMovedAcrossDisplays(windowElement: window, resultingRect: .zero, preservesFocus: true) + let screen = try XCTUnwrap(NSScreen.screens.first) + + manager.windowMovedAcrossDisplays(windowElement: window, windowId: nil, resultingRect: .zero, + destination: screen, preservesFocus: true) XCTAssertEqual(window.bringToFrontCalls, 0) - manager.windowMovedAcrossDisplays(windowElement: window, resultingRect: .zero, preservesFocus: false) + manager.windowMovedAcrossDisplays(windowElement: window, windowId: nil, resultingRect: .zero, + destination: screen, preservesFocus: false) XCTAssertEqual(window.bringToFrontCalls, 1) } } @@ -7577,7 +7625,8 @@ final class WindowSizeConstraintExecutionTests: XCTestCase { warningVisible = false } - override func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect, preservesFocus: Bool) {} + override func windowMovedAcrossDisplays(windowElement: AccessibilityElement, windowId: CGWindowID?, + resultingRect: CGRect, destination: NSScreen, preservesFocus: Bool) {} override func postProcess(result: ResultParameters, resultingRect: CGRect, incrementCount: Bool = true) { super.postProcess(result: result, resultingRect: resultingRect, incrementCount: incrementCount) @@ -7704,7 +7753,8 @@ final class CrossDisplayResizeTests: XCTestCase { private final class TestWindowManager: WindowManager { var didFinish: ((ResultParameters, CGRect) -> Void)? - override func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect, preservesFocus: Bool) {} + override func windowMovedAcrossDisplays(windowElement: AccessibilityElement, windowId: CGWindowID?, + resultingRect: CGRect, destination: NSScreen, preservesFocus: Bool) {} override func postProcess(result: ResultParameters, resultingRect: CGRect, incrementCount: Bool = true) { didFinish?(result, resultingRect) @@ -8792,3 +8842,325 @@ final class FootprintGeometrySynchronizationTests: XCTestCase { } +final class CursorScreenDetectionExecutionTests: XCTestCase { + private let windowId: CGWindowID = 523_001 + private var savedDefaults: [(Default, CodableDefault)] = [] + private var savedActions: [CGWindowID: RectangleAction] = [:] + private var savedCursorScreenFrames: [CGWindowID: CGRect] = [:] + private var savedCursorActions: [CGWindowID: WindowAction] = [:] + private var savedLogging = false + + private let left = TestScreen(frame: CGRect(x: 0, y: 0, width: 1000, height: 800)) + private let right = TestScreen(frame: CGRect(x: 1000, y: 0, width: 1000, height: 800)) + + override func setUp() { + super.setUp() + let settings: [(Default, CodableDefault)] = [ + (Defaults.useCursorScreenDetection, CodableDefault(bool: true)), + (Defaults.moveCursorAcrossDisplays, CodableDefault(int: 2)), + (Defaults.moveCursor, CodableDefault(int: 2)), + (Defaults.subsequentExecutionMode, CodableDefault(int: SubsequentExecutionMode.none.rawValue)), + (Defaults.cooperativeCornerResize, CodableDefault(bool: false)), + (Defaults.experimentalWindowAnimations, CodableDefault(bool: false)), + (Defaults.moveFixedSizeToEdge, CodableDefault(int: EdgeAlignment.edgesAndCorners.rawValue)), + (Defaults.horizontalSplitRatio, CodableDefault(float: 50)), + (Defaults.verticalSplitRatio, CodableDefault(float: 50)), + (Defaults.halvesPreserveOtherAxisSize, CodableDefault(bool: false)), + (Defaults.cycleSizesIsChanged, CodableDefault(bool: false)), + (Defaults.attemptMatchOnNextPrevDisplay, CodableDefault(int: 2)), + (Defaults.centerAcrossDisplays, CodableDefault(int: 2)), + (Defaults.gapSize, CodableDefault(float: 0)), + (Defaults.stageSize, CodableDefault(float: 0)), + (Defaults.screenEdgeGapLeft, CodableDefault(float: 0)), + (Defaults.screenEdgeGapRight, CodableDefault(float: 0)), + (Defaults.screenEdgeGapTop, CodableDefault(float: 0)), + (Defaults.screenEdgeGapBottom, CodableDefault(float: 0)), + (Defaults.cyclingOverlapOffset, CodableDefault(int: 2)), + (Defaults.combinedDisplayMode, CodableDefault(int: 2)), + (Defaults.todo, CodableDefault(int: 2)) + ] + savedDefaults = settings.map { ($0.0, $0.0.toCodable()) } + settings.forEach { $0.0.load(from: $0.1) } + savedActions = AppDelegate.windowHistory.lastRectangleActions + savedCursorScreenFrames = AppDelegate.windowHistory.cursorScreenFrames + savedCursorActions = AppDelegate.windowHistory.cursorActions + AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) + AppDelegate.windowHistory.cursorScreenFrames.removeValue(forKey: windowId) + AppDelegate.windowHistory.cursorActions.removeValue(forKey: windowId) + savedLogging = Logger.logging + Logger.logging = false + } + + override func tearDown() { + savedDefaults.forEach { $0.0.load(from: $0.1) } + AppDelegate.windowHistory.lastRectangleActions = savedActions + AppDelegate.windowHistory.cursorScreenFrames = savedCursorScreenFrames + AppDelegate.windowHistory.cursorActions = savedCursorActions + Logger.logging = savedLogging + ActiveSideSplitRatios.shared.resetAll() + super.tearDown() + } + + func testCommandMovesWindowToCursorDisplay() { + let window = TestWindow(frame: CGRect(x: 100, y: 100, width: 400, height: 300).screenFlipped) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: right) + let manager = TestWindowManager(screenDetection: detection) + + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + + XCTAssertEqual(window.frame, leftHalf(of: right)) + } + + func testPointingAtAnotherDisplayIsNotARepeatWhenRepeatsCrossDisplays() { + Defaults.subsequentExecutionMode.value = .acrossMonitor + let window = TestWindow(frame: CGRect(x: 100, y: 100, width: 400, height: 300).screenFlipped) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: left) + let manager = TestWindowManager(screenDetection: detection) + + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + XCTAssertEqual(window.frame, leftHalf(of: left)) + + detection.cursorScreen = right + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + + // Treated as a repeat, this used to bounce the window to the right half of `left`. + XCTAssertEqual(window.frame, leftHalf(of: right)) + } + + func testPointingAtAnotherDisplayIsNotARepeatWhenRepeatsCycleSizes() { + Defaults.subsequentExecutionMode.value = .resize + let window = TestWindow(frame: CGRect(x: 100, y: 100, width: 400, height: 300).screenFlipped) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: left) + let manager = TestWindowManager(screenDetection: detection) + + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + detection.cursorScreen = right + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + + XCTAssertEqual(window.frame, leftHalf(of: right)) + XCTAssertEqual(AppDelegate.windowHistory.lastRectangleActions[windowId]?.count, 1) + } + + func testRepeatWithoutMovingCursorStillCrossesDisplays() { + Defaults.subsequentExecutionMode.value = .acrossMonitor + let window = TestWindow(frame: CGRect(x: 100, y: 100, width: 400, height: 300).screenFlipped) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: left) + let manager = TestWindowManager(screenDetection: detection) + + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + XCTAssertEqual(window.frame, rightHalf(of: right)) + + // The window has left the cursor's display, but the walk continues from where it is. + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + XCTAssertEqual(window.frame, leftHalf(of: right)) + } + + func testRepeatWithoutMovingCursorWalksDisplaysLikeWithTheSettingOff() { + Defaults.subsequentExecutionMode.value = .acrossMonitor + + // An app that sizes its window in rows never lands exactly on the target, + // so Rectangle forgets its last action between presses. + func walk(cursorDetection: Bool) -> [CGRect] { + Defaults.useCursorScreenDetection.enabled = cursorDetection + AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) + AppDelegate.windowHistory.cursorScreenFrames.removeValue(forKey: windowId) + AppDelegate.windowHistory.cursorActions.removeValue(forKey: windowId) + let window = TestWindow(frame: leftHalf(of: right), heightStep: 7) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: right) + let manager = TestWindowManager(screenDetection: detection) + return (0..<6).map { _ in + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + return window.frame + } + } + + let expected = walk(cursorDetection: false) + XCTAssertEqual(Set(expected.map(\.minX)).count, 4, "the walk should visit both halves of both displays") + XCTAssertEqual(walk(cursorDetection: true), expected) + } + + func testRepeatWithoutMovingCursorStillCyclesSizes() { + Defaults.subsequentExecutionMode.value = .resize + let window = TestWindow(frame: CGRect(x: 100, y: 100, width: 400, height: 300).screenFlipped) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: right) + let manager = TestWindowManager(screenDetection: detection) + + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + + XCTAssertEqual(window.frame.minX, right.frame.minX) + XCTAssertNotEqual(window.frame.width, right.frame.width / 2) + XCTAssertEqual(AppDelegate.windowHistory.lastRectangleActions[windowId]?.count, 2) + } + + func testAnotherCommandAfterCyclingDisplaysGoesToCursorDisplay() { + Defaults.subsequentExecutionMode.value = .cycleMonitor + let window = TestWindow(frame: CGRect(x: 100, y: 100, width: 400, height: 300).screenFlipped) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: left) + let manager = TestWindowManager(screenDetection: detection) + + manager.execute(ExecutionParameters(.topLeft, windowElement: window, windowId: windowId)) + // A repeat cycles the window on to the next display while the cursor stays put. + manager.execute(ExecutionParameters(.topLeft, screen: right, windowElement: window, windowId: windowId)) + XCTAssertTrue(right.frame.screenFlipped.contains(window.frame)) + + manager.execute(ExecutionParameters(.topRight, windowElement: window, windowId: windowId)) + + XCTAssertTrue(left.frame.screenFlipped.contains(window.frame)) + XCTAssertEqual(window.frame.minX, left.frame.midX) + } + + func testCursorStaysPutWhenWindowMovesToItsDisplay() { + Defaults.moveCursorAcrossDisplays.enabled = true + let window = TestWindow(frame: CGRect(x: 100, y: 100, width: 400, height: 300).screenFlipped) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: right) + let manager = TestWindowManager(screenDetection: detection) + + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + + XCTAssertEqual(window.frame, leftHalf(of: right)) + XCTAssertEqual(manager.cursorWarps, 0) + } + + func testCursorMovedAlongWithWindowStillCountsAsTheSameDisplay() { + Defaults.subsequentExecutionMode.value = .cycleMonitor + Defaults.moveCursorAcrossDisplays.enabled = true + let window = TestWindow(frame: CGRect(x: 100, y: 100, width: 400, height: 300).screenFlipped) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: left) + let manager = TestWindowManager(screenDetection: detection) + + manager.execute(ExecutionParameters(.topLeft, windowElement: window, windowId: windowId)) + manager.execute(ExecutionParameters(.topLeft, screen: right, windowElement: window, windowId: windowId)) + + // The cursor followed the window, which mustn't make the next press a fresh one. + XCTAssertEqual(manager.cursorWarps, 1) + XCTAssertTrue(detection.cursorScreen === right) + XCTAssertFalse(AppDelegate.windowHistory.cursorChangedDisplay(for: windowId, cursorScreenFrame: right.frame)) + } + + func testNextDisplayCountsFromWindowDisplay() { + let window = TestWindow(frame: CGRect(x: 100, y: 100, width: 400, height: 300).screenFlipped) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: right) + let manager = TestWindowManager(screenDetection: detection) + + manager.execute(ExecutionParameters(.nextDisplay, windowElement: window, windowId: windowId)) + + XCTAssertTrue(right.frame.screenFlipped.contains(window.frame)) + } + + func testCursorDisplayIsIgnoredWhenSettingIsOff() { + Defaults.useCursorScreenDetection.enabled = false + let window = TestWindow(frame: CGRect(x: 100, y: 100, width: 400, height: 300).screenFlipped) + let detection = TestScreenDetection(screens: [left, right], cursorScreen: right) + let manager = TestWindowManager(screenDetection: detection) + + manager.execute(ExecutionParameters(.leftHalf, windowElement: window, windowId: windowId)) + + XCTAssertEqual(window.frame, leftHalf(of: left)) + XCTAssertNil(AppDelegate.windowHistory.cursorScreenFrames[windowId]) + } + + private func leftHalf(of screen: NSScreen) -> CGRect { + CGRect(x: screen.frame.minX, y: screen.frame.minY, + width: screen.frame.width / 2, height: screen.frame.height).screenFlipped + } + + private func rightHalf(of screen: NSScreen) -> CGRect { + CGRect(x: screen.frame.midX, y: screen.frame.minY, + width: screen.frame.width / 2, height: screen.frame.height).screenFlipped + } + + private final class TestScreen: NSScreen { + private let testFrame: CGRect + + init(frame: CGRect) { + testFrame = frame + super.init() + } + + override var frame: NSRect { testFrame } + override var visibleFrame: NSRect { testFrame } + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } + override var hash: Int { ObjectIdentifier(self).hashValue } + override func isEqual(_ object: Any?) -> Bool { (object as AnyObject?) === self } + } + + private final class TestScreenDetection: ScreenDetection { + let screens: [NSScreen] + var cursorScreen: NSScreen + + init(screens: [NSScreen], cursorScreen: NSScreen) { + self.screens = screens + self.cursorScreen = cursorScreen + } + + override func detectScreens(using frontmostWindowElement: AccessibilityElement?) -> UsableScreens? { + let windowFrame = frontmostWindowElement?.frame.screenFlipped ?? .zero + let screen = screens.first { $0.frame.contains(CGPoint(x: windowFrame.midX, y: windowFrame.midY)) } ?? screens[0] + return usableScreens(around: screen) + } + + override func detectScreensAtCursor() -> UsableScreens? { + usableScreens(around: cursorScreen) + } + + private func usableScreens(around screen: NSScreen) -> UsableScreens { + let other = screens.first { $0 !== screen } ?? screen + return UsableScreens(currentScreen: screen, adjacentScreens: AdjacentScreens(prev: other, next: other), + numScreens: screens.count, screensOrdered: screens) + } + } + + private final class TestWindow: AccessibilityElement { + private var currentFrame: CGRect + private let heightStep: CGFloat? + + /// A `heightStep` rounds the height down to whole steps, like an app + /// that sizes its window in rows, so the frame differs from the target. + init(frame: CGRect, heightStep: CGFloat? = nil) { + currentFrame = frame + self.heightStep = heightStep + super.init(AXUIElementCreateSystemWide()) + } + + override var frame: CGRect { currentFrame } + override var isSheet: Bool? { false } + override var isSystemDialog: Bool? { false } + override var minimumSize: CGSize? { nil } + override func getWindowId() -> CGWindowID? { nil } + override func isResizable() -> Bool { true } + override func bringToFront(force: Bool = false) {} + + override func setImmediateFrame(_ target: CGRect, from before: CGRect, sizeFirst: Bool, + placement: ImmediateWindowPlacement? = nil) { + setFrame(target, adjustSizeFirst: sizeFirst) + } + + override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { + var frame = frame + if let heightStep { + frame.size.height = (frame.height / heightStep).rounded(.down) * heightStep + } + currentFrame = frame + } + } + + private final class TestWindowManager: WindowManager { + private let detection: TestScreenDetection + private(set) var cursorWarps = 0 + + init(screenDetection: TestScreenDetection) { + detection = screenDetection + super.init(screenDetection: screenDetection) + } + + override func showSizeConstraintWarning(on screen: NSScreen) {} + override func hideSizeConstraintWarning() {} + + override func warpCursor(to point: CGPoint) { + cursorWarps += 1 + detection.cursorScreen = detection.screens.first { $0.frame.screenFlipped.contains(point) } ?? detection.cursorScreen + } + } +}