Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
8417afa
🎨 Initial fixes
mrkai77 Feb 8, 2026
8ab8a48
✨ Updated MultitouchTrigger
mrkai77 Feb 15, 2026
0525b20
🏁 Make WindowRecords an actor
mrkai77 Feb 15, 2026
bfa3a66
🎨 Format
mrkai77 Mar 5, 2026
2768be8
🐞 Fix duplicate packages + package resolution
mrkai77 Mar 9, 2026
f9986e8
✨ Optimizations + better zoom gesture handling
mrkai77 Mar 9, 2026
26ab2f3
🐞 Select correct window when opening Loop from gesture
mrkai77 Mar 9, 2026
42525de
✨ Higher zoom repeat threshold
mrkai77 Mar 9, 2026
23c439c
🐞 Fix event monitor getting deallocated mid-gesture
mrkai77 Mar 9, 2026
adfcb57
🔀 Merge branch 'develop' into `subsurface-gestures`
mrkai77 Apr 5, 2026
b17c6cd
↩️ Revert many changes back to develop branch's state
mrkai77 Apr 5, 2026
92cd898
♻️ Migrate MultitouchTrigger to SubsurfaceGestureRecognizer
mrkai77 Apr 5, 2026
0b59b17
✨ Gesture configuration settings tab
mrkai77 Apr 17, 2026
231708f
🎨 Format
mrkai77 Apr 17, 2026
12cb35e
✨ tune gestures
mrkai77 Apr 25, 2026
1168506
🐞 Reduce gesture titlebar activation zone to 32pt
mrkai77 Apr 24, 2026
051f6ac
✨ Warn when a gesture references a deleted keybind
mrkai77 Apr 24, 2026
002e238
🐞 Fix gesture cycle state machine
mrkai77 Apr 26, 2026
70fb5fc
✨ Cache per-fingerCount bindings inside RecognizerEntry
mrkai77 Apr 24, 2026
bde374b
🐞 Reference-count the multitouch gesture blocker
mrkai77 Apr 24, 2026
7512a08
🐞 Keep gesture binding selection in sync after popover edits
mrkai77 Apr 24, 2026
e6e76fe
🐞 Isolate MultitouchTrigger to `@MainActor`
mrkai77 Apr 24, 2026
a74e3ec
🔀 Merge branch `develop` into `subsurface-gestures`
mrkai77 Apr 28, 2026
b4370c2
🔀 Merge branch `develop` into `subsurface-gestures`
mrkai77 Apr 28, 2026
7c06e2a
🔀 Merge branch `develop` into `subsurface-gestures`
mrkai77 Apr 30, 2026
3c22499
✨ Open the radial menu the moment a gesture begins
mrkai77 May 2, 2026
9746bf7
🔀 Merge branch `develop` into `subsurface-gestures`
mrkai77 May 14, 2026
87b8553
✨ Rewrite gesture settings
mrkai77 May 14, 2026
2738418
💄 Refine gesture settings
mrkai77 May 14, 2026
32e86b3
🔀 Merge branch `develop` into `subsurface-gestures`
mrkai77 May 14, 2026
9fa4b95
✨ Disable conflicting system gestures
mrkai77 May 15, 2026
45fc093
🔀 Merge branch `develop` into `subsurface-gestures`
mrkai77 May 25, 2026
abb5a33
🐞 Fix some event monitor warnings
mrkai77 May 25, 2026
44651cc
🐞 Disable and indicate conflicting gestures
mrkai77 May 25, 2026
dddb1dc
✨ Allow consecutive larger/smaller actions via gestures
mrkai77 May 25, 2026
a3492a1
✨ Separate `pinch`/`spread` gesture triggers
mrkai77 May 25, 2026
3c572b3
✨ Restrict two-finger triggers to titlebar-only to reduct system conf…
mrkai77 May 25, 2026
5eb5a4d
🐞 Fix event tap deallocation race with high-frequency callbacks
mrkai77 May 25, 2026
a3ed79b
✨ Tune pinch/spread gesture thresholds
mrkai77 May 25, 2026
d0c3da8
✨ consolidate pinch/spread gesture thresholds
mrkai77 May 25, 2026
8844266
✨ Unify gesture terminology across codebae
mrkai77 May 25, 2026
c595b88
🌏 Add localization keys
mrkai77 May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 31 additions & 21 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
objects = {

/* Begin PBXBuildFile section */
2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28492A2F22B4B700F6CE42 /* Scribe */; };
2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B6282EE5050C00A1E26B /* Defaults */; };
2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 2A28B62B2EE5057C00A1E26B /* Luminare */; };
2A847DFC2F5E49E90099E02A /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A847DFB2F5E49E90099E02A /* Scribe */; };
2A847DFF2F5E4A080099E02A /* Subsurface in Frameworks */ = {isa = PBXBuildFile; productRef = 2A847DFE2F5E4A080099E02A /* Subsurface */; };
2A847E012F5E4A0E0099E02A /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2A847E002F5E4A0E0099E02A /* Scribe */; };
2A86890D2F80968A005B521B /* LoopDockTile.plugin in CopyFiles */ = {isa = PBXBuildFile; fileRef = 2A8689072F809625005B521B /* LoopDockTile.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
2AE091C72F81A22800EF6149 /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AE091C62F81A22800EF6149 /* Scribe */; };
2AF9238E2F540B1300F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238D2F540B1300F467FD /* Scribe */; };
2AF923902F540B2200F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238F2F540B2200F467FD /* Scribe */; };
3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */; };
B1AA00412F30000100AABBCC /* LoopUpdaterHelper in CopyFiles */ = {isa = PBXBuildFile; fileRef = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F06D76892DFF7A77007EEDA9 /* SkyLight.framework */; };
Expand Down Expand Up @@ -128,10 +128,10 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
2A847DFC2F5E49E90099E02A /* Scribe in Frameworks */,
2A847DFF2F5E4A080099E02A /* Subsurface in Frameworks */,
2A28B6292EE5050C00A1E26B /* Defaults in Frameworks */,
2A28B62C2EE5057C00A1E26B /* Luminare in Frameworks */,
2AF9238E2F540B1300F467FD /* Scribe in Frameworks */,
2A28492B2F22B4B700F6CE42 /* Scribe in Frameworks */,
F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */,
3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */,
);
Expand All @@ -141,7 +141,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
2AF923902F540B2200F467FD /* Scribe in Frameworks */,
2A847E012F5E4A0E0099E02A /* Scribe in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -235,8 +235,8 @@
2A28B6282EE5050C00A1E26B /* Defaults */,
2A28B62B2EE5057C00A1E26B /* Luminare */,
3ED0A7B82F21DF6800A58629 /* ZIPFoundation */,
2A28492A2F22B4B700F6CE42 /* Scribe */,
2AF9238D2F540B1300F467FD /* Scribe */,
2A847DFB2F5E49E90099E02A /* Scribe */,
2A847DFE2F5E4A080099E02A /* Subsurface */,
);
productName = WindowManager;
productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */;
Expand All @@ -259,7 +259,7 @@
);
name = LoopUpdaterHelper;
packageProductDependencies = (
2AF9238F2F540B2200F467FD /* Scribe */,
2A847E002F5E4A0E0099E02A /* Scribe */,
);
productName = LoopUpdaterHelper;
productReference = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */;
Expand Down Expand Up @@ -311,7 +311,8 @@
2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */,
2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */,
3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */,
2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */,
2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */,
);
productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -926,14 +927,22 @@
kind = branch;
};
};
2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */ = {
2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SenpaiHunters/Scribe";
requirement = {
branch = main;
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";
Expand All @@ -945,10 +954,6 @@
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
2A28492A2F22B4B700F6CE42 /* Scribe */ = {
isa = XCSwiftPackageProductDependency;
productName = Scribe;
};
2A28B6282EE5050C00A1E26B /* Defaults */ = {
isa = XCSwiftPackageProductDependency;
package = 2A28B6272EE5050C00A1E26B /* XCRemoteSwiftPackageReference "Defaults" */;
Expand All @@ -959,19 +964,24 @@
package = 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */;
productName = Luminare;
};
2AE091C62F81A22800EF6149 /* Scribe */ = {
2A847DFB2F5E49E90099E02A /* Scribe */ = {
isa = XCSwiftPackageProductDependency;
package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */;
package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */;
productName = Scribe;
};
2AF9238D2F540B1300F467FD /* Scribe */ = {
2AE091C62F81A22800EF6149 /* Scribe */ = {
isa = XCSwiftPackageProductDependency;
package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */;
package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */;
productName = Scribe;
};
2AF9238F2F540B2200F467FD /* Scribe */ = {
2A847DFE2F5E4A080099E02A /* Subsurface */ = {
isa = XCSwiftPackageProductDependency;
package = 2A847DFD2F5E4A080099E02A /* XCRemoteSwiftPackageReference "Subsurface" */;
productName = Subsurface;
};
2A847E002F5E4A0E0099E02A /* Scribe */ = {
isa = XCSwiftPackageProductDependency;
package = 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */;
package = 2A847DFA2F5E49E90099E02A /* XCRemoteSwiftPackageReference "Scribe" */;
productName = Scribe;
};
3ED0A7B82F21DF6800A58629 /* ZIPFoundation */ = {
Expand Down
97 changes: 80 additions & 17 deletions Loop/Core/LoopManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final class LoopManager {
private let updater = Updater.shared

private var accessibilityCheckerTask: Task<(), Never>?
private var gestureToggleTask: Task<(), Never>?

/// Opening prepares resizeContext asynchronously. We track that setup separately
/// so rapid trigger events cannot act on the previous/default context.
Expand Down Expand Up @@ -59,7 +60,7 @@ final class LoopManager {
windowActionCache: windowActionCache,
openCallback: { [weak self] action in
Task {
await self?.openLoop(startingAction: action)
try? await self?.openLoop(startingAction: action)
}
},
closeCallback: { [weak self] forceClose in
Expand All @@ -75,7 +76,7 @@ final class LoopManager {
private(set) lazy var middleClickTrigger = MiddleClickTrigger(
openCallback: { [weak self] action in
Task {
await self?.openLoop(startingAction: action)
try? await self?.openLoop(startingAction: action)
}
},
closeCallback: { [weak self] forceClose in
Expand All @@ -86,6 +87,27 @@ final class LoopManager {
checkIfLoopOpen: { [weak self] in self?.isLoopActiveAtomic ?? false }
)

private(set) lazy var multitouchTrigger = MultitouchTrigger(
windowActionCache: windowActionCache,
openCallback: { [weak self] action, window in
guard let self else { return }
try await openLoop(startingAction: action, window: window)
},
closeCallback: { [weak self] forceClose in
Task {
await self?.closeLoop(forceClose: forceClose)
}
},
changeAction: { [weak self] action, reverse in
Task {
await self?.changeAction(action, reverse: reverse)
}
},
checkIfLoopOpen: { [weak self] in
self?.isLoopActive ?? false
}
)

private(set) lazy var mouseInteractionObserver = MouseInteractionObserver(
windowActionCache: windowActionCache,
changeAction: { [weak self] newAction in
Expand All @@ -109,6 +131,8 @@ final class LoopManager {
)

func start() {
multitouchTrigger.prepare()

accessibilityCheckerTask = Task(priority: .background) { [weak self] in
for await status in AccessibilityManager.shared.stream(initial: true) {
guard let self, !Task.isCancelled else {
Expand All @@ -118,9 +142,25 @@ final class LoopManager {
if status {
await keybindTrigger.start()
middleClickTrigger.start()
if Defaults[.enableGestures] {
multitouchTrigger.start()
}
} else {
keybindTrigger.stop()
middleClickTrigger.stop()
multitouchTrigger.stop()
}
}
}

gestureToggleTask = Task(priority: .background) { [weak self] in
for await enabled in Defaults.updates(.enableGestures) {
guard let self, !Task.isCancelled else { break }

if enabled, AccessibilityManager.shared.isGranted {
multitouchTrigger.start()
} else {
multitouchTrigger.stop()
}
}
}
Expand All @@ -129,12 +169,15 @@ final class LoopManager {
func shutdown() {
accessibilityCheckerTask?.cancel()
accessibilityCheckerTask = nil
gestureToggleTask?.cancel()
gestureToggleTask = nil

indicatorService.closeAll()

keybindTrigger.stop()
middleClickTrigger.stop()
mouseInteractionObserver.stop()
multitouchTrigger.shutdown()
triggerKeyTimeoutTimer.cancel()

isLoopOpening = false
Expand All @@ -145,12 +188,29 @@ final class LoopManager {
}
}

enum LoopManagerError: LocalizedError {
case accessibilityNotGranted
case appExcluded
case fullscreenWindow

var errorDescription: String? {
switch self {
case .accessibilityNotGranted:
"Cannot open Loop: accessibility permission not granted"
case .appExcluded:
"Cannot open Loop: app is excluded"
case .fullscreenWindow:
"Cannot open Loop: target window is fullscreen"
}
}
}

// MARK: - Opening/Closing Loop

extension LoopManager {
private func openLoop(startingAction: WindowAction) async {
private func openLoop(startingAction: WindowAction, window: Window? = nil) async throws {
guard AccessibilityManager.shared.isGranted else {
return
throw LoopManagerError.accessibilityNotGranted
}

guard !isLoopOpening else {
Expand All @@ -172,13 +232,14 @@ extension LoopManager {
return
}

let window = WindowUtility.userDefinedTargetWindow()
let window = window ?? WindowUtility.userDefinedTargetWindow()

guard
window?.isAppExcluded != true,
(window?.fullscreen ?? false && Defaults[.ignoreFullscreen]) == false
else {
return
guard window?.isAppExcluded != true else {
throw LoopManagerError.appExcluded
}

guard (window?.fullscreen ?? false && Defaults[.ignoreFullscreen]) == false else {
throw LoopManagerError.fullscreenWindow
}

isLoopOpening = true
Expand Down Expand Up @@ -283,7 +344,8 @@ extension LoopManager {
_ newAction: WindowAction,
triggeredFromScreenChange: Bool = false,
disableHapticFeedback: Bool = false,
canAdvanceCycle: Bool = true
canAdvanceCycle: Bool = true,
reverse: Bool = false
) async {
guard
isLoopActive,
Expand Down Expand Up @@ -315,7 +377,7 @@ extension LoopManager {
// The ability to advance a cycle is only available when the action is triggered via a keybind or a left click on the mouse.
// This should be set to false when the mouse is moved to prevent rapid cycling.
if canAdvanceCycle {
newAction = await getNextCycleAction(newAction)
newAction = await getNextCycleAction(newAction, reverse: reverse)
} else {
if let cycle = newAction.cycle, !cycle.contains(resizeContext.action) {
newAction = cycle.first ?? .init(.noAction)
Expand Down Expand Up @@ -473,8 +535,8 @@ extension LoopManager {
}
}

private func getNextCycleAction(_ action: WindowAction) async -> WindowAction {
guard let currentCycle = action.cycle else {
private func getNextCycleAction(_ action: WindowAction, reverse: Bool) async -> WindowAction {
guard let currentCycle = action.cycle, !currentCycle.isEmpty else {
return action
}

Expand All @@ -486,12 +548,13 @@ extension LoopManager {
&& Defaults[.triggerKey].contains(.kVK_Shift) == false
&& Defaults[.cycleBackwardsOnShiftPressed]

let shouldCycleBackwards = allowReverseCycle && keybindTrigger.effectiveEventFlags.contains(.maskShift)
let shouldCycleBackwards = reverse || (allowReverseCycle && keybindTrigger.effectiveEventFlags.contains(.maskShift))
let freshStart = shouldCycleBackwards ? (currentCycle.last ?? currentCycle[0]) : currentCycle[0]
var currentIndex: Int? = nil

if Defaults[.cycleModeRestartEnabled],
resizeContext.action.direction == .noSelection || !currentCycle.contains(resizeContext.action) {
return currentCycle[0]
return freshStart
}

// If the current action is noSelection, we can preserve the index from the last action.
Expand All @@ -506,7 +569,7 @@ extension LoopManager {
}

guard var nextIndex = currentIndex else {
return currentCycle[0]
return freshStart
}

nextIndex += shouldCycleBackwards ? -1 : 1
Expand Down
47 changes: 47 additions & 0 deletions Loop/Core/Observers/Helpers/MultitouchGestureBlocker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// MultitouchGestureBlocker.swift
// Loop
//
// Created by Kai Azim on 2026-04-05.
//

import AppKit
import Scribe

/// Reference-counted because the blocker is shared across in-flight
/// gestures: one gesture ending mustn't disable blocking for others still
/// active. `start()` is also idempotent so duplicate calls don't leak the
/// previous `ActiveEventMonitor` (it self-retains via `Unmanaged.passRetained`).
@Loggable
final class MultitouchGestureBlocker {
private var monitor: ActiveEventMonitor?
private var activeCount: Int = 0

func start() {
activeCount += 1
guard monitor == nil else { return }

log.info("Starting gesture blocker")

let eventTypes: [CGEventType] = [
.scrollWheel,
CGEventType(rawValue: UInt32(NSEvent.EventType.gesture.rawValue)),
CGEventType(rawValue: UInt32(NSEvent.EventType.magnify.rawValue)),
CGEventType(rawValue: UInt32(NSEvent.EventType.rotate.rawValue)),
CGEventType(rawValue: UInt32(NSEvent.EventType.smartMagnify.rawValue))
].compactMap(\.self)

monitor = ActiveEventMonitor("gesture_blocker", events: eventTypes) { _ in .ignore }
monitor?.start()
}

func stop() {
activeCount = max(0, activeCount - 1)
guard activeCount == 0 else { return }

monitor?.stop()
monitor = nil

log.info("Stopped gesture blocker")
}
}
Loading