Skip to content

Commit 5cd9943

Browse files
author
jakubnovotny
committed
Restore display arrangement in v1.1
1 parent dd9a5d2 commit 5cd9943

5 files changed

Lines changed: 99 additions & 11 deletions

File tree

Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
<key>CFBundlePackageType</key>
1616
<string>APPL</string>
1717
<key>CFBundleShortVersionString</key>
18-
<string>1.0</string>
18+
<string>1.1</string>
1919
<key>CFBundleVersion</key>
20-
<string>1</string>
20+
<string>2</string>
2121
<key>LSMinimumSystemVersion</key>
2222
<string>14.0</string>
2323
<key>NSHighResolutionCapable</key>

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Keywords people usually search for: macOS resolution override, Mac resolution ch
1414
- Map any connected external monitor to a selected virtual resolution
1515
- Store resolution profiles per monitor identity
1616
- Use a stable virtual display identity per physical monitor
17+
- Restore saved display arrangement origin after reconnect
1718
- Remove virtual displays automatically when the physical monitor is disconnected
1819
- Restore the saved mapping when the monitor is connected again
1920
- Optional launch-at-login restoration
@@ -46,7 +47,7 @@ To create a local universal app bundle and DMG installer:
4647
```sh
4748
CONFIG=debug scripts/build-release.sh
4849
open "dist/Resolution Mapper.app"
49-
open "dist/ResolutionMapper-v1.0-macos-universal.dmg"
50+
open "dist/ResolutionMapper-v1.1-macos-universal.dmg"
5051
```
5152

5253
## Attribution

Sources/ResolutionMapper/DisplayMapperModel.swift

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,9 @@ final class DisplayMapperModel: ObservableObject {
9797
return
9898
}
9999

100-
let virtualID = try createVirtualDisplay(for: targetID, width: width, height: height, hiDPI: requestedHiDPI)
101-
try setMirrorWithRetry(targetID: targetID, sourceID: virtualID)
102-
saveLastMapping(targetID: targetID, width: width, height: height, hiDPI: requestedHiDPI)
103-
refreshDisplays()
100+
let origin = displayOrigin(for: targetID)
101+
try applyMapping(targetID: targetID, width: width, height: height, hiDPI: requestedHiDPI, origin: origin)
102+
saveLastMapping(targetID: targetID, width: width, height: height, hiDPI: requestedHiDPI, origin: origin)
104103
status = "Mapped \(DisplayNames.name(for: targetID)) to \(width)x\(height)."
105104
} catch {
106105
status = error.localizedDescription
@@ -124,7 +123,19 @@ final class DisplayMapperModel: ObservableObject {
124123
customHeight = "\(mapping.height)"
125124
useHiDPI = mapping.hiDPI
126125
useCustomResolution = true
127-
applySelectedMapping()
126+
127+
Task { @MainActor in
128+
isBusy = true
129+
defer { isBusy = false }
130+
131+
do {
132+
let origin = mapping.savedOrigin ?? displayOrigin(for: targetID)
133+
try applyMapping(targetID: targetID, width: mapping.width, height: mapping.height, hiDPI: mapping.hiDPI, origin: origin)
134+
status = "Restored \(DisplayNames.name(for: targetID)) to \(mapping.width)x\(mapping.height)."
135+
} catch {
136+
status = error.localizedDescription
137+
}
138+
}
128139
}
129140

130141
func unmapSelected() {
@@ -166,6 +177,26 @@ final class DisplayMapperModel: ObservableObject {
166177
}
167178
}
168179

180+
private func applyMapping(targetID: CGDirectDisplayID, width: Int, height: Int, hiDPI: Bool, origin: CGPoint?) throws {
181+
if let origin {
182+
try setDisplayOriginWithRetry(displayID: targetID, origin: origin)
183+
}
184+
185+
let virtualID = try createVirtualDisplay(for: targetID, width: width, height: height, hiDPI: hiDPI)
186+
187+
if let origin {
188+
try setDisplayOriginWithRetry(displayID: virtualID, origin: origin)
189+
}
190+
191+
try setMirrorWithRetry(targetID: targetID, sourceID: virtualID)
192+
193+
if let origin {
194+
try setDisplayOriginWithRetry(displayID: virtualID, origin: origin)
195+
}
196+
197+
refreshDisplays()
198+
}
199+
169200
private func createVirtualDisplay(for targetID: CGDirectDisplayID, width: Int, height: Int, hiDPI: Bool) throws -> CGDirectDisplayID {
170201
let targetKey = Self.identityKey(for: targetID)
171202
let virtualSerial = Self.stableVirtualSerial(for: targetKey)
@@ -256,9 +287,57 @@ final class DisplayMapperModel: ObservableObject {
256287
throw lastError ?? MapperError.message("Mirror setup failed.")
257288
}
258289

259-
private func saveLastMapping(targetID: CGDirectDisplayID, width: Int, height: Int, hiDPI: Bool) {
290+
private func setDisplayOrigin(displayID: CGDirectDisplayID, origin: CGPoint) throws {
291+
var config: CGDisplayConfigRef?
292+
guard CGBeginDisplayConfiguration(&config) == .success else {
293+
throw MapperError.message("Could not begin display origin configuration.")
294+
}
295+
296+
let originError = CGConfigureDisplayOrigin(config, displayID, Int32(origin.x.rounded()), Int32(origin.y.rounded()))
297+
guard originError == .success else {
298+
CGCancelDisplayConfiguration(config)
299+
throw MapperError.message("Display origin setup failed: \(originError.rawValue).")
300+
}
301+
302+
let completeError = CGCompleteDisplayConfiguration(config, .forSession)
303+
guard completeError == .success else {
304+
CGCancelDisplayConfiguration(config)
305+
throw MapperError.message("Display origin configuration failed: \(completeError.rawValue).")
306+
}
307+
}
308+
309+
private func setDisplayOriginWithRetry(displayID: CGDirectDisplayID, origin: CGPoint) throws {
310+
var lastError: Error?
311+
312+
for _ in 0..<4 {
313+
do {
314+
try setDisplayOrigin(displayID: displayID, origin: origin)
315+
return
316+
} catch {
317+
lastError = error
318+
Thread.sleep(forTimeInterval: 0.25)
319+
refreshDisplays()
320+
}
321+
}
322+
323+
throw lastError ?? MapperError.message("Display origin setup failed.")
324+
}
325+
326+
private func displayOrigin(for id: CGDirectDisplayID) -> CGPoint {
327+
CGDisplayBounds(id).origin
328+
}
329+
330+
private func saveLastMapping(targetID: CGDirectDisplayID, width: Int, height: Int, hiDPI: Bool, origin: CGPoint) {
260331
let monitorKey = Self.identityKey(for: targetID)
261-
let mapping = SavedMapping(targetDisplayID: targetID, monitorKey: monitorKey, width: width, height: height, hiDPI: hiDPI)
332+
let mapping = SavedMapping(
333+
targetDisplayID: targetID,
334+
monitorKey: monitorKey,
335+
width: width,
336+
height: height,
337+
hiDPI: hiDPI,
338+
originX: Int32(origin.x.rounded()),
339+
originY: Int32(origin.y.rounded())
340+
)
262341
if let data = try? JSONEncoder().encode(mapping) {
263342
defaults.set(data, forKey: savedMappingKey)
264343
}

Sources/ResolutionMapper/Models.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,11 @@ struct SavedMapping: Codable {
4343
var width: Int
4444
var height: Int
4545
var hiDPI: Bool
46+
var originX: Int32?
47+
var originY: Int32?
48+
49+
var savedOrigin: CGPoint? {
50+
guard let originX, let originY else { return nil }
51+
return CGPoint(x: Int(originX), y: Int(originY))
52+
}
4653
}

scripts/build-release.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ PRODUCT="ResolutionMapper"
77
CONFIG="${CONFIG:-debug}"
88
DIST="$ROOT/dist"
99
APP="$DIST/$APP_NAME.app"
10-
DMG_NAME="$PRODUCT-v1.0-macos-universal"
10+
VERSION="${VERSION:-1.1}"
11+
DMG_NAME="$PRODUCT-v$VERSION-macos-universal"
1112
DMG_STAGING="$DIST/dmg-staging"
1213
DMG_BACKGROUND="$ROOT/Resources/DmgBackground.png"
1314
RW_DMG="$DIST/$DMG_NAME-rw.dmg"

0 commit comments

Comments
 (0)