diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 9dff7aec..93c22e3d 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,13 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28492A2F22B4B700F6CE42 /* Scribe */; }; 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B6282EE5050C00A1E26B /* Defaults */; }; 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B62B2EE5057C00A1E26B /* Luminare */; }; + 2A847DFC2F5E49E90099E02A /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A847DFB2F5E49E90099E02A /* Scribe */; }; + 2A847DFF2F5E4A080099E02A /* Subsurface in Frameworks */ = {isa = PBXBuildFile; productRef = 2A847DFE2F5E4A080099E02A /* Subsurface */; }; + 2A847E012F5E4A0E0099E02A /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A847E002F5E4A0E0099E02A /* Scribe */; }; 2A86890D2F80968A005B521B /* LoopDockTile.plugin in CopyFiles */ = {isa = PBXBuildFile; fileRef = 2A8689072F809625005B521B /* LoopDockTile.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 2AE091C72F81A22800EF6149 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AE091C62F81A22800EF6149 /* Scribe */; }; - 2AF9238E2F540B1300F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238D2F540B1300F467FD /* Scribe */; }; - 2AF923902F540B2200F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238F2F540B2200F467FD /* Scribe */; }; 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */; }; B1AA00412F30000100AABBCC /* LoopUpdaterHelper in CopyFiles */ = {isa = PBXBuildFile; fileRef = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F06D76892DFF7A77007EEDA9 /* SkyLight.framework */; }; @@ -128,10 +128,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 2A847DFC2F5E49E90099E02A /* Scribe in Frameworks */, + 2A847DFF2F5E4A080099E02A /* Subsurface in Frameworks */, 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */, 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */, - 2AF9238E2F540B1300F467FD /* Scribe in Frameworks */, - 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */, F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */, 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */, ); @@ -141,7 +141,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2AF923902F540B2200F467FD /* Scribe in Frameworks */, + 2A847E012F5E4A0E0099E02A /* Scribe in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -235,8 +235,8 @@ 2A28B6282EE5050C00A1E26B /* Defaults */, 2A28B62B2EE5057C00A1E26B /* Luminare */, 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */, - 2A28492A2F22B4B700F6CE42 /* Scribe */, - 2AF9238D2F540B1300F467FD /* Scribe */, + 2A847DFB2F5E49E90099E02A /* Scribe */, + 2A847DFE2F5E4A080099E02A /* Subsurface */, ); productName = WindowManager; productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */; @@ -259,7 +259,7 @@ ); name = LoopUpdaterHelper; packageProductDependencies = ( - 2AF9238F2F540B2200F467FD /* Scribe */, + 2A847E002F5E4A0E0099E02A /* Scribe */, ); productName = LoopUpdaterHelper; productReference = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; @@ -311,7 +311,8 @@ 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */, 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */, 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, - 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */, + 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */, + 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */, ); productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */; projectDirPath = ""; @@ -926,7 +927,7 @@ kind = branch; }; }; - 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */ = { + 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SenpaiHunters/Scribe"; requirement = { @@ -934,6 +935,14 @@ kind = branch; }; }; + 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MrKai77/Subsurface"; + requirement = { + branch = main; + kind = branch; + }; + }; 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/weichsel/ZIPFoundation"; @@ -945,10 +954,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 2A28492A2F22B4B700F6CE42 /* Scribe */ = { - isa = XCSwiftPackageProductDependency; - productName = Scribe; - }; 2A28B6282EE5050C00A1E26B /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */; @@ -959,19 +964,24 @@ package = 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */; productName = Luminare; }; - 2AE091C62F81A22800EF6149 /* Scribe */ = { + 2A847DFB2F5E49E90099E02A /* Scribe */ = { isa = XCSwiftPackageProductDependency; - package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */; + package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; productName = Scribe; }; - 2AF9238D2F540B1300F467FD /* Scribe */ = { + 2AE091C62F81A22800EF6149 /* Scribe */ = { isa = XCSwiftPackageProductDependency; - package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */; + package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; productName = Scribe; }; - 2AF9238F2F540B2200F467FD /* Scribe */ = { + 2A847DFE2F5E4A080099E02A /* Subsurface */ = { + isa = XCSwiftPackageProductDependency; + package = 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */; + productName = Subsurface; + }; + 2A847E002F5E4A0E0099E02A /* Scribe */ = { isa = XCSwiftPackageProductDependency; - package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */; + package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; productName = Scribe; }; 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */ = { diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 6faf7918..b10d7d3b 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -25,6 +25,7 @@ final class LoopManager { private let updater = Updater.shared private var accessibilityCheckerTask: Task<(), Never>? + private var gestureToggleTask: Task<(), Never>? /// Opening prepares resizeContext asynchronously. We track that setup separately /// so rapid trigger events cannot act on the previous/default context. @@ -59,7 +60,7 @@ final class LoopManager { windowActionCache: windowActionCache, openCallback: { [weak self] action in Task { - await self?.openLoop(startingAction: action) + try? await self?.openLoop(startingAction: action) } }, closeCallback: { [weak self] forceClose in @@ -75,7 +76,7 @@ final class LoopManager { private(set) lazy var middleClickTrigger = MiddleClickTrigger( openCallback: { [weak self] action in Task { - await self?.openLoop(startingAction: action) + try? await self?.openLoop(startingAction: action) } }, closeCallback: { [weak self] forceClose in @@ -86,6 +87,27 @@ final class LoopManager { checkIfLoopOpen: { [weak self] in self?.isLoopActiveAtomic ?? false } ) + private(set) lazy var multitouchTrigger = MultitouchTrigger( + windowActionCache: windowActionCache, + openCallback: { [weak self] action, window in + guard let self else { return } + try await openLoop(startingAction: action, window: window) + }, + closeCallback: { [weak self] forceClose in + Task { + await self?.closeLoop(forceClose: forceClose) + } + }, + changeAction: { [weak self] action, reverse in + Task { + await self?.changeAction(action, reverse: reverse) + } + }, + checkIfLoopOpen: { [weak self] in + self?.isLoopActive ?? false + } + ) + private(set) lazy var mouseInteractionObserver = MouseInteractionObserver( windowActionCache: windowActionCache, changeAction: { [weak self] newAction in @@ -109,6 +131,8 @@ final class LoopManager { ) func start() { + multitouchTrigger.prepare() + accessibilityCheckerTask = Task(priority: .background) { [weak self] in for await status in AccessibilityManager.shared.stream(initial: true) { guard let self, !Task.isCancelled else { @@ -118,9 +142,25 @@ final class LoopManager { if status { await keybindTrigger.start() middleClickTrigger.start() + if Defaults[.enableGestures] { + multitouchTrigger.start() + } } else { keybindTrigger.stop() middleClickTrigger.stop() + multitouchTrigger.stop() + } + } + } + + gestureToggleTask = Task(priority: .background) { [weak self] in + for await enabled in Defaults.updates(.enableGestures) { + guard let self, !Task.isCancelled else { break } + + if enabled, AccessibilityManager.shared.isGranted { + multitouchTrigger.start() + } else { + multitouchTrigger.stop() } } } @@ -129,12 +169,15 @@ final class LoopManager { func shutdown() { accessibilityCheckerTask?.cancel() accessibilityCheckerTask = nil + gestureToggleTask?.cancel() + gestureToggleTask = nil indicatorService.closeAll() keybindTrigger.stop() middleClickTrigger.stop() mouseInteractionObserver.stop() + multitouchTrigger.shutdown() triggerKeyTimeoutTimer.cancel() isLoopOpening = false @@ -145,12 +188,29 @@ final class LoopManager { } } +enum LoopManagerError: LocalizedError { + case accessibilityNotGranted + case appExcluded + case fullscreenWindow + + var errorDescription: String? { + switch self { + case .accessibilityNotGranted: + "Cannot open Loop: accessibility permission not granted" + case .appExcluded: + "Cannot open Loop: app is excluded" + case .fullscreenWindow: + "Cannot open Loop: target window is fullscreen" + } + } +} + // MARK: - Opening/Closing Loop extension LoopManager { - private func openLoop(startingAction: WindowAction) async { + private func openLoop(startingAction: WindowAction, window: Window? = nil) async throws { guard AccessibilityManager.shared.isGranted else { - return + throw LoopManagerError.accessibilityNotGranted } guard !isLoopOpening else { @@ -172,13 +232,14 @@ extension LoopManager { return } - let window = WindowUtility.userDefinedTargetWindow() + let window = window ?? WindowUtility.userDefinedTargetWindow() - guard - window?.isAppExcluded != true, - (window?.fullscreen ?? false && Defaults[.ignoreFullscreen]) == false - else { - return + guard window?.isAppExcluded != true else { + throw LoopManagerError.appExcluded + } + + guard (window?.fullscreen ?? false && Defaults[.ignoreFullscreen]) == false else { + throw LoopManagerError.fullscreenWindow } isLoopOpening = true @@ -283,7 +344,8 @@ extension LoopManager { _ newAction: WindowAction, triggeredFromScreenChange: Bool = false, disableHapticFeedback: Bool = false, - canAdvanceCycle: Bool = true + canAdvanceCycle: Bool = true, + reverse: Bool = false ) async { guard isLoopActive, @@ -315,7 +377,7 @@ extension LoopManager { // The ability to advance a cycle is only available when the action is triggered via a keybind or a left click on the mouse. // This should be set to false when the mouse is moved to prevent rapid cycling. if canAdvanceCycle { - newAction = await getNextCycleAction(newAction) + newAction = await getNextCycleAction(newAction, reverse: reverse) } else { if let cycle = newAction.cycle, !cycle.contains(resizeContext.action) { newAction = cycle.first ?? .init(.noAction) @@ -473,8 +535,8 @@ extension LoopManager { } } - private func getNextCycleAction(_ action: WindowAction) async -> WindowAction { - guard let currentCycle = action.cycle else { + private func getNextCycleAction(_ action: WindowAction, reverse: Bool) async -> WindowAction { + guard let currentCycle = action.cycle, !currentCycle.isEmpty else { return action } @@ -486,12 +548,13 @@ extension LoopManager { && Defaults[.triggerKey].contains(.kVK_Shift) == false && Defaults[.cycleBackwardsOnShiftPressed] - let shouldCycleBackwards = allowReverseCycle && keybindTrigger.effectiveEventFlags.contains(.maskShift) + let shouldCycleBackwards = reverse || (allowReverseCycle && keybindTrigger.effectiveEventFlags.contains(.maskShift)) + let freshStart = shouldCycleBackwards ? (currentCycle.last ?? currentCycle[0]) : currentCycle[0] var currentIndex: Int? = nil if Defaults[.cycleModeRestartEnabled], resizeContext.action.direction == .noSelection || !currentCycle.contains(resizeContext.action) { - return currentCycle[0] + return freshStart } // If the current action is noSelection, we can preserve the index from the last action. @@ -506,7 +569,7 @@ extension LoopManager { } guard var nextIndex = currentIndex else { - return currentCycle[0] + return freshStart } nextIndex += shouldCycleBackwards ? -1 : 1 diff --git a/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift b/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift new file mode 100644 index 00000000..9596db83 --- /dev/null +++ b/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift @@ -0,0 +1,47 @@ +// +// MultitouchGestureBlocker.swift +// Loop +// +// Created by Kai Azim on 2026-04-05. +// + +import AppKit +import Scribe + +/// Reference-counted because the blocker is shared across in-flight +/// gestures: one gesture ending mustn't disable blocking for others still +/// active. `start()` is also idempotent so duplicate calls don't leak the +/// previous `ActiveEventMonitor` (it self-retains via `Unmanaged.passRetained`). +@Loggable +final class MultitouchGestureBlocker { + private var monitor: ActiveEventMonitor? + private var activeCount: Int = 0 + + func start() { + activeCount += 1 + guard monitor == nil else { return } + + log.info("Starting gesture blocker") + + let eventTypes: [CGEventType] = [ + .scrollWheel, + CGEventType(rawValue: UInt32(NSEvent.EventType.gesture.rawValue)), + CGEventType(rawValue: UInt32(NSEvent.EventType.magnify.rawValue)), + CGEventType(rawValue: UInt32(NSEvent.EventType.rotate.rawValue)), + CGEventType(rawValue: UInt32(NSEvent.EventType.smartMagnify.rawValue)) + ].compactMap(\.self) + + monitor = ActiveEventMonitor("gesture_blocker", events: eventTypes) { _ in .ignore } + monitor?.start() + } + + func stop() { + activeCount = max(0, activeCount - 1) + guard activeCount == 0 else { return } + + monitor?.stop() + monitor = nil + + log.info("Stopped gesture blocker") + } +} diff --git a/Loop/Core/Observers/Helpers/SystemGestureManager.swift b/Loop/Core/Observers/Helpers/SystemGestureManager.swift new file mode 100644 index 00000000..c365fc1a --- /dev/null +++ b/Loop/Core/Observers/Helpers/SystemGestureManager.swift @@ -0,0 +1,719 @@ +// +// SystemGestureManager.swift +// Loop +// +// Created by Kai Azim on 2026-05-15. +// + +import Defaults +import Foundation +import Scribe + +enum SystemGesturePreferenceValue: Codable, Hashable, Defaults.Serializable { + case bool(Bool) + case int(Int) + case missing +} + +@Loggable +final class SystemGestureManager { + private let restartDock: () -> () + private let activateSettings: () -> () + + init( + restartDock: @escaping () -> () = SystemGestureManager.restartDock, + activateSettings: @escaping () -> () = SystemGestureManager.activateSettings + ) { + self.restartDock = restartDock + self.activateSettings = activateSettings + } + + enum BuiltInTrackpad: String { + fileprivate static let domain = "com.apple.AppleMultitouchTrackpad" + fileprivate static let defaults = UserDefaults(suiteName: domain) + + case threeFingerHorizontalSwipeGesture = "TrackpadThreeFingerHorizSwipeGesture" + case fourFingerHorizontalSwipeGesture = "TrackpadFourFingerHorizSwipeGesture" + case threeFingerVerticalSwipeGesture = "TrackpadThreeFingerVertSwipeGesture" + case fourFingerVerticalSwipeGesture = "TrackpadFourFingerVertSwipeGesture" + case fourFingerPinchGesture = "TrackpadFourFingerPinchGesture" + case threeFingerDrag = "TrackpadThreeFingerDrag" + case threeFingerTapGesture = "TrackpadThreeFingerTapGesture" + + fileprivate var identifier: SystemGesturePreferenceIdentifier { + SystemGesturePreferenceIdentifier(domain: Self.domain, key: rawValue) + } + + func get() -> SystemGesturePreferenceValue { + identifier.get() + } + + func set(_ value: SystemGesturePreferenceValue) { + identifier.set(value) + identifier.synchronize() + } + } + + enum BluetoothTrackpad: String { + fileprivate static let domain = "com.apple.driver.AppleBluetoothMultitouch.trackpad" + fileprivate static let defaults = UserDefaults(suiteName: domain) + + case threeFingerHorizontalSwipeGesture = "TrackpadThreeFingerHorizSwipeGesture" + case fourFingerHorizontalSwipeGesture = "TrackpadFourFingerHorizSwipeGesture" + case threeFingerVerticalSwipeGesture = "TrackpadThreeFingerVertSwipeGesture" + case fourFingerVerticalSwipeGesture = "TrackpadFourFingerVertSwipeGesture" + case fourFingerPinchGesture = "TrackpadFourFingerPinchGesture" + case threeFingerDrag = "TrackpadThreeFingerDrag" + case threeFingerTapGesture = "TrackpadThreeFingerTapGesture" + + fileprivate var identifier: SystemGesturePreferenceIdentifier { + SystemGesturePreferenceIdentifier(domain: Self.domain, key: rawValue) + } + + func get() -> SystemGesturePreferenceValue { + identifier.get() + } + + func set(_ value: SystemGesturePreferenceValue) { + identifier.set(value) + identifier.synchronize() + } + } + + enum CurrentHostTrackpad: String { + fileprivate static let domain = "NSGlobalDomain.currentHost" + + case threeFingerHorizontalSwipeGesture = "com.apple.trackpad.threeFingerHorizSwipeGesture" + case fourFingerHorizontalSwipeGesture = "com.apple.trackpad.fourFingerHorizSwipeGesture" + case threeFingerVerticalSwipeGesture = "com.apple.trackpad.threeFingerVertSwipeGesture" + case fourFingerVerticalSwipeGesture = "com.apple.trackpad.fourFingerVertSwipeGesture" + case fourFingerPinchGesture = "com.apple.trackpad.fourFingerPinchSwipeGesture" + case threeFingerDrag = "com.apple.trackpad.threeFingerDragGesture" + case threeFingerTapGesture = "com.apple.trackpad.threeFingerTapGesture" + + fileprivate var identifier: SystemGesturePreferenceIdentifier { + SystemGesturePreferenceIdentifier(domain: Self.domain, key: rawValue) + } + + func get() -> SystemGesturePreferenceValue { + identifier.get() + } + + func set(_ value: SystemGesturePreferenceValue) { + identifier.set(value) + identifier.synchronize() + } + } + + enum Dock: String { + fileprivate static let domain = "com.apple.dock" + fileprivate static let defaults = UserDefaults(suiteName: domain) + + case showMissionControlGestureEnabled + case showAppExposeGestureEnabled + + fileprivate var identifier: SystemGesturePreferenceIdentifier { + SystemGesturePreferenceIdentifier(domain: Self.domain, key: rawValue) + } + + func get() -> SystemGesturePreferenceValue { + identifier.get() + } + + func set(_ value: SystemGesturePreferenceValue) { + identifier.set(value) + identifier.synchronize() + } + } + + enum SystemPreferences: String { + fileprivate static let domain = "com.apple.systempreferences" + fileprivate static let defaults = UserDefaults(suiteName: domain) + + case threeFingerDragFourFingerNavigate = "com.apple.preference.trackpad.3fdrag-4fNavigate" + + fileprivate var identifier: SystemGesturePreferenceIdentifier { + SystemGesturePreferenceIdentifier(domain: Self.domain, key: rawValue) + } + + func get() -> SystemGesturePreferenceValue { + identifier.get() + } + + func set(_ value: SystemGesturePreferenceValue) { + identifier.set(value) + identifier.synchronize() + } + } + + fileprivate enum TrackpadGesture: String { + case threeFingerHorizontalSwipeGesture = "TrackpadThreeFingerHorizSwipeGesture" + case fourFingerHorizontalSwipeGesture = "TrackpadFourFingerHorizSwipeGesture" + case threeFingerVerticalSwipeGesture = "TrackpadThreeFingerVertSwipeGesture" + case fourFingerVerticalSwipeGesture = "TrackpadFourFingerVertSwipeGesture" + case fourFingerPinchGesture = "TrackpadFourFingerPinchGesture" + case threeFingerDrag = "TrackpadThreeFingerDrag" + case threeFingerTapGesture = "TrackpadThreeFingerTapGesture" + + init?(preferenceKey: String) { + if let gesture = Self(rawValue: preferenceKey) { + self = gesture + return + } + + switch preferenceKey { + case SystemGestureManager.CurrentHostTrackpad.threeFingerHorizontalSwipeGesture.rawValue: + self = .threeFingerHorizontalSwipeGesture + case SystemGestureManager.CurrentHostTrackpad.fourFingerHorizontalSwipeGesture.rawValue: + self = .fourFingerHorizontalSwipeGesture + case SystemGestureManager.CurrentHostTrackpad.threeFingerVerticalSwipeGesture.rawValue: + self = .threeFingerVerticalSwipeGesture + case SystemGestureManager.CurrentHostTrackpad.fourFingerVerticalSwipeGesture.rawValue: + self = .fourFingerVerticalSwipeGesture + case SystemGestureManager.CurrentHostTrackpad.fourFingerPinchGesture.rawValue: + self = .fourFingerPinchGesture + case SystemGestureManager.CurrentHostTrackpad.threeFingerDrag.rawValue: + self = .threeFingerDrag + case SystemGestureManager.CurrentHostTrackpad.threeFingerTapGesture.rawValue: + self = .threeFingerTapGesture + default: + return nil + } + } + } + + static func restore() { + var backups = Defaults[.systemGesturePreferenceBackups] + var managedValues = Defaults[.systemGestureManagedValues] + + Self().restoreAll(backups: &backups, managedValues: &managedValues) + + Defaults[.systemGesturePreferenceBackups] = backups + Defaults[.systemGestureManagedValues] = managedValues + } + + static func reconcile( + enableGestures: Bool, + disableConflicts: Bool, + gestures: [Gesture] + ) { + var backups = Defaults[.systemGesturePreferenceBackups] + var managedValues = Defaults[.systemGestureManagedValues] + + Self().reconcile( + enableGestures: enableGestures, + disableConflicts: disableConflicts, + gestures: gestures, + backups: &backups, + managedValues: &managedValues + ) + + Defaults[.systemGesturePreferenceBackups] = backups + Defaults[.systemGestureManagedValues] = managedValues + } + + func reconcile( + enableGestures: Bool, + disableConflicts: Bool, + gestures: [Gesture], + backups: inout [String: SystemGesturePreferenceValue], + managedValues: inout [String: SystemGesturePreferenceValue] + ) { + normalizeStoredValues(&backups) + normalizeStoredValues(&managedValues) + + guard enableGestures, disableConflicts else { + restoreAll(backups: &backups, managedValues: &managedValues) + return + } + + let desiredValues = desiredValues(for: gestures, backups: backups, managedValues: managedValues) + guard !desiredValues.isEmpty else { + restoreAll(backups: &backups, managedValues: &managedValues) + return + } + + restoreNoLongerManagedValues( + desiredValues: desiredValues, + backups: &backups, + managedValues: &managedValues + ) + + var touchedDomains = Set() + var touchedDock = false + var touchedTrackpad = false + for (identifier, desiredValue) in desiredValues { + let currentValue = identifier.get() + let desiredValue = identifier.normalized(desiredValue) + let lastManagedValue = managedValues[identifier.compositeKey].map(identifier.normalized) + + if currentValue != lastManagedValue, currentValue != desiredValue { + backups[identifier.compositeKey] = currentValue + } + + let backupValue = backups[identifier.compositeKey].map(identifier.normalized) + guard currentValue != desiredValue else { + if backupValue != nil { + managedValues[identifier.compositeKey] = desiredValue + } else { + managedValues.removeValue(forKey: identifier.compositeKey) + } + continue + } + + guard backupValue != nil else { + managedValues.removeValue(forKey: identifier.compositeKey) + continue + } + + identifier.set(desiredValue) + managedValues[identifier.compositeKey] = desiredValue + touchedDomains.insert(identifier.domain) + touchedDock = touchedDock || identifier.isDockDomain + touchedTrackpad = touchedTrackpad || identifier.isTrackpadDomain + } + + synchronize(domains: touchedDomains) + if touchedTrackpad { + activateSettings() + } + if touchedDock { + restartDock() + } + } + + private func restoreAll( + backups: inout [String: SystemGesturePreferenceValue], + managedValues: inout [String: SystemGesturePreferenceValue] + ) { + normalizeStoredValues(&backups) + normalizeStoredValues(&managedValues) + + let identifiers = Set(backups.keys) + .union(managedValues.keys) + .compactMap(SystemGesturePreferenceIdentifier.init(compositeKey:)) + restore(identifiers, backups: &backups, managedValues: &managedValues) + backups.removeAll() + managedValues.removeAll() + } + + private func restoreNoLongerManagedValues( + desiredValues: [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue], + backups: inout [String: SystemGesturePreferenceValue], + managedValues: inout [String: SystemGesturePreferenceValue] + ) { + let desiredKeys = Set(desiredValues.keys.map(\.compositeKey)) + let staleIdentifiers = managedValues.keys + .filter { !desiredKeys.contains($0) } + .compactMap(SystemGesturePreferenceIdentifier.init(compositeKey:)) + + restore(staleIdentifiers, backups: &backups, managedValues: &managedValues) + } + + private func restore( + _ identifiers: [SystemGesturePreferenceIdentifier], + backups: inout [String: SystemGesturePreferenceValue], + managedValues: inout [String: SystemGesturePreferenceValue] + ) { + guard !identifiers.isEmpty else { return } + + var touchedDomains = Set() + var touchedDock = false + var touchedTrackpad = false + + for identifier in identifiers { + guard let backupValue = backups[identifier.compositeKey].map(identifier.normalized) else { + managedValues.removeValue(forKey: identifier.compositeKey) + continue + } + + identifier.set(backupValue) + backups.removeValue(forKey: identifier.compositeKey) + managedValues.removeValue(forKey: identifier.compositeKey) + touchedDomains.insert(identifier.domain) + touchedDock = touchedDock || identifier.isDockDomain + touchedTrackpad = touchedTrackpad || identifier.isTrackpadDomain + } + + synchronize(domains: touchedDomains) + if touchedTrackpad { + activateSettings() + } + if touchedDock { + restartDock() + } + } + + private func normalizeStoredValues(_ values: inout [String: SystemGesturePreferenceValue]) { + for (key, value) in values { + guard let identifier = SystemGesturePreferenceIdentifier(compositeKey: key) else { continue } + values[key] = identifier.normalized(value) + } + } + + private func desiredValues( + for gestures: [Gesture], + backups: [String: SystemGesturePreferenceValue], + managedValues: [String: SystemGesturePreferenceValue] + ) -> [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue] { + let hasThreeFingerGesture = gestures.contains { $0.fingerCount == 3 } + let hasFourFingerGesture = gestures.contains { $0.fingerCount == 4 } + + guard hasThreeFingerGesture || hasFourFingerGesture else { return [:] } + + var desiredValues: [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue] = [:] + + if hasThreeFingerGesture { + setTrackpadValue( + .int(0), + for: .threeFingerHorizontalSwipeGesture, + .threeFingerHorizontalSwipeGesture, + .threeFingerHorizontalSwipeGesture, + in: &desiredValues + ) + setTrackpadValue( + .int(0), + for: .threeFingerVerticalSwipeGesture, + .threeFingerVerticalSwipeGesture, + .threeFingerVerticalSwipeGesture, + in: &desiredValues + ) + setTrackpadValue( + .bool(false), + for: .threeFingerDrag, + .threeFingerDrag, + .threeFingerDrag, + in: &desiredValues + ) + setTrackpadValue( + .int(0), + for: .threeFingerTapGesture, + .threeFingerTapGesture, + .threeFingerTapGesture, + in: &desiredValues + ) + + if !hasFourFingerGesture { + let didUpgradeHorizontal = upgradeSystemGestureIfNeeded( + from: [ + BuiltInTrackpad.threeFingerHorizontalSwipeGesture.identifier, + BluetoothTrackpad.threeFingerHorizontalSwipeGesture.identifier, + CurrentHostTrackpad.threeFingerHorizontalSwipeGesture.identifier + ], + to: [ + BuiltInTrackpad.fourFingerHorizontalSwipeGesture.identifier, + BluetoothTrackpad.fourFingerHorizontalSwipeGesture.identifier, + CurrentHostTrackpad.fourFingerHorizontalSwipeGesture.identifier + ], + backups: backups, + managedValues: managedValues, + desiredValues: &desiredValues + ) + + if didUpgradeHorizontal { + desiredValues[SystemPreferences.threeFingerDragFourFingerNavigate.identifier] = .missing + } + + let didUpgradeVertical = upgradeSystemGestureIfNeeded( + from: [ + BuiltInTrackpad.threeFingerVerticalSwipeGesture.identifier, + BluetoothTrackpad.threeFingerVerticalSwipeGesture.identifier, + CurrentHostTrackpad.threeFingerVerticalSwipeGesture.identifier + ], + to: [ + BuiltInTrackpad.fourFingerVerticalSwipeGesture.identifier, + BluetoothTrackpad.fourFingerVerticalSwipeGesture.identifier, + CurrentHostTrackpad.fourFingerVerticalSwipeGesture.identifier + ], + backups: backups, + managedValues: managedValues, + desiredValues: &desiredValues + ) + + if !didUpgradeVertical { + desiredValues[Dock.showMissionControlGestureEnabled.identifier] = .bool(false) + desiredValues[Dock.showAppExposeGestureEnabled.identifier] = .bool(false) + } + } + } + + if hasFourFingerGesture { + setTrackpadValue( + .int(0), + for: .threeFingerHorizontalSwipeGesture, + .threeFingerHorizontalSwipeGesture, + .threeFingerHorizontalSwipeGesture, + in: &desiredValues + ) + setTrackpadValue( + .int(0), + for: .fourFingerHorizontalSwipeGesture, + .fourFingerHorizontalSwipeGesture, + .fourFingerHorizontalSwipeGesture, + in: &desiredValues + ) + setTrackpadValue( + .int(0), + for: .fourFingerVerticalSwipeGesture, + .fourFingerVerticalSwipeGesture, + .fourFingerVerticalSwipeGesture, + in: &desiredValues + ) + setTrackpadValue( + .int(0), + for: .fourFingerPinchGesture, + .fourFingerPinchGesture, + .fourFingerPinchGesture, + in: &desiredValues + ) + } + + return desiredValues + } + + @discardableResult + private func upgradeSystemGestureIfNeeded( + from sourceIdentifiers: [SystemGesturePreferenceIdentifier], + to targetIdentifiers: [SystemGesturePreferenceIdentifier], + backups: [String: SystemGesturePreferenceValue], + managedValues: [String: SystemGesturePreferenceValue], + desiredValues: inout [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue] + ) -> Bool { + var didUpgrade = false + for (sourceIdentifier, targetIdentifier) in zip(sourceIdentifiers, targetIdentifiers) { + guard userOwnedValue(for: sourceIdentifier, backups: backups, managedValues: managedValues) == .int(2) else { + continue + } + desiredValues[targetIdentifier] = .int(2) + didUpgrade = true + } + return didUpgrade + } + + private func userOwnedValue( + for identifier: SystemGesturePreferenceIdentifier, + backups: [String: SystemGesturePreferenceValue], + managedValues: [String: SystemGesturePreferenceValue] + ) -> SystemGesturePreferenceValue { + if let backupValue = backups[identifier.compositeKey] { + return backupValue + } + + let currentValue = identifier.get() + if currentValue == managedValues[identifier.compositeKey] { + return .missing + } + + return currentValue + } + + private func setTrackpadValue( + _ value: SystemGesturePreferenceValue, + for builtInTrackpadKey: BuiltInTrackpad, + _ bluetoothTrackpadKey: BluetoothTrackpad, + _ currentHostTrackpadKey: CurrentHostTrackpad, + in desiredValues: inout [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue] + ) { + desiredValues[builtInTrackpadKey.identifier] = value + desiredValues[bluetoothTrackpadKey.identifier] = value + desiredValues[currentHostTrackpadKey.identifier] = value + } + + private func synchronize(domains: Set) { + for domain in domains { + if domain == CurrentHostTrackpad.domain { + CFPreferencesSynchronize( + kCFPreferencesAnyApplication, + kCFPreferencesCurrentUser, + kCFPreferencesCurrentHost + ) + } else { + SystemGesturePreferenceIdentifier.defaults(for: domain)?.synchronize() + } + } + } + + private static func restartDock() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + process.arguments = ["Dock"] + try? process.run() + } + + private static func activateSettings() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/System/Library/PrivateFrameworks/SystemAdministration.framework/Resources/activateSettings") + process.arguments = ["-u"] + try? process.run() + } +} + +struct SystemGesturePreferenceIdentifier: Hashable { + let domain: String + let key: String + + fileprivate enum ValueKind { + case bool + case int + } + + var compositeKey: String { + "\(domain).\(key)" + } + + fileprivate var isTrackpadDomain: Bool { + domain == SystemGestureManager.BuiltInTrackpad.domain || + domain == SystemGestureManager.BluetoothTrackpad.domain || + domain == SystemGestureManager.CurrentHostTrackpad.domain + } + + fileprivate var isDockDomain: Bool { + domain == SystemGestureManager.Dock.domain + } + + private var valueKind: ValueKind { + if domain == SystemGestureManager.Dock.domain || + domain == SystemGestureManager.SystemPreferences.domain { + return .bool + } + + if isTrackpadDomain, + let gesture = SystemGestureManager.TrackpadGesture(preferenceKey: key), + gesture == .threeFingerDrag { + return .bool + } + + return .int + } + + init(domain: String, key: String) { + self.domain = domain + self.key = key + } + + init?(compositeKey: String) { + let knownDomains = [ + SystemGestureManager.BuiltInTrackpad.domain, + SystemGestureManager.BluetoothTrackpad.domain, + SystemGestureManager.CurrentHostTrackpad.domain, + SystemGestureManager.Dock.domain, + SystemGestureManager.SystemPreferences.domain + ].sorted { $0.count > $1.count } + + for domain in knownDomains { + let prefix = "\(domain)." + guard compositeKey.hasPrefix(prefix) else { continue } + self.domain = domain + self.key = String(compositeKey.dropFirst(prefix.count)) + return + } + + let components = compositeKey.split(separator: ".", omittingEmptySubsequences: false) + guard components.count > 1 else { return nil } + + self.key = String(components.last!) + self.domain = components.dropLast().joined(separator: ".") + } + + func get() -> SystemGesturePreferenceValue { + let value: Any? = if domain == SystemGestureManager.CurrentHostTrackpad.domain { + CFPreferencesCopyValue( + key as CFString, + kCFPreferencesAnyApplication, + kCFPreferencesCurrentUser, + kCFPreferencesCurrentHost + ) + } else { + Self.defaults(for: domain)?.object(forKey: key) + } + + guard let value else { + return .missing + } + + guard let number = value as? NSNumber else { + return .missing + } + + switch valueKind { + case .bool: + return .bool(number.boolValue) + case .int: + return .int(number.intValue) + } + } + + func set(_ value: SystemGesturePreferenceValue) { + let value = normalized(value) + + if domain == SystemGestureManager.CurrentHostTrackpad.domain { + let valueToSet: CFPropertyList? = switch value { + case let .bool(value): + value as CFBoolean + case let .int(value): + value as CFNumber + case .missing: + nil + } + + CFPreferencesSetValue( + key as CFString, + valueToSet, + kCFPreferencesAnyApplication, + kCFPreferencesCurrentUser, + kCFPreferencesCurrentHost + ) + return + } + + let defaults = Self.defaults(for: domain) + + switch value { + case let .bool(value): + defaults?.set(value, forKey: key) + case let .int(value): + defaults?.set(value, forKey: key) + case .missing: + defaults?.removeObject(forKey: key) + } + } + + fileprivate func normalized(_ value: SystemGesturePreferenceValue) -> SystemGesturePreferenceValue { + switch (valueKind, value) { + case let (.bool, .int(value)): + .bool(value != 0) + case let (.int, .bool(value)): + .int(value ? 1 : 0) + default: + value + } + } + + func synchronize() { + if domain == SystemGestureManager.CurrentHostTrackpad.domain { + CFPreferencesSynchronize( + kCFPreferencesAnyApplication, + kCFPreferencesCurrentUser, + kCFPreferencesCurrentHost + ) + return + } + + Self.defaults(for: domain)?.synchronize() + } + + fileprivate static func defaults(for domain: String) -> UserDefaults? { + switch domain { + case SystemGestureManager.BuiltInTrackpad.domain: + SystemGestureManager.BuiltInTrackpad.defaults + case SystemGestureManager.BluetoothTrackpad.domain: + SystemGestureManager.BluetoothTrackpad.defaults + case SystemGestureManager.Dock.domain: + SystemGestureManager.Dock.defaults + case SystemGestureManager.SystemPreferences.domain: + SystemGestureManager.SystemPreferences.defaults + default: + UserDefaults(suiteName: domain) + } + } +} diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift new file mode 100644 index 00000000..12ca587b --- /dev/null +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -0,0 +1,800 @@ +// +// MultitouchTrigger.swift +// Loop +// +// Created by Kai Azim on 2026-01-30. +// + +import Defaults +import Scribe +import Subsurface +import SwiftUI + +@Loggable +@MainActor +final class MultitouchTrigger { + private let windowActionCache: WindowActionCache + private let openCallback: (WindowAction, Window) async throws -> () + private let closeCallback: (Bool) -> () + private let changeAction: (WindowAction, Bool) -> () + private let checkIfLoopOpen: () -> Bool + + private let gestureMonitor = SubsurfaceMonitor() + private let gestureBlocker: MultitouchGestureBlocker = .init() + + private var recognizers: [Int: RecognizerEntry] = [:] + private var gesturesObservationTask: Task<(), Never>? + private var systemGestureReconciliationTask: Task<(), Never>? + private var isStarted = false + + /// Window most recently targeted by a `canRepeat` gesture gesture. + /// Allows the user keep shrinking/growing a window after the cursor has fallen off its (now smaller) frame. + private var lastRepeatableWindow: Window? + + private let panCycleStepSize: CGFloat = 0.15 + private let zoomActivationThreshold: CGFloat = 0.3 + private let zoomCycleStepSize: CGFloat = 0.15 + + private var radialMenuActions: [RadialMenuAction] { + RadialMenuAction.userConfiguredActions + } + + private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) + + private enum ActionKey: Hashable { + case radialSlot(Int) + case radialCenter + case gesture(UUID) + } + + private struct GestureState { + var didOpenLoopWithThisGesture = false + var isGestureRejected = false + var hasActivated = false + var hasGestureBegun = false + /// The gesture currently driving this stroke. Swapped on direction reversal + var resolvedGesture: Gesture? + var pendingTargetWindow: Window? + var lastCommittedAction: ActionKey? + var lastCommitPanDistance: CGFloat = 0 + var lastCommitPinchDistance: CGFloat = 0 + var pinchDirection: Int = 0 + } + + /// Snapshot of the gestures that apply at this finger count, so handlers + /// don't fire actions against a gesture the user has just deleted. + private struct RecognizerEntry { + let recognizer: SubsurfaceGestureRecognizer + var task: Task<(), Never>? + var state: GestureState + var radialMenuGesture: Gesture? + var directionalGestures: [Gesture] + var pinchGesture: Gesture? + var spreadGesture: Gesture? + + static func categorize( + _ gestures: [Gesture] + ) -> (radial: Gesture?, directionals: [Gesture], pinch: Gesture?, spread: Gesture?) { + let radial = gestures.first { $0.kind == .radialMenu } + let directionals = gestures.filter(\.kind.isDirectionalPan) + let pinch = gestures.first { $0.kind == .pinch } + let spread = gestures.first { $0.kind == .spread } + return (radial, directionals, pinch, spread) + } + } + + init( + windowActionCache: WindowActionCache, + openCallback: @escaping (WindowAction, Window) async throws -> (), + closeCallback: @escaping (Bool) -> (), + changeAction: @escaping (WindowAction, Bool) -> (), + checkIfLoopOpen: @escaping () -> Bool + ) { + self.windowActionCache = windowActionCache + self.openCallback = openCallback + self.closeCallback = closeCallback + self.changeAction = changeAction + self.checkIfLoopOpen = checkIfLoopOpen + + prepare() + } + + func prepare() { + reconcileSystemGestures() + startSystemGestureReconciliation() + } + + func start() { + guard !isStarted else { return } + isStarted = true + + prepare() + gestureMonitor.start() + rebuildRecognizers() + + gesturesObservationTask = Task { [weak self] in + // Watch keybinds too, so gestures referencing a deleted or no-action keybind get filtered out by `rebuildRecognizers` + for await _ in Defaults.updates(.gestures, .keybinds) { + guard !Task.isCancelled, let self else { break } + rebuildRecognizers() + } + } + } + + func stop() { + guard isStarted else { return } + isStarted = false + + reconcileSystemGestures() + gesturesObservationTask?.cancel() + gesturesObservationTask = nil + + for fingerCount in Array(recognizers.keys) { + stopRecognizer(for: fingerCount) + } + recognizers.removeAll() + + gestureMonitor.stop() + lastRepeatableWindow = nil + } + + func shutdown() { + stop() + systemGestureReconciliationTask?.cancel() + systemGestureReconciliationTask = nil + SystemGestureManager.restore() + } + + private func startSystemGestureReconciliation() { + guard systemGestureReconciliationTask == nil else { return } + + systemGestureReconciliationTask = Task(priority: .background) { [weak self] in + let updates = Defaults.updates( + .enableGestures, + .disableConflictingSystemGestures, + .gestures + ) + + for await _ in updates { + guard !Task.isCancelled, let self else { break } + reconcileSystemGestures() + } + } + } + + private nonisolated func reconcileSystemGestures() { + SystemGestureManager.reconcile( + enableGestures: Defaults[.enableGestures], + disableConflicts: Defaults[.disableConflictingSystemGestures], + gestures: Defaults[.gestures] + ) + } + + private func rebuildRecognizers() { + let allGestures = Defaults[.gestures] + let conflictingIDs = Gesture.conflictingIDs(in: allGestures) + let activeGestures = allGestures.filter { !conflictingIDs.contains($0.id) && !$0.isDisabled } + let gesturesByFingerCount = Dictionary(grouping: activeGestures, by: \.fingerCount) + let neededFingerCounts = Set(gesturesByFingerCount.keys) + + // Remove stale recognizers + for fingerCount in Array(recognizers.keys) where !neededFingerCounts.contains(fingerCount) { + stopRecognizer(for: fingerCount) + recognizers.removeValue(forKey: fingerCount) + } + + // Add new recognizers or refresh cached gestures on existing ones. + for (fingerCount, gestures) in gesturesByFingerCount { + let (radial, directionals, pinch, spread) = RecognizerEntry.categorize(gestures) + if recognizers[fingerCount] == nil { + startRecognizer(for: fingerCount, radial: radial, directionals: directionals, pinch: pinch, spread: spread) + } else { + recognizers[fingerCount]?.radialMenuGesture = radial + recognizers[fingerCount]?.directionalGestures = directionals + recognizers[fingerCount]?.pinchGesture = pinch + recognizers[fingerCount]?.spreadGesture = spread + } + } + } + + private func startRecognizer( + for fingerCount: Int, + radial: Gesture?, + directionals: [Gesture], + pinch: Gesture?, + spread: Gesture? + ) { + let recognizer = SubsurfaceGestureRecognizer(fingerCount: fingerCount) + recognizers[fingerCount] = RecognizerEntry( + recognizer: recognizer, + task: nil, + state: GestureState(), + radialMenuGesture: radial, + directionalGestures: directionals, + pinchGesture: pinch, + spreadGesture: spread + ) + + let task = Task { [weak self] in + guard let self else { return } + for await event in recognizer.events(from: gestureMonitor) { + guard !Task.isCancelled else { break } + switch event { + case let .pan(pan): + await handlePan(pan, fingerCount: fingerCount) + case let .pinch(pinch): + await handlePinch(pinch, fingerCount: fingerCount) + case .determining, .rotation: + break + } + } + } + recognizers[fingerCount]?.task = task + } + + private func stopRecognizer(for fingerCount: Int) { + guard let entry = recognizers[fingerCount] else { return } + entry.task?.cancel() + entry.recognizer.reset() + if entry.state.didOpenLoopWithThisGesture { + closeCallback(false) + } + gestureBlocker.stop() + } + + private func handlePan(_ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int) async { + guard let entry = recognizers[fingerCount] else { return } + + if let radialMenuGesture = entry.radialMenuGesture { + await handleRadialMenuPan(pan, fingerCount: fingerCount, gesture: radialMenuGesture) + } else if let directionalGesture = matchDirectionalPanGesture(angle: pan.angle, from: entry.directionalGestures) { + await handleDirectionalPan(pan, fingerCount: fingerCount, gesture: directionalGesture) + } + } + + private func handleRadialMenuPan( + _ pan: SubsurfaceGestureEvent.PanEvent, + fingerCount: Int, + gesture: Gesture + ) async { + switch pan.phase { + case .began, .changed: + if pan.phase == .began { + handleGestureBegan(fingerCount: fingerCount, gesture: gesture) + } + guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + + // Subsurface emits y-up angles (counterclockwise from +x); the radial + // menu wants 0 = up, growing clockwise. Mirror via `pi/2 - angle`. + let angleFromOrigin = .pi / 2 - pan.angle + var normalizedAngle = angleFromOrigin + if normalizedAngle < 0 { normalizedAngle += 2 * .pi } + + let actions = radialMenuActions.dropLast() + guard actions.count > 1 else { return } + + let newIndex: Int + if actions.count == 8 { + newIndex = indexWithCardinalBias(angle: normalizedAngle, actionCount: actions.count) + } else { + let actionAngleSpan = (.pi * 2) / CGFloat(actions.count) + let halfAngleSpan = actionAngleSpan / 2.0 + newIndex = Int((normalizedAngle + halfAngleSpan) / actionAngleSpan) % actions.count + } + + commitPan( + &state, + distance: pan.distance, + newKey: .radialSlot(newIndex), + fingerCount: fingerCount + ) { reverse in + triggerRadialMenuAction(at: newIndex, from: actions, reverse: reverse) + } + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + private func handleDirectionalPan( + _ pan: SubsurfaceGestureEvent.PanEvent, + fingerCount: Int, + gesture: Gesture + ) async { + guard let entry = recognizers[fingerCount] else { return } + + switch pan.phase { + case .began, .changed: + if pan.phase == .began { + handleGestureBegan(fingerCount: fingerCount, gesture: gesture) + recognizers[fingerCount]?.state.resolvedGesture = gesture + } + guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + + let activeGesture = state.resolvedGesture ?? gesture + + if panReversalDetected(state, distance: pan.distance) { + handlePanReversal( + fingerCount: fingerCount, + currentGesture: activeGesture, + oppositeGesture: oppositeDirectionalPanGesture(of: activeGesture, in: entry.directionalGestures), + distance: pan.distance + ) + return + } + + commitPan( + &state, + distance: pan.distance, + newKey: .gesture(activeGesture.id), + fingerCount: fingerCount + ) { reverse in + triggerSingleAction(from: activeGesture, reverse: reverse) + } + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + private func handlePinch(_ pinch: SubsurfaceGestureEvent.PinchEvent, fingerCount: Int) async { + guard let entry = recognizers[fingerCount] else { return } + + // Radial menu pinch triggers the center action regardless of direction + if let radialMenuGesture = entry.radialMenuGesture { + await handleRadialMenuPinch(pinch, fingerCount: fingerCount, gesture: radialMenuGesture) + return + } + + switch pinch.phase { + case .began: + // Direction unknown, reset state but defer handleGestureBegan until first .changed + recognizers[fingerCount]?.state = GestureState() + + case .changed: + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + + if !state.hasGestureBegun { + let initialGesture = pinch.distance >= pinch.originDistance ? entry.spreadGesture : entry.pinchGesture + guard let initialGesture else { + state.isGestureRejected = true + recognizers[fingerCount]?.state = state + return + } + handleGestureBegan(fingerCount: fingerCount, gesture: initialGesture) + recognizers[fingerCount]?.state.hasGestureBegun = true + recognizers[fingerCount]?.state.resolvedGesture = initialGesture + } + + guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected, + let activeGesture = state.resolvedGesture else { return } + guard await activateGestureIfNeeded( + fingerCount: fingerCount, + pinchDisplacement: pinch.distance - pinch.originDistance + ) else { return } + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + + if pinchReversalDetected(state, distance: pinch.distance) { + let opposite = activeGesture.kind == .pinch ? entry.spreadGesture : entry.pinchGesture + handlePinchReversal( + fingerCount: fingerCount, + currentGesture: activeGesture, + oppositeGesture: opposite, + distance: pinch.distance + ) + return + } + + commitPinch( + &state, + distance: pinch.distance, + originDistance: pinch.originDistance, + newKey: .gesture(activeGesture.id), + fingerCount: fingerCount + ) { reverse in + triggerSingleAction(from: activeGesture, reverse: reverse) + } + + if let window = recognizers[fingerCount]?.state.pendingTargetWindow, + resolvedWindowAction(from: activeGesture)?.canRepeat == true { + lastRepeatableWindow = window + } + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + /// Pinch within a radial menu gesture, triggers the center (last) radial menu action. + private func handleRadialMenuPinch( + _ pinch: SubsurfaceGestureEvent.PinchEvent, + fingerCount: Int, + gesture: Gesture + ) async { + switch pinch.phase { + case .began, .changed: + if pinch.phase == .began { + handleGestureBegan(fingerCount: fingerCount, gesture: gesture) + } + guard await activateGestureIfNeeded( + fingerCount: fingerCount, + pinchDisplacement: pinch.distance - pinch.originDistance + ) else { return } + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + + let actions = radialMenuActions + guard !actions.isEmpty else { return } + let centerActionIndex = actions.count - 1 + + commitRadialPinch( + &state, + distance: pinch.distance, + originDistance: pinch.originDistance, + fingerCount: fingerCount + ) { reverse in + triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: reverse) + } + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + /// Resolves the target window and starts blocking trackpad events. Loop itself + /// isn't opened until the gesture crosses the activation threshold in a `.changed` event. + private func handleGestureBegan(fingerCount: Int, gesture: Gesture) { + var window = findTargetWindow(for: gesture) + if window == nil, resolvedWindowAction(from: gesture)?.canRepeat == true { + window = lastRepeatableWindow + } + + let loopWasAlreadyOpen = checkIfLoopOpen() + + guard window != nil || loopWasAlreadyOpen else { + recognizers[fingerCount]?.state.isGestureRejected = true + return + } + + if let window, resolvedWindowAction(from: gesture)?.canRepeat == true { + lastRepeatableWindow = window + } + + var state = GestureState() + state.pendingTargetWindow = window + // Loop is already on screen, so no activation threshold to cross. + state.hasActivated = loopWasAlreadyOpen + recognizers[fingerCount]?.state = state + gestureBlocker.start() + } + + /// Opens Loop on the target window resolved at `.began`. Pinch and spread gestures + /// gate on their respective activation thresholds; pan gestures activate on the first + /// `.began` event Subsurface emits. + private func activateGestureIfNeeded( + fingerCount: Int, + pinchDisplacement: CGFloat? = nil + ) async -> Bool { + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return false } + if state.hasActivated { return true } + + if let pinchDisplacement, abs(pinchDisplacement) < zoomActivationThreshold { + return false + } + + if let window = state.pendingTargetWindow { + do { + try await openCallback(.init(.noSelection), window) + state.didOpenLoopWithThisGesture = true + } catch { + state.isGestureRejected = true + recognizers[fingerCount]?.state = state + gestureBlocker.stop() + return false + } + } + + state.hasActivated = true + recognizers[fingerCount]?.state = state + return true + } + + private func resetLoopState(for fingerCount: Int) { + if recognizers[fingerCount]?.state.didOpenLoopWithThisGesture == true { + closeCallback(false) + } + + gestureBlocker.stop() + recognizers[fingerCount]?.state = GestureState() + } + + private func findTargetWindow(for gesture: Gesture) -> Window? { + let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) + + guard let window = WindowUtility.windowAtPosition(cursorPosition) else { + return nil + } + + // 2-finger gestures are always titlebar-only to avoid system gesture conflicts. + switch gesture.fingerCount <= 2 ? .titlebar : gesture.activationZone { + case .titlebar: + let minimumTitlebarHeight = Defaults[.gestureTitlebarHeight] + + let titlebarHeight: CGFloat = if #available(macOS 26.0, *), + let cornerRadius = SkyLightToolBelt.getCornerRadii(windowID: window.cgWindowID)?.topLeading { + max(2 * cornerRadius, minimumTitlebarHeight) + } else { + minimumTitlebarHeight + } + + log.info("Detected titlebar height of \(titlebarHeight)") + + let titlebarMinY = window.frame.minY + let titlebarMaxY = window.frame.minY + titlebarHeight + let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY + return isInTitlebar ? window : nil + + case .anywhere: + return window + } + } + + /// Commit distance only advances when an action fires, so sub-step + /// jitter can't drift it past the reverse threshold. Activation is gated + /// upstream by `activateGestureIfNeeded`, so the first commit fires + /// immediately to seed Loop's initial active action. + private func commitPan( + _ state: inout GestureState, + distance: CGFloat, + newKey: ActionKey, + fingerCount: Int, + fire: (_ reverse: Bool) -> () + ) { + if state.lastCommittedAction == newKey { + let delta = distance - state.lastCommitPanDistance + if delta >= panCycleStepSize { + state.lastCommitPanDistance = distance + recognizers[fingerCount]?.state = state + fire(false) + } else if delta <= -panCycleStepSize { + state.lastCommitPanDistance = distance + recognizers[fingerCount]?.state = state + fire(true) + } + } else { + state.lastCommittedAction = newKey + state.lastCommitPanDistance = distance + recognizers[fingerCount]?.state = state + fire(false) + } + } + + private func commitRadialPinch( + _ state: inout GestureState, + distance: CGFloat, + originDistance: CGFloat, + fingerCount: Int, + fire: (_ reverse: Bool) -> () + ) { + if state.lastCommittedAction != .radialCenter { + state.lastCommittedAction = .radialCenter + state.lastCommitPinchDistance = distance + recognizers[fingerCount]?.state = state + fire(distance < originDistance) + return + } + + let delta = distance - state.lastCommitPinchDistance + if delta >= zoomCycleStepSize { + state.lastCommitPinchDistance = distance + recognizers[fingerCount]?.state = state + fire(false) + } else if delta <= -zoomCycleStepSize { + state.lastCommitPinchDistance = distance + recognizers[fingerCount]?.state = state + fire(true) + } + } + + private func commitPinch( + _ state: inout GestureState, + distance: CGFloat, + originDistance: CGFloat, + newKey: ActionKey, + fingerCount: Int, + fire: (_ reverse: Bool) -> () + ) { + if state.lastCommittedAction != newKey { + state.pinchDirection = distance >= originDistance ? 1 : -1 + state.lastCommittedAction = newKey + state.lastCommitPinchDistance = distance + recognizers[fingerCount]?.state = state + fire(false) + return + } + + let delta = (distance - state.lastCommitPinchDistance) * CGFloat(state.pinchDirection) + if delta >= zoomCycleStepSize { + state.lastCommitPinchDistance = distance + recognizers[fingerCount]?.state = state + fire(false) + } else if delta <= -zoomCycleStepSize { + state.lastCommitPinchDistance = distance + recognizers[fingerCount]?.state = state + fire(true) + } + } + + private func panReversalDetected(_ state: GestureState, distance: CGFloat) -> Bool { + state.lastCommittedAction != nil + && (distance - state.lastCommitPanDistance) <= -panCycleStepSize + } + + private func pinchReversalDetected(_ state: GestureState, distance: CGFloat) -> Bool { + guard state.lastCommittedAction != nil, state.pinchDirection != 0 else { return false } + let delta = (distance - state.lastCommitPinchDistance) * CGFloat(state.pinchDirection) + return delta <= -zoomCycleStepSize + } + + private func oppositeDirectionalPanGesture( + of current: Gesture, + in directionals: [Gesture] + ) -> Gesture? { + let opposite: Gesture.Kind? = switch current.kind { + case .panUp: .panDown + case .panDown: .panUp + case .panLeft: .panRight + case .panRight: .panLeft + default: nil + } + guard let opposite else { return nil } + return directionals.first { $0.kind == opposite } + } + + private func isCycleAction(_ gesture: Gesture) -> Bool { + resolvedWindowAction(from: gesture)?.direction == .cycle + } + + private func handlePanReversal( + fingerCount: Int, + currentGesture: Gesture, + oppositeGesture: Gesture?, + distance: CGFloat + ) { + if let oppositeGesture { + guard var state = recognizers[fingerCount]?.state else { return } + state.resolvedGesture = oppositeGesture + state.lastCommittedAction = .gesture(oppositeGesture.id) + state.lastCommitPanDistance = distance + recognizers[fingerCount]?.state = state + triggerSingleAction(from: oppositeGesture, reverse: false) + + if let window = state.pendingTargetWindow, + resolvedWindowAction(from: oppositeGesture)?.canRepeat == true { + lastRepeatableWindow = window + } + } else if isCycleAction(currentGesture) { + triggerSingleAction(from: currentGesture, reverse: true) + recognizers[fingerCount]?.state.lastCommitPanDistance = distance + } + } + + private func handlePinchReversal( + fingerCount: Int, + currentGesture: Gesture, + oppositeGesture: Gesture?, + distance: CGFloat + ) { + if let oppositeGesture { + guard var state = recognizers[fingerCount]?.state else { return } + // Direction is fixed by the new gesture's gesture type, not by current finger distance, + // since the user may not yet have crossed neutral when reversing + let direction = oppositeGesture.kind == .spread ? 1 : -1 + state.resolvedGesture = oppositeGesture + state.lastCommittedAction = .gesture(oppositeGesture.id) + state.pinchDirection = direction + state.lastCommitPinchDistance = distance + recognizers[fingerCount]?.state = state + triggerSingleAction(from: oppositeGesture, reverse: false) + + if let window = state.pendingTargetWindow, + resolvedWindowAction(from: oppositeGesture)?.canRepeat == true { + lastRepeatableWindow = window + } + } else if isCycleAction(currentGesture) { + triggerSingleAction(from: currentGesture, reverse: true) + recognizers[fingerCount]?.state.lastCommitPinchDistance = distance + } + } + + private func triggerRadialMenuAction(at index: Int, from actions: ArraySlice, reverse: Bool = false) { + guard actions.indices.contains(index) else { return } + let action = actions[index] + + let resolvedAction: WindowAction = switch action.type { + case let .custom(windowAction): + windowAction + case let .keybindReference(id): + resolveKeybindReference(id) + } + + changeAction(resolvedAction, reverse) + } + + private func resolvedWindowAction(from gesture: Gesture) -> WindowAction? { + guard case let .singleAction(actionType) = gesture.action else { return nil } + switch actionType { + case let .custom(action): return action + case let .keybindReference(id): return resolveKeybindReference(id) + } + } + + private func triggerSingleAction(from gesture: Gesture, reverse: Bool = false) { + guard let resolvedAction = resolvedWindowAction(from: gesture) else { return } + changeAction(resolvedAction, reverse) + } + + private func resolveKeybindReference(_ id: UUID) -> WindowAction { + if let cached = windowActionCache.actionsByIdentifier[id] { + return cached + } + log.warn("Gesture references keybind \(id) that no longer exists") + return Self.failedToResolveKeybindAction + } + + private func matchDirectionalPanGesture(angle: CGFloat, from gestures: [Gesture]) -> Gesture? { + let angleFromOrigin = .pi / 2 - angle + var normalizedAngle = angleFromOrigin + if normalizedAngle < 0 { normalizedAngle += 2 * .pi } + + let direction: Gesture.Kind = if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { + .panUp + } else if normalizedAngle >= .pi / 4, normalizedAngle < 3 * .pi / 4 { + .panRight + } else if normalizedAngle >= 3 * .pi / 4, normalizedAngle < 5 * .pi / 4 { + .panDown + } else { + .panLeft + } + + return gestures.first { $0.kind == direction } + } + + private func indexWithCardinalBias(angle: CGFloat, actionCount: Int, cardinalBias: CGFloat = 0.1) -> Int { + let baseAngleSpan = (.pi * 2) / CGFloat(actionCount) + let halfAngleSpan = baseAngleSpan / 2.0 + + let adjustedAngle = (angle + halfAngleSpan).truncatingRemainder(dividingBy: .pi * 2) + let rawSegment = Int(adjustedAngle / baseAngleSpan) % actionCount + + let segmentAngle = adjustedAngle.truncatingRemainder(dividingBy: baseAngleSpan) + let normalizedPosition = segmentAngle / baseAngleSpan + + let isCurrentCardinal = rawSegment % 2 == 0 + + if isCurrentCardinal { + return rawSegment + } else { + if normalizedPosition < cardinalBias / 2 { + return (rawSegment - 1 + actionCount) % actionCount + } else if normalizedPosition > 1.0 - cardinalBias / 2 { + return (rawSegment + 1) % actionCount + } else { + return rawSegment + } + } + } +} diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 64a70394..ae6c03fd 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -70,6 +70,13 @@ extension Defaults.Keys { static let cycleBackwardsOnShiftPressed = Key("cycleBackwardsOnShiftPressed", default: true, iCloud: true) static let keybinds = Key<[WindowAction]>("keybinds", default: WindowAction.defaultKeybinds, iCloud: true) + // Gestures + static let enableGestures = Key("enableGestures", default: false, iCloud: true) + static let gestures = Key<[Gesture]>("gestures", default: Gesture.defaults, iCloud: true) + static let disableConflictingSystemGestures = Key("disableConflictingSystemGestures", default: true, iCloud: true) + static let systemGesturePreferenceBackups = Key<[String: SystemGesturePreferenceValue]>("systemGesturePreferenceBackups", default: [:], iCloud: false) + static let systemGestureManagedValues = Key<[String: SystemGesturePreferenceValue]>("systemGestureManagedValues", default: [:], iCloud: false) + // Advanced static let useSystemWindowManagerWhenAvailable = Key("useSystemWindowManagerWhenAvailable", default: false, iCloud: true) static let animateWindowResizes = Key("animateWindowResizes", default: false, iCloud: true) @@ -141,6 +148,12 @@ extension Defaults.Keys { /// Reset with `defaults delete com.MrKai77.Loop triggerKeyTimeout` static let triggerKeyTimeout = Key("triggerKeyTimeout", default: 0, iCloud: true) + /// Height of the titlebar activation zone for gestures, defined in points. + /// Gestures with the `.titlebar` activation zone will only trigger when the cursor is within this distance from the top of a window. + /// Adjust with `defaults write com.MrKai77.Loop gestureTitlebarHeight -float x` + /// Reset with `defaults delete com.MrKai77.Loop gestureTitlebarHeight` + static let gestureTitlebarHeight = Key("gestureTitlebarHeight", default: 32, iCloud: true) + // Migrator static let lastMigratorURL = Key("lastMigratorURL", default: nil) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 0f72a46a..c108415f 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, "%" : { "comment" : "Unit symbol: percentage", "localizations" : { @@ -621,6 +624,23 @@ } } }, + "%lld-finger %@" : { + "comment" : "Label describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Pinch', 'Swipe Up').", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld-finger %2$@" + } + } + } + }, + "%lld-finger Swipe, Pinch, or Spread" : { + "comment" : "Label describing how to activate a radial menu gesture. Argument is the finger count." + }, + "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures." : { + "comment" : "Help text shown when the activation zone picker is disabled for 2-finger gestures" + }, "A single %@ action can only track one window. To stash\nmultiple windows, add additional %@ actions." : { "comment" : "Information in a popover displaying how a stash action can only keep track of a single window. Both %1$@ and %2$@ are replaced with the language's localization of the \"Stash\" action.", "localizations" : { @@ -1152,8 +1172,11 @@ } } }, + "Activation Zone" : { + "comment" : "Label for the activation zone picker in the gesture configuration popover" + }, "Add" : { - "comment" : "Used to add items to a list", + "comment" : "Button to add a new gesture", "localizations" : { "ar" : { "stringUnit" : { @@ -1860,6 +1883,9 @@ } } }, + "Anywhere" : { + "comment" : "Gesture activation zone covers the entire window" + }, "App icon is locked" : { "localizations" : { "ar" : { @@ -4964,7 +4990,7 @@ } }, "Customize this action's custom frame." : { - "comment" : "A help text describing the customization option for a custom-direction action.", + "comment" : "Help text on the slider icon next to a gesture action with a custom frame", "localizations" : { "ar" : { "stringUnit" : { @@ -5052,6 +5078,12 @@ } } }, + "Customize this gesture." : { + "comment" : "Help text shown when hovering a gesture configuration button" + }, + "Customize this gesture's action." : { + "comment" : "Help text shown when hovering a gesture's action button" + }, "Customize this keybind's action." : { "localizations" : { "ar" : { @@ -5141,7 +5173,7 @@ } }, "Customize what this action cycles through." : { - "comment" : "A description for a button that appears when configuring a cycling window action.", + "comment" : "Help text on the cycle icon next to a gesture action that cycles", "localizations" : { "ar" : { "stringUnit" : { @@ -5850,6 +5882,9 @@ } } }, + "Disable conflicting system gestures" : { + "comment" : "Toggle in gestures settings" + }, "Disable cursor interaction" : { "localizations" : { "ar" : { @@ -6291,6 +6326,9 @@ } } }, + "Enable gestures" : { + "comment" : "Toggle in gestures settings" + }, "Enable window snapping" : { "comment" : "A label for a toggle that enables or disables window snapping. The text is presented as a popover when the toggle is hovered over.", "localizations" : { @@ -6999,6 +7037,9 @@ } } }, + "Fingers" : { + "comment" : "Label for the finger-count stepper in the gesture configuration popover" + }, "First Fourth" : { "comment" : "Window action", "localizations" : { @@ -8066,6 +8107,12 @@ } } }, + "Gesture Type" : { + "comment" : "Label in the gesture configuration popover" + }, + "Gestures" : { + "comment" : "Section header shown in gestures settings" + }, "Go Back" : { "comment" : "Section header in the action picker of the Keybinds tab", "localizations" : { @@ -18340,7 +18387,7 @@ } }, "No Action" : { - "comment" : "Window action: no selection", + "comment" : "Label shown for a gesture with no configured action\nWindow action: no selection", "localizations" : { "ar" : { "stringUnit" : { @@ -18516,6 +18563,9 @@ } } }, + "No gestures" : { + "comment" : "Empty state title in gestures settings" + }, "No keybinds" : { "localizations" : { "ar" : { @@ -23270,6 +23320,9 @@ } } }, + "Open Radial Menu" : { + "comment" : "Label shown for a gesture configured to open the radial menu" + }, "Options" : { "comment" : "Section header shown in settings", "localizations" : { @@ -23625,6 +23678,9 @@ } } }, + "Pinch" : { + "comment" : "Gesture kind: fingers move together" + }, "Please include at least one modifier key." : { "comment" : "An error message displayed when a custom keybind is created but does not include at least one modifier key.", "localizations" : { @@ -23978,6 +24034,9 @@ } } }, + "Press \"Add\" to add a gesture" : { + "comment" : "Empty state subtitle in gestures settings" + }, "Press \"Add\" to add a keybind" : { "localizations" : { "ar" : { @@ -24865,7 +24924,7 @@ } }, "Radial Menu" : { - "comment" : "Section header shown in settings", + "comment" : "Gesture kind: opens the radial menu via swipe/pinch/spread\nSection header shown in settings", "localizations" : { "ar" : { "stringUnit" : { @@ -25220,7 +25279,7 @@ } }, "Remove" : { - "comment" : "Used to remove items from a list", + "comment" : "Button to remove selected gestures", "localizations" : { "ar" : { "stringUnit" : { @@ -27874,6 +27933,19 @@ } } }, + "Settings tab: Gestures" : { + "comment" : "Title of the gestures settings tab.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Gestures" + } + } + } + }, "Settings tab: Icon" : { "localizations" : { "ar" : { @@ -29998,6 +30070,9 @@ } } }, + "Spread" : { + "comment" : "Gesture kind: fingers move apart" + }, "Stage Manager" : { "comment" : "Section header shown in settings", "localizations" : { @@ -30530,6 +30605,18 @@ } } }, + "Swipe Down" : { + "comment" : "Gesture kind: directional swipe" + }, + "Swipe Left" : { + "comment" : "Gesture kind: directional swipe" + }, + "Swipe Right" : { + "comment" : "Gesture kind: directional swipe" + }, + "Swipe Up" : { + "comment" : "Gesture kind: directional swipe" + }, "Sync Wallpaper" : { "localizations" : { "ar" : { @@ -31059,6 +31146,9 @@ } } }, + "There are other gestures that conflict with this gesture." : { + "comment" : "Tooltip shown on a conflicting gesture in settings" + }, "There are other keybinds that conflict with this key combination." : { "localizations" : { "ar" : { @@ -31680,6 +31770,9 @@ } } }, + "Titlebar Only" : { + "comment" : "Gesture activation zone restricted to a window's titlebar" + }, "To save power, window animations are\nunavailable in Low Power Mode." : { "localizations" : { "ar" : { @@ -35583,5 +35676,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift new file mode 100644 index 00000000..f2dbd97f --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift @@ -0,0 +1,83 @@ +// +// GestureConfigPopoverView.swift +// Loop +// +// Created by Kai Azim on 2026-04-16. +// + +import Luminare +import SwiftUI + +struct GestureConfigPopoverView: View { + @State private var gesture: Gesture + @Binding private var externalGesture: Gesture + + init(gesture: Binding) { + self.gesture = gesture.wrappedValue + self._externalGesture = gesture + } + + private var kindBinding: Binding { + Binding( + get: { gesture.kind }, + set: { newKind in + let oldKind = gesture.kind + gesture.kind = newKind + + if newKind == .radialMenu { + gesture.action = .radialMenuActions + } else if oldKind == .radialMenu { + gesture.action = .singleAction(.custom(.init(.noAction))) + } + } + ) + } + + var body: some View { + LuminareSection { + LuminareCompose(String(localized: "Gesture Type", comment: "Label in the gesture configuration popover")) { + Picker("", selection: kindBinding) { + ForEach(Array(Gesture.Kind.allCases.enumerated()), id: \.element) { _, kind in + HStack { + kind.image + .frame(width: 12) + + Text(kind.displayName) + } + .tag(kind) + } + } + .labelsHidden() + } + + LuminareCompose(String(localized: "Fingers", comment: "Label for the finger-count stepper in the gesture configuration popover")) { + HStack { + Text("\(gesture.fingerCount)") + + Stepper("", value: $gesture.fingerCount, in: 2...5) + .labelsHidden() + .onChange(of: gesture.fingerCount) { count in + if count <= 2 { gesture.activationZone = .titlebar } + } + } + } + + LuminareCompose(String(localized: "Activation Zone", comment: "Label for the activation zone picker in the gesture configuration popover")) { + Picker("", selection: $gesture.activationZone) { + ForEach(Gesture.ActivationZone.allCases, id: \.self) { zone in + Label(zone.displayName, systemImage: zone.systemImage) + .tag(zone) + } + } + .labelsHidden() + .disabled(gesture.fingerCount <= 2) + } + .help(gesture.fingerCount <= 2 ? String(localized: "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures.", comment: "Help text shown when the activation zone picker is disabled for 2-finger gestures") : "") + } + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: gesture) { externalGesture = $0 } + .luminareFilledStates(.none) + .luminareBorderedStates(.none) + .padding(8) + } +} diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift new file mode 100644 index 00000000..c3aeb6be --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -0,0 +1,278 @@ +// +// GestureItemView.swift +// Loop +// +// Created by Kai Azim on 2026-04-16. +// + +import Defaults +import Luminare +import SwiftUI + +struct GestureItemView: View { + @Environment(\.luminareAnimation) var luminareAnimation + + @Default(.keybinds) private var keybinds + @Default(.gestures) private var gestures + + @State private var gesture: Gesture + @Binding private var externalGesture: Gesture + + @State private var isActionPickerPresented = false + @State private var isGestureConfigPresented = false + @State private var isConfiguringCustom = false + @State private var isConfiguringCycle = false + + init(_ gesture: Binding) { + self.gesture = gesture.wrappedValue + self._externalGesture = gesture + } + + private var hasConflict: Bool { + Gesture.conflictingIDs(in: gestures).contains(gesture.id) + } + + private var isDisabled: Bool { + gesture.isDisabled + } + + private var gestureConfigurationText: String { + switch gesture.kind { + case .radialMenu: + String( + localized: "\(gesture.fingerCount)-finger Swipe, Pinch, or Spread", + comment: "Label describing how to activate a radial menu gesture. Argument is the finger count." + ) + default: + String( + localized: "\(gesture.fingerCount)-finger \(gesture.kind.displayName)", + comment: "Label describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Pinch', 'Swipe Up')." + ) + } + } + + private var resolvedAction: WindowAction? { + switch gesture.action { + case .radialMenuActions: + nil + case let .singleAction(actionType): + actionType.resolvedAction + } + } + + private var actionTypeBinding: Binding { + Binding( + get: { + if case let .singleAction(actionType) = gesture.action { + return actionType + } + return .custom(.init(.noAction)) + }, + set: { newValue in + gesture.action = .singleAction(newValue) + } + ) + } + + private var actionBinding: Binding { + Binding( + get: { + resolvedAction ?? .init(.noAction) + }, + set: { newAction in + if case let .singleAction(actionType) = gesture.action { + switch actionType { + case .custom: + gesture.action = .singleAction(.custom(newAction)) + case let .keybindReference(id): + if let index = Defaults[.keybinds].firstIndex(where: { $0.id == id }) { + keybinds[index] = newAction + } + } + } + } + ) + } + + var body: some View { + ZStack { + Group { + if hasConflict { + gestureConfiguration + .luminareTint(overridingWith: .red) + } else { + gestureConfiguration + } + } + .luminareToolTip(attachedTo: .topLeading, hidden: !hasConflict) { + Text(String(localized: "There are other gestures that conflict with this gesture.", comment: "Tooltip shown on a conflicting gesture in settings")) + .padding(6) + } + .frame(maxWidth: .infinity, alignment: .leading) + + actionSelection + .frame(maxWidth: .infinity, alignment: .trailing) + } + .opacity(isDisabled ? 0.5 : 1) + .padding(.horizontal, 12) + .onChange(of: resolvedAction?.direction) { _ in + if resolvedAction?.direction.isCustomizable == true { + isConfiguringCustom = true + } + if resolvedAction?.direction == .cycle { + isConfiguringCycle = true + } + } + .onChange(of: gesture) { externalGesture = $0 } + } + + private var gestureConfiguration: some View { + Button { + isGestureConfigPresented = true + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(gestureConfigurationText) + .fontWeight(.regular) + .lineLimit(1) + + Text(gesture.activationZone.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 4) + .contentShape(.rect) + } + .luminareContentSize(contentMode: .fit, hasFixedHeight: true) + .luminareRoundingBehavior(top: true, bottom: true) + .luminareFilledStates([.hovering, .pressed]) + .luminareBorderedStates(.hovering) + .luminareMinHeight(24) + .opacity(hasConflict ? 0.5 : 1) + .help(String(localized: "Customize this gesture.", comment: "Help text shown when hovering a gesture configuration button")) + .padding(.leading, -4) + .luminarePopover( + isPresented: $isGestureConfigPresented, + arrowEdge: .top, + attachmentAnchor: .topLeading, + shouldHideAnchor: true, + shouldAnimate: false + ) { + GestureConfigPopoverView(gesture: $gesture) + .frame(width: 300) + } + } + + private var actionSelection: some View { + actionIndicator + .luminarePopover( + isPresented: $isActionPickerPresented, + arrowEdge: .top, + attachmentAnchor: .topTrailing, + shouldHideAnchor: true, + shouldAnimate: false + ) { + RadialMenuActionPickerView(selection: actionTypeBinding) + .frame(width: 300, height: 300) + } + .onChange(of: isActionPickerPresented) { _ in + if !isActionPickerPresented { + PickerListEventMonitorManager.shared.removeAllMonitors() + } + } + } + + private var actionIndicator: some View { + HStack(spacing: 2) { + if case .radialMenuActions = gesture.action { + HStack(spacing: 4) { + Image(.loop) + Text(String(localized: "Open Radial Menu", comment: "Label shown for a gesture configured to open the radial menu")) + .fontWeight(.regular) + .lineLimit(1) + } + .padding(.horizontal, 4) + .foregroundStyle(.secondary) + } else { + Button { + isActionPickerPresented = true + } label: { + HStack(spacing: 8) { + if let action = resolvedAction { + IconView(action: action) + + Text(action.getName()) + .fontWeight(.regular) + .lineLimit(1) + } else { + Image(systemName: "bolt.horizontal.fill") + .foregroundStyle(.secondary) + + Text(String(localized: "No Action", comment: "Label shown for a gesture with no configured action")) + .fontWeight(.regular) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 4) + } + .luminareContentSize(contentMode: .fit, hasFixedHeight: true) + .luminareRoundingBehavior(top: true, bottom: true) + .luminareFilledStates([.hovering, .pressed]) + .luminareBorderedStates(.hovering) + .luminareMinHeight(24) + .help(String(localized: "Customize this gesture's action.", comment: "Help text shown when hovering a gesture's action button")) + .padding(.leading, -4) + } + + Group { + if let resolvedAction { + if resolvedAction.direction.isCustomizable { + Button { + isConfiguringCustom = true + } label: { + Image(systemName: "slider.horizontal.3") + } + .buttonStyle(.plain) + .luminareModal(isPresented: $isConfiguringCustom) { + if resolvedAction.direction == .custom { + CustomActionConfigurationView( + action: actionBinding, + isPresented: $isConfiguringCustom + ) + .frame(width: 400) + } else { + StashActionConfigurationView( + action: actionBinding, + isPresented: $isConfiguringCustom + ) + .frame(width: 400) + } + } + .luminareModalCornerRadius(24) + .help(String(localized: "Customize this action's custom frame.", comment: "Help text on the slider icon next to a gesture action with a custom frame")) + } + + if resolvedAction.direction == .cycle { + Button { + isConfiguringCycle = true + } label: { + Image(systemName: "repeat") + } + .buttonStyle(.plain) + .luminareModal(isPresented: $isConfiguringCycle) { + CycleActionConfigurationView( + action: actionBinding, + isPresented: $isConfiguringCycle + ) + .frame(width: 400) + } + .luminareModalCornerRadius(24) + .help(String(localized: "Customize what this action cycles through.", comment: "Help text on the cycle icon next to a gesture action that cycles")) + } + } + } + .font(.title3) + .foregroundStyle(.secondary) + } + } +} diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift new file mode 100644 index 00000000..069c7777 --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -0,0 +1,93 @@ +// +// GesturesConfigurationView.swift +// Loop +// +// Created by Kai Azim on 2026-04-16. +// + +import Defaults +import Luminare +import SwiftUI + +final class GesturesConfigurationModel: ObservableObject { + @Published var selectedGestures = Set() +} + +struct GesturesConfigurationView: View { + @Environment(\.luminareAnimation) private var luminareAnimation + @StateObject private var model = GesturesConfigurationModel() + + @Default(.enableGestures) private var enableGestures + @Default(.disableConflictingSystemGestures) private var disableConflictingSystemGestures + @Default(.gestures) private var gestures + @Default(.gestureTitlebarHeight) private var gestureTitlebarHeight + + var body: some View { + LuminareForm { + settingsSection + + if enableGestures { + gesturesSection + } + } + .animation(luminareAnimation, value: enableGestures) + .onChange(of: gestures) { newValue in + let gesturesByID = Dictionary(uniqueKeysWithValues: newValue.map { ($0.id, $0) }) + let selectedIDs = model.selectedGestures.map(\.id) + model.selectedGestures = Set(selectedIDs.compactMap { gesturesByID[$0] }) + } + } + + private var settingsSection: some View { + LuminareSection { + LuminareToggle(String(localized: "Enable gestures", comment: "Toggle in gestures settings"), isOn: $enableGestures) + + if enableGestures { + LuminareToggle(String(localized: "Disable conflicting system gestures", comment: "Toggle in gestures settings"), isOn: $disableConflictingSystemGestures) + } + } + } + + private var gesturesSection: some View { + LuminareSection(String(localized: "Gestures", comment: "Section header shown in gestures settings")) { + LuminareButtonRow { + Button(String(localized: "Add", comment: "Button to add a new gesture")) { + gestures.insert( + Gesture(), + at: 0 + ) + } + + Button(String(localized: "Remove", comment: "Button to remove selected gestures"), role: .destructive) { + let selectedIDs = Set(model.selectedGestures.map(\.id)) + gestures.removeAll { selectedIDs.contains($0.id) } + } + .disabled(model.selectedGestures.isEmpty) + .keyboardShortcut(.delete) + } + .luminareRoundingBehavior(top: true) + + LuminareList( + items: $gestures, + selection: $model.selectedGestures, + id: \.id + ) { gesture in + GestureItemView(gesture) + } emptyView: { + HStack { + Spacer() + VStack { + Text(String(localized: "No gestures", comment: "Empty state title in gestures settings")) + .font(.title3) + Text(String(localized: "Press \"Add\" to add a gesture", comment: "Empty state subtitle in gestures settings")) + .font(.caption) + } + Spacer() + } + .foregroundStyle(.secondary) + .padding() + } + .luminareRoundingBehavior(bottom: true) + } + } +} diff --git a/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift b/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift index 573ad056..905e9f63 100644 --- a/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift +++ b/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift @@ -120,26 +120,23 @@ struct KeybindItemView: View { .font(.title3) .foregroundStyle(.secondary) } - .background(alignment: .leading) { - Color.clear - .frame(width: 300 - 24) - .luminarePopover( - isPresented: $isDirectionPickerPresented, - arrowEdge: .top, - shouldHideAnchor: true, - shouldAnimate: false - ) { - DirectionPickerView( - direction: $action.direction, - isInCycle: cycleIndex != nil - ) - .frame(width: 300, height: 300) - } - .onChange(of: isDirectionPickerPresented) { _ in - if !isDirectionPickerPresented { - PickerListEventMonitorManager.shared.removeAllMonitors() - } - } + .luminarePopover( + isPresented: $isDirectionPickerPresented, + arrowEdge: .top, + attachmentAnchor: .topLeading, + shouldHideAnchor: true, + shouldAnimate: false + ) { + DirectionPickerView( + direction: $action.direction, + isInCycle: cycleIndex != nil + ) + .frame(width: 300, height: 300) + } + .onChange(of: isDirectionPickerPresented) { _ in + if !isDirectionPickerPresented { + PickerListEventMonitorManager.shared.removeAllMonitors() + } } } diff --git a/Loop/Settings Window/SettingsTab.swift b/Loop/Settings Window/SettingsTab.swift index b143ec48..909c2453 100644 --- a/Loop/Settings Window/SettingsTab.swift +++ b/Loop/Settings Window/SettingsTab.swift @@ -20,6 +20,7 @@ enum SettingsTab: @MainActor LuminareTabItem, CaseIterable { case behavior case keybinds + case gestures case advanced case excludedApps @@ -43,6 +44,8 @@ enum SettingsTab: @MainActor LuminareTabItem, CaseIterable { Color(#colorLiteral(red: 0.4373228079, green: 0.6609574352, blue: 0.2663080928, alpha: 1)) case .keybinds: Color(#colorLiteral(red: 0.3882352941, green: 0.2823529412, blue: 0.1960784314, alpha: 1)) + case .gestures: + Color(#colorLiteral(red: 0.2352941176, green: 0.5568627451, blue: 0.5882352941, alpha: 1)) case .advanced: Color(#colorLiteral(red: 0.4823529412, green: 0.4745098039, blue: 0.6588235294, alpha: 1)) case .excludedApps: @@ -60,6 +63,7 @@ enum SettingsTab: @MainActor LuminareTabItem, CaseIterable { case .preview: .init(localized: "Settings tab: Preview", defaultValue: "Preview") case .behavior: .init(localized: "Settings tab: Behavior", defaultValue: "Behavior") case .keybinds: .init(localized: "Settings tab: Keybindings", defaultValue: "Keybinds") + case .gestures: .init(localized: "Settings tab: Gestures", defaultValue: "Gestures") case .advanced: .init(localized: "Settings tab: Advanced", defaultValue: "Advanced") case .excludedApps: .init(localized: "Settings tab: Excluded Apps", defaultValue: "Excluded Apps") case .about: .init(localized: "Settings tab: About", defaultValue: "About") @@ -74,6 +78,7 @@ enum SettingsTab: @MainActor LuminareTabItem, CaseIterable { case .preview: Image(systemName: "inset.filled.center.rectangle") case .behavior: Image(systemName: "gearshape.fill") case .keybinds: Image(systemName: "keyboard.fill") + case .gestures: Image(systemName: "hand.draw.fill") case .advanced: Image(systemName: "wrench.adjustable.fill") case .excludedApps: Image(systemName: "xmark.octagon.fill") case .about: Image(systemName: "info.circle.fill") @@ -95,6 +100,7 @@ enum SettingsTab: @MainActor LuminareTabItem, CaseIterable { case .preview: PreviewConfigurationView() case .behavior: BehaviorConfigurationView() case .keybinds: KeybindsConfigurationView() + case .gestures: GesturesConfigurationView() case .advanced: AdvancedConfigurationView() case .excludedApps: ExcludedAppsConfigurationView() case .about: AboutConfigurationView() @@ -102,7 +108,7 @@ enum SettingsTab: @MainActor LuminareTabItem, CaseIterable { } static let themingTabs: [Self] = [.icon, .accentColor, .radialMenu, .preview] - static let settingsTabs: [Self] = [.behavior, .keybinds] + static let settingsTabs: [Self] = [.behavior, .keybinds, .gestures] static let loopTabs: [Self] = [.advanced, .excludedApps, .about] } diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift index 2799521d..28875f25 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -85,23 +85,20 @@ struct RadialMenuActionItemView: View { private var label: some View { actionIndicator - .background(alignment: .leading) { - Color.clear - .frame(width: 300 - 24) - .luminarePopover( - isPresented: $isPickerPresented, - arrowEdge: .top, - shouldHideAnchor: true, - shouldAnimate: false - ) { - RadialMenuActionPickerView(selection: $action.type) - .frame(width: 300, height: 300) - } - .onChange(of: isPickerPresented) { _ in - if !isPickerPresented { - PickerListEventMonitorManager.shared.removeAllMonitors() - } - } + .luminarePopover( + isPresented: $isPickerPresented, + arrowEdge: .top, + attachmentAnchor: .topLeading, + shouldHideAnchor: true, + shouldAnimate: false + ) { + RadialMenuActionPickerView(selection: $action.type) + .frame(width: 300, height: 300) + } + .onChange(of: isPickerPresented) { _ in + if !isPickerPresented { + PickerListEventMonitorManager.shared.removeAllMonitors() + } } } diff --git a/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift b/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift index 77c8ba06..77020229 100644 --- a/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift +++ b/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift @@ -58,15 +58,12 @@ final class ActiveEventMonitor: BaseEventTapMonitor { super.init() let eventsOfInterest = events.reduce(CGEventMask(0)) { $0 | (1 << $1.rawValue) } - let callback: CGEventTapCallBack = { _, _, event, refcon in - // Try and obtain a reference to self, but if we fail, just return the unprocessed event. - guard let refcon else { - return Unmanaged.passUnretained(event) - } + let callback: CGEventTapCallBack = { _, eventType, event, refcon in + guard let refcon else { return nil } let observer = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - if event.type == .tapDisabledByTimeout { - // Tap timed out, schedule a restart on the tap thread so the circuit breaker can run + // Tap management notifications carry a null event, so read eventType, not event.type + if eventType == .tapDisabledByTimeout { if observer.isEnabled { let tapRunLoop = EventTapThread.shared.runLoop CFRunLoopPerformBlock(tapRunLoop, CFRunLoopMode.commonModes as CFTypeRef) { @@ -74,18 +71,18 @@ final class ActiveEventMonitor: BaseEventTapMonitor { } CFRunLoopWakeUp(tapRunLoop) } - return Unmanaged.passUnretained(event) + return nil } - if event.type == .tapDisabledByUserInput { - // Explicitly disabled by the user/system, don't auto-restart - return Unmanaged.passUnretained(event) + if eventType == .tapDisabledByUserInput { + return nil } + guard unsafeBitCast(event, to: UnsafeRawPointer?.self) != nil else { return nil } return observer.handleEvent(event: event) } - let userInfo = Unmanaged.passUnretained(self).toOpaque() + let userInfo = Unmanaged.passRetained(self).toOpaque() if let eventTap = CGEvent.tapCreate( tap: tapLocation, @@ -98,6 +95,7 @@ final class ActiveEventMonitor: BaseEventTapMonitor { setupRunLoopSource(eventTap: eventTap, readableIdentifier: name) } else { log.info("Failed to create event tap") + Unmanaged.passUnretained(self).release() } } diff --git a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift index 646cc85a..329f81c2 100644 --- a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift +++ b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift @@ -24,15 +24,21 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { private var readableIdentifier: String? private(set) var isEnabled: Bool = false + /// True while the tap's refcon holds a passRetained reference to self + private var refconRetainOutstanding: Bool = false + private var restartTimestamps: [ContinuousClock.Instant] = [] deinit { tearDownEventTap() } + /// Subclasses must pass `Unmanaged.passRetained(self).toOpaque()` as the tap's userInfo + /// before calling this, so the base class can balance that retain in `tearDownEventTap` func setupRunLoopSource(eventTap: CFMachPort, readableIdentifier: String) { let runLoop = EventTapThread.shared.runLoop self.readableIdentifier = readableIdentifier + refconRetainOutstanding = true if let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) { self.eventTap = eventTap @@ -110,18 +116,29 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { self.runLoopSource = nil isEnabled = false + // Balance the passRetained from setup on the tap thread, after invalidation, + // so any in-flight callback finishes before self can deallocate + let releaseToken: Unmanaged? = refconRetainOutstanding ? Unmanaged.passUnretained(self) : nil + refconRetainOutstanding = false + if let eventTap, CFMachPortIsValid(eventTap) { CGEvent.tapEnable(tap: eventTap, enable: false) - CFMachPortInvalidate(eventTap) } - guard let runLoop, let runLoopSource else { return } + guard let runLoop, let runLoopSource else { + if let eventTap, CFMachPortIsValid(eventTap) { + CFMachPortInvalidate(eventTap) + } + releaseToken?.release() + return + } - // Keep the tap callback's refcon pointer valid until any in-flight callback finishes - let monitor = self CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes as CFTypeRef) { + if let eventTap, CFMachPortIsValid(eventTap) { + CFMachPortInvalidate(eventTap) + } CFRunLoopRemoveSource(runLoop, runLoopSource, .commonModes) - _ = monitor + releaseToken?.release() } CFRunLoopWakeUp(runLoop) } diff --git a/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift b/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift index 5549c28c..001bbc2c 100644 --- a/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift +++ b/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift @@ -31,15 +31,12 @@ final class PassiveEventMonitor: BaseEventTapMonitor { super.init() let eventsOfInterest = events.reduce(CGEventMask(0)) { $0 | (1 << $1.rawValue) } - let callback: CGEventTapCallBack = { _, _, event, refcon in - // Try and obtain a reference to self - guard let refcon else { - return Unmanaged.passUnretained(event) - } + let callback: CGEventTapCallBack = { _, eventType, event, refcon in + guard let refcon else { return nil } let observer = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - if event.type == .tapDisabledByTimeout { - // Tap timed out, schedule a restart on the tap thread so the circuit breaker can run + // Tap management notifications carry a null event, so read eventType, not event.type + if eventType == .tapDisabledByTimeout { if observer.isEnabled { let tapRunLoop = EventTapThread.shared.runLoop CFRunLoopPerformBlock(tapRunLoop, CFRunLoopMode.commonModes as CFTypeRef) { @@ -47,20 +44,19 @@ final class PassiveEventMonitor: BaseEventTapMonitor { } CFRunLoopWakeUp(tapRunLoop) } - return Unmanaged.passUnretained(event) + return nil } - if event.type == .tapDisabledByUserInput { - // Explicitly disabled by the user/system, don't auto-restart - return Unmanaged.passUnretained(event) + if eventType == .tapDisabledByUserInput { + return nil } - // Call the callback but always pass the unmodified event through + guard unsafeBitCast(event, to: UnsafeRawPointer?.self) != nil else { return nil } observer.eventCallback(event) return Unmanaged.passUnretained(event) } - let userInfo = Unmanaged.passUnretained(self).toOpaque() + let userInfo = Unmanaged.passRetained(self).toOpaque() if let eventTap = CGEvent.tapCreate( tap: tapLocation, @@ -73,6 +69,7 @@ final class PassiveEventMonitor: BaseEventTapMonitor { setupRunLoopSource(eventTap: eventTap, readableIdentifier: name) } else { log.info("Failed to create event tap") + Unmanaged.passUnretained(self).release() } } } diff --git a/Loop/Core/SystemWindowManager.swift b/Loop/Window Management/SystemWindowManager.swift similarity index 100% rename from Loop/Core/SystemWindowManager.swift rename to Loop/Window Management/SystemWindowManager.swift diff --git a/Loop/Window Management/Window Action/Gesture.swift b/Loop/Window Management/Window Action/Gesture.swift new file mode 100644 index 00000000..663805a3 --- /dev/null +++ b/Loop/Window Management/Window Action/Gesture.swift @@ -0,0 +1,190 @@ +// +// Gesture.swift +// Loop +// +// Created by Kai Azim on 2026-04-16. +// + +import Defaults +import SwiftUI + +struct Gesture: Identifiable, Codable, Hashable, Defaults.Serializable { + let id: UUID + var fingerCount: Int + var kind: Kind + var action: Action + var activationZone: ActivationZone + + init( + id: UUID = .init(), + fingerCount: Int = 2, + kind: Kind = .radialMenu, + action: Action = .radialMenuActions, + activationZone: ActivationZone = .titlebar + ) { + self.id = id + self.fingerCount = fingerCount + self.kind = kind + self.action = action + self.activationZone = activationZone + } + + enum Kind: Codable, Hashable, CaseIterable { + /// Pan gesture that maps angle to radial menu directional slots. + case radialMenu + /// Directional pan gestures that trigger a single action. + case panUp, panDown, panLeft, panRight + /// Pinch gesture (fingers together, scale < 1). + case pinch + /// Spread gesture (fingers apart, scale > 1). + case spread + + var displayName: String { + switch self { + case .radialMenu: String(localized: "Radial Menu", comment: "Gesture kind: opens the radial menu via swipe/pinch/spread") + case .panUp: String(localized: "Swipe Up", comment: "Gesture kind: directional swipe") + case .panDown: String(localized: "Swipe Down", comment: "Gesture kind: directional swipe") + case .panLeft: String(localized: "Swipe Left", comment: "Gesture kind: directional swipe") + case .panRight: String(localized: "Swipe Right", comment: "Gesture kind: directional swipe") + case .pinch: String(localized: "Pinch", comment: "Gesture kind: fingers move together") + case .spread: String(localized: "Spread", comment: "Gesture kind: fingers move apart") + } + } + + var image: Image { + switch self { + case .radialMenu: Image(.loop) + case .panUp: Image(systemName: "arrow.up") + case .panDown: Image(systemName: "arrow.down") + case .panLeft: Image(systemName: "arrow.left") + case .panRight: Image(systemName: "arrow.right") + case .pinch: Image(systemName: "arrow.up.right.and.arrow.down.left") + case .spread: Image(systemName: "arrow.down.left.and.arrow.up.right") + } + } + + var isPan: Bool { + switch self { + case .radialMenu, .panUp, .panDown, .panLeft, .panRight: + true + case .pinch, .spread: + false + } + } + + var isDirectionalPan: Bool { + switch self { + case .panUp, .panDown, .panLeft, .panRight: + true + default: + false + } + } + } + + enum Action: Codable, Hashable { + /// Uses `RadialMenuAction.userConfiguredActions` for radial menu pan mode. + case radialMenuActions + /// A single action, either custom or referencing a keybind. + case singleAction(RadialMenuAction.ActionType) + } + + enum ActivationZone: String, Codable, Hashable, CaseIterable { + case titlebar + case anywhere + + var displayName: String { + switch self { + case .titlebar: String(localized: "Titlebar Only", comment: "Gesture activation zone restricted to a window's titlebar") + case .anywhere: String(localized: "Anywhere", comment: "Gesture activation zone covers the entire window") + } + } + + var systemImage: String { + switch self { + case .titlebar: "menubar.rectangle" + case .anywhere: "rectangle.dashed" + } + } + } +} + +// MARK: - Disabled state + +extension Gesture { + /// True when this gesture's action resolves to `noAction`. Disabled gestures + /// are skipped at runtime and rendered greyed-out in settings. + var isDisabled: Bool { + switch action { + case .radialMenuActions: + false + case let .singleAction(actionType): + (actionType.resolvedAction?.direction ?? .noAction) == .noAction + } + } +} + +// MARK: - Conflict Detection + +extension Gesture { + /// Two gestures conflict when they have the same finger count and their kinds overlap. + /// Radial menu consumes both pan and pinch, so it conflicts with ANY other gesture at the same finger count. + func conflicts(with other: Gesture) -> Bool { + guard id != other.id, fingerCount == other.fingerCount else { + return false + } + + // Radial menu uses both pan and pinch, so it conflicts with everything at the same finger count + if kind == .radialMenu || other.kind == .radialMenu { + return true + } + + // Same kind always conflicts + if kind == other.kind { + return true + } + + return false + } + + /// Returns the IDs of all gestures that conflict with at least one other gesture in the array. + static func conflictingIDs(in gestures: [Gesture]) -> Set { + var result = Set() + for i in gestures.indices { + for j in (i + 1) ..< gestures.count { + if gestures[i].conflicts(with: gestures[j]) { + result.insert(gestures[i].id) + result.insert(gestures[j].id) + } + } + } + return result + } +} + +// MARK: - Defaults + +extension Gesture { + static let defaults: [Gesture] = [ + Gesture( + fingerCount: 2, + kind: .radialMenu, + action: .radialMenuActions, + activationZone: .titlebar + ), + Gesture( + fingerCount: 2, + kind: .pinch, + action: .singleAction(.custom( + WindowAction( + "\(WindowDirection.maximize.name) + \(WindowDirection.macOSCenter.name)", + cycle: [ + .init(.maximize), + .init(.macOSCenter) + ] + ) + )), + activationZone: .titlebar + ) + ] +} diff --git a/Loop/Window Management/Window/Window.swift b/Loop/Window Management/Window/Window.swift index 8123de83..ad467673 100644 --- a/Loop/Window Management/Window/Window.swift +++ b/Loop/Window Management/Window/Window.swift @@ -590,7 +590,7 @@ final class Window { extension Window: CustomStringConvertible { var description: String { - "Window(id: \(cgWindowID), app: '\(nsRunningApplication?.localizedName ?? "")', title: '\(title ?? ""))" + "Window(id: \(cgWindowID), app: '\(nsRunningApplication?.localizedName ?? "")', title: '\(title ?? "")')" } } diff --git a/Loop/Window Management/Window/WindowUtility.swift b/Loop/Window Management/Window/WindowUtility.swift index c5447d6a..aee6f5a4 100644 --- a/Loop/Window Management/Window/WindowUtility.swift +++ b/Loop/Window Management/Window/WindowUtility.swift @@ -17,24 +17,27 @@ enum WindowUtility { static func userDefinedTargetWindow() -> Window? { var result: Window? - log.info("Getting window at cursor...") + if Defaults[.resizeWindowUnderCursor] { + log.info("Getting window at cursor...") - if Defaults[.resizeWindowUnderCursor], - let mouseLocation = CGEvent.mouseLocation, - let window = windowAtPosition(mouseLocation) { - result = window + if let mouseLocation = CGEvent.mouseLocation, + let window = windowAtPosition(mouseLocation) { + result = window + } } if result == nil { do { - log.info("Getting frontmost window...") - result = try frontmostWindow() } catch { log.warn("Failed to get frontmost window: \(error.localizedDescription)") } } + if let result { + log.debug("Determined target window: \(result)") + } + return result }