diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 68d645f7..6ac84170 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -21,28 +21,36 @@ 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 keybindObserver = KeybindObserver( + 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 } ) - 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( + 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 + 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 +62,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 +72,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 +133,9 @@ extension LoopManager { isShiftKeyPressed = false if !Defaults[.disableCursorInteraction] { - mouseMovedEventMonitor.start() - leftClickMonitor.start() + Task { @MainActor in + mouseInteractionObserver.start(initialMousePosition: initialMousePosition) + } } if !Defaults[.hideUntilDirectionIsChosen] { @@ -157,8 +164,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 +495,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 96% rename from Loop/Core/Triggers/KeybindObserver.swift rename to Loop/Core/Observers/KeybindTrigger.swift index fe6e218d..d193993f 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,8 +10,9 @@ 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 { - // Callbacks +final class KeybindTrigger { + // Parameters + private let windowActionCache: WindowActionCache private let openCallback: (WindowAction?) -> () private let closeCallback: (Bool) -> () private let checkIfLoopOpen: () -> Bool @@ -26,8 +27,6 @@ final class KeybindObserver { 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 KeybindObserver { /// - 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 KeybindObserver { 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/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..b49d9f8d --- /dev/null +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -0,0 +1,152 @@ +// +// 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") + + // Parameters + private let windowActionCache: WindowActionCache + 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 + + private var radialMenuActions: [RadialMenuWindowAction] { + Defaults[.radialMenuActions] + } + + 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 + 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) { + 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: RadialMenuWindowAction? = nil + + // 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 + 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) + case let .keybindReference(id): + if let action = windowActionCache.actionsByIdentifier[id] { changeAction(action) } + case nil: + changeAction(.init(.noAction)) + } + } + } + + 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/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index df106eb2..5cf8aee8 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,9 @@ 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 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 radialMenuActions = Key<[RadialMenuWindowAction]>( + "radialMenuActions", + default: RadialMenuWindowAction.defaultRadialMenuActions ) // 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/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/Window Management/Window Action/RadialMenuWindowAction.swift b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift new file mode 100644 index 00000000..4795fd8f --- /dev/null +++ b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift @@ -0,0 +1,26 @@ +// +// 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 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)])), + .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)) + ] +} 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") } }