Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions Swift Shift/src/Manager/MouseTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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() {
Expand All @@ -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) }
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Swift Shift/src/Manager/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 36 additions & 0 deletions Swift Shift/src/Manager/ShortcutsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions Swift Shift/src/View/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"
)
}
}
}
Expand Down
39 changes: 39 additions & 0 deletions SwiftShiftTests/WindowManagerTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import XCTest
import Cocoa
@testable import Swift_Shift_Dev

final class WindowManagerTests: XCTestCase {
Expand Down Expand Up @@ -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))
}
}
Loading