diff --git a/Swift Shift/src/Manager/MouseTracker.swift b/Swift Shift/src/Manager/MouseTracker.swift index b66b4a3..5f88e53 100644 --- a/Swift Shift/src/Manager/MouseTracker.swift +++ b/Swift Shift/src/Manager/MouseTracker.swift @@ -3,6 +3,21 @@ 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 } + +enum ScrollResizeGeometry { + static func resized(origin: NSPoint, size: CGSize, scrollDelta: CGFloat) -> CGRect { + let change = scrollDelta * 10 + let width = max(size.width + change, min(size.width, 100)) + let height = max(size.height + change, min(size.height, 100)) + return CGRect( + x: origin.x + (size.width - width) / 2, + y: origin.y + (size.height - height) / 2, + width: width, + height: height + ) + } +} + class MouseTracker { static let shared = MouseTracker() private var mouseEventMonitor: Any?, initialMouseLocation, initialWindowLocation: NSPoint? @@ -19,6 +34,9 @@ class MouseTracker { private var queuedExternalMouseUpdateScheduled = false private var enhancedUIApp: AXUIElement? private var enhancedUIPrev: Bool? + private var scrollResizeTimer: Timer? + private var scrollResizeDelta: CGFloat = 0 + private(set) var isScrollResizing = false private init() { registerForSpaceChangeNotifications() } deinit { unregisterForSpaceChangeNotifications() } private func registerForSpaceChangeNotifications() { @@ -27,7 +45,8 @@ class MouseTracker { private func unregisterForSpaceChangeNotifications() { if let obs = spaceChangeObserver { NSWorkspace.shared.notificationCenter.removeObserver(obs) } } private func handleSpaceChange() { guard currentAction != .none, trackedWindow != nil else { return } - if isTracking { forceResetTracking() } + if isScrollResizing { stopTracking(for: .resize) } + else if isTracking { forceResetTracking() } } func startTracking(for action: MouseAction, button: MouseButton) { if currentAction != .none { stopTracking(for: currentAction) } @@ -52,7 +71,38 @@ class MouseTracker { flushQueuedExternalMouseUpdate(); flushPendingMouseUpdate() AXWindowWriter.shared.endGesture() restoreEnhancedUIForTrackedApp() - invalidateTrackingTimer(); removeMouseEventMonitor(); resetTrackingVariables(); clearQueuedExternalMouseUpdate(); isTracking = false + invalidateTrackingTimer(); scrollResizeTimer?.invalidate(); scrollResizeTimer = nil + removeMouseEventMonitor(); resetTrackingVariables(); clearQueuedExternalMouseUpdate(); isTracking = false + } + + @discardableResult + func resizeWithScroll(at mouseLocation: NSPoint, delta: CGFloat) -> Bool { + guard delta.isFinite, delta != 0 else { return false } + if !isScrollResizing { + guard currentAction == .none else { return false } + prepareTracking(for: .resize, mouseLocation: mouseLocation, coordinateSpace: .coreGraphics) + guard trackedWindow != nil, initialWindowLocation != nil, windowSize != nil else { + stopTracking(for: .resize) + return false + } + isScrollResizing = true + isTracking = true + } + guard let origin = initialWindowLocation, let size = windowSize else { return false } + scrollResizeDelta += max(-10, min(10, delta)) + let rect = ScrollResizeGeometry.resized(origin: origin, size: size, scrollDelta: scrollResizeDelta) + if shouldFocusWindow && !trackedWindowIsFocused, let window = trackedWindow { + WindowManager.focus(window: window) + trackedWindowIsFocused = true + } + AXWindowWriter.shared.requestResize(origin: rect.origin, size: rect.size) + scrollResizeTimer?.invalidate() + let timer = Timer(timeInterval: 0.3, repeats: false) { [weak self] _ in + self?.stopTracking(for: .resize) + } + RunLoop.main.add(timer, forMode: .common) + scrollResizeTimer = timer + return true } private static let enhancedUIAttribute = "AXEnhancedUserInterface" as CFString /// Disables `AXEnhancedUserInterface` on the tracked window's app for the duration of a @@ -328,7 +378,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; snapRects = []; trackedWindow = nil; initialMouseLocation = nil; initialWindowLocation = nil; currentAction = .none; quadrant = nil; windowSize = nil; mouseLocationCoordinateSpace = .appKit } + private func resetTrackingVariables() { pendingMouseLocation = nil; snapRects = []; trackedWindow = nil; initialMouseLocation = nil; initialWindowLocation = nil; currentAction = .none; quadrant = nil; windowSize = nil; mouseLocationCoordinateSpace = .appKit; isScrollResizing = false; scrollResizeDelta = 0 } func pauseTracking() { isTracking = false } func resumeTracking() { if currentAction != .none && trackedWindow != nil { isTracking = true } } private func checkForKeyPresses() -> Bool { diff --git a/Swift Shift/src/Manager/Preferences.swift b/Swift Shift/src/Manager/Preferences.swift index fda53e6..c73659f 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 snapToWindows = "snapToWindows" + case resizeWithControlScroll = "resizeWithControlScroll" case requireMouseClick = "requireMouseClick" case moveWithBothMouseButtons = "moveWithBothMouseButtons" case fnShortcutWarningDismissed = "fnShortcutWarningDismissed" diff --git a/Swift Shift/src/Manager/ShortcutsManager.swift b/Swift Shift/src/Manager/ShortcutsManager.swift index 6d49e31..561346f 100644 --- a/Swift Shift/src/Manager/ShortcutsManager.swift +++ b/Swift Shift/src/Manager/ShortcutsManager.swift @@ -42,6 +42,16 @@ extension NSEvent.ModifierFlags { } } +enum ScrollResizeInput { + static func shouldResize(enabled: Bool, flags: CGEventFlags, vertical: CGFloat, horizontal: CGFloat, hasActiveShortcut: Bool) -> Bool { + let shortcutModifiers: CGEventFlags = [.maskControl, .maskCommand, .maskAlternate, .maskShift] + return enabled && !hasActiveShortcut && + flags.intersection(shortcutModifiers) == .maskControl && + vertical.isFinite && horizontal.isFinite && vertical != 0 && + abs(vertical) >= abs(horizontal) + } +} + struct KeyboardShortcut: Codable, Equatable { var keyCode: UInt16? private var modifierFlagsRawValue: UInt @@ -639,6 +649,32 @@ class ShortcutsManager { } } } + addScrollResizeSubscription() + } + + private func addScrollResizeSubscription() { + let key = "controlScrollResize" + CGEventSupervisor.shared.subscribe(as: key, to: .cgEvents(.scrollWheel), using: { [weak self] event in + guard let self = self, let scrollEvent = NSEvent(cgEvent: event) else { return } + let enabled = PreferencesManager.loadBool(for: .resizeWithControlScroll) + let vertical = scrollEvent.scrollingDeltaY + let horizontal = scrollEvent.scrollingDeltaX + guard ScrollResizeInput.shouldResize( + enabled: enabled, + flags: event.flags, + vertical: vertical, + horizontal: horizontal, + hasActiveShortcut: self.hasActiveShortcut + ) else { return } + if scrollEvent.momentumPhase != [] { + if MouseTracker.shared.isScrollResizing { event.cancel() } + return + } + if MouseTracker.shared.resizeWithScroll(at: event.location, delta: vertical) { + event.cancel() + } + }) + mouseSubscriptions.insert(key) } private func startTracking(_ userShortcut: UserShortcut, _ action: MouseAction) { diff --git a/Swift Shift/src/View/PreferencesView.swift b/Swift Shift/src/View/PreferencesView.swift index 187b354..b0689c6 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) private var focusOnApp = true @AppStorage(PreferenceKey.useQuadrants.rawValue) private var useQuadrants = false @AppStorage(PreferenceKey.snapToWindows.rawValue) private var snapToWindows = true + @AppStorage(PreferenceKey.resizeWithControlScroll.rawValue) private var resizeWithControlScroll = false var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -81,6 +82,13 @@ struct PreferencesView: View { subtitle: "Add resistance near window edges", icon: "macwindow.on.rectangle" ) + + PreferenceToggle( + isOn: $resizeWithControlScroll, + title: "Resize with Control + scroll", + subtitle: "May conflict with macOS Accessibility Zoom", + icon: "arrow.up.left.and.arrow.down.right" + ) } } } diff --git a/SwiftShiftTests/WindowManagerTests.swift b/SwiftShiftTests/WindowManagerTests.swift index 72ba5e4..dc1d9ac 100644 --- a/SwiftShiftTests/WindowManagerTests.swift +++ b/SwiftShiftTests/WindowManagerTests.swift @@ -1,4 +1,5 @@ import XCTest +import Cocoa @testable import Swift_Shift_Dev final class WindowManagerTests: XCTestCase { @@ -97,3 +98,41 @@ final class WindowManagerTests: XCTestCase { XCTAssertEqual(bounds.bottomRight.y, 0) } } + +final class ScrollResizeTests: XCTestCase { + func testScrollResizesAroundWindowCenter() { + let original = CGRect(x: 100, y: 200, width: 300, height: 400) + let larger = ScrollResizeGeometry.resized(origin: original.origin, size: original.size, scrollDelta: 2) + let smaller = ScrollResizeGeometry.resized(origin: original.origin, size: original.size, scrollDelta: -2) + + XCTAssertEqual(larger.size.width, 320) + XCTAssertEqual(larger.size.height, 420) + XCTAssertEqual(smaller.size.width, 280) + XCTAssertEqual(smaller.size.height, 380) + XCTAssertEqual(larger.midX, original.midX) + XCTAssertEqual(larger.midY, original.midY) + XCTAssertEqual(smaller.midX, original.midX) + XCTAssertEqual(smaller.midY, original.midY) + } + + func testScrollDoesNotShrinkBelowSafeSizeOrGrowSmallWindow() { + let normal = ScrollResizeGeometry.resized(origin: .zero, size: CGSize(width: 300, height: 250), scrollDelta: -100) + XCTAssertEqual(normal.size.width, 100) + XCTAssertEqual(normal.size.height, 100) + + let small = ScrollResizeGeometry.resized(origin: .zero, size: CGSize(width: 70, height: 80), scrollDelta: -100) + XCTAssertEqual(small.size.width, 70) + XCTAssertEqual(small.size.height, 80) + } + + func testOnlyControlAndVerticalScrollTriggerResize() { + let control: CGEventFlags = .maskControl + XCTAssertTrue(ScrollResizeInput.shouldResize(enabled: true, flags: control, vertical: 1, horizontal: 0, hasActiveShortcut: false)) + XCTAssertFalse(ScrollResizeInput.shouldResize(enabled: false, flags: control, vertical: 1, horizontal: 0, hasActiveShortcut: false)) + XCTAssertFalse(ScrollResizeInput.shouldResize(enabled: true, flags: [], vertical: 1, horizontal: 0, hasActiveShortcut: false)) + XCTAssertFalse(ScrollResizeInput.shouldResize(enabled: true, flags: [.maskControl, .maskShift], vertical: 1, horizontal: 0, hasActiveShortcut: false)) + XCTAssertFalse(ScrollResizeInput.shouldResize(enabled: true, flags: control, vertical: 0, horizontal: 1, hasActiveShortcut: false)) + XCTAssertFalse(ScrollResizeInput.shouldResize(enabled: true, flags: control, vertical: 1, horizontal: 2, hasActiveShortcut: false)) + XCTAssertFalse(ScrollResizeInput.shouldResize(enabled: true, flags: control, vertical: 1, horizontal: 0, hasActiveShortcut: true)) + } +}