Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
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
85 changes: 71 additions & 14 deletions Loop/Core/LoopManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -37,7 +38,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 @@ -53,7 +54,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 @@ -64,6 +65,27 @@ final class LoopManager {
checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? 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 Down Expand Up @@ -96,21 +118,54 @@ 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()
}
}
}
}
}

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 !isLoopActive else {
Expand All @@ -125,13 +180,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
}

log.info("Opening Loop with starting action: \(startingAction.description) and target window: \(window?.description ?? "(none)")")
Expand Down Expand Up @@ -216,7 +272,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 @@ -245,7 +302,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 @@ -403,7 +460,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
}
Expand All @@ -416,7 +473,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],
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