From 08d17af01152c011e0c83a6c94a2bfd477c0ddb1 Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:35:07 -0700 Subject: [PATCH 01/11] Add experimental snapping and restore animation Interpolate same-display resizing with a settling curve, preserve pending destinations for shortcut history, and let native dragging own position during size restoration. Respect Reduce Motion and keep existing mover fallbacks. --- Rectangle.xcodeproj/project.pbxproj | 4 + Rectangle/AccessibilityElement.swift | 66 ++++++-- Rectangle/Defaults.swift | 2 + Rectangle/Snapping/SnappingManager.swift | 22 ++- Rectangle/WindowManager.swift | 117 +++++++++----- Rectangle/WindowMover/WindowAnimator.swift | 178 +++++++++++++++++++++ RectangleTests/RectangleTests.swift | 13 +- 7 files changed, 341 insertions(+), 61 deletions(-) create mode 100644 Rectangle/WindowMover/WindowAnimator.swift diff --git a/Rectangle.xcodeproj/project.pbxproj b/Rectangle.xcodeproj/project.pbxproj index a27f0d646..9f84efc2e 100644 --- a/Rectangle.xcodeproj/project.pbxproj +++ b/Rectangle.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + AC0100010000000000000001 /* WindowAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0100010000000000000002 /* WindowAnimator.swift */; }; 01f52cab9bce43359775bdd7 /* JSONDefaultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = de0950b8b00f40308934a523 /* JSONDefaultTests.swift */; }; 05DBEA44B55E6F8C5FC3D6E0 /* MiddleLeftTwelfthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEABF10E66D648DDB37DABBA /* MiddleLeftTwelfthCalculation.swift */; }; 0F29956694B5BB31EBDAE445 /* UpperMiddleRightSixteenthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B5B09465C38F8B766B3C3C /* UpperMiddleRightSixteenthCalculation.swift */; }; @@ -230,6 +231,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AC0100010000000000000002 /* WindowAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAnimator.swift; sourceTree = ""; }; 16B4044DB4809116039F96EB /* SixteenthsRepeated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SixteenthsRepeated.swift; sourceTree = ""; }; 246168B345ECE6C375ACEEB7 /* BottomLeftTwelfthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomLeftTwelfthCalculation.swift; sourceTree = ""; }; 30166BCF24F27D6A00A38608 /* SpecifiedCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecifiedCalculation.swift; sourceTree = ""; }; @@ -631,6 +633,7 @@ children = ( 9824704A22B189250037B409 /* BestEffortWindowMover.swift */, 9824704622B189240037B409 /* StandardWindowMover.swift */, + AC0100010000000000000002 /* WindowAnimator.swift */, 9824704722B189240037B409 /* WindowMover.swift */, 98B3559723CE025700E410E0 /* FixedSizeWindowMover.swift */, ); @@ -1062,6 +1065,7 @@ 9821403122B38A0500ABFB3F /* TopHalfCalculation.swift in Sources */, 9818E01428B5A4FD004AA524 /* SixthsCompoundCalculation.swift in Sources */, 9824704B22B189250037B409 /* StandardWindowMover.swift in Sources */, + AC0100010000000000000001 /* WindowAnimator.swift in Sources */, 9821402722B3888100ABFB3F /* ChangeSizeCalculation.swift in Sources */, 6490B39927BF97BB0056C220 /* TopCenterRightEighthCalculation.swift in Sources */, 98B3559823CE025700E410E0 /* FixedSizeWindowMover.swift in Sources */, diff --git a/Rectangle/AccessibilityElement.swift b/Rectangle/AccessibilityElement.swift index 5f1ffc772..86057178e 100644 --- a/Rectangle/AccessibilityElement.swift +++ b/Rectangle/AccessibilityElement.swift @@ -131,7 +131,8 @@ class AccessibilityElement { /// The Accessebility API only allows size & position adjustments individually. /// To handle moving to different displays, we have to adjust the size then the position, then the size again since macOS will enforce sizes that fit on the current display. /// When windows take a long time to adjust size & position, there is some visual stutter with doing each of these actions. The stutter can be slightly reduced by removing the initial size adjustment, which can make unsnap restore appear smoother. - func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) { + func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { + WindowAnimator.shared.cancel(for: self) let appElement = applicationElement let builtInAssistiveTechnologyEnabled = NSWorkspace.shared.isVoiceOverEnabled || NSWorkspace.shared.isSwitchControlEnabled @@ -149,11 +150,46 @@ class AccessibilityElement { if adjustSizeFirst { size = frame.size } - position = frame.origin + if adjustPosition { position = frame.origin } size = frame.size } ) } + + /// Keep the existing Enhanced UI policy active for the whole transition, + /// instead of toggling application accessibility on every timer tick. + func beginAnimatedAdjustment() -> () -> Void { + let appElement = applicationElement + let restore = Defaults.enhancedUI.value.beginWindowAdjustment( + bundleIdentifier: appElement?.bundleIdentifier, + builtInAssistiveTechnologyEnabled: NSWorkspace.shared.isVoiceOverEnabled + || NSWorkspace.shared.isSwitchControlEnabled, + readEnhancedUI: { appElement?.enhancedUserInterface }, + writeEnhancedUI: { appElement?.enhancedUserInterface = $0 } + ) + // Avoid a long stream of blocking requests to an unresponsive app. + setMessagingTimeout(0.05) + return { [self] in + setMessagingTimeout(0) + restore() + } + } + + /// No per-frame readbacks or logging. The normal mover checks the achieved + /// geometry once the transition ends and applies any necessary corrections. + func setAnimationFrame(_ frame: CGRect, resizeOnly: Bool = false) -> Bool { + var size = frame.size + var position = frame.origin + guard let sizeValue = AXValueCreate(.cgSize, &size), + let positionValue = AXValueCreate(.cgPoint, &position) else { return false } + guard AXUIElementSetAttributeValue(wrappedElement, kAXSizeAttribute as CFString, sizeValue) == .success else { return false } + // During a native drag, even a correct position can already be stale + // by the time resizing finishes. Avoid competing position writes. + if !resizeOnly { + guard AXUIElementSetAttributeValue(wrappedElement, kAXPositionAttribute as CFString, positionValue) == .success else { return false } + } + return true + } private var childElements: [AccessibilityElement]? { getElementsValue(.children) @@ -540,22 +576,34 @@ enum EnhancedUI: Int { bundleIdentifier: String?, builtInAssistiveTechnologyEnabled: Bool, readEnhancedUI: () -> Bool?, - writeEnhancedUI: (Bool) -> Void, + writeEnhancedUI: @escaping (Bool) -> Void, adjustment: () -> Void ) { + let restore = beginWindowAdjustment(bundleIdentifier: bundleIdentifier, + builtInAssistiveTechnologyEnabled: builtInAssistiveTechnologyEnabled, + readEnhancedUI: readEnhancedUI, + writeEnhancedUI: writeEnhancedUI) + adjustment() + restore() + } + + func beginWindowAdjustment( + bundleIdentifier: String?, + builtInAssistiveTechnologyEnabled: Bool, + readEnhancedUI: () -> Bool?, + writeEnhancedUI: @escaping (Bool) -> Void + ) -> () -> Void { let enhancedUIWasEnabled = readEnhancedUI() if enhancedUIWasEnabled == true { writeEnhancedUI(false) } - adjustment() - - if enhancedUIWasEnabled == true, - restoresEnhancedUI( + let shouldRestore = enhancedUIWasEnabled == true && restoresEnhancedUI( bundleIdentifier: bundleIdentifier, builtInAssistiveTechnologyEnabled: builtInAssistiveTechnologyEnabled - ) { - writeEnhancedUI(true) + ) + return { + if shouldRestore { writeEnhancedUI(true) } } } } diff --git a/Rectangle/Defaults.swift b/Rectangle/Defaults.swift index 00dae42ec..89b185fae 100644 --- a/Rectangle/Defaults.swift +++ b/Rectangle/Defaults.swift @@ -12,6 +12,7 @@ class Defaults { static let cycleSizesIsChanged = BoolDefault(key: "cycleSizesIsChanged") static let cornerCycleExpansionAxis = IntEnumDefault(key: "cornerCycleExpansionAxis", defaultValue: .horizontal) static let cooperativeCornerResize = BoolDefault(key: "cooperativeCornerResize") + static let experimentalWindowAnimations = BoolDefault(key: "experimentalWindowAnimations") static let allowAnyShortcut = BoolDefault(key: "allowAnyShortcut") static let windowSnapping = OptionalBoolDefault(key: "windowSnapping") static let almostMaximizeHeight = FloatDefault(key: "almostMaximizeHeight") @@ -121,6 +122,7 @@ class Defaults { cycleSizesIsChanged, cornerCycleExpansionAxis, cooperativeCornerResize, + experimentalWindowAnimations, allowAnyShortcut, windowSnapping, almostMaximizeHeight, diff --git a/Rectangle/Snapping/SnappingManager.swift b/Rectangle/Snapping/SnappingManager.swift index bd4a43ebb..ee20742d0 100644 --- a/Rectangle/Snapping/SnappingManager.swift +++ b/Rectangle/Snapping/SnappingManager.swift @@ -195,12 +195,15 @@ class SnappingManager { func handle(event: NSEvent) { switch event.type { case .leftMouseDown: + // A manual grab owns the window from this point onward. + WindowAnimator.shared.finish() if !Defaults.obtainWindowOnClick.userDisabled { windowElement = AccessibilityElement.getWindowElementUnderCursor() windowId = windowElement?.getWindowId() initialWindowRect = windowElement?.frame } case .leftMouseUp: + WindowAnimator.shared.finish() if let currentSnapArea = self.currentSnapArea { box?.orderOut(nil) currentSnapArea.action.postSnap(windowElement: windowElement, windowId: windowId, screen: currentSnapArea.screen) @@ -317,7 +320,7 @@ class SnappingManager { } } - private func unsnapRestore(windowId: CGWindowID, currentRect: CGRect, cursorLoc: CGPoint?) { + func unsnapRestore(windowId: CGWindowID, currentRect: CGRect, cursorLoc: CGPoint?) { guard !Defaults.unsnapRestore.userDisabled else { return } // if window was put there by rectangle, restore size @@ -336,7 +339,22 @@ class SnappingManager { } } } - windowElement.setFrame(newRect, adjustSizeFirst: false) + // Let native dragging own position whenever restoring the + // width does not require moving the window under the cursor. + let resizeOnly = WindowAnimator.enabled && newRect.origin == currentRect.origin + var cursorOffset = CGPoint.zero + let initialCursor = NSEvent.mouseLocation.screenFlipped + WindowAnimator.shared.animate(windowElement, to: newRect, duration: 0.18, resizeOnly: resizeOnly, offset: { + // Follow the drag during restoration, but do not follow + // unrelated cursor movement after the button is released. + if NSEvent.pressedMouseButtons & 1 != 0 { + let cursor = NSEvent.mouseLocation.screenFlipped + cursorOffset = CGPoint(x: cursor.x - initialCursor.x, y: cursor.y - initialCursor.y) + } + return cursorOffset + }, curve: WindowAnimationCurve.unsnapValue) { frame in + windowElement.setFrame(frame, adjustSizeFirst: false, adjustPosition: !resizeOnly) + } } else { windowElement.size = restoreRect.size } diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index 3de8107ea..fcfb2b7d6 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -70,7 +70,15 @@ class WindowManager { return } if let restoreRect = AppDelegate.windowHistory.restoreRects[windowId] { - frontmostWindowElement.setFrame(restoreRect) + if WindowAnimator.enabled, frontmostWindowElement.isResizable(), + let screenFrame = screenDetection.detectScreens(using: frontmostWindowElement)?.currentScreen.frame.screenFlipped, + screenFrame.contains(restoreRect) { + WindowAnimator.shared.animate(frontmostWindowElement, to: restoreRect) { frame in + frontmostWindowElement.setFrame(frame) + } + } else { + frontmostWindowElement.setFrame(restoreRect) + } } AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) return @@ -94,7 +102,10 @@ class WindowManager { return } - let currentWindowRect: CGRect = frontmostWindowElement.frame + // Use the pending destination for shortcut cycling and restore history; + // the next transition still starts from the actual on-screen frame. + let currentWindowRect = WindowAnimator.shared.destination(for: frontmostWindowElement) + ?? frontmostWindowElement.frame var lastRectangleAction = windowId.flatMap { AppDelegate.windowHistory.lastRectangleActions[$0] } @@ -202,53 +213,71 @@ class WindowManager { source: parameters.source, isFixedSize: isFixedSize) - var resultingRect: CGRect - if let cooperativeCornerPlan { - resultingRect = applyCooperativeCornerResize(result: resultParameters, - plan: cooperativeCornerPlan) - } else { - resultingRect = apply(result: resultParameters) - } - - if let cooperativeCornerPlan { - // AX can enforce a minimum size that was not reported before the settling pass. - ActiveSideSplitRatios.shared.recordAchievedCooperativeAction(cooperativeCornerPlan.action, - achievedFrame: resultingRect.screenFlipped, - screenFrame: cooperativeCornerPlan.screenFrame, - gapSize: cooperativeCornerPlan.gapSize) - } - - if isMovedAcrossDisplays { - if calcResult.rect.size != resultingRect.size { - Logger.log("Window size wasn't applied perfectly across displays. Trying again.") + // The initial experiment only interpolates single-window, same-display + // resizes. Cooperative changes and display transfers retain their settling + // order, and non-resizable windows retain the fixed-size mover chain. + let animated = WindowAnimator.enabled && !isFixedSize && !isMovedAcrossDisplays + && !Defaults.cooperativeCornerResize.enabled + let completeMove = { [self] in + var resultingRect: CGRect + if let cooperativeCornerPlan { + resultingRect = applyCooperativeCornerResize(result: resultParameters, + plan: cooperativeCornerPlan) + } else { resultingRect = apply(result: resultParameters) - + } + + if let cooperativeCornerPlan { + // AX can enforce a minimum size that was not reported before the settling pass. + ActiveSideSplitRatios.shared.recordAchievedCooperativeAction(cooperativeCornerPlan.action, + achievedFrame: resultingRect.screenFlipped, + screenFrame: cooperativeCornerPlan.screenFrame, + gapSize: cooperativeCornerPlan.gapSize) + } + + if isMovedAcrossDisplays { if calcResult.rect.size != resultingRect.size { - Logger.log("Final attempt to adjust across displays.") - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) { [weak self] in - guard let self, self.executionID == currentExecutionID else { return } - let finalRect = self.apply(result: resultParameters) - self.windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: finalRect) - self.postProcess(result: resultParameters, resultingRect: finalRect) + Logger.log("Window size wasn't applied perfectly across displays. Trying again.") + resultingRect = apply(result: resultParameters) + + if calcResult.rect.size != resultingRect.size { + Logger.log("Final attempt to adjust across displays.") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) { [weak self] in + guard let self, self.executionID == currentExecutionID else { return } + let finalRect = self.apply(result: resultParameters) + self.windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: finalRect) + self.postProcess(result: resultParameters, resultingRect: finalRect, incrementCount: !animated) + } + return } - return } + windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: resultingRect) } - windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: resultingRect) - } - if !isMovedAcrossDisplays { - applyCooperativeCornerCleanupIfNeeded(focusedWindowId: windowId, - source: parameters.source, - oldFocusedFrame: currentNormalizedRect, - newFocusedFrame: resultingRect.screenFlipped, - screenFrame: sourceScreens.currentScreen.adjustedVisibleFrame(ignoreTodo), - currentAction: action, - lastRectangleAction: lastRectangleAction) - resultingRect = frontmostWindowElement.frame + if !isMovedAcrossDisplays { + applyCooperativeCornerCleanupIfNeeded(focusedWindowId: windowId, + source: parameters.source, + oldFocusedFrame: currentNormalizedRect, + newFocusedFrame: resultingRect.screenFlipped, + screenFrame: sourceScreens.currentScreen.adjustedVisibleFrame(ignoreTodo), + currentAction: action, + lastRectangleAction: lastRectangleAction) + resultingRect = frontmostWindowElement.frame + } + + postProcess(result: resultParameters, resultingRect: resultingRect, incrementCount: !animated) + } + if animated { + // Record the logical destination now so a repeated shortcut can cycle + // without treating an intermediate animation frame as a manual move. + recordAction(windowId: windowId, resultingRect: calcResult.rect.screenFlipped, + action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) + WindowAnimator.shared.animate(frontmostWindowElement, to: calcResult.rect.screenFlipped) { _ in + completeMove() + } + } else { + completeMove() } - - postProcess(result: resultParameters, resultingRect: resultingRect) } /// Move/resize a window based on the calculation results. @@ -278,7 +307,7 @@ class WindowManager { } } - func postProcess(result: ResultParameters, resultingRect: CGRect) { + func postProcess(result: ResultParameters, resultingRect: CGRect, incrementCount: Bool = true) { let calcResult = result.calcResult if WindowSizeConstraint.isExceeded(requested: calcResult.rect, actual: resultingRect, action: result.action) { @@ -289,7 +318,7 @@ class WindowManager { CGWarpMouseCursorPosition(resultingRect.centerPoint) } - recordAction(windowId: result.windowId, resultingRect: resultingRect, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) + recordAction(windowId: result.windowId, resultingRect: resultingRect, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction, incrementCount: incrementCount) if Logger.logging { var logItems = ["\(result.action.name)", diff --git a/Rectangle/WindowMover/WindowAnimator.swift b/Rectangle/WindowMover/WindowAnimator.swift new file mode 100644 index 000000000..7504c29a9 --- /dev/null +++ b/Rectangle/WindowMover/WindowAnimator.swift @@ -0,0 +1,178 @@ +/// WindowAnimator.swift + +import Cocoa + +enum WindowAnimationCurve { + static let duration: TimeInterval = 0.34 + + static func unsnapValue(at progress: Double) -> CGFloat { + let t = min(1, max(0, progress)) + // Spread drag restoration evenly instead of concentrating the resize + // near the start. Velocity and acceleration are zero at both ends. + return CGFloat(t * t * t * (10 + t * (-15 + 6 * t))) + } + + static func value(at progress: Double) -> CGFloat { + let progress = min(1, max(0, progress)) + // Integrate a positive velocity profile proportional to t * (1-t)^5. + // Movement gathers pace early, then settles gently without overshoot. + // Both endpoints have zero velocity, so the final frame never cuts off + // a moving spring; the same curve also drives the preview and its fade. + return CGFloat(1 - pow(1 - progress, 6) * (1 + 6 * progress)) + } +} + +/// A time-based transition. Missed timer ticks are skipped, never queued up. +/// Window I/O and the clock are supplied separately so cancellation and failures +/// can be tested without moving a user's windows. +final class WindowFrameAnimation { + let destination: CGRect + private let origin: CGRect + private let startTime: TimeInterval + private let duration: TimeInterval + private let offset: () -> CGPoint + private let curve: (Double) -> CGFloat + private let write: (CGRect) -> Bool + private let cleanup: () -> Void + private let completion: (CGRect) -> Void + private(set) var isFinished = false + + init(from: CGRect, to: CGRect, startTime: TimeInterval, duration: TimeInterval, + offset: @escaping () -> CGPoint = { .zero }, + curve: @escaping (Double) -> CGFloat = WindowAnimationCurve.value, + write: @escaping (CGRect) -> Bool, + cleanup: @escaping () -> Void, + completion: @escaping (CGRect) -> Void) { + origin = from + destination = to + self.startTime = startTime + self.duration = duration + self.offset = offset + self.curve = curve + self.write = write + self.cleanup = cleanup + self.completion = completion + } + + func tick(at time: TimeInterval) { + guard !isFinished else { return } + let progress = duration > 0 ? min(1, max(0, (time - startTime) / duration)) : 1 + if progress >= 1 { + finish() + return + } + let eased = curve(progress) + let delta = offset() + let frame = CGRect(x: origin.minX + (destination.minX - origin.minX) * eased + delta.x, + y: origin.minY + (destination.minY - origin.minY) * eased + delta.y, + width: origin.width + (destination.width - origin.width) * eased, + height: origin.height + (destination.height - origin.height) * eased) + if !write(frame) { + // A refused AX write ends interpolation; the ordinary mover settles + // the destination using the application's existing size constraints. + finish() + } + } + + func finish() { + guard !isFinished else { return } + let delta = offset() + isFinished = true + cleanup() + completion(destination.offsetBy(dx: delta.x, dy: delta.y)) + } + + func cancel() { + guard !isFinished else { return } + isFinished = true + cleanup() + } +} + +/// Main-run-loop ownership serializes AX adjustments and history updates. Only +/// one window animates at a time, including when two windows belong to one app. +final class WindowAnimator { + static let shared = WindowAnimator() + private var window: AccessibilityElement? + private var animation: WindowFrameAnimation? + private var timer: Timer? + private var mouseMonitor: Any? + + private init() { + for name in [NSApplication.willTerminateNotification, NSApplication.didChangeScreenParametersNotification] { + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + self?.finish() + } + } + } + + static var enabled: Bool { + Defaults.experimentalWindowAnimations.enabled + && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + && !NSWorkspace.shared.isVoiceOverEnabled + && !NSWorkspace.shared.isSwitchControlEnabled + } + + func destination(for element: AccessibilityElement) -> CGRect? { + window == element ? animation?.destination : nil + } + + func cancel(for element: AccessibilityElement) { + if window == element { animation?.cancel() } + } + + func finish() { + animation?.finish() + } + + func animate(_ element: AccessibilityElement, to destination: CGRect, + duration: TimeInterval = WindowAnimationCurve.duration, + resizeOnly: Bool = false, + offset: @escaping () -> CGPoint = { .zero }, + curve: @escaping (Double) -> CGFloat = WindowAnimationCurve.value, + completion: @escaping (CGRect) -> Void) { + if window == element { + animation?.cancel() + } else { + animation?.finish() + } + let origin = element.frame + guard Self.enabled, !origin.isNull, !destination.isNull, + !origin.isEmpty, !destination.isEmpty, origin != destination else { + completion(destination) + return + } + let restoreAccessibility = element.beginAnimatedAdjustment() + window = element + animation = WindowFrameAnimation(from: origin, to: destination, + startTime: ProcessInfo.processInfo.systemUptime, + duration: duration, offset: offset, curve: curve, + write: { element.setAnimationFrame($0, resizeOnly: resizeOnly) }, + cleanup: { [weak self] in + self?.timer?.invalidate() + self?.timer = nil + if let monitor = self?.mouseMonitor { + NSEvent.removeMonitor(monitor) + self?.mouseMonitor = nil + } + self?.animation = nil + self?.window = nil + restoreAccessibility() + }, completion: completion) + let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in + guard let self else { return } + if !Self.enabled { + self.finish() + } else { + self.animation?.tick(at: ProcessInfo.processInfo.systemUptime) + } + } + self.timer = timer + // Keyboard animations must also yield to a manual grab when drag-to-snap + // is disabled and SnappingManager is not listening for mouse events. + mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { [weak self] _ in + self?.finish() + } + RunLoop.main.add(timer, forMode: .common) + } +} diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 2f2520236..5f40c1801 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -4504,6 +4504,7 @@ final class WindowSizeConstraintExecutionTests: XCTestCase { let settings: [(Default, CodableDefault)] = [ (Defaults.subsequentExecutionMode, CodableDefault(int: SubsequentExecutionMode.none.rawValue)), (Defaults.cooperativeCornerResize, CodableDefault(bool: false)), + (Defaults.experimentalWindowAnimations, CodableDefault(bool: false)), (Defaults.useCursorScreenDetection, CodableDefault(bool: false)), (Defaults.moveFixedSizeToEdge, CodableDefault(int: EdgeAlignment.edgesAndCorners.rawValue)), (Defaults.gapSize, CodableDefault(float: 0)), @@ -4681,7 +4682,7 @@ final class WindowSizeConstraintExecutionTests: XCTestCase { override func getWindowId() -> CGWindowID? { nil } override func isResizable() -> Bool { true } - override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) { + override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { currentFrame = frame if frame.size == targetSize { resizeAttempts += 1 @@ -4710,8 +4711,8 @@ final class WindowSizeConstraintExecutionTests: XCTestCase { override func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) {} - override func postProcess(result: ResultParameters, resultingRect: CGRect) { - super.postProcess(result: result, resultingRect: resultingRect) + override func postProcess(result: ResultParameters, resultingRect: CGRect, incrementCount: Bool = true) { + super.postProcess(result: result, resultingRect: resultingRect, incrementCount: incrementCount) didFinish?() } } @@ -4816,8 +4817,8 @@ final class CrossDisplayResizeTests: XCTestCase { override func getWindowId() -> CGWindowID? { nil } override func isResizable() -> Bool { true } - override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) { - currentFrame = frame + override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { + currentFrame = CGRect(origin: adjustPosition ? frame.origin : currentFrame.origin, size: frame.size) if frame.size == target.size { resizeAttempts += 1 if resizeAttempts <= 2 { @@ -4832,7 +4833,7 @@ final class CrossDisplayResizeTests: XCTestCase { override func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) {} - override func postProcess(result: ResultParameters, resultingRect: CGRect) { + override func postProcess(result: ResultParameters, resultingRect: CGRect, incrementCount: Bool = true) { didFinish?(result, resultingRect) } } From 53c7e0489f51a015eb455676e16a0e5c05ddf912 Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:35:07 -0700 Subject: [PATCH 02/11] Add an adaptive frosted snap preview Use native blur with optional tint, a subtle outline, and an exterior shadow aligned at screen edges. Share the animation curve, respect reduced motion and transparency, and configure authored layers for SDR. --- Rectangle/Defaults.swift | 27 +- Rectangle/Snapping/FootprintWindow.swift | 424 ++++++++++++++++++++--- Rectangle/Snapping/SnappingManager.swift | 24 +- 3 files changed, 414 insertions(+), 61 deletions(-) diff --git a/Rectangle/Defaults.swift b/Rectangle/Defaults.swift index 89b185fae..8f676ac40 100644 --- a/Rectangle/Defaults.swift +++ b/Rectangle/Defaults.swift @@ -52,10 +52,11 @@ class Defaults { static let showAllActionsInMenu = OptionalBoolDefault(key: "showAllActionsInMenu") static let showAdditionalSizesInMenu = OptionalBoolDefault(key: "showAdditionalSizesInMenu") static var SUHasLaunchedBefore: Bool { UserDefaults.standard.bool(forKey: "SUHasLaunchedBefore") } - static let footprintAlpha = FloatDefault(key: "footprintAlpha", defaultValue: 0.3) + static let footprintAlpha = FootprintAlphaDefault() static let footprintBorderWidth = FloatDefault(key: "footprintBorderWidth", defaultValue: 2) static let footprintFade = OptionalBoolDefault(key: "footprintFade") static let footprintColor = JSONDefault(key: "footprintColor") + static let footprintBlur = BoolDefault(key: "footprintBlur") static let SUEnableAutomaticChecks = BoolDefault(key: "SUEnableAutomaticChecks") static let todo = OptionalBoolDefault(key: "todo") static let todoMode = BoolDefault(key: "todoMode") @@ -160,6 +161,7 @@ class Defaults { footprintBorderWidth, footprintFade, footprintColor, + footprintBlur, SUEnableAutomaticChecks, todo, todoMode, @@ -343,6 +345,29 @@ class StringDefault: Default { } } +class FootprintAlphaDefault: Default { + let key = "footprintAlpha" + + var value: Float { + get { + // Preserve an explicit zero and resolve the unset default by style. + (UserDefaults.standard.object(forKey: key) as? NSNumber)?.floatValue + ?? (Defaults.footprintBlur.enabled ? 0 : 0.3) + } + set { UserDefaults.standard.set(newValue, forKey: key) } + } + + var cgFloat: CGFloat { CGFloat(value) } + + func load(from codable: CodableDefault) { + if let float = codable.float { value = float } + } + + func toCodable() -> CodableDefault { + CodableDefault(float: value) + } +} + class FloatDefault: Default { public private(set) var key: String private var initialized = false diff --git a/Rectangle/Snapping/FootprintWindow.swift b/Rectangle/Snapping/FootprintWindow.swift index 0862a5e0f..6d454f150 100644 --- a/Rectangle/Snapping/FootprintWindow.swift +++ b/Rectangle/Snapping/FootprintWindow.swift @@ -2,22 +2,150 @@ import Cocoa -class FootprintWindow: NSWindow { - private var orderOutCanceled = false - +struct FootprintAccessibility { + var reduceMotion: Bool + var reduceTransparency: Bool + + static var current: Self { + Self(reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, + reduceTransparency: NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency) + } +} + +struct FootprintPresentation { + let usesBlur: Bool + let alpha: CGFloat + let fades: Bool + let animates: Bool + + init(blurRequested: Bool, alpha: CGFloat, fadeRequested: Bool, + animationRequested: Bool, accessibility: FootprintAccessibility) { + usesBlur = blurRequested && !accessibility.reduceTransparency + // A visual-effect view must remain at full window opacity for AppKit to + // composite its material correctly. The configured alpha tints its fill. + self.alpha = usesBlur || accessibility.reduceTransparency ? 1 : min(1, max(0, alpha)) + fades = fadeRequested && !accessibility.reduceMotion && !accessibility.reduceTransparency + animates = animationRequested && !accessibility.reduceMotion + } +} + +private final class FootprintContentView: NSView { + var appearanceDidChange: (() -> Void)? + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + appearanceDidChange?() + } +} + +private final class FootprintShadowWindow: NSWindow { + private let shadowLayer = CALayer() + private let cutoutLayer = CAShapeLayer() + private let padding: CGFloat = 96 + init() { - let initialRect = NSRect(x: 0, y: 0, width: 0, height: 0) - super.init(contentRect: initialRect, styleMask: .titled, backing: .buffered, defer: false) + super.init(contentRect: .zero, styleMask: .borderless, backing: .buffered, defer: false) + colorSpace = .sRGB + isOpaque = false + backgroundColor = .clear + hasShadow = false + ignoresMouseEvents = true + isReleasedWhenClosed = false + animationBehavior = .none + level = .modalPanel + collectionBehavior = [.transient, .ignoresCycle] + + let view = NSView() + view.wantsLayer = true + view.layer?.addSublayer(shadowLayer) + view.layer?.mask = cutoutLayer + cutoutLayer.fillRule = .evenOdd + shadowLayer.shadowColor = NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 1).cgColor + shadowLayer.shadowRadius = 32 + shadowLayer.shadowOffset = CGSize(width: 0, height: -8) + for layer in [view.layer, shadowLayer, cutoutLayer].compactMap({ $0 }) { + layer.contentsFormat = .RGBA8Uint + if #available(macOS 26, *) { + layer.preferredDynamicRange = .standard + } else if #available(macOS 14, *) { + layer.wantsExtendedDynamicRangeContent = false + } + } + contentView = view + } + + override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { + // Shadow padding must extend offscreen without shifting the cutout + // away from the preview when it reaches the top edge. + frameRect + } + func update(around rect: CGRect, cornerRadius: CGFloat, isDark: Bool) { + setFrame(rect.insetBy(dx: -padding, dy: -padding), display: false) + let bounds = CGRect(origin: .zero, size: frame.size) + let panel = CGRect(origin: CGPoint(x: padding, y: padding), size: rect.size) + let radius = min(cornerRadius, min(panel.width, panel.height) / 2) + let outline = CGPath(roundedRect: panel, cornerWidth: radius, cornerHeight: radius, transform: nil) + let cutout = CGMutablePath() + cutout.addRect(bounds) + cutout.addPath(outline) + + CATransaction.begin() + CATransaction.setDisableActions(true) + shadowLayer.frame = bounds + shadowLayer.shadowPath = outline + shadowLayer.shadowOpacity = isDark ? 0.65 : 0.42 + cutoutLayer.frame = bounds + // Remove the entire panel from the shadow, even behind transparent blur. + cutoutLayer.path = cutout + CATransaction.commit() + } +} + +class FootprintWindow: NSWindow { + private let boxView = NSBox() + private let effectView = NSVisualEffectView() + private var shadowWindow: FootprintShadowWindow? + private var plainCornerRadius: CGFloat = 5 + private var blurMaskRadius: CGFloat? + private let accessibility: () -> FootprintAccessibility + private let clock: () -> TimeInterval + private var accessibilityObserver: NSObjectProtocol? + private var showing = false + private var frameAnimation: WindowFrameAnimation? + private var fade: Fade? + private var timer: Timer? + + private struct Fade { + let from: CGFloat + let to: CGFloat + let start: TimeInterval + let duration: TimeInterval + } + + var presentation: FootprintPresentation { + FootprintPresentation(blurRequested: Defaults.footprintBlur.enabled, + alpha: Defaults.footprintAlpha.cgFloat, + fadeRequested: !Defaults.footprintFade.userDisabled, + animationRequested: Defaults.footprintAnimationDurationMultiplier.value > 0, + accessibility: accessibility()) + } + + init(accessibility: @escaping () -> FootprintAccessibility = { .current }, + clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { + self.accessibility = accessibility + self.clock = clock + super.init(contentRect: .zero, styleMask: .titled, backing: .buffered, defer: false) title = "Rectangle" + colorSpace = .sRGB isOpaque = false + backgroundColor = .clear level = .modalPanel hasShadow = false + ignoresMouseEvents = true isReleasedWhenClosed = false - alphaValue = Defaults.footprintFade.userDisabled - ? CGFloat(Defaults.footprintAlpha.value) - : 0 - + alphaValue = 0 + styleMask.insert(.fullSizeContentView) titleVisibility = .hidden titlebarAppearsTransparent = true @@ -26,63 +154,273 @@ class FootprintWindow: NSWindow { standardWindowButton(.miniaturizeButton)?.isHidden = true standardWindowButton(.zoomButton)?.isHidden = true standardWindowButton(.toolbarButton)?.isHidden = true - - let boxView = NSBox() - boxView.boxType = .custom - boxView.borderColor = .lightGray - boxView.borderWidth = CGFloat(Defaults.footprintBorderWidth.value) - + + let container = FootprintContentView(frame: .zero) + container.wantsLayer = true + let radius: CGFloat if #available(macOS 26.0, *) { - boxView.cornerRadius = 16 + radius = 16 } else if #available(macOS 11.0, *) { - boxView.cornerRadius = 10 + radius = 10 } else { - boxView.cornerRadius = 5 + radius = 5 } + plainCornerRadius = radius + container.layer?.cornerRadius = radius + container.layer?.masksToBounds = true + effectView.material = .fullScreenUI + // Inherit the system appearance so AppKit selects its light/dark material. + effectView.blendingMode = .behindWindow + effectView.state = .active + effectView.autoresizingMask = [.width, .height] + container.addSubview(effectView) + boxView.boxType = .custom + boxView.cornerRadius = radius boxView.wantsLayer = true - boxView.fillColor = Defaults.footprintColor.typedValue?.nsColor ?? NSColor.black - - contentView = boxView + boxView.autoresizingMask = [.width, .height] + container.addSubview(boxView) + // Keep the authored preview layers in SDR. NSVisualEffectView supplies + // blur without Liquid Glass. + for view in [container, effectView, boxView] { + view.wantsLayer = true + view.layer?.contentsFormat = .RGBA8Uint + if #available(macOS 26, *) { + view.layer?.preferredDynamicRange = .standard + } else if #available(macOS 14, *) { + view.layer?.wantsExtendedDynamicRangeContent = false + } + } + contentView = container + container.appearanceDidChange = { [weak self] in self?.updateAppearance() } + updateAppearance() + + accessibilityObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, + object: nil, queue: .main + ) { [weak self] _ in + self?.refreshAccessibility() + } + } + + deinit { + timer?.invalidate() + if let shadowWindow { + removeChildWindow(shadowWindow) + shadowWindow.close() + } + if let accessibilityObserver { + NSWorkspace.shared.notificationCenter.removeObserver(accessibilityObserver) + } } - + + private func updateAppearance() { + let style = presentation + let windowStyle: NSWindow.StyleMask = Defaults.footprintBlur.enabled ? .borderless : [.titled, .fullSizeContentView] + if styleMask != windowStyle { + styleMask = windowStyle + titleVisibility = .hidden + titlebarAppearsTransparent = true + for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton, .toolbarButton] { + standardWindowButton(button)?.isHidden = true + } + } + let isDark = (contentView?.effectiveAppearance ?? effectiveAppearance) + .bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let radius = Defaults.footprintBlur.enabled ? 12 : plainCornerRadius + contentView?.layer?.cornerRadius = radius + boxView.cornerRadius = radius + if style.usesBlur, blurMaskRadius != radius { + // Mask the material itself as well as the tint, so the compositor + // cannot leave bright material outside the rounded surface. + let mask = NSImage(size: NSSize(width: radius * 2 + 1, height: radius * 2 + 1), flipped: false) { rect in + NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1).setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + return true + } + mask.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius) + mask.resizingMode = .stretch + effectView.maskImage = mask + blurMaskRadius = radius + } + effectView.isHidden = !style.usesBlur + let outlineGray: CGFloat = isDark ? 0.7 : 0.35 + boxView.borderColor = style.usesBlur + ? NSColor(srgbRed: outlineGray, green: outlineGray, blue: outlineGray, alpha: 0.35) + : .lightGray + boxView.borderWidth = CGFloat(Defaults.footprintBorderWidth.value) + // Keep the native material fully applied. + effectView.alphaValue = 1 + if style.usesBlur { + boxView.borderWidth = UserDefaults.standard.object(forKey: Defaults.footprintBorderWidth.key) == nil + ? 1 : max(0, CGFloat(Defaults.footprintBorderWidth.value)) + } + let customColor = Defaults.footprintColor.typedValue?.nsColor + let defaultTint: NSColor = Defaults.footprintBlur.enabled && !isDark ? .white : .black + let color = customColor ?? defaultTint + if accessibility().reduceTransparency { + boxView.fillColor = color.withAlphaComponent(1) + } else if style.usesBlur { + // Alpha controls the tint while the native blur remains fully applied. + let tintAlpha = min(1, max(0, Defaults.footprintAlpha.cgFloat)) + boxView.fillColor = color.withAlphaComponent(tintAlpha) + } else { + boxView.fillColor = color + } + updateShadow() + } + + private func updateShadow() { + guard presentation.usesBlur, super.isVisible, !frame.isEmpty else { + shadowWindow?.orderOut(nil) + return + } + let shadow = shadowWindow ?? FootprintShadowWindow() + shadowWindow = shadow + let isDark = (contentView?.effectiveAppearance ?? effectiveAppearance) + .bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + shadow.update(around: frame, cornerRadius: boxView.cornerRadius, isDark: isDark) + shadow.alphaValue = alphaValue + if shadow.parent != self { + addChildWindow(shadow, ordered: .below) + } + if !shadow.isVisible { + shadow.order(.below, relativeTo: windowNumber) + } + } + + override func setFrame(_ frameRect: NSRect, display flag: Bool) { + super.setFrame(frameRect, display: flag) + updateShadow() + } + + override var alphaValue: CGFloat { + get { super.alphaValue } + set { + super.alphaValue = newValue + shadowWindow?.alphaValue = newValue + } + } + + private func hidePreview() { + shadowWindow?.orderOut(nil) + super.orderOut(nil) + } + + func refreshAccessibility() { + updateAppearance() + if !presentation.animates { frameAnimation?.finish() } + // System display changes take effect immediately, including a fade that + // was already running when Reduce Motion/Transparency was enabled. + fade = nil + alphaValue = showing ? presentation.alpha : 0 + if !showing { hidePreview() } + stopTimerIfIdle() + } + override var isVisible: Bool { - // Workaround for footprint getting pushed off of Stage Manager + // Workaround for footprint getting pushed off of Stage Manager. if StageUtil.stageCapable && StageUtil.stageEnabled && StageUtil.stageStripShow { return true } return realIsVisible } - - var realIsVisible: Bool { - if Defaults.footprintFade.userDisabled { - return super.isVisible - } else { - return alphaValue == Defaults.footprintAlpha.cgFloat + + var realIsVisible: Bool { showing && super.isVisible } + + func showPreview(in rect: CGRect, from origin: CGPoint?, duration: TimeInterval) { + frameAnimation?.cancel() + if !super.isVisible || alphaValue == 0 { + let initial = presentation.animates ? origin.map { CGRect(origin: $0, size: .zero) } : nil + setFrame(initial ?? rect, display: false) + } + orderFront(nil) + movePreview(to: rect, duration: duration) + } + + func movePreview(to rect: CGRect, duration: TimeInterval) { + frameAnimation?.cancel() + guard presentation.animates, duration > 0, frame != rect else { + setFrame(rect, display: true) + stopTimerIfIdle() + return } + frameAnimation = WindowFrameAnimation(from: frame, to: rect, startTime: clock(), duration: duration, + write: { [weak self] frame in + self?.setFrame(frame, display: true) + return true + }, cleanup: { [weak self] in + self?.frameAnimation = nil + }, completion: { [weak self] frame in + self?.setFrame(frame, display: true) + }) + startTimer() } - + override func orderFront(_ sender: Any?) { - if Defaults.footprintFade.userDisabled { + updateAppearance() + showing = true + if presentation.fades { super.orderFront(sender) + startFade(to: presentation.alpha, duration: 0.18) } else { - orderOutCanceled = true + fade = nil + alphaValue = presentation.alpha super.orderFront(sender) - animator().alphaValue = Defaults.footprintAlpha.cgFloat } + updateShadow() } - + override func orderOut(_ sender: Any?) { - if Defaults.footprintFade.userDisabled { - super.orderOut(nil) + showing = false + frameAnimation?.cancel() + if presentation.fades && super.isVisible { + startFade(to: 0, duration: 0.12) } else { - orderOutCanceled = false - NSAnimationContext.runAnimationGroup { changes in - animator().alphaValue = 0.0 - } completionHandler: { - if !self.orderOutCanceled { - super.orderOut(nil) - } + fade = nil + alphaValue = 0 + hidePreview() + stopTimerIfIdle() + } + } + + private func startFade(to alpha: CGFloat, duration: TimeInterval) { + guard alphaValue != alpha else { + fade = nil + if !showing { hidePreview() } + stopTimerIfIdle() + return + } + fade = Fade(from: alphaValue, to: alpha, start: clock(), duration: duration) + startTimer() + } + + private func startTimer() { + guard timer == nil else { return } + let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in + guard let self else { return } + self.advanceAnimations(at: self.clock()) + } + self.timer = timer + RunLoop.main.add(timer, forMode: .common) + } + + func advanceAnimations(at time: TimeInterval) { + frameAnimation?.tick(at: time) + if let fade { + let progress = min(1, max(0, (time - fade.start) / fade.duration)) + alphaValue = fade.from + (fade.to - fade.from) * WindowAnimationCurve.value(at: progress) + if progress >= 1 { + self.fade = nil + if !showing { hidePreview() } } } + stopTimerIfIdle() + } + + private func stopTimerIfIdle() { + if frameAnimation == nil && fade == nil { + timer?.invalidate() + timer = nil + } } } diff --git a/Rectangle/Snapping/SnappingManager.swift b/Rectangle/Snapping/SnappingManager.swift index ee20742d0..070f9b340 100644 --- a/Rectangle/Snapping/SnappingManager.swift +++ b/Rectangle/Snapping/SnappingManager.swift @@ -290,21 +290,9 @@ class SnappingManager { if box == nil { box = FootprintWindow() } - if Defaults.footprintAnimationDurationMultiplier.value > 0 { - if !box!.realIsVisible, let origin = getFootprintAnimationOrigin(snapArea, newBoxRect) { - let frame = CGRect(origin: origin, size: .zero) - box!.setFrame(frame, display: false) - } - } else { - box!.setFrame(newBoxRect, display: true) - } - box!.orderFront(nil) - if Defaults.footprintAnimationDurationMultiplier.value > 0 { - NSAnimationContext.runAnimationGroup { changes in - changes.duration = getFootprintAnimationDuration(box!, newBoxRect) - box!.animator().setFrame(newBoxRect, display: true) - } - } + box?.showPreview(in: newBoxRect, + from: getFootprintAnimationOrigin(snapArea, newBoxRect), + duration: getFootprintAnimationDuration()) } currentSnapArea = snapArea @@ -378,8 +366,10 @@ class SnappingManager { return AppDelegate.windowHistory.restoreRects[windowId] } - func getFootprintAnimationDuration(_ box: FootprintWindow, _ boxRect: CGRect) -> Double { - return box.animationResizeTime(boxRect) * Double(Defaults.footprintAnimationDurationMultiplier.value) + func getFootprintAnimationDuration() -> Double { + // The checkbox's standard multiplier uses the same duration as window + // snapping; retain the hidden preference as a proportional adjustment. + return WindowAnimationCurve.duration * Double(Defaults.footprintAnimationDurationMultiplier.value) / 0.75 } func getFootprintAnimationOrigin(_ snapArea: SnapArea, _ boxRect: CGRect) -> CGPoint? { From 06cd24b59df3a6125939de5480f4a9f78283a2a5 Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:35:07 -0700 Subject: [PATCH 03/11] Expose animation and blur options in Snap Areas Add the two opt-in controls and their translations, and move haptic feedback into the left column. --- Rectangle/Base.lproj/Main.storyboard | 54 +++++++++++++---- .../PrefsWindow/SnapAreaViewController.swift | 16 +++++ Rectangle/mul.lproj/Main.xcstrings | 60 +++++++++++++++++++ 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/Rectangle/Base.lproj/Main.storyboard b/Rectangle/Base.lproj/Main.storyboard index 068c7958f..97d4366be 100644 --- a/Rectangle/Base.lproj/Main.storyboard +++ b/Rectangle/Base.lproj/Main.storyboard @@ -3916,6 +3916,19 @@ DQ + - + + + @@ -4498,6 +4528,8 @@ DQ + + diff --git a/Rectangle/PrefsWindow/SnapAreaViewController.swift b/Rectangle/PrefsWindow/SnapAreaViewController.swift index a141db0cd..34aee861a 100644 --- a/Rectangle/PrefsWindow/SnapAreaViewController.swift +++ b/Rectangle/PrefsWindow/SnapAreaViewController.swift @@ -7,6 +7,8 @@ class SnapAreaViewController: NSViewController { @IBOutlet weak var windowSnappingCheckbox: NSButton! @IBOutlet weak var unsnapRestoreButton: NSButton! @IBOutlet weak var animateFootprintCheckbox: NSButton! + @IBOutlet weak var blurFootprintCheckbox: NSButton! + @IBOutlet weak var experimentalWindowAnimationsCheckbox: NSButton! @IBOutlet weak var hapticFeedbackCheckbox: NSButton! @IBOutlet weak var missionControlDraggingCheckbox: NSButton! @@ -48,6 +50,15 @@ class SnapAreaViewController: NSViewController { let newSetting: Float = sender.state == .on ? 0.75 : 0 Defaults.footprintAnimationDurationMultiplier.value = newSetting } + + @IBAction func toggleBlurFootprint(_ sender: NSButton) { + Defaults.footprintBlur.enabled = sender.state == .on + } + + @IBAction func toggleExperimentalWindowAnimations(_ sender: NSButton) { + Defaults.experimentalWindowAnimations.enabled = sender.state == .on + if sender.state == .off { WindowAnimator.shared.finish() } + } @IBAction func toggleHapticFeedback(_ sender: NSButton) { let newSetting: Bool = sender.state == .on @@ -84,6 +95,8 @@ class SnapAreaViewController: NSViewController { windowSnappingCheckbox.state = Defaults.windowSnapping.userDisabled ? .off : .on unsnapRestoreButton.state = Defaults.unsnapRestore.userDisabled ? .off : .on animateFootprintCheckbox.state = Defaults.footprintAnimationDurationMultiplier.value > 0 ? .on : .off + blurFootprintCheckbox.state = Defaults.footprintBlur.enabled ? .on : .off + experimentalWindowAnimationsCheckbox.state = Defaults.experimentalWindowAnimations.enabled ? .on : .off hapticFeedbackCheckbox.state = Defaults.hapticFeedbackOnSnap.userEnabled ? .on : .off missionControlDraggingCheckbox.state = Defaults.missionControlDragging.userDisabled ? .on : .off missionControlDraggingCheckbox.isHidden = !Defaults.missionControlDragging.userDisabled @@ -113,6 +126,9 @@ class SnapAreaViewController: NSViewController { // Only load the selects when the view appears, to fix a performance issue where switching to this tab was taking a long time to load var selectsLoaded = false override func viewWillAppear() { + blurFootprintCheckbox.state = Defaults.footprintBlur.enabled ? .on : .off + animateFootprintCheckbox.state = Defaults.footprintAnimationDurationMultiplier.value > 0 ? .on : .off + experimentalWindowAnimationsCheckbox.state = Defaults.experimentalWindowAnimations.enabled ? .on : .off if !selectsLoaded { loadSnapAreas() selectsLoaded = true diff --git a/Rectangle/mul.lproj/Main.xcstrings b/Rectangle/mul.lproj/Main.xcstrings index e216e874b..3a0463a8d 100644 --- a/Rectangle/mul.lproj/Main.xcstrings +++ b/Rectangle/mul.lproj/Main.xcstrings @@ -1,6 +1,66 @@ { "sourceLanguage" : "en", "strings" : { + "blur-preview-cell.title" : { + "comment" : "Class = \"NSButtonCell\"; title = \"Blur footprint\"; ObjectID = \"blur-preview-cell\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blur footprint" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模糊预览背景" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "模糊預覽背景" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プレビューの背景をぼかす" + } + } + } + }, + "ani-win-cell.title" : { + "comment" : "Class = \"NSButtonCell\"; title = \"Animate windows (experimental)\"; ObjectID = \"ani-win-cell\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animate windows (experimental)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "窗口动画(实验性)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "視窗動畫(實驗性)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウインドウをアニメーション表示(実験的)" + } + } + } + }, "0Ak-33-SM7.title" : { "comment" : "Class = \"NSTextFieldCell\"; title = \"Top Right\"; ObjectID = \"0Ak-33-SM7\";", "extractionState" : "extracted_with_value", From dd24d019bb7d11306b61696726233473a17d3715 Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:35:07 -0700 Subject: [PATCH 04/11] Update permission guidance for macOS 27 Use Device Control and Data Access on macOS 27 and retain the earlier permission wording on older releases. --- .../AccessibilityWindowController.swift | 12 ++++++-- Rectangle/mul.lproj/Main.xcstrings | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Rectangle/AccessibilityAuthorization/AccessibilityWindowController.swift b/Rectangle/AccessibilityAuthorization/AccessibilityWindowController.swift index 56fa1a25d..d90291509 100644 --- a/Rectangle/AccessibilityAuthorization/AccessibilityWindowController.swift +++ b/Rectangle/AccessibilityAuthorization/AccessibilityWindowController.swift @@ -24,13 +24,21 @@ class AccessibilityViewController: NSViewController { @IBOutlet weak var padlockField: NSTextField! override func viewDidLoad() { - if #available(OSX 13, *) { - sysPrefsPathField.stringValue = NSLocalizedString( + super.viewDidLoad() + if #available(macOS 27, *) { + sysPrefsPathField.stringValue = NSLocalizedString( + "Go to System Settings → Privacy & Security → Device Control and Data Access", tableName: "Main", value: "", comment: "") + } else if #available(macOS 13, *) { + sysPrefsPathField.stringValue = NSLocalizedString( "Go to System Settings → Privacy & Security → Accessibility", tableName: "Main", value: "", comment: "") + } + if #available(macOS 13, *) { openSysPrefsButton.title = NSLocalizedString( "Open System Settings", tableName: "Main", value: "", comment: "") padlockField.isHidden = true } + sysPrefsPathField.preferredMaxLayoutWidth = 250 + sysPrefsPathField.maximumNumberOfLines = 0 } @IBAction func openSystemPrefs(_ sender: Any) { diff --git a/Rectangle/mul.lproj/Main.xcstrings b/Rectangle/mul.lproj/Main.xcstrings index 3a0463a8d..1653a1a52 100644 --- a/Rectangle/mul.lproj/Main.xcstrings +++ b/Rectangle/mul.lproj/Main.xcstrings @@ -20041,6 +20041,35 @@ } } }, + "Go to System Settings → Privacy & Security → Device Control and Data Access" : { + "comment" : "macOS 27 and later permission pane name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to System Settings → Privacy & Security → Device Control and Data Access" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往 系统设置 → 隐私与安全性 → 设备控制和数据访问" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "前往 系統設定 → 隱私權與安全性 → 裝置控制和資料取用" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "システム設定→プライバシーとセキュリティ→デバイスの制御とデータへのアクセスを選択します" + } + } + } + }, "Go to System Settings → Privacy & Security → Accessibility" : { "localizations" : { "ar" : { From de6256cc44f88b03e7d9fd8a0a9f7c194ab541c4 Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:35:07 -0700 Subject: [PATCH 05/11] Document experimental animation and preview preferences --- README.md | 2 ++ TerminalCommands.md | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 31a1847a5..766499131 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Drag a window to the edge of the screen. When the mouse cursor reaches the edge | Bottom left, center, or right third | Respective third | | Bottom left or right third, then drag to bottom center | First or last two thirds, respectively | +In **Snap Areas**, enable **Animate windows (experimental)** for smooth window resizing or **Blur footprint** for a blurred snap preview. Both are off by default. Window animation has been tested in known scenarios, but remains experimental because repeatedly updating the window's size and position during the transition may cause issues in some edge cases. + ### Ignore an app Ignoring an app means that when the app is frontmost, keyboard shortcuts are un-registered from macOS. When the app is no longer frontmost, keyboard shortcuts are re-registered with macOS. This is useful for apps that have the same shortcuts like Rectangle and you do not want to change them. diff --git a/TerminalCommands.md b/TerminalCommands.md index 1d75831d1..ce859eaef 100644 --- a/TerminalCommands.md +++ b/TerminalCommands.md @@ -305,13 +305,13 @@ defaults write com.knollsoft.Rectangle cascadeActiveApp -dict-add keyCode -float ## Modify the "footprint" displayed for drag to snap area -Adjust the alpha (transparency). Default is 0.3. +Adjust the alpha (transparency). Default is 0.3, or 0 for the blurred preview, where it controls tint opacity. ```bash defaults write com.knollsoft.Rectangle footprintAlpha -float ``` -Change the border width. Default is 2 (used to be 1). +Change the border width. Default is 2 (used to be 1), or 1 for the blurred preview. A custom value overrides either default. ```bash defaults write com.knollsoft.Rectangle footprintBorderWidth -float @@ -323,13 +323,13 @@ Disable the fade. defaults write com.knollsoft.Rectangle footprintFade -int 2 ``` -Change the color. +Change the color. With blur enabled, this sets the tint color. Delete `footprintColor` to restore the automatic light/dark tint color. ```bash defaults write com.knollsoft.Rectangle footprintColor -string "{\"red\":0,\"blue\":0.5,\"green\":0.5}" ``` -Change the animation duration. The value is a multiplier. Default is 0 (no animation). +Change the animation duration. The value is a multiplier. Default is 0 (no movement animation). ```bash defaults write com.knollsoft.Rectangle footprintAnimationDurationMultiplier -float From 88617d7bb8299906ec1b3e0c0131c3e63b42e846 Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:54:23 -0700 Subject: [PATCH 06/11] Preserve title-bar animations on mouse release Only finish drag restoration on mouse-up when a window was actually being dragged. This prevents the drag monitor from prematurely settling a title-bar maximize or restore animation started by the same release event. Document that title-bar double-click uses the existing snap animation setting. Release build passed; live event ordering has not been reproduced. --- README.md | 2 +- Rectangle/Snapping/SnappingManager.swift | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 766499131..dae6b53c9 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Drag a window to the edge of the screen. When the mouse cursor reaches the edge | Bottom left, center, or right third | Respective third | | Bottom left or right third, then drag to bottom center | First or last two thirds, respectively | -In **Snap Areas**, enable **Animate windows (experimental)** for smooth window resizing or **Blur footprint** for a blurred snap preview. Both are off by default. Window animation has been tested in known scenarios, but remains experimental because repeatedly updating the window's size and position during the transition may cause issues in some edge cases. +In **Snap Areas**, enable **Animate windows (experimental)** for smooth window resizing or **Blur footprint** for a blurred snap preview. Both are off by default. Title-bar double-click maximize/restore follows the same animation setting and timing as snapping. Window animation has been tested in known scenarios, but remains experimental because repeatedly updating the window's size and position during the transition may cause issues in some edge cases. ### Ignore an app diff --git a/Rectangle/Snapping/SnappingManager.swift b/Rectangle/Snapping/SnappingManager.swift index 070f9b340..0a2649044 100644 --- a/Rectangle/Snapping/SnappingManager.swift +++ b/Rectangle/Snapping/SnappingManager.swift @@ -203,7 +203,10 @@ class SnappingManager { initialWindowRect = windowElement?.frame } case .leftMouseUp: - WindowAnimator.shared.finish() + // Only a drag release should settle drag restoration. A title-bar + // double-click can start maximize/restore on this same mouse-up; + // another event monitor must not immediately finish that animation. + if windowMoving { WindowAnimator.shared.finish() } if let currentSnapArea = self.currentSnapArea { box?.orderOut(nil) currentSnapArea.action.postSnap(windowElement: windowElement, windowId: windowId, screen: currentSnapArea.screen) From 7b2ddb9261034a5df0764a46ac56d1de60e1852a Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:30:47 -0700 Subject: [PATCH 07/11] Use DoubleDefault for footprint alpha --- Rectangle/Defaults.swift | 31 ++------- Rectangle/PrefsWindow/Config.swift | 1 + Rectangle/Snapping/FootprintWindow.swift | 4 +- RectangleTests/RectangleTests.swift | 89 ++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 26 deletions(-) diff --git a/Rectangle/Defaults.swift b/Rectangle/Defaults.swift index 8f676ac40..b52911a30 100644 --- a/Rectangle/Defaults.swift +++ b/Rectangle/Defaults.swift @@ -52,7 +52,13 @@ class Defaults { static let showAllActionsInMenu = OptionalBoolDefault(key: "showAllActionsInMenu") static let showAdditionalSizesInMenu = OptionalBoolDefault(key: "showAdditionalSizesInMenu") static var SUHasLaunchedBefore: Bool { UserDefaults.standard.bool(forKey: "SUHasLaunchedBefore") } - static let footprintAlpha = FootprintAlphaDefault() + static let footprintAlpha = DoubleDefault(key: "footprintAlpha") + static var effectiveFootprintAlpha: Double { + if UserDefaults.standard.object(forKey: footprintAlpha.key) == nil { + return footprintBlur.enabled ? 0 : 0.3 + } + return footprintAlpha.value + } static let footprintBorderWidth = FloatDefault(key: "footprintBorderWidth", defaultValue: 2) static let footprintFade = OptionalBoolDefault(key: "footprintFade") static let footprintColor = JSONDefault(key: "footprintColor") @@ -345,29 +351,6 @@ class StringDefault: Default { } } -class FootprintAlphaDefault: Default { - let key = "footprintAlpha" - - var value: Float { - get { - // Preserve an explicit zero and resolve the unset default by style. - (UserDefaults.standard.object(forKey: key) as? NSNumber)?.floatValue - ?? (Defaults.footprintBlur.enabled ? 0 : 0.3) - } - set { UserDefaults.standard.set(newValue, forKey: key) } - } - - var cgFloat: CGFloat { CGFloat(value) } - - func load(from codable: CodableDefault) { - if let float = codable.float { value = float } - } - - func toCodable() -> CodableDefault { - CodableDefault(float: value) - } -} - class FloatDefault: Default { public private(set) var key: String private var initialized = false diff --git a/Rectangle/PrefsWindow/Config.swift b/Rectangle/PrefsWindow/Config.swift index f02f1de2a..fc31f9ff5 100644 --- a/Rectangle/PrefsWindow/Config.swift +++ b/Rectangle/PrefsWindow/Config.swift @@ -28,6 +28,7 @@ extension Defaults { for exportableDefault in Defaults.array { codableDefaults[exportableDefault.key] = exportableDefault.toCodable() } + codableDefaults[footprintAlpha.key] = CodableDefault(double: effectiveFootprintAlpha) let config = Config(bundleId: "com.knollsoft.Rectangle", version: version, diff --git a/Rectangle/Snapping/FootprintWindow.swift b/Rectangle/Snapping/FootprintWindow.swift index 6d454f150..983d6153e 100644 --- a/Rectangle/Snapping/FootprintWindow.swift +++ b/Rectangle/Snapping/FootprintWindow.swift @@ -125,7 +125,7 @@ class FootprintWindow: NSWindow { var presentation: FootprintPresentation { FootprintPresentation(blurRequested: Defaults.footprintBlur.enabled, - alpha: Defaults.footprintAlpha.cgFloat, + alpha: CGFloat(Defaults.effectiveFootprintAlpha), fadeRequested: !Defaults.footprintFade.userDisabled, animationRequested: Defaults.footprintAnimationDurationMultiplier.value > 0, accessibility: accessibility()) @@ -261,7 +261,7 @@ class FootprintWindow: NSWindow { boxView.fillColor = color.withAlphaComponent(1) } else if style.usesBlur { // Alpha controls the tint while the native blur remains fully applied. - let tintAlpha = min(1, max(0, Defaults.footprintAlpha.cgFloat)) + let tintAlpha = min(1, max(0, CGFloat(Defaults.effectiveFootprintAlpha))) boxView.fillColor = color.withAlphaComponent(tintAlpha) } else { boxView.fillColor = color diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 5f40c1801..67eda9b31 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -14,6 +14,95 @@ class RectangleTests: XCTestCase { } } +final class FootprintAlphaDefaultsTests: XCTestCase { + private var savedAlpha: Double = 0 + private var savedBlur = false + private var storedAlpha: Any? + private var storedBlur: Any? + + override func setUp() { + super.setUp() + savedAlpha = Defaults.footprintAlpha.value + savedBlur = Defaults.footprintBlur.enabled + storedAlpha = UserDefaults.standard.object(forKey: Defaults.footprintAlpha.key) + storedBlur = UserDefaults.standard.object(forKey: Defaults.footprintBlur.key) + UserDefaults.standard.removeObject(forKey: Defaults.footprintAlpha.key) + } + + override func tearDown() { + Defaults.footprintAlpha.value = savedAlpha + Defaults.footprintBlur.enabled = savedBlur + UserDefaults.standard.set(storedAlpha, forKey: Defaults.footprintAlpha.key) + UserDefaults.standard.set(storedBlur, forKey: Defaults.footprintBlur.key) + super.tearDown() + } + + func testUnsetAlphaFollowsPreviewStyleWithoutSavingAValue() { + let window = FootprintWindow(accessibility: { + FootprintAccessibility(reduceMotion: false, reduceTransparency: false) + }) + defer { window.close() } + + for blurred in [false, true, false] { + Defaults.footprintBlur.enabled = blurred + XCTAssertEqual(Defaults.effectiveFootprintAlpha, blurred ? 0 : 0.3) + XCTAssertEqual(window.presentation.alpha, blurred ? 1 : 0.3) + XCTAssertEqual(window.presentation.usesBlur, blurred) + XCTAssertNil(UserDefaults.standard.object(forKey: Defaults.footprintAlpha.key)) + } + } + + func testExplicitZeroSurvivesStyleChangesReloadAndConfigRoundTrip() throws { + Defaults.footprintAlpha.value = 0 + for blurred in [false, true] { + Defaults.footprintBlur.enabled = blurred + XCTAssertEqual(Defaults.effectiveFootprintAlpha, 0) + XCTAssertEqual(DoubleDefault(key: Defaults.footprintAlpha.key).value, 0) + + let exported = try exportedAlpha() + XCTAssertEqual(exported.double, 0) + XCTAssertNil(exported.float) + Defaults.footprintAlpha.value = 0.8 + Defaults.footprintAlpha.load(from: exported) + XCTAssertEqual(Defaults.effectiveFootprintAlpha, 0) + } + } + + func testLegacyFloatValuesAndDoublePrecisionArePreserved() throws { + for json in [#"{"float":0}"#, #"{"float":0.4}"#, #"{"double":0.123456789012345}"#] { + let imported = try JSONDecoder().decode(CodableDefault.self, from: Data(json.utf8)) + let expected = imported.double ?? Double(imported.float!) + Defaults.footprintAlpha.load(from: imported) + for blurred in [false, true] { + Defaults.footprintBlur.enabled = blurred + XCTAssertEqual(Defaults.effectiveFootprintAlpha, expected) + XCTAssertEqual(DoubleDefault(key: Defaults.footprintAlpha.key).value, expected) + XCTAssertEqual(try exportedAlpha().double, expected) + } + } + } + + func testExportUsesTheEffectiveUnsetAlpha() throws { + for blurred in [false, true] { + UserDefaults.standard.removeObject(forKey: Defaults.footprintAlpha.key) + Defaults.footprintBlur.enabled = blurred + let exported = try exportedAlpha() + XCTAssertEqual(exported.double, blurred ? 0 : 0.3) + XCTAssertNil(UserDefaults.standard.object(forKey: Defaults.footprintAlpha.key)) + + Defaults.footprintAlpha.value = 0.8 + Defaults.footprintAlpha.load(from: exported) + XCTAssertEqual(Defaults.effectiveFootprintAlpha, blurred ? 0 : 0.3) + } + } + + private func exportedAlpha() throws -> CodableDefault { + let json = try XCTUnwrap(Defaults.encoded()) + let config = try XCTUnwrap(Defaults.convert(jsonString: json)) + return try XCTUnwrap(config.defaults[Defaults.footprintAlpha.key]) + } +} + class PositionCyclesTests: XCTestCase { func testSixthsReturnTrue() { From 8fecc706bda76eeaff604a99b3f0971393ea29ea Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:34:31 -0700 Subject: [PATCH 08/11] Trim animation and preview comments --- Rectangle/AccessibilityElement.swift | 11 ++++------- Rectangle/Snapping/FootprintWindow.swift | 20 ++++++-------------- Rectangle/Snapping/SnappingManager.swift | 15 +++++---------- Rectangle/WindowManager.swift | 9 ++------- Rectangle/WindowMover/WindowAnimator.swift | 21 ++++++--------------- 5 files changed, 23 insertions(+), 53 deletions(-) diff --git a/Rectangle/AccessibilityElement.swift b/Rectangle/AccessibilityElement.swift index 86057178e..3e7852551 100644 --- a/Rectangle/AccessibilityElement.swift +++ b/Rectangle/AccessibilityElement.swift @@ -156,8 +156,7 @@ class AccessibilityElement { ) } - /// Keep the existing Enhanced UI policy active for the whole transition, - /// instead of toggling application accessibility on every timer tick. + /// Holds the Enhanced UI policy for the transition; returns its cleanup closure. func beginAnimatedAdjustment() -> () -> Void { let appElement = applicationElement let restore = Defaults.enhancedUI.value.beginWindowAdjustment( @@ -167,7 +166,7 @@ class AccessibilityElement { readEnhancedUI: { appElement?.enhancedUserInterface }, writeEnhancedUI: { appElement?.enhancedUserInterface = $0 } ) - // Avoid a long stream of blocking requests to an unresponsive app. + // Bound AX calls so an unresponsive app cannot stall the animation. setMessagingTimeout(0.05) return { [self] in setMessagingTimeout(0) @@ -175,16 +174,14 @@ class AccessibilityElement { } } - /// No per-frame readbacks or logging. The normal mover checks the achieved - /// geometry once the transition ends and applies any necessary corrections. + /// Writes one frame without readback; completion handles the final placement. func setAnimationFrame(_ frame: CGRect, resizeOnly: Bool = false) -> Bool { var size = frame.size var position = frame.origin guard let sizeValue = AXValueCreate(.cgSize, &size), let positionValue = AXValueCreate(.cgPoint, &position) else { return false } guard AXUIElementSetAttributeValue(wrappedElement, kAXSizeAttribute as CFString, sizeValue) == .success else { return false } - // During a native drag, even a correct position can already be stale - // by the time resizing finishes. Avoid competing position writes. + // Native dragging owns position during size restoration. if !resizeOnly { guard AXUIElementSetAttributeValue(wrappedElement, kAXPositionAttribute as CFString, positionValue) == .success else { return false } } diff --git a/Rectangle/Snapping/FootprintWindow.swift b/Rectangle/Snapping/FootprintWindow.swift index 983d6153e..9e6a79010 100644 --- a/Rectangle/Snapping/FootprintWindow.swift +++ b/Rectangle/Snapping/FootprintWindow.swift @@ -21,8 +21,7 @@ struct FootprintPresentation { init(blurRequested: Bool, alpha: CGFloat, fadeRequested: Bool, animationRequested: Bool, accessibility: FootprintAccessibility) { usesBlur = blurRequested && !accessibility.reduceTransparency - // A visual-effect view must remain at full window opacity for AppKit to - // composite its material correctly. The configured alpha tints its fill. + // AppKit blur needs full window opacity; configured alpha controls its tint. self.alpha = usesBlur || accessibility.reduceTransparency ? 1 : min(1, max(0, alpha)) fades = fadeRequested && !accessibility.reduceMotion && !accessibility.reduceTransparency animates = animationRequested && !accessibility.reduceMotion @@ -75,8 +74,7 @@ private final class FootprintShadowWindow: NSWindow { } override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { - // Shadow padding must extend offscreen without shifting the cutout - // away from the preview when it reaches the top edge. + // Allow shadow padding beyond screen edges without shifting the preview. frameRect } @@ -96,7 +94,7 @@ private final class FootprintShadowWindow: NSWindow { shadowLayer.shadowPath = outline shadowLayer.shadowOpacity = isDark ? 0.65 : 0.42 cutoutLayer.frame = bounds - // Remove the entire panel from the shadow, even behind transparent blur. + // Exclude the transparent preview interior from the shadow. cutoutLayer.path = cutout CATransaction.commit() } @@ -169,7 +167,6 @@ class FootprintWindow: NSWindow { container.layer?.cornerRadius = radius container.layer?.masksToBounds = true effectView.material = .fullScreenUI - // Inherit the system appearance so AppKit selects its light/dark material. effectView.blendingMode = .behindWindow effectView.state = .active effectView.autoresizingMask = [.width, .height] @@ -179,8 +176,7 @@ class FootprintWindow: NSWindow { boxView.wantsLayer = true boxView.autoresizingMask = [.width, .height] container.addSubview(boxView) - // Keep the authored preview layers in SDR. NSVisualEffectView supplies - // blur without Liquid Glass. + // Keep custom preview layers in SDR. for view in [container, effectView, boxView] { view.wantsLayer = true view.layer?.contentsFormat = .RGBA8Uint @@ -230,8 +226,7 @@ class FootprintWindow: NSWindow { contentView?.layer?.cornerRadius = radius boxView.cornerRadius = radius if style.usesBlur, blurMaskRadius != radius { - // Mask the material itself as well as the tint, so the compositor - // cannot leave bright material outside the rounded surface. + // Clip the material itself to prevent bright corners outside the tint mask. let mask = NSImage(size: NSSize(width: radius * 2 + 1, height: radius * 2 + 1), flipped: false) { rect in NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1).setFill() NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() @@ -248,7 +243,6 @@ class FootprintWindow: NSWindow { ? NSColor(srgbRed: outlineGray, green: outlineGray, blue: outlineGray, alpha: 0.35) : .lightGray boxView.borderWidth = CGFloat(Defaults.footprintBorderWidth.value) - // Keep the native material fully applied. effectView.alphaValue = 1 if style.usesBlur { boxView.borderWidth = UserDefaults.standard.object(forKey: Defaults.footprintBorderWidth.key) == nil @@ -260,7 +254,6 @@ class FootprintWindow: NSWindow { if accessibility().reduceTransparency { boxView.fillColor = color.withAlphaComponent(1) } else if style.usesBlur { - // Alpha controls the tint while the native blur remains fully applied. let tintAlpha = min(1, max(0, CGFloat(Defaults.effectiveFootprintAlpha))) boxView.fillColor = color.withAlphaComponent(tintAlpha) } else { @@ -309,8 +302,7 @@ class FootprintWindow: NSWindow { func refreshAccessibility() { updateAppearance() if !presentation.animates { frameAnimation?.finish() } - // System display changes take effect immediately, including a fade that - // was already running when Reduce Motion/Transparency was enabled. + // Apply accessibility changes immediately, including during an active fade. fade = nil alphaValue = showing ? presentation.alpha : 0 if !showing { hidePreview() } diff --git a/Rectangle/Snapping/SnappingManager.swift b/Rectangle/Snapping/SnappingManager.swift index 0a2649044..b09b31c63 100644 --- a/Rectangle/Snapping/SnappingManager.swift +++ b/Rectangle/Snapping/SnappingManager.swift @@ -195,7 +195,6 @@ class SnappingManager { func handle(event: NSEvent) { switch event.type { case .leftMouseDown: - // A manual grab owns the window from this point onward. WindowAnimator.shared.finish() if !Defaults.obtainWindowOnClick.userDisabled { windowElement = AccessibilityElement.getWindowElementUnderCursor() @@ -203,9 +202,8 @@ class SnappingManager { initialWindowRect = windowElement?.frame } case .leftMouseUp: - // Only a drag release should settle drag restoration. A title-bar - // double-click can start maximize/restore on this same mouse-up; - // another event monitor must not immediately finish that animation. + // A title-bar double-click can start an animation on this same mouse-up. + // Finish only drag restoration here. if windowMoving { WindowAnimator.shared.finish() } if let currentSnapArea = self.currentSnapArea { box?.orderOut(nil) @@ -330,14 +328,12 @@ class SnappingManager { } } } - // Let native dragging own position whenever restoring the - // width does not require moving the window under the cursor. + // Preserve native drag positioning unless restoration requires a new origin. let resizeOnly = WindowAnimator.enabled && newRect.origin == currentRect.origin var cursorOffset = CGPoint.zero let initialCursor = NSEvent.mouseLocation.screenFlipped WindowAnimator.shared.animate(windowElement, to: newRect, duration: 0.18, resizeOnly: resizeOnly, offset: { - // Follow the drag during restoration, but do not follow - // unrelated cursor movement after the button is released. + // Freeze the drag offset when the mouse button is released. if NSEvent.pressedMouseButtons & 1 != 0 { let cursor = NSEvent.mouseLocation.screenFlipped cursorOffset = CGPoint(x: cursor.x - initialCursor.x, y: cursor.y - initialCursor.y) @@ -370,8 +366,7 @@ class SnappingManager { } func getFootprintAnimationDuration() -> Double { - // The checkbox's standard multiplier uses the same duration as window - // snapping; retain the hidden preference as a proportional adjustment. + // The checkbox uses 0.75; normalize it to the window animation duration. return WindowAnimationCurve.duration * Double(Defaults.footprintAnimationDurationMultiplier.value) / 0.75 } diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index fcfb2b7d6..c337c98cc 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -102,8 +102,6 @@ class WindowManager { return } - // Use the pending destination for shortcut cycling and restore history; - // the next transition still starts from the actual on-screen frame. let currentWindowRect = WindowAnimator.shared.destination(for: frontmostWindowElement) ?? frontmostWindowElement.frame @@ -213,9 +211,7 @@ class WindowManager { source: parameters.source, isFixedSize: isFixedSize) - // The initial experiment only interpolates single-window, same-display - // resizes. Cooperative changes and display transfers retain their settling - // order, and non-resizable windows retain the fixed-size mover chain. + // Cross-display and cooperative moves need the normal settling sequence. let animated = WindowAnimator.enabled && !isFixedSize && !isMovedAcrossDisplays && !Defaults.cooperativeCornerResize.enabled let completeMove = { [self] in @@ -268,8 +264,7 @@ class WindowManager { postProcess(result: resultParameters, resultingRect: resultingRect, incrementCount: !animated) } if animated { - // Record the logical destination now so a repeated shortcut can cycle - // without treating an intermediate animation frame as a manual move. + // Record the destination before animation for repeated-shortcut cycling. recordAction(windowId: windowId, resultingRect: calcResult.rect.screenFlipped, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) WindowAnimator.shared.animate(frontmostWindowElement, to: calcResult.rect.screenFlipped) { _ in diff --git a/Rectangle/WindowMover/WindowAnimator.swift b/Rectangle/WindowMover/WindowAnimator.swift index 7504c29a9..5b255777f 100644 --- a/Rectangle/WindowMover/WindowAnimator.swift +++ b/Rectangle/WindowMover/WindowAnimator.swift @@ -7,24 +7,18 @@ enum WindowAnimationCurve { static func unsnapValue(at progress: Double) -> CGFloat { let t = min(1, max(0, progress)) - // Spread drag restoration evenly instead of concentrating the resize - // near the start. Velocity and acceleration are zero at both ends. + // Quintic smoothstep: zero velocity and acceleration at both endpoints. return CGFloat(t * t * t * (10 + t * (-15 + 6 * t))) } static func value(at progress: Double) -> CGFloat { let progress = min(1, max(0, progress)) - // Integrate a positive velocity profile proportional to t * (1-t)^5. - // Movement gathers pace early, then settles gently without overshoot. - // Both endpoints have zero velocity, so the final frame never cuts off - // a moving spring; the same curve also drives the preview and its fade. + // Integral of t * (1 - t)^5, normalized to [0, 1]. return CGFloat(1 - pow(1 - progress, 6) * (1 + 6 * progress)) } } -/// A time-based transition. Missed timer ticks are skipped, never queued up. -/// Window I/O and the clock are supplied separately so cancellation and failures -/// can be tested without moving a user's windows. +/// Advances by elapsed time, skipping missed frames. final class WindowFrameAnimation { let destination: CGRect private let origin: CGRect @@ -68,8 +62,7 @@ final class WindowFrameAnimation { width: origin.width + (destination.width - origin.width) * eased, height: origin.height + (destination.height - origin.height) * eased) if !write(frame) { - // A refused AX write ends interpolation; the ordinary mover settles - // the destination using the application's existing size constraints. + // Let the normal mover settle the destination after a refused AX write. finish() } } @@ -89,8 +82,7 @@ final class WindowFrameAnimation { } } -/// Main-run-loop ownership serializes AX adjustments and history updates. Only -/// one window animates at a time, including when two windows belong to one app. +/// Coordinates one window animation at a time on the main run loop. final class WindowAnimator { static let shared = WindowAnimator() private var window: AccessibilityElement? @@ -168,8 +160,7 @@ final class WindowAnimator { } } self.timer = timer - // Keyboard animations must also yield to a manual grab when drag-to-snap - // is disabled and SnappingManager is not listening for mouse events. + // Manual grabs must interrupt animation even when drag-to-snap is disabled. mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { [weak self] _ in self?.finish() } From b8cb073bf938c0104193ed545ea5e5f0458ab670 Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:11:33 -0700 Subject: [PATCH 09/11] Refine minimum window size warning HUD --- README.md | 2 +- Rectangle/WindowSizeWarning.swift | 73 ++++++++++++++++++++++++++---- Rectangle/mul.lproj/Main.xcstrings | 4 +- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index dae6b53c9..4a59678dd 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Apple never released a public API for doing this. Rectangle Pro has next/prev Sp ### Windows overlap when using thirds or other small layouts -Some apps enforce a minimum window size that is larger than the requested layout. For example, a window with a minimum width of 600 points cannot fit a 504-point third of a display. Rectangle keeps the window on screen and briefly shows a “Window size limited” message when the app leaves it larger than the requested size. Use a larger layout, such as halves, or reduce the adjacent window manually. Rectangle cannot override an app's minimum window size. +Some apps enforce a minimum window size that is larger than the requested layout. For example, a window with a minimum width of 600 points cannot fit a 504-point third of a display. Rectangle keeps the window on screen and briefly shows a “Minimum window size reached” HUD with a window icon when the app leaves it larger than the requested size. Use a larger layout, such as halves, or reduce the adjacent window manually. Rectangle cannot override an app's minimum window size. ### Window resizing is off slightly for iTerm2 diff --git a/Rectangle/WindowSizeWarning.swift b/Rectangle/WindowSizeWarning.swift index ccad9f400..90e2952c7 100644 --- a/Rectangle/WindowSizeWarning.swift +++ b/Rectangle/WindowSizeWarning.swift @@ -21,6 +21,7 @@ enum WindowSizeConstraint { } final class WindowSizeWarning: NSPanel { + private static let padding: CGFloat = 24 private var dismissal: DispatchWorkItem? private var labels: [NSTextField] = [] @@ -46,19 +47,37 @@ final class WindowSizeWarning: NSPanel { container.blendingMode = .behindWindow container.state = .active container.wantsLayer = true - container.layer?.cornerRadius = 10 + let radius: CGFloat = 20 + container.layer?.cornerRadius = radius container.layer?.masksToBounds = true + // Mask the material itself so its blur does not bleed outside the rounded corners. + let mask = NSImage(size: NSSize(width: radius * 2 + 1, height: radius * 2 + 1), flipped: false) { rect in + NSColor.white.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + return true + } + mask.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius) + mask.resizingMode = .stretch + container.maskImage = mask + + let icon = WindowSizeWarningIcon() + icon.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(icon) + let title = NSTextField(wrappingLabelWithString: NSLocalizedString( "windowSizeWarningTitle", tableName: "Main", value: "Minimum window size reached", comment: "Title of the on-screen message when a window cannot fit its requested size")) - title.font = .boldSystemFont(ofSize: NSFont.systemFontSize) + title.font = .systemFont(ofSize: 21, weight: .semibold) + title.alignment = .center let message = NSTextField(wrappingLabelWithString: NSLocalizedString( "windowSizeWarningMessage", tableName: "Main", - value: "Unable to resize window smaller. Windows may overlap.", + value: "Unable to resize window smaller.\nWindows may overlap.", comment: "Explains that an app can prevent a window from shrinking to the requested layout")) message.font = .systemFont(ofSize: NSFont.systemFontSize) + message.textColor = .secondaryLabelColor + message.alignment = .center labels = [title, message] for label in labels { @@ -66,13 +85,17 @@ final class WindowSizeWarning: NSPanel { container.addSubview(label) } NSLayoutConstraint.activate([ - title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), - title.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18), - title.topAnchor.constraint(equalTo: container.topAnchor, constant: 14), + icon.centerXAnchor.constraint(equalTo: container.centerXAnchor), + icon.topAnchor.constraint(equalTo: container.topAnchor, constant: Self.padding), + icon.widthAnchor.constraint(equalToConstant: 48), + icon.heightAnchor.constraint(equalToConstant: 36), + title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Self.padding), + title.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Self.padding), + title.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 17), message.leadingAnchor.constraint(equalTo: title.leadingAnchor), message.trailingAnchor.constraint(equalTo: title.trailingAnchor), - message.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6), - message.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14) + message.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 10), + message.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Self.padding) ]) contentView = container } @@ -82,10 +105,10 @@ final class WindowSizeWarning: NSPanel { guard let contentView else { return } let visibleFrame = screen.visibleFrame let width = min(360, visibleFrame.width - 32) - guard width > 36, visibleFrame.height > 0 else { return } + guard width > Self.padding * 2, visibleFrame.height > 0 else { return } // Fix the text width before measuring so localized messages can wrap. - labels.forEach { $0.preferredMaxLayoutWidth = width - 36 } + labels.forEach { $0.preferredMaxLayoutWidth = width - Self.padding * 2 } contentView.setFrameSize(NSSize(width: width, height: 0)) contentView.layoutSubtreeIfNeeded() let height = min(contentView.fittingSize.height, visibleFrame.height) @@ -109,3 +132,33 @@ final class WindowSizeWarning: NSPanel { dismissal?.cancel() } } + +private final class WindowSizeWarningIcon: NSView { + override func draw(_ dirtyRect: NSRect) { + NSColor.labelColor.withAlphaComponent(0.8).setStroke() + let outline = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 6, yRadius: 6) + outline.lineWidth = 2 + outline.stroke() + + let arrows = NSBezierPath() + arrows.lineWidth = 2.5 + arrows.lineCapStyle = .round + arrows.lineJoinStyle = .round + arrows.move(to: NSPoint(x: 14, y: 26)) + arrows.line(to: NSPoint(x: 22, y: 18)) + arrows.move(to: NSPoint(x: 16, y: 18)) + arrows.line(to: NSPoint(x: 22, y: 18)) + arrows.line(to: NSPoint(x: 22, y: 24)) + arrows.move(to: NSPoint(x: 34, y: 10)) + arrows.line(to: NSPoint(x: 26, y: 18)) + arrows.move(to: NSPoint(x: 26, y: 12)) + arrows.line(to: NSPoint(x: 26, y: 18)) + arrows.line(to: NSPoint(x: 32, y: 18)) + arrows.stroke() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } +} diff --git a/Rectangle/mul.lproj/Main.xcstrings b/Rectangle/mul.lproj/Main.xcstrings index 1653a1a52..8b0c1f7ba 100644 --- a/Rectangle/mul.lproj/Main.xcstrings +++ b/Rectangle/mul.lproj/Main.xcstrings @@ -46690,7 +46690,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unable to resize window smaller. Windows may overlap." + "value" : "Unable to resize window smaller.\nWindows may overlap." } } } @@ -50777,4 +50777,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} From 35754170fd51d69c7bffa82d1e0f10d36e610631 Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:53:57 -0700 Subject: [PATCH 10/11] Keep constrained windows aligned throughout snap animations --- README.md | 2 +- Rectangle/AccessibilityElement.swift | 17 +++ Rectangle/Snapping/FootprintWindow.swift | 2 +- Rectangle/WindowManager.swift | 17 ++- .../WindowMover/BestEffortWindowMover.swift | 45 +++---- Rectangle/WindowMover/WindowAnimator.swift | 60 +++++++-- RectangleTests/RectangleTests.swift | 118 ++++++++++++++++++ 7 files changed, 226 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 4a59678dd..865347ecd 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Drag a window to the edge of the screen. When the mouse cursor reaches the edge | Bottom left, center, or right third | Respective third | | Bottom left or right third, then drag to bottom center | First or last two thirds, respectively | -In **Snap Areas**, enable **Animate windows (experimental)** for smooth window resizing or **Blur footprint** for a blurred snap preview. Both are off by default. Title-bar double-click maximize/restore follows the same animation setting and timing as snapping. Window animation has been tested in known scenarios, but remains experimental because repeatedly updating the window's size and position during the transition may cause issues in some edge cases. +In **Snap Areas**, enable **Animate windows (experimental)** for smooth window resizing or **Blur footprint** for a blurred snap preview. Both are off by default. Animated snapping keeps the selected edge or center aligned when an app limits its window size or aspect ratio. Title-bar double-click maximize/restore follows the same animation setting and timing as snapping. Window animation has been tested in known scenarios, but remains experimental because repeatedly updating the window's size and position during the transition may cause issues in some edge cases. ### Ignore an app diff --git a/Rectangle/AccessibilityElement.swift b/Rectangle/AccessibilityElement.swift index 3e7852551..38c071e0a 100644 --- a/Rectangle/AccessibilityElement.swift +++ b/Rectangle/AccessibilityElement.swift @@ -187,6 +187,23 @@ class AccessibilityElement { } return true } + + func setConstrainedAnimationFrame(_ frame: CGRect, placement: WindowAnimationPlacement, + origin: CGRect, progress: CGFloat) -> CGRect? { + var requestedSize = frame.size + guard let sizeValue = AXValueCreate(.cgSize, &requestedSize), + AXUIElementSetAttributeValue(wrappedElement, kAXSizeAttribute as CFString, sizeValue) == .success, + let actualSize = size, + actualSize.width.isFinite, actualSize.height.isFinite, + actualSize.width > 0, actualSize.height > 0 else { return nil } + + // An accepted AX write can still be clamped by the app; position using the achieved size. + let resolved = placement.frame(for: frame, actualSize: actualSize, origin: origin, progress: progress) + var position = resolved.origin + guard let positionValue = AXValueCreate(.cgPoint, &position), + AXUIElementSetAttributeValue(wrappedElement, kAXPositionAttribute as CFString, positionValue) == .success else { return nil } + return resolved + } private var childElements: [AccessibilityElement]? { getElementsValue(.children) diff --git a/Rectangle/Snapping/FootprintWindow.swift b/Rectangle/Snapping/FootprintWindow.swift index 9e6a79010..b3e8bca7e 100644 --- a/Rectangle/Snapping/FootprintWindow.swift +++ b/Rectangle/Snapping/FootprintWindow.swift @@ -337,7 +337,7 @@ class FootprintWindow: NSWindow { return } frameAnimation = WindowFrameAnimation(from: frame, to: rect, startTime: clock(), duration: duration, - write: { [weak self] frame in + write: { [weak self] frame, _ in self?.setFrame(frame, display: true) return true }, cleanup: { [weak self] in diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index c337c98cc..964a4200e 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -214,11 +214,13 @@ class WindowManager { // Cross-display and cooperative moves need the normal settling sequence. let animated = WindowAnimator.enabled && !isFixedSize && !isMovedAcrossDisplays && !Defaults.cooperativeCornerResize.enabled - let completeMove = { [self] in + let completeMove = { [self] (animationHandledPlacement: Bool) in var resultingRect: CGRect if let cooperativeCornerPlan { resultingRect = applyCooperativeCornerResize(result: resultParameters, plan: cooperativeCornerPlan) + } else if animationHandledPlacement { + resultingRect = frontmostWindowElement.frame } else { resultingRect = apply(result: resultParameters) } @@ -267,11 +269,18 @@ class WindowManager { // Record the destination before animation for repeated-shortcut cycling. recordAction(windowId: windowId, resultingRect: calcResult.rect.screenFlipped, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) - WindowAnimator.shared.animate(frontmostWindowElement, to: calcResult.rect.screenFlipped) { _ in - completeMove() + let placement = WindowAnimationPlacement( + screenFrame: visibleFrameOfDestinationScreen.screenFlipped, + sharedEdges: action.resizes ? Defaults.moveFixedSizeToEdge.value.alignmentEdges( + for: calcResult.initialRect.screenFlipped, in: visibleFrameOfDestinationScreen.screenFlipped) : nil, + constrainToScreen: !(action.allowedToExtendOutsideCurrentScreenArea && !NSScreen.screensHaveSeparateSpaces), + gap: CGFloat(Defaults.gapSize.value)) + WindowAnimator.shared.animate(frontmostWindowElement, to: calcResult.rect.screenFlipped, + placement: placement) { frame in + completeMove(!frame.isNull) } } else { - completeMove() + completeMove(false) } } diff --git a/Rectangle/WindowMover/BestEffortWindowMover.swift b/Rectangle/WindowMover/BestEffortWindowMover.swift index ef917a010..d505e1c53 100644 --- a/Rectangle/WindowMover/BestEffortWindowMover.swift +++ b/Rectangle/WindowMover/BestEffortWindowMover.swift @@ -2,6 +2,26 @@ import Foundation +enum WindowFrameBounds { + static func constrained(_ frame: CGRect, to screenFrame: CGRect, gap: CGFloat) -> CGRect { + guard !frame.isNull, !frame.isInfinite, !screenFrame.isNull, !screenFrame.isInfinite else { return frame } + var result = frame + if result.minX < screenFrame.minX { + result.origin.x = screenFrame.minX + } else if result.maxX > screenFrame.maxX { + result.origin.x = screenFrame.maxX - result.width - gap + } + + // Window coordinates grow downwards; preserve the normal mover's bottom-first correction. + if result.maxY > screenFrame.maxY { + result.origin.y = screenFrame.maxY - result.height + } else if result.minY < screenFrame.minY { + result.origin.y = screenFrame.minY + gap + } + return result + } +} + /** * After a window has been moved and resized, if the window could not be resized small enough to fit the intended size, then some of the window may appear off the screen. The BestEffortWindowMover will move the window so that it fits entirely on the screen. */ @@ -15,28 +35,9 @@ class BestEffortWindowMover: WindowMover { if action.allowedToExtendOutsideCurrentScreenArea == true && !NSScreen.screensHaveSeparateSpaces { return } - var adjustedWindowRect: CGRect = currentWindowRect - - if adjustedWindowRect.minX < visibleFrameOfScreen.minX { - - adjustedWindowRect.origin.x = visibleFrameOfScreen.minX - - } else if adjustedWindowRect.minX + adjustedWindowRect.width > visibleFrameOfScreen.minX + visibleFrameOfScreen.width { - - adjustedWindowRect.origin.x = visibleFrameOfScreen.minX + visibleFrameOfScreen.width - (adjustedWindowRect.width) - CGFloat(Defaults.gapSize.value) - } - - adjustedWindowRect = adjustedWindowRect.screenFlipped - if adjustedWindowRect.minY < visibleFrameOfScreen.minY { - - adjustedWindowRect.origin.y = visibleFrameOfScreen.minY - - } else if adjustedWindowRect.minY + adjustedWindowRect.height > visibleFrameOfScreen.minY + visibleFrameOfScreen.height { - - adjustedWindowRect.origin.y = visibleFrameOfScreen.minY + visibleFrameOfScreen.height - (adjustedWindowRect.height) - CGFloat(Defaults.gapSize.value) - } - - adjustedWindowRect = adjustedWindowRect.screenFlipped + let adjustedWindowRect = WindowFrameBounds.constrained(currentWindowRect, + to: visibleFrameOfScreen.screenFlipped, + gap: CGFloat(Defaults.gapSize.value)) if !currentWindowRect.equalTo(adjustedWindowRect) { windowElement.setFrame(adjustedWindowRect) } diff --git a/Rectangle/WindowMover/WindowAnimator.swift b/Rectangle/WindowMover/WindowAnimator.swift index 5b255777f..2c694d8ef 100644 --- a/Rectangle/WindowMover/WindowAnimator.swift +++ b/Rectangle/WindowMover/WindowAnimator.swift @@ -18,6 +18,30 @@ enum WindowAnimationCurve { } } +struct WindowAnimationPlacement { + let screenFrame: CGRect + let sharedEdges: Edge? + let constrainToScreen: Bool + let gap: CGFloat + + func frame(for requested: CGRect, actualSize: CGSize, origin: CGRect, progress: CGFloat) -> CGRect { + var frame = CGRect(origin: requested.origin, size: actualSize) + if let sharedEdges { + frame = ClampedWindowAligner.aligned(window: frame, inZone: requested, sharedEdges: sharedEdges) + } + guard constrainToScreen else { return frame } + + // Bring an initially out-of-bounds window back gradually instead of clipping its first frame. + let initialBounds = screenFrame.union(origin) + let progress = min(1, max(0, progress)) + let bounds = CGRect(x: initialBounds.minX + (screenFrame.minX - initialBounds.minX) * progress, + y: initialBounds.minY + (screenFrame.minY - initialBounds.minY) * progress, + width: initialBounds.width + (screenFrame.width - initialBounds.width) * progress, + height: initialBounds.height + (screenFrame.height - initialBounds.height) * progress) + return WindowFrameBounds.constrained(frame, to: bounds, gap: gap) + } +} + /// Advances by elapsed time, skipping missed frames. final class WindowFrameAnimation { let destination: CGRect @@ -26,7 +50,8 @@ final class WindowFrameAnimation { private let duration: TimeInterval private let offset: () -> CGPoint private let curve: (Double) -> CGFloat - private let write: (CGRect) -> Bool + private let write: (CGRect, CGFloat) -> Bool + private let finalize: ((CGRect) -> Void)? private let cleanup: () -> Void private let completion: (CGRect) -> Void private(set) var isFinished = false @@ -34,7 +59,8 @@ final class WindowFrameAnimation { init(from: CGRect, to: CGRect, startTime: TimeInterval, duration: TimeInterval, offset: @escaping () -> CGPoint = { .zero }, curve: @escaping (Double) -> CGFloat = WindowAnimationCurve.value, - write: @escaping (CGRect) -> Bool, + write: @escaping (CGRect, CGFloat) -> Bool, + finalize: ((CGRect) -> Void)? = nil, cleanup: @escaping () -> Void, completion: @escaping (CGRect) -> Void) { origin = from @@ -44,6 +70,7 @@ final class WindowFrameAnimation { self.offset = offset self.curve = curve self.write = write + self.finalize = finalize self.cleanup = cleanup self.completion = completion } @@ -61,7 +88,7 @@ final class WindowFrameAnimation { y: origin.minY + (destination.minY - origin.minY) * eased + delta.y, width: origin.width + (destination.width - origin.width) * eased, height: origin.height + (destination.height - origin.height) * eased) - if !write(frame) { + if !write(frame, eased) { // Let the normal mover settle the destination after a refused AX write. finish() } @@ -71,8 +98,10 @@ final class WindowFrameAnimation { guard !isFinished else { return } let delta = offset() isFinished = true + let finalFrame = destination.offsetBy(dx: delta.x, dy: delta.y) + finalize?(finalFrame) cleanup() - completion(destination.offsetBy(dx: delta.x, dy: delta.y)) + completion(finalFrame) } func cancel() { @@ -120,6 +149,7 @@ final class WindowAnimator { func animate(_ element: AccessibilityElement, to destination: CGRect, duration: TimeInterval = WindowAnimationCurve.duration, resizeOnly: Bool = false, + placement: WindowAnimationPlacement? = nil, offset: @escaping () -> CGPoint = { .zero }, curve: @escaping (Double) -> CGFloat = WindowAnimationCurve.value, completion: @escaping (CGRect) -> Void) { @@ -131,15 +161,29 @@ final class WindowAnimator { let origin = element.frame guard Self.enabled, !origin.isNull, !destination.isNull, !origin.isEmpty, !destination.isEmpty, origin != destination else { - completion(destination) + completion(placement == nil ? destination : .null) return } let restoreAccessibility = element.beginAnimatedAdjustment() + var finalFrame: CGRect? + let finalize: ((CGRect) -> Void)? = placement.map { placement in + { frame in + finalFrame = element.setConstrainedAnimationFrame(frame, placement: placement, + origin: origin, progress: 1) + } + } window = element animation = WindowFrameAnimation(from: origin, to: destination, startTime: ProcessInfo.processInfo.systemUptime, duration: duration, offset: offset, curve: curve, - write: { element.setAnimationFrame($0, resizeOnly: resizeOnly) }, + write: { frame, progress in + if let placement { + // Retry transient AX failures on the next tick without cutting the transition short. + _ = element.setConstrainedAnimationFrame(frame, placement: placement, origin: origin, progress: progress) + return true + } + return element.setAnimationFrame(frame, resizeOnly: resizeOnly) + }, finalize: finalize, cleanup: { [weak self] in self?.timer?.invalidate() self?.timer = nil @@ -150,7 +194,9 @@ final class WindowAnimator { self?.animation = nil self?.window = nil restoreAccessibility() - }, completion: completion) + }, completion: { frame in + completion(placement == nil ? frame : finalFrame ?? .null) + }) let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in guard let self else { return } if !Self.enabled { diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 67eda9b31..516e15151 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -4317,6 +4317,124 @@ class TodoShortcutValidatorTests: XCTestCase { } } +class WindowAnimationPlacementTests: XCTestCase { + private let screen = CGRect(x: 0, y: 29, width: 1403, height: 869) + + private func placement(for zone: CGRect, screen: CGRect? = nil) -> WindowAnimationPlacement { + let screen = screen ?? self.screen + return WindowAnimationPlacement(screenFrame: screen, sharedEdges: zone.sharedEdges(withRect: screen), + constrainToScreen: true, gap: 0) + } + + func testMinimumSizeKeepsEverySelectedEdgeAndCorner() { + let cases: [(CGRect, CGSize, CGPoint)] = [ + (CGRect(x: 702, y: 29, width: 701, height: 869), CGSize(width: 913, height: 869), CGPoint(x: 490, y: 29)), + (CGRect(x: 0, y: 29, width: 701, height: 869), CGSize(width: 913, height: 869), CGPoint(x: 0, y: 29)), + (CGRect(x: 0, y: 29, width: 1403, height: 434), CGSize(width: 1403, height: 600), CGPoint(x: 0, y: 29)), + (CGRect(x: 0, y: 464, width: 1403, height: 434), CGSize(width: 1403, height: 600), CGPoint(x: 0, y: 298)), + (CGRect(x: 0, y: 29, width: 701, height: 434), CGSize(width: 913, height: 600), CGPoint(x: 0, y: 29)), + (CGRect(x: 702, y: 29, width: 701, height: 434), CGSize(width: 913, height: 600), CGPoint(x: 490, y: 29)), + (CGRect(x: 0, y: 464, width: 701, height: 434), CGSize(width: 913, height: 600), CGPoint(x: 0, y: 298)), + (CGRect(x: 702, y: 464, width: 701, height: 434), CGSize(width: 913, height: 600), CGPoint(x: 490, y: 298)) + ] + for (zone, size, expected) in cases { + let result = placement(for: zone).frame(for: zone, actualSize: size, origin: screen, progress: 1) + XCTAssertEqual(result.origin, expected) + XCTAssertEqual(result.size, size) + } + } + + func testWidthLimitDoesNotReverseMotionOrJumpAtTheEnd() { + let origin = CGRect(x: 100, y: 100, width: 1100, height: 650) + let target = CGRect(x: 702, y: 29, width: 701, height: 869) + let placement = placement(for: target) + var previous = origin + for step in 1...100 { + let t = CGFloat(step) / 100 + let requested = CGRect(x: origin.minX + (target.minX - origin.minX) * t, + y: origin.minY + (target.minY - origin.minY) * t, + width: origin.width + (target.width - origin.width) * t, + height: origin.height + (target.height - origin.height) * t) + let result = placement.frame(for: requested, actualSize: CGSize(width: max(913, requested.width), height: requested.height), + origin: origin, progress: t) + XCTAssertGreaterThanOrEqual(result.minX, previous.minX - 0.001) + XCTAssertLessThan(abs(result.minX - previous.minX), 7) + previous = result + } + XCTAssertEqual(previous.minX, 490, accuracy: 0.001) + } + + func testMaximumAndAspectRatioSizesKeepTheRightEdgeAndVerticalCenter() { + let target = CGRect(x: 702, y: 29, width: 701, height: 869) + for size in [CGSize(width: 600, height: 400), CGSize(width: 600, height: 450)] { + let result = placement(for: target).frame(for: target, actualSize: size, origin: screen, progress: 1) + XCTAssertEqual(result.maxX, screen.maxX) + XCTAssertEqual(result.midY, screen.midY, accuracy: 0.5) + XCTAssertEqual(result.size, size) + } + } + + func testCenteredLayoutCentersBothConstrainedDimensions() { + let target = CGRect(x: 351, y: 129, width: 702, height: 669) + let result = placement(for: target).frame(for: target, actualSize: CGSize(width: 913, height: 400), origin: screen, progress: 1) + XCTAssertEqual(result.midX, target.midX, accuracy: 0.5) + XCTAssertEqual(result.midY, target.midY, accuracy: 0.5) + } + + func testDockInsetsAndNegativeDisplayCoordinatesUseTheProvidedWorkArea() { + let screens = [CGRect(x: 45, y: 29, width: 1395, height: 869), screen, + CGRect(x: 0, y: 29, width: 1440, height: 831), + CGRect(x: -1600, y: -870, width: 1600, height: 900)] + for screen in screens { + let target = CGRect(x: screen.midX, y: screen.minY, width: screen.width / 2, height: screen.height) + let result = placement(for: target, screen: screen).frame(for: target, actualSize: CGSize(width: 913, height: 600), origin: screen, progress: 1) + XCTAssertEqual(result.maxX, screen.maxX) + XCTAssertEqual(result.midY, screen.midY, accuracy: 0.5) + } + } + + func testInitiallyOutOfBoundsWindowIsNotClippedOnItsFirstFrame() { + let origin = CGRect(x: 600, y: 29, width: 913, height: 700) + let target = CGRect(x: 702, y: 29, width: 701, height: 869) + let placement = placement(for: target) + XCTAssertEqual(placement.frame(for: origin, actualSize: origin.size, origin: origin, progress: 0), origin) + let final = placement.frame(for: target, actualSize: CGSize(width: 913, height: 869), origin: origin, progress: 1) + XCTAssertEqual(final.maxX, screen.maxX) + } + + func testGapCorrectionMatchesNormalWindowBounds() { + let result = WindowFrameBounds.constrained(CGRect(x: 1000, y: 0, width: 600, height: 500), to: screen, gap: 10) + XCTAssertEqual(result.origin, CGPoint(x: 793, y: 39)) + XCTAssertTrue(WindowFrameBounds.constrained(.null, to: screen, gap: 10).isNull) + } + + func testFinalFrameIsAppliedBeforeCleanupAndOnlyOnce() { + var events: [String] = [] + let destination = CGRect(x: 700, y: 29, width: 700, height: 869) + let animation = WindowFrameAnimation(from: .zero, to: destination, startTime: 0, duration: 0.34, + write: { _, _ in events.append("tick"); return true }, + finalize: { frame in XCTAssertEqual(frame, destination); events.append("final") }, + cleanup: { events.append("cleanup") }, + completion: { _ in events.append("completion") }) + animation.tick(at: 0.1) + animation.tick(at: 0.5) + animation.finish() + XCTAssertEqual(events, ["tick", "final", "cleanup", "completion"]) + } + + func testCancellationDoesNotWriteTheDestination() { + var events: [String] = [] + let animation = WindowFrameAnimation(from: .zero, to: screen, startTime: 0, duration: 0.34, + write: { _, _ in true }, + finalize: { _ in events.append("final") }, + cleanup: { events.append("cleanup") }, + completion: { _ in events.append("completion") }) + animation.cancel() + animation.finish() + XCTAssertEqual(events, ["cleanup"]) + } +} + class ClampedWindowAlignerTests: XCTestCase { // Screen 2000x1200 at origin. Coordinates are already screen-flipped (window space): From fe06e884a174deb00080760186f554c01976ad8d Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:23:54 -0700 Subject: [PATCH 11/11] Improve drag restore responsiveness and quick-release handling Detect movement from current displayed bounds, preserve the original grab reference, and let restoration finish after mouse release. Add regression coverage and document the remaining far-right drag jumps. --- README.md | 2 +- Rectangle/Snapping/SnappingManager.swift | 132 ++++++++++---- Rectangle/Utilities/WindowUtil.swift | 10 + Rectangle/WindowMover/WindowAnimator.swift | 4 +- RectangleTests/RectangleTests.swift | 201 +++++++++++++++++++++ 5 files changed, 314 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 865347ecd..760a44a1a 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Drag a window to the edge of the screen. When the mouse cursor reaches the edge | Bottom left, center, or right third | Respective third | | Bottom left or right third, then drag to bottom center | First or last two thirds, respectively | -In **Snap Areas**, enable **Animate windows (experimental)** for smooth window resizing or **Blur footprint** for a blurred snap preview. Both are off by default. Animated snapping keeps the selected edge or center aligned when an app limits its window size or aspect ratio. Title-bar double-click maximize/restore follows the same animation setting and timing as snapping. Window animation has been tested in known scenarios, but remains experimental because repeatedly updating the window's size and position during the transition may cause issues in some edge cases. +In **Snap Areas**, enable **Animate windows (experimental)** for smooth window resizing or **Blur footprint** for a blurred snap preview. Both are off by default. Animated snapping keeps the selected edge or center aligned when an app limits its window size or aspect ratio. Dragging a snapped window starts restoring its previous size as the window begins moving, and a quick release lets the remaining restore animation continue. Far-right title-bar grabs can still show visible jumps. Title-bar double-click maximize/restore follows the same animation setting and timing as snapping. Window animation has been tested in known scenarios, but remains experimental because repeatedly updating the window's size and position during the transition may cause issues in some edge cases. ### Ignore an app diff --git a/Rectangle/Snapping/SnappingManager.swift b/Rectangle/Snapping/SnappingManager.swift index b09b31c63..31f9a6874 100644 --- a/Rectangle/Snapping/SnappingManager.swift +++ b/Rectangle/Snapping/SnappingManager.swift @@ -8,6 +8,58 @@ struct SnapArea: Equatable { let action: WindowAction } +struct WindowDragGeometry { + let initialFrame: CGRect? + let currentFrame: CGRect + + init?(initialFrame: CGRect?, initialServerFrame: CGRect?, serverFrame: CGRect?, accessibilityFrame: () -> CGRect?) { + // Compare frames from the same source; AX and WindowServer can disagree during a drag. + if let initialServerFrame, !initialServerFrame.isNull, !initialServerFrame.isEmpty, + let serverFrame, !serverFrame.isNull, !serverFrame.isEmpty { + self.initialFrame = initialServerFrame + currentFrame = serverFrame + } else { + guard let frame = accessibilityFrame(), !frame.isNull else { return nil } + self.initialFrame = initialFrame + currentFrame = frame + } + } + + var isResizing: Bool { + guard let initialFrame else { return true } + return currentFrame.size != initialFrame.size && currentFrame.numSharedEdges(withRect: initialFrame) >= 2 + } + + var isMoving: Bool { + guard let initialFrame else { return false } + return !isResizing && currentFrame.origin != initialFrame.origin + } + + var movedWithoutResizing: Bool { + initialFrame?.size == currentFrame.size && initialFrame?.origin != currentFrame.origin + } +} + +enum DragRestorePlacement { + static func referenceCursor(current: CGRect, initial: CGRect?, mouseDown: CGPoint?, fallback: CGPoint) -> CGPoint { + guard let initial, let mouseDown else { return fallback } + return CGPoint(x: current.minX + mouseDown.x - initial.minX, + y: current.minY + mouseDown.y - initial.minY) + } + + static func frame(from current: CGRect, size: CGSize, cursor: CGPoint?) -> CGRect { + var restored = CGRect(origin: current.origin, size: size) + if let cursor { + // Move only as far as the grab point needs. Keeping the old right edge would + // abruptly shift by the entire width difference when the cursor crosses the cutoff. + let inset = min(32, size.width / 2) + let neededShift = cursor.x - current.minX - (size.width - inset) + restored.origin.x += min(max(0, neededShift), max(0, current.width - size.width)) + } + return restored + } +} + class SnappingManager { private let fullIgnoreIds: [String] = Defaults.fullIgnoreBundleIds.typedValue ?? ["com.install4j", @@ -26,6 +78,9 @@ class SnappingManager { var isFullScreen: Bool = false var allowListening: Bool = true var initialWindowRect: CGRect? + private var initialWindowServerRect: CGRect? + private var initialCursorLocation: CGPoint? + private var releaseDragRestore: ((CGPoint?) -> Void)? var currentSnapArea: SnapArea? var dragPrevY: Double? var dragRestrictionExpirationTimestamp: UInt64 = 0 @@ -196,15 +251,20 @@ class SnappingManager { switch event.type { case .leftMouseDown: WindowAnimator.shared.finish() + releaseDragRestore = nil + initialCursorLocation = event.cgEvent?.location if !Defaults.obtainWindowOnClick.userDisabled { windowElement = AccessibilityElement.getWindowElementUnderCursor() windowId = windowElement?.getWindowId() initialWindowRect = windowElement?.frame + initialWindowServerRect = windowId.flatMap { WindowUtil.getWindowFrame(id: $0) } } case .leftMouseUp: - // A title-bar double-click can start an animation on this same mouse-up. - // Finish only drag restoration here. - if windowMoving { WindowAnimator.shared.finish() } + releaseDragRestore?(event.cgEvent?.location) + releaseDragRestore = nil + // A quick release must not jump to the end of drag restoration. + // Its cursor offset freezes on release, so the remaining frames can settle normally. + if windowMoving, currentSnapArea != nil { WindowAnimator.shared.finish() } if let currentSnapArea = self.currentSnapArea { box?.orderOut(nil) currentSnapArea.action.postSnap(windowElement: windowElement, windowId: windowId, screen: currentSnapArea.screen) @@ -213,12 +273,11 @@ class SnappingManager { // it's possible that the window has moved, but the mouse dragged events are not getting the updated window position // this typically only happens if the user is dragging and dropping windows really quickly // in this scenario, the footprint doesn't display but the snap will still occur, as long as the window position is updated as of mouse up. - if let currentRect = windowElement?.frame, - currentRect.size == initialWindowRect?.size, - currentRect.origin != initialWindowRect?.origin { + if let geometry = dragGeometry(), geometry.movedWithoutResizing { - if let windowId { - unsnapRestore(windowId: windowId, currentRect: currentRect, cursorLoc: event.cgEvent?.location) + // Displayed bounds may still have the old size just after finish(). + if !windowMoving, let windowId { + unsnapRestore(windowId: windowId, currentRect: geometry.currentFrame, cursorLoc: event.cgEvent?.location) } if let snapArea = snapAreaContainingCursor(priorSnapArea: currentSnapArea) { @@ -234,6 +293,8 @@ class SnappingManager { windowId = nil windowMoving = false initialWindowRect = nil + initialWindowServerRect = nil + initialCursorLocation = nil windowIdAttempt = 0 lastWindowIdAttempt = nil case .leftMouseDragged: @@ -248,22 +309,21 @@ class SnappingManager { } windowId = windowElement?.getWindowId() initialWindowRect = windowElement?.frame + initialWindowServerRect = windowId.flatMap { WindowUtil.getWindowFrame(id: $0) } windowIdAttempt += 1 lastWindowIdAttempt = event.timestamp } - guard let currentRect = windowElement?.frame - else { return } - + var currentRect: CGRect? if !windowMoving { - if let initialWindowRect, (currentRect.size == initialWindowRect.size || currentRect.numSharedEdges(withRect: initialWindowRect) < 2) { - if currentRect.origin != initialWindowRect.origin { - windowMoving = true - if let windowId { - unsnapRestore(windowId: windowId, currentRect: currentRect, cursorLoc: event.cgEvent?.location) - } + guard let geometry = dragGeometry() else { return } + currentRect = geometry.currentFrame + if geometry.isMoving { + windowMoving = true + if let windowId { + unsnapRestore(windowId: windowId, currentRect: geometry.currentFrame, cursorLoc: event.cgEvent?.location) } } - else if let windowId { + else if geometry.isResizing, let windowId { AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId) } } @@ -280,6 +340,8 @@ class SnappingManager { if snapArea == currentSnapArea { return } + + guard let currentRect = currentRect ?? dragGeometry()?.currentFrame else { return } if Defaults.hapticFeedbackOnSnap.userEnabled { NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) @@ -309,6 +371,12 @@ class SnappingManager { } } + private func dragGeometry() -> WindowDragGeometry? { + WindowDragGeometry(initialFrame: initialWindowRect, initialServerFrame: initialWindowServerRect, + serverFrame: windowId.flatMap { WindowUtil.getWindowFrame(id: $0) }, + accessibilityFrame: { windowElement?.frame }) + } + func unsnapRestore(windowId: CGWindowID, currentRect: CGRect, cursorLoc: CGPoint?) { guard !Defaults.unsnapRestore.userDisabled else { return } @@ -317,24 +385,24 @@ class SnappingManager { if let windowElement = windowElement { if #available(macOS 12, *) { // earlier versions of macOS would stutter the reposition when dragging the window - var newRect = currentRect - newRect.size = restoreRect.size - if let cursorLoc = cursorLoc { - if !newRect.contains(cursorLoc) { // keep the same maxX if possible - newRect.origin = CGPoint(x: currentRect.maxX - newRect.width, y: newRect.minY) - - if !newRect.contains(cursorLoc) { // still doesn't contain cursor - newRect.origin = CGPoint(x: cursorLoc.x - (newRect.width / 2), y: newRect.minY) - } - } - } + // Pair the displayed frame with its native grab point, not a newer mouse sample. + let initialCursor = DragRestorePlacement.referenceCursor(current: currentRect, initial: initialWindowRect, + mouseDown: initialCursorLocation, + fallback: cursorLoc ?? NSEvent.mouseLocation.screenFlipped) + let newRect = DragRestorePlacement.frame(from: currentRect, size: restoreRect.size, cursor: initialCursor) // Preserve native drag positioning unless restoration requires a new origin. let resizeOnly = WindowAnimator.enabled && newRect.origin == currentRect.origin var cursorOffset = CGPoint.zero - let initialCursor = NSEvent.mouseLocation.screenFlipped - WindowAnimator.shared.animate(windowElement, to: newRect, duration: 0.18, resizeOnly: resizeOnly, offset: { + var released = false + releaseDragRestore = { cursor in + if let cursor { + cursorOffset = CGPoint(x: cursor.x - initialCursor.x, y: cursor.y - initialCursor.y) + } + released = true + } + WindowAnimator.shared.animate(windowElement, from: currentRect, to: newRect, duration: 0.18, resizeOnly: resizeOnly, offset: { // Freeze the drag offset when the mouse button is released. - if NSEvent.pressedMouseButtons & 1 != 0 { + if !released, NSEvent.pressedMouseButtons & 1 != 0 { let cursor = NSEvent.mouseLocation.screenFlipped cursorOffset = CGPoint(x: cursor.x - initialCursor.x, y: cursor.y - initialCursor.y) } diff --git a/Rectangle/Utilities/WindowUtil.swift b/Rectangle/Utilities/WindowUtil.swift index 97d5052bf..d2571ae4c 100644 --- a/Rectangle/Utilities/WindowUtil.swift +++ b/Rectangle/Utilities/WindowUtil.swift @@ -4,6 +4,16 @@ import Foundation class WindowUtil { private static var windowListCache = TimeoutCache<[CGWindowID]?, [WindowInfo]>(timeout: 100) + + /// Drag tracking needs current geometry rather than the window list's 100 ms cache. + static func getWindowFrame(id: CGWindowID) -> CGRect? { + guard let infos = CGWindowListCopyWindowInfo(.optionIncludingWindow, id) as? [[String: Any]], + let info = infos.first(where: { ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == id }), + let bounds = info[kCGWindowBounds as String] as? [String: Any], + let frame = CGRect(dictionaryRepresentation: bounds as CFDictionary), + !frame.isNull, !frame.isEmpty else { return nil } + return frame + } static func getWindowList(ids: [CGWindowID]? = nil, all: Bool = false) -> [WindowInfo] { if let infos = windowListCache[ids] { diff --git a/Rectangle/WindowMover/WindowAnimator.swift b/Rectangle/WindowMover/WindowAnimator.swift index 2c694d8ef..d26732ba1 100644 --- a/Rectangle/WindowMover/WindowAnimator.swift +++ b/Rectangle/WindowMover/WindowAnimator.swift @@ -146,7 +146,7 @@ final class WindowAnimator { animation?.finish() } - func animate(_ element: AccessibilityElement, to destination: CGRect, + func animate(_ element: AccessibilityElement, from startingFrame: CGRect? = nil, to destination: CGRect, duration: TimeInterval = WindowAnimationCurve.duration, resizeOnly: Bool = false, placement: WindowAnimationPlacement? = nil, @@ -158,7 +158,7 @@ final class WindowAnimator { } else { animation?.finish() } - let origin = element.frame + let origin = startingFrame ?? element.frame guard Self.enabled, !origin.isNull, !destination.isNull, !origin.isEmpty, !destination.isEmpty, origin != destination else { completion(placement == nil ? destination : .null) diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 516e15151..b6feef875 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -3649,6 +3649,207 @@ class OverlapOffsetGuardsTests: XCTestCase { } } +final class WindowDragGeometryTests: XCTestCase { + private let initial = CGRect(x: -1983, y: -964, width: 1920, height: 1016) + + func testDetectsMovementWhileAccessibilityStillReportsTheInitialFrame() throws { + let moved = initial.offsetBy(dx: 0, dy: 2) + var accessibilityReads = 0 + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: initial, + serverFrame: moved, accessibilityFrame: { + accessibilityReads += 1 + return self.initial + })) + XCTAssertTrue(geometry.isMoving) + XCTAssertTrue(geometry.movedWithoutResizing) + XCTAssertEqual(geometry.currentFrame, moved) + XCTAssertEqual(accessibilityReads, 0) + } + + func testDifferentCoordinateSourcesDoNotCreateFalseMovement() throws { + let accessibility = initial.offsetBy(dx: 1, dy: 1) + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: accessibility, initialServerFrame: initial, + serverFrame: initial, accessibilityFrame: { accessibility })) + XCTAssertFalse(geometry.isMoving) + XCTAssertFalse(geometry.isResizing) + } + + func testUnavailableServerFrameFallsBackToAccessibilityBaseline() throws { + let accessibility = initial.offsetBy(dx: 1, dy: 1) + for unavailable in [nil, CGRect.null, CGRect.zero] as [CGRect?] { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: accessibility, initialServerFrame: initial, + serverFrame: unavailable, accessibilityFrame: { accessibility })) + XCTAssertFalse(geometry.isMoving) + XCTAssertEqual(geometry.currentFrame, accessibility) + } + } + + func testServerFrameWithoutServerBaselineUsesAccessibility() throws { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: nil, + serverFrame: initial.offsetBy(dx: 20, dy: 20), + accessibilityFrame: { self.initial })) + XCTAssertFalse(geometry.isMoving) + } + + func testResizingFromEitherCornerDoesNotTriggerRestore() throws { + let frames = [CGRect(x: initial.minX, y: initial.minY, width: 1800, height: 900), + CGRect(x: initial.minX + 120, y: initial.minY + 100, width: 1800, height: 916)] + for frame in frames { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: initial, + serverFrame: frame, accessibilityFrame: { nil })) + XCTAssertTrue(geometry.isResizing) + XCTAssertFalse(geometry.isMoving) + XCTAssertFalse(geometry.movedWithoutResizing) + } + } + + func testMovingAndChangingSizeAcrossDisplaysStillCountsAsMovement() throws { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: initial, + serverFrame: CGRect(x: 20, y: 30, width: 1400, height: 800), + accessibilityFrame: { nil })) + XCTAssertTrue(geometry.isMoving) + XCTAssertFalse(geometry.movedWithoutResizing) + } + + func testMissingGeometryDoesNotCreateADrag() { + XCTAssertNil(WindowDragGeometry(initialFrame: initial, initialServerFrame: nil, + serverFrame: nil, accessibilityFrame: { nil })) + XCTAssertNil(WindowDragGeometry(initialFrame: initial, initialServerFrame: nil, + serverFrame: nil, accessibilityFrame: { .null })) + } +} + +final class DragRestorePlacementTests: XCTestCase { + private let current = CGRect(x: -1983, y: -950, width: 1920, height: 1016) + private let size = CGSize(width: 1100, height: 650) + + func testDisplayedFrameUsesTheOriginalGrabOffsetInsteadOfANewerMouseSample() { + let initial = CGRect(x: -1983, y: -964, width: 1920, height: 1016) + let displayed = initial.offsetBy(dx: 0, dy: 38) + let reference = DragRestorePlacement.referenceCursor(current: displayed, initial: initial, + mouseDown: CGPoint(x: -255, y: -934), + fallback: CGPoint(x: -255, y: -858)) + XCTAssertEqual(reference, CGPoint(x: -255, y: -896)) + XCTAssertEqual(reference.y - displayed.minY, 30) + } + + func testMissingMouseDownUsesTheAvailableCursorSample() { + let cursor = CGPoint(x: -561, y: -920) + XCTAssertEqual(DragRestorePlacement.referenceCursor(current: current, initial: current, + mouseDown: nil, fallback: cursor), cursor) + } + + func testLeftGrabKeepsNativePosition() { + let restored = DragRestorePlacement.frame(from: current, size: size, cursor: CGPoint(x: -1600, y: -920)) + XCTAssertEqual(restored.origin, current.origin) + XCTAssertEqual(restored.size, size) + } + + func testRightGrabMovesOnlyEnoughToKeepThePointerInside() { + let cursor = CGPoint(x: -561, y: -920) + let restored = DragRestorePlacement.frame(from: current, size: size, cursor: cursor) + XCTAssertEqual(restored.minX, -1629) + XCTAssertEqual(restored.maxX - cursor.x, 32) + XCTAssertTrue(restored.contains(cursor)) + XCTAssertLessThan(restored.maxX, current.maxX) + } + + func testNearbyGrabPointsDoNotSwitchToTheFarRightEdge() { + let cutoff = current.minX + size.width - 32 + let left = DragRestorePlacement.frame(from: current, size: size, cursor: CGPoint(x: cutoff - 1, y: -920)) + let right = DragRestorePlacement.frame(from: current, size: size, cursor: CGPoint(x: cutoff + 1, y: -920)) + XCTAssertEqual(left.minX, current.minX) + XCTAssertEqual(right.minX - left.minX, 1) + } + + func testGrabAtTheFarRightDoesNotPushPastTheOriginalEdge() { + let restored = DragRestorePlacement.frame(from: current, size: size, + cursor: CGPoint(x: current.maxX - 1, y: -920)) + XCTAssertEqual(restored.maxX, current.maxX) + } + + func testGrowingFromAHalfScreenDoesNotReposition() { + let half = CGRect(x: 400, y: 100, width: 800, height: 900) + let restored = DragRestorePlacement.frame(from: half, size: size, cursor: CGPoint(x: 1190, y: 130)) + XCTAssertEqual(restored.origin, half.origin) + } + + func testPlacementIsTheSameRelativeToEitherDisplay() { + let cursor = CGPoint(x: -561, y: -920) + let external = DragRestorePlacement.frame(from: current, size: size, cursor: cursor) + let local = DragRestorePlacement.frame(from: current.offsetBy(dx: 2000, dy: 1000), size: size, + cursor: CGPoint(x: cursor.x + 2000, y: cursor.y + 1000)) + XCTAssertEqual(local, external.offsetBy(dx: 2000, dy: 1000)) + XCTAssertEqual(DragRestorePlacement.frame(from: current, size: size, cursor: nil).origin, current.origin) + } +} + +final class DragRestoreReleaseTests: XCTestCase { + private final class WindowElement: AccessibilityElement { + override var frame: CGRect { CGRect(x: 120, y: 120, width: 800, height: 600) } + override func beginAnimatedAdjustment() -> () -> Void { {} } + override func setAnimationFrame(_ frame: CGRect, resizeOnly: Bool = false) -> Bool { true } + } + + private final class Manager: SnappingManager { + var restores = 0 + override func unsnapRestore(windowId: CGWindowID, currentRect: CGRect, cursorLoc: CGPoint?) { + restores += 1 + } + override func snapAreaContainingCursor(priorSnapArea: SnapArea?) -> SnapArea? { nil } + } + + private func release(dragAlreadyDetected: Bool) throws -> Manager { + let saved = Defaults.windowSnapping.enabled + Defaults.windowSnapping.enabled = false + defer { Defaults.windowSnapping.enabled = saved } + let manager = Manager() + manager.windowElement = WindowElement(AXUIElementCreateSystemWide()) + manager.windowId = .max + manager.initialWindowRect = CGRect(x: 100, y: 100, width: 800, height: 600) + manager.windowMoving = dragAlreadyDetected + let event = try XCTUnwrap(NSEvent.mouseEvent(with: .leftMouseUp, location: .zero, modifierFlags: [], + timestamp: 1, windowNumber: 0, context: nil, + eventNumber: 1, clickCount: 1, pressure: 0)) + manager.handle(event: event) + return manager + } + + func testMouseUpDoesNotRestoreAgainWhenDisplayedSizeHasNotCaughtUp() throws { + let manager = try release(dragAlreadyDetected: true) + XCTAssertEqual(manager.restores, 0) + XCTAssertFalse(manager.windowMoving) + XCTAssertNil(manager.windowId) + XCTAssertNil(manager.windowElement) + } + + func testMouseUpStillRestoresAQuickDragThatHadNotBeenDetected() throws { + let manager = try release(dragAlreadyDetected: false) + XCTAssertEqual(manager.restores, 1) + XCTAssertFalse(manager.windowMoving) + XCTAssertNil(manager.initialWindowRect) + } + + func testQuickReleaseKeepsTheRemainingAnimationInsteadOfJumpingToItsDestination() throws { + let saved = Defaults.experimentalWindowAnimations.enabled + Defaults.experimentalWindowAnimations.enabled = true + defer { + WindowAnimator.shared.finish() + Defaults.experimentalWindowAnimations.enabled = saved + } + try XCTSkipUnless(WindowAnimator.enabled, "Window animations are disabled by accessibility settings") + let window = WindowElement(AXUIElementCreateSystemWide()) + let destination = CGRect(x: 300, y: 120, width: 500, height: 400) + var completions = 0 + WindowAnimator.shared.animate(window, to: destination, duration: 0.18) { _ in completions += 1 } + + _ = try release(dragAlreadyDetected: true) + + XCTAssertEqual(completions, 0) + XCTAssertEqual(WindowAnimator.shared.destination(for: window), destination) + } +} + class SnappingManagerSessionTests: XCTestCase { private var savedSnappingEnabled: Bool?