From 7742814a654a57adf82f95082e072faf98ff416b Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Wed, 13 May 2026 22:47:42 -0600 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Better-handle=20size=20constrai?= =?UTF-8?q?ned=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitmodules | 0 .../PaddingConfiguration.swift | 1 + .../Window Manipulation/WindowEngine.swift | 50 +++++++++++++++++-- .../WindowTransformAnimation.swift | 34 ++++++------- 4 files changed, 63 insertions(+), 22 deletions(-) delete mode 100644 .gitmodules diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29b..00000000 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..99b6d7c1 100644 --- a/Loop/Window Management/Window Manipulation/WindowEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowEngine.swift @@ -198,18 +198,60 @@ enum WindowEngine { try Task.checkCancellation() } - handleSizeConstrainedWindow(window: window, bounds: bounds) + handleSizeConstrainedWindow(window: window, targetFrame: targetFrame, bounds: bounds) } // MARK: - Size Constraints - private static func handleSizeConstrainedWindow(window: Window, bounds: CGRect) { + private static func handleSizeConstrainedWindow(window: Window, targetFrame: CGRect, bounds: CGRect) { guard !window.isOwnWindow, bounds != .zero else { return } 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 } + let targetEdges = targetFrame.getEdgesTouchingBounds(bounds) + + // 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. + if !windowFrame.size.approximatelyEqual(to: targetFrame.size, tolerance: 2) { + windowFrame = anchoredFrame( + for: windowFrame.size, + within: targetFrame, + targetEdges: targetEdges, + bounds: bounds + ) + } window.setPosition(windowFrame.origin) } + + 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) + } } diff --git a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift index b5bd44e4..bbed589e 100644 --- a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift +++ b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift @@ -15,6 +15,7 @@ final class WindowTransformAnimation: NSAnimation { private let window: Window private let bounds: CGRect private let shouldSetSize: Bool + private let targetEdges: Edge.Set private var didCallCompletionHandler: Bool = false private let completionHandler: (Error?) -> () @@ -36,6 +37,7 @@ final class WindowTransformAnimation: NSAnimation { self.window = window self.bounds = bounds self.shouldSetSize = shouldSetSize + self.targetEdges = newRect.getEdgesTouchingBounds(bounds) self.completionHandler = completionHandler super.init(duration: 0.3, animationCurve: .easeOut) self.frameRate = Float(NSScreen.main?.displayMode?.refreshRate ?? 60.0) @@ -93,36 +95,32 @@ 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 - } - - 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 - } + var newFrame = requestedFrame + + if shouldSetSize, lastWindowFrame.size != requestedFrame.size { + window.setSize(requestedFrame.size) + let actualFrame = window.frame + newFrame = WindowEngine.anchoredFrame( + for: actualFrame.size, + within: requestedFrame, + targetEdges: targetEdges, + bounds: bounds + ) + } else if bounds != .zero { + newFrame = newFrame.pushInside(bounds) } if lastWindowFrame.origin != newFrame.origin { window.setPosition(newFrame.origin) } - if shouldSetSize, lastWindowFrame.size != newFrame.size { - window.setSize(newFrame.size) - } - lastWindowFrame = window.frame } } From 15faf07df856c1d68e011d61d6b4203c3d261860 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Wed, 13 May 2026 22:55:44 -0600 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Loop/AboutConfiguration.swift | 2 +- .../Loop/ExcludedAppsConfiguration.swift | 4 ++-- .../Theming/AccentColorConfiguration.swift | 18 +++++++++--------- .../Theming/IconConfiguration.swift | 4 ++-- .../Theming/PreviewConfiguration.swift | 16 ++++++++-------- .../RadialMenuConfigurationView.swift | 12 ++++++------ 6 files changed, 28 insertions(+), 28 deletions(-) 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, From 4edc6de2f965b26c1df646015d4de10680f9eaed Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Wed, 13 May 2026 23:03:14 -0600 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20Review=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WindowTransformAnimation.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift index bbed589e..a6bd7a2d 100644 --- a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift +++ b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift @@ -106,13 +106,15 @@ final class WindowTransformAnimation: NSAnimation { if shouldSetSize, lastWindowFrame.size != requestedFrame.size { window.setSize(requestedFrame.size) - let actualFrame = window.frame - newFrame = WindowEngine.anchoredFrame( - for: actualFrame.size, - within: requestedFrame, - targetEdges: targetEdges, - bounds: bounds - ) + if bounds != .zero { + let actualFrame = window.frame + newFrame = WindowEngine.anchoredFrame( + for: actualFrame.size, + within: requestedFrame, + targetEdges: targetEdges, + bounds: bounds + ) + } } else if bounds != .zero { newFrame = newFrame.pushInside(bounds) } From 87e75999ec8cfb51760282b3f47d197555c36738 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 14 May 2026 01:50:59 -0600 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8=20Better-support=20fixed=20size?= =?UTF-8?q?=20windows=20(as=20opposed=20to=20fixed=20aspect=20ratio)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Window Manipulation/WindowEngine.swift | 103 +++++++---- .../WindowTransformAnimation.swift | 175 +++++++++++++++++- 2 files changed, 238 insertions(+), 40 deletions(-) diff --git a/Loop/Window Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index 99b6d7c1..f9fc8ddc 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,43 +186,66 @@ 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, targetFrame: targetFrame, bounds: bounds) + return handleSizeConstrainedWindow( + window: window, + actualFrame: actualFrame, + targetFrame: targetFrame, + bounds: bounds + ) } // MARK: - Size Constraints - private static func handleSizeConstrainedWindow(window: Window, targetFrame: CGRect, bounds: CGRect) { - guard !window.isOwnWindow, bounds != .zero else { return } - - var windowFrame = window.frame - let targetEdges = targetFrame.getEdgesTouchingBounds(bounds) + 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. - if !windowFrame.size.approximatelyEqual(to: targetFrame.size, tolerance: 2) { - windowFrame = anchoredFrame( - for: windowFrame.size, - within: targetFrame, - targetEdges: targetEdges, - bounds: bounds - ) + 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 + ) + + guard !actualFrame.origin.approximatelyEqual(to: correctedFrame.origin, tolerance: 1) else { + return actualFrame } - window.setPosition(windowFrame.origin) + window.setPosition(correctedFrame.origin) + return correctedFrame } static func anchoredFrame( @@ -254,4 +278,21 @@ enum WindowEngine { 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 + } + + // 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 a6bd7a2d..b65d07e4 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 { @@ -20,6 +34,7 @@ final class WindowTransformAnimation: NSAnimation { 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() @@ -103,26 +118,168 @@ final class WindowTransformAnimation: NSAnimation { ) var newFrame = requestedFrame + var currentOrigin = lastWindowFrame.origin + let resizeTolerance: CGFloat = 2 + + let sizeToSet = sizeToSet(for: requestedFrame) - if shouldSetSize, lastWindowFrame.size != requestedFrame.size { - window.setSize(requestedFrame.size) + 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 + } + } + + window.setSize(sizeToSet) if bounds != .zero { let actualFrame = window.frame - newFrame = WindowEngine.anchoredFrame( - for: actualFrame.size, - within: requestedFrame, - targetEdges: targetEdges, - bounds: bounds - ) + updateConstraint(actualFrame: actualFrame, requestedFrame: requestedFrame, tolerance: resizeTolerance) + + if WindowEngine.shouldAnchorDuringAnimation( + actualSize: actualFrame.size, + requestedSize: requestedFrame.size + ) { + newFrame = WindowEngine.anchoredFrame( + for: actualFrame.size, + within: requestedFrame, + targetEdges: targetEdges, + bounds: bounds + ) + } else { + newFrame = CGRect( + origin: requestedFrame.origin, + size: actualFrame.size + ) + .pushInside(bounds) + } } + } else if bounds != .zero, constraint.hasFixedAxes { + newFrame = WindowEngine.anchoredFrame( + for: lastWindowFrame.size, + within: requestedFrame, + targetEdges: targetEdges, + bounds: bounds + ) } 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) } lastWindowFrame = window.frame } + + private func sizeToSet(for requestedFrame: CGRect) -> CGSize { + switch constraint { + case .none, .fixedAspectRatio: + return requestedFrame.size + case let .fixedAxes(width, height): + return 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 WindowEngine.anchoredFrame( + for: predictedSize, + within: requestedFrame, + targetEdges: targetEdges, + bounds: bounds + ) + .origin + } + + private func updateConstraint(actualFrame: CGRect, requestedFrame: CGRect, tolerance: CGFloat) { + if case .fixedAxes = constraint { + return + } + + let acceptedSize = actualFrame.size + guard acceptedSize.width <= requestedFrame.width + tolerance, + acceptedSize.height <= requestedFrame.height + tolerance else { + constraint = .none + 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 shouldLockWidth || shouldLockHeight { + constraint = .fixedAxes(width: shouldLockWidth, height: shouldLockHeight) + } else if WindowEngine.shouldAnchorDuringAnimation( + actualSize: acceptedSize, + requestedSize: requestedFrame.size, + tolerance: tolerance + ), acceptedSize.width > 0, acceptedSize.height > 0 { + constraint = .fixedAspectRatio(acceptedSize.width / acceptedSize.height) + } + } + + 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 + } +} + +private extension CGSize { + 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) + } + } } From 96e65c959c9188e32cdb5be93d2ac5e1fb090d7c Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 14 May 2026 02:11:16 -0600 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9E=20Fix=20apps=20being=20misclas?= =?UTF-8?q?sified=20as=20fixed=20size=20instead=20of=20fixed=20aspect=20ra?= =?UTF-8?q?tio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WindowTransformAnimation.swift | 116 ++++++++++++++---- 1 file changed, 92 insertions(+), 24 deletions(-) diff --git a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift index b65d07e4..3f659c68 100644 --- a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift +++ b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift @@ -11,12 +11,12 @@ 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 } } @@ -30,6 +30,7 @@ final class WindowTransformAnimation: NSAnimation { 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?) -> () @@ -47,12 +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) @@ -158,11 +166,10 @@ final class WindowTransformAnimation: NSAnimation { actualSize: actualFrame.size, requestedSize: requestedFrame.size ) { - newFrame = WindowEngine.anchoredFrame( + newFrame = animationAnchoredFrame( for: actualFrame.size, within: requestedFrame, - targetEdges: targetEdges, - bounds: bounds + currentOrigin: currentOrigin ) } else { newFrame = CGRect( @@ -173,11 +180,10 @@ final class WindowTransformAnimation: NSAnimation { } } } else if bounds != .zero, constraint.hasFixedAxes { - newFrame = WindowEngine.anchoredFrame( + newFrame = animationAnchoredFrame( for: lastWindowFrame.size, within: requestedFrame, - targetEdges: targetEdges, - bounds: bounds + currentOrigin: lastWindowFrame.origin ) } else if bounds != .zero { newFrame = newFrame.pushInside(bounds) @@ -190,12 +196,34 @@ final class WindowTransformAnimation: NSAnimation { 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 + } + + return frame.pushInside(bounds) + } + private func sizeToSet(for requestedFrame: CGRect) -> CGSize { switch constraint { case .none, .fixedAspectRatio: - return requestedFrame.size + requestedFrame.size case let .fixedAxes(width, height): - return CGSize( + CGSize( width: width ? lastWindowFrame.width : requestedFrame.width, height: height ? lastWindowFrame.height : requestedFrame.height ) @@ -209,20 +237,15 @@ final class WindowTransformAnimation: NSAnimation { } let predictedSize = sizeToSet.fitting(aspectRatio: aspectRatio) - return WindowEngine.anchoredFrame( + return animationAnchoredFrame( for: predictedSize, within: requestedFrame, - targetEdges: targetEdges, - bounds: bounds + currentOrigin: lastWindowFrame.origin ) .origin } private func updateConstraint(actualFrame: CGRect, requestedFrame: CGRect, tolerance: CGFloat) { - if case .fixedAxes = constraint { - return - } - let acceptedSize = actualFrame.size guard acceptedSize.width <= requestedFrame.width + tolerance, acceptedSize.height <= requestedFrame.height + tolerance else { @@ -230,6 +253,30 @@ final class WindowTransformAnimation: NSAnimation { 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, @@ -244,17 +291,38 @@ final class WindowTransformAnimation: NSAnimation { 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) - } else if WindowEngine.shouldAnchorDuringAnimation( - actualSize: acceptedSize, - requestedSize: requestedFrame.size, - tolerance: tolerance - ), acceptedSize.width > 0, acceptedSize.height > 0 { - constraint = .fixedAspectRatio(acceptedSize.width / acceptedSize.height) } } + 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, From 5732b86de8ef286f6a6aedbd498b9f67f81c1e19 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Thu, 14 May 2026 02:15:19 -0600 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Extensions/CGGeometry+Extensions.swift | 13 +++++++++++++ .../Window Manipulation/WindowEngine.swift | 4 ++-- .../WindowTransformAnimation.swift | 15 --------------- 3 files changed, 15 insertions(+), 17 deletions(-) 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/Window Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index f9fc8ddc..5d44ac50 100644 --- a/Loop/Window Management/Window Manipulation/WindowEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowEngine.swift @@ -289,9 +289,9 @@ enum WindowEngine { } // Only compensate during animation when the app ended up smaller than the - // requested frame (fixed aspect ratio, fixed width, fixed height, etc.). + // 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. + // 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 3f659c68..57a6fde8 100644 --- a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift +++ b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift @@ -336,18 +336,3 @@ final class WindowTransformAnimation: NSAnimation { return requestedChanged && actualDidNotChange && constrainedBelowRequest } } - -private extension CGSize { - 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) - } - } -}