diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 212fbc7a..bf54eb5b 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 */; }; + 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, ); }; }; - 2AC4F4A82FDCAF1F00CB2FBE /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238D2F540B1300F467FD /* Scribe */; }; - 2AC4F62C2FDCAF7100CB2FBE /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 2AC4F62B2FDCAF7100CB2FBE /* Luminare */; }; + 2AAC0B022FDD30370047C801 /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 2AAC0B012FDD30370047C801 /* Luminare */; }; 2AE091C72F81A22800EF6149 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AE091C62F81A22800EF6149 /* 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,12 +128,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2AC4F4A82FDCAF1F00CB2FBE /* Scribe in Frameworks */, + 2A847DFC2F5E49E90099E02A /* Scribe in Frameworks */, + 2AAC0B022FDD30370047C801 /* Luminare in Frameworks */, + 2A847DFF2F5E4A080099E02A /* Subsurface in Frameworks */, 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */, - 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */, F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */, 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */, - 2AC4F62C2FDCAF7100CB2FBE /* Luminare in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -141,7 +141,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2AF923902F540B2200F467FD /* Scribe in Frameworks */, + 2A847E012F5E4A0E0099E02A /* Scribe in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -234,9 +234,9 @@ packageProductDependencies = ( 2A28B6282EE5050C00A1E26B /* Defaults */, 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */, - 2A28492A2F22B4B700F6CE42 /* Scribe */, - 2AF9238D2F540B1300F467FD /* Scribe */, - 2AC4F62B2FDCAF7100CB2FBE /* Luminare */, + 2A847DFB2F5E49E90099E02A /* Scribe */, + 2A847DFE2F5E4A080099E02A /* Subsurface */, + 2AAC0B012FDD30370047C801 /* Luminare */, ); productName = WindowManager; productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */; @@ -259,7 +259,7 @@ ); name = LoopUpdaterHelper; packageProductDependencies = ( - 2AF9238F2F540B2200F467FD /* Scribe */, + 2A847E002F5E4A0E0099E02A /* Scribe */, ); productName = LoopUpdaterHelper; productReference = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; @@ -310,8 +310,9 @@ packageReferences = ( 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */, 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, - 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */, - 2AC4F62A2FDCAF7100CB2FBE /* XCRemoteSwiftPackageReference "Luminare" */, + 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */, + 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */, + 2AAC0B002FDD30370047C801 /* XCLocalSwiftPackageReference "../Luminare" */, ); productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */; projectDirPath = ""; @@ -909,6 +910,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 2AAC0B002FDD30370047C801 /* XCLocalSwiftPackageReference "../Luminare" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Luminare; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */ = { isa = XCRemoteSwiftPackageReference; @@ -918,17 +926,17 @@ minimumVersion = 9.0.0; }; }; - 2AC4F62A2FDCAF7100CB2FBE /* XCRemoteSwiftPackageReference "Luminare" */ = { + 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/MrKai77/Luminare"; + repositoryURL = "https://github.com/SenpaiHunters/Scribe"; requirement = { branch = main; kind = branch; }; }; - 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */ = { + 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SenpaiHunters/Scribe"; + repositoryURL = "https://github.com/MrKai77/Subsurface"; requirement = { branch = main; kind = branch; @@ -945,33 +953,33 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 2A28492A2F22B4B700F6CE42 /* Scribe */ = { - isa = XCSwiftPackageProductDependency; - productName = Scribe; - }; 2A28B6282EE5050C00A1E26B /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; - 2AC4F62B2FDCAF7100CB2FBE /* Luminare */ = { + 2A847DFB2F5E49E90099E02A /* Scribe */ = { isa = XCSwiftPackageProductDependency; - package = 2AC4F62A2FDCAF7100CB2FBE /* XCRemoteSwiftPackageReference "Luminare" */; - productName = Luminare; + package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; + productName = Scribe; }; - 2AE091C62F81A22800EF6149 /* Scribe */ = { + 2A847DFE2F5E4A080099E02A /* Subsurface */ = { isa = XCSwiftPackageProductDependency; - package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */; - productName = Scribe; + package = 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */; + productName = Subsurface; }; - 2AF9238D2F540B1300F467FD /* Scribe */ = { + 2A847E002F5E4A0E0099E02A /* Scribe */ = { isa = XCSwiftPackageProductDependency; - package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */; + package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; productName = Scribe; }; - 2AF9238F2F540B2200F467FD /* Scribe */ = { + 2AAC0B012FDD30370047C801 /* Luminare */ = { + isa = XCSwiftPackageProductDependency; + productName = Luminare; + }; + 2AE091C62F81A22800EF6149 /* Scribe */ = { isa = XCSwiftPackageProductDependency; - package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */; + package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; productName = Scribe; }; 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */ = { diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index 119fdb42..79a2845d 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -27,6 +27,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_: Notification) { configureLogging() + DefaultsiCloudSyncRegistrar.register() // Register before broadcasting so other instances can receive the signal registerTerminateObserver() diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 6faf7918..ca6a52ff 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 .cancelled } + 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 @@ -118,9 +140,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, initial: false) { + guard let self, !Task.isCancelled else { break } + + if enabled, AccessibilityManager.shared.isGranted { + multitouchTrigger.start() + } else { + multitouchTrigger.stop() } } } @@ -129,12 +167,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,19 +186,43 @@ 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" + } + } +} + +enum LoopOpenResult { + case opened + case alreadyOpening + case alreadyOpen + case cancelled +} + // MARK: - Opening/Closing Loop extension LoopManager { - private func openLoop(startingAction: WindowAction) async { + private func openLoop(startingAction: WindowAction, window: Window? = nil) async throws -> LoopOpenResult { guard AccessibilityManager.shared.isGranted else { - return + throw LoopManagerError.accessibilityNotGranted } guard !isLoopOpening else { if startingAction.direction != .noSelection { pendingOpeningAction = startingAction } - return + return .alreadyOpening } guard !isLoopActive else { @@ -169,16 +234,17 @@ extension LoopManager { await changeAction(startingAction, disableHapticFeedback: true) } - return + return .alreadyOpen } - 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 @@ -216,7 +282,7 @@ extension LoopManager { await resizeContext.refreshResolvedState() guard !shouldCancelOpening else { - return + return .cancelled } if !Defaults[.disableCursorInteraction] { @@ -229,6 +295,7 @@ extension LoopManager { await changeAction(pendingOpeningAction ?? startingAction, disableHapticFeedback: true) triggerKeyTimeoutTimer.start() + return .opened } private func closeLoop(forceClose: Bool) async { @@ -283,7 +350,8 @@ extension LoopManager { _ newAction: WindowAction, triggeredFromScreenChange: Bool = false, disableHapticFeedback: Bool = false, - canAdvanceCycle: Bool = true + canAdvanceCycle: Bool = true, + reverse: Bool = false ) async { guard isLoopActive, @@ -295,11 +363,9 @@ extension LoopManager { return } - if StashManager.shared.handleIfStashed(newAction, screen: currentScreen) { - return - } + let allowsRepeatedSelection = newAction.allowsRepeatedSelection - guard resizeContext.action.id != newAction.id || newAction.canRepeat else { + guard resizeContext.action.id != newAction.id || allowsRepeatedSelection else { return } @@ -315,7 +381,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) @@ -340,6 +406,11 @@ extension LoopManager { newParentAction = nil } + if let stashedWindow = StashManager.shared.stashedWindow(for: newAction, on: currentScreen) { + resizeContext.setWindow(to: stashedWindow.window) + await resizeContext.refreshResolvedState() + } + if newAction.direction.willChangeScreen { var newScreen: NSScreen = currentScreen @@ -445,7 +516,7 @@ extension LoopManager { performHapticFeedback() } - if newAction != resizeContext.action || newAction.canRepeat { + if newAction != resizeContext.action || allowsRepeatedSelection { let previousActionWasNoOp = resizeContext.action.direction.isNoOp setResizeAction(to: newAction, parent: newParentAction) if !Defaults[.previewVisibility], !previousActionWasNoOp { @@ -473,8 +544,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 +557,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 +578,7 @@ extension LoopManager { } guard var nextIndex = currentIndex else { - return currentCycle[0] + return freshStart } nextIndex += shouldCycleBackwards ? -1 : 1 diff --git a/Loop/Core/Multitouch/MultitouchGestureBlocker.swift b/Loop/Core/Multitouch/MultitouchGestureBlocker.swift new file mode 100644 index 00000000..d15510e6 --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchGestureBlocker.swift @@ -0,0 +1,61 @@ +// +// 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() { + if monitor != nil { + activeCount += 1 + 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.swipe.rawValue)), + CGEventType(rawValue: UInt32(NSEvent.EventType.smartMagnify.rawValue)) + ].compactMap(\.self) + + let newMonitor = ActiveEventMonitor("gesture_blocker", events: eventTypes) { _ in .ignore } + newMonitor.start() + + guard newMonitor.isEnabled else { + log.warn("Failed to start gesture blocker") + newMonitor.stop() + return + } + + monitor = newMonitor + activeCount = 1 + } + + func stop() { + guard let monitor else { return } + + activeCount -= 1 + guard activeCount == 0 else { return } + + monitor.stop() + self.monitor = nil + + log.info("Stopped gesture blocker") + } +} diff --git a/Loop/Core/Multitouch/MultitouchGestureSession.swift b/Loop/Core/Multitouch/MultitouchGestureSession.swift new file mode 100644 index 00000000..aa2fd1cb --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchGestureSession.swift @@ -0,0 +1,191 @@ +// +// MultitouchGestureSession.swift +// Loop +// +// Created by Kai Azim on 2026-01-30. +// + +import SwiftUI + +@MainActor +final class MultitouchGestureSession { + enum ActionKey: Hashable { + case radialSlot(Int) + case radialCenter + case gesture(UUID) + } + + private(set) var didOpenLoopWithThisGesture = false + private(set) var isGestureRejected = false + private(set) var hasActivated = false + private(set) var hasGestureBegun = false + /// The gesture currently driving this stroke. Swapped on direction reversal + private(set) var resolvedGesture: GestureBinding? + private(set) var pendingTargetWindow: Window? + + private var lastCommittedAction: ActionKey? + private var lastCommitSwipeDistance: CGFloat = 0 + private var lastCommitMagnifyDistance: CGFloat = 0 + private var magnificationKind: GestureBinding.Kind? + + func reset() { + didOpenLoopWithThisGesture = false + isGestureRejected = false + hasActivated = false + hasGestureBegun = false + resolvedGesture = nil + pendingTargetWindow = nil + lastCommittedAction = nil + lastCommitSwipeDistance = 0 + lastCommitMagnifyDistance = 0 + magnificationKind = nil + } + + func begin( + targetWindow: Window?, + loopWasAlreadyOpen: Bool + ) -> Bool { + guard targetWindow != nil || loopWasAlreadyOpen else { + reject() + return false + } + + reset() + pendingTargetWindow = targetWindow + hasGestureBegun = true + // Loop is already on screen, so no activation threshold to cross. + hasActivated = loopWasAlreadyOpen + return true + } + + func reject() { + isGestureRejected = true + } + + func markActivated(openedLoop: Bool) { + if openedLoop { + didOpenLoopWithThisGesture = true + } + hasActivated = true + } + + /// 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 :) + func commitSwipe( + distance: CGFloat, + newKey: ActionKey, + step: CGFloat, + fire: (_ reverse: Bool) -> () + ) { + if lastCommittedAction == newKey { + let delta = distance - lastCommitSwipeDistance + if delta >= step { + lastCommitSwipeDistance = distance + fire(false) + } else if delta <= -step { + lastCommitSwipeDistance = distance + fire(true) + } + } else { + lastCommittedAction = newKey + lastCommitSwipeDistance = distance + fire(false) + } + } + + func commitRadialMagnify( + distance: CGFloat, + originDistance: CGFloat, + step: CGFloat, + fire: (_ reverse: Bool) -> () + ) { + if lastCommittedAction != .radialCenter { + lastCommittedAction = .radialCenter + lastCommitMagnifyDistance = distance + fire(distance < originDistance) + return + } + + let delta = distance - lastCommitMagnifyDistance + if delta >= step { + lastCommitMagnifyDistance = distance + fire(false) + } else if delta <= -step { + lastCommitMagnifyDistance = distance + fire(true) + } + } + + func commitMagnify( + gesture: GestureBinding, + distance: CGFloat, + step: CGFloat, + allowsRapidRepeat: Bool, + fire: (_ reverse: Bool) -> () + ) { + let newKey = ActionKey.gesture(gesture.id) + + if lastCommittedAction != newKey { + magnificationKind = gesture.kind + lastCommittedAction = newKey + lastCommitMagnifyDistance = distance + fire(false) + return + } + + guard allowsRapidRepeat, + let magnificationDirection = magnificationKind?.magnificationDirection + else { + return + } + + let delta = (distance - lastCommitMagnifyDistance) * magnificationDirection + if delta >= step { + lastCommitMagnifyDistance = distance + fire(false) + } else if delta <= -step { + lastCommitMagnifyDistance = distance + fire(true) + } + } + + func setResolvedGesture(_ gesture: GestureBinding) { + resolvedGesture = gesture + } + + func switchSwipeGesture(to gesture: GestureBinding, distance: CGFloat) { + resolvedGesture = gesture + lastCommittedAction = .gesture(gesture.id) + lastCommitSwipeDistance = distance + } + + func switchMagnifyGesture(to gesture: GestureBinding, distance: CGFloat) { + resolvedGesture = gesture + lastCommittedAction = .gesture(gesture.id) + magnificationKind = gesture.kind + lastCommitMagnifyDistance = distance + } + + func updateLastCommitSwipeDistance(_ distance: CGFloat) { + lastCommitSwipeDistance = distance + } + + func updateLastCommitMagnifyDistance(_ distance: CGFloat) { + lastCommitMagnifyDistance = distance + } +} + +private extension GestureBinding.Kind { + var magnificationDirection: CGFloat? { + switch self { + case .magnifyIn: + -1 + case .magnifyOut: + 1 + default: + nil + } + } +} diff --git a/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift b/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift new file mode 100644 index 00000000..bc6336c3 --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift @@ -0,0 +1,154 @@ +// +// MultitouchRecognizerRegistry.swift +// Loop +// +// Created by Kai Azim on 2026-07-06. +// + +import Subsurface + +@MainActor +final class MultitouchRecognizerRegistry { + typealias EventHandler = @MainActor (SubsurfaceGestureEvent, Int) async -> () + + struct Entry { + let recognizer: SubsurfaceGestureRecognizer + let session: MultitouchGestureSession + var task: Task<(), Never>? + var radialMenuGesture: GestureBinding? + var directionalGestures: [GestureBinding] + var magnifyInGesture: GestureBinding? + var magnifyOutGesture: GestureBinding? + + static func categorize( + _ gestures: [GestureBinding] + ) -> (radial: GestureBinding?, directionals: [GestureBinding], magnifyIn: GestureBinding?, magnifyOut: GestureBinding?) { + let radial = gestures.first { $0.kind == .radialMenu } + let gesturesByPriority = gestures.sortedByActionability + let directionals = gesturesByPriority.filter(\.kind.isDirectionalSwipe) + let magnifyIn = gesturesByPriority.first { $0.kind == .magnifyIn } + let magnifyOut = gesturesByPriority.first { $0.kind == .magnifyOut } + return (radial, directionals, magnifyIn, magnifyOut) + } + } + + struct StopResult { + let didOpenLoopWithGesture: Bool + } + + private let gestureMonitor: SubsurfaceMonitor + private let handleEvent: EventHandler + private var entries: [Int: Entry] = [:] + + init( + gestureMonitor: SubsurfaceMonitor, + handleEvent: @escaping EventHandler + ) { + self.gestureMonitor = gestureMonitor + self.handleEvent = handleEvent + } + + func entry(for fingerCount: Int) -> Entry? { + entries[fingerCount] + } + + func session(for fingerCount: Int) -> MultitouchGestureSession? { + entries[fingerCount]?.session + } + + func rebuild(with gestures: [GestureBinding]) -> [StopResult] { + let conflictingIDs = GestureBinding.conflictingActionableIDs(in: gestures) + let activeGestures = gestures.filter { !conflictingIDs.contains($0.id) } + let gesturesByFingerCount = Dictionary(grouping: activeGestures, by: \.fingerCount) + let neededFingerCounts = Set(gesturesByFingerCount.keys) + + var stopResults: [StopResult] = [] + for fingerCount in Array(entries.keys) where !neededFingerCounts.contains(fingerCount) { + if let stopResult = stopRecognizer(for: fingerCount) { + stopResults.append(stopResult) + } + entries.removeValue(forKey: fingerCount) + } + + for (fingerCount, gestures) in gesturesByFingerCount { + let (radial, directionals, magnifyIn, magnifyOut) = Entry.categorize(gestures) + if entries[fingerCount] == nil { + startRecognizer(for: fingerCount, radial: radial, directionals: directionals, magnifyIn: magnifyIn, magnifyOut: magnifyOut) + } else { + entries[fingerCount]?.radialMenuGesture = radial + entries[fingerCount]?.directionalGestures = directionals + entries[fingerCount]?.magnifyInGesture = magnifyIn + entries[fingerCount]?.magnifyOutGesture = magnifyOut + } + } + + return stopResults + } + + func stopAll() -> [StopResult] { + var stopResults: [StopResult] = [] + for fingerCount in Array(entries.keys) { + if let stopResult = stopRecognizer(for: fingerCount) { + stopResults.append(stopResult) + } + } + entries.removeAll() + return stopResults + } + + func contains(session: MultitouchGestureSession, for fingerCount: Int) -> Bool { + entries[fingerCount]?.session === session + } + + private func startRecognizer( + for fingerCount: Int, + radial: GestureBinding?, + directionals: [GestureBinding], + magnifyIn: GestureBinding?, + magnifyOut: GestureBinding? + ) { + let recognizer = SubsurfaceGestureRecognizer( + fingerCount: fingerCount, + recognizedGestureTypes: [.swipe, .magnify], + requiresExactFingerCountToContinue: true + ) + + entries[fingerCount] = Entry( + recognizer: recognizer, + session: MultitouchGestureSession(), + task: nil, + radialMenuGesture: radial, + directionalGestures: directionals, + magnifyInGesture: magnifyIn, + magnifyOutGesture: magnifyOut + ) + + let task = Task { [weak self] in + guard let self else { return } + for await event in recognizer.events(from: gestureMonitor) { + guard !Task.isCancelled else { break } + await handleEvent(event, fingerCount) + } + } + entries[fingerCount]?.task = task + } + + private func stopRecognizer(for fingerCount: Int) -> StopResult? { + guard let entry = entries[fingerCount] else { return nil } + entry.task?.cancel() + entry.recognizer.reset() + return StopResult(didOpenLoopWithGesture: entry.session.didOpenLoopWithThisGesture) + } +} + +private extension Array where Element == GestureBinding { + var sortedByActionability: [GestureBinding] { + sorted { + if $0.resolvesToNoAction != $1.resolvesToNoAction { + return !$0.resolvesToNoAction + } + + return false + } + } +} diff --git a/Loop/Core/Multitouch/MultitouchTargetResolver.swift b/Loop/Core/Multitouch/MultitouchTargetResolver.swift new file mode 100644 index 00000000..e2bf19cf --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchTargetResolver.swift @@ -0,0 +1,88 @@ +// +// MultitouchTargetResolver.swift +// Loop +// +// Created by Kai Azim on 2026-07-06. +// + +import Defaults +import Scribe +import SwiftUI + +@Loggable +@MainActor +final class MultitouchTargetResolver { + /// Window most recently targeted by a repeatable gesture. + /// Lets shrinking/growing continue after the cursor falls off the resized frame. + private var lastRepeatableWindow: Window? + private var hasResolvedGestureWindow = false + private var resolvedGestureWindow: Window? + + func reset() { + lastRepeatableWindow = nil + resetGestureState() + } + + func resetGestureState() { + hasResolvedGestureWindow = false + resolvedGestureWindow = nil + } + + func targetWindow(for gesture: GestureBinding, allowsRapidRepeat: Bool) -> Window? { + let window = findTargetWindow(for: gesture) + if window == nil, allowsRapidRepeat { + return lastRepeatableWindow + } + return window + } + + func rememberRepeatableWindow(_ window: Window?, allowsRapidRepeat: Bool) { + guard let window, allowsRapidRepeat else { return } + lastRepeatableWindow = window + } + + private func findTargetWindow(for gesture: GestureBinding) -> Window? { + let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) + let activationZone: GestureBinding.ActivationZone = gesture.fingerCount <= 2 ? .titlebar : gesture.activationZone + + if hasResolvedGestureWindow { + return resolvedGestureWindow + } + + guard let window = WindowUtility.windowAtPosition(cursorPosition) else { + hasResolvedGestureWindow = true + return nil + } + + // 2-finger gestures are always titlebar-only to avoid system gesture conflicts. + switch activationZone { + case .titlebar: + let minimumTitlebarHeight = Defaults[.gestureTitlebarHeight] + let titlebarHeight: CGFloat = if #available(macOS 26, *) { + if #unavailable(macOS 27), + let cornerRadius = SkyLightToolBelt.getCornerRadii(windowID: window.cgWindowID)?.topLeading { + max(2 * cornerRadius, minimumTitlebarHeight) + } else { + minimumTitlebarHeight + } + } else { + minimumTitlebarHeight + } + + log.debug("Detected titlebar height of \(titlebarHeight)") + + let titlebarMinY = window.frame.minY + let titlebarMaxY = window.frame.minY + titlebarHeight + let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY + let targetWindow = isInTitlebar ? window : nil + resolvedGestureWindow = targetWindow + hasResolvedGestureWindow = true + return targetWindow + + case .anywhere: + resolvedGestureWindow = window + hasResolvedGestureWindow = true + return window + } + } +} diff --git a/Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift b/Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift new file mode 100644 index 00000000..610a190a --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift @@ -0,0 +1,167 @@ +// +// MultitouchTrigger+Magnify.swift +// Loop +// +// Created by Kai Azim on 2026-01-30. +// + +import CoreGraphics +import Subsurface + +/// Magnify handling lives separately from the main trigger as it has its own +/// activation threshold and reversal model, including direction changes between +/// magnify-in and magnify-out gestures and radial-menu center commits +extension MultitouchTrigger { + func handleMagnify(_ magnify: SubsurfaceGestureEvent.MagnifyEvent, fingerCount: Int) async { + guard let entry = recognizerRegistry.entry(for: fingerCount) else { return } + + // Radial menu magnify triggers the center action regardless of direction. + if let radialMenuGesture = entry.radialMenuGesture { + await handleRadialMenuMagnify(magnify, fingerCount: fingerCount, gesture: radialMenuGesture) + return + } + + switch magnify.phase { + case .began, .changed: + if magnify.phase == .began, + recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { + recognizerRegistry.session(for: fingerCount)?.reset() + } + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } + + if !session.hasGestureBegun { + let initialGesture = magnify.distance >= magnify.originDistance ? entry.magnifyOutGesture : entry.magnifyInGesture + guard let initialGesture else { + return + } + handleGestureBegan(fingerCount: fingerCount, gesture: initialGesture) + recognizerRegistry.session(for: fingerCount)?.setResolvedGesture(initialGesture) + } + + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected, + let activeGesture = session.resolvedGesture + else { + return + } + + guard await activateGestureIfNeeded( + fingerCount: fingerCount, + magnifyDisplacement: magnify.distance - magnify.originDistance + ), + let session = recognizerRegistry.session(for: fingerCount), + !session.isGestureRejected + else { + return + } + + if magnifyReversalDetected(currentGesture: activeGesture, magnify: magnify) { + let opposite = activeGesture.kind == .magnifyIn ? entry.magnifyOutGesture : entry.magnifyInGesture + handleMagnifyReversal( + fingerCount: fingerCount, + currentGesture: activeGesture, + oppositeGesture: opposite, + distance: magnify.distance + ) + return + } + + let allowsRapidRepeatAction = resolvedWindowAction(from: activeGesture).map { + $0.allowsRapidRepeat || $0.direction == .cycle + } ?? false + + session.commitMagnify( + gesture: activeGesture, + distance: magnify.distance, + step: magnifyStepSize, + allowsRapidRepeat: allowsRapidRepeatAction + ) { reverse in + triggerSingleAction(from: activeGesture, reverse: reverse) + } + + if let window = session.pendingTargetWindow, + resolvedWindowAction(from: activeGesture)?.allowsRapidRepeat == true { + targetResolver.rememberRepeatableWindow(window, allowsRapidRepeat: true) + } + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + /// Magnify within a radial menu gesture triggers the center (last) radial menu action. + private func handleRadialMenuMagnify( + _ magnify: SubsurfaceGestureEvent.MagnifyEvent, + fingerCount: Int, + gesture: GestureBinding + ) async { + switch magnify.phase { + case .began, .changed: + if magnify.phase == .began, recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { + handleGestureBegan(fingerCount: fingerCount, gesture: gesture) + } + guard await activateGestureIfNeeded( + fingerCount: fingerCount, + magnifyDisplacement: magnify.distance - magnify.originDistance + ) else { return } + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } + + let actions = radialMenuActions + guard !actions.isEmpty else { return } + let centerActionIndex = actions.count - 1 + + session.commitRadialMagnify( + distance: magnify.distance, + originDistance: magnify.originDistance, + step: magnifyStepSize + ) { reverse in + triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: reverse) + } + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + private func magnifyReversalDetected( + currentGesture: GestureBinding, + magnify: SubsurfaceGestureEvent.MagnifyEvent + ) -> Bool { + switch currentGesture.kind { + case .magnifyIn: + magnify.distance >= magnify.originDistance + case .magnifyOut: + magnify.distance <= magnify.originDistance + default: + false + } + } + + private func handleMagnifyReversal( + fingerCount: Int, + currentGesture: GestureBinding, + oppositeGesture: GestureBinding?, + distance: CGFloat + ) { + if let oppositeGesture { + guard let session = recognizerRegistry.session(for: fingerCount) else { return } + session.switchMagnifyGesture(to: oppositeGesture, distance: distance) + triggerSingleAction(from: oppositeGesture, reverse: false) + + if let window = session.pendingTargetWindow, + resolvedWindowAction(from: oppositeGesture)?.allowsRapidRepeat == true { + targetResolver.rememberRepeatableWindow(window, allowsRapidRepeat: true) + } + } else if isCycleAction(currentGesture) { + triggerSingleAction(from: currentGesture, reverse: true) + recognizerRegistry.session(for: fingerCount)?.updateLastCommitMagnifyDistance(distance) + } else { + resetLoopState(for: fingerCount, forceClose: true) + } + } +} diff --git a/Loop/Core/Multitouch/MultitouchTrigger+Swipe.swift b/Loop/Core/Multitouch/MultitouchTrigger+Swipe.swift new file mode 100644 index 00000000..f12afa12 --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchTrigger+Swipe.swift @@ -0,0 +1,256 @@ +// +// MultitouchTrigger+Swipe.swift +// Loop +// +// Created by Kai Azim on 2026-01-30. +// + +import CoreGraphics +import Subsurface + +/// Swipe handling lives separately from the main trigger as it has its own +/// gesture-selection rules: angle-to-direction mapping, origin-crossing reversal, +/// and radial-menu slot selection all build on swipe-specific geometry +extension MultitouchTrigger { + func handleSwipe(_ swipe: SubsurfaceGestureEvent.SwipeEvent, fingerCount: Int) async { + guard let entry = recognizerRegistry.entry(for: fingerCount) else { return } + + if let radialMenuGesture = entry.radialMenuGesture { + await handleRadialMenuSwipe(swipe, fingerCount: fingerCount, gesture: radialMenuGesture) + } else { + let direction = directionalSwipeKind(angle: swipe.angle) + let directionalGesture = entry.directionalGestures.first { $0.kind == direction } + if directionalGesture != nil || entry.session.hasGestureBegun { + await handleDirectionalSwipe( + swipe, + fingerCount: fingerCount, + direction: direction, + matchedGesture: directionalGesture + ) + } + } + } + + private func handleRadialMenuSwipe( + _ swipe: SubsurfaceGestureEvent.SwipeEvent, + fingerCount: Int, + gesture: GestureBinding + ) async { + switch swipe.phase { + case .began, .changed: + if swipe.phase == .began, recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { + handleGestureBegan(fingerCount: fingerCount, gesture: gesture) + } + guard await activateGestureIfNeeded(fingerCount: fingerCount), + let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected + else { + return + } + + let normalizedAngle = normalizedAngle(fromSubsurfaceAngle: swipe.angle) + let actions = radialMenuActions.dropLast() + guard actions.count > 1 else { return } + + let newIndex: Int + if actions.count == cardinalBiasedRadialMenuActionCount { + 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 + } + + session.commitSwipe( + distance: swipe.distance, + newKey: .radialSlot(newIndex), + step: swipeCycleStepSize + ) { reverse in + triggerRadialMenuAction(at: newIndex, from: actions, reverse: reverse) + } + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + private func handleDirectionalSwipe( + _ swipe: SubsurfaceGestureEvent.SwipeEvent, + fingerCount: Int, + direction: GestureBinding.Kind, + matchedGesture: GestureBinding? + ) async { + guard let entry = recognizerRegistry.entry(for: fingerCount) else { return } + + switch swipe.phase { + case .began, .changed: + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } + + if !session.hasGestureBegun { + guard let matchedGesture else { return } + handleGestureBegan(fingerCount: fingerCount, gesture: matchedGesture) + recognizerRegistry.session(for: fingerCount)?.setResolvedGesture(matchedGesture) + } + + guard await activateGestureIfNeeded(fingerCount: fingerCount), + let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected, + let activeGesture = session.resolvedGesture ?? matchedGesture + else { + return + } + + if direction != activeGesture.kind { + if let oppositeDirection = oppositeDirectionalSwipeKind(of: activeGesture.kind), + direction == oppositeDirection { + let oppositeGesture = entry.directionalGestures.first { $0.kind == oppositeDirection } + handleSwipeReversal( + fingerCount: fingerCount, + currentGesture: activeGesture, + oppositeGesture: oppositeGesture, + distance: swipe.distance, + hasCrossedOrigin: hasSwipeCrossedOrigin(translation: swipe.translation, currentGesture: activeGesture) + ) + } else if let matchedGesture { + switchSwipeGesture(fingerCount: fingerCount, to: matchedGesture, distance: swipe.distance) + } else { + resetLoopState(for: fingerCount, forceClose: true) + } + return + } + + session.commitSwipe( + distance: swipe.distance, + newKey: .gesture(activeGesture.id), + step: swipeCycleStepSize + ) { reverse in + triggerSingleAction(from: activeGesture, reverse: reverse) + } + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + private func oppositeDirectionalSwipeKind(of kind: GestureBinding.Kind) -> GestureBinding.Kind? { + switch kind { + case .swipeUp: + .swipeDown + case .swipeDown: + .swipeUp + case .swipeLeft: + .swipeRight + case .swipeRight: + .swipeLeft + default: + nil + } + } + + private func hasSwipeCrossedOrigin(translation: CGPoint, currentGesture: GestureBinding) -> Bool { + let projection: CGFloat = switch currentGesture.kind { + case .swipeUp: + translation.y + case .swipeDown: + -translation.y + case .swipeRight: + translation.x + case .swipeLeft: + -translation.x + default: + 0 + } + + return projection < 0 + } + + private func switchSwipeGesture( + fingerCount: Int, + to gesture: GestureBinding, + distance: CGFloat + ) { + guard let session = recognizerRegistry.session(for: fingerCount) else { return } + session.switchSwipeGesture(to: gesture, distance: distance) + triggerSingleAction(from: gesture, reverse: false) + + if let window = session.pendingTargetWindow, + resolvedWindowAction(from: gesture)?.allowsRapidRepeat == true { + targetResolver.rememberRepeatableWindow(window, allowsRapidRepeat: true) + } + } + + private func handleSwipeReversal( + fingerCount: Int, + currentGesture: GestureBinding, + oppositeGesture: GestureBinding?, + distance: CGFloat, + hasCrossedOrigin: Bool + ) { + if hasCrossedOrigin, let oppositeGesture { + guard let session = recognizerRegistry.session(for: fingerCount) else { return } + session.switchSwipeGesture(to: oppositeGesture, distance: distance) + triggerSingleAction(from: oppositeGesture, reverse: false) + + if let window = session.pendingTargetWindow, + resolvedWindowAction(from: oppositeGesture)?.allowsRapidRepeat == true { + targetResolver.rememberRepeatableWindow(window, allowsRapidRepeat: true) + } + } else if isCycleAction(currentGesture) { + triggerSingleAction(from: currentGesture, reverse: true) + recognizerRegistry.session(for: fingerCount)?.updateLastCommitSwipeDistance(distance) + } else { + resetLoopState(for: fingerCount, forceClose: true) + } + } + + private func directionalSwipeKind(angle: CGFloat) -> GestureBinding.Kind { + let normalizedAngle = normalizedAngle(fromSubsurfaceAngle: angle) + + if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { + return .swipeUp + } else if normalizedAngle >= .pi / 4, normalizedAngle < 3 * .pi / 4 { + return .swipeRight + } else if normalizedAngle >= 3 * .pi / 4, normalizedAngle < 5 * .pi / 4 { + return .swipeDown + } else { + return .swipeLeft + } + } + + private func normalizedAngle(fromSubsurfaceAngle angle: CGFloat) -> CGFloat { + // Subsurface emits y-up angles (counterclockwise from +x); Loop uses 0 = up, growing clockwise. + let angleFromOrigin = .pi / 2 - angle + var normalizedAngle = angleFromOrigin + if normalizedAngle < 0 { normalizedAngle += 2 * .pi } + return normalizedAngle + } + + 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/Core/Multitouch/MultitouchTrigger.swift b/Loop/Core/Multitouch/MultitouchTrigger.swift new file mode 100644 index 00000000..af967a6b --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchTrigger.swift @@ -0,0 +1,290 @@ +// +// 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 -> LoopOpenResult + private let closeCallback: (Bool) -> () + private let changeAction: (WindowAction, Bool) -> () + private let checkIfLoopOpen: () -> Bool + + private let gestureMonitor = SubsurfaceMonitor() + private let gestureBlocker: MultitouchGestureBlocker = .init() + lazy var recognizerRegistry = MultitouchRecognizerRegistry( + gestureMonitor: gestureMonitor + ) { [weak self] event, fingerCount in + guard let self else { return } + await handleGestureEvent(event, fingerCount: fingerCount) + } + + let targetResolver = MultitouchTargetResolver() + + private var gesturesObservationTask: Task<(), Never>? + private var radialMenuActionsObservationTask: Task<(), Never>? + private var systemGestureReconciliationTask: Task<(), Never>? + private var isStarted = false + + let swipeCycleStepSize: CGFloat = 0.15 + let magnifyStepSize: CGFloat = 0.2 + let cardinalBiasedRadialMenuActionCount = 8 + + var radialMenuActions = RadialMenuAction.userConfiguredActions + + private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) + + init( + windowActionCache: WindowActionCache, + openCallback: @escaping (WindowAction, Window) async throws -> LoopOpenResult, + 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 + + startSystemGestureReconciliation() + } + + func start() { + guard !isStarted else { return } + isStarted = true + + startSystemGestureReconciliation() + gestureMonitor.start() + rebuildRecognizers() + radialMenuActions = RadialMenuAction.userConfiguredActions + + gesturesObservationTask = Task { [weak self] in + // Watch keybinds too, so gestures referencing a deleted keybind stay in sync. + for await _ in Defaults.updates(.gestures, .keybinds, initial: false) { + guard !Task.isCancelled, let self else { break } + rebuildRecognizers() + } + } + + radialMenuActionsObservationTask = Task { [weak self] in + for await _ in Defaults.updates(.enableRadialMenuCustomization, .radialMenuActions, initial: false) { + guard !Task.isCancelled, let self else { break } + radialMenuActions = RadialMenuAction.userConfiguredActions + } + } + } + + func stop() { + guard isStarted else { return } + isStarted = false + + reconcileSystemGestures() + gesturesObservationTask?.cancel() + gesturesObservationTask = nil + radialMenuActionsObservationTask?.cancel() + radialMenuActionsObservationTask = nil + + handleStopResults(recognizerRegistry.stopAll()) + + gestureMonitor.stop() + targetResolver.reset() + } + + 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() { + handleStopResults(recognizerRegistry.rebuild(with: Defaults[.gestures])) + } + + private func handleStopResults(_ stopResults: [MultitouchRecognizerRegistry.StopResult]) { + for stopResult in stopResults { + if stopResult.didOpenLoopWithGesture { + closeCallback(false) + } + gestureBlocker.stop() + } + } + + private func handleGestureEvent(_ event: SubsurfaceGestureEvent, fingerCount: Int) async { + switch event { + case let .swipe(swipe): + await handleSwipe(swipe, fingerCount: fingerCount) + case let .magnify(magnify): + await handleMagnify(magnify, fingerCount: fingerCount) + case .determining: + await handleEarlyRadialMenuGesture(phase: .determining, fingerCount: fingerCount) + case .unresolvedEnded: + await handleEarlyRadialMenuGesture(phase: event.phase, fingerCount: fingerCount) + case let .rotation(rotation): + await handleEarlyRadialMenuGesture(phase: rotation.phase, fingerCount: fingerCount) + } + } + + /// 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. + func handleGestureBegan(fingerCount: Int, gesture: GestureBinding) { + let allowsRapidRepeat = resolvedWindowAction(from: gesture)?.allowsRapidRepeat == true + let window = targetResolver.targetWindow(for: gesture, allowsRapidRepeat: allowsRapidRepeat) + + let loopWasAlreadyOpen = checkIfLoopOpen() + + guard let session = recognizerRegistry.session(for: fingerCount) else { return } + guard session.begin(targetWindow: window, loopWasAlreadyOpen: loopWasAlreadyOpen) else { + return + } + + targetResolver.rememberRepeatableWindow(window, allowsRapidRepeat: allowsRapidRepeat) + + gestureBlocker.start() + } + + private func handleEarlyRadialMenuGesture( + phase: SubsurfaceGesturePhase, + fingerCount: Int + ) async { + guard !Defaults[.hideOnNoSelection], + let gesture = recognizerRegistry.entry(for: fingerCount)?.radialMenuGesture else { return } + + switch phase { + case .determining, .began, .changed: + if recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { + handleGestureBegan(fingerCount: fingerCount, gesture: gesture) + } + _ = await activateGestureIfNeeded(fingerCount: fingerCount) + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + /// Opens Loop on the target window resolved at `.began`. Magnify In and magnifyOut gestures + /// gate on the magnify activation threshold; swipe gestures activate on the first + /// `.began` event Subsurface emits. + func activateGestureIfNeeded( + fingerCount: Int, + magnifyDisplacement: CGFloat? = nil + ) async -> Bool { + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return false } + + if let magnifyDisplacement, + abs(magnifyDisplacement) < magnifyStepSize { + return false + } + + if session.hasActivated { return true } + + var openedLoop = false + if let window = session.pendingTargetWindow { + do { + let result = try await openCallback(.init(.noSelection), window) + openedLoop = result == .opened + } catch { + if recognizerRegistry.contains(session: session, for: fingerCount) { + session.reject() + } + gestureBlocker.stop() + return false + } + } + + guard recognizerRegistry.contains(session: session, for: fingerCount) else { return false } + session.markActivated(openedLoop: openedLoop) + return true + } + + func resetLoopState(for fingerCount: Int, forceClose: Bool = false) { + if recognizerRegistry.session(for: fingerCount)?.didOpenLoopWithThisGesture == true { + closeCallback(forceClose) + } + + gestureBlocker.stop() + recognizerRegistry.session(for: fingerCount)?.reset() + targetResolver.resetGestureState() + } +} + +// MARK: - Actions + +extension MultitouchTrigger { + func isCycleAction(_ gesture: GestureBinding) -> Bool { + resolvedWindowAction(from: gesture)?.direction == .cycle + } + + 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) + } + + func resolvedWindowAction(from gesture: GestureBinding) -> 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) + } + } + + func triggerSingleAction(from gesture: GestureBinding, 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 + } +} diff --git a/Loop/Core/Multitouch/SystemGestureManager.swift b/Loop/Core/Multitouch/SystemGestureManager.swift new file mode 100644 index 00000000..97820311 --- /dev/null +++ b/Loop/Core/Multitouch/SystemGestureManager.swift @@ -0,0 +1,720 @@ +// +// 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 + } + } + } + + struct PreferenceLedger { + var backups: [String: SystemGesturePreferenceValue] + var managedValues: [String: SystemGesturePreferenceValue] + } + + static func restore() { + var ledger = PreferenceLedger( + backups: Defaults[.systemGesturePreferenceBackups], + managedValues: Defaults[.systemGestureManagedValues] + ) + + Self().restoreAll(&ledger) + + Defaults[.systemGesturePreferenceBackups] = ledger.backups + Defaults[.systemGestureManagedValues] = ledger.managedValues + } + + static func reconcile( + enableGestures: Bool, + disableConflicts: Bool, + gestures: [GestureBinding] + ) { + var ledger = PreferenceLedger( + backups: Defaults[.systemGesturePreferenceBackups], + managedValues: Defaults[.systemGestureManagedValues] + ) + + Self().reconcile( + enableGestures: enableGestures, + disableConflicts: disableConflicts, + gestures: gestures, + ledger: &ledger + ) + + Defaults[.systemGesturePreferenceBackups] = ledger.backups + Defaults[.systemGestureManagedValues] = ledger.managedValues + } + + func reconcile( + enableGestures: Bool, + disableConflicts: Bool, + gestures: [GestureBinding], + ledger: inout PreferenceLedger + ) { + normalizeStoredValues(&ledger.backups) + normalizeStoredValues(&ledger.managedValues) + + guard enableGestures, disableConflicts else { + restoreAll(&ledger) + return + } + + let desiredValues = desiredValues(for: gestures, backups: ledger.backups, managedValues: ledger.managedValues) + guard !desiredValues.isEmpty else { + restoreAll(&ledger) + return + } + + restoreNoLongerManagedValues( + desiredValues: desiredValues, + ledger: &ledger + ) + + 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 = ledger.managedValues[identifier.compositeKey].map(identifier.normalized) + + if currentValue != lastManagedValue, currentValue != desiredValue { + ledger.backups[identifier.compositeKey] = currentValue + } + + let backupValue = ledger.backups[identifier.compositeKey].map(identifier.normalized) + guard currentValue != desiredValue else { + if backupValue != nil { + ledger.managedValues[identifier.compositeKey] = desiredValue + } else { + ledger.managedValues.removeValue(forKey: identifier.compositeKey) + } + continue + } + + guard backupValue != nil else { + ledger.managedValues.removeValue(forKey: identifier.compositeKey) + continue + } + + identifier.set(desiredValue) + ledger.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(_ ledger: inout PreferenceLedger) { + normalizeStoredValues(&ledger.backups) + normalizeStoredValues(&ledger.managedValues) + + let identifiers = Set(ledger.backups.keys) + .union(ledger.managedValues.keys) + .compactMap(SystemGesturePreferenceIdentifier.init(compositeKey:)) + restore(identifiers, ledger: &ledger) + ledger.backups.removeAll() + ledger.managedValues.removeAll() + } + + private func restoreNoLongerManagedValues( + desiredValues: [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue], + ledger: inout PreferenceLedger + ) { + let desiredKeys = Set(desiredValues.keys.map(\.compositeKey)) + let staleIdentifiers = ledger.managedValues.keys + .filter { !desiredKeys.contains($0) } + .compactMap(SystemGesturePreferenceIdentifier.init(compositeKey:)) + + restore(staleIdentifiers, ledger: &ledger) + } + + private func restore( + _ identifiers: [SystemGesturePreferenceIdentifier], + ledger: inout PreferenceLedger + ) { + guard !identifiers.isEmpty else { return } + + var touchedDomains = Set() + var touchedDock = false + var touchedTrackpad = false + + for identifier in identifiers { + guard let backupValue = ledger.backups[identifier.compositeKey].map(identifier.normalized) else { + ledger.managedValues.removeValue(forKey: identifier.compositeKey) + continue + } + + identifier.set(backupValue) + ledger.backups.removeValue(forKey: identifier.compositeKey) + ledger.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: [GestureBinding], + 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/KeybindTrigger.swift b/Loop/Core/Observers/KeybindTrigger.swift index 4bfb129f..53a15d68 100644 --- a/Loop/Core/Observers/KeybindTrigger.swift +++ b/Loop/Core/Observers/KeybindTrigger.swift @@ -186,7 +186,7 @@ final class KeybindTrigger { // Try an match directly with the action keys first, then fallback to just the key code. // This prevents failures when the user is tapping the keys in rapid succession. if let action = windowActionCache.actionsByKeybind[actionKeys] { - if !isARepeat || action.canRepeat { + if !isARepeat || action.allowsRapidRepeat { openLoop(startingAction: action, overrideExistingTriggerDelayTimerAction: true) } @@ -204,7 +204,7 @@ final class KeybindTrigger { return .opening } } else if let bypassedAction = windowActionCache.bypassedActionsByKeybind[allPressedKeysBaseModifiers] { - if !isARepeat || bypassedAction.canRepeat { + if !isARepeat || bypassedAction.allowsRapidRepeat { openLoop(startingAction: bypassedAction, overrideExistingTriggerDelayTimerAction: true) } diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 64a70394..fac19bd6 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -7,90 +7,97 @@ // NOTE: While iCloud is enabled, its service is currently disabled to make GitHub actions work. import Defaults +import Scribe import SwiftUI // MARK: - UI-configurable Settings extension Defaults.Keys { // Icon - static let currentIcon = Key("currentIcon", default: "AppIcon-Classic", iCloud: true) - static let timesLooped = Key("timesLooped", default: 0, iCloud: true) - static let showDockIcon = Key("showDockIcon", default: false, iCloud: true) - static let notificationWhenIconUnlocked = Key("notificationWhenIconUnlocked", default: true, iCloud: true) + static let currentIcon = Key("currentIcon", default: "AppIcon-Classic") + static let timesLooped = Key("timesLooped", default: 0) + static let showDockIcon = Key("showDockIcon", default: false) + static let notificationWhenIconUnlocked = Key("notificationWhenIconUnlocked", default: true) // Accent Color - static let accentColorMode: Key = Key("accentColorMode", default: .system, iCloud: true) - static let customAccentColor = Key("customAccentColor", default: .teal, iCloud: true) - static let useGradient = Key("useGradient", default: false, iCloud: true) - static let gradientColor = Key("gradientColor", default: .blue, iCloud: true) + static let accentColorMode: Key = Key("accentColorMode", default: .system) + static let customAccentColor = Key("customAccentColor", default: .teal) + static let useGradient = Key("useGradient", default: false) + static let gradientColor = Key("gradientColor", default: .blue) // Radial Menu - static let radialMenuVisibility = Key("radialMenuVisibility", default: true, iCloud: true) - static let radialMenuCornerRadius = Key("radialMenuCornerRadius", default: 50, iCloud: true) - static let radialMenuThickness = Key("radialMenuThickness", default: 22, iCloud: true) - static let radialMenuActions = Key<[RadialMenuAction]>("radialMenuActions", default: RadialMenuAction.defaultRadialMenuActions, iCloud: true) + static let radialMenuVisibility = Key("radialMenuVisibility", default: true) + static let radialMenuCornerRadius = Key("radialMenuCornerRadius", default: 50) + static let radialMenuThickness = Key("radialMenuThickness", default: 22) + static let radialMenuActions = Key<[RadialMenuAction]>("radialMenuActions", default: RadialMenuAction.defaultRadialMenuActions) // Preview - static let previewVisibility = Key("previewVisibility", default: true, iCloud: true) - static let previewPadding = Key("previewPadding", default: 10, iCloud: true) - static let previewCornerRadius = Key("previewCornerRadius", default: 10, iCloud: true) - static let previewBorderThickness = Key("previewBorderThickness", default: 4, iCloud: true) - static let previewUseWindowCornerRadius = Key("previewUseWindowCornerRadius", default: true, iCloud: true) - static let previewBackgroundEnableBlur = Key("previewBackgroundEnableBlur", default: true, iCloud: true) - static let previewBackgroundAccentOpacity = Key("previewBackgroundAccentOpacity", default: 0.1, iCloud: true) + static let previewVisibility = Key("previewVisibility", default: true) + static let previewPadding = Key("previewPadding", default: 10) + static let previewCornerRadius = Key("previewCornerRadius", default: 10) + static let previewBorderThickness = Key("previewBorderThickness", default: 4) + static let previewUseWindowCornerRadius = Key("previewUseWindowCornerRadius", default: true) + static let previewBackgroundEnableBlur = Key("previewBackgroundEnableBlur", default: true) + static let previewBackgroundAccentOpacity = Key("previewBackgroundAccentOpacity", default: 0.1) // Behavior - static let launchAtLogin = Key("launchAtLogin", default: false, iCloud: true) - static let startHidden = Key("startHidden", default: false, iCloud: true) + static let launchAtLogin = Key("launchAtLogin", default: false) + static let startHidden = Key("startHidden", default: false) static let hideMenuBarIcon = Key("hideMenuBarIcon", default: false, iCloud: false) - static let animationConfiguration = Key("animationConfiguration", default: .snappy, iCloud: true) - static let windowSnapping = Key("windowSnapping", default: false, iCloud: true) - static let suppressMissionControlOnTopDrag = Key("suppressMissionControlOnTopDrag", default: true, iCloud: true) - static let restoreWindowFrameOnDrag = Key("restoreWindowFrameOnDrag", default: false, iCloud: true) - static let enablePadding = Key("enablePadding", default: false, iCloud: true) - static let padding = Key("padding", default: .zero, iCloud: true) - static let useScreenWithCursor = Key("useScreenWithCursor", default: true, iCloud: true) - static let moveCursorWithWindow = Key("moveCursorWithWindow", default: false, iCloud: true) - static let resizeWindowUnderCursor = Key("resizeWindowUnderCursor", default: false, iCloud: true) - static let focusWindowOnResize = Key("focusWindowOnResize", default: true, iCloud: true) - static let respectStageManager = Key("respectStageManager", default: true, iCloud: true) - static let stageStripSize = Key("stageStripSize", default: 150, iCloud: true) - static let animateStashedWindows = Key("animateStashedWindows", default: true, iCloud: true) - static let stashedWindowVisiblePadding = Key("stashedWindowVisiblePadding", default: 20, iCloud: true) - static let shiftFocusWhenStashed = Key("shiftFocusWhenStashed", default: true, iCloud: true) - static let cycleModeRestartEnabled = Key("cycleModeRestartEnabled", default: false, iCloud: true) + static let animationConfiguration = Key("animationConfiguration", default: .snappy) + static let windowSnapping = Key("windowSnapping", default: false) + static let suppressMissionControlOnTopDrag = Key("suppressMissionControlOnTopDrag", default: true) + static let restoreWindowFrameOnDrag = Key("restoreWindowFrameOnDrag", default: false) + static let enablePadding = Key("enablePadding", default: false) + static let padding = Key("padding", default: .zero) + static let useScreenWithCursor = Key("useScreenWithCursor", default: true) + static let moveCursorWithWindow = Key("moveCursorWithWindow", default: false) + static let resizeWindowUnderCursor = Key("resizeWindowUnderCursor", default: false) + static let focusWindowOnResize = Key("focusWindowOnResize", default: true) + static let respectStageManager = Key("respectStageManager", default: true) + static let stageStripSize = Key("stageStripSize", default: 150) + static let animateStashedWindows = Key("animateStashedWindows", default: true) + static let stashedWindowVisiblePadding = Key("stashedWindowVisiblePadding", default: 20) + static let shiftFocusWhenStashed = Key("shiftFocusWhenStashed", default: true) + static let cycleModeRestartEnabled = Key("cycleModeRestartEnabled", default: false) // Keybinds - static let triggerKey = Key>("trigger", default: [.kVK_Function], iCloud: true) - static let sideDependentTriggerKey = Key("sideDependentTriggerKey", default: true, iCloud: true) - static let triggerDelay = Key("triggerDelay", default: 0, iCloud: true) - static let doubleClickToTrigger = Key("doubleClickToTrigger", default: false, iCloud: true) - static let middleClickTriggersLoop = Key("middleClickTriggersLoop", default: false, iCloud: true) - static let enableTriggerDelayOnMiddleClick = Key("enableTriggerDelayOnMiddleClick", default: false, iCloud: true) - static let cycleBackwardsOnShiftPressed = Key("cycleBackwardsOnShiftPressed", default: true, iCloud: true) - static let keybinds = Key<[WindowAction]>("keybinds", default: WindowAction.defaultKeybinds, iCloud: true) + static let triggerKey = Key>("trigger", default: [.kVK_Function]) + static let sideDependentTriggerKey = Key("sideDependentTriggerKey", default: true) + static let triggerDelay = Key("triggerDelay", default: 0) + static let doubleClickToTrigger = Key("doubleClickToTrigger", default: false) + static let middleClickTriggersLoop = Key("middleClickTriggersLoop", default: false) + static let enableTriggerDelayOnMiddleClick = Key("enableTriggerDelayOnMiddleClick", default: false) + static let cycleBackwardsOnShiftPressed = Key("cycleBackwardsOnShiftPressed", default: true) + static let keybinds = Key<[WindowAction]>("keybinds", default: WindowAction.defaultKeybinds) + + // Gestures + static let enableGestures = Key("enableGestures", default: false) + static let gestures = Key<[GestureBinding]>("gestures", default: GestureBinding.defaults) + 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) - static let disableCursorInteraction = Key("disableCursorInteraction", default: false, iCloud: true) - static let ignoreFullscreen = Key("ignoreFullscreen", default: false, iCloud: true) - static let hideOnNoSelection = Key("hideOnNoSelection", default: false, iCloud: true) - static let hapticFeedback = Defaults.Key("hapticFeedback", default: true, iCloud: true) - static let enableRadialMenuCustomization = Defaults.Key("enableRadialMenuCustomization", default: false, iCloud: true) - static let sizeIncrement = Key("sizeIncrement", default: 20, iCloud: true) + static let useSystemWindowManagerWhenAvailable = Key("useSystemWindowManagerWhenAvailable", default: false) + static let animateWindowResizes = Key("animateWindowResizes", default: false) + static let disableCursorInteraction = Key("disableCursorInteraction", default: false) + static let ignoreFullscreen = Key("ignoreFullscreen", default: false) + static let hideOnNoSelection = Key("hideOnNoSelection", default: false) + static let hapticFeedback = Defaults.Key("hapticFeedback", default: true) + static let enableRadialMenuCustomization = Defaults.Key("enableRadialMenuCustomization", default: false) + static let sizeIncrement = Key("sizeIncrement", default: 20) /// Excluded apps - static let excludedApps = Key<[URL]>("excludedApps", default: [], iCloud: true) + static let excludedApps = Key<[URL]>("excludedApps", default: []) // About #if RELEASE - static let includeDevelopmentVersions = Key("includeDevelopmentVersions", default: false, iCloud: true) + static let includeDevelopmentVersions = Key("includeDevelopmentVersions", default: false) #else /// Development versions should check for development updates by default. - static let includeDevelopmentVersions = Key("includeDevelopmentVersions", default: true, iCloud: true) + static let includeDevelopmentVersions = Key("includeDevelopmentVersions", default: true) #endif - static let automaticallyUpdate = Key("automaticallyUpdate", default: false, iCloud: true) + static let automaticallyUpdate = Key("automaticallyUpdate", default: false) } // MARK: - Hidden Settings @@ -99,28 +106,28 @@ extension Defaults.Keys { /// Lock radial menu to the center of the screen /// Adjust with `defaults write com.MrKai77.Loop lockRadialMenuToCenter -bool true` /// Reset with `defaults delete com.MrKai77.Loop lockRadialMenuToCenter` - static let lockRadialMenuToCenter = Key("lockRadialMenuToCenter", default: false, iCloud: true) + static let lockRadialMenuToCenter = Key("lockRadialMenuToCenter", default: false) /// Minimum screen size, defined in inches on the diagonal, for which padding will be applied on windows. /// Adjust with `defaults write com.MrKai77.Loop paddingMinimumScreenSize -float x` /// Reset with `defaults delete com.MrKai77.Loop paddingMinimumScreenSize` - static let paddingMinimumScreenSize = Key("paddingMinimumScreenSize", default: 0, iCloud: true) + static let paddingMinimumScreenSize = Key("paddingMinimumScreenSize", default: 0) /// Ignore the notch height when calculating top padding, so the effective /// distance from the screen top matches non-notch displays. /// Adjust with `defaults write com.MrKai77.Loop ignoreNotch -bool true` /// Reset with `defaults delete com.MrKai77.Loop ignoreNotch` - static let ignoreNotch = Key("ignoreNotch", default: false, iCloud: true) + static let ignoreNotch = Key("ignoreNotch", default: false) /// Snap threshold for window snapping, defined in points. /// Adjust with `defaults write com.MrKai77.Loop snapThreshold -float x` /// Reset with `defaults delete com.MrKai77.Loop snapThreshold` - static let snapThreshold = Key("snapThreshold", default: 2, iCloud: true) + static let snapThreshold = Key("snapThreshold", default: 2) /// Whether to ignore low power mode for certain features, such as window animations. /// Adjust with `defaults write com.MrKai77.Loop ignoreLowPowerMode -bool x` /// Reset with `defaults delete com.MrKai77.Loop ignoreLowPowerMode` - static let ignoreLowPowerMode = Key("ignoreLowPowerMode", default: false, iCloud: true) + static let ignoreLowPowerMode = Key("ignoreLowPowerMode", default: false) /// Adjust with `defaults write com.MrKai77.Loop previewStartingPosition [option]` /// Reset with `defaults delete com.MrKai77.Loop previewStartingPosition` @@ -129,18 +136,38 @@ extension Defaults.Keys { /// - `screenCenter`: Center of the screen /// - `radialMenu`: Center of radial menu /// - `actionCenter`: Center of the selected action (e.g. for left half, it will grow from the center of that left half) - static let previewStartingPosition = Key("previewStartingPosition", default: .actionCenter, iCloud: true) + static let previewStartingPosition = Key("previewStartingPosition", default: .actionCenter) /// Disable automatic updates with `defaults write com.MrKai77.Loop updatesEnabled -bool false` /// Reset with `defaults delete com.MrKai77.Loop updatesEnabled` - static let updatesEnabled = Key("updatesEnabled", default: true, iCloud: true) + static let updatesEnabled = Key("updatesEnabled", default: true) /// Trigger key timeout, defined in seconds. Automatically closes Loop if no action is taken within the specified time. /// When set to 0 (default: disabled), the feature is disabled and Loop stays open until manually closed. /// Adjust with `defaults write com.MrKai77.Loop triggerKeyTimeout -float x` /// Reset with `defaults delete com.MrKai77.Loop triggerKeyTimeout` - static let triggerKeyTimeout = Key("triggerKeyTimeout", default: 0, iCloud: true) + static let triggerKeyTimeout = Key("triggerKeyTimeout", default: 0) + + /// 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: 50) + + /// Disable conflicting macOS system gestures while Loop gestures are enabled. + /// Adjust with `defaults write com.MrKai77.Loop disableConflictingSystemGestures -bool false` + /// Reset with `defaults delete com.MrKai77.Loop disableConflictingSystemGestures` + static let disableConflictingSystemGestures = Key("disableConflictingSystemGestures", default: true) + + /// Whether to sync all Loop settings to iCloud. + /// Adjust with `defaults write com.MrKai77.Loop enableiCloudSync -bool false` + /// Reset with `defaults delete com.MrKai77.Loop enableiCloudSync` + static let enableiCloudSync = Key("enableiCloudSync", default: true) +} + +// MARK: - Non-user-intended Settings +extension Defaults.Keys { // Migrator static let lastMigratorURL = Key("lastMigratorURL", default: nil) @@ -156,9 +183,98 @@ extension Defaults.Keys { // DataPatcher - static let patchesApplied = Key("patchesApplied", default: [], iCloud: true) + static let patchesApplied = Key("patchesApplied", default: []) // Settings static let showSettingsInspector = Key("showSettingsInspector", default: true) } + +// MARK: - iCloud Sync (not really an extension per se but feel like this is an approriate location) + +@Loggable(style: .static) +enum DefaultsiCloudSyncRegistrar { + static func register() { + let enabled = Defaults[.enableiCloudSync] + updateiCloudSync(enabled: enabled) + log.info("iCloud sync \(enabled ? "enabled" : "disabled")") + + Task { @MainActor in + for await enabled in Defaults.updates(.enableiCloudSync, initial: false) { + updateiCloudSync(enabled: enabled) + log.info("iCloud sync updated: \(enabled ? "enabled" : "disabled")") + } + } + } + + private static func updateiCloudSync(enabled: Bool) { + guard enabled else { + Defaults.iCloud.removeAll() + return + } + + Defaults.iCloud.add(.currentIcon) + Defaults.iCloud.add(.timesLooped) + Defaults.iCloud.add(.showDockIcon) + Defaults.iCloud.add(.notificationWhenIconUnlocked) + + Defaults.iCloud.add(.accentColorMode) + Defaults.iCloud.add(.customAccentColor) + Defaults.iCloud.add(.useGradient) + Defaults.iCloud.add(.gradientColor) + + Defaults.iCloud.add(.radialMenuVisibility) + Defaults.iCloud.add(.radialMenuCornerRadius) + Defaults.iCloud.add(.radialMenuThickness) + Defaults.iCloud.add(.radialMenuActions) + + Defaults.iCloud.add(.previewVisibility) + Defaults.iCloud.add(.previewPadding) + Defaults.iCloud.add(.previewCornerRadius) + Defaults.iCloud.add(.previewBorderThickness) + Defaults.iCloud.add(.previewUseWindowCornerRadius) + Defaults.iCloud.add(.previewBackgroundEnableBlur) + Defaults.iCloud.add(.previewBackgroundAccentOpacity) + + Defaults.iCloud.add(.startHidden) + Defaults.iCloud.add(.animationConfiguration) + Defaults.iCloud.add(.windowSnapping) + Defaults.iCloud.add(.suppressMissionControlOnTopDrag) + Defaults.iCloud.add(.restoreWindowFrameOnDrag) + Defaults.iCloud.add(.enablePadding) + Defaults.iCloud.add(.padding) + Defaults.iCloud.add(.useScreenWithCursor) + Defaults.iCloud.add(.moveCursorWithWindow) + Defaults.iCloud.add(.resizeWindowUnderCursor) + Defaults.iCloud.add(.focusWindowOnResize) + Defaults.iCloud.add(.respectStageManager) + Defaults.iCloud.add(.stageStripSize) + Defaults.iCloud.add(.animateStashedWindows) + Defaults.iCloud.add(.stashedWindowVisiblePadding) + Defaults.iCloud.add(.shiftFocusWhenStashed) + Defaults.iCloud.add(.cycleModeRestartEnabled) + + Defaults.iCloud.add(.triggerKey) + Defaults.iCloud.add(.sideDependentTriggerKey) + Defaults.iCloud.add(.triggerDelay) + Defaults.iCloud.add(.doubleClickToTrigger) + Defaults.iCloud.add(.middleClickTriggersLoop) + Defaults.iCloud.add(.enableTriggerDelayOnMiddleClick) + Defaults.iCloud.add(.cycleBackwardsOnShiftPressed) + Defaults.iCloud.add(.keybinds) + + Defaults.iCloud.add(.enableGestures) + Defaults.iCloud.add(.gestures) + + Defaults.iCloud.add(.useSystemWindowManagerWhenAvailable) + Defaults.iCloud.add(.animateWindowResizes) + Defaults.iCloud.add(.disableCursorInteraction) + Defaults.iCloud.add(.ignoreFullscreen) + Defaults.iCloud.add(.hideOnNoSelection) + Defaults.iCloud.add(.hapticFeedback) + Defaults.iCloud.add(.enableRadialMenuCustomization) + Defaults.iCloud.add(.sizeIncrement) + + Defaults.iCloud.add(.excludedApps) + } +} diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 01401a30..b7f4812c 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -621,6 +621,20 @@ } } }, + "%lld-finger %@" : { + "comment" : "Default title describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Magnify In', 'Swipe Up').", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld-finger %2$@" + } + } + } + }, + "%lld-finger Swipe or Magnify" : { + "comment" : "Default title describing how to activate a radial menu gesture. Argument is the finger count." + }, "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" : { @@ -1153,7 +1167,7 @@ } }, "Add" : { - "comment" : "Used to add items to a list", + "comment" : "Button to add a new gesture", "localizations" : { "ar" : { "stringUnit" : { @@ -1860,6 +1874,9 @@ } } }, + "Anywhere" : { + "comment" : "Gesture activation zone covers the entire window" + }, "App icon is locked" : { "localizations" : { "ar" : { @@ -2390,6 +2407,9 @@ } } }, + "Both" : { + "comment" : "Gesture category label: swipe and magnify gestures\nGesture kind: swipe and magnify gestures; opens the radial menu" + }, "Bottom" : { "comment" : "Label for a slider in Loop’s padding settings", "localizations" : { @@ -4165,6 +4185,9 @@ } } }, + "Continue the swipe or magnify gesture to step through cycle actions." : { + "comment" : "Section footer shown in settings" + }, "Contributors on GitHub" : { "localizations" : { "ar" : { @@ -4964,7 +4987,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 +5075,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 +5170,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 +5879,9 @@ } } }, + "Direction" : { + "comment" : "Label for the swipe direction picker in the gesture configuration modal" + }, "Disable cursor interaction" : { "localizations" : { "ar" : { @@ -6202,6 +6234,9 @@ } } }, + "Down" : { + "comment" : "Swipe direction picker option: down" + }, "Enable blur" : { "comment" : "Text for enabling the blur effect on the preview.", "localizations" : { @@ -6291,6 +6326,9 @@ } } }, + "Enable trackpad 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" : { + "comment" : "Label for the magnify gesture picker in the gesture configuration modal" + }, + "Gestures" : { + "comment" : "Section header shown in gestures settings" + }, "Go Back" : { "comment" : "Section header in the action picker of the Keybinds tab", "localizations" : { @@ -13110,7 +13157,7 @@ } }, "Left" : { - "comment" : "Label for a slider in Loop’s padding settings\nSide of a trigger key", + "comment" : "Label for a slider in Loop’s padding settings\nSide of a trigger key\nSwipe direction picker option: left", "localizations" : { "ar" : { "stringUnit" : { @@ -16918,6 +16965,15 @@ } } }, + "Magnify" : { + "comment" : "Gesture category label: magnify" + }, + "Magnify In" : { + "comment" : "Gesture kind: magnify inward\nMagnify gesture picker option: magnifyIn" + }, + "Magnify Out" : { + "comment" : "Gesture kind: magnify outward\nMagnify gesture picker option: magnifyOut" + }, "Maximize" : { "comment" : "Window action", "localizations" : { @@ -18607,7 +18663,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" : { @@ -18783,6 +18839,9 @@ } } }, + "No gestures" : { + "comment" : "Empty state title in gestures settings" + }, "No keybinds" : { "localizations" : { "ar" : { @@ -24245,6 +24304,9 @@ } } }, + "Press \"Add\" to add a gesture" : { + "comment" : "Empty state subtitle in gestures settings" + }, "Press \"Add\" to add a keybind" : { "localizations" : { "ar" : { @@ -25132,7 +25194,7 @@ } }, "Radial Menu" : { - "comment" : "Section header shown in settings", + "comment" : "Label shown for a gesture configured to open the radial menu\nSection header shown in settings", "localizations" : { "ar" : { "stringUnit" : { @@ -25487,7 +25549,7 @@ } }, "Remove" : { - "comment" : "Used to remove items from a list", + "comment" : "Button to remove selected gestures", "localizations" : { "ar" : { "stringUnit" : { @@ -26283,7 +26345,7 @@ } }, "Right" : { - "comment" : "Label for a slider in Loop’s padding settings\nSide of a trigger key", + "comment" : "Label for a slider in Loop’s padding settings\nSide of a trigger key\nSwipe direction picker option: right", "localizations" : { "ar" : { "stringUnit" : { @@ -28141,6 +28203,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" : { @@ -30886,6 +30961,21 @@ } } }, + "Swipe" : { + "comment" : "Gesture category label: swipe" + }, + "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" : { @@ -31415,6 +31505,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" : { @@ -32036,6 +32129,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" : { @@ -33545,6 +33641,9 @@ } } }, + "Up" : { + "comment" : "Swipe direction picker option: up" + }, "Update from: %@" : { "comment" : "A label at the bottom of the update view that indicates the current app version and a message suggesting an update. The argument is the current app version.", "localizations" : { @@ -35939,5 +36038,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift new file mode 100644 index 00000000..38c3bf15 --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift @@ -0,0 +1,310 @@ +// +// GestureConfigurationView.swift +// Loop +// +// Created by Kai Azim on 2026-04-16. +// + +import Luminare +import SwiftUI + +struct GestureConfigurationView: View { + @Environment(\.luminareAnimation) private var luminareAnimation + + @State private var gesture: GestureBinding + @Binding private var externalGesture: GestureBinding + @Binding private var isPresented: Bool + + init(gesture: Binding, isPresented: Binding) { + self.gesture = gesture.wrappedValue + self._externalGesture = gesture + self._isPresented = isPresented + } + + 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))) + } + } + ) + } + + private var categoryBinding: Binding { + Binding( + get: { GestureCategory(kind: gesture.kind) }, + set: { category in + switch category { + case .all: + kindBinding.wrappedValue = .radialMenu + case .swipe: + if !gesture.kind.isDirectionalSwipe { + kindBinding.wrappedValue = .swipeRight + } + case .magnify: + kindBinding.wrappedValue = .magnifyIn + } + } + ) + } + + private var swipeDirectionBinding: Binding { + Binding( + get: { SwipeDirection(kind: gesture.kind) ?? .right }, + set: { kindBinding.wrappedValue = $0.kind } + ) + } + + private var magnifyGestureBinding: Binding { + Binding( + get: { MagnifyGesture(kind: gesture.kind) ?? .magnifyIn }, + set: { kindBinding.wrappedValue = $0.kind } + ) + } + + private var fingerCountBinding: Binding { + Binding( + get: { gesture.fingerCount }, + set: { count in + gesture.fingerCount = count + + if isActivationZoneDisabled { + gesture.activationZone = .titlebar + } + } + ) + } + + private var isActivationZoneDisabled: Bool { + gesture.fingerCount <= 2 + } + + var body: some View { + LuminareForm { + LuminareSection(outerPadding: 0) { + LuminareTextField( + gesture.defaultName, + text: Binding( + get: { gesture.name ?? "" }, + set: { gesture.name = $0 } + ) + ) + .luminareFilledStates(.none) + .luminareBorderedStates(.none) + } + + LuminareSection { + LuminarePicker( + compactElements: GestureCategory.allCases, + selection: categoryBinding + ) { category in + HStack(spacing: 6) { + category.image + .frame(width: 12) + + Text(category.displayName) + } + .fixedSize() + } + .luminareContentSize(hasFixedHeight: true) + .luminareRoundingBehavior(top: true, bottom: gesture.kind == .radialMenu) + + if GestureCategory(kind: gesture.kind) == .swipe { + LuminarePickerMenu( + "Direction", + selection: swipeDirectionBinding, + items: SwipeDirection.allCases + ) { direction in + Text("\(direction.image) \(direction.displayName)") + } + } + + if GestureCategory(kind: gesture.kind) == .magnify { + LuminarePickerMenu( + "Gesture", + selection: magnifyGestureBinding, + items: MagnifyGesture.allCases + ) { magnifyGesture in + Text("\(magnifyGesture.image) \(magnifyGesture.displayName)") + } + } + } + .luminareCornerRadius(16) + + LuminareSection { + LuminareSliderPicker( + "Fingers", + Array(2...5), + selection: fingerCountBinding + ) { count in + Text("\(count)") + .monospaced() + } + .luminareSliderPickerLayout(.compact(textBoxWidth: 24)) + + LuminarePicker( + compactElements: GestureBinding.ActivationZone.allCases, + selection: $gesture.activationZone + ) { option in + HStack(spacing: 6) { + Image(systemName: option.systemImage) + + Text(option.displayName) + } + .fixedSize() + } + .luminareContentSize(hasFixedHeight: true) + .luminareRoundingBehavior(bottom: true) + .opacity(isActivationZoneDisabled ? 0.5 : 1) + .disabled(isActivationZoneDisabled) + } + .luminareCornerRadius(16) + + Button { + isPresented = false + } label: { + Text("Close", comment: "Label for a button that closes a modal window") + } + .buttonStyle(.luminare(overrideUseMainStyle: true)) + .luminareCornerRadius(8) + } + .animation(luminareAnimation, value: gesture.kind) + .onChange(of: gesture) { externalGesture = $0 } + } +} + +private enum GestureCategory: CaseIterable, Equatable { + case all + case swipe + case magnify + + init(kind: GestureBinding.Kind) { + switch kind { + case .radialMenu: + self = .all + case .swipeUp, .swipeDown, .swipeLeft, .swipeRight: + self = .swipe + case .magnifyIn, .magnifyOut: + self = .magnify + } + } + + var displayName: String { + switch self { + case .all: + String(localized: "Both", comment: "Gesture category label: swipe and magnify gestures") + case .swipe: + String(localized: "Swipe", comment: "Gesture category label: swipe") + case .magnify: + String(localized: "Magnify", comment: "Gesture category label: magnify") + } + } + + var image: Image { + switch self { + case .all: + Image(.loop) + case .swipe: + Image(systemName: "arrow.up.and.down.and.arrow.left.and.right") + case .magnify: + Image(systemName: "arrow.down.left.and.arrow.up.right") + } + } +} + +private enum SwipeDirection: CaseIterable { + case up + case down + case left + case right + + init?(kind: GestureBinding.Kind) { + switch kind { + case .swipeUp: + self = .up + case .swipeDown: + self = .down + case .swipeLeft: + self = .left + case .swipeRight: + self = .right + case .radialMenu, .magnifyIn, .magnifyOut: + return nil + } + } + + var kind: GestureBinding.Kind { + switch self { + case .up: + .swipeUp + case .down: + .swipeDown + case .left: + .swipeLeft + case .right: + .swipeRight + } + } + + var displayName: String { + switch self { + case .up: + String(localized: "Up", comment: "Swipe direction picker option: up") + case .down: + String(localized: "Down", comment: "Swipe direction picker option: down") + case .left: + String(localized: "Left", comment: "Swipe direction picker option: left") + case .right: + String(localized: "Right", comment: "Swipe direction picker option: right") + } + } + + var image: Image { + kind.image + } +} + +private enum MagnifyGesture: CaseIterable { + case magnifyIn + case magnifyOut + + init?(kind: GestureBinding.Kind) { + switch kind { + case .magnifyIn: + self = .magnifyIn + case .magnifyOut: + self = .magnifyOut + case .radialMenu, .swipeUp, .swipeDown, .swipeLeft, .swipeRight: + return nil + } + } + + var kind: GestureBinding.Kind { + switch self { + case .magnifyIn: + .magnifyIn + case .magnifyOut: + .magnifyOut + } + } + + var displayName: String { + switch self { + case .magnifyIn: + String(localized: "Magnify In", comment: "Magnify gesture picker option: magnifyIn") + case .magnifyOut: + String(localized: "Magnify Out", comment: "Magnify gesture picker option: magnifyOut") + } + } + + var image: Image { + kind.image + } +} diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift new file mode 100644 index 00000000..0f15fa9b --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -0,0 +1,249 @@ +// +// 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 + + @State private var gesture: GestureBinding + @Binding private var externalGesture: GestureBinding + private let hasConflict: Bool + + @State private var isActionPickerPresented = false + @State private var isGestureConfigPresented = false + @State private var isConfiguringCustom = false + @State private var isConfiguringCycle = false + + init(_ gesture: Binding, hasConflict: Bool) { + self.gesture = gesture.wrappedValue + self._externalGesture = gesture + self.hasConflict = hasConflict + } + + 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 { + gestureConfiguration + .luminareToolTip(attachedTo: .topTrailing, 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) + } + .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(gesture.displayName) + .fontWeight(.regular) + .lineLimit(1) + + if gesture.activationZone == .titlebar { + Text(gesture.activationZone.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 4) + .contentShape(.rect) + .animation(luminareAnimation, value: gesture.activationZone) + } + .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) + .luminareModal(isPresented: $isGestureConfigPresented) { + GestureConfigurationView( + gesture: $gesture, + isPresented: $isGestureConfigPresented + ) + .frame(width: 380) + } + .luminareModalCornerRadius(24) + } + + 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: "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..7f99a6d4 --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -0,0 +1,98 @@ +// +// 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(.gestures) private var gestures + @Default(.gestureTitlebarHeight) private var gestureTitlebarHeight + + private var conflictingGestureIDs: Set { + GestureBinding.conflictingActionableIDs(in: gestures) + } + + 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 trackpad gestures", comment: "Toggle in gestures settings"), isOn: $enableGestures) + } + } + + private var gesturesSection: some View { + LuminareSection( + String(localized: "Gestures", comment: "Section header shown in gestures settings"), + String(localized: "Continue the swipe or magnify gesture to step through cycle actions.", comment: "Section footer shown in settings") + ) { + LuminareButtonRow { + Button(String(localized: "Add", comment: "Button to add a new gesture")) { + gestures.insert( + GestureBinding(), + 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, + hasConflict: conflictingGestureIDs.contains(gesture.wrappedValue.id) + ) + } 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/Stashing/StashManager.swift b/Loop/Stashing/StashManager.swift index 2025d364..690128c3 100644 --- a/Loop/Stashing/StashManager.swift +++ b/Loop/Stashing/StashManager.swift @@ -36,6 +36,7 @@ import SwiftUI /// ## Considerations: /// - Currently supports only one revealed window at a time. @Loggable +@MainActor final class StashManager { static let shared = StashManager() private init() {} @@ -110,32 +111,31 @@ final class StashManager { } } - /// Determines whether the given window action should be intercepted by the StashManager. - /// - /// If the action targets a stashed window that is no longer visible, the currently focused - /// window will be stashed in its place. The stashed window is then either revealed or hidden, - /// depending on its current state. This allows the StashManager to take over the behavior, - /// bypassing the default flow handled by the LoopManager. - /// - /// - Parameter action: The window action triggered. - /// - Returns: `true` if the action is handled by the StashManager and the normal flow should be bypassed; otherwise, `false`. - @discardableResult - func handleIfStashed(_ action: WindowAction, screen: NSScreen) -> Bool { + /// Returns the stashed window matching the given action and screen, if one exists. + func stashedWindow(for action: WindowAction, on screen: NSScreen) -> StashedWindowInfo? { guard action.direction == .stash, let stashedWindow = store.stashedWindow(for: action, on: screen), !stashedWindow.window.isWindowHidden, !stashedWindow.window.isApplicationHidden else { + return nil + } + + return stashedWindow + } + + /// Toggles the stashed window matching the given action and screen, if one exists. + @discardableResult + func toggleStashedWindow(for action: WindowAction, on screen: NSScreen) async -> Bool { + guard let stashedWindow = stashedWindow(for: action, on: screen) else { return false } - log.info("Intercepting window action for stashed window \(stashedWindow.window.description)") + log.info("Toggling stashed window \(stashedWindow.window.description)") - Task { - if store.isWindowRevealed(stashedWindow.window.cgWindowID) { - await hideWindow(stashedWindow) - } else { - await revealWindow(stashedWindow) - } + if store.isWindowRevealed(stashedWindow.window.cgWindowID) { + await hideWindow(stashedWindow) + } else { + await revealWindow(stashedWindow) } return true @@ -434,7 +434,9 @@ private extension StashManager { .leftMouseDragged // Dragging items to stashed windows ], callback: { [weak self] cgEvent in - self?.handleMouseMoved(cgEvent: cgEvent) + Task { @MainActor in + self?.handleMouseMoved(cgEvent: cgEvent) + } } ) monitor.start() diff --git a/Loop/Stashing/StashedWindowStore.swift b/Loop/Stashing/StashedWindowStore.swift index 0948868a..8cec0bc0 100644 --- a/Loop/Stashing/StashedWindowStore.swift +++ b/Loop/Stashing/StashedWindowStore.swift @@ -10,6 +10,7 @@ import Foundation import Scribe import SwiftUI +@MainActor protocol StashedWindowsStoreDelegate: AnyObject { var stashedWindowVisiblePadding: CGFloat { get } func onStashedWindowsRestored() @@ -18,6 +19,7 @@ protocol StashedWindowsStoreDelegate: AnyObject { /// Keep the stashed windows and the revealed window ids both in memory and in Defaults. /// Restore windows stashed from a previous session. @Loggable +@MainActor final class StashedWindowsStore { weak var delegate: StashedWindowsStoreDelegate? 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..a5388a8a 100644 --- a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift +++ b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift @@ -24,23 +24,33 @@ 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 - if let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) { - self.eventTap = eventTap - self.runLoop = runLoop - self.runLoopSource = runLoopSource - CFRunLoopAddSource(runLoop, runLoopSource, .commonModes) - CFRunLoopWakeUp(runLoop) + guard let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) else { + CFMachPortInvalidate(eventTap) + Unmanaged.passUnretained(self).release() + return } + + refconRetainOutstanding = true + self.eventTap = eventTap + self.runLoop = runLoop + self.runLoopSource = runLoopSource + CFRunLoopAddSource(runLoop, runLoopSource, .commonModes) + CFRunLoopWakeUp(runLoop) } func start() { @@ -110,18 +120,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/GestureBinding.swift b/Loop/Window Management/Window Action/GestureBinding.swift new file mode 100644 index 00000000..b45e39c2 --- /dev/null +++ b/Loop/Window Management/Window Action/GestureBinding.swift @@ -0,0 +1,209 @@ +// +// GestureBinding.swift +// Loop +// +// Created by Kai Azim on 2026-04-16. +// + +import Defaults +import SwiftUI + +struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { + let id: UUID + var name: String? + var fingerCount: Int + var kind: Kind + var action: Action + var activationZone: ActivationZone + + init( + id: UUID = .init(), + name: String? = nil, + fingerCount: Int = 2, + kind: Kind = .radialMenu, + action: Action = .radialMenuActions, + activationZone: ActivationZone = .titlebar + ) { + self.id = id + self.name = name + self.fingerCount = fingerCount + self.kind = kind + self.action = action + self.activationZone = activationZone + } + + enum Kind: Codable, Hashable, CaseIterable { + /// Swipe gesture that maps angle to radial menu directional slots. + case radialMenu + /// Directional swipe gestures that trigger a single action. + case swipeUp, swipeDown, swipeLeft, swipeRight + /// Magnify In gesture (fingers together, scale < 1). + case magnifyIn + /// Magnify Out gesture (fingers apart, scale > 1). + case magnifyOut + + var displayName: String { + switch self { + case .radialMenu: String(localized: "Both", comment: "Gesture kind: swipe and magnify gestures; opens the radial menu") + case .swipeUp: String(localized: "Swipe Up", comment: "Gesture kind: directional swipe") + case .swipeDown: String(localized: "Swipe Down", comment: "Gesture kind: directional swipe") + case .swipeLeft: String(localized: "Swipe Left", comment: "Gesture kind: directional swipe") + case .swipeRight: String(localized: "Swipe Right", comment: "Gesture kind: directional swipe") + case .magnifyIn: String(localized: "Magnify In", comment: "Gesture kind: magnify inward") + case .magnifyOut: String(localized: "Magnify Out", comment: "Gesture kind: magnify outward") + } + } + + var image: Image { + switch self { + case .radialMenu: Image(.loop) + case .swipeUp: Image(systemName: "arrow.up") + case .swipeDown: Image(systemName: "arrow.down") + case .swipeLeft: Image(systemName: "arrow.left") + case .swipeRight: Image(systemName: "arrow.right") + case .magnifyIn: Image(systemName: "arrow.up.right.and.arrow.down.left") + case .magnifyOut: Image(systemName: "arrow.down.left.and.arrow.up.right") + } + } + + var isSwipe: Bool { + switch self { + case .radialMenu, .swipeUp, .swipeDown, .swipeLeft, .swipeRight: + true + case .magnifyIn, .magnifyOut: + false + } + } + + var isDirectionalSwipe: Bool { + switch self { + case .swipeUp, .swipeDown, .swipeLeft, .swipeRight: + true + default: + false + } + } + } + + enum Action: Codable, Hashable { + /// Uses `RadialMenuAction.userConfiguredActions` for radial menu swipe 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: - Naming + +extension GestureBinding { + var defaultName: String { + switch kind { + case .radialMenu: + String( + localized: "\(fingerCount)-finger Swipe or Magnify", + comment: "Default title describing how to activate a radial menu gesture. Argument is the finger count." + ) + default: + String( + localized: "\(fingerCount)-finger \(kind.displayName)", + comment: "Default title describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Magnify In', 'Swipe Up')." + ) + } + } + + var displayName: String { + if let name, !name.isEmpty { + return name + } + + return defaultName + } +} + +// MARK: - No Action + +extension GestureBinding { + var resolvesToNoAction: Bool { + switch action { + case .radialMenuActions: + false + case let .singleAction(actionType): + (actionType.resolvedAction?.direction ?? .noAction) == .noAction + } + } +} + +// MARK: - Conflict Detection + +extension GestureBinding { + /// Returns IDs for actionable gestures that conflict with at least one other actionable gesture. + static func conflictingActionableIDs(in gestures: [GestureBinding]) -> Set { + conflictingIDs(in: gestures.filter { !$0.resolvesToNoAction }) + } + + /// Two gestures conflict when they have the same finger count and their kinds overlap. + /// Radial menu consumes both swipe and magnify, so it conflicts with ANY other gesture at the same finger count. + func conflicts(with other: GestureBinding) -> Bool { + guard id != other.id, fingerCount == other.fingerCount else { + return false + } + + // Radial menu uses both swipe and magnify, 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: [GestureBinding]) -> 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 GestureBinding { + static let defaults: [GestureBinding] = [ + GestureBinding( + fingerCount: 2, + kind: .radialMenu, + action: .radialMenuActions, + activationZone: .titlebar + ) + ] +} diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index 74001fb8..618d9ec3 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -198,8 +198,20 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial return false } - var canRepeat: Bool { - willManipulateExistingWindowFrame || direction.willFocusWindow || direction == .undo + /// Whether held keys or continuous gestures may repeatedly trigger this action. + /// + /// Keyboard events can arrive repeatedly while a keybind is held down, and gesture + /// recognizers can commit repeatedly as movement crosses step thresholds. + var allowsRapidRepeat: Bool { + willManipulateExistingWindowFrame || direction.willFocusWindow + } + + /// Whether selecting this same action again should still run through Loop's action pipeline. + /// + /// Undo and stash are not rapid-repeat actions, but selecting them again should still + /// apply: undo walks back another recorded action, and stash toggles an existing stashed window. + var allowsRepeatedSelection: Bool { + allowsRapidRepeat || direction == .undo || direction == .stash } var forceProportionalFrameOnScreenChange: Bool { diff --git a/Loop/Window Management/Window Manipulation/WindowActionEngine.swift b/Loop/Window Management/Window Manipulation/WindowActionEngine.swift index d1f3c398..21bacad4 100644 --- a/Loop/Window Management/Window Manipulation/WindowActionEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowActionEngine.swift @@ -117,6 +117,12 @@ final class WindowActionEngine { return .noOp } + if direction == .stash, + let screen = context.screen, + await StashManager.shared.toggleStashedWindow(for: context.action, on: screen) { + return .noOp + } + // Quick actions that don't require resize logic if let result = handleQuickAction(context.action, window: context.window) { return result diff --git a/Loop/Window Management/Window/Window.swift b/Loop/Window Management/Window/Window.swift index 8123de83..1c1202d9 100644 --- a/Loop/Window Management/Window/Window.swift +++ b/Loop/Window Management/Window/Window.swift @@ -445,7 +445,7 @@ final class Window { @MainActor private func ownNSWindow() -> NSWindow? { - NSApp.keyWindow ?? NSApp.mainWindow + NSApp.windows.first { $0.windowNumber == cgWindowID } ?? NSApp.keyWindow ?? NSApp.mainWindow } @discardableResult @@ -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 }