diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index da72fe47..07e86f85 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -222,8 +222,9 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial /// - disablePadding: whether to disable padding. `true` when calculating non-AX-usage frames, such as for angle calculations in radial menu or in config UI. /// - screen: the screen on which the bounds are located. Only used to determine if padding should be applied (see `getBounds()`). /// - isPreview: ensures that when manipulating the preview window, the last target frame does not affect the actual resizing of the window. + /// - proportionalFrame: optional proportional frame when moving between screens. Values should be between 0.0 and 1.0. /// - Returns: the calculated frame for the specified window action. - func getFrame(window: Window?, bounds: CGRect, disablePadding: Bool = false, screen: NSScreen? = nil, isPreview: Bool = false) -> CGRect { + func getFrame(window: Window?, bounds: CGRect, disablePadding: Bool = false, screen: NSScreen? = nil, isPreview: Bool = false, proportionalFrame: CGRect? = nil) -> CGRect { let noFrameActions: [WindowDirection] = [.noAction, .cycle, .minimize, .hide] guard !noFrameActions.contains(direction) else { return NSRect(origin: bounds.center, size: .zero) @@ -234,7 +235,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial } var bounds: CGRect = getBounds(from: bounds, disablePadding: disablePadding, screen: screen) - var result: CGRect = calculateTargetFrame(direction, window, bounds, isPreview) + var result: CGRect = calculateTargetFrame(direction, window, bounds, isPreview, proportionalFrame: proportionalFrame) if !disablePadding { if !willManipulateExistingWindowFrame { @@ -293,12 +294,18 @@ extension WindowAction { /// - window: the window to be manipulated. /// - bounds: the bounds within which the window should be manipulated. /// - isPreview: whether the action is being performed on a preview window. + /// - proportionalFrame: optional proportional frame when moving between screens. /// - Returns: the calculated target frame for the specified window action. - private func calculateTargetFrame(_ direction: WindowDirection, _ window: Window?, _ bounds: CGRect, _ isPreview: Bool) -> CGRect { + private func calculateTargetFrame(_ direction: WindowDirection, _ window: Window?, _ bounds: CGRect, _ isPreview: Bool, proportionalFrame: CGRect? = nil) -> CGRect { var result: CGRect = .zero if direction.frameMultiplyValues != nil { - result = applyFrameMultiplyValues(bounds) + // When moving between screens with a proportional frame, use proportional sizing + if let proportionalFrame { + result = applyProportionalFrame(proportionalFrame, bounds) + } else { + result = applyFrameMultiplyValues(bounds) + } } else if direction.willAdjustSize { // Can't grow or shrink a window that is not resizable @@ -409,6 +416,21 @@ extension WindowAction { ) } + /// Applies a proportional frame (from a previous screen) to new bounds. + /// This is used when moving windows between screens to maintain their relative size. + /// - Parameters: + /// - proportionalFrame: The proportional frame with values between 0.0 and 1.0 + /// - bounds: The new screen bounds to apply the proportions to + /// - Returns: A new `CGRect` with the proportional frame applied to the new bounds + private func applyProportionalFrame(_ proportionalFrame: CGRect, _ bounds: CGRect) -> CGRect { + return CGRect( + x: bounds.origin.x + (bounds.width * proportionalFrame.minX), + y: bounds.origin.y + (bounds.height * proportionalFrame.minY), + width: bounds.width * proportionalFrame.width, + height: bounds.height * proportionalFrame.height + ) + } + /// Calculates the user-specified custom frame relative to the provided bounds. /// - Parameters: /// - window: the window to be manipulated. diff --git a/Loop/Window Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index b541a4f5..510bd4ab 100644 --- a/Loop/Window Management/Window Manipulation/WindowEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowEngine.swift @@ -26,7 +26,8 @@ enum WindowEngine { shouldRecord: Bool = true ) { guard action.direction != .noAction else { return } - let willChangeScreens = ScreenUtility.screenContaining(window) != screen + let currentScreen = ScreenUtility.screenContaining(window) + let willChangeScreens = currentScreen != screen let windowTitle = window.nsRunningApplication?.localizedName ?? window.title ?? "" logger.info("Resizing \(windowTitle) to \(action.direction.debugDescription) on \(screen.localizedName)") @@ -37,6 +38,13 @@ enum WindowEngine { WindowRecords.record(window, action) } + // Calculate proportional frame when moving between screens + // This ensures windows maintain their relative size across different screen resolutions + var proportionalFrame: CGRect? = nil + if willChangeScreens, let currentScreen, action.direction.frameMultiplyValues != nil { + proportionalFrame = calculateProportionalFrame(window: window, fromScreen: currentScreen, action: action) + } + // If the action is to hide, minimize or fullscreen perform the action then return if action.direction == .hide { window.toggleHidden() @@ -86,7 +94,8 @@ enum WindowEngine { let targetFrame: CGRect = action.getFrame( window: window, bounds: screen.safeScreenFrame, - screen: screen + screen: screen, + proportionalFrame: proportionalFrame ) logger.info("Target window frame: \(targetFrame.debugDescription)") @@ -282,4 +291,64 @@ enum WindowEngine { window.minimized = true } } + + /// Calculates the proportional frame of a window relative to its current screen. + /// This is used when moving windows between screens to maintain their relative size. + /// - Parameters: + /// - window: The window to calculate proportions for + /// - fromScreen: The screen the window is currently on + /// - action: The window action being performed + /// - Returns: A CGRect representing the proportional frame (values between 0.0 and 1.0) or nil if not applicable + private static func calculateProportionalFrame(window: Window, fromScreen: NSScreen, action: WindowAction) -> CGRect? { + // Only calculate proportions for actions with frame multiply values (halves, quarters, thirds, etc.) + guard action.direction.frameMultiplyValues != nil else { + return nil + } + + let currentFrame = window.frame + let currentBounds = fromScreen.safeScreenFrame + + let usePadding = PaddingSettings.enablePadding && + (Defaults[.paddingMinimumScreenSize] == 0 || fromScreen.diagonalSize > Defaults[.paddingMinimumScreenSize]) + + let adjustedBounds = if usePadding { + PaddingSettings.padding.apply(on: currentBounds) + } else { + currentBounds + } + + guard currentFrame.intersects(adjustedBounds) else { + logger.debug("Window frame doesn't intersect with source screen bounds - skipping proportional calculation") + return nil + } + + // Calculate proportional position and size relative to the adjusted bounds + let proportionalX = (currentFrame.minX - adjustedBounds.minX) / adjustedBounds.width + let proportionalY = (currentFrame.minY - adjustedBounds.minY) / adjustedBounds.height + let proportionalWidth = currentFrame.width / adjustedBounds.width + let proportionalHeight = currentFrame.height / adjustedBounds.height + + // Validate proportional values are reasonable + let tolerance: CGFloat = 0.1 + guard proportionalX > -tolerance, + proportionalY > -tolerance, + proportionalWidth > 0, + proportionalWidth <= 1.0 + tolerance, + proportionalHeight > 0, + proportionalHeight <= 1.0 + tolerance else { + logger.debug("Invalid proportional frame calculated (x=\(proportionalX), y=\(proportionalY), w=\(proportionalWidth), h=\(proportionalHeight)) - skipping") + return nil + } + + let proportions = CGRect( + x: proportionalX, + y: proportionalY, + width: proportionalWidth, + height: proportionalHeight + ) + + logger.debug("Calculated proportional frame: x=\(proportionalX), y=\(proportionalY), w=\(proportionalWidth), h=\(proportionalHeight)") + + return proportions + } } diff --git a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift index 2d871292..d06ba7ea 100644 --- a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift +++ b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift @@ -86,7 +86,7 @@ final class WindowTransformAnimation: NSAnimation { window.size = newFrame.size } - lastWindowFrame = window.frame + lastWindowFrame = newFrame if currentProgress >= 1.0 { WindowTransformAnimation.currentAnimations[window.cgWindowID] = nil