diff --git a/README.md b/README.md index 31a1847a5..760a44a1a 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. Animated snapping keeps the selected edge or center aligned when an app limits its window size or aspect ratio. Dragging a snapped window starts restoring its previous size as the window begins moving, and a quick release lets the remaining restore animation continue. Far-right title-bar grabs can still show visible jumps. Title-bar double-click maximize/restore follows the same animation setting and timing as snapping. Window animation has been tested in known scenarios, but remains experimental because repeatedly updating the window's size and position during the transition may cause issues in some edge cases. + ### Ignore an app Ignoring an app means that when the app is frontmost, keyboard shortcuts are un-registered from macOS. When the app is no longer frontmost, keyboard shortcuts are re-registered with macOS. This is useful for apps that have the same shortcuts like Rectangle and you do not want to change them. @@ -84,7 +86,7 @@ Apple never released a public API for doing this. Rectangle Pro has next/prev Sp ### Windows overlap when using thirds or other small layouts -Some apps enforce a minimum window size that is larger than the requested layout. For example, a window with a minimum width of 600 points cannot fit a 504-point third of a display. Rectangle keeps the window on screen and briefly shows a “Window size limited” message when the app leaves it larger than the requested size. Use a larger layout, such as halves, or reduce the adjacent window manually. Rectangle cannot override an app's minimum window size. +Some apps enforce a minimum window size that is larger than the requested layout. For example, a window with a minimum width of 600 points cannot fit a 504-point third of a display. Rectangle keeps the window on screen and briefly shows a “Minimum window size reached” HUD with a window icon when the app leaves it larger than the requested size. Use a larger layout, such as halves, or reduce the adjacent window manually. Rectangle cannot override an app's minimum window size. ### Window resizing is off slightly for iTerm2 diff --git a/Rectangle.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/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/AccessibilityElement.swift b/Rectangle/AccessibilityElement.swift index 5f1ffc772..38c071e0a 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,60 @@ class AccessibilityElement { if adjustSizeFirst { size = frame.size } - position = frame.origin + if adjustPosition { position = frame.origin } size = frame.size } ) } + + /// Holds the Enhanced UI policy for the transition; returns its cleanup closure. + 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 } + ) + // Bound AX calls so an unresponsive app cannot stall the animation. + setMessagingTimeout(0.05) + return { [self] in + setMessagingTimeout(0) + restore() + } + } + + /// 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 } + // Native dragging owns position during size restoration. + if !resizeOnly { + guard AXUIElementSetAttributeValue(wrappedElement, kAXPositionAttribute as CFString, positionValue) == .success else { return false } + } + 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) @@ -540,22 +590,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/Base.lproj/Main.storyboard b/Rectangle/Base.lproj/Main.storyboard index 068c7958f..97d4366be 100644 --- a/Rectangle/Base.lproj/Main.storyboard +++ b/Rectangle/Base.lproj/Main.storyboard @@ -3916,6 +3916,19 @@ DQ + - + + + @@ -4498,6 +4528,8 @@ DQ + + diff --git a/Rectangle/Defaults.swift b/Rectangle/Defaults.swift index 00dae42ec..b52911a30 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") @@ -51,10 +52,17 @@ 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 = 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") + static let footprintBlur = BoolDefault(key: "footprintBlur") static let SUEnableAutomaticChecks = BoolDefault(key: "SUEnableAutomaticChecks") static let todo = OptionalBoolDefault(key: "todo") static let todoMode = BoolDefault(key: "todoMode") @@ -121,6 +129,7 @@ class Defaults { cycleSizesIsChanged, cornerCycleExpansionAxis, cooperativeCornerResize, + experimentalWindowAnimations, allowAnyShortcut, windowSnapping, almostMaximizeHeight, @@ -158,6 +167,7 @@ class Defaults { footprintBorderWidth, footprintFade, footprintColor, + footprintBlur, SUEnableAutomaticChecks, todo, todoMode, 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/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/Snapping/FootprintWindow.swift b/Rectangle/Snapping/FootprintWindow.swift index 0862a5e0f..b3e8bca7e 100644 --- a/Rectangle/Snapping/FootprintWindow.swift +++ b/Rectangle/Snapping/FootprintWindow.swift @@ -2,22 +2,148 @@ 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 + // 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 + } +} + +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 { + // Allow shadow padding beyond screen edges without shifting the preview. + 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 + // Exclude the transparent preview interior from the shadow. + 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: CGFloat(Defaults.effectiveFootprintAlpha), + 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 +152,267 @@ 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 + 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 custom preview layers in SDR. + 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 { + // 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() + 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) + 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 { + let tintAlpha = min(1, max(0, CGFloat(Defaults.effectiveFootprintAlpha))) + 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() } + // Apply accessibility changes immediately, including during an active fade. + 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 bd4a43ebb..31f9a6874 100644 --- a/Rectangle/Snapping/SnappingManager.swift +++ b/Rectangle/Snapping/SnappingManager.swift @@ -8,6 +8,58 @@ struct SnapArea: Equatable { let action: WindowAction } +struct WindowDragGeometry { + let initialFrame: CGRect? + let currentFrame: CGRect + + init?(initialFrame: CGRect?, initialServerFrame: CGRect?, serverFrame: CGRect?, accessibilityFrame: () -> CGRect?) { + // Compare frames from the same source; AX and WindowServer can disagree during a drag. + if let initialServerFrame, !initialServerFrame.isNull, !initialServerFrame.isEmpty, + let serverFrame, !serverFrame.isNull, !serverFrame.isEmpty { + self.initialFrame = initialServerFrame + currentFrame = serverFrame + } else { + guard let frame = accessibilityFrame(), !frame.isNull else { return nil } + self.initialFrame = initialFrame + currentFrame = frame + } + } + + var isResizing: Bool { + guard let initialFrame else { return true } + return currentFrame.size != initialFrame.size && currentFrame.numSharedEdges(withRect: initialFrame) >= 2 + } + + var isMoving: Bool { + guard let initialFrame else { return false } + return !isResizing && currentFrame.origin != initialFrame.origin + } + + var movedWithoutResizing: Bool { + initialFrame?.size == currentFrame.size && initialFrame?.origin != currentFrame.origin + } +} + +enum DragRestorePlacement { + static func referenceCursor(current: CGRect, initial: CGRect?, mouseDown: CGPoint?, fallback: CGPoint) -> CGPoint { + guard let initial, let mouseDown else { return fallback } + return CGPoint(x: current.minX + mouseDown.x - initial.minX, + y: current.minY + mouseDown.y - initial.minY) + } + + static func frame(from current: CGRect, size: CGSize, cursor: CGPoint?) -> CGRect { + var restored = CGRect(origin: current.origin, size: size) + if let cursor { + // Move only as far as the grab point needs. Keeping the old right edge would + // abruptly shift by the entire width difference when the cursor crosses the cutoff. + let inset = min(32, size.width / 2) + let neededShift = cursor.x - current.minX - (size.width - inset) + restored.origin.x += min(max(0, neededShift), max(0, current.width - size.width)) + } + return restored + } +} + class SnappingManager { private let fullIgnoreIds: [String] = Defaults.fullIgnoreBundleIds.typedValue ?? ["com.install4j", @@ -26,6 +78,9 @@ class SnappingManager { var isFullScreen: Bool = false var allowListening: Bool = true var initialWindowRect: CGRect? + private var initialWindowServerRect: CGRect? + private var initialCursorLocation: CGPoint? + private var releaseDragRestore: ((CGPoint?) -> Void)? var currentSnapArea: SnapArea? var dragPrevY: Double? var dragRestrictionExpirationTimestamp: UInt64 = 0 @@ -195,12 +250,21 @@ class SnappingManager { func handle(event: NSEvent) { 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: + 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) @@ -209,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) { @@ -230,6 +293,8 @@ class SnappingManager { windowId = nil windowMoving = false initialWindowRect = nil + initialWindowServerRect = nil + initialCursorLocation = nil windowIdAttempt = 0 lastWindowIdAttempt = nil case .leftMouseDragged: @@ -244,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) } } @@ -276,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) @@ -287,21 +353,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 @@ -317,7 +371,13 @@ class SnappingManager { } } - private func unsnapRestore(windowId: CGWindowID, currentRect: CGRect, cursorLoc: CGPoint?) { + 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 } // if window was put there by rectangle, restore size @@ -325,18 +385,31 @@ 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 + 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 !released, 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) } - windowElement.setFrame(newRect, adjustSizeFirst: false) } else { windowElement.size = restoreRect.size } @@ -360,8 +433,9 @@ 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 uses 0.75; normalize it to the window animation duration. + return WindowAnimationCurve.duration * Double(Defaults.footprintAnimationDurationMultiplier.value) / 0.75 } func getFootprintAnimationOrigin(_ snapArea: SnapArea, _ boxRect: CGRect) -> CGPoint? { diff --git a/Rectangle/Utilities/WindowUtil.swift b/Rectangle/Utilities/WindowUtil.swift index 97d5052bf..d2571ae4c 100644 --- a/Rectangle/Utilities/WindowUtil.swift +++ b/Rectangle/Utilities/WindowUtil.swift @@ -4,6 +4,16 @@ import Foundation class WindowUtil { private static var windowListCache = TimeoutCache<[CGWindowID]?, [WindowInfo]>(timeout: 100) + + /// Drag tracking needs current geometry rather than the window list's 100 ms cache. + static func getWindowFrame(id: CGWindowID) -> CGRect? { + guard let infos = CGWindowListCopyWindowInfo(.optionIncludingWindow, id) as? [[String: Any]], + let info = infos.first(where: { ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == id }), + let bounds = info[kCGWindowBounds as String] as? [String: Any], + let frame = CGRect(dictionaryRepresentation: bounds as CFDictionary), + !frame.isNull, !frame.isEmpty else { return nil } + return frame + } static func getWindowList(ids: [CGWindowID]? = nil, all: Bool = false) -> [WindowInfo] { if let infos = windowListCache[ids] { diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index 3de8107ea..964a4200e 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,8 @@ class WindowManager { return } - let currentWindowRect: CGRect = frontmostWindowElement.frame + let currentWindowRect = WindowAnimator.shared.destination(for: frontmostWindowElement) + ?? frontmostWindowElement.frame var lastRectangleAction = windowId.flatMap { AppDelegate.windowHistory.lastRectangleActions[$0] } @@ -202,53 +211,77 @@ 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.") + // Cross-display and cooperative moves need the normal settling sequence. + let animated = WindowAnimator.enabled && !isFixedSize && !isMovedAcrossDisplays + && !Defaults.cooperativeCornerResize.enabled + 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) - + } + + 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 destination before animation for repeated-shortcut cycling. + recordAction(windowId: windowId, resultingRect: calcResult.rect.screenFlipped, + action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) + 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(false) } - - postProcess(result: resultParameters, resultingRect: resultingRect) } /// Move/resize a window based on the calculation results. @@ -278,7 +311,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 +322,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/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 new file mode 100644 index 000000000..d26732ba1 --- /dev/null +++ b/Rectangle/WindowMover/WindowAnimator.swift @@ -0,0 +1,215 @@ +/// 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)) + // 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)) + // Integral of t * (1 - t)^5, normalized to [0, 1]. + return CGFloat(1 - pow(1 - progress, 6) * (1 + 6 * progress)) + } +} + +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 + 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, CGFloat) -> Bool + private let finalize: ((CGRect) -> Void)? + 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, CGFloat) -> Bool, + finalize: ((CGRect) -> Void)? = nil, + 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.finalize = finalize + 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, eased) { + // Let the normal mover settle the destination after a refused AX write. + finish() + } + } + + func finish() { + guard !isFinished else { return } + let delta = offset() + isFinished = true + let finalFrame = destination.offsetBy(dx: delta.x, dy: delta.y) + finalize?(finalFrame) + cleanup() + completion(finalFrame) + } + + func cancel() { + guard !isFinished else { return } + isFinished = true + cleanup() + } +} + +/// Coordinates one window animation at a time on the main run loop. +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, from startingFrame: CGRect? = nil, 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) { + if window == element { + animation?.cancel() + } else { + animation?.finish() + } + let origin = startingFrame ?? element.frame + guard Self.enabled, !origin.isNull, !destination.isNull, + !origin.isEmpty, !destination.isEmpty, origin != destination else { + 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: { 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 + if let monitor = self?.mouseMonitor { + NSEvent.removeMonitor(monitor) + self?.mouseMonitor = nil + } + self?.animation = nil + self?.window = nil + restoreAccessibility() + }, 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 { + self.finish() + } else { + self.animation?.tick(at: ProcessInfo.processInfo.systemUptime) + } + } + self.timer = timer + // Manual grabs must interrupt animation even when drag-to-snap is disabled. + mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { [weak self] _ in + self?.finish() + } + RunLoop.main.add(timer, forMode: .common) + } +} 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 e216e874b..8b0c1f7ba 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", @@ -19981,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" : { @@ -46601,7 +46690,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unable to resize window smaller. Windows may overlap." + "value" : "Unable to resize window smaller.\nWindows may overlap." } } } @@ -50688,4 +50777,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 2f2520236..b6feef875 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -14,6 +14,95 @@ class RectangleTests: XCTestCase { } } +final class FootprintAlphaDefaultsTests: XCTestCase { + private var savedAlpha: Double = 0 + private var savedBlur = false + private var storedAlpha: Any? + private var storedBlur: Any? + + override func setUp() { + super.setUp() + savedAlpha = Defaults.footprintAlpha.value + savedBlur = Defaults.footprintBlur.enabled + storedAlpha = UserDefaults.standard.object(forKey: Defaults.footprintAlpha.key) + storedBlur = UserDefaults.standard.object(forKey: Defaults.footprintBlur.key) + UserDefaults.standard.removeObject(forKey: Defaults.footprintAlpha.key) + } + + override func tearDown() { + Defaults.footprintAlpha.value = savedAlpha + Defaults.footprintBlur.enabled = savedBlur + UserDefaults.standard.set(storedAlpha, forKey: Defaults.footprintAlpha.key) + UserDefaults.standard.set(storedBlur, forKey: Defaults.footprintBlur.key) + super.tearDown() + } + + func testUnsetAlphaFollowsPreviewStyleWithoutSavingAValue() { + let window = FootprintWindow(accessibility: { + FootprintAccessibility(reduceMotion: false, reduceTransparency: false) + }) + defer { window.close() } + + for blurred in [false, true, false] { + Defaults.footprintBlur.enabled = blurred + XCTAssertEqual(Defaults.effectiveFootprintAlpha, blurred ? 0 : 0.3) + XCTAssertEqual(window.presentation.alpha, blurred ? 1 : 0.3) + XCTAssertEqual(window.presentation.usesBlur, blurred) + XCTAssertNil(UserDefaults.standard.object(forKey: Defaults.footprintAlpha.key)) + } + } + + func testExplicitZeroSurvivesStyleChangesReloadAndConfigRoundTrip() throws { + Defaults.footprintAlpha.value = 0 + for blurred in [false, true] { + Defaults.footprintBlur.enabled = blurred + XCTAssertEqual(Defaults.effectiveFootprintAlpha, 0) + XCTAssertEqual(DoubleDefault(key: Defaults.footprintAlpha.key).value, 0) + + let exported = try exportedAlpha() + XCTAssertEqual(exported.double, 0) + XCTAssertNil(exported.float) + Defaults.footprintAlpha.value = 0.8 + Defaults.footprintAlpha.load(from: exported) + XCTAssertEqual(Defaults.effectiveFootprintAlpha, 0) + } + } + + func testLegacyFloatValuesAndDoublePrecisionArePreserved() throws { + for json in [#"{"float":0}"#, #"{"float":0.4}"#, #"{"double":0.123456789012345}"#] { + let imported = try JSONDecoder().decode(CodableDefault.self, from: Data(json.utf8)) + let expected = imported.double ?? Double(imported.float!) + Defaults.footprintAlpha.load(from: imported) + for blurred in [false, true] { + Defaults.footprintBlur.enabled = blurred + XCTAssertEqual(Defaults.effectiveFootprintAlpha, expected) + XCTAssertEqual(DoubleDefault(key: Defaults.footprintAlpha.key).value, expected) + XCTAssertEqual(try exportedAlpha().double, expected) + } + } + } + + func testExportUsesTheEffectiveUnsetAlpha() throws { + for blurred in [false, true] { + UserDefaults.standard.removeObject(forKey: Defaults.footprintAlpha.key) + Defaults.footprintBlur.enabled = blurred + let exported = try exportedAlpha() + XCTAssertEqual(exported.double, blurred ? 0 : 0.3) + XCTAssertNil(UserDefaults.standard.object(forKey: Defaults.footprintAlpha.key)) + + Defaults.footprintAlpha.value = 0.8 + Defaults.footprintAlpha.load(from: exported) + XCTAssertEqual(Defaults.effectiveFootprintAlpha, blurred ? 0 : 0.3) + } + } + + private func exportedAlpha() throws -> CodableDefault { + let json = try XCTUnwrap(Defaults.encoded()) + let config = try XCTUnwrap(Defaults.convert(jsonString: json)) + return try XCTUnwrap(config.defaults[Defaults.footprintAlpha.key]) + } +} + class PositionCyclesTests: XCTestCase { func testSixthsReturnTrue() { @@ -3560,6 +3649,207 @@ class OverlapOffsetGuardsTests: XCTestCase { } } +final class WindowDragGeometryTests: XCTestCase { + private let initial = CGRect(x: -1983, y: -964, width: 1920, height: 1016) + + func testDetectsMovementWhileAccessibilityStillReportsTheInitialFrame() throws { + let moved = initial.offsetBy(dx: 0, dy: 2) + var accessibilityReads = 0 + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: initial, + serverFrame: moved, accessibilityFrame: { + accessibilityReads += 1 + return self.initial + })) + XCTAssertTrue(geometry.isMoving) + XCTAssertTrue(geometry.movedWithoutResizing) + XCTAssertEqual(geometry.currentFrame, moved) + XCTAssertEqual(accessibilityReads, 0) + } + + func testDifferentCoordinateSourcesDoNotCreateFalseMovement() throws { + let accessibility = initial.offsetBy(dx: 1, dy: 1) + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: accessibility, initialServerFrame: initial, + serverFrame: initial, accessibilityFrame: { accessibility })) + XCTAssertFalse(geometry.isMoving) + XCTAssertFalse(geometry.isResizing) + } + + func testUnavailableServerFrameFallsBackToAccessibilityBaseline() throws { + let accessibility = initial.offsetBy(dx: 1, dy: 1) + for unavailable in [nil, CGRect.null, CGRect.zero] as [CGRect?] { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: accessibility, initialServerFrame: initial, + serverFrame: unavailable, accessibilityFrame: { accessibility })) + XCTAssertFalse(geometry.isMoving) + XCTAssertEqual(geometry.currentFrame, accessibility) + } + } + + func testServerFrameWithoutServerBaselineUsesAccessibility() throws { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: nil, + serverFrame: initial.offsetBy(dx: 20, dy: 20), + accessibilityFrame: { self.initial })) + XCTAssertFalse(geometry.isMoving) + } + + func testResizingFromEitherCornerDoesNotTriggerRestore() throws { + let frames = [CGRect(x: initial.minX, y: initial.minY, width: 1800, height: 900), + CGRect(x: initial.minX + 120, y: initial.minY + 100, width: 1800, height: 916)] + for frame in frames { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: initial, + serverFrame: frame, accessibilityFrame: { nil })) + XCTAssertTrue(geometry.isResizing) + XCTAssertFalse(geometry.isMoving) + XCTAssertFalse(geometry.movedWithoutResizing) + } + } + + func testMovingAndChangingSizeAcrossDisplaysStillCountsAsMovement() throws { + let geometry = try XCTUnwrap(WindowDragGeometry(initialFrame: initial, initialServerFrame: initial, + serverFrame: CGRect(x: 20, y: 30, width: 1400, height: 800), + accessibilityFrame: { nil })) + XCTAssertTrue(geometry.isMoving) + XCTAssertFalse(geometry.movedWithoutResizing) + } + + func testMissingGeometryDoesNotCreateADrag() { + XCTAssertNil(WindowDragGeometry(initialFrame: initial, initialServerFrame: nil, + serverFrame: nil, accessibilityFrame: { nil })) + XCTAssertNil(WindowDragGeometry(initialFrame: initial, initialServerFrame: nil, + serverFrame: nil, accessibilityFrame: { .null })) + } +} + +final class DragRestorePlacementTests: XCTestCase { + private let current = CGRect(x: -1983, y: -950, width: 1920, height: 1016) + private let size = CGSize(width: 1100, height: 650) + + func testDisplayedFrameUsesTheOriginalGrabOffsetInsteadOfANewerMouseSample() { + let initial = CGRect(x: -1983, y: -964, width: 1920, height: 1016) + let displayed = initial.offsetBy(dx: 0, dy: 38) + let reference = DragRestorePlacement.referenceCursor(current: displayed, initial: initial, + mouseDown: CGPoint(x: -255, y: -934), + fallback: CGPoint(x: -255, y: -858)) + XCTAssertEqual(reference, CGPoint(x: -255, y: -896)) + XCTAssertEqual(reference.y - displayed.minY, 30) + } + + func testMissingMouseDownUsesTheAvailableCursorSample() { + let cursor = CGPoint(x: -561, y: -920) + XCTAssertEqual(DragRestorePlacement.referenceCursor(current: current, initial: current, + mouseDown: nil, fallback: cursor), cursor) + } + + func testLeftGrabKeepsNativePosition() { + let restored = DragRestorePlacement.frame(from: current, size: size, cursor: CGPoint(x: -1600, y: -920)) + XCTAssertEqual(restored.origin, current.origin) + XCTAssertEqual(restored.size, size) + } + + func testRightGrabMovesOnlyEnoughToKeepThePointerInside() { + let cursor = CGPoint(x: -561, y: -920) + let restored = DragRestorePlacement.frame(from: current, size: size, cursor: cursor) + XCTAssertEqual(restored.minX, -1629) + XCTAssertEqual(restored.maxX - cursor.x, 32) + XCTAssertTrue(restored.contains(cursor)) + XCTAssertLessThan(restored.maxX, current.maxX) + } + + func testNearbyGrabPointsDoNotSwitchToTheFarRightEdge() { + let cutoff = current.minX + size.width - 32 + let left = DragRestorePlacement.frame(from: current, size: size, cursor: CGPoint(x: cutoff - 1, y: -920)) + let right = DragRestorePlacement.frame(from: current, size: size, cursor: CGPoint(x: cutoff + 1, y: -920)) + XCTAssertEqual(left.minX, current.minX) + XCTAssertEqual(right.minX - left.minX, 1) + } + + func testGrabAtTheFarRightDoesNotPushPastTheOriginalEdge() { + let restored = DragRestorePlacement.frame(from: current, size: size, + cursor: CGPoint(x: current.maxX - 1, y: -920)) + XCTAssertEqual(restored.maxX, current.maxX) + } + + func testGrowingFromAHalfScreenDoesNotReposition() { + let half = CGRect(x: 400, y: 100, width: 800, height: 900) + let restored = DragRestorePlacement.frame(from: half, size: size, cursor: CGPoint(x: 1190, y: 130)) + XCTAssertEqual(restored.origin, half.origin) + } + + func testPlacementIsTheSameRelativeToEitherDisplay() { + let cursor = CGPoint(x: -561, y: -920) + let external = DragRestorePlacement.frame(from: current, size: size, cursor: cursor) + let local = DragRestorePlacement.frame(from: current.offsetBy(dx: 2000, dy: 1000), size: size, + cursor: CGPoint(x: cursor.x + 2000, y: cursor.y + 1000)) + XCTAssertEqual(local, external.offsetBy(dx: 2000, dy: 1000)) + XCTAssertEqual(DragRestorePlacement.frame(from: current, size: size, cursor: nil).origin, current.origin) + } +} + +final class DragRestoreReleaseTests: XCTestCase { + private final class WindowElement: AccessibilityElement { + override var frame: CGRect { CGRect(x: 120, y: 120, width: 800, height: 600) } + override func beginAnimatedAdjustment() -> () -> Void { {} } + override func setAnimationFrame(_ frame: CGRect, resizeOnly: Bool = false) -> Bool { true } + } + + private final class Manager: SnappingManager { + var restores = 0 + override func unsnapRestore(windowId: CGWindowID, currentRect: CGRect, cursorLoc: CGPoint?) { + restores += 1 + } + override func snapAreaContainingCursor(priorSnapArea: SnapArea?) -> SnapArea? { nil } + } + + private func release(dragAlreadyDetected: Bool) throws -> Manager { + let saved = Defaults.windowSnapping.enabled + Defaults.windowSnapping.enabled = false + defer { Defaults.windowSnapping.enabled = saved } + let manager = Manager() + manager.windowElement = WindowElement(AXUIElementCreateSystemWide()) + manager.windowId = .max + manager.initialWindowRect = CGRect(x: 100, y: 100, width: 800, height: 600) + manager.windowMoving = dragAlreadyDetected + let event = try XCTUnwrap(NSEvent.mouseEvent(with: .leftMouseUp, location: .zero, modifierFlags: [], + timestamp: 1, windowNumber: 0, context: nil, + eventNumber: 1, clickCount: 1, pressure: 0)) + manager.handle(event: event) + return manager + } + + func testMouseUpDoesNotRestoreAgainWhenDisplayedSizeHasNotCaughtUp() throws { + let manager = try release(dragAlreadyDetected: true) + XCTAssertEqual(manager.restores, 0) + XCTAssertFalse(manager.windowMoving) + XCTAssertNil(manager.windowId) + XCTAssertNil(manager.windowElement) + } + + func testMouseUpStillRestoresAQuickDragThatHadNotBeenDetected() throws { + let manager = try release(dragAlreadyDetected: false) + XCTAssertEqual(manager.restores, 1) + XCTAssertFalse(manager.windowMoving) + XCTAssertNil(manager.initialWindowRect) + } + + func testQuickReleaseKeepsTheRemainingAnimationInsteadOfJumpingToItsDestination() throws { + let saved = Defaults.experimentalWindowAnimations.enabled + Defaults.experimentalWindowAnimations.enabled = true + defer { + WindowAnimator.shared.finish() + Defaults.experimentalWindowAnimations.enabled = saved + } + try XCTSkipUnless(WindowAnimator.enabled, "Window animations are disabled by accessibility settings") + let window = WindowElement(AXUIElementCreateSystemWide()) + let destination = CGRect(x: 300, y: 120, width: 500, height: 400) + var completions = 0 + WindowAnimator.shared.animate(window, to: destination, duration: 0.18) { _ in completions += 1 } + + _ = try release(dragAlreadyDetected: true) + + XCTAssertEqual(completions, 0) + XCTAssertEqual(WindowAnimator.shared.destination(for: window), destination) + } +} + class SnappingManagerSessionTests: XCTestCase { private var savedSnappingEnabled: Bool? @@ -4228,6 +4518,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): @@ -4504,6 +4912,7 @@ final class WindowSizeConstraintExecutionTests: XCTestCase { let settings: [(Default, CodableDefault)] = [ (Defaults.subsequentExecutionMode, CodableDefault(int: SubsequentExecutionMode.none.rawValue)), (Defaults.cooperativeCornerResize, CodableDefault(bool: false)), + (Defaults.experimentalWindowAnimations, CodableDefault(bool: false)), (Defaults.useCursorScreenDetection, CodableDefault(bool: false)), (Defaults.moveFixedSizeToEdge, CodableDefault(int: EdgeAlignment.edgesAndCorners.rawValue)), (Defaults.gapSize, CodableDefault(float: 0)), @@ -4681,7 +5090,7 @@ final class WindowSizeConstraintExecutionTests: XCTestCase { override func getWindowId() -> CGWindowID? { nil } override func isResizable() -> Bool { true } - override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) { + override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { currentFrame = frame if frame.size == targetSize { resizeAttempts += 1 @@ -4710,8 +5119,8 @@ final class WindowSizeConstraintExecutionTests: XCTestCase { override func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) {} - override func postProcess(result: ResultParameters, resultingRect: CGRect) { - super.postProcess(result: result, resultingRect: resultingRect) + override func postProcess(result: ResultParameters, resultingRect: CGRect, incrementCount: Bool = true) { + super.postProcess(result: result, resultingRect: resultingRect, incrementCount: incrementCount) didFinish?() } } @@ -4816,8 +5225,8 @@ final class CrossDisplayResizeTests: XCTestCase { override func getWindowId() -> CGWindowID? { nil } override func isResizable() -> Bool { true } - override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true) { - currentFrame = frame + override func setFrame(_ frame: CGRect, adjustSizeFirst: Bool = true, adjustPosition: Bool = true) { + currentFrame = CGRect(origin: adjustPosition ? frame.origin : currentFrame.origin, size: frame.size) if frame.size == target.size { resizeAttempts += 1 if resizeAttempts <= 2 { @@ -4832,7 +5241,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) } } diff --git a/TerminalCommands.md b/TerminalCommands.md index 1d75831d1..ce859eaef 100644 --- a/TerminalCommands.md +++ b/TerminalCommands.md @@ -305,13 +305,13 @@ defaults write com.knollsoft.Rectangle cascadeActiveApp -dict-add keyCode -float ## Modify the "footprint" displayed for drag to snap area -Adjust the alpha (transparency). Default is 0.3. +Adjust the alpha (transparency). Default is 0.3, or 0 for the blurred preview, where it controls tint opacity. ```bash defaults write com.knollsoft.Rectangle footprintAlpha -float ``` -Change the border width. Default is 2 (used to be 1). +Change the border width. Default is 2 (used to be 1), or 1 for the blurred preview. A custom value overrides either default. ```bash defaults write com.knollsoft.Rectangle footprintBorderWidth -float @@ -323,13 +323,13 @@ Disable the fade. defaults write com.knollsoft.Rectangle footprintFade -int 2 ``` -Change the color. +Change the color. With blur enabled, this sets the tint color. Delete `footprintColor` to restore the automatic light/dark tint color. ```bash defaults write com.knollsoft.Rectangle footprintColor -string "{\"red\":0,\"blue\":0.5,\"green\":0.5}" ``` -Change the animation duration. The value is a multiplier. Default is 0 (no animation). +Change the animation duration. The value is a multiplier. Default is 0 (no movement animation). ```bash defaults write com.knollsoft.Rectangle footprintAnimationDurationMultiplier -float