Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Rectangle/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -20966,6 +20969,9 @@
}
}
}
},
"Move window to display where cursor is" : {

},
"New" : {
"extractionState" : "manual",
Expand Down Expand Up @@ -33970,6 +33976,7 @@
}
},
"Use cursor screen detection" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
Expand Down
10 changes: 6 additions & 4 deletions Rectangle/SettingsWindow/BehaviorSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion Rectangle/SettingsWindow/BehaviorSettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
10 changes: 10 additions & 0 deletions Rectangle/ShortcutManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions Rectangle/WindowHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

}
97 changes: 74 additions & 23 deletions Rectangle/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down
Loading