Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Loop/Core/LoopManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
39 changes: 39 additions & 0 deletions Loop/Core/Triggers/Helpers/DoubleClickTimer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// DoubleClickTimer.swift
// Loop
//
// Created by Kai Azim on 2025-10-27.
//

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 }

/// 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
}

/// 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 {
openCallback(startingAction)
lastTriggerKeyPressTime = nil
} else {
lastTriggerKeyPressTime = now
}
}
}
66 changes: 66 additions & 0 deletions Loop/Core/Triggers/Helpers/TriggerDelayTimer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// TriggerDelayTimer.swift
// Loop
//
// Created by Kai Azim on 2025-10-27.
//

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] }

/// 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
}

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(startingAction)
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
startingAction = nil
}
}
83 changes: 71 additions & 12 deletions Loop/Core/Triggers/KeybindObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ final class KeybindObserver {
// State-tracking
private var pressedKeys: Set<CGKeyCode> = []
private var previousEventFlags: CGEventFlags = []

private var lastKeyReleaseTime: Date = .now
private var eventMonitor: ActiveEventMonitor?

Expand All @@ -29,6 +28,22 @@ final class KeybindObserver {

private let actionsByKeybindCache = WindowActionCache()

private var useTriggerDelay: Bool { Defaults[.triggerDelay] > 0.1 }
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:
/// - openCallback: what to do when the trigger key is pressed, and Loop should be activated.
Expand Down Expand Up @@ -124,10 +139,7 @@ final class KeybindObserver {

if checkIfLoopOpen() {
if pressedKeys.contains(.kVK_Escape) {
pressedKeys = []
canPassthroughSpecialEvents = true

closeCallback(true)
closeLoop(forceClose: true)
return true
}

Expand All @@ -142,22 +154,69 @@ 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)
return 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
}
} else {
openCallback(nil)
return false
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) // Only update Loop to the latest WindowAction
} else {
if doubleClickToTrigger {
doubleClickTimer.handleTrigger(startingAction: startingAction)
} else if useTriggerDelay {
startTriggerDelayTimer(
startingAction: startingAction,
overrideExistingTriggerDelayTimerAction: overrideExistingTriggerDelayTimerAction
)
} else {
openCallback(startingAction)
}
}
}

private func closeLoop(forceClose: Bool) {
pressedKeys = []
canPassthroughSpecialEvents = true
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)
}
}
}
50 changes: 24 additions & 26 deletions Loop/Core/Triggers/MiddleClickObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,37 @@ 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 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:
/// - 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
}

Expand Down Expand Up @@ -65,28 +74,17 @@ final class MiddleClickObserver {

if event.type == .otherMouseDown,
event.getIntegerValueField(.mouseEventButtonNumber) == 2 {
if useTriggerDelay {
startTriggerDelayTimer()
if doubleClickToTrigger {
doubleClickTimer.handleTrigger(startingAction: nil)
} else if useTriggerDelay {
triggerDelayTimer.handleTrigger(startingAction: nil)
} else {
openCallback()
openCallback(nil)
}
} else {
triggerDelayTimer?.cancel()
closeCallback()
triggerDelayTimer.cancel()
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()
}
}
}