Add mouse chord move gesture and update alerts#159
Conversation
Add an optional “Move with both mouse buttons” preference that lets users move windows without a keyboard shortcut. - Add MouseChordMoveManager to track left/right mouse button chords - Extend MouseTracker and WindowManager to support CoreGraphics mouse updates and cursor-based window lookup - Initialize and clean up mouse chord subscriptions with the app lifecycle - Improve Sparkle update presentation by temporarily activating the app, showing Dock attention for scheduled updates, and restoring accessory mode afterward
Review or Edit in CodeSandboxOpen the branch in Web Editor • VS Code • Insiders |
|
@Vida-CruX is attempting to deploy a commit to the Pablo Varela's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughThis PR adds both-button mouse-chord move support and coordinate-space-aware mouse tracking, persists per-shortcut keyboard/mouse enable flags, introduces MouseChordActionManager to subscribe to left/right chord events, refactors ShortcutView and related preferences/UI, increases the main window width, updates AppDelegate to wire chord lifecycle, changes WindowManager.move to return AXError, and refactors UpdatesManager into an NSObject singleton implementing SPUStandardUserDriverDelegate. Sequence Diagram(s)sequenceDiagram
participant User as User (both buttons)
participant MouseChordActionManager
participant ShortcutsManager
participant MouseTracker
participant WindowManager
User->>MouseChordActionManager: left+right down
MouseChordActionManager->>ShortcutsManager: check hasActiveShortcut / mouse-only config
MouseChordActionManager->>MouseTracker: startTrackingForExternalMouseUpdates(initialLocation)
MouseTracker->>WindowManager: move(window, to: point)
User->>MouseChordActionManager: release chord
MouseChordActionManager->>MouseTracker: stop/reset tracking
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
Swift Shift/src/Manager/MouseTracker.swift (1)
34-43: ⚡ Quick winRestrict external tracking to
.moveuntil resize math is coordinate-space aware.Only the move path compensates for
.coreGraphics.determineQuadrantandresizeWindowBasedOnMouseLocationstill assume AppKit Y semantics, so a future.resizecaller here will pick the wrong quadrant and invert vertical deltas. Tightening this entry point to.movewould make the contract match the implementation.✂️ Proposed fix
`@discardableResult` func startTrackingForExternalMouseUpdates(for action: MouseAction, initialMouseLocation: NSPoint) -> Bool { + guard action == .move else { + assertionFailure("External mouse updates currently support .move only") + return false + } if currentAction != .none { stopTracking(for: currentAction) } prepareTracking(for: action, mouseLocation: initialMouseLocation, coordinateSpace: .coreGraphics) guard trackedWindow != nil else { return false }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Swift` Shift/src/Manager/MouseTracker.swift around lines 34 - 43, The entrypoint startTrackingForExternalMouseUpdates(for:initialMouseLocation:) currently allows any MouseAction but only the `.move` path compensates for coreGraphics Y semantics; change it to reject non-move actions by returning false when action != .move so external callers cannot start a `.resize` path that will call determineQuadrant or resizeWindowBasedOnMouseLocation (which assume AppKit Y) until those functions are made coordinate-space aware; keep prepareTracking(...) and coreGraphics usage for `.move` but prevent other actions at this API boundary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Swift` Shift/src/Manager/ShortcutsManager.swift:
- Around line 706-726: The current logic conflates "window move is active" with
"chord sequence suppression" so stopChordMove(resetButtons: false) clears
isMoveActive on the first button-up and lets later drag/up events through;
change this by splitting those concerns: introduce a separate suppression flag
(e.g., isChordSuppressed) or suppression state in ShortcutsManager and set it
when the chord starts, have handleDrag(_:) and handleButtonUp(_:) check the new
isChordSuppressed (in addition to isMoveActive) and continue to cancel events
while either leftButtonIsDown or rightButtonIsDown is true, and only clear
isChordSuppressed when both buttons are released; leave
stopChordMove(resetButtons:) to stop the move behavior (isMoveActive) but do not
clear the suppression flag until both buttons are up (or add logic in
stopChordMove to conditionally preserve suppression when one button remains
down).
In `@Swift` Shift/src/Manager/UpdatesManager.swift:
- Around line 45-46: The clearUpdateAttention function currently sets
NSApp.dockTile.badgeLabel to an empty string which is unreliable; change it to
assign nil to NSApp.dockTile.badgeLabel so the Dock badge is properly cleared
across macOS versions. Locate the private func clearUpdateAttention() and
replace the empty-string assignment with a nil assignment to
NSApp.dockTile.badgeLabel.
- Around line 5-7: The Sparkle startup path should not require opening
UpdatesView: in UpdatesManager ensure the SPUStandardUpdaterController
initializer doesn't capture self during app command startup by passing
userDriverDelegate: nil (leave updaterDelegate as needed) so
UpdatesManager.shared.controller and .updater can initialize from
CheckUpdatesButton() without referencing UI; also update clearUpdateAttention()
to clear the Dock badge by setting NSApp.dockTile.badgeLabel = nil (not the
empty string) so the badge absence uses the optional contract.
In `@Swift` Shift/src/Manager/WindowManager.swift:
- Around line 46-52: The AX fast-path in getCurrentWindow filters out the self
process but the CGWindowList fallback does not, allowing Swift Shift windows to
be returned; update getTopWindowAtCursorUsingCGWindowList to accept an
excludingProcessID: pid_t? parameter (default nil) and skip entries whose
kCGWindowOwnerPID equals that PID, then call
getTopWindowAtCursorUsingCGWindowList(mouseLocation: mouseLocation,
excludingProcessID: NSRunningApplication.current.processIdentifier) from
getCurrentWindow so the self-process filter is applied to both paths.
---
Nitpick comments:
In `@Swift` Shift/src/Manager/MouseTracker.swift:
- Around line 34-43: The entrypoint
startTrackingForExternalMouseUpdates(for:initialMouseLocation:) currently allows
any MouseAction but only the `.move` path compensates for coreGraphics Y
semantics; change it to reject non-move actions by returning false when action
!= .move so external callers cannot start a `.resize` path that will call
determineQuadrant or resizeWindowBasedOnMouseLocation (which assume AppKit Y)
until those functions are made coordinate-space aware; keep prepareTracking(...)
and coreGraphics usage for `.move` but prevent other actions at this API
boundary.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 058a8850-859b-4994-afe9-cf9bce35bcf3
📒 Files selected for processing (8)
Swift Shift/src/AppDelegate.swiftSwift Shift/src/Manager/MouseTracker.swiftSwift Shift/src/Manager/Preferences.swiftSwift Shift/src/Manager/ShortcutsManager.swiftSwift Shift/src/Manager/UpdatesManager.swiftSwift Shift/src/Manager/WindowManager.swiftSwift Shift/src/View/PreferencesView.swiftSwift Shift/src/View/ShortcutView.swift
💤 Files with no reviewable changes (1)
- Swift Shift/src/View/ShortcutView.swift
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
I've got another proposal @Vida-CruX . Why only moving and not resizing? We can't do both, but we can let the users choose! I've had some POC on another branch to refactor the settings to allow more mouse/trackpad movements, which is challenging because we keep adding options to that top list and it's a worse experience IMO for a minimal app Here's my proposal instead: less buttons! (made from scratch for this PR, I might update it in the future if I add trackpad gestures). As you can see it allows the chord for both, with a safeguard so either you have shortcut+chord or just one chord at a time CleanShot.2026-06-05.at.20.58.54.yafw.balanced.mp4WDYT? I'll push it to your PR and we'll discuss here |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
Swift Shift/src/Manager/ShortcutsManager.swift (1)
824-835:⚠️ Potential issue | 🟠 Major | ⚡ Quick winKeep suppressing the chord sequence until both buttons are released.
Line 825 and Line 834 stop the active move immediately, but suppression also drops at that point. With one button still held, later drag/up events can leak to the foreground app mid-sequence.
Proposed fix (separate move-active vs sequence-suppression)
class MouseChordActionManager { static let shared = MouseChordActionManager() @@ private var activeAction: MouseAction? + private var isChordSuppressed = false @@ private func handleButtonDown(_ event: CGEvent) { - if activeAction != nil { + if activeAction != nil || isChordSuppressed { event.cancel() return } @@ private func handleButtonUp(_ event: CGEvent) { - if activeAction != nil { + if activeAction != nil || isChordSuppressed { event.cancel() - stopChordAction(resetButtons: false) + stopChordAction(resetButtons: false) + if !leftButtonIsDown && !rightButtonIsDown { + isChordSuppressed = false + } } } @@ private func handleDrag(_ event: CGEvent) { - if activeAction != nil { + if activeAction != nil || isChordSuppressed { guard leftButtonIsDown && rightButtonIsDown && !ShortcutsManager.shared.hasActiveShortcut else { stopChordAction(resetButtons: false) + if !leftButtonIsDown && !rightButtonIsDown { + isChordSuppressed = false + } return } @@ private func startChordActionIfReady(eventToCancelOnSuccess event: CGEvent) { @@ if MouseTracker.shared.startTrackingForExternalMouseUpdates(for: action, initialMouseLocation: event.location) { activeAction = action + isChordSuppressed = true event.cancel() } }Also applies to: 861-871
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Swift` Shift/src/Manager/ShortcutsManager.swift around lines 824 - 835, The chord handling currently calls stopChordAction(resetButtons: false) from handleButtonUp and handleDrag as soon as the move ends, but that also lifts sequence suppression and allows events to leak while one button remains; modify the logic so stopping the active move is separated from lifting sequence suppression—introduce either a new method (e.g., stopMoveAction()) or an optional parameter on stopChordAction (e.g., keepSuppression: Bool) and use it in handleButtonUp and handleDrag to stop the activeAction while keeping suppression active until both leftButtonIsDown and rightButtonIsDown are false and ShortcutsManager.shared.hasActiveShortcut is false; apply the same change to the other similar block around lines 861–871 so suppression is only cleared when both buttons are released.
🧹 Nitpick comments (1)
Swift Shift/src/Manager/ShortcutsManager.swift (1)
788-793: ⚡ Quick winAvoid loading shortcut config from
UserDefaultson every mouse event.Line 789 re-resolves configuration for every down/up/drag event. This path is hot; cache the configured mouse-only action in-memory and refresh it in
updateSubscriptions()/ shortcut-change hooks.Also applies to: 873-882
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Swift` Shift/src/Manager/ShortcutsManager.swift around lines 788 - 793, The handle(_ event: CGEvent) path currently calls configuredMouseOnlyAction() on every mouse event which hits UserDefaults frequently; cache the resolved configured mouse-only action in a stored property (e.g., cachedMouseOnlyAction) and update that cache from updateSubscriptions() and any shortcut-change hooks instead of re-reading UserDefaults inside handle(_:), and replace calls to configuredMouseOnlyAction() in handle(_:) and the similar block around the other event handler (the code currently at the other decision branch) to use the cached property; ensure stopChordAction(resetButtons:) and unsubscribe() behavior remains the same when the cached value is nil.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@Swift` Shift/src/Manager/ShortcutsManager.swift:
- Around line 824-835: The chord handling currently calls
stopChordAction(resetButtons: false) from handleButtonUp and handleDrag as soon
as the move ends, but that also lifts sequence suppression and allows events to
leak while one button remains; modify the logic so stopping the active move is
separated from lifting sequence suppression—introduce either a new method (e.g.,
stopMoveAction()) or an optional parameter on stopChordAction (e.g.,
keepSuppression: Bool) and use it in handleButtonUp and handleDrag to stop the
activeAction while keeping suppression active until both leftButtonIsDown and
rightButtonIsDown are false and ShortcutsManager.shared.hasActiveShortcut is
false; apply the same change to the other similar block around lines 861–871 so
suppression is only cleared when both buttons are released.
---
Nitpick comments:
In `@Swift` Shift/src/Manager/ShortcutsManager.swift:
- Around line 788-793: The handle(_ event: CGEvent) path currently calls
configuredMouseOnlyAction() on every mouse event which hits UserDefaults
frequently; cache the resolved configured mouse-only action in a stored property
(e.g., cachedMouseOnlyAction) and update that cache from updateSubscriptions()
and any shortcut-change hooks instead of re-reading UserDefaults inside
handle(_:), and replace calls to configuredMouseOnlyAction() in handle(_:) and
the similar block around the other event handler (the code currently at the
other decision branch) to use the cached property; ensure
stopChordAction(resetButtons:) and unsubscribe() behavior remains the same when
the cached value is nil.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: e9b43a38-bae0-4e7f-b47f-e06087c605c7
📒 Files selected for processing (7)
Swift Shift/src/AppDelegate.swiftSwift Shift/src/Constants.swiftSwift Shift/src/Manager/MouseTracker.swiftSwift Shift/src/Manager/ShortcutsManager.swiftSwift Shift/src/View/PreferencesView.swiftSwift Shift/src/View/SettingsView.swiftSwift Shift/src/View/ShortcutView.swift
💤 Files with no reviewable changes (1)
- Swift Shift/src/View/PreferencesView.swift
✅ Files skipped from review due to trivial changes (1)
- Swift Shift/src/Constants.swift
🚧 Files skipped from review as they are similar to previous changes (2)
- Swift Shift/src/AppDelegate.swift
- Swift Shift/src/Manager/MouseTracker.swift
|
Update: made it so it's obvious why you cannot do this action CleanShot.2026-06-05.at.21.11.50.yafw.balanced.mp4 |
|
Looks great! Please go ahead and push it to this PR. I’m happy to test it from there. |
|
@Vida-CruX it's pushed 😊 no i haven't touched the button detection logic |
|
FYI I think when I was testing this PR the option to focus the window does not work anymore |
Keyboard + Both mouse path now cancels the first mouse down immediately, not only the second. Mouse-only chord path now cancels and stores the first down event. If the second button completes the chord, that first click is discarded. If it was just a normal click/drag, the saved event is replayed so regular mouse use still works. Replayed events are tagged so Swift Shift ignores its own synthetic mouse events.
|
I tried a different approach, and the chord mouse feature now seems to work well.I also refactored MouseTracker, which should improve resize performance, especially in Electron apps. |
|
@Vida-CruX seems to be working most of the time. Anything else on this PR or reaady to merge? |
|
@codex review this PR |
|
Codex Review: Didn't find any major issues. Hooray! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Looks good to me. I think it’s ready to merge. |
|
@Vida-CruX released on 1.3.0. made it a bit more minimal where buttons are toggle-like now, so "both" is replaced by actually toggling both on
|

Implemented #69 , but there is one edge case worth calling out: if the cursor is already hovering over an interactive element, such as a link or button, the first mouse-down event may still reach the underlying app because Swift Shift only knows this is a “both mouse buttons” move gesture after the second button is pressed. Once both buttons are down, we start the move and cancel subsequent drag/up events, so most normal buttons/links should not activate because they usually require the completed click or mouse-up. However, controls that trigger behavior on mouseDown, or apps that open context menus immediately on right mouse down, may still react before Swift Shift takes over. I tried multiple approach but still, I didn't find a perfect solution.
You may want to mark this feature experimental.
I also haven’t tested this behavior on multi-display setups yet because I don't have external display to test. You may want to test it before release.
Summary by CodeRabbit
New Features
Improvements
Removed