From 458df10f944745290f4842b656188b1cc774ed48 Mon Sep 17 00:00:00 2001 From: Charley Peng <23526+chid@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:45:41 +1000 Subject: [PATCH] Add native-style screen edge snapping --- Swift Shift/src/Manager/MouseTracker.swift | 149 +++++++++++++++++++- Swift Shift/src/Manager/Preferences.swift | 6 + Swift Shift/src/Manager/WindowManager.swift | 16 ++- Swift Shift/src/View/PreferencesView.swift | 8 ++ 4 files changed, 171 insertions(+), 8 deletions(-) diff --git a/Swift Shift/src/Manager/MouseTracker.swift b/Swift Shift/src/Manager/MouseTracker.swift index bf064e8..5277c08 100644 --- a/Swift Shift/src/Manager/MouseTracker.swift +++ b/Swift Shift/src/Manager/MouseTracker.swift @@ -2,6 +2,37 @@ import Cocoa import Accessibility enum MouseAction: String { case move, resize, none } enum Quadrant { case topLeft, top, topRight, left, center, right, bottomLeft, bottom, bottomRight } + +private final class SnapPreviewWindow: NSWindow { + private let borderView = NSView() + + init() { + super.init(contentRect: .zero, styleMask: .borderless, backing: .buffered, defer: false) + isOpaque = false + backgroundColor = .clear + ignoresMouseEvents = true + level = .screenSaver + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + hasShadow = false + + borderView.wantsLayer = true + borderView.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.85).cgColor + borderView.layer?.borderWidth = 2 + borderView.layer?.cornerRadius = 8 + borderView.layer?.shadowColor = NSColor.black.cgColor + borderView.layer?.shadowOpacity = 0.24 + borderView.layer?.shadowRadius = 12 + borderView.layer?.shadowOffset = .zero + contentView = borderView + } + + func show(frame: CGRect) { + let cocoaFrame = WindowManager.convertCGFrameToCocoaFrame(frame) + setFrame(cocoaFrame.insetBy(dx: 4, dy: 4), display: true) + if !isVisible { orderFrontRegardless() } + } +} + class MouseTracker { static let shared = MouseTracker() private var mouseEventMonitor: Any?, initialMouseLocation, initialWindowLocation: NSPoint? @@ -9,10 +40,14 @@ class MouseTracker { private var currentAction: MouseAction = .none, trackingTimer: Timer? private let trackingTimeout: TimeInterval = 10, minimumUpdateInterval: TimeInterval = 1.0 / 120.0 private var shouldUseQuadrants = false, quadrant: Quadrant?, windowSize: CGSize?, isTracking = false + private var enableSnapping = true private var spaceChangeObserver: Any?, pendingMouseLocation: NSPoint?, lastUpdateTime: TimeInterval = 0 private var lastAppliedOrigin: NSPoint?, lastAppliedSize: CGSize? private var snapRects: [CGRect] = [] - private let snapDistance: CGFloat = 10 + private var screenSnapRects: [CGRect] = [] + private let snapDistance: CGFloat = 16 + private let nativeSnapDistance: CGFloat = 32 + private let snapPreviewWindow = SnapPreviewWindow() private let trackingQueue = DispatchQueue(label: "com.swiftshift.mousetracker") private init() { registerForSpaceChangeNotifications() } deinit { unregisterForSpaceChangeNotifications() } @@ -49,10 +84,12 @@ class MouseTracker { guard let currentWindow = WindowManager.getCurrentWindow(), !shouldIgnore(window: currentWindow) else { trackedWindow = nil; return } shouldFocusWindow = PreferencesManager.loadBool(for: .focusOnApp) shouldUseQuadrants = PreferencesManager.loadBool(for: .useQuadrants) + enableSnapping = PreferencesManager.loadBool(for: .enableSnapping, defaultValue: true) trackedWindowIsFocused = false; currentAction = action; initialMouseLocation = NSEvent.mouseLocation trackedWindow = currentWindow; initialWindowLocation = WindowManager.getPosition(window: currentWindow) windowSize = WindowManager.getSize(window: currentWindow); pendingMouseLocation = nil; lastUpdateTime = 0 snapRects = WindowManager.getVisibleWindowRects(excluding: currentWindow) + screenSnapRects = WindowManager.getScreenSnapRects() 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) @@ -107,7 +144,35 @@ class MouseTracker { 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) - if let size = windowSize { newO = snappedOrigin(forMoving: CGRect(origin: newO, size: size)) } + if let size = windowSize, snappingActive() { + if let snapFrame = nativeSnapFrame(forCursor: loc) { + snapPreviewWindow.show(frame: snapFrame) + let snapOrigin = NSPoint(x: snapFrame.minX, y: snapFrame.minY) + let snapSize = CGSize(width: snapFrame.width, height: snapFrame.height) + let moveO = !pointsApproximatelyEqual(snapOrigin, lastAppliedOrigin) + if moveO || !sizesApproximatelyEqual(snapSize, lastAppliedSize) { + lastAppliedOrigin = snapOrigin + lastAppliedSize = snapSize + WindowManager.resize(window: w, to: snapSize, from: snapOrigin, shouldMoveOrigin: true) + } + return + } + snapPreviewWindow.orderOut(nil) + if !sizesApproximatelyEqual(size, lastAppliedSize) { + WindowManager.resize(window: w, to: size, from: newO, shouldMoveOrigin: true) + lastAppliedOrigin = newO + lastAppliedSize = size + } + // Align edges to neighbouring windows based on window geometry... + newO = snappedOrigin(forMoving: CGRect(origin: newO, size: size)) + // ...but snap to screen edges based on cursor proximity. When moving, + // the window edges are offset from the cursor by the grab point, so + // keying screen snapping off the cursor (which the user drags to the + // edge) is what actually matches intent. + newO = screenSnappedOrigin(newO, size: size, cursor: loc) + } else { + snapPreviewWindow.orderOut(nil) + } if !pointsApproximatelyEqual(newO, lastAppliedOrigin) { lastAppliedOrigin = newO; WindowManager.move(window: w, to: newO) } } private func resizeWindowBasedOnMouseLocation(_ loc: NSPoint) { @@ -130,14 +195,83 @@ class MouseTracker { nw = s.width + (loc.x - im.x); nh = s.height - (loc.y - im.y) } nw = max(nw, 1); nh = max(nh, 1) - let snapped = snappedResize(origin: no, size: CGSize(width: nw, height: nh)) - no = snapped.origin; nw = snapped.size.width; nh = snapped.size.height + if snappingActive() { + let snapped = snappedResize(origin: no, size: CGSize(width: nw, height: nh)) + no = snapped.origin; nw = snapped.size.width; nh = snapped.size.height + } 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) } } + private func snappingActive() -> Bool { + return enableSnapping + } + private func nativeSnapFrame(forCursor cursor: NSPoint) -> CGRect? { + let primaryHeight = CGDisplayBounds(CGMainDisplayID()).height + let cursorCG = NSPoint(x: cursor.x, y: primaryHeight - cursor.y) + + for screen in screenSnapRects where screen.insetBy(dx: -nativeSnapDistance, dy: -nativeSnapDistance).contains(cursorCG) { + let nearLeft = abs(cursorCG.x - screen.minX) <= nativeSnapDistance + let nearRight = abs(cursorCG.x - screen.maxX) <= nativeSnapDistance + let nearTop = abs(cursorCG.y - screen.minY) <= nativeSnapDistance + let nearBottom = abs(cursorCG.y - screen.maxY) <= nativeSnapDistance + + if nearTop && nearLeft { + return CGRect(x: screen.minX, y: screen.minY, width: screen.width / 2, height: screen.height / 2) + } + if nearTop && nearRight { + return CGRect(x: screen.midX, y: screen.minY, width: screen.width / 2, height: screen.height / 2) + } + if nearBottom && nearLeft { + return CGRect(x: screen.minX, y: screen.midY, width: screen.width / 2, height: screen.height / 2) + } + if nearBottom && nearRight { + return CGRect(x: screen.midX, y: screen.midY, width: screen.width / 2, height: screen.height / 2) + } + if nearTop { + return screen + } + if nearLeft { + return CGRect(x: screen.minX, y: screen.minY, width: screen.width / 2, height: screen.height) + } + if nearRight { + return CGRect(x: screen.midX, y: screen.minY, width: screen.width / 2, height: screen.height) + } + } + + return nil + } + private func screenSnappedOrigin(_ origin: NSPoint, size: CGSize, cursor: NSPoint) -> NSPoint { + // cursor is in Cocoa coordinates (bottom-left origin); screenSnapRects are + // in CG coordinates (top-left origin), so flip the cursor's y to match. + let primaryHeight = CGDisplayBounds(CGMainDisplayID()).height + let cursorCG = NSPoint(x: cursor.x, y: primaryHeight - cursor.y) + let tolerance: CGFloat = 1 + var result = origin + for screen in screenSnapRects { + // An edge shared with an adjacent display (where the cursor currently + // sits) is an interior seam, not a real screen edge. Snapping there + // traps the window at the boundary instead of letting it cross. + let adjacentLeft = screenSnapRects.contains { $0 != screen && abs($0.maxX - screen.minX) <= tolerance && cursorCG.y >= $0.minY && cursorCG.y <= $0.maxY } + let adjacentRight = screenSnapRects.contains { $0 != screen && abs($0.minX - screen.maxX) <= tolerance && cursorCG.y >= $0.minY && cursorCG.y <= $0.maxY } + let adjacentAbove = screenSnapRects.contains { $0 != screen && abs($0.maxY - screen.minY) <= tolerance && cursorCG.x >= $0.minX && cursorCG.x <= $0.maxX } + let adjacentBelow = screenSnapRects.contains { $0 != screen && abs($0.minY - screen.maxY) <= tolerance && cursorCG.x >= $0.minX && cursorCG.x <= $0.maxX } + + if !adjacentLeft, abs(cursorCG.x - screen.minX) <= snapDistance { + result.x = screen.minX + } else if !adjacentRight, abs(cursorCG.x - screen.maxX) <= snapDistance { + result.x = screen.maxX - size.width + } + if !adjacentAbove, abs(cursorCG.y - screen.minY) <= snapDistance { + result.y = screen.minY + } else if !adjacentBelow, abs(cursorCG.y - screen.maxY) <= snapDistance { + result.y = screen.maxY - size.height + } + } + return result + } private func snappedOrigin(forMoving rect: CGRect) -> NSPoint { let dx = closestSnapDelta( from: [rect.minX, rect.maxX], @@ -152,8 +286,9 @@ class MouseTracker { private func snappedResize(origin: NSPoint, size: CGSize) -> (origin: NSPoint, size: CGSize) { var left = origin.x, right = origin.x + size.width, top = origin.y, bottom = origin.y + size.height let edges = activeResizeEdges() - let horizontalCandidates = snapRects.filter { rangesOverlap(top...bottom, $0.minY...$0.maxY) }.flatMap { [$0.minX, $0.maxX] } - let verticalCandidates = snapRects.filter { rangesOverlap(left...right, $0.minX...$0.maxX) }.flatMap { [$0.minY, $0.maxY] } + let candidateRects = snapRects + screenSnapRects + let horizontalCandidates = candidateRects.filter { rangesOverlap(top...bottom, $0.minY...$0.maxY) }.flatMap { [$0.minX, $0.maxX] } + let verticalCandidates = candidateRects.filter { rangesOverlap(left...right, $0.minX...$0.maxX) }.flatMap { [$0.minY, $0.maxY] } if edges.left, let dx = closestSnapDelta(from: [left], candidates: horizontalCandidates) { left += dx } if edges.right, let dx = closestSnapDelta(from: [right], candidates: horizontalCandidates) { right += dx } if edges.top, let dy = closestSnapDelta(from: [top], candidates: verticalCandidates) { top += dy } @@ -192,7 +327,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 = []; screenSnapRects = []; trackedWindow = nil; initialMouseLocation = nil; initialWindowLocation = nil; currentAction = .none; quadrant = nil; windowSize = nil; snapPreviewWindow.orderOut(nil) } 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 b828aaf..0637a95 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 enableSnapping = "enableSnapping" case fnShortcutWarningDismissed = "fnShortcutWarningDismissed" case ignoredApps = "ignoredApps" case didMigrateDefaultIgnoredApps = "didMigrateDefaultIgnoredApps" @@ -16,6 +17,11 @@ class PreferencesManager { static func loadBool(for key: PreferenceKey) -> Bool { return UserDefaults.standard.bool(forKey: key.rawValue) } + + static func loadBool(for key: PreferenceKey, defaultValue: Bool) -> Bool { + guard UserDefaults.standard.object(forKey: key.rawValue) != nil else { return defaultValue } + return UserDefaults.standard.bool(forKey: key.rawValue) + } static func invalidateIgnoredAppsCache() { cachedIgnoredApps = nil diff --git a/Swift Shift/src/Manager/WindowManager.swift b/Swift Shift/src/Manager/WindowManager.swift index f450c08..76d5abd 100644 --- a/Swift Shift/src/Manager/WindowManager.swift +++ b/Swift Shift/src/Manager/WindowManager.swift @@ -38,6 +38,17 @@ class WindowManager { return rect } } + static func getScreenSnapRects() -> [CGRect] { + // NSScreen frames use Cocoa's bottom-left origin, while the snap math + // operates in CG's top-left origin (same space as getVisibleWindowRects), + // so flip each visibleFrame onto the primary display's height. + let primaryHeight = CGDisplayBounds(CGMainDisplayID()).height + return NSScreen.screens.map { screen in + let frame = screen.visibleFrame + let flippedY = primaryHeight - frame.origin.y - frame.height + return CGRect(x: frame.origin.x, y: flippedY, width: frame.width, height: frame.height) + } + } static func getCurrentWindow() -> AXUIElement? { guard let ev = CGEvent(source: nil) else { return nil } let sys = AXUIElementCreateSystemWide(); var el: AXUIElement? @@ -80,6 +91,10 @@ class WindowManager { static func convertYCoordinateBecauseTheAreTwoFuckingCoordinateSystems(point: NSPoint) -> NSPoint { return NSPoint(x: point.x, y: CGDisplayBounds(CGMainDisplayID()).height - point.y) } + static func convertCGFrameToCocoaFrame(_ frame: CGRect) -> CGRect { + let primaryHeight = CGDisplayBounds(CGMainDisplayID()).height + return CGRect(x: frame.origin.x, y: primaryHeight - frame.origin.y - frame.height, width: frame.width, height: frame.height) + } static func getPosition(window: AXUIElement) -> NSPoint? { var r: CFTypeRef?; guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &r) == .success else { return nil } var p: CGPoint = .zero; AXValueGetValue(r as! AXValue, .cgPoint, &p); return NSPoint(x: p.x, y: p.y) @@ -89,4 +104,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..f228708 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.enableSnapping.rawValue) var enableSnapping = true var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -86,6 +87,13 @@ struct PreferencesView: View { ShortcutsManager.shared.removeClickActionsForAll() } } + + PreferenceToggle( + isOn: $enableSnapping, + title: "Enable snapping", + subtitle: "Snap windows to edges and corners", + icon: "dot.squareshape.split.2x2" + ) } } }