diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index c2493e91..94f39986 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -105,7 +105,12 @@ final class LoopManager { canSelectNextCycleitem: { [weak self] in self?.hasParentCycleActionAtomic ?? false }, - checkIfLoopOpen: { [weak self] in self?.isLoopActiveAtomic ?? false } + checkIfLoopOpen: { [weak self] in self?.isLoopActiveAtomic ?? false }, + screenDidChange: { [weak self] mousePosition in + Task { @MainActor in + self?.indicatorService.updateRadialMenuPosition(at: mousePosition) + } + } ) func start() { @@ -285,6 +290,16 @@ extension LoopManager { disableHapticFeedback: Bool = false, canAdvanceCycle: Bool = true ) async { + // When the opt-in cross-display radial interaction is active, the + // destination display becomes the action's target as soon as the + // cursor enters it. This keeps the preview and final window operation + // aligned with the radial menu's new location. + if Defaults[.moveRadialMenuAcrossScreens], + let cursorScreen = NSScreen.screenWithMouse, + resizeContext.screen?.isSameScreen(cursorScreen) != true { + resizeContext.setScreen(to: cursorScreen) + } + guard isLoopActive, let currentScreen = resizeContext.screen ?? resolveAndStoreTargetScreen( diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index 914c90a7..6745c276 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -20,6 +20,7 @@ final class MouseInteractionObserver { private let selectNextCycleItem: () -> () private let canSelectNextCycleitem: () -> Bool private let checkIfLoopOpen: () -> Bool + private let screenDidChange: (CGPoint) -> () private var mouseMovementMonitor: PassiveEventMonitor? private var leftClickMonitor: ActiveEventMonitor? @@ -29,6 +30,7 @@ final class MouseInteractionObserver { private var previousDistanceToMouse: CGFloat = .zero private var screenBounds: CGRect? + private var interactionScreen: NSScreen? private var shouldAccountForAbsoluteMousePosition: Bool = false private var initialMousePosition: CGPoint = .zero private var latestMousePosition: CGPoint = .zero @@ -44,31 +46,26 @@ final class MouseInteractionObserver { changeAction: @escaping (WindowAction) -> (), selectNextCycleItem: @escaping () -> (), canSelectNextCycleitem: @escaping () -> Bool, - checkIfLoopOpen: @escaping () -> Bool + checkIfLoopOpen: @escaping () -> Bool, + screenDidChange: @escaping (CGPoint) -> () ) { self.windowActionCache = windowActionCache self.changeAction = changeAction self.selectNextCycleItem = selectNextCycleItem self.canSelectNextCycleitem = canSelectNextCycleitem self.checkIfLoopOpen = checkIfLoopOpen + self.screenDidChange = screenDidChange } func start(initialMousePosition: CGPoint) { stop() - screenBounds = NSScreen.screens.first(where: { $0.frame.contains(initialMousePosition) })?.frame - - if let screenBounds { - // If the current mouse position isn't sufficient for accessing direcitonal actions due to being close to the screen's edge, then enable `shouldAccountForAbsoluteMousePosition` - let closeToMinX = abs(initialMousePosition.x - screenBounds.minX) < Self.directionalActionDistance - let closeToMaxX = abs(initialMousePosition.x - screenBounds.maxX) < Self.directionalActionDistance - let closeToMinY = abs(initialMousePosition.y - screenBounds.minY) < Self.directionalActionDistance - let closeToMaxY = abs(initialMousePosition.y - screenBounds.maxY) < Self.directionalActionDistance - - if closeToMinX || closeToMaxX || closeToMinY || closeToMaxY { - shouldAccountForAbsoluteMousePosition = true - } - } + // Resolve the screen from the position captured when Loop was opened. + // The cursor may move before the event monitor finishes starting, so + // using the current cursor screen here could skip the first transition. + interactionScreen = NSScreen.screens.first(where: { $0.frame.contains(initialMousePosition) }) + ?? NSScreen.screenWithMouse + updateScreenTracking(for: interactionScreen, mousePosition: initialMousePosition) self.initialMousePosition = initialMousePosition latestMousePosition = initialMousePosition @@ -106,6 +103,7 @@ final class MouseInteractionObserver { previousDistanceToMouse = .zero screenBounds = nil + interactionScreen = nil shouldAccountForAbsoluteMousePosition = false initialMousePosition = .zero latestMousePosition = .zero @@ -118,6 +116,13 @@ final class MouseInteractionObserver { Task { let currentMousePosition = computeLatestMousePosition(event) + + if rebaseInteractionIfNeeded(at: currentMousePosition) { + screenDidChange(currentMousePosition) + changeAction(.init(.noSelection)) + return + } + let angleToMouse = initialMousePosition.angle(to: currentMousePosition) + .radians(.pi / 2) let distanceToMouse = initialMousePosition.distance(to: currentMousePosition) @@ -166,6 +171,51 @@ final class MouseInteractionObserver { } } + /// Starts a fresh radial interaction when the cursor enters another display. + /// + /// Without rebasing, the direction would continue to be calculated from the + /// original display, so the destination display could only be reached by + /// carrying an already-selected action across the desktop. + private func rebaseInteractionIfNeeded(at mousePosition: CGPoint) -> Bool { + guard + Defaults[.moveRadialMenuAcrossScreens], + let currentScreen = NSScreen.screenWithMouse, + let interactionScreen, + !currentScreen.isSameScreen(interactionScreen) + else { + return false + } + + self.interactionScreen = currentScreen + updateScreenTracking(for: currentScreen, mousePosition: mousePosition) + + initialMousePosition = mousePosition + latestMousePosition = mousePosition + previousAngleToMouse = .zero + previousDistanceToMouse = .zero + return true + } + + /// Updates edge handling for the display containing the interaction origin. + private func updateScreenTracking(for screen: NSScreen?, mousePosition: CGPoint) { + screenBounds = screen?.frame + shouldAccountForAbsoluteMousePosition = false + + guard let screenBounds else { + return + } + + // If the current mouse position is close to a screen edge, macOS can + // clamp the cursor. Retain Loop's existing edge compensation after a + // cross-display rebase as well. + let closeToMinX = abs(mousePosition.x - screenBounds.minX) < Self.directionalActionDistance + let closeToMaxX = abs(mousePosition.x - screenBounds.maxX) < Self.directionalActionDistance + let closeToMinY = abs(mousePosition.y - screenBounds.minY) < Self.directionalActionDistance + let closeToMaxY = abs(mousePosition.y - screenBounds.maxY) < Self.directionalActionDistance + + shouldAccountForAbsoluteMousePosition = closeToMinX || closeToMaxX || closeToMinY || closeToMaxY + } + /// Computes a resolved mouse position, compensating for macOS cursor clamping at screen edges. /// /// When enabled, this method continues tracking movement along an axis even after the system diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 64a70394..57f70d0b 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -51,6 +51,7 @@ extension Defaults.Keys { static let padding = Key("padding", default: .zero, iCloud: true) static let useScreenWithCursor = Key("useScreenWithCursor", default: true, iCloud: true) static let moveCursorWithWindow = Key("moveCursorWithWindow", default: false, iCloud: true) + static let moveRadialMenuAcrossScreens = Key("moveRadialMenuAcrossScreens", default: false, iCloud: true) static let resizeWindowUnderCursor = Key("resizeWindowUnderCursor", default: false, iCloud: true) static let focusWindowOnResize = Key("focusWindowOnResize", default: true, iCloud: true) static let respectStageManager = Key("respectStageManager", default: true, iCloud: true) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 01401a30..ff34a810 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -17894,6 +17894,9 @@ } } } + }, + "Move radial menu across screens" : { + }, "Move Right" : { "comment" : "Window action", @@ -34785,6 +34788,9 @@ } } } + }, + "When the cursor enters another display while Loop is active, move the radial menu there and restart direction selection." : { + }, "Whether to allow Mission Control to open when windows\nare dragged to the top of the screen." : { "comment" : "A tooltip that explains the purpose of the \"Suppress Mission Control\" toggle in the Behavior Configuration view.", diff --git a/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift b/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift index d7136d0f..3868897d 100644 --- a/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift +++ b/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift @@ -22,6 +22,7 @@ struct BehaviorConfigurationView: View { @Default(.useSystemWindowManagerWhenAvailable) var useSystemWindowManagerWhenAvailable @Default(.useScreenWithCursor) var useScreenWithCursor @Default(.moveCursorWithWindow) var moveCursorWithWindow + @Default(.moveRadialMenuAcrossScreens) var moveRadialMenuAcrossScreens @Default(.resizeWindowUnderCursor) var resizeWindowUnderCursor @Default(.focusWindowOnResize) var focusWindowOnResize @Default(.respectStageManager) var respectStageManager @@ -99,6 +100,15 @@ struct BehaviorConfigurationView: View { LuminareToggle("Move cursor with window", isOn: $moveCursorWithWindow) } + LuminareToggle(isOn: $moveRadialMenuAcrossScreens) { + Text("Move radial menu across screens") + .padding(.trailing, 4) + .luminareToolTip(attachedTo: .topTrailing) { + Text("When the cursor enters another display while Loop is active, move the radial menu there and restart direction selection.") + .padding(6) + } + } + LuminareToggle("Resize window under cursor", isOn: $resizeWindowUnderCursor) // If the system WM is enabled, the window under the cursor requires focus. diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift index 44f9463f..16a7b50d 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift @@ -12,9 +12,12 @@ import SwiftUI @Loggable @MainActor final class RadialMenuController: WindowActionIndicator { + private let windowSize: CGFloat = 100 + 80 + private var viewModel: RadialMenuViewModel = .init(isSettingsPreview: false) private var controller: NSWindowController? private var closeTask: Task<(), Never>? + private var displayedScreen: NSScreen? func open(context: ResizeContext) { defer { viewModel.updateContext(with: context) } @@ -23,6 +26,7 @@ final class RadialMenuController: WindowActionIndicator { closeTask = nil if let window = controller?.window { + updatePositionIfNeeded(at: NSEvent.mouseLocation) viewModel.setIsShown(true, animationDuration: 0.1) window.orderFrontRegardless() return @@ -30,7 +34,6 @@ final class RadialMenuController: WindowActionIndicator { let mouseX: CGFloat = context.initialMousePosition.x let mouseY: CGFloat = context.initialMousePosition.y - let windowSize: CGFloat = 100 + 80 let panel = ActivePanel( contentRect: .zero, @@ -57,14 +60,11 @@ final class RadialMenuController: WindowActionIndicator { y: screenFrame.midY - windowSize / 2 ) ) + displayedScreen = screen } else { // Position at the mouse cursor - panel.setFrameOrigin( - NSPoint( - x: mouseX - windowSize / 2, - y: mouseY - windowSize / 2 - ) - ) + setPanelOrigin(panel, at: CGPoint(x: mouseX, y: mouseY)) + displayedScreen = NSScreen.screenWithMouse } panel.orderFrontRegardless() @@ -72,6 +72,29 @@ final class RadialMenuController: WindowActionIndicator { log.ui("Initialized controller") } + /// Moves the radial menu to the cursor only when it has entered a different + /// display. Keeping the existing position within a display preserves Loop's + /// normal radial-menu interaction, while the opt-in setting makes the menu + /// usable across a multi-display desktop. + func updatePositionIfNeeded(at mousePosition: CGPoint) { + guard + Defaults[.moveRadialMenuAcrossScreens], + !Defaults[.lockRadialMenuToCenter], + let panel = controller?.window, + let currentScreen = NSScreen.screenWithMouse + else { + return + } + + let previousScreen = displayedScreen ?? panel.screen + guard let previousScreen, !previousScreen.isSameScreen(currentScreen) else { + return + } + + setPanelOrigin(panel, at: mousePosition) + displayedScreen = currentScreen + } + func close() { guard controller != nil else { return } closeTask?.cancel() @@ -83,8 +106,19 @@ final class RadialMenuController: WindowActionIndicator { controller?.window?.orderOut(nil) controller?.close() controller = nil + displayedScreen = nil closeTask = nil log.ui("Controller closed") } } + + /// Centers the fixed-size radial panel on a global AppKit mouse position. + private func setPanelOrigin(_ panel: NSWindow, at mousePosition: CGPoint) { + panel.setFrameOrigin( + NSPoint( + x: mousePosition.x - windowSize / 2, + y: mousePosition.y - windowSize / 2 + ) + ) + } } diff --git a/Loop/Window Action Indicators/WindowActionIndicatorService.swift b/Loop/Window Action Indicators/WindowActionIndicatorService.swift index 7ceb204a..60dee3b6 100644 --- a/Loop/Window Action Indicators/WindowActionIndicatorService.swift +++ b/Loop/Window Action Indicators/WindowActionIndicatorService.swift @@ -28,6 +28,16 @@ final class WindowActionIndicatorService { } } + /// Repositions the radial menu when the cursor enters a different display. + /// + /// The radial menu normally remains anchored to the location where Loop was + /// triggered. This update is intentionally separate from `openAndUpdate` so + /// mouse movement can relocate the menu even when the selected action does + /// not change. + func updateRadialMenuPosition(at mousePosition: CGPoint) { + radialMenuController.updatePositionIfNeeded(at: mousePosition) + } + func closeAll() { radialMenuController.close() previewController.close()