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
149 changes: 142 additions & 7 deletions Swift Shift/src/Manager/MouseTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,52 @@ 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?
private var trackedWindow: AXUIElement?, trackedWindowIsFocused = false, shouldFocusWindow = false
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() }
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
Comment on lines +253 to +271

@coderabbitai coderabbitai Bot Jun 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Constrain edge snapping to screens near the cursor.

Line 253 iterates all screenSnapRects and can apply snaps for a different display if only one axis happens to align. On offset multi-monitor layouts this causes jumpy, incorrect snaps.

Proposed fix
     for screen in screenSnapRects {
+            guard screen.insetBy(dx: -snapDistance, dy: -snapDistance).contains(cursorCG) else { continue }
             // 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 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
for screen in screenSnapRects {
guard screen.insetBy(dx: -snapDistance, dy: -snapDistance).contains(cursorCG) else { continue }
// 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
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Swift` Shift/src/Manager/MouseTracker.swift around lines 253 - 271, The loop
over screenSnapRects in MouseTracker.swift is snapping to edges of displays that
are far from the cursor when only one axis aligns; to fix, bail out early for
screens that are not near the cursor by checking proximity on both axes before
computing adjacency and applying snaps: inside the for screen in screenSnapRects
loop (where cursorCG, snapDistance, result and size are used), add a guard that
ensures cursorCG.x is within (screen.minX - snapDistance) .. (screen.maxX +
snapDistance) AND cursorCG.y is within (screen.minY - snapDistance) ..
(screen.maxY + snapDistance); if not, continue so only screens near the cursor
are considered for adjacentLeft/Right/Above/Below and result.x/result.y
adjustments.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is an issue that I noticed that needs a bit of polish, but requires a lot of complexity to get right (as it would need to know the monitor layouts)

A simple solution to reduce the jerky behaviour would also be to have a dynamic guard (on position) or tolerance but fundamentally the snap behaviour and monitor move behaviour requires rework, alternatively you could have it so that snapping doesn't work on the boundaries between monitors.

In my view, I was able to 'work it out' after playing with it over a few minutes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chid, understood — thanks for the context. The guard line I proposed is actually the "dynamic guard on position" you described: it's a single check that constrains snapping to only the display the cursor is near, without requiring any knowledge of the full monitor layout. It sidesteps the cross-axis false-positive without reworking the broader snap/move logic.

That said, the existing adjacency-seam detection already handles the most important case (preventing the window from getting trapped at display boundaries), so the current behaviour is reasonable for an initial release.

If you'd like, I can open a follow-up issue to track the remaining polish (e.g. the cursor-proximity guard + any further multi-monitor move/snap rework) so it doesn't get lost.


🧠 Learnings used
Learnt from: pablopunk
Repo: pablopunk/SwiftShift PR: 150
File: Swift Shift/src/Manager/WindowManager.swift:0-0
Timestamp: 2026-05-13T17:15:02.309Z
Learning: In SwiftShift's `getVisibleWindowRects(excluding:)` (Swift Shift/src/Manager/WindowManager.swift), the exclusion filter uses exact rect equality (`rect.equalTo(excludedRect)`) to remove only the dragged window itself from snap candidates. Containing or maximized background windows must NOT be excluded — their outer edges are valid snap targets. Actual snapping is gated by perpendicular-overlap and a 10px edge-proximity check in MouseTracker, not by this exclusion filter.

}
return result
}
private func snappedOrigin(forMoving rect: CGRect) -> NSPoint {
let dx = closestSnapDelta(
from: [rect.minX, rect.maxX],
Expand All @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 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 requireMouseClick = "requireMouseClick"
case enableSnapping = "enableSnapping"
case fnShortcutWarningDismissed = "fnShortcutWarningDismissed"
case ignoredApps = "ignoredApps"
case didMigrateDefaultIgnoredApps = "didMigrateDefaultIgnoredApps"
Expand All @@ -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
Expand Down
16 changes: 15 additions & 1 deletion Swift Shift/src/Manager/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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)
Expand All @@ -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))
}
}

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) 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) {
Expand Down Expand Up @@ -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"
)
}
}
}
Expand Down