From 3bf4a97befbf20345b0a39f66fb9195a6f602bdb Mon Sep 17 00:00:00 2001 From: AkiyamaKunka Date: Sat, 17 Oct 2026 12:59:11 -0500 Subject: [PATCH] Add a repeated-commands option that switches to the opposite side Repeating Left Half while the window still sits on the left half now moves it to the right half, and the next repeat brings it back; the same for Right, Top and Bottom. The new "Switch to the opposite side on half actions" entry in the Repeated commands setting (subsequentExecutionMode 6) is off by default, so existing behaviour is unchanged. The toggle works through the action history: the calculation reports the opposite half as its resulting action, so the next press of the same shortcut is no longer a repeat and falls back to the plain half. A first execution, a window that moved since the last action, or an unrelated last action get a plain half. Thirds, fourths and the grid actions keep their existing repeat behaviour in this mode, and the option neither resizes nor traverses displays. Leaves WindowManager.swift and the execute path untouched: the logic lives in the half calculations, with a shared isRepeatedCommand overload for RectCalculationParameters. The only change on the WindowManager side is a guard in cooperativeCornerResizePlan so that, in this mode, a repeated half action that moved both of its edges (a side switch) is treated as a move rather than as a resize of the shared edge, which would otherwise squeeze the neighbour and record a wrong split ratio. Documents the value in TerminalCommands.md (and the undocumented value 5) and adds the zh-Hans string. Tests cover both axes, the complementary split ratio at 60%, the interplay with "halves preserve the other axis", the cooperative-resize guard, and that no other mode switches sides. Closes #1761 Co-Authored-By: Claude Fable 5.1 --- Rectangle/CooperativeCornerResize.swift | 16 ++ Rectangle/Localizable.xcstrings | 10 + Rectangle/SubsequentExecutionMode.swift | 4 +- .../BottomHalfCalculation.swift | 9 + .../LeftRightHalfCalculation.swift | 14 +- .../TopHalfCalculation.swift | 9 + .../WindowCalculation/WindowCalculation.swift | 17 +- ...indowManager+CooperativeCornerResize.swift | 10 + RectangleTests/RectangleTests.swift | 239 ++++++++++++++++++ TerminalCommands.md | 2 + 10 files changed, 323 insertions(+), 7 deletions(-) diff --git a/Rectangle/CooperativeCornerResize.swift b/Rectangle/CooperativeCornerResize.swift index 68604935..92ced521 100644 --- a/Rectangle/CooperativeCornerResize.swift +++ b/Rectangle/CooperativeCornerResize.swift @@ -314,6 +314,22 @@ struct CooperativeCornerResize { } } + /// Both edges along the axis moved: the window jumped to the other side (the `switchSides` + /// repeated-command mode) rather than resizing against a shared edge, so no neighbor gives way. + static func isSideSwitch(oldFocusedFrame: CGRect, + newFocusedFrame: CGRect, + axis: CornerCycleExpansionAxis, + tolerance: CGFloat) -> Bool { + switch axis { + case .horizontal: + return abs(oldFocusedFrame.minX - newFocusedFrame.minX) > tolerance + && abs(oldFocusedFrame.maxX - newFocusedFrame.maxX) > tolerance + case .vertical: + return abs(oldFocusedFrame.minY - newFocusedFrame.minY) > tolerance + && abs(oldFocusedFrame.maxY - newFocusedFrame.maxY) > tolerance + } + } + static func movedEdge(from oldFocusedFrame: CGRect, to newFocusedFrame: CGRect, axis: CornerCycleExpansionAxis, diff --git a/Rectangle/Localizable.xcstrings b/Rectangle/Localizable.xcstrings index 906ed3ac..0f390569 100644 --- a/Rectangle/Localizable.xcstrings +++ b/Rectangle/Localizable.xcstrings @@ -30265,6 +30265,16 @@ } } }, + "Switch to the opposite side on half actions" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "半屏操作切换到对侧" + } + } + } + }, "Third Fourth" : { "localizations" : { "ar" : { diff --git a/Rectangle/SubsequentExecutionMode.swift b/Rectangle/SubsequentExecutionMode.swift index c4eccdba..219290dc 100644 --- a/Rectangle/SubsequentExecutionMode.swift +++ b/Rectangle/SubsequentExecutionMode.swift @@ -9,6 +9,7 @@ enum SubsequentExecutionMode: Int { case acrossAndResize = 3 // across monitor for right/left, spectacle resize for all else case cycleMonitor = 4 case resizeAndCycleQuadrants = 5 + case switchSides = 6 // repeated half actions move to the opposite side var title: String { switch self { @@ -18,6 +19,7 @@ enum SubsequentExecutionMode: Int { case .acrossAndResize: return String(localized: "move to adjacent on left/right, or cycle size on side") case .cycleMonitor: return String(localized: "cycle through displays") case .resizeAndCycleQuadrants: return String(localized: "cycle positions on quadrants, cycle sizes on side") + case .switchSides: return String(localized: "Switch to the opposite side on half actions") } } @@ -28,7 +30,7 @@ enum SubsequentExecutionMode: Int { } } - static var ordered: [SubsequentExecutionMode] = [.none, .cycleMonitor, .resize, .acrossMonitor, .acrossAndResize, .resizeAndCycleQuadrants] + static var ordered: [SubsequentExecutionMode] = [.none, .cycleMonitor, .resize, .acrossMonitor, .acrossAndResize, .resizeAndCycleQuadrants, .switchSides] } class SubsequentExecutionDefault: Default { diff --git a/Rectangle/WindowCalculation/BottomHalfCalculation.swift b/Rectangle/WindowCalculation/BottomHalfCalculation.swift index 816acfa9..8a7b32b7 100644 --- a/Rectangle/WindowCalculation/BottomHalfCalculation.swift +++ b/Rectangle/WindowCalculation/BottomHalfCalculation.swift @@ -10,6 +10,15 @@ class BottomHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCalcul return tiledRect } + if Defaults.subsequentExecutionMode.value == .switchSides { + // Repeating Bottom Half while the window still sits on the bottom half moves it to the top + // half; see LeftRightHalfCalculation.calculateSwitchSides. + guard isRepeatedCommand(params) else { return calculateFirstRect(params) } + let opposite = RectCalculationParameters(window: params.window, visibleFrameOfScreen: params.visibleFrameOfScreen, + action: .topHalf, lastAction: params.lastAction) + return RectResult(WindowCalculationFactory.topHalfCalculation.calculateFirstRect(opposite).rect, resultingAction: .topHalf) + } + if params.lastAction == nil || !Defaults.subsequentExecutionMode.resizes { return calculateFirstRect(params) } diff --git a/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift b/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift index f53048ef..679ea84b 100644 --- a/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift +++ b/Rectangle/WindowCalculation/LeftRightHalfCalculation.swift @@ -26,7 +26,9 @@ class LeftRightHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCal return calculateAcrossDisplays(params) case .resize, .resizeAndCycleQuadrants: return calculateResize(params) - case .none, .cycleMonitor: + case .switchSides where isRepeatedCommand(params): + return calculateSwitchSides(params) + case .none, .cycleMonitor, .switchSides: let screen = usableScreens.currentScreen let oneHalfRect = calculateFirstRect(params.asRectParams()) return WindowCalculationResult(rect: oneHalfRect.rect, screen: screen, resultingAction: params.action) @@ -56,6 +58,16 @@ class LeftRightHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCal return WindowCalculationResult(rect: rectResult.rect, screen: screen, resultingAction: params.action) } + /// Repeating a half action while the window still sits on that half moves it to the opposite + /// half (Left Half → Right Half → Left Half). Reporting the opposite half as the resulting action + /// is what makes the next repeat flip back; a first execution, or a window that has moved since, + /// gets a plain half above. + func calculateSwitchSides(_ params: WindowCalculationParameters) -> WindowCalculationResult? { + let opposite: WindowAction = params.action == .rightHalf ? .leftHalf : .rightHalf + let oppositeRect = calculateFirstRect(params.asRectParams(differentAction: opposite)) + return WindowCalculationResult(rect: oppositeRect.rect, screen: params.usableScreens.currentScreen, resultingAction: opposite) + } + func calculateAcrossDisplays(_ params: WindowCalculationParameters) -> WindowCalculationResult? { let screen = params.usableScreens.currentScreen return params.action == .rightHalf diff --git a/Rectangle/WindowCalculation/TopHalfCalculation.swift b/Rectangle/WindowCalculation/TopHalfCalculation.swift index 99fbb973..0516eaa8 100644 --- a/Rectangle/WindowCalculation/TopHalfCalculation.swift +++ b/Rectangle/WindowCalculation/TopHalfCalculation.swift @@ -10,6 +10,15 @@ class TopHalfCalculation: WindowCalculation, RepeatedExecutionsInThirdsCalculati return tiledRect } + if Defaults.subsequentExecutionMode.value == .switchSides { + // Repeating Top Half while the window still sits on the top half moves it to the bottom + // half; see LeftRightHalfCalculation.calculateSwitchSides. + guard isRepeatedCommand(params) else { return calculateFirstRect(params) } + let opposite = RectCalculationParameters(window: params.window, visibleFrameOfScreen: params.visibleFrameOfScreen, + action: .bottomHalf, lastAction: params.lastAction) + return RectResult(WindowCalculationFactory.bottomHalfCalculation.calculateFirstRect(opposite).rect, resultingAction: .bottomHalf) + } + if params.lastAction == nil || !Defaults.subsequentExecutionMode.resizes { return calculateFirstRect(params) } diff --git a/Rectangle/WindowCalculation/WindowCalculation.swift b/Rectangle/WindowCalculation/WindowCalculation.swift index 09af29f4..328308d9 100644 --- a/Rectangle/WindowCalculation/WindowCalculation.swift +++ b/Rectangle/WindowCalculation/WindowCalculation.swift @@ -37,11 +37,18 @@ class WindowCalculation: Calculation { } func isRepeatedCommand(_ params: WindowCalculationParameters) -> Bool { - if let lastAction = params.lastAction, lastAction.action == params.action { - let normalizedLastRect = lastAction.rect.screenFlipped - return normalizedLastRect == params.window.rect - } - return false + isRepeatedCommand(lastAction: params.lastAction, action: params.action, windowRect: params.window.rect) + } + + func isRepeatedCommand(_ params: RectCalculationParameters) -> Bool { + isRepeatedCommand(lastAction: params.lastAction, action: params.action, windowRect: params.window.rect) + } + + /// The same action was just executed on this window and the window has not moved since. + private func isRepeatedCommand(lastAction: RectangleAction?, action: WindowAction, windowRect: CGRect) -> Bool { + guard let lastAction, lastAction.action == action else { return false } + let normalizedLastRect = lastAction.rect.screenFlipped + return normalizedLastRect == windowRect } } diff --git a/Rectangle/WindowManager+CooperativeCornerResize.swift b/Rectangle/WindowManager+CooperativeCornerResize.swift index 66abc1df..c3bd6976 100644 --- a/Rectangle/WindowManager+CooperativeCornerResize.swift +++ b/Rectangle/WindowManager+CooperativeCornerResize.swift @@ -107,6 +107,16 @@ extension WindowManager { let tolerance = CooperativeCornerResize.detectionTolerance(screenFrame: screenFrame, configuredGap: gapSize) let captureTolerance = CooperativeCornerResize.captureTolerance(screenFrame: screenFrame, axis: cooperativeAxis) let isRepeatedCooperativeAction = action.isCompatibleRepeatedResizeAction(with: lastRectangleAction?.action) + if isRepeatedCooperativeAction, !action.isCooperativeCornerAction, + Defaults.subsequentExecutionMode.value == .switchSides, + CooperativeCornerResize.isSideSwitch(oldFocusedFrame: oldFocusedFrame, newFocusedFrame: newFocusedFrame, + axis: cooperativeAxis, tolerance: tolerance) { + // A repeated half action that switched sides is a move, not a resize of the shared edge: + // planned as one, the action's moved edge would be pushed across the screen, squeezing the + // neighbor and recording a wrong split ratio. Corners cycle quadrant positions through their + // own cooperative path and keep it. + return nil + } 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 diff --git a/RectangleTests/RectangleTests.swift b/RectangleTests/RectangleTests.swift index 28684b88..74facf7c 100644 --- a/RectangleTests/RectangleTests.swift +++ b/RectangleTests/RectangleTests.swift @@ -9086,3 +9086,242 @@ final class FootprintGeometrySynchronizationTests: XCTestCase { } +final class SwitchSidesRepeatedCommandTests: XCTestCase { + private let windowId: CGWindowID = 4242 + private let screen = RepeatedMaximizeTestScreen(frame: CGRect(x: 0, y: 0, width: 1000, height: 600)) + private let floating = CGRect(x: 100, y: 100, width: 400, height: 300) + private var savedMode: SubsequentExecutionMode = .resize + private var savedPreserveOtherAxis = false + private var savedHorizontalSplitRatio: Float = 50 + private var savedVerticalSplitRatio: Float = 50 + private var savedGapSize: Float = 0 + + override func setUp() { + super.setUp() + savedMode = Defaults.subsequentExecutionMode.value + savedPreserveOtherAxis = Defaults.halvesPreserveOtherAxisSize.enabled + savedHorizontalSplitRatio = Defaults.horizontalSplitRatio.value + savedVerticalSplitRatio = Defaults.verticalSplitRatio.value + savedGapSize = Defaults.gapSize.value + Defaults.subsequentExecutionMode.value = .switchSides + Defaults.halvesPreserveOtherAxisSize.enabled = false + Defaults.horizontalSplitRatio.value = 50 + Defaults.verticalSplitRatio.value = 50 + Defaults.gapSize.value = 0 + ActiveSideSplitRatios.shared.resetAll() + } + + override func tearDown() { + Defaults.subsequentExecutionMode.value = savedMode + Defaults.halvesPreserveOtherAxisSize.enabled = savedPreserveOtherAxis + Defaults.horizontalSplitRatio.value = savedHorizontalSplitRatio + Defaults.verticalSplitRatio.value = savedVerticalSplitRatio + Defaults.gapSize.value = savedGapSize + ActiveSideSplitRatios.shared.resetAll() + super.tearDown() + } + + // MARK: - Left and right halves + + func testRepeatedLeftHalfSwitchesToTheRightHalfAndBack() throws { + let first = try XCTUnwrap(calculate(.leftHalf, window: floating, last: nil)) + XCTAssertEqual(first.resultingAction, .leftHalf) + assertRect(first.rect, plainHalf(.leftHalf)) + + let second = try XCTUnwrap(calculate(.leftHalf, window: first.rect, last: (first.resultingAction, first.rect))) + XCTAssertEqual(second.resultingAction, .rightHalf) + assertRect(second.rect, plainHalf(.rightHalf)) + + let third = try XCTUnwrap(calculate(.leftHalf, window: second.rect, last: (second.resultingAction, second.rect))) + XCTAssertEqual(third.resultingAction, .leftHalf) + assertRect(third.rect, plainHalf(.leftHalf)) + } + + func testRepeatedRightHalfSwitchesToTheLeftHalf() throws { + let first = try XCTUnwrap(calculate(.rightHalf, window: floating, last: nil)) + XCTAssertEqual(first.resultingAction, .rightHalf) + + let second = try XCTUnwrap(calculate(.rightHalf, window: first.rect, last: (first.resultingAction, first.rect))) + XCTAssertEqual(second.resultingAction, .leftHalf) + assertRect(second.rect, plainHalf(.leftHalf)) + } + + func testPressingTheOtherSideMovesThereWithoutSwitching() throws { + // Left Half, then Right Half: the window simply goes right; switching only happens on a repeat. + let left = try XCTUnwrap(calculate(.leftHalf, window: floating, last: nil)) + let right = try XCTUnwrap(calculate(.rightHalf, window: left.rect, last: (left.resultingAction, left.rect))) + XCTAssertEqual(right.resultingAction, .rightHalf) + assertRect(right.rect, plainHalf(.rightHalf)) + } + + func testAWindowThatMovedSinceTheLastActionGetsAPlainHalf() throws { + let first = try XCTUnwrap(calculate(.leftHalf, window: floating, last: nil)) + let moved = first.rect.offsetBy(dx: 0, dy: 10) + let result = try XCTUnwrap(calculate(.leftHalf, window: moved, last: (.leftHalf, first.rect))) + XCTAssertEqual(result.resultingAction, .leftHalf) + assertRect(result.rect, plainHalf(.leftHalf)) + } + + func testAnUnrelatedLastActionGetsAPlainHalf() throws { + let maximized = screen.frame + let result = try XCTUnwrap(calculate(.leftHalf, window: maximized, last: (.maximize, maximized))) + XCTAssertEqual(result.resultingAction, .leftHalf) + assertRect(result.rect, plainHalf(.leftHalf)) + } + + func testTheOppositeSideUsesTheComplementarySplitRatio() throws { + Defaults.horizontalSplitRatio.value = 60 + Defaults.verticalSplitRatio.value = 60 + ActiveSideSplitRatios.shared.resetAll() + let visible = screen.adjustedVisibleFrame() + + let left = try XCTUnwrap(calculate(.leftHalf, window: floating, last: nil)) + XCTAssertEqual(left.rect.width, floor(visible.width * 0.6), accuracy: 1) + let right = try XCTUnwrap(calculate(.leftHalf, window: left.rect, last: (left.resultingAction, left.rect))) + XCTAssertEqual(right.rect.width, floor(visible.width * 0.4), accuracy: 1) + XCTAssertEqual(right.rect.maxX, visible.maxX, accuracy: 0.5) + let back = try XCTUnwrap(calculate(.leftHalf, window: right.rect, last: (right.resultingAction, right.rect))) + XCTAssertEqual(back.rect.width, floor(visible.width * 0.6), accuracy: 1) + XCTAssertEqual(back.rect.minX, visible.minX, accuracy: 0.5) + + let top = try XCTUnwrap(calculate(.topHalf, window: floating, last: nil)) + XCTAssertEqual(top.rect.height, floor(visible.height * 0.6), accuracy: 1) + let bottom = try XCTUnwrap(calculate(.topHalf, window: top.rect, last: (top.resultingAction, top.rect))) + XCTAssertEqual(bottom.rect.height, floor(visible.height * 0.4), accuracy: 1) + XCTAssertEqual(bottom.rect.minY, visible.minY, accuracy: 0.5) + } + + // MARK: - Top and bottom halves + + func testRepeatedTopHalfSwitchesToTheBottomHalfAndBack() throws { + let first = try XCTUnwrap(calculate(.topHalf, window: floating, last: nil)) + XCTAssertEqual(first.resultingAction, .topHalf) + + let second = try XCTUnwrap(calculate(.topHalf, window: first.rect, last: (first.resultingAction, first.rect))) + XCTAssertEqual(second.resultingAction, .bottomHalf) + assertRect(second.rect, plainHalf(.bottomHalf)) + + let third = try XCTUnwrap(calculate(.topHalf, window: second.rect, last: (second.resultingAction, second.rect))) + XCTAssertEqual(third.resultingAction, .topHalf) + assertRect(third.rect, plainHalf(.topHalf)) + } + + func testRepeatedBottomHalfSwitchesToTheTopHalf() throws { + let first = try XCTUnwrap(calculate(.bottomHalf, window: floating, last: nil)) + let second = try XCTUnwrap(calculate(.bottomHalf, window: first.rect, last: (first.resultingAction, first.rect))) + XCTAssertEqual(second.resultingAction, .topHalf) + assertRect(second.rect, plainHalf(.topHalf)) + } + + // MARK: - Interplay with other options + + func testPlainHalvesStillSwitchWhenHalvesPreserveTheOtherAxis() throws { + Defaults.halvesPreserveOtherAxisSize.enabled = true + + let left = try XCTUnwrap(calculate(.leftHalf, window: floating, last: nil)) + let right = try XCTUnwrap(calculate(.leftHalf, window: left.rect, last: (left.resultingAction, left.rect))) + XCTAssertEqual(right.resultingAction, .rightHalf) + assertRect(right.rect, plainHalf(.rightHalf)) + + let top = try XCTUnwrap(calculate(.topHalf, window: floating, last: nil)) + let bottom = try XCTUnwrap(calculate(.topHalf, window: top.rect, last: (top.resultingAction, top.rect))) + XCTAssertEqual(bottom.resultingAction, .bottomHalf) + assertRect(bottom.rect, plainHalf(.bottomHalf)) + } + + func testAQuarterKeepsItsPlaceWhenHalvesPreserveTheOtherAxis() throws { + Defaults.halvesPreserveOtherAxisSize.enabled = true + let visible = screen.adjustedVisibleFrame() + let quarter = HalfSplitFrameCalculation.cornerRect(in: visible, horizontalSide: .leading, verticalSide: .leading, + horizontalFraction: 0.5, verticalFraction: 0.5) + + let result = try XCTUnwrap(calculate(.topHalf, window: quarter, last: (.topLeft, quarter))) + XCTAssertEqual(result.resultingAction, .topLeft) + assertRect(result.rect, quarter) + } + + func testASideSwitchIsNotACooperativeResize() { + let left = CGRect(x: 0, y: 0, width: 500, height: 600) + let right = CGRect(x: 500, y: 0, width: 500, height: 600) + let widerLeft = CGRect(x: 0, y: 0, width: 667, height: 600) + XCTAssertTrue(CooperativeCornerResize.isSideSwitch(oldFocusedFrame: left, newFocusedFrame: right, axis: .horizontal, tolerance: 4)) + XCTAssertFalse(CooperativeCornerResize.isSideSwitch(oldFocusedFrame: left, newFocusedFrame: widerLeft, axis: .horizontal, tolerance: 4)) + XCTAssertFalse(CooperativeCornerResize.isSideSwitch(oldFocusedFrame: left, newFocusedFrame: left, axis: .horizontal, tolerance: 4)) + + let top = CGRect(x: 0, y: 300, width: 1000, height: 300) + let bottom = CGRect(x: 0, y: 0, width: 1000, height: 300) + XCTAssertTrue(CooperativeCornerResize.isSideSwitch(oldFocusedFrame: top, newFocusedFrame: bottom, axis: .vertical, tolerance: 4)) + XCTAssertFalse(CooperativeCornerResize.isSideSwitch(oldFocusedFrame: top, newFocusedFrame: bottom, axis: .horizontal, tolerance: 4)) + } + + // MARK: - Scope of the mode + + func testOtherModesDoNotSwitchSides() throws { + for mode in SubsequentExecutionMode.ordered where mode != .switchSides { + Defaults.subsequentExecutionMode.value = mode + for action in [WindowAction.leftHalf, .rightHalf, .topHalf, .bottomHalf] { + let first = try XCTUnwrap(calculate(action, window: floating, last: nil)) + let second = try XCTUnwrap(calculate(action, window: first.rect, last: (first.resultingAction, first.rect))) + XCTAssertEqual(second.resultingAction, action, "\(mode) \(action)") + } + } + } + + func testThirdsKeepTogglingInTheSwitchSidesMode() { + let visibleFrame = CGRect(x: 0, y: 0, width: 900, height: 600) + let calculation = WindowCalculationFactory.calculationsByAction[.firstTwoThirds]! + func params(window: CGRect, last: RectangleAction?) -> RectCalculationParameters { + RectCalculationParameters(window: Window(id: windowId, rect: window), + visibleFrameOfScreen: visibleFrame, + action: .firstTwoThirds, + lastAction: last) + } + let first = calculation.calculateRect(params(window: visibleFrame, last: nil)) + XCTAssertEqual(first.rect.minX, 0) + XCTAssertEqual(first.rect.width, 600) + + let repeated = calculation.calculateRect(params(window: first.rect, + last: RectangleAction(action: .firstTwoThirds, subAction: first.subAction, rect: first.rect, count: 1))) + XCTAssertEqual(repeated.rect.minX, 300) + XCTAssertEqual(repeated.rect.width, 600) + XCTAssertNotEqual(repeated.subAction, first.subAction) + + let back = calculation.calculateRect(params(window: repeated.rect, + last: RectangleAction(action: .firstTwoThirds, subAction: repeated.subAction, rect: repeated.rect, count: 2))) + XCTAssertEqual(back.rect.minX, 0) + } + + func testTheModeIsOfferedAndNeitherResizesNorTraverses() { + XCTAssertTrue(SubsequentExecutionMode.ordered.contains(.switchSides)) + XCTAssertEqual(SubsequentExecutionMode(rawValue: 6), .switchSides) + XCTAssertFalse(SubsequentExecutionMode.switchSides.resizes) + XCTAssertFalse(Defaults.subsequentExecutionMode.resizes) + XCTAssertFalse(Defaults.subsequentExecutionMode.traversesDisplays) + XCTAssertFalse(Defaults.subsequentExecutionMode.cyclesQuadrantPositions) + } + + // MARK: - Helpers + + /// Runs the action's calculation the way WindowManager does: the window in screen coordinates, + /// and the recorded rect flipped as `recordAction` stores it, so a repeat is recognised when they match. + private func calculate(_ action: WindowAction, window: CGRect, last: (WindowAction, CGRect)?) -> WindowCalculationResult? { + let params = WindowCalculationParameters(window: Window(id: windowId, rect: window), + usableScreens: UsableScreens(currentScreen: screen, numScreens: 1), + action: action, + lastAction: last.map { RectangleAction(action: $0.0, subAction: nil, rect: $0.1.screenFlipped, count: 1) }, + ignoreTodo: false) + return WindowCalculationFactory.calculationsByAction[action]!.calculate(params) + } + + /// What a first execution of the action produces on this screen. + private func plainHalf(_ action: WindowAction) -> CGRect { + calculate(action, window: floating, last: nil)?.rect ?? .null + } + + private func assertRect(_ rect: CGRect, _ expected: CGRect, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(rect.minX, expected.minX, accuracy: 0.5, "minX", file: file, line: line) + XCTAssertEqual(rect.minY, expected.minY, accuracy: 0.5, "minY", file: file, line: line) + XCTAssertEqual(rect.width, expected.width, accuracy: 0.5, "width", file: file, line: line) + XCTAssertEqual(rect.height, expected.height, accuracy: 0.5, "height", file: file, line: line) + } +} diff --git a/TerminalCommands.md b/TerminalCommands.md index 2e972d5c..95152288 100644 --- a/TerminalCommands.md +++ b/TerminalCommands.md @@ -71,6 +71,8 @@ defaults write com.knollsoft.Rectangle subsequentExecutionMode -int 2 2: disabled 3: cycle displays for left/right actions, halves to thirds for the rest (old Rectangle behavior) 4: repeat same action on next display +5: cycle positions on quadrants, cycle sizes on side +6: switch to the opposite side on half actions ## Cycle thirds on repeated Center Half commands