From 8417afa5eb5d8d227e6b64b1342f77689b450cb2 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 7 Feb 2026 18:12:24 -0700 Subject: [PATCH 01/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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 {