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
116 changes: 29 additions & 87 deletions Loop/Core/LoopManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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>?
Expand All @@ -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
Expand All @@ -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()
}
}
}
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// KeybindObserver.swift
// KeybindTrigger.swift
// Loop
//
// Created by Kai Azim on 2023-06-18.
Expand All @@ -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
Expand All @@ -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] }
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// MiddleClickObserver.swift
// MiddleClickTrigger.swift
// Loop
//
// Created by Kai Azim on 2025-08-29.
Expand All @@ -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) -> ()
Expand Down
152 changes: 152 additions & 0 deletions Loop/Core/Observers/MouseInteractionObserver.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
1 change: 1 addition & 0 deletions Loop/Core/WindowDragManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ final class WindowDragManager {
NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0])
}

@MainActor
func addObservers() {
leftMouseDraggedMonitor = PassiveEventMonitor(
events: [.leftMouseDragged],
Expand Down
Loading