diff --git a/Rectangle.xcodeproj/project.pbxproj b/Rectangle.xcodeproj/project.pbxproj index c1bf58ee..db8d9f8d 100644 --- a/Rectangle.xcodeproj/project.pbxproj +++ b/Rectangle.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 729E0A982AFF76B1006E2F48 /* CenterProminentlyCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 729E0A972AFF76B1006E2F48 /* CenterProminentlyCalculation.swift */; }; 74804F0B2E25521C009F1F7D /* CenterTwoThirdsCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74804F0A2E25521C009F1F7D /* CenterTwoThirdsCalculation.swift */; }; 7BE578EF2C5BF4EE0083DAE3 /* CycleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */; }; + 7BE578F12EFA00010083DAE3 /* CooperativeCornerResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE578F02EFA00010083DAE3 /* CooperativeCornerResize.swift */; }; 7EC9E3BAD5BC57E49A7BCEE5 /* TwelfthsRepeated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3592F8E85CE82B8FA662A31 /* TwelfthsRepeated.swift */; }; 866661F2257D248A00A9CD2D /* RepeatedExecutionsInThirdsCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866661F1257D248A00A9CD2D /* RepeatedExecutionsInThirdsCalculation.swift */; }; 88763FEFFA3AECF8502604D6 /* LowerMiddleCenterLeftSixteenthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378C96D832D4CC6BCCF9D604 /* LowerMiddleCenterLeftSixteenthCalculation.swift */; }; @@ -77,11 +78,13 @@ 9824702F22AFA2E50037B409 /* AccessibilityAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824702E22AFA2E50037B409 /* AccessibilityAuthorization.swift */; }; 9824703122AFA8470037B409 /* RectangleStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824703022AFA8470037B409 /* RectangleStatusItem.swift */; }; 9824703722B0F3200037B409 /* WindowAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824703622B0F3200037B409 /* WindowAction.swift */; }; + 982470382EFA00010037B409 /* WindowActionCooperativeResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982470372EFA00010037B409 /* WindowActionCooperativeResize.swift */; }; 9824703922B0F37C0037B409 /* ShortcutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824703822B0F37C0037B409 /* ShortcutManager.swift */; }; 9824703B22B139780037B409 /* CUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824703A22B139780037B409 /* CUtil.swift */; }; 9824703D22B13C7E0037B409 /* AccessibilityElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824703C22B13C7E0037B409 /* AccessibilityElement.swift */; }; 9824703F22B13FBC0037B409 /* ScreenDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824703E22B13FBC0037B409 /* ScreenDetection.swift */; }; 9824704122B186D00037B409 /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824704022B186D00037B409 /* WindowManager.swift */; }; + 982470432EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982470422EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift */; }; 9824704B22B189250037B409 /* StandardWindowMover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824704622B189240037B409 /* StandardWindowMover.swift */; }; 9824704C22B189250037B409 /* WindowMover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824704722B189240037B409 /* WindowMover.swift */; }; 9824704D22B189250037B409 /* WindowCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9824704822B189250037B409 /* WindowCalculation.swift */; }; @@ -241,6 +244,7 @@ 729E0A972AFF76B1006E2F48 /* CenterProminentlyCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CenterProminentlyCalculation.swift; sourceTree = ""; }; 74804F0A2E25521C009F1F7D /* CenterTwoThirdsCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CenterTwoThirdsCalculation.swift; sourceTree = ""; }; 7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleSize.swift; sourceTree = ""; }; + 7BE578F02EFA00010083DAE3 /* CooperativeCornerResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CooperativeCornerResize.swift; sourceTree = ""; }; 7E8357E928530D10A806F3CD /* TopRightTwelfthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopRightTwelfthCalculation.swift; sourceTree = ""; }; 84E2DBE0733F31BC159BC99C /* UpperMiddleLeftSixteenthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpperMiddleLeftSixteenthCalculation.swift; sourceTree = ""; }; 866661F1257D248A00A9CD2D /* RepeatedExecutionsInThirdsCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatedExecutionsInThirdsCalculation.swift; sourceTree = ""; }; @@ -298,11 +302,13 @@ 9824702E22AFA2E50037B409 /* AccessibilityAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityAuthorization.swift; sourceTree = ""; }; 9824703022AFA8470037B409 /* RectangleStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RectangleStatusItem.swift; sourceTree = ""; }; 9824703622B0F3200037B409 /* WindowAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAction.swift; sourceTree = ""; }; + 982470372EFA00010037B409 /* WindowActionCooperativeResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowActionCooperativeResize.swift; sourceTree = ""; }; 9824703822B0F37C0037B409 /* ShortcutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutManager.swift; sourceTree = ""; }; 9824703A22B139780037B409 /* CUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CUtil.swift; sourceTree = ""; }; 9824703C22B13C7E0037B409 /* AccessibilityElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityElement.swift; sourceTree = ""; }; 9824703E22B13FBC0037B409 /* ScreenDetection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScreenDetection.swift; sourceTree = ""; }; 9824704022B186D00037B409 /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = ""; }; + 982470422EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WindowManager+CooperativeCornerResize.swift"; sourceTree = ""; }; 9824704622B189240037B409 /* StandardWindowMover.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StandardWindowMover.swift; sourceTree = ""; }; 9824704722B189240037B409 /* WindowMover.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WindowMover.swift; sourceTree = ""; }; 9824704822B189250037B409 /* WindowCalculation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WindowCalculation.swift; sourceTree = ""; }; @@ -681,15 +687,18 @@ 984EDB0E29A42ED200D119D2 /* LaunchOnLogin.swift */, 98C1008B2305F1FA006E5344 /* SubsequentExecutionMode.swift */, 7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */, + 7BE578F02EFA00010083DAE3 /* CooperativeCornerResize.swift */, 985B9BF422B93EEC00A2E8F0 /* ApplicationToggle.swift */, 9824703022AFA8470037B409 /* RectangleStatusItem.swift */, 9824703622B0F3200037B409 /* WindowAction.swift */, + 982470372EFA00010037B409 /* WindowActionCooperativeResize.swift */, 98A6EDEB2528FFC100F74B10 /* WindowActionCategory.swift */, 9824703822B0F37C0037B409 /* ShortcutManager.swift */, 9824703C22B13C7E0037B409 /* AccessibilityElement.swift */, 9824703E22B13FBC0037B409 /* ScreenDetection.swift */, 98C275662322E2DA009B9292 /* WindowHistory.swift */, 9824704022B186D00037B409 /* WindowManager.swift */, + 982470422EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift */, AA49DD1029B8C1B100690E13 /* TitleBarManager.swift */, AA49DD2029B8C1B100690E13 /* GreenButtonManager.swift */, 98987AA62391890C00BE72C4 /* Logging */, @@ -1049,12 +1058,15 @@ 98C275672322E2DA009B9292 /* WindowHistory.swift in Sources */, 9821403722B3D16700ABFB3F /* MaximizeHeightCalculation.swift in Sources */, 9824704122B186D00037B409 /* WindowManager.swift in Sources */, + 982470432EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift in Sources */, D0423D8327A8D31D008A4894 /* HorizontalThirdsRepeated.swift in Sources */, 98C97FFD25893B040061F01F /* Config.swift in Sources */, 989DA30E243FC0DF008C7AA4 /* WelcomeViewController.swift in Sources */, 9818E01228B59B64004AA524 /* ThirdsCompoundCalculation.swift in Sources */, + 7BE578F12EFA00010083DAE3 /* CooperativeCornerResize.swift in Sources */, 98C27561231FFA5F009B9292 /* SnappingManager.swift in Sources */, 9824703722B0F3200037B409 /* WindowAction.swift in Sources */, + 982470382EFA00010037B409 /* WindowActionCooperativeResize.swift in Sources */, B4521F932BD7CEFB00FD43CC /* ChangeWindowDimensionCalculation.swift in Sources */, 9821402922B3889100ABFB3F /* LowerLeftCalculation.swift in Sources */, 7BE578EF2C5BF4EE0083DAE3 /* CycleSize.swift in Sources */, diff --git a/Rectangle/AccessibilityElement.swift b/Rectangle/AccessibilityElement.swift index 00ed4838..233b7ab1 100644 --- a/Rectangle/AccessibilityElement.swift +++ b/Rectangle/AccessibilityElement.swift @@ -117,6 +117,11 @@ class AccessibilityElement { Logger.log("AX sizing proposed: \(newValue.debugDescription), result: \(size?.debugDescription ?? "N/A")") } } + + var minimumSize: CGSize? { + wrappedElement.getWrappedValue(.minSize) + ?? wrappedElement.getWrappedValue(.minimumSize) + } var frame: CGRect { guard let position = position, let size = size else { return .null } diff --git a/Rectangle/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift new file mode 100644 index 00000000..68604935 --- /dev/null +++ b/Rectangle/CooperativeCornerResize.swift @@ -0,0 +1,1451 @@ +/// CooperativeCornerResize.swift + +import Cocoa + +struct CooperativeCornerResize { + struct Candidate { + let id: CGWindowID + let frame: CGRect + let minimumSize: CGSize? + + init(id: CGWindowID, frame: CGRect, minimumSize: CGSize? = nil) { + self.id = id + self.frame = frame + self.minimumSize = minimumSize + } + } + + struct Adjustment { + let id: CGWindowID + let oldFrame: CGRect + let newFrame: CGRect + let kind: Kind + + enum Kind { + case matchingFocusedFrame + case adjacent + } + } + + struct Plan { + let focusedFrame: CGRect + let adjustments: [Adjustment] + let debugLog: [String] + } + + enum MovedEdge { + case left, right, top, bottom + } + + private enum AffectedRole { + case matchingFocusedFrame + case matchingMovingSpan + case adjacent + } + + private struct AffectedWindow { + let candidate: Candidate + let layoutFrame: CGRect + let role: AffectedRole + let kind: Adjustment.Kind + let minimumSize: CGSize + let inclusionReason: String + } + + private struct AffectedWindowsResult { + let windows: [AffectedWindow] + let debugLog: [String] + } + + private struct EdgeRange { + var min: CGFloat + var max: CGFloat + var lowerReasons: [String] = [] + var upperReasons: [String] = [] + + mutating func requireMin(_ value: CGFloat, reason: String) { + if value > min + 0.001 { + min = value + lowerReasons = [reason] + } else if abs(value - min) <= 0.001 { + lowerReasons.append(reason) + } + } + + mutating func requireMax(_ value: CGFloat, reason: String) { + if value < max - 0.001 { + max = value + upperReasons = [reason] + } else if abs(value - max) <= 0.001 { + upperReasons.append(reason) + } + } + } + + private struct ObservedCornerBoundary { + let edge: CGFloat + let priority: Int + let distanceFromRequestedEdge: CGFloat + let id: CGWindowID + } + + static func detectionTolerance(screenFrame: CGRect, configuredGap: CGFloat) -> CGFloat { + let proportionalTolerance = min(screenFrame.width, screenFrame.height) * 0.015 + return min(24, max(8, proportionalTolerance)) + } + + static func captureTolerance(screenFrame: CGRect, axis: CornerCycleExpansionAxis) -> CGFloat { + let dimension = axis == .horizontal ? screenFrame.width : screenFrame.height + return min(96, max(8, dimension * 0.08)) + } + + static func framesDiffer(_ lhs: CGRect, _ rhs: CGRect, tolerance: CGFloat) -> Bool { + guard !lhs.isNull, !rhs.isNull else { return lhs.isNull != rhs.isNull } + return abs(lhs.minX - rhs.minX) > tolerance + || abs(lhs.minY - rhs.minY) > tolerance + || abs(lhs.width - rhs.width) > tolerance + || abs(lhs.height - rhs.height) > tolerance + } + + static func frameNeedsApplication(currentFrame: CGRect, + solvedFrame: CGRect, + screenFrame: CGRect, + layoutTolerance: CGFloat) -> Bool { + frameNeedsCorrection(plannedFrame: solvedFrame, + actualFrame: currentFrame, + screenFrame: screenFrame, + tolerance: layoutTolerance) + } + + static func focusedFramePreservingOccupiedCell(requestedFocusedFrame: CGRect, + screenFrame: CGRect, + candidates: [Candidate], + axis: CornerCycleExpansionAxis, + movedEdge: MovedEdge, + tolerance: CGFloat) -> CGRect { + focusedFrameResolvingRealizedCornerBoundary(requestedFocusedFrame: requestedFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: axis, + movedEdge: movedEdge, + tolerance: tolerance) + } + + static func focusedFrameResolvingRealizedCornerBoundary(requestedFocusedFrame: CGRect, + screenFrame: CGRect, + candidates: [Candidate], + axis: CornerCycleExpansionAxis, + movedEdge: MovedEdge, + tolerance: CGFloat, + gapSize: CGFloat = 0) -> CGRect { + guard !requestedFocusedFrame.isNull, + !screenFrame.isNull + else { + return requestedFocusedFrame + } + + let desiredEdge = edgeCoordinate(requestedFocusedFrame, movedEdge) + guard let observedBoundary = observedCornerBoundary(requestedFocusedFrame: requestedFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: axis, + movedEdge: movedEdge, + desiredEdge: desiredEdge, + tolerance: tolerance, + gapSize: gapSize), + abs(observedBoundary.edge - desiredEdge) > tolerance + else { + return requestedFocusedFrame + } + + return roundedFrameInsideVisibleFrame( + sameSideFrame(baseFrame: requestedFocusedFrame, + movedEdge: movedEdge, + axis: axis, + edge: observedBoundary.edge, + newFocusedFrame: requestedFocusedFrame, + screenFrame: screenFrame), + visibleFrame: screenFrame + ) + } + + private static func observedCornerBoundary(requestedFocusedFrame: CGRect, + screenFrame: CGRect, + candidates: [Candidate], + axis: CornerCycleExpansionAxis, + movedEdge: MovedEdge, + desiredEdge: CGFloat, + tolerance: CGFloat, + gapSize: CGFloat) -> ObservedCornerBoundary? { + let observedBoundaries = candidates.compactMap { candidate -> ObservedCornerBoundary? in + let frame = candidate.frame + guard !frame.isNull, + screenFrame.intersects(frame), + matchesPerpendicularSpan(frame, requestedFocusedFrame, movedEdge: movedEdge, tolerance: tolerance) + else { + return nil + } + + if sharesSameSideFixedEdge(frame, + requestedFocusedFrame, + movedEdge: movedEdge, + axis: axis, + tolerance: tolerance) { + let edge = edgeCoordinate(frame, movedEdge) + guard isValidObservedBoundary(edge, + requestedFocusedFrame: requestedFocusedFrame, + screenFrame: screenFrame, + movedEdge: movedEdge, + axis: axis, + tolerance: tolerance) + else { + return nil + } + + return ObservedCornerBoundary(edge: edge, + priority: 0, + distanceFromRequestedEdge: abs(edge - desiredEdge), + id: candidate.id) + } + + guard let adjacentEdge = observedBoundaryFromAdjacentFrame(frame, + screenFrame: screenFrame, + movedEdge: movedEdge, + axis: axis, + tolerance: tolerance, + gapSize: gapSize), + isValidObservedBoundary(adjacentEdge, + requestedFocusedFrame: requestedFocusedFrame, + screenFrame: screenFrame, + movedEdge: movedEdge, + axis: axis, + tolerance: tolerance) + else { + return nil + } + + return ObservedCornerBoundary(edge: adjacentEdge, + priority: 1, + distanceFromRequestedEdge: abs(adjacentEdge - desiredEdge), + id: candidate.id) + } + + return observedBoundaries.max { lhs, rhs in + if lhs.priority != rhs.priority { + return lhs.priority > rhs.priority + } + if abs(lhs.distanceFromRequestedEdge - rhs.distanceFromRequestedEdge) > 0.001 { + return lhs.distanceFromRequestedEdge < rhs.distanceFromRequestedEdge + } + return lhs.id > rhs.id + } + } + + private static func sharesSameSideFixedEdge(_ candidate: CGRect, + _ focused: CGRect, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat) -> Bool { + switch movedEdge { + case .right, .top: + return abs(axisMin(candidate, axis) - axisMin(focused, axis)) <= tolerance + case .left, .bottom: + return abs(axisMax(candidate, axis) - axisMax(focused, axis)) <= tolerance + } + } + + private static func observedBoundaryFromAdjacentFrame(_ candidate: CGRect, + screenFrame: CGRect, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + gapSize: CGFloat) -> CGFloat? { + switch movedEdge { + case .right: + guard matchesOuterMax(axisMax(candidate, axis), + screenFrame: screenFrame, + axis: axis, + tolerance: tolerance, + gapSize: gapSize) else { return nil } + return axisMin(candidate, axis) - gapSize + case .left: + guard matchesOuterMin(axisMin(candidate, axis), + screenFrame: screenFrame, + axis: axis, + tolerance: tolerance, + gapSize: gapSize) else { return nil } + return axisMax(candidate, axis) + gapSize + case .top: + guard matchesOuterMax(axisMax(candidate, axis), + screenFrame: screenFrame, + axis: axis, + tolerance: tolerance, + gapSize: gapSize) else { return nil } + return axisMin(candidate, axis) - gapSize + case .bottom: + guard matchesOuterMin(axisMin(candidate, axis), + screenFrame: screenFrame, + axis: axis, + tolerance: tolerance, + gapSize: gapSize) else { return nil } + return axisMax(candidate, axis) + gapSize + } + } + + private static func isValidObservedBoundary(_ edge: CGFloat, + requestedFocusedFrame: CGRect, + screenFrame: CGRect, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat) -> Bool { + let visibleMin = axisMin(screenFrame, axis) + let visibleMax = axisMax(screenFrame, axis) + guard edge > visibleMin + tolerance, + edge < visibleMax - tolerance + else { + return false + } + + switch movedEdge { + case .right, .top: + return edge > axisMin(requestedFocusedFrame, axis) + tolerance + case .left, .bottom: + return edge < axisMax(requestedFocusedFrame, axis) - tolerance + } + } + + static func movedEdge(from oldFocusedFrame: CGRect, + to newFocusedFrame: CGRect, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat) -> MovedEdge? { + switch axis { + case .horizontal: + let leftMoved = abs(oldFocusedFrame.minX - newFocusedFrame.minX) > tolerance + let rightMoved = abs(oldFocusedFrame.maxX - newFocusedFrame.maxX) > tolerance + guard leftMoved != rightMoved else { return nil } + return leftMoved ? .left : .right + case .vertical: + let bottomMoved = abs(oldFocusedFrame.minY - newFocusedFrame.minY) > tolerance + let topMoved = abs(oldFocusedFrame.maxY - newFocusedFrame.maxY) > tolerance + guard bottomMoved != topMoved else { return nil } + return bottomMoved ? .bottom : .top + } + } + + static func adjustments(oldFocusedFrame: CGRect, + newFocusedFrame: CGRect, + screenFrame: CGRect, + candidates: [Candidate], + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + minimumSize: CGSize, + gapSize: CGFloat = 0, + captureTolerance: CGFloat? = nil, + movedEdgeOverride: MovedEdge? = nil, + candidateDiscoveryFrame: CGRect? = nil, + actionDescription: String = "repeated cooperative resize") -> [Adjustment] { + plan(oldFocusedFrame: oldFocusedFrame, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: axis, + tolerance: tolerance, + minimumSize: minimumSize, + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdgeOverride, + candidateDiscoveryFrame: candidateDiscoveryFrame, + actionDescription: actionDescription)?.adjustments ?? [] + } + + static func plan(oldFocusedFrame: CGRect, + newFocusedFrame: CGRect, + screenFrame: CGRect, + candidates: [Candidate], + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + minimumSize: CGSize, + focusedMinimumSize: CGSize? = nil, + gapSize: CGFloat = 0, + captureTolerance: CGFloat? = nil, + movedEdgeOverride: MovedEdge? = nil, + candidateDiscoveryFrame: CGRect? = nil, + actionDescription: String = "repeated cooperative resize") -> Plan? { + guard !oldFocusedFrame.isNull, + !newFocusedFrame.isNull, + !screenFrame.isNull + else { + return nil + } + + guard let movedEdge = movedEdgeOverride ?? movedEdge(from: oldFocusedFrame, + to: newFocusedFrame, + axis: axis, + tolerance: tolerance) else { + return nil + } + + let fallbackMinimumSize = normalizedMinimumSize(minimumSize) + let resolvedFocusedMinimumSize = normalizedMinimumSize(focusedMinimumSize ?? fallbackMinimumSize) + let resolvedCaptureTolerance = captureTolerance ?? tolerance + let resolvedPerpendicularCaptureTolerance = captureTolerance == nil + ? tolerance + : Self.captureTolerance(screenFrame: screenFrame, axis: perpendicularAxis(to: axis)) + let discoveryFrame = candidateDiscoveryFrame ?? oldFocusedFrame + let affectedWindowsResult = affectedWindows(discoveryFocusedFrame: discoveryFrame, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + movedEdge: movedEdge, + tolerance: tolerance, + captureTolerance: resolvedCaptureTolerance, + perpendicularCaptureTolerance: resolvedPerpendicularCaptureTolerance, + fallbackMinimumSize: fallbackMinimumSize, + gapSize: gapSize) + let affectedWindows = affectedWindowsResult.windows + guard !affectedWindows.isEmpty else { return nil } + + let oldEdge = edgeCoordinate(oldFocusedFrame, movedEdge) + let desiredEdge = edgeCoordinate(newFocusedFrame, movedEdge) + let requestedDelta = desiredEdge - oldEdge + var debugLog = [ + "Cooperative resize action type: \(actionDescription)", + "Cooperative resize visible frame: \(screenFrame.debugDescription)", + "Cooperative resize configured gap: \(gapSize)", + "Cooperative resize detection tolerance: \(tolerance)", + "Cooperative resize aggressive capture tolerance: axis \(resolvedCaptureTolerance), perpendicular \(resolvedPerpendicularCaptureTolerance)", + "Cooperative resize requested delta: \(requestedDelta)", + "Cooperative resize affected windows: \(affectedWindows.map { "\($0.candidate.id) (\($0.inclusionReason))" }.joined(separator: ", "))" + ] + affectedWindowsResult.debugLog + + var edgeRange = EdgeRange(min: axisMin(screenFrame, axis), max: axisMax(screenFrame, axis)) + constrainFocusedWindow(&edgeRange, + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + minimumSize: resolvedFocusedMinimumSize) + + for affectedWindow in affectedWindows { + constrainAffectedWindow(&edgeRange, + affectedWindow: affectedWindow, + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + gapSize: gapSize) + } + + let clampedEdge = roundedEdge(clamp(desiredEdge, min: edgeRange.min, max: edgeRange.max), + legalMin: edgeRange.min, + legalMax: edgeRange.max) + let focusedFrame = roundedFrameInsideVisibleFrame( + frameWithMinimumSize(sameSideFrame(baseFrame: newFocusedFrame, + movedEdge: movedEdge, + axis: axis, + edge: clampedEdge, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame), + minimumSize: resolvedFocusedMinimumSize, + resizeAxis: axis, + visibleFrame: screenFrame), + visibleFrame: screenFrame + ) + let proposedFocusedFrame = sameSideFrame(baseFrame: newFocusedFrame, + movedEdge: movedEdge, + axis: axis, + edge: desiredEdge, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame) + + debugLog.append("Cooperative resize proposed focused rectangle: \(proposedFocusedFrame.debugDescription)") + debugLog.append("Cooperative resize clamped focused rectangle: \(focusedFrame.debugDescription)") + + let adjustments = affectedWindows.map { affectedWindow -> Adjustment in + let proposedFrame = proposedFrameForAffectedWindow(affectedWindow, + desiredEdge: desiredEdge, + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + gapSize: gapSize) + let clampedFrame = clampedFrameForAffectedWindow(affectedWindow, + clampedEdge: clampedEdge, + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + gapSize: gapSize) + debugLog.append("Cooperative resize proposed rectangle for \(affectedWindow.candidate.id): \(proposedFrame.debugDescription)") + debugLog.append("Cooperative resize clamped rectangle for \(affectedWindow.candidate.id): \(clampedFrame.debugDescription)") + + return Adjustment(id: affectedWindow.candidate.id, + oldFrame: affectedWindow.candidate.frame, + newFrame: clampedFrame, + kind: affectedWindow.kind) + } + + let appliedDelta = clampedEdge - oldEdge + if abs(appliedDelta - requestedDelta) > 0.001 { + let reasons = reductionReasons(forDesiredEdge: desiredEdge, edgeRange: edgeRange) + debugLog.append("Cooperative resize reduced requested movement from \(requestedDelta) to \(appliedDelta): \(reasons.joined(separator: "; "))") + } + if edgeRange.min > edgeRange.max { + debugLog.append("Cooperative resize layout over-constrained: legal shared-edge range \(edgeRange.min)...\(edgeRange.max)") + } + + return Plan(focusedFrame: focusedFrame, + adjustments: adjustments, + debugLog: debugLog) + } + + static func correctionPlan(oldFocusedFrame: CGRect, + requestedFocusedFrame: CGRect, + plannedPlan: Plan, + screenFrame: CGRect, + candidates: [Candidate], + actualFocusedFrame: CGRect, + actualCandidateFramesById: [CGWindowID: CGRect], + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + layoutTolerance: CGFloat, + minimumSize: CGSize, + focusedMinimumSize: CGSize? = nil, + gapSize: CGFloat = 0, + captureTolerance: CGFloat? = nil, + movedEdgeOverride: MovedEdge? = nil, + candidateDiscoveryFrame: CGRect? = nil, + actionDescription: String = "cooperative resize settling pass") -> Plan? { + let plannedAdjustmentsById = Dictionary(uniqueKeysWithValues: plannedPlan.adjustments.map { ($0.id, $0) }) + let focusedNeedsCorrection = frameNeedsCorrection(plannedFrame: plannedPlan.focusedFrame, + actualFrame: actualFocusedFrame, + screenFrame: screenFrame, + tolerance: layoutTolerance) + let cooperatingNeedsCorrection = plannedPlan.adjustments.contains { adjustment in + guard let actualFrame = actualCandidateFramesById[adjustment.id] else { return false } + return frameNeedsCorrection(plannedFrame: adjustment.newFrame, + actualFrame: actualFrame, + screenFrame: screenFrame, + tolerance: layoutTolerance) + } + + guard focusedNeedsCorrection || cooperatingNeedsCorrection else { + return nil + } + + let fallbackMinimumSize = normalizedMinimumSize(minimumSize) + let effectiveFocusedMinimumSize = effectiveMinimumSize(base: focusedMinimumSize ?? fallbackMinimumSize, + plannedFrame: plannedPlan.focusedFrame, + actualFrame: actualFocusedFrame, + layoutTolerance: layoutTolerance) + let effectiveCandidates = candidates.map { candidate -> Candidate in + guard let plannedAdjustment = plannedAdjustmentsById[candidate.id], + let actualFrame = actualCandidateFramesById[candidate.id] + else { + return candidate + } + + let baseMinimumSize = candidate.minimumSize ?? fallbackMinimumSize + let effectiveMinimum = effectiveMinimumSize(base: baseMinimumSize, + plannedFrame: plannedAdjustment.newFrame, + actualFrame: actualFrame, + layoutTolerance: layoutTolerance) + return Candidate(id: candidate.id, frame: candidate.frame, minimumSize: effectiveMinimum) + } + + guard let correctedPlan = plan(oldFocusedFrame: oldFocusedFrame, + newFocusedFrame: requestedFocusedFrame, + screenFrame: screenFrame, + candidates: effectiveCandidates, + axis: axis, + tolerance: tolerance, + minimumSize: minimumSize, + focusedMinimumSize: effectiveFocusedMinimumSize, + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdgeOverride, + candidateDiscoveryFrame: candidateDiscoveryFrame, + actionDescription: actionDescription) + else { + return nil + } + + return Plan(focusedFrame: correctedPlan.focusedFrame, + adjustments: correctedPlan.adjustments, + debugLog: ["Cooperative resize settling pass: actual frames rejected planned layout"] + correctedPlan.debugLog) + } + + static func focusedWindowIsExpanding(oldFrame: CGRect, newFrame: CGRect, axis: CornerCycleExpansionAxis) -> Bool { + switch axis { + case .horizontal: + return newFrame.width > oldFrame.width + case .vertical: + return newFrame.height > oldFrame.height + } + } + + private static func affectedWindows(discoveryFocusedFrame: CGRect, + newFocusedFrame: CGRect, + screenFrame: CGRect, + candidates: [Candidate], + movedEdge: MovedEdge, + tolerance: CGFloat, + captureTolerance: CGFloat, + perpendicularCaptureTolerance: CGFloat, + fallbackMinimumSize: CGSize, + gapSize: CGFloat) -> AffectedWindowsResult { + var matchingFocusedFrameAdjustments: [AffectedWindow] = [] + var matchingMovingSpanAdjustments: [AffectedWindow] = [] + var adjacentAdjustments: [AffectedWindow] = [] + var debugLog: [String] = [] + + for candidate in candidates { + guard !candidate.frame.isNull else { + debugLog.append("Cooperative resize candidate \(candidate.id) excluded: null frame") + continue + } + guard screenFrame.intersects(candidate.frame) else { + debugLog.append("Cooperative resize candidate \(candidate.id) excluded: outside visible frame") + continue + } + + let minimumSize = normalizedMinimumSize(candidate.minimumSize ?? fallbackMinimumSize) + let strictLayoutFrame = normalizedFullSpanFrame(candidate.frame, + focusedFrame: discoveryFocusedFrame, + movedEdge: movedEdge, + tolerance: tolerance) + + if approximatelyMatchesFrame(candidate.frame, discoveryFocusedFrame, tolerance: tolerance) { + matchingFocusedFrameAdjustments.append(AffectedWindow(candidate: candidate, + layoutFrame: strictLayoutFrame, + role: .matchingFocusedFrame, + kind: .matchingFocusedFrame, + minimumSize: minimumSize, + inclusionReason: "matches focused frame")) + debugLog.append("Cooperative resize candidate \(candidate.id) included: matches focused frame") + continue + } + + if matchesMovingSpan(candidate.frame, discoveryFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), + isSupportedPerpendicularSpan(candidate.frame, discoveryFocusedFrame, movedEdge: movedEdge, tolerance: tolerance) { + matchingMovingSpanAdjustments.append(AffectedWindow(candidate: candidate, + layoutFrame: strictLayoutFrame, + role: .matchingMovingSpan, + kind: .matchingFocusedFrame, + minimumSize: minimumSize, + inclusionReason: "matches moving span")) + debugLog.append("Cooperative resize candidate \(candidate.id) included: matches moving span") + continue + } + + if isSupportedPerpendicularSpan(candidate.frame, discoveryFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), + touchesOldMovingEdge(candidate.frame, discoveryFocusedFrame, movedEdge: movedEdge, tolerance: tolerance, gapSize: gapSize) { + adjacentAdjustments.append(AffectedWindow(candidate: candidate, + layoutFrame: strictLayoutFrame, + role: .adjacent, + kind: .adjacent, + minimumSize: minimumSize, + inclusionReason: "touches shared edge")) + debugLog.append("Cooperative resize candidate \(candidate.id) included: touches shared edge") + continue + } + + guard let captured = capturedWindow(candidate: candidate, + discoveryFocusedFrame: discoveryFocusedFrame, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + movedEdge: movedEdge, + captureTolerance: captureTolerance, + perpendicularCaptureTolerance: perpendicularCaptureTolerance, + gapSize: gapSize, + minimumSize: minimumSize) + else { + debugLog.append("Cooperative resize candidate \(candidate.id) excluded: no shared edge, boundary crossing, or capture-tolerance match") + continue + } + + switch captured.role { + case .matchingFocusedFrame: + matchingFocusedFrameAdjustments.append(captured) + case .matchingMovingSpan: + matchingMovingSpanAdjustments.append(captured) + case .adjacent: + adjacentAdjustments.append(captured) + } + debugLog.append("Cooperative resize candidate \(candidate.id) included: \(captured.inclusionReason)") + } + + return AffectedWindowsResult(windows: matchingFocusedFrameAdjustments + matchingMovingSpanAdjustments + adjacentAdjustments, + debugLog: debugLog) + } + + private static func capturedWindow(candidate: Candidate, + discoveryFocusedFrame: CGRect, + newFocusedFrame: CGRect, + screenFrame: CGRect, + movedEdge: MovedEdge, + captureTolerance: CGFloat, + perpendicularCaptureTolerance: CGFloat, + gapSize: CGFloat, + minimumSize: CGSize) -> AffectedWindow? { + let perpendicularSupported = isSupportedPerpendicularSpan(candidate.frame, + discoveryFocusedFrame, + movedEdge: movedEdge, + tolerance: perpendicularCaptureTolerance) + let perpendicularOverlaps = perpendicularOverlap(candidate.frame, + discoveryFocusedFrame, + movedEdge: movedEdge) > 0 + + let touchesDiscoveryEdge = touchesOldMovingEdge(candidate.frame, + discoveryFocusedFrame, + movedEdge: movedEdge, + tolerance: captureTolerance, + gapSize: gapSize) + let touchesTargetEdge = touchesOldMovingEdge(candidate.frame, + newFocusedFrame, + movedEdge: movedEdge, + tolerance: captureTolerance, + gapSize: gapSize) + let crossesDiscoveryBoundary = crossesSharedBoundary(candidate.frame, + boundary: edgeCoordinate(discoveryFocusedFrame, movedEdge), + movedEdge: movedEdge, + gapSize: gapSize) + let crossesTargetBoundary = crossesSharedBoundary(candidate.frame, + boundary: edgeCoordinate(newFocusedFrame, movedEdge), + movedEdge: movedEdge, + gapSize: gapSize) + let overlapsTargetBoundary = overlapsBoundaryBand(candidate.frame, + boundary: edgeCoordinate(newFocusedFrame, movedEdge), + axis: axis(for: movedEdge), + tolerance: captureTolerance) + + let edgeCapture = perpendicularSupported && (touchesDiscoveryEdge + || touchesTargetEdge + || (newFocusedFrame.intersects(candidate.frame) && overlapsTargetBoundary)) + let boundaryCapture = perpendicularOverlaps && (crossesDiscoveryBoundary || crossesTargetBoundary) + + guard edgeCapture || boundaryCapture + else { + return nil + } + + let assignment = nearestRole(candidate.frame, + focusedFrame: newFocusedFrame, + screenFrame: screenFrame, + movedEdge: movedEdge, + gapSize: gapSize) + let layoutFrame = normalizedCapturedFrame(candidate.frame, + focusedFrame: discoveryFocusedFrame, + screenFrame: screenFrame, + movedEdge: movedEdge, + role: assignment, + tolerance: perpendicularCaptureTolerance, + gapSize: gapSize) + let reasonParts = [ + touchesDiscoveryEdge ? "capture-tolerance shared edge" : nil, + touchesTargetEdge ? "capture-tolerance target edge" : nil, + crossesDiscoveryBoundary || crossesTargetBoundary ? "boundary crossing" : nil, + overlapsTargetBoundary ? "target-boundary overlap" : nil, + "nearest-region \(assignment)" + ].compactMap { $0 } + let kind: Adjustment.Kind = assignment == .adjacent ? .adjacent : .matchingFocusedFrame + + return AffectedWindow(candidate: candidate, + layoutFrame: layoutFrame, + role: assignment, + kind: kind, + minimumSize: minimumSize, + inclusionReason: reasonParts.joined(separator: ", ")) + } + + private static func nearestRole(_ candidate: CGRect, + focusedFrame: CGRect, + screenFrame: CGRect, + movedEdge: MovedEdge, + gapSize: CGFloat) -> AffectedRole { + let axis = axis(for: movedEdge) + let boundary = edgeCoordinate(focusedFrame, movedEdge) + let sameInterval: (min: CGFloat, max: CGFloat) + let adjacentInterval: (min: CGFloat, max: CGFloat) + + switch movedEdge { + case .right, .top: + sameInterval = (axisMin(focusedFrame, axis), boundary) + adjacentInterval = (boundary + gapSize, axisMax(screenFrame, axis)) + case .left, .bottom: + sameInterval = (boundary, axisMax(focusedFrame, axis)) + adjacentInterval = (axisMin(screenFrame, axis), boundary - gapSize) + } + + let candidateInterval = (axisMin(candidate, axis), axisMax(candidate, axis)) + let sameOverlap = intervalOverlap(candidateInterval, sameInterval) + let adjacentOverlap = intervalOverlap(candidateInterval, adjacentInterval) + + if abs(adjacentOverlap - sameOverlap) > 0.001 { + return adjacentOverlap > sameOverlap ? .adjacent : .matchingMovingSpan + } + + let center = (candidateInterval.0 + candidateInterval.1) / 2.0 + switch movedEdge { + case .right, .top: + return center >= boundary ? .adjacent : .matchingMovingSpan + case .left, .bottom: + return center <= boundary ? .adjacent : .matchingMovingSpan + } + } + + private static func crossesSharedBoundary(_ candidate: CGRect, + boundary: CGFloat, + movedEdge: MovedEdge, + gapSize: CGFloat) -> Bool { + let axis = axis(for: movedEdge) + let candidateMin = axisMin(candidate, axis) + let candidateMax = axisMax(candidate, axis) + + switch movedEdge { + case .right, .top: + return candidateMin < boundary + gapSize && candidateMax > boundary + case .left, .bottom: + return candidateMin < boundary && candidateMax > boundary - gapSize + } + } + + private static func overlapsBoundaryBand(_ candidate: CGRect, + boundary: CGFloat, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat) -> Bool { + axisMin(candidate, axis) <= boundary + tolerance + && axisMax(candidate, axis) >= boundary - tolerance + } + + private static func perpendicularOverlap(_ candidate: CGRect, + _ focused: CGRect, + movedEdge: MovedEdge) -> CGFloat { + switch movedEdge { + case .left, .right: + return intervalOverlap((candidate.minY, candidate.maxY), (focused.minY, focused.maxY)) + case .top, .bottom: + return intervalOverlap((candidate.minX, candidate.maxX), (focused.minX, focused.maxX)) + } + } + + private static func intervalOverlap(_ lhs: (CGFloat, CGFloat), _ rhs: (CGFloat, CGFloat)) -> CGFloat { + max(0, min(lhs.1, rhs.1) - max(lhs.0, rhs.0)) + } + + private static func normalizedCapturedFrame(_ candidate: CGRect, + focusedFrame: CGRect, + screenFrame: CGRect, + movedEdge: MovedEdge, + role: AffectedRole, + tolerance: CGFloat, + gapSize: CGFloat) -> CGRect { + var result = normalizedFullSpanFrame(candidate, + focusedFrame: focusedFrame, + movedEdge: movedEdge, + tolerance: tolerance) + guard role == .adjacent else { + return result + } + + switch movedEdge { + case .right: + result.size.width = outerMax(for: candidate.maxX, + screenMin: screenFrame.minX, + screenMax: screenFrame.maxX, + gapSize: gapSize) - result.minX + case .left: + let oldMaxX = result.maxX + result.origin.x = outerMin(for: candidate.minX, + screenMin: screenFrame.minX, + screenMax: screenFrame.maxX, + gapSize: gapSize) + result.size.width = oldMaxX - result.minX + case .top: + result.size.height = outerMax(for: candidate.maxY, + screenMin: screenFrame.minY, + screenMax: screenFrame.maxY, + gapSize: gapSize) - result.minY + case .bottom: + let oldMaxY = result.maxY + result.origin.y = outerMin(for: candidate.minY, + screenMin: screenFrame.minY, + screenMax: screenFrame.maxY, + gapSize: gapSize) + result.size.height = oldMaxY - result.minY + } + return result + } + + private static func outerMin(for candidateMin: CGFloat, + screenMin: CGFloat, + screenMax: CGFloat, + gapSize: CGFloat) -> CGFloat { + let resolvedGapSize = max(0, min(gapSize, (screenMax - screenMin) / 2.0)) + guard resolvedGapSize > 0 else { return screenMin } + + let gapMin = screenMin + resolvedGapSize + return abs(candidateMin - screenMin) < abs(candidateMin - gapMin) + ? screenMin + : gapMin + } + + private static func outerMax(for candidateMax: CGFloat, + screenMin: CGFloat, + screenMax: CGFloat, + gapSize: CGFloat) -> CGFloat { + let resolvedGapSize = max(0, min(gapSize, (screenMax - screenMin) / 2.0)) + guard resolvedGapSize > 0 else { return screenMax } + + let gapMax = screenMax - resolvedGapSize + return abs(candidateMax - screenMax) < abs(candidateMax - gapMax) + ? screenMax + : gapMax + } + + private static func matchesOuterMin(_ value: CGFloat, + screenFrame: CGRect, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + gapSize: CGFloat) -> Bool { + let screenMin = axisMin(screenFrame, axis) + return abs(value - screenMin) <= tolerance + || abs(value - (screenMin + gapSize)) <= tolerance + } + + private static func matchesOuterMax(_ value: CGFloat, + screenFrame: CGRect, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + gapSize: CGFloat) -> Bool { + let screenMax = axisMax(screenFrame, axis) + return abs(value - screenMax) <= tolerance + || abs(value - (screenMax - gapSize)) <= tolerance + } + + private static func constrainFocusedWindow(_ edgeRange: inout EdgeRange, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + newFocusedFrame: CGRect, + screenFrame: CGRect, + minimumSize: CGSize) { + constrainSameSideWindow(&edgeRange, + label: "focused window", + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + minimumSize: minimumSize) + } + + private static func constrainAffectedWindow(_ edgeRange: inout EdgeRange, + affectedWindow: AffectedWindow, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + newFocusedFrame: CGRect, + screenFrame: CGRect, + gapSize: CGFloat) { + let label = "window \(affectedWindow.candidate.id)" + switch affectedWindow.role { + case .matchingFocusedFrame, .matchingMovingSpan: + constrainSameSideWindow(&edgeRange, + label: label, + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + minimumSize: affectedWindow.minimumSize) + case .adjacent: + constrainAdjacentWindow(&edgeRange, + label: label, + movedEdge: movedEdge, + axis: axis, + candidateFrame: affectedWindow.layoutFrame, + screenFrame: screenFrame, + minimumSize: affectedWindow.minimumSize, + gapSize: gapSize) + } + } + + private static func constrainSameSideWindow(_ edgeRange: inout EdgeRange, + label: String, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + newFocusedFrame: CGRect, + screenFrame: CGRect, + minimumSize: CGSize) { + let minimumAxisSize = axisSize(minimumSize, axis) + switch movedEdge { + case .right, .top: + let fixedMin = clamp(axisMin(newFocusedFrame, axis), + min: axisMin(screenFrame, axis), + max: axisMax(screenFrame, axis)) + edgeRange.requireMin(fixedMin + minimumAxisSize, reason: "\(label) minimum \(axisSizeName(axis))") + case .left, .bottom: + let fixedMax = clamp(axisMax(newFocusedFrame, axis), + min: axisMin(screenFrame, axis), + max: axisMax(screenFrame, axis)) + edgeRange.requireMax(fixedMax - minimumAxisSize, reason: "\(label) minimum \(axisSizeName(axis))") + } + } + + private static func constrainAdjacentWindow(_ edgeRange: inout EdgeRange, + label: String, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + candidateFrame: CGRect, + screenFrame: CGRect, + minimumSize: CGSize, + gapSize: CGFloat) { + let minimumAxisSize = axisSize(minimumSize, axis) + switch movedEdge { + case .right, .top: + let outerMax = min(axisMax(candidateFrame, axis), axisMax(screenFrame, axis)) + edgeRange.requireMax(outerMax - gapSize - minimumAxisSize, reason: "\(label) minimum \(axisSizeName(axis)) inside visible frame with gap") + case .left, .bottom: + let outerMin = max(axisMin(candidateFrame, axis), axisMin(screenFrame, axis)) + edgeRange.requireMin(outerMin + gapSize + minimumAxisSize, reason: "\(label) minimum \(axisSizeName(axis)) inside visible frame with gap") + } + } + + private static func proposedFrameForAffectedWindow(_ affectedWindow: AffectedWindow, + desiredEdge: CGFloat, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + newFocusedFrame: CGRect, + screenFrame: CGRect, + gapSize: CGFloat) -> CGRect { + frameForAffectedWindow(affectedWindow, + edge: desiredEdge, + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + gapSize: gapSize) + } + + private static func clampedFrameForAffectedWindow(_ affectedWindow: AffectedWindow, + clampedEdge: CGFloat, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + newFocusedFrame: CGRect, + screenFrame: CGRect, + gapSize: CGFloat) -> CGRect { + roundedFrameInsideVisibleFrame( + frameForAffectedWindow(affectedWindow, + edge: clampedEdge, + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + gapSize: gapSize), + visibleFrame: screenFrame + ) + } + + private static func frameForAffectedWindow(_ affectedWindow: AffectedWindow, + edge: CGFloat, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + newFocusedFrame: CGRect, + screenFrame: CGRect, + gapSize: CGFloat) -> CGRect { + switch affectedWindow.role { + case .matchingFocusedFrame: + return frameWithMinimumSize(sameSideFrame(baseFrame: newFocusedFrame, + movedEdge: movedEdge, + axis: axis, + edge: edge, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame), + minimumSize: affectedWindow.minimumSize, + resizeAxis: axis, + visibleFrame: screenFrame) + case .matchingMovingSpan: + return frameWithMinimumSize(sameSideFrame(baseFrame: affectedWindow.layoutFrame, + movedEdge: movedEdge, + axis: axis, + edge: edge, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame), + minimumSize: affectedWindow.minimumSize, + resizeAxis: axis, + visibleFrame: screenFrame) + case .adjacent: + return frameWithMinimumSize(adjacentFrame(baseFrame: affectedWindow.layoutFrame, + movedEdge: movedEdge, + axis: axis, + edge: edge, + screenFrame: screenFrame, + gapSize: gapSize), + minimumSize: affectedWindow.minimumSize, + resizeAxis: axis, + visibleFrame: screenFrame) + } + } + + private static func sameSideFrame(baseFrame: CGRect, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + edge: CGFloat, + newFocusedFrame: CGRect, + screenFrame: CGRect) -> CGRect { + let visibleMin = axisMin(screenFrame, axis) + let visibleMax = axisMax(screenFrame, axis) + switch movedEdge { + case .right, .top: + let fixedMin = clamp(axisMin(newFocusedFrame, axis), min: visibleMin, max: visibleMax) + return frame(baseFrame, axis: axis, min: fixedMin, max: edge, visibleFrame: screenFrame) + case .left, .bottom: + let fixedMax = clamp(axisMax(newFocusedFrame, axis), min: visibleMin, max: visibleMax) + return frame(baseFrame, axis: axis, min: edge, max: fixedMax, visibleFrame: screenFrame) + } + } + + private static func adjacentFrame(baseFrame: CGRect, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + edge: CGFloat, + screenFrame: CGRect, + gapSize: CGFloat) -> CGRect { + switch movedEdge { + case .right, .top: + let outerMax = min(axisMax(baseFrame, axis), axisMax(screenFrame, axis)) + return frame(baseFrame, axis: axis, min: edge + gapSize, max: outerMax, visibleFrame: screenFrame) + case .left, .bottom: + let outerMin = max(axisMin(baseFrame, axis), axisMin(screenFrame, axis)) + return frame(baseFrame, axis: axis, min: outerMin, max: edge - gapSize, visibleFrame: screenFrame) + } + } + + private static func frame(_ baseFrame: CGRect, + axis: CornerCycleExpansionAxis, + min: CGFloat, + max: CGFloat, + visibleFrame: CGRect) -> CGRect { + var rect = baseFrame + let orderedMin = Swift.min(min, max) + let orderedMax = Swift.max(min, max) + switch axis { + case .horizontal: + let perpMin = Swift.max(baseFrame.minY, visibleFrame.minY) + let perpMax = Swift.min(baseFrame.maxY, visibleFrame.maxY) + rect.origin.x = orderedMin + rect.size.width = orderedMax - orderedMin + rect.origin.y = perpMin + rect.size.height = Swift.max(0, perpMax - perpMin) + case .vertical: + let perpMin = Swift.max(baseFrame.minX, visibleFrame.minX) + let perpMax = Swift.min(baseFrame.maxX, visibleFrame.maxX) + rect.origin.x = perpMin + rect.size.width = Swift.max(0, perpMax - perpMin) + rect.origin.y = orderedMin + rect.size.height = orderedMax - orderedMin + } + return rect + } + + private static func roundedFrameInsideVisibleFrame(_ frame: CGRect, visibleFrame: CGRect) -> CGRect { + var rect = CGRect(x: round(frame.origin.x), + y: round(frame.origin.y), + width: round(frame.width), + height: round(frame.height)) + + if rect.minX < visibleFrame.minX { + let delta = visibleFrame.minX - rect.minX + rect.origin.x += delta + rect.size.width = Swift.max(0, rect.width - delta) + } + if rect.minY < visibleFrame.minY { + let delta = visibleFrame.minY - rect.minY + rect.origin.y += delta + rect.size.height = Swift.max(0, rect.height - delta) + } + if rect.maxX > visibleFrame.maxX { + rect.size.width = Swift.max(0, visibleFrame.maxX - rect.minX) + } + if rect.maxY > visibleFrame.maxY { + rect.size.height = Swift.max(0, visibleFrame.maxY - rect.minY) + } + + return rect + } + + private static func frameWithMinimumSize(_ frame: CGRect, + minimumSize: CGSize, + resizeAxis: CornerCycleExpansionAxis, + visibleFrame: CGRect) -> CGRect { + var rect = frame + switch resizeAxis { + case .horizontal: + if rect.height < minimumSize.height { + rect.size.height = min(minimumSize.height, visibleFrame.height) + if rect.maxY > visibleFrame.maxY { + rect.origin.y = visibleFrame.maxY - rect.height + } + if rect.minY < visibleFrame.minY { + rect.origin.y = visibleFrame.minY + } + } + case .vertical: + if rect.width < minimumSize.width { + rect.size.width = min(minimumSize.width, visibleFrame.width) + if rect.maxX > visibleFrame.maxX { + rect.origin.x = visibleFrame.maxX - rect.width + } + if rect.minX < visibleFrame.minX { + rect.origin.x = visibleFrame.minX + } + } + } + return rect + } + + private static func roundedEdge(_ edge: CGFloat, legalMin: CGFloat, legalMax: CGFloat) -> CGFloat { + let rounded = round(edge) + let integerMin = ceil(legalMin) + let integerMax = floor(legalMax) + guard integerMin <= integerMax else { + return rounded + } + return clamp(rounded, min: integerMin, max: integerMax) + } + + private static func reductionReasons(forDesiredEdge desiredEdge: CGFloat, edgeRange: EdgeRange) -> [String] { + if edgeRange.min > edgeRange.max { + return (edgeRange.lowerReasons + edgeRange.upperReasons).isEmpty + ? ["no legal shared-edge position satisfies all constraints"] + : edgeRange.lowerReasons + edgeRange.upperReasons + } + if desiredEdge < edgeRange.min { + return edgeRange.lowerReasons.isEmpty ? ["visible frame lower bound"] : edgeRange.lowerReasons + } + if desiredEdge > edgeRange.max { + return edgeRange.upperReasons.isEmpty ? ["visible frame upper bound"] : edgeRange.upperReasons + } + return ["integer pixel rounding"] + } + + private static func frameNeedsCorrection(plannedFrame: CGRect, + actualFrame: CGRect, + screenFrame: CGRect, + tolerance: CGFloat) -> Bool { + guard !actualFrame.isNull else { return false } + if !screenFrame.insetBy(dx: -tolerance, dy: -tolerance).contains(actualFrame) { + return true + } + return abs(plannedFrame.minX - actualFrame.minX) > tolerance + || abs(plannedFrame.minY - actualFrame.minY) > tolerance + || abs(plannedFrame.width - actualFrame.width) > tolerance + || abs(plannedFrame.height - actualFrame.height) > tolerance + } + + private static func effectiveMinimumSize(base: CGSize, + plannedFrame: CGRect, + actualFrame: CGRect, + layoutTolerance: CGFloat) -> CGSize { + let normalizedBase = normalizedMinimumSize(base) + guard !actualFrame.isNull else { return normalizedBase } + + let effectiveWidth = actualFrame.width > plannedFrame.width + layoutTolerance + ? max(normalizedBase.width, actualFrame.width) + : normalizedBase.width + let effectiveHeight = actualFrame.height > plannedFrame.height + layoutTolerance + ? max(normalizedBase.height, actualFrame.height) + : normalizedBase.height + return CGSize(width: effectiveWidth, height: effectiveHeight) + } + + private static func edgeCoordinate(_ frame: CGRect, _ edge: MovedEdge) -> CGFloat { + switch edge { + case .left: + return frame.minX + case .right: + return frame.maxX + case .top: + return frame.maxY + case .bottom: + return frame.minY + } + } + + private static func axisMin(_ frame: CGRect, _ axis: CornerCycleExpansionAxis) -> CGFloat { + axis == .horizontal ? frame.minX : frame.minY + } + + private static func axisMax(_ frame: CGRect, _ axis: CornerCycleExpansionAxis) -> CGFloat { + axis == .horizontal ? frame.maxX : frame.maxY + } + + private static func axisSize(_ size: CGSize, _ axis: CornerCycleExpansionAxis) -> CGFloat { + axis == .horizontal ? size.width : size.height + } + + private static func axisSizeName(_ axis: CornerCycleExpansionAxis) -> String { + axis == .horizontal ? "width" : "height" + } + + private static func axis(for movedEdge: MovedEdge) -> CornerCycleExpansionAxis { + switch movedEdge { + case .left, .right: + return .horizontal + case .top, .bottom: + return .vertical + } + } + + private static func perpendicularAxis(to axis: CornerCycleExpansionAxis) -> CornerCycleExpansionAxis { + axis == .horizontal ? .vertical : .horizontal + } + + private static func clamp(_ value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat { + if min > max { + return value < min ? min : max + } + return Swift.min(Swift.max(value, min), max) + } + + private static func normalizedMinimumSize(_ size: CGSize) -> CGSize { + CGSize(width: max(1, size.width), height: max(1, size.height)) + } + + private static func normalizedFullSpanFrame(_ candidate: CGRect, + focusedFrame: CGRect, + movedEdge: MovedEdge, + tolerance: CGFloat) -> CGRect { + var result = candidate + switch movedEdge { + case .left, .right: + if abs(candidate.minY - focusedFrame.minY) <= tolerance, + abs(candidate.maxY - focusedFrame.maxY) <= tolerance { + result.origin.y = focusedFrame.minY + result.size.height = focusedFrame.height + } + case .top, .bottom: + if abs(candidate.minX - focusedFrame.minX) <= tolerance, + abs(candidate.maxX - focusedFrame.maxX) <= tolerance { + result.origin.x = focusedFrame.minX + result.size.width = focusedFrame.width + } + } + return result + } + + private static func touchesOldMovingEdge(_ candidate: CGRect, + _ focused: CGRect, + movedEdge: MovedEdge, + tolerance: CGFloat, + gapSize: CGFloat) -> Bool { + switch movedEdge { + case .left: + return abs((focused.minX - candidate.maxX) - gapSize) <= tolerance + case .right: + return abs((candidate.minX - focused.maxX) - gapSize) <= tolerance + case .bottom: + return abs((focused.minY - candidate.maxY) - gapSize) <= tolerance + case .top: + return abs((candidate.minY - focused.maxY) - gapSize) <= tolerance + } + } + + private static func isSupportedPerpendicularSpan(_ candidate: CGRect, + _ focused: CGRect, + movedEdge: MovedEdge, + tolerance: CGFloat) -> Bool { + switch movedEdge { + case .left, .right: + return approximatelyMatchesSpan(candidateMin: candidate.minY, + candidateMax: candidate.maxY, + focusedMin: focused.minY, + focusedMax: focused.maxY, + tolerance: tolerance) + case .top, .bottom: + return approximatelyMatchesSpan(candidateMin: candidate.minX, + candidateMax: candidate.maxX, + focusedMin: focused.minX, + focusedMax: focused.maxX, + tolerance: tolerance) + } + } + + private static func matchesPerpendicularSpan(_ candidate: CGRect, + _ focused: CGRect, + movedEdge: MovedEdge, + tolerance: CGFloat) -> Bool { + switch movedEdge { + case .left, .right: + return abs(candidate.minY - focused.minY) <= tolerance + && abs(candidate.maxY - focused.maxY) <= tolerance + case .top, .bottom: + return abs(candidate.minX - focused.minX) <= tolerance + && abs(candidate.maxX - focused.maxX) <= tolerance + } + } + + private static func matchesMovingSpan(_ candidate: CGRect, + _ focused: CGRect, + movedEdge: MovedEdge, + tolerance: CGFloat) -> Bool { + switch movedEdge { + case .left, .right: + return abs(candidate.minX - focused.minX) <= tolerance + && abs(candidate.maxX - focused.maxX) <= tolerance + case .top, .bottom: + return abs(candidate.minY - focused.minY) <= tolerance + && abs(candidate.maxY - focused.maxY) <= tolerance + } + } + + private static func approximatelyMatchesSpan(candidateMin: CGFloat, + candidateMax: CGFloat, + focusedMin: CGFloat, + focusedMax: CGFloat, + tolerance: CGFloat) -> Bool { + let fullMatch = abs(candidateMin - focusedMin) <= tolerance + && abs(candidateMax - focusedMax) <= tolerance + if fullMatch { + return true + } + + let candidateIsWithinFocused = candidateMin >= focusedMin - tolerance + && candidateMax <= focusedMax + tolerance + let sharesFocusedBoundary = abs(candidateMin - focusedMin) <= tolerance + || abs(candidateMax - focusedMax) <= tolerance + let meaningfulSpan = candidateMax - candidateMin > tolerance + + return candidateIsWithinFocused && sharesFocusedBoundary && meaningfulSpan + } + + private static func isContainedInSameSideRegion(_ candidate: CGRect, + _ focused: CGRect, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat) -> Bool { + let candidateMin = axisMin(candidate, axis) + let candidateMax = axisMax(candidate, axis) + let focusedMin = axisMin(focused, axis) + let focusedMax = axisMax(focused, axis) + let candidateIsWithinFocused = candidateMin >= focusedMin - tolerance + && candidateMax <= focusedMax + tolerance + + guard candidateIsWithinFocused else { + return false + } + + switch movedEdge { + case .right, .top: + return abs(candidateMin - focusedMin) <= tolerance + case .left, .bottom: + return abs(candidateMax - focusedMax) <= tolerance + } + } + + private static func approximatelyMatchesFrame(_ candidate: CGRect, + _ focused: CGRect, + tolerance: CGFloat) -> Bool { + abs(candidate.minX - focused.minX) <= tolerance + && abs(candidate.maxX - focused.maxX) <= tolerance + && abs(candidate.minY - focused.minY) <= tolerance + && abs(candidate.maxY - focused.maxY) <= tolerance + } +} diff --git a/Rectangle/Defaults.swift b/Rectangle/Defaults.swift index 26b24475..babc3413 100644 --- a/Rectangle/Defaults.swift +++ b/Rectangle/Defaults.swift @@ -11,6 +11,7 @@ class Defaults { static let selectedCycleSizes = CycleSizesDefault() static let cycleSizesIsChanged = BoolDefault(key: "cycleSizesIsChanged") static let cornerCycleExpansionAxis = IntEnumDefault(key: "cornerCycleExpansionAxis", defaultValue: .horizontal) + static let cooperativeCornerResize = BoolDefault(key: "cooperativeCornerResize") static let allowAnyShortcut = BoolDefault(key: "allowAnyShortcut") static let windowSnapping = OptionalBoolDefault(key: "windowSnapping") static let almostMaximizeHeight = FloatDefault(key: "almostMaximizeHeight") @@ -110,6 +111,7 @@ class Defaults { selectedCycleSizes, cycleSizesIsChanged, cornerCycleExpansionAxis, + cooperativeCornerResize, allowAnyShortcut, windowSnapping, almostMaximizeHeight, diff --git a/Rectangle/PrefsWindow/SettingsViewController.swift b/Rectangle/PrefsWindow/SettingsViewController.swift index fa08fe57..83efbb9d 100644 --- a/Rectangle/PrefsWindow/SettingsViewController.swift +++ b/Rectangle/PrefsWindow/SettingsViewController.swift @@ -45,6 +45,7 @@ class SettingsViewController: NSViewController { private var cycleSizeCheckboxes = [NSButton]() private var cornerCycleExpansionAxisButtons = [NSButton]() + private var cooperativeCornerResizeCheckbox: NSButton? private var combinedDisplayModeCheckbox: NSButton? private var greenButtonOverrideCheckbox: NSButton? @@ -129,6 +130,10 @@ class SettingsViewController: NSViewController { Defaults.cornerCycleExpansionAxis.value = axis setToggleStatesForCornerCycleExpansionAxisButtons() } + + @objc func toggleCooperativeCornerResize(_ sender: NSButton) { + Defaults.cooperativeCornerResize.enabled = sender.state == .on + } @IBAction func checkForUpdates(_ sender: Any) { AppDelegate.instance.updaterController?.checkForUpdates(sender) @@ -1037,11 +1042,14 @@ class SettingsViewController: NSViewController { self.cycleSizeCheckboxes = cycleSizeCheckboxes let cornerCycleExpansionAxisRow = makeCornerCycleExpansionAxisRow() + let cooperativeCornerResizeCheckbox = makeCooperativeCornerResizeCheckbox() + self.cooperativeCornerResizeCheckbox = cooperativeCornerResizeCheckbox cycleSizesView.orientation = .vertical cycleSizesView.alignment = .leading cycleSizesView.spacing = 8 cycleSizesView.addArrangedSubview(makeCycleSizesRow(cycleSizeCheckboxes)) cycleSizesView.addArrangedSubview(cornerCycleExpansionAxisRow) + cycleSizesView.addArrangedSubview(cooperativeCornerResizeCheckbox) initializeCycleSizesView(animated: false) @@ -1127,6 +1135,7 @@ class SettingsViewController: NSViewController { } setToggleStatesForCycleSizeCheckboxes() setToggleStatesForCornerCycleExpansionAxisButtons() + setToggleStateForCooperativeCornerResizeCheckbox() } private func initializeCycleSizesView(animated: Bool = false) { @@ -1135,6 +1144,7 @@ class SettingsViewController: NSViewController { if showOptionsView { setToggleStatesForCycleSizeCheckboxes() setToggleStatesForCornerCycleExpansionAxisButtons() + setToggleStateForCooperativeCornerResizeCheckbox() } setVisibility(shown: showOptionsView, ofView: cycleSizesView, withConstraint: cycleSizesViewHeightConstraint, animated: animated) @@ -1280,6 +1290,15 @@ class SettingsViewController: NSViewController { button.setContentCompressionResistancePriority(.required, for: .vertical) return button } + + private func makeCooperativeCornerResizeCheckbox() -> NSButton { + let button = NSButton(checkboxWithTitle: NSLocalizedString("Resize adjacent windows when cycling side or corner shortcuts", tableName: "Main", value: "", comment: ""), + target: self, + action: #selector(toggleCooperativeCornerResize(_:))) + button.refusesFirstResponder = true + button.setContentCompressionResistancePriority(.required, for: .vertical) + return button + } private func configureHalfSplitRatioPopUpButton(_ popUpButton: HalfSplitRatioPopUpButton) { popUpButton.removeAllItems() @@ -1364,6 +1383,10 @@ class SettingsViewController: NSViewController { } } + private func setToggleStateForCooperativeCornerResizeCheckbox() { + cooperativeCornerResizeCheckbox?.state = Defaults.cooperativeCornerResize.enabled ? .on : .off + } + } extension SettingsViewController { diff --git a/Rectangle/Utilities/AXExtension.swift b/Rectangle/Utilities/AXExtension.swift index 2a6d0de1..5a9c040b 100644 --- a/Rectangle/Utilities/AXExtension.swift +++ b/Rectangle/Utilities/AXExtension.swift @@ -5,6 +5,8 @@ import Foundation extension NSAccessibility.Attribute { static let enhancedUserInterface = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") static let windowIds = NSAccessibility.Attribute(rawValue: "AXWindowsIDs") + static let minSize = NSAccessibility.Attribute(rawValue: "AXMinSize") + static let minimumSize = NSAccessibility.Attribute(rawValue: "AXMinimumSize") } extension AXValue { diff --git a/Rectangle/WindowActionCooperativeResize.swift b/Rectangle/WindowActionCooperativeResize.swift new file mode 100644 index 00000000..1353846b --- /dev/null +++ b/Rectangle/WindowActionCooperativeResize.swift @@ -0,0 +1,89 @@ +/// WindowActionCooperativeResize.swift + +import Foundation + +extension WindowAction { + var cooperativeResizeAxis: CornerCycleExpansionAxis? { + switch self { + case .topLeft, .topRight, .bottomLeft, .bottomRight: + return Defaults.cornerCycleExpansionAxis.value + case .leftHalf, .rightHalf: + return .horizontal + case .topHalf, .bottomHalf: + return .vertical + default: + return nil + } + } + + var cooperativeResizeMovedEdge: CooperativeCornerResize.MovedEdge? { + switch cooperativeResizeSide { + case .left: + return .right + case .right: + return .left + case .top: + return .bottom + case .bottom: + return .top + case .none: + return nil + } + } + + func isCompatibleRepeatedResizeAction(with other: WindowAction?) -> Bool { + guard let other else { return false } + if isCooperativeCornerAction, other.isCooperativeCornerAction { + return self == other + } + return cooperativeResizeSide == other.cooperativeResizeSide + && cooperativeResizeAxis == other.cooperativeResizeAxis + } + + var isCooperativeCornerAction: Bool { + switch self { + case .topLeft, .topRight, .bottomLeft, .bottomRight: + return true + default: + return false + } + } + + private var cooperativeResizeSide: CooperativeResizeSide? { + switch self { + case .leftHalf: + return .left + case .rightHalf: + return .right + case .topHalf: + return .top + case .bottomHalf: + return .bottom + case .topLeft: + return Defaults.cornerCycleExpansionAxis.value == .horizontal ? .left : .top + case .topRight: + return Defaults.cornerCycleExpansionAxis.value == .horizontal ? .right : .top + case .bottomLeft: + return Defaults.cornerCycleExpansionAxis.value == .horizontal ? .left : .bottom + case .bottomRight: + return Defaults.cornerCycleExpansionAxis.value == .horizontal ? .right : .bottom + default: + return nil + } + } +} + +private enum CooperativeResizeSide { + case left, right, top, bottom +} + +extension ExecutionSource { + var allowsCooperativeResize: Bool { + switch self { + case .keyboardShortcut, .dragToSnap: + return true + case .menuItem, .url, .titleBar: + return false + } + } +} diff --git a/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift b/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift index 0dafbc82..d768717c 100644 --- a/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift +++ b/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift @@ -68,7 +68,7 @@ extension CornerCycleExpansionCalculation { } func calculateRepeatedRect(_ params: RectCalculationParameters) -> RectResult { - guard params.lastAction?.action == params.action else { + guard params.action.isCompatibleRepeatedResizeAction(with: params.lastAction?.action) else { return calculateFirstRect(params) } @@ -78,7 +78,9 @@ extension CornerCycleExpansionCalculation { } let currentIndex = sortedPositions.firstIndex { cycleSize in - calculateRect(for: cycleSize, params: params).rect.equalTo(params.window.rect) + currentFrame(params.window.rect, + matchesCycleFrame: calculateRect(for: cycleSize, params: params).rect, + params: params) } if let currentIndex { @@ -123,4 +125,92 @@ extension CornerCycleExpansionCalculation { horizontalFraction: horizontalFraction, verticalFraction: verticalFraction) } + + private func currentFrame(_ currentFrame: CGRect, + matchesCycleFrame cycleFrame: CGRect, + params: RectCalculationParameters) -> Bool { + if cycleFrame.equalTo(currentFrame) { + return true + } + + let gappedCycleFrame = gapAdjustedCycleFrame(cycleFrame, params: params) + if gappedCycleFrame.equalTo(currentFrame) { + return true + } + + guard params.action.isCooperativeCornerAction else { + return false + } + + let axis = Defaults.cornerCycleExpansionAxis.value + let tolerance = max(CGFloat(4), CGFloat(Defaults.gapSize.value) * 2.0 + 4.0) + let currentAxisSize = axisSize(currentFrame, axis) + let expectedAxisSizes = [axisSize(cycleFrame, axis), axisSize(gappedCycleFrame, axis)] + guard expectedAxisSizes.contains(where: { abs(currentAxisSize - $0) <= tolerance }) else { + return false + } + + return [cycleFrame, gappedCycleFrame].contains { expectedFrame in + matchesFixedEdge(currentFrame, expectedFrame, action: params.action, axis: axis, tolerance: tolerance) + && matchesPerpendicularSpan(currentFrame, expectedFrame, axis: axis, tolerance: tolerance) + } + } + + private func gapAdjustedCycleFrame(_ cycleFrame: CGRect, params: RectCalculationParameters) -> CGRect { + let gapsApplicable = params.action.gapsApplicable + guard Defaults.gapSize.value > 0, + gapsApplicable != .none + else { + return cycleFrame + } + + return GapCalculation.applyGaps(cycleFrame, + dimension: gapsApplicable, + sharedEdges: params.action.gapSharedEdge, + gapSize: Defaults.gapSize.value, + skipTopGap: Defaults.skipGapTopEdge.enabled) + } + + private func matchesFixedEdge(_ currentFrame: CGRect, + _ expectedFrame: CGRect, + action: WindowAction, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat) -> Bool { + guard let movedEdge = action.cooperativeResizeMovedEdge else { + return false + } + + switch movedEdge { + case .right, .top: + return abs(axisMin(currentFrame, axis) - axisMin(expectedFrame, axis)) <= tolerance + case .left, .bottom: + return abs(axisMax(currentFrame, axis) - axisMax(expectedFrame, axis)) <= tolerance + } + } + + private func matchesPerpendicularSpan(_ currentFrame: CGRect, + _ expectedFrame: CGRect, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat) -> Bool { + switch axis { + case .horizontal: + return abs(currentFrame.minY - expectedFrame.minY) <= tolerance + && abs(currentFrame.maxY - expectedFrame.maxY) <= tolerance + case .vertical: + return abs(currentFrame.minX - expectedFrame.minX) <= tolerance + && abs(currentFrame.maxX - expectedFrame.maxX) <= tolerance + } + } + + private func axisMin(_ frame: CGRect, _ axis: CornerCycleExpansionAxis) -> CGFloat { + axis == .horizontal ? frame.minX : frame.minY + } + + private func axisMax(_ frame: CGRect, _ axis: CornerCycleExpansionAxis) -> CGFloat { + axis == .horizontal ? frame.maxX : frame.maxY + } + + private func axisSize(_ frame: CGRect, _ axis: CornerCycleExpansionAxis) -> CGFloat { + axis == .horizontal ? frame.width : frame.height + } } diff --git a/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift b/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift index 1eff882a..6a677bcb 100644 --- a/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift +++ b/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift @@ -18,5 +18,31 @@ extension RepeatedExecutionsInThirdsCalculation { let fraction = cycleDivision.fraction return calculateFractionalRect(params, fraction: fraction) } + + func calculateRepeatedRect(_ params: RectCalculationParameters) -> RectResult { + guard params.action.isCompatibleRepeatedResizeAction(with: params.lastAction?.action) else { + return calculateFirstRect(params) + } + + let sortedPositions = sortedCycleSizes() + guard !sortedPositions.isEmpty else { + return calculateFirstRect(params) + } + + let currentIndex = sortedPositions.firstIndex { cycleSize in + calculateRect(for: cycleSize, params: params).rect.equalTo(params.window.rect) + } + + if let currentIndex { + let nextIndex = (currentIndex + 1) % sortedPositions.count + return calculateRect(for: sortedPositions[nextIndex], params: params) + } + + guard let count = params.lastAction?.count else { + return calculateFirstRect(params) + } + + return calculateRect(for: sortedPositions[cycleIndex(forExecutionCount: count, in: sortedPositions)], params: params) + } } diff --git a/Rectangle/WindowManager+CooperativeCornerResize.swift b/Rectangle/WindowManager+CooperativeCornerResize.swift new file mode 100644 index 00000000..86727b17 --- /dev/null +++ b/Rectangle/WindowManager+CooperativeCornerResize.swift @@ -0,0 +1,861 @@ +/// WindowManager+CooperativeCornerResize.swift + +import Cocoa + +extension WindowManager { + + func applyCooperativeCornerResize(result: ResultParameters, + plan: CooperativeCornerApplicationPlan) -> CGRect { + var activePlan = plan + let focusedWindowIsExpanding = CooperativeCornerResize.focusedWindowIsExpanding(oldFrame: result.windowElement.frame.screenFlipped, + newFrame: activePlan.focusedFrame, + axis: activePlan.axis) + + if focusedWindowIsExpanding { + apply(activePlan.adjustments, + kind: .adjacent, + screenFrame: activePlan.screenFrame, + layoutTolerance: activePlan.layoutTolerance) + apply(activePlan.adjustments, + kind: .matchingFocusedFrame, + screenFrame: activePlan.screenFrame, + layoutTolerance: activePlan.layoutTolerance) + + if let correctedPlan = correctionPlanAfterApplyingCooperatingWindows(activePlan) { + activePlan = correctedPlan + apply(activePlan.adjustments, + kind: .adjacent, + screenFrame: activePlan.screenFrame, + layoutTolerance: activePlan.layoutTolerance) + apply(activePlan.adjustments, + kind: .matchingFocusedFrame, + screenFrame: activePlan.screenFrame, + layoutTolerance: activePlan.layoutTolerance) + } + + var resultingRect = applyFocusedCooperativeFrameIfNeeded(activePlan.focusedFrame, + result: result, + layoutTolerance: activePlan.layoutTolerance) + if let settledRect = settleCooperativeCornerResizeIfNeeded(plan: activePlan, + focusedWindowElement: result.windowElement, + result: result) { + resultingRect = settledRect + } + return resultingRect + } + + var resultingRect = applyFocusedCooperativeFrameIfNeeded(activePlan.focusedFrame, + result: result, + layoutTolerance: activePlan.layoutTolerance) + apply(activePlan.adjustments, + kind: .matchingFocusedFrame, + screenFrame: activePlan.screenFrame, + layoutTolerance: activePlan.layoutTolerance) + apply(activePlan.adjustments, + kind: .adjacent, + screenFrame: activePlan.screenFrame, + layoutTolerance: activePlan.layoutTolerance) + + if let settledRect = settleCooperativeCornerResizeIfNeeded(plan: activePlan, + focusedWindowElement: result.windowElement, + result: result) { + resultingRect = settledRect + } + return resultingRect + } + + func cooperativeCornerResizePlan(focusedWindowId: CGWindowID, + focusedWindowIsFixedSize: Bool, + focusedWindowMinimumSize: CGSize?, + action: WindowAction, + source: ExecutionSource, + oldFocusedFrame: CGRect, + newFocusedFrame: CGRect, + screenFrame: CGRect, + destinationScreenIsCurrentScreen: Bool, + lastRectangleAction: RectangleAction?) -> CooperativeCornerApplicationPlan? { + guard Defaults.cooperativeCornerResize.enabled, + source.allowsCooperativeResize, + !focusedWindowIsFixedSize, + destinationScreenIsCurrentScreen, + let cooperativeAxis = action.cooperativeResizeAxis, + let movedEdge = action.cooperativeResizeMovedEdge + else { + return nil + } + + let gapSize = max(0, CGFloat(Defaults.gapSize.value)) + let tolerance = CooperativeCornerResize.detectionTolerance(screenFrame: screenFrame, configuredGap: gapSize) + let captureTolerance = CooperativeCornerResize.captureTolerance(screenFrame: screenFrame, axis: cooperativeAxis) + let isRepeatedCooperativeAction = action.isCompatibleRepeatedResizeAction(with: lastRectangleAction?.action) + let actionDescription = isRepeatedCooperativeAction ? "repeated cooperative resize" : "initial corner/side cooperative placement" + let screenFrameAX = screenFrame.screenFlipped + let elementsById = AccessibilityElement.getAllWindowElements().reduce(into: [CGWindowID: AccessibilityElement]()) { elements, element in + guard let candidateId = element.getWindowId(), + candidateId != focusedWindowId, + element.isWindow == true, + element.isMinimized != true, + element.isFullScreen != true, + element.isHidden != true, + element.isSheet != true, + element.isResizable() + else { + return + } + + let frame = element.frame + guard !frame.isNull, screenFrameAX.intersects(frame) else { + return + } + + elements[candidateId] = element + } + + let candidates = elementsById.map { id, element in + CooperativeCornerResize.Candidate(id: id, + frame: element.frame.screenFlipped, + minimumSize: element.minimumSize) + } + let minimumSize = CGSize(width: max(1, CGFloat(Defaults.minimumWindowWidth.value)), + height: max(1, CGFloat(Defaults.minimumWindowHeight.value))) + let requestedFocusedFrame = (!isRepeatedCooperativeAction && action.isCooperativeCornerAction) + ? CooperativeCornerResize.focusedFrameResolvingRealizedCornerBoundary(requestedFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: cooperativeAxis, + movedEdge: movedEdge, + tolerance: tolerance, + gapSize: gapSize) + : newFocusedFrame + let candidateDiscoveryFrame = isRepeatedCooperativeAction ? oldFocusedFrame : requestedFocusedFrame + guard let plan = CooperativeCornerResize.plan(oldFocusedFrame: oldFocusedFrame, + newFocusedFrame: requestedFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: cooperativeAxis, + tolerance: tolerance, + minimumSize: minimumSize, + focusedMinimumSize: focusedWindowMinimumSize, + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdge, + candidateDiscoveryFrame: candidateDiscoveryFrame, + actionDescription: actionDescription) + else { + return nil + } + + plan.debugLog.forEach(Logger.log) + + let adjustments: [CooperativeCornerWindowAdjustment] = plan.adjustments.compactMap { adjustment in + guard let element = elementsById[adjustment.id] else { return nil } + return CooperativeCornerWindowAdjustment(element: element, + id: adjustment.id, + newFrame: adjustment.newFrame, + kind: adjustment.kind) + } + guard !adjustments.isEmpty else { return nil } + + return CooperativeCornerApplicationPlan(oldFocusedFrame: oldFocusedFrame, + requestedFocusedFrame: requestedFocusedFrame, + focusedFrame: plan.focusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: cooperativeAxis, + detectionTolerance: tolerance, + captureTolerance: captureTolerance, + layoutTolerance: 4, + minimumSize: minimumSize, + focusedMinimumSize: focusedWindowMinimumSize, + gapSize: gapSize, + movedEdge: movedEdge, + candidateDiscoveryFrame: candidateDiscoveryFrame, + actionDescription: actionDescription, + adjustments: adjustments, + debugLog: plan.debugLog) + } + + func applyCooperativeCornerCleanupIfNeeded(focusedWindowId: CGWindowID, + source: ExecutionSource, + oldFocusedFrame: CGRect, + newFocusedFrame: CGRect, + screenFrame: CGRect, + currentAction: WindowAction, + lastRectangleAction: RectangleAction?) { + guard Defaults.cooperativeCornerResize.enabled, + source.allowsCooperativeResize, + let previousAction = lastRectangleAction?.action, + previousAction.isCooperativeCornerAction, + currentAction != previousAction, + let cooperativeAxis = previousAction.cooperativeResizeAxis, + let movedEdge = previousAction.cooperativeResizeMovedEdge, + !oldFocusedFrame.isNull, + !newFocusedFrame.isNull, + !screenFrame.isNull, + screenFrame.intersects(oldFocusedFrame) + else { + return + } + + let gapSize = max(0, CGFloat(Defaults.gapSize.value)) + let tolerance = CooperativeCornerResize.detectionTolerance(screenFrame: screenFrame, configuredGap: gapSize) + let captureTolerance = CooperativeCornerResize.captureTolerance(screenFrame: screenFrame, axis: cooperativeAxis) + let layoutTolerance: CGFloat = 4 + let screenFrameAX = screenFrame.screenFlipped + let allElementsById = AccessibilityElement.getAllWindowElements().reduce(into: [CGWindowID: AccessibilityElement]()) { elements, element in + guard let candidateId = element.getWindowId(), + element.isWindow == true, + element.isMinimized != true, + element.isFullScreen != true, + element.isHidden != true, + element.isSheet != true, + element.isResizable() + else { + return + } + + let frame = element.frame + guard !frame.isNull, screenFrameAX.intersects(frame) else { + return + } + + elements[candidateId] = element + } + var elementsById = allElementsById.filter { $0.key != focusedWindowId } + + let candidates = elementsById.map { id, element in + CooperativeCornerResize.Candidate(id: id, + frame: element.frame.screenFlipped, + minimumSize: element.minimumSize) + } + guard let observedCornerFrame = observedCornerOccupantFrame(action: previousAction, + oldFocusedFrame: oldFocusedFrame, + candidates: candidates, + screenFrame: screenFrame, + axis: cooperativeAxis, + tolerance: tolerance, + captureTolerance: captureTolerance, + gapSize: gapSize), + let targetFrame = cleanupTargetFrame(action: previousAction, + observedFrame: observedCornerFrame, + screenFrame: screenFrame, + axis: cooperativeAxis, + movedEdge: movedEdge, + tolerance: tolerance, + includeCycleTargets: (lastRectangleAction?.count ?? 0) > 1) + else { + return + } + + let minimumSize = CGSize(width: max(1, CGFloat(Defaults.minimumWindowWidth.value)), + height: max(1, CGFloat(Defaults.minimumWindowHeight.value))) + let syntheticFocusedMinimumSize = CGSize(width: 1, height: 1) + if let focusedElement = allElementsById[focusedWindowId], + frameIsAdjacentToCleanupSource(focusedElement.frame.screenFlipped, + sourceFrame: observedCornerFrame, + movedEdge: movedEdge, + axis: cooperativeAxis, + tolerance: tolerance, + gapSize: gapSize) { + elementsById[focusedWindowId] = focusedElement + } + + let cleanupCandidates = elementsById.map { id, element in + CooperativeCornerResize.Candidate(id: id, + frame: element.frame.screenFlipped, + minimumSize: element.minimumSize) + } + guard let cleanupPlan = CooperativeCornerResize.plan(oldFocusedFrame: observedCornerFrame, + newFocusedFrame: targetFrame, + screenFrame: screenFrame, + candidates: cleanupCandidates, + axis: cooperativeAxis, + tolerance: tolerance, + minimumSize: minimumSize, + focusedMinimumSize: syntheticFocusedMinimumSize, + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdge, + candidateDiscoveryFrame: observedCornerFrame, + actionDescription: "cooperative resize cleanup after focused window left corner") + else { + return + } + + cleanupPlan.debugLog.forEach(Logger.log) + let cleanupAdjustments = applicationAdjustments(for: cleanupPlan.adjustments, elementsById: elementsById) + guard cleanupAdjustments.contains(where: { adjustment in + CooperativeCornerResize.frameNeedsApplication(currentFrame: adjustment.element.frame.screenFlipped, + solvedFrame: adjustment.newFrame, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) + }) else { + Logger.log("Cooperative resize cleanup no-op: solved frames already match current frames") + return + } + + applyCleanupAdjustments(cleanupAdjustments, + oldFocusedFrame: observedCornerFrame, + solvedFocusedFrame: cleanupPlan.focusedFrame, + axis: cooperativeAxis, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) + + let actualCandidateFramesById = Dictionary(uniqueKeysWithValues: cleanupAdjustments.map { adjustment in + (adjustment.id, adjustment.element.frame.screenFlipped) + }) + guard let correctionPlan = CooperativeCornerResize.correctionPlan(oldFocusedFrame: observedCornerFrame, + requestedFocusedFrame: targetFrame, + plannedPlan: cleanupPlan, + screenFrame: screenFrame, + candidates: cleanupCandidates, + actualFocusedFrame: cleanupPlan.focusedFrame, + actualCandidateFramesById: actualCandidateFramesById, + axis: cooperativeAxis, + tolerance: tolerance, + layoutTolerance: layoutTolerance, + minimumSize: minimumSize, + focusedMinimumSize: syntheticFocusedMinimumSize, + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdge, + candidateDiscoveryFrame: observedCornerFrame, + actionDescription: "cooperative resize cleanup settling pass") + else { + return + } + + correctionPlan.debugLog.forEach(Logger.log) + applyCleanupAdjustments(applicationAdjustments(for: correctionPlan.adjustments, elementsById: elementsById), + oldFocusedFrame: observedCornerFrame, + solvedFocusedFrame: correctionPlan.focusedFrame, + axis: cooperativeAxis, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) + } + + private func applicationAdjustments(for geometryAdjustments: [CooperativeCornerResize.Adjustment], + elementsById: [CGWindowID: AccessibilityElement]) -> [CooperativeCornerWindowAdjustment] { + geometryAdjustments.compactMap { adjustment in + guard let element = elementsById[adjustment.id] else { return nil } + return CooperativeCornerWindowAdjustment(element: element, + id: adjustment.id, + newFrame: adjustment.newFrame, + kind: adjustment.kind) + } + } + + private func applyCleanupAdjustments(_ adjustments: [CooperativeCornerWindowAdjustment], + oldFocusedFrame: CGRect, + solvedFocusedFrame: CGRect, + axis: CornerCycleExpansionAxis, + screenFrame: CGRect, + layoutTolerance: CGFloat) { + let focusedWindowIsExpanding = CooperativeCornerResize.focusedWindowIsExpanding(oldFrame: oldFocusedFrame, + newFrame: solvedFocusedFrame, + axis: axis) + if focusedWindowIsExpanding { + apply(adjustments, + kind: .adjacent, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) + apply(adjustments, + kind: .matchingFocusedFrame, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) + } else { + apply(adjustments, + kind: .matchingFocusedFrame, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) + apply(adjustments, + kind: .adjacent, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) + } + } + + private func observedCornerOccupantFrame(action: WindowAction, + oldFocusedFrame: CGRect, + candidates: [CooperativeCornerResize.Candidate], + screenFrame: CGRect, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + captureTolerance: CGFloat, + gapSize: CGFloat) -> CGRect? { + let cornerCandidates = candidates.filter { candidate in + let frame = candidate.frame + return abs(axisSize(frame, axis) - axisSize(oldFocusedFrame, axis)) <= max(tolerance, captureTolerance) + && self.frame(frame, + occupiesCornerFor: action, + screenFrame: screenFrame, + tolerance: tolerance, + gapSize: gapSize) + } + + return cornerCandidates.max { lhs, rhs in + let lhsSize = axisSize(lhs.frame, axis) + let rhsSize = axisSize(rhs.frame, axis) + if abs(lhsSize - rhsSize) > tolerance { + return lhsSize < rhsSize + } + return lhs.id > rhs.id + }?.frame + } + + func cleanupTargetFrame(action: WindowAction, + observedFrame: CGRect, + screenFrame: CGRect, + axis: CornerCycleExpansionAxis, + movedEdge: CooperativeCornerResize.MovedEdge, + tolerance: CGFloat, + includeCycleTargets: Bool) -> CGRect? { + let observedSize = axisSize(observedFrame, axis) + guard let configuredFrame = configuredCornerFrame(action: action, screenFrame: screenFrame), + observedSize > axisSize(configuredFrame, axis) + tolerance + else { + return nil + } + + let intendedSizes = intendedCornerAxisSizes(action: action, + screenFrame: screenFrame, + axis: axis, + includeCycleTargets: includeCycleTargets) + guard let targetSize = intendedSizes.min(by: { lhs, rhs in + abs(lhs - observedSize) < abs(rhs - observedSize) + }), + abs(targetSize - observedSize) > tolerance + else { + return nil + } + + return frame(observedFrame, + axis: axis, + movedEdge: movedEdge, + size: targetSize, + screenFrame: screenFrame) + } + + func frameIsAdjacentToCleanupSource(_ frame: CGRect, + sourceFrame: CGRect, + movedEdge: CooperativeCornerResize.MovedEdge, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + gapSize: CGFloat) -> Bool { + guard movedEdgeMatches(axis: axis, movedEdge: movedEdge), + matchesPerpendicularSpan(frame, sourceFrame: sourceFrame, movedEdge: movedEdge, tolerance: tolerance) + else { + return false + } + + switch movedEdge { + case .right: + return abs((frame.minX - sourceFrame.maxX) - gapSize) <= tolerance + case .left: + return abs((sourceFrame.minX - frame.maxX) - gapSize) <= tolerance + case .top: + return abs((frame.minY - sourceFrame.maxY) - gapSize) <= tolerance + case .bottom: + return abs((sourceFrame.minY - frame.maxY) - gapSize) <= tolerance + } + } + + private func movedEdgeMatches(axis: CornerCycleExpansionAxis, + movedEdge: CooperativeCornerResize.MovedEdge) -> Bool { + switch (axis, movedEdge) { + case (.horizontal, .left), (.horizontal, .right), (.vertical, .top), (.vertical, .bottom): + return true + default: + return false + } + } + + private func matchesPerpendicularSpan(_ frame: CGRect, + sourceFrame: CGRect, + movedEdge: CooperativeCornerResize.MovedEdge, + tolerance: CGFloat) -> Bool { + switch movedEdge { + case .left, .right: + return abs(frame.minY - sourceFrame.minY) <= tolerance + && abs(frame.maxY - sourceFrame.maxY) <= tolerance + case .top, .bottom: + return abs(frame.minX - sourceFrame.minX) <= tolerance + && abs(frame.maxX - sourceFrame.maxX) <= tolerance + } + } + + private func intendedCornerAxisSizes(action: WindowAction, + screenFrame: CGRect, + axis: CornerCycleExpansionAxis, + includeCycleTargets: Bool) -> [CGFloat] { + var sizes: [CGFloat] = [] + func appendUnique(_ value: CGFloat) { + guard value > 0, + !sizes.contains(where: { abs($0 - value) <= 0.001 }) + else { + return + } + sizes.append(value) + } + + if let configuredFrame = configuredCornerFrame(action: action, screenFrame: screenFrame) { + appendUnique(axisSize(configuredFrame, axis)) + } + + guard includeCycleTargets, + Defaults.subsequentExecutionMode.resizes else { + return sizes + } + + let useDefaultPositions = !Defaults.cycleSizesIsChanged.enabled + let positions = useDefaultPositions ? CycleSize.defaultSizes : Defaults.selectedCycleSizes.value + let sortedPositions = CycleSize.sortedSizes.filter { positions.contains($0) } + let screenSize = axis == .horizontal ? screenFrame.width : screenFrame.height + sortedPositions.forEach { cycleSize in + appendUnique(floor(screenSize * CGFloat(cycleSize.fraction) + 0.0001)) + } + return sizes + } + + private func configuredCornerFrame(action: WindowAction, screenFrame: CGRect) -> CGRect? { + let horizontalRatio = Defaults.horizontalSplitRatio.value / 100.0 + let verticalRatio = Defaults.verticalSplitRatio.value / 100.0 + let horizontalSide: HalfSplitSide + let verticalSide: HalfSplitSide + let horizontalFraction: Float + let verticalFraction: Float + + switch action { + case .topLeft: + horizontalSide = .leading + verticalSide = .leading + horizontalFraction = horizontalRatio + verticalFraction = verticalRatio + case .topRight: + horizontalSide = .trailing + verticalSide = .leading + horizontalFraction = 1.0 - horizontalRatio + verticalFraction = verticalRatio + case .bottomLeft: + horizontalSide = .leading + verticalSide = .trailing + horizontalFraction = horizontalRatio + verticalFraction = 1.0 - verticalRatio + case .bottomRight: + horizontalSide = .trailing + verticalSide = .trailing + horizontalFraction = 1.0 - horizontalRatio + verticalFraction = 1.0 - verticalRatio + default: + return nil + } + + return HalfSplitFrameCalculation.cornerRect(in: screenFrame, + horizontalSide: horizontalSide, + verticalSide: verticalSide, + horizontalFraction: horizontalFraction, + verticalFraction: verticalFraction) + } + + private func frame(_ frame: CGRect, + occupiesCornerFor action: WindowAction, + screenFrame: CGRect, + tolerance: CGFloat, + gapSize: CGFloat) -> Bool { + guard !frame.isNull, + screenFrame.intersects(frame) + else { + return false + } + + switch action { + case .topLeft: + return matchesOuterMin(frame.minX, screenFrame: screenFrame, axis: .horizontal, tolerance: tolerance, gapSize: gapSize) + && matchesOuterMax(frame.maxY, screenFrame: screenFrame, axis: .vertical, tolerance: tolerance, gapSize: gapSize) + case .topRight: + return matchesOuterMax(frame.maxX, screenFrame: screenFrame, axis: .horizontal, tolerance: tolerance, gapSize: gapSize) + && matchesOuterMax(frame.maxY, screenFrame: screenFrame, axis: .vertical, tolerance: tolerance, gapSize: gapSize) + case .bottomLeft: + return matchesOuterMin(frame.minX, screenFrame: screenFrame, axis: .horizontal, tolerance: tolerance, gapSize: gapSize) + && matchesOuterMin(frame.minY, screenFrame: screenFrame, axis: .vertical, tolerance: tolerance, gapSize: gapSize) + case .bottomRight: + return matchesOuterMax(frame.maxX, screenFrame: screenFrame, axis: .horizontal, tolerance: tolerance, gapSize: gapSize) + && matchesOuterMin(frame.minY, screenFrame: screenFrame, axis: .vertical, tolerance: tolerance, gapSize: gapSize) + default: + return false + } + } + + private func frame(_ observedFrame: CGRect, + axis: CornerCycleExpansionAxis, + movedEdge: CooperativeCornerResize.MovedEdge, + size: CGFloat, + screenFrame: CGRect) -> CGRect { + var target = observedFrame + let boundedSize = max(0, min(size, axis == .horizontal ? screenFrame.width : screenFrame.height)) + switch movedEdge { + case .right: + target.size.width = boundedSize + case .left: + target.origin.x = observedFrame.maxX - boundedSize + target.size.width = boundedSize + case .top: + target.size.height = boundedSize + case .bottom: + target.origin.y = observedFrame.maxY - boundedSize + target.size.height = boundedSize + } + return target + } + + private func axisSize(_ frame: CGRect, _ axis: CornerCycleExpansionAxis) -> CGFloat { + axis == .horizontal ? frame.width : frame.height + } + + private func matchesOuterMin(_ value: CGFloat, + screenFrame: CGRect, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + gapSize: CGFloat) -> Bool { + let screenMin = axis == .horizontal ? screenFrame.minX : screenFrame.minY + return abs(value - screenMin) <= tolerance + || abs(value - (screenMin + gapSize)) <= tolerance + } + + private func matchesOuterMax(_ value: CGFloat, + screenFrame: CGRect, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + gapSize: CGFloat) -> Bool { + let screenMax = axis == .horizontal ? screenFrame.maxX : screenFrame.maxY + return abs(value - screenMax) <= tolerance + || abs(value - (screenMax - gapSize)) <= tolerance + } + + private func apply(_ adjustments: [CooperativeCornerWindowAdjustment], + kind: CooperativeCornerResize.Adjustment.Kind, + screenFrame: CGRect, + layoutTolerance: CGFloat) { + adjustments.filter { $0.kind == kind }.forEach { adjustment in + if CooperativeCornerResize.frameNeedsApplication(currentFrame: adjustment.element.frame.screenFlipped, + solvedFrame: adjustment.newFrame, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) { + adjustment.element.setFrame(adjustment.newFrame.screenFlipped) + } else { + Logger.log("Cooperative resize no-op for \(adjustment.id): current frame already matches solved frame") + } + } + } + + private func correctionPlanAfterApplyingCooperatingWindows(_ plan: CooperativeCornerApplicationPlan) -> CooperativeCornerApplicationPlan? { + let actualCandidateFramesById = Dictionary(uniqueKeysWithValues: plan.adjustments.map { adjustment in + (adjustment.id, adjustment.element.frame.screenFlipped) + }) + + guard let correctionPlan = CooperativeCornerResize.correctionPlan(oldFocusedFrame: plan.oldFocusedFrame, + requestedFocusedFrame: plan.requestedFocusedFrame, + plannedPlan: plan.asGeometryPlan(), + screenFrame: plan.screenFrame, + candidates: plan.candidates, + actualFocusedFrame: plan.focusedFrame, + actualCandidateFramesById: actualCandidateFramesById, + axis: plan.axis, + tolerance: plan.detectionTolerance, + layoutTolerance: plan.layoutTolerance, + minimumSize: plan.minimumSize, + focusedMinimumSize: plan.focusedMinimumSize, + gapSize: plan.gapSize, + captureTolerance: plan.captureTolerance, + movedEdgeOverride: plan.movedEdge, + candidateDiscoveryFrame: plan.candidateDiscoveryFrame, + actionDescription: plan.actionDescription) + else { + return nil + } + + correctionPlan.debugLog.forEach(Logger.log) + return plan.replacingGeometry(with: correctionPlan) + } + + private func settleCooperativeCornerResizeIfNeeded(plan: CooperativeCornerApplicationPlan, + focusedWindowElement: AccessibilityElement, + result: ResultParameters) -> CGRect? { + let actualFocusedFrame = focusedWindowElement.frame.screenFlipped + let actualCandidateFramesById = Dictionary(uniqueKeysWithValues: plan.adjustments.map { adjustment in + (adjustment.id, adjustment.element.frame.screenFlipped) + }) + + guard let correctionPlan = CooperativeCornerResize.correctionPlan(oldFocusedFrame: plan.oldFocusedFrame, + requestedFocusedFrame: plan.requestedFocusedFrame, + plannedPlan: plan.asGeometryPlan(), + screenFrame: plan.screenFrame, + candidates: plan.candidates, + actualFocusedFrame: actualFocusedFrame, + actualCandidateFramesById: actualCandidateFramesById, + axis: plan.axis, + tolerance: plan.detectionTolerance, + layoutTolerance: plan.layoutTolerance, + minimumSize: plan.minimumSize, + focusedMinimumSize: plan.focusedMinimumSize, + gapSize: plan.gapSize, + captureTolerance: plan.captureTolerance, + movedEdgeOverride: plan.movedEdge, + candidateDiscoveryFrame: plan.candidateDiscoveryFrame, + actionDescription: plan.actionDescription) + else { + return nil + } + + correctionPlan.debugLog.forEach(Logger.log) + let elementsById = Dictionary(uniqueKeysWithValues: plan.adjustments.map { ($0.id, $0.element) }) + let correctionAdjustments: [CooperativeCornerWindowAdjustment] = correctionPlan.adjustments.compactMap { adjustment in + guard let element = elementsById[adjustment.id] else { return nil } + return CooperativeCornerWindowAdjustment(element: element, + id: adjustment.id, + newFrame: adjustment.newFrame, + kind: adjustment.kind) + } + + let focusedWindowIsExpanding = CooperativeCornerResize.focusedWindowIsExpanding(oldFrame: plan.oldFocusedFrame, + newFrame: correctionPlan.focusedFrame, + axis: plan.axis) + if focusedWindowIsExpanding { + apply(correctionAdjustments, + kind: .adjacent, + screenFrame: plan.screenFrame, + layoutTolerance: plan.layoutTolerance) + apply(correctionAdjustments, + kind: .matchingFocusedFrame, + screenFrame: plan.screenFrame, + layoutTolerance: plan.layoutTolerance) + _ = applyFocusedCooperativeFrameIfNeeded(correctionPlan.focusedFrame, + result: result, + layoutTolerance: plan.layoutTolerance) + } else { + _ = applyFocusedCooperativeFrameIfNeeded(correctionPlan.focusedFrame, + result: result, + layoutTolerance: plan.layoutTolerance) + apply(correctionAdjustments, + kind: .matchingFocusedFrame, + screenFrame: plan.screenFrame, + layoutTolerance: plan.layoutTolerance) + apply(correctionAdjustments, + kind: .adjacent, + screenFrame: plan.screenFrame, + layoutTolerance: plan.layoutTolerance) + } + + return focusedWindowElement.frame + } + + private func applyFocusedCooperativeFrameIfNeeded(_ frame: CGRect, + result: ResultParameters, + layoutTolerance: CGFloat) -> CGRect { + if CooperativeCornerResize.frameNeedsApplication(currentFrame: result.windowElement.frame.screenFlipped, + solvedFrame: frame, + screenFrame: result.visibleFrameOfScreen, + layoutTolerance: layoutTolerance) { + var calcResult = result.calcResult + calcResult.rect = frame + let focusedResult = ResultParameters(windowId: result.windowId, + action: result.action, + windowElement: result.windowElement, + calcResult: calcResult, + usableScreens: result.usableScreens, + visibleFrameOfScreen: result.visibleFrameOfScreen, + source: result.source, + isFixedSize: result.isFixedSize) + moveWindow(toRect: frame, result: focusedResult) + } else { + Logger.log("Cooperative resize focused no-op: current frame already matches solved frame") + } + return result.windowElement.frame + } +} + +struct CooperativeCornerWindowAdjustment { + let element: AccessibilityElement + let id: CGWindowID + let newFrame: CGRect + let kind: CooperativeCornerResize.Adjustment.Kind +} + +struct CooperativeCornerApplicationPlan { + let oldFocusedFrame: CGRect + let requestedFocusedFrame: CGRect + let focusedFrame: CGRect + let screenFrame: CGRect + let candidates: [CooperativeCornerResize.Candidate] + let axis: CornerCycleExpansionAxis + let detectionTolerance: CGFloat + let captureTolerance: CGFloat + let layoutTolerance: CGFloat + let minimumSize: CGSize + let focusedMinimumSize: CGSize? + let gapSize: CGFloat + let movedEdge: CooperativeCornerResize.MovedEdge + let candidateDiscoveryFrame: CGRect + let actionDescription: String + let adjustments: [CooperativeCornerWindowAdjustment] + let debugLog: [String] + + func needsApplication(focusedCurrentFrame: CGRect) -> Bool { + if CooperativeCornerResize.frameNeedsApplication(currentFrame: focusedCurrentFrame, + solvedFrame: focusedFrame, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) { + return true + } + + return adjustments.contains { adjustment in + CooperativeCornerResize.frameNeedsApplication(currentFrame: adjustment.element.frame.screenFlipped, + solvedFrame: adjustment.newFrame, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) + } + } + + func asGeometryPlan() -> CooperativeCornerResize.Plan { + let geometryAdjustments = adjustments.map { adjustment in + CooperativeCornerResize.Adjustment(id: adjustment.id, + oldFrame: adjustment.element.frame.screenFlipped, + newFrame: adjustment.newFrame, + kind: adjustment.kind) + } + return CooperativeCornerResize.Plan(focusedFrame: focusedFrame, + adjustments: geometryAdjustments, + debugLog: debugLog) + } + + func replacingGeometry(with geometryPlan: CooperativeCornerResize.Plan) -> CooperativeCornerApplicationPlan? { + let elementsById = Dictionary(uniqueKeysWithValues: adjustments.map { ($0.id, $0.element) }) + let updatedAdjustments: [CooperativeCornerWindowAdjustment] = geometryPlan.adjustments.compactMap { adjustment in + guard let element = elementsById[adjustment.id] else { return nil } + return CooperativeCornerWindowAdjustment(element: element, + id: adjustment.id, + newFrame: adjustment.newFrame, + kind: adjustment.kind) + } + + guard !updatedAdjustments.isEmpty else { return nil } + + return CooperativeCornerApplicationPlan(oldFocusedFrame: oldFocusedFrame, + requestedFocusedFrame: requestedFocusedFrame, + focusedFrame: geometryPlan.focusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: axis, + detectionTolerance: detectionTolerance, + captureTolerance: captureTolerance, + layoutTolerance: layoutTolerance, + minimumSize: minimumSize, + focusedMinimumSize: focusedMinimumSize, + gapSize: gapSize, + movedEdge: movedEdge, + candidateDiscoveryFrame: candidateDiscoveryFrame, + actionDescription: actionDescription, + adjustments: updatedAdjustments, + debugLog: geometryPlan.debugLog) + } +} diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index f7834712..c4e87458 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -122,17 +122,37 @@ class WindowManager { if Defaults.cyclingOverlapOffset.userEnabled, action.positionCycles { calcResult.rect = applyOverlapOffsetIfNeeded(calcResult.rect, windowId: windowId, screen: calcResult.screen) } + + let isFixedSize = (!frontmostWindowElement.isResizable() && action.resizes) || frontmostWindowElement.isSystemDialog == true + let visibleFrameOfDestinationScreen = calcResult.resultingScreenFrame ?? calcResult.screen.adjustedVisibleFrame(ignoreTodo) + let cooperativeCornerPlan = cooperativeCornerResizePlan(focusedWindowId: windowId, + focusedWindowIsFixedSize: isFixedSize, + focusedWindowMinimumSize: frontmostWindowElement.minimumSize, + action: action, + source: parameters.source, + oldFocusedFrame: currentNormalizedRect, + newFocusedFrame: calcResult.rect, + screenFrame: visibleFrameOfDestinationScreen, + destinationScreenIsCurrentScreen: usableScreens.currentScreen == calcResult.screen, + lastRectangleAction: lastRectangleAction) + if let cooperativeCornerPlan { + calcResult.rect = cooperativeCornerPlan.focusedFrame + } - if currentNormalizedRect.equalTo(calcResult.rect) { + if let cooperativeCornerPlan { + if !cooperativeCornerPlan.needsApplication(focusedCurrentFrame: currentNormalizedRect) { + Logger.log("Cooperative resize no-op: solved frames already match current frames") + recordAction(windowId: windowId, resultingRect: currentWindowRect, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) + return + } + } else if currentNormalizedRect.equalTo(calcResult.rect) { Logger.log("Current frame is equal to new frame") recordAction(windowId: windowId, resultingRect: currentWindowRect, action: calcResult.resultingAction, subAction: calcResult.resultingSubAction) return } - - let visibleFrameOfDestinationScreen = calcResult.resultingScreenFrame ?? calcResult.screen.adjustedVisibleFrame(ignoreTodo) - let isFixedSize = (!frontmostWindowElement.isResizable() && action.resizes) || frontmostWindowElement.isSystemDialog == true + let resultParameters = ResultParameters(windowId: windowId, action: action, windowElement: frontmostWindowElement, @@ -142,7 +162,13 @@ class WindowManager { source: parameters.source, isFixedSize: isFixedSize) - var resultingRect = apply(result: resultParameters) + var resultingRect: CGRect + if let cooperativeCornerPlan { + resultingRect = applyCooperativeCornerResize(result: resultParameters, + plan: cooperativeCornerPlan) + } else { + resultingRect = apply(result: resultParameters) + } let isMovedAcrossDisplays = usableScreens.currentScreen != calcResult.screen if isMovedAcrossDisplays { @@ -163,6 +189,17 @@ class WindowManager { } windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: resultingRect) } + + if !isMovedAcrossDisplays { + applyCooperativeCornerCleanupIfNeeded(focusedWindowId: windowId, + source: parameters.source, + oldFocusedFrame: currentNormalizedRect, + newFocusedFrame: resultingRect.screenFlipped, + screenFrame: usableScreens.currentScreen.adjustedVisibleFrame(ignoreTodo), + currentAction: action, + lastRectangleAction: lastRectangleAction) + resultingRect = frontmostWindowElement.frame + } postProcess(result: resultParameters, resultingRect: resultingRect) } @@ -170,17 +207,20 @@ class WindowManager { /// Move/resize a window based on the calculation results. /// - Returns: The rect of the window after applying the window action func apply(result: ResultParameters) -> CGRect { + let newRect = result.calcResult.rect + if !result.windowElement.frame.screenFlipped.equalTo(newRect) { + moveWindow(toRect: newRect, result: result) + } + return result.windowElement.frame + } + + func moveWindow(toRect rect: CGRect, result: ResultParameters) { let windowMoverChain = result.isFixedSize ? fixedSizeWindowMoverChain : standardWindowMoverChain - - let newRect = result.calcResult.rect - for windowMover in windowMoverChain { - windowMover.moveWindow(toRect: newRect, resultParameters: result) + windowMover.moveWindow(toRect: rect, resultParameters: result) } - - return result.windowElement.frame } func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) { diff --git a/Rectangle/mul.lproj/Main.xcstrings b/Rectangle/mul.lproj/Main.xcstrings index bceb5dd0..12f774c0 100644 --- a/Rectangle/mul.lproj/Main.xcstrings +++ b/Rectangle/mul.lproj/Main.xcstrings @@ -37676,6 +37676,9 @@ } } } + }, + "Resize adjacent windows when cycling side or corner shortcuts" : { + }, "rgM-f4-ycn.title" : { "comment" : "Class = \"NSMenuItem\"; title = \"Smart Dashes\"; ObjectID = \"rgM-f4-ycn\";", diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 49e6cd26..65630f8c 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -80,6 +80,20 @@ class PositionCyclesTests: XCTestCase { } } +class CooperativeResizeSourceTests: XCTestCase { + + func testKeyboardShortcutsAndDragSnappingAllowCooperativeResize() { + XCTAssertTrue(ExecutionSource.keyboardShortcut.allowsCooperativeResize) + XCTAssertTrue(ExecutionSource.dragToSnap.allowsCooperativeResize) + } + + func testNonSnappingSourcesDoNotAllowCooperativeResize() { + XCTAssertFalse(ExecutionSource.menuItem.allowsCooperativeResize) + XCTAssertFalse(ExecutionSource.url.allowsCooperativeResize) + XCTAssertFalse(ExecutionSource.titleBar.allowsCooperativeResize) + } +} + class ScreenFlippedTests: XCTestCase { func testScreenFlippedIsOwnInverse() { @@ -127,6 +141,1083 @@ class DefaultsExportTests: XCTestCase { XCTAssertTrue(keys.contains("cyclingOverlapOffset"), "cyclingOverlapOffset missing from Defaults.array") XCTAssertTrue(keys.contains("cyclingOverlapOffsetSize"), "cyclingOverlapOffsetSize missing from Defaults.array") XCTAssertTrue(keys.contains("cyclingOverlapMaxCascade"), "cyclingOverlapMaxCascade missing from Defaults.array") + XCTAssertTrue(keys.contains("cooperativeCornerResize"), "cooperativeCornerResize missing from Defaults.array") + } +} + +class CooperativeCornerResizeTests: XCTestCase { + private let screenFrame = CGRect(x: 0, y: 0, width: 1200, height: 900) + private let minimumSize = CGSize(width: 100, height: 100) + private let tolerance: CGFloat = 8 + + func testBottomLeftVerticalExpansionShrinksTopLeftNeighbor() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 300, width: 800, height: 600)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topLeft], axis: .vertical) + + XCTAssertEqual(adjustments.count, 1) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 0, y: 600, width: 800, height: 300)) + XCTAssertEqual(focusedNew.maxY, adjustments[0].newFrame.minY, accuracy: 0.001) + } + + func testBottomLeftVerticalExpansionKeepsFullTwoThirdsWhenFeasible() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 300, width: 800, height: 600)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topLeft], axis: .vertical) else { + XCTFail("Expected cooperative resize plan") + return + } + + assertRect(plan.focusedFrame, equals: focusedNew) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 600, width: 800, height: 300)) + } + + func testBottomLeftVerticalExpansionIsReducedByCooperatingMinimumHeight() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, + frame: CGRect(x: 0, y: 300, width: 800, height: 600), + minimumSize: CGSize(width: 100, height: 400)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topLeft], axis: .vertical) else { + XCTFail("Expected cooperative resize plan") + return + } + + assertRect(plan.focusedFrame, equals: CGRect(x: 0, y: 0, width: 800, height: 500)) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 500, width: 800, height: 400)) + XCTAssertTrue(plan.debugLog.contains { $0.contains("reduced requested movement") }) + } + + func testBottomLeftVerticalExpansionUsesVisibleFrameInsteadOfRawScreenFrame() { + let visibleFrame = CGRect(x: 0, y: 0, width: 1200, height: 840) + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, + frame: CGRect(x: 0, y: 300, width: 800, height: 600), + minimumSize: CGSize(width: 100, height: 300)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + screenFrame: visibleFrame, + candidates: [topLeft], + axis: .vertical) else { + XCTFail("Expected cooperative resize plan") + return + } + + assertRect(plan.focusedFrame, equals: CGRect(x: 0, y: 0, width: 800, height: 540)) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 540, width: 800, height: 300)) + XCTAssertLessThanOrEqual(plan.adjustments[0].newFrame.maxY, visibleFrame.maxY) + } + + func testVerticalExpansionRoundsSharedEdgeAtOneThirdTwoThirdsBoundary() { + let screenFrame = CGRect(x: 0, y: 0, width: 1200, height: 1000) + let oneThird = screenFrame.height / 3.0 + let twoThirds = screenFrame.height * 2.0 / 3.0 + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: oneThird) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: twoThirds) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: oneThird, width: 800, height: twoThirds)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + screenFrame: screenFrame, + candidates: [topLeft], + axis: .vertical) else { + XCTFail("Expected cooperative resize plan") + return + } + + assertRect(plan.focusedFrame, equals: CGRect(x: 0, y: 0, width: 800, height: 667)) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 667, width: 800, height: 333)) + XCTAssertEqual(plan.focusedFrame.maxY, plan.adjustments[0].newFrame.minY, accuracy: 0.001) + } + + func testAffectedWindowsReceiveOneFinalFrameEach() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 900) + let focusedNew = CGRect(x: 0, y: 0, width: 400, height: 900) + let bottomLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 0, width: 800, height: 300)) + let bottomRight = CooperativeCornerResize.Candidate(id: 3, frame: CGRect(x: 800, y: 0, width: 400, height: 300)) + let topRight = CooperativeCornerResize.Candidate(id: 4, frame: CGRect(x: 800, y: 300, width: 400, height: 600)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [bottomLeft, bottomRight, topRight], + axis: .horizontal) else { + XCTFail("Expected cooperative resize plan") + return + } + + let adjustedIds = plan.adjustments.map(\.id) + XCTAssertEqual(adjustedIds.count, Set(adjustedIds).count) + XCTAssertEqual(adjustedIds.sorted(), [2, 3, 4]) + } + + func testUnrelatedWindowsAreNotMoved() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 300, width: 800, height: 600)) + let unrelated = CooperativeCornerResize.Candidate(id: 3, frame: CGRect(x: 850, y: 50, width: 250, height: 250)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [topLeft, unrelated], + axis: .vertical) else { + XCTFail("Expected cooperative resize plan") + return + } + + XCTAssertEqual(plan.adjustments.map(\.id), [topLeft.id]) + } + + func testNearGridNeighborIsDetectedAndNormalized() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let terminalLikeTop = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 5, y: 304, width: 790, height: 596)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [terminalLikeTop], + axis: .vertical, + tolerance: 20) else { + XCTFail("Expected cooperative resize plan") + return + } + + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 600, width: 800, height: 300)) + } + + func testInitialCornerPushPlansAgainstRequestedSharedBoundary() { + let focusedOld = CGRect(x: 225, y: 120, width: 420, height: 360) + let focusedNew = CGRect(x: 0, y: 0, width: 600, height: 450) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 450, width: 600, height: 450)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [topLeft], + axis: .vertical, + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew, + actionDescription: "initial corner/side cooperative placement") else { + XCTFail("Expected initial cooperative placement plan") + return + } + + assertRect(plan.focusedFrame, equals: focusedNew) + assertRect(plan.adjustments[0].newFrame, equals: topLeft.frame) + XCTAssertFalse(CooperativeCornerResize.frameNeedsApplication(currentFrame: topLeft.frame, + solvedFrame: plan.adjustments[0].newFrame, + screenFrame: screenFrame, + layoutTolerance: 4)) + XCTAssertTrue(plan.debugLog.contains { $0.contains("initial corner/side cooperative placement") }) + } + + func testInitialCornerPushWithOversizedFocusedMinimumMovesBoundaryBeforeOverlap() { + let focusedOld = CGRect(x: 225, y: 120, width: 420, height: 360) + let focusedNew = CGRect(x: 0, y: 0, width: 600, height: 450) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 450, width: 600, height: 450)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [topLeft], + axis: .vertical, + focusedMinimumSize: CGSize(width: 100, height: 520), + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew) else { + XCTFail("Expected oversized focused cooperative placement plan") + return + } + + assertRect(plan.focusedFrame, equals: CGRect(x: 0, y: 0, width: 600, height: 520)) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 520, width: 600, height: 380)) + XCTAssertEqual(plan.focusedFrame.maxY, plan.adjustments[0].newFrame.minY, accuracy: 0.001) + XCTAssertLessThanOrEqual(plan.adjustments[0].newFrame.maxY, screenFrame.maxY) + } + + func testInitialSidePushWithOversizedNeighborGivesFocusedWindowPartialTarget() { + let focusedOld = CGRect(x: 180, y: 80, width: 480, height: 640) + let focusedNew = CGRect(x: 0, y: 0, width: 600, height: 900) + let rightSide = CooperativeCornerResize.Candidate(id: 2, + frame: CGRect(x: 600, y: 0, width: 600, height: 900), + minimumSize: CGSize(width: 700, height: 100)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [rightSide], + axis: .horizontal, + movedEdgeOverride: .right, + candidateDiscoveryFrame: focusedNew) else { + XCTFail("Expected initial side cooperative placement plan") + return + } + + assertRect(plan.focusedFrame, equals: CGRect(x: 0, y: 0, width: 500, height: 900)) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 500, y: 0, width: 700, height: 900)) + XCTAssertEqual(plan.focusedFrame.maxX, plan.adjustments[0].newFrame.minX, accuracy: 0.001) + } + + func testInitialCornerPushWithOversizedFocusedWindowAdjustsBothAxesWithoutSpill() { + let focusedOld = CGRect(x: 250, y: 160, width: 320, height: 260) + let focusedNew = CGRect(x: 0, y: 0, width: 400, height: 450) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 450, width: 400, height: 450)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [topLeft], + axis: .vertical, + focusedMinimumSize: CGSize(width: 700, height: 520), + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew) else { + XCTFail("Expected both-axis oversized cooperative placement plan") + return + } + + assertRect(plan.focusedFrame, equals: CGRect(x: 0, y: 0, width: 700, height: 520)) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 520, width: 400, height: 380)) + XCTAssertLessThanOrEqual(plan.focusedFrame.maxX, screenFrame.maxX) + XCTAssertLessThanOrEqual(plan.adjustments[0].newFrame.maxY, screenFrame.maxY) + XCTAssertEqual(plan.focusedFrame.maxY, plan.adjustments[0].newFrame.minY, accuracy: 0.001) + XCTAssertFalse(plan.focusedFrame.intersects(plan.adjustments[0].newFrame)) + } + + func testExistingCorrectInitialLayoutIsNoOpForAllSolvedFrames() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 600) + let focusedNew = focusedOld + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 600, width: 800, height: 300)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [topLeft], + axis: .vertical, + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew) else { + XCTFail("Expected cooperative no-op plan") + return + } + + XCTAssertFalse(CooperativeCornerResize.frameNeedsApplication(currentFrame: focusedOld, + solvedFrame: plan.focusedFrame, + screenFrame: screenFrame, + layoutTolerance: 4)) + XCTAssertFalse(CooperativeCornerResize.frameNeedsApplication(currentFrame: topLeft.frame, + solvedFrame: plan.adjustments[0].newFrame, + screenFrame: screenFrame, + layoutTolerance: 4)) + } + + func testInitialCornerPlacementPreservesExplicitlyShrunkOccupiedCell() { + let focusedOld = CGRect(x: 225, y: 120, width: 420, height: 360) + let focusedDefault = CGRect(x: 0, y: 300, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 600, width: 800, height: 300)) + let bottomLeft = CooperativeCornerResize.Candidate(id: 3, frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + let focusedNew = CooperativeCornerResize.focusedFramePreservingOccupiedCell(requestedFocusedFrame: focusedDefault, + screenFrame: screenFrame, + candidates: [topLeft, bottomLeft], + axis: .vertical, + movedEdge: .bottom, + tolerance: tolerance) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [topLeft, bottomLeft], + axis: .vertical, + captureTolerance: 72, + movedEdgeOverride: .bottom, + candidateDiscoveryFrame: focusedNew, + actionDescription: "initial corner/side cooperative placement") else { + XCTFail("Expected initial cooperative placement plan") + return + } + + assertRect(focusedNew, equals: topLeft.frame) + assertRect(plan.focusedFrame, equals: topLeft.frame) + assertRect(plan.adjustments[0].newFrame, equals: topLeft.frame) + assertRect(plan.adjustments[1].newFrame, equals: bottomLeft.frame) + } + + func testInitialCornerPlacementUsesLargerRealizedBoundaryBeforePlanning() { + let focusedOld = CGRect(x: 225, y: 120, width: 420, height: 360) + let focusedDefault = CGRect(x: 400, y: 600, width: 800, height: 300) + let constrainedTopRight = CooperativeCornerResize.Candidate(id: 2, + frame: CGRect(x: 400, y: 540, width: 800, height: 360)) + let focusedNew = CooperativeCornerResize.focusedFrameResolvingRealizedCornerBoundary(requestedFocusedFrame: focusedDefault, + screenFrame: screenFrame, + candidates: [constrainedTopRight], + axis: .vertical, + movedEdge: .bottom, + tolerance: tolerance) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [constrainedTopRight], + axis: .vertical, + movedEdgeOverride: .bottom, + candidateDiscoveryFrame: focusedNew, + actionDescription: "initial corner/side cooperative placement") else { + XCTFail("Expected initial cooperative placement plan") + return + } + + assertRect(focusedNew, equals: constrainedTopRight.frame) + assertRect(plan.focusedFrame, equals: constrainedTopRight.frame) + assertRect(plan.adjustments[0].newFrame, equals: constrainedTopRight.frame) + XCTAssertFalse(CooperativeCornerResize.frameNeedsApplication(currentFrame: constrainedTopRight.frame, + solvedFrame: plan.adjustments[0].newFrame, + screenFrame: screenFrame, + layoutTolerance: 4)) + } + + func testInitialCornerPlacementUsesObservedCyclicBoundaryBeforePlanning() { + let focusedDefault = CGRect(x: 400, y: 600, width: 800, height: 300) + let cycledTopRight = CooperativeCornerResize.Candidate(id: 2, + frame: CGRect(x: 400, y: 300, width: 800, height: 600)) + + let focusedNew = CooperativeCornerResize.focusedFrameResolvingRealizedCornerBoundary(requestedFocusedFrame: focusedDefault, + screenFrame: screenFrame, + candidates: [cycledTopRight], + axis: .vertical, + movedEdge: .bottom, + tolerance: tolerance) + + assertRect(focusedNew, equals: cycledTopRight.frame) + } + + func testCornerCleanupRebalancesRemainingWindowsAfterMinConstrainedWindowLeaves() { + let removedTopRight = CGRect(x: 400, y: 540, width: 800, height: 360) + let targetTopRight = CGRect(x: 400, y: 600, width: 800, height: 300) + let remainingTopRight = CooperativeCornerResize.Candidate(id: 2, + frame: CGRect(x: 400, y: 540, width: 800, height: 360)) + let bottomRight = CooperativeCornerResize.Candidate(id: 3, + frame: CGRect(x: 400, y: 0, width: 800, height: 540)) + + guard let plan = cooperativePlan(focusedOld: removedTopRight, + focusedNew: targetTopRight, + candidates: [remainingTopRight, bottomRight], + axis: .vertical, + focusedMinimumSize: CGSize(width: 1, height: 1), + movedEdgeOverride: .bottom, + candidateDiscoveryFrame: removedTopRight, + actionDescription: "cooperative resize cleanup after focused window left corner") else { + XCTFail("Expected cleanup cooperative plan") + return + } + + assertRect(plan.focusedFrame, equals: targetTopRight) + assertRect(plan.adjustments[0].newFrame, equals: targetTopRight) + assertRect(plan.adjustments[1].newFrame, equals: CGRect(x: 400, y: 0, width: 800, height: 600)) + } + + func testCornerCleanupKeepsConstrainedLayoutWhenRemainingWindowCannotFitTarget() { + let removedTopRight = CGRect(x: 400, y: 540, width: 800, height: 360) + let targetTopRight = CGRect(x: 400, y: 600, width: 800, height: 300) + let remainingTopRight = CooperativeCornerResize.Candidate(id: 2, + frame: CGRect(x: 400, y: 540, width: 800, height: 360), + minimumSize: CGSize(width: 100, height: 360)) + let bottomRight = CooperativeCornerResize.Candidate(id: 3, + frame: CGRect(x: 400, y: 0, width: 800, height: 540)) + + guard let plan = cooperativePlan(focusedOld: removedTopRight, + focusedNew: targetTopRight, + candidates: [remainingTopRight, bottomRight], + axis: .vertical, + focusedMinimumSize: CGSize(width: 1, height: 1), + movedEdgeOverride: .bottom, + candidateDiscoveryFrame: removedTopRight, + actionDescription: "cooperative resize cleanup after focused window left corner") else { + XCTFail("Expected constrained cleanup cooperative plan") + return + } + + assertRect(plan.focusedFrame, equals: removedTopRight) + assertRect(plan.adjustments[0].newFrame, equals: remainingTopRight.frame) + assertRect(plan.adjustments[1].newFrame, equals: bottomRight.frame) + } + + func testCornerCleanupRebalancesFocusedAdjacentDestinationAfterMinConstrainedWindowLeaves() { + let removedTopLeft = CGRect(x: 0, y: 540, width: 800, height: 360) + let targetTopLeft = CGRect(x: 0, y: 600, width: 800, height: 300) + let remainingTopLeft = CooperativeCornerResize.Candidate(id: 2, + frame: removedTopLeft) + let focusedBottomLeft = CooperativeCornerResize.Candidate(id: 99, + frame: CGRect(x: 0, y: 0, width: 800, height: 540)) + + guard let plan = cooperativePlan(focusedOld: removedTopLeft, + focusedNew: targetTopLeft, + candidates: [remainingTopLeft, focusedBottomLeft], + axis: .vertical, + focusedMinimumSize: CGSize(width: 1, height: 1), + movedEdgeOverride: .bottom, + candidateDiscoveryFrame: removedTopLeft, + actionDescription: "cooperative resize cleanup after focused window left corner") else { + XCTFail("Expected focused destination cleanup cooperative plan") + return + } + + let focusedDestinationAdjustment = plan.adjustments.first { $0.id == focusedBottomLeft.id } + + assertRect(plan.focusedFrame, equals: targetTopLeft) + assertRect(focusedDestinationAdjustment?.newFrame ?? .null, + equals: CGRect(x: 0, y: 0, width: 800, height: 600)) + } + + func testNearbyWindowEightPercentOffGridIsCapturedAndNormalized() { + let focusedOld = CGRect(x: 250, y: 160, width: 320, height: 260) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let offGridTop = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 90, y: 660, width: 620, height: 240)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [offGridTop], + axis: .vertical, + captureTolerance: 72, + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew) else { + XCTFail("Expected off-grid nearby window to be captured") + return + } + + XCTAssertEqual(plan.adjustments.map(\.id), [offGridTop.id]) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 600, width: 800, height: 300)) + XCTAssertTrue(plan.debugLog.contains { $0.contains("capture-tolerance") }) + } + + func testBoundaryCrossingWindowIsCapturedAndAssignedAdjacent() { + let focusedOld = CGRect(x: 250, y: 160, width: 320, height: 260) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let crossingTop = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 560, width: 800, height: 340)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [crossingTop], + axis: .vertical, + captureTolerance: 72, + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew) else { + XCTFail("Expected boundary-crossing window to be captured") + return + } + + XCTAssertEqual(plan.adjustments[0].kind, .adjacent) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 600, width: 800, height: 300)) + XCTAssertTrue(plan.debugLog.contains { $0.contains("boundary crossing") }) + } + + func testGapConsumingWindowIsCorrectedWhenFeasible() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 600) + let focusedNew = focusedOld + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 605, width: 800, height: 295)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [topLeft], + axis: .vertical, + gapSize: 12, + captureTolerance: 72, + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew) else { + XCTFail("Expected gap-consuming window to be corrected") + return + } + + assertRect(plan.focusedFrame, equals: focusedNew) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 612, width: 800, height: 288)) + XCTAssertEqual(plan.adjustments[0].newFrame.minY - plan.focusedFrame.maxY, 12, accuracy: 0.001) + } + + func testAggressiveCaptureIgnoresUnrelatedFloatingWindow() { + let focusedOld = CGRect(x: 250, y: 160, width: 320, height: 260) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let floating = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 900, y: 620, width: 180, height: 160)) + + let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [floating], + axis: .vertical, + captureTolerance: 72, + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew) + + XCTAssertNil(plan) + } + + func testConfiguredGapIsPreservedForVerticalStack() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 312, width: 800, height: 588)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [topLeft], + axis: .vertical, + gapSize: 12) else { + XCTFail("Expected cooperative resize plan") + return + } + + assertRect(plan.focusedFrame, equals: focusedNew) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 612, width: 800, height: 288)) + XCTAssertEqual(plan.adjustments[0].newFrame.minY - plan.focusedFrame.maxY, 12, accuracy: 0.001) + } + + func testConfiguredGapAndOversizedNeighborReduceExpansion() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, + frame: CGRect(x: 0, y: 312, width: 800, height: 588), + minimumSize: CGSize(width: 100, height: 350)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [topLeft], + axis: .vertical, + gapSize: 12) else { + XCTFail("Expected cooperative resize plan") + return + } + + assertRect(plan.focusedFrame, equals: CGRect(x: 0, y: 0, width: 800, height: 538)) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 0, y: 550, width: 800, height: 350)) + XCTAssertEqual(plan.adjustments[0].newFrame.minY - plan.focusedFrame.maxY, 12, accuracy: 0.001) + } + + func testCapturedGapPerimeterSurvivesOversizedNeighbor() { + let focusedOld = CGRect(x: 12, y: 12, width: 782, height: 282) + let focusedNew = CGRect(x: 12, y: 12, width: 782, height: 582) + let topLeft = CooperativeCornerResize.Candidate(id: 2, + frame: CGRect(x: 102, y: 654, width: 602, height: 234), + minimumSize: CGSize(width: 100, height: 350)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [topLeft], + axis: .vertical, + gapSize: 12, + captureTolerance: 72, + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew) else { + XCTFail("Expected gap-aware cooperative resize plan") + return + } + + assertRect(plan.focusedFrame, equals: CGRect(x: 12, y: 12, width: 782, height: 514)) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 12, y: 538, width: 782, height: 350)) + XCTAssertEqual(plan.adjustments[0].newFrame.minY - plan.focusedFrame.maxY, 12, accuracy: 0.001) + XCTAssertEqual(plan.adjustments[0].newFrame.maxY, screenFrame.maxY - 12, accuracy: 0.001) + } + + func testCapturedHorizontalGapPerimeterSurvivesOversizedNeighbor() { + let focusedOld = CGRect(x: 12, y: 12, width: 282, height: 876) + let focusedNew = CGRect(x: 12, y: 12, width: 582, height: 876) + let rightSide = CooperativeCornerResize.Candidate(id: 2, + frame: CGRect(x: 654, y: 72, width: 534, height: 756), + minimumSize: CGSize(width: 700, height: 100)) + + guard let plan = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [rightSide], + axis: .horizontal, + gapSize: 12, + captureTolerance: 96, + movedEdgeOverride: .right, + candidateDiscoveryFrame: focusedNew) else { + XCTFail("Expected horizontal gap-aware cooperative resize plan") + return + } + + assertRect(plan.focusedFrame, equals: CGRect(x: 12, y: 12, width: 464, height: 876)) + assertRect(plan.adjustments[0].newFrame, equals: CGRect(x: 488, y: 12, width: 700, height: 876)) + XCTAssertEqual(plan.adjustments[0].newFrame.minX - plan.focusedFrame.maxX, 12, accuracy: 0.001) + XCTAssertEqual(plan.adjustments[0].newFrame.maxX, screenFrame.maxX - 12, accuracy: 0.001) + } + + func testSettlingPassAdjustsVerticalOversizedNeighbor() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 300, width: 800, height: 600)) + + guard let planned = cooperativePlan(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topLeft], axis: .vertical), + let correction = correctionPlan(focusedOld: focusedOld, + focusedNew: focusedNew, + plannedPlan: planned, + candidates: [topLeft], + actualFocusedFrame: planned.focusedFrame, + actualCandidateFramesById: [2: CGRect(x: 0, y: 500, width: 800, height: 400)], + axis: .vertical) else { + XCTFail("Expected cooperative correction plan") + return + } + + assertRect(correction.focusedFrame, equals: CGRect(x: 0, y: 0, width: 800, height: 500)) + assertRect(correction.adjustments[0].newFrame, equals: CGRect(x: 0, y: 500, width: 800, height: 400)) + } + + func testPreFocusedSettlingPreservesGapForOversizedTopNeighbor() { + let focusedOld = CGRect(x: 1600, y: 160, width: 620, height: 540) + let focusedNew = CGRect(x: 1077, y: 160, width: 2103, height: 1060) + let topRight = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 1077, y: 1155, width: 2103, height: 600)) + + guard let planned = cooperativePlan(focusedOld: focusedOld, + focusedNew: focusedNew, + screenFrame: CGRect(x: 0, y: 140, width: 3200, height: 1635), + candidates: [topRight], + axis: .vertical, + tolerance: 24, + gapSize: 20, + captureTolerance: 96, + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew), + let correction = CooperativeCornerResize.correctionPlan(oldFocusedFrame: focusedOld, + requestedFocusedFrame: focusedNew, + plannedPlan: planned, + screenFrame: CGRect(x: 0, y: 140, width: 3200, height: 1635), + candidates: [topRight], + actualFocusedFrame: planned.focusedFrame, + actualCandidateFramesById: [topRight.id: topRight.frame], + axis: .vertical, + tolerance: 24, + layoutTolerance: 4, + minimumSize: minimumSize, + gapSize: 20, + captureTolerance: 96, + movedEdgeOverride: .top, + candidateDiscoveryFrame: focusedNew) else { + XCTFail("Expected pre-focused correction plan") + return + } + + assertRect(correction.focusedFrame, equals: CGRect(x: 1077, y: 160, width: 2103, height: 975)) + assertRect(correction.adjustments[0].newFrame, equals: CGRect(x: 1077, y: 1155, width: 2103, height: 600)) + XCTAssertEqual(correction.adjustments[0].newFrame.minY - correction.focusedFrame.maxY, 20, accuracy: 0.001) + } + + func testSettlingPassAdjustsHorizontalOversizedNeighbor() { + let focusedOld = CGRect(x: 0, y: 0, width: 600, height: 900) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 900) + let rightSide = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 600, y: 0, width: 600, height: 900)) + + guard let planned = cooperativePlan(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [rightSide], axis: .horizontal), + let correction = correctionPlan(focusedOld: focusedOld, + focusedNew: focusedNew, + plannedPlan: planned, + candidates: [rightSide], + actualFocusedFrame: planned.focusedFrame, + actualCandidateFramesById: [2: CGRect(x: 700, y: 0, width: 500, height: 900)], + axis: .horizontal) else { + XCTFail("Expected cooperative correction plan") + return + } + + assertRect(correction.focusedFrame, equals: CGRect(x: 0, y: 0, width: 700, height: 900)) + assertRect(correction.adjustments[0].newFrame, equals: CGRect(x: 700, y: 0, width: 500, height: 900)) + } + + func testSettlingPassHandlesBothAxisOversizedNeighborWithoutSpill() { + let focusedOld = CGRect(x: 0, y: 0, width: 400, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 400, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 300, width: 400, height: 600)) + + guard let planned = cooperativePlan(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topLeft], axis: .vertical), + let correction = correctionPlan(focusedOld: focusedOld, + focusedNew: focusedNew, + plannedPlan: planned, + candidates: [topLeft], + actualFocusedFrame: planned.focusedFrame, + actualCandidateFramesById: [2: CGRect(x: 0, y: 500, width: 700, height: 400)], + axis: .vertical) else { + XCTFail("Expected cooperative correction plan") + return + } + + assertRect(correction.focusedFrame, equals: CGRect(x: 0, y: 0, width: 400, height: 500)) + assertRect(correction.adjustments[0].newFrame, equals: CGRect(x: 0, y: 500, width: 700, height: 400)) + XCTAssertLessThanOrEqual(correction.adjustments[0].newFrame.maxX, screenFrame.maxX) + XCTAssertLessThanOrEqual(correction.adjustments[0].newFrame.maxY, screenFrame.maxY) + } + + func testSettlingPassHandlesOversizedInitiatingWindow() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 300, width: 800, height: 600)) + + guard let planned = cooperativePlan(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topLeft], axis: .vertical), + let correction = correctionPlan(focusedOld: focusedOld, + focusedNew: focusedNew, + plannedPlan: planned, + candidates: [topLeft], + actualFocusedFrame: CGRect(x: 0, y: 0, width: 800, height: 700), + actualCandidateFramesById: [2: planned.adjustments[0].newFrame], + axis: .vertical) else { + XCTFail("Expected cooperative correction plan") + return + } + + assertRect(correction.focusedFrame, equals: CGRect(x: 0, y: 0, width: 800, height: 700)) + assertRect(correction.adjustments[0].newFrame, equals: CGRect(x: 0, y: 700, width: 800, height: 200)) + } + + func testSettlingPassIsSkippedWhenActualFramesMatchPlan() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 300, width: 800, height: 600)) + + guard let planned = cooperativePlan(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topLeft], axis: .vertical) else { + XCTFail("Expected cooperative resize plan") + return + } + + let correction = correctionPlan(focusedOld: focusedOld, + focusedNew: focusedNew, + plannedPlan: planned, + candidates: [topLeft], + actualFocusedFrame: planned.focusedFrame, + actualCandidateFramesById: [2: planned.adjustments[0].newFrame], + axis: .vertical) + + XCTAssertNil(correction) + } + + func testBottomLeftVerticalShrinkExpandsTopLeftNeighbor() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 600) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 300) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 600, width: 800, height: 300)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topLeft], axis: .vertical) + + XCTAssertEqual(adjustments.count, 1) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 0, y: 300, width: 800, height: 600)) + XCTAssertEqual(focusedNew.maxY, adjustments[0].newFrame.minY, accuracy: 0.001) + } + + func testMatchingBottomLeftWindowShrinksWithFocusedWindowBeforeNeighborExpands() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 600) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 300) + let matchingBottomLeft = CooperativeCornerResize.Candidate(id: 2, frame: focusedOld) + let topLeft = CooperativeCornerResize.Candidate(id: 3, frame: CGRect(x: 0, y: 600, width: 800, height: 300)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [matchingBottomLeft, topLeft], axis: .vertical) + + XCTAssertEqual(adjustments.count, 2) + XCTAssertEqual(adjustments[0].id, matchingBottomLeft.id) + XCTAssertEqual(adjustments[0].kind, .matchingFocusedFrame) + assertRect(adjustments[0].newFrame, equals: focusedNew) + XCTAssertEqual(adjustments[1].id, topLeft.id) + XCTAssertEqual(adjustments[1].kind, .adjacent) + assertRect(adjustments[1].newFrame, equals: CGRect(x: 0, y: 300, width: 800, height: 600)) + } + + func testTopLeftHorizontalExpansionShrinksTopRightNeighbor() { + let focusedOld = CGRect(x: 0, y: 300, width: 600, height: 600) + let focusedNew = CGRect(x: 0, y: 300, width: 800, height: 600) + let topRight = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 600, y: 300, width: 600, height: 600)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topRight], axis: .horizontal) + + XCTAssertEqual(adjustments.count, 1) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 800, y: 300, width: 400, height: 600)) + XCTAssertEqual(focusedNew.maxX, adjustments[0].newFrame.minX, accuracy: 0.001) + } + + func testTopLeftHorizontalShrinkExpandsTopRightNeighbor() { + let focusedOld = CGRect(x: 0, y: 300, width: 800, height: 600) + let focusedNew = CGRect(x: 0, y: 300, width: 600, height: 600) + let topRight = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 800, y: 300, width: 400, height: 600)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topRight], axis: .horizontal) + + XCTAssertEqual(adjustments.count, 1) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 600, y: 300, width: 600, height: 600)) + XCTAssertEqual(focusedNew.maxX, adjustments[0].newFrame.minX, accuracy: 0.001) + } + + func testLeftSideHorizontalExpansionShrinksRightSideNeighbor() { + let focusedOld = CGRect(x: 0, y: 0, width: 600, height: 900) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 900) + let rightSide = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 600, y: 0, width: 600, height: 900)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [rightSide], axis: .horizontal) + + XCTAssertEqual(adjustments.count, 1) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 800, y: 0, width: 400, height: 900)) + XCTAssertEqual(focusedNew.maxX, adjustments[0].newFrame.minX, accuracy: 0.001) + } + + func testLeftSideHorizontalExpansionShrinksStackedRightCornerNeighbors() { + let focusedOld = CGRect(x: 0, y: 0, width: 600, height: 900) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 900) + let bottomRight = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 600, y: 0, width: 600, height: 450)) + let topRight = CooperativeCornerResize.Candidate(id: 3, frame: CGRect(x: 600, y: 450, width: 600, height: 450)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [bottomRight, topRight], axis: .horizontal) + + XCTAssertEqual(adjustments.count, 2) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 800, y: 0, width: 400, height: 450)) + assertRect(adjustments[1].newFrame, equals: CGRect(x: 800, y: 450, width: 400, height: 450)) + } + + func testLeftSideHorizontalShrinkExpandsRightSideNeighbor() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 900) + let focusedNew = CGRect(x: 0, y: 0, width: 600, height: 900) + let rightSide = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 800, y: 0, width: 400, height: 900)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [rightSide], axis: .horizontal) + + XCTAssertEqual(adjustments.count, 1) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 600, y: 0, width: 600, height: 900)) + XCTAssertEqual(focusedNew.maxX, adjustments[0].newFrame.minX, accuracy: 0.001) + } + + func testMatchingLeftSideWindowShrinksWithFocusedWindowBeforeRightSideNeighborExpands() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 900) + let focusedNew = CGRect(x: 0, y: 0, width: 600, height: 900) + let matchingLeftSide = CooperativeCornerResize.Candidate(id: 2, frame: focusedOld) + let rightSide = CooperativeCornerResize.Candidate(id: 3, frame: CGRect(x: 800, y: 0, width: 400, height: 900)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [matchingLeftSide, rightSide], axis: .horizontal) + + XCTAssertEqual(adjustments.count, 2) + XCTAssertEqual(adjustments[0].id, matchingLeftSide.id) + XCTAssertEqual(adjustments[0].kind, .matchingFocusedFrame) + assertRect(adjustments[0].newFrame, equals: focusedNew) + XCTAssertEqual(adjustments[1].id, rightSide.id) + XCTAssertEqual(adjustments[1].kind, .adjacent) + assertRect(adjustments[1].newFrame, equals: CGRect(x: 600, y: 0, width: 600, height: 900)) + } + + func testLeftSideShrinkAlsoShrinksPartialBottomLeftOccupant() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 900) + let focusedNew = CGRect(x: 0, y: 0, width: 400, height: 900) + let bottomLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 0, width: 800, height: 300)) + let bottomRight = CooperativeCornerResize.Candidate(id: 3, frame: CGRect(x: 800, y: 0, width: 400, height: 300)) + let topRight = CooperativeCornerResize.Candidate(id: 4, frame: CGRect(x: 800, y: 300, width: 400, height: 600)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, + focusedNew: focusedNew, + candidates: [bottomLeft, bottomRight, topRight], + axis: .horizontal) + + XCTAssertEqual(adjustments.count, 3) + XCTAssertEqual(adjustments[0].id, bottomLeft.id) + XCTAssertEqual(adjustments[0].kind, .matchingFocusedFrame) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 0, y: 0, width: 400, height: 300)) + assertRect(adjustments[1].newFrame, equals: CGRect(x: 400, y: 0, width: 800, height: 300)) + assertRect(adjustments[2].newFrame, equals: CGRect(x: 400, y: 300, width: 800, height: 600)) + } + + func testRightSideHorizontalExpansionShrinksLeftSideNeighbor() { + let focusedOld = CGRect(x: 600, y: 0, width: 600, height: 900) + let focusedNew = CGRect(x: 400, y: 0, width: 800, height: 900) + let leftSide = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 0, width: 600, height: 900)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [leftSide], axis: .horizontal) + + XCTAssertEqual(adjustments.count, 1) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 0, y: 0, width: 400, height: 900)) + XCTAssertEqual(adjustments[0].newFrame.maxX, focusedNew.minX, accuracy: 0.001) + } + + func testTopSideVerticalExpansionShrinksBottomSideNeighbor() { + let focusedOld = CGRect(x: 0, y: 450, width: 1200, height: 450) + let focusedNew = CGRect(x: 0, y: 300, width: 1200, height: 600) + let bottomSide = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 0, width: 1200, height: 450)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [bottomSide], axis: .vertical) + + XCTAssertEqual(adjustments.count, 1) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 0, y: 0, width: 1200, height: 300)) + XCTAssertEqual(adjustments[0].newFrame.maxY, focusedNew.minY, accuracy: 0.001) + } + + func testTopSideVerticalExpansionShrinksStackedBottomCornerNeighbors() { + let focusedOld = CGRect(x: 0, y: 450, width: 1200, height: 450) + let focusedNew = CGRect(x: 0, y: 300, width: 1200, height: 600) + let bottomLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 0, width: 600, height: 450)) + let bottomRight = CooperativeCornerResize.Candidate(id: 3, frame: CGRect(x: 600, y: 0, width: 600, height: 450)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [bottomLeft, bottomRight], axis: .vertical) + + XCTAssertEqual(adjustments.count, 2) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 0, y: 0, width: 600, height: 300)) + assertRect(adjustments[1].newFrame, equals: CGRect(x: 600, y: 0, width: 600, height: 300)) + } + + func testTopSideVerticalShrinkExpandsBottomSideNeighbor() { + let focusedOld = CGRect(x: 0, y: 300, width: 1200, height: 600) + let focusedNew = CGRect(x: 0, y: 450, width: 1200, height: 450) + let bottomSide = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 0, width: 1200, height: 300)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [bottomSide], axis: .vertical) + + XCTAssertEqual(adjustments.count, 1) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 0, y: 0, width: 1200, height: 450)) + XCTAssertEqual(adjustments[0].newFrame.maxY, focusedNew.minY, accuracy: 0.001) + } + + func testBottomSideVerticalExpansionShrinksTopSideNeighbor() { + let focusedOld = CGRect(x: 0, y: 0, width: 1200, height: 450) + let focusedNew = CGRect(x: 0, y: 0, width: 1200, height: 600) + let topSide = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 0, y: 450, width: 1200, height: 450)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topSide], axis: .vertical) + + XCTAssertEqual(adjustments.count, 1) + assertRect(adjustments[0].newFrame, equals: CGRect(x: 0, y: 600, width: 1200, height: 300)) + XCTAssertEqual(focusedNew.maxY, adjustments[0].newFrame.minY, accuracy: 0.001) + } + + func testNoAdjacentCandidateFound() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [], axis: .vertical) + + XCTAssertTrue(adjustments.isEmpty) + } + + func testAmbiguousFloatingWindowIsIgnored() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let floating = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 20, y: 302, width: 500, height: 240)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [floating], axis: .vertical) + + XCTAssertTrue(adjustments.isEmpty) + } + + func testToleranceMatchesNormalTiledNeighborsWithGaps() { + let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 300) + let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) + let topLeft = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 3, y: 305, width: 794, height: 592)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topLeft], axis: .vertical) + + XCTAssertEqual(adjustments.count, 1) + XCTAssertEqual(adjustments[0].newFrame.minY, focusedNew.maxY, accuracy: 0.001) + } + + func testNonCycledAxisRemainsUnchanged() { + let focusedOld = CGRect(x: 0, y: 300, width: 600, height: 600) + let focusedNew = CGRect(x: 0, y: 300, width: 800, height: 600) + let topRight = CooperativeCornerResize.Candidate(id: 2, frame: CGRect(x: 600, y: 300, width: 600, height: 600)) + + let adjustments = cooperativeAdjustments(focusedOld: focusedOld, focusedNew: focusedNew, candidates: [topRight], axis: .horizontal) + + XCTAssertEqual(adjustments.count, 1) + XCTAssertEqual(adjustments[0].newFrame.minY, topRight.frame.minY, accuracy: 0.001) + XCTAssertEqual(adjustments[0].newFrame.height, topRight.frame.height, accuracy: 0.001) + } + + func testDisabledExperimentalPreferenceLeavesCornerCalculationUnchanged() { + let savedValue = Defaults.cooperativeCornerResize.enabled + let savedAxis = Defaults.cornerCycleExpansionAxis.value + let savedHorizontalSplitRatio = Defaults.horizontalSplitRatio.value + let savedVerticalSplitRatio = Defaults.verticalSplitRatio.value + defer { + Defaults.cooperativeCornerResize.enabled = savedValue + Defaults.cornerCycleExpansionAxis.value = savedAxis + Defaults.horizontalSplitRatio.value = savedHorizontalSplitRatio + Defaults.verticalSplitRatio.value = savedVerticalSplitRatio + } + + Defaults.cooperativeCornerResize.enabled = false + Defaults.cornerCycleExpansionAxis.value = .vertical + Defaults.horizontalSplitRatio.value = 50 + Defaults.verticalSplitRatio.value = 50 + + let visibleFrame = CGRect(x: 10, y: 20, width: 1200, height: 900) + let firstRect = WindowCalculationFactory.lowerLeftCalculation.calculateRect(RectCalculationParameters(window: Window(id: 1, rect: visibleFrame), + visibleFrameOfScreen: visibleFrame, + action: .bottomLeft, + lastAction: nil)).rect + let repeatedRect = WindowCalculationFactory.lowerLeftCalculation.calculateRect(RectCalculationParameters(window: Window(id: 1, rect: firstRect), + visibleFrameOfScreen: visibleFrame, + action: .bottomLeft, + lastAction: RectangleAction(action: .bottomLeft, + subAction: nil, + rect: firstRect, + count: 1))).rect + + assertRect(repeatedRect, equals: CGRect(x: 10, y: 20, width: 600, height: 600)) + } + + private func cooperativeAdjustments(focusedOld: CGRect, + focusedNew: CGRect, + candidates: [CooperativeCornerResize.Candidate], + axis: CornerCycleExpansionAxis, + gapSize: CGFloat = 0) -> [CooperativeCornerResize.Adjustment] { + CooperativeCornerResize.adjustments(oldFocusedFrame: focusedOld, + newFocusedFrame: focusedNew, + screenFrame: screenFrame, + candidates: candidates, + axis: axis, + tolerance: tolerance, + minimumSize: minimumSize, + gapSize: gapSize) + } + + private func cooperativePlan(focusedOld: CGRect, + focusedNew: CGRect, + screenFrame: CGRect? = nil, + candidates: [CooperativeCornerResize.Candidate], + axis: CornerCycleExpansionAxis, + tolerance: CGFloat? = nil, + gapSize: CGFloat = 0, + focusedMinimumSize: CGSize? = nil, + captureTolerance: CGFloat? = nil, + movedEdgeOverride: CooperativeCornerResize.MovedEdge? = nil, + candidateDiscoveryFrame: CGRect? = nil, + actionDescription: String = "test cooperative resize") -> CooperativeCornerResize.Plan? { + CooperativeCornerResize.plan(oldFocusedFrame: focusedOld, + newFocusedFrame: focusedNew, + screenFrame: screenFrame ?? self.screenFrame, + candidates: candidates, + axis: axis, + tolerance: tolerance ?? self.tolerance, + minimumSize: minimumSize, + focusedMinimumSize: focusedMinimumSize, + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdgeOverride, + candidateDiscoveryFrame: candidateDiscoveryFrame, + actionDescription: actionDescription) + } + + private func correctionPlan(focusedOld: CGRect, + focusedNew: CGRect, + plannedPlan: CooperativeCornerResize.Plan, + candidates: [CooperativeCornerResize.Candidate], + actualFocusedFrame: CGRect, + actualCandidateFramesById: [CGWindowID: CGRect], + axis: CornerCycleExpansionAxis, + gapSize: CGFloat = 0, + captureTolerance: CGFloat? = nil, + movedEdgeOverride: CooperativeCornerResize.MovedEdge? = nil, + candidateDiscoveryFrame: CGRect? = nil) -> CooperativeCornerResize.Plan? { + CooperativeCornerResize.correctionPlan(oldFocusedFrame: focusedOld, + requestedFocusedFrame: focusedNew, + plannedPlan: plannedPlan, + screenFrame: screenFrame, + candidates: candidates, + actualFocusedFrame: actualFocusedFrame, + actualCandidateFramesById: actualCandidateFramesById, + axis: axis, + tolerance: tolerance, + layoutTolerance: 4, + minimumSize: minimumSize, + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdgeOverride, + candidateDiscoveryFrame: candidateDiscoveryFrame) + } + + private func assertRect(_ rect: CGRect, equals expected: CGRect, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(rect.origin.x, expected.origin.x, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(rect.origin.y, expected.origin.y, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(rect.width, expected.width, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(rect.height, expected.height, accuracy: 0.001, file: file, line: line) } } @@ -156,6 +1247,8 @@ class HalfSplitCornerCalculationTests: XCTestCase { private var savedCornerCycleExpansionAxis: CornerCycleExpansionAxis = .horizontal private var savedCycleSizesIsChanged = false private var savedSelectedCycleSizes = Set() + private var savedGapSize: Float = 0 + private var savedSkipGapTopEdge = false private let visibleFrame = CGRect(x: 10, y: 20, width: 1200, height: 900) override func setUp() { @@ -166,8 +1259,12 @@ class HalfSplitCornerCalculationTests: XCTestCase { savedCornerCycleExpansionAxis = Defaults.cornerCycleExpansionAxis.value savedCycleSizesIsChanged = Defaults.cycleSizesIsChanged.enabled savedSelectedCycleSizes = Defaults.selectedCycleSizes.value + savedGapSize = Defaults.gapSize.value + savedSkipGapTopEdge = Defaults.skipGapTopEdge.enabled Defaults.subsequentExecutionMode.value = .resize Defaults.cycleSizesIsChanged.enabled = false + Defaults.gapSize.value = 0 + Defaults.skipGapTopEdge.enabled = false } override func tearDown() { @@ -177,6 +1274,8 @@ class HalfSplitCornerCalculationTests: XCTestCase { Defaults.cornerCycleExpansionAxis.value = savedCornerCycleExpansionAxis Defaults.cycleSizesIsChanged.enabled = savedCycleSizesIsChanged Defaults.selectedCycleSizes.value = savedSelectedCycleSizes + Defaults.gapSize.value = savedGapSize + Defaults.skipGapTopEdge.enabled = savedSkipGapTopEdge super.tearDown() } @@ -275,6 +1374,89 @@ class HalfSplitCornerCalculationTests: XCTestCase { XCTAssertEqual(firstFrame.width, secondFrame.width, accuracy: 0.001) } + func testRepeatedCornerCyclingRecognizesGapAdjustedCooperativeBoundary() { + Defaults.horizontalSplitRatio.value = CycleSize.twoThirds.percentValue + Defaults.verticalSplitRatio.value = CycleSize.oneThird.percentValue + Defaults.cornerCycleExpansionAxis.value = .vertical + Defaults.gapSize.value = 20 + + let rawTwoThirdsFrame = WindowCalculationFactory.lowerRightCalculation.calculateRect(params(for: .bottomRight)).rect + var cooperativeCurrentFrame = GapCalculation.applyGaps(rawTwoThirdsFrame, + dimension: .both, + sharedEdges: WindowAction.bottomRight.gapSharedEdge, + gapSize: Defaults.gapSize.value, + skipTopGap: Defaults.skipGapTopEdge.enabled) + cooperativeCurrentFrame.size.height = rawTwoThirdsFrame.height + + let repeatedFrame = WindowCalculationFactory.lowerRightCalculation.calculateRect(repeatedParams(for: .bottomRight, + currentRect: cooperativeCurrentFrame, + count: 1)).rect + + assertRect(rawTwoThirdsFrame, equals: CGRect(x: 810, y: 20, width: 400, height: 600)) + assertRect(cooperativeCurrentFrame, equals: CGRect(x: 820, y: 40, width: 370, height: 600)) + assertRect(repeatedFrame, equals: CGRect(x: 810, y: 20, width: 400, height: 300)) + } + + func testCleanupTargetSkipsCornerReducedByAdjacentMinimumConstraint() { + Defaults.horizontalSplitRatio.value = CycleSize.twoThirds.percentValue + Defaults.verticalSplitRatio.value = CycleSize.oneThird.percentValue + Defaults.cornerCycleExpansionAxis.value = .vertical + + let reducedBottomLeft = CGRect(x: 10, y: 20, width: 800, height: 500) + let cleanupTarget = WindowManager().cleanupTargetFrame(action: .bottomLeft, + observedFrame: reducedBottomLeft, + screenFrame: visibleFrame, + axis: .vertical, + movedEdge: .top, + tolerance: 8, + includeCycleTargets: true) + + XCTAssertNil(cleanupTarget) + } + + func testCleanupTargetAllowsExpandedMinimumConstrainedCorner() { + Defaults.horizontalSplitRatio.value = CycleSize.twoThirds.percentValue + Defaults.verticalSplitRatio.value = CycleSize.oneThird.percentValue + Defaults.cornerCycleExpansionAxis.value = .vertical + + let expandedTopLeft = CGRect(x: 10, y: 400, width: 800, height: 520) + let cleanupTarget = WindowManager().cleanupTargetFrame(action: .topLeft, + observedFrame: expandedTopLeft, + screenFrame: visibleFrame, + axis: .vertical, + movedEdge: .bottom, + tolerance: 8, + includeCycleTargets: false) + + assertRect(cleanupTarget ?? .null, equals: CGRect(x: 10, y: 620, width: 800, height: 300)) + } + + func testCleanupSourceAdjacencyOnlyMatchesDirectDestination() { + let manager = WindowManager() + let sourceTopLeft = CGRect(x: 10, y: 400, width: 800, height: 520) + let adjacentBottomLeft = CGRect(x: 10, y: 20, width: 800, height: 380) + let otherBottom = CGRect(x: 810, y: 20, width: 400, height: 380) + + XCTAssertTrue(manager.frameIsAdjacentToCleanupSource(adjacentBottomLeft, + sourceFrame: sourceTopLeft, + movedEdge: .bottom, + axis: .vertical, + tolerance: 8, + gapSize: 0)) + XCTAssertFalse(manager.frameIsAdjacentToCleanupSource(otherBottom, + sourceFrame: sourceTopLeft, + movedEdge: .bottom, + axis: .vertical, + tolerance: 8, + gapSize: 0)) + XCTAssertFalse(manager.frameIsAdjacentToCleanupSource(adjacentBottomLeft, + sourceFrame: sourceTopLeft, + movedEdge: .right, + axis: .vertical, + tolerance: 8, + gapSize: 0)) + } + func testRepeatedCornersWithVerticalExpansionCycleHeightOnly() { setSplitRatio(60) Defaults.cornerCycleExpansionAxis.value = .vertical @@ -331,6 +1513,75 @@ class HalfSplitCornerCalculationTests: XCTestCase { assertRect(secondRepeatedRect, equals: CGRect(x: 10, y: 20, width: 600, height: 300)) } + func testRepeatedSideShortcutAdvancesWhenCurrentFrameMatchesSplitRatioCycleSize() { + setSplitRatio(CycleSize.twoThirds.percentValue) + + let leftFrame = WindowCalculationFactory.leftHalfCalculation.calculateRect(params(for: .leftHalf)).rect + let repeatedLeftFrame = WindowCalculationFactory.leftHalfCalculation.calculateRepeatedRect(repeatedParams(for: .leftHalf, currentRect: leftFrame)).rect + + assertRect(leftFrame, equals: CGRect(x: 10, y: 20, width: 800, height: 900)) + assertRect(repeatedLeftFrame, equals: CGRect(x: 10, y: 20, width: 400, height: 900)) + } + + func testRepeatedTopShortcutAdvancesWhenCurrentFrameMatchesSplitRatioCycleSize() { + setSplitRatio(CycleSize.twoThirds.percentValue) + + let topFrame = WindowCalculationFactory.topHalfCalculation.calculateRect(params(for: .topHalf)).rect + let repeatedTopFrame = WindowCalculationFactory.topHalfCalculation.calculateRepeatedRect(repeatedParams(for: .topHalf, currentRect: topFrame)).rect + + assertRect(topFrame, equals: CGRect(x: 10, y: 320, width: 1200, height: 600)) + assertRect(repeatedTopFrame, equals: CGRect(x: 10, y: 620, width: 1200, height: 300)) + } + + func testHorizontalCornerShortcutCanCycleAfterCompatibleSideShortcut() { + setSplitRatio(50) + Defaults.cornerCycleExpansionAxis.value = .horizontal + + let leftFrame = WindowCalculationFactory.leftHalfCalculation.calculateRect(params(for: .leftHalf)).rect + let topLeftFrame = WindowCalculationFactory.upperLeftCalculation.calculateRect(RectCalculationParameters(window: Window(id: 1, rect: leftFrame), + visibleFrameOfScreen: visibleFrame, + action: .topLeft, + lastAction: RectangleAction(action: .leftHalf, + subAction: nil, + rect: leftFrame, + count: 1))).rect + + assertRect(topLeftFrame, equals: CGRect(x: 10, y: 470, width: 800, height: 450)) + } + + func testVerticalCornerShortcutCanCycleAfterCompatibleSideShortcut() { + setSplitRatio(50) + Defaults.cornerCycleExpansionAxis.value = .vertical + + let topFrame = WindowCalculationFactory.topHalfCalculation.calculateRect(params(for: .topHalf)).rect + let topLeftFrame = WindowCalculationFactory.upperLeftCalculation.calculateRect(RectCalculationParameters(window: Window(id: 1, rect: topFrame), + visibleFrameOfScreen: visibleFrame, + action: .topLeft, + lastAction: RectangleAction(action: .topHalf, + subAction: nil, + rect: topFrame, + count: 1))).rect + + assertRect(topLeftFrame, equals: CGRect(x: 10, y: 320, width: 600, height: 600)) + } + + func testDifferentTopCornerShortcutDoesNotTriggerVerticalExpansionCycleAtOneThirdSplit() { + setSplitRatio(CycleSize.oneThird.percentValue) + Defaults.cornerCycleExpansionAxis.value = .vertical + + let topLeftFrame = WindowCalculationFactory.upperLeftCalculation.calculateRect(params(for: .topLeft)).rect + let topRightFrame = WindowCalculationFactory.upperRightCalculation.calculateRect(RectCalculationParameters(window: Window(id: 1, rect: topLeftFrame), + visibleFrameOfScreen: visibleFrame, + action: .topRight, + lastAction: RectangleAction(action: .topLeft, + subAction: nil, + rect: topLeftFrame, + count: 1))).rect + + assertRect(topLeftFrame, equals: CGRect(x: 10, y: 620, width: 400, height: 300)) + assertRect(topRightFrame, equals: CGRect(x: 410, y: 620, width: 800, height: 300)) + } + func testRepeatedHalfActionWithNoCycleSizesSelectedUsesFirstRect() { setSplitRatio(60) Defaults.cycleSizesIsChanged.enabled = true