Skip to content
8 changes: 7 additions & 1 deletion Rectangle/Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ class Defaults {
static let attemptMatchOnNextPrevDisplay = OptionalBoolDefault(key: "attemptMatchOnNextPrevDisplay")
static let altThirdCycle = OptionalBoolDefault(key: "altThirdCycle")
static let centerHalfCycles = OptionalBoolDefault(key: "centerHalfCycles")
static let cyclingOverlapOffset = OptionalBoolDefault(key: "cyclingOverlapOffset")
static let cyclingOverlapOffsetSize = FloatDefault(key: "cyclingOverlapOffsetSize", defaultValue: 11)
static let cyclingOverlapMaxCascade = IntDefault(key: "cyclingOverlapMaxCascade", defaultValue: 1)
static let fullIgnoreBundleIds = JSONDefault<[String]>(key: "fullIgnoreBundleIds")
static let notifiedOfProblemApps = BoolDefault(key: "notifiedOfProblemApps")
static let specifiedHeight = FloatDefault(key: "specifiedHeight", defaultValue: 1050)
Expand Down Expand Up @@ -183,7 +186,10 @@ class Defaults {
systemWideMouseDown,
systemWideMouseDownApps,
screensOrderedByX,
showAdditionalSizesInMenu
showAdditionalSizesInMenu,
cyclingOverlapOffset,
cyclingOverlapOffsetSize,
cyclingOverlapMaxCascade
]
}

Expand Down
13 changes: 12 additions & 1 deletion Rectangle/PrefsWindow/SettingsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ class SettingsViewController: NSViewController {
Defaults.showAdditionalSizesInMenu.enabled = enabled
Notification.Name.showAdditionalSizesInMenuChanged.post()
}

@objc func toggleCyclingOverlapOffset(_ sender: NSButton) {
Defaults.cyclingOverlapOffset.enabled = sender.state == .on
}

@IBAction func checkForUpdates(_ sender: Any) {
AppDelegate.instance.updaterController?.checkForUpdates(sender)
Expand Down Expand Up @@ -812,10 +816,16 @@ class SettingsViewController: NSViewController {
sixteenthsCyclingShortcutView.shortcutValidator = passThroughValidator
}

let overlapOffsetCheckbox = NSButton(checkboxWithTitle: NSLocalizedString("Offset cycling position on overlap", tableName: "Main", value: "", comment: ""), target: self, action: #selector(toggleCyclingOverlapOffset(_:)))
overlapOffsetCheckbox.state = Defaults.cyclingOverlapOffset.userEnabled ? .on : .off
overlapOffsetCheckbox.translatesAutoresizingMaskIntoConstraints = false
overlapOffsetCheckbox.alignment = .left

mainStackView.addArrangedSubview(gridHeaderLabel)
mainStackView.setCustomSpacing(4, after: gridHeaderLabel)
mainStackView.addArrangedSubview(showAdditionalSizesCheckbox)
mainStackView.setCustomSpacing(8, after: showAdditionalSizesCheckbox)
mainStackView.addArrangedSubview(overlapOffsetCheckbox)
mainStackView.setCustomSpacing(8, after: overlapOffsetCheckbox)
mainStackView.addArrangedSubview(cyclingHintLabel)
mainStackView.setCustomSpacing(8, after: cyclingHintLabel)
mainStackView.addArrangedSubview(topLeftEighthRow)
Expand Down Expand Up @@ -883,6 +893,7 @@ class SettingsViewController: NSViewController {
hSplitField.widthAnchor.constraint(equalToConstant: 160),
vSplitField.widthAnchor.constraint(equalToConstant: 160),
showAdditionalSizesCheckbox.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
overlapOffsetCheckbox.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
smallerWidthShortcutView.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
topVerticalThirdShortcutView.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
middleVerticalThirdShortcutView.leadingAnchor.constraint(equalTo: largerWidthShortcutView.leadingAnchor),
Expand Down
21 changes: 21 additions & 0 deletions Rectangle/WindowAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,27 @@ enum WindowAction: Int, Codable {
}
}

var positionCycles: Bool {
switch self {
case .maximize, .almostMaximize, .maximizeHeight,
.larger, .smaller, .largerWidth, .smallerWidth, .largerHeight, .smallerHeight,
.center, .centerProminently,
.restore,
.nextDisplay, .previousDisplay,
.displayOne, .displayTwo, .displayThree, .displayFour, .displayFive,
.displaySix, .displaySeven, .displayEight, .displayNine,
.moveLeft, .moveRight, .moveUp, .moveDown,
.doubleHeightUp, .doubleHeightDown, .doubleWidthLeft, .doubleWidthRight,
.halveHeightUp, .halveHeightDown, .halveWidthLeft, .halveWidthRight,
.reverseAll, .tileAll, .cascadeAll, .cascadeActiveApp, .tileActiveApp,
.leftTodo, .rightTodo,
.specified:
return false
default:
return true
}
}

var category: WindowActionCategory? { // used to specify a submenu
switch self {
case .firstThird, .centerThird, .lastThird, .firstTwoThirds, .centerTwoThirds, .lastTwoThirds: return .thirds
Expand Down
66 changes: 64 additions & 2 deletions Rectangle/WindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,15 @@ class WindowManager {
calcResult.rect = GapCalculation.applyGaps(calcResult.rect, dimension: gapsApplicable, sharedEdges: gapSharedEdges, gapSize: Defaults.gapSize.value)
}

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
}

Expand Down Expand Up @@ -196,6 +200,64 @@ class WindowManager {
}
}

private func applyOverlapOffsetIfNeeded(_ rect: CGRect, windowId: CGWindowID, screen: NSScreen) -> CGRect {
let overlapOffset = CGFloat(Defaults.cyclingOverlapOffsetSize.value)
guard overlapOffset > 0 else { return rect }

let screenFrameAX = screen.adjustedVisibleFrame().screenFlipped
let tolerance: CGFloat = 4
let maxCascade = min(5, max(1, Defaults.cyclingOverlapMaxCascade.value))

let otherWindows = AccessibilityElement.getAllWindowElements().filter { element in
guard element.getWindowId() != windowId,
element.isWindow == true,
element.isMinimized != true,
element.isHidden != true,
element.isSheet != true
else { return false }

let frame = element.frame
return !frame.isNull && screenFrameAX.intersects(frame)
}

let screenFrameNormalized = screen.adjustedVisibleFrame()
var candidate = rect
var cascadeLevel = 0

while cascadeLevel < maxCascade {
let candidateAX = candidate.screenFlipped
let hasOverlap = otherWindows.contains { element in
let otherFrame = element.frame
return abs(otherFrame.origin.x - candidateAX.origin.x) < tolerance
&& abs(otherFrame.origin.y - candidateAX.origin.y) < tolerance
}

guard hasOverlap else { break }

candidate.origin.x += overlapOffset
candidate.origin.y += overlapOffset
cascadeLevel += 1

if candidate.origin.x + candidate.width > screenFrameNormalized.maxX {
candidate.origin.x = screenFrameNormalized.maxX - candidate.width
}
if candidate.origin.y + candidate.height > screenFrameNormalized.maxY {
candidate.origin.y = screenFrameNormalized.maxY - candidate.height
}
if candidate.origin.x < screenFrameNormalized.origin.x {
candidate.origin.x = screenFrameNormalized.origin.x
}
if candidate.origin.y < screenFrameNormalized.origin.y {
candidate.origin.y = screenFrameNormalized.origin.y
}
}

if cascadeLevel > 0 {
Logger.log("Cycling overlap detected, applied \(cascadeLevel) x \(overlapOffset)pt cascade offset")
}
return candidate
}

func postProcess(result: ResultParameters, resultingRect: CGRect) {
let calcResult = result.calcResult

Expand Down
199 changes: 190 additions & 9 deletions RectangleTests/RectangleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,204 @@ import XCTest
class RectangleTests: XCTestCase {

override func setUp() {
// Put setup code here. This method is called before the invocation of each test method in the class.
}

override func tearDown() {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
}

class PositionCyclesTests: XCTestCase {

func testSixthsReturnTrue() {
XCTAssertTrue(WindowAction.topLeftSixth.positionCycles)
XCTAssertTrue(WindowAction.topCenterSixth.positionCycles)
XCTAssertTrue(WindowAction.topRightSixth.positionCycles)
XCTAssertTrue(WindowAction.bottomLeftSixth.positionCycles)
XCTAssertTrue(WindowAction.bottomCenterSixth.positionCycles)
XCTAssertTrue(WindowAction.bottomRightSixth.positionCycles)
}

func testEighthsReturnTrue() {
XCTAssertTrue(WindowAction.topLeftEighth.positionCycles)
XCTAssertTrue(WindowAction.topCenterLeftEighth.positionCycles)
XCTAssertTrue(WindowAction.bottomRightEighth.positionCycles)
}

func testNinthsReturnTrue() {
XCTAssertTrue(WindowAction.topLeftNinth.positionCycles)
XCTAssertTrue(WindowAction.middleCenterNinth.positionCycles)
XCTAssertTrue(WindowAction.bottomRightNinth.positionCycles)
}

func testTwelfthsReturnTrue() {
XCTAssertTrue(WindowAction.topLeftTwelfth.positionCycles)
XCTAssertTrue(WindowAction.middleCenterLeftTwelfth.positionCycles)
XCTAssertTrue(WindowAction.bottomRightTwelfth.positionCycles)
}

func testSixteenthsReturnTrue() {
XCTAssertTrue(WindowAction.topLeftSixteenth.positionCycles)
XCTAssertTrue(WindowAction.upperMiddleCenterLeftSixteenth.positionCycles)
XCTAssertTrue(WindowAction.lowerMiddleRightSixteenth.positionCycles)
XCTAssertTrue(WindowAction.bottomRightSixteenth.positionCycles)
}

func testGridPositionsReturnTrue() {
XCTAssertTrue(WindowAction.leftHalf.positionCycles)
XCTAssertTrue(WindowAction.rightHalf.positionCycles)
XCTAssertTrue(WindowAction.topLeft.positionCycles)
XCTAssertTrue(WindowAction.bottomRight.positionCycles)
XCTAssertTrue(WindowAction.firstThird.positionCycles)
XCTAssertTrue(WindowAction.lastThird.positionCycles)
XCTAssertTrue(WindowAction.firstFourth.positionCycles)
XCTAssertTrue(WindowAction.topHalf.positionCycles)
XCTAssertTrue(WindowAction.bottomHalf.positionCycles)
}

func testNonPositionalActionsReturnFalse() {
XCTAssertFalse(WindowAction.maximize.positionCycles)
XCTAssertFalse(WindowAction.maximizeHeight.positionCycles)
XCTAssertFalse(WindowAction.almostMaximize.positionCycles)
XCTAssertFalse(WindowAction.center.positionCycles)
XCTAssertFalse(WindowAction.centerProminently.positionCycles)
XCTAssertFalse(WindowAction.restore.positionCycles)
XCTAssertFalse(WindowAction.moveLeft.positionCycles)
XCTAssertFalse(WindowAction.moveRight.positionCycles)
XCTAssertFalse(WindowAction.nextDisplay.positionCycles)
XCTAssertFalse(WindowAction.previousDisplay.positionCycles)
XCTAssertFalse(WindowAction.larger.positionCycles)
XCTAssertFalse(WindowAction.smaller.positionCycles)
XCTAssertFalse(WindowAction.tileAll.positionCycles)
XCTAssertFalse(WindowAction.cascadeAll.positionCycles)
XCTAssertFalse(WindowAction.specified.positionCycles)
}
}

class ScreenFlippedTests: XCTestCase {

func testScreenFlippedIsOwnInverse() {
let rect = CGRect(x: 100, y: 200, width: 400, height: 300)
let flipped = rect.screenFlipped
let doubleFlipped = flipped.screenFlipped
XCTAssertEqual(rect.origin.x, doubleFlipped.origin.x, accuracy: 0.001)
XCTAssertEqual(rect.origin.y, doubleFlipped.origin.y, accuracy: 0.001)
XCTAssertEqual(rect.width, doubleFlipped.width, accuracy: 0.001)
XCTAssertEqual(rect.height, doubleFlipped.height, accuracy: 0.001)
}

func testScreenFlippedPreservesSize() {
let rect = CGRect(x: 50, y: 100, width: 800, height: 600)
let flipped = rect.screenFlipped
XCTAssertEqual(rect.width, flipped.width, accuracy: 0.001)
XCTAssertEqual(rect.height, flipped.height, accuracy: 0.001)
}

func testScreenFlippedPreservesX() {
let rect = CGRect(x: 250, y: 300, width: 500, height: 400)
let flipped = rect.screenFlipped
XCTAssertEqual(rect.origin.x, flipped.origin.x, accuracy: 0.001)
}

func testScreenFlippedNullRectReturnsNull() {
let nullRect = CGRect.null
let flipped = nullRect.screenFlipped
XCTAssertTrue(flipped.isNull)
}

func testScreenFlippedNegativeCoordinates() {
let rect = CGRect(x: -1000, y: -500, width: 400, height: 300)
let flipped = rect.screenFlipped
let doubleFlipped = flipped.screenFlipped
XCTAssertEqual(rect.origin.x, doubleFlipped.origin.x, accuracy: 0.001)
XCTAssertEqual(rect.origin.y, doubleFlipped.origin.y, accuracy: 0.001)
}
}

class DefaultsExportTests: XCTestCase {

func testOverlapDefaultsInExportArray() {
let keys = Defaults.array.map { $0.key }
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")
}
}

class OverlapOffsetGuardsTests: XCTestCase {

func testExample() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
func testMaxCascadeClampedToMinOne() {
let result = min(5, max(1, 0))
XCTAssertEqual(result, 1)
}

func testPerformanceExample() {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
func testMaxCascadeClampedToMaxFive() {
let result = min(5, max(1, 999))
XCTAssertEqual(result, 5)
}

func testMaxCascadeNegativeClampsToOne() {
let result = min(5, max(1, -10))
XCTAssertEqual(result, 1)
}

func testMaxCascadeNormalValuePassesThrough() {
let result = min(5, max(1, 3))
XCTAssertEqual(result, 3)
}

func testOffsetClampingKeepsRectInScreen() {
let screenFrame = CGRect(x: 0, y: 0, width: 2336, height: 1466)
var candidate = CGRect(x: 2300, y: 1400, width: 400, height: 300)
let overlapOffset: CGFloat = 11

candidate.origin.x += overlapOffset
candidate.origin.y += overlapOffset

if candidate.origin.x + candidate.width > screenFrame.maxX {
candidate.origin.x = screenFrame.maxX - candidate.width
}
if candidate.origin.y + candidate.height > screenFrame.maxY {
candidate.origin.y = screenFrame.maxY - candidate.height
}
if candidate.origin.x < screenFrame.origin.x {
candidate.origin.x = screenFrame.origin.x
}
if candidate.origin.y < screenFrame.origin.y {
candidate.origin.y = screenFrame.origin.y
}

XCTAssertLessThanOrEqual(candidate.origin.x + candidate.width, screenFrame.maxX)
XCTAssertLessThanOrEqual(candidate.origin.y + candidate.height, screenFrame.maxY)
XCTAssertGreaterThanOrEqual(candidate.origin.x, screenFrame.origin.x)
XCTAssertGreaterThanOrEqual(candidate.origin.y, screenFrame.origin.y)
}

func testOffsetClampingWithNegativeScreenOrigin() {
let screenFrame = CGRect(x: -1372, y: 1510, width: 3840, height: 2160)
var candidate = CGRect(x: -1372, y: 1510, width: 960, height: 540)
let overlapOffset: CGFloat = 11

candidate.origin.x += overlapOffset
candidate.origin.y += overlapOffset

if candidate.origin.x + candidate.width > screenFrame.maxX {
candidate.origin.x = screenFrame.maxX - candidate.width
}
if candidate.origin.y + candidate.height > screenFrame.maxY {
candidate.origin.y = screenFrame.maxY - candidate.height
}
if candidate.origin.x < screenFrame.origin.x {
candidate.origin.x = screenFrame.origin.x
}
if candidate.origin.y < screenFrame.origin.y {
candidate.origin.y = screenFrame.origin.y
}

XCTAssertLessThanOrEqual(candidate.origin.x + candidate.width, screenFrame.maxX)
XCTAssertLessThanOrEqual(candidate.origin.y + candidate.height, screenFrame.maxY)
XCTAssertGreaterThanOrEqual(candidate.origin.x, screenFrame.origin.x)
XCTAssertGreaterThanOrEqual(candidate.origin.y, screenFrame.origin.y)
XCTAssertEqual(candidate.origin.x, -1372 + 11, accuracy: 0.001)
XCTAssertEqual(candidate.origin.y, 1510 + 11, accuracy: 0.001)
}
}
Loading
Loading