From fb1bffe78b2b91d05d64174fb44973d4ed1108fb Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 27 Oct 2025 20:36:32 -0600 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9E=20Restore=20trigger=20delay=20?= =?UTF-8?q?functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 2 +- .../Triggers/Helpers/TriggerDelayTimer.swift | 48 +++++++++++++++++ Loop/Core/Triggers/KeybindObserver.swift | 53 +++++++++++++++---- Loop/Core/Triggers/MiddleClickObserver.swift | 37 +++++-------- 4 files changed, 106 insertions(+), 34 deletions(-) create mode 100644 Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index ae3030f5..7ea2ee79 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -32,7 +32,7 @@ final class LoopManager: ObservableObject { private(set) lazy var middleClickObserver = MiddleClickObserver( openCallback: { [weak self] in self?.openLoop(startingAction: $0) }, - closeCallback: { [weak self] in self?.closeLoop(forceClose: false) } + closeCallback: { [weak self] in self?.closeLoop(forceClose: $0) } ) private(set) lazy var mouseMovedEventMonitor = PassiveEventMonitor( diff --git a/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift b/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift new file mode 100644 index 00000000..36c6ae2a --- /dev/null +++ b/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift @@ -0,0 +1,48 @@ +// +// TriggerDelayTimer.swift +// Loop +// +// Created by Kai Azim on 2025-10-27. +// + +import Defaults +import Foundation + +final class TriggerDelayTimer { + private var triggerDelayTimer: Task<(), Never>? + private var startingAction: WindowAction? + private var triggerDelay: CGFloat { Defaults[.triggerDelay] } + + var isActive: Bool { + triggerDelayTimer != nil + } + + init( + startingAction action: WindowAction?, + openCallback: @escaping (WindowAction?) -> () + ) { + self.startingAction = action + + self.triggerDelayTimer = Task { @MainActor in + try? await Task.sleep(for: .seconds(triggerDelay)) + guard !Task.isCancelled else { return } + + openCallback(startingAction) + cancel() + } + } + + deinit { + cancel() + } + + func updateStartingAction(with newAction: WindowAction?) { + startingAction = newAction + } + + func cancel() { + triggerDelayTimer?.cancel() + triggerDelayTimer = nil + startingAction = nil + } +} diff --git a/Loop/Core/Triggers/KeybindObserver.swift b/Loop/Core/Triggers/KeybindObserver.swift index c65ffdf3..5b95b529 100644 --- a/Loop/Core/Triggers/KeybindObserver.swift +++ b/Loop/Core/Triggers/KeybindObserver.swift @@ -19,7 +19,6 @@ final class KeybindObserver { // State-tracking private var pressedKeys: Set = [] private var previousEventFlags: CGEventFlags = [] - private var lastKeyReleaseTime: Date = .now private var eventMonitor: ActiveEventMonitor? @@ -29,6 +28,9 @@ final class KeybindObserver { private let actionsByKeybindCache = WindowActionCache() + private var useTriggerDelay: Bool { Defaults[.triggerDelay] > 0.1 } + private var triggerDelayTimer: TriggerDelayTimer? + /// Initializes a ``KeybindObserver``. /// - Parameters: /// - openCallback: what to do when the trigger key is pressed, and Loop should be activated. @@ -124,10 +126,7 @@ final class KeybindObserver { if checkIfLoopOpen() { if pressedKeys.contains(.kVK_Escape) { - pressedKeys = [] - canPassthroughSpecialEvents = true - - closeCallback(true) + closeLoop(forceClose: true) return true } @@ -142,22 +141,58 @@ final class KeybindObserver { } if type != .keyDown, !containsTrigger { - closeCallback(false) + closeLoop(forceClose: false) return true } } if type != .keyUp, containsTrigger { - if let action = actionsByKeybindCache[actionKeys], !isARepeat || action.willManipulateExistingWindowFrame { - openCallback(action) + if let action = actionsByKeybindCache[actionKeys] { + if !isARepeat || action.willManipulateExistingWindowFrame { + openLoop(startingAction: action, overrideExistingTriggerDelayTimerAction: true) + } return true } else { - openCallback(nil) + openLoop(startingAction: nil, overrideExistingTriggerDelayTimerAction: !isARepeat) return false } + } else { + closeLoop(forceClose: false) } // If this wasn't a valid keybind, return false, which will then forward the key event to the frontmost app return false } + + private func openLoop(startingAction: WindowAction?, overrideExistingTriggerDelayTimerAction: Bool) { + if checkIfLoopOpen() { + openCallback(startingAction) + } else { + if useTriggerDelay { + // If a trigger delay timer is already active, only update its startingAction when + // overrideExistingTriggerDelayTimerAction is true. If it's false, keep the existing + // timer and its startingAction (do not create a new timer with nil). + if triggerDelayTimer?.isActive ?? false { + if overrideExistingTriggerDelayTimerAction { + triggerDelayTimer?.updateStartingAction(with: startingAction) + } + } else { + // No active timer, create one with the provided startingAction. + triggerDelayTimer = TriggerDelayTimer( + startingAction: startingAction, + openCallback: openCallback + ) + } + } else { + openCallback(startingAction) + } + } + } + + private func closeLoop(forceClose: Bool) { + pressedKeys = [] + canPassthroughSpecialEvents = true + triggerDelayTimer?.cancel() + closeCallback(forceClose) + } } diff --git a/Loop/Core/Triggers/MiddleClickObserver.swift b/Loop/Core/Triggers/MiddleClickObserver.swift index 7e821f90..bb944dcf 100644 --- a/Loop/Core/Triggers/MiddleClickObserver.swift +++ b/Loop/Core/Triggers/MiddleClickObserver.swift @@ -11,28 +11,27 @@ import Defaults /// Reads middle-click events using a PassiveEventMonitor, and triggers Loop open/close callbacks, when appropriate. final class MiddleClickObserver { // Callbacks - private let openCallback: () -> () - private let closeCallback: () -> () + private let openCallback: (WindowAction?) -> () + private let closeCallback: (Bool) -> () // State-tracking private var monitor: PassiveEventMonitor? - private var triggerDelayTimer: Task<(), Never>? // Defaults private var middleClickTriggersLoop: Bool { Defaults[.middleClickTriggersLoop] } private var useTriggerDelay: Bool { Defaults[.enableTriggerDelayOnMiddleClick] && Defaults[.triggerDelay] > 0.1 } - private var triggerDelay: TimeInterval { Defaults[.triggerDelay] } + private var triggerDelayTimer: TriggerDelayTimer? /// Initializes a ``MiddleClickObserver``. /// - Parameters: - /// - 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. + /// - openCallback: what to do when the middle mouse button is pressed, and Loop should be activated. + /// - closeCallback: what to do when the middle mouse button is released, and Loop should be closed. init( openCallback: @escaping (WindowAction?) -> (), - closeCallback: @escaping () -> () + closeCallback: @escaping (Bool) -> () ) { // We will never start off with an action from this trigger, so pass in nil - self.openCallback = { openCallback(nil) } + self.openCallback = openCallback self.closeCallback = closeCallback } @@ -66,27 +65,17 @@ final class MiddleClickObserver { if event.type == .otherMouseDown, event.getIntegerValueField(.mouseEventButtonNumber) == 2 { if useTriggerDelay { - startTriggerDelayTimer() + triggerDelayTimer = TriggerDelayTimer( + startingAction: nil, + openCallback: openCallback + ) } else { - openCallback() + openCallback(nil) } } else { triggerDelayTimer?.cancel() - closeCallback() + closeCallback(false) } } } - - /// Starts a trigger delay timer, which will call the open callback after the specified delay. - func startTriggerDelayTimer() { - triggerDelayTimer?.cancel() - - triggerDelayTimer = Task { @MainActor in - try? await Task.sleep(for: .seconds(triggerDelay)) - guard !Task.isCancelled else { return } - triggerDelayTimer = nil - - openCallback() - } - } } From e72c148a4e84bfc47280264c84cb56bfb6254869 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 27 Oct 2025 21:09:45 -0600 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9E=20Restore=20double=20click=20t?= =?UTF-8?q?o=20trigger=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Triggers/Helpers/DoubleClickTimer.swift | 36 ++++++++++++ .../Triggers/Helpers/TriggerDelayTimer.swift | 16 ++--- Loop/Core/Triggers/KeybindObserver.swift | 58 +++++++++++++------ Loop/Core/Triggers/MiddleClickObserver.swift | 23 +++++--- 4 files changed, 101 insertions(+), 32 deletions(-) create mode 100644 Loop/Core/Triggers/Helpers/DoubleClickTimer.swift diff --git a/Loop/Core/Triggers/Helpers/DoubleClickTimer.swift b/Loop/Core/Triggers/Helpers/DoubleClickTimer.swift new file mode 100644 index 00000000..301181ca --- /dev/null +++ b/Loop/Core/Triggers/Helpers/DoubleClickTimer.swift @@ -0,0 +1,36 @@ +// +// DoubleClickTimer.swift +// Loop +// +// Created by Kai Azim on 2025-10-27. +// + +import AppKit +import Defaults + +final class DoubleClickTimer { + private var lastTriggerKeyPressTime: Date? + private let openCallback: (WindowAction?) -> () + + private var doubleClickInterval: TimeInterval { + NSEvent.doubleClickInterval + } + + init(openCallback: @escaping (WindowAction?) -> ()) { + self.openCallback = openCallback + } + + /// Call this when a trigger action (like `open`) is requested. + func handleTrigger(startingAction: WindowAction?) { + let now = Date() + + if let last = lastTriggerKeyPressTime, now.timeIntervalSince(last) < doubleClickInterval { + // Detected a double-press, trigger immediately + openCallback(startingAction) + lastTriggerKeyPressTime = nil // Reset to avoid triple triggering + } else { + // First press — record the time + lastTriggerKeyPressTime = now + } + } +} diff --git a/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift b/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift index 36c6ae2a..cfbef2c1 100644 --- a/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift +++ b/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift @@ -11,23 +11,25 @@ import Foundation final class TriggerDelayTimer { private var triggerDelayTimer: Task<(), Never>? private var startingAction: WindowAction? + private let openCallback: (WindowAction?) -> () private var triggerDelay: CGFloat { Defaults[.triggerDelay] } var isActive: Bool { triggerDelayTimer != nil } - init( - startingAction action: WindowAction?, - openCallback: @escaping (WindowAction?) -> () - ) { - self.startingAction = action + init(openCallback: @escaping (WindowAction?) -> ()) { + self.openCallback = openCallback + } - self.triggerDelayTimer = Task { @MainActor in + func handleTrigger(startingAction: WindowAction?) { + self.startingAction = startingAction + cancel() // Ensure no previous timer is active + triggerDelayTimer = Task { @MainActor in try? await Task.sleep(for: .seconds(triggerDelay)) guard !Task.isCancelled else { return } - openCallback(startingAction) + openCallback(self.startingAction) cancel() } } diff --git a/Loop/Core/Triggers/KeybindObserver.swift b/Loop/Core/Triggers/KeybindObserver.swift index 5b95b529..8da8b5a8 100644 --- a/Loop/Core/Triggers/KeybindObserver.swift +++ b/Loop/Core/Triggers/KeybindObserver.swift @@ -29,7 +29,20 @@ final class KeybindObserver { private let actionsByKeybindCache = WindowActionCache() private var useTriggerDelay: Bool { Defaults[.triggerDelay] > 0.1 } - private var triggerDelayTimer: TriggerDelayTimer? + private var doubleClickToTrigger: Bool { Defaults[.doubleClickToTrigger] } + private lazy var triggerDelayTimer = TriggerDelayTimer(openCallback: openCallback) + private lazy var doubleClickTimer = DoubleClickTimer { [weak self] action in + guard let self else { return } + + if useTriggerDelay { + startTriggerDelayTimer( + startingAction: action, + overrideExistingTriggerDelayTimerAction: true + ) + } else { + openCallback(action) + } + } /// Initializes a ``KeybindObserver``. /// - Parameters: @@ -166,23 +179,15 @@ final class KeybindObserver { private func openLoop(startingAction: WindowAction?, overrideExistingTriggerDelayTimerAction: Bool) { if checkIfLoopOpen() { - openCallback(startingAction) + openCallback(startingAction) // Only update Loop to the latest WindowAction } else { - if useTriggerDelay { - // If a trigger delay timer is already active, only update its startingAction when - // overrideExistingTriggerDelayTimerAction is true. If it's false, keep the existing - // timer and its startingAction (do not create a new timer with nil). - if triggerDelayTimer?.isActive ?? false { - if overrideExistingTriggerDelayTimerAction { - triggerDelayTimer?.updateStartingAction(with: startingAction) - } - } else { - // No active timer, create one with the provided startingAction. - triggerDelayTimer = TriggerDelayTimer( - startingAction: startingAction, - openCallback: openCallback - ) - } + if doubleClickToTrigger { + doubleClickTimer.handleTrigger(startingAction: startingAction) + } else if useTriggerDelay { + startTriggerDelayTimer( + startingAction: startingAction, + overrideExistingTriggerDelayTimerAction: overrideExistingTriggerDelayTimerAction + ) } else { openCallback(startingAction) } @@ -192,7 +197,24 @@ final class KeybindObserver { private func closeLoop(forceClose: Bool) { pressedKeys = [] canPassthroughSpecialEvents = true - triggerDelayTimer?.cancel() + triggerDelayTimer.cancel() closeCallback(forceClose) } + + private func startTriggerDelayTimer( + startingAction: WindowAction?, + overrideExistingTriggerDelayTimerAction: Bool + ) { + // If a trigger delay timer is already active, only update its startingAction when + // overrideExistingTriggerDelayTimerAction is true. If it's false, keep the existing + // timer and its startingAction (do not create a new timer with nil). + if triggerDelayTimer.isActive { + if overrideExistingTriggerDelayTimerAction { + triggerDelayTimer.updateStartingAction(with: startingAction) + } + } else { + // No active timer, create one with the provided startingAction. + triggerDelayTimer.handleTrigger(startingAction: startingAction) + } + } } diff --git a/Loop/Core/Triggers/MiddleClickObserver.swift b/Loop/Core/Triggers/MiddleClickObserver.swift index bb944dcf..8a664852 100644 --- a/Loop/Core/Triggers/MiddleClickObserver.swift +++ b/Loop/Core/Triggers/MiddleClickObserver.swift @@ -20,7 +20,17 @@ final class MiddleClickObserver { // Defaults private var middleClickTriggersLoop: Bool { Defaults[.middleClickTriggersLoop] } private var useTriggerDelay: Bool { Defaults[.enableTriggerDelayOnMiddleClick] && Defaults[.triggerDelay] > 0.1 } - private var triggerDelayTimer: TriggerDelayTimer? + private var doubleClickToTrigger: Bool { Defaults[.doubleClickToTrigger] } + private lazy var triggerDelayTimer = TriggerDelayTimer(openCallback: openCallback) + private lazy var doubleClickTimer = DoubleClickTimer { [weak self] action in + guard let self else { return } + + if useTriggerDelay { + triggerDelayTimer.handleTrigger(startingAction: nil) + } else { + openCallback(action) + } + } /// Initializes a ``MiddleClickObserver``. /// - Parameters: @@ -64,16 +74,15 @@ final class MiddleClickObserver { if event.type == .otherMouseDown, event.getIntegerValueField(.mouseEventButtonNumber) == 2 { - if useTriggerDelay { - triggerDelayTimer = TriggerDelayTimer( - startingAction: nil, - openCallback: openCallback - ) + if doubleClickToTrigger { + doubleClickTimer.handleTrigger(startingAction: nil) + } else if useTriggerDelay { + triggerDelayTimer.handleTrigger(startingAction: nil) } else { openCallback(nil) } } else { - triggerDelayTimer?.cancel() + triggerDelayTimer.cancel() closeCallback(false) } } From cc2f6ca33798f7b019f4d90932e413ebac662d62 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 27 Oct 2025 21:22:58 -0600 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=93=84=20Add=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Triggers/Helpers/DoubleClickTimer.swift | 19 +++++----- .../Triggers/Helpers/TriggerDelayTimer.swift | 36 +++++++++++++------ 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/Loop/Core/Triggers/Helpers/DoubleClickTimer.swift b/Loop/Core/Triggers/Helpers/DoubleClickTimer.swift index 301181ca..ce029392 100644 --- a/Loop/Core/Triggers/Helpers/DoubleClickTimer.swift +++ b/Loop/Core/Triggers/Helpers/DoubleClickTimer.swift @@ -8,28 +8,31 @@ import AppKit import Defaults +/// A utility class that detects double-click (double-press) events within a specified time interval. +/// +/// It tracks the timing of successive trigger actions (such as key presses) and determines whether +/// two occur within the system-defined (and user-customizable) `NSEvent.doubleClickInterval`. final class DoubleClickTimer { private var lastTriggerKeyPressTime: Date? private let openCallback: (WindowAction?) -> () + private var doubleClickInterval: TimeInterval { NSEvent.doubleClickInterval } - private var doubleClickInterval: TimeInterval { - NSEvent.doubleClickInterval - } - + /// Creates a new `DoubleClickTimer` instance with the specified callback to invoke on a double-press event. + /// - Parameter openCallback: A closure that is called when a double-click is detected. The closure receives the `WindowAction` associated with the trigger as its parameter. init(openCallback: @escaping (WindowAction?) -> ()) { self.openCallback = openCallback } - /// Call this when a trigger action (like `open`) is requested. + /// Handles a trigger event (such as a key press) and determines whether it qualifies as a "double-click". + /// - Parameter startingAction: The `WindowAction` associated with the trigger. func handleTrigger(startingAction: WindowAction?) { let now = Date() + // If we detect a double-press, trigger immediately. Otherwise, just record the time if let last = lastTriggerKeyPressTime, now.timeIntervalSince(last) < doubleClickInterval { - // Detected a double-press, trigger immediately openCallback(startingAction) - lastTriggerKeyPressTime = nil // Reset to avoid triple triggering + lastTriggerKeyPressTime = nil } else { - // First press — record the time lastTriggerKeyPressTime = now } } diff --git a/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift b/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift index cfbef2c1..2ab8e70c 100644 --- a/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift +++ b/Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift @@ -8,40 +8,56 @@ import Defaults import Foundation +/// A utility class that delays triggering an action until a specified amount of time has passed. +/// +/// It is used to defer the execution of a callback (in this case, opening Loop) by a user-configurable +/// number of seconds, as defined by the user’s `Defaults[.triggerDelay]` setting. +/// When using keybinds, you may also want to update the starting action without restarting the timer. +/// In that case, use the `updateStartingAction` method. final class TriggerDelayTimer { private var triggerDelayTimer: Task<(), Never>? private var startingAction: WindowAction? private let openCallback: (WindowAction?) -> () private var triggerDelay: CGFloat { Defaults[.triggerDelay] } - var isActive: Bool { - triggerDelayTimer != nil - } + /// Indicates whether the delay timer is currently active. + var isActive: Bool { triggerDelayTimer != nil } + /// Creates a new `TriggerDelayTimer` instance with the specified callback to invoke after a user-configured delay has elapsed. + /// - Parameter openCallback: A closure that is called once the trigger delay completes successfully. The closure receives the latest `WindowAction` depending on what has been set. init(openCallback: @escaping (WindowAction?) -> ()) { self.openCallback = openCallback } - func handleTrigger(startingAction: WindowAction?) { - self.startingAction = startingAction + deinit { + cancel() + } + + /// Handles a trigger event (such as a key press) and starts or restarts the delay timer. + /// + /// If another trigger is received before the delay elapses, the previous timer is canceled and restarted. + /// Once the configured delay duration passes without interruption, the provided callback is invoked, with the latest inputted starting action. + /// - Parameter startingAction: The `WindowAction` associated with the trigger. + func handleTrigger(startingAction action: WindowAction?) { + startingAction = action cancel() // Ensure no previous timer is active + triggerDelayTimer = Task { @MainActor in try? await Task.sleep(for: .seconds(triggerDelay)) guard !Task.isCancelled else { return } - openCallback(self.startingAction) + openCallback(startingAction) cancel() } } - deinit { - cancel() - } - + /// Updates the stored `startingAction` value without restarting the timer. To be used with keybinds. + /// - Parameter newAction: The new `WindowAction` to associate with the current trigger delay. func updateStartingAction(with newAction: WindowAction?) { startingAction = newAction } + /// Cancels any active delay timer and clears the stored action. func cancel() { triggerDelayTimer?.cancel() triggerDelayTimer = nil From 02c4aad245c8374cda4a273980050897ca1ed17a Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 27 Oct 2025 21:29:58 -0600 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9E=20Fix=20trigger=20delay's=20in?= =?UTF-8?q?itial=20action=20if=20user=20rapidly=20switches=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Triggers/KeybindObserver.swift | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Loop/Core/Triggers/KeybindObserver.swift b/Loop/Core/Triggers/KeybindObserver.swift index 8da8b5a8..6f7ed758 100644 --- a/Loop/Core/Triggers/KeybindObserver.swift +++ b/Loop/Core/Triggers/KeybindObserver.swift @@ -159,18 +159,20 @@ final class KeybindObserver { } } - if type != .keyUp, containsTrigger { - if let action = actionsByKeybindCache[actionKeys] { - if !isARepeat || action.willManipulateExistingWindowFrame { - openLoop(startingAction: action, overrideExistingTriggerDelayTimerAction: true) + if type != .keyUp { + if containsTrigger { + if let action = actionsByKeybindCache[actionKeys] { + if !isARepeat || action.willManipulateExistingWindowFrame { + openLoop(startingAction: action, overrideExistingTriggerDelayTimerAction: true) + } + return true + } else { + openLoop(startingAction: nil, overrideExistingTriggerDelayTimerAction: !isARepeat) + return false } - return true } else { - openLoop(startingAction: nil, overrideExistingTriggerDelayTimerAction: !isARepeat) - return false + closeLoop(forceClose: false) } - } else { - closeLoop(forceClose: false) } // If this wasn't a valid keybind, return false, which will then forward the key event to the frontmost app