From 83bfa727b6ba44bb7ab585aa5c77368a14b42b22 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 15 Jun 2026 16:27:56 -0700 Subject: [PATCH 01/17] Add cooperative window planner to corner actions --- Rectangle.xcodeproj/project.pbxproj | 4 + Rectangle/CooperativeCornerResize.swift | 122 ++++ Rectangle/Defaults.swift | 2 + .../PrefsWindow/SettingsViewController.swift | 23 + Rectangle/WindowManager.swift | 118 +++- Rectangle/mul.lproj/Main.xcstrings | 519 +++++++++--------- RectangleTests/RectangleTests.swift | 150 +++++ 7 files changed, 674 insertions(+), 264 deletions(-) create mode 100644 Rectangle/CooperativeCornerResize.swift diff --git a/Rectangle.xcodeproj/project.pbxproj b/Rectangle.xcodeproj/project.pbxproj index c1bf58ee..82ac1d68 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 */; }; @@ -241,6 +242,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 = ""; }; @@ -681,6 +683,7 @@ 984EDB0E29A42ED200D119D2 /* LaunchOnLogin.swift */, 98C1008B2305F1FA006E5344 /* SubsequentExecutionMode.swift */, 7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */, + 7BE578F02EFA00010083DAE3 /* CooperativeCornerResize.swift */, 985B9BF422B93EEC00A2E8F0 /* ApplicationToggle.swift */, 9824703022AFA8470037B409 /* RectangleStatusItem.swift */, 9824703622B0F3200037B409 /* WindowAction.swift */, @@ -1053,6 +1056,7 @@ 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 */, B4521F932BD7CEFB00FD43CC /* ChangeWindowDimensionCalculation.swift in Sources */, diff --git a/Rectangle/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift new file mode 100644 index 00000000..b71266b1 --- /dev/null +++ b/Rectangle/CooperativeCornerResize.swift @@ -0,0 +1,122 @@ +/// CooperativeCornerResize.swift + +import Cocoa + +struct CooperativeCornerResize { + struct Candidate { + let id: CGWindowID + let frame: CGRect + } + + struct Adjustment { + let id: CGWindowID + let oldFrame: CGRect + let newFrame: CGRect + } + + enum MovedEdge { + case left, right, top, bottom + } + + 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) -> [Adjustment] { + guard let movedEdge = movedEdge(from: oldFocusedFrame, to: newFocusedFrame, axis: axis, tolerance: tolerance) else { + return [] + } + + return candidates.compactMap { candidate in + guard !candidate.frame.isNull, + screenFrame.intersects(candidate.frame), + approximatelyMatchesPerpendicularSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), + touchesOldMovingEdge(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance) + else { + return nil + } + + var newFrame = candidate.frame + switch movedEdge { + case .left: + newFrame.size.width = newFocusedFrame.minX - candidate.frame.minX + case .right: + newFrame.origin.x = newFocusedFrame.maxX + newFrame.size.width = candidate.frame.maxX - newFocusedFrame.maxX + case .bottom: + newFrame.size.height = newFocusedFrame.minY - candidate.frame.minY + case .top: + newFrame.origin.y = newFocusedFrame.maxY + newFrame.size.height = candidate.frame.maxY - newFocusedFrame.maxY + } + + guard newFrame.width >= minimumSize.width, + newFrame.height >= minimumSize.height, + screenFrame.intersects(newFrame) + else { + return nil + } + + return Adjustment(id: candidate.id, oldFrame: candidate.frame, newFrame: newFrame) + } + } + + 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 touchesOldMovingEdge(_ candidate: CGRect, + _ focused: CGRect, + movedEdge: MovedEdge, + tolerance: CGFloat) -> Bool { + switch movedEdge { + case .left: + return abs(candidate.maxX - focused.minX) <= tolerance + case .right: + return abs(candidate.minX - focused.maxX) <= tolerance + case .bottom: + return abs(candidate.maxY - focused.minY) <= tolerance + case .top: + return abs(candidate.minY - focused.maxY) <= tolerance + } + } + + private static func approximatelyMatchesPerpendicularSpan(_ 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 + } + } +} 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..ff6cc0f7 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 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/WindowManager.swift b/Rectangle/WindowManager.swift index f7834712..5240bdb1 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -131,8 +131,16 @@ class WindowManager { return } - let visibleFrameOfDestinationScreen = calcResult.resultingScreenFrame ?? calcResult.screen.adjustedVisibleFrame(ignoreTodo) let isFixedSize = (!frontmostWindowElement.isResizable() && action.resizes) || frontmostWindowElement.isSystemDialog == true + let visibleFrameOfDestinationScreen = calcResult.resultingScreenFrame ?? calcResult.screen.adjustedVisibleFrame(ignoreTodo) + let cooperativeCornerAdjustments = cooperativeCornerResizeAdjustments(focusedWindowId: windowId, + focusedWindowIsFixedSize: isFixedSize, + action: action, + source: parameters.source, + oldFocusedFrame: currentNormalizedRect, + newFocusedFrame: calcResult.rect, + screenFrame: visibleFrameOfDestinationScreen, + lastRectangleAction: lastRectangleAction) let resultParameters = ResultParameters(windowId: windowId, action: action, windowElement: frontmostWindowElement, @@ -142,19 +150,19 @@ class WindowManager { source: parameters.source, isFixedSize: isFixedSize) - var resultingRect = apply(result: resultParameters) + var resultingRect = apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerAdjustments) let isMovedAcrossDisplays = usableScreens.currentScreen != calcResult.screen if isMovedAcrossDisplays { if calcResult.rect.height != resultingRect.height { Logger.log("Window size wasn't applied perfectly across displays. Trying again.") - resultingRect = apply(result: resultParameters) + resultingRect = apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerAdjustments) if calcResult.rect.height != resultingRect.height { Logger.log("Final attempt to adjust across displays.") DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) { [weak self] in guard let self else { return } - let finalRect = self.apply(result: resultParameters) + let finalRect = self.apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerAdjustments) self.windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: finalRect) self.postProcess(result: resultParameters, resultingRect: finalRect) } @@ -170,17 +178,99 @@ 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 { + apply(result: result, cooperativeCornerAdjustments: []) + } + + private func apply(result: ResultParameters, cooperativeCornerAdjustments: [CooperativeCornerWindowAdjustment]) -> CGRect { let windowMoverChain = result.isFixedSize ? fixedSizeWindowMoverChain : standardWindowMoverChain let newRect = result.calcResult.rect + let focusedWindowIsExpanding = CooperativeCornerResize.focusedWindowIsExpanding(oldFrame: result.windowElement.frame.screenFlipped, + newFrame: newRect, + axis: Defaults.cornerCycleExpansionAxis.value) + + if focusedWindowIsExpanding { + apply(cooperativeCornerAdjustments) + } for windowMover in windowMoverChain { windowMover.moveWindow(toRect: newRect, resultParameters: result) } - - return result.windowElement.frame + + let resultingRect = result.windowElement.frame + + if !focusedWindowIsExpanding { + apply(cooperativeCornerAdjustments) + } + + return resultingRect + } + + private func cooperativeCornerResizeAdjustments(focusedWindowId: CGWindowID, + focusedWindowIsFixedSize: Bool, + action: WindowAction, + source: ExecutionSource, + oldFocusedFrame: CGRect, + newFocusedFrame: CGRect, + screenFrame: CGRect, + lastRectangleAction: RectangleAction?) -> [CooperativeCornerWindowAdjustment] { + guard Defaults.cooperativeCornerResize.enabled, + source == .keyboardShortcut, + !focusedWindowIsFixedSize, + action.isBasicCorner, + lastRectangleAction?.action == action + else { + return [] + } + + let tolerance = max(CGFloat(Defaults.gapSize.value) + 4, 4) + 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) + } + let minimumSize = CGSize(width: max(1, CGFloat(Defaults.minimumWindowWidth.value)), + height: max(1, CGFloat(Defaults.minimumWindowHeight.value))) + let adjustments = CooperativeCornerResize.adjustments(oldFocusedFrame: oldFocusedFrame, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: Defaults.cornerCycleExpansionAxis.value, + tolerance: tolerance, + minimumSize: minimumSize) + + return adjustments.compactMap { adjustment in + guard let element = elementsById[adjustment.id] else { return nil } + return CooperativeCornerWindowAdjustment(element: element, newFrame: adjustment.newFrame) + } + } + + private func apply(_ adjustments: [CooperativeCornerWindowAdjustment]) { + adjustments.forEach { adjustment in + adjustment.element.setFrame(adjustment.newFrame.screenFlipped) + } } func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) { @@ -315,3 +405,19 @@ struct ExecutionParameters { enum ExecutionSource { case keyboardShortcut, dragToSnap, menuItem, url, titleBar } + +private struct CooperativeCornerWindowAdjustment { + let element: AccessibilityElement + let newFrame: CGRect +} + +private extension WindowAction { + var isBasicCorner: Bool { + switch self { + case .topLeft, .topRight, .bottomLeft, .bottomRight: + return true + default: + return false + } + } +} diff --git a/Rectangle/mul.lproj/Main.xcstrings b/Rectangle/mul.lproj/Main.xcstrings index bceb5dd0..e01a0554 100644 --- a/Rectangle/mul.lproj/Main.xcstrings +++ b/Rectangle/mul.lproj/Main.xcstrings @@ -13019,6 +13019,9 @@ } } } + }, + "Cyclic corner shortcuts expand:" : { + }, "d7y-s8-7GE.title" : { "comment" : "Class = \"NSTextFieldCell\"; title = \"Top Side\"; ObjectID = \"d7y-s8-7GE\";", @@ -21204,194 +21207,6 @@ } } }, - "Side Split Ratio" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "절반 분할 비율" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tỷ lệ chia một nửa" - } - } - } - }, - "Sides" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "Moitiés" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Meitats" - } - }, - "ca-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Meitats" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Poloviny" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hälften" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mitades" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mitades" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Moitiés" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bagian" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Metà" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "半分" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "절반" - } - }, - "lt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pusės" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Halvdeler" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sides" - } - }, - "nn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Halvdelar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Połowy" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Metades" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Metades" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Половинки" - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Polovice" - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hälfter" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yarımlar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Половини" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Một nửa" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "半屏" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "一半螢幕" - } - }, - "zh-Hant-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "一半螢幕" - } - } - } - }, "heT-W6-Fyf.title" : { "comment" : "Class = \"NSButtonCell\"; title = \"Double-click window title bar to maximize/restore\"; ObjectID = \"heT-W6-Fyf\";", "extractionState" : "extracted_with_value", @@ -22436,6 +22251,9 @@ } } } + }, + "horizontally" : { + }, "hQb-2v-fYv.title" : { "comment" : "Class = \"NSMenuItem\"; title = \"Smart Quotes\"; ObjectID = \"hQb-2v-fYv\";", @@ -27944,358 +27762,358 @@ } } }, - "Left side, top/bottom side near corners" : { + "Left or right side" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "Moitié gauche, moitié du haut/bas près des coins" + "value" : "Moitié gauche ou droite" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "ca-ES" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "cs" : { "stringUnit" : { "state" : "translated", - "value" : "Levá polovina, horní/spodní polovina blízko rohů" + "value" : "Levá nebo pravá polovina" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Linke Hälfte, Obere/Untere Hälfte in der Nähe der Ecken" + "value" : "Linke oder Rechte Hälfte" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Mitad izquierda, mitad superior/inferior cerca de las esquinas" + "value" : "Mitad izquierda o derecha" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Mitad izquierda, mitad superior/inferior cerca de las esquinas" + "value" : "Mitad izquierda o derecha" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Moitié gauche, moitié du haut/bas près des coins" + "value" : "Moitié gauche ou droite" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Metà sinistra, metà superiore/inferiore vicino gli angoli" + "value" : "Metà destra o sinistra" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "左半分、画面端付近の上半分・下半分" + "value" : "左半分または右半分" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "왼쪽 반, 위/아래 반에 근접한 모서리" + "value" : "왼쪽 또는 오른쪽 반" } }, "lt" : { "stringUnit" : { "state" : "translated", - "value" : "Kairė pusė, viršutinė/apačioje pusė šalia kampų" + "value" : "Kairė arba dešinė pusė" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Venstre halvdel, øverste/nederste halvdel nære hjørne" + "value" : "Venstre eller høgre halvdel" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "nn" : { "stringUnit" : { "state" : "translated", - "value" : "Venstre halvdel, øvste/nedste halvdel nære hjørne" + "value" : "Venstre eller høgre halvdel" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Metade esquerda, metade de cima/baixo próximo das bordas" + "value" : "Metade esquerda ou direita" } }, "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "ro" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Левая половина, верхняя/нижняя половина возле углов" + "value" : "Левая или правая половина" } }, "sk" : { "stringUnit" : { "state" : "translated", - "value" : "Ľavá polovica, horná/spodná polovica blízko rohov" + "value" : "Ľavá alebo pravá polovica" } }, "sv-SE" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Sol yarım (köşelere yaklaşıldığında ise üst/alt yarım)" + "value" : "Sol veya sağ yarım" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Ліва половина, верхня/нижня половина близько кутів" + "value" : "Ліва або права половина" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nửa trái, nửa trên/dưới nếu ngay góc" + "value" : "Nửa trái hoặc phải" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "左半屏,靠近角落则上/下半屏" + "value" : "左半屏或右半屏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "左半,靠近角落則上半/下半" + "value" : "左半或右半" } }, "zh-Hant-HK" : { "stringUnit" : { "state" : "translated", - "value" : "左半,靠近角落則上半/下半" + "value" : "左半或右半" } } } }, - "Left or right side" : { + "Left side, top/bottom side near corners" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "Moitié gauche ou droite" + "value" : "Moitié gauche, moitié du haut/bas près des coins" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "ca-ES" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "cs" : { "stringUnit" : { "state" : "translated", - "value" : "Levá nebo pravá polovina" + "value" : "Levá polovina, horní/spodní polovina blízko rohů" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Linke oder Rechte Hälfte" + "value" : "Linke Hälfte, Obere/Untere Hälfte in der Nähe der Ecken" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Mitad izquierda o derecha" + "value" : "Mitad izquierda, mitad superior/inferior cerca de las esquinas" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Mitad izquierda o derecha" + "value" : "Mitad izquierda, mitad superior/inferior cerca de las esquinas" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Moitié gauche ou droite" + "value" : "Moitié gauche, moitié du haut/bas près des coins" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Metà destra o sinistra" + "value" : "Metà sinistra, metà superiore/inferiore vicino gli angoli" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "左半分または右半分" + "value" : "左半分、画面端付近の上半分・下半分" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "왼쪽 또는 오른쪽 반" + "value" : "왼쪽 반, 위/아래 반에 근접한 모서리" } }, "lt" : { "stringUnit" : { "state" : "translated", - "value" : "Kairė arba dešinė pusė" + "value" : "Kairė pusė, viršutinė/apačioje pusė šalia kampų" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Venstre eller høgre halvdel" + "value" : "Venstre halvdel, øverste/nederste halvdel nære hjørne" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "nn" : { "stringUnit" : { "state" : "translated", - "value" : "Venstre eller høgre halvdel" + "value" : "Venstre halvdel, øvste/nedste halvdel nære hjørne" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Metade esquerda ou direita" + "value" : "Metade esquerda, metade de cima/baixo próximo das bordas" } }, "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "ro" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Левая или правая половина" + "value" : "Левая половина, верхняя/нижняя половина возле углов" } }, "sk" : { "stringUnit" : { "state" : "translated", - "value" : "Ľavá alebo pravá polovica" + "value" : "Ľavá polovica, horná/spodná polovica blízko rohov" } }, "sv-SE" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Sol veya sağ yarım" + "value" : "Sol yarım (köşelere yaklaşıldığında ise üst/alt yarım)" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Ліва або права половина" + "value" : "Ліва половина, верхня/нижня половина близько кутів" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nửa trái hoặc phải" + "value" : "Nửa trái, nửa trên/dưới nếu ngay góc" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "左半屏或右半屏" + "value" : "左半屏,靠近角落则上/下半屏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "左半或右半" + "value" : "左半,靠近角落則上半/下半" } }, "zh-Hant-HK" : { "stringUnit" : { "state" : "translated", - "value" : "左半或右半" + "value" : "左半,靠近角落則上半/下半" } } } @@ -28485,15 +28303,6 @@ } } } - }, - "Cyclic corner shortcuts expand:" : { - - }, - "horizontally" : { - - }, - "vertically" : { - }, "LqQ-pM-jRN.title" : { "comment" : "Class = \"NSTextFieldCell\"; title = \"Bottom Left Sixth\"; ObjectID = \"LqQ-pM-jRN\";", @@ -37676,6 +37485,9 @@ } } } + }, + "Resize adjacent windows when cycling corner shortcuts" : { + }, "rgM-f4-ycn.title" : { "comment" : "Class = \"NSMenuItem\"; title = \"Smart Dashes\"; ObjectID = \"rgM-f4-ycn\";", @@ -39077,6 +38889,194 @@ } } }, + "Side Split Ratio" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "절반 분할 비율" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tỷ lệ chia một nửa" + } + } + } + }, + "Sides" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moitiés" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meitats" + } + }, + "ca-ES" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meitats" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poloviny" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hälften" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitades" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitades" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moitiés" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bagian" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metà" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "半分" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "절반" + } + }, + "lt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pusės" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Halvdeler" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sides" + } + }, + "nn" : { + "stringUnit" : { + "state" : "translated", + "value" : "Halvdelar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Połowy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metades" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metades" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Половинки" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Polovice" + } + }, + "sv-SE" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hälfter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yarımlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Половини" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Một nửa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "半屏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "一半螢幕" + } + }, + "zh-Hant-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "一半螢幕" + } + } + } + }, "Sixteenths" : { "localizations" : { "ko" : { @@ -44311,6 +44311,9 @@ } } } + }, + "vertically" : { + }, "Vfd-fe-hCe.title" : { "comment" : "Class = \"NSButtonCell\"; title = \"ⓘ\"; ObjectID = \"Vfd-fe-hCe\";", @@ -50622,4 +50625,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 49e6cd26..159a946d 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -127,6 +127,156 @@ 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 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 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 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) -> [CooperativeCornerResize.Adjustment] { + CooperativeCornerResize.adjustments(oldFocusedFrame: focusedOld, + newFocusedFrame: focusedNew, + screenFrame: screenFrame, + candidates: candidates, + axis: axis, + tolerance: tolerance, + minimumSize: minimumSize) + } + + 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) } } From 80c94f75f3c3d211f7200d753945af5a6aaf168d Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 15 Jun 2026 18:12:34 -0700 Subject: [PATCH 02/17] Cooperative cycles on side actions. --- Rectangle/CooperativeCornerResize.swift | 47 ++++++- .../PrefsWindow/SettingsViewController.swift | 2 +- ...epeatedExecutionsInThirdsCalculation.swift | 26 ++++ Rectangle/WindowManager.swift | 39 ++++-- Rectangle/mul.lproj/Main.xcstrings | 2 +- RectangleTests/RectangleTests.swift | 126 ++++++++++++++++++ 6 files changed, 224 insertions(+), 18 deletions(-) diff --git a/Rectangle/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift index b71266b1..ef34da43 100644 --- a/Rectangle/CooperativeCornerResize.swift +++ b/Rectangle/CooperativeCornerResize.swift @@ -12,6 +12,12 @@ struct CooperativeCornerResize { let id: CGWindowID let oldFrame: CGRect let newFrame: CGRect + let kind: Kind + + enum Kind { + case matchingFocusedFrame + case adjacent + } } enum MovedEdge { @@ -47,8 +53,28 @@ struct CooperativeCornerResize { return [] } - return candidates.compactMap { candidate in + let matchingFocusedFrameAdjustments = candidates.compactMap { candidate -> Adjustment? in guard !candidate.frame.isNull, + screenFrame.intersects(candidate.frame), + approximatelyMatchesFrame(candidate.frame, oldFocusedFrame, tolerance: tolerance), + newFocusedFrame.width >= minimumSize.width, + newFocusedFrame.height >= minimumSize.height, + screenFrame.intersects(newFocusedFrame) + else { + return nil + } + + return Adjustment(id: candidate.id, + oldFrame: candidate.frame, + newFrame: newFocusedFrame, + kind: .matchingFocusedFrame) + } + + let matchingFocusedFrameIds = Set(matchingFocusedFrameAdjustments.map(\.id)) + + let adjacentAdjustments = candidates.compactMap { candidate -> Adjustment? in + guard !matchingFocusedFrameIds.contains(candidate.id), + !candidate.frame.isNull, screenFrame.intersects(candidate.frame), approximatelyMatchesPerpendicularSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), touchesOldMovingEdge(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance) @@ -70,15 +96,21 @@ struct CooperativeCornerResize { newFrame.size.height = candidate.frame.maxY - newFocusedFrame.maxY } - guard newFrame.width >= minimumSize.width, + guard !candidate.frame.isNull, + newFrame.width >= minimumSize.width, newFrame.height >= minimumSize.height, screenFrame.intersects(newFrame) else { return nil } - return Adjustment(id: candidate.id, oldFrame: candidate.frame, newFrame: newFrame) + return Adjustment(id: candidate.id, + oldFrame: candidate.frame, + newFrame: newFrame, + kind: .adjacent) } + + return matchingFocusedFrameAdjustments + adjacentAdjustments } static func focusedWindowIsExpanding(oldFrame: CGRect, newFrame: CGRect, axis: CornerCycleExpansionAxis) -> Bool { @@ -119,4 +151,13 @@ struct CooperativeCornerResize { && abs(candidate.maxX - focused.maxX) <= 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/PrefsWindow/SettingsViewController.swift b/Rectangle/PrefsWindow/SettingsViewController.swift index ff6cc0f7..83efbb9d 100644 --- a/Rectangle/PrefsWindow/SettingsViewController.swift +++ b/Rectangle/PrefsWindow/SettingsViewController.swift @@ -1292,7 +1292,7 @@ class SettingsViewController: NSViewController { } private func makeCooperativeCornerResizeCheckbox() -> NSButton { - let button = NSButton(checkboxWithTitle: NSLocalizedString("Resize adjacent windows when cycling corner shortcuts", tableName: "Main", value: "", comment: ""), + 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 diff --git a/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift b/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift index 1eff882a..f8ed3de4 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.lastAction?.action == params.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.swift b/Rectangle/WindowManager.swift index 5240bdb1..d1f3312f 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -140,6 +140,7 @@ class WindowManager { oldFocusedFrame: currentNormalizedRect, newFocusedFrame: calcResult.rect, screenFrame: visibleFrameOfDestinationScreen, + destinationScreenIsCurrentScreen: usableScreens.currentScreen == calcResult.screen, lastRectangleAction: lastRectangleAction) let resultParameters = ResultParameters(windowId: windowId, action: action, @@ -187,12 +188,16 @@ class WindowManager { : standardWindowMoverChain let newRect = result.calcResult.rect - let focusedWindowIsExpanding = CooperativeCornerResize.focusedWindowIsExpanding(oldFrame: result.windowElement.frame.screenFlipped, - newFrame: newRect, - axis: Defaults.cornerCycleExpansionAxis.value) + let cooperativeAxis = result.action.cooperativeResizeAxis + let focusedWindowIsExpanding = cooperativeAxis.map { + CooperativeCornerResize.focusedWindowIsExpanding(oldFrame: result.windowElement.frame.screenFlipped, + newFrame: newRect, + axis: $0) + } ?? false if focusedWindowIsExpanding { - apply(cooperativeCornerAdjustments) + apply(cooperativeCornerAdjustments, kind: .adjacent) + apply(cooperativeCornerAdjustments, kind: .matchingFocusedFrame) } for windowMover in windowMoverChain { @@ -202,7 +207,8 @@ class WindowManager { let resultingRect = result.windowElement.frame if !focusedWindowIsExpanding { - apply(cooperativeCornerAdjustments) + apply(cooperativeCornerAdjustments, kind: .matchingFocusedFrame) + apply(cooperativeCornerAdjustments, kind: .adjacent) } return resultingRect @@ -215,11 +221,13 @@ class WindowManager { oldFocusedFrame: CGRect, newFocusedFrame: CGRect, screenFrame: CGRect, + destinationScreenIsCurrentScreen: Bool, lastRectangleAction: RectangleAction?) -> [CooperativeCornerWindowAdjustment] { guard Defaults.cooperativeCornerResize.enabled, source == .keyboardShortcut, !focusedWindowIsFixedSize, - action.isBasicCorner, + destinationScreenIsCurrentScreen, + let cooperativeAxis = action.cooperativeResizeAxis, lastRectangleAction?.action == action else { return [] @@ -257,18 +265,18 @@ class WindowManager { newFocusedFrame: newFocusedFrame, screenFrame: screenFrame, candidates: candidates, - axis: Defaults.cornerCycleExpansionAxis.value, + axis: cooperativeAxis, tolerance: tolerance, minimumSize: minimumSize) return adjustments.compactMap { adjustment in guard let element = elementsById[adjustment.id] else { return nil } - return CooperativeCornerWindowAdjustment(element: element, newFrame: adjustment.newFrame) + return CooperativeCornerWindowAdjustment(element: element, newFrame: adjustment.newFrame, kind: adjustment.kind) } } - private func apply(_ adjustments: [CooperativeCornerWindowAdjustment]) { - adjustments.forEach { adjustment in + private func apply(_ adjustments: [CooperativeCornerWindowAdjustment], kind: CooperativeCornerResize.Adjustment.Kind) { + adjustments.filter { $0.kind == kind }.forEach { adjustment in adjustment.element.setFrame(adjustment.newFrame.screenFlipped) } } @@ -409,15 +417,20 @@ enum ExecutionSource { private struct CooperativeCornerWindowAdjustment { let element: AccessibilityElement let newFrame: CGRect + let kind: CooperativeCornerResize.Adjustment.Kind } private extension WindowAction { - var isBasicCorner: Bool { + var cooperativeResizeAxis: CornerCycleExpansionAxis? { switch self { case .topLeft, .topRight, .bottomLeft, .bottomRight: - return true + return Defaults.cornerCycleExpansionAxis.value + case .leftHalf, .rightHalf: + return .horizontal + case .topHalf, .bottomHalf: + return .vertical default: - return false + return nil } } } diff --git a/Rectangle/mul.lproj/Main.xcstrings b/Rectangle/mul.lproj/Main.xcstrings index e01a0554..eab6e80c 100644 --- a/Rectangle/mul.lproj/Main.xcstrings +++ b/Rectangle/mul.lproj/Main.xcstrings @@ -37486,7 +37486,7 @@ } } }, - "Resize adjacent windows when cycling corner shortcuts" : { + "Resize adjacent windows when cycling side or corner shortcuts" : { }, "rgM-f4-ycn.title" : { diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 159a946d..a45157f8 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -160,6 +160,23 @@ class CooperativeCornerResizeTests: XCTestCase { 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) @@ -184,6 +201,95 @@ class CooperativeCornerResizeTests: XCTestCase { 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 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 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 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) @@ -481,6 +587,26 @@ 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 testRepeatedHalfActionWithNoCycleSizesSelectedUsesFirstRect() { setSplitRatio(60) Defaults.cycleSizesIsChanged.enabled = true From 9521bc7f78bb8fe43b762df83b2d937070371eb6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 15 Jun 2026 18:41:36 -0700 Subject: [PATCH 03/17] Allow the cooperative window manager to resize both corner and side actions --- Rectangle.xcodeproj/project.pbxproj | 4 ++ Rectangle/CooperativeCornerResize.swift | 44 +++++++++++--- Rectangle/WindowActionCooperativeResize.swift | 51 ++++++++++++++++ .../RepeatedExecutionsCalculation.swift | 2 +- ...epeatedExecutionsInThirdsCalculation.swift | 2 +- Rectangle/WindowManager.swift | 17 +----- RectangleTests/RectangleTests.swift | 58 +++++++++++++++++++ 7 files changed, 151 insertions(+), 27 deletions(-) create mode 100644 Rectangle/WindowActionCooperativeResize.swift diff --git a/Rectangle.xcodeproj/project.pbxproj b/Rectangle.xcodeproj/project.pbxproj index 82ac1d68..ae40ffbe 100644 --- a/Rectangle.xcodeproj/project.pbxproj +++ b/Rectangle.xcodeproj/project.pbxproj @@ -78,6 +78,7 @@ 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 */; }; @@ -300,6 +301,7 @@ 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 = ""; }; @@ -687,6 +689,7 @@ 985B9BF422B93EEC00A2E8F0 /* ApplicationToggle.swift */, 9824703022AFA8470037B409 /* RectangleStatusItem.swift */, 9824703622B0F3200037B409 /* WindowAction.swift */, + 982470372EFA00010037B409 /* WindowActionCooperativeResize.swift */, 98A6EDEB2528FFC100F74B10 /* WindowActionCategory.swift */, 9824703822B0F37C0037B409 /* ShortcutManager.swift */, 9824703C22B13C7E0037B409 /* AccessibilityElement.swift */, @@ -1059,6 +1062,7 @@ 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/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift index ef34da43..c2f6e8e1 100644 --- a/Rectangle/CooperativeCornerResize.swift +++ b/Rectangle/CooperativeCornerResize.swift @@ -76,7 +76,7 @@ struct CooperativeCornerResize { guard !matchingFocusedFrameIds.contains(candidate.id), !candidate.frame.isNull, screenFrame.intersects(candidate.frame), - approximatelyMatchesPerpendicularSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), + isSupportedPerpendicularSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), touchesOldMovingEdge(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance) else { return nil @@ -138,20 +138,46 @@ struct CooperativeCornerResize { } } - private static func approximatelyMatchesPerpendicularSpan(_ candidate: CGRect, - _ focused: CGRect, - movedEdge: MovedEdge, - tolerance: CGFloat) -> Bool { + private static func isSupportedPerpendicularSpan(_ 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 + return approximatelyMatchesSpan(candidateMin: candidate.minY, + candidateMax: candidate.maxY, + focusedMin: focused.minY, + focusedMax: focused.maxY, + tolerance: tolerance) case .top, .bottom: - return abs(candidate.minX - focused.minX) <= tolerance - && abs(candidate.maxX - focused.maxX) <= tolerance + return approximatelyMatchesSpan(candidateMin: candidate.minX, + candidateMax: candidate.maxX, + focusedMin: focused.minX, + focusedMax: focused.maxX, + tolerance: 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 approximatelyMatchesFrame(_ candidate: CGRect, _ focused: CGRect, tolerance: CGFloat) -> Bool { diff --git a/Rectangle/WindowActionCooperativeResize.swift b/Rectangle/WindowActionCooperativeResize.swift new file mode 100644 index 00000000..220561f1 --- /dev/null +++ b/Rectangle/WindowActionCooperativeResize.swift @@ -0,0 +1,51 @@ +/// 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 + } + } + + func isCompatibleRepeatedResizeAction(with other: WindowAction?) -> Bool { + guard let other else { return false } + return cooperativeResizeSide == other.cooperativeResizeSide + && cooperativeResizeAxis == other.cooperativeResizeAxis + } + + 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 +} diff --git a/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift b/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift index 0dafbc82..67260484 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) } diff --git a/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift b/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift index f8ed3de4..6a677bcb 100644 --- a/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift +++ b/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift @@ -20,7 +20,7 @@ extension RepeatedExecutionsInThirdsCalculation { } func calculateRepeatedRect(_ params: RectCalculationParameters) -> RectResult { - guard params.lastAction?.action == params.action else { + guard params.action.isCompatibleRepeatedResizeAction(with: params.lastAction?.action) else { return calculateFirstRect(params) } diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index d1f3312f..ab290bf2 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -228,7 +228,7 @@ class WindowManager { !focusedWindowIsFixedSize, destinationScreenIsCurrentScreen, let cooperativeAxis = action.cooperativeResizeAxis, - lastRectangleAction?.action == action + action.isCompatibleRepeatedResizeAction(with: lastRectangleAction?.action) else { return [] } @@ -419,18 +419,3 @@ private struct CooperativeCornerWindowAdjustment { let newFrame: CGRect let kind: CooperativeCornerResize.Adjustment.Kind } - -private 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 - } - } -} diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index a45157f8..ed74f5f5 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -213,6 +213,19 @@ class CooperativeCornerResizeTests: XCTestCase { 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) @@ -266,6 +279,19 @@ class CooperativeCornerResizeTests: XCTestCase { 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) @@ -607,6 +633,38 @@ class HalfSplitCornerCalculationTests: XCTestCase { 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 testRepeatedHalfActionWithNoCycleSizesSelectedUsesFirstRect() { setSplitRatio(60) Defaults.cycleSizesIsChanged.enabled = true From c234731f969aedec9b633b654114d6ee80b49be1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 15 Jun 2026 18:52:21 -0700 Subject: [PATCH 04/17] Fix issue where a corner window doesn't resize when hidden by side window --- Rectangle/CooperativeCornerResize.swift | 52 ++++++++++++++++++++++++- RectangleTests/RectangleTests.swift | 20 ++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/Rectangle/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift index c2f6e8e1..5a588b90 100644 --- a/Rectangle/CooperativeCornerResize.swift +++ b/Rectangle/CooperativeCornerResize.swift @@ -72,8 +72,44 @@ struct CooperativeCornerResize { let matchingFocusedFrameIds = Set(matchingFocusedFrameAdjustments.map(\.id)) + let matchingMovingSpanAdjustments = candidates.compactMap { candidate -> Adjustment? in + guard !matchingFocusedFrameIds.contains(candidate.id), + !candidate.frame.isNull, + screenFrame.intersects(candidate.frame), + matchesMovingSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), + isSupportedPerpendicularSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance) + else { + return nil + } + + var newFrame = candidate.frame + switch movedEdge { + case .left, .right: + newFrame.origin.x = newFocusedFrame.minX + newFrame.size.width = newFocusedFrame.width + case .top, .bottom: + newFrame.origin.y = newFocusedFrame.minY + newFrame.size.height = newFocusedFrame.height + } + + guard newFrame.width >= minimumSize.width, + newFrame.height >= minimumSize.height, + screenFrame.intersects(newFrame) + else { + return nil + } + + return Adjustment(id: candidate.id, + oldFrame: candidate.frame, + newFrame: newFrame, + kind: .matchingFocusedFrame) + } + + let matchingMovingSpanIds = Set(matchingMovingSpanAdjustments.map(\.id)) + let adjacentAdjustments = candidates.compactMap { candidate -> Adjustment? in guard !matchingFocusedFrameIds.contains(candidate.id), + !matchingMovingSpanIds.contains(candidate.id), !candidate.frame.isNull, screenFrame.intersects(candidate.frame), isSupportedPerpendicularSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), @@ -110,7 +146,7 @@ struct CooperativeCornerResize { kind: .adjacent) } - return matchingFocusedFrameAdjustments + adjacentAdjustments + return matchingFocusedFrameAdjustments + matchingMovingSpanAdjustments + adjacentAdjustments } static func focusedWindowIsExpanding(oldFrame: CGRect, newFrame: CGRect, axis: CornerCycleExpansionAxis) -> Bool { @@ -158,6 +194,20 @@ struct CooperativeCornerResize { } } + 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, diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index ed74f5f5..a4a920d6 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -255,6 +255,26 @@ class CooperativeCornerResizeTests: XCTestCase { 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) From 58b489bf2a79cc4545f4918f8bffe5b36d9a6765 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 16 Jun 2026 12:07:27 -0700 Subject: [PATCH 05/17] Fix issue where <50% side split ratios would invoke a cyclic action at the time of moving window to a different corner. --- Rectangle/WindowActionCooperativeResize.swift | 12 ++++++++++++ RectangleTests/RectangleTests.swift | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Rectangle/WindowActionCooperativeResize.swift b/Rectangle/WindowActionCooperativeResize.swift index 220561f1..18f417c9 100644 --- a/Rectangle/WindowActionCooperativeResize.swift +++ b/Rectangle/WindowActionCooperativeResize.swift @@ -18,10 +18,22 @@ extension WindowAction { 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 } + private var isCooperativeCornerAction: Bool { + switch self { + case .topLeft, .topRight, .bottomLeft, .bottomRight: + return true + default: + return false + } + } + private var cooperativeResizeSide: CooperativeResizeSide? { switch self { case .leftHalf: diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index a4a920d6..bfa75806 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -685,6 +685,23 @@ class HalfSplitCornerCalculationTests: XCTestCase { 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 From 42852e2c5bcd6ff354516631c3b1d35c7dbbb321 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 16 Jun 2026 21:20:43 -0700 Subject: [PATCH 06/17] Update Main.xcstrings to upstream/main and add branch changes --- Rectangle/mul.lproj/Main.xcstrings | 516 ++++++++++++++--------------- 1 file changed, 258 insertions(+), 258 deletions(-) diff --git a/Rectangle/mul.lproj/Main.xcstrings b/Rectangle/mul.lproj/Main.xcstrings index eab6e80c..12f774c0 100644 --- a/Rectangle/mul.lproj/Main.xcstrings +++ b/Rectangle/mul.lproj/Main.xcstrings @@ -13019,9 +13019,6 @@ } } } - }, - "Cyclic corner shortcuts expand:" : { - }, "d7y-s8-7GE.title" : { "comment" : "Class = \"NSTextFieldCell\"; title = \"Top Side\"; ObjectID = \"d7y-s8-7GE\";", @@ -21207,6 +21204,194 @@ } } }, + "Side Split Ratio" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "절반 분할 비율" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tỷ lệ chia một nửa" + } + } + } + }, + "Sides" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moitiés" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meitats" + } + }, + "ca-ES" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meitats" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poloviny" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hälften" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitades" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitades" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moitiés" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bagian" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metà" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "半分" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "절반" + } + }, + "lt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pusės" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Halvdeler" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sides" + } + }, + "nn" : { + "stringUnit" : { + "state" : "translated", + "value" : "Halvdelar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Połowy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metades" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metades" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Половинки" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Polovice" + } + }, + "sv-SE" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hälfter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yarımlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Половини" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Một nửa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "半屏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "一半螢幕" + } + }, + "zh-Hant-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "一半螢幕" + } + } + } + }, "heT-W6-Fyf.title" : { "comment" : "Class = \"NSButtonCell\"; title = \"Double-click window title bar to maximize/restore\"; ObjectID = \"heT-W6-Fyf\";", "extractionState" : "extracted_with_value", @@ -22251,9 +22436,6 @@ } } } - }, - "horizontally" : { - }, "hQb-2v-fYv.title" : { "comment" : "Class = \"NSMenuItem\"; title = \"Smart Quotes\"; ObjectID = \"hQb-2v-fYv\";", @@ -27762,358 +27944,358 @@ } } }, - "Left or right side" : { + "Left side, top/bottom side near corners" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "Moitié gauche ou droite" + "value" : "Moitié gauche, moitié du haut/bas près des coins" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "ca-ES" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "cs" : { "stringUnit" : { "state" : "translated", - "value" : "Levá nebo pravá polovina" + "value" : "Levá polovina, horní/spodní polovina blízko rohů" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Linke oder Rechte Hälfte" + "value" : "Linke Hälfte, Obere/Untere Hälfte in der Nähe der Ecken" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Mitad izquierda o derecha" + "value" : "Mitad izquierda, mitad superior/inferior cerca de las esquinas" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Mitad izquierda o derecha" + "value" : "Mitad izquierda, mitad superior/inferior cerca de las esquinas" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Moitié gauche ou droite" + "value" : "Moitié gauche, moitié du haut/bas près des coins" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Metà destra o sinistra" + "value" : "Metà sinistra, metà superiore/inferiore vicino gli angoli" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "左半分または右半分" + "value" : "左半分、画面端付近の上半分・下半分" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "왼쪽 또는 오른쪽 반" + "value" : "왼쪽 반, 위/아래 반에 근접한 모서리" } }, "lt" : { "stringUnit" : { "state" : "translated", - "value" : "Kairė arba dešinė pusė" + "value" : "Kairė pusė, viršutinė/apačioje pusė šalia kampų" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Venstre eller høgre halvdel" + "value" : "Venstre halvdel, øverste/nederste halvdel nære hjørne" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "nn" : { "stringUnit" : { "state" : "translated", - "value" : "Venstre eller høgre halvdel" + "value" : "Venstre halvdel, øvste/nedste halvdel nære hjørne" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Metade esquerda ou direita" + "value" : "Metade esquerda, metade de cima/baixo próximo das bordas" } }, "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "ro" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Левая или правая половина" + "value" : "Левая половина, верхняя/нижняя половина возле углов" } }, "sk" : { "stringUnit" : { "state" : "translated", - "value" : "Ľavá alebo pravá polovica" + "value" : "Ľavá polovica, horná/spodná polovica blízko rohov" } }, "sv-SE" : { "stringUnit" : { "state" : "translated", - "value" : "Left or right side" + "value" : "Left side, top/bottom side near corners" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Sol veya sağ yarım" + "value" : "Sol yarım (köşelere yaklaşıldığında ise üst/alt yarım)" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Ліва або права половина" + "value" : "Ліва половина, верхня/нижня половина близько кутів" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nửa trái hoặc phải" + "value" : "Nửa trái, nửa trên/dưới nếu ngay góc" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "左半屏或右半屏" + "value" : "左半屏,靠近角落则上/下半屏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "左半或右半" + "value" : "左半,靠近角落則上半/下半" } }, "zh-Hant-HK" : { "stringUnit" : { "state" : "translated", - "value" : "左半或右半" + "value" : "左半,靠近角落則上半/下半" } } } }, - "Left side, top/bottom side near corners" : { + "Left or right side" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "Moitié gauche, moitié du haut/bas près des coins" + "value" : "Moitié gauche ou droite" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "ca-ES" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "cs" : { "stringUnit" : { "state" : "translated", - "value" : "Levá polovina, horní/spodní polovina blízko rohů" + "value" : "Levá nebo pravá polovina" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Linke Hälfte, Obere/Untere Hälfte in der Nähe der Ecken" + "value" : "Linke oder Rechte Hälfte" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Mitad izquierda, mitad superior/inferior cerca de las esquinas" + "value" : "Mitad izquierda o derecha" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Mitad izquierda, mitad superior/inferior cerca de las esquinas" + "value" : "Mitad izquierda o derecha" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Moitié gauche, moitié du haut/bas près des coins" + "value" : "Moitié gauche ou droite" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Metà sinistra, metà superiore/inferiore vicino gli angoli" + "value" : "Metà destra o sinistra" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "左半分、画面端付近の上半分・下半分" + "value" : "左半分または右半分" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "왼쪽 반, 위/아래 반에 근접한 모서리" + "value" : "왼쪽 또는 오른쪽 반" } }, "lt" : { "stringUnit" : { "state" : "translated", - "value" : "Kairė pusė, viršutinė/apačioje pusė šalia kampų" + "value" : "Kairė arba dešinė pusė" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Venstre halvdel, øverste/nederste halvdel nære hjørne" + "value" : "Venstre eller høgre halvdel" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "nn" : { "stringUnit" : { "state" : "translated", - "value" : "Venstre halvdel, øvste/nedste halvdel nære hjørne" + "value" : "Venstre eller høgre halvdel" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Metade esquerda, metade de cima/baixo próximo das bordas" + "value" : "Metade esquerda ou direita" } }, "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "ro" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Левая половина, верхняя/нижняя половина возле углов" + "value" : "Левая или правая половина" } }, "sk" : { "stringUnit" : { "state" : "translated", - "value" : "Ľavá polovica, horná/spodná polovica blízko rohov" + "value" : "Ľavá alebo pravá polovica" } }, "sv-SE" : { "stringUnit" : { "state" : "translated", - "value" : "Left side, top/bottom side near corners" + "value" : "Left or right side" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Sol yarım (köşelere yaklaşıldığında ise üst/alt yarım)" + "value" : "Sol veya sağ yarım" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Ліва половина, верхня/нижня половина близько кутів" + "value" : "Ліва або права половина" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nửa trái, nửa trên/dưới nếu ngay góc" + "value" : "Nửa trái hoặc phải" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "左半屏,靠近角落则上/下半屏" + "value" : "左半屏或右半屏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "左半,靠近角落則上半/下半" + "value" : "左半或右半" } }, "zh-Hant-HK" : { "stringUnit" : { "state" : "translated", - "value" : "左半,靠近角落則上半/下半" + "value" : "左半或右半" } } } @@ -28303,6 +28485,15 @@ } } } + }, + "Cyclic corner shortcuts expand:" : { + + }, + "horizontally" : { + + }, + "vertically" : { + }, "LqQ-pM-jRN.title" : { "comment" : "Class = \"NSTextFieldCell\"; title = \"Bottom Left Sixth\"; ObjectID = \"LqQ-pM-jRN\";", @@ -38889,194 +39080,6 @@ } } }, - "Side Split Ratio" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "절반 분할 비율" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tỷ lệ chia một nửa" - } - } - } - }, - "Sides" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "Moitiés" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Meitats" - } - }, - "ca-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Meitats" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Poloviny" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hälften" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mitades" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mitades" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Moitiés" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bagian" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Metà" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "半分" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "절반" - } - }, - "lt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pusės" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Halvdeler" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sides" - } - }, - "nn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Halvdelar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Połowy" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Metades" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Metades" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Половинки" - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Polovice" - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hälfter" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yarımlar" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Половини" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Một nửa" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "半屏" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "一半螢幕" - } - }, - "zh-Hant-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "一半螢幕" - } - } - } - }, "Sixteenths" : { "localizations" : { "ko" : { @@ -44311,9 +44314,6 @@ } } } - }, - "vertically" : { - }, "Vfd-fe-hCe.title" : { "comment" : "Class = \"NSButtonCell\"; title = \"ⓘ\"; ObjectID = \"Vfd-fe-hCe\";", @@ -50625,4 +50625,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} From 9b0c86bce02d708bbda1eb4e642607afd165c938 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 19 Jun 2026 10:48:02 -0700 Subject: [PATCH 07/17] refactor cooperative resize behavior so it is constraint-aware and applies the entire resize plan once. --- Rectangle/AccessibilityElement.swift | 5 + Rectangle/CooperativeCornerResize.swift | 551 +++++++++++++++++++++--- Rectangle/Utilities/AXExtension.swift | 2 + Rectangle/WindowManager.swift | 85 ++-- RectangleTests/RectangleTests.swift | 126 ++++++ 5 files changed, 673 insertions(+), 96 deletions(-) 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 index 5a588b90..01952ad3 100644 --- a/Rectangle/CooperativeCornerResize.swift +++ b/Rectangle/CooperativeCornerResize.swift @@ -6,6 +6,13 @@ 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 { @@ -20,10 +27,54 @@ struct CooperativeCornerResize { } } + 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 role: AffectedRole + let kind: Adjustment.Kind + let minimumSize: CGSize + } + + 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) + } + } + } + static func movedEdge(from oldFocusedFrame: CGRect, to newFocusedFrame: CGRect, axis: CornerCycleExpansionAxis, @@ -49,30 +100,156 @@ struct CooperativeCornerResize { axis: CornerCycleExpansionAxis, tolerance: CGFloat, minimumSize: CGSize) -> [Adjustment] { - guard let movedEdge = movedEdge(from: oldFocusedFrame, to: newFocusedFrame, axis: axis, tolerance: tolerance) else { - return [] + plan(oldFocusedFrame: oldFocusedFrame, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: axis, + tolerance: tolerance, + minimumSize: minimumSize)?.adjustments ?? [] + } + + static func plan(oldFocusedFrame: CGRect, + newFocusedFrame: CGRect, + screenFrame: CGRect, + candidates: [Candidate], + axis: CornerCycleExpansionAxis, + tolerance: CGFloat, + minimumSize: CGSize, + focusedMinimumSize: CGSize? = nil) -> Plan? { + guard let movedEdge = movedEdge(from: oldFocusedFrame, to: newFocusedFrame, axis: axis, tolerance: tolerance), + !oldFocusedFrame.isNull, + !newFocusedFrame.isNull, + !screenFrame.isNull + else { + return nil + } + + let fallbackMinimumSize = normalizedMinimumSize(minimumSize) + let resolvedFocusedMinimumSize = normalizedMinimumSize(focusedMinimumSize ?? fallbackMinimumSize) + let affectedWindows = affectedWindows(oldFocusedFrame: oldFocusedFrame, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + movedEdge: movedEdge, + tolerance: tolerance, + fallbackMinimumSize: fallbackMinimumSize) + guard !affectedWindows.isEmpty else { return nil } + + let oldEdge = edgeCoordinate(oldFocusedFrame, movedEdge) + let desiredEdge = edgeCoordinate(newFocusedFrame, movedEdge) + let requestedDelta = desiredEdge - oldEdge + var debugLog = [ + "Cooperative resize visible frame: \(screenFrame.debugDescription)", + "Cooperative resize requested delta: \(requestedDelta)", + "Cooperative resize affected windows: \(affectedWindows.map { "\($0.candidate.id)" }.joined(separator: ", "))" + ] + + 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) + } + + let clampedEdge = roundedEdge(clamp(desiredEdge, min: edgeRange.min, max: edgeRange.max), + legalMin: edgeRange.min, + legalMax: edgeRange.max) + let focusedFrame = roundedFrameInsideVisibleFrame( + sameSideFrame(baseFrame: newFocusedFrame, + movedEdge: movedEdge, + axis: axis, + edge: clampedEdge, + newFocusedFrame: newFocusedFrame, + screenFrame: 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) + let clampedFrame = clampedFrameForAffectedWindow(affectedWindow, + clampedEdge: clampedEdge, + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame) + 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: "; "))") } - let matchingFocusedFrameAdjustments = candidates.compactMap { candidate -> Adjustment? in + return Plan(focusedFrame: focusedFrame, + adjustments: adjustments, + debugLog: 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(oldFocusedFrame: CGRect, + newFocusedFrame: CGRect, + screenFrame: CGRect, + candidates: [Candidate], + movedEdge: MovedEdge, + tolerance: CGFloat, + fallbackMinimumSize: CGSize) -> [AffectedWindow] { + let matchingFocusedFrameAdjustments = candidates.compactMap { candidate -> AffectedWindow? in guard !candidate.frame.isNull, screenFrame.intersects(candidate.frame), - approximatelyMatchesFrame(candidate.frame, oldFocusedFrame, tolerance: tolerance), - newFocusedFrame.width >= minimumSize.width, - newFocusedFrame.height >= minimumSize.height, - screenFrame.intersects(newFocusedFrame) + approximatelyMatchesFrame(candidate.frame, oldFocusedFrame, tolerance: tolerance) else { return nil } - return Adjustment(id: candidate.id, - oldFrame: candidate.frame, - newFrame: newFocusedFrame, - kind: .matchingFocusedFrame) + return AffectedWindow(candidate: candidate, + role: .matchingFocusedFrame, + kind: .matchingFocusedFrame, + minimumSize: normalizedMinimumSize(candidate.minimumSize ?? fallbackMinimumSize)) } - let matchingFocusedFrameIds = Set(matchingFocusedFrameAdjustments.map(\.id)) + let matchingFocusedFrameIds = Set(matchingFocusedFrameAdjustments.map(\.candidate.id)) - let matchingMovingSpanAdjustments = candidates.compactMap { candidate -> Adjustment? in + let matchingMovingSpanAdjustments = candidates.compactMap { candidate -> AffectedWindow? in guard !matchingFocusedFrameIds.contains(candidate.id), !candidate.frame.isNull, screenFrame.intersects(candidate.frame), @@ -82,32 +259,15 @@ struct CooperativeCornerResize { return nil } - var newFrame = candidate.frame - switch movedEdge { - case .left, .right: - newFrame.origin.x = newFocusedFrame.minX - newFrame.size.width = newFocusedFrame.width - case .top, .bottom: - newFrame.origin.y = newFocusedFrame.minY - newFrame.size.height = newFocusedFrame.height - } - - guard newFrame.width >= minimumSize.width, - newFrame.height >= minimumSize.height, - screenFrame.intersects(newFrame) - else { - return nil - } - - return Adjustment(id: candidate.id, - oldFrame: candidate.frame, - newFrame: newFrame, - kind: .matchingFocusedFrame) + return AffectedWindow(candidate: candidate, + role: .matchingMovingSpan, + kind: .matchingFocusedFrame, + minimumSize: normalizedMinimumSize(candidate.minimumSize ?? fallbackMinimumSize)) } - let matchingMovingSpanIds = Set(matchingMovingSpanAdjustments.map(\.id)) + let matchingMovingSpanIds = Set(matchingMovingSpanAdjustments.map(\.candidate.id)) - let adjacentAdjustments = candidates.compactMap { candidate -> Adjustment? in + let adjacentAdjustments = candidates.compactMap { candidate -> AffectedWindow? in guard !matchingFocusedFrameIds.contains(candidate.id), !matchingMovingSpanIds.contains(candidate.id), !candidate.frame.isNull, @@ -118,44 +278,307 @@ struct CooperativeCornerResize { return nil } - var newFrame = candidate.frame - switch movedEdge { - case .left: - newFrame.size.width = newFocusedFrame.minX - candidate.frame.minX - case .right: - newFrame.origin.x = newFocusedFrame.maxX - newFrame.size.width = candidate.frame.maxX - newFocusedFrame.maxX - case .bottom: - newFrame.size.height = newFocusedFrame.minY - candidate.frame.minY - case .top: - newFrame.origin.y = newFocusedFrame.maxY - newFrame.size.height = candidate.frame.maxY - newFocusedFrame.maxY - } + return AffectedWindow(candidate: candidate, + role: .adjacent, + kind: .adjacent, + minimumSize: normalizedMinimumSize(candidate.minimumSize ?? fallbackMinimumSize)) + } - guard !candidate.frame.isNull, - newFrame.width >= minimumSize.width, - newFrame.height >= minimumSize.height, - screenFrame.intersects(newFrame) - else { - return nil - } + return matchingFocusedFrameAdjustments + matchingMovingSpanAdjustments + adjacentAdjustments + } + + 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) + } - return Adjustment(id: candidate.id, - oldFrame: candidate.frame, - newFrame: newFrame, - kind: .adjacent) + private static func constrainAffectedWindow(_ edgeRange: inout EdgeRange, + affectedWindow: AffectedWindow, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + newFocusedFrame: CGRect, + screenFrame: CGRect) { + 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.candidate.frame, + screenFrame: screenFrame, + minimumSize: affectedWindow.minimumSize) } + } - return matchingFocusedFrameAdjustments + matchingMovingSpanAdjustments + adjacentAdjustments + 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))") + } } - static func focusedWindowIsExpanding(oldFrame: CGRect, newFrame: CGRect, axis: CornerCycleExpansionAxis) -> Bool { + private static func constrainAdjacentWindow(_ edgeRange: inout EdgeRange, + label: String, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + candidateFrame: CGRect, + screenFrame: CGRect, + minimumSize: CGSize) { + let minimumAxisSize = axisSize(minimumSize, axis) + switch movedEdge { + case .right, .top: + let outerMax = min(axisMax(candidateFrame, axis), axisMax(screenFrame, axis)) + edgeRange.requireMax(outerMax - minimumAxisSize, reason: "\(label) minimum \(axisSizeName(axis)) inside visible frame") + case .left, .bottom: + let outerMin = max(axisMin(candidateFrame, axis), axisMin(screenFrame, axis)) + edgeRange.requireMin(outerMin + minimumAxisSize, reason: "\(label) minimum \(axisSizeName(axis)) inside visible frame") + } + } + + private static func proposedFrameForAffectedWindow(_ affectedWindow: AffectedWindow, + desiredEdge: CGFloat, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + newFocusedFrame: CGRect, + screenFrame: CGRect) -> CGRect { + frameForAffectedWindow(affectedWindow, + edge: desiredEdge, + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame) + } + + private static func clampedFrameForAffectedWindow(_ affectedWindow: AffectedWindow, + clampedEdge: CGFloat, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + newFocusedFrame: CGRect, + screenFrame: CGRect) -> CGRect { + roundedFrameInsideVisibleFrame( + frameForAffectedWindow(affectedWindow, + edge: clampedEdge, + movedEdge: movedEdge, + axis: axis, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame), + visibleFrame: screenFrame + ) + } + + private static func frameForAffectedWindow(_ affectedWindow: AffectedWindow, + edge: CGFloat, + movedEdge: MovedEdge, + axis: CornerCycleExpansionAxis, + newFocusedFrame: CGRect, + screenFrame: CGRect) -> CGRect { + switch affectedWindow.role { + case .matchingFocusedFrame: + return sameSideFrame(baseFrame: newFocusedFrame, + movedEdge: movedEdge, + axis: axis, + edge: edge, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame) + case .matchingMovingSpan: + return sameSideFrame(baseFrame: affectedWindow.candidate.frame, + movedEdge: movedEdge, + axis: axis, + edge: edge, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame) + case .adjacent: + return adjacentFrame(baseFrame: affectedWindow.candidate.frame, + movedEdge: movedEdge, + axis: axis, + edge: edge, + screenFrame: 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) -> CGRect { + switch movedEdge { + case .right, .top: + let outerMax = min(axisMax(baseFrame, axis), axisMax(screenFrame, axis)) + return frame(baseFrame, axis: axis, min: edge, 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, 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: - return newFrame.width > oldFrame.width + 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: - return newFrame.height > oldFrame.height + 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 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 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 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 touchesOldMovingEdge(_ candidate: CGRect, 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/WindowManager.swift b/Rectangle/WindowManager.swift index ab290bf2..c6fb0fe6 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -133,15 +133,19 @@ class WindowManager { let isFixedSize = (!frontmostWindowElement.isResizable() && action.resizes) || frontmostWindowElement.isSystemDialog == true let visibleFrameOfDestinationScreen = calcResult.resultingScreenFrame ?? calcResult.screen.adjustedVisibleFrame(ignoreTodo) - let cooperativeCornerAdjustments = cooperativeCornerResizeAdjustments(focusedWindowId: windowId, - focusedWindowIsFixedSize: isFixedSize, - action: action, - source: parameters.source, - oldFocusedFrame: currentNormalizedRect, - newFocusedFrame: calcResult.rect, - screenFrame: visibleFrameOfDestinationScreen, - destinationScreenIsCurrentScreen: usableScreens.currentScreen == calcResult.screen, - lastRectangleAction: lastRectangleAction) + 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 + } let resultParameters = ResultParameters(windowId: windowId, action: action, windowElement: frontmostWindowElement, @@ -151,19 +155,19 @@ class WindowManager { source: parameters.source, isFixedSize: isFixedSize) - var resultingRect = apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerAdjustments) + var resultingRect = apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? []) let isMovedAcrossDisplays = usableScreens.currentScreen != calcResult.screen if isMovedAcrossDisplays { if calcResult.rect.height != resultingRect.height { Logger.log("Window size wasn't applied perfectly across displays. Trying again.") - resultingRect = apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerAdjustments) + resultingRect = apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? []) if calcResult.rect.height != resultingRect.height { Logger.log("Final attempt to adjust across displays.") DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) { [weak self] in guard let self else { return } - let finalRect = self.apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerAdjustments) + let finalRect = self.apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? []) self.windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: finalRect) self.postProcess(result: resultParameters, resultingRect: finalRect) } @@ -214,15 +218,16 @@ class WindowManager { return resultingRect } - private func cooperativeCornerResizeAdjustments(focusedWindowId: CGWindowID, - focusedWindowIsFixedSize: Bool, - action: WindowAction, - source: ExecutionSource, - oldFocusedFrame: CGRect, - newFocusedFrame: CGRect, - screenFrame: CGRect, - destinationScreenIsCurrentScreen: Bool, - lastRectangleAction: RectangleAction?) -> [CooperativeCornerWindowAdjustment] { + private 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 == .keyboardShortcut, !focusedWindowIsFixedSize, @@ -230,7 +235,7 @@ class WindowManager { let cooperativeAxis = action.cooperativeResizeAxis, action.isCompatibleRepeatedResizeAction(with: lastRectangleAction?.action) else { - return [] + return nil } let tolerance = max(CGFloat(Defaults.gapSize.value) + 4, 4) @@ -257,22 +262,33 @@ class WindowManager { } let candidates = elementsById.map { id, element in - CooperativeCornerResize.Candidate(id: id, frame: element.frame.screenFlipped) + 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 adjustments = CooperativeCornerResize.adjustments(oldFocusedFrame: oldFocusedFrame, - newFocusedFrame: newFocusedFrame, - screenFrame: screenFrame, - candidates: candidates, - axis: cooperativeAxis, - tolerance: tolerance, - minimumSize: minimumSize) - - return adjustments.compactMap { adjustment in + guard let plan = CooperativeCornerResize.plan(oldFocusedFrame: oldFocusedFrame, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: cooperativeAxis, + tolerance: tolerance, + minimumSize: minimumSize, + focusedMinimumSize: focusedWindowMinimumSize) + 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, newFrame: adjustment.newFrame, kind: adjustment.kind) } + guard !adjustments.isEmpty else { return nil } + + return CooperativeCornerApplicationPlan(focusedFrame: plan.focusedFrame, adjustments: adjustments) } private func apply(_ adjustments: [CooperativeCornerWindowAdjustment], kind: CooperativeCornerResize.Adjustment.Kind) { @@ -419,3 +435,8 @@ private struct CooperativeCornerWindowAdjustment { let newFrame: CGRect let kind: CooperativeCornerResize.Adjustment.Kind } + +private struct CooperativeCornerApplicationPlan { + let focusedFrame: CGRect + let adjustments: [CooperativeCornerWindowAdjustment] +} diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index bfa75806..99b9b4f0 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -148,6 +148,118 @@ class CooperativeCornerResizeTests: XCTestCase { 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 testBottomLeftVerticalShrinkExpandsTopLeftNeighbor() { let focusedOld = CGRect(x: 0, y: 0, width: 800, height: 600) let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 300) @@ -424,6 +536,20 @@ class CooperativeCornerResizeTests: XCTestCase { minimumSize: minimumSize) } + private func cooperativePlan(focusedOld: CGRect, + focusedNew: CGRect, + screenFrame: CGRect? = nil, + candidates: [CooperativeCornerResize.Candidate], + axis: CornerCycleExpansionAxis) -> CooperativeCornerResize.Plan? { + CooperativeCornerResize.plan(oldFocusedFrame: focusedOld, + newFocusedFrame: focusedNew, + screenFrame: screenFrame ?? self.screenFrame, + candidates: candidates, + axis: axis, + tolerance: tolerance, + minimumSize: minimumSize) + } + 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) From b1dfadc727be10cb41e6f3c8919233f05fc57896 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 19 Jun 2026 11:48:54 -0700 Subject: [PATCH 08/17] Logic refactor WIP gate. Respects min size on refresh. wider tolerance for smaller apps. Allows for gap resize feature --- Rectangle/CooperativeCornerResize.swift | 310 ++++++++++++++++++++---- Rectangle/WindowManager.swift | 104 +++++++- RectangleTests/RectangleTests.swift | 201 ++++++++++++++- 3 files changed, 553 insertions(+), 62 deletions(-) diff --git a/Rectangle/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift index 01952ad3..fcedeca9 100644 --- a/Rectangle/CooperativeCornerResize.swift +++ b/Rectangle/CooperativeCornerResize.swift @@ -45,6 +45,7 @@ struct CooperativeCornerResize { private struct AffectedWindow { let candidate: Candidate + let layoutFrame: CGRect let role: AffectedRole let kind: Adjustment.Kind let minimumSize: CGSize @@ -75,6 +76,11 @@ struct CooperativeCornerResize { } } + 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 movedEdge(from oldFocusedFrame: CGRect, to newFocusedFrame: CGRect, axis: CornerCycleExpansionAxis, @@ -99,14 +105,16 @@ struct CooperativeCornerResize { candidates: [Candidate], axis: CornerCycleExpansionAxis, tolerance: CGFloat, - minimumSize: CGSize) -> [Adjustment] { + minimumSize: CGSize, + gapSize: CGFloat = 0) -> [Adjustment] { plan(oldFocusedFrame: oldFocusedFrame, newFocusedFrame: newFocusedFrame, screenFrame: screenFrame, candidates: candidates, axis: axis, tolerance: tolerance, - minimumSize: minimumSize)?.adjustments ?? [] + minimumSize: minimumSize, + gapSize: gapSize)?.adjustments ?? [] } static func plan(oldFocusedFrame: CGRect, @@ -116,7 +124,8 @@ struct CooperativeCornerResize { axis: CornerCycleExpansionAxis, tolerance: CGFloat, minimumSize: CGSize, - focusedMinimumSize: CGSize? = nil) -> Plan? { + focusedMinimumSize: CGSize? = nil, + gapSize: CGFloat = 0) -> Plan? { guard let movedEdge = movedEdge(from: oldFocusedFrame, to: newFocusedFrame, axis: axis, tolerance: tolerance), !oldFocusedFrame.isNull, !newFocusedFrame.isNull, @@ -133,7 +142,8 @@ struct CooperativeCornerResize { candidates: candidates, movedEdge: movedEdge, tolerance: tolerance, - fallbackMinimumSize: fallbackMinimumSize) + fallbackMinimumSize: fallbackMinimumSize, + gapSize: gapSize) guard !affectedWindows.isEmpty else { return nil } let oldEdge = edgeCoordinate(oldFocusedFrame, movedEdge) @@ -141,6 +151,8 @@ struct CooperativeCornerResize { let requestedDelta = desiredEdge - oldEdge var debugLog = [ "Cooperative resize visible frame: \(screenFrame.debugDescription)", + "Cooperative resize configured gap: \(gapSize)", + "Cooperative resize detection tolerance: \(tolerance)", "Cooperative resize requested delta: \(requestedDelta)", "Cooperative resize affected windows: \(affectedWindows.map { "\($0.candidate.id)" }.joined(separator: ", "))" ] @@ -159,19 +171,23 @@ struct CooperativeCornerResize { movedEdge: movedEdge, axis: axis, newFocusedFrame: newFocusedFrame, - screenFrame: screenFrame) + screenFrame: screenFrame, + gapSize: gapSize) } let clampedEdge = roundedEdge(clamp(desiredEdge, min: edgeRange.min, max: edgeRange.max), legalMin: edgeRange.min, legalMax: edgeRange.max) let focusedFrame = roundedFrameInsideVisibleFrame( - sameSideFrame(baseFrame: newFocusedFrame, - movedEdge: movedEdge, - axis: axis, - edge: clampedEdge, - newFocusedFrame: newFocusedFrame, - screenFrame: screenFrame), + 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, @@ -190,13 +206,15 @@ struct CooperativeCornerResize { movedEdge: movedEdge, axis: axis, newFocusedFrame: newFocusedFrame, - screenFrame: screenFrame) + screenFrame: screenFrame, + gapSize: gapSize) let clampedFrame = clampedFrameForAffectedWindow(affectedWindow, clampedEdge: clampedEdge, movedEdge: movedEdge, axis: axis, newFocusedFrame: newFocusedFrame, - screenFrame: screenFrame) + 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)") @@ -211,12 +229,83 @@ struct CooperativeCornerResize { 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) -> 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) + 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: @@ -232,7 +321,8 @@ struct CooperativeCornerResize { candidates: [Candidate], movedEdge: MovedEdge, tolerance: CGFloat, - fallbackMinimumSize: CGSize) -> [AffectedWindow] { + fallbackMinimumSize: CGSize, + gapSize: CGFloat) -> [AffectedWindow] { let matchingFocusedFrameAdjustments = candidates.compactMap { candidate -> AffectedWindow? in guard !candidate.frame.isNull, screenFrame.intersects(candidate.frame), @@ -242,12 +332,16 @@ struct CooperativeCornerResize { } return AffectedWindow(candidate: candidate, + layoutFrame: normalizedFullSpanFrame(candidate.frame, + focusedFrame: oldFocusedFrame, + movedEdge: movedEdge, + tolerance: tolerance), role: .matchingFocusedFrame, kind: .matchingFocusedFrame, minimumSize: normalizedMinimumSize(candidate.minimumSize ?? fallbackMinimumSize)) } - let matchingFocusedFrameIds = Set(matchingFocusedFrameAdjustments.map(\.candidate.id)) + let matchingFocusedFrameIds = Set(matchingFocusedFrameAdjustments.map { $0.candidate.id }) let matchingMovingSpanAdjustments = candidates.compactMap { candidate -> AffectedWindow? in guard !matchingFocusedFrameIds.contains(candidate.id), @@ -260,12 +354,16 @@ struct CooperativeCornerResize { } return AffectedWindow(candidate: candidate, + layoutFrame: normalizedFullSpanFrame(candidate.frame, + focusedFrame: oldFocusedFrame, + movedEdge: movedEdge, + tolerance: tolerance), role: .matchingMovingSpan, kind: .matchingFocusedFrame, minimumSize: normalizedMinimumSize(candidate.minimumSize ?? fallbackMinimumSize)) } - let matchingMovingSpanIds = Set(matchingMovingSpanAdjustments.map(\.candidate.id)) + let matchingMovingSpanIds = Set(matchingMovingSpanAdjustments.map { $0.candidate.id }) let adjacentAdjustments = candidates.compactMap { candidate -> AffectedWindow? in guard !matchingFocusedFrameIds.contains(candidate.id), @@ -273,12 +371,16 @@ struct CooperativeCornerResize { !candidate.frame.isNull, screenFrame.intersects(candidate.frame), isSupportedPerpendicularSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), - touchesOldMovingEdge(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance) + touchesOldMovingEdge(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance, gapSize: gapSize) else { return nil } return AffectedWindow(candidate: candidate, + layoutFrame: normalizedFullSpanFrame(candidate.frame, + focusedFrame: oldFocusedFrame, + movedEdge: movedEdge, + tolerance: tolerance), role: .adjacent, kind: .adjacent, minimumSize: normalizedMinimumSize(candidate.minimumSize ?? fallbackMinimumSize)) @@ -307,7 +409,8 @@ struct CooperativeCornerResize { movedEdge: MovedEdge, axis: CornerCycleExpansionAxis, newFocusedFrame: CGRect, - screenFrame: CGRect) { + screenFrame: CGRect, + gapSize: CGFloat) { let label = "window \(affectedWindow.candidate.id)" switch affectedWindow.role { case .matchingFocusedFrame, .matchingMovingSpan: @@ -323,9 +426,10 @@ struct CooperativeCornerResize { label: label, movedEdge: movedEdge, axis: axis, - candidateFrame: affectedWindow.candidate.frame, + candidateFrame: affectedWindow.layoutFrame, screenFrame: screenFrame, - minimumSize: affectedWindow.minimumSize) + minimumSize: affectedWindow.minimumSize, + gapSize: gapSize) } } @@ -357,15 +461,16 @@ struct CooperativeCornerResize { axis: CornerCycleExpansionAxis, candidateFrame: CGRect, screenFrame: CGRect, - minimumSize: CGSize) { + 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 - minimumAxisSize, reason: "\(label) minimum \(axisSizeName(axis)) inside visible frame") + 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 + minimumAxisSize, reason: "\(label) minimum \(axisSizeName(axis)) inside visible frame") + edgeRange.requireMin(outerMin + gapSize + minimumAxisSize, reason: "\(label) minimum \(axisSizeName(axis)) inside visible frame with gap") } } @@ -374,13 +479,15 @@ struct CooperativeCornerResize { movedEdge: MovedEdge, axis: CornerCycleExpansionAxis, newFocusedFrame: CGRect, - screenFrame: CGRect) -> CGRect { + screenFrame: CGRect, + gapSize: CGFloat) -> CGRect { frameForAffectedWindow(affectedWindow, edge: desiredEdge, movedEdge: movedEdge, axis: axis, newFocusedFrame: newFocusedFrame, - screenFrame: screenFrame) + screenFrame: screenFrame, + gapSize: gapSize) } private static func clampedFrameForAffectedWindow(_ affectedWindow: AffectedWindow, @@ -388,14 +495,16 @@ struct CooperativeCornerResize { movedEdge: MovedEdge, axis: CornerCycleExpansionAxis, newFocusedFrame: CGRect, - screenFrame: CGRect) -> CGRect { + screenFrame: CGRect, + gapSize: CGFloat) -> CGRect { roundedFrameInsideVisibleFrame( frameForAffectedWindow(affectedWindow, edge: clampedEdge, movedEdge: movedEdge, axis: axis, newFocusedFrame: newFocusedFrame, - screenFrame: screenFrame), + screenFrame: screenFrame, + gapSize: gapSize), visibleFrame: screenFrame ) } @@ -405,28 +514,39 @@ struct CooperativeCornerResize { movedEdge: MovedEdge, axis: CornerCycleExpansionAxis, newFocusedFrame: CGRect, - screenFrame: CGRect) -> CGRect { + screenFrame: CGRect, + gapSize: CGFloat) -> CGRect { switch affectedWindow.role { case .matchingFocusedFrame: - return sameSideFrame(baseFrame: newFocusedFrame, - movedEdge: movedEdge, - axis: axis, - edge: edge, - newFocusedFrame: newFocusedFrame, - screenFrame: screenFrame) + 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 sameSideFrame(baseFrame: affectedWindow.candidate.frame, - movedEdge: movedEdge, - axis: axis, - edge: edge, - newFocusedFrame: newFocusedFrame, - screenFrame: screenFrame) + 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 adjacentFrame(baseFrame: affectedWindow.candidate.frame, - movedEdge: movedEdge, - axis: axis, - edge: edge, - screenFrame: screenFrame) + return frameWithMinimumSize(adjacentFrame(baseFrame: affectedWindow.layoutFrame, + movedEdge: movedEdge, + axis: axis, + edge: edge, + screenFrame: screenFrame, + gapSize: gapSize), + minimumSize: affectedWindow.minimumSize, + resizeAxis: axis, + visibleFrame: screenFrame) } } @@ -452,14 +572,15 @@ struct CooperativeCornerResize { movedEdge: MovedEdge, axis: CornerCycleExpansionAxis, edge: CGFloat, - screenFrame: CGRect) -> CGRect { + 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, max: outerMax, visibleFrame: screenFrame) + 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, visibleFrame: screenFrame) + return frame(baseFrame, axis: axis, min: outerMin, max: edge - gapSize, visibleFrame: screenFrame) } } @@ -516,6 +637,36 @@ struct CooperativeCornerResize { 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) @@ -541,6 +692,36 @@ struct CooperativeCornerResize { 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: @@ -581,19 +762,42 @@ struct CooperativeCornerResize { 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) -> Bool { + tolerance: CGFloat, + gapSize: CGFloat) -> Bool { switch movedEdge { case .left: - return abs(candidate.maxX - focused.minX) <= tolerance + return abs((focused.minX - candidate.maxX) - gapSize) <= tolerance case .right: - return abs(candidate.minX - focused.maxX) <= tolerance + return abs((candidate.minX - focused.maxX) - gapSize) <= tolerance case .bottom: - return abs(candidate.maxY - focused.minY) <= tolerance + return abs((focused.minY - candidate.maxY) - gapSize) <= tolerance case .top: - return abs(candidate.minY - focused.maxY) <= tolerance + return abs((candidate.minY - focused.maxY) - gapSize) <= tolerance } } diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index c6fb0fe6..d7b85cf5 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -156,6 +156,11 @@ class WindowManager { isFixedSize: isFixedSize) var resultingRect = apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? []) + if let cooperativeCornerPlan, + let settledRect = settleCooperativeCornerResizeIfNeeded(plan: cooperativeCornerPlan, + focusedWindowElement: frontmostWindowElement) { + resultingRect = settledRect + } let isMovedAcrossDisplays = usableScreens.currentScreen != calcResult.screen if isMovedAcrossDisplays { @@ -238,7 +243,8 @@ class WindowManager { return nil } - let tolerance = max(CGFloat(Defaults.gapSize.value) + 4, 4) + let gapSize = max(0, CGFloat(Defaults.gapSize.value)) + let tolerance = CooperativeCornerResize.detectionTolerance(screenFrame: screenFrame, configuredGap: gapSize) let screenFrameAX = screenFrame.screenFlipped let elementsById = AccessibilityElement.getAllWindowElements().reduce(into: [CGWindowID: AccessibilityElement]()) { elements, element in guard let candidateId = element.getWindowId(), @@ -275,7 +281,8 @@ class WindowManager { axis: cooperativeAxis, tolerance: tolerance, minimumSize: minimumSize, - focusedMinimumSize: focusedWindowMinimumSize) + focusedMinimumSize: focusedWindowMinimumSize, + gapSize: gapSize) else { return nil } @@ -284,11 +291,26 @@ class WindowManager { let adjustments: [CooperativeCornerWindowAdjustment] = plan.adjustments.compactMap { adjustment in guard let element = elementsById[adjustment.id] else { return nil } - return CooperativeCornerWindowAdjustment(element: element, newFrame: adjustment.newFrame, kind: adjustment.kind) + return CooperativeCornerWindowAdjustment(element: element, + id: adjustment.id, + newFrame: adjustment.newFrame, + kind: adjustment.kind) } guard !adjustments.isEmpty else { return nil } - return CooperativeCornerApplicationPlan(focusedFrame: plan.focusedFrame, adjustments: adjustments) + return CooperativeCornerApplicationPlan(oldFocusedFrame: oldFocusedFrame, + requestedFocusedFrame: newFocusedFrame, + focusedFrame: plan.focusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: cooperativeAxis, + detectionTolerance: tolerance, + layoutTolerance: 4, + minimumSize: minimumSize, + focusedMinimumSize: focusedWindowMinimumSize, + gapSize: gapSize, + adjustments: adjustments, + debugLog: plan.debugLog) } private func apply(_ adjustments: [CooperativeCornerWindowAdjustment], kind: CooperativeCornerResize.Adjustment.Kind) { @@ -296,6 +318,56 @@ class WindowManager { adjustment.element.setFrame(adjustment.newFrame.screenFlipped) } } + + private func settleCooperativeCornerResizeIfNeeded(plan: CooperativeCornerApplicationPlan, + focusedWindowElement: AccessibilityElement) -> 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) + 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) + apply(correctionAdjustments, kind: .matchingFocusedFrame) + focusedWindowElement.setFrame(correctionPlan.focusedFrame.screenFlipped) + } else { + focusedWindowElement.setFrame(correctionPlan.focusedFrame.screenFlipped) + apply(correctionAdjustments, kind: .matchingFocusedFrame) + apply(correctionAdjustments, kind: .adjacent) + } + + return focusedWindowElement.frame + } func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) { windowElement.bringToFront(force: true) @@ -432,11 +504,35 @@ enum ExecutionSource { private struct CooperativeCornerWindowAdjustment { let element: AccessibilityElement + let id: CGWindowID let newFrame: CGRect let kind: CooperativeCornerResize.Adjustment.Kind } private 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 layoutTolerance: CGFloat + let minimumSize: CGSize + let focusedMinimumSize: CGSize? + let gapSize: CGFloat let adjustments: [CooperativeCornerWindowAdjustment] + let debugLog: [String] + + 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) + } } diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 99b9b4f0..e4932836 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -260,6 +260,170 @@ class CooperativeCornerResizeTests: XCTestCase { 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 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 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 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) @@ -526,28 +690,55 @@ class CooperativeCornerResizeTests: XCTestCase { private func cooperativeAdjustments(focusedOld: CGRect, focusedNew: CGRect, candidates: [CooperativeCornerResize.Candidate], - axis: CornerCycleExpansionAxis) -> [CooperativeCornerResize.Adjustment] { + axis: CornerCycleExpansionAxis, + gapSize: CGFloat = 0) -> [CooperativeCornerResize.Adjustment] { CooperativeCornerResize.adjustments(oldFocusedFrame: focusedOld, newFocusedFrame: focusedNew, screenFrame: screenFrame, candidates: candidates, axis: axis, tolerance: tolerance, - minimumSize: minimumSize) + minimumSize: minimumSize, + gapSize: gapSize) } private func cooperativePlan(focusedOld: CGRect, focusedNew: CGRect, screenFrame: CGRect? = nil, candidates: [CooperativeCornerResize.Candidate], - axis: CornerCycleExpansionAxis) -> CooperativeCornerResize.Plan? { + axis: CornerCycleExpansionAxis, + tolerance: CGFloat? = nil, + gapSize: CGFloat = 0) -> CooperativeCornerResize.Plan? { CooperativeCornerResize.plan(oldFocusedFrame: focusedOld, newFocusedFrame: focusedNew, screenFrame: screenFrame ?? self.screenFrame, candidates: candidates, axis: axis, - tolerance: tolerance, - minimumSize: minimumSize) + tolerance: tolerance ?? self.tolerance, + minimumSize: minimumSize, + gapSize: gapSize) + } + + private func correctionPlan(focusedOld: CGRect, + focusedNew: CGRect, + plannedPlan: CooperativeCornerResize.Plan, + candidates: [CooperativeCornerResize.Candidate], + actualFocusedFrame: CGRect, + actualCandidateFramesById: [CGWindowID: CGRect], + axis: CornerCycleExpansionAxis, + gapSize: CGFloat = 0) -> 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) } private func assertRect(_ rect: CGRect, equals expected: CGRect, file: StaticString = #filePath, line: UInt = #line) { From 51edcf064d025649c9b0bd420d6cd67e1d0e7ead Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 19 Jun 2026 12:31:02 -0700 Subject: [PATCH 09/17] Wider tolerances for nearby matches for windows not exactly grid size. Min width will force the split ratio to be moved to the nearest successful split. Logic to picking up nearby windows that are cutoff by the side split and wider range capture. Incorporates gap between windows feature. --- Rectangle/CooperativeCornerResize.swift | 427 +++++++++++++++--- Rectangle/WindowActionCooperativeResize.swift | 15 + Rectangle/WindowManager.swift | 176 ++++++-- RectangleTests/RectangleTests.swift | 222 ++++++++- 4 files changed, 732 insertions(+), 108 deletions(-) diff --git a/Rectangle/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift index fcedeca9..357738c9 100644 --- a/Rectangle/CooperativeCornerResize.swift +++ b/Rectangle/CooperativeCornerResize.swift @@ -49,6 +49,12 @@ struct CooperativeCornerResize { 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 { @@ -81,6 +87,29 @@ struct CooperativeCornerResize { 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 movedEdge(from oldFocusedFrame: CGRect, to newFocusedFrame: CGRect, axis: CornerCycleExpansionAxis, @@ -106,7 +135,11 @@ struct CooperativeCornerResize { axis: CornerCycleExpansionAxis, tolerance: CGFloat, minimumSize: CGSize, - gapSize: CGFloat = 0) -> [Adjustment] { + 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, @@ -114,7 +147,11 @@ struct CooperativeCornerResize { axis: axis, tolerance: tolerance, minimumSize: minimumSize, - gapSize: gapSize)?.adjustments ?? [] + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdgeOverride, + candidateDiscoveryFrame: candidateDiscoveryFrame, + actionDescription: actionDescription)?.adjustments ?? [] } static func plan(oldFocusedFrame: CGRect, @@ -125,37 +162,57 @@ struct CooperativeCornerResize { tolerance: CGFloat, minimumSize: CGSize, focusedMinimumSize: CGSize? = nil, - gapSize: CGFloat = 0) -> Plan? { - guard let movedEdge = movedEdge(from: oldFocusedFrame, to: newFocusedFrame, axis: axis, tolerance: tolerance), - !oldFocusedFrame.isNull, + 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 affectedWindows = affectedWindows(oldFocusedFrame: oldFocusedFrame, - newFocusedFrame: newFocusedFrame, - screenFrame: screenFrame, - candidates: candidates, - movedEdge: movedEdge, - tolerance: tolerance, - fallbackMinimumSize: fallbackMinimumSize, - gapSize: gapSize) + 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)" }.joined(separator: ", "))" - ] + "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, @@ -250,7 +307,11 @@ struct CooperativeCornerResize { layoutTolerance: CGFloat, minimumSize: CGSize, focusedMinimumSize: CGSize? = nil, - gapSize: CGFloat = 0) -> Plan? { + 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, @@ -296,7 +357,11 @@ struct CooperativeCornerResize { tolerance: tolerance, minimumSize: minimumSize, focusedMinimumSize: effectiveFocusedMinimumSize, - gapSize: gapSize) + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdgeOverride, + candidateDiscoveryFrame: candidateDiscoveryFrame, + actionDescription: actionDescription) else { return nil } @@ -315,78 +380,283 @@ struct CooperativeCornerResize { } } - private static func affectedWindows(oldFocusedFrame: CGRect, + private static func affectedWindows(discoveryFocusedFrame: CGRect, newFocusedFrame: CGRect, screenFrame: CGRect, candidates: [Candidate], movedEdge: MovedEdge, tolerance: CGFloat, + captureTolerance: CGFloat, + perpendicularCaptureTolerance: CGFloat, fallbackMinimumSize: CGSize, - gapSize: CGFloat) -> [AffectedWindow] { - let matchingFocusedFrameAdjustments = candidates.compactMap { candidate -> AffectedWindow? in - guard !candidate.frame.isNull, - screenFrame.intersects(candidate.frame), - approximatelyMatchesFrame(candidate.frame, oldFocusedFrame, tolerance: tolerance) - else { - return nil + 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 } - return AffectedWindow(candidate: candidate, - layoutFrame: normalizedFullSpanFrame(candidate.frame, - focusedFrame: oldFocusedFrame, - movedEdge: movedEdge, - tolerance: tolerance), - role: .matchingFocusedFrame, - kind: .matchingFocusedFrame, - minimumSize: normalizedMinimumSize(candidate.minimumSize ?? fallbackMinimumSize)) - } + 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 + } - let matchingFocusedFrameIds = Set(matchingFocusedFrameAdjustments.map { $0.candidate.id }) + 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 + } - let matchingMovingSpanAdjustments = candidates.compactMap { candidate -> AffectedWindow? in - guard !matchingFocusedFrameIds.contains(candidate.id), - !candidate.frame.isNull, - screenFrame.intersects(candidate.frame), - matchesMovingSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), - isSupportedPerpendicularSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance) - else { - return nil + 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 } - return AffectedWindow(candidate: candidate, - layoutFrame: normalizedFullSpanFrame(candidate.frame, - focusedFrame: oldFocusedFrame, - movedEdge: movedEdge, - tolerance: tolerance), - role: .matchingMovingSpan, - kind: .matchingFocusedFrame, - minimumSize: normalizedMinimumSize(candidate.minimumSize ?? fallbackMinimumSize)) - } - - let matchingMovingSpanIds = Set(matchingMovingSpanAdjustments.map { $0.candidate.id }) - - let adjacentAdjustments = candidates.compactMap { candidate -> AffectedWindow? in - guard !matchingFocusedFrameIds.contains(candidate.id), - !matchingMovingSpanIds.contains(candidate.id), - !candidate.frame.isNull, - screenFrame.intersects(candidate.frame), - isSupportedPerpendicularSpan(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), - touchesOldMovingEdge(candidate.frame, oldFocusedFrame, movedEdge: movedEdge, tolerance: tolerance, gapSize: gapSize) + guard let captured = capturedWindow(candidate: candidate, + discoveryFocusedFrame: discoveryFocusedFrame, + newFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + movedEdge: movedEdge, + captureTolerance: captureTolerance, + perpendicularCaptureTolerance: perpendicularCaptureTolerance, + gapSize: gapSize, + minimumSize: minimumSize) else { - return nil + 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) + 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 + } - return AffectedWindow(candidate: candidate, - layoutFrame: normalizedFullSpanFrame(candidate.frame, - focusedFrame: oldFocusedFrame, - movedEdge: movedEdge, - tolerance: tolerance), - role: .adjacent, - kind: .adjacent, - minimumSize: normalizedMinimumSize(candidate.minimumSize ?? fallbackMinimumSize)) + 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)) } + } - return matchingFocusedFrameAdjustments + matchingMovingSpanAdjustments + adjacentAdjustments + 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) -> CGRect { + var result = normalizedFullSpanFrame(candidate, + focusedFrame: focusedFrame, + movedEdge: movedEdge, + tolerance: tolerance) + guard role == .adjacent else { + return result + } + + switch movedEdge { + case .right: + result.size.width = screenFrame.maxX - result.minX + case .left: + let oldMaxX = result.maxX + result.origin.x = screenFrame.minX + result.size.width = oldMaxX - screenFrame.minX + case .top: + result.size.height = screenFrame.maxY - result.minY + case .bottom: + let oldMaxY = result.maxY + result.origin.y = screenFrame.minY + result.size.height = oldMaxY - screenFrame.minY + } + return result } private static func constrainFocusedWindow(_ edgeRange: inout EdgeRange, @@ -751,6 +1021,19 @@ struct CooperativeCornerResize { 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 diff --git a/Rectangle/WindowActionCooperativeResize.swift b/Rectangle/WindowActionCooperativeResize.swift index 18f417c9..ae736423 100644 --- a/Rectangle/WindowActionCooperativeResize.swift +++ b/Rectangle/WindowActionCooperativeResize.swift @@ -16,6 +16,21 @@ extension WindowAction { } } + 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 { diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index d7b85cf5..d2ae9571 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -122,14 +122,6 @@ class WindowManager { if Defaults.cyclingOverlapOffset.userEnabled, action.positionCycles { calcResult.rect = applyOverlapOffsetIfNeeded(calcResult.rect, windowId: windowId, screen: calcResult.screen) } - - 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 isFixedSize = (!frontmostWindowElement.isResizable() && action.resizes) || frontmostWindowElement.isSystemDialog == true let visibleFrameOfDestinationScreen = calcResult.resultingScreenFrame ?? calcResult.screen.adjustedVisibleFrame(ignoreTodo) @@ -146,6 +138,21 @@ class WindowManager { if let cooperativeCornerPlan { calcResult.rect = cooperativeCornerPlan.focusedFrame } + + 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 resultParameters = ResultParameters(windowId: windowId, action: action, windowElement: frontmostWindowElement, @@ -155,7 +162,9 @@ class WindowManager { source: parameters.source, isFixedSize: isFixedSize) - var resultingRect = apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? []) + var resultingRect = apply(result: resultParameters, + cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? [], + layoutTolerance: cooperativeCornerPlan?.layoutTolerance ?? 0) if let cooperativeCornerPlan, let settledRect = settleCooperativeCornerResizeIfNeeded(plan: cooperativeCornerPlan, focusedWindowElement: frontmostWindowElement) { @@ -166,13 +175,17 @@ class WindowManager { if isMovedAcrossDisplays { if calcResult.rect.height != resultingRect.height { Logger.log("Window size wasn't applied perfectly across displays. Trying again.") - resultingRect = apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? []) + resultingRect = apply(result: resultParameters, + cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? [], + layoutTolerance: cooperativeCornerPlan?.layoutTolerance ?? 0) if calcResult.rect.height != resultingRect.height { Logger.log("Final attempt to adjust across displays.") DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) { [weak self] in guard let self else { return } - let finalRect = self.apply(result: resultParameters, cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? []) + let finalRect = self.apply(result: resultParameters, + cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? [], + layoutTolerance: cooperativeCornerPlan?.layoutTolerance ?? 0) self.windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: finalRect) self.postProcess(result: resultParameters, resultingRect: finalRect) } @@ -188,10 +201,12 @@ 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 { - apply(result: result, cooperativeCornerAdjustments: []) + apply(result: result, cooperativeCornerAdjustments: [], layoutTolerance: 0) } - private func apply(result: ResultParameters, cooperativeCornerAdjustments: [CooperativeCornerWindowAdjustment]) -> CGRect { + private func apply(result: ResultParameters, + cooperativeCornerAdjustments: [CooperativeCornerWindowAdjustment], + layoutTolerance: CGFloat) -> CGRect { let windowMoverChain = result.isFixedSize ? fixedSizeWindowMoverChain : standardWindowMoverChain @@ -205,19 +220,38 @@ class WindowManager { } ?? false if focusedWindowIsExpanding { - apply(cooperativeCornerAdjustments, kind: .adjacent) - apply(cooperativeCornerAdjustments, kind: .matchingFocusedFrame) - } - - for windowMover in windowMoverChain { - windowMover.moveWindow(toRect: newRect, resultParameters: result) + apply(cooperativeCornerAdjustments, + kind: .adjacent, + screenFrame: result.visibleFrameOfScreen, + layoutTolerance: layoutTolerance) + apply(cooperativeCornerAdjustments, + kind: .matchingFocusedFrame, + screenFrame: result.visibleFrameOfScreen, + layoutTolerance: layoutTolerance) + } + + if CooperativeCornerResize.frameNeedsApplication(currentFrame: result.windowElement.frame.screenFlipped, + solvedFrame: newRect, + screenFrame: result.visibleFrameOfScreen, + layoutTolerance: layoutTolerance) { + for windowMover in windowMoverChain { + windowMover.moveWindow(toRect: newRect, resultParameters: result) + } + } else if !cooperativeCornerAdjustments.isEmpty { + Logger.log("Cooperative resize focused no-op: current frame already matches solved frame") } let resultingRect = result.windowElement.frame if !focusedWindowIsExpanding { - apply(cooperativeCornerAdjustments, kind: .matchingFocusedFrame) - apply(cooperativeCornerAdjustments, kind: .adjacent) + apply(cooperativeCornerAdjustments, + kind: .matchingFocusedFrame, + screenFrame: result.visibleFrameOfScreen, + layoutTolerance: layoutTolerance) + apply(cooperativeCornerAdjustments, + kind: .adjacent, + screenFrame: result.visibleFrameOfScreen, + layoutTolerance: layoutTolerance) } return resultingRect @@ -238,13 +272,17 @@ class WindowManager { !focusedWindowIsFixedSize, destinationScreenIsCurrentScreen, let cooperativeAxis = action.cooperativeResizeAxis, - action.isCompatibleRepeatedResizeAction(with: lastRectangleAction?.action) + 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 candidateDiscoveryFrame = isRepeatedCooperativeAction ? oldFocusedFrame : newFocusedFrame let screenFrameAX = screenFrame.screenFlipped let elementsById = AccessibilityElement.getAllWindowElements().reduce(into: [CGWindowID: AccessibilityElement]()) { elements, element in guard let candidateId = element.getWindowId(), @@ -282,7 +320,11 @@ class WindowManager { tolerance: tolerance, minimumSize: minimumSize, focusedMinimumSize: focusedWindowMinimumSize, - gapSize: gapSize) + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdge, + candidateDiscoveryFrame: candidateDiscoveryFrame, + actionDescription: actionDescription) else { return nil } @@ -305,17 +347,31 @@ class WindowManager { 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) } - private func apply(_ adjustments: [CooperativeCornerWindowAdjustment], kind: CooperativeCornerResize.Adjustment.Kind) { + private func apply(_ adjustments: [CooperativeCornerWindowAdjustment], + kind: CooperativeCornerResize.Adjustment.Kind, + screenFrame: CGRect, + layoutTolerance: CGFloat) { adjustments.filter { $0.kind == kind }.forEach { adjustment in - adjustment.element.setFrame(adjustment.newFrame.screenFlipped) + 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") + } } } @@ -338,7 +394,11 @@ class WindowManager { layoutTolerance: plan.layoutTolerance, minimumSize: plan.minimumSize, focusedMinimumSize: plan.focusedMinimumSize, - gapSize: plan.gapSize) + gapSize: plan.gapSize, + captureTolerance: plan.captureTolerance, + movedEdgeOverride: plan.movedEdge, + candidateDiscoveryFrame: plan.candidateDiscoveryFrame, + actionDescription: plan.actionDescription) else { return nil } @@ -357,17 +417,49 @@ class WindowManager { newFrame: correctionPlan.focusedFrame, axis: plan.axis) if focusedWindowIsExpanding { - apply(correctionAdjustments, kind: .adjacent) - apply(correctionAdjustments, kind: .matchingFocusedFrame) - focusedWindowElement.setFrame(correctionPlan.focusedFrame.screenFlipped) + apply(correctionAdjustments, + kind: .adjacent, + screenFrame: plan.screenFrame, + layoutTolerance: plan.layoutTolerance) + apply(correctionAdjustments, + kind: .matchingFocusedFrame, + screenFrame: plan.screenFrame, + layoutTolerance: plan.layoutTolerance) + applyFocusedCooperativeFrameIfNeeded(correctionPlan.focusedFrame, + focusedWindowElement: focusedWindowElement, + screenFrame: plan.screenFrame, + layoutTolerance: plan.layoutTolerance) } else { - focusedWindowElement.setFrame(correctionPlan.focusedFrame.screenFlipped) - apply(correctionAdjustments, kind: .matchingFocusedFrame) - apply(correctionAdjustments, kind: .adjacent) + applyFocusedCooperativeFrameIfNeeded(correctionPlan.focusedFrame, + focusedWindowElement: focusedWindowElement, + screenFrame: plan.screenFrame, + 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, + focusedWindowElement: AccessibilityElement, + screenFrame: CGRect, + layoutTolerance: CGFloat) { + if CooperativeCornerResize.frameNeedsApplication(currentFrame: focusedWindowElement.frame.screenFlipped, + solvedFrame: frame, + screenFrame: screenFrame, + layoutTolerance: layoutTolerance) { + focusedWindowElement.setFrame(frame.screenFlipped) + } else { + Logger.log("Cooperative resize focused no-op: current frame already matches solved frame") + } + } func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) { windowElement.bringToFront(force: true) @@ -517,13 +609,33 @@ private struct CooperativeCornerApplicationPlan { 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, diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index e4932836..b6c915f2 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -277,6 +277,204 @@ class CooperativeCornerResizeTests: XCTestCase { 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 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) @@ -708,7 +906,12 @@ class CooperativeCornerResizeTests: XCTestCase { candidates: [CooperativeCornerResize.Candidate], axis: CornerCycleExpansionAxis, tolerance: CGFloat? = nil, - gapSize: CGFloat = 0) -> CooperativeCornerResize.Plan? { + 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, @@ -716,7 +919,12 @@ class CooperativeCornerResizeTests: XCTestCase { axis: axis, tolerance: tolerance ?? self.tolerance, minimumSize: minimumSize, - gapSize: gapSize) + focusedMinimumSize: focusedMinimumSize, + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdgeOverride, + candidateDiscoveryFrame: candidateDiscoveryFrame, + actionDescription: actionDescription) } private func correctionPlan(focusedOld: CGRect, @@ -726,7 +934,10 @@ class CooperativeCornerResizeTests: XCTestCase { actualFocusedFrame: CGRect, actualCandidateFramesById: [CGWindowID: CGRect], axis: CornerCycleExpansionAxis, - gapSize: CGFloat = 0) -> CooperativeCornerResize.Plan? { + gapSize: CGFloat = 0, + captureTolerance: CGFloat? = nil, + movedEdgeOverride: CooperativeCornerResize.MovedEdge? = nil, + candidateDiscoveryFrame: CGRect? = nil) -> CooperativeCornerResize.Plan? { CooperativeCornerResize.correctionPlan(oldFocusedFrame: focusedOld, requestedFocusedFrame: focusedNew, plannedPlan: plannedPlan, @@ -738,7 +949,10 @@ class CooperativeCornerResizeTests: XCTestCase { tolerance: tolerance, layoutTolerance: 4, minimumSize: minimumSize, - gapSize: gapSize) + gapSize: gapSize, + captureTolerance: captureTolerance, + movedEdgeOverride: movedEdgeOverride, + candidateDiscoveryFrame: candidateDiscoveryFrame) } private func assertRect(_ rect: CGRect, equals expected: CGRect, file: StaticString = #filePath, line: UInt = #line) { From dcf86be60de28ce868a0ff4fd1f8c5a3484a8a0b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 20 Jun 2026 13:35:15 -0700 Subject: [PATCH 10/17] Fix outer perimeter to prevent window creep into gap area --- Rectangle/CooperativeCornerResize.swift | 56 +++++++++++++++++++++---- RectangleTests/RectangleTests.swift | 50 ++++++++++++++++++++++ 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/Rectangle/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift index 357738c9..9d73c8c4 100644 --- a/Rectangle/CooperativeCornerResize.swift +++ b/Rectangle/CooperativeCornerResize.swift @@ -535,7 +535,8 @@ struct CooperativeCornerResize { screenFrame: screenFrame, movedEdge: movedEdge, role: assignment, - tolerance: perpendicularCaptureTolerance) + tolerance: perpendicularCaptureTolerance, + gapSize: gapSize) let reasonParts = [ touchesDiscoveryEdge ? "capture-tolerance shared edge" : nil, touchesTargetEdge ? "capture-tolerance target edge" : nil, @@ -633,7 +634,8 @@ struct CooperativeCornerResize { screenFrame: CGRect, movedEdge: MovedEdge, role: AffectedRole, - tolerance: CGFloat) -> CGRect { + tolerance: CGFloat, + gapSize: CGFloat) -> CGRect { var result = normalizedFullSpanFrame(candidate, focusedFrame: focusedFrame, movedEdge: movedEdge, @@ -644,21 +646,59 @@ struct CooperativeCornerResize { switch movedEdge { case .right: - result.size.width = screenFrame.maxX - result.minX + 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 = screenFrame.minX - result.size.width = oldMaxX - screenFrame.minX + 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 = screenFrame.maxY - result.minY + 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 = screenFrame.minY - result.size.height = oldMaxY - screenFrame.minY + 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 constrainFocusedWindow(_ edgeRange: inout EdgeRange, movedEdge: MovedEdge, axis: CornerCycleExpansionAxis, diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index b6c915f2..e5a22d63 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -515,6 +515,56 @@ class CooperativeCornerResizeTests: XCTestCase { 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) From 0d2372b75f0de2ca90f80dac033a97024de72734 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 20 Jun 2026 13:43:45 -0700 Subject: [PATCH 11/17] If enabled, use cooperative resizer in dragggable snap areas. --- Rectangle/WindowActionCooperativeResize.swift | 11 +++++++++++ Rectangle/WindowManager.swift | 2 +- RectangleTests/RectangleTests.swift | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Rectangle/WindowActionCooperativeResize.swift b/Rectangle/WindowActionCooperativeResize.swift index ae736423..8e886ca3 100644 --- a/Rectangle/WindowActionCooperativeResize.swift +++ b/Rectangle/WindowActionCooperativeResize.swift @@ -76,3 +76,14 @@ extension WindowAction { 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/WindowManager.swift b/Rectangle/WindowManager.swift index d2ae9571..35303ac3 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -268,7 +268,7 @@ class WindowManager { destinationScreenIsCurrentScreen: Bool, lastRectangleAction: RectangleAction?) -> CooperativeCornerApplicationPlan? { guard Defaults.cooperativeCornerResize.enabled, - source == .keyboardShortcut, + source.allowsCooperativeResize, !focusedWindowIsFixedSize, destinationScreenIsCurrentScreen, let cooperativeAxis = action.cooperativeResizeAxis, diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index e5a22d63..67173b77 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() { From ac4b8de434ba0d9bc08ed3abd7993d79dec9ade8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 20 Jun 2026 14:05:20 -0700 Subject: [PATCH 12/17] Add a window target resolver to avoid pushing to default size and capturing the opposite windows. --- Rectangle/CooperativeCornerResize.swift | 94 +++++++++ Rectangle/WindowActionCooperativeResize.swift | 2 +- Rectangle/WindowManager.swift | 192 +++++++++++++++--- RectangleTests/RectangleTests.swift | 69 +++++++ 4 files changed, 331 insertions(+), 26 deletions(-) diff --git a/Rectangle/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift index 9d73c8c4..0e2d6804 100644 --- a/Rectangle/CooperativeCornerResize.swift +++ b/Rectangle/CooperativeCornerResize.swift @@ -110,6 +110,62 @@ struct CooperativeCornerResize { tolerance: layoutTolerance) } + static func focusedFramePreservingOccupiedCell(requestedFocusedFrame: CGRect, + screenFrame: CGRect, + candidates: [Candidate], + axis: CornerCycleExpansionAxis, + movedEdge: MovedEdge, + tolerance: CGFloat) -> CGRect { + guard !requestedFocusedFrame.isNull, + !screenFrame.isNull + else { + return requestedFocusedFrame + } + + let requestedSize = axisSize(requestedFocusedFrame.size, axis) + let desiredEdge = edgeCoordinate(requestedFocusedFrame, movedEdge) + let matchingCandidates = candidates.filter { candidate in + let frame = candidate.frame + guard !frame.isNull, + screenFrame.intersects(frame), + axisSize(frame.size, axis) < requestedSize - tolerance, + matchesPerpendicularSpan(frame, requestedFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), + isContainedInSameSideRegion(frame, requestedFocusedFrame, movedEdge: movedEdge, axis: axis, tolerance: tolerance) + else { + return false + } + + let candidateEdge = edgeCoordinate(frame, movedEdge) + switch movedEdge { + case .right, .top: + return candidateEdge < desiredEdge - tolerance + case .left, .bottom: + return candidateEdge > desiredEdge + tolerance + } + } + + guard let occupiedCell = matchingCandidates.min(by: { lhs, rhs in + let lhsSize = axisSize(lhs.frame.size, axis) + let rhsSize = axisSize(rhs.frame.size, axis) + if abs(lhsSize - rhsSize) > tolerance { + return lhsSize < rhsSize + } + return lhs.id < rhs.id + }) else { + return requestedFocusedFrame + } + + return roundedFrameInsideVisibleFrame( + sameSideFrame(baseFrame: requestedFocusedFrame, + movedEdge: movedEdge, + axis: axis, + edge: edgeCoordinate(occupiedCell.frame, movedEdge), + newFocusedFrame: requestedFocusedFrame, + screenFrame: screenFrame), + visibleFrame: screenFrame + ) + } + static func movedEdge(from oldFocusedFrame: CGRect, to newFocusedFrame: CGRect, axis: CornerCycleExpansionAxis, @@ -1144,6 +1200,20 @@ struct CooperativeCornerResize { } } + 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, @@ -1178,6 +1248,30 @@ struct CooperativeCornerResize { 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 { diff --git a/Rectangle/WindowActionCooperativeResize.swift b/Rectangle/WindowActionCooperativeResize.swift index 8e886ca3..1353846b 100644 --- a/Rectangle/WindowActionCooperativeResize.swift +++ b/Rectangle/WindowActionCooperativeResize.swift @@ -40,7 +40,7 @@ extension WindowAction { && cooperativeResizeAxis == other.cooperativeResizeAxis } - private var isCooperativeCornerAction: Bool { + var isCooperativeCornerAction: Bool { switch self { case .topLeft, .topRight, .bottomLeft, .bottomRight: return true diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index 35303ac3..2e1cd917 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -162,13 +162,12 @@ class WindowManager { source: parameters.source, isFixedSize: isFixedSize) - var resultingRect = apply(result: resultParameters, - cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? [], - layoutTolerance: cooperativeCornerPlan?.layoutTolerance ?? 0) - if let cooperativeCornerPlan, - let settledRect = settleCooperativeCornerResizeIfNeeded(plan: cooperativeCornerPlan, - focusedWindowElement: frontmostWindowElement) { - resultingRect = settledRect + var resultingRect: CGRect + if let cooperativeCornerPlan { + resultingRect = applyCooperativeCornerResize(result: resultParameters, + plan: cooperativeCornerPlan) + } else { + resultingRect = apply(result: resultParameters) } let isMovedAcrossDisplays = usableScreens.currentScreen != calcResult.screen @@ -257,6 +256,66 @@ class WindowManager { return resultingRect } + private 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 + } + private func cooperativeCornerResizePlan(focusedWindowId: CGWindowID, focusedWindowIsFixedSize: Bool, focusedWindowMinimumSize: CGSize?, @@ -282,7 +341,6 @@ class WindowManager { 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 candidateDiscoveryFrame = isRepeatedCooperativeAction ? oldFocusedFrame : newFocusedFrame let screenFrameAX = screenFrame.screenFlipped let elementsById = AccessibilityElement.getAllWindowElements().reduce(into: [CGWindowID: AccessibilityElement]()) { elements, element in guard let candidateId = element.getWindowId(), @@ -312,8 +370,17 @@ class WindowManager { } let minimumSize = CGSize(width: max(1, CGFloat(Defaults.minimumWindowWidth.value)), height: max(1, CGFloat(Defaults.minimumWindowHeight.value))) + let requestedFocusedFrame = (!isRepeatedCooperativeAction && action.isCooperativeCornerAction) + ? CooperativeCornerResize.focusedFramePreservingOccupiedCell(requestedFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: cooperativeAxis, + movedEdge: movedEdge, + tolerance: tolerance) + : newFocusedFrame + let candidateDiscoveryFrame = isRepeatedCooperativeAction ? oldFocusedFrame : requestedFocusedFrame guard let plan = CooperativeCornerResize.plan(oldFocusedFrame: oldFocusedFrame, - newFocusedFrame: newFocusedFrame, + newFocusedFrame: requestedFocusedFrame, screenFrame: screenFrame, candidates: candidates, axis: cooperativeAxis, @@ -341,7 +408,7 @@ class WindowManager { guard !adjustments.isEmpty else { return nil } return CooperativeCornerApplicationPlan(oldFocusedFrame: oldFocusedFrame, - requestedFocusedFrame: newFocusedFrame, + requestedFocusedFrame: requestedFocusedFrame, focusedFrame: plan.focusedFrame, screenFrame: screenFrame, candidates: candidates, @@ -375,8 +442,39 @@ class WindowManager { } } + 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) -> CGRect? { + 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) @@ -425,15 +523,13 @@ class WindowManager { kind: .matchingFocusedFrame, screenFrame: plan.screenFrame, layoutTolerance: plan.layoutTolerance) - applyFocusedCooperativeFrameIfNeeded(correctionPlan.focusedFrame, - focusedWindowElement: focusedWindowElement, - screenFrame: plan.screenFrame, - layoutTolerance: plan.layoutTolerance) + _ = applyFocusedCooperativeFrameIfNeeded(correctionPlan.focusedFrame, + result: result, + layoutTolerance: plan.layoutTolerance) } else { - applyFocusedCooperativeFrameIfNeeded(correctionPlan.focusedFrame, - focusedWindowElement: focusedWindowElement, - screenFrame: plan.screenFrame, - layoutTolerance: plan.layoutTolerance) + _ = applyFocusedCooperativeFrameIfNeeded(correctionPlan.focusedFrame, + result: result, + layoutTolerance: plan.layoutTolerance) apply(correctionAdjustments, kind: .matchingFocusedFrame, screenFrame: plan.screenFrame, @@ -448,17 +544,32 @@ class WindowManager { } private func applyFocusedCooperativeFrameIfNeeded(_ frame: CGRect, - focusedWindowElement: AccessibilityElement, - screenFrame: CGRect, - layoutTolerance: CGFloat) { - if CooperativeCornerResize.frameNeedsApplication(currentFrame: focusedWindowElement.frame.screenFlipped, + result: ResultParameters, + layoutTolerance: CGFloat) -> CGRect { + if CooperativeCornerResize.frameNeedsApplication(currentFrame: result.windowElement.frame.screenFlipped, solvedFrame: frame, - screenFrame: screenFrame, + screenFrame: result.visibleFrameOfScreen, layoutTolerance: layoutTolerance) { - focusedWindowElement.setFrame(frame.screenFlipped) + let windowMoverChain = result.isFixedSize + ? fixedSizeWindowMoverChain + : standardWindowMoverChain + 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) + for windowMover in windowMoverChain { + windowMover.moveWindow(toRect: frame, resultParameters: focusedResult) + } } else { Logger.log("Cooperative resize focused no-op: current frame already matches solved frame") } + return result.windowElement.frame } func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) { @@ -647,4 +758,35 @@ private struct CooperativeCornerApplicationPlan { 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/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 67173b77..697ca15a 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -409,6 +409,36 @@ class CooperativeCornerResizeTests: XCTestCase { 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 testNearbyWindowEightPercentOffGridIsCapturedAndNormalized() { let focusedOld = CGRect(x: 250, y: 160, width: 320, height: 260) let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) @@ -600,6 +630,45 @@ class CooperativeCornerResizeTests: XCTestCase { 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) From 8e3efec68fccd631eae283c64d35b4aa4de2f6d2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 30 Jun 2026 21:36:17 -0700 Subject: [PATCH 13/17] Refactor Cooperative Resizer functionality in Window Manager into an extension --- Rectangle.xcodeproj/project.pbxproj | 4 + ...indowManager+CooperativeCornerResize.swift | 403 +++++++++++++++ Rectangle/WindowManager.swift | 466 +----------------- 3 files changed, 417 insertions(+), 456 deletions(-) create mode 100644 Rectangle/WindowManager+CooperativeCornerResize.swift diff --git a/Rectangle.xcodeproj/project.pbxproj b/Rectangle.xcodeproj/project.pbxproj index ae40ffbe..db8d9f8d 100644 --- a/Rectangle.xcodeproj/project.pbxproj +++ b/Rectangle.xcodeproj/project.pbxproj @@ -84,6 +84,7 @@ 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 */; }; @@ -307,6 +308,7 @@ 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 = ""; }; @@ -696,6 +698,7 @@ 9824703E22B13FBC0037B409 /* ScreenDetection.swift */, 98C275662322E2DA009B9292 /* WindowHistory.swift */, 9824704022B186D00037B409 /* WindowManager.swift */, + 982470422EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift */, AA49DD1029B8C1B100690E13 /* TitleBarManager.swift */, AA49DD2029B8C1B100690E13 /* GreenButtonManager.swift */, 98987AA62391890C00BE72C4 /* Logging */, @@ -1055,6 +1058,7 @@ 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 */, diff --git a/Rectangle/WindowManager+CooperativeCornerResize.swift b/Rectangle/WindowManager+CooperativeCornerResize.swift new file mode 100644 index 00000000..8f1bcfe1 --- /dev/null +++ b/Rectangle/WindowManager+CooperativeCornerResize.swift @@ -0,0 +1,403 @@ +/// 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.focusedFramePreservingOccupiedCell(requestedFocusedFrame: newFocusedFrame, + screenFrame: screenFrame, + candidates: candidates, + axis: cooperativeAxis, + movedEdge: movedEdge, + tolerance: tolerance) + : 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) + } + + 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 2e1cd917..b7b3f1a0 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -174,17 +174,13 @@ class WindowManager { if isMovedAcrossDisplays { if calcResult.rect.height != resultingRect.height { Logger.log("Window size wasn't applied perfectly across displays. Trying again.") - resultingRect = apply(result: resultParameters, - cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? [], - layoutTolerance: cooperativeCornerPlan?.layoutTolerance ?? 0) + resultingRect = apply(result: resultParameters) if calcResult.rect.height != resultingRect.height { Logger.log("Final attempt to adjust across displays.") DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) { [weak self] in guard let self else { return } - let finalRect = self.apply(result: resultParameters, - cooperativeCornerAdjustments: cooperativeCornerPlan?.adjustments ?? [], - layoutTolerance: cooperativeCornerPlan?.layoutTolerance ?? 0) + let finalRect = self.apply(result: resultParameters) self.windowMovedAcrossDisplays(windowElement: frontmostWindowElement, resultingRect: finalRect) self.postProcess(result: resultParameters, resultingRect: finalRect) } @@ -200,376 +196,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 { - apply(result: result, cooperativeCornerAdjustments: [], layoutTolerance: 0) + let newRect = result.calcResult.rect + if !result.windowElement.frame.screenFlipped.equalTo(newRect) { + moveWindow(toRect: newRect, result: result) + } + return result.windowElement.frame } - private func apply(result: ResultParameters, - cooperativeCornerAdjustments: [CooperativeCornerWindowAdjustment], - layoutTolerance: CGFloat) -> CGRect { + func moveWindow(toRect rect: CGRect, result: ResultParameters) { let windowMoverChain = result.isFixedSize ? fixedSizeWindowMoverChain : standardWindowMoverChain - - let newRect = result.calcResult.rect - let cooperativeAxis = result.action.cooperativeResizeAxis - let focusedWindowIsExpanding = cooperativeAxis.map { - CooperativeCornerResize.focusedWindowIsExpanding(oldFrame: result.windowElement.frame.screenFlipped, - newFrame: newRect, - axis: $0) - } ?? false - - if focusedWindowIsExpanding { - apply(cooperativeCornerAdjustments, - kind: .adjacent, - screenFrame: result.visibleFrameOfScreen, - layoutTolerance: layoutTolerance) - apply(cooperativeCornerAdjustments, - kind: .matchingFocusedFrame, - screenFrame: result.visibleFrameOfScreen, - layoutTolerance: layoutTolerance) - } - - if CooperativeCornerResize.frameNeedsApplication(currentFrame: result.windowElement.frame.screenFlipped, - solvedFrame: newRect, - screenFrame: result.visibleFrameOfScreen, - layoutTolerance: layoutTolerance) { - for windowMover in windowMoverChain { - windowMover.moveWindow(toRect: newRect, resultParameters: result) - } - } else if !cooperativeCornerAdjustments.isEmpty { - Logger.log("Cooperative resize focused no-op: current frame already matches solved frame") - } - - let resultingRect = result.windowElement.frame - - if !focusedWindowIsExpanding { - apply(cooperativeCornerAdjustments, - kind: .matchingFocusedFrame, - screenFrame: result.visibleFrameOfScreen, - layoutTolerance: layoutTolerance) - apply(cooperativeCornerAdjustments, - kind: .adjacent, - screenFrame: result.visibleFrameOfScreen, - layoutTolerance: layoutTolerance) - } - - return resultingRect - } - - private 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 - } - - private 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.focusedFramePreservingOccupiedCell(requestedFocusedFrame: newFocusedFrame, - screenFrame: screenFrame, - candidates: candidates, - axis: cooperativeAxis, - movedEdge: movedEdge, - tolerance: tolerance) - : 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) - } - - 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) { - let windowMoverChain = result.isFixedSize - ? fixedSizeWindowMoverChain - : standardWindowMoverChain - 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) - for windowMover in windowMoverChain { - windowMover.moveWindow(toRect: frame, resultParameters: focusedResult) - } - } else { - Logger.log("Cooperative resize focused no-op: current frame already matches solved frame") + for windowMover in windowMoverChain { + windowMover.moveWindow(toRect: rect, resultParameters: result) } - return result.windowElement.frame } func windowMovedAcrossDisplays(windowElement: AccessibilityElement, resultingRect: CGRect) { @@ -704,89 +344,3 @@ struct ExecutionParameters { enum ExecutionSource { case keyboardShortcut, dragToSnap, menuItem, url, titleBar } - -private struct CooperativeCornerWindowAdjustment { - let element: AccessibilityElement - let id: CGWindowID - let newFrame: CGRect - let kind: CooperativeCornerResize.Adjustment.Kind -} - -private 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) - } -} From d167fa1b3037ecae2196a9eba3538250057de6ea Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 1 Jul 2026 10:38:20 -0700 Subject: [PATCH 14/17] Refine cooperative corner resize preflight and cleanup Use realized constrained/cyclic corner boundaries for initial placement, avoid cleanup from reduced adjacent corners, and make repeated cycling recognize cooperative gap-adjusted frames. --- Rectangle/CooperativeCornerResize.swift | 226 ++++++++-- .../RepeatedExecutionsCalculation.swift | 92 +++- ...indowManager+CooperativeCornerResize.swift | 407 +++++++++++++++++- Rectangle/WindowManager.swift | 10 + RectangleTests/RectangleTests.swift | 163 +++++++ 5 files changed, 862 insertions(+), 36 deletions(-) diff --git a/Rectangle/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift index 0e2d6804..68604935 100644 --- a/Rectangle/CooperativeCornerResize.swift +++ b/Rectangle/CooperativeCornerResize.swift @@ -82,6 +82,13 @@ struct CooperativeCornerResize { } } + 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)) @@ -116,54 +123,195 @@ struct CooperativeCornerResize { 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 requestedSize = axisSize(requestedFocusedFrame.size, axis) let desiredEdge = edgeCoordinate(requestedFocusedFrame, movedEdge) - let matchingCandidates = candidates.filter { candidate in + 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), - axisSize(frame.size, axis) < requestedSize - tolerance, - matchesPerpendicularSpan(frame, requestedFocusedFrame, movedEdge: movedEdge, tolerance: tolerance), - isContainedInSameSideRegion(frame, requestedFocusedFrame, movedEdge: movedEdge, axis: axis, tolerance: tolerance) + matchesPerpendicularSpan(frame, requestedFocusedFrame, movedEdge: movedEdge, tolerance: tolerance) else { - return false + 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) } - let candidateEdge = edgeCoordinate(frame, movedEdge) - switch movedEdge { - case .right, .top: - return candidateEdge < desiredEdge - tolerance - case .left, .bottom: - return candidateEdge > desiredEdge + tolerance + 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) } - guard let occupiedCell = matchingCandidates.min(by: { lhs, rhs in - let lhsSize = axisSize(lhs.frame.size, axis) - let rhsSize = axisSize(rhs.frame.size, axis) - if abs(lhsSize - rhsSize) > tolerance { - return lhsSize < rhsSize + return observedBoundaries.max { lhs, rhs in + if lhs.priority != rhs.priority { + return lhs.priority > rhs.priority } - return lhs.id < rhs.id - }) else { - return requestedFocusedFrame + if abs(lhs.distanceFromRequestedEdge - rhs.distanceFromRequestedEdge) > 0.001 { + return lhs.distanceFromRequestedEdge < rhs.distanceFromRequestedEdge + } + return lhs.id > rhs.id } + } - return roundedFrameInsideVisibleFrame( - sameSideFrame(baseFrame: requestedFocusedFrame, - movedEdge: movedEdge, - axis: axis, - edge: edgeCoordinate(occupiedCell.frame, movedEdge), - newFocusedFrame: requestedFocusedFrame, - screenFrame: screenFrame), - visibleFrame: screenFrame - ) + 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, @@ -755,6 +903,26 @@ struct CooperativeCornerResize { : 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, diff --git a/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift b/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift index 67260484..d768717c 100644 --- a/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift +++ b/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift @@ -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/WindowManager+CooperativeCornerResize.swift b/Rectangle/WindowManager+CooperativeCornerResize.swift index 8f1bcfe1..a79058a5 100644 --- a/Rectangle/WindowManager+CooperativeCornerResize.swift +++ b/Rectangle/WindowManager+CooperativeCornerResize.swift @@ -119,12 +119,13 @@ extension WindowManager { let minimumSize = CGSize(width: max(1, CGFloat(Defaults.minimumWindowWidth.value)), height: max(1, CGFloat(Defaults.minimumWindowHeight.value))) let requestedFocusedFrame = (!isRepeatedCooperativeAction && action.isCooperativeCornerAction) - ? CooperativeCornerResize.focusedFramePreservingOccupiedCell(requestedFocusedFrame: newFocusedFrame, - screenFrame: screenFrame, - candidates: candidates, - axis: cooperativeAxis, - movedEdge: movedEdge, - tolerance: tolerance) + ? 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, @@ -174,6 +175,400 @@ extension WindowManager { 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 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) + } + 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) + guard let cleanupPlan = CooperativeCornerResize.plan(oldFocusedFrame: observedCornerFrame, + newFocusedFrame: targetFrame, + screenFrame: screenFrame, + candidates: candidates, + 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: candidates, + 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) + } + + 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, diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index b7b3f1a0..6eccfa17 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -189,6 +189,16 @@ 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) + } postProcess(result: resultParameters, resultingRect: resultingRect) } diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 697ca15a..806ff200 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -439,6 +439,104 @@ class CooperativeCornerResizeTests: XCTestCase { 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 testNearbyWindowEightPercentOffGridIsCapturedAndNormalized() { let focusedOld = CGRect(x: 250, y: 160, width: 320, height: 260) let focusedNew = CGRect(x: 0, y: 0, width: 800, height: 600) @@ -1122,6 +1220,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() { @@ -1132,8 +1232,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() { @@ -1143,6 +1247,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() } @@ -1241,6 +1347,63 @@ 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 testRepeatedCornersWithVerticalExpansionCycleHeightOnly() { setSplitRatio(60) Defaults.cornerCycleExpansionAxis.value = .vertical From 299a73f2d229f96f1142f1e8761d35f9b06294a6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 1 Jul 2026 11:09:05 -0700 Subject: [PATCH 15/17] Dynamic Side Split Ratio --- Rectangle.xcodeproj/project.pbxproj | 4 + Rectangle/ActiveSideSplitRatios.swift | 152 ++++++++++++++++++ .../PrefsWindow/SettingsViewController.swift | 13 ++ .../BottomHalfCalculation.swift | 10 +- .../LeftRightHalfCalculation.swift | 8 +- .../LowerLeftCalculation.swift | 23 ++- .../LowerRightCalculation.swift | 23 ++- .../RepeatedExecutionsCalculation.swift | 30 ++-- ...epeatedExecutionsInThirdsCalculation.swift | 26 +++ .../TopHalfCalculation.swift | 10 +- .../UpperLeftCalculation.swift | 23 ++- .../UpperRightCalculation.swift | 23 ++- ...indowManager+CooperativeCornerResize.swift | 4 +- Rectangle/WindowManager.swift | 4 + RectangleTests/RectangleTests.swift | 79 +++++++++ 15 files changed, 361 insertions(+), 71 deletions(-) create mode 100644 Rectangle/ActiveSideSplitRatios.swift diff --git a/Rectangle.xcodeproj/project.pbxproj b/Rectangle.xcodeproj/project.pbxproj index db8d9f8d..58648988 100644 --- a/Rectangle.xcodeproj/project.pbxproj +++ b/Rectangle.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 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 */; }; + 7BE578F32EFE11010083DAE3 /* ActiveSideSplitRatios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE578F22EFE11010083DAE3 /* ActiveSideSplitRatios.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 */; }; @@ -245,6 +246,7 @@ 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 = ""; }; + 7BE578F22EFE11010083DAE3 /* ActiveSideSplitRatios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSideSplitRatios.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 = ""; }; @@ -687,6 +689,7 @@ 984EDB0E29A42ED200D119D2 /* LaunchOnLogin.swift */, 98C1008B2305F1FA006E5344 /* SubsequentExecutionMode.swift */, 7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */, + 7BE578F22EFE11010083DAE3 /* ActiveSideSplitRatios.swift */, 7BE578F02EFA00010083DAE3 /* CooperativeCornerResize.swift */, 985B9BF422B93EEC00A2E8F0 /* ApplicationToggle.swift */, 9824703022AFA8470037B409 /* RectangleStatusItem.swift */, @@ -1056,6 +1059,7 @@ D04CE31027817ABE00BD47B3 /* BottomRightNinthCalculation.swift in Sources */, 98A009B92512538D00CFBF0C /* TopRightSixthCalculation.swift in Sources */, 98C275672322E2DA009B9292 /* WindowHistory.swift in Sources */, + 7BE578F32EFE11010083DAE3 /* ActiveSideSplitRatios.swift in Sources */, 9821403722B3D16700ABFB3F /* MaximizeHeightCalculation.swift in Sources */, 9824704122B186D00037B409 /* WindowManager.swift in Sources */, 982470432EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift in Sources */, diff --git a/Rectangle/ActiveSideSplitRatios.swift b/Rectangle/ActiveSideSplitRatios.swift new file mode 100644 index 00000000..d6078238 --- /dev/null +++ b/Rectangle/ActiveSideSplitRatios.swift @@ -0,0 +1,152 @@ +/// ActiveSideSplitRatios.swift + +import Foundation + +final class ActiveSideSplitRatios { + + static let shared = ActiveSideSplitRatios() + + private struct ScreenKey: Hashable { + let minX: Int + let minY: Int + let width: Int + let height: Int + + init(_ frame: CGRect) { + minX = Self.keyPart(frame.minX) + minY = Self.keyPart(frame.minY) + width = Self.keyPart(frame.width) + height = Self.keyPart(frame.height) + } + + private static func keyPart(_ value: CGFloat) -> Int { + Int(round(value * 1000.0)) + } + } + + private struct SplitRatios { + var horizontal: Float? + var vertical: Float? + } + + private var ratiosByScreen = [ScreenKey: SplitRatios]() + private var configuredHorizontalPercent: Float + private var configuredVerticalPercent: Float + + private init() { + configuredHorizontalPercent = Defaults.horizontalSplitRatio.value + configuredVerticalPercent = Defaults.verticalSplitRatio.value + } + + func horizontalRatio(for screenFrame: CGRect) -> Float { + resetChangedConfiguredDefaults() + return ratiosByScreen[ScreenKey(screenFrame)]?.horizontal ?? configuredHorizontalRatio + } + + func verticalRatio(for screenFrame: CGRect) -> Float { + resetChangedConfiguredDefaults() + return ratiosByScreen[ScreenKey(screenFrame)]?.vertical ?? configuredVerticalRatio + } + + func recordSideAction(_ action: WindowAction, targetFrame: CGRect, screenFrame: CGRect) { + resetChangedConfiguredDefaults() + + guard !targetFrame.isNull, + !screenFrame.isNull, + screenFrame.width > 0, + screenFrame.height > 0 + else { + return + } + + switch action { + case .leftHalf: + setHorizontalRatio(Float(targetFrame.width / screenFrame.width), for: screenFrame) + case .rightHalf: + setHorizontalRatio(1.0 - Float(targetFrame.width / screenFrame.width), for: screenFrame) + case .topHalf: + setVerticalRatio(Float(targetFrame.height / screenFrame.height), for: screenFrame) + case .bottomHalf: + setVerticalRatio(1.0 - Float(targetFrame.height / screenFrame.height), for: screenFrame) + default: + return + } + } + + func resetAll() { + ratiosByScreen.removeAll() + configuredHorizontalPercent = Defaults.horizontalSplitRatio.value + configuredVerticalPercent = Defaults.verticalSplitRatio.value + } + + func reset(for screenFrame: CGRect) { + ratiosByScreen.removeValue(forKey: ScreenKey(screenFrame)) + } + + private var configuredHorizontalRatio: Float { + normalized(Defaults.horizontalSplitRatio.value / 100.0) + } + + private var configuredVerticalRatio: Float { + normalized(Defaults.verticalSplitRatio.value / 100.0) + } + + private func setHorizontalRatio(_ ratio: Float, for screenFrame: CGRect) { + updateRatios(for: screenFrame) { splitRatios in + splitRatios.horizontal = normalized(ratio) + } + } + + private func setVerticalRatio(_ ratio: Float, for screenFrame: CGRect) { + updateRatios(for: screenFrame) { splitRatios in + splitRatios.vertical = normalized(ratio) + } + } + + private func updateRatios(for screenFrame: CGRect, update: (inout SplitRatios) -> Void) { + let key = ScreenKey(screenFrame) + var splitRatios = ratiosByScreen[key] ?? SplitRatios() + update(&splitRatios) + ratiosByScreen[key] = splitRatios + } + + private func resetChangedConfiguredDefaults() { + let currentHorizontalPercent = Defaults.horizontalSplitRatio.value + let currentVerticalPercent = Defaults.verticalSplitRatio.value + + if abs(currentHorizontalPercent - configuredHorizontalPercent) > CycleSize.matchingTolerance { + resetHorizontalRatios() + configuredHorizontalPercent = currentHorizontalPercent + } + + if abs(currentVerticalPercent - configuredVerticalPercent) > CycleSize.matchingTolerance { + resetVerticalRatios() + configuredVerticalPercent = currentVerticalPercent + } + } + + private func resetHorizontalRatios() { + Array(ratiosByScreen.keys).forEach { key in + ratiosByScreen[key]?.horizontal = nil + removeEmptyRatios(for: key) + } + } + + private func resetVerticalRatios() { + Array(ratiosByScreen.keys).forEach { key in + ratiosByScreen[key]?.vertical = nil + removeEmptyRatios(for: key) + } + } + + private func removeEmptyRatios(for key: ScreenKey) { + if ratiosByScreen[key]?.horizontal == nil, + ratiosByScreen[key]?.vertical == nil { + ratiosByScreen.removeValue(forKey: key) + } + } + + private func normalized(_ ratio: Float) -> Float { + min(1.0, max(0.0, ratio)) + } +} diff --git a/Rectangle/PrefsWindow/SettingsViewController.swift b/Rectangle/PrefsWindow/SettingsViewController.swift index 83efbb9d..b5624045 100644 --- a/Rectangle/PrefsWindow/SettingsViewController.swift +++ b/Rectangle/PrefsWindow/SettingsViewController.swift @@ -1331,6 +1331,7 @@ class SettingsViewController: NSViewController { } defaults.value = cycleSize.percentValue + ActiveSideSplitRatios.shared.resetAll() popUpButton.customField?.stringValue = "\(Int(round(cycleSize.percentValue)))" popUpButton.customField?.isHidden = true } @@ -1407,6 +1408,7 @@ extension SettingsViewController: NSTextFieldDelegate { Debounce.input(sender.floatValue, comparedAgainst: sender.floatValue) { floatValue in defaults.value = floatValue + self.resetActiveSideSplitRatiosIfNeeded(for: defaults) sender.defaultsSetAction?() } } @@ -1419,9 +1421,20 @@ extension SettingsViewController: NSTextFieldDelegate { let fallback = sender.fallbackValue sender.stringValue = "\(Int(fallback))" defaults.value = fallback + resetActiveSideSplitRatiosIfNeeded(for: defaults) sender.defaultsSetAction?() } } + + private func resetActiveSideSplitRatiosIfNeeded(for defaults: FloatDefault) { + guard defaults.key == Defaults.horizontalSplitRatio.key + || defaults.key == Defaults.verticalSplitRatio.key + else { + return + } + + ActiveSideSplitRatios.shared.resetAll() + } } class AutoSaveFloatField: NSTextField { diff --git a/Rectangle/WindowCalculation/BottomHalfCalculation.swift b/Rectangle/WindowCalculation/BottomHalfCalculation.swift index b3dc00af..ece30723 100644 --- a/Rectangle/WindowCalculation/BottomHalfCalculation.swift +++ b/Rectangle/WindowCalculation/BottomHalfCalculation.swift @@ -10,15 +10,21 @@ class BottomHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCalcul return calculateFirstRect(params) } - return calculateRepeatedRect(params) + return calculateRepeatedSideRect(params) } func calculateFirstRect(_ params: RectCalculationParameters) -> RectResult { - return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, side: .trailing, fraction: 1.0 - Defaults.verticalSplitRatio.value / 100.0)) + return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, + side: .trailing, + fraction: 1.0 - ActiveSideSplitRatios.shared.verticalRatio(for: params.visibleFrameOfScreen))) } func calculateFractionalRect(_ params: RectCalculationParameters, fraction: Float) -> RectResult { return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, side: .trailing, fraction: fraction)) } + + func calculateRepeatedRect(_ params: RectCalculationParameters) -> RectResult { + calculateRepeatedSideRect(params) + } } diff --git a/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift b/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift index 89ae64a0..25facb96 100644 --- a/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift +++ b/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift @@ -28,7 +28,7 @@ class LeftRightHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCal } func calculateFirstRect(_ params: RectCalculationParameters) -> RectResult { - let ratio = Defaults.horizontalSplitRatio.value / 100.0 + let ratio = ActiveSideSplitRatios.shared.horizontalRatio(for: params.visibleFrameOfScreen) let side: HalfSplitSide = params.action == .rightHalf ? .trailing : .leading let fraction = side == .trailing ? 1.0 - ratio : ratio return RectResult(HalfSplitFrameCalculation.horizontalRect(in: params.visibleFrameOfScreen, side: side, fraction: fraction)) @@ -39,9 +39,13 @@ class LeftRightHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCal return RectResult(HalfSplitFrameCalculation.horizontalRect(in: params.visibleFrameOfScreen, side: side, fraction: fraction)) } + func calculateRepeatedRect(_ params: RectCalculationParameters) -> RectResult { + calculateRepeatedSideRect(params) + } + func calculateResize(_ params: WindowCalculationParameters) -> WindowCalculationResult? { let screen = params.usableScreens.currentScreen - let rectResult: RectResult = calculateRepeatedRect(params.asRectParams()) + let rectResult: RectResult = calculateRepeatedSideRect(params.asRectParams()) return WindowCalculationResult(rect: rectResult.rect, screen: screen, resultingAction: params.action) } diff --git a/Rectangle/WindowCalculation/LowerLeftCalculation.swift b/Rectangle/WindowCalculation/LowerLeftCalculation.swift index d621d189..2848bcda 100644 --- a/Rectangle/WindowCalculation/LowerLeftCalculation.swift +++ b/Rectangle/WindowCalculation/LowerLeftCalculation.swift @@ -6,8 +6,6 @@ class LowerLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, let horizontalSide: HalfSplitSide = .leading let verticalSide: HalfSplitSide = .trailing - var horizontalSplitFraction: Float { Defaults.horizontalSplitRatio.value / 100.0 } - var verticalSplitFraction: Float { 1.0 - Defaults.verticalSplitRatio.value / 100.0 } override func calculateRect(_ params: RectCalculationParameters) -> RectResult { @@ -19,7 +17,7 @@ class LowerLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculation(params.visibleFrameOfScreen) } } - return quarterRect(params.visibleFrameOfScreen) + return quarterRect(params) } if params.lastAction == nil || !Defaults.subsequentExecutionMode.resizes { @@ -29,18 +27,17 @@ class LowerLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculateRepeatedRect(params) } - func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { - return RectResult(cornerRect(visibleFrameOfScreen, - horizontalFraction: horizontalSplitFraction, - verticalFraction: verticalSplitFraction), + func quarterRect(_ params: RectCalculationParameters) -> RectResult { + return RectResult(cornerRect(params, + horizontalFraction: horizontalSplitFraction(params), + verticalFraction: verticalSplitFraction(params)), subAction: .bottomLeftQuarter) } - private func cornerRect(_ visibleFrameOfScreen: CGRect, horizontalFraction: Float, verticalFraction: Float) -> CGRect { - HalfSplitFrameCalculation.cornerRect(in: visibleFrameOfScreen, - horizontalSide: horizontalSide, - verticalSide: verticalSide, - horizontalFraction: horizontalFraction, - verticalFraction: verticalFraction) + func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { + quarterRect(RectCalculationParameters(window: Window(id: 0, rect: visibleFrameOfScreen), + visibleFrameOfScreen: visibleFrameOfScreen, + action: .bottomLeft, + lastAction: nil)) } } diff --git a/Rectangle/WindowCalculation/LowerRightCalculation.swift b/Rectangle/WindowCalculation/LowerRightCalculation.swift index a95fc035..a82a79d5 100644 --- a/Rectangle/WindowCalculation/LowerRightCalculation.swift +++ b/Rectangle/WindowCalculation/LowerRightCalculation.swift @@ -6,8 +6,6 @@ class LowerRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, let horizontalSide: HalfSplitSide = .trailing let verticalSide: HalfSplitSide = .trailing - var horizontalSplitFraction: Float { 1.0 - Defaults.horizontalSplitRatio.value / 100.0 } - var verticalSplitFraction: Float { 1.0 - Defaults.verticalSplitRatio.value / 100.0 } override func calculateRect(_ params: RectCalculationParameters) -> RectResult { @@ -19,7 +17,7 @@ class LowerRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculation(params.visibleFrameOfScreen) } } - return quarterRect(params.visibleFrameOfScreen) + return quarterRect(params) } if params.lastAction == nil || !Defaults.subsequentExecutionMode.resizes { @@ -29,18 +27,17 @@ class LowerRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculateRepeatedRect(params) } - func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { - return RectResult(cornerRect(visibleFrameOfScreen, - horizontalFraction: horizontalSplitFraction, - verticalFraction: verticalSplitFraction), + func quarterRect(_ params: RectCalculationParameters) -> RectResult { + return RectResult(cornerRect(params, + horizontalFraction: horizontalSplitFraction(params), + verticalFraction: verticalSplitFraction(params)), subAction: .bottomRightQuarter) } - private func cornerRect(_ visibleFrameOfScreen: CGRect, horizontalFraction: Float, verticalFraction: Float) -> CGRect { - HalfSplitFrameCalculation.cornerRect(in: visibleFrameOfScreen, - horizontalSide: horizontalSide, - verticalSide: verticalSide, - horizontalFraction: horizontalFraction, - verticalFraction: verticalFraction) + func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { + quarterRect(RectCalculationParameters(window: Window(id: 0, rect: visibleFrameOfScreen), + visibleFrameOfScreen: visibleFrameOfScreen, + action: .bottomRight, + lastAction: nil)) } } diff --git a/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift b/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift index d768717c..e9ca8eba 100644 --- a/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift +++ b/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift @@ -51,16 +51,14 @@ extension RepeatedExecutionsCalculation { protocol CornerCycleExpansionCalculation: RepeatedExecutionsCalculation { var horizontalSide: HalfSplitSide { get } var verticalSide: HalfSplitSide { get } - var horizontalSplitFraction: Float { get } - var verticalSplitFraction: Float { get } } extension CornerCycleExpansionCalculation { func calculateFirstRect(_ params: RectCalculationParameters) -> RectResult { - RectResult(cornerRect(params.visibleFrameOfScreen, - horizontalFraction: horizontalSplitFraction, - verticalFraction: verticalSplitFraction)) + RectResult(cornerRect(params, + horizontalFraction: horizontalSplitFraction(params), + verticalFraction: verticalSplitFraction(params))) } func calculateRect(for cycleDivision: CycleSize, params: RectCalculationParameters) -> RectResult { @@ -100,16 +98,16 @@ extension CornerCycleExpansionCalculation { switch Defaults.cornerCycleExpansionAxis.value { case .horizontal: - let cycledRect = cornerRect(params.visibleFrameOfScreen, + let cycledRect = cornerRect(params, horizontalFraction: fraction, - verticalFraction: verticalSplitFraction) + verticalFraction: verticalSplitFraction(params)) return RectResult(CGRect(x: cycledRect.origin.x, y: normalRect.origin.y, width: cycledRect.width, height: normalRect.height)) case .vertical: - let cycledRect = cornerRect(params.visibleFrameOfScreen, - horizontalFraction: horizontalSplitFraction, + let cycledRect = cornerRect(params, + horizontalFraction: horizontalSplitFraction(params), verticalFraction: fraction) return RectResult(CGRect(x: normalRect.origin.x, y: cycledRect.origin.y, @@ -118,8 +116,18 @@ extension CornerCycleExpansionCalculation { } } - private func cornerRect(_ visibleFrameOfScreen: CGRect, horizontalFraction: Float, verticalFraction: Float) -> CGRect { - HalfSplitFrameCalculation.cornerRect(in: visibleFrameOfScreen, + func horizontalSplitFraction(_ params: RectCalculationParameters) -> Float { + let ratio = ActiveSideSplitRatios.shared.horizontalRatio(for: params.visibleFrameOfScreen) + return horizontalSide == .trailing ? 1.0 - ratio : ratio + } + + func verticalSplitFraction(_ params: RectCalculationParameters) -> Float { + let ratio = ActiveSideSplitRatios.shared.verticalRatio(for: params.visibleFrameOfScreen) + return verticalSide == .trailing ? 1.0 - ratio : ratio + } + + func cornerRect(_ params: RectCalculationParameters, horizontalFraction: Float, verticalFraction: Float) -> CGRect { + HalfSplitFrameCalculation.cornerRect(in: params.visibleFrameOfScreen, horizontalSide: horizontalSide, verticalSide: verticalSide, horizontalFraction: horizontalFraction, diff --git a/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift b/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift index 6a677bcb..d14594e9 100644 --- a/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift +++ b/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift @@ -44,5 +44,31 @@ extension RepeatedExecutionsInThirdsCalculation { return calculateRect(for: sortedPositions[cycleIndex(forExecutionCount: count, in: sortedPositions)], params: params) } + + func calculateRepeatedSideRect(_ 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) + } + + guard let count = params.lastAction?.count else { + return calculateFirstRect(params) + } + + var position = cycleIndex(forExecutionCount: count, in: sortedPositions) + for _ in sortedPositions.indices { + let result = calculateRect(for: sortedPositions[position], params: params) + if !result.rect.equalTo(params.window.rect) { + return result + } + position = (position + 1) % sortedPositions.count + } + + return calculateFirstRect(params) + } } diff --git a/Rectangle/WindowCalculation/TopHalfCalculation.swift b/Rectangle/WindowCalculation/TopHalfCalculation.swift index a121938a..adc15934 100644 --- a/Rectangle/WindowCalculation/TopHalfCalculation.swift +++ b/Rectangle/WindowCalculation/TopHalfCalculation.swift @@ -10,15 +10,21 @@ class TopHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCalculati return calculateFirstRect(params) } - return calculateRepeatedRect(params) + return calculateRepeatedSideRect(params) } func calculateFirstRect(_ params: RectCalculationParameters) -> RectResult { - return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, side: .leading, fraction: Defaults.verticalSplitRatio.value / 100.0)) + return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, + side: .leading, + fraction: ActiveSideSplitRatios.shared.verticalRatio(for: params.visibleFrameOfScreen))) } func calculateFractionalRect(_ params: RectCalculationParameters, fraction: Float) -> RectResult { return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, side: .leading, fraction: fraction)) } + + func calculateRepeatedRect(_ params: RectCalculationParameters) -> RectResult { + calculateRepeatedSideRect(params) + } } diff --git a/Rectangle/WindowCalculation/UpperLeftCalculation.swift b/Rectangle/WindowCalculation/UpperLeftCalculation.swift index d6208f39..63c1ee3d 100644 --- a/Rectangle/WindowCalculation/UpperLeftCalculation.swift +++ b/Rectangle/WindowCalculation/UpperLeftCalculation.swift @@ -6,8 +6,6 @@ class UpperLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, let horizontalSide: HalfSplitSide = .leading let verticalSide: HalfSplitSide = .leading - var horizontalSplitFraction: Float { Defaults.horizontalSplitRatio.value / 100.0 } - var verticalSplitFraction: Float { Defaults.verticalSplitRatio.value / 100.0 } override func calculateRect(_ params: RectCalculationParameters) -> RectResult { @@ -19,7 +17,7 @@ class UpperLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculation(params.visibleFrameOfScreen) } } - return quarterRect(params.visibleFrameOfScreen) + return quarterRect(params) } if params.lastAction == nil || !Defaults.subsequentExecutionMode.resizes { @@ -29,18 +27,17 @@ class UpperLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculateRepeatedRect(params) } - func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { - return RectResult(cornerRect(visibleFrameOfScreen, - horizontalFraction: horizontalSplitFraction, - verticalFraction: verticalSplitFraction), + func quarterRect(_ params: RectCalculationParameters) -> RectResult { + return RectResult(cornerRect(params, + horizontalFraction: horizontalSplitFraction(params), + verticalFraction: verticalSplitFraction(params)), subAction: .topLeftQuarter) } - private func cornerRect(_ visibleFrameOfScreen: CGRect, horizontalFraction: Float, verticalFraction: Float) -> CGRect { - HalfSplitFrameCalculation.cornerRect(in: visibleFrameOfScreen, - horizontalSide: horizontalSide, - verticalSide: verticalSide, - horizontalFraction: horizontalFraction, - verticalFraction: verticalFraction) + func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { + quarterRect(RectCalculationParameters(window: Window(id: 0, rect: visibleFrameOfScreen), + visibleFrameOfScreen: visibleFrameOfScreen, + action: .topLeft, + lastAction: nil)) } } diff --git a/Rectangle/WindowCalculation/UpperRightCalculation.swift b/Rectangle/WindowCalculation/UpperRightCalculation.swift index 3b5be43d..1e2ad6d7 100644 --- a/Rectangle/WindowCalculation/UpperRightCalculation.swift +++ b/Rectangle/WindowCalculation/UpperRightCalculation.swift @@ -6,8 +6,6 @@ class UpperRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, let horizontalSide: HalfSplitSide = .trailing let verticalSide: HalfSplitSide = .leading - var horizontalSplitFraction: Float { 1.0 - Defaults.horizontalSplitRatio.value / 100.0 } - var verticalSplitFraction: Float { Defaults.verticalSplitRatio.value / 100.0 } override func calculateRect(_ params: RectCalculationParameters) -> RectResult { @@ -19,7 +17,7 @@ class UpperRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculation(params.visibleFrameOfScreen) } } - return quarterRect(params.visibleFrameOfScreen) + return quarterRect(params) } if params.lastAction == nil || !Defaults.subsequentExecutionMode.resizes { @@ -29,18 +27,17 @@ class UpperRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculateRepeatedRect(params) } - func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { - return RectResult(cornerRect(visibleFrameOfScreen, - horizontalFraction: horizontalSplitFraction, - verticalFraction: verticalSplitFraction), + func quarterRect(_ params: RectCalculationParameters) -> RectResult { + return RectResult(cornerRect(params, + horizontalFraction: horizontalSplitFraction(params), + verticalFraction: verticalSplitFraction(params)), subAction: .topRightQuarter) } - private func cornerRect(_ visibleFrameOfScreen: CGRect, horizontalFraction: Float, verticalFraction: Float) -> CGRect { - HalfSplitFrameCalculation.cornerRect(in: visibleFrameOfScreen, - horizontalSide: horizontalSide, - verticalSide: verticalSide, - horizontalFraction: horizontalFraction, - verticalFraction: verticalFraction) + func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { + quarterRect(RectCalculationParameters(window: Window(id: 0, rect: visibleFrameOfScreen), + visibleFrameOfScreen: visibleFrameOfScreen, + action: .topRight, + lastAction: nil)) } } diff --git a/Rectangle/WindowManager+CooperativeCornerResize.swift b/Rectangle/WindowManager+CooperativeCornerResize.swift index a79058a5..bbfaae4e 100644 --- a/Rectangle/WindowManager+CooperativeCornerResize.swift +++ b/Rectangle/WindowManager+CooperativeCornerResize.swift @@ -455,8 +455,8 @@ extension WindowManager { } private func configuredCornerFrame(action: WindowAction, screenFrame: CGRect) -> CGRect? { - let horizontalRatio = Defaults.horizontalSplitRatio.value / 100.0 - let verticalRatio = Defaults.verticalSplitRatio.value / 100.0 + let horizontalRatio = ActiveSideSplitRatios.shared.horizontalRatio(for: screenFrame) + let verticalRatio = ActiveSideSplitRatios.shared.verticalRatio(for: screenFrame) let horizontalSide: HalfSplitSide let verticalSide: HalfSplitSide let horizontalFraction: Float diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index 6eccfa17..584dd013 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -139,6 +139,10 @@ class WindowManager { calcResult.rect = cooperativeCornerPlan.focusedFrame } + ActiveSideSplitRatios.shared.recordSideAction(calcResult.resultingAction, + targetFrame: calcResult.initialRect, + screenFrame: visibleFrameOfDestinationScreen) + if let cooperativeCornerPlan { if !cooperativeCornerPlan.needsApplication(focusedCurrentFrame: currentNormalizedRect) { Logger.log("Cooperative resize no-op: solved frames already match current frames") diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 806ff200..83e0d4f8 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -1238,6 +1238,7 @@ class HalfSplitCornerCalculationTests: XCTestCase { Defaults.cycleSizesIsChanged.enabled = false Defaults.gapSize.value = 0 Defaults.skipGapTopEdge.enabled = false + ActiveSideSplitRatios.shared.resetAll() } override func tearDown() { @@ -1249,6 +1250,7 @@ class HalfSplitCornerCalculationTests: XCTestCase { Defaults.selectedCycleSizes.value = savedSelectedCycleSizes Defaults.gapSize.value = savedGapSize Defaults.skipGapTopEdge.enabled = savedSkipGapTopEdge + ActiveSideSplitRatios.shared.resetAll() super.tearDown() } @@ -1480,6 +1482,83 @@ class HalfSplitCornerCalculationTests: XCTestCase { assertRect(repeatedTopFrame, equals: CGRect(x: 10, y: 620, width: 1200, height: 300)) } + func testRepeatedRightShortcutUpdatesActiveSplitForSubsequentCorners() { + Defaults.horizontalSplitRatio.value = CycleSize.twoThirds.percentValue + Defaults.verticalSplitRatio.value = 50 + ActiveSideSplitRatios.shared.resetAll() + + let firstRightFrame = WindowCalculationFactory.rightHalfCalculation.calculateRect(params(for: .rightHalf)).rect + let repeatedRightFrame = WindowCalculationFactory.rightHalfCalculation.calculateRepeatedRect(repeatedParams(for: .rightHalf, + currentRect: firstRightFrame)).rect + + ActiveSideSplitRatios.shared.recordSideAction(.rightHalf, + targetFrame: repeatedRightFrame, + screenFrame: visibleFrame) + + let topRightFrame = WindowCalculationFactory.upperRightCalculation.calculateRect(params(for: .topRight)).rect + let topLeftFrame = WindowCalculationFactory.upperLeftCalculation.calculateRect(params(for: .topLeft)).rect + + assertRect(firstRightFrame, equals: CGRect(x: 810, y: 20, width: 400, height: 900)) + assertRect(repeatedRightFrame, equals: CGRect(x: 410, y: 20, width: 800, height: 900)) + assertRect(topRightFrame, equals: CGRect(x: 410, y: 470, width: 800, height: 450)) + assertRect(topLeftFrame, equals: CGRect(x: 10, y: 470, width: 400, height: 450)) + XCTAssertEqual(Defaults.horizontalSplitRatio.value, CycleSize.twoThirds.percentValue, accuracy: 0.001) + } + + func testRepeatedBottomShortcutUpdatesActiveSplitForSubsequentCorners() { + Defaults.horizontalSplitRatio.value = 50 + Defaults.verticalSplitRatio.value = CycleSize.twoThirds.percentValue + ActiveSideSplitRatios.shared.resetAll() + + let firstBottomFrame = WindowCalculationFactory.bottomHalfCalculation.calculateRect(params(for: .bottomHalf)).rect + let repeatedBottomFrame = WindowCalculationFactory.bottomHalfCalculation.calculateRepeatedRect(repeatedParams(for: .bottomHalf, + currentRect: firstBottomFrame)).rect + + ActiveSideSplitRatios.shared.recordSideAction(.bottomHalf, + targetFrame: repeatedBottomFrame, + screenFrame: visibleFrame) + + let bottomRightFrame = WindowCalculationFactory.lowerRightCalculation.calculateRect(params(for: .bottomRight)).rect + let topRightFrame = WindowCalculationFactory.upperRightCalculation.calculateRect(params(for: .topRight)).rect + + assertRect(firstBottomFrame, equals: CGRect(x: 10, y: 20, width: 1200, height: 300)) + assertRect(repeatedBottomFrame, equals: CGRect(x: 10, y: 20, width: 1200, height: 600)) + assertRect(bottomRightFrame, equals: CGRect(x: 610, y: 20, width: 600, height: 600)) + assertRect(topRightFrame, equals: CGRect(x: 610, y: 620, width: 600, height: 300)) + XCTAssertEqual(Defaults.verticalSplitRatio.value, CycleSize.twoThirds.percentValue, accuracy: 0.001) + } + + func testActiveSideSplitIsScopedToDisplayFrame() { + Defaults.horizontalSplitRatio.value = CycleSize.twoThirds.percentValue + ActiveSideSplitRatios.shared.resetAll() + + let cycledRightFrame = CGRect(x: 410, y: 20, width: 800, height: 900) + let otherDisplayFrame = CGRect(x: 2000, y: 20, width: 1200, height: 900) + + ActiveSideSplitRatios.shared.recordSideAction(.rightHalf, + targetFrame: cycledRightFrame, + screenFrame: visibleFrame) + + XCTAssertEqual(ActiveSideSplitRatios.shared.horizontalRatio(for: visibleFrame), + CycleSize.oneThird.fraction, + accuracy: 0.001) + XCTAssertEqual(ActiveSideSplitRatios.shared.horizontalRatio(for: otherDisplayFrame), + CycleSize.twoThirds.fraction, + accuracy: 0.001) + } + + func testChangingSavedSplitRatioResetsActiveRuntimeSplit() { + Defaults.horizontalSplitRatio.value = CycleSize.twoThirds.percentValue + ActiveSideSplitRatios.shared.resetAll() + ActiveSideSplitRatios.shared.recordSideAction(.rightHalf, + targetFrame: CGRect(x: 410, y: 20, width: 800, height: 900), + screenFrame: visibleFrame) + + Defaults.horizontalSplitRatio.value = 50 + + XCTAssertEqual(ActiveSideSplitRatios.shared.horizontalRatio(for: visibleFrame), 0.5, accuracy: 0.001) + } + func testHorizontalCornerShortcutCanCycleAfterCompatibleSideShortcut() { setSplitRatio(50) Defaults.cornerCycleExpansionAxis.value = .horizontal From 315f0f03e3d2201dd896e71343d8021ad1217dc5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 1 Jul 2026 11:33:09 -0700 Subject: [PATCH 16/17] Revert "Dynamic Side Split Ratio" This reverts commit 299a73f2d229f96f1142f1e8761d35f9b06294a6. --- Rectangle.xcodeproj/project.pbxproj | 4 - Rectangle/ActiveSideSplitRatios.swift | 152 ------------------ .../PrefsWindow/SettingsViewController.swift | 13 -- .../BottomHalfCalculation.swift | 10 +- .../LeftRightHalfCalculation.swift | 8 +- .../LowerLeftCalculation.swift | 23 +-- .../LowerRightCalculation.swift | 23 +-- .../RepeatedExecutionsCalculation.swift | 30 ++-- ...epeatedExecutionsInThirdsCalculation.swift | 26 --- .../TopHalfCalculation.swift | 10 +- .../UpperLeftCalculation.swift | 23 +-- .../UpperRightCalculation.swift | 23 +-- ...indowManager+CooperativeCornerResize.swift | 4 +- Rectangle/WindowManager.swift | 4 - RectangleTests/RectangleTests.swift | 79 --------- 15 files changed, 71 insertions(+), 361 deletions(-) delete mode 100644 Rectangle/ActiveSideSplitRatios.swift diff --git a/Rectangle.xcodeproj/project.pbxproj b/Rectangle.xcodeproj/project.pbxproj index 58648988..db8d9f8d 100644 --- a/Rectangle.xcodeproj/project.pbxproj +++ b/Rectangle.xcodeproj/project.pbxproj @@ -34,7 +34,6 @@ 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 */; }; - 7BE578F32EFE11010083DAE3 /* ActiveSideSplitRatios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE578F22EFE11010083DAE3 /* ActiveSideSplitRatios.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 */; }; @@ -246,7 +245,6 @@ 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 = ""; }; - 7BE578F22EFE11010083DAE3 /* ActiveSideSplitRatios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSideSplitRatios.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 = ""; }; @@ -689,7 +687,6 @@ 984EDB0E29A42ED200D119D2 /* LaunchOnLogin.swift */, 98C1008B2305F1FA006E5344 /* SubsequentExecutionMode.swift */, 7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */, - 7BE578F22EFE11010083DAE3 /* ActiveSideSplitRatios.swift */, 7BE578F02EFA00010083DAE3 /* CooperativeCornerResize.swift */, 985B9BF422B93EEC00A2E8F0 /* ApplicationToggle.swift */, 9824703022AFA8470037B409 /* RectangleStatusItem.swift */, @@ -1059,7 +1056,6 @@ D04CE31027817ABE00BD47B3 /* BottomRightNinthCalculation.swift in Sources */, 98A009B92512538D00CFBF0C /* TopRightSixthCalculation.swift in Sources */, 98C275672322E2DA009B9292 /* WindowHistory.swift in Sources */, - 7BE578F32EFE11010083DAE3 /* ActiveSideSplitRatios.swift in Sources */, 9821403722B3D16700ABFB3F /* MaximizeHeightCalculation.swift in Sources */, 9824704122B186D00037B409 /* WindowManager.swift in Sources */, 982470432EFA00010037B409 /* WindowManager+CooperativeCornerResize.swift in Sources */, diff --git a/Rectangle/ActiveSideSplitRatios.swift b/Rectangle/ActiveSideSplitRatios.swift deleted file mode 100644 index d6078238..00000000 --- a/Rectangle/ActiveSideSplitRatios.swift +++ /dev/null @@ -1,152 +0,0 @@ -/// ActiveSideSplitRatios.swift - -import Foundation - -final class ActiveSideSplitRatios { - - static let shared = ActiveSideSplitRatios() - - private struct ScreenKey: Hashable { - let minX: Int - let minY: Int - let width: Int - let height: Int - - init(_ frame: CGRect) { - minX = Self.keyPart(frame.minX) - minY = Self.keyPart(frame.minY) - width = Self.keyPart(frame.width) - height = Self.keyPart(frame.height) - } - - private static func keyPart(_ value: CGFloat) -> Int { - Int(round(value * 1000.0)) - } - } - - private struct SplitRatios { - var horizontal: Float? - var vertical: Float? - } - - private var ratiosByScreen = [ScreenKey: SplitRatios]() - private var configuredHorizontalPercent: Float - private var configuredVerticalPercent: Float - - private init() { - configuredHorizontalPercent = Defaults.horizontalSplitRatio.value - configuredVerticalPercent = Defaults.verticalSplitRatio.value - } - - func horizontalRatio(for screenFrame: CGRect) -> Float { - resetChangedConfiguredDefaults() - return ratiosByScreen[ScreenKey(screenFrame)]?.horizontal ?? configuredHorizontalRatio - } - - func verticalRatio(for screenFrame: CGRect) -> Float { - resetChangedConfiguredDefaults() - return ratiosByScreen[ScreenKey(screenFrame)]?.vertical ?? configuredVerticalRatio - } - - func recordSideAction(_ action: WindowAction, targetFrame: CGRect, screenFrame: CGRect) { - resetChangedConfiguredDefaults() - - guard !targetFrame.isNull, - !screenFrame.isNull, - screenFrame.width > 0, - screenFrame.height > 0 - else { - return - } - - switch action { - case .leftHalf: - setHorizontalRatio(Float(targetFrame.width / screenFrame.width), for: screenFrame) - case .rightHalf: - setHorizontalRatio(1.0 - Float(targetFrame.width / screenFrame.width), for: screenFrame) - case .topHalf: - setVerticalRatio(Float(targetFrame.height / screenFrame.height), for: screenFrame) - case .bottomHalf: - setVerticalRatio(1.0 - Float(targetFrame.height / screenFrame.height), for: screenFrame) - default: - return - } - } - - func resetAll() { - ratiosByScreen.removeAll() - configuredHorizontalPercent = Defaults.horizontalSplitRatio.value - configuredVerticalPercent = Defaults.verticalSplitRatio.value - } - - func reset(for screenFrame: CGRect) { - ratiosByScreen.removeValue(forKey: ScreenKey(screenFrame)) - } - - private var configuredHorizontalRatio: Float { - normalized(Defaults.horizontalSplitRatio.value / 100.0) - } - - private var configuredVerticalRatio: Float { - normalized(Defaults.verticalSplitRatio.value / 100.0) - } - - private func setHorizontalRatio(_ ratio: Float, for screenFrame: CGRect) { - updateRatios(for: screenFrame) { splitRatios in - splitRatios.horizontal = normalized(ratio) - } - } - - private func setVerticalRatio(_ ratio: Float, for screenFrame: CGRect) { - updateRatios(for: screenFrame) { splitRatios in - splitRatios.vertical = normalized(ratio) - } - } - - private func updateRatios(for screenFrame: CGRect, update: (inout SplitRatios) -> Void) { - let key = ScreenKey(screenFrame) - var splitRatios = ratiosByScreen[key] ?? SplitRatios() - update(&splitRatios) - ratiosByScreen[key] = splitRatios - } - - private func resetChangedConfiguredDefaults() { - let currentHorizontalPercent = Defaults.horizontalSplitRatio.value - let currentVerticalPercent = Defaults.verticalSplitRatio.value - - if abs(currentHorizontalPercent - configuredHorizontalPercent) > CycleSize.matchingTolerance { - resetHorizontalRatios() - configuredHorizontalPercent = currentHorizontalPercent - } - - if abs(currentVerticalPercent - configuredVerticalPercent) > CycleSize.matchingTolerance { - resetVerticalRatios() - configuredVerticalPercent = currentVerticalPercent - } - } - - private func resetHorizontalRatios() { - Array(ratiosByScreen.keys).forEach { key in - ratiosByScreen[key]?.horizontal = nil - removeEmptyRatios(for: key) - } - } - - private func resetVerticalRatios() { - Array(ratiosByScreen.keys).forEach { key in - ratiosByScreen[key]?.vertical = nil - removeEmptyRatios(for: key) - } - } - - private func removeEmptyRatios(for key: ScreenKey) { - if ratiosByScreen[key]?.horizontal == nil, - ratiosByScreen[key]?.vertical == nil { - ratiosByScreen.removeValue(forKey: key) - } - } - - private func normalized(_ ratio: Float) -> Float { - min(1.0, max(0.0, ratio)) - } -} diff --git a/Rectangle/PrefsWindow/SettingsViewController.swift b/Rectangle/PrefsWindow/SettingsViewController.swift index b5624045..83efbb9d 100644 --- a/Rectangle/PrefsWindow/SettingsViewController.swift +++ b/Rectangle/PrefsWindow/SettingsViewController.swift @@ -1331,7 +1331,6 @@ class SettingsViewController: NSViewController { } defaults.value = cycleSize.percentValue - ActiveSideSplitRatios.shared.resetAll() popUpButton.customField?.stringValue = "\(Int(round(cycleSize.percentValue)))" popUpButton.customField?.isHidden = true } @@ -1408,7 +1407,6 @@ extension SettingsViewController: NSTextFieldDelegate { Debounce.input(sender.floatValue, comparedAgainst: sender.floatValue) { floatValue in defaults.value = floatValue - self.resetActiveSideSplitRatiosIfNeeded(for: defaults) sender.defaultsSetAction?() } } @@ -1421,20 +1419,9 @@ extension SettingsViewController: NSTextFieldDelegate { let fallback = sender.fallbackValue sender.stringValue = "\(Int(fallback))" defaults.value = fallback - resetActiveSideSplitRatiosIfNeeded(for: defaults) sender.defaultsSetAction?() } } - - private func resetActiveSideSplitRatiosIfNeeded(for defaults: FloatDefault) { - guard defaults.key == Defaults.horizontalSplitRatio.key - || defaults.key == Defaults.verticalSplitRatio.key - else { - return - } - - ActiveSideSplitRatios.shared.resetAll() - } } class AutoSaveFloatField: NSTextField { diff --git a/Rectangle/WindowCalculation/BottomHalfCalculation.swift b/Rectangle/WindowCalculation/BottomHalfCalculation.swift index ece30723..b3dc00af 100644 --- a/Rectangle/WindowCalculation/BottomHalfCalculation.swift +++ b/Rectangle/WindowCalculation/BottomHalfCalculation.swift @@ -10,21 +10,15 @@ class BottomHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCalcul return calculateFirstRect(params) } - return calculateRepeatedSideRect(params) + return calculateRepeatedRect(params) } func calculateFirstRect(_ params: RectCalculationParameters) -> RectResult { - return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, - side: .trailing, - fraction: 1.0 - ActiveSideSplitRatios.shared.verticalRatio(for: params.visibleFrameOfScreen))) + return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, side: .trailing, fraction: 1.0 - Defaults.verticalSplitRatio.value / 100.0)) } func calculateFractionalRect(_ params: RectCalculationParameters, fraction: Float) -> RectResult { return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, side: .trailing, fraction: fraction)) } - - func calculateRepeatedRect(_ params: RectCalculationParameters) -> RectResult { - calculateRepeatedSideRect(params) - } } diff --git a/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift b/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift index 25facb96..89ae64a0 100644 --- a/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift +++ b/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift @@ -28,7 +28,7 @@ class LeftRightHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCal } func calculateFirstRect(_ params: RectCalculationParameters) -> RectResult { - let ratio = ActiveSideSplitRatios.shared.horizontalRatio(for: params.visibleFrameOfScreen) + let ratio = Defaults.horizontalSplitRatio.value / 100.0 let side: HalfSplitSide = params.action == .rightHalf ? .trailing : .leading let fraction = side == .trailing ? 1.0 - ratio : ratio return RectResult(HalfSplitFrameCalculation.horizontalRect(in: params.visibleFrameOfScreen, side: side, fraction: fraction)) @@ -39,13 +39,9 @@ class LeftRightHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCal return RectResult(HalfSplitFrameCalculation.horizontalRect(in: params.visibleFrameOfScreen, side: side, fraction: fraction)) } - func calculateRepeatedRect(_ params: RectCalculationParameters) -> RectResult { - calculateRepeatedSideRect(params) - } - func calculateResize(_ params: WindowCalculationParameters) -> WindowCalculationResult? { let screen = params.usableScreens.currentScreen - let rectResult: RectResult = calculateRepeatedSideRect(params.asRectParams()) + let rectResult: RectResult = calculateRepeatedRect(params.asRectParams()) return WindowCalculationResult(rect: rectResult.rect, screen: screen, resultingAction: params.action) } diff --git a/Rectangle/WindowCalculation/LowerLeftCalculation.swift b/Rectangle/WindowCalculation/LowerLeftCalculation.swift index 2848bcda..d621d189 100644 --- a/Rectangle/WindowCalculation/LowerLeftCalculation.swift +++ b/Rectangle/WindowCalculation/LowerLeftCalculation.swift @@ -6,6 +6,8 @@ class LowerLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, let horizontalSide: HalfSplitSide = .leading let verticalSide: HalfSplitSide = .trailing + var horizontalSplitFraction: Float { Defaults.horizontalSplitRatio.value / 100.0 } + var verticalSplitFraction: Float { 1.0 - Defaults.verticalSplitRatio.value / 100.0 } override func calculateRect(_ params: RectCalculationParameters) -> RectResult { @@ -17,7 +19,7 @@ class LowerLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculation(params.visibleFrameOfScreen) } } - return quarterRect(params) + return quarterRect(params.visibleFrameOfScreen) } if params.lastAction == nil || !Defaults.subsequentExecutionMode.resizes { @@ -27,17 +29,18 @@ class LowerLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculateRepeatedRect(params) } - func quarterRect(_ params: RectCalculationParameters) -> RectResult { - return RectResult(cornerRect(params, - horizontalFraction: horizontalSplitFraction(params), - verticalFraction: verticalSplitFraction(params)), + func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { + return RectResult(cornerRect(visibleFrameOfScreen, + horizontalFraction: horizontalSplitFraction, + verticalFraction: verticalSplitFraction), subAction: .bottomLeftQuarter) } - func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { - quarterRect(RectCalculationParameters(window: Window(id: 0, rect: visibleFrameOfScreen), - visibleFrameOfScreen: visibleFrameOfScreen, - action: .bottomLeft, - lastAction: nil)) + private func cornerRect(_ visibleFrameOfScreen: CGRect, horizontalFraction: Float, verticalFraction: Float) -> CGRect { + HalfSplitFrameCalculation.cornerRect(in: visibleFrameOfScreen, + horizontalSide: horizontalSide, + verticalSide: verticalSide, + horizontalFraction: horizontalFraction, + verticalFraction: verticalFraction) } } diff --git a/Rectangle/WindowCalculation/LowerRightCalculation.swift b/Rectangle/WindowCalculation/LowerRightCalculation.swift index a82a79d5..a95fc035 100644 --- a/Rectangle/WindowCalculation/LowerRightCalculation.swift +++ b/Rectangle/WindowCalculation/LowerRightCalculation.swift @@ -6,6 +6,8 @@ class LowerRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, let horizontalSide: HalfSplitSide = .trailing let verticalSide: HalfSplitSide = .trailing + var horizontalSplitFraction: Float { 1.0 - Defaults.horizontalSplitRatio.value / 100.0 } + var verticalSplitFraction: Float { 1.0 - Defaults.verticalSplitRatio.value / 100.0 } override func calculateRect(_ params: RectCalculationParameters) -> RectResult { @@ -17,7 +19,7 @@ class LowerRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculation(params.visibleFrameOfScreen) } } - return quarterRect(params) + return quarterRect(params.visibleFrameOfScreen) } if params.lastAction == nil || !Defaults.subsequentExecutionMode.resizes { @@ -27,17 +29,18 @@ class LowerRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculateRepeatedRect(params) } - func quarterRect(_ params: RectCalculationParameters) -> RectResult { - return RectResult(cornerRect(params, - horizontalFraction: horizontalSplitFraction(params), - verticalFraction: verticalSplitFraction(params)), + func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { + return RectResult(cornerRect(visibleFrameOfScreen, + horizontalFraction: horizontalSplitFraction, + verticalFraction: verticalSplitFraction), subAction: .bottomRightQuarter) } - func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { - quarterRect(RectCalculationParameters(window: Window(id: 0, rect: visibleFrameOfScreen), - visibleFrameOfScreen: visibleFrameOfScreen, - action: .bottomRight, - lastAction: nil)) + private func cornerRect(_ visibleFrameOfScreen: CGRect, horizontalFraction: Float, verticalFraction: Float) -> CGRect { + HalfSplitFrameCalculation.cornerRect(in: visibleFrameOfScreen, + horizontalSide: horizontalSide, + verticalSide: verticalSide, + horizontalFraction: horizontalFraction, + verticalFraction: verticalFraction) } } diff --git a/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift b/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift index e9ca8eba..d768717c 100644 --- a/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift +++ b/Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift @@ -51,14 +51,16 @@ extension RepeatedExecutionsCalculation { protocol CornerCycleExpansionCalculation: RepeatedExecutionsCalculation { var horizontalSide: HalfSplitSide { get } var verticalSide: HalfSplitSide { get } + var horizontalSplitFraction: Float { get } + var verticalSplitFraction: Float { get } } extension CornerCycleExpansionCalculation { func calculateFirstRect(_ params: RectCalculationParameters) -> RectResult { - RectResult(cornerRect(params, - horizontalFraction: horizontalSplitFraction(params), - verticalFraction: verticalSplitFraction(params))) + RectResult(cornerRect(params.visibleFrameOfScreen, + horizontalFraction: horizontalSplitFraction, + verticalFraction: verticalSplitFraction)) } func calculateRect(for cycleDivision: CycleSize, params: RectCalculationParameters) -> RectResult { @@ -98,16 +100,16 @@ extension CornerCycleExpansionCalculation { switch Defaults.cornerCycleExpansionAxis.value { case .horizontal: - let cycledRect = cornerRect(params, + let cycledRect = cornerRect(params.visibleFrameOfScreen, horizontalFraction: fraction, - verticalFraction: verticalSplitFraction(params)) + verticalFraction: verticalSplitFraction) return RectResult(CGRect(x: cycledRect.origin.x, y: normalRect.origin.y, width: cycledRect.width, height: normalRect.height)) case .vertical: - let cycledRect = cornerRect(params, - horizontalFraction: horizontalSplitFraction(params), + let cycledRect = cornerRect(params.visibleFrameOfScreen, + horizontalFraction: horizontalSplitFraction, verticalFraction: fraction) return RectResult(CGRect(x: normalRect.origin.x, y: cycledRect.origin.y, @@ -116,18 +118,8 @@ extension CornerCycleExpansionCalculation { } } - func horizontalSplitFraction(_ params: RectCalculationParameters) -> Float { - let ratio = ActiveSideSplitRatios.shared.horizontalRatio(for: params.visibleFrameOfScreen) - return horizontalSide == .trailing ? 1.0 - ratio : ratio - } - - func verticalSplitFraction(_ params: RectCalculationParameters) -> Float { - let ratio = ActiveSideSplitRatios.shared.verticalRatio(for: params.visibleFrameOfScreen) - return verticalSide == .trailing ? 1.0 - ratio : ratio - } - - func cornerRect(_ params: RectCalculationParameters, horizontalFraction: Float, verticalFraction: Float) -> CGRect { - HalfSplitFrameCalculation.cornerRect(in: params.visibleFrameOfScreen, + private func cornerRect(_ visibleFrameOfScreen: CGRect, horizontalFraction: Float, verticalFraction: Float) -> CGRect { + HalfSplitFrameCalculation.cornerRect(in: visibleFrameOfScreen, horizontalSide: horizontalSide, verticalSide: verticalSide, horizontalFraction: horizontalFraction, diff --git a/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift b/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift index d14594e9..6a677bcb 100644 --- a/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift +++ b/Rectangle/WindowCalculation/RepeatedExecutionsInThirdsCalculation.swift @@ -44,31 +44,5 @@ extension RepeatedExecutionsInThirdsCalculation { return calculateRect(for: sortedPositions[cycleIndex(forExecutionCount: count, in: sortedPositions)], params: params) } - - func calculateRepeatedSideRect(_ 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) - } - - guard let count = params.lastAction?.count else { - return calculateFirstRect(params) - } - - var position = cycleIndex(forExecutionCount: count, in: sortedPositions) - for _ in sortedPositions.indices { - let result = calculateRect(for: sortedPositions[position], params: params) - if !result.rect.equalTo(params.window.rect) { - return result - } - position = (position + 1) % sortedPositions.count - } - - return calculateFirstRect(params) - } } diff --git a/Rectangle/WindowCalculation/TopHalfCalculation.swift b/Rectangle/WindowCalculation/TopHalfCalculation.swift index adc15934..a121938a 100644 --- a/Rectangle/WindowCalculation/TopHalfCalculation.swift +++ b/Rectangle/WindowCalculation/TopHalfCalculation.swift @@ -10,21 +10,15 @@ class TopHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCalculati return calculateFirstRect(params) } - return calculateRepeatedSideRect(params) + return calculateRepeatedRect(params) } func calculateFirstRect(_ params: RectCalculationParameters) -> RectResult { - return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, - side: .leading, - fraction: ActiveSideSplitRatios.shared.verticalRatio(for: params.visibleFrameOfScreen))) + return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, side: .leading, fraction: Defaults.verticalSplitRatio.value / 100.0)) } func calculateFractionalRect(_ params: RectCalculationParameters, fraction: Float) -> RectResult { return RectResult(HalfSplitFrameCalculation.verticalRect(in: params.visibleFrameOfScreen, side: .leading, fraction: fraction)) } - - func calculateRepeatedRect(_ params: RectCalculationParameters) -> RectResult { - calculateRepeatedSideRect(params) - } } diff --git a/Rectangle/WindowCalculation/UpperLeftCalculation.swift b/Rectangle/WindowCalculation/UpperLeftCalculation.swift index 63c1ee3d..d6208f39 100644 --- a/Rectangle/WindowCalculation/UpperLeftCalculation.swift +++ b/Rectangle/WindowCalculation/UpperLeftCalculation.swift @@ -6,6 +6,8 @@ class UpperLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, let horizontalSide: HalfSplitSide = .leading let verticalSide: HalfSplitSide = .leading + var horizontalSplitFraction: Float { Defaults.horizontalSplitRatio.value / 100.0 } + var verticalSplitFraction: Float { Defaults.verticalSplitRatio.value / 100.0 } override func calculateRect(_ params: RectCalculationParameters) -> RectResult { @@ -17,7 +19,7 @@ class UpperLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculation(params.visibleFrameOfScreen) } } - return quarterRect(params) + return quarterRect(params.visibleFrameOfScreen) } if params.lastAction == nil || !Defaults.subsequentExecutionMode.resizes { @@ -27,17 +29,18 @@ class UpperLeftCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculateRepeatedRect(params) } - func quarterRect(_ params: RectCalculationParameters) -> RectResult { - return RectResult(cornerRect(params, - horizontalFraction: horizontalSplitFraction(params), - verticalFraction: verticalSplitFraction(params)), + func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { + return RectResult(cornerRect(visibleFrameOfScreen, + horizontalFraction: horizontalSplitFraction, + verticalFraction: verticalSplitFraction), subAction: .topLeftQuarter) } - func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { - quarterRect(RectCalculationParameters(window: Window(id: 0, rect: visibleFrameOfScreen), - visibleFrameOfScreen: visibleFrameOfScreen, - action: .topLeft, - lastAction: nil)) + private func cornerRect(_ visibleFrameOfScreen: CGRect, horizontalFraction: Float, verticalFraction: Float) -> CGRect { + HalfSplitFrameCalculation.cornerRect(in: visibleFrameOfScreen, + horizontalSide: horizontalSide, + verticalSide: verticalSide, + horizontalFraction: horizontalFraction, + verticalFraction: verticalFraction) } } diff --git a/Rectangle/WindowCalculation/UpperRightCalculation.swift b/Rectangle/WindowCalculation/UpperRightCalculation.swift index 1e2ad6d7..3b5be43d 100644 --- a/Rectangle/WindowCalculation/UpperRightCalculation.swift +++ b/Rectangle/WindowCalculation/UpperRightCalculation.swift @@ -6,6 +6,8 @@ class UpperRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, let horizontalSide: HalfSplitSide = .trailing let verticalSide: HalfSplitSide = .leading + var horizontalSplitFraction: Float { 1.0 - Defaults.horizontalSplitRatio.value / 100.0 } + var verticalSplitFraction: Float { Defaults.verticalSplitRatio.value / 100.0 } override func calculateRect(_ params: RectCalculationParameters) -> RectResult { @@ -17,7 +19,7 @@ class UpperRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculation(params.visibleFrameOfScreen) } } - return quarterRect(params) + return quarterRect(params.visibleFrameOfScreen) } if params.lastAction == nil || !Defaults.subsequentExecutionMode.resizes { @@ -27,17 +29,18 @@ class UpperRightCalculation: WindowCalculation, CornerCycleExpansionCalculation, return calculateRepeatedRect(params) } - func quarterRect(_ params: RectCalculationParameters) -> RectResult { - return RectResult(cornerRect(params, - horizontalFraction: horizontalSplitFraction(params), - verticalFraction: verticalSplitFraction(params)), + func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { + return RectResult(cornerRect(visibleFrameOfScreen, + horizontalFraction: horizontalSplitFraction, + verticalFraction: verticalSplitFraction), subAction: .topRightQuarter) } - func quarterRect(_ visibleFrameOfScreen: CGRect) -> RectResult { - quarterRect(RectCalculationParameters(window: Window(id: 0, rect: visibleFrameOfScreen), - visibleFrameOfScreen: visibleFrameOfScreen, - action: .topRight, - lastAction: nil)) + private func cornerRect(_ visibleFrameOfScreen: CGRect, horizontalFraction: Float, verticalFraction: Float) -> CGRect { + HalfSplitFrameCalculation.cornerRect(in: visibleFrameOfScreen, + horizontalSide: horizontalSide, + verticalSide: verticalSide, + horizontalFraction: horizontalFraction, + verticalFraction: verticalFraction) } } diff --git a/Rectangle/WindowManager+CooperativeCornerResize.swift b/Rectangle/WindowManager+CooperativeCornerResize.swift index bbfaae4e..a79058a5 100644 --- a/Rectangle/WindowManager+CooperativeCornerResize.swift +++ b/Rectangle/WindowManager+CooperativeCornerResize.swift @@ -455,8 +455,8 @@ extension WindowManager { } private func configuredCornerFrame(action: WindowAction, screenFrame: CGRect) -> CGRect? { - let horizontalRatio = ActiveSideSplitRatios.shared.horizontalRatio(for: screenFrame) - let verticalRatio = ActiveSideSplitRatios.shared.verticalRatio(for: screenFrame) + let horizontalRatio = Defaults.horizontalSplitRatio.value / 100.0 + let verticalRatio = Defaults.verticalSplitRatio.value / 100.0 let horizontalSide: HalfSplitSide let verticalSide: HalfSplitSide let horizontalFraction: Float diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index 584dd013..6eccfa17 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -139,10 +139,6 @@ class WindowManager { calcResult.rect = cooperativeCornerPlan.focusedFrame } - ActiveSideSplitRatios.shared.recordSideAction(calcResult.resultingAction, - targetFrame: calcResult.initialRect, - screenFrame: visibleFrameOfDestinationScreen) - if let cooperativeCornerPlan { if !cooperativeCornerPlan.needsApplication(focusedCurrentFrame: currentNormalizedRect) { Logger.log("Cooperative resize no-op: solved frames already match current frames") diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 83e0d4f8..806ff200 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -1238,7 +1238,6 @@ class HalfSplitCornerCalculationTests: XCTestCase { Defaults.cycleSizesIsChanged.enabled = false Defaults.gapSize.value = 0 Defaults.skipGapTopEdge.enabled = false - ActiveSideSplitRatios.shared.resetAll() } override func tearDown() { @@ -1250,7 +1249,6 @@ class HalfSplitCornerCalculationTests: XCTestCase { Defaults.selectedCycleSizes.value = savedSelectedCycleSizes Defaults.gapSize.value = savedGapSize Defaults.skipGapTopEdge.enabled = savedSkipGapTopEdge - ActiveSideSplitRatios.shared.resetAll() super.tearDown() } @@ -1482,83 +1480,6 @@ class HalfSplitCornerCalculationTests: XCTestCase { assertRect(repeatedTopFrame, equals: CGRect(x: 10, y: 620, width: 1200, height: 300)) } - func testRepeatedRightShortcutUpdatesActiveSplitForSubsequentCorners() { - Defaults.horizontalSplitRatio.value = CycleSize.twoThirds.percentValue - Defaults.verticalSplitRatio.value = 50 - ActiveSideSplitRatios.shared.resetAll() - - let firstRightFrame = WindowCalculationFactory.rightHalfCalculation.calculateRect(params(for: .rightHalf)).rect - let repeatedRightFrame = WindowCalculationFactory.rightHalfCalculation.calculateRepeatedRect(repeatedParams(for: .rightHalf, - currentRect: firstRightFrame)).rect - - ActiveSideSplitRatios.shared.recordSideAction(.rightHalf, - targetFrame: repeatedRightFrame, - screenFrame: visibleFrame) - - let topRightFrame = WindowCalculationFactory.upperRightCalculation.calculateRect(params(for: .topRight)).rect - let topLeftFrame = WindowCalculationFactory.upperLeftCalculation.calculateRect(params(for: .topLeft)).rect - - assertRect(firstRightFrame, equals: CGRect(x: 810, y: 20, width: 400, height: 900)) - assertRect(repeatedRightFrame, equals: CGRect(x: 410, y: 20, width: 800, height: 900)) - assertRect(topRightFrame, equals: CGRect(x: 410, y: 470, width: 800, height: 450)) - assertRect(topLeftFrame, equals: CGRect(x: 10, y: 470, width: 400, height: 450)) - XCTAssertEqual(Defaults.horizontalSplitRatio.value, CycleSize.twoThirds.percentValue, accuracy: 0.001) - } - - func testRepeatedBottomShortcutUpdatesActiveSplitForSubsequentCorners() { - Defaults.horizontalSplitRatio.value = 50 - Defaults.verticalSplitRatio.value = CycleSize.twoThirds.percentValue - ActiveSideSplitRatios.shared.resetAll() - - let firstBottomFrame = WindowCalculationFactory.bottomHalfCalculation.calculateRect(params(for: .bottomHalf)).rect - let repeatedBottomFrame = WindowCalculationFactory.bottomHalfCalculation.calculateRepeatedRect(repeatedParams(for: .bottomHalf, - currentRect: firstBottomFrame)).rect - - ActiveSideSplitRatios.shared.recordSideAction(.bottomHalf, - targetFrame: repeatedBottomFrame, - screenFrame: visibleFrame) - - let bottomRightFrame = WindowCalculationFactory.lowerRightCalculation.calculateRect(params(for: .bottomRight)).rect - let topRightFrame = WindowCalculationFactory.upperRightCalculation.calculateRect(params(for: .topRight)).rect - - assertRect(firstBottomFrame, equals: CGRect(x: 10, y: 20, width: 1200, height: 300)) - assertRect(repeatedBottomFrame, equals: CGRect(x: 10, y: 20, width: 1200, height: 600)) - assertRect(bottomRightFrame, equals: CGRect(x: 610, y: 20, width: 600, height: 600)) - assertRect(topRightFrame, equals: CGRect(x: 610, y: 620, width: 600, height: 300)) - XCTAssertEqual(Defaults.verticalSplitRatio.value, CycleSize.twoThirds.percentValue, accuracy: 0.001) - } - - func testActiveSideSplitIsScopedToDisplayFrame() { - Defaults.horizontalSplitRatio.value = CycleSize.twoThirds.percentValue - ActiveSideSplitRatios.shared.resetAll() - - let cycledRightFrame = CGRect(x: 410, y: 20, width: 800, height: 900) - let otherDisplayFrame = CGRect(x: 2000, y: 20, width: 1200, height: 900) - - ActiveSideSplitRatios.shared.recordSideAction(.rightHalf, - targetFrame: cycledRightFrame, - screenFrame: visibleFrame) - - XCTAssertEqual(ActiveSideSplitRatios.shared.horizontalRatio(for: visibleFrame), - CycleSize.oneThird.fraction, - accuracy: 0.001) - XCTAssertEqual(ActiveSideSplitRatios.shared.horizontalRatio(for: otherDisplayFrame), - CycleSize.twoThirds.fraction, - accuracy: 0.001) - } - - func testChangingSavedSplitRatioResetsActiveRuntimeSplit() { - Defaults.horizontalSplitRatio.value = CycleSize.twoThirds.percentValue - ActiveSideSplitRatios.shared.resetAll() - ActiveSideSplitRatios.shared.recordSideAction(.rightHalf, - targetFrame: CGRect(x: 410, y: 20, width: 800, height: 900), - screenFrame: visibleFrame) - - Defaults.horizontalSplitRatio.value = 50 - - XCTAssertEqual(ActiveSideSplitRatios.shared.horizontalRatio(for: visibleFrame), 0.5, accuracy: 0.001) - } - func testHorizontalCornerShortcutCanCycleAfterCompatibleSideShortcut() { setSplitRatio(50) Defaults.cornerCycleExpansionAxis.value = .horizontal From 5a79c5476b6a749badded1d4cb1375e4dcb1c80c Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 2 Jul 2026 05:59:28 -0700 Subject: [PATCH 17/17] Fix cleanup resize for focused adjacent corner --- ...indowManager+CooperativeCornerResize.swift | 71 +++++++++++++++++-- Rectangle/WindowManager.swift | 1 + RectangleTests/RectangleTests.swift | 53 ++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/Rectangle/WindowManager+CooperativeCornerResize.swift b/Rectangle/WindowManager+CooperativeCornerResize.swift index a79058a5..86727b17 100644 --- a/Rectangle/WindowManager+CooperativeCornerResize.swift +++ b/Rectangle/WindowManager+CooperativeCornerResize.swift @@ -202,9 +202,8 @@ extension WindowManager { let captureTolerance = CooperativeCornerResize.captureTolerance(screenFrame: screenFrame, axis: cooperativeAxis) let layoutTolerance: CGFloat = 4 let screenFrameAX = screenFrame.screenFlipped - let elementsById = AccessibilityElement.getAllWindowElements().reduce(into: [CGWindowID: AccessibilityElement]()) { elements, element in + let allElementsById = 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, @@ -222,6 +221,7 @@ extension WindowManager { elements[candidateId] = element } + var elementsById = allElementsById.filter { $0.key != focusedWindowId } let candidates = elementsById.map { id, element in CooperativeCornerResize.Candidate(id: id, @@ -250,10 +250,25 @@ extension WindowManager { 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: candidates, + candidates: cleanupCandidates, axis: cooperativeAxis, tolerance: tolerance, minimumSize: minimumSize, @@ -293,7 +308,7 @@ extension WindowManager { requestedFocusedFrame: targetFrame, plannedPlan: cleanupPlan, screenFrame: screenFrame, - candidates: candidates, + candidates: cleanupCandidates, actualFocusedFrame: cleanupPlan.focusedFrame, actualCandidateFramesById: actualCandidateFramesById, axis: cooperativeAxis, @@ -421,6 +436,54 @@ extension WindowManager { 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, diff --git a/Rectangle/WindowManager.swift b/Rectangle/WindowManager.swift index 6eccfa17..c4e87458 100644 --- a/Rectangle/WindowManager.swift +++ b/Rectangle/WindowManager.swift @@ -198,6 +198,7 @@ class WindowManager { screenFrame: usableScreens.currentScreen.adjustedVisibleFrame(ignoreTodo), currentAction: action, lastRectangleAction: lastRectangleAction) + resultingRect = frontmostWindowElement.frame } postProcess(result: resultParameters, resultingRect: resultingRect) diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 806ff200..65630f8c 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -537,6 +537,33 @@ class CooperativeCornerResizeTests: XCTestCase { 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) @@ -1404,6 +1431,32 @@ class HalfSplitCornerCalculationTests: XCTestCase { 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