From 8417afa5eb5d8d227e6b64b1342f77689b450cb2 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 7 Feb 2026 18:12:24 -0700 Subject: [PATCH 01/61] =?UTF-8?q?=F0=9F=8E=A8=20Initial=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # Loop.xcodeproj/project.pbxproj --- Loop.xcodeproj/project.pbxproj | 15 +++++++++++++++ .../Window Manipulation/WindowEngine.swift | 2 +- Loop/Window Management/Window/Window.swift | 2 +- .../Window/WindowUtility.swift | 17 ++++++++++------- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index e6cee510..a696330c 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 2A0EEE852F381A3A00C40CF4 /* Subtrack in Frameworks */ = {isa = PBXBuildFile; productRef = 2A0EEE842F381A3A00C40CF4 /* Subtrack */; }; 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28492A2F22B4B700F6CE42 /* Scribe */; }; 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B6282EE5050C00A1E26B /* Defaults */; }; 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B62B2EE5057C00A1E26B /* Luminare */; }; @@ -105,6 +106,7 @@ 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */, F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */, 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */, + 2A0EEE852F381A3A00C40CF4 /* Subtrack in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -181,6 +183,7 @@ 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */, 2A28492A2F22B4B700F6CE42 /* Scribe */, 2AF9238D2F540B1300F467FD /* Scribe */, + 2A0EEE842F381A3A00C40CF4 /* Subtrack */, ); productName = WindowManager; productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */; @@ -253,6 +256,7 @@ 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */, 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */, + 2A0EEE832F381A3A00C40CF4 /* XCLocalSwiftPackageReference "../Subtrack" */, ); productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */; projectDirPath = ""; @@ -748,6 +752,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 2A0EEE832F381A3A00C40CF4 /* XCLocalSwiftPackageReference "../Subtrack" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Subtrack; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */ = { isa = XCRemoteSwiftPackageReference; @@ -784,6 +795,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 2A0EEE842F381A3A00C40CF4 /* Subtrack */ = { + isa = XCSwiftPackageProductDependency; + productName = Subtrack; + }; 2A28492A2F22B4B700F6CE42 /* Scribe */ = { isa = XCSwiftPackageProductDependency; productName = Scribe; diff --git a/Loop/Window Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index 7c6363c5..6f88dc73 100644 --- a/Loop/Window Management/Window Manipulation/WindowEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowEngine.swift @@ -83,7 +83,7 @@ enum WindowEngine { try await resizeWindow( window, targetFrame: targetFrame, - bounds: context.bounds, + bounds: context.paddedBounds, willChangeScreens: willChangeScreens, animate: shouldAnimate ) diff --git a/Loop/Window Management/Window/Window.swift b/Loop/Window Management/Window/Window.swift index 11079688..65363560 100644 --- a/Loop/Window Management/Window/Window.swift +++ b/Loop/Window Management/Window/Window.swift @@ -437,7 +437,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 4a38a57d..d7eda1a5 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 } From 8ab8a48519461547ad19f4101514759ec8967299 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 15 Feb 2026 14:23:08 -0700 Subject: [PATCH 02/61] =?UTF-8?q?=E2=9C=A8=20Updated=20MultitouchTrigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 24 + Loop/Core/Observers/MultitouchTrigger.swift | 500 ++++++++++++++++++++ 2 files changed, 524 insertions(+) create mode 100644 Loop/Core/Observers/MultitouchTrigger.swift diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 9909c45c..bdeb2bd2 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -65,6 +65,28 @@ final class LoopManager { }, checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false } ) + + private(set) lazy var multitouchTrigger = MultitouchTrigger( + windowActionCache: windowActionCache, + openCallback: { [weak self] action in + Task { + await self?.openLoop(startingAction: action) + } + }, + closeCallback: { [weak self] forceClose in + Task { + await self?.closeLoop(forceClose: forceClose) + } + }, + changeAction: { [weak self] action in + Task { + await self?.changeAction(action) + } + }, + checkIfLoopOpen: { [weak self] in + self?.isLoopActive ?? false + } + ) private(set) lazy var mouseInteractionObserver = MouseInteractionObserver( windowActionCache: windowActionCache, @@ -98,9 +120,11 @@ final class LoopManager { if status { await keybindTrigger.start() middleClickTrigger.start() + multitouchTrigger.start() } else { keybindTrigger.stop() middleClickTrigger.stop() + multitouchTrigger.stop() } } } diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift new file mode 100644 index 00000000..19ddb011 --- /dev/null +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -0,0 +1,500 @@ +// +// MultitouchTrigger.swift +// Loop +// +// Created by Kai Azim on 2026-01-30. +// + +import Subsurface +import Scribe +import SwiftUI + +@Loggable +final class MultitouchTrigger { + private let windowActionCache: WindowActionCache + private let openCallback: (WindowAction) -> () + private let closeCallback: (Bool) -> () + private let changeAction: (WindowAction) -> () + private let checkIfLoopOpen: () -> Bool + + struct GestureInfo { + let position: CGPoint + let distance: CGFloat + } + + private var originGestureInfo: GestureInfo? + private var lastGestureInfo: GestureInfo? + private var maxTouchesInCurrentGesture: Int = 0 + private var isCurrentGestureRejected = false + private var didOpenLoopWithThisGesture = false + + private var lastTriggeredActionIndex: Int? + private var lastTriggeredDistance: CGFloat = 0 + private var lastTriggeredZoomDistance: CGFloat = 0 + + private struct PositionHistoryEntry { + let avgPosition: CGPoint + let touch1Position: CGPoint + let touch2Position: CGPoint + let timestamp: TimeInterval + } + + private var positionHistory: [PositionHistoryEntry] = [] + private let maxHistoryEntries = 5 // Track last 5 positions for smoothing + + private let initialGestureThreshold: CGFloat = 0.025 + private let gestureRepeatThreshold: CGFloat = 0.2 + private let zoomRepeatThreshold: CGFloat = 0.2 + + private var inactivityTask: Task<(), Never>? + private let gestureBlocker: GestureBlocker = .init() + + private var radialMenuActions: [RadialMenuAction] { + RadialMenuAction.userConfiguredActions + } + + private let subtrack = SubsurfaceMonitor() + private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) // This helps to keep a stable ID + + init( + windowActionCache: WindowActionCache, + openCallback: @escaping (WindowAction) -> (), + closeCallback: @escaping (Bool) -> (), + changeAction: @escaping (WindowAction) -> (), + checkIfLoopOpen: @escaping () -> Bool + ) { + self.windowActionCache = windowActionCache + self.openCallback = openCallback + self.closeCallback = closeCallback + self.changeAction = changeAction + self.checkIfLoopOpen = checkIfLoopOpen + } + + func start() { + Task { + for await (_, touchData) in subtrack.contacts() { + resetInactivityTimer() + + let palmFiltered = touchData.filter { $0.finger != nil && $0.hand != nil } + + if palmFiltered.count != maxTouchesInCurrentGesture, + palmFiltered.isEmpty || palmFiltered.count > maxTouchesInCurrentGesture { + maxTouchesInCurrentGesture = palmFiltered.count + } + + if palmFiltered.count == 2, maxTouchesInCurrentGesture == 2 { + handleTwoFingerGesture(with: palmFiltered) + } else { + resetGesture() + } + } + } + + subtrack.start() + } + + func stop() { + subtrack.stop() + resetGesture() + } + + private func resetInactivityTimer() { + inactivityTask?.cancel() + + inactivityTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + if Task.isCancelled { return } + self?.resetGesture() + } + } + + private func handleTwoFingerGesture(with touches: [MTContact]) { + // Skip processing if this gesture sequence was already rejected + guard !isCurrentGestureRejected else { + return + } + + let info = GestureInfo( + position: averagePosition(of: touches), + distance: distance(between: touches) + ) + + guard let originInfo = originGestureInfo, let lastInfo = lastGestureInfo else { + // Check if cursor is over a titlebar before activating + guard isCursorOverTitlebar() else { + isCurrentGestureRejected = true // Mark as rejected to skip future events + return + } + + let loopWasAlreadyOpen = checkIfLoopOpen() + + originGestureInfo = info + lastGestureInfo = info + lastTriggeredActionIndex = nil + lastTriggeredDistance = 0 + lastTriggeredZoomDistance = info.distance // Initialize to current finger distance + gestureBlocker.start() + + // Reset position history for new gesture + positionHistory.removeAll() + + // Only open Loop if it wasn't already open + if !loopWasAlreadyOpen { + didOpenLoopWithThisGesture = true + openCallback(.init(.noSelection)) + } + + return + } + + // Check for zoom-in gesture first (independent of translation) + let touch1 = touches[0] + let touch2 = touches[1] + + let pos1 = CGPoint( + x: CGFloat(touch1.normalizedVector.position.x), + y: CGFloat(touch1.normalizedVector.position.y) + ) + let pos2 = CGPoint( + x: CGFloat(touch2.normalizedVector.position.x), + y: CGFloat(touch2.normalizedVector.position.y) + ) + + // Update position history for stable direction detection at low velocities + positionHistory.append(PositionHistoryEntry( + avgPosition: info.position, + touch1Position: pos1, + touch2Position: pos2, + timestamp: Date.timeIntervalSinceReferenceDate + )) + if positionHistory.count > maxHistoryEntries { + positionHistory.removeFirst() + } + + // Calculate zoom distance (finger spread) + let fingerDistance = info.distance + + // Check if fingers are spreading apart by comparing distances + let isZooming: Bool + if let oldFingerDistance = fingerDistanceFromHistory() { + // Use position history for stable detection + let distanceChange = fingerDistance - oldFingerDistance + isZooming = distanceChange > initialGestureThreshold // Use initial threshold for sensitive detection + } else if positionHistory.count >= 1 { + // Use last frame's distance if history is building up + let lastFingerDistance = lastInfo.distance + let distanceChange = fingerDistance - lastFingerDistance + isZooming = distanceChange > initialGestureThreshold + } else { + // First frame: no zoom detection yet + isZooming = false + } + + // Prioritize zoom gestures over directional gestures + if isZooming { + let actions = radialMenuActions + let centerActionIndex = actions.count - 1 + + // Trigger center action if it's a new action or moved significantly further + if let lastIndex = lastTriggeredActionIndex { + if lastIndex == centerActionIndex { + // Same action - only trigger if we've spread fingers further + guard fingerDistance >= lastTriggeredDistance + zoomRepeatThreshold else { return } + } + } + + lastTriggeredActionIndex = centerActionIndex + lastTriggeredDistance = fingerDistance + triggerAction(at: centerActionIndex, from: actions[...]) + return // Don't process directional actions while zooming + } + + // Process directional actions (swiping) + let deltaPositionFromLast = CGSize( + width: info.position.x - lastInfo.position.x, + height: info.position.y - lastInfo.position.y + ) + + let translationMagFromLast = hypot(deltaPositionFromLast.width, deltaPositionFromLast.height) + + // Use lower threshold for initial gesture when no action is selected + let threshold: CGFloat = lastTriggeredActionIndex == nil ? initialGestureThreshold : gestureRepeatThreshold + guard translationMagFromLast >= threshold else { return } + + lastGestureInfo = info + + // Detect backward movement using position history + let vectorFromOrigin = CGSize( + width: info.position.x - originInfo.position.x, + height: info.position.y - originInfo.position.y + ) + + let magFromOrigin = hypot(vectorFromOrigin.width, vectorFromOrigin.height) + + var didReset = false + + // Use position history for stable direction detection + if let movementDirection = directionFromHistory(to: info.position), + magFromOrigin > 0 { + let magMovement = hypot(movementDirection.width, movementDirection.height) + + if magMovement >= initialGestureThreshold { // Only if meaningful movement occurred + let dotProduct = movementDirection.width * vectorFromOrigin.width + + movementDirection.height * vectorFromOrigin.height + let cosAngle = dotProduct / (magFromOrigin * magMovement) + + // Negative dot product means moving toward origin (opposite direction) + if cosAngle < -0.5 { // ~120 degree threshold + originGestureInfo = info + lastTriggeredActionIndex = nil + lastTriggeredDistance = 0 + lastTriggeredZoomDistance = 0 + didReset = true + print("RESET (history)") + } + } + } + + // Clear history after reset so new origin has clean slate + if didReset { + positionHistory.removeAll() + } + + // Calculate angle from origin or movement direction if we just reset + let angleFromOrigin: CGFloat + let currentDistance: CGFloat + + if didReset { + // Use movement direction since we're at the new origin + angleFromOrigin = atan2(-deltaPositionFromLast.height, deltaPositionFromLast.width) + .pi / 2 + currentDistance = translationMagFromLast + } else { + let deltaPositionFromOrigin = CGSize( + width: info.position.x - originInfo.position.x, + height: info.position.y - originInfo.position.y + ) + angleFromOrigin = atan2(-deltaPositionFromOrigin.height, deltaPositionFromOrigin.width) + .pi / 2 + currentDistance = hypot(deltaPositionFromOrigin.width, deltaPositionFromOrigin.height) + } + + var normalizedAngle = angleFromOrigin + if normalizedAngle < 0 { normalizedAngle += 2 * .pi } + + let actions = radialMenuActions.dropLast() + guard actions.count > 1 else { return } + + let newIndex: Int + if actions.count == 8 { + // For exactly 8 actions, bias toward cardinal directions + // Cardinal directions are at indices 0, 2, 4, 6 (N, E, S, W) + // Diagonal directions are at indices 1, 3, 5, 7 (NE, SE, SW, NW) + newIndex = indexWithCardinalBias(angle: normalizedAngle, actionCount: actions.count) + } else { + // Standard even distribution for other action counts + let actionAngleSpan = (.pi * 2) / CGFloat(actions.count) + let halfAngleSpan = actionAngleSpan / 2.0 + newIndex = Int((normalizedAngle + halfAngleSpan) / actionAngleSpan) % actions.count + } + + // Only trigger if it's a new action OR we've moved significantly further in the same direction + if let lastIndex = lastTriggeredActionIndex { + if newIndex == lastIndex { + // Same action - only trigger if we've moved further from origin + guard currentDistance >= lastTriggeredDistance + gestureRepeatThreshold else { return } + } + } + + lastTriggeredActionIndex = newIndex + lastTriggeredDistance = currentDistance + triggerAction(at: newIndex, from: actions) + } + + func resetGesture() { + isCurrentGestureRejected = false + + guard lastGestureInfo != nil else { + return + } + + // Only close Loop if this gesture was responsible for opening it + if didOpenLoopWithThisGesture { + closeCallback(false) + } + + gestureBlocker.stop() + lastGestureInfo = nil + maxTouchesInCurrentGesture = 0 + didOpenLoopWithThisGesture = false + positionHistory.removeAll() // Clear history on gesture end + } + + private func averagePosition(of touches: [MTContact]) -> CGPoint { + let sum = touches.reduce(into: CGPoint.zero) { result, touch in + result.x += CGFloat(touch.normalizedVector.position.x) + result.y += CGFloat(touch.normalizedVector.position.y) + } + + return CGPoint( + x: sum.x / CGFloat(touches.count), + y: sum.y / CGFloat(touches.count) + ) + } + + /// Calculates movement direction from position history + /// Returns nil if insufficient history available + private func directionFromHistory(to currentPosition: CGPoint) -> CGSize? { + guard positionHistory.count >= 3 else { return nil } + + // Use oldest available position for maximum stability + let oldestEntry = positionHistory.first! + + return CGSize( + width: currentPosition.x - oldestEntry.avgPosition.x, + height: currentPosition.y - oldestEntry.avgPosition.y + ) + } + + /// Calculates finger spread from position history + /// Returns nil if insufficient history available + private func fingerDistanceFromHistory() -> CGFloat? { + guard let oldestEntry = positionHistory.first else { return nil } + + let oldDistance = hypot( + oldestEntry.touch2Position.x - oldestEntry.touch1Position.x, + oldestEntry.touch2Position.y - oldestEntry.touch1Position.y + ) + + return oldDistance + } + + private func distance(between touches: [MTContact]) -> CGFloat { + guard touches.count == 2 else { return 0 } + + let p1 = CGPoint(x: CGFloat(touches[0].normalizedVector.position.x), y: CGFloat(touches[0].normalizedVector.position.y)) + let p2 = CGPoint(x: CGFloat(touches[1].normalizedVector.position.x), y: CGFloat(touches[1].normalizedVector.position.y)) + + return hypot(p2.x - p1.x, p2.y - p1.y) + } + + /// Maps an angle to an action index with bias toward cardinal directions. + /// For 8 actions, cardinal directions (N, E, S, W) get wider angular ranges, + /// requiring users to explicitly aim for ~45° to trigger diagonal actions. + /// - Parameter cardinalBias: How much larger cardinals are relative to diagonals. + /// A value of 0.1 makes cardinal zones 10% wider and diagonal zones 10% narrower than uniform (0.0 = equal sizes, 1.0 = diagonals disappear). + private func indexWithCardinalBias(angle: CGFloat, actionCount: Int, cardinalBias: CGFloat = 0.1) -> Int { + let baseAngleSpan = (.pi * 2) / CGFloat(actionCount) // 45° for 8 actions + let halfAngleSpan = baseAngleSpan / 2.0 + + // Match the original centered mapping (boundaries at ±22.5° for 8 actions) + let adjustedAngle = (angle + halfAngleSpan).truncatingRemainder(dividingBy: .pi * 2) + + // Determine which 45° segment we're in (modulo handles angle == 2π) + let rawSegment = Int(adjustedAngle / baseAngleSpan) % actionCount + + // Calculate position within the segment (0.0 to 1.0) + let segmentAngle = adjustedAngle.truncatingRemainder(dividingBy: baseAngleSpan) + let normalizedPosition = segmentAngle / baseAngleSpan + + // Cardinal directions are at even indices (0, 2, 4, 6) + let isCurrentCardinal = rawSegment % 2 == 0 + + if isCurrentCardinal { + // Cardinal keeps its entire segment + return rawSegment + } else { + // Diagonal segment - cede edges to adjacent cardinals + if normalizedPosition < cardinalBias / 2 { + return (rawSegment - 1 + actionCount) % actionCount + } else if normalizedPosition > 1.0 - cardinalBias / 2 { + return (rawSegment + 1) % actionCount + } else { + return rawSegment + } + } + } + + private func isCursorOverTitlebar() -> Bool { + // If Loop is already open, intercept all gestures regardless of cursor position + if checkIfLoopOpen() { + return true + } + + // Get current cursor position + let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) + + // Get window at cursor position using existing WindowUtility + guard let window = WindowUtility.windowAtPosition(cursorPosition) else { + return false + } + + // Respect app exclusion settings + if window.isAppExcluded { + return false + } + + // Assume large titlebar variant + let titlebarHeight: CGFloat = 52.0 + + let titlebarMinY = window.frame.minY + let titlebarMaxY = window.frame.minY + titlebarHeight + + let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY + + // Check if cursor is within titlebar region + return isInTitlebar + } + + private func triggerAction(at index: Int, from actions: ArraySlice) { + let action = actions[index] + + let resolvedAction: WindowAction = switch action.type { + case let .custom(windowAction): + windowAction + case let .keybindReference(id): + windowActionCache.actionsByIdentifier[id] ?? Self.failedToResolveKeybindAction + } + + changeAction(resolvedAction) + } + + private func triggerCenterAction() { + // The center action is the last item in radialMenuActions + guard let centerAction = radialMenuActions.last else { return } + + let resolvedAction: WindowAction = switch centerAction.type { + case let .custom(windowAction): + windowAction + case let .keybindReference(id): + windowActionCache.actionsByIdentifier[id] ?? Self.failedToResolveKeybindAction + } + + changeAction(resolvedAction) + } +} + +@Loggable +private final class GestureBlocker { + private var monitor: ActiveEventMonitor? + + func start() { + log.info("Starting gesture blocker") + + let eventTypes: [CGEventType] = [ + .scrollWheel, + CGEventType(rawValue: UInt32(NSEvent.EventType.gesture.rawValue)), + CGEventType(rawValue: UInt32(NSEvent.EventType.magnify.rawValue)), + CGEventType(rawValue: UInt32(NSEvent.EventType.rotate.rawValue)), + CGEventType(rawValue: UInt32(NSEvent.EventType.smartMagnify.rawValue)) + ].compactMap(\.self) + + monitor = ActiveEventMonitor(events: eventTypes) { _ in .ignore } + monitor?.start() + } + + func stop() { + monitor?.stop() + monitor = nil + + log.info("Stopped gesture blocker") + } +} From 0525b20c556cb0268b604006eb67ecfcd90e3d79 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 15 Feb 2026 14:23:39 -0700 Subject: [PATCH 03/61] =?UTF-8?q?=F0=9F=8F=81=20Make=20WindowRecords=20an?= =?UTF-8?q?=20actor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # Loop/Window Management/Window Manipulation/WindowFrameResolver.swift --- Loop.xcodeproj/project.pbxproj | 22 ++-- Loop/Core/LoopManager.swift | 12 +- Loop/Core/Observers/MultitouchTrigger.swift | 2 +- Loop/Core/WindowDragManager.swift | 10 +- .../WindowActionFramedPreview.swift | 54 +++++++++ .../CustomActionConfigurationView.swift | 31 +---- .../StashActionConfigurationView.swift | 31 +---- Loop/Stashing/StashManager.swift | 87 +++++++------- Loop/Stashing/StashedWindowInfo.swift | 11 +- .../Preview Window/PreviewViewModel.swift | 107 +++++++++--------- .../Radial Menu/RadialMenuViewModel.swift | 12 +- .../Window Action/IconView.swift | 16 ++- .../Window Action/WindowAction.swift | 4 +- .../Window Manipulation/ResizeContext.swift | 8 +- .../WindowActionEngine.swift | 3 +- .../Window Manipulation/WindowEngine.swift | 21 ++-- .../WindowFrameResolver.swift | 34 +++--- .../Window Manipulation/WindowRecords.swift | 24 ++-- 18 files changed, 255 insertions(+), 234 deletions(-) create mode 100644 Loop/Settings Window/Settings/Keybinds/Modal Views/Components/WindowActionFramedPreview.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index a696330c..7cb8efe5 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,12 +7,12 @@ objects = { /* Begin PBXBuildFile section */ - 2A0EEE852F381A3A00C40CF4 /* Subtrack in Frameworks */ = {isa = PBXBuildFile; productRef = 2A0EEE842F381A3A00C40CF4 /* Subtrack */; }; 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28492A2F22B4B700F6CE42 /* Scribe */; }; 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B6282EE5050C00A1E26B /* Defaults */; }; 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B62B2EE5057C00A1E26B /* Luminare */; }; 2AF9238E2F540B1300F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238D2F540B1300F467FD /* Scribe */; }; 2AF923902F540B2200F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238F2F540B2200F467FD /* Scribe */; }; + 2A74ABA32F3E65A100EBF95C /* Subsurface in Frameworks */ = {isa = PBXBuildFile; productRef = 2A74ABA22F3E65A100EBF95C /* Subsurface */; }; 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 */; }; @@ -101,12 +101,12 @@ buildActionMask = 2147483647; files = ( 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */, + 2A74ABA32F3E65A100EBF95C /* Subsurface in Frameworks */, 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */, 2AF9238E2F540B1300F467FD /* Scribe in Frameworks */, 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */, F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */, 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */, - 2A0EEE852F381A3A00C40CF4 /* Subtrack in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -182,8 +182,8 @@ 2A28B62B2EE5057C00A1E26B /* Luminare */, 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */, 2A28492A2F22B4B700F6CE42 /* Scribe */, - 2AF9238D2F540B1300F467FD /* Scribe */, - 2A0EEE842F381A3A00C40CF4 /* Subtrack */, + 2A8EDDBB2F23743B005457F8 /* Scribe */, + 2A74ABA22F3E65A100EBF95C /* Subsurface */ ); productName = WindowManager; productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */; @@ -255,8 +255,8 @@ 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */, 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */, 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, - 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */, 2A0EEE832F381A3A00C40CF4 /* XCLocalSwiftPackageReference "../Subtrack" */, + 2A8EDDBA2F23743B005457F8 /* XCRemoteSwiftPackageReference "Scribe" */, ); productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */; projectDirPath = ""; @@ -753,9 +753,9 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 2A0EEE832F381A3A00C40CF4 /* XCLocalSwiftPackageReference "../Subtrack" */ = { + 2A74ABA12F3E65A100EBF95C /* XCLocalSwiftPackageReference "../Subsurface" */ = { isa = XCLocalSwiftPackageReference; - relativePath = ../Subtrack; + relativePath = ../Subsurface; }; /* End XCLocalSwiftPackageReference section */ @@ -795,10 +795,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 2A0EEE842F381A3A00C40CF4 /* Subtrack */ = { - isa = XCSwiftPackageProductDependency; - productName = Subtrack; - }; 2A28492A2F22B4B700F6CE42 /* Scribe */ = { isa = XCSwiftPackageProductDependency; productName = Scribe; @@ -813,6 +809,10 @@ package = 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */; productName = Luminare; }; + 2A74ABA22F3E65A100EBF95C /* Subsurface */ = { + isa = XCSwiftPackageProductDependency; + productName = Subsurface; + }; 2AF9238D2F540B1300F467FD /* Scribe */ = { isa = XCSwiftPackageProductDependency; package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */; diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index bdeb2bd2..da01da5f 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -169,7 +169,7 @@ extension LoopManager { let initialFrame: CGRect = if let window { // In case of a stashed window, use the revealed frame instead to prevent issue with frame calculation later. - StashManager.shared.getRevealedFrameForStashedWindow( + await StashManager.shared.getRevealedFrameForStashedWindow( id: window.cgWindowID ) ?? window.frame } else { @@ -270,7 +270,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 = getNextCycleAction(newAction) + newAction = await getNextCycleAction(newAction) } else { if let cycle = newAction.cycle, !cycle.contains(resizeContext.action) { newAction = cycle.first ?? .init(.noAction) @@ -333,8 +333,8 @@ extension LoopManager { if resizeContext.action.direction.isNoOp || resizeContext.action.willManipulateExistingWindowFrame { if let targetWindow = resizeContext.window { let screenSwitchingCustomActionName = "autogenerated_screen_switching_action" - - if let lastAction = WindowRecords.getCurrentAction(for: targetWindow), + + if let lastAction = await WindowRecords.shared.getCurrentAction(for: targetWindow), lastAction.getName() != screenSwitchingCustomActionName, !lastAction.forceProportionalFrameOnScreenChange { resizeContext.setAction(to: lastAction, parent: nil) @@ -424,7 +424,7 @@ extension LoopManager { } } - private func getNextCycleAction(_ action: WindowAction) -> WindowAction { + private func getNextCycleAction(_ action: WindowAction) async -> WindowAction { guard let currentCycle = action.cycle else { return action } @@ -450,7 +450,7 @@ extension LoopManager { if resizeContext.action.direction == .noSelection, !currentCycle.contains(resizeContext.action), let window = resizeContext.window, - let latestRecord = WindowRecords.getCurrentAction(for: window) { + let latestRecord = await WindowRecords.shared.getCurrentAction(for: window) { currentIndex = currentCycle.firstIndex(of: latestRecord) } else { currentIndex = currentCycle.firstIndex(of: resizeContext.action) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 19ddb011..4f5898d7 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -43,7 +43,7 @@ final class MultitouchTrigger { private let maxHistoryEntries = 5 // Track last 5 positions for smoothing private let initialGestureThreshold: CGFloat = 0.025 - private let gestureRepeatThreshold: CGFloat = 0.2 + private let gestureRepeatThreshold: CGFloat = 0.25 private let zoomRepeatThreshold: CGFloat = 0.2 private var inactivityTask: Task<(), Never>? diff --git a/Loop/Core/WindowDragManager.swift b/Loop/Core/WindowDragManager.swift index 0b5e8fdc..b32dd37c 100644 --- a/Loop/Core/WindowDragManager.swift +++ b/Loop/Core/WindowDragManager.swift @@ -98,7 +98,7 @@ final class WindowDragManager { hasWindowResized(window.frame, initialFrame) { if hasWindowMoved(window.frame, initialFrame) { if Defaults[.restoreWindowFrameOnDrag] { - restoreInitialWindowSize(window) + await restoreInitialWindowSize(window) } if Defaults[.windowSnapping] { @@ -116,7 +116,7 @@ final class WindowDragManager { } StashManager.shared.onWindowManipulated(window.cgWindowID) - WindowRecords.eraseRecords(for: window) + await WindowRecords.shared.eraseRecords(for: window) } } } @@ -194,10 +194,10 @@ final class WindowDragManager { !initialFrame.bottomRightPoint.approximatelyEqual(to: windowFrame.bottomRightPoint) } - private func restoreInitialWindowSize(_ window: Window) { + private func restoreInitialWindowSize(_ window: Window) async { let startFrame = window.frame - guard let initialFrame = WindowRecords.getInitialFrame(for: window) else { + guard let initialFrame = await WindowRecords.shared.getInitialFrame(for: window) else { return } @@ -224,7 +224,7 @@ final class WindowDragManager { } } - WindowRecords.eraseRecords(for: window) + await WindowRecords.shared.eraseRecords(for: window) } private func processSnapAction() { diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/Components/WindowActionFramedPreview.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/Components/WindowActionFramedPreview.swift new file mode 100644 index 00000000..58ed524b --- /dev/null +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/Components/WindowActionFramedPreview.swift @@ -0,0 +1,54 @@ +// +// WindowActionFramedPreview.swift +// Loop +// +// Created by Kai Azim on 2026-02-15. +// + +import SwiftUI +import Luminare + +struct WindowActionFramedPreview: View { + @ObservedObject private var accentColorController: AccentColorController = .shared + @Environment(\.luminareAnimation) private var luminareAnimation + @State private var frame: CGRect = .zero + let action: WindowAction + + var body: some View { + ScreenView(isBlurred: action.sizeMode != .custom) { + GeometryReader { geo in + ZStack { + if action.sizeMode == .custom { + blurredWindow + .frame(width: frame.width, height: frame.height) + .offset(x: frame.origin.x, y: frame.origin.y) + .animation(luminareAnimation, value: frame) + } + } + .frame( + width: geo.size.width, + height: geo.size.height, + alignment: .topLeading + ) + .onChange(of: action, initial: true) { + Task { + frame = await WindowFrameResolver.getFrame( + for: action, + window: nil, + bounds: CGRect(origin: .zero, size: geo.size) + ) + } + } + } + } + } + + private var blurredWindow: some View { + VisualEffectView(material: .hudWindow, blendingMode: .withinWindow) + .overlay { + RoundedRectangle(cornerRadius: 12 - 5) + .strokeBorder(accentColorController.color1, lineWidth: 2) + } + .clipShape(RoundedRectangle(cornerRadius: 12 - 5)) + } +} diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift index 791dfc81..cc3eb472 100644 --- a/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift @@ -55,26 +55,8 @@ struct CustomActionConfigurationView: View { var body: some View { VStack(spacing: 12) { - ScreenView(isBlurred: action.sizeMode != .custom) { - GeometryReader { geo in - ZStack { - if action.sizeMode == .custom { - let frame = WindowFrameResolver.getFrame( - for: action, - window: nil, - bounds: CGRect(origin: .zero, size: geo.size) - ) - - blurredWindow() - .frame(width: frame.width, height: frame.height) - .offset(x: frame.origin.x, y: frame.origin.y) - .animation(luminareAnimation, value: frame) - } - } - .frame(width: geo.size.width, height: geo.size.height, alignment: .topLeading) - } - } - .onChange(of: action) { windowAction = $0 } + WindowActionFramedPreview(action: action) + .onChange(of: action) { windowAction = $0 } configurationSections() actionButtons() @@ -358,13 +340,4 @@ struct CustomActionConfigurationView: View { } } } - - private func blurredWindow() -> some View { - VisualEffectView(material: .hudWindow, blendingMode: .withinWindow) - .overlay { - RoundedRectangle(cornerRadius: 12 - 5) - .strokeBorder(accentColorController.color1, lineWidth: 2) - } - .clipShape(RoundedRectangle(cornerRadius: 12 - 5)) - } } diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift index a022847c..8c0071d6 100644 --- a/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift @@ -60,26 +60,8 @@ struct StashActionConfigurationView: View { var body: some View { VStack(spacing: 12) { - ScreenView(isBlurred: action.sizeMode != .custom) { - GeometryReader { geo in - ZStack { - if action.sizeMode == .custom { - let frame = WindowFrameResolver.getFrame( - for: action, - window: nil, - bounds: CGRect(origin: .zero, size: geo.size) - ) - - blurredWindow() - .frame(width: frame.width, height: frame.height) - .offset(x: frame.origin.x, y: frame.origin.y) - .animation(luminareAnimation, value: frame) - } - } - .frame(width: geo.size.width, height: geo.size.height, alignment: .topLeading) - } - } - .onChange(of: action) { windowAction = $0 } + WindowActionFramedPreview(action: action) + .onChange(of: action) { windowAction = $0 } configurationSections() actionButtons() @@ -301,13 +283,4 @@ struct StashActionConfigurationView: View { } } } - - private func blurredWindow() -> some View { - VisualEffectView(material: .hudWindow, blendingMode: .withinWindow) - .overlay { - RoundedRectangle(cornerRadius: 12 - 5) - .strokeBorder(accentColorController.color1, lineWidth: 2) - } - .clipShape(RoundedRectangle(cornerRadius: 12 - 5)) - } } diff --git a/Loop/Stashing/StashManager.swift b/Loop/Stashing/StashManager.swift index f9826b8b..b73a93be 100644 --- a/Loop/Stashing/StashManager.swift +++ b/Loop/Stashing/StashManager.swift @@ -83,8 +83,10 @@ final class StashManager { } func onApplicationWillTerminate() { - // Move back all stashed windows back into the screen before closing the app: - restoreAllStashedWindows(animate: false) + Task { + // Move back all stashed windows back into the screen before closing the app + await restoreAllStashedWindows(animate: false) + } } func onWindowManipulated(_ id: CGWindowID) { @@ -92,10 +94,12 @@ final class StashManager { } func onConfigurationChanged() { - for stashedWindow in store.stashed.values { - let frame = stashedWindow.computeStashedFrame(peekSize: stashedWindowVisiblePadding) - // Don't animate when configuration changes - stashedWindow.window.setFrame(frame) + Task { + for stashedWindow in store.stashed.values { + let frame = await stashedWindow.computeStashedFrame(peekSize: stashedWindowVisiblePadding) + // Don't animate when configuration changes + stashedWindow.window.setFrame(frame) + } } } @@ -130,14 +134,8 @@ final class StashManager { return true } - func getRevealedFrameForStashedWindow(id: CGWindowID) -> CGRect? { - store.stashed[id]?.computeRevealedFrame() - } - - deinit { - mouseMovedTask?.cancel() - stopListeningToRevealTriggers() - restoreAllStashedWindows(animate: false) + func getRevealedFrameForStashedWindow(id: CGWindowID) async -> CGRect? { + await store.stashed[id]?.computeRevealedFrame() } } @@ -173,12 +171,19 @@ extension StashManager { } else if action.direction == .unstash { // No need to reset the frame here: the frame has already been moved to the stash area // by the code that sent the windowResized notification. - unstash(window.cgWindowID, resetFrame: false, resetFrameAnimated: animate) + Task { + await unstash(window.cgWindowID, resetFrame: false, resetFrameAnimated: animate) + } } else if action.direction == .undo { - guard let action = WindowRecords.getCurrentAction(for: window) else { return } - guard action.direction != .undo else { return } - - onWindowResized(action: action, window: window, screen: screen) + Task { + guard let action = await WindowRecords.shared.getCurrentAction(for: window), + action.direction != .undo + else { + return + } + + onWindowResized(action: action, window: window, screen: screen) + } } else if action.direction.willGrow || action.direction.willShrink || action.direction.willAdjustSize { @@ -209,7 +214,7 @@ extension StashManager { private func stash(_ windowToStash: StashedWindowInfo) async { log.info("stash \(windowToStash.window.description)") - unstashOverlappingWindows(windowToStash) + await unstashOverlappingWindows(windowToStash) store.setStashedWindow(cgWindowID: windowToStash.window.cgWindowID, to: windowToStash) await hideWindow(windowToStash) @@ -217,21 +222,21 @@ extension StashManager { } /// Stop monitoring the window with the given `CGWindowID`. - private func unstash(_ windowID: CGWindowID, resetFrame: Bool, resetFrameAnimated: Bool) { + private func unstash(_ windowID: CGWindowID, resetFrame: Bool, resetFrameAnimated: Bool) async { if let windowToUnstash = store.stashed[windowID] { - unstash(windowToUnstash, resetFrame: resetFrame, resetFrameAnimated: resetFrameAnimated) + await unstash(windowToUnstash, resetFrame: resetFrame, resetFrameAnimated: resetFrameAnimated) } else { unmanage(windowID: windowID) } } /// Stop monitoring the window. If `resetFrame` is true, the window will be moved to its initial frame. - private func unstash(_ window: StashedWindowInfo, resetFrame: Bool, resetFrameAnimated: Bool) { + private func unstash(_ window: StashedWindowInfo, resetFrame: Bool, resetFrameAnimated: Bool) async { log.info("unstash \(window.window.description)") if resetFrame { let action = WindowAction(.initialFrame) - let initialFrame = WindowFrameResolver.getFrame( + let initialFrame = await WindowFrameResolver.getFrame( for: action, window: window.window, bounds: window.screen.cgSafeScreenFrame @@ -252,9 +257,9 @@ extension StashManager { unmanage(windowID: window.window.cgWindowID) } - func restoreAllStashedWindows(animate: Bool) { + func restoreAllStashedWindows(animate: Bool) async { for stashedWindowID in store.stashed.keys { - unstash(stashedWindowID, resetFrame: true, resetFrameAnimated: animate) + await unstash(stashedWindowID, resetFrame: true, resetFrameAnimated: animate) } } } @@ -278,7 +283,7 @@ private extension StashManager { } } - let frame = window.computeRevealedFrame() + let frame = await window.computeRevealedFrame() if shiftFocusWhenStashed { Task { @MainActor in @@ -303,7 +308,7 @@ private extension StashManager { func hideWindow(_ window: StashedWindowInfo, shouldUnfocus: Bool = true) async { guard !shouldThrottle(windowID: window.window.cgWindowID) else { return } - let frame = window.computeStashedFrame(peekSize: stashedWindowVisiblePadding) + let frame = await window.computeStashedFrame(peekSize: stashedWindowVisiblePadding) if shouldUnfocus { unfocus(window.window.cgWindowID) @@ -439,12 +444,12 @@ private extension StashManager { for window in windows { if store.isWindowRevealed(window.window.cgWindowID) { - if shouldHide(window: window, for: mouseLocation) { + if await shouldHide(window: window, for: mouseLocation) { await hideWindow(window) } else { break } - } else if isMouseOverStashed(window: window, location: mouseLocation) { + } else if await isMouseOverStashed(window: window, location: mouseLocation) { // The cursor is over the topmost stashed window that should be revealed // revealWindow will move it on screen and hide any other revealed window. await revealWindow(window) @@ -468,7 +473,7 @@ private extension StashManager { for window in windows { if store.isWindowRevealed(window.window.cgWindowID) { if appWindow.cgWindowID != window.window.cgWindowID, - !isMouseOverStashed(window: window, location: mouseLocation) { + await !isMouseOverStashed(window: window, location: mouseLocation) { await hideWindow(window, shouldUnfocus: false) // No need to unfocus, since the user already did that } else { break @@ -497,17 +502,17 @@ private extension StashManager { /// Determines whether a revealed window should be hidden based on the mouse location. /// Adds a tolerance to the revealed frame to avoid hiding the window during minor cursor movement and on resize. - private func shouldHide(window: StashedWindowInfo, for location: CGPoint) -> Bool { + private func shouldHide(window: StashedWindowInfo, for location: CGPoint) async -> Bool { // Hide the window if the cursor is neither over the revealedFrame nor the stashedFrame. let tolerance: CGFloat = 15 - let revealedFrame = window.computeRevealedFrame().insetBy(dx: -tolerance, dy: -tolerance) - let stashedFrame = window.computeStashedFrame(peekSize: stashedWindowVisiblePadding) + let revealedFrame = await window.computeRevealedFrame().insetBy(dx: -tolerance, dy: -tolerance) + let stashedFrame = await window.computeStashedFrame(peekSize: stashedWindowVisiblePadding) return !revealedFrame.contains(location) && !stashedFrame.contains(location) } /// Checks if the mouse is currently hovering over the stashed frame of a window. - private func isMouseOverStashed(window: StashedWindowInfo, location: CGPoint) -> Bool { - let stashedFrame = window.computeStashedFrame(peekSize: stashedWindowVisiblePadding) + private func isMouseOverStashed(window: StashedWindowInfo, location: CGPoint) async -> Bool { + let stashedFrame = await window.computeStashedFrame(peekSize: stashedWindowVisiblePadding) return stashedFrame.contains(location) } } @@ -523,8 +528,8 @@ private extension StashManager { /// /// If there is not enough space, the stashed window will be unstashed (i.e., made fully visible and removed from the stash) /// and replaced by `windowToStash` - func unstashOverlappingWindows(_ windowToStash: StashedWindowInfo) { - let newFrame = windowToStash.computeRevealedFrame() + func unstashOverlappingWindows(_ windowToStash: StashedWindowInfo) async { + let newFrame = await windowToStash.computeRevealedFrame() for (id, stashedWindow) in store.stashed { // windowToStash is already managed by StashManager. Can't overlap with itself. @@ -536,14 +541,14 @@ private extension StashManager { // No need for frame comparaison, it will always overlap. if stashedWindow.action.id == windowToStash.action.id, stashedWindow.screen.isSameScreen(windowToStash.screen) { log.info("Trying to stash a window in the same place as another one. Replacing…") - unstash(stashedWindow, resetFrame: true, resetFrameAnimated: animate) + await unstash(stashedWindow, resetFrame: true, resetFrameAnimated: animate) } else { - let currentFrame = stashedWindow.computeStashedFrame(peekSize: stashedWindowVisiblePadding) + let currentFrame = await stashedWindow.computeStashedFrame(peekSize: stashedWindowVisiblePadding) let tolerance = minimumVisibleSizeToKeepWindowStacked if !isThereEnoughNonOverlappingSpace(between: newFrame, and: currentFrame, edge: windowToStash.action.stashEdge, tolerance: tolerance) { log.info("Trying to stash a window overlapping another one. Replacing…") - unstash(stashedWindow, resetFrame: true, resetFrameAnimated: animate) + await unstash(stashedWindow, resetFrame: true, resetFrameAnimated: animate) } } } diff --git a/Loop/Stashing/StashedWindowInfo.swift b/Loop/Stashing/StashedWindowInfo.swift index 1ae1c713..1a26255d 100644 --- a/Loop/Stashing/StashedWindowInfo.swift +++ b/Loop/Stashing/StashedWindowInfo.swift @@ -15,12 +15,13 @@ struct StashedWindowInfo: Equatable { let screen: NSScreen let action: WindowAction - // MARK: - Frame computation + // MARK: - Frame computation + // TODO: Move to WindowFrameResolver /// Computes the frame for a stashed window. - func computeStashedFrame(peekSize: CGFloat, maxPeekPercent: CGFloat = 0.2) -> CGRect { + func computeStashedFrame(peekSize: CGFloat, maxPeekPercent: CGFloat = 0.2) async -> CGRect { let bounds = screen.cgSafeScreenFrame - var frame = WindowFrameResolver.getFrame(for: action, window: window, bounds: bounds) + var frame = await WindowFrameResolver.getFrame(for: action, window: window, bounds: bounds) let minPeekSize: CGFloat = 1 @@ -47,9 +48,9 @@ struct StashedWindowInfo: Equatable { return frame } - func computeRevealedFrame() -> CGRect { + func computeRevealedFrame() async -> CGRect { let context = ResizeContext(window: window, screen: screen) context.setAction(to: action, parent: nil) - return context.getTargetFrame().padded + return await context.getTargetFrame().padded } } diff --git a/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift b/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift index a9e75bd3..4de1df75 100644 --- a/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift +++ b/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift @@ -35,63 +35,66 @@ final class PreviewViewModel: ObservableObject { overrideCornerRadii = nil } - let isCurrentlyHidden = !isShown - var paddedFrame = context.getTargetFrame().padded - - if let bounds = context.screen?.displayBounds { - paddedFrame.origin.x -= bounds.minX - paddedFrame.origin.y -= bounds.minY - } - - // In settings preview, actions that manipulate existing window frames (larger/smaller, - // grow/shrink, move) cannot be previewed without a real window. - let shouldBecomeVisible = if isSettingsPreview, context.action.willManipulateExistingWindowFrame { - false - } else { - paddedFrame.size.area > 0 - } - - var newShownState: Bool = isShown - var newComputedFrame: CGRect = computedFrame - - // If the window is currently shown, but needs to be hidden - if !isCurrentlyHidden, !shouldBecomeVisible { - newShownState = false - } - - // If the window is currently hidden, but it needs to be shown. - else if isCurrentlyHidden, shouldBecomeVisible { - if !isScreenSwitch { - let startingFrame = computeStartingFrame( - for: Defaults[.previewStartingPosition], - targetFrame: paddedFrame, - context: context - ) - - // Set starting position without animation - computedFrame = startingFrame + // Compute new frame in a separate thread + Task { + let isCurrentlyHidden = !isShown + var paddedFrame = await context.getTargetFrame().padded + + if let bounds = context.screen?.displayBounds { + paddedFrame.origin.x -= bounds.minX + paddedFrame.origin.y -= bounds.minY } - - newShownState = true - newComputedFrame = paddedFrame - } - - // Window is already visible and should stay visible - update frame - else if !isCurrentlyHidden, shouldBecomeVisible { - newComputedFrame = paddedFrame - } - - if isScreenSwitch { - computedFrame = newComputedFrame - isShown = newShownState - } else { - withAnimation(Defaults[.animationConfiguration].previewWindow) { + + // In settings preview, actions that manipulate existing window frames (larger/smaller, + // grow/shrink, move) cannot be previewed without a real window. + let shouldBecomeVisible = if isSettingsPreview, context.action.willManipulateExistingWindowFrame { + false + } else { + paddedFrame.size.area > 0 + } + + var newShownState: Bool = isShown + var newComputedFrame: CGRect = computedFrame + + // If the window is currently shown, but needs to be hidden + if !isCurrentlyHidden, !shouldBecomeVisible { + newShownState = false + } + + // If the window is currently hidden, but it needs to be shown. + else if isCurrentlyHidden, shouldBecomeVisible { + if !isScreenSwitch { + let startingFrame = computeStartingFrame( + for: Defaults[.previewStartingPosition], + targetFrame: paddedFrame, + context: context + ) + + // Set starting position without animation + computedFrame = startingFrame + } + + newShownState = true + newComputedFrame = paddedFrame + } + + // Window is already visible and should stay visible - update frame + else if !isCurrentlyHidden, shouldBecomeVisible { + newComputedFrame = paddedFrame + } + + if isScreenSwitch { computedFrame = newComputedFrame isShown = newShownState + } else { + withAnimation(Defaults[.animationConfiguration].previewWindow) { + computedFrame = newComputedFrame + isShown = newShownState + } } + + log.ui("Current previewed frame: \(computedFrame) for \(context.action)") } - - log.ui("Current previewed frame: \(computedFrame) for \(context.action)") } private func computeStartingFrame( diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift index c4054674..066c57ac 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift @@ -108,11 +108,13 @@ final class RadialMenuViewModel: ObservableObject { currentAction = context.action parentAction = context.parentAction - recomputeAngle(context: context) + Task { + await recomputeAngle(context: context) + } } - private func recomputeAngle(context: ResizeContext) { - guard let targetAngle = calculateTargetAngle(context: context) else { + private func recomputeAngle(context: ResizeContext) async { + guard let targetAngle = await calculateTargetAngle(context: context) else { return } @@ -125,7 +127,7 @@ final class RadialMenuViewModel: ObservableObject { } } - private func calculateTargetAngle(context: ResizeContext) -> Angle? { + private func calculateTargetAngle(context: ResizeContext) async -> Angle? { // Check directional radial menu actions first if let index = directionalRadialMenuActions.firstIndex(where: { $0.associatedActionId == effectiveWindowAction.id }) { let actionAngleSpan = 360.0 / CGFloat(directionalRadialMenuActions.count) @@ -133,7 +135,7 @@ final class RadialMenuViewModel: ObservableObject { } // Otherwise, default to the current action's radial menu angle - return currentAction.radialMenuAngle(context: context) + return await currentAction.radialMenuAngle(context: context) } private func shouldAnimateTransition(closestAngle: Angle) -> Bool { diff --git a/Loop/Window Management/Window Action/IconView.swift b/Loop/Window Management/Window Action/IconView.swift index 9cfed265..4c890fe2 100644 --- a/Loop/Window Management/Window Action/IconView.swift +++ b/Loop/Window Management/Window Action/IconView.swift @@ -88,12 +88,16 @@ final class IconRenderView: NSView { ) { guard action != currentAction else { return } currentAction = action - updatePath(duration: animated ? 0.2 : 0.0) + Task { + await updatePath(duration: animated ? 0.2 : 0.0) + } } override func layout() { super.layout() - updatePath(duration: 0.0) + Task { + await updatePath(duration: 0.0) + } } override func viewDidChangeEffectiveAppearance() { @@ -129,7 +133,7 @@ final class IconRenderView: NSView { } } - private func updatePath(duration: CFTimeInterval) { + private func updatePath(duration: CFTimeInterval) async { strokeLayer.frame = bounds fillLayer.frame = bounds @@ -139,7 +143,7 @@ final class IconRenderView: NSView { let fillInset = strokeInset + inset let fillBounds = bounds.insetBy(dx: fillInset, dy: fillInset) - guard let displayMode = determineDisplayMode(fillBounds: fillBounds) else { + guard let displayMode = await determineDisplayMode(fillBounds: fillBounds) else { fillLayer.opacity = 0 imageLayer.opacity = 0 return @@ -177,12 +181,12 @@ final class IconRenderView: NSView { strokeLayer.path = strokePath } - private func determineDisplayMode(fillBounds: CGRect) -> DisplayMode? { + private func determineDisplayMode(fillBounds: CGRect) async -> DisplayMode? { if let image = currentAction.image { return .image(image.nsImage) } - let frame = WindowFrameResolver.getFrame( + let frame = await WindowFrameResolver.getFrame( for: currentAction, window: nil, bounds: .init(origin: .zero, size: .init(width: 1, height: 1)), diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index 8df21d0f..5b56eddb 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -190,7 +190,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial /// /// - Parameter context: the resize context containing the pre-computed target frame. /// - Returns: the angle to show in the radial menu, or `nil` if the action does not have a radial menu angle. - func radialMenuAngle(context: ResizeContext) -> Angle? { + func radialMenuAngle(context: ResizeContext) async -> Angle? { guard direction.frameMultiplyValues != nil, direction.hasRadialMenuAngle @@ -198,7 +198,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial return nil } - let targetFrame = context.getTargetFrame().normalized + let targetFrame = await context.getTargetFrame().normalized let angle = CGPoint(x: 0.5, y: 0.5).angle(to: targetFrame.center) let result: Angle = angle * -1 diff --git a/Loop/Window Management/Window Manipulation/ResizeContext.swift b/Loop/Window Management/Window Manipulation/ResizeContext.swift index 2a5aa9a4..9a8b1240 100644 --- a/Loop/Window Management/Window Manipulation/ResizeContext.swift +++ b/Loop/Window Management/Window Manipulation/ResizeContext.swift @@ -82,16 +82,16 @@ final class ResizeContext { needsRecompute = true } - func getTargetFrame() -> ComputedFrame { + func getTargetFrame() async -> ComputedFrame { if needsRecompute { - recomputeTargetFrame() + await recomputeTargetFrame() } return cachedTargetFrame } - private func recomputeTargetFrame() { - let result = WindowFrameResolver.getFrame(resizeContext: self) + private func recomputeTargetFrame() async { + let result = await WindowFrameResolver.getFrame(resizeContext: self) let normalized = CGRect( x: (result.frame.minX - bounds.minX) / bounds.width, diff --git a/Loop/Window Management/Window Manipulation/WindowActionEngine.swift b/Loop/Window Management/Window Manipulation/WindowActionEngine.swift index cb52e6c4..862f60a7 100644 --- a/Loop/Window Management/Window Manipulation/WindowActionEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowActionEngine.swift @@ -116,9 +116,10 @@ final class WindowActionEngine { // Perform the resize let appliedFrame = try await WindowEngine.performResize(context: context) + let intendedFrame = await context.getTargetFrame().padded // Return the frame that should be stored (either from system WM or from calculation) - return .resized(frame: appliedFrame ?? context.getTargetFrame().padded) + return .resized(frame: appliedFrame ?? intendedFrame) } // MARK: - Focus Actions diff --git a/Loop/Window Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index 6f88dc73..dd86563c 100644 --- a/Loop/Window Management/Window Manipulation/WindowEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowEngine.swift @@ -29,25 +29,27 @@ enum WindowEngine { guard !quickActions.contains(context.action.direction) else { return nil } let willChangeScreens = ScreenUtility.screenContaining(window) != context.screen - let targetFrame = context.getTargetFrame().padded + let targetFrame = await context.getTargetFrame().padded log.info("Resizing \(window) to \(targetFrame)") // Record first frame if needed - WindowRecords.recordFirstIfNeeded(for: window) + await WindowRecords.shared.recordFirstIfNeeded(for: window) - let storeAsFrame = WindowRecords.shouldStoreAsFinalFrame(context.action) + let storeAsFrame = await WindowRecords.shared.shouldStoreAsFinalFrame(context.action) // If this action doesn't require storage as a frame, then record it beforehand. // Otherwise, this action will be recorded *after* resizing, such that its final frame is considered if undoing. if !storeAsFrame { - WindowRecords.record(window, context.action) + await WindowRecords.shared.record(window, context.action) } defer { - if context.action.direction == .undo { - WindowRecords.removeLastAction(for: window) - } else if storeAsFrame { - WindowRecords.record(window, context.action) + Task { + if context.action.direction == .undo { + await WindowRecords.shared.removeLastAction(for: window) + } else if storeAsFrame { + await WindowRecords.shared.record(window, context.action) + } } } @@ -117,7 +119,8 @@ enum WindowEngine { ) async -> Bool { var action = action - if action.direction == .undo, let lastAction = WindowRecords.getLastAction(for: window) { + if action.direction == .undo, + let lastAction = await WindowRecords.shared.getLastAction(for: window) { action = lastAction } diff --git a/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift b/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift index 09d82a3e..cf77c4ca 100644 --- a/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift +++ b/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift @@ -25,16 +25,16 @@ enum WindowFrameResolver { window: Window?, bounds: CGRect, padding: PaddingConfiguration? = nil - ) -> CGRect { + ) async -> CGRect { let context = ResizeContext(window: window, bounds: bounds, padding: padding, action: action) - return getFrame(resizeContext: context).frame + return await getFrame(resizeContext: context).frame } /// Returns the frame for the specified window action using the provided resize context. /// The returned frame is non-padded. Use `PaddingConfiguration.apply(to:bounds:action:window:)` to apply padding. /// - Parameter resizeContext: the context containing window, screen, bounds, and tracking frame/edge adjustment state. /// - Returns: a tuple containing the computed frame and the sides to adjust for grow/shrink actions. - static func getFrame(resizeContext: ResizeContext) -> FrameResult { + static func getFrame(resizeContext: ResizeContext) async -> FrameResult { let action = resizeContext.action let bounds = resizeContext.paddedBounds let direction = action.direction @@ -50,7 +50,7 @@ enum WindowFrameResolver { nil } - var result: CGRect = calculateTargetFrame( + var result: CGRect = await calculateTargetFrame( sidesToAdjust: &sidesToAdjust, context: resizeContext ) @@ -74,7 +74,7 @@ extension WindowFrameResolver { private static func calculateTargetFrame( sidesToAdjust: inout Edge.Set?, context: ResizeContext - ) -> CGRect { + ) async -> CGRect { let bounds = context.paddedBounds let action = context.action let window = context.window @@ -139,12 +139,12 @@ extension WindowFrameResolver { ) } else if direction.willMove { - let frameToResizeFrom = context.getTargetFrame().raw + let frameToResizeFrom = await context.getTargetFrame().raw result = calculatePositionAdjustment(for: action, frameToResizeFrom: frameToResizeFrom) } else if direction.isCustomizable { - result = calculateCustomFrame(for: action, window: window, bounds: bounds) + result = await calculateCustomFrame(for: action, window: window, bounds: bounds) } else if direction == .center { result = calculateCenterFrame(window: window, bounds: bounds) @@ -153,10 +153,10 @@ extension WindowFrameResolver { result = calculateMacOSCenterFrame(window: window, bounds: bounds) } else if direction == .undo, let window { - result = getLastActionFrame(window: window, bounds: bounds) + result = await getLastActionFrame(window: window, bounds: bounds) } else if direction == .initialFrame, let window { - result = getInitialFrame(window: window) + result = await getInitialFrame(window: window) } else if direction == .maximizeHeight, let window { result = getMaximizeHeightFrame(window: window, bounds: bounds, padding: context.padding) @@ -165,7 +165,7 @@ extension WindowFrameResolver { result = getMaximizeWidthFrame(window: window, bounds: bounds, padding: context.padding) } else if direction == .unstash, let window { - result = getInitialFrame(window: window) + result = await getInitialFrame(window: window) } else if direction == .fillAvailableSpace, let window { result = getFillAvailableSpaceFrame(window: window) @@ -198,7 +198,7 @@ extension WindowFrameResolver { /// - window: the window to be manipulated. /// - bounds: the bounds within which the window should be manipulated. /// - Returns: the calculated custom frame based on the specified parameters. - private static func calculateCustomFrame(for action: WindowAction, window: Window?, bounds: CGRect) -> CGRect { + private static func calculateCustomFrame(for action: WindowAction, window: Window?, bounds: CGRect) async -> CGRect { var result = CGRect(origin: bounds.origin, size: .zero) // Size Calculation @@ -207,7 +207,7 @@ extension WindowFrameResolver { result.size = window.size } else if let sizeMode = action.sizeMode, sizeMode == .initialSize, let window { - if let initialFrame = WindowRecords.getInitialFrame(for: window) { + if let initialFrame = await WindowRecords.shared.getInitialFrame(for: window) { result.size = initialFrame.size } @@ -358,11 +358,11 @@ extension WindowFrameResolver { /// - window: the window for which the last action frame is to be retrieved. /// - bounds: the bounds within which the window should be manipulated. /// - Returns: the frame of the last action performed on the window, or the current frame if no last action is found. - private static func getLastActionFrame(window: Window, bounds: CGRect) -> CGRect { - if let previousAction = WindowRecords.getLastAction(for: window) { + private static func getLastActionFrame(window: Window, bounds: CGRect) async -> CGRect { + if let previousAction = await WindowRecords.shared.getLastAction(for: window) { log.info("Last action was \(previousAction.description)") - return WindowFrameResolver.getFrame( + return await WindowFrameResolver.getFrame( for: previousAction, window: window, bounds: bounds @@ -376,8 +376,8 @@ extension WindowFrameResolver { /// Retrieves the initial frame for the specified window, based on the initial frame recorded in `WindowRecords`. /// - Parameter window: the window for which the initial frame is to be retrieved. /// - Returns: the initial frame of the window, or the current frame if no initial frame is found. - private static func getInitialFrame(window: Window) -> CGRect { - if let initialFrame = WindowRecords.getInitialFrame(for: window) { + private static func getInitialFrame(window: Window) async -> CGRect { + if let initialFrame = await WindowRecords.shared.getInitialFrame(for: window) { return initialFrame } else { log.info("Didn't find initial frame; using current frame") diff --git a/Loop/Window Management/Window Manipulation/WindowRecords.swift b/Loop/Window Management/Window Manipulation/WindowRecords.swift index 9e0c26fe..a90759eb 100644 --- a/Loop/Window Management/Window Manipulation/WindowRecords.swift +++ b/Loop/Window Management/Window Manipulation/WindowRecords.swift @@ -8,9 +8,11 @@ import Scribe import SwiftUI -@Loggable(style: .static) -enum WindowRecords { - private static var recordsByWindowID: [CGWindowID: WindowRecords.Record] = [:] +@Loggable +actor WindowRecords { + nonisolated static let shared = WindowRecords() + + private var recordsByWindowID: [CGWindowID: WindowRecords.Record] = [:] struct Record { let initialFrame: CGRect @@ -24,7 +26,7 @@ enum WindowRecords { /// Erase all previous records for a window /// - Parameter window: Window to erase - static func eraseRecords(for window: Window) { + func eraseRecords(for window: Window) { guard recordsByWindowID[window.cgWindowID] != nil else { // Records don't exist return @@ -34,7 +36,7 @@ enum WindowRecords { log.success("Erased records for: \(window)") } - static func recordFirstIfNeeded(for window: Window) { + func recordFirstIfNeeded(for window: Window) { guard recordsByWindowID[window.cgWindowID] == nil else { return } recordsByWindowID[window.cgWindowID] = Record(initialFrame: window.frame) log.info("Recorded first for: \(window)") @@ -43,7 +45,7 @@ enum WindowRecords { /// Determines if an action should be recorded using its frame instead of the action applied onto it. /// - Parameter action: the action to apply onto the window. /// - Returns: Whether this action should be recorded with its final frame instead of using the action. - static func shouldStoreAsFinalFrame(_ action: WindowAction) -> Bool { + func shouldStoreAsFinalFrame(_ action: WindowAction) -> Bool { // Actions that are stored as frames need to be recorded *after* resize. // These actions are context-dependent, and cannot simply be called as an action to restore the previous state. let storeAsFrame = action.direction.willChangeScreen || action.willManipulateExistingWindowFrame @@ -54,7 +56,7 @@ enum WindowRecords { /// - Parameters: /// - window: Window to record /// - action: WindowAction to record - static func record(_ window: Window, _ action: WindowAction) { + func record(_ window: Window, _ action: WindowAction) { // If the window has not been recorded, record it recordFirstIfNeeded(for: window) @@ -100,7 +102,7 @@ enum WindowRecords { } /// Removes the last action performed on the specified window. This will NOT remove the first action for the specified window. - static func removeLastAction(for window: Window) { + func removeLastAction(for window: Window) { guard let record = recordsByWindowID[window.cgWindowID], record.actions.count > 1 else { @@ -117,7 +119,7 @@ enum WindowRecords { /// - Parameters: /// - window: Window to check /// - Returns: The window action - static func getLastAction(for window: Window) -> WindowAction? { + func getLastAction(for window: Window) -> WindowAction? { guard let record = recordsByWindowID[window.cgWindowID], record.actions.count >= 2 else { @@ -131,7 +133,7 @@ enum WindowRecords { /// - Parameters: /// - window: Window to check /// - Returns: The window action - static func getCurrentAction(for window: Window) -> WindowAction? { + func getCurrentAction(for window: Window) -> WindowAction? { guard let record = recordsByWindowID[window.cgWindowID], record.actions.count >= 1 else { @@ -141,7 +143,7 @@ enum WindowRecords { return record.actions[0] } - static func getInitialFrame(for window: Window) -> CGRect? { + func getInitialFrame(for window: Window) -> CGRect? { recordsByWindowID[window.cgWindowID]?.initialFrame } } From bfa3a66888e973f8f4f6e7d9fe509745cd6212c6 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Wed, 4 Mar 2026 21:06:35 -0700 Subject: [PATCH 04/61] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 6 ++--- Loop/Core/Observers/MultitouchTrigger.swift | 22 +++++++++---------- .../WindowActionFramedPreview.swift | 6 ++--- Loop/Stashing/StashManager.swift | 2 +- Loop/Stashing/StashedWindowInfo.swift | 3 ++- .../Preview Window/PreviewViewModel.swift | 20 ++++++++--------- 6 files changed, 30 insertions(+), 29 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index da01da5f..5f37230f 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -65,10 +65,10 @@ final class LoopManager { }, checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false } ) - + private(set) lazy var multitouchTrigger = MultitouchTrigger( windowActionCache: windowActionCache, - openCallback: { [weak self] action in + openCallback: { [weak self] action in Task { await self?.openLoop(startingAction: action) } @@ -333,7 +333,7 @@ extension LoopManager { if resizeContext.action.direction.isNoOp || resizeContext.action.willManipulateExistingWindowFrame { if let targetWindow = resizeContext.window { let screenSwitchingCustomActionName = "autogenerated_screen_switching_action" - + if let lastAction = await WindowRecords.shared.getCurrentAction(for: targetWindow), lastAction.getName() != screenSwitchingCustomActionName, !lastAction.forceProportionalFrameOnScreenChange { diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 4f5898d7..10f0e0c4 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -5,8 +5,8 @@ // Created by Kai Azim on 2026-01-30. // -import Subsurface import Scribe +import Subsurface import SwiftUI @Loggable @@ -27,7 +27,7 @@ final class MultitouchTrigger { private var maxTouchesInCurrentGesture: Int = 0 private var isCurrentGestureRejected = false private var didOpenLoopWithThisGesture = false - + private var lastTriggeredActionIndex: Int? private var lastTriggeredDistance: CGFloat = 0 private var lastTriggeredZoomDistance: CGFloat = 0 @@ -40,7 +40,7 @@ final class MultitouchTrigger { } private var positionHistory: [PositionHistoryEntry] = [] - private let maxHistoryEntries = 5 // Track last 5 positions for smoothing + private let maxHistoryEntries = 5 // Track last 5 positions for smoothing private let initialGestureThreshold: CGFloat = 0.025 private let gestureRepeatThreshold: CGFloat = 0.25 @@ -122,7 +122,7 @@ final class MultitouchTrigger { guard let originInfo = originGestureInfo, let lastInfo = lastGestureInfo else { // Check if cursor is over a titlebar before activating guard isCursorOverTitlebar() else { - isCurrentGestureRejected = true // Mark as rejected to skip future events + isCurrentGestureRejected = true // Mark as rejected to skip future events return } @@ -132,7 +132,7 @@ final class MultitouchTrigger { lastGestureInfo = info lastTriggeredActionIndex = nil lastTriggeredDistance = 0 - lastTriggeredZoomDistance = info.distance // Initialize to current finger distance + lastTriggeredZoomDistance = info.distance // Initialize to current finger distance gestureBlocker.start() // Reset position history for new gesture @@ -179,7 +179,7 @@ final class MultitouchTrigger { if let oldFingerDistance = fingerDistanceFromHistory() { // Use position history for stable detection let distanceChange = fingerDistance - oldFingerDistance - isZooming = distanceChange > initialGestureThreshold // Use initial threshold for sensitive detection + isZooming = distanceChange > initialGestureThreshold // Use initial threshold for sensitive detection } else if positionHistory.count >= 1 { // Use last frame's distance if history is building up let lastFingerDistance = lastInfo.distance @@ -206,7 +206,7 @@ final class MultitouchTrigger { lastTriggeredActionIndex = centerActionIndex lastTriggeredDistance = fingerDistance triggerAction(at: centerActionIndex, from: actions[...]) - return // Don't process directional actions while zooming + return // Don't process directional actions while zooming } // Process directional actions (swiping) @@ -238,13 +238,13 @@ final class MultitouchTrigger { magFromOrigin > 0 { let magMovement = hypot(movementDirection.width, movementDirection.height) - if magMovement >= initialGestureThreshold { // Only if meaningful movement occurred + if magMovement >= initialGestureThreshold { // Only if meaningful movement occurred let dotProduct = movementDirection.width * vectorFromOrigin.width + movementDirection.height * vectorFromOrigin.height let cosAngle = dotProduct / (magFromOrigin * magMovement) // Negative dot product means moving toward origin (opposite direction) - if cosAngle < -0.5 { // ~120 degree threshold + if cosAngle < -0.5 { // ~120 degree threshold originGestureInfo = info lastTriggeredActionIndex = nil lastTriggeredDistance = 0 @@ -325,7 +325,7 @@ final class MultitouchTrigger { lastGestureInfo = nil maxTouchesInCurrentGesture = 0 didOpenLoopWithThisGesture = false - positionHistory.removeAll() // Clear history on gesture end + positionHistory.removeAll() // Clear history on gesture end } private func averagePosition(of touches: [MTContact]) -> CGPoint { @@ -382,7 +382,7 @@ final class MultitouchTrigger { /// - Parameter cardinalBias: How much larger cardinals are relative to diagonals. /// A value of 0.1 makes cardinal zones 10% wider and diagonal zones 10% narrower than uniform (0.0 = equal sizes, 1.0 = diagonals disappear). private func indexWithCardinalBias(angle: CGFloat, actionCount: Int, cardinalBias: CGFloat = 0.1) -> Int { - let baseAngleSpan = (.pi * 2) / CGFloat(actionCount) // 45° for 8 actions + let baseAngleSpan = (.pi * 2) / CGFloat(actionCount) // 45° for 8 actions let halfAngleSpan = baseAngleSpan / 2.0 // Match the original centered mapping (boundaries at ±22.5° for 8 actions) diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/Components/WindowActionFramedPreview.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/Components/WindowActionFramedPreview.swift index 58ed524b..12687a6d 100644 --- a/Loop/Settings Window/Settings/Keybinds/Modal Views/Components/WindowActionFramedPreview.swift +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/Components/WindowActionFramedPreview.swift @@ -5,15 +5,15 @@ // Created by Kai Azim on 2026-02-15. // -import SwiftUI import Luminare +import SwiftUI struct WindowActionFramedPreview: View { @ObservedObject private var accentColorController: AccentColorController = .shared @Environment(\.luminareAnimation) private var luminareAnimation @State private var frame: CGRect = .zero let action: WindowAction - + var body: some View { ScreenView(isBlurred: action.sizeMode != .custom) { GeometryReader { geo in @@ -42,7 +42,7 @@ struct WindowActionFramedPreview: View { } } } - + private var blurredWindow: some View { VisualEffectView(material: .hudWindow, blendingMode: .withinWindow) .overlay { diff --git a/Loop/Stashing/StashManager.swift b/Loop/Stashing/StashManager.swift index b73a93be..3179525a 100644 --- a/Loop/Stashing/StashManager.swift +++ b/Loop/Stashing/StashManager.swift @@ -181,7 +181,7 @@ extension StashManager { else { return } - + onWindowResized(action: action, window: window, screen: screen) } } else if action.direction.willGrow diff --git a/Loop/Stashing/StashedWindowInfo.swift b/Loop/Stashing/StashedWindowInfo.swift index 1a26255d..6afacc46 100644 --- a/Loop/Stashing/StashedWindowInfo.swift +++ b/Loop/Stashing/StashedWindowInfo.swift @@ -15,7 +15,8 @@ struct StashedWindowInfo: Equatable { let screen: NSScreen let action: WindowAction - // MARK: - Frame computation + // MARK: - Frame computation + // TODO: Move to WindowFrameResolver /// Computes the frame for a stashed window. diff --git a/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift b/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift index 4de1df75..9c7814c1 100644 --- a/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift +++ b/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift @@ -39,12 +39,12 @@ final class PreviewViewModel: ObservableObject { Task { let isCurrentlyHidden = !isShown var paddedFrame = await context.getTargetFrame().padded - + if let bounds = context.screen?.displayBounds { paddedFrame.origin.x -= bounds.minX paddedFrame.origin.y -= bounds.minY } - + // In settings preview, actions that manipulate existing window frames (larger/smaller, // grow/shrink, move) cannot be previewed without a real window. let shouldBecomeVisible = if isSettingsPreview, context.action.willManipulateExistingWindowFrame { @@ -52,15 +52,15 @@ final class PreviewViewModel: ObservableObject { } else { paddedFrame.size.area > 0 } - + var newShownState: Bool = isShown var newComputedFrame: CGRect = computedFrame - + // If the window is currently shown, but needs to be hidden if !isCurrentlyHidden, !shouldBecomeVisible { newShownState = false } - + // If the window is currently hidden, but it needs to be shown. else if isCurrentlyHidden, shouldBecomeVisible { if !isScreenSwitch { @@ -69,20 +69,20 @@ final class PreviewViewModel: ObservableObject { targetFrame: paddedFrame, context: context ) - + // Set starting position without animation computedFrame = startingFrame } - + newShownState = true newComputedFrame = paddedFrame } - + // Window is already visible and should stay visible - update frame else if !isCurrentlyHidden, shouldBecomeVisible { newComputedFrame = paddedFrame } - + if isScreenSwitch { computedFrame = newComputedFrame isShown = newShownState @@ -92,7 +92,7 @@ final class PreviewViewModel: ObservableObject { isShown = newShownState } } - + log.ui("Current previewed frame: \(computedFrame) for \(context.action)") } } From 2768be8e9b8d76f20d2aab61bfeeef28c6fd0803 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 8 Mar 2026 18:22:41 -0600 Subject: [PATCH 05/61] =?UTF-8?q?=F0=9F=90=9E=20Fix=20duplicate=20packages?= =?UTF-8?q?=20+=20package=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop.xcodeproj/project.pbxproj | 61 ++++++++++++++++------------------ 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 7cb8efe5..30342c9e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,12 +7,11 @@ objects = { /* Begin PBXBuildFile section */ - 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28492A2F22B4B700F6CE42 /* Scribe */; }; 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B6282EE5050C00A1E26B /* Defaults */; }; 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B62B2EE5057C00A1E26B /* Luminare */; }; - 2AF9238E2F540B1300F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238D2F540B1300F467FD /* Scribe */; }; - 2AF923902F540B2200F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238F2F540B2200F467FD /* Scribe */; }; - 2A74ABA32F3E65A100EBF95C /* Subsurface in Frameworks */ = {isa = PBXBuildFile; productRef = 2A74ABA22F3E65A100EBF95C /* Subsurface */; }; + 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 */; }; 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 */; }; @@ -100,11 +99,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 2A847DFC2F5E49E90099E02A /* Scribe in Frameworks */, + 2A847DFF2F5E4A080099E02A /* Subsurface in Frameworks */, 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */, - 2A74ABA32F3E65A100EBF95C /* Subsurface in Frameworks */, 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */, - 2AF9238E2F540B1300F467FD /* Scribe in Frameworks */, - 2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */, F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */, 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */, ); @@ -114,7 +112,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2AF923902F540B2200F467FD /* Scribe in Frameworks */, + 2A847E012F5E4A0E0099E02A /* Scribe in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -181,9 +179,8 @@ 2A28B6282EE5050C00A1E26B /* Defaults */, 2A28B62B2EE5057C00A1E26B /* Luminare */, 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */, - 2A28492A2F22B4B700F6CE42 /* Scribe */, - 2A8EDDBB2F23743B005457F8 /* Scribe */, - 2A74ABA22F3E65A100EBF95C /* Subsurface */ + 2A847DFB2F5E49E90099E02A /* Scribe */, + 2A847DFE2F5E4A080099E02A /* Subsurface */, ); productName = WindowManager; productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */; @@ -206,7 +203,7 @@ ); name = LoopUpdaterHelper; packageProductDependencies = ( - 2AF9238F2F540B2200F467FD /* Scribe */, + 2A847E002F5E4A0E0099E02A /* Scribe */, ); productName = LoopUpdaterHelper; productReference = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; @@ -255,8 +252,8 @@ 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */, 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */, 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, - 2A0EEE832F381A3A00C40CF4 /* XCLocalSwiftPackageReference "../Subtrack" */, - 2A8EDDBA2F23743B005457F8 /* XCRemoteSwiftPackageReference "Scribe" */, + 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */, + 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */, ); productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */; projectDirPath = ""; @@ -752,13 +749,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - 2A74ABA12F3E65A100EBF95C /* XCLocalSwiftPackageReference "../Subsurface" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../Subsurface; - }; -/* End XCLocalSwiftPackageReference section */ - /* Begin XCRemoteSwiftPackageReference section */ 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */ = { isa = XCRemoteSwiftPackageReference; @@ -776,7 +766,7 @@ kind = branch; }; }; - 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */ = { + 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SenpaiHunters/Scribe"; requirement = { @@ -784,6 +774,14 @@ kind = branch; }; }; + 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MrKai77/Subsurface"; + requirement = { + branch = main; + kind = branch; + }; + }; 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/weichsel/ZIPFoundation"; @@ -795,10 +793,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 2A28492A2F22B4B700F6CE42 /* Scribe */ = { - isa = XCSwiftPackageProductDependency; - productName = Scribe; - }; 2A28B6282EE5050C00A1E26B /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */; @@ -809,18 +803,19 @@ package = 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */; productName = Luminare; }; - 2A74ABA22F3E65A100EBF95C /* Subsurface */ = { + 2A847DFB2F5E49E90099E02A /* Scribe */ = { isa = XCSwiftPackageProductDependency; - productName = Subsurface; + package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; + productName = Scribe; }; - 2AF9238D2F540B1300F467FD /* Scribe */ = { + 2A847DFE2F5E4A080099E02A /* Subsurface */ = { isa = XCSwiftPackageProductDependency; - package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */; - productName = Scribe; + package = 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */; + productName = Subsurface; }; - 2AF9238F2F540B2200F467FD /* Scribe */ = { + 2A847E002F5E4A0E0099E02A /* Scribe */ = { isa = XCSwiftPackageProductDependency; - package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */; + package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; productName = Scribe; }; 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */ = { From f9986e87bdf34e6d1bf910559bc6ff599c248184 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 8 Mar 2026 20:37:32 -0600 Subject: [PATCH 06/61] =?UTF-8?q?=E2=9C=A8=20Optimizations=20+=20better=20?= =?UTF-8?q?zoom=20gesture=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 41 +++++-- Loop/Core/Observers/MultitouchTrigger.swift | 128 ++++++-------------- 2 files changed, 63 insertions(+), 106 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 5f37230f..225dc291 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -39,7 +39,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 @@ -55,7 +55,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 @@ -69,9 +69,8 @@ final class LoopManager { private(set) lazy var multitouchTrigger = MultitouchTrigger( windowActionCache: windowActionCache, openCallback: { [weak self] action in - Task { - await self?.openLoop(startingAction: action) - } + guard let self else { return } + try await self.openLoop(startingAction: action) }, closeCallback: { [weak self] forceClose in Task { @@ -131,12 +130,29 @@ final class LoopManager { } } +enum LoopManagerError: LocalizedError { + case accessibilityNotGranted + case appExcluded + case fullscreenWindow + + var errorDescription: String? { + switch self { + case .accessibilityNotGranted: + "Cannot open Loop: accessibility permission not granted" + case .appExcluded: + "Cannot open Loop: app is excluded" + case .fullscreenWindow: + "Cannot open Loop: target window is fullscreen" + } + } +} + // MARK: - Opening/Closing Loop extension LoopManager { - private func openLoop(startingAction: WindowAction) async { + private func openLoop(startingAction: WindowAction) async throws { guard AccessibilityManager.shared.isGranted else { - return + throw LoopManagerError.accessibilityNotGranted } guard !isLoopActive else { @@ -153,11 +169,12 @@ extension LoopManager { let 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 } log.info("Opening Loop with starting action: \(startingAction.description) and target window: \(window?.description ?? "(none)")") diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 10f0e0c4..50534cc1 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -12,7 +12,7 @@ import SwiftUI @Loggable final class MultitouchTrigger { private let windowActionCache: WindowActionCache - private let openCallback: (WindowAction) -> () + private let openCallback: (WindowAction) async throws -> () private let closeCallback: (Bool) -> () private let changeAction: (WindowAction) -> () private let checkIfLoopOpen: () -> Bool @@ -30,12 +30,9 @@ final class MultitouchTrigger { private var lastTriggeredActionIndex: Int? private var lastTriggeredDistance: CGFloat = 0 - private var lastTriggeredZoomDistance: CGFloat = 0 private struct PositionHistoryEntry { let avgPosition: CGPoint - let touch1Position: CGPoint - let touch2Position: CGPoint let timestamp: TimeInterval } @@ -43,6 +40,7 @@ final class MultitouchTrigger { private let maxHistoryEntries = 5 // Track last 5 positions for smoothing private let initialGestureThreshold: CGFloat = 0.025 + private let initialZoomThreshold: CGFloat = 0.1 private let gestureRepeatThreshold: CGFloat = 0.25 private let zoomRepeatThreshold: CGFloat = 0.2 @@ -58,7 +56,7 @@ final class MultitouchTrigger { init( windowActionCache: WindowActionCache, - openCallback: @escaping (WindowAction) -> (), + openCallback: @escaping (WindowAction) async throws -> (), closeCallback: @escaping (Bool) -> (), changeAction: @escaping (WindowAction) -> (), checkIfLoopOpen: @escaping () -> Bool @@ -83,7 +81,7 @@ final class MultitouchTrigger { } if palmFiltered.count == 2, maxTouchesInCurrentGesture == 2 { - handleTwoFingerGesture(with: palmFiltered) + await handleTwoFingerGesture(with: palmFiltered) } else { resetGesture() } @@ -108,7 +106,7 @@ final class MultitouchTrigger { } } - private func handleTwoFingerGesture(with touches: [MTContact]) { + private func handleTwoFingerGesture(with touches: [MTContact]) async { // Skip processing if this gesture sequence was already rejected guard !isCurrentGestureRejected else { return @@ -132,7 +130,6 @@ final class MultitouchTrigger { lastGestureInfo = info lastTriggeredActionIndex = nil lastTriggeredDistance = 0 - lastTriggeredZoomDistance = info.distance // Initialize to current finger distance gestureBlocker.start() // Reset position history for new gesture @@ -140,31 +137,22 @@ final class MultitouchTrigger { // Only open Loop if it wasn't already open if !loopWasAlreadyOpen { - didOpenLoopWithThisGesture = true - openCallback(.init(.noSelection)) + do { + try await openCallback(.init(.noSelection)) + didOpenLoopWithThisGesture = true + } catch { + gestureBlocker.stop() + isCurrentGestureRejected = true + return + } } return } - // Check for zoom-in gesture first (independent of translation) - let touch1 = touches[0] - let touch2 = touches[1] - - let pos1 = CGPoint( - x: CGFloat(touch1.normalizedVector.position.x), - y: CGFloat(touch1.normalizedVector.position.y) - ) - let pos2 = CGPoint( - x: CGFloat(touch2.normalizedVector.position.x), - y: CGFloat(touch2.normalizedVector.position.y) - ) - // Update position history for stable direction detection at low velocities positionHistory.append(PositionHistoryEntry( avgPosition: info.position, - touch1Position: pos1, - touch2Position: pos2, timestamp: Date.timeIntervalSinceReferenceDate )) if positionHistory.count > maxHistoryEntries { @@ -174,21 +162,8 @@ final class MultitouchTrigger { // Calculate zoom distance (finger spread) let fingerDistance = info.distance - // Check if fingers are spreading apart by comparing distances - let isZooming: Bool - if let oldFingerDistance = fingerDistanceFromHistory() { - // Use position history for stable detection - let distanceChange = fingerDistance - oldFingerDistance - isZooming = distanceChange > initialGestureThreshold // Use initial threshold for sensitive detection - } else if positionHistory.count >= 1 { - // Use last frame's distance if history is building up - let lastFingerDistance = lastInfo.distance - let distanceChange = fingerDistance - lastFingerDistance - isZooming = distanceChange > initialGestureThreshold - } else { - // First frame: no zoom detection yet - isZooming = false - } + // Check if fingers are spreading apart compared to gesture start + let isZooming = (fingerDistance - originInfo.distance) > initialZoomThreshold // Prioritize zoom gestures over directional gestures if isZooming { @@ -217,13 +192,6 @@ final class MultitouchTrigger { let translationMagFromLast = hypot(deltaPositionFromLast.width, deltaPositionFromLast.height) - // Use lower threshold for initial gesture when no action is selected - let threshold: CGFloat = lastTriggeredActionIndex == nil ? initialGestureThreshold : gestureRepeatThreshold - guard translationMagFromLast >= threshold else { return } - - lastGestureInfo = info - - // Detect backward movement using position history let vectorFromOrigin = CGSize( width: info.position.x - originInfo.position.x, height: info.position.y - originInfo.position.y @@ -233,8 +201,9 @@ final class MultitouchTrigger { var didReset = false - // Use position history for stable direction detection - if let movementDirection = directionFromHistory(to: info.position), + // Only check backward motion if an action has been triggered (otherwise there's nothing to "undo") + if lastTriggeredActionIndex != nil, + let movementDirection = directionFromHistory(to: info.position), magFromOrigin > 0 { let magMovement = hypot(movementDirection.width, movementDirection.height) @@ -246,36 +215,34 @@ final class MultitouchTrigger { // Negative dot product means moving toward origin (opposite direction) if cosAngle < -0.5 { // ~120 degree threshold originGestureInfo = info + lastGestureInfo = info lastTriggeredActionIndex = nil lastTriggeredDistance = 0 - lastTriggeredZoomDistance = 0 didReset = true - print("RESET (history)") + changeAction(.init(.noSelection)) } } } - // Clear history after reset so new origin has clean slate + // If we just reset, clear history and return early, so that the user can start a fresh direction if didReset { positionHistory.removeAll() + return } - // Calculate angle from origin or movement direction if we just reset - let angleFromOrigin: CGFloat - let currentDistance: CGFloat + // Use lower threshold for initial gesture when no action is selected + let threshold: CGFloat = lastTriggeredActionIndex == nil ? initialGestureThreshold : gestureRepeatThreshold + guard translationMagFromLast >= threshold else { return } - if didReset { - // Use movement direction since we're at the new origin - angleFromOrigin = atan2(-deltaPositionFromLast.height, deltaPositionFromLast.width) + .pi / 2 - currentDistance = translationMagFromLast - } else { - let deltaPositionFromOrigin = CGSize( - width: info.position.x - originInfo.position.x, - height: info.position.y - originInfo.position.y - ) - angleFromOrigin = atan2(-deltaPositionFromOrigin.height, deltaPositionFromOrigin.width) + .pi / 2 - currentDistance = hypot(deltaPositionFromOrigin.width, deltaPositionFromOrigin.height) - } + lastGestureInfo = info + + // Calculate angle from origin + let deltaPositionFromOrigin = CGSize( + width: info.position.x - originInfo.position.x, + height: info.position.y - originInfo.position.y + ) + let angleFromOrigin = atan2(-deltaPositionFromOrigin.height, deltaPositionFromOrigin.width) + .pi / 2 + let currentDistance = hypot(deltaPositionFromOrigin.width, deltaPositionFromOrigin.height) var normalizedAngle = angleFromOrigin if normalizedAngle < 0 { normalizedAngle += 2 * .pi } @@ -354,19 +321,6 @@ final class MultitouchTrigger { ) } - /// Calculates finger spread from position history - /// Returns nil if insufficient history available - private func fingerDistanceFromHistory() -> CGFloat? { - guard let oldestEntry = positionHistory.first else { return nil } - - let oldDistance = hypot( - oldestEntry.touch2Position.x - oldestEntry.touch1Position.x, - oldestEntry.touch2Position.y - oldestEntry.touch1Position.y - ) - - return oldDistance - } - private func distance(between touches: [MTContact]) -> CGFloat { guard touches.count == 2 else { return 0 } @@ -456,20 +410,6 @@ final class MultitouchTrigger { changeAction(resolvedAction) } - - private func triggerCenterAction() { - // The center action is the last item in radialMenuActions - guard let centerAction = radialMenuActions.last else { return } - - let resolvedAction: WindowAction = switch centerAction.type { - case let .custom(windowAction): - windowAction - case let .keybindReference(id): - windowActionCache.actionsByIdentifier[id] ?? Self.failedToResolveKeybindAction - } - - changeAction(resolvedAction) - } } @Loggable From 26ab2f3e24cca31c4f642d4fb7ae8ba80861e055 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 8 Mar 2026 20:49:54 -0600 Subject: [PATCH 07/61] =?UTF-8?q?=F0=9F=90=9E=20Select=20correct=20window?= =?UTF-8?q?=20when=20opening=20Loop=20from=20gesture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 8 ++-- Loop/Core/Observers/MultitouchTrigger.swift | 52 +++++++++------------ 2 files changed, 26 insertions(+), 34 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 225dc291..2724863a 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -68,9 +68,9 @@ final class LoopManager { private(set) lazy var multitouchTrigger = MultitouchTrigger( windowActionCache: windowActionCache, - openCallback: { [weak self] action in + openCallback: { [weak self] action, window in guard let self else { return } - try await self.openLoop(startingAction: action) + try await openLoop(startingAction: action, window: window) }, closeCallback: { [weak self] forceClose in Task { @@ -150,7 +150,7 @@ enum LoopManagerError: LocalizedError { // MARK: - Opening/Closing Loop extension LoopManager { - private func openLoop(startingAction: WindowAction) async throws { + private func openLoop(startingAction: WindowAction, window: Window? = nil) async throws { guard AccessibilityManager.shared.isGranted else { throw LoopManagerError.accessibilityNotGranted } @@ -167,7 +167,7 @@ extension LoopManager { return } - let window = WindowUtility.userDefinedTargetWindow() + let window = window ?? WindowUtility.userDefinedTargetWindow() guard window?.isAppExcluded != true else { throw LoopManagerError.appExcluded diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 50534cc1..2cbc41dd 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -12,7 +12,7 @@ import SwiftUI @Loggable final class MultitouchTrigger { private let windowActionCache: WindowActionCache - private let openCallback: (WindowAction) async throws -> () + private let openCallback: (WindowAction, Window) async throws -> () private let closeCallback: (Bool) -> () private let changeAction: (WindowAction) -> () private let checkIfLoopOpen: () -> Bool @@ -39,10 +39,11 @@ final class MultitouchTrigger { private var positionHistory: [PositionHistoryEntry] = [] private let maxHistoryEntries = 5 // Track last 5 positions for smoothing - private let initialGestureThreshold: CGFloat = 0.025 + private let initialSlideThreshold: CGFloat = 0.025 + private let slideRepeatThreshold: CGFloat = 0.25 + private let initialZoomThreshold: CGFloat = 0.1 - private let gestureRepeatThreshold: CGFloat = 0.25 - private let zoomRepeatThreshold: CGFloat = 0.2 + private let zoomRepeatThreshold: CGFloat = 0.25 private var inactivityTask: Task<(), Never>? private let gestureBlocker: GestureBlocker = .init() @@ -51,12 +52,12 @@ final class MultitouchTrigger { RadialMenuAction.userConfiguredActions } - private let subtrack = SubsurfaceMonitor() + private let gestureMonitor = SubsurfaceMonitor() private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) // This helps to keep a stable ID init( windowActionCache: WindowActionCache, - openCallback: @escaping (WindowAction) async throws -> (), + openCallback: @escaping (WindowAction, Window) async throws -> (), closeCallback: @escaping (Bool) -> (), changeAction: @escaping (WindowAction) -> (), checkIfLoopOpen: @escaping () -> Bool @@ -70,7 +71,7 @@ final class MultitouchTrigger { func start() { Task { - for await (_, touchData) in subtrack.contacts() { + for await (_, touchData) in gestureMonitor.contacts() { resetInactivityTimer() let palmFiltered = touchData.filter { $0.finger != nil && $0.hand != nil } @@ -88,11 +89,11 @@ final class MultitouchTrigger { } } - subtrack.start() + gestureMonitor.start() } func stop() { - subtrack.stop() + gestureMonitor.stop() resetGesture() } @@ -119,13 +120,14 @@ final class MultitouchTrigger { guard let originInfo = originGestureInfo, let lastInfo = lastGestureInfo else { // Check if cursor is over a titlebar before activating - guard isCursorOverTitlebar() else { + let window = isCursorOverTitlebarOfWindow() + let loopWasAlreadyOpen = checkIfLoopOpen() + + guard window != nil || loopWasAlreadyOpen else { isCurrentGestureRejected = true // Mark as rejected to skip future events return } - let loopWasAlreadyOpen = checkIfLoopOpen() - originGestureInfo = info lastGestureInfo = info lastTriggeredActionIndex = nil @@ -136,9 +138,9 @@ final class MultitouchTrigger { positionHistory.removeAll() // Only open Loop if it wasn't already open - if !loopWasAlreadyOpen { + if let window, !loopWasAlreadyOpen { do { - try await openCallback(.init(.noSelection)) + try await openCallback(.init(.noSelection), window) didOpenLoopWithThisGesture = true } catch { gestureBlocker.stop() @@ -207,7 +209,7 @@ final class MultitouchTrigger { magFromOrigin > 0 { let magMovement = hypot(movementDirection.width, movementDirection.height) - if magMovement >= initialGestureThreshold { // Only if meaningful movement occurred + if magMovement >= initialSlideThreshold { // Only if meaningful movement occurred let dotProduct = movementDirection.width * vectorFromOrigin.width + movementDirection.height * vectorFromOrigin.height let cosAngle = dotProduct / (magFromOrigin * magMovement) @@ -231,7 +233,7 @@ final class MultitouchTrigger { } // Use lower threshold for initial gesture when no action is selected - let threshold: CGFloat = lastTriggeredActionIndex == nil ? initialGestureThreshold : gestureRepeatThreshold + let threshold: CGFloat = lastTriggeredActionIndex == nil ? initialSlideThreshold : slideRepeatThreshold guard translationMagFromLast >= threshold else { return } lastGestureInfo = info @@ -267,7 +269,7 @@ final class MultitouchTrigger { if let lastIndex = lastTriggeredActionIndex { if newIndex == lastIndex { // Same action - only trigger if we've moved further from origin - guard currentDistance >= lastTriggeredDistance + gestureRepeatThreshold else { return } + guard currentDistance >= lastTriggeredDistance + slideRepeatThreshold else { return } } } @@ -367,23 +369,13 @@ final class MultitouchTrigger { } } - private func isCursorOverTitlebar() -> Bool { - // If Loop is already open, intercept all gestures regardless of cursor position - if checkIfLoopOpen() { - return true - } - + private func isCursorOverTitlebarOfWindow() -> Window? { // Get current cursor position let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) // Get window at cursor position using existing WindowUtility guard let window = WindowUtility.windowAtPosition(cursorPosition) else { - return false - } - - // Respect app exclusion settings - if window.isAppExcluded { - return false + return nil } // Assume large titlebar variant @@ -395,7 +387,7 @@ final class MultitouchTrigger { let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY // Check if cursor is within titlebar region - return isInTitlebar + return isInTitlebar ? window : nil } private func triggerAction(at index: Int, from actions: ArraySlice) { From 42525dec40a891b7ad57fd74fa42831e69a3cf20 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 8 Mar 2026 20:59:20 -0600 Subject: [PATCH 08/61] =?UTF-8?q?=E2=9C=A8=20Higher=20zoom=20repeat=20thre?= =?UTF-8?q?shold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 2cbc41dd..b222235b 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -43,7 +43,7 @@ final class MultitouchTrigger { private let slideRepeatThreshold: CGFloat = 0.25 private let initialZoomThreshold: CGFloat = 0.1 - private let zoomRepeatThreshold: CGFloat = 0.25 + private let zoomRepeatThreshold: CGFloat = 0.4 private var inactivityTask: Task<(), Never>? private let gestureBlocker: GestureBlocker = .init() From 23c439c37ecb6b2ee65b91b0fb31becd34e6d46c Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 8 Mar 2026 21:00:56 -0600 Subject: [PATCH 09/61] =?UTF-8?q?=F0=9F=90=9E=20Fix=20event=20monitor=20ge?= =?UTF-8?q?tting=20deallocated=20mid-gesture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Event Monitoring/ActiveEventMonitor.swift | 10 ++++++++-- .../Event Monitoring/BaseEventTapMonitor.swift | 8 ++++++-- .../Event Monitoring/PassiveEventMonitor.swift | 10 ++++++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift b/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift index e76bf969..31142456 100644 --- a/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift +++ b/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift @@ -62,13 +62,19 @@ final class ActiveEventMonitor: BaseEventTapMonitor { // If disabled, simply pass the event through, but attempt to restart the event tap. if event.type == .tapDisabledByTimeout || event.type == .tapDisabledByUserInput { - observer.start() + if observer.isEnabled { + observer.start() + } + return Unmanaged.passUnretained(event) } return observer.handleEvent(event: event) } - let userInfo = Unmanaged.passUnretained(self).toOpaque() + + let retained = Unmanaged.passRetained(self as BaseEventTapMonitor) + self.retainedSelf = retained + let userInfo = retained.toOpaque() if let eventTap = CGEvent.tapCreate( tap: tapLocation, diff --git a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift index 90470727..381ff13a 100644 --- a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift +++ b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift @@ -13,6 +13,7 @@ import Scribe @Loggable class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { let id = UUID() + var retainedSelf: Unmanaged? private var eventTap: CFMachPort? private var runLoop: CFRunLoop? @@ -34,6 +35,9 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { CFMachPortInvalidate(eventTap) self.eventTap = nil } + + retainedSelf?.release() + retainedSelf = nil } func setupRunLoopSource(eventTap: CFMachPort) { @@ -50,20 +54,20 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { func start() { guard let eventTap else { return } + isEnabled = true log.info("Starting BaseEventTapMonitor with ID \(id)") CGEvent.tapEnable(tap: eventTap, enable: true) - isEnabled = true } func stop() { guard let eventTap else { return } + isEnabled = false log.info("Stopping BaseEventTapMonitor with ID \(id)") CGEvent.tapEnable(tap: eventTap, enable: false) - isEnabled = false } static func == (lhs: BaseEventTapMonitor, rhs: BaseEventTapMonitor) -> Bool { diff --git a/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift b/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift index f3943c48..32bad712 100644 --- a/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift +++ b/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift @@ -38,7 +38,10 @@ final class PassiveEventMonitor: BaseEventTapMonitor { // If disabled, attempt to restart the event tap if event.type == .tapDisabledByTimeout || event.type == .tapDisabledByUserInput { - observer.start() + if observer.isEnabled { + observer.start() + } + return Unmanaged.passUnretained(event) } @@ -46,7 +49,10 @@ final class PassiveEventMonitor: BaseEventTapMonitor { observer.eventCallback(event) return Unmanaged.passUnretained(event) } - let userInfo = Unmanaged.passUnretained(self).toOpaque() + + let retained = Unmanaged.passRetained(self as BaseEventTapMonitor) + self.retainedSelf = retained + let userInfo = retained.toOpaque() if let eventTap = CGEvent.tapCreate( tap: tapLocation, From b17c6cde028306dca3f0f9c86e686a98c7475d34 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Apr 2026 00:11:30 -0600 Subject: [PATCH 10/61] =?UTF-8?q?=E2=86=A9=EF=B8=8F=20Revert=20many=20chan?= =?UTF-8?q?ges=20back=20to=20develop=20branch's=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 2 +- Loop/Stashing/StashedWindowInfo.swift | 2 - .../Preview Window/PreviewViewModel.swift | 95 +++++++++---------- .../Radial Menu/RadialMenuViewModel.swift | 12 +-- .../Window Action/IconView.swift | 16 ++-- .../Window Action/WindowAction.swift | 4 +- 6 files changed, 60 insertions(+), 71 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 84b2c3e7..81a36ca5 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -184,7 +184,7 @@ extension LoopManager { let initialFrame: CGRect = if let window { // In case of a stashed window, use the revealed frame instead to prevent issue with frame calculation later. - await StashManager.shared.getRevealedFrameForStashedWindow( + StashManager.shared.getRevealedFrameForStashedWindow( id: window.cgWindowID ) ?? window.frame } else { diff --git a/Loop/Stashing/StashedWindowInfo.swift b/Loop/Stashing/StashedWindowInfo.swift index 26fc48b0..937948d8 100644 --- a/Loop/Stashing/StashedWindowInfo.swift +++ b/Loop/Stashing/StashedWindowInfo.swift @@ -17,8 +17,6 @@ struct StashedWindowInfo: Equatable { // MARK: - Frame computation - // TODO: Move to WindowFrameResolver - /// Computes the frame for a stashed window. func computeStashedFrame(peekSize: CGFloat, maxPeekPercent: CGFloat = 0.2) async -> CGRect { let bounds = screen.cgSafeScreenFrame diff --git a/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift b/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift index 9c7814c1..a9e75bd3 100644 --- a/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift +++ b/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift @@ -35,66 +35,63 @@ final class PreviewViewModel: ObservableObject { overrideCornerRadii = nil } - // Compute new frame in a separate thread - Task { - let isCurrentlyHidden = !isShown - var paddedFrame = await context.getTargetFrame().padded - - if let bounds = context.screen?.displayBounds { - paddedFrame.origin.x -= bounds.minX - paddedFrame.origin.y -= bounds.minY - } + let isCurrentlyHidden = !isShown + var paddedFrame = context.getTargetFrame().padded - // In settings preview, actions that manipulate existing window frames (larger/smaller, - // grow/shrink, move) cannot be previewed without a real window. - let shouldBecomeVisible = if isSettingsPreview, context.action.willManipulateExistingWindowFrame { - false - } else { - paddedFrame.size.area > 0 - } + if let bounds = context.screen?.displayBounds { + paddedFrame.origin.x -= bounds.minX + paddedFrame.origin.y -= bounds.minY + } - var newShownState: Bool = isShown - var newComputedFrame: CGRect = computedFrame + // In settings preview, actions that manipulate existing window frames (larger/smaller, + // grow/shrink, move) cannot be previewed without a real window. + let shouldBecomeVisible = if isSettingsPreview, context.action.willManipulateExistingWindowFrame { + false + } else { + paddedFrame.size.area > 0 + } - // If the window is currently shown, but needs to be hidden - if !isCurrentlyHidden, !shouldBecomeVisible { - newShownState = false - } + var newShownState: Bool = isShown + var newComputedFrame: CGRect = computedFrame - // If the window is currently hidden, but it needs to be shown. - else if isCurrentlyHidden, shouldBecomeVisible { - if !isScreenSwitch { - let startingFrame = computeStartingFrame( - for: Defaults[.previewStartingPosition], - targetFrame: paddedFrame, - context: context - ) - - // Set starting position without animation - computedFrame = startingFrame - } - - newShownState = true - newComputedFrame = paddedFrame - } + // If the window is currently shown, but needs to be hidden + if !isCurrentlyHidden, !shouldBecomeVisible { + newShownState = false + } - // Window is already visible and should stay visible - update frame - else if !isCurrentlyHidden, shouldBecomeVisible { - newComputedFrame = paddedFrame + // If the window is currently hidden, but it needs to be shown. + else if isCurrentlyHidden, shouldBecomeVisible { + if !isScreenSwitch { + let startingFrame = computeStartingFrame( + for: Defaults[.previewStartingPosition], + targetFrame: paddedFrame, + context: context + ) + + // Set starting position without animation + computedFrame = startingFrame } - if isScreenSwitch { + newShownState = true + newComputedFrame = paddedFrame + } + + // Window is already visible and should stay visible - update frame + else if !isCurrentlyHidden, shouldBecomeVisible { + newComputedFrame = paddedFrame + } + + if isScreenSwitch { + computedFrame = newComputedFrame + isShown = newShownState + } else { + withAnimation(Defaults[.animationConfiguration].previewWindow) { computedFrame = newComputedFrame isShown = newShownState - } else { - withAnimation(Defaults[.animationConfiguration].previewWindow) { - computedFrame = newComputedFrame - isShown = newShownState - } } - - log.ui("Current previewed frame: \(computedFrame) for \(context.action)") } + + log.ui("Current previewed frame: \(computedFrame) for \(context.action)") } private func computeStartingFrame( diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift index 066c57ac..c4054674 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift @@ -108,13 +108,11 @@ final class RadialMenuViewModel: ObservableObject { currentAction = context.action parentAction = context.parentAction - Task { - await recomputeAngle(context: context) - } + recomputeAngle(context: context) } - private func recomputeAngle(context: ResizeContext) async { - guard let targetAngle = await calculateTargetAngle(context: context) else { + private func recomputeAngle(context: ResizeContext) { + guard let targetAngle = calculateTargetAngle(context: context) else { return } @@ -127,7 +125,7 @@ final class RadialMenuViewModel: ObservableObject { } } - private func calculateTargetAngle(context: ResizeContext) async -> Angle? { + private func calculateTargetAngle(context: ResizeContext) -> Angle? { // Check directional radial menu actions first if let index = directionalRadialMenuActions.firstIndex(where: { $0.associatedActionId == effectiveWindowAction.id }) { let actionAngleSpan = 360.0 / CGFloat(directionalRadialMenuActions.count) @@ -135,7 +133,7 @@ final class RadialMenuViewModel: ObservableObject { } // Otherwise, default to the current action's radial menu angle - return await currentAction.radialMenuAngle(context: context) + return currentAction.radialMenuAngle(context: context) } private func shouldAnimateTransition(closestAngle: Angle) -> Bool { diff --git a/Loop/Window Management/Window Action/IconView.swift b/Loop/Window Management/Window Action/IconView.swift index 93445591..74a2083c 100644 --- a/Loop/Window Management/Window Action/IconView.swift +++ b/Loop/Window Management/Window Action/IconView.swift @@ -88,16 +88,12 @@ final class IconRenderView: NSView { ) { guard action != currentAction else { return } currentAction = action - Task { - await updatePath(duration: animated ? 0.2 : 0.0) - } + updatePath(duration: animated ? 0.2 : 0.0) } override func layout() { super.layout() - Task { - await updatePath(duration: 0.0) - } + updatePath(duration: 0.0) } override func viewDidChangeEffectiveAppearance() { @@ -133,7 +129,7 @@ final class IconRenderView: NSView { } } - private func updatePath(duration: CFTimeInterval) async { + private func updatePath(duration: CFTimeInterval) { strokeLayer.frame = bounds fillLayer.frame = bounds @@ -143,7 +139,7 @@ final class IconRenderView: NSView { let fillInset = strokeInset + inset let fillBounds = bounds.insetBy(dx: fillInset, dy: fillInset) - guard let displayMode = await determineDisplayMode(fillBounds: fillBounds) else { + guard let displayMode = determineDisplayMode(fillBounds: fillBounds) else { fillLayer.opacity = 0 imageLayer.opacity = 0 return @@ -181,12 +177,12 @@ final class IconRenderView: NSView { strokeLayer.path = strokePath } - private func determineDisplayMode(fillBounds: CGRect) async -> DisplayMode? { + private func determineDisplayMode(fillBounds: CGRect) -> DisplayMode? { if let image = currentAction.image { return .image(image.nsImage) } - let frame = await WindowFrameResolver.getFrame( + let frame = WindowFrameResolver.getFrame( for: currentAction, bounds: .init(origin: .zero, size: .init(width: 1, height: 1)), padding: .zero diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index 5b56eddb..8df21d0f 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -190,7 +190,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial /// /// - Parameter context: the resize context containing the pre-computed target frame. /// - Returns: the angle to show in the radial menu, or `nil` if the action does not have a radial menu angle. - func radialMenuAngle(context: ResizeContext) async -> Angle? { + func radialMenuAngle(context: ResizeContext) -> Angle? { guard direction.frameMultiplyValues != nil, direction.hasRadialMenuAngle @@ -198,7 +198,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial return nil } - let targetFrame = await context.getTargetFrame().normalized + let targetFrame = context.getTargetFrame().normalized let angle = CGPoint(x: 0.5, y: 0.5).angle(to: targetFrame.center) let result: Angle = angle * -1 From 92cd8988b856fce9f455d2dac29e530a089dd4be Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Apr 2026 03:39:15 -0600 Subject: [PATCH 11/61] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Migrate=20Multitouch?= =?UTF-8?q?Trigger=20to=20SubsurfaceGestureRecognizer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 13 +- .../Helpers/MultitouchGestureBlocker.swift | 36 ++ Loop/Core/Observers/MultitouchTrigger.swift | 388 +++++------------- 3 files changed, 143 insertions(+), 294 deletions(-) create mode 100644 Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 81a36ca5..1de7f786 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -75,9 +75,9 @@ final class LoopManager { await self?.closeLoop(forceClose: forceClose) } }, - changeAction: { [weak self] action in + changeAction: { [weak self] action, reverse in Task { - await self?.changeAction(action) + await self?.changeAction(action, reverse: reverse) } }, checkIfLoopOpen: { [weak self] in @@ -257,7 +257,8 @@ extension LoopManager { _ newAction: WindowAction, triggeredFromScreenChange: Bool = false, disableHapticFeedback: Bool = false, - canAdvanceCycle: Bool = true + canAdvanceCycle: Bool = true, + reverse: Bool = false ) async { guard isLoopActive, @@ -286,7 +287,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) @@ -444,7 +445,7 @@ extension LoopManager { } } - private func getNextCycleAction(_ action: WindowAction) async -> WindowAction { + private func getNextCycleAction(_ action: WindowAction, reverse: Bool) async -> WindowAction { guard let currentCycle = action.cycle else { return action } @@ -457,7 +458,7 @@ 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)) var currentIndex: Int? = nil if Defaults[.cycleModeRestartEnabled], diff --git a/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift b/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift new file mode 100644 index 00000000..6cab5069 --- /dev/null +++ b/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift @@ -0,0 +1,36 @@ +// +// MultitouchGestureBlocker.swift +// Loop +// +// Created by Kai Azim on 2026-04-05. +// + +import AppKit +import Scribe + +@Loggable +final class MultitouchGestureBlocker { + private var monitor: ActiveEventMonitor? + + func start() { + log.info("Starting gesture blocker") + + let eventTypes: [CGEventType] = [ + .scrollWheel, + CGEventType(rawValue: UInt32(NSEvent.EventType.gesture.rawValue)), + CGEventType(rawValue: UInt32(NSEvent.EventType.magnify.rawValue)), + CGEventType(rawValue: UInt32(NSEvent.EventType.rotate.rawValue)), + CGEventType(rawValue: UInt32(NSEvent.EventType.smartMagnify.rawValue)) + ].compactMap(\.self) + + monitor = ActiveEventMonitor("gesture_blocker", events: eventTypes) { _ in .ignore } + monitor?.start() + } + + func stop() { + monitor?.stop() + monitor = nil + + log.info("Stopped gesture blocker") + } +} diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 8f14e517..c18aa3b0 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -14,52 +14,35 @@ final class MultitouchTrigger { private let windowActionCache: WindowActionCache private let openCallback: (WindowAction, Window) async throws -> () private let closeCallback: (Bool) -> () - private let changeAction: (WindowAction) -> () + private let changeAction: (WindowAction, Bool) -> () private let checkIfLoopOpen: () -> Bool - struct GestureInfo { - let position: CGPoint - let distance: CGFloat - } + private let gestureMonitor = SubsurfaceMonitor() + private let gestureRecognizer = SubsurfaceGestureRecognizer(fingerCount: 2) + private let gestureBlocker: MultitouchGestureBlocker = .init() - private var originGestureInfo: GestureInfo? - private var lastGestureInfo: GestureInfo? - private var maxTouchesInCurrentGesture: Int = 0 - private var isCurrentGestureRejected = false private var didOpenLoopWithThisGesture = false + private var isGestureRejected = false private var lastTriggeredActionIndex: Int? private var lastTriggeredDistance: CGFloat = 0 - private struct PositionHistoryEntry { - let avgPosition: CGPoint - let timestamp: TimeInterval - } - - private var positionHistory: [PositionHistoryEntry] = [] - private let maxHistoryEntries = 5 // Track last 5 positions for smoothing - - private let initialSlideThreshold: CGFloat = 0.025 - private let slideRepeatThreshold: CGFloat = 0.25 - - private let initialZoomThreshold: CGFloat = 0.1 - private let zoomRepeatThreshold: CGFloat = 0.4 - - private var inactivityTask: Task<(), Never>? - private let gestureBlocker: GestureBlocker = .init() + private let panActivationThreshold: CGFloat = 0.3 + private let panCycleStepSize: CGFloat = 0.1 + private let pinchActivationThreshold: CGFloat = 0.4 + private let pinchCycleStepSize: CGFloat = 0.6 private var radialMenuActions: [RadialMenuAction] { RadialMenuAction.userConfiguredActions } - private let gestureMonitor = SubsurfaceMonitor() - private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) // This helps to keep a stable ID + private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) init( windowActionCache: WindowActionCache, openCallback: @escaping (WindowAction, Window) async throws -> (), closeCallback: @escaping (Bool) -> (), - changeAction: @escaping (WindowAction) -> (), + changeAction: @escaping (WindowAction, Bool) -> (), checkIfLoopOpen: @escaping () -> Bool ) { self.windowActionCache = windowActionCache @@ -70,295 +53,155 @@ final class MultitouchTrigger { } func start() { - Task { - for await (_, touchData) in gestureMonitor.contacts() { - resetInactivityTimer() - - let palmFiltered = touchData.filter { $0.finger != nil && $0.hand != nil } - - if palmFiltered.count != maxTouchesInCurrentGesture, - palmFiltered.isEmpty || palmFiltered.count > maxTouchesInCurrentGesture { - maxTouchesInCurrentGesture = palmFiltered.count - } + gestureMonitor.start() - if palmFiltered.count == 2, maxTouchesInCurrentGesture == 2 { - await handleTwoFingerGesture(with: palmFiltered) - } else { - resetGesture() + Task { + for await event in gestureRecognizer.events(from: gestureMonitor) { + switch event { + case let .pan(pan): + await handlePan(pan) + case let .pinch(pinch): + await handlePinch(pinch) + case .rotation: + break } } } - - gestureMonitor.start() } func stop() { gestureMonitor.stop() - resetGesture() + gestureRecognizer.reset() + resetLoopState() } - private func resetInactivityTimer() { - inactivityTask?.cancel() - - inactivityTask = Task { [weak self] in - try? await Task.sleep(for: .milliseconds(250)) - if Task.isCancelled { return } - self?.resetGesture() - } - } + private func handlePan(_ pan: SubsurfaceGestureEvent.PanEvent) async { + switch pan.phase { + case .began: + await handleGestureBegan() - private func handleTwoFingerGesture(with touches: [MTContact]) async { - // Skip processing if this gesture sequence was already rejected - guard !isCurrentGestureRejected else { - return - } + case .changed: + guard !isGestureRejected else { return } - let info = GestureInfo( - position: averagePosition(of: touches), - distance: distance(between: touches) - ) + let angleFromOrigin = pan.angle + .pi / 2 + var normalizedAngle = angleFromOrigin + if normalizedAngle < 0 { normalizedAngle += 2 * .pi } - guard let originInfo = originGestureInfo, let lastInfo = lastGestureInfo else { - // Check if cursor is over a titlebar before activating - let window = isCursorOverTitlebarOfWindow() - let loopWasAlreadyOpen = checkIfLoopOpen() + let actions = radialMenuActions.dropLast() + guard actions.count > 1 else { return } - guard window != nil || loopWasAlreadyOpen else { - isCurrentGestureRejected = true // Mark as rejected to skip future events - return + let newIndex: Int + if actions.count == 8 { + newIndex = indexWithCardinalBias(angle: normalizedAngle, actionCount: actions.count) + } else { + let actionAngleSpan = (.pi * 2) / CGFloat(actions.count) + let halfAngleSpan = actionAngleSpan / 2.0 + newIndex = Int((normalizedAngle + halfAngleSpan) / actionAngleSpan) % actions.count } - originGestureInfo = info - lastGestureInfo = info - lastTriggeredActionIndex = nil - lastTriggeredDistance = 0 - gestureBlocker.start() - - // Reset position history for new gesture - positionHistory.removeAll() - - // Only open Loop if it wasn't already open - if let window, !loopWasAlreadyOpen { - do { - try await openCallback(.init(.noSelection), window) - didOpenLoopWithThisGesture = true - } catch { - gestureBlocker.stop() - isCurrentGestureRejected = true - return - } + // Determine if we're cycling the same action backward (pulling back) + let isSameAction = lastTriggeredActionIndex == newIndex + let isReversing = isSameAction && pan.distance < lastTriggeredDistance - panCycleStepSize + + if isSameAction { + guard abs(pan.distance - lastTriggeredDistance) >= panCycleStepSize else { return } } - return - } + lastTriggeredActionIndex = newIndex + lastTriggeredDistance = pan.distance + triggerAction(at: newIndex, from: actions, reverse: isReversing) + + case .ended, .cancelled: + resetLoopState() - // Update position history for stable direction detection at low velocities - positionHistory.append(PositionHistoryEntry( - avgPosition: info.position, - timestamp: Date.timeIntervalSinceReferenceDate - )) - if positionHistory.count > maxHistoryEntries { - positionHistory.removeFirst() + default: + break } + } - // Calculate zoom distance (finger spread) - let fingerDistance = info.distance + private func handlePinch(_ pinch: SubsurfaceGestureEvent.PinchEvent) async { + switch pinch.phase { + case .began: + await handleGestureBegan() - // Check if fingers are spreading apart compared to gesture start - let isZooming = (fingerDistance - originInfo.distance) > initialZoomThreshold + case .changed: + guard !isGestureRejected else { return } - // Prioritize zoom gestures over directional gestures - if isZooming { let actions = radialMenuActions let centerActionIndex = actions.count - 1 + let isSameAction = lastTriggeredActionIndex == centerActionIndex + let isReversing = isSameAction && pinch.scale < lastTriggeredDistance - pinchCycleStepSize - // Trigger center action if it's a new action or moved significantly further - if let lastIndex = lastTriggeredActionIndex { - if lastIndex == centerActionIndex { - // Same action - only trigger if we've spread fingers further - guard fingerDistance >= lastTriggeredDistance + zoomRepeatThreshold else { return } - } + if isSameAction { + guard abs(pinch.scale - lastTriggeredDistance) >= pinchCycleStepSize else { return } + } else { + guard abs(pinch.scale - 1.0) >= pinchActivationThreshold else { return } } lastTriggeredActionIndex = centerActionIndex - lastTriggeredDistance = fingerDistance - triggerAction(at: centerActionIndex, from: actions[...]) - return // Don't process directional actions while zooming - } + lastTriggeredDistance = pinch.scale + triggerAction(at: centerActionIndex, from: actions[...], reverse: isReversing) - // Process directional actions (swiping) - let deltaPositionFromLast = CGSize( - width: info.position.x - lastInfo.position.x, - height: info.position.y - lastInfo.position.y - ) - - let translationMagFromLast = hypot(deltaPositionFromLast.width, deltaPositionFromLast.height) - - let vectorFromOrigin = CGSize( - width: info.position.x - originInfo.position.x, - height: info.position.y - originInfo.position.y - ) - - let magFromOrigin = hypot(vectorFromOrigin.width, vectorFromOrigin.height) - - var didReset = false - - // Only check backward motion if an action has been triggered (otherwise there's nothing to "undo") - if lastTriggeredActionIndex != nil, - let movementDirection = directionFromHistory(to: info.position), - magFromOrigin > 0 { - let magMovement = hypot(movementDirection.width, movementDirection.height) - - if magMovement >= initialSlideThreshold { // Only if meaningful movement occurred - let dotProduct = movementDirection.width * vectorFromOrigin.width + - movementDirection.height * vectorFromOrigin.height - let cosAngle = dotProduct / (magFromOrigin * magMovement) - - // Negative dot product means moving toward origin (opposite direction) - if cosAngle < -0.5 { // ~120 degree threshold - originGestureInfo = info - lastGestureInfo = info - lastTriggeredActionIndex = nil - lastTriggeredDistance = 0 - didReset = true - changeAction(.init(.noSelection)) - } - } - } + case .ended, .cancelled: + resetLoopState() - // If we just reset, clear history and return early, so that the user can start a fresh direction - if didReset { - positionHistory.removeAll() - return + default: + break } + } - // Use lower threshold for initial gesture when no action is selected - let threshold: CGFloat = lastTriggeredActionIndex == nil ? initialSlideThreshold : slideRepeatThreshold - guard translationMagFromLast >= threshold else { return } - - lastGestureInfo = info - - // Calculate angle from origin - let deltaPositionFromOrigin = CGSize( - width: info.position.x - originInfo.position.x, - height: info.position.y - originInfo.position.y - ) - let angleFromOrigin = atan2(-deltaPositionFromOrigin.height, deltaPositionFromOrigin.width) + .pi / 2 - let currentDistance = hypot(deltaPositionFromOrigin.width, deltaPositionFromOrigin.height) - - var normalizedAngle = angleFromOrigin - if normalizedAngle < 0 { normalizedAngle += 2 * .pi } - - let actions = radialMenuActions.dropLast() - guard actions.count > 1 else { return } + private func handleGestureBegan() async { + let window = isCursorOverTitlebarOfWindow() + let loopWasAlreadyOpen = checkIfLoopOpen() - let newIndex: Int - if actions.count == 8 { - // For exactly 8 actions, bias toward cardinal directions - // Cardinal directions are at indices 0, 2, 4, 6 (N, E, S, W) - // Diagonal directions are at indices 1, 3, 5, 7 (NE, SE, SW, NW) - newIndex = indexWithCardinalBias(angle: normalizedAngle, actionCount: actions.count) - } else { - // Standard even distribution for other action counts - let actionAngleSpan = (.pi * 2) / CGFloat(actions.count) - let halfAngleSpan = actionAngleSpan / 2.0 - newIndex = Int((normalizedAngle + halfAngleSpan) / actionAngleSpan) % actions.count + guard window != nil || loopWasAlreadyOpen else { + isGestureRejected = true + return } - // Only trigger if it's a new action OR we've moved significantly further in the same direction - if let lastIndex = lastTriggeredActionIndex { - if newIndex == lastIndex { - // Same action - only trigger if we've moved further from origin - guard currentDistance >= lastTriggeredDistance + slideRepeatThreshold else { return } + isGestureRejected = false + lastTriggeredActionIndex = nil + lastTriggeredDistance = 0 + gestureBlocker.start() + + if let window, !loopWasAlreadyOpen { + do { + try await openCallback(.init(.noSelection), window) + didOpenLoopWithThisGesture = true + } catch { + gestureBlocker.stop() + isGestureRejected = true } } - - lastTriggeredActionIndex = newIndex - lastTriggeredDistance = currentDistance - triggerAction(at: newIndex, from: actions) } - func resetGesture() { - isCurrentGestureRejected = false - - guard lastGestureInfo != nil else { - return - } - - // Only close Loop if this gesture was responsible for opening it + private func resetLoopState() { if didOpenLoopWithThisGesture { closeCallback(false) } gestureBlocker.stop() - lastGestureInfo = nil - maxTouchesInCurrentGesture = 0 didOpenLoopWithThisGesture = false - positionHistory.removeAll() // Clear history on gesture end + isGestureRejected = false + lastTriggeredActionIndex = nil + lastTriggeredDistance = 0 } - private func averagePosition(of touches: [MTContact]) -> CGPoint { - let sum = touches.reduce(into: CGPoint.zero) { result, touch in - result.x += CGFloat(touch.normalizedVector.position.x) - result.y += CGFloat(touch.normalizedVector.position.y) - } - - return CGPoint( - x: sum.x / CGFloat(touches.count), - y: sum.y / CGFloat(touches.count) - ) - } - - /// Calculates movement direction from position history - /// Returns nil if insufficient history available - private func directionFromHistory(to currentPosition: CGPoint) -> CGSize? { - guard positionHistory.count >= 3 else { return nil } - - // Use oldest available position for maximum stability - let oldestEntry = positionHistory.first! - - return CGSize( - width: currentPosition.x - oldestEntry.avgPosition.x, - height: currentPosition.y - oldestEntry.avgPosition.y - ) - } - - private func distance(between touches: [MTContact]) -> CGFloat { - guard touches.count == 2 else { return 0 } - - let p1 = CGPoint(x: CGFloat(touches[0].normalizedVector.position.x), y: CGFloat(touches[0].normalizedVector.position.y)) - let p2 = CGPoint(x: CGFloat(touches[1].normalizedVector.position.x), y: CGFloat(touches[1].normalizedVector.position.y)) - - return hypot(p2.x - p1.x, p2.y - p1.y) - } - - /// Maps an angle to an action index with bias toward cardinal directions. - /// For 8 actions, cardinal directions (N, E, S, W) get wider angular ranges, - /// requiring users to explicitly aim for ~45° to trigger diagonal actions. - /// - Parameter cardinalBias: How much larger cardinals are relative to diagonals. - /// A value of 0.1 makes cardinal zones 10% wider and diagonal zones 10% narrower than uniform (0.0 = equal sizes, 1.0 = diagonals disappear). private func indexWithCardinalBias(angle: CGFloat, actionCount: Int, cardinalBias: CGFloat = 0.1) -> Int { - let baseAngleSpan = (.pi * 2) / CGFloat(actionCount) // 45° for 8 actions + let baseAngleSpan = (.pi * 2) / CGFloat(actionCount) let halfAngleSpan = baseAngleSpan / 2.0 - // Match the original centered mapping (boundaries at ±22.5° for 8 actions) let adjustedAngle = (angle + halfAngleSpan).truncatingRemainder(dividingBy: .pi * 2) - - // Determine which 45° segment we're in (modulo handles angle == 2π) let rawSegment = Int(adjustedAngle / baseAngleSpan) % actionCount - // Calculate position within the segment (0.0 to 1.0) let segmentAngle = adjustedAngle.truncatingRemainder(dividingBy: baseAngleSpan) let normalizedPosition = segmentAngle / baseAngleSpan - // Cardinal directions are at even indices (0, 2, 4, 6) let isCurrentCardinal = rawSegment % 2 == 0 if isCurrentCardinal { - // Cardinal keeps its entire segment return rawSegment } else { - // Diagonal segment - cede edges to adjacent cardinals if normalizedPosition < cardinalBias / 2 { return (rawSegment - 1 + actionCount) % actionCount } else if normalizedPosition > 1.0 - cardinalBias / 2 { @@ -370,15 +213,12 @@ final class MultitouchTrigger { } private func isCursorOverTitlebarOfWindow() -> Window? { - // Get current cursor position let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) - // Get window at cursor position using existing WindowUtility guard let window = WindowUtility.windowAtPosition(cursorPosition) else { return nil } - // Assume large titlebar variant let titlebarHeight: CGFloat = 52.0 let titlebarMinY = window.frame.minY @@ -386,11 +226,10 @@ final class MultitouchTrigger { let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY - // Check if cursor is within titlebar region return isInTitlebar ? window : nil } - private func triggerAction(at index: Int, from actions: ArraySlice) { + private func triggerAction(at index: Int, from actions: ArraySlice, reverse: Bool = false) { let action = actions[index] let resolvedAction: WindowAction = switch action.type { @@ -400,33 +239,6 @@ final class MultitouchTrigger { windowActionCache.actionsByIdentifier[id] ?? Self.failedToResolveKeybindAction } - changeAction(resolvedAction) - } -} - -@Loggable -private final class GestureBlocker { - private var monitor: ActiveEventMonitor? - - func start() { - log.info("Starting gesture blocker") - - let eventTypes: [CGEventType] = [ - .scrollWheel, - CGEventType(rawValue: UInt32(NSEvent.EventType.gesture.rawValue)), - CGEventType(rawValue: UInt32(NSEvent.EventType.magnify.rawValue)), - CGEventType(rawValue: UInt32(NSEvent.EventType.rotate.rawValue)), - CGEventType(rawValue: UInt32(NSEvent.EventType.smartMagnify.rawValue)) - ].compactMap(\.self) - - monitor = ActiveEventMonitor("gesture_blocker", events: eventTypes) { _ in .ignore } - monitor?.start() - } - - func stop() { - monitor?.stop() - monitor = nil - - log.info("Stopped gesture blocker") + changeAction(resolvedAction, reverse) } } From 0b59b177fef4240d985df4cbea5fb83142f5b479 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 17 Apr 2026 00:01:40 -0600 Subject: [PATCH 12/61] =?UTF-8?q?=E2=9C=A8=20Gesture=20configuration=20set?= =?UTF-8?q?tings=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 17 +- Loop/Core/Observers/MultitouchTrigger.swift | 353 ++++++++++++++---- Loop/Extensions/Defaults+Extensions.swift | 10 + Loop/Localizable.xcstrings | 53 ++- .../Gestures/GestureBindingItemView.swift | 250 +++++++++++++ .../Gestures/GestureConfigPopoverView.swift | 79 ++++ .../Gestures/GesturesConfigurationView.swift | 87 +++++ Loop/Settings Window/SettingsTab.swift | 8 +- .../Window Action/GestureBinding.swift | 172 +++++++++ 9 files changed, 948 insertions(+), 81 deletions(-) create mode 100644 Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift create mode 100644 Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift create mode 100644 Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift create mode 100644 Loop/Window Management/Window Action/GestureBinding.swift diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 1de7f786..eac4350c 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -24,6 +24,7 @@ final class LoopManager { private let updater = Updater.shared private var accessibilityCheckerTask: Task<(), Never>? + private var gestureToggleTask: Task<(), Never>? private(set) var isLoopActive: Bool = false @@ -117,7 +118,9 @@ final class LoopManager { if status { await keybindTrigger.start() middleClickTrigger.start() - multitouchTrigger.start() + if Defaults[.enableGestures] { + multitouchTrigger.start() + } } else { keybindTrigger.stop() middleClickTrigger.stop() @@ -125,6 +128,18 @@ final class LoopManager { } } } + + gestureToggleTask = Task(priority: .background) { [weak self] in + for await enabled in Defaults.updates(.enableGestures) { + guard let self, !Task.isCancelled else { break } + + if enabled, AccessibilityManager.shared.isGranted { + multitouchTrigger.start() + } else { + multitouchTrigger.stop() + } + } + } } } diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index c18aa3b0..588836af 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -5,6 +5,7 @@ // Created by Kai Azim on 2026-01-30. // +import Defaults import Scribe import Subsurface import SwiftUI @@ -18,14 +19,13 @@ final class MultitouchTrigger { private let checkIfLoopOpen: () -> Bool private let gestureMonitor = SubsurfaceMonitor() - private let gestureRecognizer = SubsurfaceGestureRecognizer(fingerCount: 2) private let gestureBlocker: MultitouchGestureBlocker = .init() - private var didOpenLoopWithThisGesture = false - private var isGestureRejected = false + private var recognizersByFingerCount: [Int: SubsurfaceGestureRecognizer] = [:] + private var eventTasksByFingerCount: [Int: Task] = [:] + private var gestureStatesByFingerCount: [Int: GestureState] = [:] - private var lastTriggeredActionIndex: Int? - private var lastTriggeredDistance: CGFloat = 0 + private var bindingsObservationTask: Task? private let panActivationThreshold: CGFloat = 0.3 private let panCycleStepSize: CGFloat = 0.1 @@ -38,6 +38,13 @@ final class MultitouchTrigger { private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) + private struct GestureState { + var didOpenLoopWithThisGesture = false + var isGestureRejected = false + var lastTriggeredActionIndex: Int? + var lastTriggeredDistance: CGFloat = 0 + } + init( windowActionCache: WindowActionCache, openCallback: @escaping (WindowAction, Window) async throws -> (), @@ -54,14 +61,64 @@ final class MultitouchTrigger { func start() { gestureMonitor.start() + rebuildRecognizers() + + bindingsObservationTask = Task { [weak self] in + for await _ in Defaults.updates(.gestureBindings) { + guard !Task.isCancelled, let self else { break } + self.rebuildRecognizers() + } + } + } + + func stop() { + bindingsObservationTask?.cancel() + bindingsObservationTask = nil + + for (fingerCount, _) in eventTasksByFingerCount { + stopRecognizer(for: fingerCount) + } + + eventTasksByFingerCount.removeAll() + recognizersByFingerCount.removeAll() + gestureStatesByFingerCount.removeAll() + + gestureMonitor.stop() + } + + private func rebuildRecognizers() { + let bindings = Defaults[.gestureBindings] + let neededFingerCounts = Set(bindings.map(\.fingerCount)) + let currentFingerCounts = Set(recognizersByFingerCount.keys) + + // Remove stale recognizers + for fingerCount in currentFingerCounts.subtracting(neededFingerCounts) { + stopRecognizer(for: fingerCount) + recognizersByFingerCount.removeValue(forKey: fingerCount) + eventTasksByFingerCount.removeValue(forKey: fingerCount) + gestureStatesByFingerCount.removeValue(forKey: fingerCount) + } - Task { - for await event in gestureRecognizer.events(from: gestureMonitor) { + // Add new recognizers + for fingerCount in neededFingerCounts.subtracting(currentFingerCounts) { + startRecognizer(for: fingerCount) + } + } + + private func startRecognizer(for fingerCount: Int) { + let recognizer = SubsurfaceGestureRecognizer(fingerCount: fingerCount) + recognizersByFingerCount[fingerCount] = recognizer + gestureStatesByFingerCount[fingerCount] = GestureState() + + eventTasksByFingerCount[fingerCount] = Task { [weak self] in + guard let self else { return } + for await event in recognizer.events(from: gestureMonitor) { + guard !Task.isCancelled else { break } switch event { case let .pan(pan): - await handlePan(pan) + await self.handlePan(pan, fingerCount: fingerCount) case let .pinch(pinch): - await handlePinch(pinch) + await self.handlePinch(pinch, fingerCount: fingerCount) case .rotation: break } @@ -69,19 +126,37 @@ final class MultitouchTrigger { } } - func stop() { - gestureMonitor.stop() - gestureRecognizer.reset() - resetLoopState() + private func stopRecognizer(for fingerCount: Int) { + eventTasksByFingerCount[fingerCount]?.cancel() + recognizersByFingerCount[fingerCount]?.reset() + if let state = gestureStatesByFingerCount[fingerCount], state.didOpenLoopWithThisGesture { + closeCallback(false) + } + gestureBlocker.stop() + } + + private func handlePan(_ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int) async { + let bindings = Defaults[.gestureBindings] + let panBindings = bindings.filter { $0.gestureType.isPan && $0.fingerCount == fingerCount } + + if let radialMenuBinding = panBindings.first(where: { $0.gestureType == .radialMenu }) { + await handleRadialMenuPan(pan, fingerCount: fingerCount, binding: radialMenuBinding) + } else if let directionalBinding = matchDirectionalPanBinding(angle: pan.angle, from: panBindings) { + await handleDirectionalPan(pan, fingerCount: fingerCount, binding: directionalBinding) + } } - private func handlePan(_ pan: SubsurfaceGestureEvent.PanEvent) async { + private func handleRadialMenuPan( + _ pan: SubsurfaceGestureEvent.PanEvent, + fingerCount: Int, + binding: GestureBinding + ) async { switch pan.phase { case .began: - await handleGestureBegan() + await handleGestureBegan(fingerCount: fingerCount, binding: binding) case .changed: - guard !isGestureRejected else { return } + guard gestureStatesByFingerCount[fingerCount]?.isGestureRejected != true else { return } let angleFromOrigin = pan.angle + .pi / 2 var normalizedAngle = angleFromOrigin @@ -99,92 +174,244 @@ final class MultitouchTrigger { newIndex = Int((normalizedAngle + halfAngleSpan) / actionAngleSpan) % actions.count } - // Determine if we're cycling the same action backward (pulling back) - let isSameAction = lastTriggeredActionIndex == newIndex - let isReversing = isSameAction && pan.distance < lastTriggeredDistance - panCycleStepSize + let state = gestureStatesByFingerCount[fingerCount] ?? GestureState() + let isSameAction = state.lastTriggeredActionIndex == newIndex + let isReversing = isSameAction && pan.distance < state.lastTriggeredDistance - panCycleStepSize + + if isSameAction { + guard abs(pan.distance - state.lastTriggeredDistance) >= panCycleStepSize else { return } + } + + gestureStatesByFingerCount[fingerCount]?.lastTriggeredActionIndex = newIndex + gestureStatesByFingerCount[fingerCount]?.lastTriggeredDistance = pan.distance + triggerRadialMenuAction(at: newIndex, from: actions, reverse: isReversing) + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + private func handleDirectionalPan( + _ pan: SubsurfaceGestureEvent.PanEvent, + fingerCount: Int, + binding: GestureBinding + ) async { + switch pan.phase { + case .began: + await handleGestureBegan(fingerCount: fingerCount, binding: binding) + + case .changed: + guard gestureStatesByFingerCount[fingerCount]?.isGestureRejected != true else { return } + + let state = gestureStatesByFingerCount[fingerCount] ?? GestureState() + let isSameAction = state.lastTriggeredActionIndex == 0 + let isReversing = isSameAction && pan.distance < state.lastTriggeredDistance - panCycleStepSize if isSameAction { - guard abs(pan.distance - lastTriggeredDistance) >= panCycleStepSize else { return } + guard abs(pan.distance - state.lastTriggeredDistance) >= panCycleStepSize else { return } } - lastTriggeredActionIndex = newIndex - lastTriggeredDistance = pan.distance - triggerAction(at: newIndex, from: actions, reverse: isReversing) + gestureStatesByFingerCount[fingerCount]?.lastTriggeredActionIndex = 0 + gestureStatesByFingerCount[fingerCount]?.lastTriggeredDistance = pan.distance + triggerSingleAction(from: binding, reverse: isReversing) case .ended, .cancelled: - resetLoopState() + resetLoopState(for: fingerCount) default: break } } - private func handlePinch(_ pinch: SubsurfaceGestureEvent.PinchEvent) async { + private func handlePinch(_ pinch: SubsurfaceGestureEvent.PinchEvent, fingerCount: Int) async { + let bindings = Defaults[.gestureBindings] + + // If a radial menu binding exists at this finger count, pinch triggers the center action + if let radialMenuBinding = bindings.first(where: { $0.gestureType == .radialMenu && $0.fingerCount == fingerCount }) { + await handleRadialMenuPinch(pinch, fingerCount: fingerCount, binding: radialMenuBinding) + } else if let pinchBinding = bindings.first(where: { $0.gestureType == .pinch && $0.fingerCount == fingerCount }) { + await handleSingleActionPinch(pinch, fingerCount: fingerCount, binding: pinchBinding) + } + } + + /// Pinch within a radial menu binding, triggers the center (last) radial menu action. + private func handleRadialMenuPinch( + _ pinch: SubsurfaceGestureEvent.PinchEvent, + fingerCount: Int, + binding: GestureBinding + ) async { switch pinch.phase { case .began: - await handleGestureBegan() + await handleGestureBegan(fingerCount: fingerCount, binding: binding) case .changed: - guard !isGestureRejected else { return } + guard gestureStatesByFingerCount[fingerCount]?.isGestureRejected != true else { return } let actions = radialMenuActions let centerActionIndex = actions.count - 1 - let isSameAction = lastTriggeredActionIndex == centerActionIndex - let isReversing = isSameAction && pinch.scale < lastTriggeredDistance - pinchCycleStepSize + + let state = gestureStatesByFingerCount[fingerCount] ?? GestureState() + let isSameAction = state.lastTriggeredActionIndex == centerActionIndex + let isReversing = isSameAction && pinch.scale < state.lastTriggeredDistance - pinchCycleStepSize + + if isSameAction { + guard abs(pinch.scale - state.lastTriggeredDistance) >= pinchCycleStepSize else { return } + } else { + guard abs(pinch.scale - 1.0) >= pinchActivationThreshold else { return } + } + + gestureStatesByFingerCount[fingerCount]?.lastTriggeredActionIndex = centerActionIndex + gestureStatesByFingerCount[fingerCount]?.lastTriggeredDistance = pinch.scale + triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: isReversing) + + case .ended, .cancelled: + resetLoopState(for: fingerCount) + + default: + break + } + } + + /// Standalone pinch binding, triggers the binding's configured action. + private func handleSingleActionPinch( + _ pinch: SubsurfaceGestureEvent.PinchEvent, + fingerCount: Int, + binding: GestureBinding + ) async { + switch pinch.phase { + case .began: + await handleGestureBegan(fingerCount: fingerCount, binding: binding) + + case .changed: + guard gestureStatesByFingerCount[fingerCount]?.isGestureRejected != true else { return } + + let state = gestureStatesByFingerCount[fingerCount] ?? GestureState() + let isSameAction = state.lastTriggeredActionIndex == 0 + let isReversing = isSameAction && pinch.scale < state.lastTriggeredDistance - pinchCycleStepSize if isSameAction { - guard abs(pinch.scale - lastTriggeredDistance) >= pinchCycleStepSize else { return } + guard abs(pinch.scale - state.lastTriggeredDistance) >= pinchCycleStepSize else { return } } else { guard abs(pinch.scale - 1.0) >= pinchActivationThreshold else { return } } - lastTriggeredActionIndex = centerActionIndex - lastTriggeredDistance = pinch.scale - triggerAction(at: centerActionIndex, from: actions[...], reverse: isReversing) + gestureStatesByFingerCount[fingerCount]?.lastTriggeredActionIndex = 0 + gestureStatesByFingerCount[fingerCount]?.lastTriggeredDistance = pinch.scale + triggerSingleAction(from: binding, reverse: isReversing) case .ended, .cancelled: - resetLoopState() + resetLoopState(for: fingerCount) default: break } } - private func handleGestureBegan() async { - let window = isCursorOverTitlebarOfWindow() + private func handleGestureBegan(fingerCount: Int, binding: GestureBinding) async { + let window = findTargetWindow(for: binding) let loopWasAlreadyOpen = checkIfLoopOpen() guard window != nil || loopWasAlreadyOpen else { - isGestureRejected = true + gestureStatesByFingerCount[fingerCount]?.isGestureRejected = true return } - isGestureRejected = false - lastTriggeredActionIndex = nil - lastTriggeredDistance = 0 + gestureStatesByFingerCount[fingerCount]?.isGestureRejected = false + gestureStatesByFingerCount[fingerCount]?.lastTriggeredActionIndex = nil + gestureStatesByFingerCount[fingerCount]?.lastTriggeredDistance = 0 gestureBlocker.start() if let window, !loopWasAlreadyOpen { do { try await openCallback(.init(.noSelection), window) - didOpenLoopWithThisGesture = true + gestureStatesByFingerCount[fingerCount]?.didOpenLoopWithThisGesture = true } catch { gestureBlocker.stop() - isGestureRejected = true + gestureStatesByFingerCount[fingerCount]?.isGestureRejected = true } } } - private func resetLoopState() { - if didOpenLoopWithThisGesture { + private func resetLoopState(for fingerCount: Int) { + if gestureStatesByFingerCount[fingerCount]?.didOpenLoopWithThisGesture == true { closeCallback(false) } gestureBlocker.stop() - didOpenLoopWithThisGesture = false - isGestureRejected = false - lastTriggeredActionIndex = nil - lastTriggeredDistance = 0 + gestureStatesByFingerCount[fingerCount] = GestureState() + } + + private func findTargetWindow(for binding: GestureBinding) -> Window? { + let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) + + guard let window = WindowUtility.windowAtPosition(cursorPosition) else { + return nil + } + + switch binding.activationZone { + case .titlebar: + let titlebarHeight: CGFloat = Defaults[.gestureTitlebarHeight] + let titlebarMinY = window.frame.minY + let titlebarMaxY = window.frame.minY + titlebarHeight + let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY + return isInTitlebar ? window : nil + + case .anywhere: + return window + } + } + + private func triggerRadialMenuAction(at index: Int, from actions: ArraySlice, reverse: Bool = false) { + let action = actions[index] + + let resolvedAction: WindowAction = switch action.type { + case let .custom(windowAction): + windowAction + case let .keybindReference(id): + windowActionCache.actionsByIdentifier[id] ?? Self.failedToResolveKeybindAction + } + + changeAction(resolvedAction, reverse) + } + + private func triggerSingleAction(from binding: GestureBinding, reverse: Bool = false) { + let resolvedAction: WindowAction + + switch binding.action { + case .radialMenuActions: + return + case let .singleAction(actionType): + switch actionType { + case let .custom(windowAction): + resolvedAction = windowAction + case let .keybindReference(id): + resolvedAction = windowActionCache.actionsByIdentifier[id] ?? Self.failedToResolveKeybindAction + } + } + + changeAction(resolvedAction, reverse) + } + + private func matchDirectionalPanBinding(angle: CGFloat, from bindings: [GestureBinding]) -> GestureBinding? { + let angleFromOrigin = angle + .pi / 2 + var normalizedAngle = angleFromOrigin + if normalizedAngle < 0 { normalizedAngle += 2 * .pi } + + let direction: GestureBinding.GestureType + if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { + direction = .panUp + } else if normalizedAngle >= .pi / 4 && normalizedAngle < 3 * .pi / 4 { + direction = .panRight + } else if normalizedAngle >= 3 * .pi / 4 && normalizedAngle < 5 * .pi / 4 { + direction = .panDown + } else { + direction = .panLeft + } + + return bindings.first { $0.gestureType == direction } } private func indexWithCardinalBias(angle: CGFloat, actionCount: Int, cardinalBias: CGFloat = 0.1) -> Int { @@ -211,34 +438,4 @@ final class MultitouchTrigger { } } } - - private func isCursorOverTitlebarOfWindow() -> Window? { - let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) - - guard let window = WindowUtility.windowAtPosition(cursorPosition) else { - return nil - } - - let titlebarHeight: CGFloat = 52.0 - - let titlebarMinY = window.frame.minY - let titlebarMaxY = window.frame.minY + titlebarHeight - - let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY - - return isInTitlebar ? window : nil - } - - private func triggerAction(at index: Int, from actions: ArraySlice, reverse: Bool = false) { - let action = actions[index] - - let resolvedAction: WindowAction = switch action.type { - case let .custom(windowAction): - windowAction - case let .keybindReference(id): - windowActionCache.actionsByIdentifier[id] ?? Self.failedToResolveKeybindAction - } - - changeAction(resolvedAction, reverse) - } } diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 64a70394..8f5d15b7 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -70,6 +70,10 @@ extension Defaults.Keys { static let cycleBackwardsOnShiftPressed = Key("cycleBackwardsOnShiftPressed", default: true, iCloud: true) static let keybinds = Key<[WindowAction]>("keybinds", default: WindowAction.defaultKeybinds, iCloud: true) + // Gestures + static let enableGestures = Key("enableGestures", default: false, iCloud: true) + static let gestureBindings = Key<[GestureBinding]>("gestureBindings", default: GestureBinding.defaultBindings, iCloud: true) + // Advanced static let useSystemWindowManagerWhenAvailable = Key("useSystemWindowManagerWhenAvailable", default: false, iCloud: true) static let animateWindowResizes = Key("animateWindowResizes", default: false, iCloud: true) @@ -140,6 +144,12 @@ extension Defaults.Keys { /// 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) + + /// Height of the titlebar activation zone for gesture bindings, 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: 52, iCloud: true) // Migrator diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 0f72a46a..d1a7ee84 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, "%" : { "comment" : "Unit symbol: percentage", "localizations" : { @@ -1152,6 +1155,10 @@ } } }, + "Activation Zone" : { + "comment" : "A label describing the activation zone of a gesture binding.", + "isCommentAutoGenerated" : true + }, "Add" : { "comment" : "Used to add items to a list", "localizations" : { @@ -6291,6 +6298,10 @@ } } }, + "Enable gestures" : { + "comment" : "Toggle label for enabling gestures.", + "isCommentAutoGenerated" : true + }, "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 +7010,10 @@ } } }, + "Fingers" : { + "comment" : "Label for the number of fingers in the gesture.", + "isCommentAutoGenerated" : true + }, "First Fourth" : { "comment" : "Window action", "localizations" : { @@ -8066,6 +8081,13 @@ } } }, + "Gesture Bindings" : { + "comment" : "Section header shown in gestures settings" + }, + "Gesture Type" : { + "comment" : "A label describing the type of gesture.", + "isCommentAutoGenerated" : true + }, "Go Back" : { "comment" : "Section header in the action picker of the Keybinds tab", "localizations" : { @@ -18516,6 +18538,10 @@ } } }, + "No gesture bindings" : { + "comment" : "A message displayed when there are no gesture bindings configured.", + "isCommentAutoGenerated" : true + }, "No keybinds" : { "localizations" : { "ar" : { @@ -23270,6 +23296,10 @@ } } }, + "Open Radial Menu" : { + "comment" : "A label for an action that opens a radial menu.", + "isCommentAutoGenerated" : true + }, "Options" : { "comment" : "Section header shown in settings", "localizations" : { @@ -23978,6 +24008,10 @@ } } }, + "Press \"Add\" to add a gesture binding" : { + "comment" : "A description displayed when there are no gesture bindings configured. It instructs the user to add one.", + "isCommentAutoGenerated" : true + }, "Press \"Add\" to add a keybind" : { "localizations" : { "ar" : { @@ -27874,6 +27908,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" : { @@ -31413,6 +31460,10 @@ } } }, + "This gesture conflicts with another binding." : { + "comment" : "A tooltip that appears when a gesture conflicts with another binding.", + "isCommentAutoGenerated" : true + }, "This macOS version is no longer supported." : { "comment" : "Text displayed in a notification when the current macOS version is no longer supported.", "localizations" : { @@ -35583,5 +35634,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift new file mode 100644 index 00000000..237ac326 --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift @@ -0,0 +1,250 @@ +// +// GestureBindingItemView.swift +// Loop +// +// Created by Kai Azim on 2026-04-16. +// + +import Defaults +import Luminare +import SwiftUI + +struct GestureBindingItemView: View { + @Environment(\.luminareItemBeingHovered) private var isHovering + @Environment(\.luminareAnimation) var luminareAnimation + + @Default(.keybinds) private var keybinds + + @State private var binding: GestureBinding + @Binding private var externalBinding: 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(_ binding: Binding, hasConflict: Bool = false) { + self.binding = binding.wrappedValue + self._externalBinding = binding + self.hasConflict = hasConflict + } + + var body: some View { + ZStack { + gestureConfiguration + .frame(maxWidth: .infinity, alignment: .leading) + + label + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 12) + .onChange(of: binding) { externalBinding = $0 } + } + + private var label: some View { + actionIndicator + .background(alignment: .trailing) { + if isHovering || isActionPickerPresented { + Color.clear + .frame(width: 300 - 24) + .luminarePopover( + isPresented: $isActionPickerPresented, + arrowEdge: .top, + shouldHideAnchor: true, + shouldAnimate: false + ) { + RadialMenuActionPickerView(selection: actionTypeBinding) + .frame(width: 300, height: 300) + } + .luminareSheetClosesOnDefocus(true) + .onChange(of: isActionPickerPresented) { _ in + if !isActionPickerPresented { + PickerListEventMonitorManager.shared.removeAllMonitors() + } + } + } + } + } + + var actionIndicator: some View { + HStack(spacing: 2) { + if case .radialMenuActions = binding.action { + HStack(spacing: 4) { + Image(.loop) + Text("Open Radial Menu") + } + .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("No Action") + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 4) + } + .luminareContentSize(contentMode: .fit, hasFixedHeight: true) + .luminareRoundingBehavior(top: true, bottom: true) + .luminareFilledStates([.hovering, .pressed]) + .luminareBorderedStates(.hovering) + .luminareMinHeight(24) + .padding(.leading, -4) + } + + Group { + if let resolvedAction { + if resolvedAction.direction.isCustomizable { + Button { + isConfiguringCustom = true + } label: { + Image(systemName: "slider.horizontal.3") + } + .buttonStyle(.plain) + .luminareModalWithPredefinedSheetStyle( + isPresented: $isConfiguringCustom, + isCompact: false + ) { + if resolvedAction.direction == .custom { + CustomActionConfigurationView( + action: actionBinding, + isPresented: $isConfiguringCustom + ) + .frame(width: 400) + } else { + StashActionConfigurationView( + action: actionBinding, + isPresented: $isConfiguringCustom + ) + .frame(width: 400) + } + } + .help("Customize this action's custom frame.") + } + + if resolvedAction.direction == .cycle { + Button { + isConfiguringCycle = true + } label: { + Image(systemName: "repeat") + } + .buttonStyle(.plain) + .luminareModalWithPredefinedSheetStyle( + isPresented: $isConfiguringCycle, + isCompact: false + ) { + CycleActionConfigurationView( + action: actionBinding, + isPresented: $isConfiguringCycle + ) + .frame(width: 400) + } + .help("Customize what this action cycles through.") + } + } + } + .font(.title3) + .foregroundStyle(isHovering ? .primary : .secondary) + } + } + + private var gestureConfiguration: some View { + Button { + isGestureConfigPresented = true + } label: { + Text(gestureConfigurationText) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .luminarePlateau() + .luminareRoundingBehavior(top: true, bottom: true) + .padding(.trailing, 4) + .luminareToolTip(attachedTo: .topTrailing, hidden: !hasConflict) { + Text("This gesture conflicts with another binding.") + .padding(6) + } + .luminareTint(overridingWith: .red) + .background(alignment: .leading) { + if isHovering || isGestureConfigPresented { + Color.clear + .frame(width: 270 - 24) + .luminarePopover( + isPresented: $isGestureConfigPresented, + arrowEdge: .top, + shouldHideAnchor: true, + shouldAnimate: false + ) { + GestureConfigPopoverView(binding: $binding) + .frame(width: 270) + } + .luminareSheetClosesOnDefocus(true) + } + } + } + + private var gestureConfigurationText: String { + switch binding.gestureType { + case .radialMenu: + "\(binding.fingerCount)-finger Gesture" + default: + "\(binding.fingerCount)-finger \(binding.gestureType.displayName)" + } + } + + private var resolvedAction: WindowAction? { + switch binding.action { + case .radialMenuActions: + return nil + case let .singleAction(actionType): + return actionType.resolvedAction + } + } + + private var actionTypeBinding: Binding { + Binding( + get: { + if case let .singleAction(actionType) = binding.action { + return actionType + } + return .custom(.init(.noAction)) + }, + set: { newValue in + binding.action = .singleAction(newValue) + } + ) + } + + private var actionBinding: Binding { + Binding( + get: { + resolvedAction ?? .init(.noAction) + }, + set: { newAction in + if case let .singleAction(actionType) = binding.action { + switch actionType { + case .custom: + binding.action = .singleAction(.custom(newAction)) + case let .keybindReference(id): + if let index = Defaults[.keybinds].firstIndex(where: { $0.id == id }) { + keybinds[index] = newAction + } + } + } + } + ) + } +} diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift new file mode 100644 index 00000000..a13e6dbb --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift @@ -0,0 +1,79 @@ +// +// GestureConfigPopoverView.swift +// Loop +// +// Created by Kai Azim on 2026-04-16. +// + +import SwiftUI +import Luminare + +struct GestureConfigPopoverView: View { + @State private var binding: GestureBinding + @Binding private var externalBinding: GestureBinding + + init(binding: Binding) { + self.binding = binding.wrappedValue + self._externalBinding = binding + } + + private var gestureTypeBinding: Binding { + Binding( + get: { binding.gestureType }, + set: { newType in + let oldType = binding.gestureType + binding.gestureType = newType + + if newType == .radialMenu { + binding.action = .radialMenuActions + } else if oldType == .radialMenu { + binding.action = .singleAction(.custom(.init(.noAction))) + } + } + ) + } + + var body: some View { + LuminareSection { + LuminareCompose("Gesture Type") { + Picker("", selection: gestureTypeBinding) { + ForEach(Array(GestureBinding.GestureType.allCases.enumerated()), id: \.element) { _, type in + HStack { + type.image + .frame(width: 12) + + Text(type.displayName) + } + .tag(type) + } + } + .labelsHidden() + } + + LuminareCompose("Fingers") { + HStack { + TextField("", value: $binding.fingerCount, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 40) + + Stepper("", value: $binding.fingerCount, in: 2...5) + .labelsHidden() + } + } + + LuminareCompose("Activation Zone") { + Picker("", selection: $binding.activationZone) { + ForEach(GestureBinding.ActivationZone.allCases, id: \.self) { zone in + Label(zone.displayName, systemImage: zone.systemImage) + .tag(zone) + } + } + .labelsHidden() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: binding) { externalBinding = $0 } + .luminareFilledStates(.none) + .luminareBorderedStates(.none) + } +} diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift new file mode 100644 index 00000000..96d2a66f --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -0,0 +1,87 @@ +// +// GesturesConfigurationView.swift +// Loop +// +// Created by Kai Azim on 2026-04-16. +// + +import Defaults +import Luminare +import SwiftUI + +final class GesturesConfigurationModel: ObservableObject { + @Published var selectedBindings = Set() +} + +struct GesturesConfigurationView: View { + @Environment(\.luminareAnimation) private var luminareAnimation + @StateObject private var model = GesturesConfigurationModel() + + @Default(.enableGestures) private var enableGestures + @Default(.gestureBindings) private var gestureBindings + @Default(.gestureTitlebarHeight) private var gestureTitlebarHeight + + private var conflictingIDs: Set { + GestureBinding.conflictingIDs(in: gestureBindings) + } + + var body: some View { + Group { + settingsSection + bindingsSection + .disabled(!enableGestures) + } + } + + private var settingsSection: some View { + LuminareSection { + LuminareToggle("Enable gestures", isOn: $enableGestures) + } + } + + private var bindingsSection: some View { + LuminareSection(String(localized: "Gesture Bindings", comment: "Section header shown in gestures settings")) { + HStack(spacing: 4) { + Button("Add") { + gestureBindings.insert( + GestureBinding(), + at: 0 + ) + } + .luminareRoundingBehavior(topLeading: true) + + Button("Remove", role: .destructive) { + gestureBindings.removeAll(where: model.selectedBindings.contains) + } + .luminareRoundingBehavior(topTrailing: true) + .disabled(model.selectedBindings.isEmpty) + .keyboardShortcut(.delete) + } + + LuminareList( + items: $gestureBindings, + selection: $model.selectedBindings, + id: \.id + ) { binding in + GestureBindingItemView( + binding, + hasConflict: conflictingIDs.contains(binding.wrappedValue.id) + ) + } emptyView: { + HStack { + Spacer() + VStack { + Text("No gesture bindings") + .font(.title3) + Text("Press \"Add\" to add a gesture binding") + .font(.caption) + } + Spacer() + } + .foregroundStyle(.secondary) + .padding() + } + .luminareRoundingBehavior(bottom: true) + } + } +} diff --git a/Loop/Settings Window/SettingsTab.swift b/Loop/Settings Window/SettingsTab.swift index add77b9c..5b0ed89a 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/Window Management/Window Action/GestureBinding.swift b/Loop/Window Management/Window Action/GestureBinding.swift new file mode 100644 index 00000000..1ddf931e --- /dev/null +++ b/Loop/Window Management/Window Action/GestureBinding.swift @@ -0,0 +1,172 @@ +// +// 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 fingerCount: Int + var gestureType: GestureType + var action: GestureAction + var activationZone: ActivationZone + + init( + id: UUID = .init(), + fingerCount: Int = 2, + gestureType: GestureType = .radialMenu, + action: GestureAction = .radialMenuActions, + activationZone: ActivationZone = .titlebar + ) { + self.id = id + self.fingerCount = fingerCount + self.gestureType = gestureType + self.action = action + self.activationZone = activationZone + } + + enum GestureType: Codable, Hashable, CaseIterable { + /// Pan gesture that maps angle to radial menu directional slots. + case radialMenu + /// Directional pan gestures that trigger a single action. + case panUp, panDown, panLeft, panRight + /// Pinch gesture. + case pinch + + var displayName: String { + switch self { + case .radialMenu: "Radial Menu" + case .panUp: "Swipe Up" + case .panDown: "Swipe Down" + case .panLeft: "Swipe Left" + case .panRight: "Swipe Right" + case .pinch: "Pinch" + } + } + + var image: Image { + switch self { + case .radialMenu: Image(.loop) + case .panUp: Image(systemName: "arrow.up") + case .panDown: Image(systemName: "arrow.down") + case .panLeft: Image(systemName: "arrow.left") + case .panRight: Image(systemName: "arrow.right") + case .pinch: Image(systemName: "arrow.down.left.and.arrow.up.right") + } + } + + var isPan: Bool { + switch self { + case .radialMenu, .panUp, .panDown, .panLeft, .panRight: + true + case .pinch: + false + } + } + + var isDirectionalPan: Bool { + switch self { + case .panUp, .panDown, .panLeft, .panRight: + true + default: + false + } + } + } + + enum GestureAction: Codable, Hashable { + /// Uses `RadialMenuAction.userConfiguredActions` for radial menu pan mode. + case radialMenuActions + /// A single action, either custom or referencing a keybind. + case singleAction(RadialMenuAction.ActionType) + } + + enum ActivationZone: String, Codable, Hashable, CaseIterable { + case titlebar + case anywhere + + var displayName: String { + switch self { + case .titlebar: "Titlebar" + + case .anywhere: "Anywhere" + } + } + + var systemImage: String { + switch self { + case .titlebar: "menubar.rectangle" + case .anywhere: "rectangle.dashed" + } + } + } +} + +// MARK: - Conflict Detection + +extension GestureBinding { + /// Two bindings conflict when they have the same finger count and their gesture types overlap. + /// Radial menu consumes both pan and pinch, so it conflicts with ANY other binding 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 pan and pinch, so it conflicts with everything at the same finger count + if gestureType == .radialMenu || other.gestureType == .radialMenu { + return true + } + + // Same gesture type always conflicts + if gestureType == other.gestureType { + return true + } + + return false + } + + /// Returns the IDs of all bindings that conflict with at least one other binding in the array. + static func conflictingIDs(in bindings: [GestureBinding]) -> Set { + var result = Set() + for i in bindings.indices { + for j in (i + 1).. Date: Fri, 17 Apr 2026 00:03:01 -0600 Subject: [PATCH 13/61] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 25 +++++++++---------- Loop/Extensions/Defaults+Extensions.swift | 2 +- .../Gestures/GestureBindingItemView.swift | 4 +-- .../Gestures/GestureConfigPopoverView.swift | 6 ++--- .../Window Action/GestureBinding.swift | 12 ++++----- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 588836af..7d05d166 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -22,10 +22,10 @@ final class MultitouchTrigger { private let gestureBlocker: MultitouchGestureBlocker = .init() private var recognizersByFingerCount: [Int: SubsurfaceGestureRecognizer] = [:] - private var eventTasksByFingerCount: [Int: Task] = [:] + private var eventTasksByFingerCount: [Int: Task<(), Never>] = [:] private var gestureStatesByFingerCount: [Int: GestureState] = [:] - private var bindingsObservationTask: Task? + private var bindingsObservationTask: Task<(), Never>? private let panActivationThreshold: CGFloat = 0.3 private let panCycleStepSize: CGFloat = 0.1 @@ -66,7 +66,7 @@ final class MultitouchTrigger { bindingsObservationTask = Task { [weak self] in for await _ in Defaults.updates(.gestureBindings) { guard !Task.isCancelled, let self else { break } - self.rebuildRecognizers() + rebuildRecognizers() } } } @@ -116,9 +116,9 @@ final class MultitouchTrigger { guard !Task.isCancelled else { break } switch event { case let .pan(pan): - await self.handlePan(pan, fingerCount: fingerCount) + await handlePan(pan, fingerCount: fingerCount) case let .pinch(pinch): - await self.handlePinch(pinch, fingerCount: fingerCount) + await handlePinch(pinch, fingerCount: fingerCount) case .rotation: break } @@ -400,15 +400,14 @@ final class MultitouchTrigger { var normalizedAngle = angleFromOrigin if normalizedAngle < 0 { normalizedAngle += 2 * .pi } - let direction: GestureBinding.GestureType - if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { - direction = .panUp - } else if normalizedAngle >= .pi / 4 && normalizedAngle < 3 * .pi / 4 { - direction = .panRight - } else if normalizedAngle >= 3 * .pi / 4 && normalizedAngle < 5 * .pi / 4 { - direction = .panDown + let direction: GestureBinding.GestureType = if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { + .panUp + } else if normalizedAngle >= .pi / 4, normalizedAngle < 3 * .pi / 4 { + .panRight + } else if normalizedAngle >= 3 * .pi / 4, normalizedAngle < 5 * .pi / 4 { + .panDown } else { - direction = .panLeft + .panLeft } return bindings.first { $0.gestureType == direction } diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 8f5d15b7..672bac10 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -144,7 +144,7 @@ extension Defaults.Keys { /// 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) - + /// Height of the titlebar activation zone for gesture bindings, 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` diff --git a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift index 237ac326..3bbc44fc 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift @@ -208,9 +208,9 @@ struct GestureBindingItemView: View { private var resolvedAction: WindowAction? { switch binding.action { case .radialMenuActions: - return nil + nil case let .singleAction(actionType): - return actionType.resolvedAction + actionType.resolvedAction } } diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift index a13e6dbb..3ea3efa9 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift @@ -5,8 +5,8 @@ // Created by Kai Azim on 2026-04-16. // -import SwiftUI import Luminare +import SwiftUI struct GestureConfigPopoverView: View { @State private var binding: GestureBinding @@ -49,13 +49,13 @@ struct GestureConfigPopoverView: View { } .labelsHidden() } - + LuminareCompose("Fingers") { HStack { TextField("", value: $binding.fingerCount, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 40) - + Stepper("", value: $binding.fingerCount, in: 2...5) .labelsHidden() } diff --git a/Loop/Window Management/Window Action/GestureBinding.swift b/Loop/Window Management/Window Action/GestureBinding.swift index 1ddf931e..669acfc7 100644 --- a/Loop/Window Management/Window Action/GestureBinding.swift +++ b/Loop/Window Management/Window Action/GestureBinding.swift @@ -36,7 +36,7 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { case panUp, panDown, panLeft, panRight /// Pinch gesture. case pinch - + var displayName: String { switch self { case .radialMenu: "Radial Menu" @@ -47,7 +47,7 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { case .pinch: "Pinch" } } - + var image: Image { switch self { case .radialMenu: Image(.loop) @@ -88,15 +88,15 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { enum ActivationZone: String, Codable, Hashable, CaseIterable { case titlebar case anywhere - + var displayName: String { switch self { case .titlebar: "Titlebar" - + case .anywhere: "Anywhere" } } - + var systemImage: String { switch self { case .titlebar: "menubar.rectangle" @@ -133,7 +133,7 @@ extension GestureBinding { static func conflictingIDs(in bindings: [GestureBinding]) -> Set { var result = Set() for i in bindings.indices { - for j in (i + 1).. Date: Sat, 25 Apr 2026 14:23:35 -0600 Subject: [PATCH 14/61] =?UTF-8?q?=E2=9C=A8=20tune=20gestures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift | 1 + Loop/Core/Observers/MultitouchTrigger.swift | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift b/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift index 6cab5069..c966e572 100644 --- a/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift +++ b/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift @@ -13,6 +13,7 @@ final class MultitouchGestureBlocker { private var monitor: ActiveEventMonitor? func start() { + stop() log.info("Starting gesture blocker") let eventTypes: [CGEventType] = [ diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 7d05d166..adefe647 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -28,9 +28,9 @@ final class MultitouchTrigger { private var bindingsObservationTask: Task<(), Never>? private let panActivationThreshold: CGFloat = 0.3 - private let panCycleStepSize: CGFloat = 0.1 + private let panCycleStepSize: CGFloat = 0.2 private let pinchActivationThreshold: CGFloat = 0.4 - private let pinchCycleStepSize: CGFloat = 0.6 + private let pinchCycleStepSize: CGFloat = 0.7 private var radialMenuActions: [RadialMenuAction] { RadialMenuAction.userConfiguredActions From 116850657f633223ce2f460d74aebd499f3586b0 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 23 Apr 2026 21:32:58 -0600 Subject: [PATCH 15/61] =?UTF-8?q?=F0=9F=90=9E=20Reduce=20gesture=20titleba?= =?UTF-8?q?r=20activation=20zone=20to=2032pt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Extensions/Defaults+Extensions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 672bac10..c8913831 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -149,7 +149,7 @@ extension Defaults.Keys { /// 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: 52, iCloud: true) + static let gestureTitlebarHeight = Key("gestureTitlebarHeight", default: 32, iCloud: true) // Migrator From 051f6aca14d4d1d2bf0314cae5fb74024255983b Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 23 Apr 2026 21:33:15 -0600 Subject: [PATCH 16/61] =?UTF-8?q?=E2=9C=A8=20Warn=20when=20a=20gesture=20r?= =?UTF-8?q?eferences=20a=20deleted=20keybind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index adefe647..31f336a6 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -371,7 +371,7 @@ final class MultitouchTrigger { case let .custom(windowAction): windowAction case let .keybindReference(id): - windowActionCache.actionsByIdentifier[id] ?? Self.failedToResolveKeybindAction + resolveKeybindReference(id) } changeAction(resolvedAction, reverse) @@ -388,13 +388,21 @@ final class MultitouchTrigger { case let .custom(windowAction): resolvedAction = windowAction case let .keybindReference(id): - resolvedAction = windowActionCache.actionsByIdentifier[id] ?? Self.failedToResolveKeybindAction + resolvedAction = resolveKeybindReference(id) } } changeAction(resolvedAction, reverse) } + private func resolveKeybindReference(_ id: UUID) -> WindowAction { + if let cached = windowActionCache.actionsByIdentifier[id] { + return cached + } + log.warn("Gesture references keybind \(id) that no longer exists") + return Self.failedToResolveKeybindAction + } + private func matchDirectionalPanBinding(angle: CGFloat, from bindings: [GestureBinding]) -> GestureBinding? { let angleFromOrigin = angle + .pi / 2 var normalizedAngle = angleFromOrigin From 002e2380be0ee887e5cb07d58057fad72aaaf800 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 25 Apr 2026 19:02:43 -0600 Subject: [PATCH 17/61] =?UTF-8?q?=F0=9F=90=9E=20Fix=20gesture=20cycle=20st?= =?UTF-8?q?ate=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 173 ++++++++++++++------ 1 file changed, 119 insertions(+), 54 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 31f336a6..8c2fec1c 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -38,11 +38,21 @@ final class MultitouchTrigger { private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) + private enum ActionKey: Hashable { + case radialSlot(Int) + case radialCenter + case binding(UUID) + } + private struct GestureState { var didOpenLoopWithThisGesture = false var isGestureRejected = false - var lastTriggeredActionIndex: Int? - var lastTriggeredDistance: CGFloat = 0 + var lastCommittedAction: ActionKey? + var lastCommitPanDistance: CGFloat = 0 + /// Signed by `pinchDirection` so pinch-in advances are positive deltas. + var lastCommitPinchOffset: CGFloat = 0 + /// `+1` outward, `-1` inward; locked at activation. + var pinchDirection: Int = 0 } init( @@ -156,7 +166,7 @@ final class MultitouchTrigger { await handleGestureBegan(fingerCount: fingerCount, binding: binding) case .changed: - guard gestureStatesByFingerCount[fingerCount]?.isGestureRejected != true else { return } + guard var state = gestureStatesByFingerCount[fingerCount], !state.isGestureRejected else { return } let angleFromOrigin = pan.angle + .pi / 2 var normalizedAngle = angleFromOrigin @@ -174,18 +184,15 @@ final class MultitouchTrigger { newIndex = Int((normalizedAngle + halfAngleSpan) / actionAngleSpan) % actions.count } - let state = gestureStatesByFingerCount[fingerCount] ?? GestureState() - let isSameAction = state.lastTriggeredActionIndex == newIndex - let isReversing = isSameAction && pan.distance < state.lastTriggeredDistance - panCycleStepSize - - if isSameAction { - guard abs(pan.distance - state.lastTriggeredDistance) >= panCycleStepSize else { return } + commitPan( + &state, + distance: pan.distance, + newKey: .radialSlot(newIndex), + fingerCount: fingerCount + ) { reverse in + triggerRadialMenuAction(at: newIndex, from: actions, reverse: reverse) } - gestureStatesByFingerCount[fingerCount]?.lastTriggeredActionIndex = newIndex - gestureStatesByFingerCount[fingerCount]?.lastTriggeredDistance = pan.distance - triggerRadialMenuAction(at: newIndex, from: actions, reverse: isReversing) - case .ended, .cancelled: resetLoopState(for: fingerCount) @@ -204,20 +211,17 @@ final class MultitouchTrigger { await handleGestureBegan(fingerCount: fingerCount, binding: binding) case .changed: - guard gestureStatesByFingerCount[fingerCount]?.isGestureRejected != true else { return } - - let state = gestureStatesByFingerCount[fingerCount] ?? GestureState() - let isSameAction = state.lastTriggeredActionIndex == 0 - let isReversing = isSameAction && pan.distance < state.lastTriggeredDistance - panCycleStepSize - - if isSameAction { - guard abs(pan.distance - state.lastTriggeredDistance) >= panCycleStepSize else { return } + guard var state = gestureStatesByFingerCount[fingerCount], !state.isGestureRejected else { return } + + commitPan( + &state, + distance: pan.distance, + newKey: .binding(binding.id), + fingerCount: fingerCount + ) { reverse in + triggerSingleAction(from: binding, reverse: reverse) } - gestureStatesByFingerCount[fingerCount]?.lastTriggeredActionIndex = 0 - gestureStatesByFingerCount[fingerCount]?.lastTriggeredDistance = pan.distance - triggerSingleAction(from: binding, reverse: isReversing) - case .ended, .cancelled: resetLoopState(for: fingerCount) @@ -248,25 +252,21 @@ final class MultitouchTrigger { await handleGestureBegan(fingerCount: fingerCount, binding: binding) case .changed: - guard gestureStatesByFingerCount[fingerCount]?.isGestureRejected != true else { return } + guard var state = gestureStatesByFingerCount[fingerCount], !state.isGestureRejected else { return } let actions = radialMenuActions + guard !actions.isEmpty else { return } let centerActionIndex = actions.count - 1 - let state = gestureStatesByFingerCount[fingerCount] ?? GestureState() - let isSameAction = state.lastTriggeredActionIndex == centerActionIndex - let isReversing = isSameAction && pinch.scale < state.lastTriggeredDistance - pinchCycleStepSize - - if isSameAction { - guard abs(pinch.scale - state.lastTriggeredDistance) >= pinchCycleStepSize else { return } - } else { - guard abs(pinch.scale - 1.0) >= pinchActivationThreshold else { return } + commitPinch( + &state, + scale: pinch.scale, + newKey: .radialCenter, + fingerCount: fingerCount + ) { reverse in + triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: reverse) } - gestureStatesByFingerCount[fingerCount]?.lastTriggeredActionIndex = centerActionIndex - gestureStatesByFingerCount[fingerCount]?.lastTriggeredDistance = pinch.scale - triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: isReversing) - case .ended, .cancelled: resetLoopState(for: fingerCount) @@ -286,22 +286,17 @@ final class MultitouchTrigger { await handleGestureBegan(fingerCount: fingerCount, binding: binding) case .changed: - guard gestureStatesByFingerCount[fingerCount]?.isGestureRejected != true else { return } - - let state = gestureStatesByFingerCount[fingerCount] ?? GestureState() - let isSameAction = state.lastTriggeredActionIndex == 0 - let isReversing = isSameAction && pinch.scale < state.lastTriggeredDistance - pinchCycleStepSize - - if isSameAction { - guard abs(pinch.scale - state.lastTriggeredDistance) >= pinchCycleStepSize else { return } - } else { - guard abs(pinch.scale - 1.0) >= pinchActivationThreshold else { return } + guard var state = gestureStatesByFingerCount[fingerCount], !state.isGestureRejected else { return } + + commitPinch( + &state, + scale: pinch.scale, + newKey: .binding(binding.id), + fingerCount: fingerCount + ) { reverse in + triggerSingleAction(from: binding, reverse: reverse) } - gestureStatesByFingerCount[fingerCount]?.lastTriggeredActionIndex = 0 - gestureStatesByFingerCount[fingerCount]?.lastTriggeredDistance = pinch.scale - triggerSingleAction(from: binding, reverse: isReversing) - case .ended, .cancelled: resetLoopState(for: fingerCount) @@ -320,8 +315,10 @@ final class MultitouchTrigger { } gestureStatesByFingerCount[fingerCount]?.isGestureRejected = false - gestureStatesByFingerCount[fingerCount]?.lastTriggeredActionIndex = nil - gestureStatesByFingerCount[fingerCount]?.lastTriggeredDistance = 0 + gestureStatesByFingerCount[fingerCount]?.lastCommittedAction = nil + gestureStatesByFingerCount[fingerCount]?.lastCommitPanDistance = 0 + gestureStatesByFingerCount[fingerCount]?.lastCommitPinchOffset = 0 + gestureStatesByFingerCount[fingerCount]?.pinchDirection = 0 gestureBlocker.start() if let window, !loopWasAlreadyOpen { @@ -364,7 +361,75 @@ final class MultitouchTrigger { } } + /// Commit distance only advances when an action fires, so sub-step + /// jitter can't drift it past the reverse threshold. + private func commitPan( + _ state: inout GestureState, + distance: CGFloat, + newKey: ActionKey, + fingerCount: Int, + fire: (_ reverse: Bool) -> () + ) { + if state.lastCommittedAction == nil { + guard distance >= panActivationThreshold else { return } + state.lastCommittedAction = newKey + state.lastCommitPanDistance = distance + gestureStatesByFingerCount[fingerCount] = state + fire(false) + return + } + + if state.lastCommittedAction == newKey { + let delta = distance - state.lastCommitPanDistance + if delta >= panCycleStepSize { + state.lastCommitPanDistance = distance + gestureStatesByFingerCount[fingerCount] = state + fire(false) + } else if delta <= -panCycleStepSize { + state.lastCommitPanDistance = distance + gestureStatesByFingerCount[fingerCount] = state + fire(true) + } + } else { + state.lastCommittedAction = newKey + state.lastCommitPanDistance = distance + gestureStatesByFingerCount[fingerCount] = state + fire(false) + } + } + + private func commitPinch( + _ state: inout GestureState, + scale: CGFloat, + newKey: ActionKey, + fingerCount: Int, + fire: (_ reverse: Bool) -> () + ) { + if state.lastCommittedAction != newKey { + guard abs(scale - 1.0) >= pinchActivationThreshold else { return } + state.pinchDirection = scale >= 1.0 ? 1 : -1 + state.lastCommittedAction = newKey + state.lastCommitPinchOffset = (scale - 1.0) * CGFloat(state.pinchDirection) + gestureStatesByFingerCount[fingerCount] = state + fire(false) + return + } + + let offset = (scale - 1.0) * CGFloat(state.pinchDirection) + let delta = offset - state.lastCommitPinchOffset + if delta >= pinchCycleStepSize { + state.lastCommitPinchOffset = offset + gestureStatesByFingerCount[fingerCount] = state + fire(false) + } else if delta <= -pinchCycleStepSize { + state.lastCommitPinchOffset = offset + gestureStatesByFingerCount[fingerCount] = state + fire(true) + } + } + private func triggerRadialMenuAction(at index: Int, from actions: ArraySlice, reverse: Bool = false) { + guard actions.indices.contains(index) else { return } let action = actions[index] let resolvedAction: WindowAction = switch action.type { From 70fb5fcf87c963e93d00243a56aeb0e0edbceb00 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 23 Apr 2026 21:37:40 -0600 Subject: [PATCH 18/61] =?UTF-8?q?=E2=9C=A8=20Cache=20per-fingerCount=20bin?= =?UTF-8?q?dings=20inside=20RecognizerEntry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 140 ++++++++++++-------- 1 file changed, 85 insertions(+), 55 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 8c2fec1c..3be0fb79 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -21,10 +21,7 @@ final class MultitouchTrigger { private let gestureMonitor = SubsurfaceMonitor() private let gestureBlocker: MultitouchGestureBlocker = .init() - private var recognizersByFingerCount: [Int: SubsurfaceGestureRecognizer] = [:] - private var eventTasksByFingerCount: [Int: Task<(), Never>] = [:] - private var gestureStatesByFingerCount: [Int: GestureState] = [:] - + private var recognizers: [Int: RecognizerEntry] = [:] private var bindingsObservationTask: Task<(), Never>? private let panActivationThreshold: CGFloat = 0.3 @@ -55,6 +52,26 @@ final class MultitouchTrigger { var pinchDirection: Int = 0 } + /// Snapshot of the bindings that apply at this finger count, so handlers + /// don't fire actions against a binding the user has just deleted. + private struct RecognizerEntry { + let recognizer: SubsurfaceGestureRecognizer + var task: Task<(), Never>? + var state: GestureState + var radialMenuBinding: GestureBinding? + var directionalBindings: [GestureBinding] + var pinchBinding: GestureBinding? + + static func categorize( + _ bindings: [GestureBinding] + ) -> (radial: GestureBinding?, directionals: [GestureBinding], pinch: GestureBinding?) { + let radial = bindings.first { $0.gestureType == .radialMenu } + let directionals = bindings.filter(\.gestureType.isDirectionalPan) + let pinch = bindings.first { $0.gestureType == .pinch } + return (radial, directionals, pinch) + } + } + init( windowActionCache: WindowActionCache, openCallback: @escaping (WindowAction, Window) async throws -> (), @@ -85,42 +102,54 @@ final class MultitouchTrigger { bindingsObservationTask?.cancel() bindingsObservationTask = nil - for (fingerCount, _) in eventTasksByFingerCount { + for fingerCount in Array(recognizers.keys) { stopRecognizer(for: fingerCount) } - - eventTasksByFingerCount.removeAll() - recognizersByFingerCount.removeAll() - gestureStatesByFingerCount.removeAll() + recognizers.removeAll() gestureMonitor.stop() } private func rebuildRecognizers() { - let bindings = Defaults[.gestureBindings] - let neededFingerCounts = Set(bindings.map(\.fingerCount)) - let currentFingerCounts = Set(recognizersByFingerCount.keys) + let bindingsByFingerCount = Dictionary(grouping: Defaults[.gestureBindings], by: \.fingerCount) + let neededFingerCounts = Set(bindingsByFingerCount.keys) // Remove stale recognizers - for fingerCount in currentFingerCounts.subtracting(neededFingerCounts) { + for fingerCount in Array(recognizers.keys) where !neededFingerCounts.contains(fingerCount) { stopRecognizer(for: fingerCount) - recognizersByFingerCount.removeValue(forKey: fingerCount) - eventTasksByFingerCount.removeValue(forKey: fingerCount) - gestureStatesByFingerCount.removeValue(forKey: fingerCount) + recognizers.removeValue(forKey: fingerCount) } - // Add new recognizers - for fingerCount in neededFingerCounts.subtracting(currentFingerCounts) { - startRecognizer(for: fingerCount) + // Add new recognizers or refresh cached bindings on existing ones. + for (fingerCount, bindings) in bindingsByFingerCount { + let (radial, directionals, pinch) = RecognizerEntry.categorize(bindings) + if recognizers[fingerCount] == nil { + startRecognizer(for: fingerCount, radial: radial, directionals: directionals, pinch: pinch) + } else { + recognizers[fingerCount]?.radialMenuBinding = radial + recognizers[fingerCount]?.directionalBindings = directionals + recognizers[fingerCount]?.pinchBinding = pinch + } } } - private func startRecognizer(for fingerCount: Int) { + private func startRecognizer( + for fingerCount: Int, + radial: GestureBinding?, + directionals: [GestureBinding], + pinch: GestureBinding? + ) { let recognizer = SubsurfaceGestureRecognizer(fingerCount: fingerCount) - recognizersByFingerCount[fingerCount] = recognizer - gestureStatesByFingerCount[fingerCount] = GestureState() - - eventTasksByFingerCount[fingerCount] = Task { [weak self] in + recognizers[fingerCount] = RecognizerEntry( + recognizer: recognizer, + task: nil, + state: GestureState(), + radialMenuBinding: radial, + directionalBindings: directionals, + pinchBinding: pinch + ) + + let task = Task { [weak self] in guard let self else { return } for await event in recognizer.events(from: gestureMonitor) { guard !Task.isCancelled else { break } @@ -134,24 +163,25 @@ final class MultitouchTrigger { } } } + recognizers[fingerCount]?.task = task } private func stopRecognizer(for fingerCount: Int) { - eventTasksByFingerCount[fingerCount]?.cancel() - recognizersByFingerCount[fingerCount]?.reset() - if let state = gestureStatesByFingerCount[fingerCount], state.didOpenLoopWithThisGesture { + guard let entry = recognizers[fingerCount] else { return } + entry.task?.cancel() + entry.recognizer.reset() + if entry.state.didOpenLoopWithThisGesture { closeCallback(false) } gestureBlocker.stop() } private func handlePan(_ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int) async { - let bindings = Defaults[.gestureBindings] - let panBindings = bindings.filter { $0.gestureType.isPan && $0.fingerCount == fingerCount } + guard let entry = recognizers[fingerCount] else { return } - if let radialMenuBinding = panBindings.first(where: { $0.gestureType == .radialMenu }) { + if let radialMenuBinding = entry.radialMenuBinding { await handleRadialMenuPan(pan, fingerCount: fingerCount, binding: radialMenuBinding) - } else if let directionalBinding = matchDirectionalPanBinding(angle: pan.angle, from: panBindings) { + } else if let directionalBinding = matchDirectionalPanBinding(angle: pan.angle, from: entry.directionalBindings) { await handleDirectionalPan(pan, fingerCount: fingerCount, binding: directionalBinding) } } @@ -166,7 +196,7 @@ final class MultitouchTrigger { await handleGestureBegan(fingerCount: fingerCount, binding: binding) case .changed: - guard var state = gestureStatesByFingerCount[fingerCount], !state.isGestureRejected else { return } + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } let angleFromOrigin = pan.angle + .pi / 2 var normalizedAngle = angleFromOrigin @@ -211,7 +241,7 @@ final class MultitouchTrigger { await handleGestureBegan(fingerCount: fingerCount, binding: binding) case .changed: - guard var state = gestureStatesByFingerCount[fingerCount], !state.isGestureRejected else { return } + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } commitPan( &state, @@ -231,12 +261,12 @@ final class MultitouchTrigger { } private func handlePinch(_ pinch: SubsurfaceGestureEvent.PinchEvent, fingerCount: Int) async { - let bindings = Defaults[.gestureBindings] + guard let entry = recognizers[fingerCount] else { return } // If a radial menu binding exists at this finger count, pinch triggers the center action - if let radialMenuBinding = bindings.first(where: { $0.gestureType == .radialMenu && $0.fingerCount == fingerCount }) { + if let radialMenuBinding = entry.radialMenuBinding { await handleRadialMenuPinch(pinch, fingerCount: fingerCount, binding: radialMenuBinding) - } else if let pinchBinding = bindings.first(where: { $0.gestureType == .pinch && $0.fingerCount == fingerCount }) { + } else if let pinchBinding = entry.pinchBinding { await handleSingleActionPinch(pinch, fingerCount: fingerCount, binding: pinchBinding) } } @@ -252,7 +282,7 @@ final class MultitouchTrigger { await handleGestureBegan(fingerCount: fingerCount, binding: binding) case .changed: - guard var state = gestureStatesByFingerCount[fingerCount], !state.isGestureRejected else { return } + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } let actions = radialMenuActions guard !actions.isEmpty else { return } @@ -286,7 +316,7 @@ final class MultitouchTrigger { await handleGestureBegan(fingerCount: fingerCount, binding: binding) case .changed: - guard var state = gestureStatesByFingerCount[fingerCount], !state.isGestureRejected else { return } + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } commitPinch( &state, @@ -310,35 +340,35 @@ final class MultitouchTrigger { let loopWasAlreadyOpen = checkIfLoopOpen() guard window != nil || loopWasAlreadyOpen else { - gestureStatesByFingerCount[fingerCount]?.isGestureRejected = true + recognizers[fingerCount]?.state.isGestureRejected = true return } - gestureStatesByFingerCount[fingerCount]?.isGestureRejected = false - gestureStatesByFingerCount[fingerCount]?.lastCommittedAction = nil - gestureStatesByFingerCount[fingerCount]?.lastCommitPanDistance = 0 - gestureStatesByFingerCount[fingerCount]?.lastCommitPinchOffset = 0 - gestureStatesByFingerCount[fingerCount]?.pinchDirection = 0 + recognizers[fingerCount]?.state.isGestureRejected = false + recognizers[fingerCount]?.state.lastCommittedAction = nil + recognizers[fingerCount]?.state.lastCommitPanDistance = 0 + recognizers[fingerCount]?.state.lastCommitPinchOffset = 0 + recognizers[fingerCount]?.state.pinchDirection = 0 gestureBlocker.start() if let window, !loopWasAlreadyOpen { do { try await openCallback(.init(.noSelection), window) - gestureStatesByFingerCount[fingerCount]?.didOpenLoopWithThisGesture = true + recognizers[fingerCount]?.state.didOpenLoopWithThisGesture = true } catch { gestureBlocker.stop() - gestureStatesByFingerCount[fingerCount]?.isGestureRejected = true + recognizers[fingerCount]?.state.isGestureRejected = true } } } private func resetLoopState(for fingerCount: Int) { - if gestureStatesByFingerCount[fingerCount]?.didOpenLoopWithThisGesture == true { + if recognizers[fingerCount]?.state.didOpenLoopWithThisGesture == true { closeCallback(false) } gestureBlocker.stop() - gestureStatesByFingerCount[fingerCount] = GestureState() + recognizers[fingerCount]?.state = GestureState() } private func findTargetWindow(for binding: GestureBinding) -> Window? { @@ -374,7 +404,7 @@ final class MultitouchTrigger { guard distance >= panActivationThreshold else { return } state.lastCommittedAction = newKey state.lastCommitPanDistance = distance - gestureStatesByFingerCount[fingerCount] = state + recognizers[fingerCount]?.state = state fire(false) return } @@ -383,17 +413,17 @@ final class MultitouchTrigger { let delta = distance - state.lastCommitPanDistance if delta >= panCycleStepSize { state.lastCommitPanDistance = distance - gestureStatesByFingerCount[fingerCount] = state + recognizers[fingerCount]?.state = state fire(false) } else if delta <= -panCycleStepSize { state.lastCommitPanDistance = distance - gestureStatesByFingerCount[fingerCount] = state + recognizers[fingerCount]?.state = state fire(true) } } else { state.lastCommittedAction = newKey state.lastCommitPanDistance = distance - gestureStatesByFingerCount[fingerCount] = state + recognizers[fingerCount]?.state = state fire(false) } } @@ -410,7 +440,7 @@ final class MultitouchTrigger { state.pinchDirection = scale >= 1.0 ? 1 : -1 state.lastCommittedAction = newKey state.lastCommitPinchOffset = (scale - 1.0) * CGFloat(state.pinchDirection) - gestureStatesByFingerCount[fingerCount] = state + recognizers[fingerCount]?.state = state fire(false) return } @@ -419,11 +449,11 @@ final class MultitouchTrigger { let delta = offset - state.lastCommitPinchOffset if delta >= pinchCycleStepSize { state.lastCommitPinchOffset = offset - gestureStatesByFingerCount[fingerCount] = state + recognizers[fingerCount]?.state = state fire(false) } else if delta <= -pinchCycleStepSize { state.lastCommitPinchOffset = offset - gestureStatesByFingerCount[fingerCount] = state + recognizers[fingerCount]?.state = state fire(true) } } From bde374bd98faaa812e7d0d33545bd00fbc2283a5 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 23 Apr 2026 21:38:04 -0600 Subject: [PATCH 19/61] =?UTF-8?q?=F0=9F=90=9E=20Reference-count=20the=20mu?= =?UTF-8?q?ltitouch=20gesture=20blocker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Observers/Helpers/MultitouchGestureBlocker.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift b/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift index c966e572..9596db83 100644 --- a/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift +++ b/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift @@ -8,12 +8,19 @@ 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() { - stop() + activeCount += 1 + guard monitor == nil else { return } + log.info("Starting gesture blocker") let eventTypes: [CGEventType] = [ @@ -29,6 +36,9 @@ final class MultitouchGestureBlocker { } func stop() { + activeCount = max(0, activeCount - 1) + guard activeCount == 0 else { return } + monitor?.stop() monitor = nil From 7512a0832b75b4dd81a05b944b8a29eff6076a0e Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 23 Apr 2026 21:39:29 -0600 Subject: [PATCH 20/61] =?UTF-8?q?=F0=9F=90=9E=20Keep=20gesture=20binding?= =?UTF-8?q?=20selection=20in=20sync=20after=20popover=20edits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/Gestures/GesturesConfigurationView.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index 96d2a66f..a6cdc47d 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -31,6 +31,14 @@ struct GesturesConfigurationView: View { bindingsSection .disabled(!enableGestures) } + // GestureBinding's synthesized Hashable covers its mutable fields, + // so editing a selected binding rehashes the stored struct and the + // Set goes stale. Reconcile by id after every change. + .onChange(of: gestureBindings) { newValue in + let bindingsByID = Dictionary(uniqueKeysWithValues: newValue.map { ($0.id, $0) }) + let selectedIDs = model.selectedBindings.map(\.id) + model.selectedBindings = Set(selectedIDs.compactMap { bindingsByID[$0] }) + } } private var settingsSection: some View { @@ -51,7 +59,8 @@ struct GesturesConfigurationView: View { .luminareRoundingBehavior(topLeading: true) Button("Remove", role: .destructive) { - gestureBindings.removeAll(where: model.selectedBindings.contains) + let selectedIDs = Set(model.selectedBindings.map(\.id)) + gestureBindings.removeAll { selectedIDs.contains($0.id) } } .luminareRoundingBehavior(topTrailing: true) .disabled(model.selectedBindings.isEmpty) From e6e76fee51844acfc0f99717ac124338326a26c7 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 23 Apr 2026 21:42:45 -0600 Subject: [PATCH 21/61] =?UTF-8?q?=F0=9F=90=9E=20Isolate=20MultitouchTrigge?= =?UTF-8?q?r=20to=20`@MainActor`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 3 ++- .../Settings/Gestures/GesturesConfigurationView.swift | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 3be0fb79..7b49f15e 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -11,6 +11,7 @@ import Subsurface import SwiftUI @Loggable +@MainActor final class MultitouchTrigger { private let windowActionCache: WindowActionCache private let openCallback: (WindowAction, Window) async throws -> () @@ -158,7 +159,7 @@ final class MultitouchTrigger { await handlePan(pan, fingerCount: fingerCount) case let .pinch(pinch): await handlePinch(pinch, fingerCount: fingerCount) - case .rotation: + case .determining, .rotation: break } } diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index a6cdc47d..51331bd6 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -31,9 +31,6 @@ struct GesturesConfigurationView: View { bindingsSection .disabled(!enableGestures) } - // GestureBinding's synthesized Hashable covers its mutable fields, - // so editing a selected binding rehashes the stored struct and the - // Set goes stale. Reconcile by id after every change. .onChange(of: gestureBindings) { newValue in let bindingsByID = Dictionary(uniqueKeysWithValues: newValue.map { ($0.id, $0) }) let selectedIDs = model.selectedBindings.map(\.id) From 3c224991ff917dfed5098b9d8c79ff16528e3753 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 2 May 2026 17:22:45 -0600 Subject: [PATCH 22/61] =?UTF-8?q?=E2=9C=A8=20Open=20the=20radial=20menu=20?= =?UTF-8?q?the=20moment=20a=20gesture=20begins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 9f55f802..9b8def00 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -25,7 +25,6 @@ final class MultitouchTrigger { private var recognizers: [Int: RecognizerEntry] = [:] private var bindingsObservationTask: Task<(), Never>? - private let panActivationThreshold: CGFloat = 0.1 private let panCycleStepSize: CGFloat = 0.2 private let pinchActivationThreshold: CGFloat = 0.4 private let pinchCycleStepSize: CGFloat = 0.7 @@ -197,7 +196,7 @@ final class MultitouchTrigger { if pan.phase == .began { handleGestureBegan(fingerCount: fingerCount, binding: binding) } - guard await activateGestureIfNeeded(fingerCount: fingerCount, panDistance: pan.distance) else { return } + guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } // Subsurface emits y-up angles (counterclockwise from +x); the radial @@ -245,7 +244,7 @@ final class MultitouchTrigger { if pan.phase == .began { handleGestureBegan(fingerCount: fingerCount, binding: binding) } - guard await activateGestureIfNeeded(fingerCount: fingerCount, panDistance: pan.distance) else { return } + guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } commitPan( @@ -361,18 +360,16 @@ final class MultitouchTrigger { gestureBlocker.start() } - /// Gates `.changed` events until the gesture's pan distance / pinch scale - /// crosses the configured activation threshold, then opens Loop on the - /// target window resolved at `.began`. + /// Opens Loop on the target window resolved at `.began`. Pinch gestures still + /// gate on `pinchActivationThreshold`; pan gestures activate on the first + /// `.began` event Subsurface emits. private func activateGestureIfNeeded( fingerCount: Int, - panDistance: CGFloat? = nil, pinchScale: CGFloat? = nil ) async -> Bool { guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return false } if state.hasActivated { return true } - if let panDistance, panDistance < panActivationThreshold { return false } if let pinchScale, abs(pinchScale - 1.0) < pinchActivationThreshold { return false } if let window = state.pendingTargetWindow { From 87b855320b1e47f694614e9aead090403f64d128 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 14 May 2026 14:24:59 -0600 Subject: [PATCH 23/61] =?UTF-8?q?=E2=9C=A8=20Rewrite=20gesture=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Localizable.xcstrings | 14 +- .../Gestures/GestureBindingItemView.swift | 133 +++++++++--------- .../Gestures/GestureConfigPopoverView.swift | 1 + .../Gestures/GesturesConfigurationView.swift | 16 +-- .../Settings/Keybinds/KeybindItemView.swift | 37 +++-- .../RadialMenuActionItemView.swift | 31 ++-- 6 files changed, 109 insertions(+), 123 deletions(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index d1a7ee84..3d7fb891 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -5059,6 +5059,14 @@ } } }, + "Customize this gesture binding." : { + "comment" : "A button that opens a popover for configuring a gesture binding.", + "isCommentAutoGenerated" : true + }, + "Customize this gesture's action." : { + "comment" : "A description of the action customization feature.", + "isCommentAutoGenerated" : true + }, "Customize this keybind's action." : { "localizations" : { "ar" : { @@ -23297,7 +23305,7 @@ } }, "Open Radial Menu" : { - "comment" : "A label for an action that opens a radial menu.", + "comment" : "A label displayed in a button that opens a radial menu.", "isCommentAutoGenerated" : true }, "Options" : { @@ -31460,10 +31468,6 @@ } } }, - "This gesture conflicts with another binding." : { - "comment" : "A tooltip that appears when a gesture conflicts with another binding.", - "isCommentAutoGenerated" : true - }, "This macOS version is no longer supported." : { "comment" : "Text displayed in a notification when the current macOS version is no longer supported.", "localizations" : { diff --git a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift index 3bbc44fc..afd2975f 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift @@ -10,24 +10,21 @@ import Luminare import SwiftUI struct GestureBindingItemView: View { - @Environment(\.luminareItemBeingHovered) private var isHovering @Environment(\.luminareAnimation) var luminareAnimation @Default(.keybinds) private var keybinds @State private var binding: GestureBinding @Binding private var externalBinding: 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(_ binding: Binding, hasConflict: Bool = false) { + init(_ binding: Binding) { self.binding = binding.wrappedValue self._externalBinding = binding - self.hasConflict = hasConflict } var body: some View { @@ -35,44 +32,77 @@ struct GestureBindingItemView: View { gestureConfiguration .frame(maxWidth: .infinity, alignment: .leading) - label + 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: binding) { externalBinding = $0 } } - private var label: some View { + private var gestureConfiguration: some View { + Button { + isGestureConfigPresented = true + } label: { + Text(gestureConfigurationText) + .fontWeight(.regular) + .lineLimit(1) + .padding(.horizontal, 4) + .contentShape(.rect) + } + .luminareContentSize(contentMode: .fit, hasFixedHeight: true) + .luminareRoundingBehavior(top: true, bottom: true) + .luminareFilledStates([.hovering, .pressed]) + .luminareBorderedStates(.hovering) + .luminareMinHeight(24) + .help("Customize this gesture binding.") + .padding(.leading, -4) + .luminarePopover( + isPresented: $isGestureConfigPresented, + arrowEdge: .top, + attachmentAnchor: .topLeading, + shouldHideAnchor: true, + shouldAnimate: false + ) { + GestureConfigPopoverView(binding: $binding) + .frame(width: 300) + } + } + + private var actionSelection: some View { actionIndicator - .background(alignment: .trailing) { - if isHovering || isActionPickerPresented { - Color.clear - .frame(width: 300 - 24) - .luminarePopover( - isPresented: $isActionPickerPresented, - arrowEdge: .top, - shouldHideAnchor: true, - shouldAnimate: false - ) { - RadialMenuActionPickerView(selection: actionTypeBinding) - .frame(width: 300, height: 300) - } - .luminareSheetClosesOnDefocus(true) - .onChange(of: isActionPickerPresented) { _ in - if !isActionPickerPresented { - PickerListEventMonitorManager.shared.removeAllMonitors() - } - } + .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() } } } - var actionIndicator: some View { + private var actionIndicator: some View { HStack(spacing: 2) { if case .radialMenuActions = binding.action { HStack(spacing: 4) { Image(.loop) Text("Open Radial Menu") + .fontWeight(.regular) + .lineLimit(1) } .padding(.horizontal, 4) .foregroundStyle(.secondary) @@ -92,6 +122,8 @@ struct GestureBindingItemView: View { .foregroundStyle(.secondary) Text("No Action") + .fontWeight(.regular) + .lineLimit(1) .foregroundStyle(.secondary) } } @@ -102,6 +134,7 @@ struct GestureBindingItemView: View { .luminareFilledStates([.hovering, .pressed]) .luminareBorderedStates(.hovering) .luminareMinHeight(24) + .help("Customize this gesture's action.") .padding(.leading, -4) } @@ -114,10 +147,7 @@ struct GestureBindingItemView: View { Image(systemName: "slider.horizontal.3") } .buttonStyle(.plain) - .luminareModalWithPredefinedSheetStyle( - isPresented: $isConfiguringCustom, - isCompact: false - ) { + .luminareModal(isPresented: $isConfiguringCustom) { if resolvedAction.direction == .custom { CustomActionConfigurationView( action: actionBinding, @@ -132,6 +162,7 @@ struct GestureBindingItemView: View { .frame(width: 400) } } + .luminareModalCornerRadius(24) .help("Customize this action's custom frame.") } @@ -142,59 +173,23 @@ struct GestureBindingItemView: View { Image(systemName: "repeat") } .buttonStyle(.plain) - .luminareModalWithPredefinedSheetStyle( - isPresented: $isConfiguringCycle, - isCompact: false - ) { + .luminareModal(isPresented: $isConfiguringCycle) { CycleActionConfigurationView( action: actionBinding, isPresented: $isConfiguringCycle ) .frame(width: 400) } + .luminareModalCornerRadius(24) .help("Customize what this action cycles through.") } } } .font(.title3) - .foregroundStyle(isHovering ? .primary : .secondary) + .foregroundStyle(.secondary) } } - private var gestureConfiguration: some View { - Button { - isGestureConfigPresented = true - } label: { - Text(gestureConfigurationText) - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - .buttonStyle(.plain) - .luminarePlateau() - .luminareRoundingBehavior(top: true, bottom: true) - .padding(.trailing, 4) - .luminareToolTip(attachedTo: .topTrailing, hidden: !hasConflict) { - Text("This gesture conflicts with another binding.") - .padding(6) - } - .luminareTint(overridingWith: .red) - .background(alignment: .leading) { - if isHovering || isGestureConfigPresented { - Color.clear - .frame(width: 270 - 24) - .luminarePopover( - isPresented: $isGestureConfigPresented, - arrowEdge: .top, - shouldHideAnchor: true, - shouldAnimate: false - ) { - GestureConfigPopoverView(binding: $binding) - .frame(width: 270) - } - .luminareSheetClosesOnDefocus(true) - } - } - } private var gestureConfigurationText: String { switch binding.gestureType { diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift index 3ea3efa9..fbdcb034 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift @@ -75,5 +75,6 @@ struct GestureConfigPopoverView: View { .onChange(of: binding) { externalBinding = $0 } .luminareFilledStates(.none) .luminareBorderedStates(.none) + .padding(8) } } diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index 51331bd6..6b43c597 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -21,12 +21,8 @@ struct GesturesConfigurationView: View { @Default(.gestureBindings) private var gestureBindings @Default(.gestureTitlebarHeight) private var gestureTitlebarHeight - private var conflictingIDs: Set { - GestureBinding.conflictingIDs(in: gestureBindings) - } - var body: some View { - Group { + LuminareForm { settingsSection bindingsSection .disabled(!enableGestures) @@ -46,33 +42,29 @@ struct GesturesConfigurationView: View { private var bindingsSection: some View { LuminareSection(String(localized: "Gesture Bindings", comment: "Section header shown in gestures settings")) { - HStack(spacing: 4) { + LuminareButtonRow { Button("Add") { gestureBindings.insert( GestureBinding(), at: 0 ) } - .luminareRoundingBehavior(topLeading: true) Button("Remove", role: .destructive) { let selectedIDs = Set(model.selectedBindings.map(\.id)) gestureBindings.removeAll { selectedIDs.contains($0.id) } } - .luminareRoundingBehavior(topTrailing: true) .disabled(model.selectedBindings.isEmpty) .keyboardShortcut(.delete) } + .luminareRoundingBehavior(top: true) LuminareList( items: $gestureBindings, selection: $model.selectedBindings, id: \.id ) { binding in - GestureBindingItemView( - binding, - hasConflict: conflictingIDs.contains(binding.wrappedValue.id) - ) + GestureBindingItemView(binding) } emptyView: { HStack { Spacer() 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/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() + } } } From 2738418bd074d7c3306103055f97e95439a5021d Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 14 May 2026 16:27:22 -0600 Subject: [PATCH 24/61] =?UTF-8?q?=F0=9F=92=84=20Refine=20gesture=20setting?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/Gestures/GestureConfigPopoverView.swift | 4 +--- .../Settings/Gestures/GesturesConfigurationView.swift | 7 +++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift index fbdcb034..1fd3d360 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift @@ -52,9 +52,7 @@ struct GestureConfigPopoverView: View { LuminareCompose("Fingers") { HStack { - TextField("", value: $binding.fingerCount, format: .number) - .textFieldStyle(.roundedBorder) - .frame(width: 40) + Text("\(binding.fingerCount)") Stepper("", value: $binding.fingerCount, in: 2...5) .labelsHidden() diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index 6b43c597..4b7de8cb 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -24,9 +24,12 @@ struct GesturesConfigurationView: View { var body: some View { LuminareForm { settingsSection - bindingsSection - .disabled(!enableGestures) + + if enableGestures { + bindingsSection + } } + .animation(luminareAnimation, value: enableGestures) .onChange(of: gestureBindings) { newValue in let bindingsByID = Dictionary(uniqueKeysWithValues: newValue.map { ($0.id, $0) }) let selectedIDs = model.selectedBindings.map(\.id) From 9fa4b95544bcb7944146fb474980e9787dbdd410 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 15 May 2026 03:09:05 -0600 Subject: [PATCH 25/61] =?UTF-8?q?=E2=9C=A8=20Disable=20conflicting=20syste?= =?UTF-8?q?m=20gestures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 4 +- .../Helpers/SystemGestureManager.swift | 719 ++++++++++++++++++ Loop/Core/Observers/MultitouchTrigger.swift | 61 +- Loop/Extensions/Defaults+Extensions.swift | 3 + Loop/Localizable.xcstrings | 4 + .../Gestures/GesturesConfigurationView.swift | 5 + .../SystemWindowManager.swift | 0 7 files changed, 794 insertions(+), 2 deletions(-) create mode 100644 Loop/Core/Observers/Helpers/SystemGestureManager.swift rename Loop/{Core => Window Management}/SystemWindowManager.swift (100%) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 2bb9d52a..e6304628 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -131,6 +131,8 @@ final class LoopManager { ) func start() { + multitouchTrigger.prepare() + accessibilityCheckerTask = Task(priority: .background) { [weak self] in for await status in AccessibilityManager.shared.stream(initial: true) { guard let self, !Task.isCancelled else { @@ -175,7 +177,7 @@ final class LoopManager { keybindTrigger.stop() middleClickTrigger.stop() mouseInteractionObserver.stop() - multitouchTrigger.stop() + multitouchTrigger.shutdown() triggerKeyTimeoutTimer.cancel() isLoopOpening = false diff --git a/Loop/Core/Observers/Helpers/SystemGestureManager.swift b/Loop/Core/Observers/Helpers/SystemGestureManager.swift new file mode 100644 index 00000000..88a9e92b --- /dev/null +++ b/Loop/Core/Observers/Helpers/SystemGestureManager.swift @@ -0,0 +1,719 @@ +// +// SystemGestureManager.swift +// Loop +// +// Created by Kai Azim on 2026-05-15. +// + +import Defaults +import Foundation +import Scribe + +enum SystemGesturePreferenceValue: Codable, Hashable, Defaults.Serializable { + case bool(Bool) + case int(Int) + case missing +} + +@Loggable +final class SystemGestureManager { + private let restartDock: () -> () + private let activateSettings: () -> () + + init( + restartDock: @escaping () -> () = SystemGestureManager.restartDock, + activateSettings: @escaping () -> () = SystemGestureManager.activateSettings + ) { + self.restartDock = restartDock + self.activateSettings = activateSettings + } + + enum BuiltInTrackpad: String { + fileprivate static let domain = "com.apple.AppleMultitouchTrackpad" + fileprivate static let defaults = UserDefaults(suiteName: domain) + + case threeFingerHorizontalSwipeGesture = "TrackpadThreeFingerHorizSwipeGesture" + case fourFingerHorizontalSwipeGesture = "TrackpadFourFingerHorizSwipeGesture" + case threeFingerVerticalSwipeGesture = "TrackpadThreeFingerVertSwipeGesture" + case fourFingerVerticalSwipeGesture = "TrackpadFourFingerVertSwipeGesture" + case fourFingerPinchGesture = "TrackpadFourFingerPinchGesture" + case threeFingerDrag = "TrackpadThreeFingerDrag" + case threeFingerTapGesture = "TrackpadThreeFingerTapGesture" + + fileprivate var identifier: SystemGesturePreferenceIdentifier { + SystemGesturePreferenceIdentifier(domain: Self.domain, key: rawValue) + } + + func get() -> SystemGesturePreferenceValue { + identifier.get() + } + + func set(_ value: SystemGesturePreferenceValue) { + identifier.set(value) + identifier.synchronize() + } + } + + enum BluetoothTrackpad: String { + fileprivate static let domain = "com.apple.driver.AppleBluetoothMultitouch.trackpad" + fileprivate static let defaults = UserDefaults(suiteName: domain) + + case threeFingerHorizontalSwipeGesture = "TrackpadThreeFingerHorizSwipeGesture" + case fourFingerHorizontalSwipeGesture = "TrackpadFourFingerHorizSwipeGesture" + case threeFingerVerticalSwipeGesture = "TrackpadThreeFingerVertSwipeGesture" + case fourFingerVerticalSwipeGesture = "TrackpadFourFingerVertSwipeGesture" + case fourFingerPinchGesture = "TrackpadFourFingerPinchGesture" + case threeFingerDrag = "TrackpadThreeFingerDrag" + case threeFingerTapGesture = "TrackpadThreeFingerTapGesture" + + fileprivate var identifier: SystemGesturePreferenceIdentifier { + SystemGesturePreferenceIdentifier(domain: Self.domain, key: rawValue) + } + + func get() -> SystemGesturePreferenceValue { + identifier.get() + } + + func set(_ value: SystemGesturePreferenceValue) { + identifier.set(value) + identifier.synchronize() + } + } + + enum CurrentHostTrackpad: String { + fileprivate static let domain = "NSGlobalDomain.currentHost" + + case threeFingerHorizontalSwipeGesture = "com.apple.trackpad.threeFingerHorizSwipeGesture" + case fourFingerHorizontalSwipeGesture = "com.apple.trackpad.fourFingerHorizSwipeGesture" + case threeFingerVerticalSwipeGesture = "com.apple.trackpad.threeFingerVertSwipeGesture" + case fourFingerVerticalSwipeGesture = "com.apple.trackpad.fourFingerVertSwipeGesture" + case fourFingerPinchGesture = "com.apple.trackpad.fourFingerPinchSwipeGesture" + case threeFingerDrag = "com.apple.trackpad.threeFingerDragGesture" + case threeFingerTapGesture = "com.apple.trackpad.threeFingerTapGesture" + + fileprivate var identifier: SystemGesturePreferenceIdentifier { + SystemGesturePreferenceIdentifier(domain: Self.domain, key: rawValue) + } + + func get() -> SystemGesturePreferenceValue { + identifier.get() + } + + func set(_ value: SystemGesturePreferenceValue) { + identifier.set(value) + identifier.synchronize() + } + } + + enum Dock: String { + fileprivate static let domain = "com.apple.dock" + fileprivate static let defaults = UserDefaults(suiteName: domain) + + case showMissionControlGestureEnabled + case showAppExposeGestureEnabled + + fileprivate var identifier: SystemGesturePreferenceIdentifier { + SystemGesturePreferenceIdentifier(domain: Self.domain, key: rawValue) + } + + func get() -> SystemGesturePreferenceValue { + identifier.get() + } + + func set(_ value: SystemGesturePreferenceValue) { + identifier.set(value) + identifier.synchronize() + } + } + + enum SystemPreferences: String { + fileprivate static let domain = "com.apple.systempreferences" + fileprivate static let defaults = UserDefaults(suiteName: domain) + + case threeFingerDragFourFingerNavigate = "com.apple.preference.trackpad.3fdrag-4fNavigate" + + fileprivate var identifier: SystemGesturePreferenceIdentifier { + SystemGesturePreferenceIdentifier(domain: Self.domain, key: rawValue) + } + + func get() -> SystemGesturePreferenceValue { + identifier.get() + } + + func set(_ value: SystemGesturePreferenceValue) { + identifier.set(value) + identifier.synchronize() + } + } + + fileprivate enum TrackpadGesture: String { + case threeFingerHorizontalSwipeGesture = "TrackpadThreeFingerHorizSwipeGesture" + case fourFingerHorizontalSwipeGesture = "TrackpadFourFingerHorizSwipeGesture" + case threeFingerVerticalSwipeGesture = "TrackpadThreeFingerVertSwipeGesture" + case fourFingerVerticalSwipeGesture = "TrackpadFourFingerVertSwipeGesture" + case fourFingerPinchGesture = "TrackpadFourFingerPinchGesture" + case threeFingerDrag = "TrackpadThreeFingerDrag" + case threeFingerTapGesture = "TrackpadThreeFingerTapGesture" + + init?(preferenceKey: String) { + if let gesture = Self(rawValue: preferenceKey) { + self = gesture + return + } + + switch preferenceKey { + case SystemGestureManager.CurrentHostTrackpad.threeFingerHorizontalSwipeGesture.rawValue: + self = .threeFingerHorizontalSwipeGesture + case SystemGestureManager.CurrentHostTrackpad.fourFingerHorizontalSwipeGesture.rawValue: + self = .fourFingerHorizontalSwipeGesture + case SystemGestureManager.CurrentHostTrackpad.threeFingerVerticalSwipeGesture.rawValue: + self = .threeFingerVerticalSwipeGesture + case SystemGestureManager.CurrentHostTrackpad.fourFingerVerticalSwipeGesture.rawValue: + self = .fourFingerVerticalSwipeGesture + case SystemGestureManager.CurrentHostTrackpad.fourFingerPinchGesture.rawValue: + self = .fourFingerPinchGesture + case SystemGestureManager.CurrentHostTrackpad.threeFingerDrag.rawValue: + self = .threeFingerDrag + case SystemGestureManager.CurrentHostTrackpad.threeFingerTapGesture.rawValue: + self = .threeFingerTapGesture + default: + return nil + } + } + } + + static func restore() { + var backups = Defaults[.systemGesturePreferenceBackups] + var managedValues = Defaults[.systemGestureManagedValues] + + Self().restoreAll(backups: &backups, managedValues: &managedValues) + + Defaults[.systemGesturePreferenceBackups] = backups + Defaults[.systemGestureManagedValues] = managedValues + } + + static func reconcile( + enableGestures: Bool, + disableConflicts: Bool, + bindings: [GestureBinding] + ) { + var backups = Defaults[.systemGesturePreferenceBackups] + var managedValues = Defaults[.systemGestureManagedValues] + + Self().reconcile( + enableGestures: enableGestures, + disableConflicts: disableConflicts, + bindings: bindings, + backups: &backups, + managedValues: &managedValues + ) + + Defaults[.systemGesturePreferenceBackups] = backups + Defaults[.systemGestureManagedValues] = managedValues + } + + func reconcile( + enableGestures: Bool, + disableConflicts: Bool, + bindings: [GestureBinding], + backups: inout [String: SystemGesturePreferenceValue], + managedValues: inout [String: SystemGesturePreferenceValue] + ) { + normalizeStoredValues(&backups) + normalizeStoredValues(&managedValues) + + guard enableGestures, disableConflicts else { + restoreAll(backups: &backups, managedValues: &managedValues) + return + } + + let desiredValues = desiredValues(for: bindings, backups: backups, managedValues: managedValues) + guard !desiredValues.isEmpty else { + restoreAll(backups: &backups, managedValues: &managedValues) + return + } + + restoreNoLongerManagedValues( + desiredValues: desiredValues, + backups: &backups, + managedValues: &managedValues + ) + + var touchedDomains = Set() + var touchedDock = false + var touchedTrackpad = false + for (identifier, desiredValue) in desiredValues { + let currentValue = identifier.get() + let desiredValue = identifier.normalized(desiredValue) + let lastManagedValue = managedValues[identifier.compositeKey].map(identifier.normalized) + + if currentValue != lastManagedValue, currentValue != desiredValue { + backups[identifier.compositeKey] = currentValue + } + + let backupValue = backups[identifier.compositeKey].map(identifier.normalized) + guard currentValue != desiredValue else { + if backupValue != nil { + managedValues[identifier.compositeKey] = desiredValue + } else { + managedValues.removeValue(forKey: identifier.compositeKey) + } + continue + } + + guard backupValue != nil else { + managedValues.removeValue(forKey: identifier.compositeKey) + continue + } + + identifier.set(desiredValue) + managedValues[identifier.compositeKey] = desiredValue + touchedDomains.insert(identifier.domain) + touchedDock = touchedDock || identifier.isDockDomain + touchedTrackpad = touchedTrackpad || identifier.isTrackpadDomain + } + + synchronize(domains: touchedDomains) + if touchedTrackpad { + activateSettings() + } + if touchedDock { + restartDock() + } + } + + private func restoreAll( + backups: inout [String: SystemGesturePreferenceValue], + managedValues: inout [String: SystemGesturePreferenceValue] + ) { + normalizeStoredValues(&backups) + normalizeStoredValues(&managedValues) + + let identifiers = Set(backups.keys) + .union(managedValues.keys) + .compactMap(SystemGesturePreferenceIdentifier.init(compositeKey:)) + restore(identifiers, backups: &backups, managedValues: &managedValues) + backups.removeAll() + managedValues.removeAll() + } + + private func restoreNoLongerManagedValues( + desiredValues: [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue], + backups: inout [String: SystemGesturePreferenceValue], + managedValues: inout [String: SystemGesturePreferenceValue] + ) { + let desiredKeys = Set(desiredValues.keys.map(\.compositeKey)) + let staleIdentifiers = managedValues.keys + .filter { !desiredKeys.contains($0) } + .compactMap(SystemGesturePreferenceIdentifier.init(compositeKey:)) + + restore(staleIdentifiers, backups: &backups, managedValues: &managedValues) + } + + private func restore( + _ identifiers: [SystemGesturePreferenceIdentifier], + backups: inout [String: SystemGesturePreferenceValue], + managedValues: inout [String: SystemGesturePreferenceValue] + ) { + guard !identifiers.isEmpty else { return } + + var touchedDomains = Set() + var touchedDock = false + var touchedTrackpad = false + + for identifier in identifiers { + guard let backupValue = backups[identifier.compositeKey].map(identifier.normalized) else { + managedValues.removeValue(forKey: identifier.compositeKey) + continue + } + + identifier.set(backupValue) + backups.removeValue(forKey: identifier.compositeKey) + managedValues.removeValue(forKey: identifier.compositeKey) + touchedDomains.insert(identifier.domain) + touchedDock = touchedDock || identifier.isDockDomain + touchedTrackpad = touchedTrackpad || identifier.isTrackpadDomain + } + + synchronize(domains: touchedDomains) + if touchedTrackpad { + activateSettings() + } + if touchedDock { + restartDock() + } + } + + private func normalizeStoredValues(_ values: inout [String: SystemGesturePreferenceValue]) { + for (key, value) in values { + guard let identifier = SystemGesturePreferenceIdentifier(compositeKey: key) else { continue } + values[key] = identifier.normalized(value) + } + } + + private func desiredValues( + for bindings: [GestureBinding], + backups: [String: SystemGesturePreferenceValue], + managedValues: [String: SystemGesturePreferenceValue] + ) -> [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue] { + let hasThreeFingerGesture = bindings.contains { $0.fingerCount == 3 } + let hasFourFingerGesture = bindings.contains { $0.fingerCount == 4 } + + guard hasThreeFingerGesture || hasFourFingerGesture else { return [:] } + + var desiredValues: [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue] = [:] + + if hasThreeFingerGesture { + setTrackpadValue( + .int(0), + for: .threeFingerHorizontalSwipeGesture, + .threeFingerHorizontalSwipeGesture, + .threeFingerHorizontalSwipeGesture, + in: &desiredValues + ) + setTrackpadValue( + .int(0), + for: .threeFingerVerticalSwipeGesture, + .threeFingerVerticalSwipeGesture, + .threeFingerVerticalSwipeGesture, + in: &desiredValues + ) + setTrackpadValue( + .bool(false), + for: .threeFingerDrag, + .threeFingerDrag, + .threeFingerDrag, + in: &desiredValues + ) + setTrackpadValue( + .int(0), + for: .threeFingerTapGesture, + .threeFingerTapGesture, + .threeFingerTapGesture, + in: &desiredValues + ) + + if !hasFourFingerGesture { + let didUpgradeHorizontal = upgradeSystemGestureIfNeeded( + from: [ + BuiltInTrackpad.threeFingerHorizontalSwipeGesture.identifier, + BluetoothTrackpad.threeFingerHorizontalSwipeGesture.identifier, + CurrentHostTrackpad.threeFingerHorizontalSwipeGesture.identifier + ], + to: [ + BuiltInTrackpad.fourFingerHorizontalSwipeGesture.identifier, + BluetoothTrackpad.fourFingerHorizontalSwipeGesture.identifier, + CurrentHostTrackpad.fourFingerHorizontalSwipeGesture.identifier + ], + backups: backups, + managedValues: managedValues, + desiredValues: &desiredValues + ) + + if didUpgradeHorizontal { + desiredValues[SystemPreferences.threeFingerDragFourFingerNavigate.identifier] = .missing + } + + let didUpgradeVertical = upgradeSystemGestureIfNeeded( + from: [ + BuiltInTrackpad.threeFingerVerticalSwipeGesture.identifier, + BluetoothTrackpad.threeFingerVerticalSwipeGesture.identifier, + CurrentHostTrackpad.threeFingerVerticalSwipeGesture.identifier + ], + to: [ + BuiltInTrackpad.fourFingerVerticalSwipeGesture.identifier, + BluetoothTrackpad.fourFingerVerticalSwipeGesture.identifier, + CurrentHostTrackpad.fourFingerVerticalSwipeGesture.identifier + ], + backups: backups, + managedValues: managedValues, + desiredValues: &desiredValues + ) + + if !didUpgradeVertical { + desiredValues[Dock.showMissionControlGestureEnabled.identifier] = .bool(false) + desiredValues[Dock.showAppExposeGestureEnabled.identifier] = .bool(false) + } + } + } + + if hasFourFingerGesture { + setTrackpadValue( + .int(0), + for: .threeFingerHorizontalSwipeGesture, + .threeFingerHorizontalSwipeGesture, + .threeFingerHorizontalSwipeGesture, + in: &desiredValues + ) + setTrackpadValue( + .int(0), + for: .fourFingerHorizontalSwipeGesture, + .fourFingerHorizontalSwipeGesture, + .fourFingerHorizontalSwipeGesture, + in: &desiredValues + ) + setTrackpadValue( + .int(0), + for: .fourFingerVerticalSwipeGesture, + .fourFingerVerticalSwipeGesture, + .fourFingerVerticalSwipeGesture, + in: &desiredValues + ) + setTrackpadValue( + .int(0), + for: .fourFingerPinchGesture, + .fourFingerPinchGesture, + .fourFingerPinchGesture, + in: &desiredValues + ) + } + + return desiredValues + } + + @discardableResult + private func upgradeSystemGestureIfNeeded( + from sourceIdentifiers: [SystemGesturePreferenceIdentifier], + to targetIdentifiers: [SystemGesturePreferenceIdentifier], + backups: [String: SystemGesturePreferenceValue], + managedValues: [String: SystemGesturePreferenceValue], + desiredValues: inout [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue] + ) -> Bool { + var didUpgrade = false + for (sourceIdentifier, targetIdentifier) in zip(sourceIdentifiers, targetIdentifiers) { + guard userOwnedValue(for: sourceIdentifier, backups: backups, managedValues: managedValues) == .int(2) else { + continue + } + desiredValues[targetIdentifier] = .int(2) + didUpgrade = true + } + return didUpgrade + } + + private func userOwnedValue( + for identifier: SystemGesturePreferenceIdentifier, + backups: [String: SystemGesturePreferenceValue], + managedValues: [String: SystemGesturePreferenceValue] + ) -> SystemGesturePreferenceValue { + if let backupValue = backups[identifier.compositeKey] { + return backupValue + } + + let currentValue = identifier.get() + if currentValue == managedValues[identifier.compositeKey] { + return .missing + } + + return currentValue + } + + private func setTrackpadValue( + _ value: SystemGesturePreferenceValue, + for builtInTrackpadKey: BuiltInTrackpad, + _ bluetoothTrackpadKey: BluetoothTrackpad, + _ currentHostTrackpadKey: CurrentHostTrackpad, + in desiredValues: inout [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue] + ) { + desiredValues[builtInTrackpadKey.identifier] = value + desiredValues[bluetoothTrackpadKey.identifier] = value + desiredValues[currentHostTrackpadKey.identifier] = value + } + + private func synchronize(domains: Set) { + for domain in domains { + if domain == CurrentHostTrackpad.domain { + CFPreferencesSynchronize( + kCFPreferencesAnyApplication, + kCFPreferencesCurrentUser, + kCFPreferencesCurrentHost + ) + } else { + SystemGesturePreferenceIdentifier.defaults(for: domain)?.synchronize() + } + } + } + + private static func restartDock() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + process.arguments = ["Dock"] + try? process.run() + } + + private static func activateSettings() { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/System/Library/PrivateFrameworks/SystemAdministration.framework/Resources/activateSettings") + process.arguments = ["-u"] + try? process.run() + } +} + +struct SystemGesturePreferenceIdentifier: Hashable { + let domain: String + let key: String + + fileprivate enum ValueKind { + case bool + case int + } + + var compositeKey: String { + "\(domain).\(key)" + } + + fileprivate var isTrackpadDomain: Bool { + domain == SystemGestureManager.BuiltInTrackpad.domain || + domain == SystemGestureManager.BluetoothTrackpad.domain || + domain == SystemGestureManager.CurrentHostTrackpad.domain + } + + fileprivate var isDockDomain: Bool { + domain == SystemGestureManager.Dock.domain + } + + private var valueKind: ValueKind { + if domain == SystemGestureManager.Dock.domain || + domain == SystemGestureManager.SystemPreferences.domain { + return .bool + } + + if isTrackpadDomain, + let gesture = SystemGestureManager.TrackpadGesture(preferenceKey: key), + gesture == .threeFingerDrag { + return .bool + } + + return .int + } + + init(domain: String, key: String) { + self.domain = domain + self.key = key + } + + init?(compositeKey: String) { + let knownDomains = [ + SystemGestureManager.BuiltInTrackpad.domain, + SystemGestureManager.BluetoothTrackpad.domain, + SystemGestureManager.CurrentHostTrackpad.domain, + SystemGestureManager.Dock.domain, + SystemGestureManager.SystemPreferences.domain + ].sorted { $0.count > $1.count } + + for domain in knownDomains { + let prefix = "\(domain)." + guard compositeKey.hasPrefix(prefix) else { continue } + self.domain = domain + self.key = String(compositeKey.dropFirst(prefix.count)) + return + } + + let components = compositeKey.split(separator: ".", omittingEmptySubsequences: false) + guard components.count > 1 else { return nil } + + self.key = String(components.last!) + self.domain = components.dropLast().joined(separator: ".") + } + + func get() -> SystemGesturePreferenceValue { + let value: Any? = if domain == SystemGestureManager.CurrentHostTrackpad.domain { + CFPreferencesCopyValue( + key as CFString, + kCFPreferencesAnyApplication, + kCFPreferencesCurrentUser, + kCFPreferencesCurrentHost + ) + } else { + Self.defaults(for: domain)?.object(forKey: key) + } + + guard let value else { + return .missing + } + + guard let number = value as? NSNumber else { + return .missing + } + + switch valueKind { + case .bool: + return .bool(number.boolValue) + case .int: + return .int(number.intValue) + } + } + + func set(_ value: SystemGesturePreferenceValue) { + let value = normalized(value) + + if domain == SystemGestureManager.CurrentHostTrackpad.domain { + let valueToSet: CFPropertyList? = switch value { + case let .bool(value): + value as CFBoolean + case let .int(value): + value as CFNumber + case .missing: + nil + } + + CFPreferencesSetValue( + key as CFString, + valueToSet, + kCFPreferencesAnyApplication, + kCFPreferencesCurrentUser, + kCFPreferencesCurrentHost + ) + return + } + + let defaults = Self.defaults(for: domain) + + switch value { + case let .bool(value): + defaults?.set(value, forKey: key) + case let .int(value): + defaults?.set(value, forKey: key) + case .missing: + defaults?.removeObject(forKey: key) + } + } + + fileprivate func normalized(_ value: SystemGesturePreferenceValue) -> SystemGesturePreferenceValue { + switch (valueKind, value) { + case let (.bool, .int(value)): + .bool(value != 0) + case let (.int, .bool(value)): + .int(value ? 1 : 0) + default: + value + } + } + + func synchronize() { + if domain == SystemGestureManager.CurrentHostTrackpad.domain { + CFPreferencesSynchronize( + kCFPreferencesAnyApplication, + kCFPreferencesCurrentUser, + kCFPreferencesCurrentHost + ) + return + } + + Self.defaults(for: domain)?.synchronize() + } + + fileprivate static func defaults(for domain: String) -> UserDefaults? { + switch domain { + case SystemGestureManager.BuiltInTrackpad.domain: + SystemGestureManager.BuiltInTrackpad.defaults + case SystemGestureManager.BluetoothTrackpad.domain: + SystemGestureManager.BluetoothTrackpad.defaults + case SystemGestureManager.Dock.domain: + SystemGestureManager.Dock.defaults + case SystemGestureManager.SystemPreferences.domain: + SystemGestureManager.SystemPreferences.defaults + default: + UserDefaults(suiteName: domain) + } + } +} diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 9b8def00..562a6218 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -24,6 +24,8 @@ final class MultitouchTrigger { private var recognizers: [Int: RecognizerEntry] = [:] private var bindingsObservationTask: Task<(), Never>? + private var systemGestureReconciliationTask: Task<(), Never>? + private var isStarted = false private let panCycleStepSize: CGFloat = 0.2 private let pinchActivationThreshold: CGFloat = 0.4 @@ -84,9 +86,20 @@ final class MultitouchTrigger { self.closeCallback = closeCallback self.changeAction = changeAction self.checkIfLoopOpen = checkIfLoopOpen + + prepare() + } + + func prepare() { + reconcileSystemGestures() + startSystemGestureReconciliation() } func start() { + guard !isStarted else { return } + isStarted = true + + prepare() gestureMonitor.start() rebuildRecognizers() @@ -99,6 +112,10 @@ final class MultitouchTrigger { } func stop() { + guard isStarted else { return } + isStarted = false + + reconcileSystemGestures() bindingsObservationTask?.cancel() bindingsObservationTask = nil @@ -110,6 +127,38 @@ final class MultitouchTrigger { gestureMonitor.stop() } + 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, + .gestureBindings + ) + + for await _ in updates { + guard !Task.isCancelled, let self else { break } + reconcileSystemGestures() + } + } + } + + private nonisolated func reconcileSystemGestures() { + SystemGestureManager.reconcile( + enableGestures: Defaults[.enableGestures], + disableConflicts: Defaults[.disableConflictingSystemGestures], + bindings: Defaults[.gestureBindings] + ) + } + private func rebuildRecognizers() { let bindingsByFingerCount = Dictionary(grouping: Defaults[.gestureBindings], by: \.fingerCount) let neededFingerCounts = Set(bindingsByFingerCount.keys) @@ -407,7 +456,17 @@ final class MultitouchTrigger { switch binding.activationZone { case .titlebar: - let titlebarHeight: CGFloat = Defaults[.gestureTitlebarHeight] + let minimumTitlebarHeight = Defaults[.gestureTitlebarHeight] + + let titlebarHeight: CGFloat = if #available(macOS 26.0, *), + let cornerRadius = SkyLightToolBelt.getCornerRadii(windowID: window.cgWindowID)?.topLeading { + max(2 * cornerRadius, minimumTitlebarHeight) + } else { + minimumTitlebarHeight + } + + log.info("Detected titlebar height of \(titlebarHeight)") + let titlebarMinY = window.frame.minY let titlebarMaxY = window.frame.minY + titlebarHeight let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index c8913831..eaa2a6d5 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -73,6 +73,9 @@ extension Defaults.Keys { // Gestures static let enableGestures = Key("enableGestures", default: false, iCloud: true) static let gestureBindings = Key<[GestureBinding]>("gestureBindings", default: GestureBinding.defaultBindings, iCloud: true) + static let disableConflictingSystemGestures = Key("disableConflictingSystemGestures", default: true, iCloud: true) + static let systemGesturePreferenceBackups = Key<[String: SystemGesturePreferenceValue]>("systemGesturePreferenceBackups", default: [:], iCloud: false) + static let systemGestureManagedValues = Key<[String: SystemGesturePreferenceValue]>("systemGestureManagedValues", default: [:], iCloud: false) // Advanced static let useSystemWindowManagerWhenAvailable = Key("useSystemWindowManagerWhenAvailable", default: false, iCloud: true) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 3d7fb891..d5509970 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -5865,6 +5865,10 @@ } } }, + "Disable conflicting system gestures" : { + "comment" : "Toggle to disable conflicting system gestures.", + "isCommentAutoGenerated" : true + }, "Disable cursor interaction" : { "localizations" : { "ar" : { diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index 5524db54..d9d6197c 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -18,6 +18,7 @@ struct GesturesConfigurationView: View { @StateObject private var model = GesturesConfigurationModel() @Default(.enableGestures) private var enableGestures + @Default(.disableConflictingSystemGestures) private var disableConflictingSystemGestures @Default(.gestureBindings) private var gestureBindings @Default(.gestureTitlebarHeight) private var gestureTitlebarHeight @@ -40,6 +41,10 @@ struct GesturesConfigurationView: View { private var settingsSection: some View { LuminareSection { LuminareToggle("Enable gestures", isOn: $enableGestures) + + if enableGestures { + LuminareToggle("Disable conflicting system gestures", isOn: $disableConflictingSystemGestures) + } } } 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 From abb5a335d68b54c221c05d6920daaf0a3c73db2e Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 25 May 2026 02:52:50 -0600 Subject: [PATCH 26/61] =?UTF-8?q?=F0=9F=90=9E=20Fix=20some=20event=20monit?= =?UTF-8?q?or=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Event Monitoring/BaseEventTapMonitor.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift index 646cc85a..4e6c8ffc 100644 --- a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift +++ b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift @@ -110,16 +110,25 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { self.runLoopSource = nil isEnabled = false + // Disable immediately to stop new events, but invalidate and remove the source + // on the tap thread so CF's port bookkeeping stays consistent. 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) + } + 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 } From 44651cc6f2093dab87e95b6ca80887a13f4d1819 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 25 May 2026 03:07:49 -0600 Subject: [PATCH 27/61] =?UTF-8?q?=F0=9F=90=9E=20Disable=20and=20indicate?= =?UTF-8?q?=20conflicting=20gestures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 5 ++++- .../Settings/Gestures/GestureBindingItemView.swift | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 562a6218..ce70073d 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -160,7 +160,10 @@ final class MultitouchTrigger { } private func rebuildRecognizers() { - let bindingsByFingerCount = Dictionary(grouping: Defaults[.gestureBindings], by: \.fingerCount) + let allBindings = Defaults[.gestureBindings] + let conflictingIDs = GestureBinding.conflictingIDs(in: allBindings) + let activeBindings = allBindings.filter { !conflictingIDs.contains($0.id) } + let bindingsByFingerCount = Dictionary(grouping: activeBindings, by: \.fingerCount) let neededFingerCounts = Set(bindingsByFingerCount.keys) // Remove stale recognizers diff --git a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift index 9c48921b..2229dd80 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift @@ -13,6 +13,7 @@ struct GestureBindingItemView: View { @Environment(\.luminareAnimation) var luminareAnimation @Default(.keybinds) private var keybinds + @Default(.gestureBindings) private var gestureBindings @State private var binding: GestureBinding @Binding private var externalBinding: GestureBinding @@ -27,9 +28,18 @@ struct GestureBindingItemView: View { self._externalBinding = binding } + private var hasConflict: Bool { + GestureBinding.conflictingIDs(in: gestureBindings).contains(binding.id) + } + var body: some View { ZStack { gestureConfiguration + .luminareToolTip(attachedTo: .topLeading, hidden: !hasConflict) { + Text("There are other gesture bindings that conflict with this gesture.") + .padding(6) + } + .luminareTint(overridingWith: .red) .frame(maxWidth: .infinity, alignment: .leading) actionSelection @@ -62,6 +72,7 @@ struct GestureBindingItemView: View { .luminareFilledStates([.hovering, .pressed]) .luminareBorderedStates(.hovering) .luminareMinHeight(24) + .opacity(hasConflict ? 0.5 : 1) .help("Customize this gesture binding.") .padding(.leading, -4) .luminarePopover( From dddb1dcff33c5411db04594839283fda5c2be3f4 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 25 May 2026 03:22:58 -0600 Subject: [PATCH 28/61] =?UTF-8?q?=E2=9C=A8=20Allow=20consecutive=20larger/?= =?UTF-8?q?smaller=20actions=20via=20gestures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 36 +++++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index ce70073d..55efdef2 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -27,6 +27,10 @@ final class MultitouchTrigger { private var systemGestureReconciliationTask: Task<(), Never>? private var isStarted = false + /// Window most recently targeted by a `canRepeat` gesture binding. + /// Allows the user keep shrinking/growing a window after the cursor has fallen off its (now smaller) frame. + private var lastRepeatableWindow: Window? + private let panCycleStepSize: CGFloat = 0.2 private let pinchActivationThreshold: CGFloat = 0.4 private let pinchCycleStepSize: CGFloat = 0.7 @@ -125,6 +129,7 @@ final class MultitouchTrigger { recognizers.removeAll() gestureMonitor.stop() + lastRepeatableWindow = nil } func shutdown() { @@ -396,7 +401,11 @@ final class MultitouchTrigger { /// Resolves the target window and starts blocking trackpad events. Loop itself /// isn't opened until the gesture crosses the activation threshold in a `.changed` event. private func handleGestureBegan(fingerCount: Int, binding: GestureBinding) { - let window = findTargetWindow(for: binding) + var window = findTargetWindow(for: binding) + if window == nil, resolvedWindowAction(from: binding)?.canRepeat == true { + window = lastRepeatableWindow + } + let loopWasAlreadyOpen = checkIfLoopOpen() guard window != nil || loopWasAlreadyOpen else { @@ -404,6 +413,10 @@ final class MultitouchTrigger { return } + if let window, resolvedWindowAction(from: binding)?.canRepeat == true { + lastRepeatableWindow = window + } + var state = GestureState() state.pendingTargetWindow = window // Loop is already on screen, so no activation threshold to cross. @@ -553,21 +566,16 @@ final class MultitouchTrigger { changeAction(resolvedAction, reverse) } - private func triggerSingleAction(from binding: GestureBinding, reverse: Bool = false) { - let resolvedAction: WindowAction - - switch binding.action { - case .radialMenuActions: - return - case let .singleAction(actionType): - switch actionType { - case let .custom(windowAction): - resolvedAction = windowAction - case let .keybindReference(id): - resolvedAction = resolveKeybindReference(id) - } + private func resolvedWindowAction(from binding: GestureBinding) -> WindowAction? { + guard case let .singleAction(actionType) = binding.action else { return nil } + switch actionType { + case let .custom(action): return action + case let .keybindReference(id): return resolveKeybindReference(id) } + } + private func triggerSingleAction(from binding: GestureBinding, reverse: Bool = false) { + guard let resolvedAction = resolvedWindowAction(from: binding) else { return } changeAction(resolvedAction, reverse) } From a3492a1791d309d4af8b699f95f46556d9da6181 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 25 May 2026 03:29:51 -0600 Subject: [PATCH 29/61] =?UTF-8?q?=E2=9C=A8=20Separate=20`pinch`/`spread`?= =?UTF-8?q?=20gesture=20triggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 79 ++++++++++++------- .../Window Action/GestureBinding.swift | 8 +- 2 files changed, 57 insertions(+), 30 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 55efdef2..4fe944ad 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -51,6 +51,9 @@ final class MultitouchTrigger { var didOpenLoopWithThisGesture = false var isGestureRejected = false var hasActivated = false + var hasGestureBegun = false + /// Locked once direction is detected so mid-gesture scale crossings don't switch bindings + var resolvedPinchBinding: GestureBinding? var pendingTargetWindow: Window? var lastCommittedAction: ActionKey? var lastCommitPanDistance: CGFloat = 0 @@ -67,14 +70,16 @@ final class MultitouchTrigger { var radialMenuBinding: GestureBinding? var directionalBindings: [GestureBinding] var pinchBinding: GestureBinding? + var spreadBinding: GestureBinding? static func categorize( _ bindings: [GestureBinding] - ) -> (radial: GestureBinding?, directionals: [GestureBinding], pinch: GestureBinding?) { + ) -> (radial: GestureBinding?, directionals: [GestureBinding], pinch: GestureBinding?, spread: GestureBinding?) { let radial = bindings.first { $0.gestureType == .radialMenu } let directionals = bindings.filter(\.gestureType.isDirectionalPan) let pinch = bindings.first { $0.gestureType == .pinch } - return (radial, directionals, pinch) + let spread = bindings.first { $0.gestureType == .spread } + return (radial, directionals, pinch, spread) } } @@ -179,13 +184,14 @@ final class MultitouchTrigger { // Add new recognizers or refresh cached bindings on existing ones. for (fingerCount, bindings) in bindingsByFingerCount { - let (radial, directionals, pinch) = RecognizerEntry.categorize(bindings) + let (radial, directionals, pinch, spread) = RecognizerEntry.categorize(bindings) if recognizers[fingerCount] == nil { - startRecognizer(for: fingerCount, radial: radial, directionals: directionals, pinch: pinch) + startRecognizer(for: fingerCount, radial: radial, directionals: directionals, pinch: pinch, spread: spread) } else { recognizers[fingerCount]?.radialMenuBinding = radial recognizers[fingerCount]?.directionalBindings = directionals recognizers[fingerCount]?.pinchBinding = pinch + recognizers[fingerCount]?.spreadBinding = spread } } } @@ -194,7 +200,8 @@ final class MultitouchTrigger { for fingerCount: Int, radial: GestureBinding?, directionals: [GestureBinding], - pinch: GestureBinding? + pinch: GestureBinding?, + spread: GestureBinding? ) { let recognizer = SubsurfaceGestureRecognizer(fingerCount: fingerCount) recognizers[fingerCount] = RecognizerEntry( @@ -203,7 +210,8 @@ final class MultitouchTrigger { state: GestureState(), radialMenuBinding: radial, directionalBindings: directionals, - pinchBinding: pinch + pinchBinding: pinch, + spreadBinding: spread ) let task = Task { [weak self] in @@ -324,39 +332,50 @@ final class MultitouchTrigger { private func handlePinch(_ pinch: SubsurfaceGestureEvent.PinchEvent, fingerCount: Int) async { guard let entry = recognizers[fingerCount] else { return } - // If a radial menu binding exists at this finger count, pinch triggers the center action + // Radial menu pinch triggers the center action regardless of direction if let radialMenuBinding = entry.radialMenuBinding { await handleRadialMenuPinch(pinch, fingerCount: fingerCount, binding: radialMenuBinding) - } else if let pinchBinding = entry.pinchBinding { - await handleSingleActionPinch(pinch, fingerCount: fingerCount, binding: pinchBinding) + return } - } - /// Pinch within a radial menu binding, triggers the center (last) radial menu action. - private func handleRadialMenuPinch( - _ pinch: SubsurfaceGestureEvent.PinchEvent, - fingerCount: Int, - binding: GestureBinding - ) async { switch pinch.phase { - case .began, .changed: - if pinch.phase == .began { + case .began: + // Direction unknown — reset state but defer handleGestureBegan until first .changed + recognizers[fingerCount]?.state = GestureState() + + case .changed: + guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + + // Lock in the binding on the first .changed that reveals direction + if !state.hasGestureBegun { + let binding = pinch.scale >= 1.0 ? entry.spreadBinding : entry.pinchBinding + guard let binding else { + state.isGestureRejected = true + recognizers[fingerCount]?.state = state + return + } handleGestureBegan(fingerCount: fingerCount, binding: binding) + recognizers[fingerCount]?.state.hasGestureBegun = true + recognizers[fingerCount]?.state.resolvedPinchBinding = binding } + + guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected, + let binding = state.resolvedPinchBinding else { return } guard await activateGestureIfNeeded(fingerCount: fingerCount, pinchScale: pinch.scale) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } - let actions = radialMenuActions - guard !actions.isEmpty else { return } - let centerActionIndex = actions.count - 1 - commitPinch( &state, scale: pinch.scale, - newKey: .radialCenter, + newKey: .binding(binding.id), fingerCount: fingerCount ) { reverse in - triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: reverse) + triggerSingleAction(from: binding, reverse: reverse) + } + + if let window = recognizers[fingerCount]?.state.pendingTargetWindow, + resolvedWindowAction(from: binding)?.canRepeat == true { + lastRepeatableWindow = window } case .ended, .cancelled: @@ -367,8 +386,8 @@ final class MultitouchTrigger { } } - /// Standalone pinch binding, triggers the binding's configured action. - private func handleSingleActionPinch( + /// Pinch within a radial menu binding, triggers the center (last) radial menu action. + private func handleRadialMenuPinch( _ pinch: SubsurfaceGestureEvent.PinchEvent, fingerCount: Int, binding: GestureBinding @@ -381,13 +400,17 @@ final class MultitouchTrigger { guard await activateGestureIfNeeded(fingerCount: fingerCount, pinchScale: pinch.scale) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + let actions = radialMenuActions + guard !actions.isEmpty else { return } + let centerActionIndex = actions.count - 1 + commitPinch( &state, scale: pinch.scale, - newKey: .binding(binding.id), + newKey: .radialCenter, fingerCount: fingerCount ) { reverse in - triggerSingleAction(from: binding, reverse: reverse) + triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: reverse) } case .ended, .cancelled: diff --git a/Loop/Window Management/Window Action/GestureBinding.swift b/Loop/Window Management/Window Action/GestureBinding.swift index 669acfc7..aec944e0 100644 --- a/Loop/Window Management/Window Action/GestureBinding.swift +++ b/Loop/Window Management/Window Action/GestureBinding.swift @@ -34,8 +34,10 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { case radialMenu /// Directional pan gestures that trigger a single action. case panUp, panDown, panLeft, panRight - /// Pinch gesture. + /// Pinch gesture (fingers together, scale < 1). case pinch + /// Spread gesture (fingers apart, scale > 1). + case spread var displayName: String { switch self { @@ -45,6 +47,7 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { case .panLeft: "Swipe Left" case .panRight: "Swipe Right" case .pinch: "Pinch" + case .spread: "Spread" } } @@ -56,6 +59,7 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { case .panLeft: Image(systemName: "arrow.left") case .panRight: Image(systemName: "arrow.right") case .pinch: Image(systemName: "arrow.down.left.and.arrow.up.right") + case .spread: Image(systemName: "arrow.up.right.and.arrow.down.left") } } @@ -63,7 +67,7 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { switch self { case .radialMenu, .panUp, .panDown, .panLeft, .panRight: true - case .pinch: + case .pinch, .spread: false } } From 3c572b3714d63e6183dc2d3251ed05b77abbcdf4 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 25 May 2026 03:34:52 -0600 Subject: [PATCH 30/61] =?UTF-8?q?=E2=9C=A8=20Restrict=20two-finger=20trigg?= =?UTF-8?q?ers=20to=20titlebar-only=20to=20reduct=20system=20conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 3 ++- .../Settings/Gestures/GestureConfigPopoverView.swift | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 4fe944ad..8d6bb86a 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -493,7 +493,8 @@ final class MultitouchTrigger { return nil } - switch binding.activationZone { + // 2-finger gestures are always titlebar-only to avoid system gesture conflicts. + switch binding.fingerCount <= 2 ? .titlebar : binding.activationZone { case .titlebar: let minimumTitlebarHeight = Defaults[.gestureTitlebarHeight] diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift index 1fd3d360..d11ddc34 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift @@ -56,6 +56,9 @@ struct GestureConfigPopoverView: View { Stepper("", value: $binding.fingerCount, in: 2...5) .labelsHidden() + .onChange(of: binding.fingerCount) { count in + if count <= 2 { binding.activationZone = .titlebar } + } } } @@ -67,7 +70,9 @@ struct GestureConfigPopoverView: View { } } .labelsHidden() + .disabled(binding.fingerCount <= 2) } + .help(binding.fingerCount <= 2 ? "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures." : "") } .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: binding) { externalBinding = $0 } From 5eb5a4dc5017d3bf32da8afbbd8137fd19bd8cb2 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 25 May 2026 04:31:26 -0600 Subject: [PATCH 31/61] =?UTF-8?q?=F0=9F=90=9E=20Fix=20event=20tap=20deallo?= =?UTF-8?q?cation=20race=20with=20high-frequency=20callbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Event Monitoring/ActiveEventMonitor.swift | 22 ++++++++---------- .../BaseEventTapMonitor.swift | 18 +++++++++++---- .../PassiveEventMonitor.swift | 23 ++++++++----------- 3 files changed, 33 insertions(+), 30 deletions(-) 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 4e6c8ffc..9ddfd1eb 100644 --- a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift +++ b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift @@ -24,15 +24,21 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { private var readableIdentifier: String? private(set) var isEnabled: Bool = false + /// True while the tap's refcon holds a passRetained reference to self + private var refconRetainOutstanding: Bool = false + private var restartTimestamps: [ContinuousClock.Instant] = [] deinit { tearDownEventTap() } + /// Subclasses must pass `Unmanaged.passRetained(self).toOpaque()` as the tap's userInfo + /// before calling this, so the base class can balance that retain in `tearDownEventTap` func setupRunLoopSource(eventTap: CFMachPort, readableIdentifier: String) { let runLoop = EventTapThread.shared.runLoop self.readableIdentifier = readableIdentifier + self.refconRetainOutstanding = true if let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) { self.eventTap = eventTap @@ -110,8 +116,11 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { self.runLoopSource = nil isEnabled = false - // Disable immediately to stop new events, but invalidate and remove the source - // on the tap thread so CF's port bookkeeping stays consistent. + // 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) } @@ -120,17 +129,16 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { 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() } } } From a3ed79bf107b3597aecb12773185627b76e47890 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 25 May 2026 13:43:09 -0600 Subject: [PATCH 32/61] =?UTF-8?q?=E2=9C=A8=20Tune=20pinch/spread=20gesture?= =?UTF-8?q?=20thresholds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 161 +++++++++++++++--- .../Gestures/GestureBindingItemView.swift | 18 +- .../BaseEventTapMonitor.swift | 2 +- .../Window Action/GestureBinding.swift | 4 +- 4 files changed, 154 insertions(+), 31 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 8d6bb86a..1faca63c 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -31,9 +31,12 @@ final class MultitouchTrigger { /// Allows the user keep shrinking/growing a window after the cursor has fallen off its (now smaller) frame. private var lastRepeatableWindow: Window? - private let panCycleStepSize: CGFloat = 0.2 - private let pinchActivationThreshold: CGFloat = 0.4 - private let pinchCycleStepSize: CGFloat = 0.7 + private let panCycleStepSize: CGFloat = 0.15 + + private let pinchActivationThreshold: CGFloat = 0.1 + private let spreadActivationThreshold: CGFloat = 0.4 + private let pinchCycleStepSize: CGFloat = 0.2 + private let spreadCycleStepSize: CGFloat = 0.7 private var radialMenuActions: [RadialMenuAction] { RadialMenuAction.userConfiguredActions @@ -52,8 +55,8 @@ final class MultitouchTrigger { var isGestureRejected = false var hasActivated = false var hasGestureBegun = false - /// Locked once direction is detected so mid-gesture scale crossings don't switch bindings - var resolvedPinchBinding: GestureBinding? + /// The binding currently driving this stroke. Swapped on direction reversal + var resolvedBinding: GestureBinding? var pendingTargetWindow: Window? var lastCommittedAction: ActionKey? var lastCommitPanDistance: CGFloat = 0 @@ -304,21 +307,36 @@ final class MultitouchTrigger { fingerCount: Int, binding: GestureBinding ) async { + guard let entry = recognizers[fingerCount] else { return } + switch pan.phase { case .began, .changed: if pan.phase == .began { handleGestureBegan(fingerCount: fingerCount, binding: binding) + recognizers[fingerCount]?.state.resolvedBinding = binding } guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + let activeBinding = state.resolvedBinding ?? binding + + if panReversalDetected(state, distance: pan.distance) { + handlePanReversal( + fingerCount: fingerCount, + currentBinding: activeBinding, + oppositeBinding: oppositeDirectionalPanBinding(of: activeBinding, in: entry.directionalBindings), + distance: pan.distance + ) + return + } + commitPan( &state, distance: pan.distance, - newKey: .binding(binding.id), + newKey: .binding(activeBinding.id), fingerCount: fingerCount ) { reverse in - triggerSingleAction(from: binding, reverse: reverse) + triggerSingleAction(from: activeBinding, reverse: reverse) } case .ended, .cancelled: @@ -340,41 +358,51 @@ final class MultitouchTrigger { switch pinch.phase { case .began: - // Direction unknown — reset state but defer handleGestureBegan until first .changed + // Direction unknown, reset state but defer handleGestureBegan until first .changed recognizers[fingerCount]?.state = GestureState() case .changed: guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } - // Lock in the binding on the first .changed that reveals direction if !state.hasGestureBegun { - let binding = pinch.scale >= 1.0 ? entry.spreadBinding : entry.pinchBinding - guard let binding else { + let initialBinding = pinch.scale >= 1.0 ? entry.spreadBinding : entry.pinchBinding + guard let initialBinding else { state.isGestureRejected = true recognizers[fingerCount]?.state = state return } - handleGestureBegan(fingerCount: fingerCount, binding: binding) + handleGestureBegan(fingerCount: fingerCount, binding: initialBinding) recognizers[fingerCount]?.state.hasGestureBegun = true - recognizers[fingerCount]?.state.resolvedPinchBinding = binding + recognizers[fingerCount]?.state.resolvedBinding = initialBinding } guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected, - let binding = state.resolvedPinchBinding else { return } + let activeBinding = state.resolvedBinding else { return } guard await activateGestureIfNeeded(fingerCount: fingerCount, pinchScale: pinch.scale) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + if pinchReversalDetected(state, scale: pinch.scale) { + let opposite = activeBinding.gestureType == .pinch ? entry.spreadBinding : entry.pinchBinding + handlePinchReversal( + fingerCount: fingerCount, + currentBinding: activeBinding, + oppositeBinding: opposite, + scale: pinch.scale + ) + return + } + commitPinch( &state, scale: pinch.scale, - newKey: .binding(binding.id), + newKey: .binding(activeBinding.id), fingerCount: fingerCount ) { reverse in - triggerSingleAction(from: binding, reverse: reverse) + triggerSingleAction(from: activeBinding, reverse: reverse) } if let window = recognizers[fingerCount]?.state.pendingTargetWindow, - resolvedWindowAction(from: binding)?.canRepeat == true { + resolvedWindowAction(from: activeBinding)?.canRepeat == true { lastRepeatableWindow = window } @@ -448,8 +476,8 @@ final class MultitouchTrigger { gestureBlocker.start() } - /// Opens Loop on the target window resolved at `.began`. Pinch gestures still - /// gate on `pinchActivationThreshold`; pan gestures activate on the first + /// Opens Loop on the target window resolved at `.began`. Pinch and spread gestures + /// gate on their respective activation thresholds; pan gestures activate on the first /// `.began` event Subsurface emits. private func activateGestureIfNeeded( fingerCount: Int, @@ -458,7 +486,10 @@ final class MultitouchTrigger { guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return false } if state.hasActivated { return true } - if let pinchScale, abs(pinchScale - 1.0) < pinchActivationThreshold { return false } + if let pinchScale { + let threshold = pinchScale >= 1.0 ? spreadActivationThreshold : pinchActivationThreshold + if abs(pinchScale - 1.0) < threshold { return false } + } if let window = state.pendingTargetWindow { do { @@ -565,17 +596,103 @@ final class MultitouchTrigger { let offset = (scale - 1.0) * CGFloat(state.pinchDirection) let delta = offset - state.lastCommitPinchOffset - if delta >= pinchCycleStepSize { + let stepSize = state.pinchDirection > 0 ? spreadCycleStepSize : pinchCycleStepSize + if delta >= stepSize { state.lastCommitPinchOffset = offset recognizers[fingerCount]?.state = state fire(false) - } else if delta <= -pinchCycleStepSize { + } else if delta <= -stepSize { state.lastCommitPinchOffset = offset recognizers[fingerCount]?.state = state fire(true) } } + private func panReversalDetected(_ state: GestureState, distance: CGFloat) -> Bool { + state.lastCommittedAction != nil + && (distance - state.lastCommitPanDistance) <= -panCycleStepSize + } + + private func pinchReversalDetected(_ state: GestureState, scale: CGFloat) -> Bool { + guard state.lastCommittedAction != nil, state.pinchDirection != 0 else { return false } + let offset = (scale - 1.0) * CGFloat(state.pinchDirection) + let stepSize = state.pinchDirection > 0 ? spreadCycleStepSize : pinchCycleStepSize + return (offset - state.lastCommitPinchOffset) <= -stepSize + } + + private func oppositeDirectionalPanBinding( + of current: GestureBinding, + in directionals: [GestureBinding] + ) -> GestureBinding? { + let opposite: GestureBinding.GestureType? = switch current.gestureType { + case .panUp: .panDown + case .panDown: .panUp + case .panLeft: .panRight + case .panRight: .panLeft + default: nil + } + guard let opposite else { return nil } + return directionals.first { $0.gestureType == opposite } + } + + private func isCycleAction(_ binding: GestureBinding) -> Bool { + resolvedWindowAction(from: binding)?.direction == .cycle + } + + private func handlePanReversal( + fingerCount: Int, + currentBinding: GestureBinding, + oppositeBinding: GestureBinding?, + distance: CGFloat + ) { + if let oppositeBinding { + guard var state = recognizers[fingerCount]?.state else { return } + state.resolvedBinding = oppositeBinding + state.lastCommittedAction = .binding(oppositeBinding.id) + state.lastCommitPanDistance = distance + recognizers[fingerCount]?.state = state + triggerSingleAction(from: oppositeBinding, reverse: false) + + if let window = state.pendingTargetWindow, + resolvedWindowAction(from: oppositeBinding)?.canRepeat == true { + lastRepeatableWindow = window + } + } else if isCycleAction(currentBinding) { + triggerSingleAction(from: currentBinding, reverse: true) + recognizers[fingerCount]?.state.lastCommitPanDistance = distance + } + } + + private func handlePinchReversal( + fingerCount: Int, + currentBinding: GestureBinding, + oppositeBinding: GestureBinding?, + scale: CGFloat + ) { + if let oppositeBinding { + guard var state = recognizers[fingerCount]?.state else { return } + // Direction is fixed by the new binding's gesture type, not by current scale, + // since the user may still be on the same side of 1.0 when reversing + let direction = oppositeBinding.gestureType == .spread ? 1 : -1 + state.resolvedBinding = oppositeBinding + state.lastCommittedAction = .binding(oppositeBinding.id) + state.pinchDirection = direction + state.lastCommitPinchOffset = (scale - 1.0) * CGFloat(direction) + recognizers[fingerCount]?.state = state + triggerSingleAction(from: oppositeBinding, reverse: false) + + if let window = state.pendingTargetWindow, + resolvedWindowAction(from: oppositeBinding)?.canRepeat == true { + lastRepeatableWindow = window + } + } else if isCycleAction(currentBinding) { + triggerSingleAction(from: currentBinding, reverse: true) + guard var state = recognizers[fingerCount]?.state else { return } + state.lastCommitPinchOffset = (scale - 1.0) * CGFloat(state.pinchDirection) + recognizers[fingerCount]?.state = state + } + } + private func triggerRadialMenuAction(at index: Int, from actions: ArraySlice, reverse: Bool = false) { guard actions.indices.contains(index) else { return } let action = actions[index] diff --git a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift index 2229dd80..c6c2d5a5 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift @@ -34,13 +34,19 @@ struct GestureBindingItemView: View { var body: some View { ZStack { - gestureConfiguration - .luminareToolTip(attachedTo: .topLeading, hidden: !hasConflict) { - Text("There are other gesture bindings that conflict with this gesture.") - .padding(6) + Group { + if hasConflict { + gestureConfiguration + .luminareTint(overridingWith: .red) + } else { + gestureConfiguration } - .luminareTint(overridingWith: .red) - .frame(maxWidth: .infinity, alignment: .leading) + } + .luminareToolTip(attachedTo: .topLeading, hidden: !hasConflict) { + Text("There are other gesture bindings that conflict with this gesture.") + .padding(6) + } + .frame(maxWidth: .infinity, alignment: .leading) actionSelection .frame(maxWidth: .infinity, alignment: .trailing) diff --git a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift index 9ddfd1eb..329f81c2 100644 --- a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift +++ b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift @@ -38,7 +38,7 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { func setupRunLoopSource(eventTap: CFMachPort, readableIdentifier: String) { let runLoop = EventTapThread.shared.runLoop self.readableIdentifier = readableIdentifier - self.refconRetainOutstanding = true + refconRetainOutstanding = true if let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) { self.eventTap = eventTap diff --git a/Loop/Window Management/Window Action/GestureBinding.swift b/Loop/Window Management/Window Action/GestureBinding.swift index aec944e0..397c1478 100644 --- a/Loop/Window Management/Window Action/GestureBinding.swift +++ b/Loop/Window Management/Window Action/GestureBinding.swift @@ -58,8 +58,8 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { case .panDown: Image(systemName: "arrow.down") case .panLeft: Image(systemName: "arrow.left") case .panRight: Image(systemName: "arrow.right") - case .pinch: Image(systemName: "arrow.down.left.and.arrow.up.right") - case .spread: Image(systemName: "arrow.up.right.and.arrow.down.left") + case .pinch: Image(systemName: "arrow.up.right.and.arrow.down.left") + case .spread: Image(systemName: "arrow.down.left.and.arrow.up.right") } } From d0c3da815cfd128e3ebac22c0e9f6629bd4467df Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 25 May 2026 14:35:45 -0600 Subject: [PATCH 33/61] =?UTF-8?q?=E2=9C=A8=20consolidate=20pinch/spread=20?= =?UTF-8?q?gesture=20thresholds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 7 +- Loop/Core/Observers/MultitouchTrigger.swift | 106 ++++++++++++-------- 2 files changed, 70 insertions(+), 43 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index e6304628..b10d7d3b 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -536,7 +536,7 @@ extension LoopManager { } private func getNextCycleAction(_ action: WindowAction, reverse: Bool) async -> WindowAction { - guard let currentCycle = action.cycle else { + guard let currentCycle = action.cycle, !currentCycle.isEmpty else { return action } @@ -549,11 +549,12 @@ extension LoopManager { && Defaults[.cycleBackwardsOnShiftPressed] 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. @@ -568,7 +569,7 @@ extension LoopManager { } guard var nextIndex = currentIndex else { - return currentCycle[0] + return freshStart } nextIndex += shouldCycleBackwards ? -1 : 1 diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 1faca63c..27c0f63c 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -32,11 +32,8 @@ final class MultitouchTrigger { private var lastRepeatableWindow: Window? private let panCycleStepSize: CGFloat = 0.15 - - private let pinchActivationThreshold: CGFloat = 0.1 - private let spreadActivationThreshold: CGFloat = 0.4 - private let pinchCycleStepSize: CGFloat = 0.2 - private let spreadCycleStepSize: CGFloat = 0.7 + private let zoomActivationThreshold: CGFloat = 0.3 + private let zoomCycleStepSize: CGFloat = 0.15 private var radialMenuActions: [RadialMenuAction] { RadialMenuAction.userConfiguredActions @@ -60,7 +57,7 @@ final class MultitouchTrigger { var pendingTargetWindow: Window? var lastCommittedAction: ActionKey? var lastCommitPanDistance: CGFloat = 0 - var lastCommitPinchOffset: CGFloat = 0 + var lastCommitPinchDistance: CGFloat = 0 var pinchDirection: Int = 0 } @@ -365,7 +362,7 @@ final class MultitouchTrigger { guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } if !state.hasGestureBegun { - let initialBinding = pinch.scale >= 1.0 ? entry.spreadBinding : entry.pinchBinding + let initialBinding = pinch.distance >= pinch.originDistance ? entry.spreadBinding : entry.pinchBinding guard let initialBinding else { state.isGestureRejected = true recognizers[fingerCount]?.state = state @@ -378,23 +375,27 @@ final class MultitouchTrigger { guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected, let activeBinding = state.resolvedBinding else { return } - guard await activateGestureIfNeeded(fingerCount: fingerCount, pinchScale: pinch.scale) else { return } + guard await activateGestureIfNeeded( + fingerCount: fingerCount, + pinchDisplacement: pinch.distance - pinch.originDistance + ) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } - if pinchReversalDetected(state, scale: pinch.scale) { + if pinchReversalDetected(state, distance: pinch.distance) { let opposite = activeBinding.gestureType == .pinch ? entry.spreadBinding : entry.pinchBinding handlePinchReversal( fingerCount: fingerCount, currentBinding: activeBinding, oppositeBinding: opposite, - scale: pinch.scale + distance: pinch.distance ) return } commitPinch( &state, - scale: pinch.scale, + distance: pinch.distance, + originDistance: pinch.originDistance, newKey: .binding(activeBinding.id), fingerCount: fingerCount ) { reverse in @@ -425,17 +426,20 @@ final class MultitouchTrigger { if pinch.phase == .began { handleGestureBegan(fingerCount: fingerCount, binding: binding) } - guard await activateGestureIfNeeded(fingerCount: fingerCount, pinchScale: pinch.scale) else { return } + guard await activateGestureIfNeeded( + fingerCount: fingerCount, + pinchDisplacement: pinch.distance - pinch.originDistance + ) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } let actions = radialMenuActions guard !actions.isEmpty else { return } let centerActionIndex = actions.count - 1 - commitPinch( + commitRadialPinch( &state, - scale: pinch.scale, - newKey: .radialCenter, + distance: pinch.distance, + originDistance: pinch.originDistance, fingerCount: fingerCount ) { reverse in triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: reverse) @@ -481,14 +485,13 @@ final class MultitouchTrigger { /// `.began` event Subsurface emits. private func activateGestureIfNeeded( fingerCount: Int, - pinchScale: CGFloat? = nil + pinchDisplacement: CGFloat? = nil ) async -> Bool { guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return false } if state.hasActivated { return true } - if let pinchScale { - let threshold = pinchScale >= 1.0 ? spreadActivationThreshold : pinchActivationThreshold - if abs(pinchScale - 1.0) < threshold { return false } + if let pinchDisplacement, abs(pinchDisplacement) < zoomActivationThreshold { + return false } if let window = state.pendingTargetWindow { @@ -578,31 +581,57 @@ final class MultitouchTrigger { } } + private func commitRadialPinch( + _ state: inout GestureState, + distance: CGFloat, + originDistance: CGFloat, + fingerCount: Int, + fire: (_ reverse: Bool) -> () + ) { + if state.lastCommittedAction != .radialCenter { + state.lastCommittedAction = .radialCenter + state.lastCommitPinchDistance = distance + recognizers[fingerCount]?.state = state + fire(distance < originDistance) + return + } + + let delta = distance - state.lastCommitPinchDistance + if delta >= zoomCycleStepSize { + state.lastCommitPinchDistance = distance + recognizers[fingerCount]?.state = state + fire(false) + } else if delta <= -zoomCycleStepSize { + state.lastCommitPinchDistance = distance + recognizers[fingerCount]?.state = state + fire(true) + } + } + private func commitPinch( _ state: inout GestureState, - scale: CGFloat, + distance: CGFloat, + originDistance: CGFloat, newKey: ActionKey, fingerCount: Int, fire: (_ reverse: Bool) -> () ) { if state.lastCommittedAction != newKey { - state.pinchDirection = scale >= 1.0 ? 1 : -1 + state.pinchDirection = distance >= originDistance ? 1 : -1 state.lastCommittedAction = newKey - state.lastCommitPinchOffset = (scale - 1.0) * CGFloat(state.pinchDirection) + state.lastCommitPinchDistance = distance recognizers[fingerCount]?.state = state fire(false) return } - let offset = (scale - 1.0) * CGFloat(state.pinchDirection) - let delta = offset - state.lastCommitPinchOffset - let stepSize = state.pinchDirection > 0 ? spreadCycleStepSize : pinchCycleStepSize - if delta >= stepSize { - state.lastCommitPinchOffset = offset + let delta = (distance - state.lastCommitPinchDistance) * CGFloat(state.pinchDirection) + if delta >= zoomCycleStepSize { + state.lastCommitPinchDistance = distance recognizers[fingerCount]?.state = state fire(false) - } else if delta <= -stepSize { - state.lastCommitPinchOffset = offset + } else if delta <= -zoomCycleStepSize { + state.lastCommitPinchDistance = distance recognizers[fingerCount]?.state = state fire(true) } @@ -613,11 +642,10 @@ final class MultitouchTrigger { && (distance - state.lastCommitPanDistance) <= -panCycleStepSize } - private func pinchReversalDetected(_ state: GestureState, scale: CGFloat) -> Bool { + private func pinchReversalDetected(_ state: GestureState, distance: CGFloat) -> Bool { guard state.lastCommittedAction != nil, state.pinchDirection != 0 else { return false } - let offset = (scale - 1.0) * CGFloat(state.pinchDirection) - let stepSize = state.pinchDirection > 0 ? spreadCycleStepSize : pinchCycleStepSize - return (offset - state.lastCommitPinchOffset) <= -stepSize + let delta = (distance - state.lastCommitPinchDistance) * CGFloat(state.pinchDirection) + return delta <= -zoomCycleStepSize } private func oppositeDirectionalPanBinding( @@ -667,17 +695,17 @@ final class MultitouchTrigger { fingerCount: Int, currentBinding: GestureBinding, oppositeBinding: GestureBinding?, - scale: CGFloat + distance: CGFloat ) { if let oppositeBinding { guard var state = recognizers[fingerCount]?.state else { return } - // Direction is fixed by the new binding's gesture type, not by current scale, - // since the user may still be on the same side of 1.0 when reversing + // Direction is fixed by the new binding's gesture type, not by current finger distance, + // since the user may not yet have crossed neutral when reversing let direction = oppositeBinding.gestureType == .spread ? 1 : -1 state.resolvedBinding = oppositeBinding state.lastCommittedAction = .binding(oppositeBinding.id) state.pinchDirection = direction - state.lastCommitPinchOffset = (scale - 1.0) * CGFloat(direction) + state.lastCommitPinchDistance = distance recognizers[fingerCount]?.state = state triggerSingleAction(from: oppositeBinding, reverse: false) @@ -687,9 +715,7 @@ final class MultitouchTrigger { } } else if isCycleAction(currentBinding) { triggerSingleAction(from: currentBinding, reverse: true) - guard var state = recognizers[fingerCount]?.state else { return } - state.lastCommitPinchOffset = (scale - 1.0) * CGFloat(state.pinchDirection) - recognizers[fingerCount]?.state = state + recognizers[fingerCount]?.state.lastCommitPinchDistance = distance } } From 88442661473da03cf932a8c868153f91dd6a40b3 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 25 May 2026 14:55:53 -0600 Subject: [PATCH 34/61] =?UTF-8?q?=E2=9C=A8=20Unify=20gesture=20terminology?= =?UTF-8?q?=20across=20codebae?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Helpers/SystemGestureManager.swift | 14 +- Loop/Core/Observers/MultitouchTrigger.swift | 228 +++++++++--------- Loop/Extensions/Defaults+Extensions.swift | 4 +- Loop/Localizable.xcstrings | 26 +- .../Gestures/GestureConfigPopoverView.swift | 56 ++--- ...ngItemView.swift => GestureItemView.swift} | 44 ++-- .../Gestures/GesturesConfigurationView.swift | 40 +-- .../{GestureBinding.swift => Gesture.swift} | 58 ++--- 8 files changed, 239 insertions(+), 231 deletions(-) rename Loop/Settings Window/Settings/Gestures/{GestureBindingItemView.swift => GestureItemView.swift} (87%) rename Loop/Window Management/Window Action/{GestureBinding.swift => Gesture.swift} (73%) diff --git a/Loop/Core/Observers/Helpers/SystemGestureManager.swift b/Loop/Core/Observers/Helpers/SystemGestureManager.swift index 88a9e92b..c365fc1a 100644 --- a/Loop/Core/Observers/Helpers/SystemGestureManager.swift +++ b/Loop/Core/Observers/Helpers/SystemGestureManager.swift @@ -195,7 +195,7 @@ final class SystemGestureManager { static func reconcile( enableGestures: Bool, disableConflicts: Bool, - bindings: [GestureBinding] + gestures: [Gesture] ) { var backups = Defaults[.systemGesturePreferenceBackups] var managedValues = Defaults[.systemGestureManagedValues] @@ -203,7 +203,7 @@ final class SystemGestureManager { Self().reconcile( enableGestures: enableGestures, disableConflicts: disableConflicts, - bindings: bindings, + gestures: gestures, backups: &backups, managedValues: &managedValues ) @@ -215,7 +215,7 @@ final class SystemGestureManager { func reconcile( enableGestures: Bool, disableConflicts: Bool, - bindings: [GestureBinding], + gestures: [Gesture], backups: inout [String: SystemGesturePreferenceValue], managedValues: inout [String: SystemGesturePreferenceValue] ) { @@ -227,7 +227,7 @@ final class SystemGestureManager { return } - let desiredValues = desiredValues(for: bindings, backups: backups, managedValues: managedValues) + let desiredValues = desiredValues(for: gestures, backups: backups, managedValues: managedValues) guard !desiredValues.isEmpty else { restoreAll(backups: &backups, managedValues: &managedValues) return @@ -352,12 +352,12 @@ final class SystemGestureManager { } private func desiredValues( - for bindings: [GestureBinding], + for gestures: [Gesture], backups: [String: SystemGesturePreferenceValue], managedValues: [String: SystemGesturePreferenceValue] ) -> [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue] { - let hasThreeFingerGesture = bindings.contains { $0.fingerCount == 3 } - let hasFourFingerGesture = bindings.contains { $0.fingerCount == 4 } + let hasThreeFingerGesture = gestures.contains { $0.fingerCount == 3 } + let hasFourFingerGesture = gestures.contains { $0.fingerCount == 4 } guard hasThreeFingerGesture || hasFourFingerGesture else { return [:] } diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 27c0f63c..ec9a61a1 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -23,11 +23,11 @@ final class MultitouchTrigger { private let gestureBlocker: MultitouchGestureBlocker = .init() private var recognizers: [Int: RecognizerEntry] = [:] - private var bindingsObservationTask: Task<(), Never>? + private var gesturesObservationTask: Task<(), Never>? private var systemGestureReconciliationTask: Task<(), Never>? private var isStarted = false - /// Window most recently targeted by a `canRepeat` gesture binding. + /// Window most recently targeted by a `canRepeat` gesture gesture. /// Allows the user keep shrinking/growing a window after the cursor has fallen off its (now smaller) frame. private var lastRepeatableWindow: Window? @@ -44,7 +44,7 @@ final class MultitouchTrigger { private enum ActionKey: Hashable { case radialSlot(Int) case radialCenter - case binding(UUID) + case gesture(UUID) } private struct GestureState { @@ -52,8 +52,8 @@ final class MultitouchTrigger { var isGestureRejected = false var hasActivated = false var hasGestureBegun = false - /// The binding currently driving this stroke. Swapped on direction reversal - var resolvedBinding: GestureBinding? + /// The gesture currently driving this stroke. Swapped on direction reversal + var resolvedGesture: Gesture? var pendingTargetWindow: Window? var lastCommittedAction: ActionKey? var lastCommitPanDistance: CGFloat = 0 @@ -61,24 +61,24 @@ final class MultitouchTrigger { var pinchDirection: Int = 0 } - /// Snapshot of the bindings that apply at this finger count, so handlers - /// don't fire actions against a binding the user has just deleted. + /// Snapshot of the gestures that apply at this finger count, so handlers + /// don't fire actions against a gesture the user has just deleted. private struct RecognizerEntry { let recognizer: SubsurfaceGestureRecognizer var task: Task<(), Never>? var state: GestureState - var radialMenuBinding: GestureBinding? - var directionalBindings: [GestureBinding] - var pinchBinding: GestureBinding? - var spreadBinding: GestureBinding? + var radialMenuGesture: Gesture? + var directionalGestures: [Gesture] + var pinchGesture: Gesture? + var spreadGesture: Gesture? static func categorize( - _ bindings: [GestureBinding] - ) -> (radial: GestureBinding?, directionals: [GestureBinding], pinch: GestureBinding?, spread: GestureBinding?) { - let radial = bindings.first { $0.gestureType == .radialMenu } - let directionals = bindings.filter(\.gestureType.isDirectionalPan) - let pinch = bindings.first { $0.gestureType == .pinch } - let spread = bindings.first { $0.gestureType == .spread } + _ gestures: [Gesture] + ) -> (radial: Gesture?, directionals: [Gesture], pinch: Gesture?, spread: Gesture?) { + let radial = gestures.first { $0.kind == .radialMenu } + let directionals = gestures.filter(\.kind.isDirectionalPan) + let pinch = gestures.first { $0.kind == .pinch } + let spread = gestures.first { $0.kind == .spread } return (radial, directionals, pinch, spread) } } @@ -112,8 +112,8 @@ final class MultitouchTrigger { gestureMonitor.start() rebuildRecognizers() - bindingsObservationTask = Task { [weak self] in - for await _ in Defaults.updates(.gestureBindings) { + gesturesObservationTask = Task { [weak self] in + for await _ in Defaults.updates(.gestures) { guard !Task.isCancelled, let self else { break } rebuildRecognizers() } @@ -125,8 +125,8 @@ final class MultitouchTrigger { isStarted = false reconcileSystemGestures() - bindingsObservationTask?.cancel() - bindingsObservationTask = nil + gesturesObservationTask?.cancel() + gesturesObservationTask = nil for fingerCount in Array(recognizers.keys) { stopRecognizer(for: fingerCount) @@ -151,7 +151,7 @@ final class MultitouchTrigger { let updates = Defaults.updates( .enableGestures, .disableConflictingSystemGestures, - .gestureBindings + .gestures ) for await _ in updates { @@ -165,16 +165,16 @@ final class MultitouchTrigger { SystemGestureManager.reconcile( enableGestures: Defaults[.enableGestures], disableConflicts: Defaults[.disableConflictingSystemGestures], - bindings: Defaults[.gestureBindings] + gestures: Defaults[.gestures] ) } private func rebuildRecognizers() { - let allBindings = Defaults[.gestureBindings] - let conflictingIDs = GestureBinding.conflictingIDs(in: allBindings) - let activeBindings = allBindings.filter { !conflictingIDs.contains($0.id) } - let bindingsByFingerCount = Dictionary(grouping: activeBindings, by: \.fingerCount) - let neededFingerCounts = Set(bindingsByFingerCount.keys) + let allGestures = Defaults[.gestures] + let conflictingIDs = Gesture.conflictingIDs(in: allGestures) + let activeGestures = allGestures.filter { !conflictingIDs.contains($0.id) } + let gesturesByFingerCount = Dictionary(grouping: activeGestures, by: \.fingerCount) + let neededFingerCounts = Set(gesturesByFingerCount.keys) // Remove stale recognizers for fingerCount in Array(recognizers.keys) where !neededFingerCounts.contains(fingerCount) { @@ -182,36 +182,36 @@ final class MultitouchTrigger { recognizers.removeValue(forKey: fingerCount) } - // Add new recognizers or refresh cached bindings on existing ones. - for (fingerCount, bindings) in bindingsByFingerCount { - let (radial, directionals, pinch, spread) = RecognizerEntry.categorize(bindings) + // Add new recognizers or refresh cached gestures on existing ones. + for (fingerCount, gestures) in gesturesByFingerCount { + let (radial, directionals, pinch, spread) = RecognizerEntry.categorize(gestures) if recognizers[fingerCount] == nil { startRecognizer(for: fingerCount, radial: radial, directionals: directionals, pinch: pinch, spread: spread) } else { - recognizers[fingerCount]?.radialMenuBinding = radial - recognizers[fingerCount]?.directionalBindings = directionals - recognizers[fingerCount]?.pinchBinding = pinch - recognizers[fingerCount]?.spreadBinding = spread + recognizers[fingerCount]?.radialMenuGesture = radial + recognizers[fingerCount]?.directionalGestures = directionals + recognizers[fingerCount]?.pinchGesture = pinch + recognizers[fingerCount]?.spreadGesture = spread } } } private func startRecognizer( for fingerCount: Int, - radial: GestureBinding?, - directionals: [GestureBinding], - pinch: GestureBinding?, - spread: GestureBinding? + radial: Gesture?, + directionals: [Gesture], + pinch: Gesture?, + spread: Gesture? ) { let recognizer = SubsurfaceGestureRecognizer(fingerCount: fingerCount) recognizers[fingerCount] = RecognizerEntry( recognizer: recognizer, task: nil, state: GestureState(), - radialMenuBinding: radial, - directionalBindings: directionals, - pinchBinding: pinch, - spreadBinding: spread + radialMenuGesture: radial, + directionalGestures: directionals, + pinchGesture: pinch, + spreadGesture: spread ) let task = Task { [weak self] in @@ -244,22 +244,22 @@ final class MultitouchTrigger { private func handlePan(_ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int) async { guard let entry = recognizers[fingerCount] else { return } - if let radialMenuBinding = entry.radialMenuBinding { - await handleRadialMenuPan(pan, fingerCount: fingerCount, binding: radialMenuBinding) - } else if let directionalBinding = matchDirectionalPanBinding(angle: pan.angle, from: entry.directionalBindings) { - await handleDirectionalPan(pan, fingerCount: fingerCount, binding: directionalBinding) + if let radialMenuGesture = entry.radialMenuGesture { + await handleRadialMenuPan(pan, fingerCount: fingerCount, gesture: radialMenuGesture) + } else if let directionalGesture = matchDirectionalPanGesture(angle: pan.angle, from: entry.directionalGestures) { + await handleDirectionalPan(pan, fingerCount: fingerCount, gesture: directionalGesture) } } private func handleRadialMenuPan( _ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int, - binding: GestureBinding + gesture: Gesture ) async { switch pan.phase { case .began, .changed: if pan.phase == .began { - handleGestureBegan(fingerCount: fingerCount, binding: binding) + handleGestureBegan(fingerCount: fingerCount, gesture: gesture) } guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } @@ -302,26 +302,26 @@ final class MultitouchTrigger { private func handleDirectionalPan( _ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int, - binding: GestureBinding + gesture: Gesture ) async { guard let entry = recognizers[fingerCount] else { return } switch pan.phase { case .began, .changed: if pan.phase == .began { - handleGestureBegan(fingerCount: fingerCount, binding: binding) - recognizers[fingerCount]?.state.resolvedBinding = binding + handleGestureBegan(fingerCount: fingerCount, gesture: gesture) + recognizers[fingerCount]?.state.resolvedGesture = gesture } guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } - let activeBinding = state.resolvedBinding ?? binding + let activeGesture = state.resolvedGesture ?? gesture if panReversalDetected(state, distance: pan.distance) { handlePanReversal( fingerCount: fingerCount, - currentBinding: activeBinding, - oppositeBinding: oppositeDirectionalPanBinding(of: activeBinding, in: entry.directionalBindings), + currentGesture: activeGesture, + oppositeGesture: oppositeDirectionalPanGesture(of: activeGesture, in: entry.directionalGestures), distance: pan.distance ) return @@ -330,10 +330,10 @@ final class MultitouchTrigger { commitPan( &state, distance: pan.distance, - newKey: .binding(activeBinding.id), + newKey: .gesture(activeGesture.id), fingerCount: fingerCount ) { reverse in - triggerSingleAction(from: activeBinding, reverse: reverse) + triggerSingleAction(from: activeGesture, reverse: reverse) } case .ended, .cancelled: @@ -348,8 +348,8 @@ final class MultitouchTrigger { guard let entry = recognizers[fingerCount] else { return } // Radial menu pinch triggers the center action regardless of direction - if let radialMenuBinding = entry.radialMenuBinding { - await handleRadialMenuPinch(pinch, fingerCount: fingerCount, binding: radialMenuBinding) + if let radialMenuGesture = entry.radialMenuGesture { + await handleRadialMenuPinch(pinch, fingerCount: fingerCount, gesture: radialMenuGesture) return } @@ -362,19 +362,19 @@ final class MultitouchTrigger { guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } if !state.hasGestureBegun { - let initialBinding = pinch.distance >= pinch.originDistance ? entry.spreadBinding : entry.pinchBinding - guard let initialBinding else { + let initialGesture = pinch.distance >= pinch.originDistance ? entry.spreadGesture : entry.pinchGesture + guard let initialGesture else { state.isGestureRejected = true recognizers[fingerCount]?.state = state return } - handleGestureBegan(fingerCount: fingerCount, binding: initialBinding) + handleGestureBegan(fingerCount: fingerCount, gesture: initialGesture) recognizers[fingerCount]?.state.hasGestureBegun = true - recognizers[fingerCount]?.state.resolvedBinding = initialBinding + recognizers[fingerCount]?.state.resolvedGesture = initialGesture } guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected, - let activeBinding = state.resolvedBinding else { return } + let activeGesture = state.resolvedGesture else { return } guard await activateGestureIfNeeded( fingerCount: fingerCount, pinchDisplacement: pinch.distance - pinch.originDistance @@ -382,11 +382,11 @@ final class MultitouchTrigger { guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } if pinchReversalDetected(state, distance: pinch.distance) { - let opposite = activeBinding.gestureType == .pinch ? entry.spreadBinding : entry.pinchBinding + let opposite = activeGesture.kind == .pinch ? entry.spreadGesture : entry.pinchGesture handlePinchReversal( fingerCount: fingerCount, - currentBinding: activeBinding, - oppositeBinding: opposite, + currentGesture: activeGesture, + oppositeGesture: opposite, distance: pinch.distance ) return @@ -396,14 +396,14 @@ final class MultitouchTrigger { &state, distance: pinch.distance, originDistance: pinch.originDistance, - newKey: .binding(activeBinding.id), + newKey: .gesture(activeGesture.id), fingerCount: fingerCount ) { reverse in - triggerSingleAction(from: activeBinding, reverse: reverse) + triggerSingleAction(from: activeGesture, reverse: reverse) } if let window = recognizers[fingerCount]?.state.pendingTargetWindow, - resolvedWindowAction(from: activeBinding)?.canRepeat == true { + resolvedWindowAction(from: activeGesture)?.canRepeat == true { lastRepeatableWindow = window } @@ -415,16 +415,16 @@ final class MultitouchTrigger { } } - /// Pinch within a radial menu binding, triggers the center (last) radial menu action. + /// Pinch within a radial menu gesture, triggers the center (last) radial menu action. private func handleRadialMenuPinch( _ pinch: SubsurfaceGestureEvent.PinchEvent, fingerCount: Int, - binding: GestureBinding + gesture: Gesture ) async { switch pinch.phase { case .began, .changed: if pinch.phase == .began { - handleGestureBegan(fingerCount: fingerCount, binding: binding) + handleGestureBegan(fingerCount: fingerCount, gesture: gesture) } guard await activateGestureIfNeeded( fingerCount: fingerCount, @@ -455,9 +455,9 @@ final class MultitouchTrigger { /// Resolves the target window and starts blocking trackpad events. Loop itself /// isn't opened until the gesture crosses the activation threshold in a `.changed` event. - private func handleGestureBegan(fingerCount: Int, binding: GestureBinding) { - var window = findTargetWindow(for: binding) - if window == nil, resolvedWindowAction(from: binding)?.canRepeat == true { + private func handleGestureBegan(fingerCount: Int, gesture: Gesture) { + var window = findTargetWindow(for: gesture) + if window == nil, resolvedWindowAction(from: gesture)?.canRepeat == true { window = lastRepeatableWindow } @@ -468,7 +468,7 @@ final class MultitouchTrigger { return } - if let window, resolvedWindowAction(from: binding)?.canRepeat == true { + if let window, resolvedWindowAction(from: gesture)?.canRepeat == true { lastRepeatableWindow = window } @@ -520,7 +520,7 @@ final class MultitouchTrigger { recognizers[fingerCount]?.state = GestureState() } - private func findTargetWindow(for binding: GestureBinding) -> Window? { + private func findTargetWindow(for gesture: Gesture) -> Window? { let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) guard let window = WindowUtility.windowAtPosition(cursorPosition) else { @@ -528,7 +528,7 @@ final class MultitouchTrigger { } // 2-finger gestures are always titlebar-only to avoid system gesture conflicts. - switch binding.fingerCount <= 2 ? .titlebar : binding.activationZone { + switch gesture.fingerCount <= 2 ? .titlebar : gesture.activationZone { case .titlebar: let minimumTitlebarHeight = Defaults[.gestureTitlebarHeight] @@ -648,11 +648,11 @@ final class MultitouchTrigger { return delta <= -zoomCycleStepSize } - private func oppositeDirectionalPanBinding( - of current: GestureBinding, - in directionals: [GestureBinding] - ) -> GestureBinding? { - let opposite: GestureBinding.GestureType? = switch current.gestureType { + private func oppositeDirectionalPanGesture( + of current: Gesture, + in directionals: [Gesture] + ) -> Gesture? { + let opposite: Gesture.Kind? = switch current.kind { case .panUp: .panDown case .panDown: .panUp case .panLeft: .panRight @@ -660,61 +660,61 @@ final class MultitouchTrigger { default: nil } guard let opposite else { return nil } - return directionals.first { $0.gestureType == opposite } + return directionals.first { $0.kind == opposite } } - private func isCycleAction(_ binding: GestureBinding) -> Bool { - resolvedWindowAction(from: binding)?.direction == .cycle + private func isCycleAction(_ gesture: Gesture) -> Bool { + resolvedWindowAction(from: gesture)?.direction == .cycle } private func handlePanReversal( fingerCount: Int, - currentBinding: GestureBinding, - oppositeBinding: GestureBinding?, + currentGesture: Gesture, + oppositeGesture: Gesture?, distance: CGFloat ) { - if let oppositeBinding { + if let oppositeGesture { guard var state = recognizers[fingerCount]?.state else { return } - state.resolvedBinding = oppositeBinding - state.lastCommittedAction = .binding(oppositeBinding.id) + state.resolvedGesture = oppositeGesture + state.lastCommittedAction = .gesture(oppositeGesture.id) state.lastCommitPanDistance = distance recognizers[fingerCount]?.state = state - triggerSingleAction(from: oppositeBinding, reverse: false) + triggerSingleAction(from: oppositeGesture, reverse: false) if let window = state.pendingTargetWindow, - resolvedWindowAction(from: oppositeBinding)?.canRepeat == true { + resolvedWindowAction(from: oppositeGesture)?.canRepeat == true { lastRepeatableWindow = window } - } else if isCycleAction(currentBinding) { - triggerSingleAction(from: currentBinding, reverse: true) + } else if isCycleAction(currentGesture) { + triggerSingleAction(from: currentGesture, reverse: true) recognizers[fingerCount]?.state.lastCommitPanDistance = distance } } private func handlePinchReversal( fingerCount: Int, - currentBinding: GestureBinding, - oppositeBinding: GestureBinding?, + currentGesture: Gesture, + oppositeGesture: Gesture?, distance: CGFloat ) { - if let oppositeBinding { + if let oppositeGesture { guard var state = recognizers[fingerCount]?.state else { return } - // Direction is fixed by the new binding's gesture type, not by current finger distance, + // Direction is fixed by the new gesture's gesture type, not by current finger distance, // since the user may not yet have crossed neutral when reversing - let direction = oppositeBinding.gestureType == .spread ? 1 : -1 - state.resolvedBinding = oppositeBinding - state.lastCommittedAction = .binding(oppositeBinding.id) + let direction = oppositeGesture.kind == .spread ? 1 : -1 + state.resolvedGesture = oppositeGesture + state.lastCommittedAction = .gesture(oppositeGesture.id) state.pinchDirection = direction state.lastCommitPinchDistance = distance recognizers[fingerCount]?.state = state - triggerSingleAction(from: oppositeBinding, reverse: false) + triggerSingleAction(from: oppositeGesture, reverse: false) if let window = state.pendingTargetWindow, - resolvedWindowAction(from: oppositeBinding)?.canRepeat == true { + resolvedWindowAction(from: oppositeGesture)?.canRepeat == true { lastRepeatableWindow = window } - } else if isCycleAction(currentBinding) { - triggerSingleAction(from: currentBinding, reverse: true) + } else if isCycleAction(currentGesture) { + triggerSingleAction(from: currentGesture, reverse: true) recognizers[fingerCount]?.state.lastCommitPinchDistance = distance } } @@ -733,16 +733,16 @@ final class MultitouchTrigger { changeAction(resolvedAction, reverse) } - private func resolvedWindowAction(from binding: GestureBinding) -> WindowAction? { - guard case let .singleAction(actionType) = binding.action else { return nil } + private func resolvedWindowAction(from gesture: Gesture) -> WindowAction? { + guard case let .singleAction(actionType) = gesture.action else { return nil } switch actionType { case let .custom(action): return action case let .keybindReference(id): return resolveKeybindReference(id) } } - private func triggerSingleAction(from binding: GestureBinding, reverse: Bool = false) { - guard let resolvedAction = resolvedWindowAction(from: binding) else { return } + private func triggerSingleAction(from gesture: Gesture, reverse: Bool = false) { + guard let resolvedAction = resolvedWindowAction(from: gesture) else { return } changeAction(resolvedAction, reverse) } @@ -754,12 +754,12 @@ final class MultitouchTrigger { return Self.failedToResolveKeybindAction } - private func matchDirectionalPanBinding(angle: CGFloat, from bindings: [GestureBinding]) -> GestureBinding? { + private func matchDirectionalPanGesture(angle: CGFloat, from gestures: [Gesture]) -> Gesture? { let angleFromOrigin = .pi / 2 - angle var normalizedAngle = angleFromOrigin if normalizedAngle < 0 { normalizedAngle += 2 * .pi } - let direction: GestureBinding.GestureType = if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { + let direction: Gesture.Kind = if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { .panUp } else if normalizedAngle >= .pi / 4, normalizedAngle < 3 * .pi / 4 { .panRight @@ -769,7 +769,7 @@ final class MultitouchTrigger { .panLeft } - return bindings.first { $0.gestureType == direction } + return gestures.first { $0.kind == direction } } private func indexWithCardinalBias(angle: CGFloat, actionCount: Int, cardinalBias: CGFloat = 0.1) -> Int { diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index eaa2a6d5..ae6c03fd 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -72,7 +72,7 @@ extension Defaults.Keys { // Gestures static let enableGestures = Key("enableGestures", default: false, iCloud: true) - static let gestureBindings = Key<[GestureBinding]>("gestureBindings", default: GestureBinding.defaultBindings, iCloud: true) + static let gestures = Key<[Gesture]>("gestures", default: Gesture.defaults, iCloud: true) static let disableConflictingSystemGestures = Key("disableConflictingSystemGestures", default: true, iCloud: true) static let systemGesturePreferenceBackups = Key<[String: SystemGesturePreferenceValue]>("systemGesturePreferenceBackups", default: [:], iCloud: false) static let systemGestureManagedValues = Key<[String: SystemGesturePreferenceValue]>("systemGestureManagedValues", default: [:], iCloud: false) @@ -148,7 +148,7 @@ extension Defaults.Keys { /// Reset with `defaults delete com.MrKai77.Loop triggerKeyTimeout` static let triggerKeyTimeout = Key("triggerKeyTimeout", default: 0, iCloud: true) - /// Height of the titlebar activation zone for gesture bindings, defined in points. + /// 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` diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index d5509970..9cbd9c1b 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -624,6 +624,10 @@ } } }, + "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures." : { + "comment" : "A description of the restriction on 2-finger gestures.", + "isCommentAutoGenerated" : true + }, "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" : { @@ -5059,8 +5063,8 @@ } } }, - "Customize this gesture binding." : { - "comment" : "A button that opens a popover for configuring a gesture binding.", + "Customize this gesture." : { + "comment" : "A tooltip that appears when a user touches a gesture configuration button.", "isCommentAutoGenerated" : true }, "Customize this gesture's action." : { @@ -8093,13 +8097,13 @@ } } }, - "Gesture Bindings" : { - "comment" : "Section header shown in gestures settings" - }, "Gesture Type" : { "comment" : "A label describing the type of gesture.", "isCommentAutoGenerated" : true }, + "Gestures" : { + "comment" : "Section header shown in gestures settings" + }, "Go Back" : { "comment" : "Section header in the action picker of the Keybinds tab", "localizations" : { @@ -18550,8 +18554,8 @@ } } }, - "No gesture bindings" : { - "comment" : "A message displayed when there are no gesture bindings configured.", + "No gestures" : { + "comment" : "A message displayed when there are no gestures.", "isCommentAutoGenerated" : true }, "No keybinds" : { @@ -24020,8 +24024,8 @@ } } }, - "Press \"Add\" to add a gesture binding" : { - "comment" : "A description displayed when there are no gesture bindings configured. It instructs the user to add one.", + "Press \"Add\" to add a gesture" : { + "comment" : "A description of the action to add a gesture.", "isCommentAutoGenerated" : true }, "Press \"Add\" to add a keybind" : { @@ -31118,6 +31122,10 @@ } } }, + "There are other gestures that conflict with this gesture." : { + "comment" : "A tooltip that appears when a gesture has a conflicting gesture.", + "isCommentAutoGenerated" : true + }, "There are other keybinds that conflict with this key combination." : { "localizations" : { "ar" : { diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift index d11ddc34..8faa8bd4 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift @@ -9,25 +9,25 @@ import Luminare import SwiftUI struct GestureConfigPopoverView: View { - @State private var binding: GestureBinding - @Binding private var externalBinding: GestureBinding + @State private var gesture: Gesture + @Binding private var externalGesture: Gesture - init(binding: Binding) { - self.binding = binding.wrappedValue - self._externalBinding = binding + init(gesture: Binding) { + self.gesture = gesture.wrappedValue + self._externalGesture = gesture } - private var gestureTypeBinding: Binding { + private var kindBinding: Binding { Binding( - get: { binding.gestureType }, - set: { newType in - let oldType = binding.gestureType - binding.gestureType = newType + get: { gesture.kind }, + set: { newKind in + let oldKind = gesture.kind + gesture.kind = newKind - if newType == .radialMenu { - binding.action = .radialMenuActions - } else if oldType == .radialMenu { - binding.action = .singleAction(.custom(.init(.noAction))) + if newKind == .radialMenu { + gesture.action = .radialMenuActions + } else if oldKind == .radialMenu { + gesture.action = .singleAction(.custom(.init(.noAction))) } } ) @@ -36,15 +36,15 @@ struct GestureConfigPopoverView: View { var body: some View { LuminareSection { LuminareCompose("Gesture Type") { - Picker("", selection: gestureTypeBinding) { - ForEach(Array(GestureBinding.GestureType.allCases.enumerated()), id: \.element) { _, type in + Picker("", selection: kindBinding) { + ForEach(Array(Gesture.Kind.allCases.enumerated()), id: \.element) { _, kind in HStack { - type.image + kind.image .frame(width: 12) - Text(type.displayName) + Text(kind.displayName) } - .tag(type) + .tag(kind) } } .labelsHidden() @@ -52,30 +52,30 @@ struct GestureConfigPopoverView: View { LuminareCompose("Fingers") { HStack { - Text("\(binding.fingerCount)") + Text("\(gesture.fingerCount)") - Stepper("", value: $binding.fingerCount, in: 2...5) + Stepper("", value: $gesture.fingerCount, in: 2...5) .labelsHidden() - .onChange(of: binding.fingerCount) { count in - if count <= 2 { binding.activationZone = .titlebar } + .onChange(of: gesture.fingerCount) { count in + if count <= 2 { gesture.activationZone = .titlebar } } } } LuminareCompose("Activation Zone") { - Picker("", selection: $binding.activationZone) { - ForEach(GestureBinding.ActivationZone.allCases, id: \.self) { zone in + Picker("", selection: $gesture.activationZone) { + ForEach(Gesture.ActivationZone.allCases, id: \.self) { zone in Label(zone.displayName, systemImage: zone.systemImage) .tag(zone) } } .labelsHidden() - .disabled(binding.fingerCount <= 2) + .disabled(gesture.fingerCount <= 2) } - .help(binding.fingerCount <= 2 ? "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures." : "") + .help(gesture.fingerCount <= 2 ? "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures." : "") } .frame(maxWidth: .infinity, alignment: .leading) - .onChange(of: binding) { externalBinding = $0 } + .onChange(of: gesture) { externalGesture = $0 } .luminareFilledStates(.none) .luminareBorderedStates(.none) .padding(8) diff --git a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift similarity index 87% rename from Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift rename to Loop/Settings Window/Settings/Gestures/GestureItemView.swift index c6c2d5a5..7f32ca17 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureBindingItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -1,5 +1,5 @@ // -// GestureBindingItemView.swift +// GestureItemView.swift // Loop // // Created by Kai Azim on 2026-04-16. @@ -9,27 +9,27 @@ import Defaults import Luminare import SwiftUI -struct GestureBindingItemView: View { +struct GestureItemView: View { @Environment(\.luminareAnimation) var luminareAnimation @Default(.keybinds) private var keybinds - @Default(.gestureBindings) private var gestureBindings + @Default(.gestures) private var gestures - @State private var binding: GestureBinding - @Binding private var externalBinding: GestureBinding + @State private var gesture: Gesture + @Binding private var externalGesture: Gesture @State private var isActionPickerPresented = false @State private var isGestureConfigPresented = false @State private var isConfiguringCustom = false @State private var isConfiguringCycle = false - init(_ binding: Binding) { - self.binding = binding.wrappedValue - self._externalBinding = binding + init(_ gesture: Binding) { + self.gesture = gesture.wrappedValue + self._externalGesture = gesture } private var hasConflict: Bool { - GestureBinding.conflictingIDs(in: gestureBindings).contains(binding.id) + Gesture.conflictingIDs(in: gestures).contains(gesture.id) } var body: some View { @@ -43,7 +43,7 @@ struct GestureBindingItemView: View { } } .luminareToolTip(attachedTo: .topLeading, hidden: !hasConflict) { - Text("There are other gesture bindings that conflict with this gesture.") + Text("There are other gestures that conflict with this gesture.") .padding(6) } .frame(maxWidth: .infinity, alignment: .leading) @@ -60,7 +60,7 @@ struct GestureBindingItemView: View { isConfiguringCycle = true } } - .onChange(of: binding) { externalBinding = $0 } + .onChange(of: gesture) { externalGesture = $0 } } private var gestureConfiguration: some View { @@ -79,7 +79,7 @@ struct GestureBindingItemView: View { .luminareBorderedStates(.hovering) .luminareMinHeight(24) .opacity(hasConflict ? 0.5 : 1) - .help("Customize this gesture binding.") + .help("Customize this gesture.") .padding(.leading, -4) .luminarePopover( isPresented: $isGestureConfigPresented, @@ -88,7 +88,7 @@ struct GestureBindingItemView: View { shouldHideAnchor: true, shouldAnimate: false ) { - GestureConfigPopoverView(binding: $binding) + GestureConfigPopoverView(gesture: $gesture) .frame(width: 300) } } @@ -114,7 +114,7 @@ struct GestureBindingItemView: View { private var actionIndicator: some View { HStack(spacing: 2) { - if case .radialMenuActions = binding.action { + if case .radialMenuActions = gesture.action { HStack(spacing: 4) { Image(.loop) Text("Open Radial Menu") @@ -208,16 +208,16 @@ struct GestureBindingItemView: View { } private var gestureConfigurationText: String { - switch binding.gestureType { + switch gesture.kind { case .radialMenu: - "\(binding.fingerCount)-finger Gesture" + "\(gesture.fingerCount)-finger Gesture" default: - "\(binding.fingerCount)-finger \(binding.gestureType.displayName)" + "\(gesture.fingerCount)-finger \(gesture.kind.displayName)" } } private var resolvedAction: WindowAction? { - switch binding.action { + switch gesture.action { case .radialMenuActions: nil case let .singleAction(actionType): @@ -228,13 +228,13 @@ struct GestureBindingItemView: View { private var actionTypeBinding: Binding { Binding( get: { - if case let .singleAction(actionType) = binding.action { + if case let .singleAction(actionType) = gesture.action { return actionType } return .custom(.init(.noAction)) }, set: { newValue in - binding.action = .singleAction(newValue) + gesture.action = .singleAction(newValue) } ) } @@ -245,10 +245,10 @@ struct GestureBindingItemView: View { resolvedAction ?? .init(.noAction) }, set: { newAction in - if case let .singleAction(actionType) = binding.action { + if case let .singleAction(actionType) = gesture.action { switch actionType { case .custom: - binding.action = .singleAction(.custom(newAction)) + gesture.action = .singleAction(.custom(newAction)) case let .keybindReference(id): if let index = Defaults[.keybinds].firstIndex(where: { $0.id == id }) { keybinds[index] = newAction diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index d9d6197c..55128582 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -10,7 +10,7 @@ import Luminare import SwiftUI final class GesturesConfigurationModel: ObservableObject { - @Published var selectedBindings = Set() + @Published var selectedGestures = Set() } struct GesturesConfigurationView: View { @@ -19,7 +19,7 @@ struct GesturesConfigurationView: View { @Default(.enableGestures) private var enableGestures @Default(.disableConflictingSystemGestures) private var disableConflictingSystemGestures - @Default(.gestureBindings) private var gestureBindings + @Default(.gestures) private var gestures @Default(.gestureTitlebarHeight) private var gestureTitlebarHeight var body: some View { @@ -27,14 +27,14 @@ struct GesturesConfigurationView: View { settingsSection if enableGestures { - bindingsSection + gesturesSection } } .animation(luminareAnimation, value: enableGestures) - .onChange(of: gestureBindings) { newValue in - let bindingsByID = Dictionary(uniqueKeysWithValues: newValue.map { ($0.id, $0) }) - let selectedIDs = model.selectedBindings.map(\.id) - model.selectedBindings = Set(selectedIDs.compactMap { bindingsByID[$0] }) + .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] }) } } @@ -48,38 +48,38 @@ struct GesturesConfigurationView: View { } } - private var bindingsSection: some View { - LuminareSection(String(localized: "Gesture Bindings", comment: "Section header shown in gestures settings")) { + private var gesturesSection: some View { + LuminareSection(String(localized: "Gestures", comment: "Section header shown in gestures settings")) { LuminareButtonRow { Button("Add") { - gestureBindings.insert( - GestureBinding(), + gestures.insert( + Gesture(), at: 0 ) } Button("Remove", role: .destructive) { - let selectedIDs = Set(model.selectedBindings.map(\.id)) - gestureBindings.removeAll { selectedIDs.contains($0.id) } + let selectedIDs = Set(model.selectedGestures.map(\.id)) + gestures.removeAll { selectedIDs.contains($0.id) } } - .disabled(model.selectedBindings.isEmpty) + .disabled(model.selectedGestures.isEmpty) .keyboardShortcut(.delete) } .luminareRoundingBehavior(top: true) LuminareList( - items: $gestureBindings, - selection: $model.selectedBindings, + items: $gestures, + selection: $model.selectedGestures, id: \.id - ) { binding in - GestureBindingItemView(binding) + ) { gesture in + GestureItemView(gesture) } emptyView: { HStack { Spacer() VStack { - Text("No gesture bindings") + Text("No gestures") .font(.title3) - Text("Press \"Add\" to add a gesture binding") + Text("Press \"Add\" to add a gesture") .font(.caption) } Spacer() diff --git a/Loop/Window Management/Window Action/GestureBinding.swift b/Loop/Window Management/Window Action/Gesture.swift similarity index 73% rename from Loop/Window Management/Window Action/GestureBinding.swift rename to Loop/Window Management/Window Action/Gesture.swift index 397c1478..9c251e98 100644 --- a/Loop/Window Management/Window Action/GestureBinding.swift +++ b/Loop/Window Management/Window Action/Gesture.swift @@ -1,5 +1,5 @@ // -// GestureBinding.swift +// Gesture.swift // Loop // // Created by Kai Azim on 2026-04-16. @@ -8,28 +8,28 @@ import Defaults import SwiftUI -struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { +struct Gesture: Identifiable, Codable, Hashable, Defaults.Serializable { let id: UUID var fingerCount: Int - var gestureType: GestureType - var action: GestureAction + var kind: Kind + var action: Action var activationZone: ActivationZone init( id: UUID = .init(), fingerCount: Int = 2, - gestureType: GestureType = .radialMenu, - action: GestureAction = .radialMenuActions, + kind: Kind = .radialMenu, + action: Action = .radialMenuActions, activationZone: ActivationZone = .titlebar ) { self.id = id self.fingerCount = fingerCount - self.gestureType = gestureType + self.kind = kind self.action = action self.activationZone = activationZone } - enum GestureType: Codable, Hashable, CaseIterable { + enum Kind: Codable, Hashable, CaseIterable { /// Pan gesture that maps angle to radial menu directional slots. case radialMenu /// Directional pan gestures that trigger a single action. @@ -82,7 +82,7 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { } } - enum GestureAction: Codable, Hashable { + enum Action: Codable, Hashable { /// Uses `RadialMenuAction.userConfiguredActions` for radial menu pan mode. case radialMenuActions /// A single action, either custom or referencing a keybind. @@ -112,35 +112,35 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { // MARK: - Conflict Detection -extension GestureBinding { - /// Two bindings conflict when they have the same finger count and their gesture types overlap. - /// Radial menu consumes both pan and pinch, so it conflicts with ANY other binding at the same finger count. - func conflicts(with other: GestureBinding) -> Bool { +extension Gesture { + /// Two gestures conflict when they have the same finger count and their kinds overlap. + /// Radial menu consumes both pan and pinch, so it conflicts with ANY other gesture at the same finger count. + func conflicts(with other: Gesture) -> Bool { guard id != other.id, fingerCount == other.fingerCount else { return false } // Radial menu uses both pan and pinch, so it conflicts with everything at the same finger count - if gestureType == .radialMenu || other.gestureType == .radialMenu { + if kind == .radialMenu || other.kind == .radialMenu { return true } - // Same gesture type always conflicts - if gestureType == other.gestureType { + // Same kind always conflicts + if kind == other.kind { return true } return false } - /// Returns the IDs of all bindings that conflict with at least one other binding in the array. - static func conflictingIDs(in bindings: [GestureBinding]) -> Set { + /// Returns the IDs of all gestures that conflict with at least one other gesture in the array. + static func conflictingIDs(in gestures: [Gesture]) -> Set { var result = Set() - for i in bindings.indices { - for j in (i + 1) ..< bindings.count { - if bindings[i].conflicts(with: bindings[j]) { - result.insert(bindings[i].id) - result.insert(bindings[j].id) + 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) } } } @@ -150,17 +150,17 @@ extension GestureBinding { // MARK: - Defaults -extension GestureBinding { - static let defaultBindings: [GestureBinding] = [ - GestureBinding( +extension Gesture { + static let defaults: [Gesture] = [ + Gesture( fingerCount: 2, - gestureType: .radialMenu, + kind: .radialMenu, action: .radialMenuActions, activationZone: .titlebar ), - GestureBinding( + Gesture( fingerCount: 2, - gestureType: .pinch, + kind: .pinch, action: .singleAction(.custom( WindowAction( "\(WindowDirection.maximize.name) + \(WindowDirection.macOSCenter.name)", From c595b885314d749cf994eb61be044296c3553feb Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 25 May 2026 15:13:53 -0600 Subject: [PATCH 35/61] =?UTF-8?q?=F0=9F=8C=8F=20Add=20localization=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 5 +- Loop/Localizable.xcstrings | 86 +++++++---- .../Gestures/GestureConfigPopoverView.swift | 8 +- .../Settings/Gestures/GestureItemView.swift | 145 ++++++++++-------- .../Gestures/GesturesConfigurationView.swift | 12 +- .../Window Action/Gesture.swift | 34 ++-- 6 files changed, 174 insertions(+), 116 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index ec9a61a1..12ca587b 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -113,7 +113,8 @@ final class MultitouchTrigger { rebuildRecognizers() gesturesObservationTask = Task { [weak self] in - for await _ in Defaults.updates(.gestures) { + // Watch keybinds too, so gestures referencing a deleted or no-action keybind get filtered out by `rebuildRecognizers` + for await _ in Defaults.updates(.gestures, .keybinds) { guard !Task.isCancelled, let self else { break } rebuildRecognizers() } @@ -172,7 +173,7 @@ final class MultitouchTrigger { private func rebuildRecognizers() { let allGestures = Defaults[.gestures] let conflictingIDs = Gesture.conflictingIDs(in: allGestures) - let activeGestures = allGestures.filter { !conflictingIDs.contains($0.id) } + let activeGestures = allGestures.filter { !conflictingIDs.contains($0.id) && !$0.isDisabled } let gesturesByFingerCount = Dictionary(grouping: activeGestures, by: \.fingerCount) let neededFingerCounts = Set(gesturesByFingerCount.keys) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 9cbd9c1b..c108415f 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -624,9 +624,22 @@ } } }, + "%lld-finger %@" : { + "comment" : "Label describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Pinch', 'Swipe Up').", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld-finger %2$@" + } + } + } + }, + "%lld-finger Swipe, Pinch, or Spread" : { + "comment" : "Label describing how to activate a radial menu gesture. Argument is the finger count." + }, "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures." : { - "comment" : "A description of the restriction on 2-finger gestures.", - "isCommentAutoGenerated" : true + "comment" : "Help text shown when the activation zone picker is disabled for 2-finger gestures" }, "A single %@ action can only track one window. To stash\nmultiple windows, add additional %@ actions." : { "comment" : "Information in a popover displaying how a stash action can only keep track of a single window. Both %1$@ and %2$@ are replaced with the language's localization of the \"Stash\" action.", @@ -1160,11 +1173,10 @@ } }, "Activation Zone" : { - "comment" : "A label describing the activation zone of a gesture binding.", - "isCommentAutoGenerated" : true + "comment" : "Label for the activation zone picker in the gesture configuration popover" }, "Add" : { - "comment" : "Used to add items to a list", + "comment" : "Button to add a new gesture", "localizations" : { "ar" : { "stringUnit" : { @@ -1871,6 +1883,9 @@ } } }, + "Anywhere" : { + "comment" : "Gesture activation zone covers the entire window" + }, "App icon is locked" : { "localizations" : { "ar" : { @@ -4975,7 +4990,7 @@ } }, "Customize this action's custom frame." : { - "comment" : "A help text describing the customization option for a custom-direction action.", + "comment" : "Help text on the slider icon next to a gesture action with a custom frame", "localizations" : { "ar" : { "stringUnit" : { @@ -5064,12 +5079,10 @@ } }, "Customize this gesture." : { - "comment" : "A tooltip that appears when a user touches a gesture configuration button.", - "isCommentAutoGenerated" : true + "comment" : "Help text shown when hovering a gesture configuration button" }, "Customize this gesture's action." : { - "comment" : "A description of the action customization feature.", - "isCommentAutoGenerated" : true + "comment" : "Help text shown when hovering a gesture's action button" }, "Customize this keybind's action." : { "localizations" : { @@ -5160,7 +5173,7 @@ } }, "Customize what this action cycles through." : { - "comment" : "A description for a button that appears when configuring a cycling window action.", + "comment" : "Help text on the cycle icon next to a gesture action that cycles", "localizations" : { "ar" : { "stringUnit" : { @@ -5870,8 +5883,7 @@ } }, "Disable conflicting system gestures" : { - "comment" : "Toggle to disable conflicting system gestures.", - "isCommentAutoGenerated" : true + "comment" : "Toggle in gestures settings" }, "Disable cursor interaction" : { "localizations" : { @@ -6315,8 +6327,7 @@ } }, "Enable gestures" : { - "comment" : "Toggle label for enabling gestures.", - "isCommentAutoGenerated" : true + "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.", @@ -7027,8 +7038,7 @@ } }, "Fingers" : { - "comment" : "Label for the number of fingers in the gesture.", - "isCommentAutoGenerated" : true + "comment" : "Label for the finger-count stepper in the gesture configuration popover" }, "First Fourth" : { "comment" : "Window action", @@ -8098,8 +8108,7 @@ } }, "Gesture Type" : { - "comment" : "A label describing the type of gesture.", - "isCommentAutoGenerated" : true + "comment" : "Label in the gesture configuration popover" }, "Gestures" : { "comment" : "Section header shown in gestures settings" @@ -18378,7 +18387,7 @@ } }, "No Action" : { - "comment" : "Window action: no selection", + "comment" : "Label shown for a gesture with no configured action\nWindow action: no selection", "localizations" : { "ar" : { "stringUnit" : { @@ -18555,8 +18564,7 @@ } }, "No gestures" : { - "comment" : "A message displayed when there are no gestures.", - "isCommentAutoGenerated" : true + "comment" : "Empty state title in gestures settings" }, "No keybinds" : { "localizations" : { @@ -23313,8 +23321,7 @@ } }, "Open Radial Menu" : { - "comment" : "A label displayed in a button that opens a radial menu.", - "isCommentAutoGenerated" : true + "comment" : "Label shown for a gesture configured to open the radial menu" }, "Options" : { "comment" : "Section header shown in settings", @@ -23671,6 +23678,9 @@ } } }, + "Pinch" : { + "comment" : "Gesture kind: fingers move together" + }, "Please include at least one modifier key." : { "comment" : "An error message displayed when a custom keybind is created but does not include at least one modifier key.", "localizations" : { @@ -24025,8 +24035,7 @@ } }, "Press \"Add\" to add a gesture" : { - "comment" : "A description of the action to add a gesture.", - "isCommentAutoGenerated" : true + "comment" : "Empty state subtitle in gestures settings" }, "Press \"Add\" to add a keybind" : { "localizations" : { @@ -24915,7 +24924,7 @@ } }, "Radial Menu" : { - "comment" : "Section header shown in settings", + "comment" : "Gesture kind: opens the radial menu via swipe/pinch/spread\nSection header shown in settings", "localizations" : { "ar" : { "stringUnit" : { @@ -25270,7 +25279,7 @@ } }, "Remove" : { - "comment" : "Used to remove items from a list", + "comment" : "Button to remove selected gestures", "localizations" : { "ar" : { "stringUnit" : { @@ -30061,6 +30070,9 @@ } } }, + "Spread" : { + "comment" : "Gesture kind: fingers move apart" + }, "Stage Manager" : { "comment" : "Section header shown in settings", "localizations" : { @@ -30593,6 +30605,18 @@ } } }, + "Swipe Down" : { + "comment" : "Gesture kind: directional swipe" + }, + "Swipe Left" : { + "comment" : "Gesture kind: directional swipe" + }, + "Swipe Right" : { + "comment" : "Gesture kind: directional swipe" + }, + "Swipe Up" : { + "comment" : "Gesture kind: directional swipe" + }, "Sync Wallpaper" : { "localizations" : { "ar" : { @@ -31123,8 +31147,7 @@ } }, "There are other gestures that conflict with this gesture." : { - "comment" : "A tooltip that appears when a gesture has a conflicting gesture.", - "isCommentAutoGenerated" : true + "comment" : "Tooltip shown on a conflicting gesture in settings" }, "There are other keybinds that conflict with this key combination." : { "localizations" : { @@ -31747,6 +31770,9 @@ } } }, + "Titlebar Only" : { + "comment" : "Gesture activation zone restricted to a window's titlebar" + }, "To save power, window animations are\nunavailable in Low Power Mode." : { "localizations" : { "ar" : { diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift index 8faa8bd4..f2dbd97f 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift @@ -35,7 +35,7 @@ struct GestureConfigPopoverView: View { var body: some View { LuminareSection { - LuminareCompose("Gesture Type") { + LuminareCompose(String(localized: "Gesture Type", comment: "Label in the gesture configuration popover")) { Picker("", selection: kindBinding) { ForEach(Array(Gesture.Kind.allCases.enumerated()), id: \.element) { _, kind in HStack { @@ -50,7 +50,7 @@ struct GestureConfigPopoverView: View { .labelsHidden() } - LuminareCompose("Fingers") { + LuminareCompose(String(localized: "Fingers", comment: "Label for the finger-count stepper in the gesture configuration popover")) { HStack { Text("\(gesture.fingerCount)") @@ -62,7 +62,7 @@ struct GestureConfigPopoverView: View { } } - LuminareCompose("Activation Zone") { + LuminareCompose(String(localized: "Activation Zone", comment: "Label for the activation zone picker in the gesture configuration popover")) { Picker("", selection: $gesture.activationZone) { ForEach(Gesture.ActivationZone.allCases, id: \.self) { zone in Label(zone.displayName, systemImage: zone.systemImage) @@ -72,7 +72,7 @@ struct GestureConfigPopoverView: View { .labelsHidden() .disabled(gesture.fingerCount <= 2) } - .help(gesture.fingerCount <= 2 ? "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures." : "") + .help(gesture.fingerCount <= 2 ? String(localized: "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures.", comment: "Help text shown when the activation zone picker is disabled for 2-finger gestures") : "") } .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: gesture) { externalGesture = $0 } diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift index 7f32ca17..c3aeb6be 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -32,6 +32,68 @@ struct GestureItemView: View { Gesture.conflictingIDs(in: gestures).contains(gesture.id) } + private var isDisabled: Bool { + gesture.isDisabled + } + + private var gestureConfigurationText: String { + switch gesture.kind { + case .radialMenu: + String( + localized: "\(gesture.fingerCount)-finger Swipe, Pinch, or Spread", + comment: "Label describing how to activate a radial menu gesture. Argument is the finger count." + ) + default: + String( + localized: "\(gesture.fingerCount)-finger \(gesture.kind.displayName)", + comment: "Label describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Pinch', 'Swipe Up')." + ) + } + } + + private var resolvedAction: WindowAction? { + switch gesture.action { + case .radialMenuActions: + nil + case let .singleAction(actionType): + actionType.resolvedAction + } + } + + private var actionTypeBinding: Binding { + Binding( + get: { + if case let .singleAction(actionType) = gesture.action { + return actionType + } + return .custom(.init(.noAction)) + }, + set: { newValue in + gesture.action = .singleAction(newValue) + } + ) + } + + private var actionBinding: Binding { + Binding( + get: { + resolvedAction ?? .init(.noAction) + }, + set: { newAction in + if case let .singleAction(actionType) = gesture.action { + switch actionType { + case .custom: + gesture.action = .singleAction(.custom(newAction)) + case let .keybindReference(id): + if let index = Defaults[.keybinds].firstIndex(where: { $0.id == id }) { + keybinds[index] = newAction + } + } + } + } + ) + } + var body: some View { ZStack { Group { @@ -43,7 +105,7 @@ struct GestureItemView: View { } } .luminareToolTip(attachedTo: .topLeading, hidden: !hasConflict) { - Text("There are other gestures that conflict with this gesture.") + 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) @@ -51,6 +113,7 @@ struct GestureItemView: View { actionSelection .frame(maxWidth: .infinity, alignment: .trailing) } + .opacity(isDisabled ? 0.5 : 1) .padding(.horizontal, 12) .onChange(of: resolvedAction?.direction) { _ in if resolvedAction?.direction.isCustomizable == true { @@ -67,11 +130,17 @@ struct GestureItemView: View { Button { isGestureConfigPresented = true } label: { - Text(gestureConfigurationText) - .fontWeight(.regular) - .lineLimit(1) - .padding(.horizontal, 4) - .contentShape(.rect) + VStack(alignment: .leading, spacing: 2) { + Text(gestureConfigurationText) + .fontWeight(.regular) + .lineLimit(1) + + Text(gesture.activationZone.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 4) + .contentShape(.rect) } .luminareContentSize(contentMode: .fit, hasFixedHeight: true) .luminareRoundingBehavior(top: true, bottom: true) @@ -79,7 +148,7 @@ struct GestureItemView: View { .luminareBorderedStates(.hovering) .luminareMinHeight(24) .opacity(hasConflict ? 0.5 : 1) - .help("Customize this gesture.") + .help(String(localized: "Customize this gesture.", comment: "Help text shown when hovering a gesture configuration button")) .padding(.leading, -4) .luminarePopover( isPresented: $isGestureConfigPresented, @@ -117,7 +186,7 @@ struct GestureItemView: View { if case .radialMenuActions = gesture.action { HStack(spacing: 4) { Image(.loop) - Text("Open Radial Menu") + Text(String(localized: "Open Radial Menu", comment: "Label shown for a gesture configured to open the radial menu")) .fontWeight(.regular) .lineLimit(1) } @@ -138,7 +207,7 @@ struct GestureItemView: View { Image(systemName: "bolt.horizontal.fill") .foregroundStyle(.secondary) - Text("No Action") + Text(String(localized: "No Action", comment: "Label shown for a gesture with no configured action")) .fontWeight(.regular) .lineLimit(1) .foregroundStyle(.secondary) @@ -151,7 +220,7 @@ struct GestureItemView: View { .luminareFilledStates([.hovering, .pressed]) .luminareBorderedStates(.hovering) .luminareMinHeight(24) - .help("Customize this gesture's action.") + .help(String(localized: "Customize this gesture's action.", comment: "Help text shown when hovering a gesture's action button")) .padding(.leading, -4) } @@ -180,7 +249,7 @@ struct GestureItemView: View { } } .luminareModalCornerRadius(24) - .help("Customize this action's custom frame.") + .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 { @@ -198,7 +267,7 @@ struct GestureItemView: View { .frame(width: 400) } .luminareModalCornerRadius(24) - .help("Customize what this action cycles through.") + .help(String(localized: "Customize what this action cycles through.", comment: "Help text on the cycle icon next to a gesture action that cycles")) } } } @@ -206,56 +275,4 @@ struct GestureItemView: View { .foregroundStyle(.secondary) } } - - private var gestureConfigurationText: String { - switch gesture.kind { - case .radialMenu: - "\(gesture.fingerCount)-finger Gesture" - default: - "\(gesture.fingerCount)-finger \(gesture.kind.displayName)" - } - } - - 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 - } - } - } - } - ) - } } diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index 55128582..069c7777 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -40,10 +40,10 @@ struct GesturesConfigurationView: View { private var settingsSection: some View { LuminareSection { - LuminareToggle("Enable gestures", isOn: $enableGestures) + LuminareToggle(String(localized: "Enable gestures", comment: "Toggle in gestures settings"), isOn: $enableGestures) if enableGestures { - LuminareToggle("Disable conflicting system gestures", isOn: $disableConflictingSystemGestures) + LuminareToggle(String(localized: "Disable conflicting system gestures", comment: "Toggle in gestures settings"), isOn: $disableConflictingSystemGestures) } } } @@ -51,14 +51,14 @@ struct GesturesConfigurationView: View { private var gesturesSection: some View { LuminareSection(String(localized: "Gestures", comment: "Section header shown in gestures settings")) { LuminareButtonRow { - Button("Add") { + Button(String(localized: "Add", comment: "Button to add a new gesture")) { gestures.insert( Gesture(), at: 0 ) } - Button("Remove", role: .destructive) { + 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) } } @@ -77,9 +77,9 @@ struct GesturesConfigurationView: View { HStack { Spacer() VStack { - Text("No gestures") + Text(String(localized: "No gestures", comment: "Empty state title in gestures settings")) .font(.title3) - Text("Press \"Add\" to add a gesture") + Text(String(localized: "Press \"Add\" to add a gesture", comment: "Empty state subtitle in gestures settings")) .font(.caption) } Spacer() diff --git a/Loop/Window Management/Window Action/Gesture.swift b/Loop/Window Management/Window Action/Gesture.swift index 9c251e98..663805a3 100644 --- a/Loop/Window Management/Window Action/Gesture.swift +++ b/Loop/Window Management/Window Action/Gesture.swift @@ -41,13 +41,13 @@ struct Gesture: Identifiable, Codable, Hashable, Defaults.Serializable { var displayName: String { switch self { - case .radialMenu: "Radial Menu" - case .panUp: "Swipe Up" - case .panDown: "Swipe Down" - case .panLeft: "Swipe Left" - case .panRight: "Swipe Right" - case .pinch: "Pinch" - case .spread: "Spread" + case .radialMenu: String(localized: "Radial Menu", comment: "Gesture kind: opens the radial menu via swipe/pinch/spread") + case .panUp: String(localized: "Swipe Up", comment: "Gesture kind: directional swipe") + case .panDown: String(localized: "Swipe Down", comment: "Gesture kind: directional swipe") + case .panLeft: String(localized: "Swipe Left", comment: "Gesture kind: directional swipe") + case .panRight: String(localized: "Swipe Right", comment: "Gesture kind: directional swipe") + case .pinch: String(localized: "Pinch", comment: "Gesture kind: fingers move together") + case .spread: String(localized: "Spread", comment: "Gesture kind: fingers move apart") } } @@ -95,9 +95,8 @@ struct Gesture: Identifiable, Codable, Hashable, Defaults.Serializable { var displayName: String { switch self { - case .titlebar: "Titlebar" - - case .anywhere: "Anywhere" + 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") } } @@ -110,6 +109,21 @@ struct Gesture: Identifiable, Codable, Hashable, Defaults.Serializable { } } +// MARK: - Disabled state + +extension Gesture { + /// True when this gesture's action resolves to `noAction`. Disabled gestures + /// are skipped at runtime and rendered greyed-out in settings. + var isDisabled: Bool { + switch action { + case .radialMenuActions: + false + case let .singleAction(actionType): + (actionType.resolvedAction?.direction ?? .noAction) == .noAction + } + } +} + // MARK: - Conflict Detection extension Gesture { From ee54a15693e66bf77fdd7525090b62dabc17b48f Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 13 Jun 2026 00:17:07 -0700 Subject: [PATCH 36/61] =?UTF-8?q?=E2=9C=A8=20macOS=20Golden=20Gate=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop.xcodeproj/project.pbxproj | 42 ++++++++++----------- Loop/Core/Observers/MultitouchTrigger.swift | 15 +++++--- Loop/Extensions/Defaults+Extensions.swift | 2 +- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 93c22e3d..bf54eb5b 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -8,11 +8,11 @@ /* Begin PBXBuildFile section */ 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B6282EE5050C00A1E26B /* Defaults */; }; - 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B62B2EE5057C00A1E26B /* Luminare */; }; 2A847DFC2F5E49E90099E02A /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A847DFB2F5E49E90099E02A /* Scribe */; }; 2A847DFF2F5E4A080099E02A /* Subsurface in Frameworks */ = {isa = PBXBuildFile; productRef = 2A847DFE2F5E4A080099E02A /* Subsurface */; }; 2A847E012F5E4A0E0099E02A /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A847E002F5E4A0E0099E02A /* Scribe */; }; 2A86890D2F80968A005B521B /* LoopDockTile.plugin in CopyFiles */ = {isa = PBXBuildFile; fileRef = 2A8689072F809625005B521B /* LoopDockTile.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 2AAC0B022FDD30370047C801 /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 2AAC0B012FDD30370047C801 /* Luminare */; }; 2AE091C72F81A22800EF6149 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AE091C62F81A22800EF6149 /* Scribe */; }; 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */; }; B1AA00412F30000100AABBCC /* LoopUpdaterHelper in CopyFiles */ = {isa = PBXBuildFile; fileRef = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; @@ -129,9 +129,9 @@ buildActionMask = 2147483647; files = ( 2A847DFC2F5E49E90099E02A /* Scribe in Frameworks */, + 2AAC0B022FDD30370047C801 /* Luminare in Frameworks */, 2A847DFF2F5E4A080099E02A /* Subsurface in Frameworks */, 2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */, - 2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */, F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */, 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */, ); @@ -233,10 +233,10 @@ name = Loop; packageProductDependencies = ( 2A28B6282EE5050C00A1E26B /* Defaults */, - 2A28B62B2EE5057C00A1E26B /* Luminare */, 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */, 2A847DFB2F5E49E90099E02A /* Scribe */, 2A847DFE2F5E4A080099E02A /* Subsurface */, + 2AAC0B012FDD30370047C801 /* Luminare */, ); productName = WindowManager; productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */; @@ -309,10 +309,10 @@ mainGroup = A8E59C2C297F5E9A0064D4BA; packageReferences = ( 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */, - 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */, 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */, 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */, + 2AAC0B002FDD30370047C801 /* XCLocalSwiftPackageReference "../Luminare" */, ); productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */; projectDirPath = ""; @@ -910,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; @@ -919,14 +926,6 @@ minimumVersion = 9.0.0; }; }; - 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/mrkai77/luminare"; - requirement = { - branch = main; - kind = branch; - }; - }; 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SenpaiHunters/Scribe"; @@ -959,21 +958,11 @@ package = 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; - 2A28B62B2EE5057C00A1E26B /* Luminare */ = { - isa = XCSwiftPackageProductDependency; - package = 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */; - productName = Luminare; - }; 2A847DFB2F5E49E90099E02A /* Scribe */ = { isa = XCSwiftPackageProductDependency; package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; productName = Scribe; }; - 2AE091C62F81A22800EF6149 /* Scribe */ = { - isa = XCSwiftPackageProductDependency; - package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; - productName = Scribe; - }; 2A847DFE2F5E4A080099E02A /* Subsurface */ = { isa = XCSwiftPackageProductDependency; package = 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */; @@ -984,6 +973,15 @@ package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; productName = Scribe; }; + 2AAC0B012FDD30370047C801 /* Luminare */ = { + isa = XCSwiftPackageProductDependency; + productName = Luminare; + }; + 2AE091C62F81A22800EF6149 /* Scribe */ = { + isa = XCSwiftPackageProductDependency; + package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */; + productName = Scribe; + }; 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */ = { isa = XCSwiftPackageProductDependency; package = 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 12ca587b..d3a9ecc2 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -532,12 +532,17 @@ final class MultitouchTrigger { switch gesture.fingerCount <= 2 ? .titlebar : gesture.activationZone { case .titlebar: let minimumTitlebarHeight = Defaults[.gestureTitlebarHeight] - - let titlebarHeight: CGFloat = if #available(macOS 26.0, *), - let cornerRadius = SkyLightToolBelt.getCornerRadii(windowID: window.cgWindowID)?.topLeading { - max(2 * cornerRadius, minimumTitlebarHeight) + let titlebarHeight: CGFloat + + if #available(macOS 26, *) { + if #unavailable(macOS 27), + let cornerRadius = SkyLightToolBelt.getCornerRadii(windowID: window.cgWindowID)?.topLeading { + titlebarHeight = max(2 * cornerRadius, minimumTitlebarHeight) + } else { + titlebarHeight = minimumTitlebarHeight + } } else { - minimumTitlebarHeight + titlebarHeight = minimumTitlebarHeight } log.info("Detected titlebar height of \(titlebarHeight)") diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index ae6c03fd..35944066 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -152,7 +152,7 @@ extension Defaults.Keys { /// Gestures with the `.titlebar` activation zone will only trigger when the cursor is within this distance from the top of a window. /// Adjust with `defaults write com.MrKai77.Loop gestureTitlebarHeight -float x` /// Reset with `defaults delete com.MrKai77.Loop gestureTitlebarHeight` - static let gestureTitlebarHeight = Key("gestureTitlebarHeight", default: 32, iCloud: true) + static let gestureTitlebarHeight = Key("gestureTitlebarHeight", default: 50, iCloud: true) // Migrator From 1afb2de2828e236c9446790a7fbb9a4a07d5b207 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 25 Jun 2026 03:32:51 -0600 Subject: [PATCH 37/61] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index d3a9ecc2..a5a17563 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -532,17 +532,15 @@ final class MultitouchTrigger { switch gesture.fingerCount <= 2 ? .titlebar : gesture.activationZone { case .titlebar: let minimumTitlebarHeight = Defaults[.gestureTitlebarHeight] - let titlebarHeight: CGFloat - - if #available(macOS 26, *) { + let titlebarHeight: CGFloat = if #available(macOS 26, *) { if #unavailable(macOS 27), - let cornerRadius = SkyLightToolBelt.getCornerRadii(windowID: window.cgWindowID)?.topLeading { - titlebarHeight = max(2 * cornerRadius, minimumTitlebarHeight) + let cornerRadius = SkyLightToolBelt.getCornerRadii(windowID: window.cgWindowID)?.topLeading { + max(2 * cornerRadius, minimumTitlebarHeight) } else { - titlebarHeight = minimumTitlebarHeight + minimumTitlebarHeight } } else { - titlebarHeight = minimumTitlebarHeight + minimumTitlebarHeight } log.info("Detected titlebar height of \(titlebarHeight)") From e48d6b724e79e932d2f7952f65da941b541ca2cf Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 25 Jun 2026 03:44:43 -0600 Subject: [PATCH 38/61] =?UTF-8?q?=F0=9F=90=9E=20Fix=20gesture=20conflict?= =?UTF-8?q?=20filtering=20for=20disabled=20gestures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 5 +++-- .../Settings/Gestures/GestureItemView.swift | 2 +- .../Window Action/Gesture.swift | 19 +++++-------------- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index a5a17563..578a7526 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -172,8 +172,9 @@ final class MultitouchTrigger { private func rebuildRecognizers() { let allGestures = Defaults[.gestures] - let conflictingIDs = Gesture.conflictingIDs(in: allGestures) - let activeGestures = allGestures.filter { !conflictingIDs.contains($0.id) && !$0.isDisabled } + let enabledGestures = allGestures.filter { !$0.isDisabled } + let conflictingIDs = Gesture.conflictingEnabledIDs(in: allGestures) + let activeGestures = enabledGestures.filter { !conflictingIDs.contains($0.id) } let gesturesByFingerCount = Dictionary(grouping: activeGestures, by: \.fingerCount) let neededFingerCounts = Set(gesturesByFingerCount.keys) diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift index c3aeb6be..142a81aa 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -29,7 +29,7 @@ struct GestureItemView: View { } private var hasConflict: Bool { - Gesture.conflictingIDs(in: gestures).contains(gesture.id) + Gesture.conflictingEnabledIDs(in: gestures).contains(gesture.id) } private var isDisabled: Bool { diff --git a/Loop/Window Management/Window Action/Gesture.swift b/Loop/Window Management/Window Action/Gesture.swift index 663805a3..6ccebd37 100644 --- a/Loop/Window Management/Window Action/Gesture.swift +++ b/Loop/Window Management/Window Action/Gesture.swift @@ -127,6 +127,11 @@ extension Gesture { // MARK: - Conflict Detection extension Gesture { + /// Returns the IDs of enabled gestures that conflict with at least one other enabled gesture. + static func conflictingEnabledIDs(in gestures: [Gesture]) -> Set { + conflictingIDs(in: gestures.filter { !$0.isDisabled }) + } + /// Two gestures conflict when they have the same finger count and their kinds overlap. /// Radial menu consumes both pan and pinch, so it conflicts with ANY other gesture at the same finger count. func conflicts(with other: Gesture) -> Bool { @@ -171,20 +176,6 @@ extension Gesture { kind: .radialMenu, action: .radialMenuActions, activationZone: .titlebar - ), - Gesture( - fingerCount: 2, - kind: .pinch, - action: .singleAction(.custom( - WindowAction( - "\(WindowDirection.maximize.name) + \(WindowDirection.macOSCenter.name)", - cycle: [ - .init(.maximize), - .init(.macOSCenter) - ] - ) - )), - activationZone: .titlebar ) ] } From 4ddad4bd348caf957e88d16baee31bf944a085f6 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 28 Jun 2026 13:30:44 -0600 Subject: [PATCH 39/61] =?UTF-8?q?=F0=9F=9A=9B=20`Gesture`=20->=20`GestureB?= =?UTF-8?q?inding`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Helpers/SystemGestureManager.swift | 6 +- Loop/Core/Observers/MultitouchTrigger.swift | 60 ++--- Loop/Extensions/Defaults+Extensions.swift | 2 +- .../Gestures/GestureConfigPopoverView.swift | 14 +- .../Settings/Gestures/GestureItemView.swift | 8 +- .../Gestures/GestureTrackpadPreview.swift | 249 ++++++++++++++++++ .../Gestures/GesturesConfigurationView.swift | 4 +- .../{Gesture.swift => GestureBinding.swift} | 20 +- 8 files changed, 307 insertions(+), 56 deletions(-) create mode 100644 Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift rename Loop/Window Management/Window Action/{Gesture.swift => GestureBinding.swift} (92%) diff --git a/Loop/Core/Observers/Helpers/SystemGestureManager.swift b/Loop/Core/Observers/Helpers/SystemGestureManager.swift index c365fc1a..573769a5 100644 --- a/Loop/Core/Observers/Helpers/SystemGestureManager.swift +++ b/Loop/Core/Observers/Helpers/SystemGestureManager.swift @@ -195,7 +195,7 @@ final class SystemGestureManager { static func reconcile( enableGestures: Bool, disableConflicts: Bool, - gestures: [Gesture] + gestures: [GestureBinding] ) { var backups = Defaults[.systemGesturePreferenceBackups] var managedValues = Defaults[.systemGestureManagedValues] @@ -215,7 +215,7 @@ final class SystemGestureManager { func reconcile( enableGestures: Bool, disableConflicts: Bool, - gestures: [Gesture], + gestures: [GestureBinding], backups: inout [String: SystemGesturePreferenceValue], managedValues: inout [String: SystemGesturePreferenceValue] ) { @@ -352,7 +352,7 @@ final class SystemGestureManager { } private func desiredValues( - for gestures: [Gesture], + for gestures: [GestureBinding], backups: [String: SystemGesturePreferenceValue], managedValues: [String: SystemGesturePreferenceValue] ) -> [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue] { diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 578a7526..ca4dcc6a 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -53,7 +53,7 @@ final class MultitouchTrigger { var hasActivated = false var hasGestureBegun = false /// The gesture currently driving this stroke. Swapped on direction reversal - var resolvedGesture: Gesture? + var resolvedGesture: GestureBinding? var pendingTargetWindow: Window? var lastCommittedAction: ActionKey? var lastCommitPanDistance: CGFloat = 0 @@ -67,14 +67,14 @@ final class MultitouchTrigger { let recognizer: SubsurfaceGestureRecognizer var task: Task<(), Never>? var state: GestureState - var radialMenuGesture: Gesture? - var directionalGestures: [Gesture] - var pinchGesture: Gesture? - var spreadGesture: Gesture? + var radialMenuGesture: GestureBinding? + var directionalGestures: [GestureBinding] + var pinchGesture: GestureBinding? + var spreadGesture: GestureBinding? static func categorize( - _ gestures: [Gesture] - ) -> (radial: Gesture?, directionals: [Gesture], pinch: Gesture?, spread: Gesture?) { + _ gestures: [GestureBinding] + ) -> (radial: GestureBinding?, directionals: [GestureBinding], pinch: GestureBinding?, spread: GestureBinding?) { let radial = gestures.first { $0.kind == .radialMenu } let directionals = gestures.filter(\.kind.isDirectionalPan) let pinch = gestures.first { $0.kind == .pinch } @@ -173,7 +173,7 @@ final class MultitouchTrigger { private func rebuildRecognizers() { let allGestures = Defaults[.gestures] let enabledGestures = allGestures.filter { !$0.isDisabled } - let conflictingIDs = Gesture.conflictingEnabledIDs(in: allGestures) + let conflictingIDs = GestureBinding.conflictingEnabledIDs(in: allGestures) let activeGestures = enabledGestures.filter { !conflictingIDs.contains($0.id) } let gesturesByFingerCount = Dictionary(grouping: activeGestures, by: \.fingerCount) let neededFingerCounts = Set(gesturesByFingerCount.keys) @@ -200,10 +200,10 @@ final class MultitouchTrigger { private func startRecognizer( for fingerCount: Int, - radial: Gesture?, - directionals: [Gesture], - pinch: Gesture?, - spread: Gesture? + radial: GestureBinding?, + directionals: [GestureBinding], + pinch: GestureBinding?, + spread: GestureBinding? ) { let recognizer = SubsurfaceGestureRecognizer(fingerCount: fingerCount) recognizers[fingerCount] = RecognizerEntry( @@ -256,7 +256,7 @@ final class MultitouchTrigger { private func handleRadialMenuPan( _ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int, - gesture: Gesture + gesture: GestureBinding ) async { switch pan.phase { case .began, .changed: @@ -304,7 +304,7 @@ final class MultitouchTrigger { private func handleDirectionalPan( _ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int, - gesture: Gesture + gesture: GestureBinding ) async { guard let entry = recognizers[fingerCount] else { return } @@ -421,7 +421,7 @@ final class MultitouchTrigger { private func handleRadialMenuPinch( _ pinch: SubsurfaceGestureEvent.PinchEvent, fingerCount: Int, - gesture: Gesture + gesture: GestureBinding ) async { switch pinch.phase { case .began, .changed: @@ -457,7 +457,7 @@ final class MultitouchTrigger { /// Resolves the target window and starts blocking trackpad events. Loop itself /// isn't opened until the gesture crosses the activation threshold in a `.changed` event. - private func handleGestureBegan(fingerCount: Int, gesture: Gesture) { + private func handleGestureBegan(fingerCount: Int, gesture: GestureBinding) { var window = findTargetWindow(for: gesture) if window == nil, resolvedWindowAction(from: gesture)?.canRepeat == true { window = lastRepeatableWindow @@ -522,7 +522,7 @@ final class MultitouchTrigger { recognizers[fingerCount]?.state = GestureState() } - private func findTargetWindow(for gesture: Gesture) -> Window? { + private func findTargetWindow(for gesture: GestureBinding) -> Window? { let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) guard let window = WindowUtility.windowAtPosition(cursorPosition) else { @@ -654,10 +654,10 @@ final class MultitouchTrigger { } private func oppositeDirectionalPanGesture( - of current: Gesture, - in directionals: [Gesture] - ) -> Gesture? { - let opposite: Gesture.Kind? = switch current.kind { + of current: GestureBinding, + in directionals: [GestureBinding] + ) -> GestureBinding? { + let opposite: GestureBinding.Kind? = switch current.kind { case .panUp: .panDown case .panDown: .panUp case .panLeft: .panRight @@ -668,14 +668,14 @@ final class MultitouchTrigger { return directionals.first { $0.kind == opposite } } - private func isCycleAction(_ gesture: Gesture) -> Bool { + private func isCycleAction(_ gesture: GestureBinding) -> Bool { resolvedWindowAction(from: gesture)?.direction == .cycle } private func handlePanReversal( fingerCount: Int, - currentGesture: Gesture, - oppositeGesture: Gesture?, + currentGesture: GestureBinding, + oppositeGesture: GestureBinding?, distance: CGFloat ) { if let oppositeGesture { @@ -698,8 +698,8 @@ final class MultitouchTrigger { private func handlePinchReversal( fingerCount: Int, - currentGesture: Gesture, - oppositeGesture: Gesture?, + currentGesture: GestureBinding, + oppositeGesture: GestureBinding?, distance: CGFloat ) { if let oppositeGesture { @@ -738,7 +738,7 @@ final class MultitouchTrigger { changeAction(resolvedAction, reverse) } - private func resolvedWindowAction(from gesture: Gesture) -> WindowAction? { + private func resolvedWindowAction(from gesture: GestureBinding) -> WindowAction? { guard case let .singleAction(actionType) = gesture.action else { return nil } switch actionType { case let .custom(action): return action @@ -746,7 +746,7 @@ final class MultitouchTrigger { } } - private func triggerSingleAction(from gesture: Gesture, reverse: Bool = false) { + private func triggerSingleAction(from gesture: GestureBinding, reverse: Bool = false) { guard let resolvedAction = resolvedWindowAction(from: gesture) else { return } changeAction(resolvedAction, reverse) } @@ -759,12 +759,12 @@ final class MultitouchTrigger { return Self.failedToResolveKeybindAction } - private func matchDirectionalPanGesture(angle: CGFloat, from gestures: [Gesture]) -> Gesture? { + private func matchDirectionalPanGesture(angle: CGFloat, from gestures: [GestureBinding]) -> GestureBinding? { let angleFromOrigin = .pi / 2 - angle var normalizedAngle = angleFromOrigin if normalizedAngle < 0 { normalizedAngle += 2 * .pi } - let direction: Gesture.Kind = if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { + let direction: GestureBinding.Kind = if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { .panUp } else if normalizedAngle >= .pi / 4, normalizedAngle < 3 * .pi / 4 { .panRight diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 35944066..512035b5 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -72,7 +72,7 @@ extension Defaults.Keys { // Gestures static let enableGestures = Key("enableGestures", default: false, iCloud: true) - static let gestures = Key<[Gesture]>("gestures", default: Gesture.defaults, iCloud: true) + static let gestures = Key<[GestureBinding]>("gestures", default: GestureBinding.defaults, iCloud: true) static let disableConflictingSystemGestures = Key("disableConflictingSystemGestures", default: true, iCloud: true) static let systemGesturePreferenceBackups = Key<[String: SystemGesturePreferenceValue]>("systemGesturePreferenceBackups", default: [:], iCloud: false) static let systemGestureManagedValues = Key<[String: SystemGesturePreferenceValue]>("systemGestureManagedValues", default: [:], iCloud: false) diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift index f2dbd97f..4b82ef45 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift @@ -9,15 +9,15 @@ import Luminare import SwiftUI struct GestureConfigPopoverView: View { - @State private var gesture: Gesture - @Binding private var externalGesture: Gesture + @State private var gesture: GestureBinding + @Binding private var externalGesture: GestureBinding - init(gesture: Binding) { + init(gesture: Binding) { self.gesture = gesture.wrappedValue self._externalGesture = gesture } - private var kindBinding: Binding { + private var kindBinding: Binding { Binding( get: { gesture.kind }, set: { newKind in @@ -35,9 +35,11 @@ struct GestureConfigPopoverView: View { var body: some View { LuminareSection { + GestureTrackpadPreview(gesture: gesture) + LuminareCompose(String(localized: "Gesture Type", comment: "Label in the gesture configuration popover")) { Picker("", selection: kindBinding) { - ForEach(Array(Gesture.Kind.allCases.enumerated()), id: \.element) { _, kind in + ForEach(Array(GestureBinding.Kind.allCases.enumerated()), id: \.element) { _, kind in HStack { kind.image .frame(width: 12) @@ -64,7 +66,7 @@ struct GestureConfigPopoverView: View { LuminareCompose(String(localized: "Activation Zone", comment: "Label for the activation zone picker in the gesture configuration popover")) { Picker("", selection: $gesture.activationZone) { - ForEach(Gesture.ActivationZone.allCases, id: \.self) { zone in + ForEach(GestureBinding.ActivationZone.allCases, id: \.self) { zone in Label(zone.displayName, systemImage: zone.systemImage) .tag(zone) } diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift index 142a81aa..9ce27ea7 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -15,21 +15,21 @@ struct GestureItemView: View { @Default(.keybinds) private var keybinds @Default(.gestures) private var gestures - @State private var gesture: Gesture - @Binding private var externalGesture: Gesture + @State private var gesture: GestureBinding + @Binding private var externalGesture: GestureBinding @State private var isActionPickerPresented = false @State private var isGestureConfigPresented = false @State private var isConfiguringCustom = false @State private var isConfiguringCycle = false - init(_ gesture: Binding) { + init(_ gesture: Binding) { self.gesture = gesture.wrappedValue self._externalGesture = gesture } private var hasConflict: Bool { - Gesture.conflictingEnabledIDs(in: gestures).contains(gesture.id) + GestureBinding.conflictingEnabledIDs(in: gestures).contains(gesture.id) } private var isDisabled: Bool { diff --git a/Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift b/Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift new file mode 100644 index 00000000..d0129f2b --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift @@ -0,0 +1,249 @@ +// +// GestureTrackpadPreview.swift +// Loop +// +// Created by Kai Azim on 2026-07-02. +// + +import SwiftUI + +struct GestureTrackpadPreview: View { + let gesture: GestureBinding + + private let trackpadCornerRadius: CGFloat = 16 + private let trackpadInset: CGFloat = 13 + private let movementDistance: CGFloat = 30 + private let previewDuration = 1.9 + private let loopPauseDuration = 0.25 + private let radialMenuSegmentCount = 6 + + var body: some View { + TimelineView(.animation) { context in + GeometryReader { proxy in + let rect = CGRect(origin: .zero, size: proxy.size) + let usableRect = rect.insetBy(dx: trackpadInset, dy: trackpadInset) + let sample = GesturePreviewSample( + kind: gesture.kind, + elapsedTime: elapsedTime(for: context.date), + previewDuration: previewDuration + ) + let fingerRadius = 8.0 + let opacity = opacity(for: sample.localPhase) + let progress = movementProgress(for: sample.localPhase) + + ZStack { + trackpadShape + + ForEach(Array(fingerPositions( + in: usableRect, + sample: sample, + progress: progress, + fingerRadius: fingerRadius + ).enumerated()), id: \.offset) { _, point in + fingerDot(radius: fingerRadius) + .position(point) + } + .opacity(opacity) + } + } + } + .aspectRatio(16.0 / 9.0, contentMode: .fill) + .accessibilityHidden(true) + } + + private var trackpadShape: some View { + RoundedRectangle(cornerRadius: trackpadCornerRadius) + .fill( + LinearGradient( + colors: [ + Color(nsColor: .controlBackgroundColor).opacity(0.92), + Color(nsColor: .windowBackgroundColor).opacity(0.68) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay { + RoundedRectangle(cornerRadius: trackpadCornerRadius) + .strokeBorder(.white.opacity(0.16), lineWidth: 1) + } + .overlay { + RoundedRectangle(cornerRadius: trackpadCornerRadius - 4) + .strokeBorder(.black.opacity(0.2), lineWidth: 1) + .padding(3) + } + .shadow(color: .black.opacity(0.2), radius: 8, y: 3) + } + + private func fingerDot(radius: CGFloat) -> some View { + Circle() + .foregroundStyle(.tint.opacity(0.25)) + .background(.secondary, in: .circle) + .frame(width: radius * 2, height: radius * 2) + } + + private func fingerPositions( + in rect: CGRect, + sample: GesturePreviewSample, + progress: CGFloat, + fingerRadius: CGFloat + ) -> [CGPoint] { + FingerLayout.positions(count: gesture.fingerCount).map { normalizedPoint in + let basePoint = point(from: normalizedPoint, in: rect) + let animatedPoint = basePoint + sample.offset( + for: normalizedPoint, + progress: progress, + distance: movementDistance + ) + return clamp(animatedPoint, to: rect, padding: fingerRadius + 1) + } + } + + private func point(from normalizedPoint: CGPoint, in rect: CGRect) -> CGPoint { + CGPoint( + x: rect.midX + normalizedPoint.x * rect.width * 0.5, + y: rect.midY + normalizedPoint.y * rect.height * 0.5 + ) + } + + private func clamp(_ point: CGPoint, to rect: CGRect, padding: CGFloat) -> CGPoint { + CGPoint( + x: min(max(point.x, rect.minX + padding), rect.maxX - padding), + y: min(max(point.y, rect.minY + padding), rect.maxY - padding) + ) + } + + private func elapsedTime(for date: Date) -> TimeInterval { + let activeDuration = gesture.kind == .radialMenu ? previewDuration * Double(radialMenuSegmentCount) : previewDuration + let loopDuration = activeDuration + loopPauseDuration + return date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: loopDuration) + } + + private func opacity(for phase: CGFloat) -> CGFloat { + let fadeInEnd = CGFloat(0.35 / 1.9) + let moveEnd = CGFloat(1.2 / 1.9) + let fadeOutEnd = CGFloat(1.45 / 1.9) + + if phase < fadeInEnd { + return easeOut(phase / fadeInEnd) + } else if phase < moveEnd { + return 1 + } else if phase < fadeOutEnd { + return 1 - easeInOut((phase - moveEnd) / (fadeOutEnd - moveEnd)) + } else { + return 0 + } + } + + private func movementProgress(for phase: CGFloat) -> CGFloat { + let fadeInEnd = CGFloat(0.35 / 1.9) + let moveEnd = CGFloat(1.2 / 1.9) + + guard phase >= fadeInEnd else { return 0 } + guard phase < moveEnd else { return 1 } + return easeInOut((phase - fadeInEnd) / (moveEnd - fadeInEnd)) + } + + private func easeOut(_ value: CGFloat) -> CGFloat { + 1 - pow(1 - min(max(value, 0), 1), 3) + } + + private func easeInOut(_ value: CGFloat) -> CGFloat { + let value = min(max(value, 0), 1) + return value < 0.5 + ? 4 * value * value * value + : 1 - pow(-2 * value + 2, 3) / 2 + } +} + +private struct GesturePreviewSample { + let kind: GestureBinding.Kind + let localPhase: CGFloat + + init(kind: GestureBinding.Kind, elapsedTime: TimeInterval, previewDuration: TimeInterval) { + if kind == .radialMenu { + let radialMenuPreviewKinds: [GestureBinding.Kind] = [ + .pinch, + .spread, + .panUp, + .panDown, + .panLeft, + .panRight + ] + let activeElapsedTime = min(elapsedTime, previewDuration * Double(radialMenuPreviewKinds.count)) + let segmentProgress = activeElapsedTime / previewDuration + let segment = min(Int(segmentProgress), radialMenuPreviewKinds.count - 1) + self.kind = radialMenuPreviewKinds[segment] + self.localPhase = CGFloat(segmentProgress - Double(segment)) + } else { + self.kind = kind + self.localPhase = CGFloat(min(elapsedTime, previewDuration) / previewDuration) + } + } + + func offset(for normalizedPoint: CGPoint, progress: CGFloat, distance: CGFloat) -> CGSize { + let pinchSpreadDistance = distance * 1.2 + + return switch kind { + case .radialMenu: + CGSize(width: distance * progress, height: 0) + case .panUp: + CGSize(width: 0, height: -distance * progress) + case .panDown: + CGSize(width: 0, height: distance * progress) + case .panLeft: + CGSize(width: -distance * progress, height: 0) + case .panRight: + CGSize(width: distance * progress, height: 0) + case .pinch: + CGSize( + width: -normalizedPoint.x * pinchSpreadDistance * progress, + height: -normalizedPoint.y * pinchSpreadDistance * progress + ) + case .spread: + CGSize( + width: normalizedPoint.x * pinchSpreadDistance * progress, + height: normalizedPoint.y * pinchSpreadDistance * progress + ) + } + } +} + +private enum FingerLayout { + static func positions(count: Int) -> [CGPoint] { + switch count { + case 2: + [ + CGPoint(x: -0.28, y: 0.34), + CGPoint(x: 0.28, y: -0.34) + ] + case 3: + [ + CGPoint(x: -0.42, y: 0.12), + CGPoint(x: 0, y: -0.16), + CGPoint(x: 0.42, y: 0.12) + ] + case 4: + [ + CGPoint(x: -0.56, y: 0.18), + CGPoint(x: -0.18, y: -0.18), + CGPoint(x: 0.18, y: -0.12), + CGPoint(x: 0.56, y: 0.22) + ] + case 5: + [ + CGPoint(x: -0.68, y: 0.42), + CGPoint(x: -0.46, y: 0.08), + CGPoint(x: -0.14, y: -0.20), + CGPoint(x: 0.22, y: -0.12), + CGPoint(x: 0.58, y: 0.22) + ] + default: + [] + } + } +} + +private func + (point: CGPoint, size: CGSize) -> CGPoint { + CGPoint(x: point.x + size.width, y: point.y + size.height) +} diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index 069c7777..34d43b62 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -10,7 +10,7 @@ import Luminare import SwiftUI final class GesturesConfigurationModel: ObservableObject { - @Published var selectedGestures = Set() + @Published var selectedGestures = Set() } struct GesturesConfigurationView: View { @@ -53,7 +53,7 @@ struct GesturesConfigurationView: View { LuminareButtonRow { Button(String(localized: "Add", comment: "Button to add a new gesture")) { gestures.insert( - Gesture(), + GestureBinding(), at: 0 ) } diff --git a/Loop/Window Management/Window Action/Gesture.swift b/Loop/Window Management/Window Action/GestureBinding.swift similarity index 92% rename from Loop/Window Management/Window Action/Gesture.swift rename to Loop/Window Management/Window Action/GestureBinding.swift index 6ccebd37..9c5baa3b 100644 --- a/Loop/Window Management/Window Action/Gesture.swift +++ b/Loop/Window Management/Window Action/GestureBinding.swift @@ -1,5 +1,5 @@ // -// Gesture.swift +// GestureBinding.swift // Loop // // Created by Kai Azim on 2026-04-16. @@ -8,7 +8,7 @@ import Defaults import SwiftUI -struct Gesture: Identifiable, Codable, Hashable, Defaults.Serializable { +struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { let id: UUID var fingerCount: Int var kind: Kind @@ -111,7 +111,7 @@ struct Gesture: Identifiable, Codable, Hashable, Defaults.Serializable { // MARK: - Disabled state -extension Gesture { +extension GestureBinding { /// True when this gesture's action resolves to `noAction`. Disabled gestures /// are skipped at runtime and rendered greyed-out in settings. var isDisabled: Bool { @@ -126,15 +126,15 @@ extension Gesture { // MARK: - Conflict Detection -extension Gesture { +extension GestureBinding { /// Returns the IDs of enabled gestures that conflict with at least one other enabled gesture. - static func conflictingEnabledIDs(in gestures: [Gesture]) -> Set { + static func conflictingEnabledIDs(in gestures: [GestureBinding]) -> Set { conflictingIDs(in: gestures.filter { !$0.isDisabled }) } /// Two gestures conflict when they have the same finger count and their kinds overlap. /// Radial menu consumes both pan and pinch, so it conflicts with ANY other gesture at the same finger count. - func conflicts(with other: Gesture) -> Bool { + func conflicts(with other: GestureBinding) -> Bool { guard id != other.id, fingerCount == other.fingerCount else { return false } @@ -153,7 +153,7 @@ extension Gesture { } /// Returns the IDs of all gestures that conflict with at least one other gesture in the array. - static func conflictingIDs(in gestures: [Gesture]) -> Set { + static func conflictingIDs(in gestures: [GestureBinding]) -> Set { var result = Set() for i in gestures.indices { for j in (i + 1) ..< gestures.count { @@ -169,9 +169,9 @@ extension Gesture { // MARK: - Defaults -extension Gesture { - static let defaults: [Gesture] = [ - Gesture( +extension GestureBinding { + static let defaults: [GestureBinding] = [ + GestureBinding( fingerCount: 2, kind: .radialMenu, action: .radialMenuActions, From 2e593d2233e694179fb92dda00c6c41bd5f5ed50 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 2 Jul 2026 02:38:42 -0600 Subject: [PATCH 40/61] =?UTF-8?q?=E2=9C=A8=20Refine=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Localizable.xcstrings | 8 ++++---- .../Settings/Gestures/GestureItemView.swift | 2 +- Loop/Window Management/Window Action/GestureBinding.swift | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index de91445b..d03a4cc5 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -1353,6 +1353,9 @@ } } }, + "All" : { + "comment" : "Gesture kind: all gesture types; opens the radial menu" + }, "Allow radial menu customization" : { "comment" : "A toggle that allows users to enable or disable the ability to customize the actions available in the radial menu.", "localizations" : { @@ -23587,9 +23590,6 @@ } } }, - "Open Radial Menu" : { - "comment" : "Label shown for a gesture configured to open the radial menu" - }, "Options" : { "comment" : "Section header shown in settings", "localizations" : { @@ -25191,7 +25191,7 @@ } }, "Radial Menu" : { - "comment" : "Gesture kind: opens the radial menu via swipe/pinch/spread\nSection header shown in settings", + "comment" : "Label shown for a gesture configured to open the radial menu\nSection header shown in settings", "localizations" : { "ar" : { "stringUnit" : { diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift index 9ce27ea7..f32e0c64 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -186,7 +186,7 @@ struct GestureItemView: View { if case .radialMenuActions = gesture.action { HStack(spacing: 4) { Image(.loop) - Text(String(localized: "Open Radial Menu", comment: "Label shown for a gesture configured to open the radial menu")) + Text(String(localized: "Radial Menu", comment: "Label shown for a gesture configured to open the radial menu")) .fontWeight(.regular) .lineLimit(1) } diff --git a/Loop/Window Management/Window Action/GestureBinding.swift b/Loop/Window Management/Window Action/GestureBinding.swift index 9c5baa3b..0efa675e 100644 --- a/Loop/Window Management/Window Action/GestureBinding.swift +++ b/Loop/Window Management/Window Action/GestureBinding.swift @@ -41,7 +41,7 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { var displayName: String { switch self { - case .radialMenu: String(localized: "Radial Menu", comment: "Gesture kind: opens the radial menu via swipe/pinch/spread") + case .radialMenu: String(localized: "All", comment: "Gesture kind: all gesture types; opens the radial menu") case .panUp: String(localized: "Swipe Up", comment: "Gesture kind: directional swipe") case .panDown: String(localized: "Swipe Down", comment: "Gesture kind: directional swipe") case .panLeft: String(localized: "Swipe Left", comment: "Gesture kind: directional swipe") From 2b0b8631da9aff7c5385bd1a62c2803574b20e64 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 2 Jul 2026 03:16:24 -0600 Subject: [PATCH 41/61] =?UTF-8?q?=E2=9C=A8=20Respect=20`hideOnNoSelection`?= =?UTF-8?q?=20user=20pref?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 81 ++++++++++++++------- Loop/Localizable.xcstrings | 2 +- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index ca4dcc6a..fbace389 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -27,13 +27,14 @@ final class MultitouchTrigger { private var systemGestureReconciliationTask: Task<(), Never>? private var isStarted = false - /// Window most recently targeted by a `canRepeat` gesture gesture. - /// Allows the user keep shrinking/growing a window after the cursor has fallen off its (now smaller) frame. + /// 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 let panCycleStepSize: CGFloat = 0.15 - private let zoomActivationThreshold: CGFloat = 0.3 - private let zoomCycleStepSize: CGFloat = 0.15 + private let pinchActivationThreshold: CGFloat = 0.3 + private let pinchCycleStepSize: CGFloat = 0.15 + private let cardinalBiasedRadialMenuActionCount = 8 private var radialMenuActions: [RadialMenuAction] { RadialMenuAction.userConfiguredActions @@ -178,13 +179,11 @@ final class MultitouchTrigger { let gesturesByFingerCount = Dictionary(grouping: activeGestures, by: \.fingerCount) let neededFingerCounts = Set(gesturesByFingerCount.keys) - // Remove stale recognizers for fingerCount in Array(recognizers.keys) where !neededFingerCounts.contains(fingerCount) { stopRecognizer(for: fingerCount) recognizers.removeValue(forKey: fingerCount) } - // Add new recognizers or refresh cached gestures on existing ones. for (fingerCount, gestures) in gesturesByFingerCount { let (radial, directionals, pinch, spread) = RecognizerEntry.categorize(gestures) if recognizers[fingerCount] == nil { @@ -225,8 +224,12 @@ final class MultitouchTrigger { await handlePan(pan, fingerCount: fingerCount) case let .pinch(pinch): await handlePinch(pinch, fingerCount: fingerCount) - case .determining, .rotation: - break + 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) } } } @@ -260,23 +263,18 @@ final class MultitouchTrigger { ) async { switch pan.phase { case .began, .changed: - if pan.phase == .began { + if pan.phase == .began, recognizers[fingerCount]?.state.hasGestureBegun != true { handleGestureBegan(fingerCount: fingerCount, gesture: gesture) } guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } - // Subsurface emits y-up angles (counterclockwise from +x); the radial - // menu wants 0 = up, growing clockwise. Mirror via `pi/2 - angle`. - let angleFromOrigin = .pi / 2 - pan.angle - var normalizedAngle = angleFromOrigin - if normalizedAngle < 0 { normalizedAngle += 2 * .pi } - + let normalizedAngle = normalizedAngle(fromSubsurfaceAngle: pan.angle) let actions = radialMenuActions.dropLast() guard actions.count > 1 else { return } let newIndex: Int - if actions.count == 8 { + if actions.count == cardinalBiasedRadialMenuActionCount { newIndex = indexWithCardinalBias(angle: normalizedAngle, actionCount: actions.count) } else { let actionAngleSpan = (.pi * 2) / CGFloat(actions.count) @@ -310,7 +308,7 @@ final class MultitouchTrigger { switch pan.phase { case .began, .changed: - if pan.phase == .began { + if pan.phase == .began, recognizers[fingerCount]?.state.hasGestureBegun != true { handleGestureBegan(fingerCount: fingerCount, gesture: gesture) recognizers[fingerCount]?.state.resolvedGesture = gesture } @@ -425,7 +423,7 @@ final class MultitouchTrigger { ) async { switch pinch.phase { case .began, .changed: - if pinch.phase == .began { + if pinch.phase == .began, recognizers[fingerCount]?.state.hasGestureBegun != true { handleGestureBegan(fingerCount: fingerCount, gesture: gesture) } guard await activateGestureIfNeeded( @@ -476,12 +474,35 @@ final class MultitouchTrigger { var state = GestureState() state.pendingTargetWindow = window + state.hasGestureBegun = true // Loop is already on screen, so no activation threshold to cross. state.hasActivated = loopWasAlreadyOpen recognizers[fingerCount]?.state = state gestureBlocker.start() } + private func handleEarlyRadialMenuGesture( + phase: SubsurfaceGesturePhase, + fingerCount: Int + ) async { + guard !Defaults[.hideOnNoSelection], + let gesture = recognizers[fingerCount]?.radialMenuGesture else { return } + + switch phase { + case .determining, .began, .changed: + if recognizers[fingerCount]?.state.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`. Pinch and spread gestures /// gate on their respective activation thresholds; pan gestures activate on the first /// `.began` event Subsurface emits. @@ -492,7 +513,7 @@ final class MultitouchTrigger { guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return false } if state.hasActivated { return true } - if let pinchDisplacement, abs(pinchDisplacement) < zoomActivationThreshold { + if let pinchDisplacement, abs(pinchDisplacement) < pinchActivationThreshold { return false } @@ -602,11 +623,11 @@ final class MultitouchTrigger { } let delta = distance - state.lastCommitPinchDistance - if delta >= zoomCycleStepSize { + if delta >= pinchCycleStepSize { state.lastCommitPinchDistance = distance recognizers[fingerCount]?.state = state fire(false) - } else if delta <= -zoomCycleStepSize { + } else if delta <= -pinchCycleStepSize { state.lastCommitPinchDistance = distance recognizers[fingerCount]?.state = state fire(true) @@ -631,11 +652,11 @@ final class MultitouchTrigger { } let delta = (distance - state.lastCommitPinchDistance) * CGFloat(state.pinchDirection) - if delta >= zoomCycleStepSize { + if delta >= pinchCycleStepSize { state.lastCommitPinchDistance = distance recognizers[fingerCount]?.state = state fire(false) - } else if delta <= -zoomCycleStepSize { + } else if delta <= -pinchCycleStepSize { state.lastCommitPinchDistance = distance recognizers[fingerCount]?.state = state fire(true) @@ -650,7 +671,7 @@ final class MultitouchTrigger { private func pinchReversalDetected(_ state: GestureState, distance: CGFloat) -> Bool { guard state.lastCommittedAction != nil, state.pinchDirection != 0 else { return false } let delta = (distance - state.lastCommitPinchDistance) * CGFloat(state.pinchDirection) - return delta <= -zoomCycleStepSize + return delta <= -pinchCycleStepSize } private func oppositeDirectionalPanGesture( @@ -760,9 +781,7 @@ final class MultitouchTrigger { } private func matchDirectionalPanGesture(angle: CGFloat, from gestures: [GestureBinding]) -> GestureBinding? { - let angleFromOrigin = .pi / 2 - angle - var normalizedAngle = angleFromOrigin - if normalizedAngle < 0 { normalizedAngle += 2 * .pi } + let normalizedAngle = normalizedAngle(fromSubsurfaceAngle: angle) let direction: GestureBinding.Kind = if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { .panUp @@ -777,6 +796,14 @@ final class MultitouchTrigger { return gestures.first { $0.kind == direction } } + 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 diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index d03a4cc5..5e98cc11 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -36033,4 +36033,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} From 7685c2815375e319fff0966a2bf8c332f9425bdd Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 4 Jul 2026 21:39:15 -0600 Subject: [PATCH 42/61] =?UTF-8?q?=E2=9C=A8=20Set=20`requiresExactFingerCou?= =?UTF-8?q?ntToContinue`=20to=20true?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index fbace389..3d5cbbe9 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -204,7 +204,11 @@ final class MultitouchTrigger { pinch: GestureBinding?, spread: GestureBinding? ) { - let recognizer = SubsurfaceGestureRecognizer(fingerCount: fingerCount) + let recognizer = SubsurfaceGestureRecognizer( + fingerCount: fingerCount, + requiresExactFingerCountToContinue: true + ) + recognizers[fingerCount] = RecognizerEntry( recognizer: recognizer, task: nil, From 35716fe2362242715883bc166c1f3a417d292b83 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 4 Jul 2026 22:30:01 -0600 Subject: [PATCH 43/61] =?UTF-8?q?=E2=9C=A8=20Ability=20to=20disable=20iClo?= =?UTF-8?q?ud=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also helps to avoid a startup race where multiple lazily initialized `iCloud: true` keys could concurrently mutate Defaults’ internal iCloud observation set and crash in `Set.insert`. --- Loop/App/AppDelegate.swift | 1 + Loop/Extensions/Defaults+Extensions.swift | 240 +++++++++++++++------- 2 files changed, 171 insertions(+), 70 deletions(-) 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/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 512035b5..418f31b8 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -7,97 +7,98 @@ // 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, iCloud: true) - static let gestures = Key<[GestureBinding]>("gestures", default: GestureBinding.defaults, iCloud: true) - static let disableConflictingSystemGestures = Key("disableConflictingSystemGestures", default: true, iCloud: true) + static let enableGestures = Key("enableGestures", default: false) + static let gestures = Key<[GestureBinding]>("gestures", default: GestureBinding.defaults) + static let disableConflictingSystemGestures = Key("disableConflictingSystemGestures", default: true) static let systemGesturePreferenceBackups = Key<[String: SystemGesturePreferenceValue]>("systemGesturePreferenceBackups", default: [:], iCloud: false) static let systemGestureManagedValues = Key<[String: SystemGesturePreferenceValue]>("systemGestureManagedValues", default: [:], iCloud: false) // Advanced - static let useSystemWindowManagerWhenAvailable = Key("useSystemWindowManagerWhenAvailable", default: false, iCloud: true) - static let animateWindowResizes = Key("animateWindowResizes", default: false, iCloud: true) - 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 @@ -106,28 +107,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` @@ -136,24 +137,33 @@ 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, iCloud: true) + static let gestureTitlebarHeight = Key("gestureTitlebarHeight", default: 50) + /// Whether to sync all Loop settings to iCloud. + /// Adjust with `defaults write com.MrKai77.Loop enableiCloudSync -b 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) @@ -169,9 +179,99 @@ 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(.disableConflictingSystemGestures) + + 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) + } +} From 372024589ba376a5cfb39ec2fb7a9f50ef29c9cf Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 4 Jul 2026 22:31:47 -0600 Subject: [PATCH 44/61] =?UTF-8?q?=F0=9F=92=84=20Improve=20gesture=20config?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Gestures/GestureConfigPopoverView.swift | 72 +++++++++---------- .../Settings/Gestures/GestureItemView.swift | 2 +- .../Gestures/GestureTrackpadPreview.swift | 20 +----- 3 files changed, 39 insertions(+), 55 deletions(-) diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift index 4b82ef45..751fb49f 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift @@ -34,52 +34,52 @@ struct GestureConfigPopoverView: View { } var body: some View { - LuminareSection { + VStack(spacing: 12) { GestureTrackpadPreview(gesture: gesture) - - LuminareCompose(String(localized: "Gesture Type", comment: "Label in the gesture configuration popover")) { - Picker("", selection: kindBinding) { - ForEach(Array(GestureBinding.Kind.allCases.enumerated()), id: \.element) { _, kind in - HStack { - kind.image - .frame(width: 12) - - Text(kind.displayName) + + LuminareSection { + LuminareCompose(String(localized: "Gesture Type", comment: "Label in the gesture configuration popover")) { + Picker("", selection: kindBinding) { + ForEach(Array(GestureBinding.Kind.allCases.enumerated()), id: \.element) { _, kind in + HStack { + kind.image + .frame(width: 12) + + Text(kind.displayName) + } + .tag(kind) } - .tag(kind) } + .labelsHidden() } - .labelsHidden() - } - - LuminareCompose(String(localized: "Fingers", comment: "Label for the finger-count stepper in the gesture configuration popover")) { - HStack { - Text("\(gesture.fingerCount)") - - Stepper("", value: $gesture.fingerCount, in: 2...5) - .labelsHidden() - .onChange(of: gesture.fingerCount) { count in - if count <= 2 { gesture.activationZone = .titlebar } - } + + LuminareCompose(String(localized: "Fingers", comment: "Label for the finger-count stepper in the gesture configuration popover")) { + HStack { + Text("\(gesture.fingerCount)") + + Stepper("", value: $gesture.fingerCount, in: 2...5) + .labelsHidden() + .onChange(of: gesture.fingerCount) { count in + if count <= 2 { gesture.activationZone = .titlebar } + } + } } - } - - LuminareCompose(String(localized: "Activation Zone", comment: "Label for the activation zone picker in the gesture configuration popover")) { - Picker("", selection: $gesture.activationZone) { - ForEach(GestureBinding.ActivationZone.allCases, id: \.self) { zone in - Label(zone.displayName, systemImage: zone.systemImage) - .tag(zone) + + LuminareCompose(String(localized: "Activation Zone", comment: "Label for the activation zone picker in the gesture configuration popover")) { + Picker("", selection: $gesture.activationZone) { + ForEach(GestureBinding.ActivationZone.allCases, id: \.self) { zone in + Label(zone.displayName, systemImage: zone.systemImage) + .tag(zone) + } } + .labelsHidden() + .disabled(gesture.fingerCount <= 2) } - .labelsHidden() - .disabled(gesture.fingerCount <= 2) + .help(gesture.fingerCount <= 2 ? String(localized: "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures.", comment: "Help text shown when the activation zone picker is disabled for 2-finger gestures") : "") } - .help(gesture.fingerCount <= 2 ? String(localized: "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures.", comment: "Help text shown when the activation zone picker is disabled for 2-finger gestures") : "") + .luminareCornerRadius(16) } - .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: gesture) { externalGesture = $0 } - .luminareFilledStates(.none) - .luminareBorderedStates(.none) .padding(8) } } diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift index f32e0c64..8e1e1dc6 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -158,7 +158,7 @@ struct GestureItemView: View { shouldAnimate: false ) { GestureConfigPopoverView(gesture: $gesture) - .frame(width: 300) + .frame(width: 320) } } diff --git a/Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift b/Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift index 23d3495d..e0c139a5 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift @@ -47,31 +47,15 @@ struct GestureTrackpadPreview: View { } } .aspectRatio(16.0 / 9.0, contentMode: .fill) - .accessibilityHidden(true) } private var trackpadShape: some View { RoundedRectangle(cornerRadius: trackpadCornerRadius) - .fill( - LinearGradient( - colors: [ - Color(nsColor: .controlBackgroundColor).opacity(0.92), - Color(nsColor: .windowBackgroundColor).opacity(0.68) - ], - startPoint: .top, - endPoint: .bottom - ) - ) + .foregroundStyle(.black.opacity(0.05)) .overlay { RoundedRectangle(cornerRadius: trackpadCornerRadius) - .strokeBorder(.white.opacity(0.16), lineWidth: 1) - } - .overlay { - RoundedRectangle(cornerRadius: trackpadCornerRadius - 4) - .strokeBorder(.black.opacity(0.2), lineWidth: 1) - .padding(3) + .strokeBorder(.tertiary, lineWidth: 1) } - .shadow(color: .black.opacity(0.2), radius: 8, y: 3) } private func fingerDot(radius: CGFloat) -> some View { From 4b13e5dd7b16208a048d622370974ab43c2f1cb8 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 16:09:35 -0600 Subject: [PATCH 45/61] =?UTF-8?q?=F0=9F=92=84=20Refine=20gesture=20configu?= =?UTF-8?q?ration=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Localizable.xcstrings | 49 +-- .../Gestures/GestureConfigPopoverView.swift | 85 ----- .../Gestures/GestureConfigurationView.swift | 324 ++++++++++++++++++ .../Settings/Gestures/GestureItemView.swift | 38 +- .../Gestures/GestureTrackpadPreview.swift | 235 ------------- .../Gestures/GesturesConfigurationView.swift | 2 +- .../Window Action/GestureBinding.swift | 32 +- 7 files changed, 393 insertions(+), 372 deletions(-) delete mode 100644 Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift create mode 100644 Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift delete mode 100644 Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 5e98cc11..4194bb74 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -625,7 +625,7 @@ } }, "%lld-finger %@" : { - "comment" : "Label describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Pinch', 'Swipe Up').", + "comment" : "Default title describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Pinch', 'Swipe Up').", "localizations" : { "en" : { "stringUnit" : { @@ -635,11 +635,8 @@ } } }, - "%lld-finger Swipe, Pinch, or Spread" : { - "comment" : "Label describing how to activate a radial menu gesture. Argument is the finger count." - }, - "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures." : { - "comment" : "Help text shown when the activation zone picker is disabled for 2-finger gestures" + "%lld-finger Swipe or Zoom" : { + "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.", @@ -1172,9 +1169,6 @@ } } }, - "Activation Zone" : { - "comment" : "Label for the activation zone picker in the gesture configuration popover" - }, "Add" : { "comment" : "Button to add a new gesture", "localizations" : { @@ -1353,9 +1347,6 @@ } } }, - "All" : { - "comment" : "Gesture kind: all gesture types; opens the radial menu" - }, "Allow radial menu customization" : { "comment" : "A toggle that allows users to enable or disable the ability to customize the actions available in the radial menu.", "localizations" : { @@ -2419,6 +2410,9 @@ } } }, + "Both" : { + "comment" : "Gesture kind: swipe and zoom gestures; opens the radial menu\nGesture tab label: swipe and zoom gestures" + }, "Bottom" : { "comment" : "Label for a slider in Loop’s padding settings", "localizations" : { @@ -5885,6 +5879,9 @@ } } }, + "Direction" : { + "comment" : "Label for the swipe direction picker in the gesture configuration modal" + }, "Disable conflicting system gestures" : { "comment" : "Toggle in gestures settings" }, @@ -6240,6 +6237,9 @@ } } }, + "Down" : { + "comment" : "Swipe direction picker option: down" + }, "Enable blur" : { "comment" : "Text for enabling the blur effect on the preview.", "localizations" : { @@ -6329,7 +6329,7 @@ } } }, - "Enable gestures" : { + "Enable trackpad gestures" : { "comment" : "Toggle in gestures settings" }, "Enable window snapping" : { @@ -8110,8 +8110,8 @@ } } }, - "Gesture Type" : { - "comment" : "Label in the gesture configuration popover" + "Gesture" : { + "comment" : "Label for the zoom gesture picker in the gesture configuration modal" }, "Gestures" : { "comment" : "Section header shown in gestures settings" @@ -13160,7 +13160,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" : { @@ -23946,7 +23946,7 @@ } }, "Pinch" : { - "comment" : "Gesture kind: fingers move together" + "comment" : "Gesture kind: fingers move together\nZoom gesture picker option: pinch" }, "Please include at least one modifier key." : { "comment" : "An error message displayed when a custom keybind is created but does not include at least one modifier key.", @@ -26342,7 +26342,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" : { @@ -30427,7 +30427,7 @@ } }, "Spread" : { - "comment" : "Gesture kind: fingers move apart" + "comment" : "Gesture kind: fingers move apart\nZoom gesture picker option: spread" }, "Stage Manager" : { "comment" : "Section header shown in settings", @@ -30961,6 +30961,9 @@ } } }, + "Swipe" : { + "comment" : "Gesture tab label: swipe" + }, "Swipe Down" : { "comment" : "Gesture kind: directional swipe" }, @@ -33638,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" : { @@ -36030,7 +36036,10 @@ } } } + }, + "Zoom" : { + "comment" : "Gesture tab label: zoom" } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift deleted file mode 100644 index 751fb49f..00000000 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigPopoverView.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// GestureConfigPopoverView.swift -// Loop -// -// Created by Kai Azim on 2026-04-16. -// - -import Luminare -import SwiftUI - -struct GestureConfigPopoverView: View { - @State private var gesture: GestureBinding - @Binding private var externalGesture: GestureBinding - - init(gesture: Binding) { - self.gesture = gesture.wrappedValue - self._externalGesture = gesture - } - - private var kindBinding: Binding { - Binding( - get: { gesture.kind }, - set: { newKind in - let oldKind = gesture.kind - gesture.kind = newKind - - if newKind == .radialMenu { - gesture.action = .radialMenuActions - } else if oldKind == .radialMenu { - gesture.action = .singleAction(.custom(.init(.noAction))) - } - } - ) - } - - var body: some View { - VStack(spacing: 12) { - GestureTrackpadPreview(gesture: gesture) - - LuminareSection { - LuminareCompose(String(localized: "Gesture Type", comment: "Label in the gesture configuration popover")) { - Picker("", selection: kindBinding) { - ForEach(Array(GestureBinding.Kind.allCases.enumerated()), id: \.element) { _, kind in - HStack { - kind.image - .frame(width: 12) - - Text(kind.displayName) - } - .tag(kind) - } - } - .labelsHidden() - } - - LuminareCompose(String(localized: "Fingers", comment: "Label for the finger-count stepper in the gesture configuration popover")) { - HStack { - Text("\(gesture.fingerCount)") - - Stepper("", value: $gesture.fingerCount, in: 2...5) - .labelsHidden() - .onChange(of: gesture.fingerCount) { count in - if count <= 2 { gesture.activationZone = .titlebar } - } - } - } - - LuminareCompose(String(localized: "Activation Zone", comment: "Label for the activation zone picker in the gesture configuration popover")) { - Picker("", selection: $gesture.activationZone) { - ForEach(GestureBinding.ActivationZone.allCases, id: \.self) { zone in - Label(zone.displayName, systemImage: zone.systemImage) - .tag(zone) - } - } - .labelsHidden() - .disabled(gesture.fingerCount <= 2) - } - .help(gesture.fingerCount <= 2 ? String(localized: "2-finger gestures are restricted to the titlebar to avoid conflicting with system gestures.", comment: "Help text shown when the activation zone picker is disabled for 2-finger gestures") : "") - } - .luminareCornerRadius(16) - } - .onChange(of: gesture) { externalGesture = $0 } - .padding(8) - } -} diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift new file mode 100644 index 00000000..412d3261 --- /dev/null +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift @@ -0,0 +1,324 @@ +// +// 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 tabBinding: Binding { + Binding( + get: { GestureTab(kind: gesture.kind) }, + set: { tab in + switch tab { + case .all: + kindBinding.wrappedValue = .radialMenu + case .swipe: + if !gesture.kind.isDirectionalPan { + kindBinding.wrappedValue = .panRight + } + case .zoom: + kindBinding.wrappedValue = .pinch + } + } + ) + } + + private var swipeDirectionBinding: Binding { + Binding( + get: { SwipeDirection(kind: gesture.kind) ?? .right }, + set: { kindBinding.wrappedValue = $0.kind } + ) + } + + private var zoomGestureBinding: Binding { + Binding( + get: { ZoomGesture(kind: gesture.kind) ?? .pinch }, + 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: GestureTab.allCases, + selection: tabBinding + ) { tab in + HStack(spacing: 6) { + tab.image + .frame(width: 12) + + Text(tab.displayName) + } + .fixedSize() + } + .luminareContentSize(hasFixedHeight: true) + .luminareRoundingBehavior(top: true, bottom: gesture.kind == .radialMenu) + + if GestureTab(kind: gesture.kind) == .swipe { + LuminareCompose(String(localized: "Direction", comment: "Label for the swipe direction picker in the gesture configuration modal")) { + Picker("", selection: swipeDirectionBinding) { + ForEach(SwipeDirection.allCases, id: \.self) { direction in + Label { + Text(direction.displayName) + } icon: { + direction.image + } + .tag(direction) + } + } + .labelsHidden() + .buttonStyle(.plain) + } + } + + if GestureTab(kind: gesture.kind) == .zoom { + LuminareCompose(String(localized: "Gesture", comment: "Label for the zoom gesture picker in the gesture configuration modal")) { + Picker("", selection: zoomGestureBinding) { + ForEach(ZoomGesture.allCases, id: \.self) { zoomGesture in + Label { + Text(zoomGesture.displayName) + } icon: { + zoomGesture.image + } + .tag(zoomGesture) + } + } + .labelsHidden() + .buttonStyle(.plain) + } + } + } + .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 GestureTab: CaseIterable, Equatable { + case all + case swipe + case zoom + + init(kind: GestureBinding.Kind) { + switch kind { + case .radialMenu: + self = .all + case .panUp, .panDown, .panLeft, .panRight: + self = .swipe + case .pinch, .spread: + self = .zoom + } + } + + var displayName: String { + switch self { + case .all: + String(localized: "Both", comment: "Gesture tab label: swipe and zoom gestures") + case .swipe: + String(localized: "Swipe", comment: "Gesture tab label: swipe") + case .zoom: + String(localized: "Zoom", comment: "Gesture tab label: zoom") + } + } + + var image: Image { + switch self { + case .all: + Image(.loop) + case .swipe: + Image(systemName: "arrow.up.and.down.and.arrow.left.and.right") + case .zoom: + 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 .panUp: + self = .up + case .panDown: + self = .down + case .panLeft: + self = .left + case .panRight: + self = .right + case .radialMenu, .pinch, .spread: + return nil + } + } + + var kind: GestureBinding.Kind { + switch self { + case .up: + .panUp + case .down: + .panDown + case .left: + .panLeft + case .right: + .panRight + } + } + + 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 ZoomGesture: CaseIterable { + case pinch + case spread + + init?(kind: GestureBinding.Kind) { + switch kind { + case .pinch: + self = .pinch + case .spread: + self = .spread + case .radialMenu, .panUp, .panDown, .panLeft, .panRight: + return nil + } + } + + var kind: GestureBinding.Kind { + switch self { + case .pinch: + .pinch + case .spread: + .spread + } + } + + var displayName: String { + switch self { + case .pinch: + String(localized: "Pinch", comment: "Zoom gesture picker option: pinch") + case .spread: + String(localized: "Spread", comment: "Zoom gesture picker option: spread") + } + } + + var image: Image { + kind.image + } +} diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift index 8e1e1dc6..336c6c85 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -32,25 +32,6 @@ struct GestureItemView: View { GestureBinding.conflictingEnabledIDs(in: gestures).contains(gesture.id) } - private var isDisabled: Bool { - gesture.isDisabled - } - - private var gestureConfigurationText: String { - switch gesture.kind { - case .radialMenu: - String( - localized: "\(gesture.fingerCount)-finger Swipe, Pinch, or Spread", - comment: "Label describing how to activate a radial menu gesture. Argument is the finger count." - ) - default: - String( - localized: "\(gesture.fingerCount)-finger \(gesture.kind.displayName)", - comment: "Label describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Pinch', 'Swipe Up')." - ) - } - } - private var resolvedAction: WindowAction? { switch gesture.action { case .radialMenuActions: @@ -113,7 +94,6 @@ struct GestureItemView: View { actionSelection .frame(maxWidth: .infinity, alignment: .trailing) } - .opacity(isDisabled ? 0.5 : 1) .padding(.horizontal, 12) .onChange(of: resolvedAction?.direction) { _ in if resolvedAction?.direction.isCustomizable == true { @@ -131,7 +111,7 @@ struct GestureItemView: View { isGestureConfigPresented = true } label: { VStack(alignment: .leading, spacing: 2) { - Text(gestureConfigurationText) + Text(gesture.displayName) .fontWeight(.regular) .lineLimit(1) @@ -150,16 +130,14 @@ struct GestureItemView: View { .opacity(hasConflict ? 0.5 : 1) .help(String(localized: "Customize this gesture.", comment: "Help text shown when hovering a gesture configuration button")) .padding(.leading, -4) - .luminarePopover( - isPresented: $isGestureConfigPresented, - arrowEdge: .top, - attachmentAnchor: .topLeading, - shouldHideAnchor: true, - shouldAnimate: false - ) { - GestureConfigPopoverView(gesture: $gesture) - .frame(width: 320) + .luminareModal(isPresented: $isGestureConfigPresented) { + GestureConfigurationView( + gesture: $gesture, + isPresented: $isGestureConfigPresented + ) + .frame(width: 380) } + .luminareModalCornerRadius(24) } private var actionSelection: some View { diff --git a/Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift b/Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift deleted file mode 100644 index e0c139a5..00000000 --- a/Loop/Settings Window/Settings/Gestures/GestureTrackpadPreview.swift +++ /dev/null @@ -1,235 +0,0 @@ -// -// GestureTrackpadPreview.swift -// Loop -// -// Created by Kai Azim on 2026-07-02. -// - -import SwiftUI - -struct GestureTrackpadPreview: View { - let gesture: GestureBinding - - private let trackpadCornerRadius: CGFloat = 16 - private let trackpadInset: CGFloat = 13 - private let movementDistance: CGFloat = 30 - private let previewDuration: CGFloat = 1.9 - private let loopPauseDuration: CGFloat = 0.25 - - var body: some View { - TimelineView(.animation) { context in - GeometryReader { proxy in - let rect = CGRect(origin: .zero, size: proxy.size) - let usableRect = rect.insetBy(dx: trackpadInset, dy: trackpadInset) - let sample = GesturePreviewSample( - kind: gesture.kind, - elapsedTime: elapsedTime(for: context.date), - previewDuration: previewDuration - ) - let fingerRadius = 8.0 - let opacity = opacity(for: sample.localPhase) - let progress = movementProgress(for: sample.localPhase) - - ZStack { - trackpadShape - - ForEach(Array(fingerPositions( - in: usableRect, - sample: sample, - progress: progress, - fingerRadius: fingerRadius - ).enumerated()), id: \.offset) { _, point in - fingerDot(radius: fingerRadius) - .position(point) - } - .opacity(opacity) - } - } - } - .aspectRatio(16.0 / 9.0, contentMode: .fill) - } - - private var trackpadShape: some View { - RoundedRectangle(cornerRadius: trackpadCornerRadius) - .foregroundStyle(.black.opacity(0.05)) - .overlay { - RoundedRectangle(cornerRadius: trackpadCornerRadius) - .strokeBorder(.tertiary, lineWidth: 1) - } - } - - private func fingerDot(radius: CGFloat) -> some View { - Circle() - .foregroundStyle(.tint.opacity(0.25)) - .background(.secondary, in: .circle) - .frame(width: radius * 2, height: radius * 2) - } - - private func fingerPositions( - in rect: CGRect, - sample: GesturePreviewSample, - progress: CGFloat, - fingerRadius: CGFloat - ) -> [CGPoint] { - FingerLayout.positions(count: gesture.fingerCount).map { normalizedPoint in - let basePoint = point(from: normalizedPoint, in: rect) - let animatedPoint = basePoint + sample.offset( - for: normalizedPoint, - progress: progress, - distance: movementDistance - ) - return clamp(animatedPoint, to: rect, padding: fingerRadius + 1) - } - } - - private func point(from normalizedPoint: CGPoint, in rect: CGRect) -> CGPoint { - CGPoint( - x: rect.midX + normalizedPoint.x * rect.width * 0.5, - y: rect.midY + normalizedPoint.y * rect.height * 0.5 - ) - } - - private func clamp(_ point: CGPoint, to rect: CGRect, padding: CGFloat) -> CGPoint { - CGPoint( - x: min(max(point.x, rect.minX + padding), rect.maxX - padding), - y: min(max(point.y, rect.minY + padding), rect.maxY - padding) - ) - } - - private func elapsedTime(for date: Date) -> TimeInterval { - let activeDuration = gesture.kind == .radialMenu - ? previewDuration * Double(GesturePreviewSample.radialMenuPreviewKinds.count) - : previewDuration - let loopDuration = activeDuration + loopPauseDuration - return date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: loopDuration) - } - - private func opacity(for phase: CGFloat) -> CGFloat { - let fadeInEnd = CGFloat(0.35 / previewDuration) - let moveEnd = CGFloat(1.2 / previewDuration) - let fadeOutEnd = CGFloat(1.45 / previewDuration) - - if phase < fadeInEnd { - return easeOut(phase / fadeInEnd) - } else if phase < moveEnd { - return 1 - } else if phase < fadeOutEnd { - return 1 - easeInOut((phase - moveEnd) / (fadeOutEnd - moveEnd)) - } else { - return 0 - } - } - - private func movementProgress(for phase: CGFloat) -> CGFloat { - let fadeInEnd = CGFloat(0.35 / previewDuration) - let moveEnd = CGFloat(1.2 / previewDuration) - - guard phase >= fadeInEnd else { return 0 } - guard phase < moveEnd else { return 1 } - return easeInOut((phase - fadeInEnd) / (moveEnd - fadeInEnd)) - } - - private func easeOut(_ value: CGFloat) -> CGFloat { - 1 - pow(1 - min(max(value, 0), 1), 3) - } - - private func easeInOut(_ value: CGFloat) -> CGFloat { - let value = min(max(value, 0), 1) - return value < 0.5 - ? 4 * value * value * value - : 1 - pow(-2 * value + 2, 3) / 2 - } -} - -private struct GesturePreviewSample { - static let radialMenuPreviewKinds: [GestureBinding.Kind] = [ - .pinch, - .spread, - .panUp, - .panDown, - .panLeft, - .panRight - ] - - let kind: GestureBinding.Kind - let localPhase: CGFloat - - init(kind: GestureBinding.Kind, elapsedTime: TimeInterval, previewDuration: TimeInterval) { - if kind == .radialMenu { - let activeElapsedTime = min(elapsedTime, previewDuration * Double(Self.radialMenuPreviewKinds.count)) - let segmentProgress = activeElapsedTime / previewDuration - let segment = min(Int(segmentProgress), Self.radialMenuPreviewKinds.count - 1) - self.kind = Self.radialMenuPreviewKinds[segment] - self.localPhase = CGFloat(segmentProgress - Double(segment)) - } else { - self.kind = kind - self.localPhase = CGFloat(min(elapsedTime, previewDuration) / previewDuration) - } - } - - func offset(for normalizedPoint: CGPoint, progress: CGFloat, distance: CGFloat) -> CGSize { - let pinchSpreadDistance = distance * 1.2 - - return switch kind { - case .radialMenu: - CGSize(width: distance * progress, height: 0) - case .panUp: - CGSize(width: 0, height: -distance * progress) - case .panDown: - CGSize(width: 0, height: distance * progress) - case .panLeft: - CGSize(width: -distance * progress, height: 0) - case .panRight: - CGSize(width: distance * progress, height: 0) - case .pinch: - CGSize( - width: -normalizedPoint.x * pinchSpreadDistance * progress, - height: -normalizedPoint.y * pinchSpreadDistance * progress - ) - case .spread: - CGSize( - width: normalizedPoint.x * pinchSpreadDistance * progress, - height: normalizedPoint.y * pinchSpreadDistance * progress - ) - } - } -} - -private enum FingerLayout { - static func positions(count: Int) -> [CGPoint] { - switch count { - case 2: - [ - CGPoint(x: -0.28, y: 0.34), - CGPoint(x: 0.28, y: -0.34) - ] - case 3: - [ - CGPoint(x: -0.42, y: 0.12), - CGPoint(x: 0, y: -0.16), - CGPoint(x: 0.42, y: 0.12) - ] - case 4: - [ - CGPoint(x: -0.56, y: 0.18), - CGPoint(x: -0.18, y: -0.18), - CGPoint(x: 0.18, y: -0.12), - CGPoint(x: 0.56, y: 0.22) - ] - case 5: - [ - CGPoint(x: -0.68, y: 0.42), - CGPoint(x: -0.46, y: 0.08), - CGPoint(x: -0.14, y: -0.20), - CGPoint(x: 0.22, y: -0.12), - CGPoint(x: 0.58, y: 0.22) - ] - default: - [] - } - } -} - -private func + (point: CGPoint, size: CGSize) -> CGPoint { - CGPoint(x: point.x + size.width, y: point.y + size.height) -} diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index 34d43b62..314eb19b 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -40,7 +40,7 @@ struct GesturesConfigurationView: View { private var settingsSection: some View { LuminareSection { - LuminareToggle(String(localized: "Enable gestures", comment: "Toggle in gestures settings"), isOn: $enableGestures) + LuminareToggle(String(localized: "Enable trackpad gestures", comment: "Toggle in gestures settings"), isOn: $enableGestures) if enableGestures { LuminareToggle(String(localized: "Disable conflicting system gestures", comment: "Toggle in gestures settings"), isOn: $disableConflictingSystemGestures) diff --git a/Loop/Window Management/Window Action/GestureBinding.swift b/Loop/Window Management/Window Action/GestureBinding.swift index 0efa675e..77246c5c 100644 --- a/Loop/Window Management/Window Action/GestureBinding.swift +++ b/Loop/Window Management/Window Action/GestureBinding.swift @@ -10,6 +10,7 @@ import SwiftUI struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { let id: UUID + var name: String? var fingerCount: Int var kind: Kind var action: Action @@ -17,12 +18,14 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { 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 @@ -41,7 +44,7 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { var displayName: String { switch self { - case .radialMenu: String(localized: "All", comment: "Gesture kind: all gesture types; opens the radial menu") + case .radialMenu: String(localized: "Both", comment: "Gesture kind: swipe and zoom gestures; opens the radial menu") case .panUp: String(localized: "Swipe Up", comment: "Gesture kind: directional swipe") case .panDown: String(localized: "Swipe Down", comment: "Gesture kind: directional swipe") case .panLeft: String(localized: "Swipe Left", comment: "Gesture kind: directional swipe") @@ -109,6 +112,33 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { } } +// MARK: - Naming + +extension GestureBinding { + var defaultName: String { + switch kind { + case .radialMenu: + String( + localized: "\(fingerCount)-finger Swipe or Zoom", + 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. 'Pinch', 'Swipe Up')." + ) + } + } + + var displayName: String { + if let name, !name.isEmpty { + return name + } + + return defaultName + } +} + // MARK: - Disabled state extension GestureBinding { From c286c2a6fc5bea43539713ee0bdc88a168884939 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 16:14:45 -0600 Subject: [PATCH 46/61] =?UTF-8?q?=E2=9C=A8=20Hide=20"Disable=20conflicting?= =?UTF-8?q?=20system=20gestures"=20setting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Extensions/Defaults+Extensions.swift | 9 ++++++--- Loop/Localizable.xcstrings | 3 --- .../Settings/Gestures/GesturesConfigurationView.swift | 5 ----- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 418f31b8..fac19bd6 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -74,7 +74,6 @@ extension Defaults.Keys { // Gestures static let enableGestures = Key("enableGestures", default: false) static let gestures = Key<[GestureBinding]>("gestures", default: GestureBinding.defaults) - static let disableConflictingSystemGestures = Key("disableConflictingSystemGestures", default: true) static let systemGesturePreferenceBackups = Key<[String: SystemGesturePreferenceValue]>("systemGesturePreferenceBackups", default: [:], iCloud: false) static let systemGestureManagedValues = Key<[String: SystemGesturePreferenceValue]>("systemGestureManagedValues", default: [:], iCloud: false) @@ -155,8 +154,13 @@ extension Defaults.Keys { /// 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 -b FALSE` + /// 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) } @@ -261,7 +265,6 @@ enum DefaultsiCloudSyncRegistrar { Defaults.iCloud.add(.enableGestures) Defaults.iCloud.add(.gestures) - Defaults.iCloud.add(.disableConflictingSystemGestures) Defaults.iCloud.add(.useSystemWindowManagerWhenAvailable) Defaults.iCloud.add(.animateWindowResizes) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 4194bb74..94f5d07c 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -5882,9 +5882,6 @@ "Direction" : { "comment" : "Label for the swipe direction picker in the gesture configuration modal" }, - "Disable conflicting system gestures" : { - "comment" : "Toggle in gestures settings" - }, "Disable cursor interaction" : { "localizations" : { "ar" : { diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index 314eb19b..3df8a299 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -18,7 +18,6 @@ struct GesturesConfigurationView: View { @StateObject private var model = GesturesConfigurationModel() @Default(.enableGestures) private var enableGestures - @Default(.disableConflictingSystemGestures) private var disableConflictingSystemGestures @Default(.gestures) private var gestures @Default(.gestureTitlebarHeight) private var gestureTitlebarHeight @@ -41,10 +40,6 @@ struct GesturesConfigurationView: View { private var settingsSection: some View { LuminareSection { LuminareToggle(String(localized: "Enable trackpad gestures", comment: "Toggle in gestures settings"), isOn: $enableGestures) - - if enableGestures { - LuminareToggle(String(localized: "Disable conflicting system gestures", comment: "Toggle in gestures settings"), isOn: $disableConflictingSystemGestures) - } } } From dacb030d854225fbab73e5d421b98ef417aed79a Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 16:50:50 -0600 Subject: [PATCH 47/61] =?UTF-8?q?=E2=9C=A8=20Show=20`noAction`=20actions?= =?UTF-8?q?=20in=20gestures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 7 +++---- .../Settings/Gestures/GestureItemView.swift | 2 +- .../Window Action/GestureBinding.swift | 12 +++++------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index 3d5cbbe9..e27340cd 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -114,7 +114,7 @@ final class MultitouchTrigger { rebuildRecognizers() gesturesObservationTask = Task { [weak self] in - // Watch keybinds too, so gestures referencing a deleted or no-action keybind get filtered out by `rebuildRecognizers` + // Watch keybinds too, so gestures referencing a deleted keybind stay in sync. for await _ in Defaults.updates(.gestures, .keybinds) { guard !Task.isCancelled, let self else { break } rebuildRecognizers() @@ -173,9 +173,8 @@ final class MultitouchTrigger { private func rebuildRecognizers() { let allGestures = Defaults[.gestures] - let enabledGestures = allGestures.filter { !$0.isDisabled } - let conflictingIDs = GestureBinding.conflictingEnabledIDs(in: allGestures) - let activeGestures = enabledGestures.filter { !conflictingIDs.contains($0.id) } + let conflictingIDs = GestureBinding.conflictingActionableIDs(in: allGestures) + let activeGestures = allGestures.filter { !conflictingIDs.contains($0.id) } let gesturesByFingerCount = Dictionary(grouping: activeGestures, by: \.fingerCount) let neededFingerCounts = Set(gesturesByFingerCount.keys) diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift index 336c6c85..10b744e7 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -29,7 +29,7 @@ struct GestureItemView: View { } private var hasConflict: Bool { - GestureBinding.conflictingEnabledIDs(in: gestures).contains(gesture.id) + GestureBinding.conflictingActionableIDs(in: gestures).contains(gesture.id) } private var resolvedAction: WindowAction? { diff --git a/Loop/Window Management/Window Action/GestureBinding.swift b/Loop/Window Management/Window Action/GestureBinding.swift index 77246c5c..561ac00f 100644 --- a/Loop/Window Management/Window Action/GestureBinding.swift +++ b/Loop/Window Management/Window Action/GestureBinding.swift @@ -139,12 +139,10 @@ extension GestureBinding { } } -// MARK: - Disabled state +// MARK: - No Action extension GestureBinding { - /// True when this gesture's action resolves to `noAction`. Disabled gestures - /// are skipped at runtime and rendered greyed-out in settings. - var isDisabled: Bool { + var resolvesToNoAction: Bool { switch action { case .radialMenuActions: false @@ -157,9 +155,9 @@ extension GestureBinding { // MARK: - Conflict Detection extension GestureBinding { - /// Returns the IDs of enabled gestures that conflict with at least one other enabled gesture. - static func conflictingEnabledIDs(in gestures: [GestureBinding]) -> Set { - conflictingIDs(in: gestures.filter { !$0.isDisabled }) + /// 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. From 4ff025caf66d22bb58aec7c7dd301727db2d9f42 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 20:07:24 -0600 Subject: [PATCH 48/61] =?UTF-8?q?=E2=9C=A8=20Fix=20gesture=20reversal=20ha?= =?UTF-8?q?ndling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/MultitouchTrigger.swift | 193 ++++++++++++++------ 1 file changed, 135 insertions(+), 58 deletions(-) diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Observers/MultitouchTrigger.swift index e27340cd..3617eec6 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Observers/MultitouchTrigger.swift @@ -254,8 +254,17 @@ final class MultitouchTrigger { if let radialMenuGesture = entry.radialMenuGesture { await handleRadialMenuPan(pan, fingerCount: fingerCount, gesture: radialMenuGesture) - } else if let directionalGesture = matchDirectionalPanGesture(angle: pan.angle, from: entry.directionalGestures) { - await handleDirectionalPan(pan, fingerCount: fingerCount, gesture: directionalGesture) + } else { + let direction = directionalPanKind(angle: pan.angle) + let directionalGesture = matchDirectionalPanGesture(kind: direction, from: entry.directionalGestures) + if directionalGesture != nil || entry.state.hasGestureBegun { + await handleDirectionalPan( + pan, + fingerCount: fingerCount, + direction: direction, + matchedGesture: directionalGesture + ) + } } } @@ -305,28 +314,37 @@ final class MultitouchTrigger { private func handleDirectionalPan( _ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int, - gesture: GestureBinding + direction: GestureBinding.Kind, + matchedGesture: GestureBinding? ) async { guard let entry = recognizers[fingerCount] else { return } switch pan.phase { case .began, .changed: - if pan.phase == .began, recognizers[fingerCount]?.state.hasGestureBegun != true { - handleGestureBegan(fingerCount: fingerCount, gesture: gesture) - recognizers[fingerCount]?.state.resolvedGesture = gesture + if recognizers[fingerCount]?.state.hasGestureBegun != true { + guard let matchedGesture else { return } + handleGestureBegan(fingerCount: fingerCount, gesture: matchedGesture) + recognizers[fingerCount]?.state.resolvedGesture = matchedGesture } + guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } - - let activeGesture = state.resolvedGesture ?? gesture - - if panReversalDetected(state, distance: pan.distance) { - handlePanReversal( - fingerCount: fingerCount, - currentGesture: activeGesture, - oppositeGesture: oppositeDirectionalPanGesture(of: activeGesture, in: entry.directionalGestures), - distance: pan.distance - ) + guard let activeGesture = state.resolvedGesture ?? matchedGesture else { return } + + if direction != activeGesture.kind { + if let matchedGesture { + switchPanGesture(fingerCount: fingerCount, to: matchedGesture, distance: pan.distance) + } else if direction == oppositeDirectionalPanKind(of: activeGesture.kind) { + handlePanReversal( + fingerCount: fingerCount, + currentGesture: activeGesture, + oppositeGesture: oppositeDirectionalPanGesture(of: activeGesture, in: entry.directionalGestures), + distance: pan.distance + ) + } else { + cancelPanGesture(fingerCount: fingerCount) + } return } @@ -357,18 +375,16 @@ final class MultitouchTrigger { } switch pinch.phase { - case .began: - // Direction unknown, reset state but defer handleGestureBegan until first .changed - recognizers[fingerCount]?.state = GestureState() - - case .changed: - guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + case .began, .changed: + if pinch.phase == .began, + recognizers[fingerCount]?.state.hasGestureBegun != true { + recognizers[fingerCount]?.state = GestureState() + } + guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } if !state.hasGestureBegun { let initialGesture = pinch.distance >= pinch.originDistance ? entry.spreadGesture : entry.pinchGesture guard let initialGesture else { - state.isGestureRejected = true - recognizers[fingerCount]?.state = state return } handleGestureBegan(fingerCount: fingerCount, gesture: initialGesture) @@ -377,14 +393,19 @@ final class MultitouchTrigger { } guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected, - let activeGesture = state.resolvedGesture else { return } + let activeGesture = state.resolvedGesture else { + return + } guard await activateGestureIfNeeded( fingerCount: fingerCount, - pinchDisplacement: pinch.distance - pinch.originDistance - ) else { return } + pinchDisplacement: pinch.distance - pinch.originDistance, + pinchActivationThreshold: pinchCycleStepSize + ) else { + return + } guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } - if pinchReversalDetected(state, distance: pinch.distance) { + if pinchReversalDetected(currentGesture: activeGesture, pinch: pinch) { let opposite = activeGesture.kind == .pinch ? entry.spreadGesture : entry.pinchGesture handlePinchReversal( fingerCount: fingerCount, @@ -400,7 +421,8 @@ final class MultitouchTrigger { distance: pinch.distance, originDistance: pinch.originDistance, newKey: .gesture(activeGesture.id), - fingerCount: fingerCount + fingerCount: fingerCount, + canRepeat: canRepeatGestureAction(activeGesture) ) { reverse in triggerSingleAction(from: activeGesture, reverse: reverse) } @@ -511,12 +533,14 @@ final class MultitouchTrigger { /// `.began` event Subsurface emits. private func activateGestureIfNeeded( fingerCount: Int, - pinchDisplacement: CGFloat? = nil + pinchDisplacement: CGFloat? = nil, + pinchActivationThreshold: CGFloat? = nil ) async -> Bool { guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return false } if state.hasActivated { return true } - if let pinchDisplacement, abs(pinchDisplacement) < pinchActivationThreshold { + if let pinchDisplacement, + abs(pinchDisplacement) < (pinchActivationThreshold ?? self.pinchActivationThreshold) { return false } @@ -537,9 +561,9 @@ final class MultitouchTrigger { return true } - private func resetLoopState(for fingerCount: Int) { + private func resetLoopState(for fingerCount: Int, forceClose: Bool = false) { if recognizers[fingerCount]?.state.didOpenLoopWithThisGesture == true { - closeCallback(false) + closeCallback(forceClose) } gestureBlocker.stop() @@ -643,6 +667,7 @@ final class MultitouchTrigger { originDistance: CGFloat, newKey: ActionKey, fingerCount: Int, + canRepeat: Bool, fire: (_ reverse: Bool) -> () ) { if state.lastCommittedAction != newKey { @@ -654,6 +679,8 @@ final class MultitouchTrigger { return } + guard canRepeat else { return } + let delta = (distance - state.lastCommitPinchDistance) * CGFloat(state.pinchDirection) if delta >= pinchCycleStepSize { state.lastCommitPinchDistance = distance @@ -666,36 +693,80 @@ final class MultitouchTrigger { } } - private func panReversalDetected(_ state: GestureState, distance: CGFloat) -> Bool { - state.lastCommittedAction != nil - && (distance - state.lastCommitPanDistance) <= -panCycleStepSize - } - - private func pinchReversalDetected(_ state: GestureState, distance: CGFloat) -> Bool { - guard state.lastCommittedAction != nil, state.pinchDirection != 0 else { return false } - let delta = (distance - state.lastCommitPinchDistance) * CGFloat(state.pinchDirection) - return delta <= -pinchCycleStepSize + private func pinchReversalDetected( + currentGesture: GestureBinding, + pinch: SubsurfaceGestureEvent.PinchEvent + ) -> Bool { + switch currentGesture.kind { + case .pinch: + return pinch.distance >= pinch.originDistance + case .spread: + return pinch.distance <= pinch.originDistance + default: + return false + } } private func oppositeDirectionalPanGesture( of current: GestureBinding, in directionals: [GestureBinding] ) -> GestureBinding? { - let opposite: GestureBinding.Kind? = switch current.kind { - case .panUp: .panDown - case .panDown: .panUp - case .panLeft: .panRight - case .panRight: .panLeft - default: nil - } - guard let opposite else { return nil } + guard let opposite = oppositeDirectionalPanKind(of: current.kind) else { return nil } return directionals.first { $0.kind == opposite } } + private func oppositeDirectionalPanKind(of kind: GestureBinding.Kind) -> GestureBinding.Kind? { + switch kind { + case .panUp: + return .panDown + case .panDown: + return .panUp + case .panLeft: + return .panRight + case .panRight: + return .panLeft + default: + return nil + } + } + private func isCycleAction(_ gesture: GestureBinding) -> Bool { resolvedWindowAction(from: gesture)?.direction == .cycle } + private func canRepeatGestureAction(_ gesture: GestureBinding) -> Bool { + guard let action = resolvedWindowAction(from: gesture) else { return false } + return action.canRepeat || action.direction == .cycle + } + + private func switchPanGesture( + fingerCount: Int, + to gesture: GestureBinding, + distance: CGFloat + ) { + guard var state = recognizers[fingerCount]?.state else { return } + state.resolvedGesture = gesture + state.lastCommittedAction = .gesture(gesture.id) + state.lastCommitPanDistance = distance + recognizers[fingerCount]?.state = state + triggerSingleAction(from: gesture, reverse: false) + + if let window = state.pendingTargetWindow, + resolvedWindowAction(from: gesture)?.canRepeat == true { + lastRepeatableWindow = window + } + } + + private func cancelPanGesture(fingerCount: Int) { + resetLoopState(for: fingerCount, forceClose: true) + } + + private func cancelPinchGesture( + fingerCount: Int + ) { + resetLoopState(for: fingerCount, forceClose: true) + } + private func handlePanReversal( fingerCount: Int, currentGesture: GestureBinding, @@ -717,6 +788,8 @@ final class MultitouchTrigger { } else if isCycleAction(currentGesture) { triggerSingleAction(from: currentGesture, reverse: true) recognizers[fingerCount]?.state.lastCommitPanDistance = distance + } else { + cancelPanGesture(fingerCount: fingerCount) } } @@ -745,6 +818,8 @@ final class MultitouchTrigger { } else if isCycleAction(currentGesture) { triggerSingleAction(from: currentGesture, reverse: true) recognizers[fingerCount]?.state.lastCommitPinchDistance = distance + } else { + cancelPinchGesture(fingerCount: fingerCount) } } @@ -783,20 +858,22 @@ final class MultitouchTrigger { return Self.failedToResolveKeybindAction } - private func matchDirectionalPanGesture(angle: CGFloat, from gestures: [GestureBinding]) -> GestureBinding? { + private func matchDirectionalPanGesture(kind: GestureBinding.Kind, from gestures: [GestureBinding]) -> GestureBinding? { + gestures.first { $0.kind == kind } + } + + private func directionalPanKind(angle: CGFloat) -> GestureBinding.Kind { let normalizedAngle = normalizedAngle(fromSubsurfaceAngle: angle) - let direction: GestureBinding.Kind = if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { - .panUp + if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { + return .panUp } else if normalizedAngle >= .pi / 4, normalizedAngle < 3 * .pi / 4 { - .panRight + return .panRight } else if normalizedAngle >= 3 * .pi / 4, normalizedAngle < 5 * .pi / 4 { - .panDown + return .panDown } else { - .panLeft + return .panLeft } - - return gestures.first { $0.kind == direction } } private func normalizedAngle(fromSubsurfaceAngle angle: CGFloat) -> CGFloat { From 503950553e7cb71629b5eb1d2ef488f7796e9e63 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 20:18:00 -0600 Subject: [PATCH 49/61] =?UTF-8?q?=E2=9C=A8=20Modularize=20`MultitouchTrigg?= =?UTF-8?q?er`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MultitouchGestureBlocker.swift | 0 .../Multitouch/MultitouchGestureSession.swift | 174 +++++++ .../MultitouchRecognizerRegistry.swift | 140 ++++++ .../Multitouch/MultitouchTargetResolver.swift | 69 +++ .../MultitouchTrigger.swift | 438 ++++-------------- .../SystemGestureManager.swift | 0 6 files changed, 483 insertions(+), 338 deletions(-) rename Loop/Core/{Observers/Helpers => Multitouch}/MultitouchGestureBlocker.swift (100%) create mode 100644 Loop/Core/Multitouch/MultitouchGestureSession.swift create mode 100644 Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift create mode 100644 Loop/Core/Multitouch/MultitouchTargetResolver.swift rename Loop/Core/{Observers => Multitouch}/MultitouchTrigger.swift (55%) rename Loop/Core/{Observers/Helpers => Multitouch}/SystemGestureManager.swift (100%) diff --git a/Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift b/Loop/Core/Multitouch/MultitouchGestureBlocker.swift similarity index 100% rename from Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift rename to Loop/Core/Multitouch/MultitouchGestureBlocker.swift diff --git a/Loop/Core/Multitouch/MultitouchGestureSession.swift b/Loop/Core/Multitouch/MultitouchGestureSession.swift new file mode 100644 index 00000000..1ad1ccf5 --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchGestureSession.swift @@ -0,0 +1,174 @@ +// +// 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 lastCommitPanDistance: CGFloat = 0 + private var lastCommitPinchDistance: CGFloat = 0 + private var pinchDirection: Int = 0 + + func reset() { + didOpenLoopWithThisGesture = false + isGestureRejected = false + hasActivated = false + hasGestureBegun = false + resolvedGesture = nil + pendingTargetWindow = nil + lastCommittedAction = nil + lastCommitPanDistance = 0 + lastCommitPinchDistance = 0 + pinchDirection = 0 + } + + func begin( + gesture _: GestureBinding, + 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 commitPan( + distance: CGFloat, + newKey: ActionKey, + step: CGFloat, + fire: (_ reverse: Bool) -> () + ) { + if lastCommittedAction == newKey { + let delta = distance - lastCommitPanDistance + if delta >= step { + lastCommitPanDistance = distance + fire(false) + } else if delta <= -step { + lastCommitPanDistance = distance + fire(true) + } + } else { + lastCommittedAction = newKey + lastCommitPanDistance = distance + fire(false) + } + } + + func commitRadialPinch( + distance: CGFloat, + originDistance: CGFloat, + step: CGFloat, + fire: (_ reverse: Bool) -> () + ) { + if lastCommittedAction != .radialCenter { + lastCommittedAction = .radialCenter + lastCommitPinchDistance = distance + fire(distance < originDistance) + return + } + + let delta = distance - lastCommitPinchDistance + if delta >= step { + lastCommitPinchDistance = distance + fire(false) + } else if delta <= -step { + lastCommitPinchDistance = distance + fire(true) + } + } + + func commitPinch( + distance: CGFloat, + originDistance: CGFloat, + newKey: ActionKey, + step: CGFloat, + canRepeat: Bool, + fire: (_ reverse: Bool) -> () + ) { + if lastCommittedAction != newKey { + pinchDirection = distance >= originDistance ? 1 : -1 + lastCommittedAction = newKey + lastCommitPinchDistance = distance + fire(false) + return + } + + guard canRepeat else { return } + + let delta = (distance - lastCommitPinchDistance) * CGFloat(pinchDirection) + if delta >= step { + lastCommitPinchDistance = distance + fire(false) + } else if delta <= -step { + lastCommitPinchDistance = distance + fire(true) + } + } + + func setResolvedGesture(_ gesture: GestureBinding) { + resolvedGesture = gesture + } + + func switchPanGesture(to gesture: GestureBinding, distance: CGFloat) { + resolvedGesture = gesture + lastCommittedAction = .gesture(gesture.id) + lastCommitPanDistance = distance + } + + func switchPinchGesture(to gesture: GestureBinding, distance: CGFloat, direction: Int) { + resolvedGesture = gesture + lastCommittedAction = .gesture(gesture.id) + pinchDirection = direction + lastCommitPinchDistance = distance + } + + func updateLastCommitPanDistance(_ distance: CGFloat) { + lastCommitPanDistance = distance + } + + func updateLastCommitPinchDistance(_ distance: CGFloat) { + lastCommitPinchDistance = distance + } +} diff --git a/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift b/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift new file mode 100644 index 00000000..fc8ab6b1 --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift @@ -0,0 +1,140 @@ +// +// 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 pinchGesture: GestureBinding? + var spreadGesture: GestureBinding? + + static func categorize( + _ gestures: [GestureBinding] + ) -> (radial: GestureBinding?, directionals: [GestureBinding], pinch: GestureBinding?, spread: GestureBinding?) { + let radial = gestures.first { $0.kind == .radialMenu } + let directionals = gestures.filter(\.kind.isDirectionalPan) + let pinch = gestures.first { $0.kind == .pinch } + let spread = gestures.first { $0.kind == .spread } + return (radial, directionals, pinch, spread) + } + } + + 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, pinch, spread) = Entry.categorize(gestures) + if entries[fingerCount] == nil { + startRecognizer(for: fingerCount, radial: radial, directionals: directionals, pinch: pinch, spread: spread) + } else { + entries[fingerCount]?.radialMenuGesture = radial + entries[fingerCount]?.directionalGestures = directionals + entries[fingerCount]?.pinchGesture = pinch + entries[fingerCount]?.spreadGesture = spread + } + } + + 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], + pinch: GestureBinding?, + spread: GestureBinding? + ) { + let recognizer = SubsurfaceGestureRecognizer( + fingerCount: fingerCount, + requiresExactFingerCountToContinue: true + ) + + entries[fingerCount] = Entry( + recognizer: recognizer, + session: MultitouchGestureSession(), + task: nil, + radialMenuGesture: radial, + directionalGestures: directionals, + pinchGesture: pinch, + spreadGesture: spread + ) + + let task = Task { [weak self] in + guard let self else { return } + for await event in recognizer.events(from: gestureMonitor) { + guard !Task.isCancelled else { break } + 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) + } +} diff --git a/Loop/Core/Multitouch/MultitouchTargetResolver.swift b/Loop/Core/Multitouch/MultitouchTargetResolver.swift new file mode 100644 index 00000000..b8a8e0d7 --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchTargetResolver.swift @@ -0,0 +1,69 @@ +// +// 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? + + func reset() { + lastRepeatableWindow = nil + } + + func targetWindow(for gesture: GestureBinding, canRepeat: Bool) -> Window? { + let window = findTargetWindow(for: gesture) + if window == nil, canRepeat { + return lastRepeatableWindow + } + return window + } + + func rememberRepeatableWindow(_ window: Window?, canRepeat: Bool) { + guard let window, canRepeat else { return } + lastRepeatableWindow = window + } + + private func findTargetWindow(for gesture: GestureBinding) -> Window? { + let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) + + guard let window = WindowUtility.windowAtPosition(cursorPosition) else { + return nil + } + + // 2-finger gestures are always titlebar-only to avoid system gesture conflicts. + switch gesture.fingerCount <= 2 ? .titlebar : gesture.activationZone { + case .titlebar: + let minimumTitlebarHeight = Defaults[.gestureTitlebarHeight] + let titlebarHeight: CGFloat = if #available(macOS 26, *) { + if #unavailable(macOS 27), + let cornerRadius = SkyLightToolBelt.getCornerRadii(windowID: window.cgWindowID)?.topLeading { + max(2 * cornerRadius, minimumTitlebarHeight) + } else { + minimumTitlebarHeight + } + } else { + minimumTitlebarHeight + } + + log.info("Detected titlebar height of \(titlebarHeight)") + + let titlebarMinY = window.frame.minY + let titlebarMaxY = window.frame.minY + titlebarHeight + let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY + return isInTitlebar ? window : nil + + case .anywhere: + return window + } + } +} diff --git a/Loop/Core/Observers/MultitouchTrigger.swift b/Loop/Core/Multitouch/MultitouchTrigger.swift similarity index 55% rename from Loop/Core/Observers/MultitouchTrigger.swift rename to Loop/Core/Multitouch/MultitouchTrigger.swift index 3617eec6..32179f9b 100644 --- a/Loop/Core/Observers/MultitouchTrigger.swift +++ b/Loop/Core/Multitouch/MultitouchTrigger.swift @@ -21,16 +21,19 @@ final class MultitouchTrigger { private let gestureMonitor = SubsurfaceMonitor() private let gestureBlocker: MultitouchGestureBlocker = .init() + private lazy var recognizerRegistry = MultitouchRecognizerRegistry( + gestureMonitor: gestureMonitor + ) { [weak self] event, fingerCount in + guard let self else { return } + await handleGestureEvent(event, fingerCount: fingerCount) + } + + private let targetResolver = MultitouchTargetResolver() - private var recognizers: [Int: RecognizerEntry] = [:] private var gesturesObservationTask: Task<(), Never>? private var systemGestureReconciliationTask: Task<(), Never>? private var isStarted = false - /// Window most recently targeted by a repeatable gesture. - /// Lets shrinking/growing continue after the cursor falls off the resized frame. - private var lastRepeatableWindow: Window? - private let panCycleStepSize: CGFloat = 0.15 private let pinchActivationThreshold: CGFloat = 0.3 private let pinchCycleStepSize: CGFloat = 0.15 @@ -42,48 +45,6 @@ final class MultitouchTrigger { private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) - private enum ActionKey: Hashable { - case radialSlot(Int) - case radialCenter - case gesture(UUID) - } - - private struct GestureState { - var didOpenLoopWithThisGesture = false - var isGestureRejected = false - var hasActivated = false - var hasGestureBegun = false - /// The gesture currently driving this stroke. Swapped on direction reversal - var resolvedGesture: GestureBinding? - var pendingTargetWindow: Window? - var lastCommittedAction: ActionKey? - var lastCommitPanDistance: CGFloat = 0 - var lastCommitPinchDistance: CGFloat = 0 - var pinchDirection: Int = 0 - } - - /// Snapshot of the gestures that apply at this finger count, so handlers - /// don't fire actions against a gesture the user has just deleted. - private struct RecognizerEntry { - let recognizer: SubsurfaceGestureRecognizer - var task: Task<(), Never>? - var state: GestureState - var radialMenuGesture: GestureBinding? - var directionalGestures: [GestureBinding] - var pinchGesture: GestureBinding? - var spreadGesture: GestureBinding? - - static func categorize( - _ gestures: [GestureBinding] - ) -> (radial: GestureBinding?, directionals: [GestureBinding], pinch: GestureBinding?, spread: GestureBinding?) { - let radial = gestures.first { $0.kind == .radialMenu } - let directionals = gestures.filter(\.kind.isDirectionalPan) - let pinch = gestures.first { $0.kind == .pinch } - let spread = gestures.first { $0.kind == .spread } - return (radial, directionals, pinch, spread) - } - } - init( windowActionCache: WindowActionCache, openCallback: @escaping (WindowAction, Window) async throws -> (), @@ -130,13 +91,10 @@ final class MultitouchTrigger { gesturesObservationTask?.cancel() gesturesObservationTask = nil - for fingerCount in Array(recognizers.keys) { - stopRecognizer(for: fingerCount) - } - recognizers.removeAll() + handleStopResults(recognizerRegistry.stopAll()) gestureMonitor.stop() - lastRepeatableWindow = nil + targetResolver.reset() } func shutdown() { @@ -172,92 +130,42 @@ final class MultitouchTrigger { } private func rebuildRecognizers() { - let allGestures = Defaults[.gestures] - let conflictingIDs = GestureBinding.conflictingActionableIDs(in: allGestures) - let activeGestures = allGestures.filter { !conflictingIDs.contains($0.id) } - let gesturesByFingerCount = Dictionary(grouping: activeGestures, by: \.fingerCount) - let neededFingerCounts = Set(gesturesByFingerCount.keys) - - for fingerCount in Array(recognizers.keys) where !neededFingerCounts.contains(fingerCount) { - stopRecognizer(for: fingerCount) - recognizers.removeValue(forKey: fingerCount) - } - - for (fingerCount, gestures) in gesturesByFingerCount { - let (radial, directionals, pinch, spread) = RecognizerEntry.categorize(gestures) - if recognizers[fingerCount] == nil { - startRecognizer(for: fingerCount, radial: radial, directionals: directionals, pinch: pinch, spread: spread) - } else { - recognizers[fingerCount]?.radialMenuGesture = radial - recognizers[fingerCount]?.directionalGestures = directionals - recognizers[fingerCount]?.pinchGesture = pinch - recognizers[fingerCount]?.spreadGesture = spread - } - } + handleStopResults(recognizerRegistry.rebuild(with: Defaults[.gestures])) } - private func startRecognizer( - for fingerCount: Int, - radial: GestureBinding?, - directionals: [GestureBinding], - pinch: GestureBinding?, - spread: GestureBinding? - ) { - let recognizer = SubsurfaceGestureRecognizer( - fingerCount: fingerCount, - requiresExactFingerCountToContinue: true - ) - - recognizers[fingerCount] = RecognizerEntry( - recognizer: recognizer, - task: nil, - state: GestureState(), - radialMenuGesture: radial, - directionalGestures: directionals, - pinchGesture: pinch, - spreadGesture: spread - ) - - let task = Task { [weak self] in - guard let self else { return } - for await event in recognizer.events(from: gestureMonitor) { - guard !Task.isCancelled else { break } - switch event { - case let .pan(pan): - await handlePan(pan, fingerCount: fingerCount) - case let .pinch(pinch): - await handlePinch(pinch, fingerCount: fingerCount) - case .determining: - 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) - } + private func handleStopResults(_ stopResults: [MultitouchRecognizerRegistry.StopResult]) { + for stopResult in stopResults { + if stopResult.didOpenLoopWithGesture { + closeCallback(false) } + gestureBlocker.stop() } - recognizers[fingerCount]?.task = task } - private func stopRecognizer(for fingerCount: Int) { - guard let entry = recognizers[fingerCount] else { return } - entry.task?.cancel() - entry.recognizer.reset() - if entry.state.didOpenLoopWithThisGesture { - closeCallback(false) + private func handleGestureEvent(_ event: SubsurfaceGestureEvent, fingerCount: Int) async { + switch event { + case let .pan(pan): + await handlePan(pan, fingerCount: fingerCount) + case let .pinch(pinch): + await handlePinch(pinch, 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) } - gestureBlocker.stop() } private func handlePan(_ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int) async { - guard let entry = recognizers[fingerCount] else { return } + guard let entry = recognizerRegistry.entry(for: fingerCount) else { return } if let radialMenuGesture = entry.radialMenuGesture { await handleRadialMenuPan(pan, fingerCount: fingerCount, gesture: radialMenuGesture) } else { let direction = directionalPanKind(angle: pan.angle) let directionalGesture = matchDirectionalPanGesture(kind: direction, from: entry.directionalGestures) - if directionalGesture != nil || entry.state.hasGestureBegun { + if directionalGesture != nil || entry.session.hasGestureBegun { await handleDirectionalPan( pan, fingerCount: fingerCount, @@ -275,11 +183,11 @@ final class MultitouchTrigger { ) async { switch pan.phase { case .began, .changed: - if pan.phase == .began, recognizers[fingerCount]?.state.hasGestureBegun != true { + if pan.phase == .began, recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { handleGestureBegan(fingerCount: fingerCount, gesture: gesture) } guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } - guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } let normalizedAngle = normalizedAngle(fromSubsurfaceAngle: pan.angle) let actions = radialMenuActions.dropLast() @@ -294,11 +202,10 @@ final class MultitouchTrigger { newIndex = Int((normalizedAngle + halfAngleSpan) / actionAngleSpan) % actions.count } - commitPan( - &state, + session.commitPan( distance: pan.distance, newKey: .radialSlot(newIndex), - fingerCount: fingerCount + step: panCycleStepSize ) { reverse in triggerRadialMenuAction(at: newIndex, from: actions, reverse: reverse) } @@ -317,20 +224,20 @@ final class MultitouchTrigger { direction: GestureBinding.Kind, matchedGesture: GestureBinding? ) async { - guard let entry = recognizers[fingerCount] else { return } + guard let entry = recognizerRegistry.entry(for: fingerCount) else { return } switch pan.phase { case .began, .changed: - if recognizers[fingerCount]?.state.hasGestureBegun != true { + if recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { guard let matchedGesture else { return } handleGestureBegan(fingerCount: fingerCount, gesture: matchedGesture) - recognizers[fingerCount]?.state.resolvedGesture = matchedGesture + recognizerRegistry.session(for: fingerCount)?.setResolvedGesture(matchedGesture) } - guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } - guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } - guard let activeGesture = state.resolvedGesture ?? matchedGesture else { return } + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } + guard let activeGesture = session.resolvedGesture ?? matchedGesture else { return } if direction != activeGesture.kind { if let matchedGesture { @@ -348,11 +255,10 @@ final class MultitouchTrigger { return } - commitPan( - &state, + session.commitPan( distance: pan.distance, newKey: .gesture(activeGesture.id), - fingerCount: fingerCount + step: panCycleStepSize ) { reverse in triggerSingleAction(from: activeGesture, reverse: reverse) } @@ -366,7 +272,7 @@ final class MultitouchTrigger { } private func handlePinch(_ pinch: SubsurfaceGestureEvent.PinchEvent, fingerCount: Int) async { - guard let entry = recognizers[fingerCount] else { return } + guard let entry = recognizerRegistry.entry(for: fingerCount) else { return } // Radial menu pinch triggers the center action regardless of direction if let radialMenuGesture = entry.radialMenuGesture { @@ -377,23 +283,22 @@ final class MultitouchTrigger { switch pinch.phase { case .began, .changed: if pinch.phase == .began, - recognizers[fingerCount]?.state.hasGestureBegun != true { - recognizers[fingerCount]?.state = GestureState() + recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { + recognizerRegistry.session(for: fingerCount)?.reset() } - guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } - if !state.hasGestureBegun { + if !session.hasGestureBegun { let initialGesture = pinch.distance >= pinch.originDistance ? entry.spreadGesture : entry.pinchGesture guard let initialGesture else { return } handleGestureBegan(fingerCount: fingerCount, gesture: initialGesture) - recognizers[fingerCount]?.state.hasGestureBegun = true - recognizers[fingerCount]?.state.resolvedGesture = initialGesture + recognizerRegistry.session(for: fingerCount)?.setResolvedGesture(initialGesture) } - guard let state = recognizers[fingerCount]?.state, !state.isGestureRejected, - let activeGesture = state.resolvedGesture else { + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected, + let activeGesture = session.resolvedGesture else { return } guard await activateGestureIfNeeded( @@ -403,7 +308,7 @@ final class MultitouchTrigger { ) else { return } - guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } if pinchReversalDetected(currentGesture: activeGesture, pinch: pinch) { let opposite = activeGesture.kind == .pinch ? entry.spreadGesture : entry.pinchGesture @@ -416,20 +321,19 @@ final class MultitouchTrigger { return } - commitPinch( - &state, + session.commitPinch( distance: pinch.distance, originDistance: pinch.originDistance, newKey: .gesture(activeGesture.id), - fingerCount: fingerCount, + step: pinchCycleStepSize, canRepeat: canRepeatGestureAction(activeGesture) ) { reverse in triggerSingleAction(from: activeGesture, reverse: reverse) } - if let window = recognizers[fingerCount]?.state.pendingTargetWindow, + if let window = session.pendingTargetWindow, resolvedWindowAction(from: activeGesture)?.canRepeat == true { - lastRepeatableWindow = window + targetResolver.rememberRepeatableWindow(window, canRepeat: true) } case .ended, .cancelled: @@ -448,24 +352,23 @@ final class MultitouchTrigger { ) async { switch pinch.phase { case .began, .changed: - if pinch.phase == .began, recognizers[fingerCount]?.state.hasGestureBegun != true { + if pinch.phase == .began, recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { handleGestureBegan(fingerCount: fingerCount, gesture: gesture) } guard await activateGestureIfNeeded( fingerCount: fingerCount, pinchDisplacement: pinch.distance - pinch.originDistance ) else { return } - guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return } + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } let actions = radialMenuActions guard !actions.isEmpty else { return } let centerActionIndex = actions.count - 1 - commitRadialPinch( - &state, + session.commitRadialPinch( distance: pinch.distance, originDistance: pinch.originDistance, - fingerCount: fingerCount + step: pinchCycleStepSize ) { reverse in triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: reverse) } @@ -481,28 +384,18 @@ final class MultitouchTrigger { /// Resolves the target window and starts blocking trackpad events. Loop itself /// isn't opened until the gesture crosses the activation threshold in a `.changed` event. private func handleGestureBegan(fingerCount: Int, gesture: GestureBinding) { - var window = findTargetWindow(for: gesture) - if window == nil, resolvedWindowAction(from: gesture)?.canRepeat == true { - window = lastRepeatableWindow - } + let canRepeat = resolvedWindowAction(from: gesture)?.canRepeat == true + let window = targetResolver.targetWindow(for: gesture, canRepeat: canRepeat) let loopWasAlreadyOpen = checkIfLoopOpen() - guard window != nil || loopWasAlreadyOpen else { - recognizers[fingerCount]?.state.isGestureRejected = true + guard let session = recognizerRegistry.session(for: fingerCount) else { return } + guard session.begin(gesture: gesture, targetWindow: window, loopWasAlreadyOpen: loopWasAlreadyOpen) else { return } - if let window, resolvedWindowAction(from: gesture)?.canRepeat == true { - lastRepeatableWindow = window - } + targetResolver.rememberRepeatableWindow(window, canRepeat: canRepeat) - var state = GestureState() - state.pendingTargetWindow = window - state.hasGestureBegun = true - // Loop is already on screen, so no activation threshold to cross. - state.hasActivated = loopWasAlreadyOpen - recognizers[fingerCount]?.state = state gestureBlocker.start() } @@ -511,11 +404,11 @@ final class MultitouchTrigger { fingerCount: Int ) async { guard !Defaults[.hideOnNoSelection], - let gesture = recognizers[fingerCount]?.radialMenuGesture else { return } + let gesture = recognizerRegistry.entry(for: fingerCount)?.radialMenuGesture else { return } switch phase { case .determining, .began, .changed: - if recognizers[fingerCount]?.state.hasGestureBegun != true { + if recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { handleGestureBegan(fingerCount: fingerCount, gesture: gesture) } _ = await activateGestureIfNeeded(fingerCount: fingerCount) @@ -536,161 +429,40 @@ final class MultitouchTrigger { pinchDisplacement: CGFloat? = nil, pinchActivationThreshold: CGFloat? = nil ) async -> Bool { - guard var state = recognizers[fingerCount]?.state, !state.isGestureRejected else { return false } - if state.hasActivated { return true } + guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return false } + if session.hasActivated { return true } if let pinchDisplacement, abs(pinchDisplacement) < (pinchActivationThreshold ?? self.pinchActivationThreshold) { return false } - if let window = state.pendingTargetWindow { + var openedLoop = false + if let window = session.pendingTargetWindow { do { try await openCallback(.init(.noSelection), window) - state.didOpenLoopWithThisGesture = true + openedLoop = true } catch { - state.isGestureRejected = true - recognizers[fingerCount]?.state = state + if recognizerRegistry.contains(session: session, for: fingerCount) { + session.reject() + } gestureBlocker.stop() return false } } - state.hasActivated = true - recognizers[fingerCount]?.state = state + guard recognizerRegistry.contains(session: session, for: fingerCount) else { return false } + session.markActivated(openedLoop: openedLoop) return true } private func resetLoopState(for fingerCount: Int, forceClose: Bool = false) { - if recognizers[fingerCount]?.state.didOpenLoopWithThisGesture == true { + if recognizerRegistry.session(for: fingerCount)?.didOpenLoopWithThisGesture == true { closeCallback(forceClose) } gestureBlocker.stop() - recognizers[fingerCount]?.state = GestureState() - } - - private func findTargetWindow(for gesture: GestureBinding) -> Window? { - let cursorPosition = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) - - guard let window = WindowUtility.windowAtPosition(cursorPosition) else { - return nil - } - - // 2-finger gestures are always titlebar-only to avoid system gesture conflicts. - switch gesture.fingerCount <= 2 ? .titlebar : gesture.activationZone { - case .titlebar: - let minimumTitlebarHeight = Defaults[.gestureTitlebarHeight] - let titlebarHeight: CGFloat = if #available(macOS 26, *) { - if #unavailable(macOS 27), - let cornerRadius = SkyLightToolBelt.getCornerRadii(windowID: window.cgWindowID)?.topLeading { - max(2 * cornerRadius, minimumTitlebarHeight) - } else { - minimumTitlebarHeight - } - } else { - minimumTitlebarHeight - } - - log.info("Detected titlebar height of \(titlebarHeight)") - - let titlebarMinY = window.frame.minY - let titlebarMaxY = window.frame.minY + titlebarHeight - let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY - return isInTitlebar ? window : nil - - case .anywhere: - return window - } - } - - /// Commit distance only advances when an action fires, so sub-step - /// jitter can't drift it past the reverse threshold. Activation is gated - /// upstream by `activateGestureIfNeeded`, so the first commit fires - /// immediately to seed Loop's initial active action. - private func commitPan( - _ state: inout GestureState, - distance: CGFloat, - newKey: ActionKey, - fingerCount: Int, - fire: (_ reverse: Bool) -> () - ) { - if state.lastCommittedAction == newKey { - let delta = distance - state.lastCommitPanDistance - if delta >= panCycleStepSize { - state.lastCommitPanDistance = distance - recognizers[fingerCount]?.state = state - fire(false) - } else if delta <= -panCycleStepSize { - state.lastCommitPanDistance = distance - recognizers[fingerCount]?.state = state - fire(true) - } - } else { - state.lastCommittedAction = newKey - state.lastCommitPanDistance = distance - recognizers[fingerCount]?.state = state - fire(false) - } - } - - private func commitRadialPinch( - _ state: inout GestureState, - distance: CGFloat, - originDistance: CGFloat, - fingerCount: Int, - fire: (_ reverse: Bool) -> () - ) { - if state.lastCommittedAction != .radialCenter { - state.lastCommittedAction = .radialCenter - state.lastCommitPinchDistance = distance - recognizers[fingerCount]?.state = state - fire(distance < originDistance) - return - } - - let delta = distance - state.lastCommitPinchDistance - if delta >= pinchCycleStepSize { - state.lastCommitPinchDistance = distance - recognizers[fingerCount]?.state = state - fire(false) - } else if delta <= -pinchCycleStepSize { - state.lastCommitPinchDistance = distance - recognizers[fingerCount]?.state = state - fire(true) - } - } - - private func commitPinch( - _ state: inout GestureState, - distance: CGFloat, - originDistance: CGFloat, - newKey: ActionKey, - fingerCount: Int, - canRepeat: Bool, - fire: (_ reverse: Bool) -> () - ) { - if state.lastCommittedAction != newKey { - state.pinchDirection = distance >= originDistance ? 1 : -1 - state.lastCommittedAction = newKey - state.lastCommitPinchDistance = distance - recognizers[fingerCount]?.state = state - fire(false) - return - } - - guard canRepeat else { return } - - let delta = (distance - state.lastCommitPinchDistance) * CGFloat(state.pinchDirection) - if delta >= pinchCycleStepSize { - state.lastCommitPinchDistance = distance - recognizers[fingerCount]?.state = state - fire(false) - } else if delta <= -pinchCycleStepSize { - state.lastCommitPinchDistance = distance - recognizers[fingerCount]?.state = state - fire(true) - } + recognizerRegistry.session(for: fingerCount)?.reset() } private func pinchReversalDetected( @@ -699,11 +471,11 @@ final class MultitouchTrigger { ) -> Bool { switch currentGesture.kind { case .pinch: - return pinch.distance >= pinch.originDistance + pinch.distance >= pinch.originDistance case .spread: - return pinch.distance <= pinch.originDistance + pinch.distance <= pinch.originDistance default: - return false + false } } @@ -718,15 +490,15 @@ final class MultitouchTrigger { private func oppositeDirectionalPanKind(of kind: GestureBinding.Kind) -> GestureBinding.Kind? { switch kind { case .panUp: - return .panDown + .panDown case .panDown: - return .panUp + .panUp case .panLeft: - return .panRight + .panRight case .panRight: - return .panLeft + .panLeft default: - return nil + nil } } @@ -744,16 +516,13 @@ final class MultitouchTrigger { to gesture: GestureBinding, distance: CGFloat ) { - guard var state = recognizers[fingerCount]?.state else { return } - state.resolvedGesture = gesture - state.lastCommittedAction = .gesture(gesture.id) - state.lastCommitPanDistance = distance - recognizers[fingerCount]?.state = state + guard let session = recognizerRegistry.session(for: fingerCount) else { return } + session.switchPanGesture(to: gesture, distance: distance) triggerSingleAction(from: gesture, reverse: false) - if let window = state.pendingTargetWindow, + if let window = session.pendingTargetWindow, resolvedWindowAction(from: gesture)?.canRepeat == true { - lastRepeatableWindow = window + targetResolver.rememberRepeatableWindow(window, canRepeat: true) } } @@ -774,20 +543,17 @@ final class MultitouchTrigger { distance: CGFloat ) { if let oppositeGesture { - guard var state = recognizers[fingerCount]?.state else { return } - state.resolvedGesture = oppositeGesture - state.lastCommittedAction = .gesture(oppositeGesture.id) - state.lastCommitPanDistance = distance - recognizers[fingerCount]?.state = state + guard let session = recognizerRegistry.session(for: fingerCount) else { return } + session.switchPanGesture(to: oppositeGesture, distance: distance) triggerSingleAction(from: oppositeGesture, reverse: false) - if let window = state.pendingTargetWindow, + if let window = session.pendingTargetWindow, resolvedWindowAction(from: oppositeGesture)?.canRepeat == true { - lastRepeatableWindow = window + targetResolver.rememberRepeatableWindow(window, canRepeat: true) } } else if isCycleAction(currentGesture) { triggerSingleAction(from: currentGesture, reverse: true) - recognizers[fingerCount]?.state.lastCommitPanDistance = distance + recognizerRegistry.session(for: fingerCount)?.updateLastCommitPanDistance(distance) } else { cancelPanGesture(fingerCount: fingerCount) } @@ -800,24 +566,20 @@ final class MultitouchTrigger { distance: CGFloat ) { if let oppositeGesture { - guard var state = recognizers[fingerCount]?.state else { return } + guard let session = recognizerRegistry.session(for: fingerCount) else { return } // Direction is fixed by the new gesture's gesture type, not by current finger distance, // since the user may not yet have crossed neutral when reversing let direction = oppositeGesture.kind == .spread ? 1 : -1 - state.resolvedGesture = oppositeGesture - state.lastCommittedAction = .gesture(oppositeGesture.id) - state.pinchDirection = direction - state.lastCommitPinchDistance = distance - recognizers[fingerCount]?.state = state + session.switchPinchGesture(to: oppositeGesture, distance: distance, direction: direction) triggerSingleAction(from: oppositeGesture, reverse: false) - if let window = state.pendingTargetWindow, + if let window = session.pendingTargetWindow, resolvedWindowAction(from: oppositeGesture)?.canRepeat == true { - lastRepeatableWindow = window + targetResolver.rememberRepeatableWindow(window, canRepeat: true) } } else if isCycleAction(currentGesture) { triggerSingleAction(from: currentGesture, reverse: true) - recognizers[fingerCount]?.state.lastCommitPinchDistance = distance + recognizerRegistry.session(for: fingerCount)?.updateLastCommitPinchDistance(distance) } else { cancelPinchGesture(fingerCount: fingerCount) } diff --git a/Loop/Core/Observers/Helpers/SystemGestureManager.swift b/Loop/Core/Multitouch/SystemGestureManager.swift similarity index 100% rename from Loop/Core/Observers/Helpers/SystemGestureManager.swift rename to Loop/Core/Multitouch/SystemGestureManager.swift From 0a91141c14285202c2dfe4848818c1a40095fddb Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 21:47:25 -0600 Subject: [PATCH 50/61] =?UTF-8?q?=F0=9F=92=84=20small=20ui=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/Gestures/GestureItemView.swift | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift index 10b744e7..179d7fb2 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -77,19 +77,12 @@ struct GestureItemView: View { var body: some View { ZStack { - Group { - if hasConflict { - gestureConfiguration - .luminareTint(overridingWith: .red) - } else { - gestureConfiguration + 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) } - } - .luminareToolTip(attachedTo: .topLeading, hidden: !hasConflict) { - Text(String(localized: "There are other gestures that conflict with this gesture.", comment: "Tooltip shown on a conflicting gesture in settings")) - .padding(6) - } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) actionSelection .frame(maxWidth: .infinity, alignment: .trailing) From 8aefff9839f81863629b83b5f1cde4e9d93db239 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 22:54:16 -0600 Subject: [PATCH 51/61] =?UTF-8?q?=E2=9C=A8=20Use=20`LuminarePickerMenu`=20?= =?UTF-8?q?for=20direction/gesture=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Localizable.xcstrings | 3 -- .../Gestures/GestureConfigurationView.swift | 38 ++++++------------- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 94f5d07c..eccf955b 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -1,9 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - - }, "%" : { "comment" : "Unit symbol: percentage", "localizations" : { diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift index 412d3261..344307ef 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift @@ -117,36 +117,22 @@ struct GestureConfigurationView: View { .luminareRoundingBehavior(top: true, bottom: gesture.kind == .radialMenu) if GestureTab(kind: gesture.kind) == .swipe { - LuminareCompose(String(localized: "Direction", comment: "Label for the swipe direction picker in the gesture configuration modal")) { - Picker("", selection: swipeDirectionBinding) { - ForEach(SwipeDirection.allCases, id: \.self) { direction in - Label { - Text(direction.displayName) - } icon: { - direction.image - } - .tag(direction) - } - } - .labelsHidden() - .buttonStyle(.plain) + LuminarePickerMenu( + "Direction", + selection: swipeDirectionBinding, + items: SwipeDirection.allCases + ) { direction in + Text("\(direction.image) \(direction.displayName)") } } if GestureTab(kind: gesture.kind) == .zoom { - LuminareCompose(String(localized: "Gesture", comment: "Label for the zoom gesture picker in the gesture configuration modal")) { - Picker("", selection: zoomGestureBinding) { - ForEach(ZoomGesture.allCases, id: \.self) { zoomGesture in - Label { - Text(zoomGesture.displayName) - } icon: { - zoomGesture.image - } - .tag(zoomGesture) - } - } - .labelsHidden() - .buttonStyle(.plain) + LuminarePickerMenu( + "Gesture", + selection: zoomGestureBinding, + items: ZoomGesture.allCases + ) { gesture in + Text("\(gesture.image) \(gesture.displayName)") } } } From 4b20c41471458a137de8ceb878c063413b294427 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 23:19:05 -0600 Subject: [PATCH 52/61] =?UTF-8?q?=E2=9C=A8=20Use=20swipe/magnify=20termino?= =?UTF-8?q?logy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Multitouch/MultitouchGestureSession.swift | 62 +++--- .../MultitouchRecognizerRegistry.swift | 30 +-- Loop/Core/Multitouch/MultitouchTrigger.swift | 202 +++++++++--------- Loop/Localizable.xcstrings | 28 +-- .../Gestures/GestureConfigurationView.swift | 112 +++++----- .../Window Action/GestureBinding.swift | 60 +++--- 6 files changed, 247 insertions(+), 247 deletions(-) diff --git a/Loop/Core/Multitouch/MultitouchGestureSession.swift b/Loop/Core/Multitouch/MultitouchGestureSession.swift index 1ad1ccf5..d330a725 100644 --- a/Loop/Core/Multitouch/MultitouchGestureSession.swift +++ b/Loop/Core/Multitouch/MultitouchGestureSession.swift @@ -24,9 +24,9 @@ final class MultitouchGestureSession { private(set) var pendingTargetWindow: Window? private var lastCommittedAction: ActionKey? - private var lastCommitPanDistance: CGFloat = 0 - private var lastCommitPinchDistance: CGFloat = 0 - private var pinchDirection: Int = 0 + private var lastCommitSwipeDistance: CGFloat = 0 + private var lastCommitMagnifyDistance: CGFloat = 0 + private var magnificationDirection: Int = 0 func reset() { didOpenLoopWithThisGesture = false @@ -36,9 +36,9 @@ final class MultitouchGestureSession { resolvedGesture = nil pendingTargetWindow = nil lastCommittedAction = nil - lastCommitPanDistance = 0 - lastCommitPinchDistance = 0 - pinchDirection = 0 + lastCommitSwipeDistance = 0 + lastCommitMagnifyDistance = 0 + magnificationDirection = 0 } func begin( @@ -74,29 +74,29 @@ final class MultitouchGestureSession { /// 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 commitPan( + func commitSwipe( distance: CGFloat, newKey: ActionKey, step: CGFloat, fire: (_ reverse: Bool) -> () ) { if lastCommittedAction == newKey { - let delta = distance - lastCommitPanDistance + let delta = distance - lastCommitSwipeDistance if delta >= step { - lastCommitPanDistance = distance + lastCommitSwipeDistance = distance fire(false) } else if delta <= -step { - lastCommitPanDistance = distance + lastCommitSwipeDistance = distance fire(true) } } else { lastCommittedAction = newKey - lastCommitPanDistance = distance + lastCommitSwipeDistance = distance fire(false) } } - func commitRadialPinch( + func commitRadialMagnify( distance: CGFloat, originDistance: CGFloat, step: CGFloat, @@ -104,22 +104,22 @@ final class MultitouchGestureSession { ) { if lastCommittedAction != .radialCenter { lastCommittedAction = .radialCenter - lastCommitPinchDistance = distance + lastCommitMagnifyDistance = distance fire(distance < originDistance) return } - let delta = distance - lastCommitPinchDistance + let delta = distance - lastCommitMagnifyDistance if delta >= step { - lastCommitPinchDistance = distance + lastCommitMagnifyDistance = distance fire(false) } else if delta <= -step { - lastCommitPinchDistance = distance + lastCommitMagnifyDistance = distance fire(true) } } - func commitPinch( + func commitMagnify( distance: CGFloat, originDistance: CGFloat, newKey: ActionKey, @@ -128,21 +128,21 @@ final class MultitouchGestureSession { fire: (_ reverse: Bool) -> () ) { if lastCommittedAction != newKey { - pinchDirection = distance >= originDistance ? 1 : -1 + magnificationDirection = distance >= originDistance ? 1 : -1 lastCommittedAction = newKey - lastCommitPinchDistance = distance + lastCommitMagnifyDistance = distance fire(false) return } guard canRepeat else { return } - let delta = (distance - lastCommitPinchDistance) * CGFloat(pinchDirection) + let delta = (distance - lastCommitMagnifyDistance) * CGFloat(magnificationDirection) if delta >= step { - lastCommitPinchDistance = distance + lastCommitMagnifyDistance = distance fire(false) } else if delta <= -step { - lastCommitPinchDistance = distance + lastCommitMagnifyDistance = distance fire(true) } } @@ -151,24 +151,24 @@ final class MultitouchGestureSession { resolvedGesture = gesture } - func switchPanGesture(to gesture: GestureBinding, distance: CGFloat) { + func switchSwipeGesture(to gesture: GestureBinding, distance: CGFloat) { resolvedGesture = gesture lastCommittedAction = .gesture(gesture.id) - lastCommitPanDistance = distance + lastCommitSwipeDistance = distance } - func switchPinchGesture(to gesture: GestureBinding, distance: CGFloat, direction: Int) { + func switchMagnifyGesture(to gesture: GestureBinding, distance: CGFloat, direction: Int) { resolvedGesture = gesture lastCommittedAction = .gesture(gesture.id) - pinchDirection = direction - lastCommitPinchDistance = distance + magnificationDirection = direction + lastCommitMagnifyDistance = distance } - func updateLastCommitPanDistance(_ distance: CGFloat) { - lastCommitPanDistance = distance + func updateLastCommitSwipeDistance(_ distance: CGFloat) { + lastCommitSwipeDistance = distance } - func updateLastCommitPinchDistance(_ distance: CGFloat) { - lastCommitPinchDistance = distance + func updateLastCommitMagnifyDistance(_ distance: CGFloat) { + lastCommitMagnifyDistance = distance } } diff --git a/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift b/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift index fc8ab6b1..f7f93b70 100644 --- a/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift +++ b/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift @@ -17,17 +17,17 @@ final class MultitouchRecognizerRegistry { var task: Task<(), Never>? var radialMenuGesture: GestureBinding? var directionalGestures: [GestureBinding] - var pinchGesture: GestureBinding? - var spreadGesture: GestureBinding? + var magnifyInGesture: GestureBinding? + var magnifyOutGesture: GestureBinding? static func categorize( _ gestures: [GestureBinding] - ) -> (radial: GestureBinding?, directionals: [GestureBinding], pinch: GestureBinding?, spread: GestureBinding?) { + ) -> (radial: GestureBinding?, directionals: [GestureBinding], magnifyIn: GestureBinding?, magnifyOut: GestureBinding?) { let radial = gestures.first { $0.kind == .radialMenu } - let directionals = gestures.filter(\.kind.isDirectionalPan) - let pinch = gestures.first { $0.kind == .pinch } - let spread = gestures.first { $0.kind == .spread } - return (radial, directionals, pinch, spread) + let directionals = gestures.filter(\.kind.isDirectionalSwipe) + let magnifyIn = gestures.first { $0.kind == .magnifyIn } + let magnifyOut = gestures.first { $0.kind == .magnifyOut } + return (radial, directionals, magnifyIn, magnifyOut) } } @@ -70,14 +70,14 @@ final class MultitouchRecognizerRegistry { } for (fingerCount, gestures) in gesturesByFingerCount { - let (radial, directionals, pinch, spread) = Entry.categorize(gestures) + let (radial, directionals, magnifyIn, magnifyOut) = Entry.categorize(gestures) if entries[fingerCount] == nil { - startRecognizer(for: fingerCount, radial: radial, directionals: directionals, pinch: pinch, spread: spread) + startRecognizer(for: fingerCount, radial: radial, directionals: directionals, magnifyIn: magnifyIn, magnifyOut: magnifyOut) } else { entries[fingerCount]?.radialMenuGesture = radial entries[fingerCount]?.directionalGestures = directionals - entries[fingerCount]?.pinchGesture = pinch - entries[fingerCount]?.spreadGesture = spread + entries[fingerCount]?.magnifyInGesture = magnifyIn + entries[fingerCount]?.magnifyOutGesture = magnifyOut } } @@ -103,8 +103,8 @@ final class MultitouchRecognizerRegistry { for fingerCount: Int, radial: GestureBinding?, directionals: [GestureBinding], - pinch: GestureBinding?, - spread: GestureBinding? + magnifyIn: GestureBinding?, + magnifyOut: GestureBinding? ) { let recognizer = SubsurfaceGestureRecognizer( fingerCount: fingerCount, @@ -117,8 +117,8 @@ final class MultitouchRecognizerRegistry { task: nil, radialMenuGesture: radial, directionalGestures: directionals, - pinchGesture: pinch, - spreadGesture: spread + magnifyInGesture: magnifyIn, + magnifyOutGesture: magnifyOut ) let task = Task { [weak self] in diff --git a/Loop/Core/Multitouch/MultitouchTrigger.swift b/Loop/Core/Multitouch/MultitouchTrigger.swift index 32179f9b..5d3f20d7 100644 --- a/Loop/Core/Multitouch/MultitouchTrigger.swift +++ b/Loop/Core/Multitouch/MultitouchTrigger.swift @@ -34,9 +34,9 @@ final class MultitouchTrigger { private var systemGestureReconciliationTask: Task<(), Never>? private var isStarted = false - private let panCycleStepSize: CGFloat = 0.15 - private let pinchActivationThreshold: CGFloat = 0.3 - private let pinchCycleStepSize: CGFloat = 0.15 + private let swipeCycleStepSize: CGFloat = 0.15 + private let magnifyActivationThreshold: CGFloat = 0.3 + private let magnifyCycleStepSize: CGFloat = 0.15 private let cardinalBiasedRadialMenuActionCount = 8 private var radialMenuActions: [RadialMenuAction] { @@ -144,10 +144,10 @@ final class MultitouchTrigger { private func handleGestureEvent(_ event: SubsurfaceGestureEvent, fingerCount: Int) async { switch event { - case let .pan(pan): - await handlePan(pan, fingerCount: fingerCount) - case let .pinch(pinch): - await handlePinch(pinch, fingerCount: fingerCount) + 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: @@ -157,17 +157,17 @@ final class MultitouchTrigger { } } - private func handlePan(_ pan: SubsurfaceGestureEvent.PanEvent, fingerCount: Int) async { + private func handleSwipe(_ swipe: SubsurfaceGestureEvent.SwipeEvent, fingerCount: Int) async { guard let entry = recognizerRegistry.entry(for: fingerCount) else { return } if let radialMenuGesture = entry.radialMenuGesture { - await handleRadialMenuPan(pan, fingerCount: fingerCount, gesture: radialMenuGesture) + await handleRadialMenuSwipe(swipe, fingerCount: fingerCount, gesture: radialMenuGesture) } else { - let direction = directionalPanKind(angle: pan.angle) - let directionalGesture = matchDirectionalPanGesture(kind: direction, from: entry.directionalGestures) + let direction = directionalSwipeKind(angle: swipe.angle) + let directionalGesture = matchDirectionalSwipeGesture(kind: direction, from: entry.directionalGestures) if directionalGesture != nil || entry.session.hasGestureBegun { - await handleDirectionalPan( - pan, + await handleDirectionalSwipe( + swipe, fingerCount: fingerCount, direction: direction, matchedGesture: directionalGesture @@ -176,20 +176,20 @@ final class MultitouchTrigger { } } - private func handleRadialMenuPan( - _ pan: SubsurfaceGestureEvent.PanEvent, + private func handleRadialMenuSwipe( + _ swipe: SubsurfaceGestureEvent.SwipeEvent, fingerCount: Int, gesture: GestureBinding ) async { - switch pan.phase { + switch swipe.phase { case .began, .changed: - if pan.phase == .began, recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { + if swipe.phase == .began, recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { handleGestureBegan(fingerCount: fingerCount, gesture: gesture) } guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } - let normalizedAngle = normalizedAngle(fromSubsurfaceAngle: pan.angle) + let normalizedAngle = normalizedAngle(fromSubsurfaceAngle: swipe.angle) let actions = radialMenuActions.dropLast() guard actions.count > 1 else { return } @@ -202,10 +202,10 @@ final class MultitouchTrigger { newIndex = Int((normalizedAngle + halfAngleSpan) / actionAngleSpan) % actions.count } - session.commitPan( - distance: pan.distance, + session.commitSwipe( + distance: swipe.distance, newKey: .radialSlot(newIndex), - step: panCycleStepSize + step: swipeCycleStepSize ) { reverse in triggerRadialMenuAction(at: newIndex, from: actions, reverse: reverse) } @@ -218,15 +218,15 @@ final class MultitouchTrigger { } } - private func handleDirectionalPan( - _ pan: SubsurfaceGestureEvent.PanEvent, + private func handleDirectionalSwipe( + _ swipe: SubsurfaceGestureEvent.SwipeEvent, fingerCount: Int, direction: GestureBinding.Kind, matchedGesture: GestureBinding? ) async { guard let entry = recognizerRegistry.entry(for: fingerCount) else { return } - switch pan.phase { + switch swipe.phase { case .began, .changed: if recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { guard let matchedGesture else { return } @@ -241,24 +241,24 @@ final class MultitouchTrigger { if direction != activeGesture.kind { if let matchedGesture { - switchPanGesture(fingerCount: fingerCount, to: matchedGesture, distance: pan.distance) - } else if direction == oppositeDirectionalPanKind(of: activeGesture.kind) { - handlePanReversal( + switchSwipeGesture(fingerCount: fingerCount, to: matchedGesture, distance: swipe.distance) + } else if direction == oppositeDirectionalSwipeKind(of: activeGesture.kind) { + handleSwipeReversal( fingerCount: fingerCount, currentGesture: activeGesture, - oppositeGesture: oppositeDirectionalPanGesture(of: activeGesture, in: entry.directionalGestures), - distance: pan.distance + oppositeGesture: oppositeDirectionalSwipeGesture(of: activeGesture, in: entry.directionalGestures), + distance: swipe.distance ) } else { - cancelPanGesture(fingerCount: fingerCount) + cancelSwipeGesture(fingerCount: fingerCount) } return } - session.commitPan( - distance: pan.distance, + session.commitSwipe( + distance: swipe.distance, newKey: .gesture(activeGesture.id), - step: panCycleStepSize + step: swipeCycleStepSize ) { reverse in triggerSingleAction(from: activeGesture, reverse: reverse) } @@ -271,25 +271,25 @@ final class MultitouchTrigger { } } - private func handlePinch(_ pinch: SubsurfaceGestureEvent.PinchEvent, fingerCount: Int) async { + private func handleMagnify(_ magnify: SubsurfaceGestureEvent.MagnifyEvent, fingerCount: Int) async { guard let entry = recognizerRegistry.entry(for: fingerCount) else { return } - // Radial menu pinch triggers the center action regardless of direction + // Radial menu magnify triggers the center action regardless of direction. if let radialMenuGesture = entry.radialMenuGesture { - await handleRadialMenuPinch(pinch, fingerCount: fingerCount, gesture: radialMenuGesture) + await handleRadialMenuMagnify(magnify, fingerCount: fingerCount, gesture: radialMenuGesture) return } - switch pinch.phase { + switch magnify.phase { case .began, .changed: - if pinch.phase == .began, + 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 = pinch.distance >= pinch.originDistance ? entry.spreadGesture : entry.pinchGesture + let initialGesture = magnify.distance >= magnify.originDistance ? entry.magnifyOutGesture : entry.magnifyInGesture guard let initialGesture else { return } @@ -303,29 +303,29 @@ final class MultitouchTrigger { } guard await activateGestureIfNeeded( fingerCount: fingerCount, - pinchDisplacement: pinch.distance - pinch.originDistance, - pinchActivationThreshold: pinchCycleStepSize + magnifyDisplacement: magnify.distance - magnify.originDistance, + magnifyActivationThreshold: magnifyCycleStepSize ) else { return } guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } - if pinchReversalDetected(currentGesture: activeGesture, pinch: pinch) { - let opposite = activeGesture.kind == .pinch ? entry.spreadGesture : entry.pinchGesture - handlePinchReversal( + if magnifyReversalDetected(currentGesture: activeGesture, magnify: magnify) { + let opposite = activeGesture.kind == .magnifyIn ? entry.magnifyOutGesture : entry.magnifyInGesture + handleMagnifyReversal( fingerCount: fingerCount, currentGesture: activeGesture, oppositeGesture: opposite, - distance: pinch.distance + distance: magnify.distance ) return } - session.commitPinch( - distance: pinch.distance, - originDistance: pinch.originDistance, + session.commitMagnify( + distance: magnify.distance, + originDistance: magnify.originDistance, newKey: .gesture(activeGesture.id), - step: pinchCycleStepSize, + step: magnifyCycleStepSize, canRepeat: canRepeatGestureAction(activeGesture) ) { reverse in triggerSingleAction(from: activeGesture, reverse: reverse) @@ -344,20 +344,20 @@ final class MultitouchTrigger { } } - /// Pinch within a radial menu gesture, triggers the center (last) radial menu action. - private func handleRadialMenuPinch( - _ pinch: SubsurfaceGestureEvent.PinchEvent, + /// 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 pinch.phase { + switch magnify.phase { case .began, .changed: - if pinch.phase == .began, recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { + if magnify.phase == .began, recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { handleGestureBegan(fingerCount: fingerCount, gesture: gesture) } guard await activateGestureIfNeeded( fingerCount: fingerCount, - pinchDisplacement: pinch.distance - pinch.originDistance + magnifyDisplacement: magnify.distance - magnify.originDistance ) else { return } guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } @@ -365,10 +365,10 @@ final class MultitouchTrigger { guard !actions.isEmpty else { return } let centerActionIndex = actions.count - 1 - session.commitRadialPinch( - distance: pinch.distance, - originDistance: pinch.originDistance, - step: pinchCycleStepSize + session.commitRadialMagnify( + distance: magnify.distance, + originDistance: magnify.originDistance, + step: magnifyCycleStepSize ) { reverse in triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: reverse) } @@ -421,19 +421,19 @@ final class MultitouchTrigger { } } - /// Opens Loop on the target window resolved at `.began`. Pinch and spread gestures - /// gate on their respective activation thresholds; pan gestures activate on the first + /// 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. private func activateGestureIfNeeded( fingerCount: Int, - pinchDisplacement: CGFloat? = nil, - pinchActivationThreshold: CGFloat? = nil + magnifyDisplacement: CGFloat? = nil, + magnifyActivationThreshold: CGFloat? = nil ) async -> Bool { guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return false } if session.hasActivated { return true } - if let pinchDisplacement, - abs(pinchDisplacement) < (pinchActivationThreshold ?? self.pinchActivationThreshold) { + if let magnifyDisplacement, + abs(magnifyDisplacement) < (magnifyActivationThreshold ?? self.magnifyActivationThreshold) { return false } @@ -465,38 +465,38 @@ final class MultitouchTrigger { recognizerRegistry.session(for: fingerCount)?.reset() } - private func pinchReversalDetected( + private func magnifyReversalDetected( currentGesture: GestureBinding, - pinch: SubsurfaceGestureEvent.PinchEvent + magnify: SubsurfaceGestureEvent.MagnifyEvent ) -> Bool { switch currentGesture.kind { - case .pinch: - pinch.distance >= pinch.originDistance - case .spread: - pinch.distance <= pinch.originDistance + case .magnifyIn: + magnify.distance >= magnify.originDistance + case .magnifyOut: + magnify.distance <= magnify.originDistance default: false } } - private func oppositeDirectionalPanGesture( + private func oppositeDirectionalSwipeGesture( of current: GestureBinding, in directionals: [GestureBinding] ) -> GestureBinding? { - guard let opposite = oppositeDirectionalPanKind(of: current.kind) else { return nil } + guard let opposite = oppositeDirectionalSwipeKind(of: current.kind) else { return nil } return directionals.first { $0.kind == opposite } } - private func oppositeDirectionalPanKind(of kind: GestureBinding.Kind) -> GestureBinding.Kind? { + private func oppositeDirectionalSwipeKind(of kind: GestureBinding.Kind) -> GestureBinding.Kind? { switch kind { - case .panUp: - .panDown - case .panDown: - .panUp - case .panLeft: - .panRight - case .panRight: - .panLeft + case .swipeUp: + .swipeDown + case .swipeDown: + .swipeUp + case .swipeLeft: + .swipeRight + case .swipeRight: + .swipeLeft default: nil } @@ -511,13 +511,13 @@ final class MultitouchTrigger { return action.canRepeat || action.direction == .cycle } - private func switchPanGesture( + private func switchSwipeGesture( fingerCount: Int, to gesture: GestureBinding, distance: CGFloat ) { guard let session = recognizerRegistry.session(for: fingerCount) else { return } - session.switchPanGesture(to: gesture, distance: distance) + session.switchSwipeGesture(to: gesture, distance: distance) triggerSingleAction(from: gesture, reverse: false) if let window = session.pendingTargetWindow, @@ -526,17 +526,17 @@ final class MultitouchTrigger { } } - private func cancelPanGesture(fingerCount: Int) { + private func cancelSwipeGesture(fingerCount: Int) { resetLoopState(for: fingerCount, forceClose: true) } - private func cancelPinchGesture( + private func cancelMagnifyGesture( fingerCount: Int ) { resetLoopState(for: fingerCount, forceClose: true) } - private func handlePanReversal( + private func handleSwipeReversal( fingerCount: Int, currentGesture: GestureBinding, oppositeGesture: GestureBinding?, @@ -544,7 +544,7 @@ final class MultitouchTrigger { ) { if let oppositeGesture { guard let session = recognizerRegistry.session(for: fingerCount) else { return } - session.switchPanGesture(to: oppositeGesture, distance: distance) + session.switchSwipeGesture(to: oppositeGesture, distance: distance) triggerSingleAction(from: oppositeGesture, reverse: false) if let window = session.pendingTargetWindow, @@ -553,13 +553,13 @@ final class MultitouchTrigger { } } else if isCycleAction(currentGesture) { triggerSingleAction(from: currentGesture, reverse: true) - recognizerRegistry.session(for: fingerCount)?.updateLastCommitPanDistance(distance) + recognizerRegistry.session(for: fingerCount)?.updateLastCommitSwipeDistance(distance) } else { - cancelPanGesture(fingerCount: fingerCount) + cancelSwipeGesture(fingerCount: fingerCount) } } - private func handlePinchReversal( + private func handleMagnifyReversal( fingerCount: Int, currentGesture: GestureBinding, oppositeGesture: GestureBinding?, @@ -569,8 +569,8 @@ final class MultitouchTrigger { guard let session = recognizerRegistry.session(for: fingerCount) else { return } // Direction is fixed by the new gesture's gesture type, not by current finger distance, // since the user may not yet have crossed neutral when reversing - let direction = oppositeGesture.kind == .spread ? 1 : -1 - session.switchPinchGesture(to: oppositeGesture, distance: distance, direction: direction) + let direction = oppositeGesture.kind == .magnifyOut ? 1 : -1 + session.switchMagnifyGesture(to: oppositeGesture, distance: distance, direction: direction) triggerSingleAction(from: oppositeGesture, reverse: false) if let window = session.pendingTargetWindow, @@ -579,9 +579,9 @@ final class MultitouchTrigger { } } else if isCycleAction(currentGesture) { triggerSingleAction(from: currentGesture, reverse: true) - recognizerRegistry.session(for: fingerCount)?.updateLastCommitPinchDistance(distance) + recognizerRegistry.session(for: fingerCount)?.updateLastCommitMagnifyDistance(distance) } else { - cancelPinchGesture(fingerCount: fingerCount) + cancelMagnifyGesture(fingerCount: fingerCount) } } @@ -620,21 +620,21 @@ final class MultitouchTrigger { return Self.failedToResolveKeybindAction } - private func matchDirectionalPanGesture(kind: GestureBinding.Kind, from gestures: [GestureBinding]) -> GestureBinding? { + private func matchDirectionalSwipeGesture(kind: GestureBinding.Kind, from gestures: [GestureBinding]) -> GestureBinding? { gestures.first { $0.kind == kind } } - private func directionalPanKind(angle: CGFloat) -> GestureBinding.Kind { + private func directionalSwipeKind(angle: CGFloat) -> GestureBinding.Kind { let normalizedAngle = normalizedAngle(fromSubsurfaceAngle: angle) if normalizedAngle >= 7 * .pi / 4 || normalizedAngle < .pi / 4 { - return .panUp + return .swipeUp } else if normalizedAngle >= .pi / 4, normalizedAngle < 3 * .pi / 4 { - return .panRight + return .swipeRight } else if normalizedAngle >= 3 * .pi / 4, normalizedAngle < 5 * .pi / 4 { - return .panDown + return .swipeDown } else { - return .panLeft + return .swipeLeft } } diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index eccf955b..c873165f 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -622,7 +622,7 @@ } }, "%lld-finger %@" : { - "comment" : "Default title describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Pinch', 'Swipe Up').", + "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" : { @@ -632,7 +632,7 @@ } } }, - "%lld-finger Swipe or Zoom" : { + "%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." : { @@ -2408,7 +2408,7 @@ } }, "Both" : { - "comment" : "Gesture kind: swipe and zoom gestures; opens the radial menu\nGesture tab label: swipe and zoom gestures" + "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", @@ -8105,7 +8105,7 @@ } }, "Gesture" : { - "comment" : "Label for the zoom gesture picker in the gesture configuration modal" + "comment" : "Label for the magnify gesture picker in the gesture configuration modal" }, "Gestures" : { "comment" : "Section header shown in gestures settings" @@ -16962,6 +16962,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" : { @@ -23939,9 +23948,6 @@ } } }, - "Pinch" : { - "comment" : "Gesture kind: fingers move together\nZoom gesture picker option: pinch" - }, "Please include at least one modifier key." : { "comment" : "An error message displayed when a custom keybind is created but does not include at least one modifier key.", "localizations" : { @@ -30420,9 +30426,6 @@ } } }, - "Spread" : { - "comment" : "Gesture kind: fingers move apart\nZoom gesture picker option: spread" - }, "Stage Manager" : { "comment" : "Section header shown in settings", "localizations" : { @@ -30956,7 +30959,7 @@ } }, "Swipe" : { - "comment" : "Gesture tab label: swipe" + "comment" : "Gesture category label: swipe" }, "Swipe Down" : { "comment" : "Gesture kind: directional swipe" @@ -36030,9 +36033,6 @@ } } } - }, - "Zoom" : { - "comment" : "Gesture tab label: zoom" } }, "version" : "1.1" diff --git a/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift index 344307ef..38c3bf15 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureConfigurationView.swift @@ -37,19 +37,19 @@ struct GestureConfigurationView: View { ) } - private var tabBinding: Binding { + private var categoryBinding: Binding { Binding( - get: { GestureTab(kind: gesture.kind) }, - set: { tab in - switch tab { + get: { GestureCategory(kind: gesture.kind) }, + set: { category in + switch category { case .all: kindBinding.wrappedValue = .radialMenu case .swipe: - if !gesture.kind.isDirectionalPan { - kindBinding.wrappedValue = .panRight + if !gesture.kind.isDirectionalSwipe { + kindBinding.wrappedValue = .swipeRight } - case .zoom: - kindBinding.wrappedValue = .pinch + case .magnify: + kindBinding.wrappedValue = .magnifyIn } } ) @@ -62,9 +62,9 @@ struct GestureConfigurationView: View { ) } - private var zoomGestureBinding: Binding { + private var magnifyGestureBinding: Binding { Binding( - get: { ZoomGesture(kind: gesture.kind) ?? .pinch }, + get: { MagnifyGesture(kind: gesture.kind) ?? .magnifyIn }, set: { kindBinding.wrappedValue = $0.kind } ) } @@ -102,21 +102,21 @@ struct GestureConfigurationView: View { LuminareSection { LuminarePicker( - compactElements: GestureTab.allCases, - selection: tabBinding - ) { tab in + compactElements: GestureCategory.allCases, + selection: categoryBinding + ) { category in HStack(spacing: 6) { - tab.image + category.image .frame(width: 12) - Text(tab.displayName) + Text(category.displayName) } .fixedSize() } .luminareContentSize(hasFixedHeight: true) .luminareRoundingBehavior(top: true, bottom: gesture.kind == .radialMenu) - if GestureTab(kind: gesture.kind) == .swipe { + if GestureCategory(kind: gesture.kind) == .swipe { LuminarePickerMenu( "Direction", selection: swipeDirectionBinding, @@ -126,13 +126,13 @@ struct GestureConfigurationView: View { } } - if GestureTab(kind: gesture.kind) == .zoom { + if GestureCategory(kind: gesture.kind) == .magnify { LuminarePickerMenu( "Gesture", - selection: zoomGestureBinding, - items: ZoomGesture.allCases - ) { gesture in - Text("\(gesture.image) \(gesture.displayName)") + selection: magnifyGestureBinding, + items: MagnifyGesture.allCases + ) { magnifyGesture in + Text("\(magnifyGesture.image) \(magnifyGesture.displayName)") } } } @@ -180,30 +180,30 @@ struct GestureConfigurationView: View { } } -private enum GestureTab: CaseIterable, Equatable { +private enum GestureCategory: CaseIterable, Equatable { case all case swipe - case zoom + case magnify init(kind: GestureBinding.Kind) { switch kind { case .radialMenu: self = .all - case .panUp, .panDown, .panLeft, .panRight: + case .swipeUp, .swipeDown, .swipeLeft, .swipeRight: self = .swipe - case .pinch, .spread: - self = .zoom + case .magnifyIn, .magnifyOut: + self = .magnify } } var displayName: String { switch self { case .all: - String(localized: "Both", comment: "Gesture tab label: swipe and zoom gestures") + String(localized: "Both", comment: "Gesture category label: swipe and magnify gestures") case .swipe: - String(localized: "Swipe", comment: "Gesture tab label: swipe") - case .zoom: - String(localized: "Zoom", comment: "Gesture tab label: zoom") + String(localized: "Swipe", comment: "Gesture category label: swipe") + case .magnify: + String(localized: "Magnify", comment: "Gesture category label: magnify") } } @@ -213,7 +213,7 @@ private enum GestureTab: CaseIterable, Equatable { Image(.loop) case .swipe: Image(systemName: "arrow.up.and.down.and.arrow.left.and.right") - case .zoom: + case .magnify: Image(systemName: "arrow.down.left.and.arrow.up.right") } } @@ -227,15 +227,15 @@ private enum SwipeDirection: CaseIterable { init?(kind: GestureBinding.Kind) { switch kind { - case .panUp: + case .swipeUp: self = .up - case .panDown: + case .swipeDown: self = .down - case .panLeft: + case .swipeLeft: self = .left - case .panRight: + case .swipeRight: self = .right - case .radialMenu, .pinch, .spread: + case .radialMenu, .magnifyIn, .magnifyOut: return nil } } @@ -243,13 +243,13 @@ private enum SwipeDirection: CaseIterable { var kind: GestureBinding.Kind { switch self { case .up: - .panUp + .swipeUp case .down: - .panDown + .swipeDown case .left: - .panLeft + .swipeLeft case .right: - .panRight + .swipeRight } } @@ -271,36 +271,36 @@ private enum SwipeDirection: CaseIterable { } } -private enum ZoomGesture: CaseIterable { - case pinch - case spread +private enum MagnifyGesture: CaseIterable { + case magnifyIn + case magnifyOut init?(kind: GestureBinding.Kind) { switch kind { - case .pinch: - self = .pinch - case .spread: - self = .spread - case .radialMenu, .panUp, .panDown, .panLeft, .panRight: + case .magnifyIn: + self = .magnifyIn + case .magnifyOut: + self = .magnifyOut + case .radialMenu, .swipeUp, .swipeDown, .swipeLeft, .swipeRight: return nil } } var kind: GestureBinding.Kind { switch self { - case .pinch: - .pinch - case .spread: - .spread + case .magnifyIn: + .magnifyIn + case .magnifyOut: + .magnifyOut } } var displayName: String { switch self { - case .pinch: - String(localized: "Pinch", comment: "Zoom gesture picker option: pinch") - case .spread: - String(localized: "Spread", comment: "Zoom gesture picker option: spread") + case .magnifyIn: + String(localized: "Magnify In", comment: "Magnify gesture picker option: magnifyIn") + case .magnifyOut: + String(localized: "Magnify Out", comment: "Magnify gesture picker option: magnifyOut") } } diff --git a/Loop/Window Management/Window Action/GestureBinding.swift b/Loop/Window Management/Window Action/GestureBinding.swift index 561ac00f..b45e39c2 100644 --- a/Loop/Window Management/Window Action/GestureBinding.swift +++ b/Loop/Window Management/Window Action/GestureBinding.swift @@ -33,51 +33,51 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { } enum Kind: Codable, Hashable, CaseIterable { - /// Pan gesture that maps angle to radial menu directional slots. + /// Swipe gesture that maps angle to radial menu directional slots. case radialMenu - /// Directional pan gestures that trigger a single action. - case panUp, panDown, panLeft, panRight - /// Pinch gesture (fingers together, scale < 1). - case pinch - /// Spread gesture (fingers apart, scale > 1). - case spread + /// 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 zoom gestures; opens the radial menu") - case .panUp: String(localized: "Swipe Up", comment: "Gesture kind: directional swipe") - case .panDown: String(localized: "Swipe Down", comment: "Gesture kind: directional swipe") - case .panLeft: String(localized: "Swipe Left", comment: "Gesture kind: directional swipe") - case .panRight: String(localized: "Swipe Right", comment: "Gesture kind: directional swipe") - case .pinch: String(localized: "Pinch", comment: "Gesture kind: fingers move together") - case .spread: String(localized: "Spread", comment: "Gesture kind: fingers move apart") + 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 .panUp: Image(systemName: "arrow.up") - case .panDown: Image(systemName: "arrow.down") - case .panLeft: Image(systemName: "arrow.left") - case .panRight: Image(systemName: "arrow.right") - case .pinch: Image(systemName: "arrow.up.right.and.arrow.down.left") - case .spread: Image(systemName: "arrow.down.left.and.arrow.up.right") + 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 isPan: Bool { + var isSwipe: Bool { switch self { - case .radialMenu, .panUp, .panDown, .panLeft, .panRight: + case .radialMenu, .swipeUp, .swipeDown, .swipeLeft, .swipeRight: true - case .pinch, .spread: + case .magnifyIn, .magnifyOut: false } } - var isDirectionalPan: Bool { + var isDirectionalSwipe: Bool { switch self { - case .panUp, .panDown, .panLeft, .panRight: + case .swipeUp, .swipeDown, .swipeLeft, .swipeRight: true default: false @@ -86,7 +86,7 @@ struct GestureBinding: Identifiable, Codable, Hashable, Defaults.Serializable { } enum Action: Codable, Hashable { - /// Uses `RadialMenuAction.userConfiguredActions` for radial menu pan mode. + /// Uses `RadialMenuAction.userConfiguredActions` for radial menu swipe mode. case radialMenuActions /// A single action, either custom or referencing a keybind. case singleAction(RadialMenuAction.ActionType) @@ -119,13 +119,13 @@ extension GestureBinding { switch kind { case .radialMenu: String( - localized: "\(fingerCount)-finger Swipe or Zoom", + 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. 'Pinch', 'Swipe Up')." + comment: "Default title describing a gesture. First argument is the finger count, second is the gesture kind name (e.g. 'Magnify In', 'Swipe Up')." ) } } @@ -161,13 +161,13 @@ extension GestureBinding { } /// Two gestures conflict when they have the same finger count and their kinds overlap. - /// Radial menu consumes both pan and pinch, so it conflicts with ANY other gesture at the same finger count. + /// 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 pan and pinch, so it conflicts with everything at the same finger count + // 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 } From a72dbc394be0a2c526adcc4a11c3de4f3c9b44f9 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 23:23:20 -0600 Subject: [PATCH 53/61] =?UTF-8?q?=E2=9C=A8=20Add=20info=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Localizable.xcstrings | 3 +++ .../Settings/Gestures/GesturesConfigurationView.swift | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index c873165f..b7f4812c 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -4185,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" : { diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index 3df8a299..eaf9ab77 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -44,7 +44,10 @@ struct GesturesConfigurationView: View { } private var gesturesSection: some View { - LuminareSection(String(localized: "Gestures", comment: "Section header shown in gestures settings")) { + 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( From ed17e3dbd50848852962eeedaff20438cbe03df5 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 23:31:47 -0600 Subject: [PATCH 54/61] =?UTF-8?q?=F0=9F=90=9E=20Prioritize=20actionable=20?= =?UTF-8?q?gestures=20over=20`noAction`=20placeholders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MultitouchRecognizerRegistry.swift | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift b/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift index f7f93b70..793bc996 100644 --- a/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift +++ b/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift @@ -24,9 +24,10 @@ final class MultitouchRecognizerRegistry { _ gestures: [GestureBinding] ) -> (radial: GestureBinding?, directionals: [GestureBinding], magnifyIn: GestureBinding?, magnifyOut: GestureBinding?) { let radial = gestures.first { $0.kind == .radialMenu } - let directionals = gestures.filter(\.kind.isDirectionalSwipe) - let magnifyIn = gestures.first { $0.kind == .magnifyIn } - let magnifyOut = gestures.first { $0.kind == .magnifyOut } + 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) } } @@ -138,3 +139,15 @@ final class MultitouchRecognizerRegistry { 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 + } + } +} From 9a514bb0799c7523cbd3a59c5d6bdf8fb7765882 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 5 Jul 2026 23:48:03 -0600 Subject: [PATCH 55/61] =?UTF-8?q?=E2=9A=A1=20Harden=20multitouch=20gesture?= =?UTF-8?q?=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 20 +++++++++++----- Loop/Core/Multitouch/MultitouchTrigger.swift | 23 +++++++++++++------ .../BaseEventTapMonitor.swift | 18 +++++++++------ 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index b10d7d3b..8fdf2ac2 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -90,8 +90,8 @@ final class LoopManager { private(set) lazy var multitouchTrigger = MultitouchTrigger( windowActionCache: windowActionCache, openCallback: { [weak self] action, window in - guard let self else { return } - try await openLoop(startingAction: action, window: window) + guard let self else { return .cancelled } + return try await openLoop(startingAction: action, window: window) }, closeCallback: { [weak self] forceClose in Task { @@ -205,10 +205,17 @@ enum LoopManagerError: LocalizedError { } } +enum LoopOpenResult { + case opened + case alreadyOpening + case alreadyOpen + case cancelled +} + // MARK: - Opening/Closing Loop extension LoopManager { - private func openLoop(startingAction: WindowAction, window: Window? = nil) async throws { + private func openLoop(startingAction: WindowAction, window: Window? = nil) async throws -> LoopOpenResult { guard AccessibilityManager.shared.isGranted else { throw LoopManagerError.accessibilityNotGranted } @@ -217,7 +224,7 @@ extension LoopManager { if startingAction.direction != .noSelection { pendingOpeningAction = startingAction } - return + return .alreadyOpening } guard !isLoopActive else { @@ -229,7 +236,7 @@ extension LoopManager { await changeAction(startingAction, disableHapticFeedback: true) } - return + return .alreadyOpen } let window = window ?? WindowUtility.userDefinedTargetWindow() @@ -277,7 +284,7 @@ extension LoopManager { await resizeContext.refreshResolvedState() guard !shouldCancelOpening else { - return + return .cancelled } if !Defaults[.disableCursorInteraction] { @@ -290,6 +297,7 @@ extension LoopManager { await changeAction(pendingOpeningAction ?? startingAction, disableHapticFeedback: true) triggerKeyTimeoutTimer.start() + return .opened } private func closeLoop(forceClose: Bool) async { diff --git a/Loop/Core/Multitouch/MultitouchTrigger.swift b/Loop/Core/Multitouch/MultitouchTrigger.swift index 5d3f20d7..df6f81ab 100644 --- a/Loop/Core/Multitouch/MultitouchTrigger.swift +++ b/Loop/Core/Multitouch/MultitouchTrigger.swift @@ -14,7 +14,7 @@ import SwiftUI @MainActor final class MultitouchTrigger { private let windowActionCache: WindowActionCache - private let openCallback: (WindowAction, Window) async throws -> () + private let openCallback: (WindowAction, Window) async throws -> LoopOpenResult private let closeCallback: (Bool) -> () private let changeAction: (WindowAction, Bool) -> () private let checkIfLoopOpen: () -> Bool @@ -31,6 +31,7 @@ final class MultitouchTrigger { private let targetResolver = MultitouchTargetResolver() private var gesturesObservationTask: Task<(), Never>? + private var radialMenuActionsObservationTask: Task<(), Never>? private var systemGestureReconciliationTask: Task<(), Never>? private var isStarted = false @@ -39,15 +40,13 @@ final class MultitouchTrigger { private let magnifyCycleStepSize: CGFloat = 0.15 private let cardinalBiasedRadialMenuActionCount = 8 - private var radialMenuActions: [RadialMenuAction] { - RadialMenuAction.userConfiguredActions - } + private var radialMenuActions = RadialMenuAction.userConfiguredActions private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) init( windowActionCache: WindowActionCache, - openCallback: @escaping (WindowAction, Window) async throws -> (), + openCallback: @escaping (WindowAction, Window) async throws -> LoopOpenResult, closeCallback: @escaping (Bool) -> (), changeAction: @escaping (WindowAction, Bool) -> (), checkIfLoopOpen: @escaping () -> Bool @@ -73,6 +72,7 @@ final class MultitouchTrigger { prepare() gestureMonitor.start() rebuildRecognizers() + radialMenuActions = RadialMenuAction.userConfiguredActions gesturesObservationTask = Task { [weak self] in // Watch keybinds too, so gestures referencing a deleted keybind stay in sync. @@ -81,6 +81,13 @@ final class MultitouchTrigger { rebuildRecognizers() } } + + radialMenuActionsObservationTask = Task { [weak self] in + for await _ in Defaults.updates(.enableRadialMenuCustomization, .radialMenuActions) { + guard !Task.isCancelled, let self else { break } + radialMenuActions = RadialMenuAction.userConfiguredActions + } + } } func stop() { @@ -90,6 +97,8 @@ final class MultitouchTrigger { reconcileSystemGestures() gesturesObservationTask?.cancel() gesturesObservationTask = nil + radialMenuActionsObservationTask?.cancel() + radialMenuActionsObservationTask = nil handleStopResults(recognizerRegistry.stopAll()) @@ -440,8 +449,8 @@ final class MultitouchTrigger { var openedLoop = false if let window = session.pendingTargetWindow { do { - try await openCallback(.init(.noSelection), window) - openedLoop = true + let result = try await openCallback(.init(.noSelection), window) + openedLoop = result == .opened } catch { if recognizerRegistry.contains(session: session, for: fingerCount) { session.reject() diff --git a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift index 329f81c2..a5388a8a 100644 --- a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift +++ b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift @@ -38,15 +38,19 @@ class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { func setupRunLoopSource(eventTap: CFMachPort, readableIdentifier: String) { let runLoop = EventTapThread.shared.runLoop self.readableIdentifier = readableIdentifier - refconRetainOutstanding = true - if let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) { - self.eventTap = eventTap - 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() { From 62a0165298517462a99788ea25333cef054bfde2 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 6 Jul 2026 00:06:00 -0600 Subject: [PATCH 56/61] =?UTF-8?q?=E2=9A=A1=20Avoid=20repetitive=20gesture?= =?UTF-8?q?=20rejection=20processing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Multitouch/MultitouchTrigger.swift | 5 +- .../Multitouch/SystemGestureManager.swift | 97 ++++++++++--------- .../Settings/Gestures/GestureItemView.swift | 9 +- .../Gestures/GesturesConfigurationView.swift | 9 +- 4 files changed, 63 insertions(+), 57 deletions(-) diff --git a/Loop/Core/Multitouch/MultitouchTrigger.swift b/Loop/Core/Multitouch/MultitouchTrigger.swift index df6f81ab..518e1833 100644 --- a/Loop/Core/Multitouch/MultitouchTrigger.swift +++ b/Loop/Core/Multitouch/MultitouchTrigger.swift @@ -237,12 +237,13 @@ final class MultitouchTrigger { switch swipe.phase { case .began, .changed: - if recognizerRegistry.session(for: fingerCount)?.hasGestureBegun != true { + 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 let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } guard await activateGestureIfNeeded(fingerCount: fingerCount) else { return } guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } diff --git a/Loop/Core/Multitouch/SystemGestureManager.swift b/Loop/Core/Multitouch/SystemGestureManager.swift index 573769a5..97820311 100644 --- a/Loop/Core/Multitouch/SystemGestureManager.swift +++ b/Loop/Core/Multitouch/SystemGestureManager.swift @@ -182,14 +182,21 @@ final class SystemGestureManager { } } + struct PreferenceLedger { + var backups: [String: SystemGesturePreferenceValue] + var managedValues: [String: SystemGesturePreferenceValue] + } + static func restore() { - var backups = Defaults[.systemGesturePreferenceBackups] - var managedValues = Defaults[.systemGestureManagedValues] + var ledger = PreferenceLedger( + backups: Defaults[.systemGesturePreferenceBackups], + managedValues: Defaults[.systemGestureManagedValues] + ) - Self().restoreAll(backups: &backups, managedValues: &managedValues) + Self().restoreAll(&ledger) - Defaults[.systemGesturePreferenceBackups] = backups - Defaults[.systemGestureManagedValues] = managedValues + Defaults[.systemGesturePreferenceBackups] = ledger.backups + Defaults[.systemGestureManagedValues] = ledger.managedValues } static func reconcile( @@ -197,46 +204,45 @@ final class SystemGestureManager { disableConflicts: Bool, gestures: [GestureBinding] ) { - var backups = Defaults[.systemGesturePreferenceBackups] - var managedValues = Defaults[.systemGestureManagedValues] + var ledger = PreferenceLedger( + backups: Defaults[.systemGesturePreferenceBackups], + managedValues: Defaults[.systemGestureManagedValues] + ) Self().reconcile( enableGestures: enableGestures, disableConflicts: disableConflicts, gestures: gestures, - backups: &backups, - managedValues: &managedValues + ledger: &ledger ) - Defaults[.systemGesturePreferenceBackups] = backups - Defaults[.systemGestureManagedValues] = managedValues + Defaults[.systemGesturePreferenceBackups] = ledger.backups + Defaults[.systemGestureManagedValues] = ledger.managedValues } func reconcile( enableGestures: Bool, disableConflicts: Bool, gestures: [GestureBinding], - backups: inout [String: SystemGesturePreferenceValue], - managedValues: inout [String: SystemGesturePreferenceValue] + ledger: inout PreferenceLedger ) { - normalizeStoredValues(&backups) - normalizeStoredValues(&managedValues) + normalizeStoredValues(&ledger.backups) + normalizeStoredValues(&ledger.managedValues) guard enableGestures, disableConflicts else { - restoreAll(backups: &backups, managedValues: &managedValues) + restoreAll(&ledger) return } - let desiredValues = desiredValues(for: gestures, backups: backups, managedValues: managedValues) + let desiredValues = desiredValues(for: gestures, backups: ledger.backups, managedValues: ledger.managedValues) guard !desiredValues.isEmpty else { - restoreAll(backups: &backups, managedValues: &managedValues) + restoreAll(&ledger) return } restoreNoLongerManagedValues( desiredValues: desiredValues, - backups: &backups, - managedValues: &managedValues + ledger: &ledger ) var touchedDomains = Set() @@ -245,29 +251,29 @@ final class SystemGestureManager { for (identifier, desiredValue) in desiredValues { let currentValue = identifier.get() let desiredValue = identifier.normalized(desiredValue) - let lastManagedValue = managedValues[identifier.compositeKey].map(identifier.normalized) + let lastManagedValue = ledger.managedValues[identifier.compositeKey].map(identifier.normalized) if currentValue != lastManagedValue, currentValue != desiredValue { - backups[identifier.compositeKey] = currentValue + ledger.backups[identifier.compositeKey] = currentValue } - let backupValue = backups[identifier.compositeKey].map(identifier.normalized) + let backupValue = ledger.backups[identifier.compositeKey].map(identifier.normalized) guard currentValue != desiredValue else { if backupValue != nil { - managedValues[identifier.compositeKey] = desiredValue + ledger.managedValues[identifier.compositeKey] = desiredValue } else { - managedValues.removeValue(forKey: identifier.compositeKey) + ledger.managedValues.removeValue(forKey: identifier.compositeKey) } continue } guard backupValue != nil else { - managedValues.removeValue(forKey: identifier.compositeKey) + ledger.managedValues.removeValue(forKey: identifier.compositeKey) continue } identifier.set(desiredValue) - managedValues[identifier.compositeKey] = desiredValue + ledger.managedValues[identifier.compositeKey] = desiredValue touchedDomains.insert(identifier.domain) touchedDock = touchedDock || identifier.isDockDomain touchedTrackpad = touchedTrackpad || identifier.isTrackpadDomain @@ -282,38 +288,33 @@ final class SystemGestureManager { } } - private func restoreAll( - backups: inout [String: SystemGesturePreferenceValue], - managedValues: inout [String: SystemGesturePreferenceValue] - ) { - normalizeStoredValues(&backups) - normalizeStoredValues(&managedValues) + private func restoreAll(_ ledger: inout PreferenceLedger) { + normalizeStoredValues(&ledger.backups) + normalizeStoredValues(&ledger.managedValues) - let identifiers = Set(backups.keys) - .union(managedValues.keys) + let identifiers = Set(ledger.backups.keys) + .union(ledger.managedValues.keys) .compactMap(SystemGesturePreferenceIdentifier.init(compositeKey:)) - restore(identifiers, backups: &backups, managedValues: &managedValues) - backups.removeAll() - managedValues.removeAll() + restore(identifiers, ledger: &ledger) + ledger.backups.removeAll() + ledger.managedValues.removeAll() } private func restoreNoLongerManagedValues( desiredValues: [SystemGesturePreferenceIdentifier: SystemGesturePreferenceValue], - backups: inout [String: SystemGesturePreferenceValue], - managedValues: inout [String: SystemGesturePreferenceValue] + ledger: inout PreferenceLedger ) { let desiredKeys = Set(desiredValues.keys.map(\.compositeKey)) - let staleIdentifiers = managedValues.keys + let staleIdentifiers = ledger.managedValues.keys .filter { !desiredKeys.contains($0) } .compactMap(SystemGesturePreferenceIdentifier.init(compositeKey:)) - restore(staleIdentifiers, backups: &backups, managedValues: &managedValues) + restore(staleIdentifiers, ledger: &ledger) } private func restore( _ identifiers: [SystemGesturePreferenceIdentifier], - backups: inout [String: SystemGesturePreferenceValue], - managedValues: inout [String: SystemGesturePreferenceValue] + ledger: inout PreferenceLedger ) { guard !identifiers.isEmpty else { return } @@ -322,14 +323,14 @@ final class SystemGestureManager { var touchedTrackpad = false for identifier in identifiers { - guard let backupValue = backups[identifier.compositeKey].map(identifier.normalized) else { - managedValues.removeValue(forKey: identifier.compositeKey) + guard let backupValue = ledger.backups[identifier.compositeKey].map(identifier.normalized) else { + ledger.managedValues.removeValue(forKey: identifier.compositeKey) continue } identifier.set(backupValue) - backups.removeValue(forKey: identifier.compositeKey) - managedValues.removeValue(forKey: identifier.compositeKey) + ledger.backups.removeValue(forKey: identifier.compositeKey) + ledger.managedValues.removeValue(forKey: identifier.compositeKey) touchedDomains.insert(identifier.domain) touchedDock = touchedDock || identifier.isDockDomain touchedTrackpad = touchedTrackpad || identifier.isTrackpadDomain diff --git a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift index 179d7fb2..9584cfdf 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -13,23 +13,20 @@ struct GestureItemView: View { @Environment(\.luminareAnimation) var luminareAnimation @Default(.keybinds) private var keybinds - @Default(.gestures) private var gestures @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) { + init(_ gesture: Binding, hasConflict: Bool) { self.gesture = gesture.wrappedValue self._externalGesture = gesture - } - - private var hasConflict: Bool { - GestureBinding.conflictingActionableIDs(in: gestures).contains(gesture.id) + self.hasConflict = hasConflict } private var resolvedAction: WindowAction? { diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index eaf9ab77..568cb076 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -20,6 +20,10 @@ struct GesturesConfigurationView: View { @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 { @@ -70,7 +74,10 @@ struct GesturesConfigurationView: View { selection: $model.selectedGestures, id: \.id ) { gesture in - GestureItemView(gesture) + GestureItemView( + gesture, + hasConflict: conflictingGestureIDs.contains(gesture.wrappedValue.id) + ) } emptyView: { HStack { Spacer() From 44303f9160d899f5bad7077be036342cf55d3fdc Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 6 Jul 2026 00:13:55 -0600 Subject: [PATCH 57/61] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/Gestures/GesturesConfigurationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift index 568cb076..7f99a6d4 100644 --- a/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift +++ b/Loop/Settings Window/Settings/Gestures/GesturesConfigurationView.swift @@ -20,7 +20,7 @@ struct GesturesConfigurationView: View { @Default(.enableGestures) private var enableGestures @Default(.gestures) private var gestures @Default(.gestureTitlebarHeight) private var gestureTitlebarHeight - + private var conflictingGestureIDs: Set { GestureBinding.conflictingActionableIDs(in: gestures) } From 082ba3b32b41a2cf29df72cfaeeacbf87a09b014 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 6 Jul 2026 00:46:07 -0600 Subject: [PATCH 58/61] =?UTF-8?q?=F0=9F=8E=A8=20Organize=20code=20more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 2 +- .../Multitouch/MultitouchGestureBlocker.swift | 1 + .../Multitouch/MultitouchGestureSession.swift | 36 +- .../Multitouch/MultitouchTargetResolver.swift | 2 +- .../MultitouchTrigger+Magnify.swift | 168 +++++++ .../Multitouch/MultitouchTrigger+Swipe.swift | 256 +++++++++++ Loop/Core/Multitouch/MultitouchTrigger.swift | 433 +----------------- 7 files changed, 474 insertions(+), 424 deletions(-) create mode 100644 Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift create mode 100644 Loop/Core/Multitouch/MultitouchTrigger+Swipe.swift diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 8fdf2ac2..73347c5b 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -154,7 +154,7 @@ final class LoopManager { } gestureToggleTask = Task(priority: .background) { [weak self] in - for await enabled in Defaults.updates(.enableGestures) { + for await enabled in Defaults.updates(.enableGestures, initial: false) { guard let self, !Task.isCancelled else { break } if enabled, AccessibilityManager.shared.isGranted { diff --git a/Loop/Core/Multitouch/MultitouchGestureBlocker.swift b/Loop/Core/Multitouch/MultitouchGestureBlocker.swift index 9596db83..b06fc868 100644 --- a/Loop/Core/Multitouch/MultitouchGestureBlocker.swift +++ b/Loop/Core/Multitouch/MultitouchGestureBlocker.swift @@ -28,6 +28,7 @@ final class MultitouchGestureBlocker { 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) diff --git a/Loop/Core/Multitouch/MultitouchGestureSession.swift b/Loop/Core/Multitouch/MultitouchGestureSession.swift index d330a725..619c1931 100644 --- a/Loop/Core/Multitouch/MultitouchGestureSession.swift +++ b/Loop/Core/Multitouch/MultitouchGestureSession.swift @@ -26,7 +26,7 @@ final class MultitouchGestureSession { private var lastCommittedAction: ActionKey? private var lastCommitSwipeDistance: CGFloat = 0 private var lastCommitMagnifyDistance: CGFloat = 0 - private var magnificationDirection: Int = 0 + private var magnificationKind: GestureBinding.Kind? func reset() { didOpenLoopWithThisGesture = false @@ -38,7 +38,7 @@ final class MultitouchGestureSession { lastCommittedAction = nil lastCommitSwipeDistance = 0 lastCommitMagnifyDistance = 0 - magnificationDirection = 0 + magnificationKind = nil } func begin( @@ -120,24 +120,29 @@ final class MultitouchGestureSession { } func commitMagnify( + gesture: GestureBinding, distance: CGFloat, - originDistance: CGFloat, - newKey: ActionKey, step: CGFloat, canRepeat: Bool, fire: (_ reverse: Bool) -> () ) { + let newKey = ActionKey.gesture(gesture.id) + if lastCommittedAction != newKey { - magnificationDirection = distance >= originDistance ? 1 : -1 + magnificationKind = gesture.kind lastCommittedAction = newKey lastCommitMagnifyDistance = distance fire(false) return } - guard canRepeat else { return } + guard canRepeat, + let magnificationDirection = magnificationKind?.magnificationDirection + else { + return + } - let delta = (distance - lastCommitMagnifyDistance) * CGFloat(magnificationDirection) + let delta = (distance - lastCommitMagnifyDistance) * magnificationDirection if delta >= step { lastCommitMagnifyDistance = distance fire(false) @@ -157,10 +162,10 @@ final class MultitouchGestureSession { lastCommitSwipeDistance = distance } - func switchMagnifyGesture(to gesture: GestureBinding, distance: CGFloat, direction: Int) { + func switchMagnifyGesture(to gesture: GestureBinding, distance: CGFloat) { resolvedGesture = gesture lastCommittedAction = .gesture(gesture.id) - magnificationDirection = direction + magnificationKind = gesture.kind lastCommitMagnifyDistance = distance } @@ -172,3 +177,16 @@ final class MultitouchGestureSession { 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/MultitouchTargetResolver.swift b/Loop/Core/Multitouch/MultitouchTargetResolver.swift index b8a8e0d7..3109b386 100644 --- a/Loop/Core/Multitouch/MultitouchTargetResolver.swift +++ b/Loop/Core/Multitouch/MultitouchTargetResolver.swift @@ -55,7 +55,7 @@ final class MultitouchTargetResolver { minimumTitlebarHeight } - log.info("Detected titlebar height of \(titlebarHeight)") + log.debug("Detected titlebar height of \(titlebarHeight)") let titlebarMinY = window.frame.minY let titlebarMaxY = window.frame.minY + titlebarHeight diff --git a/Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift b/Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift new file mode 100644 index 00000000..14ee100e --- /dev/null +++ b/Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift @@ -0,0 +1,168 @@ +// +// 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, + magnifyActivationThreshold: magnifyCycleStepSize + ), + 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 canRepeatAction = resolvedWindowAction(from: activeGesture).map { + $0.canRepeat || $0.direction == .cycle + } ?? false + + session.commitMagnify( + gesture: activeGesture, + distance: magnify.distance, + step: magnifyCycleStepSize, + canRepeat: canRepeatAction + ) { reverse in + triggerSingleAction(from: activeGesture, reverse: reverse) + } + + if let window = session.pendingTargetWindow, + resolvedWindowAction(from: activeGesture)?.canRepeat == true { + targetResolver.rememberRepeatableWindow(window, canRepeat: 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: magnifyCycleStepSize + ) { 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)?.canRepeat == true { + targetResolver.rememberRepeatableWindow(window, canRepeat: 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..8b14abd2 --- /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)?.canRepeat == true { + targetResolver.rememberRepeatableWindow(window, canRepeat: 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)?.canRepeat == true { + targetResolver.rememberRepeatableWindow(window, canRepeat: 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 index 518e1833..09f95c15 100644 --- a/Loop/Core/Multitouch/MultitouchTrigger.swift +++ b/Loop/Core/Multitouch/MultitouchTrigger.swift @@ -21,26 +21,26 @@ final class MultitouchTrigger { private let gestureMonitor = SubsurfaceMonitor() private let gestureBlocker: MultitouchGestureBlocker = .init() - private lazy var recognizerRegistry = MultitouchRecognizerRegistry( + lazy var recognizerRegistry = MultitouchRecognizerRegistry( gestureMonitor: gestureMonitor ) { [weak self] event, fingerCount in guard let self else { return } await handleGestureEvent(event, fingerCount: fingerCount) } - private let targetResolver = MultitouchTargetResolver() + let targetResolver = MultitouchTargetResolver() private var gesturesObservationTask: Task<(), Never>? private var radialMenuActionsObservationTask: Task<(), Never>? private var systemGestureReconciliationTask: Task<(), Never>? private var isStarted = false - private let swipeCycleStepSize: CGFloat = 0.15 - private let magnifyActivationThreshold: CGFloat = 0.3 - private let magnifyCycleStepSize: CGFloat = 0.15 - private let cardinalBiasedRadialMenuActionCount = 8 + let swipeCycleStepSize: CGFloat = 0.15 + let magnifyActivationThreshold: CGFloat = 0.3 + let magnifyCycleStepSize: CGFloat = 0.15 + let cardinalBiasedRadialMenuActionCount = 8 - private var radialMenuActions = RadialMenuAction.userConfiguredActions + var radialMenuActions = RadialMenuAction.userConfiguredActions private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) @@ -57,11 +57,6 @@ final class MultitouchTrigger { self.changeAction = changeAction self.checkIfLoopOpen = checkIfLoopOpen - prepare() - } - - func prepare() { - reconcileSystemGestures() startSystemGestureReconciliation() } @@ -69,21 +64,21 @@ final class MultitouchTrigger { guard !isStarted else { return } isStarted = true - prepare() + 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) { + 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) { + for await _ in Defaults.updates(.enableRadialMenuCustomization, .radialMenuActions, initial: false) { guard !Task.isCancelled, let self else { break } radialMenuActions = RadialMenuAction.userConfiguredActions } @@ -166,234 +161,9 @@ final class MultitouchTrigger { } } - private 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 = matchDirectionalSwipeGesture(kind: direction, from: entry.directionalGestures) - 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) else { return } - guard 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) else { return } - guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return } - guard let activeGesture = session.resolvedGesture ?? matchedGesture else { return } - - if direction != activeGesture.kind { - if let matchedGesture { - switchSwipeGesture(fingerCount: fingerCount, to: matchedGesture, distance: swipe.distance) - } else if direction == oppositeDirectionalSwipeKind(of: activeGesture.kind) { - handleSwipeReversal( - fingerCount: fingerCount, - currentGesture: activeGesture, - oppositeGesture: oppositeDirectionalSwipeGesture(of: activeGesture, in: entry.directionalGestures), - distance: swipe.distance - ) - } else { - cancelSwipeGesture(fingerCount: fingerCount) - } - 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 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, - magnifyActivationThreshold: magnifyCycleStepSize - ) else { - return - } - guard 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 - } - - session.commitMagnify( - distance: magnify.distance, - originDistance: magnify.originDistance, - newKey: .gesture(activeGesture.id), - step: magnifyCycleStepSize, - canRepeat: canRepeatGestureAction(activeGesture) - ) { reverse in - triggerSingleAction(from: activeGesture, reverse: reverse) - } - - if let window = session.pendingTargetWindow, - resolvedWindowAction(from: activeGesture)?.canRepeat == true { - targetResolver.rememberRepeatableWindow(window, canRepeat: 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: magnifyCycleStepSize - ) { reverse in - triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: reverse) - } - - case .ended, .cancelled: - resetLoopState(for: fingerCount) - - default: - break - } - } - /// Resolves the target window and starts blocking trackpad events. Loop itself /// isn't opened until the gesture crosses the activation threshold in a `.changed` event. - private func handleGestureBegan(fingerCount: Int, gesture: GestureBinding) { + func handleGestureBegan(fingerCount: Int, gesture: GestureBinding) { let canRepeat = resolvedWindowAction(from: gesture)?.canRepeat == true let window = targetResolver.targetWindow(for: gesture, canRepeat: canRepeat) @@ -434,7 +204,7 @@ final class MultitouchTrigger { /// 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. - private func activateGestureIfNeeded( + func activateGestureIfNeeded( fingerCount: Int, magnifyDisplacement: CGFloat? = nil, magnifyActivationThreshold: CGFloat? = nil @@ -466,7 +236,7 @@ final class MultitouchTrigger { return true } - private func resetLoopState(for fingerCount: Int, forceClose: Bool = false) { + func resetLoopState(for fingerCount: Int, forceClose: Bool = false) { if recognizerRegistry.session(for: fingerCount)?.didOpenLoopWithThisGesture == true { closeCallback(forceClose) } @@ -474,128 +244,16 @@ final class MultitouchTrigger { gestureBlocker.stop() recognizerRegistry.session(for: fingerCount)?.reset() } +} - 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 oppositeDirectionalSwipeGesture( - of current: GestureBinding, - in directionals: [GestureBinding] - ) -> GestureBinding? { - guard let opposite = oppositeDirectionalSwipeKind(of: current.kind) else { return nil } - return directionals.first { $0.kind == opposite } - } +// MARK: - Actions - 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 isCycleAction(_ gesture: GestureBinding) -> Bool { +extension MultitouchTrigger { + func isCycleAction(_ gesture: GestureBinding) -> Bool { resolvedWindowAction(from: gesture)?.direction == .cycle } - private func canRepeatGestureAction(_ gesture: GestureBinding) -> Bool { - guard let action = resolvedWindowAction(from: gesture) else { return false } - return action.canRepeat || action.direction == .cycle - } - - 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)?.canRepeat == true { - targetResolver.rememberRepeatableWindow(window, canRepeat: true) - } - } - - private func cancelSwipeGesture(fingerCount: Int) { - resetLoopState(for: fingerCount, forceClose: true) - } - - private func cancelMagnifyGesture( - fingerCount: Int - ) { - resetLoopState(for: fingerCount, forceClose: true) - } - - private func handleSwipeReversal( - fingerCount: Int, - currentGesture: GestureBinding, - oppositeGesture: GestureBinding?, - distance: CGFloat - ) { - if 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)?.canRepeat == true { - targetResolver.rememberRepeatableWindow(window, canRepeat: true) - } - } else if isCycleAction(currentGesture) { - triggerSingleAction(from: currentGesture, reverse: true) - recognizerRegistry.session(for: fingerCount)?.updateLastCommitSwipeDistance(distance) - } else { - cancelSwipeGesture(fingerCount: fingerCount) - } - } - - private func handleMagnifyReversal( - fingerCount: Int, - currentGesture: GestureBinding, - oppositeGesture: GestureBinding?, - distance: CGFloat - ) { - if let oppositeGesture { - guard let session = recognizerRegistry.session(for: fingerCount) else { return } - // Direction is fixed by the new gesture's gesture type, not by current finger distance, - // since the user may not yet have crossed neutral when reversing - let direction = oppositeGesture.kind == .magnifyOut ? 1 : -1 - session.switchMagnifyGesture(to: oppositeGesture, distance: distance, direction: direction) - triggerSingleAction(from: oppositeGesture, reverse: false) - - if let window = session.pendingTargetWindow, - resolvedWindowAction(from: oppositeGesture)?.canRepeat == true { - targetResolver.rememberRepeatableWindow(window, canRepeat: true) - } - } else if isCycleAction(currentGesture) { - triggerSingleAction(from: currentGesture, reverse: true) - recognizerRegistry.session(for: fingerCount)?.updateLastCommitMagnifyDistance(distance) - } else { - cancelMagnifyGesture(fingerCount: fingerCount) - } - } - - private func triggerRadialMenuAction(at index: Int, from actions: ArraySlice, reverse: Bool = false) { + func triggerRadialMenuAction(at index: Int, from actions: ArraySlice, reverse: Bool = false) { guard actions.indices.contains(index) else { return } let action = actions[index] @@ -609,7 +267,7 @@ final class MultitouchTrigger { changeAction(resolvedAction, reverse) } - private func resolvedWindowAction(from gesture: GestureBinding) -> WindowAction? { + func resolvedWindowAction(from gesture: GestureBinding) -> WindowAction? { guard case let .singleAction(actionType) = gesture.action else { return nil } switch actionType { case let .custom(action): return action @@ -617,7 +275,7 @@ final class MultitouchTrigger { } } - private func triggerSingleAction(from gesture: GestureBinding, reverse: Bool = false) { + func triggerSingleAction(from gesture: GestureBinding, reverse: Bool = false) { guard let resolvedAction = resolvedWindowAction(from: gesture) else { return } changeAction(resolvedAction, reverse) } @@ -629,55 +287,4 @@ final class MultitouchTrigger { log.warn("Gesture references keybind \(id) that no longer exists") return Self.failedToResolveKeybindAction } - - private func matchDirectionalSwipeGesture(kind: GestureBinding.Kind, from gestures: [GestureBinding]) -> GestureBinding? { - gestures.first { $0.kind == kind } - } - - 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 - } - } - } } From 2fd8de4cabbe53a7574f2ecb92f3c07115faec12 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 6 Jul 2026 00:59:48 -0600 Subject: [PATCH 59/61] =?UTF-8?q?=E2=9A=A1=20improve=20multitouch=20gestur?= =?UTF-8?q?e=20lifecycle=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 2 -- .../Multitouch/MultitouchGestureBlocker.swift | 27 ++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 73347c5b..232043e3 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -131,8 +131,6 @@ final class LoopManager { ) func start() { - multitouchTrigger.prepare() - accessibilityCheckerTask = Task(priority: .background) { [weak self] in for await status in AccessibilityManager.shared.stream(initial: true) { guard let self, !Task.isCancelled else { diff --git a/Loop/Core/Multitouch/MultitouchGestureBlocker.swift b/Loop/Core/Multitouch/MultitouchGestureBlocker.swift index b06fc868..d15510e6 100644 --- a/Loop/Core/Multitouch/MultitouchGestureBlocker.swift +++ b/Loop/Core/Multitouch/MultitouchGestureBlocker.swift @@ -18,8 +18,10 @@ final class MultitouchGestureBlocker { private var activeCount: Int = 0 func start() { - activeCount += 1 - guard monitor == nil else { return } + if monitor != nil { + activeCount += 1 + return + } log.info("Starting gesture blocker") @@ -32,16 +34,27 @@ final class MultitouchGestureBlocker { CGEventType(rawValue: UInt32(NSEvent.EventType.smartMagnify.rawValue)) ].compactMap(\.self) - monitor = ActiveEventMonitor("gesture_blocker", events: eventTypes) { _ in .ignore } - monitor?.start() + 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() { - activeCount = max(0, activeCount - 1) + guard let monitor else { return } + + activeCount -= 1 guard activeCount == 0 else { return } - monitor?.stop() - monitor = nil + monitor.stop() + self.monitor = nil log.info("Stopped gesture blocker") } From f56c1de891e93a518a306a20154fbf8ae0c1b12a Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 6 Jul 2026 01:50:56 -0600 Subject: [PATCH 60/61] =?UTF-8?q?=E2=9C=A8=20Normalize=20repeated=20action?= =?UTF-8?q?=20handling=20+=20fix=20Loop=20not=20being=20able=20to=20resize?= =?UTF-8?q?=20own=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 13 +++--- .../Multitouch/MultitouchGestureSession.swift | 5 +-- .../Multitouch/MultitouchTargetResolver.swift | 8 ++-- .../MultitouchTrigger+Magnify.swift | 21 +++++----- .../Multitouch/MultitouchTrigger+Swipe.swift | 8 ++-- Loop/Core/Multitouch/MultitouchTrigger.swift | 16 ++++---- Loop/Core/Observers/KeybindTrigger.swift | 4 +- .../Settings/Gestures/GestureItemView.swift | 9 +++-- Loop/Stashing/StashManager.swift | 40 ++++++++++--------- Loop/Stashing/StashedWindowStore.swift | 2 + .../Window Action/WindowAction.swift | 16 +++++++- .../WindowActionEngine.swift | 6 +++ Loop/Window Management/Window/Window.swift | 2 +- 13 files changed, 87 insertions(+), 63 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 232043e3..ca6a52ff 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -363,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 } @@ -408,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 @@ -513,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 { diff --git a/Loop/Core/Multitouch/MultitouchGestureSession.swift b/Loop/Core/Multitouch/MultitouchGestureSession.swift index 619c1931..aa2fd1cb 100644 --- a/Loop/Core/Multitouch/MultitouchGestureSession.swift +++ b/Loop/Core/Multitouch/MultitouchGestureSession.swift @@ -42,7 +42,6 @@ final class MultitouchGestureSession { } func begin( - gesture _: GestureBinding, targetWindow: Window?, loopWasAlreadyOpen: Bool ) -> Bool { @@ -123,7 +122,7 @@ final class MultitouchGestureSession { gesture: GestureBinding, distance: CGFloat, step: CGFloat, - canRepeat: Bool, + allowsRapidRepeat: Bool, fire: (_ reverse: Bool) -> () ) { let newKey = ActionKey.gesture(gesture.id) @@ -136,7 +135,7 @@ final class MultitouchGestureSession { return } - guard canRepeat, + guard allowsRapidRepeat, let magnificationDirection = magnificationKind?.magnificationDirection else { return diff --git a/Loop/Core/Multitouch/MultitouchTargetResolver.swift b/Loop/Core/Multitouch/MultitouchTargetResolver.swift index 3109b386..d0b0a275 100644 --- a/Loop/Core/Multitouch/MultitouchTargetResolver.swift +++ b/Loop/Core/Multitouch/MultitouchTargetResolver.swift @@ -20,16 +20,16 @@ final class MultitouchTargetResolver { lastRepeatableWindow = nil } - func targetWindow(for gesture: GestureBinding, canRepeat: Bool) -> Window? { + func targetWindow(for gesture: GestureBinding, allowsRapidRepeat: Bool) -> Window? { let window = findTargetWindow(for: gesture) - if window == nil, canRepeat { + if window == nil, allowsRapidRepeat { return lastRepeatableWindow } return window } - func rememberRepeatableWindow(_ window: Window?, canRepeat: Bool) { - guard let window, canRepeat else { return } + func rememberRepeatableWindow(_ window: Window?, allowsRapidRepeat: Bool) { + guard let window, allowsRapidRepeat else { return } lastRepeatableWindow = window } diff --git a/Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift b/Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift index 14ee100e..610a190a 100644 --- a/Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift +++ b/Loop/Core/Multitouch/MultitouchTrigger+Magnify.swift @@ -46,8 +46,7 @@ extension MultitouchTrigger { guard await activateGestureIfNeeded( fingerCount: fingerCount, - magnifyDisplacement: magnify.distance - magnify.originDistance, - magnifyActivationThreshold: magnifyCycleStepSize + magnifyDisplacement: magnify.distance - magnify.originDistance ), let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected @@ -66,22 +65,22 @@ extension MultitouchTrigger { return } - let canRepeatAction = resolvedWindowAction(from: activeGesture).map { - $0.canRepeat || $0.direction == .cycle + let allowsRapidRepeatAction = resolvedWindowAction(from: activeGesture).map { + $0.allowsRapidRepeat || $0.direction == .cycle } ?? false session.commitMagnify( gesture: activeGesture, distance: magnify.distance, - step: magnifyCycleStepSize, - canRepeat: canRepeatAction + step: magnifyStepSize, + allowsRapidRepeat: allowsRapidRepeatAction ) { reverse in triggerSingleAction(from: activeGesture, reverse: reverse) } if let window = session.pendingTargetWindow, - resolvedWindowAction(from: activeGesture)?.canRepeat == true { - targetResolver.rememberRepeatableWindow(window, canRepeat: true) + resolvedWindowAction(from: activeGesture)?.allowsRapidRepeat == true { + targetResolver.rememberRepeatableWindow(window, allowsRapidRepeat: true) } case .ended, .cancelled: @@ -116,7 +115,7 @@ extension MultitouchTrigger { session.commitRadialMagnify( distance: magnify.distance, originDistance: magnify.originDistance, - step: magnifyCycleStepSize + step: magnifyStepSize ) { reverse in triggerRadialMenuAction(at: centerActionIndex, from: actions[...], reverse: reverse) } @@ -155,8 +154,8 @@ extension MultitouchTrigger { triggerSingleAction(from: oppositeGesture, reverse: false) if let window = session.pendingTargetWindow, - resolvedWindowAction(from: oppositeGesture)?.canRepeat == true { - targetResolver.rememberRepeatableWindow(window, canRepeat: true) + resolvedWindowAction(from: oppositeGesture)?.allowsRapidRepeat == true { + targetResolver.rememberRepeatableWindow(window, allowsRapidRepeat: true) } } else if isCycleAction(currentGesture) { triggerSingleAction(from: currentGesture, reverse: true) diff --git a/Loop/Core/Multitouch/MultitouchTrigger+Swipe.swift b/Loop/Core/Multitouch/MultitouchTrigger+Swipe.swift index 8b14abd2..f12afa12 100644 --- a/Loop/Core/Multitouch/MultitouchTrigger+Swipe.swift +++ b/Loop/Core/Multitouch/MultitouchTrigger+Swipe.swift @@ -178,8 +178,8 @@ extension MultitouchTrigger { triggerSingleAction(from: gesture, reverse: false) if let window = session.pendingTargetWindow, - resolvedWindowAction(from: gesture)?.canRepeat == true { - targetResolver.rememberRepeatableWindow(window, canRepeat: true) + resolvedWindowAction(from: gesture)?.allowsRapidRepeat == true { + targetResolver.rememberRepeatableWindow(window, allowsRapidRepeat: true) } } @@ -196,8 +196,8 @@ extension MultitouchTrigger { triggerSingleAction(from: oppositeGesture, reverse: false) if let window = session.pendingTargetWindow, - resolvedWindowAction(from: oppositeGesture)?.canRepeat == true { - targetResolver.rememberRepeatableWindow(window, canRepeat: true) + resolvedWindowAction(from: oppositeGesture)?.allowsRapidRepeat == true { + targetResolver.rememberRepeatableWindow(window, allowsRapidRepeat: true) } } else if isCycleAction(currentGesture) { triggerSingleAction(from: currentGesture, reverse: true) diff --git a/Loop/Core/Multitouch/MultitouchTrigger.swift b/Loop/Core/Multitouch/MultitouchTrigger.swift index 09f95c15..68db3632 100644 --- a/Loop/Core/Multitouch/MultitouchTrigger.swift +++ b/Loop/Core/Multitouch/MultitouchTrigger.swift @@ -36,8 +36,7 @@ final class MultitouchTrigger { private var isStarted = false let swipeCycleStepSize: CGFloat = 0.15 - let magnifyActivationThreshold: CGFloat = 0.3 - let magnifyCycleStepSize: CGFloat = 0.15 + let magnifyStepSize: CGFloat = 0.3 let cardinalBiasedRadialMenuActionCount = 8 var radialMenuActions = RadialMenuAction.userConfiguredActions @@ -164,17 +163,17 @@ final class MultitouchTrigger { /// 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 canRepeat = resolvedWindowAction(from: gesture)?.canRepeat == true - let window = targetResolver.targetWindow(for: gesture, canRepeat: canRepeat) + 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(gesture: gesture, targetWindow: window, loopWasAlreadyOpen: loopWasAlreadyOpen) else { + guard session.begin(targetWindow: window, loopWasAlreadyOpen: loopWasAlreadyOpen) else { return } - targetResolver.rememberRepeatableWindow(window, canRepeat: canRepeat) + targetResolver.rememberRepeatableWindow(window, allowsRapidRepeat: allowsRapidRepeat) gestureBlocker.start() } @@ -206,14 +205,13 @@ final class MultitouchTrigger { /// `.began` event Subsurface emits. func activateGestureIfNeeded( fingerCount: Int, - magnifyDisplacement: CGFloat? = nil, - magnifyActivationThreshold: CGFloat? = nil + magnifyDisplacement: CGFloat? = nil ) async -> Bool { guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return false } if session.hasActivated { return true } if let magnifyDisplacement, - abs(magnifyDisplacement) < (magnifyActivationThreshold ?? self.magnifyActivationThreshold) { + abs(magnifyDisplacement) < magnifyStepSize { return false } 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/Settings Window/Settings/Gestures/GestureItemView.swift b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift index 9584cfdf..0f15fa9b 100644 --- a/Loop/Settings Window/Settings/Gestures/GestureItemView.swift +++ b/Loop/Settings Window/Settings/Gestures/GestureItemView.swift @@ -105,12 +105,15 @@ struct GestureItemView: View { .fontWeight(.regular) .lineLimit(1) - Text(gesture.activationZone.displayName) - .font(.caption) - .foregroundStyle(.secondary) + 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) 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/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 ad467673..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 From 64aeab3b2e93837cb28cb35bfe08e37b3ff193f9 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 6 Jul 2026 02:26:02 -0600 Subject: [PATCH 61/61] =?UTF-8?q?=F0=9F=90=9E=20Fix=20multitouch=20gesture?= =?UTF-8?q?=20targeting=20and=20magnify=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MultitouchRecognizerRegistry.swift | 1 + .../Multitouch/MultitouchTargetResolver.swift | 23 +++++++++++++++++-- Loop/Core/Multitouch/MultitouchTrigger.swift | 6 +++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift b/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift index 793bc996..bc6336c3 100644 --- a/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift +++ b/Loop/Core/Multitouch/MultitouchRecognizerRegistry.swift @@ -109,6 +109,7 @@ final class MultitouchRecognizerRegistry { ) { let recognizer = SubsurfaceGestureRecognizer( fingerCount: fingerCount, + recognizedGestureTypes: [.swipe, .magnify], requiresExactFingerCountToContinue: true ) diff --git a/Loop/Core/Multitouch/MultitouchTargetResolver.swift b/Loop/Core/Multitouch/MultitouchTargetResolver.swift index d0b0a275..e2bf19cf 100644 --- a/Loop/Core/Multitouch/MultitouchTargetResolver.swift +++ b/Loop/Core/Multitouch/MultitouchTargetResolver.swift @@ -15,9 +15,17 @@ 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? { @@ -35,13 +43,19 @@ final class MultitouchTargetResolver { 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 gesture.fingerCount <= 2 ? .titlebar : gesture.activationZone { + switch activationZone { case .titlebar: let minimumTitlebarHeight = Defaults[.gestureTitlebarHeight] let titlebarHeight: CGFloat = if #available(macOS 26, *) { @@ -60,9 +74,14 @@ final class MultitouchTargetResolver { let titlebarMinY = window.frame.minY let titlebarMaxY = window.frame.minY + titlebarHeight let isInTitlebar = cursorPosition.y >= titlebarMinY && cursorPosition.y <= titlebarMaxY - return isInTitlebar ? window : nil + 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.swift b/Loop/Core/Multitouch/MultitouchTrigger.swift index 68db3632..af967a6b 100644 --- a/Loop/Core/Multitouch/MultitouchTrigger.swift +++ b/Loop/Core/Multitouch/MultitouchTrigger.swift @@ -36,7 +36,7 @@ final class MultitouchTrigger { private var isStarted = false let swipeCycleStepSize: CGFloat = 0.15 - let magnifyStepSize: CGFloat = 0.3 + let magnifyStepSize: CGFloat = 0.2 let cardinalBiasedRadialMenuActionCount = 8 var radialMenuActions = RadialMenuAction.userConfiguredActions @@ -208,13 +208,14 @@ final class MultitouchTrigger { magnifyDisplacement: CGFloat? = nil ) async -> Bool { guard let session = recognizerRegistry.session(for: fingerCount), !session.isGestureRejected else { return false } - if session.hasActivated { return true } if let magnifyDisplacement, abs(magnifyDisplacement) < magnifyStepSize { return false } + if session.hasActivated { return true } + var openedLoop = false if let window = session.pendingTargetWindow { do { @@ -241,6 +242,7 @@ final class MultitouchTrigger { gestureBlocker.stop() recognizerRegistry.session(for: fingerCount)?.reset() + targetResolver.resetGestureState() } }