From e636f41aed9d046ec8004f31f218c5b94941d403 Mon Sep 17 00:00:00 2001 From: Vida-CruX Date: Thu, 4 Jun 2026 08:44:38 +0800 Subject: [PATCH 1/7] Add mouse chord move gesture and update alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Swift Shift/src/AppDelegate.swift | 2 + Swift Shift/src/Manager/MouseTracker.swift | 60 +++++-- Swift Shift/src/Manager/Preferences.swift | 1 + .../src/Manager/ShortcutsManager.swift | 162 ++++++++++++++++++ Swift Shift/src/Manager/UpdatesManager.swift | 46 ++++- Swift Shift/src/Manager/WindowManager.swift | 13 +- Swift Shift/src/View/PreferencesView.swift | 11 ++ Swift Shift/src/View/ShortcutView.swift | 1 - 8 files changed, 271 insertions(+), 25 deletions(-) diff --git a/Swift Shift/src/AppDelegate.swift b/Swift Shift/src/AppDelegate.swift index 4726bf3..95aff64 100644 --- a/Swift Shift/src/AppDelegate.swift +++ b/Swift Shift/src/AppDelegate.swift @@ -13,6 +13,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { let _ = ShortcutsManager.shared // immediately register shortcuts so we won't wait for the UI + MouseChordMoveManager.shared.updateSubscriptions() } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -23,5 +24,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { shortcutMonitor?.removeAllActions() ShortcutsManager.shared.cleanupAllShortcuts() + MouseChordMoveManager.shared.cleanup() } } diff --git a/Swift Shift/src/Manager/MouseTracker.swift b/Swift Shift/src/Manager/MouseTracker.swift index bf064e8..294f0dd 100644 --- a/Swift Shift/src/Manager/MouseTracker.swift +++ b/Swift Shift/src/Manager/MouseTracker.swift @@ -2,6 +2,7 @@ import Cocoa import Accessibility enum MouseAction: String { case move, resize, none } enum Quadrant { case topLeft, top, topRight, left, center, right, bottomLeft, bottom, bottomRight } +private enum MouseLocationCoordinateSpace { case appKit, coreGraphics } class MouseTracker { static let shared = MouseTracker() private var mouseEventMonitor: Any?, initialMouseLocation, initialWindowLocation: NSPoint? @@ -13,6 +14,7 @@ class MouseTracker { private var lastAppliedOrigin: NSPoint?, lastAppliedSize: CGSize? private var snapRects: [CGRect] = [] private let snapDistance: CGFloat = 10 + private var mouseLocationCoordinateSpace: MouseLocationCoordinateSpace = .appKit private let trackingQueue = DispatchQueue(label: "com.swiftshift.mousetracker") private init() { registerForSpaceChangeNotifications() } deinit { unregisterForSpaceChangeNotifications() } @@ -26,16 +28,27 @@ class MouseTracker { } func startTracking(for action: MouseAction, button: MouseButton) { if currentAction != .none { stopTracking(for: currentAction) } - prepareTracking(for: action) + prepareTracking(for: action, mouseLocation: NSEvent.mouseLocation, coordinateSpace: .appKit) if trackedWindow != nil { registerMouseEventMonitor(button: button); startTrackingTimer(); isTracking = true } } + @discardableResult + func startTrackingForExternalMouseUpdates(for action: MouseAction, initialMouseLocation: NSPoint) -> Bool { + if currentAction != .none { stopTracking(for: currentAction) } + prepareTracking(for: action, mouseLocation: initialMouseLocation, coordinateSpace: .coreGraphics) + guard trackedWindow != nil else { + return false + } + isTracking = true + startTrackingTimer() + return true + } func stopTracking(for action: MouseAction) { guard currentAction == action else { return } flushPendingMouseUpdate(); invalidateTrackingTimer(); removeMouseEventMonitor(); resetTrackingVariables(); isTracking = false } func forceResetTracking() { guard currentAction != .none, let window = trackedWindow else { return } - initialMouseLocation = NSEvent.mouseLocation + initialMouseLocation = currentMouseLocation() initialWindowLocation = WindowManager.getPosition(window: window) windowSize = WindowManager.getSize(window: window) pendingMouseLocation = nil @@ -45,11 +58,13 @@ class MouseTracker { quadrant = determineQuadrant(mouseLocation: m, windowSize: s, windowLocation: w) } } - private func prepareTracking(for action: MouseAction) { - guard let currentWindow = WindowManager.getCurrentWindow(), !shouldIgnore(window: currentWindow) else { trackedWindow = nil; return } + private func prepareTracking(for action: MouseAction, mouseLocation: NSPoint, coordinateSpace: MouseLocationCoordinateSpace) { + mouseLocationCoordinateSpace = coordinateSpace + let currentWindow = coordinateSpace == .coreGraphics ? WindowManager.getCurrentWindow(at: mouseLocation) : WindowManager.getCurrentWindow() + guard let currentWindow = currentWindow, !shouldIgnore(window: currentWindow) else { trackedWindow = nil; return } shouldFocusWindow = PreferencesManager.loadBool(for: .focusOnApp) shouldUseQuadrants = PreferencesManager.loadBool(for: .useQuadrants) - trackedWindowIsFocused = false; currentAction = action; initialMouseLocation = NSEvent.mouseLocation + trackedWindowIsFocused = false; currentAction = action; initialMouseLocation = mouseLocation trackedWindow = currentWindow; initialWindowLocation = WindowManager.getPosition(window: currentWindow) windowSize = WindowManager.getSize(window: currentWindow); pendingMouseLocation = nil; lastUpdateTime = 0 snapRects = WindowManager.getVisibleWindowRects(excluding: currentWindow) @@ -92,11 +107,22 @@ class MouseTracker { } } private func handleMouseMoved(_ event: NSEvent) { - guard isTracking, let _ = initialMouseLocation, let _ = initialWindowLocation, let _ = trackedWindow else { return } - if checkForKeyPresses() { pauseTracking(); return } + updateTrackingFromCurrentMouseLocation(timestamp: event.timestamp) + } + func updateTrackingFromCurrentMouseLocation(timestamp: TimeInterval) { + updateTracking(withMouseLocation: currentMouseLocation(), timestamp: timestamp) + } + func updateTracking(withMouseLocation mouseLocation: NSPoint, timestamp: TimeInterval, allowsKeyInterruption: Bool = true) { + guard isTracking, let _ = initialMouseLocation, let _ = initialWindowLocation, let _ = trackedWindow else { + return + } + if allowsKeyInterruption && checkForKeyPresses() { + pauseTracking() + return + } if shouldFocusWindow && !trackedWindowIsFocused, let w = trackedWindow { WindowManager.focus(window: w); trackedWindowIsFocused = true } - pendingMouseLocation = NSEvent.mouseLocation - if event.timestamp - lastUpdateTime >= minimumUpdateInterval { flushPendingMouseUpdate(at: event.timestamp) } + pendingMouseLocation = mouseLocation + if timestamp - lastUpdateTime >= minimumUpdateInterval { flushPendingMouseUpdate(at: timestamp) } } private func flushPendingMouseUpdate(at timestamp: TimeInterval? = nil) { guard let loc = pendingMouseLocation else { return }; pendingMouseLocation = nil @@ -106,9 +132,13 @@ class MouseTracker { private func moveWindowBasedOnMouseLocation(_ loc: NSPoint) { guard let im = initialMouseLocation, let iw = initialWindowLocation, let w = trackedWindow else { return } let dx = loc.x - im.x, dy = loc.y - im.y - var newO = NSPoint(x: iw.x + dx, y: iw.y - dy) + let newY = mouseLocationCoordinateSpace == .coreGraphics ? iw.y + dy : iw.y - dy + var newO = NSPoint(x: iw.x + dx, y: newY) if let size = windowSize { newO = snappedOrigin(forMoving: CGRect(origin: newO, size: size)) } - if !pointsApproximatelyEqual(newO, lastAppliedOrigin) { lastAppliedOrigin = newO; WindowManager.move(window: w, to: newO) } + if !pointsApproximatelyEqual(newO, lastAppliedOrigin) { + let result = WindowManager.move(window: w, to: newO) + if result == .success { lastAppliedOrigin = newO } + } } private func resizeWindowBasedOnMouseLocation(_ loc: NSPoint) { guard let s = windowSize, let im = initialMouseLocation, let iw = initialWindowLocation, let w = trackedWindow else { return } @@ -192,7 +222,7 @@ class MouseTracker { } private func invalidateTrackingTimer() { trackingTimer?.invalidate(); trackingTimer = nil } private func removeMouseEventMonitor() { if let m = mouseEventMonitor { NSEvent.removeMonitor(m); mouseEventMonitor = nil } } - private func resetTrackingVariables() { pendingMouseLocation = nil; lastUpdateTime = 0; lastAppliedOrigin = nil; lastAppliedSize = nil; snapRects = []; trackedWindow = nil; initialMouseLocation = nil; initialWindowLocation = nil; currentAction = .none; quadrant = nil; windowSize = nil } + private func resetTrackingVariables() { pendingMouseLocation = nil; lastUpdateTime = 0; lastAppliedOrigin = nil; lastAppliedSize = nil; snapRects = []; trackedWindow = nil; initialMouseLocation = nil; initialWindowLocation = nil; currentAction = .none; quadrant = nil; windowSize = nil; mouseLocationCoordinateSpace = .appKit } func pauseTracking() { isTracking = false } func resumeTracking() { if currentAction != .none && trackedWindow != nil { isTracking = true } } private func checkForKeyPresses() -> Bool { @@ -206,4 +236,10 @@ class MouseTracker { private func sizesApproximatelyEqual(_ a: CGSize?, _ b: CGSize?) -> Bool { guard let a = a, let b = b else { return false }; return abs(a.width - b.width) < 0.5 && abs(a.height - b.height) < 0.5 } + private func currentMouseLocation() -> NSPoint { + if mouseLocationCoordinateSpace == .coreGraphics, let event = CGEvent(source: nil) { + return event.location + } + return NSEvent.mouseLocation + } } diff --git a/Swift Shift/src/Manager/Preferences.swift b/Swift Shift/src/Manager/Preferences.swift index b828aaf..02eaa1c 100644 --- a/Swift Shift/src/Manager/Preferences.swift +++ b/Swift Shift/src/Manager/Preferences.swift @@ -5,6 +5,7 @@ enum PreferenceKey: String { case showMenuBarIcon = "showMenuBarIcon" case useQuadrants = "useQuadrants" case requireMouseClick = "requireMouseClick" + case moveWithBothMouseButtons = "moveWithBothMouseButtons" case fnShortcutWarningDismissed = "fnShortcutWarningDismissed" case ignoredApps = "ignoredApps" case didMigrateDefaultIgnoredApps = "didMigrateDefaultIgnoredApps" diff --git a/Swift Shift/src/Manager/ShortcutsManager.swift b/Swift Shift/src/Manager/ShortcutsManager.swift index 26f993d..53ba9d5 100644 --- a/Swift Shift/src/Manager/ShortcutsManager.swift +++ b/Swift Shift/src/Manager/ShortcutsManager.swift @@ -169,6 +169,9 @@ class ShortcutsManager { private(set) var activeShortcuts: [ShortcutType: Bool] = [:] private var mouseSubscriptions: Set = [] private var workspaceNotificationObserver: Any? + var hasActiveShortcut: Bool { + activeShortcuts.values.contains(true) + } private init() { for type in ShortcutType.allCases { @@ -590,3 +593,162 @@ class ShortcutsManager { mouseSubscriptions.remove(upKey) } } + +class MouseChordMoveManager { + static let shared = MouseChordMoveManager() + + private let subscriberKey = "moveWithBothMouseButtons_mouseChord" + private var isSubscribed = false + private var isMoveActive = false + private var leftButtonIsDown = false + private var rightButtonIsDown = false + private var workspaceNotificationObserver: Any? + + private init() { + registerForWorkspaceNotifications() + } + + deinit { + cleanup() + } + + func updateSubscriptions() { + if PreferencesManager.loadBool(for: .moveWithBothMouseButtons) { + subscribeIfNeeded() + } else { + stopChordMove(resetButtons: true) + unsubscribe() + } + } + + func cleanup() { + stopChordMove(resetButtons: true) + unsubscribe() + unregisterForWorkspaceNotifications() + } + + private func registerForWorkspaceNotifications() { + let notificationCenter = NSWorkspace.shared.notificationCenter + workspaceNotificationObserver = notificationCenter.addObserver( + forName: NSWorkspace.activeSpaceDidChangeNotification, + object: nil, + queue: .main) { [weak self] _ in + self?.stopChordMove(resetButtons: true) + } + } + + private func unregisterForWorkspaceNotifications() { + if let observer = workspaceNotificationObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + workspaceNotificationObserver = nil + } + } + + private func subscribeIfNeeded() { + guard !isSubscribed else { + return + } + + CGEventSupervisor.shared.subscribe( + as: subscriberKey, + to: .cgEvents(.leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .leftMouseDragged, .rightMouseDragged), + using: { [weak self] event in + self?.handle(event) + }) + + isSubscribed = true + } + + private func unsubscribe() { + guard isSubscribed else { + return + } + CGEventSupervisor.shared.cancel(subscriber: subscriberKey) + isSubscribed = false + } + + private func handle(_ event: CGEvent) { + guard PreferencesManager.loadBool(for: .moveWithBothMouseButtons) else { + stopChordMove(resetButtons: true) + unsubscribe() + return + } + + switch event.type { + case .leftMouseDown: + leftButtonIsDown = true + handleButtonDown(event) + case .rightMouseDown: + rightButtonIsDown = true + handleButtonDown(event) + case .leftMouseUp: + leftButtonIsDown = false + handleButtonUp(event) + case .rightMouseUp: + rightButtonIsDown = false + handleButtonUp(event) + case .leftMouseDragged, .rightMouseDragged: + handleDrag(event) + default: + break + } + } + + private func handleButtonDown(_ event: CGEvent) { + if isMoveActive { + event.cancel() + return + } + + startChordMoveIfReady(eventToCancelOnSuccess: event) + } + + private func handleButtonUp(_ event: CGEvent) { + if isMoveActive { + event.cancel() + stopChordMove(resetButtons: false) + } + } + + private func handleDrag(_ event: CGEvent) { + if isMoveActive { + guard leftButtonIsDown && rightButtonIsDown && !ShortcutsManager.shared.hasActiveShortcut else { + stopChordMove(resetButtons: false) + return + } + + MouseTracker.shared.updateTracking(withMouseLocation: event.location, timestamp: ProcessInfo.processInfo.systemUptime, allowsKeyInterruption: false) + event.cancel() + return + } + + startChordMoveIfReady(eventToCancelOnSuccess: event) + } + + private func startChordMoveIfReady(eventToCancelOnSuccess event: CGEvent) { + guard leftButtonIsDown && rightButtonIsDown else { + return + } + + guard !ShortcutsManager.shared.hasActiveShortcut else { + return + } + + if MouseTracker.shared.startTrackingForExternalMouseUpdates(for: .move, initialMouseLocation: event.location) { + isMoveActive = true + event.cancel() + } + } + + private func stopChordMove(resetButtons: Bool) { + if isMoveActive { + MouseTracker.shared.stopTracking(for: .move) + isMoveActive = false + } + + if resetButtons { + leftButtonIsDown = false + rightButtonIsDown = false + } + } +} diff --git a/Swift Shift/src/Manager/UpdatesManager.swift b/Swift Shift/src/Manager/UpdatesManager.swift index cb34ce7..ecf2159 100644 --- a/Swift Shift/src/Manager/UpdatesManager.swift +++ b/Swift Shift/src/Manager/UpdatesManager.swift @@ -1,16 +1,48 @@ import Sparkle +import AppKit -class UpdatesManager { - static var shared = UpdatesManager() - let controller: SPUStandardUpdaterController - let updater: SPUUpdater +class UpdatesManager: NSObject, SPUStandardUserDriverDelegate { + static let shared = UpdatesManager() + lazy var controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: self) + lazy var updater = controller.updater + private var presentedUpdateUI = false - init() { - self.controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) - self.updater = controller.updater + var supportsGentleScheduledUpdateReminders: Bool { + true + } + + override private init() { + super.init() } func checkForUpdates() { return self.updater.checkForUpdates() } + + func standardUserDriverWillHandleShowingUpdate(_ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, state: SPUUserUpdateState) { + guard handleShowingUpdate else { return } + presentedUpdateUI = true + NSApp.setActivationPolicy(.regular) + + if !state.userInitiated { + NSApp.dockTile.badgeLabel = "1" + } + } + + func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { + clearUpdateAttention() + } + + func standardUserDriverWillFinishUpdateSession() { + clearUpdateAttention() + + if presentedUpdateUI { + NSApp.setActivationPolicy(.accessory) + presentedUpdateUI = false + } + } + + private func clearUpdateAttention() { + NSApp.dockTile.badgeLabel = "" + } } diff --git a/Swift Shift/src/Manager/WindowManager.swift b/Swift Shift/src/Manager/WindowManager.swift index f450c08..3560963 100644 --- a/Swift Shift/src/Manager/WindowManager.swift +++ b/Swift Shift/src/Manager/WindowManager.swift @@ -7,9 +7,10 @@ struct WindowBounds { let bottomRight: NSPoint } class WindowManager { - static func move(window: AXUIElement, to point: NSPoint) { + @discardableResult + static func move(window: AXUIElement, to point: NSPoint) -> AXError { var p = point; let v = AXValueCreate(.cgPoint, &p)! - AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, v) + return AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, v) } static func resize(window: AXUIElement, to s: CGSize, from o: NSPoint, shouldMoveOrigin: Bool = true) { if shouldMoveOrigin { move(window: window, to: o) } @@ -40,12 +41,15 @@ class WindowManager { } static func getCurrentWindow() -> AXUIElement? { guard let ev = CGEvent(source: nil) else { return nil } + return getCurrentWindow(at: ev.location) + } + static func getCurrentWindow(at mouseLocation: NSPoint) -> AXUIElement? { let sys = AXUIElementCreateSystemWide(); var el: AXUIElement? - if AXUIElementCopyElementAtPosition(sys, Float(ev.location.x), Float(ev.location.y), &el) == .success, let el = el, let w = getWindow(from: el) { + if AXUIElementCopyElementAtPosition(sys, Float(mouseLocation.x), Float(mouseLocation.y), &el) == .success, let el = el, let w = getWindow(from: el) { var pid: pid_t = 0; AXUIElementGetPid(w, &pid) if pid != NSRunningApplication.current.processIdentifier { return w } } - return getTopWindowAtCursorUsingCGWindowList(mouseLocation: ev.location) + return getTopWindowAtCursorUsingCGWindowList(mouseLocation: mouseLocation) } private static func getTopWindowAtCursorUsingCGWindowList(mouseLocation: NSPoint) -> AXUIElement? { let list = CGWindowListCopyWindowInfo([.excludeDesktopElements, .optionOnScreenOnly], kCGNullWindowID) as? [[String: AnyObject]] ?? [] @@ -89,4 +93,3 @@ class WindowManager { return WindowBounds(topLeft: fixed, topRight: NSPoint(x: fixed.x + windowSize.width, y: fixed.y), bottomLeft: NSPoint(x: fixed.x, y: fixed.y - windowSize.height), bottomRight: NSPoint(x: fixed.x + windowSize.width, y: fixed.y - windowSize.height)) } } - diff --git a/Swift Shift/src/View/PreferencesView.swift b/Swift Shift/src/View/PreferencesView.swift index b213b35..e82b8bc 100644 --- a/Swift Shift/src/View/PreferencesView.swift +++ b/Swift Shift/src/View/PreferencesView.swift @@ -36,6 +36,7 @@ struct PreferencesView: View { @AppStorage(PreferenceKey.focusOnApp.rawValue) var focusOnApp = true @AppStorage(PreferenceKey.useQuadrants.rawValue) var useQuadrants = false @AppStorage(PreferenceKey.requireMouseClick.rawValue) var requireMouseClick = false + @AppStorage(PreferenceKey.moveWithBothMouseButtons.rawValue) var moveWithBothMouseButtons = false var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -86,6 +87,16 @@ struct PreferencesView: View { ShortcutsManager.shared.removeClickActionsForAll() } } + + PreferenceToggle( + isOn: $moveWithBothMouseButtons, + title: "Move with both mouse buttons", + subtitle: "No keyboard shortcut required", + icon: "cursorarrow.click.2" + ) + .onChange(of: moveWithBothMouseButtons) { _ in + MouseChordMoveManager.shared.updateSubscriptions() + } } } } diff --git a/Swift Shift/src/View/ShortcutView.swift b/Swift Shift/src/View/ShortcutView.swift index 2a09adc..8ba2b4f 100644 --- a/Swift Shift/src/View/ShortcutView.swift +++ b/Swift Shift/src/View/ShortcutView.swift @@ -169,7 +169,6 @@ private struct ShortcutRecorderView: NSViewRepresentable { func updateNSView(_ nsView: FnShortcutRecorderControl, context: Context) { nsView.shortcut = shortcut - nsView.translatesAutoresizingMaskIntoConstraints = false } func makeCoordinator() -> Coordinator { From 7f0d3ae0cc986eeded515b67b6ace2bc0db9c697 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Fri, 5 Jun 2026 21:06:15 +0200 Subject: [PATCH 2/7] Refine shortcut trigger settings UI --- Swift Shift/src/AppDelegate.swift | 4 +- Swift Shift/src/Constants.swift | 2 +- Swift Shift/src/Manager/MouseTracker.swift | 12 +- .../src/Manager/ShortcutsManager.swift | 223 ++++++++++++++---- Swift Shift/src/View/PreferencesView.swift | 24 -- Swift Shift/src/View/SettingsView.swift | 11 +- Swift Shift/src/View/ShortcutView.swift | 213 +++++++++++++---- 7 files changed, 360 insertions(+), 129 deletions(-) diff --git a/Swift Shift/src/AppDelegate.swift b/Swift Shift/src/AppDelegate.swift index 95aff64..6debca7 100644 --- a/Swift Shift/src/AppDelegate.swift +++ b/Swift Shift/src/AppDelegate.swift @@ -13,7 +13,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { let _ = ShortcutsManager.shared // immediately register shortcuts so we won't wait for the UI - MouseChordMoveManager.shared.updateSubscriptions() + MouseChordActionManager.shared.updateSubscriptions() } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -24,6 +24,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { shortcutMonitor?.removeAllActions() ShortcutsManager.shared.cleanupAllShortcuts() - MouseChordMoveManager.shared.cleanup() + MouseChordActionManager.shared.cleanup() } } diff --git a/Swift Shift/src/Constants.swift b/Swift Shift/src/Constants.swift index 97c1830..9430994 100644 --- a/Swift Shift/src/Constants.swift +++ b/Swift Shift/src/Constants.swift @@ -10,4 +10,4 @@ let DEFAULT_IGNORED_APP_BUNDLE_ID = [ ] let IGNORE_APP_BUNDLE_ID = SYSTEM_IGNORED_APP_BUNDLE_ID + DEFAULT_IGNORED_APP_BUNDLE_ID -let MAIN_WINDOW_WIDTH = 320.0 +let MAIN_WINDOW_WIDTH = 360.0 diff --git a/Swift Shift/src/Manager/MouseTracker.swift b/Swift Shift/src/Manager/MouseTracker.swift index 294f0dd..168ea12 100644 --- a/Swift Shift/src/Manager/MouseTracker.swift +++ b/Swift Shift/src/Manager/MouseTracker.swift @@ -96,7 +96,17 @@ class MouseTracker { } private func registerMouseEventMonitor(button: MouseButton) { removeMouseEventMonitor() - let mask: NSEvent.EventTypeMask = button == .left ? .leftMouseDragged : (button == .right ? .rightMouseDragged : .mouseMoved) + let mask: NSEvent.EventTypeMask + switch button { + case .left: + mask = .leftMouseDragged + case .right: + mask = .rightMouseDragged + case .both: + mask = [.leftMouseDragged, .rightMouseDragged] + case .none: + mask = .mouseMoved + } mouseEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [mask]) { [weak self] e in self?.handleMouseMoved(e) } } private func startTrackingTimer() { diff --git a/Swift Shift/src/Manager/ShortcutsManager.swift b/Swift Shift/src/Manager/ShortcutsManager.swift index 53ba9d5..2fc6658 100644 --- a/Swift Shift/src/Manager/ShortcutsManager.swift +++ b/Swift Shift/src/Manager/ShortcutsManager.swift @@ -11,6 +11,7 @@ enum MouseButton: String, CaseIterable { case none = "None" case left = "Left" case right = "Right" + case both = "Both" static func parse(rawValue: String?) -> MouseButton { guard let rawValue = rawValue else { return .none } @@ -154,15 +155,23 @@ struct UserShortcut { var shortcut: Shortcut? var keyboardShortcut: KeyboardShortcut? var mouseButton: MouseButton + var keyboardEnabled: Bool + var mouseEnabled: Bool - init(type: ShortcutType, shortcut: Shortcut? = nil, keyboardShortcut: KeyboardShortcut? = nil, mouseButton: MouseButton) { + init(type: ShortcutType, shortcut: Shortcut? = nil, keyboardShortcut: KeyboardShortcut? = nil, mouseButton: MouseButton, keyboardEnabled: Bool = true, mouseEnabled: Bool = false) { self.type = type self.shortcut = shortcut self.keyboardShortcut = keyboardShortcut ?? shortcut.map { KeyboardShortcut(shortcut: $0) } self.mouseButton = mouseButton + self.keyboardEnabled = keyboardEnabled + self.mouseEnabled = mouseEnabled } } +extension Notification.Name { + static let shortcutsDidChange = Notification.Name("shortcutsDidChange") +} + class ShortcutsManager { static let shared = ShortcutsManager() var globalMonitors: [Any] = [] @@ -232,43 +241,57 @@ class ShortcutsManager { } func save(_ userShortcut: UserShortcut) { - guard let keyboardShortcut = userShortcut.keyboardShortcut else { - delete(for: userShortcut.type) - return - } - - do { - let data = try JSONEncoder().encode(keyboardShortcut) - UserDefaults.standard.set(data, forKey: keyboardShortcutKey(for: userShortcut.type)) - UserDefaults.standard.set(userShortcut.mouseButton.rawValue, forKey: mouseButtonKey(for: userShortcut.type)) + if let keyboardShortcut = userShortcut.keyboardShortcut { + do { + let data = try JSONEncoder().encode(keyboardShortcut) + UserDefaults.standard.set(data, forKey: keyboardShortcutKey(for: userShortcut.type)) - if let shortcut = userShortcut.shortcut ?? keyboardShortcut.shortcutRecorderShortcut { - let data = try NSKeyedArchiver.archivedData(withRootObject: shortcut, requiringSecureCoding: false) - UserDefaults.standard.set(data, forKey: userShortcut.type.rawValue) - } else { - UserDefaults.standard.removeObject(forKey: userShortcut.type.rawValue) + if let shortcut = userShortcut.shortcut ?? keyboardShortcut.shortcutRecorderShortcut { + let data = try NSKeyedArchiver.archivedData(withRootObject: shortcut, requiringSecureCoding: false) + UserDefaults.standard.set(data, forKey: userShortcut.type.rawValue) + } else { + UserDefaults.standard.removeObject(forKey: userShortcut.type.rawValue) + } + } catch { + print("Error: \(error)") } - } catch { - print("Error: \(error)") + } else { + UserDefaults.standard.removeObject(forKey: keyboardShortcutKey(for: userShortcut.type)) + UserDefaults.standard.removeObject(forKey: userShortcut.type.rawValue) } + + UserDefaults.standard.set(userShortcut.mouseButton.rawValue, forKey: mouseButtonKey(for: userShortcut.type)) + UserDefaults.standard.set(userShortcut.keyboardEnabled, forKey: keyboardEnabledKey(for: userShortcut.type)) + UserDefaults.standard.set(userShortcut.mouseEnabled, forKey: mouseEnabledKey(for: userShortcut.type)) + enforceMouseOnlyExclusivity(for: userShortcut) + updateGlobalShortcuts() + MouseChordActionManager.shared.updateSubscriptions() + NotificationCenter.default.post(name: .shortcutsDidChange, object: userShortcut.type) } func load(for type: ShortcutType) -> UserShortcut? { let mouseButton = MouseButton.parse(rawValue: UserDefaults.standard.string(forKey: mouseButtonKey(for: type))) + let hasSavedKeyboardTrigger = UserDefaults.standard.object(forKey: keyboardEnabledKey(for: type)) != nil + let hasSavedMouseTrigger = UserDefaults.standard.object(forKey: mouseEnabledKey(for: type)) != nil + let keyboardEnabled = loadTriggerBool(forKey: keyboardEnabledKey(for: type), defaultValue: true) + let mouseEnabled = loadTriggerBool( + forKey: mouseEnabledKey(for: type), + defaultValue: !hasSavedKeyboardTrigger && !hasSavedMouseTrigger && PreferencesManager.loadBool(for: .requireMouseClick) && mouseButton != .none + ) if let data = UserDefaults.standard.data(forKey: keyboardShortcutKey(for: type)) { do { let keyboardShortcut = try JSONDecoder().decode(KeyboardShortcut.self, from: data) let shortcut = loadLegacyShortcut(for: type) ?? keyboardShortcut.shortcutRecorderShortcut - return UserShortcut(type: type, shortcut: shortcut, keyboardShortcut: keyboardShortcut, mouseButton: mouseButton) + return UserShortcut(type: type, shortcut: shortcut, keyboardShortcut: keyboardShortcut, mouseButton: mouseButton, keyboardEnabled: keyboardEnabled, mouseEnabled: mouseEnabled) } catch { print("Error decoding shortcut: \(error.localizedDescription)") } } if let shortcut = loadLegacyShortcut(for: type) { - let migrated = UserShortcut(type: type, shortcut: shortcut, mouseButton: mouseButton) + let migrated = UserShortcut(type: type, shortcut: shortcut, mouseButton: mouseButton, keyboardEnabled: keyboardEnabled, mouseEnabled: mouseEnabled) if let keyboardShortcut = migrated.keyboardShortcut { do { @@ -283,20 +306,25 @@ class ShortcutsManager { return migrated } - return nil + return UserShortcut(type: type, mouseButton: mouseButton, keyboardEnabled: keyboardEnabled, mouseEnabled: mouseEnabled) } func delete(for type: ShortcutType) { UserDefaults.standard.removeObject(forKey: keyboardShortcutKey(for: type)) UserDefaults.standard.removeObject(forKey: type.rawValue) UserDefaults.standard.removeObject(forKey: mouseButtonKey(for: type)) + UserDefaults.standard.removeObject(forKey: keyboardEnabledKey(for: type)) + UserDefaults.standard.removeObject(forKey: mouseEnabledKey(for: type)) updateGlobalShortcuts() + MouseChordActionManager.shared.updateSubscriptions() + NotificationCenter.default.post(name: .shortcutsDidChange, object: type) } func removeClickActionsForAll() { for type in ShortcutType.allCases { if var userShortcut = load(for: type) { userShortcut.mouseButton = .none + userShortcut.mouseEnabled = false self.save(userShortcut) } } @@ -334,6 +362,30 @@ class ShortcutsManager { "\(type.rawValue)_mouseButton" } + private func keyboardEnabledKey(for type: ShortcutType) -> String { + "\(type.rawValue)_keyboardEnabled" + } + + private func mouseEnabledKey(for type: ShortcutType) -> String { + "\(type.rawValue)_mouseEnabled" + } + + private func loadTriggerBool(forKey key: String, defaultValue: Bool) -> Bool { + guard UserDefaults.standard.object(forKey: key) != nil else { return defaultValue } + return UserDefaults.standard.bool(forKey: key) + } + + private func enforceMouseOnlyExclusivity(for userShortcut: UserShortcut) { + guard !userShortcut.keyboardEnabled && userShortcut.mouseEnabled else { return } + + for type in ShortcutType.allCases where type != userShortcut.type { + guard let other = load(for: type), !other.keyboardEnabled, other.mouseEnabled else { continue } + UserDefaults.standard.set(true, forKey: keyboardEnabledKey(for: type)) + UserDefaults.standard.set(false, forKey: mouseEnabledKey(for: type)) + UserDefaults.standard.set(MouseButton.none.rawValue, forKey: mouseButtonKey(for: type)) + } + } + private func loadLegacyShortcut(for type: ShortcutType) -> Shortcut? { guard let data = UserDefaults.standard.data(forKey: type.rawValue) else { return nil } do { @@ -506,7 +558,7 @@ class ShortcutsManager { clearActionsAndMonitors() for type in ShortcutType.allCases { - if let userShortcut = load(for: type), let keyboardShortcut = userShortcut.keyboardShortcut { + if let userShortcut = load(for: type), userShortcut.keyboardEnabled, let keyboardShortcut = userShortcut.keyboardShortcut { let mouseAction = type == .move ? MouseAction.move : MouseAction.resize if keyboardShortcut.usesFunctionModifier || userShortcut.shortcut == nil { @@ -527,11 +579,16 @@ class ShortcutsManager { } private func startTracking(_ userShortcut: UserShortcut, _ action: MouseAction) { - if userShortcut.mouseButton == .none { + if !userShortcut.mouseEnabled || userShortcut.mouseButton == .none { MouseTracker.shared.startTracking(for: action, button: .none) return } + if userShortcut.mouseButton == .both { + startTrackingWithBothMouseButtons(userShortcut, action) + return + } + let downEvent: CGEventType = userShortcut.mouseButton == .left ? .leftMouseDown : .rightMouseDown let upEvent: CGEventType = userShortcut.mouseButton == .left ? .leftMouseUp : .rightMouseUp let downKey = "\(action.rawValue)_mouseDown" @@ -571,6 +628,67 @@ class ShortcutsManager { mouseSubscriptions.insert(upKey) } + private func startTrackingWithBothMouseButtons(_ userShortcut: UserShortcut, _ action: MouseAction) { + let downKey = "\(action.rawValue)_mouseDown" + let upKey = "\(action.rawValue)_mouseUp" + var leftButtonIsDown = false + var rightButtonIsDown = false + var isMouseTracking = false + + cleanupMouseSubscriptions(action: action) + + CGEventSupervisor.shared.subscribe( + as: downKey, + to: .cgEvents(.leftMouseDown, .rightMouseDown), + using: { [weak self] event in + guard let self = self, self.activeShortcuts[userShortcut.type] == true else { return } + guard self.isShortcutStillPressed(userShortcut) else { + self.stopTracking(userShortcut, action) + return + } + + if event.type == .leftMouseDown { + leftButtonIsDown = true + } else if event.type == .rightMouseDown { + rightButtonIsDown = true + } + + if leftButtonIsDown && rightButtonIsDown && !isMouseTracking { + isMouseTracking = true + event.cancel() + MouseTracker.shared.startTracking(for: action, button: .both) + } else if isMouseTracking { + event.cancel() + } + }) + + CGEventSupervisor.shared.subscribe( + as: upKey, + to: .cgEvents(.leftMouseUp, .rightMouseUp), + using: { [weak self] event in + guard let self = self, self.activeShortcuts[userShortcut.type] == true else { return } + guard self.isShortcutStillPressed(userShortcut) else { + self.stopTracking(userShortcut, action) + return + } + + if event.type == .leftMouseUp { + leftButtonIsDown = false + } else if event.type == .rightMouseUp { + rightButtonIsDown = false + } + + if isMouseTracking { + event.cancel() + MouseTracker.shared.stopTracking(for: action) + isMouseTracking = false + } + }) + + mouseSubscriptions.insert(downKey) + mouseSubscriptions.insert(upKey) + } + private func stopTracking(_ userShortcut: UserShortcut, _ action: MouseAction) { MouseTracker.shared.stopTracking(for: action) cleanupMouseSubscriptions(action: action) @@ -594,12 +712,12 @@ class ShortcutsManager { } } -class MouseChordMoveManager { - static let shared = MouseChordMoveManager() +class MouseChordActionManager { + static let shared = MouseChordActionManager() - private let subscriberKey = "moveWithBothMouseButtons_mouseChord" + private let subscriberKey = "mouseOnlyBothButtonsChord" private var isSubscribed = false - private var isMoveActive = false + private var activeAction: MouseAction? private var leftButtonIsDown = false private var rightButtonIsDown = false private var workspaceNotificationObserver: Any? @@ -613,16 +731,16 @@ class MouseChordMoveManager { } func updateSubscriptions() { - if PreferencesManager.loadBool(for: .moveWithBothMouseButtons) { + if configuredMouseOnlyAction() != nil { subscribeIfNeeded() } else { - stopChordMove(resetButtons: true) + stopChordAction(resetButtons: true) unsubscribe() } } func cleanup() { - stopChordMove(resetButtons: true) + stopChordAction(resetButtons: true) unsubscribe() unregisterForWorkspaceNotifications() } @@ -633,7 +751,7 @@ class MouseChordMoveManager { forName: NSWorkspace.activeSpaceDidChangeNotification, object: nil, queue: .main) { [weak self] _ in - self?.stopChordMove(resetButtons: true) + self?.stopChordAction(resetButtons: true) } } @@ -668,8 +786,8 @@ class MouseChordMoveManager { } private func handle(_ event: CGEvent) { - guard PreferencesManager.loadBool(for: .moveWithBothMouseButtons) else { - stopChordMove(resetButtons: true) + guard configuredMouseOnlyAction() != nil else { + stopChordAction(resetButtons: true) unsubscribe() return } @@ -695,25 +813,25 @@ class MouseChordMoveManager { } private func handleButtonDown(_ event: CGEvent) { - if isMoveActive { + if activeAction != nil { event.cancel() return } - startChordMoveIfReady(eventToCancelOnSuccess: event) + startChordActionIfReady(eventToCancelOnSuccess: event) } private func handleButtonUp(_ event: CGEvent) { - if isMoveActive { + if activeAction != nil { event.cancel() - stopChordMove(resetButtons: false) + stopChordAction(resetButtons: false) } } private func handleDrag(_ event: CGEvent) { - if isMoveActive { + if activeAction != nil { guard leftButtonIsDown && rightButtonIsDown && !ShortcutsManager.shared.hasActiveShortcut else { - stopChordMove(resetButtons: false) + stopChordAction(resetButtons: false) return } @@ -722,28 +840,28 @@ class MouseChordMoveManager { return } - startChordMoveIfReady(eventToCancelOnSuccess: event) + startChordActionIfReady(eventToCancelOnSuccess: event) } - private func startChordMoveIfReady(eventToCancelOnSuccess event: CGEvent) { + private func startChordActionIfReady(eventToCancelOnSuccess event: CGEvent) { guard leftButtonIsDown && rightButtonIsDown else { return } - guard !ShortcutsManager.shared.hasActiveShortcut else { + guard !ShortcutsManager.shared.hasActiveShortcut, let action = configuredMouseOnlyAction() else { return } - if MouseTracker.shared.startTrackingForExternalMouseUpdates(for: .move, initialMouseLocation: event.location) { - isMoveActive = true + if MouseTracker.shared.startTrackingForExternalMouseUpdates(for: action, initialMouseLocation: event.location) { + activeAction = action event.cancel() } } - private func stopChordMove(resetButtons: Bool) { - if isMoveActive { - MouseTracker.shared.stopTracking(for: .move) - isMoveActive = false + private func stopChordAction(resetButtons: Bool) { + if let activeAction { + MouseTracker.shared.stopTracking(for: activeAction) + self.activeAction = nil } if resetButtons { @@ -751,4 +869,15 @@ class MouseChordMoveManager { rightButtonIsDown = false } } + + private func configuredMouseOnlyAction() -> MouseAction? { + for type in ShortcutType.allCases { + guard let shortcut = ShortcutsManager.shared.load(for: type), !shortcut.keyboardEnabled, shortcut.mouseEnabled else { + continue + } + return type == .move ? .move : .resize + } + + return nil + } } diff --git a/Swift Shift/src/View/PreferencesView.swift b/Swift Shift/src/View/PreferencesView.swift index e82b8bc..bab340a 100644 --- a/Swift Shift/src/View/PreferencesView.swift +++ b/Swift Shift/src/View/PreferencesView.swift @@ -35,8 +35,6 @@ struct PreferencesView: View { @AppStorage(PreferenceKey.showMenuBarIcon.rawValue) var showMenuBarIcon = true @AppStorage(PreferenceKey.focusOnApp.rawValue) var focusOnApp = true @AppStorage(PreferenceKey.useQuadrants.rawValue) var useQuadrants = false - @AppStorage(PreferenceKey.requireMouseClick.rawValue) var requireMouseClick = false - @AppStorage(PreferenceKey.moveWithBothMouseButtons.rawValue) var moveWithBothMouseButtons = false var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -75,28 +73,6 @@ struct PreferencesView: View { subtitle: "Resize from nearest edge/corner", icon: "rectangle.split.2x2" ) - - PreferenceToggle( - isOn: $requireMouseClick, - title: "Require mouse click", - subtitle: "Use mouse buttons in shortcuts", - icon: "computermouse" - ) - .onChange(of: requireMouseClick) { newValue in - if newValue == false { - ShortcutsManager.shared.removeClickActionsForAll() - } - } - - PreferenceToggle( - isOn: $moveWithBothMouseButtons, - title: "Move with both mouse buttons", - subtitle: "No keyboard shortcut required", - icon: "cursorarrow.click.2" - ) - .onChange(of: moveWithBothMouseButtons) { _ in - MouseChordMoveManager.shared.updateSubscriptions() - } } } } diff --git a/Swift Shift/src/View/SettingsView.swift b/Swift Shift/src/View/SettingsView.swift index 45a5adf..049ac73 100644 --- a/Swift Shift/src/View/SettingsView.swift +++ b/Swift Shift/src/View/SettingsView.swift @@ -18,7 +18,6 @@ struct SettingsView: View { @State private var hasPermissions = false @State private var hasFunctionKeyShortcut = false @State private var hadFunctionKeyShortcut = false - @AppStorage(PreferenceKey.requireMouseClick.rawValue) var requireMouseClick = false @AppStorage(PreferenceKey.fnShortcutWarningDismissed.rawValue) var fnShortcutWarningDismissed = false private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -26,7 +25,8 @@ struct SettingsView: View { self._hasPermissions = State(initialValue: hasPermissions) let hasFnShortcut = ShortcutType.allCases.contains { type in - ShortcutsManager.shared.load(for: type)?.keyboardShortcut?.usesFunctionModifier == true + guard let shortcut = ShortcutsManager.shared.load(for: type), shortcut.keyboardEnabled else { return false } + return shortcut.keyboardShortcut?.usesFunctionModifier == true } self._hasFunctionKeyShortcut = State(initialValue: hasFnShortcut) self._hadFunctionKeyShortcut = State(initialValue: hasFnShortcut) @@ -38,7 +38,8 @@ struct SettingsView: View { private func refreshFunctionKeyWarning() { let hasFnShortcut = ShortcutType.allCases.contains { type in - ShortcutsManager.shared.load(for: type)?.keyboardShortcut?.usesFunctionModifier == true + guard let shortcut = ShortcutsManager.shared.load(for: type), shortcut.keyboardEnabled else { return false } + return shortcut.keyboardShortcut?.usesFunctionModifier == true } if !hasFnShortcut { @@ -68,7 +69,7 @@ struct SettingsView: View { if hasPermissions { VStack(alignment: .leading, spacing: 8) { - VStack(spacing: requireMouseClick ? 16 : 4) { + VStack(spacing: 12) { ForEach(Array(ShortcutType.allCases), id: \.self) { type in ShortcutView(type: type, onShortcutChanged: refreshFunctionKeyWarning) } @@ -80,8 +81,10 @@ struct SettingsView: View { } } } + .padding(.top, 8) } else { PermissionRequestView() + .padding(.top, 8) } } diff --git a/Swift Shift/src/View/ShortcutView.swift b/Swift Shift/src/View/ShortcutView.swift index 8ba2b4f..597e69a 100644 --- a/Swift Shift/src/View/ShortcutView.swift +++ b/Swift Shift/src/View/ShortcutView.swift @@ -233,9 +233,40 @@ struct ShortcutFnWarningView: View { } } +private struct TriggerToggleButton: View { + let title: String + let icon: String + let isOn: Bool + let canTurnOff: Bool + let onToggle: (Bool) -> Void + + var body: some View { + Button { + if isOn && !canTurnOff { return } + onToggle(!isOn) + } label: { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 10)) + Text(title) + .font(.system(size: 10, weight: isOn ? .semibold : .regular)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background( + Capsule() + .fill(isOn ? Color.teal.opacity(0.2) : Color.primary.opacity(0.05)) + ) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .foregroundStyle(isOn ? .teal : .secondary) + .help(isOn && !canTurnOff ? "At least one trigger is required" : "") + } +} + struct ShortcutView: View { @State private var shortcut: UserShortcut - @AppStorage(PreferenceKey.requireMouseClick.rawValue) private var requireMouseClick = false let onShortcutChanged: () -> Void init(type: ShortcutType, onShortcutChanged: @escaping () -> Void = {}) { @@ -245,82 +276,163 @@ struct ShortcutView: View { } var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { Image(systemName: actionIcon()) - .font(.system(size: 14, weight: .medium)) + .font(.system(size: 15, weight: .medium)) .foregroundStyle(.tint) .frame(width: 22) Text(shortcut.type.rawValue) - .font(.system(size: 13, weight: .semibold)) - .frame(width: 50, alignment: .leading) + .font(.system(size: 15, weight: .semibold)) + } + VStack(spacing: 6) { + keyboardRow + mouseRow + } + } + .padding(10) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + .onAppear { + loadShortcutFromStorage() + } + .onReceive(NotificationCenter.default.publisher(for: .shortcutsDidChange)) { _ in + loadShortcutFromStorage() + } + } + + private var keyboardRow: some View { + HStack(spacing: 8) { + TriggerToggleButton( + title: "Keyboard", + icon: "keyboard", + isOn: shortcut.keyboardEnabled, + canTurnOff: shortcut.mouseEnabled, + onToggle: setKeyboardEnabled + ) + .frame(width: 116) + + HStack(spacing: 4) { ShortcutRecorderView(shortcut: $shortcut.keyboardShortcut) .onChange(of: shortcut.keyboardShortcut) { newValue in shortcut.shortcut = newValue?.shortcutRecorderShortcut - if newValue == nil { - ShortcutsManager.shared.delete(for: shortcut.type) - } else { - ShortcutsManager.shared.save(shortcut) - } - onShortcutChanged() + saveShortcut() } - .frame(maxWidth: .infinity, alignment: .leading) + .disabled(!shortcut.keyboardEnabled) Button { shortcut.shortcut = nil shortcut.keyboardShortcut = nil - onShortcutChanged() + saveShortcut() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 13)) .foregroundStyle(.tertiary) } .buttonStyle(.plain) + .disabled(!shortcut.keyboardEnabled) } + .frame(maxWidth: .infinity) + .opacity(shortcut.keyboardEnabled ? 1 : 0.25) + .animation(.easeInOut(duration: 0.18), value: shortcut.keyboardEnabled) + } + .animation(.easeInOut(duration: 0.18), value: shortcut.keyboardEnabled) + } - if requireMouseClick { - HStack(spacing: 6) { - Image(systemName: "computermouse") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - .frame(width: 22) - - HStack(spacing: 4) { - ForEach(Array(MouseButton.allCases), id: \.self) { mouseButton in - let selected = mouseButton == shortcut.mouseButton - Button { - shortcut.mouseButton = mouseButton - ShortcutsManager.shared.save(shortcut) - onShortcutChanged() - } label: { - HStack(spacing: 3) { - if mouseButton != .none { - Image(systemName: clickIcon(mouseButton)) - .font(.system(size: 9)) - } - Text(mouseButton.rawValue) - .font(.system(size: 10, weight: selected ? .semibold : .regular)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - .background( - Capsule() - .fill(selected ? Color.teal.opacity(0.2) : Color.primary.opacity(0.05)) - ) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - .foregroundStyle(selected ? .teal : .secondary) - } + private var mouseRow: some View { + HStack(spacing: 8) { + TriggerToggleButton( + title: "Mouse", + icon: "computermouse", + isOn: shortcut.mouseEnabled, + canTurnOff: shortcut.keyboardEnabled, + onToggle: setMouseEnabled + ) + .frame(width: 116) + + mouseButtonPicker + .frame(maxWidth: .infinity) + .disabled(!shortcut.mouseEnabled) + .opacity(shortcut.mouseEnabled ? 1 : 0.25) + .animation(.easeInOut(duration: 0.18), value: shortcut.mouseEnabled) + } + .animation(.easeInOut(duration: 0.18), value: shortcut.mouseEnabled) + } + + private var mouseButtonPicker: some View { + HStack(spacing: 4) { + ForEach([MouseButton.left, .right, .both], id: \.self) { mouseButton in + let isAvailable = shortcut.keyboardEnabled || mouseButton == .both + let selected = mouseButton == shortcut.mouseButton || (!shortcut.keyboardEnabled && mouseButton == .both) + Button { + guard isAvailable else { return } + shortcut.mouseButton = mouseButton + saveShortcut() + } label: { + HStack(spacing: 3) { + Image(systemName: clickIcon(mouseButton)) + .font(.system(size: 9)) + Text(mouseButton.rawValue) + .font(.system(size: 10, weight: selected ? .semibold : .regular)) } + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .background( + Capsule() + .fill(selected ? Color.teal.opacity(0.2) : Color.primary.opacity(0.05)) + ) + .clipShape(Capsule()) } - .onAppear { - loadShortcutFromStorage() + .buttonStyle(.plain) + .foregroundStyle(selected ? .teal : .secondary) + .opacity(isAvailable ? 1 : 0.35) + .animation(.easeInOut(duration: 0.18), value: isAvailable) + .disabled(!isAvailable) + } + } + } + + private func setKeyboardEnabled(_ enabled: Bool) { + guard enabled || shortcut.mouseEnabled else { return } + + shortcut.keyboardEnabled = enabled + if !enabled { + shortcut.mouseEnabled = true + shortcut.mouseButton = .both + } else if shortcut.mouseEnabled && shortcut.mouseButton == .none { + shortcut.mouseButton = .left + } + + saveShortcut() + } + + private func setMouseEnabled(_ enabled: Bool) { + guard enabled || shortcut.keyboardEnabled else { return } + + shortcut.mouseEnabled = enabled + if enabled { + if shortcut.keyboardEnabled { + if shortcut.mouseButton == .none { + shortcut.mouseButton = .left } + } else { + shortcut.mouseButton = .both } + } else { + shortcut.mouseButton = .none } + + saveShortcut() + } + + private func saveShortcut() { + ShortcutsManager.shared.save(shortcut) + onShortcutChanged() } private func loadShortcutFromStorage() { @@ -339,6 +451,7 @@ struct ShortcutView: View { switch clickType { case .left: return "capsule.lefthalf.filled" case .right: return "capsule.righthalf.filled" + case .both: return "capsule.fill" case .none: return "" } } From e01c20df0dcb91bbb66a7d21f4de310fff0135a6 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Fri, 5 Jun 2026 21:13:35 +0200 Subject: [PATCH 3/7] Show trigger conflict feedback --- .../src/Manager/ShortcutsManager.swift | 12 --- Swift Shift/src/View/ShortcutView.swift | 90 ++++++++++++++++++- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/Swift Shift/src/Manager/ShortcutsManager.swift b/Swift Shift/src/Manager/ShortcutsManager.swift index 2fc6658..05439de 100644 --- a/Swift Shift/src/Manager/ShortcutsManager.swift +++ b/Swift Shift/src/Manager/ShortcutsManager.swift @@ -263,7 +263,6 @@ class ShortcutsManager { UserDefaults.standard.set(userShortcut.mouseButton.rawValue, forKey: mouseButtonKey(for: userShortcut.type)) UserDefaults.standard.set(userShortcut.keyboardEnabled, forKey: keyboardEnabledKey(for: userShortcut.type)) UserDefaults.standard.set(userShortcut.mouseEnabled, forKey: mouseEnabledKey(for: userShortcut.type)) - enforceMouseOnlyExclusivity(for: userShortcut) updateGlobalShortcuts() MouseChordActionManager.shared.updateSubscriptions() @@ -375,17 +374,6 @@ class ShortcutsManager { return UserDefaults.standard.bool(forKey: key) } - private func enforceMouseOnlyExclusivity(for userShortcut: UserShortcut) { - guard !userShortcut.keyboardEnabled && userShortcut.mouseEnabled else { return } - - for type in ShortcutType.allCases where type != userShortcut.type { - guard let other = load(for: type), !other.keyboardEnabled, other.mouseEnabled else { continue } - UserDefaults.standard.set(true, forKey: keyboardEnabledKey(for: type)) - UserDefaults.standard.set(false, forKey: mouseEnabledKey(for: type)) - UserDefaults.standard.set(MouseButton.none.rawValue, forKey: mouseButtonKey(for: type)) - } - } - private func loadLegacyShortcut(for type: ShortcutType) -> Shortcut? { guard let data = UserDefaults.standard.data(forKey: type.rawValue) else { return nil } do { diff --git a/Swift Shift/src/View/ShortcutView.swift b/Swift Shift/src/View/ShortcutView.swift index 597e69a..2f43da5 100644 --- a/Swift Shift/src/View/ShortcutView.swift +++ b/Swift Shift/src/View/ShortcutView.swift @@ -238,6 +238,7 @@ private struct TriggerToggleButton: View { let icon: String let isOn: Bool let canTurnOff: Bool + let isFlashingError: Bool let onToggle: (Bool) -> Void var body: some View { @@ -255,18 +256,36 @@ private struct TriggerToggleButton: View { .padding(.vertical, 6) .background( Capsule() - .fill(isOn ? Color.teal.opacity(0.2) : Color.primary.opacity(0.05)) + .fill(triggerBackgroundColor) ) .clipShape(Capsule()) } .buttonStyle(.plain) - .foregroundStyle(isOn ? .teal : .secondary) + .foregroundStyle(isFlashingError ? .red : (isOn ? .teal : .secondary)) + .animation(.easeInOut(duration: 0.18), value: isFlashingError) .help(isOn && !canTurnOff ? "At least one trigger is required" : "") } + + private var triggerBackgroundColor: Color { + if isFlashingError { + return .red.opacity(0.28) + } + + return isOn ? Color.teal.opacity(0.2) : Color.primary.opacity(0.05) + } +} + +private enum TriggerKind { + case keyboard + case mouse } struct ShortcutView: View { @State private var shortcut: UserShortcut + @State private var keyboardTriggerErrorPulse = false + @State private var mouseTriggerErrorPulse = false + @State private var errorToastVisible = false + @State private var errorToastMessage = "" let onShortcutChanged: () -> Void init(type: ShortcutType, onShortcutChanged: @escaping () -> Void = {}) { @@ -291,6 +310,11 @@ struct ShortcutView: View { keyboardRow mouseRow } + + if errorToastVisible { + errorToast + .transition(.opacity.combined(with: .scale(scale: 0.98))) + } } .padding(10) .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) @@ -313,6 +337,7 @@ struct ShortcutView: View { icon: "keyboard", isOn: shortcut.keyboardEnabled, canTurnOff: shortcut.mouseEnabled, + isFlashingError: keyboardTriggerErrorPulse, onToggle: setKeyboardEnabled ) .frame(width: 116) @@ -351,6 +376,7 @@ struct ShortcutView: View { icon: "computermouse", isOn: shortcut.mouseEnabled, canTurnOff: shortcut.keyboardEnabled, + isFlashingError: mouseTriggerErrorPulse, onToggle: setMouseEnabled ) .frame(width: 116) @@ -364,6 +390,22 @@ struct ShortcutView: View { .animation(.easeInOut(duration: 0.18), value: shortcut.mouseEnabled) } + private var errorToast: some View { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10, weight: .semibold)) + Text(errorToastMessage) + .font(.system(size: 10, weight: .medium)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + private var mouseButtonPicker: some View { HStack(spacing: 4) { ForEach([MouseButton.left, .right, .both], id: \.self) { mouseButton in @@ -400,6 +442,11 @@ struct ShortcutView: View { private func setKeyboardEnabled(_ enabled: Bool) { guard enabled || shortcut.mouseEnabled else { return } + if !enabled && shortcut.mouseEnabled && hasOtherMouseOnlyAction() { + showTriggerConflict(on: .keyboard) + return + } + shortcut.keyboardEnabled = enabled if !enabled { shortcut.mouseEnabled = true @@ -414,6 +461,11 @@ struct ShortcutView: View { private func setMouseEnabled(_ enabled: Bool) { guard enabled || shortcut.keyboardEnabled else { return } + if enabled && !shortcut.keyboardEnabled && hasOtherMouseOnlyAction() { + showTriggerConflict(on: .mouse) + return + } + shortcut.mouseEnabled = enabled if enabled { if shortcut.keyboardEnabled { @@ -430,6 +482,40 @@ struct ShortcutView: View { saveShortcut() } + private func showTriggerConflict(on trigger: TriggerKind) { + errorToastMessage = "You cannot use the same trigger for both actions" + + withAnimation(.easeIn(duration: 0.08)) { + errorToastVisible = true + switch trigger { + case .keyboard: + keyboardTriggerErrorPulse = true + case .mouse: + mouseTriggerErrorPulse = true + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.14) { + withAnimation(.easeOut(duration: 0.45)) { + keyboardTriggerErrorPulse = false + mouseTriggerErrorPulse = false + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) { + withAnimation(.easeOut(duration: 0.2)) { + errorToastVisible = false + } + } + } + + private func hasOtherMouseOnlyAction() -> Bool { + ShortcutType.allCases.contains { type in + guard type != shortcut.type, let other = ShortcutsManager.shared.load(for: type) else { return false } + return !other.keyboardEnabled && other.mouseEnabled + } + } + private func saveShortcut() { ShortcutsManager.shared.save(shortcut) onShortcutChanged() From 75d58c653f9012d2f0f1ff985e6b052587af4ee8 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Fri, 5 Jun 2026 21:21:19 +0200 Subject: [PATCH 4/7] Address mouse chord review feedback --- Swift Shift/src/Manager/MouseTracker.swift | 24 ++++++++-- .../src/Manager/ShortcutsManager.swift | 48 +++++++++++++------ Swift Shift/src/Manager/UpdatesManager.swift | 2 +- Swift Shift/src/Manager/WindowManager.swift | 8 ++-- 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/Swift Shift/src/Manager/MouseTracker.swift b/Swift Shift/src/Manager/MouseTracker.swift index 168ea12..89cc35f 100644 --- a/Swift Shift/src/Manager/MouseTracker.swift +++ b/Swift Shift/src/Manager/MouseTracker.swift @@ -55,7 +55,7 @@ class MouseTracker { lastAppliedOrigin = initialWindowLocation lastAppliedSize = windowSize if currentAction == .resize, shouldUseQuadrants, let m = initialMouseLocation, let w = initialWindowLocation, let s = windowSize { - quadrant = determineQuadrant(mouseLocation: m, windowSize: s, windowLocation: w) + quadrant = determineQuadrant(mouseLocation: windowBoundsMouseLocation(m), windowSize: s, windowLocation: w) } } private func prepareTracking(for action: MouseAction, mouseLocation: NSPoint, coordinateSpace: MouseLocationCoordinateSpace) { @@ -70,13 +70,29 @@ class MouseTracker { snapRects = WindowManager.getVisibleWindowRects(excluding: currentWindow) lastAppliedOrigin = initialWindowLocation; lastAppliedSize = windowSize if action == .resize && shouldUseQuadrants, let m = initialMouseLocation, let w = initialWindowLocation, let s = windowSize { - quadrant = determineQuadrant(mouseLocation: m, windowSize: s, windowLocation: w) + quadrant = determineQuadrant(mouseLocation: windowBoundsMouseLocation(m), windowSize: s, windowLocation: w) } } private func shouldIgnore(window: AXUIElement) -> Bool { guard let app = WindowManager.getNSApplication(from: window), let bid = app.bundleIdentifier, PreferencesManager.isAppIgnored(bid) else { return false } return true } + private func verticalDelta(from initial: NSPoint, to current: NSPoint) -> CGFloat { + switch mouseLocationCoordinateSpace { + case .appKit: + return current.y - initial.y + case .coreGraphics: + return initial.y - current.y + } + } + private func windowBoundsMouseLocation(_ mouseLocation: NSPoint) -> NSPoint { + switch mouseLocationCoordinateSpace { + case .appKit: + return mouseLocation + case .coreGraphics: + return WindowManager.convertYCoordinateBecauseTheAreTwoFuckingCoordinateSystems(point: mouseLocation) + } + } private func determineQuadrant(mouseLocation: NSPoint, windowSize: CGSize, windowLocation: NSPoint) -> Quadrant { let b = WindowManager.getWindowBounds(windowLocation: windowLocation, windowSize: windowSize) let cSize = 0.25, sSize = (1 - cSize) / 2 @@ -154,7 +170,7 @@ class MouseTracker { guard let s = windowSize, let im = initialMouseLocation, let iw = initialWindowLocation, let w = trackedWindow else { return } var nw = s.width, nh = s.height, no = iw if shouldUseQuadrants, let q = quadrant { - let dx = loc.x - im.x, dy = loc.y - im.y + let dx = loc.x - im.x, dy = verticalDelta(from: im, to: loc) switch q { case .topLeft: nw -= dx; nh += dy; no.x += dx; no.y -= dy case .top: nh += dy; no.y -= dy @@ -167,7 +183,7 @@ class MouseTracker { case .bottomRight: nw += dx; nh -= dy } } else { - nw = s.width + (loc.x - im.x); nh = s.height - (loc.y - im.y) + nw = s.width + (loc.x - im.x); nh = s.height - verticalDelta(from: im, to: loc) } nw = max(nw, 1); nh = max(nh, 1) let snapped = snappedResize(origin: no, size: CGSize(width: nw, height: nh)) diff --git a/Swift Shift/src/Manager/ShortcutsManager.swift b/Swift Shift/src/Manager/ShortcutsManager.swift index 05439de..da300ab 100644 --- a/Swift Shift/src/Manager/ShortcutsManager.swift +++ b/Swift Shift/src/Manager/ShortcutsManager.swift @@ -706,6 +706,8 @@ class MouseChordActionManager { private let subscriberKey = "mouseOnlyBothButtonsChord" private var isSubscribed = false private var activeAction: MouseAction? + private var cachedMouseOnlyAction: MouseAction? + private var isChordSuppressed = false private var leftButtonIsDown = false private var rightButtonIsDown = false private var workspaceNotificationObserver: Any? @@ -719,7 +721,9 @@ class MouseChordActionManager { } func updateSubscriptions() { - if configuredMouseOnlyAction() != nil { + cachedMouseOnlyAction = configuredMouseOnlyAction() + + if cachedMouseOnlyAction != nil { subscribeIfNeeded() } else { stopChordAction(resetButtons: true) @@ -774,7 +778,7 @@ class MouseChordActionManager { } private func handle(_ event: CGEvent) { - guard configuredMouseOnlyAction() != nil else { + guard cachedMouseOnlyAction != nil else { stopChordAction(resetButtons: true) unsubscribe() return @@ -801,7 +805,7 @@ class MouseChordActionManager { } private func handleButtonDown(_ event: CGEvent) { - if activeAction != nil { + if activeAction != nil || isChordSuppressed { event.cancel() return } @@ -810,20 +814,22 @@ class MouseChordActionManager { } private func handleButtonUp(_ event: CGEvent) { - if activeAction != nil { + if activeAction != nil || isChordSuppressed { event.cancel() - stopChordAction(resetButtons: false) + stopActiveChordAction() + clearSuppressionIfChordEnded() } } private func handleDrag(_ event: CGEvent) { - if activeAction != nil { - guard leftButtonIsDown && rightButtonIsDown && !ShortcutsManager.shared.hasActiveShortcut else { - stopChordAction(resetButtons: false) - return + if activeAction != nil || isChordSuppressed { + if leftButtonIsDown && rightButtonIsDown && !ShortcutsManager.shared.hasActiveShortcut, activeAction != nil { + MouseTracker.shared.updateTracking(withMouseLocation: event.location, timestamp: ProcessInfo.processInfo.systemUptime, allowsKeyInterruption: false) + } else { + stopActiveChordAction() + clearSuppressionIfChordEnded() } - MouseTracker.shared.updateTracking(withMouseLocation: event.location, timestamp: ProcessInfo.processInfo.systemUptime, allowsKeyInterruption: false) event.cancel() return } @@ -836,25 +842,39 @@ class MouseChordActionManager { return } - guard !ShortcutsManager.shared.hasActiveShortcut, let action = configuredMouseOnlyAction() else { + guard !ShortcutsManager.shared.hasActiveShortcut, let action = cachedMouseOnlyAction else { return } if MouseTracker.shared.startTrackingForExternalMouseUpdates(for: action, initialMouseLocation: event.location) { activeAction = action + isChordSuppressed = true event.cancel() } } private func stopChordAction(resetButtons: Bool) { + stopActiveChordAction() + + if resetButtons { + leftButtonIsDown = false + rightButtonIsDown = false + isChordSuppressed = false + } else { + clearSuppressionIfChordEnded() + } + } + + private func stopActiveChordAction() { if let activeAction { MouseTracker.shared.stopTracking(for: activeAction) self.activeAction = nil } + } - if resetButtons { - leftButtonIsDown = false - rightButtonIsDown = false + private func clearSuppressionIfChordEnded() { + if !leftButtonIsDown && !rightButtonIsDown { + isChordSuppressed = false } } diff --git a/Swift Shift/src/Manager/UpdatesManager.swift b/Swift Shift/src/Manager/UpdatesManager.swift index ecf2159..8a0f286 100644 --- a/Swift Shift/src/Manager/UpdatesManager.swift +++ b/Swift Shift/src/Manager/UpdatesManager.swift @@ -43,6 +43,6 @@ class UpdatesManager: NSObject, SPUStandardUserDriverDelegate { } private func clearUpdateAttention() { - NSApp.dockTile.badgeLabel = "" + NSApp.dockTile.badgeLabel = nil } } diff --git a/Swift Shift/src/Manager/WindowManager.swift b/Swift Shift/src/Manager/WindowManager.swift index 3560963..fd8d7f2 100644 --- a/Swift Shift/src/Manager/WindowManager.swift +++ b/Swift Shift/src/Manager/WindowManager.swift @@ -44,17 +44,19 @@ class WindowManager { return getCurrentWindow(at: ev.location) } static func getCurrentWindow(at mouseLocation: NSPoint) -> AXUIElement? { + let currentPID = NSRunningApplication.current.processIdentifier let sys = AXUIElementCreateSystemWide(); var el: AXUIElement? if AXUIElementCopyElementAtPosition(sys, Float(mouseLocation.x), Float(mouseLocation.y), &el) == .success, let el = el, let w = getWindow(from: el) { var pid: pid_t = 0; AXUIElementGetPid(w, &pid) - if pid != NSRunningApplication.current.processIdentifier { return w } + if pid != currentPID { return w } } - return getTopWindowAtCursorUsingCGWindowList(mouseLocation: mouseLocation) + return getTopWindowAtCursorUsingCGWindowList(mouseLocation: mouseLocation, excludingProcessID: currentPID) } - private static func getTopWindowAtCursorUsingCGWindowList(mouseLocation: NSPoint) -> AXUIElement? { + private static func getTopWindowAtCursorUsingCGWindowList(mouseLocation: NSPoint, excludingProcessID: pid_t? = nil) -> AXUIElement? { let list = CGWindowListCopyWindowInfo([.excludeDesktopElements, .optionOnScreenOnly], kCGNullWindowID) as? [[String: AnyObject]] ?? [] for e in list.sorted(by: { ($0[kCGWindowLayer as String] as? Int ?? 0) < ($1[kCGWindowLayer as String] as? Int ?? 0) }) { if let bDict = e[kCGWindowBounds as String] as? [String: CGFloat], let b = CGRect(dictionaryRepresentation: bDict as CFDictionary), b.contains(mouseLocation), let pid = e[kCGWindowOwnerPID as String] as? pid_t { + if pid == excludingProcessID { continue } let app = AXUIElementCreateApplication(pid); var val: AnyObject? if let nsApp = getNSApplication(from: app), let bid = nsApp.bundleIdentifier, PreferencesManager.isAppIgnored(bid) { continue } if AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &val) == .success, let wList = val as? [AXUIElement] { From 87f23c7038b5be281d1645892f8395ca08229765 Mon Sep 17 00:00:00 2001 From: Vida-CruX Date: Tue, 9 Jun 2026 20:51:09 +0800 Subject: [PATCH 5/7] refactor: improved the chord logic 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. --- Swift Shift.xcodeproj/project.pbxproj | 4 +- .../src/Manager/ShortcutsManager.swift | 123 ++++++++++++++++-- 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/Swift Shift.xcodeproj/project.pbxproj b/Swift Shift.xcodeproj/project.pbxproj index bfe370a..e95eaf6 100644 --- a/Swift Shift.xcodeproj/project.pbxproj +++ b/Swift Shift.xcodeproj/project.pbxproj @@ -405,7 +405,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1.2.2; DEVELOPMENT_ASSET_PATHS = "\"Swift Shift/Preview Content\""; - DEVELOPMENT_TEAM = 2TZ4Q825M7; + DEVELOPMENT_TEAM = 6326L59TL6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -439,7 +439,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1.2.2; DEVELOPMENT_ASSET_PATHS = "\"Swift Shift/Preview Content\""; - DEVELOPMENT_TEAM = 2TZ4Q825M7; + DEVELOPMENT_TEAM = 6326L59TL6; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/Swift Shift/src/Manager/ShortcutsManager.swift b/Swift Shift/src/Manager/ShortcutsManager.swift index da300ab..4e445ec 100644 --- a/Swift Shift/src/Manager/ShortcutsManager.swift +++ b/Swift Shift/src/Manager/ShortcutsManager.swift @@ -641,12 +641,11 @@ class ShortcutsManager { rightButtonIsDown = true } + event.cancel() + if leftButtonIsDown && rightButtonIsDown && !isMouseTracking { isMouseTracking = true - event.cancel() MouseTracker.shared.startTracking(for: action, button: .both) - } else if isMouseTracking { - event.cancel() } }) @@ -666,8 +665,9 @@ class ShortcutsManager { rightButtonIsDown = false } + event.cancel() + if isMouseTracking { - event.cancel() MouseTracker.shared.stopTracking(for: action) isMouseTracking = false } @@ -703,11 +703,18 @@ class ShortcutsManager { class MouseChordActionManager { static let shared = MouseChordActionManager() + private struct PendingMouseDown { + let event: CGEvent + } + private let subscriberKey = "mouseOnlyBothButtonsChord" + private let replayedMouseEventMarker: Int64 = 0x5357465453484946 private var isSubscribed = false private var activeAction: MouseAction? private var cachedMouseOnlyAction: MouseAction? private var isChordSuppressed = false + private var isPassingThroughMouseGesture = false + private var pendingInitialMouseDown: PendingMouseDown? private var leftButtonIsDown = false private var rightButtonIsDown = false private var workspaceNotificationObserver: Any? @@ -778,6 +785,10 @@ class MouseChordActionManager { } private func handle(_ event: CGEvent) { + guard !isReplayedMouseEvent(event) else { + return + } + guard cachedMouseOnlyAction != nil else { stopChordAction(resetButtons: true) unsubscribe() @@ -810,7 +821,24 @@ class MouseChordActionManager { return } - startChordActionIfReady(eventToCancelOnSuccess: event) + if isPassingThroughMouseGesture { + return + } + + guard pendingInitialMouseDown != nil else { + if capturePendingInitialMouseDown(event) { + event.cancel() + } + return + } + + if startChordActionIfReady(eventToCancelOnSuccess: event) { + pendingInitialMouseDown = nil + return + } + + replayPendingInitialMouseDown() + isPassingThroughMouseGesture = true } private func handleButtonUp(_ event: CGEvent) { @@ -818,6 +846,19 @@ class MouseChordActionManager { event.cancel() stopActiveChordAction() clearSuppressionIfChordEnded() + return + } + + if pendingInitialMouseDown != nil { + replayPendingInitialMouseDown() + replayMouseEvent(event) + event.cancel() + clearPassThroughIfGestureEnded() + return + } + + if isPassingThroughMouseGesture { + clearPassThroughIfGestureEnded() } } @@ -834,27 +875,83 @@ class MouseChordActionManager { return } - startChordActionIfReady(eventToCancelOnSuccess: event) + if isPassingThroughMouseGesture { + return + } + + if pendingInitialMouseDown != nil { + if startChordActionIfReady(eventToCancelOnSuccess: event) { + pendingInitialMouseDown = nil + return + } + + replayPendingInitialMouseDown() + replayMouseEvent(event) + event.cancel() + isPassingThroughMouseGesture = true + return + } + + _ = startChordActionIfReady(eventToCancelOnSuccess: event) } - private func startChordActionIfReady(eventToCancelOnSuccess event: CGEvent) { + @discardableResult + private func startChordActionIfReady(eventToCancelOnSuccess event: CGEvent) -> Bool { guard leftButtonIsDown && rightButtonIsDown else { - return + return false } guard !ShortcutsManager.shared.hasActiveShortcut, let action = cachedMouseOnlyAction else { - return + return false } - if MouseTracker.shared.startTrackingForExternalMouseUpdates(for: action, initialMouseLocation: event.location) { + let initialMouseLocation = pendingInitialMouseDown?.event.location ?? event.location + + if MouseTracker.shared.startTrackingForExternalMouseUpdates(for: action, initialMouseLocation: initialMouseLocation) { activeAction = action isChordSuppressed = true event.cancel() + return true + } + + return false + } + + private func capturePendingInitialMouseDown(_ event: CGEvent) -> Bool { + guard let copiedEvent = event.copy() else { + return false } + + pendingInitialMouseDown = PendingMouseDown(event: copiedEvent) + return true + } + + private func replayPendingInitialMouseDown() { + guard let pendingInitialMouseDown else { + return + } + + replayMouseEvent(pendingInitialMouseDown.event) + self.pendingInitialMouseDown = nil + } + + private func replayMouseEvent(_ event: CGEvent) { + guard let copiedEvent = event.copy() else { + return + } + + copiedEvent.setIntegerValueField(.eventSourceUserData, value: replayedMouseEventMarker) + copiedEvent.post(tap: .cghidEventTap) + } + + private func isReplayedMouseEvent(_ event: CGEvent) -> Bool { + event.getIntegerValueField(.eventSourceUserData) == replayedMouseEventMarker } private func stopChordAction(resetButtons: Bool) { stopActiveChordAction() + pendingInitialMouseDown = nil + isPassingThroughMouseGesture = false if resetButtons { leftButtonIsDown = false @@ -878,6 +975,12 @@ class MouseChordActionManager { } } + private func clearPassThroughIfGestureEnded() { + if !leftButtonIsDown && !rightButtonIsDown { + isPassingThroughMouseGesture = false + } + } + private func configuredMouseOnlyAction() -> MouseAction? { for type in ShortcutType.allCases { guard let shortcut = ShortcutsManager.shared.load(for: type), !shortcut.keyboardEnabled, shortcut.mouseEnabled else { From 5f293fb7219a8eee5d05f70e09f28d6391f86a9e Mon Sep 17 00:00:00 2001 From: Vida-CruX Date: Tue, 9 Jun 2026 21:12:23 +0800 Subject: [PATCH 6/7] refactor: improved MouseTracker so resizing is more snappy now --- Swift Shift/src/Manager/MouseTracker.swift | 47 ++++++++++++++++++- .../src/Manager/ShortcutsManager.swift | 21 ++++++++- Swift Shift/src/Manager/WindowManager.swift | 8 ++-- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/Swift Shift/src/Manager/MouseTracker.swift b/Swift Shift/src/Manager/MouseTracker.swift index 89cc35f..499d409 100644 --- a/Swift Shift/src/Manager/MouseTracker.swift +++ b/Swift Shift/src/Manager/MouseTracker.swift @@ -16,6 +16,8 @@ class MouseTracker { private let snapDistance: CGFloat = 10 private var mouseLocationCoordinateSpace: MouseLocationCoordinateSpace = .appKit private let trackingQueue = DispatchQueue(label: "com.swiftshift.mousetracker") + private var queuedExternalMouseUpdate: (location: NSPoint, timestamp: TimeInterval)? + private var queuedExternalMouseUpdateScheduled = false private init() { registerForSpaceChangeNotifications() } deinit { unregisterForSpaceChangeNotifications() } private func registerForSpaceChangeNotifications() { @@ -44,7 +46,7 @@ class MouseTracker { } func stopTracking(for action: MouseAction) { guard currentAction == action else { return } - flushPendingMouseUpdate(); invalidateTrackingTimer(); removeMouseEventMonitor(); resetTrackingVariables(); isTracking = false + flushQueuedExternalMouseUpdate(); flushPendingMouseUpdate(); invalidateTrackingTimer(); removeMouseEventMonitor(); resetTrackingVariables(); clearQueuedExternalMouseUpdate(); isTracking = false } func forceResetTracking() { guard currentAction != .none, let window = trackedWindow else { return } @@ -138,6 +140,15 @@ class MouseTracker { func updateTrackingFromCurrentMouseLocation(timestamp: TimeInterval) { updateTracking(withMouseLocation: currentMouseLocation(), timestamp: timestamp) } + func queueExternalMouseUpdate(withMouseLocation mouseLocation: NSPoint, timestamp: TimeInterval) { + trackingQueue.async { [weak self] in + guard let self = self else { return } + self.queuedExternalMouseUpdate = (mouseLocation, timestamp) + guard !self.queuedExternalMouseUpdateScheduled else { return } + self.queuedExternalMouseUpdateScheduled = true + DispatchQueue.main.async { [weak self] in self?.drainQueuedExternalMouseUpdate() } + } + } func updateTracking(withMouseLocation mouseLocation: NSPoint, timestamp: TimeInterval, allowsKeyInterruption: Bool = true) { guard isTracking, let _ = initialMouseLocation, let _ = initialWindowLocation, let _ = trackedWindow else { return @@ -150,6 +161,36 @@ class MouseTracker { pendingMouseLocation = mouseLocation if timestamp - lastUpdateTime >= minimumUpdateInterval { flushPendingMouseUpdate(at: timestamp) } } + private func drainQueuedExternalMouseUpdate() { + guard let update = takeQueuedExternalMouseUpdate() else { return } + updateTracking(withMouseLocation: update.location, timestamp: update.timestamp, allowsKeyInterruption: false) + trackingQueue.async { [weak self] in + guard let self = self else { return } + if self.queuedExternalMouseUpdate != nil { + DispatchQueue.main.async { [weak self] in self?.drainQueuedExternalMouseUpdate() } + } else { + self.queuedExternalMouseUpdateScheduled = false + } + } + } + private func flushQueuedExternalMouseUpdate() { + guard let update = takeQueuedExternalMouseUpdate() else { return } + updateTracking(withMouseLocation: update.location, timestamp: update.timestamp, allowsKeyInterruption: false) + } + private func takeQueuedExternalMouseUpdate() -> (location: NSPoint, timestamp: TimeInterval)? { + trackingQueue.sync { + let update = queuedExternalMouseUpdate + queuedExternalMouseUpdate = nil + if update == nil { queuedExternalMouseUpdateScheduled = false } + return update + } + } + private func clearQueuedExternalMouseUpdate() { + trackingQueue.sync { + queuedExternalMouseUpdate = nil + queuedExternalMouseUpdateScheduled = false + } + } private func flushPendingMouseUpdate(at timestamp: TimeInterval? = nil) { guard let loc = pendingMouseLocation else { return }; pendingMouseLocation = nil if currentAction == .move { moveWindowBasedOnMouseLocation(loc) } else if currentAction == .resize { resizeWindowBasedOnMouseLocation(loc) } @@ -191,7 +232,9 @@ class MouseTracker { let ns = CGSize(width: nw, height: nh) let moveO = !pointsApproximatelyEqual(no, lastAppliedOrigin) if moveO || !sizesApproximatelyEqual(ns, lastAppliedSize) { - lastAppliedOrigin = no; lastAppliedSize = ns; WindowManager.resize(window: w, to: ns, from: no, shouldMoveOrigin: moveO) + if WindowManager.resize(window: w, to: ns, from: no, shouldMoveOrigin: moveO) { + lastAppliedOrigin = no; lastAppliedSize = ns + } } } private func snappedOrigin(forMoving rect: CGRect) -> NSPoint { diff --git a/Swift Shift/src/Manager/ShortcutsManager.swift b/Swift Shift/src/Manager/ShortcutsManager.swift index 4e445ec..f2fed5a 100644 --- a/Swift Shift/src/Manager/ShortcutsManager.swift +++ b/Swift Shift/src/Manager/ShortcutsManager.swift @@ -789,6 +789,8 @@ class MouseChordActionManager { return } + syncButtonStateFromSystem() + guard cachedMouseOnlyAction != nil else { stopChordAction(resetButtons: true) unsubscribe() @@ -813,6 +815,8 @@ class MouseChordActionManager { default: break } + + recoverIfGestureEnded() } private func handleButtonDown(_ event: CGEvent) { @@ -865,7 +869,7 @@ class MouseChordActionManager { private func handleDrag(_ event: CGEvent) { if activeAction != nil || isChordSuppressed { if leftButtonIsDown && rightButtonIsDown && !ShortcutsManager.shared.hasActiveShortcut, activeAction != nil { - MouseTracker.shared.updateTracking(withMouseLocation: event.location, timestamp: ProcessInfo.processInfo.systemUptime, allowsKeyInterruption: false) + MouseTracker.shared.queueExternalMouseUpdate(withMouseLocation: event.location, timestamp: ProcessInfo.processInfo.systemUptime) } else { stopActiveChordAction() clearSuppressionIfChordEnded() @@ -981,6 +985,21 @@ class MouseChordActionManager { } } + private func syncButtonStateFromSystem() { + let leftIsPressed = CGEventSource.buttonState(.hidSystemState, button: .left) + let rightIsPressed = CGEventSource.buttonState(.hidSystemState, button: .right) + leftButtonIsDown = leftIsPressed + rightButtonIsDown = rightIsPressed + } + + private func recoverIfGestureEnded() { + syncButtonStateFromSystem() + + if !leftButtonIsDown && !rightButtonIsDown { + stopChordAction(resetButtons: true) + } + } + private func configuredMouseOnlyAction() -> MouseAction? { for type in ShortcutType.allCases { guard let shortcut = ShortcutsManager.shared.load(for: type), !shortcut.keyboardEnabled, shortcut.mouseEnabled else { diff --git a/Swift Shift/src/Manager/WindowManager.swift b/Swift Shift/src/Manager/WindowManager.swift index fd8d7f2..2bb81cd 100644 --- a/Swift Shift/src/Manager/WindowManager.swift +++ b/Swift Shift/src/Manager/WindowManager.swift @@ -12,10 +12,12 @@ class WindowManager { var p = point; let v = AXValueCreate(.cgPoint, &p)! return AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, v) } - static func resize(window: AXUIElement, to s: CGSize, from o: NSPoint, shouldMoveOrigin: Bool = true) { - if shouldMoveOrigin { move(window: window, to: o) } + @discardableResult + static func resize(window: AXUIElement, to s: CGSize, from o: NSPoint, shouldMoveOrigin: Bool = true) -> Bool { + let moveResult = shouldMoveOrigin ? move(window: window, to: o) : .success var sz = s; let v = AXValueCreate(.cgSize, &sz)! - AXUIElementSetAttributeValue(window, kAXSizeAttribute as CFString, v) + let sizeResult = AXUIElementSetAttributeValue(window, kAXSizeAttribute as CFString, v) + return moveResult == .success && sizeResult == .success } static func getSize(window: AXUIElement) -> NSSize? { var r: CFTypeRef?; guard AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &r) == .success else { return nil } From 1b4c1dec75bf2adca4f5ce6d5882a438a4e602ae Mon Sep 17 00:00:00 2001 From: Vida-CruX Date: Tue, 9 Jun 2026 21:24:06 +0800 Subject: [PATCH 7/7] Restore project.pbxproj --- Swift Shift.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Swift Shift.xcodeproj/project.pbxproj b/Swift Shift.xcodeproj/project.pbxproj index e95eaf6..bfe370a 100644 --- a/Swift Shift.xcodeproj/project.pbxproj +++ b/Swift Shift.xcodeproj/project.pbxproj @@ -405,7 +405,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1.2.2; DEVELOPMENT_ASSET_PATHS = "\"Swift Shift/Preview Content\""; - DEVELOPMENT_TEAM = 6326L59TL6; + DEVELOPMENT_TEAM = 2TZ4Q825M7; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -439,7 +439,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1.2.2; DEVELOPMENT_ASSET_PATHS = "\"Swift Shift/Preview Content\""; - DEVELOPMENT_TEAM = 6326L59TL6; + DEVELOPMENT_TEAM = 2TZ4Q825M7; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES;