diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29b..00000000 diff --git a/Loop/Extensions/CGGeometry+Extensions.swift b/Loop/Extensions/CGGeometry+Extensions.swift index 5e685a73..8f4ff35b 100644 --- a/Loop/Extensions/CGGeometry+Extensions.swift +++ b/Loop/Extensions/CGGeometry+Extensions.swift @@ -61,6 +61,19 @@ extension CGSize { height: height ) } + + func fitting(aspectRatio: CGFloat) -> CGSize { + guard width > 0, height > 0, aspectRatio > 0 else { + return self + } + + let sizeAspectRatio = width / height + if sizeAspectRatio > aspectRatio { + return CGSize(width: height * aspectRatio, height: height) + } else { + return CGSize(width: width, height: width / aspectRatio) + } + } } extension CGRect { diff --git a/Loop/Settings Window/Loop/AboutConfiguration.swift b/Loop/Settings Window/Loop/AboutConfiguration.swift index d7ee9a73..ffb68782 100644 --- a/Loop/Settings Window/Loop/AboutConfiguration.swift +++ b/Loop/Settings Window/Loop/AboutConfiguration.swift @@ -249,7 +249,7 @@ struct AboutConfigurationView: View { Button { Task { await updater.fetchLatestInfo(bypassUpdatesEnabled: true) - + switch updater.updateState { case .available: await updater.showUpdateWindowIfEligible() diff --git a/Loop/Settings Window/Loop/ExcludedAppsConfiguration.swift b/Loop/Settings Window/Loop/ExcludedAppsConfiguration.swift index af8f2b21..21834887 100644 --- a/Loop/Settings Window/Loop/ExcludedAppsConfiguration.swift +++ b/Loop/Settings Window/Loop/ExcludedAppsConfiguration.swift @@ -20,7 +20,7 @@ struct ExcludedAppsConfigurationView: View { Button("Add") { showAppChooser() } - + Button("Remove", role: .destructive) { excludedApps.removeAll { selectedApps.contains($0) } } @@ -28,7 +28,7 @@ struct ExcludedAppsConfigurationView: View { .keyboardShortcut(.delete) } .luminareRoundingBehavior(top: true) - + LuminareList( items: $excludedApps, selection: $selectedApps, diff --git a/Loop/Settings Window/Theming/AccentColorConfiguration.swift b/Loop/Settings Window/Theming/AccentColorConfiguration.swift index 5fd2a431..3e59a37c 100644 --- a/Loop/Settings Window/Theming/AccentColorConfiguration.swift +++ b/Loop/Settings Window/Theming/AccentColorConfiguration.swift @@ -27,14 +27,14 @@ struct AccentColorConfigurationView: View { LuminareForm { LuminareSection { accentColorModePicker - + LuminareToggle("Gradient", isOn: $useGradient) - + if accentColorMode == .wallpaper { syncWallpaperButton } } - + if accentColorMode == .custom { LuminareSection(String(localized: "Color", comment: "Section header shown in settings")) { LuminareColorPicker( @@ -42,7 +42,7 @@ struct AccentColorConfigurationView: View { style: .textFieldWithColorWell() ) .luminareRoundingBehavior(top: true, bottom: true) - + if useGradient { LuminareColorPicker( color: $gradientColor, @@ -57,7 +57,7 @@ struct AccentColorConfigurationView: View { } .animation(luminareAnimation, value: accentColorMode) } - + private var accentColorModePicker: some View { LuminarePicker( elements: AccentColorOption.allCases, @@ -66,10 +66,10 @@ struct AccentColorConfigurationView: View { ) { option in VStack(spacing: 6) { Spacer() - + option.image Text(option.text) - + Spacer() } .font(.title3) @@ -78,12 +78,12 @@ struct AccentColorConfigurationView: View { .luminareRoundingBehavior(top: true) .environment(\.appearsActive, true) // Keep on active state to show accent color } - + private var syncWallpaperButton: some View { Button(action: syncWallpaper) { HStack { Text("Sync Wallpaper") - + if didSyncWallpaper { Image(systemName: "checkmark") .foregroundStyle(.green) diff --git a/Loop/Settings Window/Theming/IconConfiguration.swift b/Loop/Settings Window/Theming/IconConfiguration.swift index 16037ada..68f29f56 100644 --- a/Loop/Settings Window/Theming/IconConfiguration.swift +++ b/Loop/Settings Window/Theming/IconConfiguration.swift @@ -112,7 +112,7 @@ struct IconConfigurationView: View { get: { IconManager.currentAppIcon }, set: { currentIcon = $0.assetName - + Task { IconManager.refreshCurrentAppIcon() } @@ -131,7 +131,7 @@ struct IconConfigurationView: View { } .luminareRoundingBehavior(top: true, bottom: true) } - + LuminareSection(String(localized: "Options", comment: "Section header shown in settings")) { LuminareToggle("Show in dock", isOn: $showDockIcon) LuminareToggle( diff --git a/Loop/Settings Window/Theming/PreviewConfiguration.swift b/Loop/Settings Window/Theming/PreviewConfiguration.swift index 16986d11..0cc8cdf6 100644 --- a/Loop/Settings Window/Theming/PreviewConfiguration.swift +++ b/Loop/Settings Window/Theming/PreviewConfiguration.swift @@ -31,7 +31,7 @@ struct PreviewConfigurationView: View { }, set: { previewVisibility = $0 - + if !previewVisibility { moveCursorWithWindow = false } @@ -46,7 +46,7 @@ struct PreviewConfigurationView: View { } .animation(luminareAnimation, value: previewVisibility) } - + LuminareSlider( "Padding", value: $previewPadding.doubleBinding, @@ -56,7 +56,7 @@ struct PreviewConfigurationView: View { clampsLower: true, suffix: Text("px", comment: "Unit symbol: pixels") ) - + // On macOS Sequoia and below, simply show the corner radius slider. if #unavailable(macOS 26) { LuminareSlider( @@ -69,7 +69,7 @@ struct PreviewConfigurationView: View { suffix: Text("px", comment: "Unit symbol: pixels") ) } - + LuminareSlider( "Border thickness", value: $previewBorderThickness.doubleBinding, @@ -80,7 +80,7 @@ struct PreviewConfigurationView: View { suffix: Text("px", comment: "Unit symbol: pixels") ) } - + // On macOS Tahoe and above, Loop has the ability to read the selected window's corner radius. // So display it in a separate section, with the option to configure this functionality. if #available(macOS 26, *) { @@ -89,7 +89,7 @@ struct PreviewConfigurationView: View { "Prioritize selected window’s corner radius", isOn: $previewUseWindowCornerRadius ) - + LuminareSlider( previewUseWindowCornerRadius ? "Default corner radius" : "Corner radius", value: $previewCornerRadius.doubleBinding, @@ -102,10 +102,10 @@ struct PreviewConfigurationView: View { } .animation(luminareAnimation, value: previewUseWindowCornerRadius) } - + LuminareSection("Background") { LuminareToggle("Enable blur", isOn: $previewBackgroundEnableBlur) - + LuminareSlider( "Accent opacity", value: $previewBackgroundAccentOpacity.doubleBinding, diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift index c3fde565..03affaab 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -12,7 +12,7 @@ import SwiftUI struct RadialMenuConfigurationView: View { @EnvironmentObject private var windowModel: SettingsWindowManager @Environment(\.luminareAnimation) private var luminareAnimation - + @Default(.radialMenuVisibility) private var radialMenuVisibility @Default(.radialMenuCornerRadius) private var radialMenuCornerRadius @Default(.radialMenuThickness) private var radialMenuThickness @@ -24,7 +24,7 @@ struct RadialMenuConfigurationView: View { LuminareForm { LuminareSection { LuminareToggle("Radial menu", isOn: $radialMenuVisibility) - + if radialMenuVisibility { LuminareSlider( "Corner radius", @@ -40,7 +40,7 @@ struct RadialMenuConfigurationView: View { radialMenuThickness = radialMenuCornerRadius - 1 } } - + LuminareSlider( "Thickness", value: $radialMenuThickness.doubleBinding, @@ -58,7 +58,7 @@ struct RadialMenuConfigurationView: View { } } .animation(luminareAnimation, value: radialMenuVisibility) - + if enableRadialMenuCustomization { LuminareSection( String(localized: "Actions", comment: "Header for radial menu section shown in settings"), @@ -68,7 +68,7 @@ struct RadialMenuConfigurationView: View { Button("Add") { radialMenuActions.insert(.custom(.init(.noAction)), at: 0) } - + Button("Remove", role: .destructive) { radialMenuActions.removeAll(where: selectedRadialMenuActions.contains) } @@ -76,7 +76,7 @@ struct RadialMenuConfigurationView: View { .keyboardShortcut(.delete) } .luminareRoundingBehavior(top: true) - + LuminareList( items: $radialMenuActions, selection: $selectedRadialMenuActions, diff --git a/Loop/Window Management/Window Manipulation/PaddingConfiguration.swift b/Loop/Window Management/Window Manipulation/PaddingConfiguration.swift index 64f9c8cf..605eba23 100644 --- a/Loop/Window Management/Window Manipulation/PaddingConfiguration.swift +++ b/Loop/Window Management/Window Manipulation/PaddingConfiguration.swift @@ -76,6 +76,7 @@ struct PaddingConfiguration: Codable, Defaults.Serializable, Hashable { if let resolvedWindowProperties, !resolvedWindowProperties.isResizable { let centeredFrame = resolvedWindowProperties.frame.size .center(inside: result) + .pushInside(bounds) return centeredFrame } diff --git a/Loop/Window Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index 02b10e17..5d44ac50 100644 --- a/Loop/Window Management/Window Manipulation/WindowEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowEngine.swift @@ -64,10 +64,13 @@ enum WindowEngine { await window.focus() } + let finalFrame: CGRect + // Attempt system window manager if possible if !willChangeScreens, useSystemWM, #available(macOS 15, *), await resizeWithSystemWindowManager(window: window, to: context.action) { + finalFrame = window.frame } else { if context.resolvedWindowProperties?.isFullscreen ?? true { // Otherwise, we obviously need to disable fullscreen to resize the window @@ -81,7 +84,7 @@ enum WindowEngine { ) do { - try await resizeWindow( + finalFrame = try await resizeWindow( window, targetFrame: targetFrame, bounds: context.paddedBounds, @@ -91,6 +94,7 @@ enum WindowEngine { ) } catch { log.error(error.localizedDescription) + finalFrame = window.frame } if Defaults[.moveCursorWithWindow] { @@ -98,29 +102,26 @@ enum WindowEngine { } } + let postResizeProperties = context.resolvedWindowProperties.map { + Window.ResolvedProperties(updating: finalFrame, from: $0) + } + // Record post-resize actions (replaces former defer block) if context.action.direction == .undo { await WindowRecords.shared.removeLastAction(for: window) } else if storeAsFrame { - // Pass nil for resolvedProperties so that record() reads the "live" - // post-resize frame via window.frame, rather than the stale - // pre-resize snapshot. await WindowRecords.shared.record( window, - resolvedProperties: nil, + resolvedProperties: postResizeProperties, context.action ) } // Update the snapshot - let actualFrame = window.frame - if let existing = context.resolvedWindowProperties { - context.resolvedWindowProperties = Window.ResolvedProperties( - updating: actualFrame, - from: existing - ) + if let postResizeProperties { + context.resolvedWindowProperties = postResizeProperties } - context.lastAppliedFrame = actualFrame + context.lastAppliedFrame = finalFrame context.resolvedRecord = await WindowRecords.ResolvedRecord(for: window) if let screen = context.screen { @@ -185,31 +186,113 @@ enum WindowEngine { willChangeScreens: Bool, animate: Bool, resolvedProperties: Window.ResolvedProperties? = nil - ) async throws { + ) async throws -> CGRect { + let actualFrame: CGRect + if animate { try await window.setFrameAnimated(targetFrame, bounds: bounds, resolvedProperties: resolvedProperties) + actualFrame = window.frame } else { await window.setFrame(targetFrame, sizeFirst: willChangeScreens, resolvedProperties: resolvedProperties) try Task.checkCancellation() - } - if !animate, !window.frame.approximatelyEqual(to: targetFrame) { - await window.setFrame(targetFrame, resolvedProperties: resolvedProperties) - try Task.checkCancellation() + var frameAfterResize = window.frame + if !frameAfterResize.approximatelyEqual(to: targetFrame) { + await window.setFrame(targetFrame, resolvedProperties: resolvedProperties) + try Task.checkCancellation() + frameAfterResize = window.frame + } + actualFrame = frameAfterResize } - handleSizeConstrainedWindow(window: window, bounds: bounds) + return handleSizeConstrainedWindow( + window: window, + actualFrame: actualFrame, + targetFrame: targetFrame, + bounds: bounds + ) } // MARK: - Size Constraints - private static func handleSizeConstrainedWindow(window: Window, bounds: CGRect) { - guard !window.isOwnWindow, bounds != .zero else { return } + private static func handleSizeConstrainedWindow( + window: Window, + actualFrame: CGRect, + targetFrame: CGRect, + bounds: CGRect + ) -> CGRect { + guard !window.isOwnWindow, bounds != .zero else { + return actualFrame + } + + // Some windows have size constraints such as fixed aspect ratios, fixed width, + // fixed height, etc. When that happens, preserve the intended anchor by + // re-positioning the resulting frame after the resize completes. + guard !actualFrame.size.approximatelyEqual(to: targetFrame.size, tolerance: 2) else { + return actualFrame + } + + let targetEdges = targetFrame.getEdgesTouchingBounds(bounds) + let correctedFrame = anchoredFrame( + for: actualFrame.size, + within: targetFrame, + targetEdges: targetEdges, + bounds: bounds + ) - var windowFrame = window.frame - if windowFrame.maxX > bounds.maxX { windowFrame.origin.x = bounds.maxX - windowFrame.width } - if windowFrame.maxY > bounds.maxY { windowFrame.origin.y = bounds.maxY - windowFrame.height } + guard !actualFrame.origin.approximatelyEqual(to: correctedFrame.origin, tolerance: 1) else { + return actualFrame + } + + window.setPosition(correctedFrame.origin) + return correctedFrame + } + + static func anchoredFrame( + for actualSize: CGSize, + within requestedFrame: CGRect, + targetEdges: Edge.Set, + bounds: CGRect + ) -> CGRect { + var frame = CGRect(origin: requestedFrame.origin, size: actualSize) + + if targetEdges.contains(.leading), targetEdges.contains(.trailing) { + frame.origin.x = requestedFrame.midX - actualSize.width / 2 + } else if targetEdges.contains(.leading) { + frame.origin.x = requestedFrame.minX + } else if targetEdges.contains(.trailing) { + frame.origin.x = requestedFrame.maxX - actualSize.width + } else { + frame.origin.x = requestedFrame.midX - actualSize.width / 2 + } + + if targetEdges.contains(.top), targetEdges.contains(.bottom) { + frame.origin.y = requestedFrame.midY - actualSize.height / 2 + } else if targetEdges.contains(.top) { + frame.origin.y = requestedFrame.minY + } else if targetEdges.contains(.bottom) { + frame.origin.y = requestedFrame.maxY - actualSize.height + } else { + frame.origin.y = requestedFrame.midY - actualSize.height / 2 + } + + return frame.pushInside(bounds) + } + + static func shouldAnchorDuringAnimation( + actualSize: CGSize, + requestedSize: CGSize, + tolerance: CGFloat = 2 + ) -> Bool { + guard !actualSize.approximatelyEqual(to: requestedSize, tolerance: tolerance) else { + return false + } - window.setPosition(windowFrame.origin) + // Only compensate during animation when the app ended up smaller than the + // requested frame (fixed aspect ratio, fixed width, fixed height, etc.) + // If the app stays larger because of a minimum size, preserving the + // requested motion avoids visible jitter while shrinking/moving + return actualSize.width <= requestedSize.width + tolerance && + actualSize.height <= requestedSize.height + tolerance } } diff --git a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift index b5bd44e4..57a6fde8 100644 --- a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift +++ b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift @@ -7,6 +7,20 @@ import SwiftUI +private enum ResizeAnimationConstraint { + case none + case fixedAxes(width: Bool, height: Bool) + case fixedAspectRatio(CGFloat) + + var hasFixedAxes: Bool { + if case .fixedAxes = self { + return true + } + + return false + } +} + /// Animate a window's resize! @MainActor final class WindowTransformAnimation: NSAnimation { @@ -15,10 +29,13 @@ final class WindowTransformAnimation: NSAnimation { private let window: Window private let bounds: CGRect private let shouldSetSize: Bool + private let targetEdges: Edge.Set + private let stationaryAxes: (x: Bool, y: Bool) private var didCallCompletionHandler: Bool = false private let completionHandler: (Error?) -> () private var lastWindowFrame: CGRect = .zero + private var constraint: ResizeAnimationConstraint = .none // Using ids for each ongoing animation, we can cancel as a new window animation is started for that specific window private var id: UUID = .init() @@ -31,11 +48,19 @@ final class WindowTransformAnimation: NSAnimation { shouldSetSize: Bool, completionHandler: @escaping (Error?) -> () ) { + let originalFrame = window.frame self.targetFrame = newRect - self.originalFrame = window.frame + self.originalFrame = originalFrame self.window = window self.bounds = bounds self.shouldSetSize = shouldSetSize + self.targetEdges = newRect.getEdgesTouchingBounds(bounds) + self.stationaryAxes = ( + x: newRect.origin.x.approximatelyEquals(to: originalFrame.origin.x, tolerance: 2) && + newRect.width.approximatelyEquals(to: originalFrame.width, tolerance: 2), + y: newRect.origin.y.approximatelyEquals(to: originalFrame.origin.y, tolerance: 2) && + newRect.height.approximatelyEquals(to: originalFrame.height, tolerance: 2) + ) self.completionHandler = completionHandler super.init(duration: 0.3, animationCurve: .easeOut) self.frameRate = Float(NSScreen.main?.displayMode?.refreshRate ?? 60.0) @@ -93,36 +118,221 @@ final class WindowTransformAnimation: NSAnimation { private func apply(progress: Float) { let value = CGFloat(1.0 - pow(1.0 - progress, 3)) - var newFrame = CGRect( + let requestedFrame = CGRect( x: round(originalFrame.origin.x + value * (targetFrame.origin.x - originalFrame.origin.x)), y: round(originalFrame.origin.y + value * (targetFrame.origin.y - originalFrame.origin.y)), width: round(originalFrame.size.width + value * (targetFrame.size.width - originalFrame.size.width)), height: round(originalFrame.size.height + value * (targetFrame.size.height - originalFrame.size.height)) ) - // Keep the window inside the bounds - if bounds != .zero { - let xDiff = lastWindowFrame.width - newFrame.width - if newFrame.maxX + xDiff > lastWindowFrame.maxX || currentValue >= 0.5, - newFrame.maxX + xDiff > bounds.maxX { - newFrame.origin.x = bounds.maxX - lastWindowFrame.width + var newFrame = requestedFrame + var currentOrigin = lastWindowFrame.origin + let resizeTolerance: CGFloat = 2 + + let sizeToSet = sizeToSet(for: requestedFrame) + + if shouldSetSize, !lastWindowFrame.size.approximatelyEqual(to: sizeToSet, tolerance: resizeTolerance) { + let growsHorizontally = sizeToSet.width > lastWindowFrame.width + resizeTolerance + let growsVertically = sizeToSet.height > lastWindowFrame.height + resizeTolerance + + if let preResizeOrigin = predictedPreResizeOrigin( + requestedFrame: requestedFrame, + sizeToSet: sizeToSet + ) { + if !lastWindowFrame.origin.approximatelyEqual(to: preResizeOrigin, tolerance: 1) { + window.setPosition(preResizeOrigin) + currentOrigin = preResizeOrigin + } + } else if growsHorizontally || growsVertically { + var preResizeOrigin = lastWindowFrame.origin + if growsHorizontally { + preResizeOrigin.x = requestedFrame.origin.x + } + if growsVertically { + preResizeOrigin.y = requestedFrame.origin.y + } + if !lastWindowFrame.origin.approximatelyEqual(to: preResizeOrigin, tolerance: 1) { + window.setPosition(preResizeOrigin) + currentOrigin = preResizeOrigin + } } - let yDiff = lastWindowFrame.height - newFrame.height - if newFrame.maxY + yDiff > lastWindowFrame.maxY || currentValue >= 0.5, - newFrame.maxY + yDiff > bounds.maxY { - newFrame.origin.y = bounds.maxY - lastWindowFrame.height + window.setSize(sizeToSet) + if bounds != .zero { + let actualFrame = window.frame + updateConstraint(actualFrame: actualFrame, requestedFrame: requestedFrame, tolerance: resizeTolerance) + + if WindowEngine.shouldAnchorDuringAnimation( + actualSize: actualFrame.size, + requestedSize: requestedFrame.size + ) { + newFrame = animationAnchoredFrame( + for: actualFrame.size, + within: requestedFrame, + currentOrigin: currentOrigin + ) + } else { + newFrame = CGRect( + origin: requestedFrame.origin, + size: actualFrame.size + ) + .pushInside(bounds) + } } + } else if bounds != .zero, constraint.hasFixedAxes { + newFrame = animationAnchoredFrame( + for: lastWindowFrame.size, + within: requestedFrame, + currentOrigin: lastWindowFrame.origin + ) + } else if bounds != .zero { + newFrame = newFrame.pushInside(bounds) } - if lastWindowFrame.origin != newFrame.origin { + if !currentOrigin.approximatelyEqual(to: newFrame.origin, tolerance: 1) { window.setPosition(newFrame.origin) } - if shouldSetSize, lastWindowFrame.size != newFrame.size { - window.setSize(newFrame.size) + lastWindowFrame = window.frame + } + + private func animationAnchoredFrame( + for actualSize: CGSize, + within requestedFrame: CGRect, + currentOrigin: CGPoint + ) -> CGRect { + var frame = WindowEngine.anchoredFrame( + for: actualSize, + within: requestedFrame, + targetEdges: targetEdges, + bounds: bounds + ) + + if stationaryAxes.x, actualSize.width.approximatelyEquals(to: requestedFrame.width, tolerance: 2) { + frame.origin.x = currentOrigin.x + } + if stationaryAxes.y, actualSize.height.approximatelyEquals(to: requestedFrame.height, tolerance: 2) { + frame.origin.y = currentOrigin.y } - lastWindowFrame = window.frame + return frame.pushInside(bounds) + } + + private func sizeToSet(for requestedFrame: CGRect) -> CGSize { + switch constraint { + case .none, .fixedAspectRatio: + requestedFrame.size + case let .fixedAxes(width, height): + CGSize( + width: width ? lastWindowFrame.width : requestedFrame.width, + height: height ? lastWindowFrame.height : requestedFrame.height + ) + } + } + + private func predictedPreResizeOrigin(requestedFrame: CGRect, sizeToSet: CGSize) -> CGPoint? { + guard case let .fixedAspectRatio(aspectRatio) = constraint, + bounds != .zero else { + return nil + } + + let predictedSize = sizeToSet.fitting(aspectRatio: aspectRatio) + return animationAnchoredFrame( + for: predictedSize, + within: requestedFrame, + currentOrigin: lastWindowFrame.origin + ) + .origin + } + + private func updateConstraint(actualFrame: CGRect, requestedFrame: CGRect, tolerance: CGFloat) { + let acceptedSize = actualFrame.size + guard acceptedSize.width <= requestedFrame.width + tolerance, + acceptedSize.height <= requestedFrame.height + tolerance else { + constraint = .none + return + } + + let requestedWidthChanged = !requestedFrame.width.approximatelyEquals( + to: lastWindowFrame.width, + tolerance: tolerance + ) + let requestedHeightChanged = !requestedFrame.height.approximatelyEquals( + to: lastWindowFrame.height, + tolerance: tolerance + ) + + if requestedWidthChanged, requestedHeightChanged, + hasStableAspectRatio(acceptedSize, comparedTo: lastWindowFrame.size), + WindowEngine.shouldAnchorDuringAnimation( + actualSize: acceptedSize, + requestedSize: requestedFrame.size, + tolerance: tolerance + ), acceptedSize.width > 0, acceptedSize.height > 0 { + constraint = .fixedAspectRatio(acceptedSize.width / acceptedSize.height) + return + } + + if case .fixedAxes = constraint { + return + } + + let shouldLockWidth = shouldLockSizeAxis( + actual: actualFrame.width, + previous: lastWindowFrame.width, + requested: requestedFrame.width, + tolerance: tolerance + ) + + let shouldLockHeight = shouldLockSizeAxis( + actual: actualFrame.height, + previous: lastWindowFrame.height, + requested: requestedFrame.height, + tolerance: tolerance + ) + + if didSizeChange(acceptedSize, comparedTo: lastWindowFrame.size, tolerance: tolerance), + hasStableAspectRatio(acceptedSize, comparedTo: lastWindowFrame.size), + WindowEngine.shouldAnchorDuringAnimation( + actualSize: acceptedSize, + requestedSize: requestedFrame.size, + tolerance: tolerance + ), acceptedSize.width > 0, acceptedSize.height > 0 { + constraint = .fixedAspectRatio(acceptedSize.width / acceptedSize.height) + return + } + + if shouldLockWidth || shouldLockHeight { + constraint = .fixedAxes(width: shouldLockWidth, height: shouldLockHeight) + } + } + + private func didSizeChange(_ size: CGSize, comparedTo previousSize: CGSize, tolerance: CGFloat) -> Bool { + !size.approximatelyEqual(to: previousSize, tolerance: tolerance) + } + + private func hasStableAspectRatio(_ size: CGSize, comparedTo previousSize: CGSize) -> Bool { + guard size.width > 0, size.height > 0, + previousSize.width > 0, previousSize.height > 0 else { + return false + } + + return (size.width / size.height).approximatelyEquals( + to: previousSize.width / previousSize.height, + tolerance: 0.01 + ) + } + + private func shouldLockSizeAxis( + actual: CGFloat, + previous: CGFloat, + requested: CGFloat, + tolerance: CGFloat + ) -> Bool { + let requestedChanged = !requested.approximatelyEquals(to: previous, tolerance: tolerance) + let actualDidNotChange = actual.approximatelyEquals(to: previous, tolerance: tolerance) + let constrainedBelowRequest = actual <= requested + tolerance + + return requestedChanged && actualDidNotChange && constrainedBelowRequest } }