From 0c2afc02d1215bbb2a27ee1401136961a3e13b72 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Tue, 11 Nov 2025 15:46:53 -0700 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Introduce=20MouseInteractionObs?= =?UTF-8?q?erver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 113 ++++---------- .../Helpers/DoubleClickTimer.swift | 0 .../Helpers/TriggerDelayTimer.swift | 0 .../KeybindTrigger.swift} | 39 ++--- .../MiddleClickTrigger.swift} | 4 +- .../Observers/MouseInteractionObserver.swift | 139 ++++++++++++++++++ Loop/Core/WindowDragManager.swift | 1 + .../Keybind Recorder/TriggerKeycorder.swift | 4 +- .../Event Monitoring/ActiveEventMonitor.swift | 1 + .../PassiveEventMonitor.swift | 1 + 10 files changed, 186 insertions(+), 116 deletions(-) rename Loop/Core/{Triggers => Observers}/Helpers/DoubleClickTimer.swift (100%) rename Loop/Core/{Triggers => Observers}/Helpers/TriggerDelayTimer.swift (100%) rename Loop/Core/{Triggers/KeybindObserver.swift => Observers/KeybindTrigger.swift} (87%) rename Loop/Core/{Triggers/MiddleClickObserver.swift => Observers/MiddleClickTrigger.swift} (97%) create mode 100644 Loop/Core/Observers/MouseInteractionObserver.swift diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 68d645f7..ab0e598c 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -24,25 +24,30 @@ final class LoopManager: ObservableObject { private let radialMenuController = RadialMenuController() private let previewController = PreviewController() - private(set) lazy var keybindObserver = KeybindObserver( + private(set) lazy var keybindTrigger = KeybindTrigger( openCallback: { [weak self] in self?.openLoop(startingAction: $0) }, closeCallback: { [weak self] in self?.closeLoop(forceClose: $0) }, checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false } ) - private(set) lazy var middleClickObserver = MiddleClickObserver( + private(set) lazy var middleClickTrigger = MiddleClickTrigger( openCallback: { [weak self] in self?.openLoop(startingAction: $0) }, closeCallback: { [weak self] in self?.closeLoop(forceClose: $0) } ) - private(set) lazy var mouseMovedEventMonitor = PassiveEventMonitor( - events: [.mouseMoved, .otherMouseDragged], - callback: mouseMoved - ) - - private(set) lazy var leftClickMonitor = PassiveEventMonitor( - events: [.leftMouseDown], - callback: leftMouseDown + private(set) lazy var mouseInteractionObserver = MouseInteractionObserver( + changeAction: { [weak self] newAction in + /// If the mouse moved, that means that the keybind trigger should no longer passthrough special events such as the emoji key. + self?.keybindTrigger.canPassthroughSpecialEvents = false + self?.changeAction(newAction, canAdvanceCycle: false) + }, + selectNextCycleItem: { [weak self] in + if let parentCycleAction = self?.parentCycleAction { + self?.changeAction(parentCycleAction, disableHapticFeedback: true) + } + }, + getInitialMousePosition: { [weak self] in self?.initialMousePosition ?? .zero }, + checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false } ) private var accessibilityCheckerTask: Task<(), Never>? @@ -54,9 +59,7 @@ final class LoopManager: ObservableObject { @Published var currentAction: WindowAction = .init(.noAction) private var parentCycleAction: WindowAction? - private(set) var initialMousePosition: CGPoint = .init() - private var angleToMouse: Angle = .init(degrees: 0) - private var distanceToMouse: CGFloat = 0 + private(set) var initialMousePosition: CGPoint = .zero func start() { accessibilityCheckerTask = Task(priority: .background) { [weak self] in @@ -66,11 +69,11 @@ final class LoopManager: ObservableObject { } if status { - await keybindObserver.start() - await middleClickObserver.start() + await keybindTrigger.start() + await middleClickTrigger.start() } else { - await keybindObserver.stop() - await middleClickObserver.stop() + await keybindTrigger.stop() + await middleClickTrigger.stop() } } } @@ -127,8 +130,9 @@ extension LoopManager { isShiftKeyPressed = false if !Defaults[.disableCursorInteraction] { - mouseMovedEventMonitor.start() - leftClickMonitor.start() + Task { @MainActor in + mouseInteractionObserver.start(initialMousePosition: initialMousePosition) + } } if !Defaults[.hideUntilDirectionIsChosen] { @@ -157,8 +161,9 @@ extension LoopManager { closeWindows() - mouseMovedEventMonitor.stop() - leftClickMonitor.stop() + Task { @MainActor in + mouseInteractionObserver.stop() + } // Handle normal actions with a target window if let targetWindow, @@ -487,69 +492,3 @@ extension LoopManager { } } } - -// MARK: - Radial Menu - -extension LoopManager { - private func mouseMoved(cgEvent _: CGEvent) { - Task { @MainActor in - guard isLoopActive else { return } - keybindObserver.canPassthroughSpecialEvents = false - - let noActionDistance: CGFloat = 10 - - let currentMouseLocation = NSEvent.mouseLocation - let mouseAngle = Angle(radians: initialMousePosition.angle(to: currentMouseLocation)) - let mouseDistance = initialMousePosition.distance(to: currentMouseLocation) - - // Return if the mouse didn't move - if mouseAngle == angleToMouse, mouseDistance == distanceToMouse { - return - } - - // Get angle & distance to mouse - angleToMouse = mouseAngle - distanceToMouse = mouseDistance - - var resizeDirection: WindowAction = .init(.noAction) - - // If mouse over 50 points away, select half or quarter positions - if distanceToMouse > 50 - Defaults[.radialMenuThickness] { - switch Int((angleToMouse.normalized().degrees + 22.5) / 45) { - case 0, 8: resizeDirection = Defaults[.radialMenuRight] - case 1: resizeDirection = Defaults[.radialMenuBottomRight] - case 2: resizeDirection = Defaults[.radialMenuBottom] - case 3: resizeDirection = Defaults[.radialMenuBottomLeft] - case 4: resizeDirection = Defaults[.radialMenuLeft] - case 5: resizeDirection = Defaults[.radialMenuTopLeft] - case 6: resizeDirection = Defaults[.radialMenuTop] - case 7: resizeDirection = Defaults[.radialMenuTopRight] - default: break - } - } else if distanceToMouse > noActionDistance { - resizeDirection = Defaults[.radialMenuCenter] - } - - changeAction(resizeDirection, canAdvanceCycle: false) - } - } - - private func leftMouseDown(cgEvent event: CGEvent) { - /// Ensure that the source originates from the HID state ID. - /// Otherwise, this event was likely sent from Loop to focus the frontmost click (see `Window.focus` which sends a `SLSEvent` to the window) - let sourceID = CGEventSourceStateID(rawValue: Int32(event.getIntegerValueField(.eventSourceStateID))) - guard sourceID == .hidSystemState else { - return - } - - Task { @MainActor [weak self] in - guard let self, isLoopActive, currentAction.direction != .noAction else { - return - } - - if let parentCycleAction { - changeAction(parentCycleAction, disableHapticFeedback: true) - } - } - } -} diff --git a/Loop/Core/Triggers/Helpers/DoubleClickTimer.swift b/Loop/Core/Observers/Helpers/DoubleClickTimer.swift similarity index 100% rename from Loop/Core/Triggers/Helpers/DoubleClickTimer.swift rename to Loop/Core/Observers/Helpers/DoubleClickTimer.swift diff --git a/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift b/Loop/Core/Observers/Helpers/TriggerDelayTimer.swift similarity index 100% rename from Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift rename to Loop/Core/Observers/Helpers/TriggerDelayTimer.swift diff --git a/Loop/Core/Triggers/KeybindObserver.swift b/Loop/Core/Observers/KeybindTrigger.swift similarity index 87% rename from Loop/Core/Triggers/KeybindObserver.swift rename to Loop/Core/Observers/KeybindTrigger.swift index fe6e218d..06703e1a 100644 --- a/Loop/Core/Triggers/KeybindObserver.swift +++ b/Loop/Core/Observers/KeybindTrigger.swift @@ -1,5 +1,5 @@ // -// KeybindObserver.swift +// KeybindTrigger.swift // Loop // // Created by Kai Azim on 2023-06-18. @@ -10,7 +10,7 @@ import Defaults /// Monitors `keyDown`, `keyUp`, and `flagsChanged` events using an ActiveEventMonitor, invoking Loop’s open and close callbacks as needed. /// Additionally, this class manages keybind action retrieval and updates Loop based on those actions. -final class KeybindObserver { +final class KeybindTrigger { // Callbacks private let openCallback: (WindowAction?) -> () private let closeCallback: (Bool) -> () @@ -99,19 +99,17 @@ final class KeybindObserver { } // If this is a valid event, don't passthrough - let result = performKeybind( + if performKeybind( type: event.type, isARepeat: event.getIntegerValueField(.keyboardEventAutorepeat) == 1, flags: filteredFlags - ) - - if result == .consume { + ) { return .ignore } - // If this shouldn't consume the event, and Loop isn't in the process of opening (possibly due to trigger delays), - // check if it was a system keybind (ex. screenshot), and in that case, passthrough and force-close Loop - if result != .opening, event.type == .keyDown, CGKeyCode.systemKeybinds.contains(pressedKeys) { + // If this wasn't, check if it was a system keybind (ex. screenshot), and + // in that case, passthrough and force-close Loop + if event.type == .keyDown, CGKeyCode.systemKeybinds.contains(pressedKeys) { closeLoop(forceClose: true) } @@ -131,19 +129,13 @@ final class KeybindObserver { eventMonitor = nil } - enum PerformKeybindResult { - case consume - case forward - case opening - } - /// Determines if an event corresponds to a valid Loop action. /// - Parameters: /// - type: the type of this event. /// - isARepeat: whether this event is a repeat event. /// - flags: modifier flags associated with this event. /// - Returns: whether this event was processed by Loop. - private func performKeybind(type: CGEventType, isARepeat: Bool, flags: CGEventFlags) -> PerformKeybindResult { + private func performKeybind(type: CGEventType, isARepeat: Bool, flags: CGEventFlags) -> Bool { let flagKeys = sideDependentTriggerKey ? flags.keyCodes : flags.keyCodes.baseModifiers let allPressedKeys: Set = pressedKeys.union(flagKeys) let actionKeys: Set = allPressedKeys.subtracting(triggerKey) @@ -152,7 +144,7 @@ final class KeybindObserver { if checkIfLoopOpen() { if pressedKeys.contains(.kVK_Escape) { closeLoop(forceClose: true) - return .consume + return true } if type == .keyUp { @@ -162,12 +154,12 @@ final class KeybindObserver { lastKeyReleaseTime = Date.now } - return .forward + return false } if type != .keyDown, !containsTrigger { closeLoop(forceClose: false) - return .consume + return true } } @@ -177,16 +169,13 @@ final class KeybindObserver { if !isARepeat || action.willManipulateExistingWindowFrame { openLoop(startingAction: action, overrideExistingTriggerDelayTimerAction: true) } - - /// Only consume the event if the last command actually opened Loop. - /// The main reason Loop *wouldn't* open after an `openLoop` call would be because the user has enabled a trigger delay. - return checkIfLoopOpen() ? .consume : .opening + return true } // Only trigger Loop without an action if the only pressed keys perfectly matches the trigger key. if allPressedKeys == triggerKey { openLoop(startingAction: nil, overrideExistingTriggerDelayTimerAction: !isARepeat) - return .opening + return false } } else { closeLoop(forceClose: false) @@ -194,7 +183,7 @@ final class KeybindObserver { } // If this wasn't a valid keybind, return false, which will then forward the key event to the frontmost app - return .forward + return false } private func openLoop(startingAction: WindowAction?, overrideExistingTriggerDelayTimerAction: Bool) { diff --git a/Loop/Core/Triggers/MiddleClickObserver.swift b/Loop/Core/Observers/MiddleClickTrigger.swift similarity index 97% rename from Loop/Core/Triggers/MiddleClickObserver.swift rename to Loop/Core/Observers/MiddleClickTrigger.swift index 04566465..a4c6a6cb 100644 --- a/Loop/Core/Triggers/MiddleClickObserver.swift +++ b/Loop/Core/Observers/MiddleClickTrigger.swift @@ -1,5 +1,5 @@ // -// MiddleClickObserver.swift +// MiddleClickTrigger.swift // Loop // // Created by Kai Azim on 2025-08-29. @@ -9,7 +9,7 @@ import AppKit import Defaults /// Reads middle-click events using a PassiveEventMonitor, and triggers Loop open/close callbacks, when appropriate. -final class MiddleClickObserver { +final class MiddleClickTrigger { // Callbacks private let openCallback: (WindowAction?) -> () private let closeCallback: (Bool) -> () diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift new file mode 100644 index 00000000..cb87a0d5 --- /dev/null +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -0,0 +1,139 @@ +// +// MouseInteractionObserver.swift +// Loop +// +// Created by Kai Azim on 2025-11-11. +// + +import Defaults +import OSLog +import SwiftUI + +final class MouseInteractionObserver { + private let logger = Logger(category: "MouseInteractionObserver") + + // Callbacks + private let changeAction: (WindowAction) -> () + private let selectNextCycleItem: () -> () + private let getInitialMousePosition: () -> CGPoint + private let checkIfLoopOpen: () -> Bool + + private var mouseEventMonitor: PassiveEventMonitor? + + // State-keeping for previous calculations + private var previousAngleToMouse: Angle = .zero + private var previousDistanceToMouse: CGFloat = .zero + + init( + changeAction: @escaping (WindowAction) -> (), + selectNextCycleItem: @escaping () -> (), + getInitialMousePosition: @escaping () -> CGPoint, + checkIfLoopOpen: @escaping () -> Bool + ) { + self.changeAction = changeAction + self.selectNextCycleItem = selectNextCycleItem + self.getInitialMousePosition = getInitialMousePosition + self.checkIfLoopOpen = checkIfLoopOpen + } + + @MainActor + func start(initialMousePosition _: CGPoint) { + mouseEventMonitor = PassiveEventMonitor( + events: [ + .mouseMoved, // switch action when mouse is moved + .otherMouseDragged, // switch action when mouse is moved with the middle mouse button clicked + .leftMouseDown // Increment a cycle action on a left click + ], + callback: mouseEvent + ) + + // swiftformat:disable:next redundantSelf + logger.info("Started with initial mouse position: \(self.getInitialMousePosition().debugDescription)") + } + + @MainActor + func stop() { + mouseEventMonitor?.stop() + mouseEventMonitor = nil + + previousAngleToMouse = .zero + previousDistanceToMouse = .zero + + logger.info("Stopped, all stored states cleared.") + } + + private func mouseEvent(_ event: CGEvent) { + switch event.type { + case .mouseMoved, .otherMouseDragged: + processNewMouseLocation(event.location) + case .leftMouseDown: + activateNextCycleAction(event) + default: + break + } + } + + private func processNewMouseLocation(_: CGPoint) { + Task { @MainActor in + guard checkIfLoopOpen() else { return } + + let noActionDistance: CGFloat = 10 + + let initialMousePosition = getInitialMousePosition() + let currentMousePosition = NSEvent.mouseLocation + + let angleToMouse = Angle(radians: initialMousePosition.angle(to: currentMousePosition)) + let distanceToMouse = initialMousePosition.distance(to: currentMousePosition) + + // Return if the mouse didn't move + guard + angleToMouse != previousAngleToMouse, + distanceToMouse != previousDistanceToMouse + else { + return + } + + // Get angle & distance to mouse + previousAngleToMouse = angleToMouse + previousDistanceToMouse = distanceToMouse + + var newAction: WindowAction = .init(.noAction) + + // If mouse over 50 points away, select half or quarter positions + if distanceToMouse > 50 - Defaults[.radialMenuThickness] { + switch Int((angleToMouse.normalized().degrees + 22.5) / 45) { + case 0, 8: newAction = Defaults[.radialMenuRight] + case 1: newAction = Defaults[.radialMenuBottomRight] + case 2: newAction = Defaults[.radialMenuBottom] + case 3: newAction = Defaults[.radialMenuBottomLeft] + case 4: newAction = Defaults[.radialMenuLeft] + case 5: newAction = Defaults[.radialMenuTopLeft] + case 6: newAction = Defaults[.radialMenuTop] + case 7: newAction = Defaults[.radialMenuTopRight] + default: break + } + } else if distanceToMouse > noActionDistance { + newAction = Defaults[.radialMenuCenter] + } + + changeAction(newAction) + } + } + + private func activateNextCycleAction(_ event: CGEvent) { + /// Ensure that the source originates from the HID state ID. + /// Otherwise, this event was likely sent from Loop to focus the frontmost click (see `Window.focus` which sends a `SLSEvent` to the window) + let sourceID = CGEventSourceStateID(rawValue: Int32(event.getIntegerValueField(.eventSourceStateID))) + guard sourceID == .hidSystemState else { + return + } + + Task { @MainActor in + guard checkIfLoopOpen() else { + return + } + + selectNextCycleItem() + } + } +} diff --git a/Loop/Core/WindowDragManager.swift b/Loop/Core/WindowDragManager.swift index 914f5bfc..872f6564 100644 --- a/Loop/Core/WindowDragManager.swift +++ b/Loop/Core/WindowDragManager.swift @@ -34,6 +34,7 @@ final class WindowDragManager { NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) } + @MainActor func addObservers() { leftMouseDraggedMonitor = PassiveEventMonitor( events: [.leftMouseDragged], diff --git a/Loop/Settings Window/Settings/Keybindings/Keybind Recorder/TriggerKeycorder.swift b/Loop/Settings Window/Settings/Keybindings/Keybind Recorder/TriggerKeycorder.swift index 26cb81fc..ff9614b7 100644 --- a/Loop/Settings Window/Settings/Keybindings/Keybind Recorder/TriggerKeycorder.swift +++ b/Loop/Settings Window/Settings/Keybindings/Keybind Recorder/TriggerKeycorder.swift @@ -104,7 +104,7 @@ struct TriggerKeycorder: View { isActive = true // So that if doesn't interfere with the key detection here - LoopManager.shared.keybindObserver.stop() + LoopManager.shared.keybindTrigger.stop() eventMonitor = LocalEventMonitor(events: [.keyDown, .flagsChanged]) { event in // keyDown event is only used to track escape key @@ -154,7 +154,7 @@ struct TriggerKeycorder: View { eventMonitor?.stop() eventMonitor = nil - LoopManager.shared.keybindObserver.start() + LoopManager.shared.keybindTrigger.start() } } diff --git a/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift b/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift index e82a7fb5..05055e2f 100644 --- a/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift +++ b/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift @@ -8,6 +8,7 @@ import CoreGraphics /// Active event monitor that can process and alter events when needed. +@MainActor final class ActiveEventMonitor: BaseEventTapMonitor { private let eventCallback: (CGEvent) -> Unmanaged? diff --git a/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift b/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift index 18fa00bd..35430009 100644 --- a/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift +++ b/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift @@ -9,6 +9,7 @@ import CoreGraphics /// Passive monitor that only listens to events. /// Callback will be called on a separate thread to keep the CFMachPort's callback fast. +@MainActor final class PassiveEventMonitor: BaseEventTapMonitor { private let eventCallback: (CGEvent) -> () From f1f56c0289f0b731438b8031c7c055e8beeab9a0 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Tue, 11 Nov 2025 15:53:18 -0700 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9E=20Rebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/KeybindTrigger.swift | 35 ++++++++++++------- .../PassiveEventMonitor.swift | 1 - 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/Loop/Core/Observers/KeybindTrigger.swift b/Loop/Core/Observers/KeybindTrigger.swift index 06703e1a..4dc7acdd 100644 --- a/Loop/Core/Observers/KeybindTrigger.swift +++ b/Loop/Core/Observers/KeybindTrigger.swift @@ -99,17 +99,19 @@ final class KeybindTrigger { } // If this is a valid event, don't passthrough - if performKeybind( + let result = performKeybind( type: event.type, isARepeat: event.getIntegerValueField(.keyboardEventAutorepeat) == 1, flags: filteredFlags - ) { + ) + + if result == .consume { return .ignore } - // If this wasn't, check if it was a system keybind (ex. screenshot), and - // in that case, passthrough and force-close Loop - if event.type == .keyDown, CGKeyCode.systemKeybinds.contains(pressedKeys) { + // If this shouldn't consume the event, and Loop isn't in the process of opening (possibly due to trigger delays), + // check if it was a system keybind (ex. screenshot), and in that case, passthrough and force-close Loop + if result != .opening, event.type == .keyDown, CGKeyCode.systemKeybinds.contains(pressedKeys) { closeLoop(forceClose: true) } @@ -129,13 +131,19 @@ final class KeybindTrigger { eventMonitor = nil } + enum PerformKeybindResult { + case consume + case forward + case opening + } + /// Determines if an event corresponds to a valid Loop action. /// - Parameters: /// - type: the type of this event. /// - isARepeat: whether this event is a repeat event. /// - flags: modifier flags associated with this event. /// - Returns: whether this event was processed by Loop. - private func performKeybind(type: CGEventType, isARepeat: Bool, flags: CGEventFlags) -> Bool { + private func performKeybind(type: CGEventType, isARepeat: Bool, flags: CGEventFlags) -> PerformKeybindResult { let flagKeys = sideDependentTriggerKey ? flags.keyCodes : flags.keyCodes.baseModifiers let allPressedKeys: Set = pressedKeys.union(flagKeys) let actionKeys: Set = allPressedKeys.subtracting(triggerKey) @@ -144,7 +152,7 @@ final class KeybindTrigger { if checkIfLoopOpen() { if pressedKeys.contains(.kVK_Escape) { closeLoop(forceClose: true) - return true + return .consume } if type == .keyUp { @@ -154,12 +162,12 @@ final class KeybindTrigger { lastKeyReleaseTime = Date.now } - return false + return .forward } if type != .keyDown, !containsTrigger { closeLoop(forceClose: false) - return true + return .consume } } @@ -169,13 +177,16 @@ final class KeybindTrigger { if !isARepeat || action.willManipulateExistingWindowFrame { openLoop(startingAction: action, overrideExistingTriggerDelayTimerAction: true) } - return true + + /// Only consume the event if the last command actually opened Loop. + /// The main reason Loop *wouldn't* open after an `openLoop` call would be because the user has enabled a trigger delay. + return checkIfLoopOpen() ? .consume : .opening } // Only trigger Loop without an action if the only pressed keys perfectly matches the trigger key. if allPressedKeys == triggerKey { openLoop(startingAction: nil, overrideExistingTriggerDelayTimerAction: !isARepeat) - return false + return .opening } } else { closeLoop(forceClose: false) @@ -183,7 +194,7 @@ final class KeybindTrigger { } // If this wasn't a valid keybind, return false, which will then forward the key event to the frontmost app - return false + return .forward } private func openLoop(startingAction: WindowAction?, overrideExistingTriggerDelayTimerAction: Bool) { diff --git a/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift b/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift index 35430009..18fa00bd 100644 --- a/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift +++ b/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift @@ -9,7 +9,6 @@ import CoreGraphics /// Passive monitor that only listens to events. /// Callback will be called on a separate thread to keep the CFMachPort's callback fast. -@MainActor final class PassiveEventMonitor: BaseEventTapMonitor { private let eventCallback: (CGEvent) -> () From 9df99f6538b825d6147d35ff5257da588b058ecf Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Tue, 11 Nov 2025 16:30:35 -0700 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20New=20`radialMenuDirectionalAct?= =?UTF-8?q?ions`=20and=20`radialMenuCenterAction`=20Defaults=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 3 + Loop/Core/Observers/KeybindTrigger.swift | 9 +- .../Observers/MouseInteractionObserver.swift | 42 ++++--- Loop/Extensions/Defaults+Extensions.swift | 103 ++---------------- .../Logger+Extensions.swift | 0 .../RadialMenuWindowAction.swift | 29 +++++ .../Window Action/WindowAction+Defaults.swift | 42 +++++++ .../Window Action/WindowAction.swift | 2 +- .../Window Action/WindowActionCache.swift | 25 ++++- 9 files changed, 134 insertions(+), 121 deletions(-) rename Loop/{Utilities => Extensions}/Logger+Extensions.swift (100%) create mode 100644 Loop/Window Management/Window Action/RadialMenuWindowAction.swift create mode 100644 Loop/Window Management/Window Action/WindowAction+Defaults.swift diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index ab0e598c..6ac84170 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -21,10 +21,12 @@ final class LoopManager: ObservableObject { static var sidesToAdjust: Edge.Set? static var lastTargetFrame: CGRect = .zero + private let windowActionCache = WindowActionCache() private let radialMenuController = RadialMenuController() private let previewController = PreviewController() private(set) lazy var keybindTrigger = KeybindTrigger( + windowActionCache: windowActionCache, openCallback: { [weak self] in self?.openLoop(startingAction: $0) }, closeCallback: { [weak self] in self?.closeLoop(forceClose: $0) }, checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false } @@ -36,6 +38,7 @@ final class LoopManager: ObservableObject { ) private(set) lazy var mouseInteractionObserver = MouseInteractionObserver( + windowActionCache: windowActionCache, changeAction: { [weak self] newAction in /// If the mouse moved, that means that the keybind trigger should no longer passthrough special events such as the emoji key. self?.keybindTrigger.canPassthroughSpecialEvents = false diff --git a/Loop/Core/Observers/KeybindTrigger.swift b/Loop/Core/Observers/KeybindTrigger.swift index 4dc7acdd..d193993f 100644 --- a/Loop/Core/Observers/KeybindTrigger.swift +++ b/Loop/Core/Observers/KeybindTrigger.swift @@ -11,7 +11,8 @@ import Defaults /// Monitors `keyDown`, `keyUp`, and `flagsChanged` events using an ActiveEventMonitor, invoking Loop’s open and close callbacks as needed. /// Additionally, this class manages keybind action retrieval and updates Loop based on those actions. final class KeybindTrigger { - // Callbacks + // Parameters + private let windowActionCache: WindowActionCache private let openCallback: (WindowAction?) -> () private let closeCallback: (Bool) -> () private let checkIfLoopOpen: () -> Bool @@ -26,8 +27,6 @@ final class KeybindTrigger { private let specialEvents: [CGKeyCode] = [.kVK_Globe_Emoji] var canPassthroughSpecialEvents = true // If mouse has been moved - private let actionsByKeybindCache = WindowActionCache() - private var useTriggerDelay: Bool { Defaults[.triggerDelay] > 0.1 } private var doubleClickToTrigger: Bool { Defaults[.doubleClickToTrigger] } private var sideDependentTriggerKey: Bool { Defaults[.sideDependentTriggerKey] } @@ -54,10 +53,12 @@ final class KeybindTrigger { /// - openCallback: what to do when the trigger key is pressed, and Loop should be activated. /// - closeCallback: what to do when the trigger key is released, and Loop should be closed. init( + windowActionCache: WindowActionCache, openCallback: @escaping (WindowAction?) -> (), closeCallback: @escaping (Bool) -> (), checkIfLoopOpen: @escaping () -> Bool ) { + self.windowActionCache = windowActionCache self.openCallback = openCallback self.closeCallback = closeCallback self.checkIfLoopOpen = checkIfLoopOpen @@ -173,7 +174,7 @@ final class KeybindTrigger { if type != .keyUp { if containsTrigger { - if let action = actionsByKeybindCache[actionKeys] { + if let action = windowActionCache.actionsByKeybind[actionKeys] { if !isARepeat || action.willManipulateExistingWindowFrame { openLoop(startingAction: action, overrideExistingTriggerDelayTimerAction: true) } diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index cb87a0d5..c87e055c 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -12,7 +12,8 @@ import SwiftUI final class MouseInteractionObserver { private let logger = Logger(category: "MouseInteractionObserver") - // Callbacks + // Parameters + private let windowActionCache: WindowActionCache private let changeAction: (WindowAction) -> () private let selectNextCycleItem: () -> () private let getInitialMousePosition: () -> CGPoint @@ -24,12 +25,22 @@ final class MouseInteractionObserver { private var previousAngleToMouse: Angle = .zero private var previousDistanceToMouse: CGFloat = .zero + private var radialMenuDirectionalActions: [RadialMenuWindowAction] { + Defaults[.radialMenuDirectionalActions] + } + + private var radialMenuCenterAction: RadialMenuWindowAction { + Defaults[.radialMenuCenterAction] + } + init( + windowActionCache: WindowActionCache, changeAction: @escaping (WindowAction) -> (), selectNextCycleItem: @escaping () -> (), getInitialMousePosition: @escaping () -> CGPoint, checkIfLoopOpen: @escaping () -> Bool ) { + self.windowActionCache = windowActionCache self.changeAction = changeAction self.selectNextCycleItem = selectNextCycleItem self.getInitialMousePosition = getInitialMousePosition @@ -97,26 +108,27 @@ final class MouseInteractionObserver { previousAngleToMouse = angleToMouse previousDistanceToMouse = distanceToMouse - var newAction: WindowAction = .init(.noAction) + var newAction: RadialMenuWindowAction? = nil // If mouse over 50 points away, select half or quarter positions if distanceToMouse > 50 - Defaults[.radialMenuThickness] { - switch Int((angleToMouse.normalized().degrees + 22.5) / 45) { - case 0, 8: newAction = Defaults[.radialMenuRight] - case 1: newAction = Defaults[.radialMenuBottomRight] - case 2: newAction = Defaults[.radialMenuBottom] - case 3: newAction = Defaults[.radialMenuBottomLeft] - case 4: newAction = Defaults[.radialMenuLeft] - case 5: newAction = Defaults[.radialMenuTopLeft] - case 6: newAction = Defaults[.radialMenuTop] - case 7: newAction = Defaults[.radialMenuTopRight] - default: break - } + let actions = radialMenuDirectionalActions + let actionAngleSpan = 360.0 / CGFloat(actions.count) + let halfAngleSpan = actionAngleSpan / 2.0 + let index = Int((angleToMouse.normalized().degrees + halfAngleSpan) / actionAngleSpan) % actions.count + newAction = actions[index] } else if distanceToMouse > noActionDistance { - newAction = Defaults[.radialMenuCenter] + newAction = radialMenuCenterAction } - changeAction(newAction) + switch newAction { + case let .custom(windowAction): + changeAction(windowAction) + case let .keybindReference(id): + if let action = windowActionCache.actionsByIdentifier[id] { changeAction(action) } + case nil: + changeAction(.init(.noAction)) + } } } diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index df106eb2..c79438ef 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -67,54 +67,7 @@ extension Defaults.Keys { static let middleClickTriggersLoop = Key("middleClickTriggersLoop", default: false, iCloud: true) static let enableTriggerDelayOnMiddleClick = Key("enableTriggerDelayOnMiddleClick", default: false, iCloud: true) static let cycleBackwardsOnShiftPressed = Key("cycleBackwardsOnShiftPressed", default: true, iCloud: true) - static let keybinds = Key<[WindowAction]>( - "keybinds", - default: [ - WindowAction(.maximize, keybind: [.kVK_Space]), - WindowAction(.center, keybind: [.kVK_Return]), - WindowAction( - .init(localized: "Top Cycle"), - cycle: [ - .init(.topHalf), - .init(.topThird), - .init(.topTwoThirds) - ], - keybind: [.kVK_UpArrow] - ), - WindowAction( - .init(localized: "Bottom Cycle"), - cycle: [ - .init(.bottomHalf), - .init(.bottomThird), - .init(.bottomTwoThirds) - ], - keybind: [.kVK_DownArrow] - ), - WindowAction( - .init(localized: "Right Cycle"), - cycle: [ - .init(.rightHalf), - .init(.rightThird), - .init(.rightTwoThirds) - ], - keybind: [.kVK_RightArrow] - ), - WindowAction( - .init(localized: "Left Cycle"), - cycle: [ - .init(.leftHalf), - .init(.leftThird), - .init(.leftTwoThirds) - ], - keybind: [.kVK_LeftArrow] - ), - WindowAction(.topLeftQuarter, keybind: [.kVK_UpArrow, .kVK_LeftArrow]), - WindowAction(.topRightQuarter, keybind: [.kVK_UpArrow, .kVK_RightArrow]), - WindowAction(.bottomRightQuarter, keybind: [.kVK_DownArrow, .kVK_RightArrow]), - WindowAction(.bottomLeftQuarter, keybind: [.kVK_DownArrow, .kVK_LeftArrow]) - ], - iCloud: true - ) + static let keybinds = Key<[WindowAction]>("keybinds", default: WindowAction.defaultKeybinds, iCloud: true) // Advanced static let useSystemWindowManagerWhenAvailable = Key("useSystemWindowManagerWhenAvailable", default: false, iCloud: true) @@ -162,54 +115,14 @@ extension Defaults.Keys { static let previewStartingPosition = Key("previewStartingPosition", default: .screenCenter, iCloud: true) // Radial Menu - // It is not recommended to manually edit these entries yet, as it has not been tested. - static let radialMenuTop = Key( - "radialMenuTop", - default: .init([ - .init(.topHalf), - .init(.topThird), - .init(.topTwoThirds) - ]), - iCloud: true - ) - static let radialMenuTopRight = Key("radialMenuTopRight", default: .init(.topRightQuarter), iCloud: true) - static let radialMenuRight = Key( - "radialMenuRight", - default: .init([ - .init(.rightHalf), - .init(.rightThird), - .init(.rightTwoThirds) - ]), - iCloud: true - ) - static let radialMenuBottomRight = Key("radialMenuBottomRight", default: .init(.bottomRightQuarter), iCloud: true) - static let radialMenuBottom = Key( - "radialMenuBottom", - default: .init([ - .init(.bottomHalf), - .init(.bottomThird), - .init(.bottomTwoThirds) - ]), - iCloud: true + static let radialMenuDirectionalActions = Key<[RadialMenuWindowAction]>( + "radialMenuDirectionalActions", + default: RadialMenuWindowAction.defaultRadialMenuDirectionalActions ) - static let radialMenuBottomLeft = Key("radialMenuBottomLeft", default: .init(.bottomLeftQuarter), iCloud: true) - static let radialMenuLeft = Key( - "radialMenuLeft", - default: .init([ - .init(.leftHalf), - .init(.leftThird), - .init(.leftTwoThirds) - ]), - iCloud: true - ) - static let radialMenuTopLeft = Key("radialMenuTopLeft", default: .init(.topLeftQuarter), iCloud: true) - static let radialMenuCenter = Key( - "radialMenuCenter", - default: .init([ - .init(.maximize), - .init(.macOSCenter) - ]), - iCloud: true + + static let radialMenuCenterAction = Key( + "radialMenuDirectionalActions", + default: RadialMenuWindowAction.defaultRadialMenuCenterAction ) // Migrator diff --git a/Loop/Utilities/Logger+Extensions.swift b/Loop/Extensions/Logger+Extensions.swift similarity index 100% rename from Loop/Utilities/Logger+Extensions.swift rename to Loop/Extensions/Logger+Extensions.swift diff --git a/Loop/Window Management/Window Action/RadialMenuWindowAction.swift b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift new file mode 100644 index 00000000..1edbc090 --- /dev/null +++ b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift @@ -0,0 +1,29 @@ +// +// RadialMenuWindowAction.swift +// Loop +// +// Created by Kai Azim on 2025-11-11. +// + +import Defaults +import Foundation + +enum RadialMenuWindowAction: Codable, Defaults.Serializable { + case custom(WindowAction) + case keybindReference(UUID) + + static let defaultRadialMenuDirectionalActions: [RadialMenuWindowAction] = [ + .custom(.init([.init(.rightHalf), .init(.rightThird), .init(.rightTwoThirds)])), + .custom(.init(.bottomRightQuarter)), + .custom(.init([.init(.bottomHalf), .init(.bottomThird), .init(.bottomTwoThirds)])), + .custom(.init(.bottomLeftQuarter)), + .custom(.init([.init(.leftHalf), .init(.leftThird), .init(.leftTwoThirds)])), + .custom(.init(.topLeftQuarter)), + .custom(.init([.init(.topHalf), .init(.topThird), .init(.topTwoThirds)])), + .custom(.init(.topRightQuarter)) + ] + + static let defaultRadialMenuCenterAction: RadialMenuWindowAction = .custom( + .init([.init(.maximize), .init(.macOSCenter)]) + ) +} diff --git a/Loop/Window Management/Window Action/WindowAction+Defaults.swift b/Loop/Window Management/Window Action/WindowAction+Defaults.swift new file mode 100644 index 00000000..457868fb --- /dev/null +++ b/Loop/Window Management/Window Action/WindowAction+Defaults.swift @@ -0,0 +1,42 @@ +// +// WindowAction+Defaults.swift +// Loop +// +// Created by Kai Azim on 2025-11-11. +// + +import Defaults +import Foundation + +// MARK: Keybinds + +extension WindowAction { + static let defaultKeybinds: [WindowAction] = [ + WindowAction(.maximize, keybind: [.kVK_Space]), + WindowAction(.center, keybind: [.kVK_Return]), + WindowAction( + .init(localized: "Top Cycle"), + cycle: [.init(.topHalf), .init(.topThird), .init(.topTwoThirds)], + keybind: [.kVK_UpArrow] + ), + WindowAction( + .init(localized: "Bottom Cycle"), + cycle: [.init(.bottomHalf), .init(.bottomThird), .init(.bottomTwoThirds)], + keybind: [.kVK_DownArrow] + ), + WindowAction( + .init(localized: "Right Cycle"), + cycle: [.init(.rightHalf), .init(.rightThird), .init(.rightTwoThirds)], + keybind: [.kVK_RightArrow] + ), + WindowAction( + .init(localized: "Left Cycle"), + cycle: [.init(.leftHalf), .init(.leftThird), .init(.leftTwoThirds)], + keybind: [.kVK_LeftArrow] + ), + WindowAction(.topLeftQuarter, keybind: [.kVK_UpArrow, .kVK_LeftArrow]), + WindowAction(.topRightQuarter, keybind: [.kVK_UpArrow, .kVK_RightArrow]), + WindowAction(.bottomRightQuarter, keybind: [.kVK_DownArrow, .kVK_RightArrow]), + WindowAction(.bottomLeftQuarter, keybind: [.kVK_DownArrow, .kVK_LeftArrow]) + ] +} diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index ae1e4e13..03819a28 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -15,7 +15,7 @@ import SwiftUI struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serializable { private static let logger = Logger(category: "WindowAction") - var id: UUID = .init() + private(set) var id: UUID = .init() /// Initializes a `WindowAction` with the specified parameters. Only to be used when decoding from JSON. /// - Parameters: diff --git a/Loop/Window Management/Window Action/WindowActionCache.swift b/Loop/Window Management/Window Action/WindowActionCache.swift index f45f4715..33d26c84 100644 --- a/Loop/Window Management/Window Action/WindowActionCache.swift +++ b/Loop/Window Management/Window Action/WindowActionCache.swift @@ -12,7 +12,9 @@ import OSLog /// Caches the user's actions in a dictionary keyed by its keybind. /// This is called from `KeybindObserver`, to retrieve the user's actions in an efficient manner. final class WindowActionCache { - private var actionsByKeybind: [Set: WindowAction] = [:] + private(set) var actionsByKeybind: [Set: WindowAction] = [:] + private(set) var actionsByIdentifier: [UUID: WindowAction] = [:] + private var observationTask: Task<(), Never>? private let logger = Logger(category: "WindowActionCache") @@ -38,13 +40,15 @@ final class WindowActionCache { } } - subscript(_ keybind: Set) -> WindowAction? { - actionsByKeybind[keybind] - } - /// Rebuilds the cache and includes extra entries for cycle actions with shift keys if the user has enabled `cycleBackwardsOnShiftPressed`. private func regenerateCache() { let keybinds: [WindowAction] = Defaults[.keybinds].filter { !$0.keybind.isEmpty } + + regenerateActionsByKeybind(from: keybinds) + regenerateActionsByIdentifier(from: keybinds) + } + + private func regenerateActionsByKeybind(from keybinds: [WindowAction]) { let cycleBackwardsOnShiftPressed: Bool = Defaults[.cycleBackwardsOnShiftPressed] actionsByKeybind = Dictionary( @@ -61,6 +65,15 @@ final class WindowActionCache { ) } - logger.info("Finished regenerating keybinds -> action dictionary") + logger.info("Finished regenerating actionsByKeybind") + } + + private func regenerateActionsByIdentifier(from keybinds: [WindowAction]) { + actionsByIdentifier = Dictionary( + keybinds.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + logger.info("Finished regenerating actionsByIdentifier") } } From 8d522f492fd3d1bfd517b0c78e0e068e1f2b3212 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 13 Nov 2025 18:34:38 -0700 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8=20Consolidate=20radial=20menu=20a?= =?UTF-8?q?ctions=20into=20single=20`radialMenuActions`=20array?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MouseInteractionObserver.swift | 12 ++++-------- Loop/Extensions/Defaults+Extensions.swift | 11 +++-------- .../Window Action/RadialMenuWindowAction.swift | 7 ++----- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index c87e055c..aa37d100 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -25,12 +25,8 @@ final class MouseInteractionObserver { private var previousAngleToMouse: Angle = .zero private var previousDistanceToMouse: CGFloat = .zero - private var radialMenuDirectionalActions: [RadialMenuWindowAction] { - Defaults[.radialMenuDirectionalActions] - } - - private var radialMenuCenterAction: RadialMenuWindowAction { - Defaults[.radialMenuCenterAction] + private var radialMenuActions: [RadialMenuWindowAction] { + Defaults[.radialMenuActions] } init( @@ -112,13 +108,13 @@ final class MouseInteractionObserver { // If mouse over 50 points away, select half or quarter positions if distanceToMouse > 50 - Defaults[.radialMenuThickness] { - let actions = radialMenuDirectionalActions + let actions = Array(radialMenuActions[1...]) let actionAngleSpan = 360.0 / CGFloat(actions.count) let halfAngleSpan = actionAngleSpan / 2.0 let index = Int((angleToMouse.normalized().degrees + halfAngleSpan) / actionAngleSpan) % actions.count newAction = actions[index] } else if distanceToMouse > noActionDistance { - newAction = radialMenuCenterAction + newAction = radialMenuActions.first } switch newAction { diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index c79438ef..5cf8aee8 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -115,14 +115,9 @@ extension Defaults.Keys { static let previewStartingPosition = Key("previewStartingPosition", default: .screenCenter, iCloud: true) // Radial Menu - static let radialMenuDirectionalActions = Key<[RadialMenuWindowAction]>( - "radialMenuDirectionalActions", - default: RadialMenuWindowAction.defaultRadialMenuDirectionalActions - ) - - static let radialMenuCenterAction = Key( - "radialMenuDirectionalActions", - default: RadialMenuWindowAction.defaultRadialMenuCenterAction + static let radialMenuActions = Key<[RadialMenuWindowAction]>( + "radialMenuActions", + default: RadialMenuWindowAction.defaultRadialMenuActions ) // Migrator diff --git a/Loop/Window Management/Window Action/RadialMenuWindowAction.swift b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift index 1edbc090..4795fd8f 100644 --- a/Loop/Window Management/Window Action/RadialMenuWindowAction.swift +++ b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift @@ -12,7 +12,8 @@ enum RadialMenuWindowAction: Codable, Defaults.Serializable { case custom(WindowAction) case keybindReference(UUID) - static let defaultRadialMenuDirectionalActions: [RadialMenuWindowAction] = [ + static let defaultRadialMenuActions: [RadialMenuWindowAction] = [ + .custom(.init([.init(.maximize), .init(.macOSCenter)])), .custom(.init([.init(.rightHalf), .init(.rightThird), .init(.rightTwoThirds)])), .custom(.init(.bottomRightQuarter)), .custom(.init([.init(.bottomHalf), .init(.bottomThird), .init(.bottomTwoThirds)])), @@ -22,8 +23,4 @@ enum RadialMenuWindowAction: Codable, Defaults.Serializable { .custom(.init([.init(.topHalf), .init(.topThird), .init(.topTwoThirds)])), .custom(.init(.topRightQuarter)) ] - - static let defaultRadialMenuCenterAction: RadialMenuWindowAction = .custom( - .init([.init(.maximize), .init(.macOSCenter)]) - ) } From 6a4851a99684ee9a88d09094890540e535bfc7c6 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 22 Nov 2025 20:06:49 -0700 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9A=A1=20Only=20launch=20MainActor=20tas?= =?UTF-8?q?k=20when=20changing=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Observers/MouseInteractionObserver.swift | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index aa37d100..d0d1f069 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -81,42 +81,42 @@ final class MouseInteractionObserver { } private func processNewMouseLocation(_: CGPoint) { - Task { @MainActor in - guard checkIfLoopOpen() else { return } + guard checkIfLoopOpen() else { return } - let noActionDistance: CGFloat = 10 + let noActionDistance: CGFloat = 10 - let initialMousePosition = getInitialMousePosition() - let currentMousePosition = NSEvent.mouseLocation + let initialMousePosition = getInitialMousePosition() + let currentMousePosition = NSEvent.mouseLocation - let angleToMouse = Angle(radians: initialMousePosition.angle(to: currentMousePosition)) - let distanceToMouse = initialMousePosition.distance(to: currentMousePosition) + let angleToMouse = Angle(radians: initialMousePosition.angle(to: currentMousePosition)) + let distanceToMouse = initialMousePosition.distance(to: currentMousePosition) - // Return if the mouse didn't move - guard - angleToMouse != previousAngleToMouse, - distanceToMouse != previousDistanceToMouse - else { - return - } + // Return if the mouse didn't move + guard + angleToMouse != previousAngleToMouse, + distanceToMouse != previousDistanceToMouse + else { + return + } - // Get angle & distance to mouse - previousAngleToMouse = angleToMouse - previousDistanceToMouse = distanceToMouse - - var newAction: RadialMenuWindowAction? = nil - - // If mouse over 50 points away, select half or quarter positions - if distanceToMouse > 50 - Defaults[.radialMenuThickness] { - let actions = Array(radialMenuActions[1...]) - let actionAngleSpan = 360.0 / CGFloat(actions.count) - let halfAngleSpan = actionAngleSpan / 2.0 - let index = Int((angleToMouse.normalized().degrees + halfAngleSpan) / actionAngleSpan) % actions.count - newAction = actions[index] - } else if distanceToMouse > noActionDistance { - newAction = radialMenuActions.first - } + // Get angle & distance to mouse + previousAngleToMouse = angleToMouse + previousDistanceToMouse = distanceToMouse + + var newAction: RadialMenuWindowAction? = nil + + // If mouse over 50 points away, select half or quarter positions + if distanceToMouse > 50 - Defaults[.radialMenuThickness] { + let actions = Array(radialMenuActions[1...]) + let actionAngleSpan = 360.0 / CGFloat(actions.count) + let halfAngleSpan = actionAngleSpan / 2.0 + let index = Int((angleToMouse.normalized().degrees + halfAngleSpan) / actionAngleSpan) % actions.count + newAction = actions[index] + } else if distanceToMouse > noActionDistance { + newAction = radialMenuActions.first + } + Task { @MainActor in switch newAction { case let .custom(windowAction): changeAction(windowAction) From ce31c48416a5850995c6f312f8d31f510b42b4d3 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 22 Nov 2025 20:08:26 -0700 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=A8=20Copilot=20suggestions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MouseInteractionObserver.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index d0d1f069..b49d9f8d 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -93,7 +93,7 @@ final class MouseInteractionObserver { // Return if the mouse didn't move guard - angleToMouse != previousAngleToMouse, + angleToMouse != previousAngleToMouse || distanceToMouse != previousDistanceToMouse else { return @@ -107,6 +107,11 @@ final class MouseInteractionObserver { // If mouse over 50 points away, select half or quarter positions if distanceToMouse > 50 - Defaults[.radialMenuThickness] { + guard radialMenuActions.count > 1 else { + newAction = radialMenuActions.first + return + } + let actions = Array(radialMenuActions[1...]) let actionAngleSpan = 360.0 / CGFloat(actions.count) let halfAngleSpan = actionAngleSpan / 2.0