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
17 changes: 16 additions & 1 deletion Loop/Core/LoopManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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(
Expand Down
78 changes: 64 additions & 14 deletions Loop/Core/Observers/MouseInteractionObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -106,6 +103,7 @@ final class MouseInteractionObserver {
previousDistanceToMouse = .zero

screenBounds = nil
interactionScreen = nil
shouldAccountForAbsoluteMousePosition = false
initialMousePosition = .zero
latestMousePosition = .zero
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Loop/Extensions/Defaults+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ extension Defaults.Keys {
static let padding = Key<PaddingConfiguration>("padding", default: .zero, iCloud: true)
static let useScreenWithCursor = Key<Bool>("useScreenWithCursor", default: true, iCloud: true)
static let moveCursorWithWindow = Key<Bool>("moveCursorWithWindow", default: false, iCloud: true)
static let moveRadialMenuAcrossScreens = Key<Bool>("moveRadialMenuAcrossScreens", default: false, iCloud: true)
static let resizeWindowUnderCursor = Key<Bool>("resizeWindowUnderCursor", default: false, iCloud: true)
static let focusWindowOnResize = Key<Bool>("focusWindowOnResize", default: true, iCloud: true)
static let respectStageManager = Key<Bool>("respectStageManager", default: true, iCloud: true)
Expand Down
6 changes: 6 additions & 0 deletions Loop/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -17894,6 +17894,9 @@
}
}
}
},
"Move radial menu across screens" : {

},
"Move Right" : {
"comment" : "Window action",
Expand Down Expand Up @@ -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.",
Expand Down
10 changes: 10 additions & 0 deletions Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -23,14 +26,14 @@ final class RadialMenuController: WindowActionIndicator {
closeTask = nil

if let window = controller?.window {
updatePositionIfNeeded(at: NSEvent.mouseLocation)
viewModel.setIsShown(true, animationDuration: 0.1)
window.orderFrontRegardless()
return
}

let mouseX: CGFloat = context.initialMousePosition.x
let mouseY: CGFloat = context.initialMousePosition.y
let windowSize: CGFloat = 100 + 80

let panel = ActivePanel(
contentRect: .zero,
Expand All @@ -57,21 +60,41 @@ 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()

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()
Expand All @@ -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
)
)
}
}
10 changes: 10 additions & 0 deletions Loop/Window Action Indicators/WindowActionIndicatorService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down