Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion Rectangle/WindowCalculation/NextPrevDisplayCalculation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,18 @@ class NextPrevDisplayCalculation: WindowCalculation {
let rectResult = calculation.calculateRect(newCalculationParams)

return WindowCalculationResult(rect: rectResult.rect, screen: screen, resultingAction: lastAction.action)
} else {
// Issue #1723: opt-in ON but no replayable lastAction (e.g. a manually positioned
// window). Map the window proportionally from the source screen to the destination
// screen so it keeps its relative spot instead of jumping to the center.
let sourceFrame = params.usableScreens.currentScreen.adjustedVisibleFrame(params.ignoreTodo)
let mappedRect = NextPrevDisplayCalculation.relativePositionedRect(window: rectParams.window.rect,
source: sourceFrame,
destination: rectParams.visibleFrameOfScreen)
return WindowCalculationResult(rect: mappedRect, screen: screen, resultingAction: params.action)
}
}

let rectResult = calculateRect(rectParams)
let resultingAction: WindowAction = rectResult.resultingAction ?? params.action
return WindowCalculationResult(rect: rectResult.rect, screen: screen, resultingAction: resultingAction)
Expand All @@ -55,4 +64,38 @@ class NextPrevDisplayCalculation: WindowCalculation {

return WindowCalculationFactory.centerCalculation.calculateRect(params)
}

/// Proportionally map `window` from the coordinate space of `source` to `destination`,
/// preserving its relative position and size as fractions of the source frame, then clamp the
/// result inside `destination` so a near-full-size window can never overflow. Shared by the
/// next/previous-display and specific-display moves when `attemptMatchOnNextPrevDisplay` is on
/// but there is no Rectangle snap action to replay (issue #1723).
static func relativePositionedRect(window: CGRect, source: CGRect, destination: CGRect) -> CGRect {
guard source.width > 0, source.height > 0 else { return window }

let originXFrac = (window.minX - source.minX) / source.width
let originYFrac = (window.minY - source.minY) / source.height
let widthFrac = window.width / source.width
let heightFrac = window.height / source.height

var rect = CGRect(x: destination.minX + originXFrac * destination.width,
y: destination.minY + originYFrac * destination.height,
width: widthFrac * destination.width,
height: heightFrac * destination.height)

if rect.maxX > destination.maxX {
rect.origin.x = destination.maxX - rect.width
}
if rect.minX < destination.minX {
rect.origin.x = destination.minX
}
if rect.maxY > destination.maxY {
rect.origin.y = destination.maxY - rect.height
}
if rect.minY < destination.minY {
rect.origin.y = destination.minY
}

return rect
}
}
11 changes: 11 additions & 0 deletions Rectangle/WindowCalculation/SpecificDisplayCalculation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ class SpecificDisplayCalculation: WindowCalculation {
let rectResult = calculation.calculateRect(newCalculationParams)

return WindowCalculationResult(rect: rectResult.rect, screen: targetScreen, resultingAction: lastAction.action)
} else {
// Issue #1723: opt-in ON but no replayable lastAction (e.g. a manually positioned
// window). Map the window proportionally from the source screen to the destination
// screen so it keeps its relative spot instead of jumping to the center. Parity with
// NextPrevDisplayCalculation: display 1/2/3 moves behave like next/prev moves.
let sourceFrame = params.usableScreens.currentScreen.adjustedVisibleFrame(params.ignoreTodo)
let mappedRect = NextPrevDisplayCalculation.relativePositionedRect(
window: rectParams.window.rect,
source: sourceFrame,
destination: rectParams.visibleFrameOfScreen)
return WindowCalculationResult(rect: mappedRect, screen: targetScreen, resultingAction: params.action)
}
}

Expand Down
67 changes: 67 additions & 0 deletions RectangleTests/RectangleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3559,3 +3559,70 @@ class DerivedWindowIdTests: XCTestCase {
XCTAssertEqual(Set(ids).count, ids.count)
}
}

final class NextPrevDisplayMappingTests: XCTestCase {
// The opt-in is NOT needed to test the pure helper; setUp/tearDown still save & restore it
// so the wiring tests below are isolated from host state.
private var savedOptIn: Bool?

override func setUp() {
super.setUp()
savedOptIn = Defaults.attemptMatchOnNextPrevDisplay.enabled
Defaults.attemptMatchOnNextPrevDisplay.enabled = true
}

override func tearDown() {
Defaults.attemptMatchOnNextPrevDisplay.enabled = savedOptIn
super.tearDown()
}

// --- Helper-level: RED until relativePositionedRect exists, then GREEN ---

func testRelativePositionedRectMapsRightThirdToRightThird() {
// Issue #1723 repro geometry: a window pinned to the right third (full height) of a
// 3000x2000 source must land at the right third of a 1500x1000 destination.
let source = CGRect(x: 0, y: 0, width: 3000, height: 2000)
let window = CGRect(x: 2000, y: 0, width: 1000, height: 2000)
let destination = CGRect(x: 0, y: 0, width: 1500, height: 1000)

XCTAssertEqual(
NextPrevDisplayCalculation.relativePositionedRect(window: window, source: source, destination: destination),
CGRect(x: 1000, y: 0, width: 500, height: 1000)
)
}

func testRelativePositionedRectPreservesCenteredQuarter() {
// A centered-quarter window stays a centered quarter after the cross-display map.
let source = CGRect(x: 0, y: 0, width: 2560, height: 1440)
let window = CGRect(x: 960, y: 360, width: 640, height: 720) // centered quarter
let destination = CGRect(x: 0, y: 0, width: 1280, height: 720)

XCTAssertEqual(
NextPrevDisplayCalculation.relativePositionedRect(window: window, source: source, destination: destination),
CGRect(x: 480, y: 180, width: 320, height: 360)
)
}

func testRelativePositionedRectClampsOverflowIntoDestination() {
// Window occupies 60% width starting at the 50% mark on a 1000x1000 source.
// Mapped onto a 500x500 destination: width 300 @ x 250 -> maxX 550 > 500 -> clamp to x 200.
let source = CGRect(x: 0, y: 0, width: 1000, height: 1000)
let window = CGRect(x: 500, y: 0, width: 600, height: 1000)
let destination = CGRect(x: 0, y: 0, width: 500, height: 500)

XCTAssertEqual(
NextPrevDisplayCalculation.relativePositionedRect(window: window, source: source, destination: destination),
CGRect(x: 200, y: 0, width: 300, height: 500)
)
}

func testRelativePositionedRectIsIdentityWhenSourceEqualsDestination() {
let frame = CGRect(x: 0, y: 0, width: 2000, height: 1000)
let window = CGRect(x: 300, y: 200, width: 500, height: 600)

XCTAssertEqual(
NextPrevDisplayCalculation.relativePositionedRect(window: window, source: frame, destination: frame),
window
)
}
}
15 changes: 15 additions & 0 deletions TerminalCommands.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ The preferences window is purposefully slim, but there's a lot that can be modif
- [Change the behavior of double-click window title bar](#change-the-behavior-of-double-click-window-title-bar)
- [Change the order of displays to order by x coordinate](#change-the-order-of-displays-to-order-by-x-coordinate-for-next-and-prev-displays-commands)
- [Keep window size when moving a maximized window to another display](#keep-window-size-when-moving-a-maximized-window-to-another-display)
- [Attempt to preserve window position when moving to another display](#attempt-to-preserve-window-position-when-moving-to-another-display)
- [Offset cycling position when overlapping another window](#offset-cycling-position-when-overlapping-another-window)
- [Move windows that can't fill the snap area to the edge](#move-windows-that-cant-fill-the-snap-area-to-the-edge)

Expand Down Expand Up @@ -537,6 +538,20 @@ To restore the default behavior:
defaults write com.knollsoft.Rectangle autoMaximize -int 0
```

## Attempt to preserve window position when moving to another display

By default, moving a window to the next, previous, or a specific display centers it on the destination display. Enable this to instead try preserving the window's position on the destination. If the previous action was a Rectangle snap (half, third, maximize, etc.), that snap is replayed on the destination display. If the window was positioned manually, its rect is mapped proportionally from the source display to the destination display (a window at the right third stays at the right third) and clamped so it never overflows. This is off by default.

```bash
defaults write com.knollsoft.Rectangle attemptMatchOnNextPrevDisplay -int 1
```

To disable it again:

```bash
defaults write com.knollsoft.Rectangle attemptMatchOnNextPrevDisplay -int 2
```

## Offset cycling position when overlapping another window

When cycling through grid positions (sixths, eighths, ninths, twelfths, sixteenths, or quarters with quadrant cycling mode), the target position may land exactly on top of another window, hiding it completely. Enable this to apply a small offset when an overlap is detected, so you can see there's a window underneath.
Expand Down