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
1 change: 0 additions & 1 deletion Loop/Core/LoopManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ final class LoopManager: ObservableObject {
self?.changeAction(parentCycleAction, disableHapticFeedback: true)
}
},
getInitialMousePosition: { [weak self] in self?.initialMousePosition ?? .zero },
checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false }
)

Expand Down
98 changes: 84 additions & 14 deletions Loop/Core/Observers/MouseInteractionObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import Scribe
import SwiftUI

final class MouseInteractionObserver {
private static let directionalActionDistance: CGFloat = 50
private static let noActionDistance: CGFloat = 10

// Parameters
private let windowActionCache: WindowActionCache
private let changeAction: (WindowAction) -> ()
private let selectNextCycleItem: () -> ()
private let getInitialMousePosition: () -> CGPoint
private let checkIfLoopOpen: () -> Bool

private var mouseEventMonitor: PassiveEventMonitor?
Expand All @@ -23,6 +25,11 @@ final class MouseInteractionObserver {
private var previousAngleToMouse: Angle = .zero
private var previousDistanceToMouse: CGFloat = .zero

private var screenBounds: CGRect?
private var shouldAccountForAbsoluteMousePosition: Bool = false
private var initialMousePosition: CGPoint = .zero
private var latestMousePosition: CGPoint = .zero

private var radialMenuActions: [RadialMenuAction] {
RadialMenuAction.userConfiguredActions
}
Expand All @@ -33,18 +40,33 @@ final class MouseInteractionObserver {
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) {
func start(initialMousePosition: CGPoint) {
screenBounds = NSScreen.screens.first(where: { $0.frame.contains(initialMousePosition) })?.frame

if let screenBounds {
/// If the current mouse position isn't sufficient for accessing direcitonal actions due to being close to the screen's edge, then enable `shouldAccountForAbsoluteMousePosition`
let closeToMinX = abs(initialMousePosition.x - screenBounds.minX) < Self.directionalActionDistance
let closeToMaxX = abs(initialMousePosition.x - screenBounds.maxX) < Self.directionalActionDistance
let closeToMinY = abs(initialMousePosition.y - screenBounds.minY) < Self.directionalActionDistance
let closeToMaxY = abs(initialMousePosition.y - screenBounds.maxY) < Self.directionalActionDistance

if closeToMinX || closeToMaxX || closeToMinY || closeToMaxY {
shouldAccountForAbsoluteMousePosition = true
}
}

self.initialMousePosition = initialMousePosition
latestMousePosition = initialMousePosition

mouseEventMonitor = PassiveEventMonitor(
events: [
.mouseMoved, // switch action when mouse is moved
Expand All @@ -55,7 +77,7 @@ final class MouseInteractionObserver {
)

// swiftformat:disable:next redundantSelf
Log.info("Started with initial mouse position: \(self.getInitialMousePosition().debugDescription)", category: .mouseInteractionObserver)
Log.info("Started with initial mouse position: \(latestMousePosition.debugDescription)", category: .mouseInteractionObserver)
}

@MainActor
Expand All @@ -66,28 +88,29 @@ final class MouseInteractionObserver {
previousAngleToMouse = .zero
previousDistanceToMouse = .zero

screenBounds = nil
shouldAccountForAbsoluteMousePosition = false
initialMousePosition = .zero
latestMousePosition = .zero

Log.success("Stopped, all stored states cleared.", category: .mouseInteractionObserver)
}

private func mouseEvent(_ event: CGEvent) {
switch event.type {
case .mouseMoved, .otherMouseDragged:
processNewMouseLocation(event.location)
processNewMouseLocation(event)
case .leftMouseDown:
activateNextCycleAction(event)
default:
break
}
}

private func processNewMouseLocation(_: CGPoint) {
private func processNewMouseLocation(_ event: CGEvent) {
guard checkIfLoopOpen() else { return }

let noActionDistance: CGFloat = 10

let initialMousePosition = getInitialMousePosition()
let currentMousePosition = NSEvent.mouseLocation

let currentMousePosition = computeLatestMousePosition(event)
let angleToMouse = initialMousePosition.angle(to: currentMousePosition) + .radians(.pi / 2)
let distanceToMouse = initialMousePosition.distance(to: currentMousePosition)

Expand All @@ -106,7 +129,7 @@ final class MouseInteractionObserver {
var newAction: RadialMenuAction? = nil

// If mouse over 50 points away, select half or quarter positions
if distanceToMouse > 50 - Defaults[.radialMenuThickness] {
if distanceToMouse > Self.directionalActionDistance - Defaults[.radialMenuThickness] {
guard radialMenuActions.count > 1 else {
newAction = radialMenuActions.first
return
Expand All @@ -117,7 +140,7 @@ final class MouseInteractionObserver {
let halfAngleSpan = actionAngleSpan / 2.0
let index = Int((angleToMouse.normalized().degrees + halfAngleSpan) / actionAngleSpan) % actions.count
newAction = actions[index]
} else if distanceToMouse > noActionDistance {
} else if distanceToMouse > Self.noActionDistance {
newAction = radialMenuActions.last
}

Expand All @@ -137,6 +160,53 @@ final class MouseInteractionObserver {
}
}

/// Computes a resolved mouse position, compensating for macOS cursor clamping at screen edges.
///
/// When enabled, this method continues tracking movement along an axis even after the system
/// cursor becomes pinned to a screen edge by applying the event’s delta to the last known position,
/// while clamping the result to a limited distance from the edge, just enough to access directional actions.
///
/// - Parameter event: the CGEvent associated with this mouse movement
/// - Returns: the computed absolute mouse position
private func computeLatestMousePosition(_ event: CGEvent) -> CGPoint {
let current = NSEvent.mouseLocation

guard shouldAccountForAbsoluteMousePosition, let bounds = screenBounds else {
latestMousePosition = current
return latestMousePosition
}

let edgeThreshold: CGFloat = 1
let deltaX = event.getDoubleValueField(.mouseEventDeltaX)
let deltaY = event.getDoubleValueField(.mouseEventDeltaY)
let maxOffset = Self.directionalActionDistance

let atMinX = abs(current.x - bounds.minX) < edgeThreshold
let atMaxX = abs(current.x - bounds.maxX) < edgeThreshold
let atMinY = abs(current.y - bounds.minY) < edgeThreshold
let atMaxY = abs(current.y - bounds.maxY) < edgeThreshold

var resolved = current

if atMinX || atMaxX {
let unclampedX = latestMousePosition.x + deltaX
let minX = bounds.minX - maxOffset
let maxX = bounds.maxX + maxOffset

resolved.x = min(max(unclampedX, minX), maxX)

} else if atMinY || atMaxY {
let unclampedY = latestMousePosition.y + deltaY
let minY = bounds.minY - maxOffset
let maxY = bounds.maxY + maxOffset

resolved.y = min(max(unclampedY, minY), maxY)
}

latestMousePosition = resolved
return resolved
}

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)
Expand Down
8 changes: 6 additions & 2 deletions Loop/Utilities/AnimationConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,12 @@ enum AnimationConfiguration: Int, Defaults.Serializable, CaseIterable, Identifia
}
}

static var radialMenuAngle: Animation {
Animation.timingCurve(0.22, 1, 0.36, 1, duration: 0.2)
var radialMenuAngle: Animation {
if self == .instant {
.linear(duration: 0)
} else {
.timingCurve(0.22, 1, 0.36, 1, duration: 0.2)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ final class RadialMenuViewModel: ObservableObject {

let closestAngle = Angle.degrees(angle).angleDifference(to: targetAngle)
let shouldAnimate = shouldAnimateTransition(closestAngle: closestAngle)
let animation = Defaults[.animationConfiguration].radialMenuAngle

withAnimation(shouldAnimate ? AnimationConfiguration.radialMenuAngle : .linear(duration: 0)) {
withAnimation(shouldAnimate ? animation : .linear(duration: 0)) {
angle += closestAngle.degrees
}
}
Expand Down