From f0d27475b5d44e3b71ea920b146bbb19a9512536 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/27] 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 | 20 ++- Rectangle/WindowManager.swift | 117 +++++++++----- Rectangle/WindowMover/WindowAnimator.swift | 178 +++++++++++++++++++++ RectangleTests/RectangleTests.swift | 13 +- 7 files changed, 340 insertions(+), 60 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 9a1087d30..8bffab946 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) @@ -544,22 +580,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 e2bac436b..3ebc4f3f1 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) @@ -337,7 +340,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 3ede2865e..44dc59ba1 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -5324,6 +5324,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)), @@ -5501,7 +5502,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 @@ -5530,8 +5531,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?() } } @@ -5636,8 +5637,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 { @@ -5652,7 +5653,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 0f9e23834939efcd0cc339b1799d0fd41b0f0e5a 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/27] 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 3ebc4f3f1..5f668b33b 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 @@ -384,8 +372,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 93591e46ec093afacdf7a976fe6f15b704af012a 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/27] 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 3d9f0bfcf..cb4a86cf3 100644 --- a/Rectangle/Base.lproj/Main.storyboard +++ b/Rectangle/Base.lproj/Main.storyboard @@ -3912,6 +3912,19 @@ DQ + - + + + @@ -4494,6 +4524,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 406f578e6..feeae6d75 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 43dbe0418df5b24a03e18efa7ed2658e479e18c4 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/27] 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 feeae6d75..71b83fc02 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 583b008a15fef7e251dfc4272d03d0b08fba907f 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/27] 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 df79208fc..bf5b8f11a 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. + ### Tile windows in rows or columns Enable **Show additional sizes in menu** to include Rows and Columns in the **Tiling** submenu. Their keyboard shortcuts remain available when the submenu is hidden. diff --git a/TerminalCommands.md b/TerminalCommands.md index 4ea0562ff..909d5ba35 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 8c0b372d184e03fa1da8db173afec2a37fc50cd7 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/27] 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 bf5b8f11a..fdc8297bd 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. ### Tile windows in rows or columns diff --git a/Rectangle/Snapping/SnappingManager.swift b/Rectangle/Snapping/SnappingManager.swift index 5f668b33b..fc5a9526c 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 a7c0fa8d8c95eac65adb6f095fd234fe7f2326e3 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/27] 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 44dc59ba1..13780e71a 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -732,6 +732,95 @@ class BandTilingTests: 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 26a8c704f4962832093fbcab44e80e2685f5035c 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/27] 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 8bffab946..3c02f070d 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 fc5a9526c..ae1419002 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) @@ -331,14 +329,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) @@ -376,8 +372,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 991af18fa0087a4a3bc39c818f7751cf264b6bb1 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/27] Refine minimum window size warning HUD --- README.md | 2 +- Rectangle/WindowSizeWarning.swift | 73 ++++++++++++++++++++++++++---- Rectangle/mul.lproj/Main.xcstrings | 2 +- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fdc8297bd..e27608a8b 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,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 71b83fc02..9c5afe20e 100644 --- a/Rectangle/mul.lproj/Main.xcstrings +++ b/Rectangle/mul.lproj/Main.xcstrings @@ -46795,7 +46795,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unable to resize window smaller. Windows may overlap." + "value" : "Unable to resize window smaller.\nWindows may overlap." } } } From 6479a3c16c6fad76c13af1da3c0c86406f701fea 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/27] 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 e27608a8b..0a012e603 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. ### Tile windows in rows or columns diff --git a/Rectangle/AccessibilityElement.swift b/Rectangle/AccessibilityElement.swift index 3c02f070d..10f74ff1f 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 13780e71a..03097ffa4 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -5098,6 +5098,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 b105349ad8bb48128f3906417f15261a5e4f225c 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/27] 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 0a012e603..3d22a9ece 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. ### Tile windows in rows or columns diff --git a/Rectangle/Snapping/SnappingManager.swift b/Rectangle/Snapping/SnappingManager.swift index ae1419002..1802f53a4 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 } guard !isRestoreSuppressedBySizeChange(windowId: windowId) else { return } @@ -318,24 +386,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 ff5057384..4bcb4b624 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, forceRefresh: Bool = false) -> [WindowInfo] { if !forceRefresh, 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 03097ffa4..5397eee1c 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -4367,6 +4367,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? From dc8c2fbe1610c0387e6a814d59a528fa048c2df3 Mon Sep 17 00:00:00 2001 From: bozhenpeng <42286547+kiteretsu903@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:50:05 -0700 Subject: [PATCH 12/27] Implement Direct Resize and Blue preview window animations Add native drag handoff, preview rendering, verified parking and recovery, and constrained cross-display placement. Preserve animated title-bar and command restoration while applying drag restoration immediately. Default new animation selections to Direct Resize, move Animate footprint to the left settings column, and expose the Blue preview label. Keep the existing repository tests compatible with the updated interfaces. New animation tests, native harnesses, visual fixtures and historical evidence are maintained only in the local rectangle-animation-tests skill, outside the repository. Rebase integration preserves upstream row/column tiling and alignment tolerance, shares its forceRefresh window-list API, and adapts its test fixture to position-aware frame writes. Validation: 395 repository tests and 810 isolated local-skill tests pass; universal Release builds successfully. --- Rectangle.xcodeproj/project.pbxproj | 42 + Rectangle/AccessibilityElement.swift | 118 +- Rectangle/AppDelegate.swift | 5 +- Rectangle/Base.lproj/Main.storyboard | 30 +- Rectangle/Defaults.swift | 38 + Rectangle/PrefsWindow/Config.swift | 1 + .../PrefsWindow/SnapAreaViewController.swift | 77 +- Rectangle/Rectangle-Bridging-Header.h | 1 + Rectangle/ScreenDetection.swift | 18 +- Rectangle/ShortcutManager.swift | 8 +- Rectangle/Snapping/FootprintWindow.swift | 533 ++++- Rectangle/Snapping/SnappingManager.swift | 519 ++++- Rectangle/TitleBarManager.swift | 32 +- .../Utilities/NotificationExtension.swift | 2 + Rectangle/Utilities/WindowUtil.swift | 5 +- Rectangle/WindowManager.swift | 128 +- .../WindowMover/DirectWindowAnimator.swift | 215 ++ .../FrostedRestoreDragController.swift | 919 +++++++++ .../WindowMover/WindowAnimationCurves.swift | 116 ++ Rectangle/WindowMover/WindowAnimator.swift | 691 ++++++- Rectangle/WindowMover/WindowFrostBackdrop.h | 8 + Rectangle/WindowMover/WindowFrostBackdrop.m | 68 + .../WindowMover/WindowFrostOverlay.swift | 1759 +++++++++++++++++ Rectangle/WindowMover/WindowFrostTiming.swift | 118 ++ .../WindowMover/WindowFrostTransition.swift | 768 +++++++ Rectangle/WindowMover/WindowParkingPlan.swift | 422 ++++ Rectangle/WindowMover/WindowRecovery.swift | 1567 +++++++++++++++ Rectangle/main.swift | 21 + Rectangle/mul.lproj/Main.xcstrings | 291 ++- RectangleTests/RectangleTests.swift | 32 +- docs/window-animation-paths.md | 52 + 31 files changed, 8251 insertions(+), 353 deletions(-) create mode 100644 Rectangle/WindowMover/DirectWindowAnimator.swift create mode 100644 Rectangle/WindowMover/FrostedRestoreDragController.swift create mode 100644 Rectangle/WindowMover/WindowAnimationCurves.swift create mode 100644 Rectangle/WindowMover/WindowFrostBackdrop.h create mode 100644 Rectangle/WindowMover/WindowFrostBackdrop.m create mode 100644 Rectangle/WindowMover/WindowFrostOverlay.swift create mode 100644 Rectangle/WindowMover/WindowFrostTiming.swift create mode 100644 Rectangle/WindowMover/WindowFrostTransition.swift create mode 100644 Rectangle/WindowMover/WindowParkingPlan.swift create mode 100644 Rectangle/WindowMover/WindowRecovery.swift create mode 100644 Rectangle/main.swift create mode 100644 docs/window-animation-paths.md diff --git a/Rectangle.xcodeproj/project.pbxproj b/Rectangle.xcodeproj/project.pbxproj index 9f84efc2e..91caaae0e 100644 --- a/Rectangle.xcodeproj/project.pbxproj +++ b/Rectangle.xcodeproj/project.pbxproj @@ -7,6 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + AC0200030000000000000001 /* WindowAnimationCurves.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0200030000000000000002 /* WindowAnimationCurves.swift */; }; + AC0200010000000000000001 /* DirectWindowAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0200010000000000000002 /* DirectWindowAnimator.swift */; }; + FA0909000000000000000001 /* WindowFrostBackdrop.m in Sources */ = {isa = PBXBuildFile; fileRef = FA0909000000000000000002 /* WindowFrostBackdrop.m */; }; + 8C26A1AFDD10F55B30A01396 /* WindowParkingPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 493D7C3A9A0862208AB1FF2F /* WindowParkingPlan.swift */; }; + 902C1FD6E980891330D82EB0 /* WindowRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEAB1823232F501367106AD1 /* WindowRecovery.swift */; }; + 6327CF99951E05F7DFD51E3F /* WindowFrostOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C146B1DBD4423BAAA1D006F7 /* WindowFrostOverlay.swift */; }; + 1B0BB35D9283429E87054ACB /* WindowFrostTiming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54507951B9124315A9C77D40 /* WindowFrostTiming.swift */; }; + 17C6C52D1BD8FE2F2F432665 /* WindowFrostTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E84630744879E2D387C9DDC /* WindowFrostTransition.swift */; }; + BA1E805F112BA25BDB051EE9 /* FrostedRestoreDragController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B756AFBC1AECB3D048CCA08E /* FrostedRestoreDragController.swift */; }; + 301EBED2C882862F3AA47AEC /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AD07D8B7F1D22EDE5226841 /* main.swift */; }; 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 */; }; @@ -231,6 +241,17 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AC0200030000000000000002 /* WindowAnimationCurves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAnimationCurves.swift; sourceTree = ""; }; + AC0200010000000000000002 /* DirectWindowAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectWindowAnimator.swift; sourceTree = ""; }; + FA0909000000000000000002 /* WindowFrostBackdrop.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WindowFrostBackdrop.m; sourceTree = ""; }; + FA0909000000000000000003 /* WindowFrostBackdrop.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WindowFrostBackdrop.h; sourceTree = ""; }; + 493D7C3A9A0862208AB1FF2F /* WindowParkingPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowParkingPlan.swift; sourceTree = ""; }; + DEAB1823232F501367106AD1 /* WindowRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowRecovery.swift; sourceTree = ""; }; + C146B1DBD4423BAAA1D006F7 /* WindowFrostOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrostOverlay.swift; sourceTree = ""; }; + 54507951B9124315A9C77D40 /* WindowFrostTiming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrostTiming.swift; sourceTree = ""; }; + 0E84630744879E2D387C9DDC /* WindowFrostTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrostTransition.swift; sourceTree = ""; }; + B756AFBC1AECB3D048CCA08E /* FrostedRestoreDragController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrostedRestoreDragController.swift; sourceTree = ""; }; + 0AD07D8B7F1D22EDE5226841 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 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 = ""; }; @@ -631,9 +652,19 @@ 982140EA22B7DB0400ABFB3F /* WindowMover */ = { isa = PBXGroup; children = ( + 493D7C3A9A0862208AB1FF2F /* WindowParkingPlan.swift */, + DEAB1823232F501367106AD1 /* WindowRecovery.swift */, + C146B1DBD4423BAAA1D006F7 /* WindowFrostOverlay.swift */, + 54507951B9124315A9C77D40 /* WindowFrostTiming.swift */, + FA0909000000000000000002 /* WindowFrostBackdrop.m */, + FA0909000000000000000003 /* WindowFrostBackdrop.h */, + 0E84630744879E2D387C9DDC /* WindowFrostTransition.swift */, + B756AFBC1AECB3D048CCA08E /* FrostedRestoreDragController.swift */, 9824704A22B189250037B409 /* BestEffortWindowMover.swift */, 9824704622B189240037B409 /* StandardWindowMover.swift */, AC0100010000000000000002 /* WindowAnimator.swift */, + AC0200010000000000000002 /* DirectWindowAnimator.swift */, + AC0200030000000000000002 /* WindowAnimationCurves.swift */, 9824704722B189240037B409 /* WindowMover.swift */, 98B3559723CE025700E410E0 /* FixedSizeWindowMover.swift */, ); @@ -699,6 +730,7 @@ 9824700B22AF9B7D0037B409 /* Rectangle */ = { isa = PBXGroup; children = ( + 0AD07D8B7F1D22EDE5226841 /* main.swift */, 9824700C22AF9B7D0037B409 /* AppDelegate.swift */, 9824701022AF9B7E0037B409 /* Assets.xcassets */, 482E9E412EB1528D00D7928A /* AppIcon.icon */, @@ -1066,6 +1098,16 @@ 9818E01428B5A4FD004AA524 /* SixthsCompoundCalculation.swift in Sources */, 9824704B22B189250037B409 /* StandardWindowMover.swift in Sources */, AC0100010000000000000001 /* WindowAnimator.swift in Sources */, + AC0200010000000000000001 /* DirectWindowAnimator.swift in Sources */, + AC0200030000000000000001 /* WindowAnimationCurves.swift in Sources */, + 8C26A1AFDD10F55B30A01396 /* WindowParkingPlan.swift in Sources */, + 902C1FD6E980891330D82EB0 /* WindowRecovery.swift in Sources */, + 6327CF99951E05F7DFD51E3F /* WindowFrostOverlay.swift in Sources */, + 1B0BB35D9283429E87054ACB /* WindowFrostTiming.swift in Sources */, + FA0909000000000000000001 /* WindowFrostBackdrop.m in Sources */, + 17C6C52D1BD8FE2F2F432665 /* WindowFrostTransition.swift in Sources */, + BA1E805F112BA25BDB051EE9 /* FrostedRestoreDragController.swift in Sources */, + 301EBED2C882862F3AA47AEC /* main.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 10f74ff1f..ff41a52e6 100644 --- a/Rectangle/AccessibilityElement.swift +++ b/Rectangle/AccessibilityElement.swift @@ -72,6 +72,28 @@ class AccessibilityElement { guard let role = role else { return nil } return role == .staticText } + + func isTitleBarSpacer(in titleBar: CGRect) -> Bool { + guard role == .splitter else { return false } + var settable: DarwinBoolean = false + let status = AXUIElementIsAttributeSettable(wrappedElement, kAXValueAttribute as CFString, &settable) + let value = wrappedElement.getValue(.value) + return Self.isTitleBarSpacer(role: role, parentRole: getElementValue(.parent)?.role, + frame: frame, titleBar: titleBar, childCount: childElements?.count, + hasValue: value != nil && (value as? String) != "", + hasOrientation: wrappedElement.getValue(.orientation) != nil, + valueSettable: status == .success ? settable.boolValue : nil) + } + + /// Some toolkits expose empty titlebar spacing as a splitter. A real + /// adjustable divider, or spacing below the titlebar, keeps its own clicks. + static func isTitleBarSpacer(role: NSAccessibility.Role?, parentRole: NSAccessibility.Role?, + frame: CGRect, titleBar: CGRect, childCount: Int?, + hasValue: Bool, hasOrientation: Bool, valueSettable: Bool?) -> Bool { + role == .splitter && parentRole == .toolbar && childCount == 0 + && !hasValue && !hasOrientation && valueSettable == false + && !frame.isEmpty && !frame.isInfinite && titleBar.contains(frame) + } private var subrole: NSAccessibility.Subrole? { guard let value = wrappedElement.getValue(.subrole) as? String else { return nil } @@ -113,6 +135,7 @@ class AccessibilityElement { } set { guard let newValue = newValue else { return } + if WindowAnimator.shared.deferUntilReleased(element: self, action: { [self] in self.size = newValue }) { return } wrappedElement.setValue(.size, newValue) Logger.log("AX sizing proposed: \(newValue.debugDescription), result: \(size?.debugDescription ?? "N/A")") } @@ -132,7 +155,9 @@ class AccessibilityElement { /// 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, adjustPosition: Bool = true) { - WindowAnimator.shared.cancel(for: self) + if WindowAnimator.shared.deferUntilReleased(element: self, action: { [self] in + setFrame(frame, adjustSizeFirst: adjustSizeFirst, adjustPosition: adjustPosition) + }) { return } let appElement = applicationElement let builtInAssistiveTechnologyEnabled = NSWorkspace.shared.isVoiceOverEnabled || NSWorkspace.shared.isSwitchControlEnabled @@ -174,8 +199,23 @@ class AccessibilityElement { } } + /// Writes one frame without readback; completion handles the final placement. + /// The coordinator has already released recovery but still owns this drag. + func setOwnedOrdinaryDragFrame(_ frame: CGRect, token: UUID) { + guard WindowAnimator.shared.ownsOrdinaryDrag(element: self, token: token) else { return } + // setFrame/size treat callers as external requests and cancel an active + // transition. This token-checked path is the transition's own movement. + // Its Enhanced UI adjustment remains held until the drag completes. + wrappedElement.setValue(.size, frame.size) + wrappedElement.setValue(.position, frame.origin) + wrappedElement.setValue(.size, frame.size) + } + /// Writes one frame without readback; completion handles the final placement. func setAnimationFrame(_ frame: CGRect, resizeOnly: Bool = false) -> Bool { + if WindowAnimator.shared.deferUntilReleased(element: self, action: { [self] in + setFrame(frame, adjustSizeFirst: false, adjustPosition: !resizeOnly) + }) { return false } var size = frame.size var position = frame.origin guard let sizeValue = AXValueCreate(.cgSize, &size), @@ -190,20 +230,39 @@ class AccessibilityElement { 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, + if WindowAnimator.shared.deferUntilReleased(element: self, action: { [self] in + setFrame(frame) + }) { return nil } + // Advance position even when the following resize/readback is delayed by + // native drag settlement. A size-first chain could skip every position + // write during a cross-display animation and only move at completion. + let provisional = placement.frame(for: frame, actualSize: frame.size, origin: origin, progress: progress) + guard writeAnimationPosition(provisional.origin) == .success, + writeAnimationSize(frame.size) == .success else { return nil } + // Chromium can report a stale size after accepting a resize. Intermediate + // frames must not align again using that old size while native updates + // are still being applied. Only the final frame reports achieved geometry. + guard progress >= 1, 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 } + guard writeAnimationPosition(resolved.origin) == .success else { return nil } return resolved } + + func writeAnimationPosition(_ position: CGPoint) -> AXError { + var position = position + guard let value = AXValueCreate(.cgPoint, &position) else { return .failure } + return AXUIElementSetAttributeValue(wrappedElement, kAXPositionAttribute as CFString, value) + } + + func writeAnimationSize(_ size: CGSize) -> AXError { + var size = size + guard let value = AXValueCreate(.cgSize, &size) else { return .failure } + return AXUIElementSetAttributeValue(wrappedElement, kAXSizeAttribute as CFString, value) + } private var childElements: [AccessibilityElement]? { getElementsValue(.children) @@ -404,16 +463,36 @@ extension AccessibilityElement { return nil } - private static func getWindowInfo(_ location: CGPoint) -> WindowInfo? { - WindowUtil.getWindowList().first(where: {windowInfo in + private static func getWindowInfo(_ location: CGPoint, eventWindowID: CGWindowID? = nil) -> WindowInfo? { + // Reused frost surfaces stay ordered at alpha zero. WindowServer still + // lists them above the dragged app, even though they ignore mouse input. + let ignoredWindows = Set(NSApp.windows.compactMap { window in + window.ignoresMouseEvents ? CGWindowID(exactly: window.windowNumber) : nil + }) + // A new click can follow a focus change or reveal within the list's + // 100 ms cache lifetime. Select from the current stacking order. + return windowInfoUnderCursor(at: location, windows: WindowUtil.getWindowList(ids: eventWindowID.map { [$0] }, forceRefresh: true), + eventWindowID: eventWindowID, ignoring: ignoredWindows, + frostSurfaces: WindowFrostRendererConnection.shared.inputTransparentSurfaces) + } + + static func windowInfoUnderCursor(at location: CGPoint, windows: [WindowInfo], + eventWindowID: CGWindowID? = nil, + ignoring ignoredWindows: Set = [], + frostSurfaces: [CGWindowID: pid_t] = [:]) -> WindowInfo? { + windows.first(where: { windowInfo in windowInfo.level < 21 // 21 is the level of the Notification Center + && windowInfo.alpha > 0 + && !ignoredWindows.contains(windowInfo.id) + && !FrostedRestoreDragRules.isInputTransparentFrostSurface(windowID: windowInfo.id, + ownerPID: windowInfo.pid, level: Int(windowInfo.level), registered: frostSurfaces) && !["Dock", "WindowManager"].contains(windowInfo.processName) - && windowInfo.frame.contains(location) + && (eventWindowID.map { windowInfo.id == $0 } ?? windowInfo.frame.contains(location)) }) } - static func getWindowElementUnderCursor() -> AccessibilityElement? { - let position = NSEvent.mouseLocation.screenFlipped + static func getWindowElementUnderCursor(at mouseDown: CGPoint? = nil, eventWindowID: CGWindowID? = nil) -> AccessibilityElement? { + let position = mouseDown ?? NSEvent.mouseLocation.screenFlipped var systemWideFirst = Defaults.systemWideMouseDown.userEnabled if Defaults.systemWideMouseDown.notSet, let frontAppId = ApplicationToggle.frontAppId { @@ -422,11 +501,12 @@ extension AccessibilityElement { if systemWideFirst, let element = AccessibilityElement(position), - let windowElement = element.windowElement { + let windowElement = element.windowElement, + eventWindowID == nil || windowElement.windowId == eventWindowID { return windowElement } - if let info = getWindowInfo(position) { + if let info = getWindowInfo(position, eventWindowID: eventWindowID) { if !Defaults.dragFromStage.userDisabled { if StageUtil.stageCapable && StageUtil.stageEnabled, let group = StageUtil.getStageStripWindowGroup(info.id), @@ -440,7 +520,7 @@ extension AccessibilityElement { if let windowElement = (windowElements.first { $0.windowId == info.id }) { return windowElement } - if let windowElement = (windowElements.first { $0.frame == info.frame }) { + if eventWindowID == nil, let windowElement = (windowElements.first { $0.frame == info.frame }) { return windowElement } } @@ -448,7 +528,8 @@ extension AccessibilityElement { if !systemWideFirst, let element = AccessibilityElement(position), - let windowElement = element.windowElement { + let windowElement = element.windowElement, + eventWindowID == nil || windowElement.windowId == eventWindowID { if Logger.logging, let pid = windowElement.pid { let appName = NSRunningApplication(processIdentifier: pid)?.localizedName ?? "" @@ -461,6 +542,9 @@ extension AccessibilityElement { // the frontmost app's own accessibility windows don't depend on it. if let frontAppElement = getFrontApplicationElement(), let windowElements = frontAppElement.windowElements { + if let eventWindowID { + return windowElements.first { $0.windowId == eventWindowID } + } let windowElement = windowElements .map { (element: $0, frame: $0.frame) } .filter { !$0.frame.isNull && $0.frame.contains(position) } diff --git a/Rectangle/AppDelegate.swift b/Rectangle/AppDelegate.swift index 11825ef09..f5d807854 100644 --- a/Rectangle/AppDelegate.swift +++ b/Rectangle/AppDelegate.swift @@ -5,7 +5,6 @@ import Sparkle import ServiceManagement import os.log -@NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { static let launcherAppId = "com.knollsoft.RectangleLauncher" @@ -49,6 +48,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ aNotification: Notification) { Defaults.loadFromSupportDir() + Defaults.normalizeWindowAnimationPreferences() + WindowRecoverySession.resumePendingRecovery() migrateShowEighthsInMenu() checkVersion() @@ -160,6 +161,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.initializeTodo() checkForProblematicApps() MacTilingDefaults.checkForBuiltInTiling(skipIfAlreadyNotified: true) + WindowRecoverySession.prewarm() + WindowFrostRendererConnection.shared.prewarm() } func checkForConflictingApps() { diff --git a/Rectangle/Base.lproj/Main.storyboard b/Rectangle/Base.lproj/Main.storyboard index cb4a86cf3..eec478932 100644 --- a/Rectangle/Base.lproj/Main.storyboard +++ b/Rectangle/Base.lproj/Main.storyboard @@ -3935,18 +3935,33 @@ DQ + + + @@ -3955,19 +3970,6 @@ DQ -