Skip to content
Merged
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
8 changes: 8 additions & 0 deletions Rectangle.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@
98BEFA482620DEDD00D9D54F /* NSImageExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BEFA472620DEDC00D9D54F /* NSImageExtension.swift */; };
98C1008C2305F1FA006E5344 /* SubsequentExecutionMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C1008B2305F1FA006E5344 /* SubsequentExecutionMode.swift */; };
98C1008E230B9EF6006E5344 /* NextPrevDisplayCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C1008D230B9EF6006E5344 /* NextPrevDisplayCalculation.swift */; };
AF09B1000000000000000001 /* DisplayTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF09B1000000000000000002 /* DisplayTransfer.swift */; };
AF09B1000000000000000003 /* DisplayTransferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF09B1000000000000000004 /* DisplayTransferTests.swift */; };
98C2755E231FF6A9009B9292 /* EventMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2755D231FF6A9009B9292 /* EventMonitor.swift */; };
98C27561231FFA5F009B9292 /* SnappingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C27560231FFA5F009B9292 /* SnappingManager.swift */; };
98C275672322E2DA009B9292 /* WindowHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C275662322E2DA009B9292 /* WindowHistory.swift */; };
Expand Down Expand Up @@ -360,6 +362,8 @@
98BEFA472620DEDC00D9D54F /* NSImageExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSImageExtension.swift; sourceTree = "<group>"; };
98C1008B2305F1FA006E5344 /* SubsequentExecutionMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubsequentExecutionMode.swift; sourceTree = "<group>"; };
98C1008D230B9EF6006E5344 /* NextPrevDisplayCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextPrevDisplayCalculation.swift; sourceTree = "<group>"; };
AF09B1000000000000000002 /* DisplayTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayTransfer.swift; sourceTree = "<group>"; };
AF09B1000000000000000004 /* DisplayTransferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayTransferTests.swift; sourceTree = "<group>"; };
98C2755D231FF6A9009B9292 /* EventMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMonitor.swift; sourceTree = "<group>"; };
98C27560231FFA5F009B9292 /* SnappingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnappingManager.swift; sourceTree = "<group>"; };
98C275662322E2DA009B9292 /* WindowHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowHistory.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -547,6 +551,7 @@
988D068222EB4EF3004EABD7 /* MoveUpDownCalculation.swift */,
988D067C22EB4E17004EABD7 /* AlmostMaximizeCalculation.swift */,
98C1008D230B9EF6006E5344 /* NextPrevDisplayCalculation.swift */,
AF09B1000000000000000002 /* DisplayTransfer.swift */,
98FA9496235A2D7600F95C4F /* RepeatedExecutionsCalculation.swift */,
98C6DEEF23CE191700CC0C1E /* GapCalculation.swift */,
9851A5C2251BEBA300ECF78C /* OrientationAware.swift */,
Expand Down Expand Up @@ -739,6 +744,7 @@
children = (
9824701F22AF9B7E0037B409 /* RectangleTests.swift */,
AF09A0000000000000000004 /* GridTilingTests.swift */,
AF09B1000000000000000004 /* DisplayTransferTests.swift */,
944F25D02CE5A144004B2FD2 /* ShortcutRecordingObserverTests.swift */,
de0950b8b00f40308934a523 /* JSONDefaultTests.swift */,
9824702122AF9B7E0037B409 /* Info.plist */,
Expand Down Expand Up @@ -1052,6 +1058,7 @@
7B4C0A1F2F1A000100C0FFEE /* HalvesPreserveOtherAxisSize.swift in Sources */,
9824704D22B189250037B409 /* WindowCalculation.swift in Sources */,
98C1008E230B9EF6006E5344 /* NextPrevDisplayCalculation.swift in Sources */,
AF09B1000000000000000001 /* DisplayTransfer.swift in Sources */,
9824703122AFA8470037B409 /* RectangleStatusItem.swift in Sources */,
D04CE31027817ABE00BD47B3 /* BottomRightNinthCalculation.swift in Sources */,
98A009B92512538D00CFBF0C /* TopRightSixthCalculation.swift in Sources */,
Expand Down Expand Up @@ -1175,6 +1182,7 @@
files = (
9824702022AF9B7E0037B409 /* RectangleTests.swift in Sources */,
AF09A0000000000000000003 /* GridTilingTests.swift in Sources */,
AF09B1000000000000000003 /* DisplayTransferTests.swift in Sources */,
944F25D12CE5A144004B2FD2 /* ShortcutRecordingObserverTests.swift in Sources */,
01f52cab9bce43359775bdd7 /* JSONDefaultTests.swift in Sources */,
);
Expand Down
2 changes: 2 additions & 0 deletions Rectangle/Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class Defaults {
static let todoSidebarSide = IntEnumDefault<TodoSidebarSide>(key: "todoSidebarSide", defaultValue: .right)
static let snapModifiers = IntDefault(key: "snapModifiers")
static let attemptMatchOnNextPrevDisplay = OptionalBoolDefault(key: "attemptMatchOnNextPrevDisplay")
static let keepWindowPositionOnDisplayChange = OptionalBoolDefault(key: "keepWindowPositionOnDisplayChange")
static let altThirdCycle = OptionalBoolDefault(key: "altThirdCycle")
static let centerHalfCycles = OptionalBoolDefault(key: "centerHalfCycles")
static let cyclingOverlapOffset = OptionalBoolDefault(key: "cyclingOverlapOffset")
Expand Down Expand Up @@ -186,6 +187,7 @@ class Defaults {
todoSidebarSide,
snapModifiers,
attemptMatchOnNextPrevDisplay,
keepWindowPositionOnDisplayChange,
altThirdCycle,
centerHalfCycles,
fullIgnoreBundleIds,
Expand Down
6 changes: 6 additions & 0 deletions Rectangle/SettingsWindow/BehaviorSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ struct BehaviorSettingsView: View {
// MARK: - Cursor & Display Rules
Section {
Toggle("Move cursor along with window across displays", isOn: $viewModel.moveCursorAcrossDisplays)
VStack(alignment: .leading, spacing: 2) {
Toggle("Keep window position when moving across displays", isOn: $viewModel.keepWindowPositionOnDisplayChange)
Text("Windows stay against the same screen edges on the new display. When off, they are centered.")
.font(.caption)
.foregroundColor(.secondary)
}
Toggle("Preserve side axis size for half actions, similar to Windows", isOn: $viewModel.halvesPreserveOtherAxisSize)
Toggle("Animate windows (experimental)", isOn: $viewModel.experimentalAnimations)
Toggle("Show Extra shortcuts in menu", isOn: $viewModel.showAdditionalSizesInMenu)
Expand Down
8 changes: 8 additions & 0 deletions Rectangle/SettingsWindow/BehaviorSettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ final class BehaviorSettingsViewModel: ObservableObject {
}
}

@Published var keepWindowPositionOnDisplayChange: Bool {
didSet {
Defaults.keepWindowPositionOnDisplayChange.enabled = keepWindowPositionOnDisplayChange
}
}

@Published var useCursorScreenDetection: Bool {
didSet {
Defaults.useCursorScreenDetection.enabled = useCursorScreenDetection
Expand Down Expand Up @@ -168,6 +174,7 @@ final class BehaviorSettingsViewModel: ObservableObject {
self.skipGapTopEdge = Defaults.skipGapTopEdge.enabled

self.moveCursorAcrossDisplays = Defaults.moveCursorAcrossDisplays.userEnabled
self.keepWindowPositionOnDisplayChange = !Defaults.keepWindowPositionOnDisplayChange.userDisabled
self.useCursorScreenDetection = Defaults.useCursorScreenDetection.enabled

self.doubleClickTitleBar = WindowAction(rawValue: Defaults.doubleClickTitleBar.value - 1) != nil
Expand Down Expand Up @@ -216,6 +223,7 @@ final class BehaviorSettingsViewModel: ObservableObject {
self.gapSize = Double(Defaults.gapSize.value)
self.skipGapTopEdge = Defaults.skipGapTopEdge.enabled
self.moveCursorAcrossDisplays = Defaults.moveCursorAcrossDisplays.userEnabled
self.keepWindowPositionOnDisplayChange = !Defaults.keepWindowPositionOnDisplayChange.userDisabled
self.doubleClickTitleBar = WindowAction(rawValue: Defaults.doubleClickTitleBar.value - 1) != nil
self.autoMaximize = !Defaults.autoMaximize.userDisabled
self.greenButtonOverride = Defaults.greenButtonOverride.enabled
Expand Down
128 changes: 128 additions & 0 deletions Rectangle/WindowCalculation/DisplayTransfer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/// DisplayTransfer.swift

import CoreGraphics

/// Places a window on a destination display so that it keeps the spot it had on the source
/// display, instead of being centered (issue #1666).
///
/// Each axis is worked out on its own, and both use the same rule: an edge that was against the
/// source screen edge is put back against the matching destination screen edge. The rest follows
/// from that.
///
/// - Against both edges: the window follows both, so it spans the destination on that axis. A
/// maximized window stays maximized, a left half stays full height.
/// - Against one edge: the window keeps its size and stays against that edge, so a window parked
/// in a corner arrives in the same corner.
/// - Against neither: the window keeps its size and its center keeps the same relative spot, so a
/// window sitting in the right third arrives in the right third.
///
/// Sizes only change where the window has to follow the screen edges, which is what makes this
/// feel like dragging the window across yourself. The exception is a window that is larger than
/// the destination display, which is cut down to fit.
///
/// The distance from an edge is carried over rather than flattened to zero, so a window that was
/// snapped with gaps arrives with the same gaps.
///
/// One case is handled differently: a window against three screen edges - spanning one axis and
/// against one edge of the other, like a left half or a top half - that moves between a landscape
/// and a portrait display. Following the rule above would stretch a left half into a tall sliver
/// down the whole length of a portrait display. Instead it stays against the middle one of its
/// three edges, keeps its size, and is centered along that edge. Picking a corner would mean
/// guessing from how the displays happen to be arranged, whereas centering makes no assumption.
class DisplayTransfer {

/// How far from the screen edge a window edge can be and still count as being against it.
/// Windows are rarely placed to the exact point - the accessibility API rounds and apps round
/// to their own grid - and with gaps turned on nothing is ever flush to begin with.
static var edgeTolerance: CGFloat { 4 + Defaults.gapSize.cgFloat }

static func transferredRect(window: CGRect, source: CGRect, destination: CGRect, edgeTolerance: CGFloat = DisplayTransfer.edgeTolerance) -> CGRect {
guard source.width > 0, source.height > 0, destination.width > 0, destination.height > 0 else {
return window
}

var horizontal = transfer(window: (window.minX, window.width),
source: (source.minX, source.width),
destination: (destination.minX, destination.width),
edgeTolerance: edgeTolerance)
var vertical = transfer(window: (window.minY, window.height),
source: (source.minY, source.height),
destination: (destination.minY, destination.height),
edgeTolerance: edgeTolerance)

if changesOrientation(from: source, to: destination) {
// Against three edges: one axis spans, and the other is against exactly one edge, which
// is the middle one of the three. The spanning axis is the one that gets centered.
if horizontal.contact == .both, vertical.contact.isOneEdge {
horizontal.span = centered(length: window.width, on: horizontal.span,
destination: (destination.minX, destination.width))
} else if vertical.contact == .both, horizontal.contact.isOneEdge {
vertical.span = centered(length: window.height, on: vertical.span,
destination: (destination.minY, destination.height))
}
}

return CGRect(x: horizontal.span.origin,
y: vertical.span.origin,
width: horizontal.span.length,
height: vertical.span.length)
}

/// One axis of the window, the source screen and the destination screen, as a starting point
/// and a length.
private typealias Span = (origin: CGFloat, length: CGFloat)

/// Which of the two screen edges on an axis the window was against.
private enum Contact {
case neither, start, end, both

var isOneEdge: Bool { self == .start || self == .end }
}

/// Landscape to portrait or back. A square display is neither, so it never counts.
private static func changesOrientation(from source: CGRect, to destination: CGRect) -> Bool {
return (source.width > source.height && destination.height > destination.width)
|| (source.height > source.width && destination.width > destination.height)
}

/// Keeps `length` and centers it on the destination, rather than following both edges. If the
/// window is too long to fit between the edges, following them is all that can be done anyway.
private static func centered(length: CGFloat, on spanned: Span, destination: Span) -> Span {
guard length < spanned.length else { return spanned }
return (destination.origin + (destination.length - length) / 2, length)
}

private static func transfer(window: Span, source: Span, destination: Span, edgeTolerance: CGFloat) -> (span: Span, contact: Contact) {
// A window hanging off the screen has a negative inset here, which counts as being against
// that edge: bringing it back into view is the only sensible thing to do with it.
let startInset = window.origin - source.origin
let endInset = (source.origin + source.length) - (window.origin + window.length)
let againstStart = startInset <= edgeTolerance
let againstEnd = endInset <= edgeTolerance

if againstStart, againstEnd {
// Following both edges at once is what stretches the window to the destination.
let start = clamp(startInset, 0, destination.length / 2)
let end = clamp(endInset, 0, destination.length / 2)
return ((destination.origin + start, destination.length - start - end), .both)
}

let length = min(window.length, destination.length)
let slack = destination.length - length

if againstStart {
return ((destination.origin + clamp(startInset, 0, slack), length), .start)
}
if againstEnd {
return ((destination.origin + destination.length - length - clamp(endInset, 0, slack), length), .end)
}

let centerFraction = (window.origin + window.length / 2 - source.origin) / source.length
let origin = destination.origin + centerFraction * destination.length - length / 2
return ((clamp(origin, destination.origin, destination.origin + slack), length), .neither)
}

private static func clamp(_ value: CGFloat, _ lowerBound: CGFloat, _ upperBound: CGFloat) -> CGFloat {
return min(max(value, lowerBound), upperBound)
}
}
63 changes: 38 additions & 25 deletions Rectangle/WindowCalculation/NextPrevDisplayCalculation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,32 +20,45 @@ class NextPrevDisplayCalculation: WindowCalculation {
if let screen = screen {
let rectParams = params.asRectParams(visibleFrame: screen.adjustedVisibleFrame(params.ignoreTodo))

if Defaults.attemptMatchOnNextPrevDisplay.userEnabled {
if let lastAction = params.lastAction,
let calculation = WindowCalculationFactory.calculationsByAction[lastAction.action] {

if let windowId = params.window.id {
AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId)
}

let newCalculationParams = RectCalculationParameters(
window: rectParams.window,
visibleFrameOfScreen: rectParams.visibleFrameOfScreen,
action: lastAction.action,
lastAction: nil)
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)
if Defaults.attemptMatchOnNextPrevDisplay.userEnabled,
let lastAction = params.lastAction,
let calculation = WindowCalculationFactory.calculationsByAction[lastAction.action] {

if let windowId = params.window.id {
AppDelegate.windowHistory.lastRectangleActions.removeValue(forKey: windowId)
}

let newCalculationParams = RectCalculationParameters(
window: rectParams.window,
visibleFrameOfScreen: rectParams.visibleFrameOfScreen,
action: lastAction.action,
lastAction: nil)
let rectResult = calculation.calculateRect(newCalculationParams)

return WindowCalculationResult(rect: rectResult.rect, screen: screen, resultingAction: lastAction.action)
}

let sourceFrame = params.usableScreens.currentScreen.adjustedVisibleFrame(params.ignoreTodo)

if !Defaults.keepWindowPositionOnDisplayChange.userDisabled {
// A maximized window is re-maximized below instead, so that it stays recorded as
// maximized on the destination display and can still be restored from there.
let remaximizes = params.lastAction?.action == .maximize && !Defaults.autoMaximize.userDisabled

if !remaximizes {
let transferredRect = DisplayTransfer.transferredRect(window: rectParams.window.rect,
source: sourceFrame,
destination: rectParams.visibleFrameOfScreen)
return WindowCalculationResult(rect: transferredRect, screen: screen, resultingAction: params.action)
}
} else if Defaults.attemptMatchOnNextPrevDisplay.userEnabled {
// 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 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)
Expand Down
Loading
Loading