From 4c63dc9a1c3fee9c9b0d9947ede5dd31826ec619 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:41:29 -0400 Subject: [PATCH 1/2] Persist and restore multiple rulers --- Free Ruler/AppDelegate.swift | 54 ++++++++++ Free Ruler/GroupedRulerWindow.swift | 22 +++- Free Ruler/Prefs.swift | 36 +++++++ Free Ruler/Ruler.swift | 33 ++++++ Free Ruler/UITestSupport+App.swift | 1 + FreeRulerTests/RulerCoreTests.swift | 154 ++++++++++++++++++++++++++++ 6 files changed, 299 insertions(+), 1 deletion(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 49029fd..6596b47 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -79,6 +79,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { settingsController.close() } } + manager.onStateChanged = { [weak self] _ in + self?.saveRulerSetState() + } return manager }() @@ -185,6 +188,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { configureUpdater() #endif + restoreSavedRulers() showRulers() } @@ -400,6 +404,55 @@ class AppDelegate: NSObject, NSApplicationDelegate { updateMouseTickTimer() } + func restoreSavedRulers() { + if let restoredState = prefs.loadRulerSetState() { + rulerManager.restore( + restoredState.rulers, + activeRulerID: restoredState.activeRulerID + ) + return + } + + if let migratedState = migratedLegacyRulerState() { + rulerManager.restore([migratedState], activeRulerID: migratedState.id) + } + } + + private func saveRulerSetState() { + prefs.saveRulerSetState( + rulers: rulerManager.states, + activeRulerID: rulerManager.activeRulerID + ) + } + + private func migratedLegacyRulerState() -> RulerInstanceState? { + let defaults = UserDefaults.standard + let horizontalAutosaveName = "horizontal-ruler" + let verticalAutosaveName = "vertical-ruler" + let hasLegacyAutosave = defaults.object(forKey: "NSWindow Frame \(horizontalAutosaveName)") != nil + || defaults.object(forKey: "NSWindow Frame \(verticalAutosaveName)") != nil + guard hasLegacyAutosave else { return nil } + + let settings = RulerSettings(defaults: prefs) + let horizontalWindow = RulerWindow( + ruler: Ruler(.horizontal, name: horizontalAutosaveName) + ) + let verticalWindow = RulerWindow( + ruler: Ruler(.vertical, name: verticalAutosaveName) + ) + _ = horizontalWindow.setFrameUsingName(NSWindow.FrameAutosaveName(horizontalAutosaveName)) + _ = verticalWindow.setFrameUsingName(NSWindow.FrameAutosaveName(verticalAutosaveName)) + + return RulerInstanceState( + settings: settings, + layout: RulerLayoutState( + horizontalFrame: horizontalWindow.frame, + verticalFrame: verticalWindow.frame, + zeroCorner: settings.zeroCorner + ) + ) + } + func toggleRuler(orientation: Orientation) { if !rulerManager.hasRulers && rulers.isEmpty { createRulersIfNeeded() @@ -1046,6 +1099,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ aNotification: Notification) { closeRulerColorPanel() + saveRulerSetState() prefs.save() } diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 6466054..6a7dfe5 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1612,6 +1612,7 @@ final class RulerManager { private(set) var controllers: [GroupedRulerController] = [] private(set) var activeRulerID: UUID? var onActiveControllerChanged: ((GroupedRulerController?) -> Void)? + var onStateChanged: ((RulerManager) -> Void)? init( initialStates: [RulerInstanceState] = [], @@ -1668,7 +1669,7 @@ final class RulerManager { return controller } - func restore(_ states: [RulerInstanceState]) { + func restore(_ states: [RulerInstanceState], activeRulerID restoredActiveRulerID: UUID? = nil) { for controller in controllers { controller.hide() } @@ -1680,6 +1681,13 @@ final class RulerManager { for state in states where state.hasVisibleWing { addRuler(state: state) } + + if let restoredActiveRulerID = restoredActiveRulerID, + let restoredActiveController = controller(id: restoredActiveRulerID) { + markActive(restoredActiveController) + } + + notifyStateChanged() } func showAll() { @@ -1708,6 +1716,8 @@ final class RulerManager { activeRulerID = controllers.last?.state.id onActiveControllerChanged?(activeController) } + + notifyStateChanged() } func markActive(_ controller: GroupedRulerController) { @@ -1715,6 +1725,7 @@ final class RulerManager { activeRulerID = controller.state.id onActiveControllerChanged?(controller) + notifyStateChanged() } func controller(containing window: NSWindow?) -> GroupedRulerController? { @@ -1723,6 +1734,10 @@ final class RulerManager { return controllers.first { $0.groupedWindow === window } } + func controller(id: UUID) -> GroupedRulerController? { + return controllers.first { $0.state.id == id } + } + private func configure(_ controller: GroupedRulerController) { controller.onBecameActive = { [weak self, weak controller] _ in guard let controller = controller else { return } @@ -1733,8 +1748,13 @@ final class RulerManager { self?.activeRulerID == controller.state.id else { return } self?.activeRulerID = controller.state.id + self?.notifyStateChanged() } } + + private func notifyStateChanged() { + onStateChanged?(self) + } } #endif diff --git a/Free Ruler/Prefs.swift b/Free Ruler/Prefs.swift index 2ec2b4e..d9a9f8e 100644 --- a/Free Ruler/Prefs.swift +++ b/Free Ruler/Prefs.swift @@ -129,6 +129,8 @@ class Prefs: NSObject { } extension Prefs { + static let rulerSetStateKey = "rulerSetState" + static var defaultZeroCorner: ZeroCorner { return .topLeft } @@ -189,4 +191,38 @@ extension Prefs { from: data ) } + + func saveRulerSetState(rulers: [RulerInstanceState], activeRulerID: UUID?) { + let visibleRulers = rulers.filter(\.hasVisibleWing) + guard !visibleRulers.isEmpty else { + clearRulerSetState() + return + } + + let activeRulerIDToSave = activeRulerID.flatMap { activeRulerID in + visibleRulers.contains { $0.id == activeRulerID } ? activeRulerID : nil + } + let state = StoredRulerSetState( + rulers: visibleRulers, + activeRulerID: activeRulerIDToSave + ) + + guard let data = try? JSONEncoder().encode(state) else { return } + + UserDefaults.standard.set(data, forKey: Self.rulerSetStateKey) + } + + func loadRulerSetState() -> StoredRulerSetState? { + guard let data = UserDefaults.standard.data(forKey: Self.rulerSetStateKey), + let state = try? JSONDecoder().decode(StoredRulerSetState.self, from: data), + state.schemaVersion == StoredRulerSetState.currentSchemaVersion else { + return nil + } + + return state.sanitizedForRestore() + } + + func clearRulerSetState() { + UserDefaults.standard.removeObject(forKey: Self.rulerSetStateKey) + } } diff --git a/Free Ruler/Ruler.swift b/Free Ruler/Ruler.swift index 914ad86..28052b5 100644 --- a/Free Ruler/Ruler.swift +++ b/Free Ruler/Ruler.swift @@ -377,6 +377,39 @@ struct RulerInstanceState: Identifiable, Equatable, Codable { } } +struct StoredRulerSetState: Equatable, Codable { + static let currentSchemaVersion = 1 + + var schemaVersion: Int + var rulers: [RulerInstanceState] + var activeRulerID: UUID? + + init( + schemaVersion: Int = StoredRulerSetState.currentSchemaVersion, + rulers: [RulerInstanceState], + activeRulerID: UUID? + ) { + self.schemaVersion = schemaVersion + self.rulers = rulers + self.activeRulerID = activeRulerID + } + + func sanitizedForRestore() -> StoredRulerSetState? { + let visibleRulers = rulers.filter(\.hasVisibleWing) + guard !visibleRulers.isEmpty else { return nil } + + let restoredActiveRulerID = activeRulerID.flatMap { activeRulerID in + visibleRulers.contains { $0.id == activeRulerID } ? activeRulerID : nil + } + + return StoredRulerSetState( + schemaVersion: schemaVersion, + rulers: visibleRulers, + activeRulerID: restoredActiveRulerID + ) + } +} + struct RulerCornerPlacement: Equatable { let xSide: RulerHorizontalSide let ySide: RulerVerticalSide diff --git a/Free Ruler/UITestSupport+App.swift b/Free Ruler/UITestSupport+App.swift index 09bb087..dede0be 100644 --- a/Free Ruler/UITestSupport+App.swift +++ b/Free Ruler/UITestSupport+App.swift @@ -12,6 +12,7 @@ extension UITestSupport { "rulerColor", "unit", "zeroCorner", + Prefs.rulerSetStateKey, "NSWindow Frame horizontal-ruler", "NSWindow Frame vertical-ruler", "NSWindow Frame preferencesWindow", diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index e2fdcd3..d76af1f 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -511,6 +511,145 @@ final class RulerCoreTests: XCTestCase { } } + func testSavedRulerSetStateRoundTripsThroughUserDefaults() { + withRestoredRulerSetState { + let firstID = UUID(uuidString: "8B425683-3E8E-4B2C-9F79-1B39FC70622D")! + let secondID = UUID(uuidString: "6B688B39-FC3E-454C-94C8-E77B3131F600")! + let states = [ + RulerInstanceState( + id: firstID, + settings: RulerSettings(unit: .pixels), + visibility: RulerWingVisibility(horizontal: true, vertical: false), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 200, y: 300), + horizontalLength: 320, + verticalLength: 180 + ) + ), + RulerInstanceState( + id: secondID, + settings: RulerSettings(unit: .inches), + visibility: RulerWingVisibility(horizontal: false, vertical: true), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 400, y: 500), + horizontalLength: 220, + verticalLength: 280 + ) + ), + ] + + prefs.saveRulerSetState(rulers: states, activeRulerID: secondID) + let restoredState = prefs.loadRulerSetState() + + XCTAssertEqual(restoredState?.schemaVersion, StoredRulerSetState.currentSchemaVersion) + XCTAssertEqual(restoredState?.rulers, states) + XCTAssertEqual(restoredState?.activeRulerID, secondID) + } + } + + func testSavedRulerSetStateFallsBackForCorruptOrUnknownSchemaData() throws { + try withRestoredRulerSetState { + UserDefaults.standard.set(Data("not-json".utf8), forKey: Prefs.rulerSetStateKey) + + XCTAssertNil(prefs.loadRulerSetState()) + + let unknownSchemaState = StoredRulerSetState( + schemaVersion: StoredRulerSetState.currentSchemaVersion + 1, + rulers: [ + RulerInstanceState.createFromDefaults() + ], + activeRulerID: nil + ) + let data = try JSONEncoder().encode(unknownSchemaState) + UserDefaults.standard.set(data, forKey: Prefs.rulerSetStateKey) + + XCTAssertNil(prefs.loadRulerSetState()) + } + } + + func testRulerManagerRestoresSavedActiveRulerID() { + let firstID = UUID(uuidString: "CE2FB5D8-109F-4482-8F54-1381075EE8C8")! + let secondID = UUID(uuidString: "3BF78AE6-446F-4C43-82B4-F7D0CFEDDE83")! + let manager = RulerManager() + defer { + for controller in manager.controllers { + controller.hide() + } + } + + manager.restore( + [ + RulerInstanceState( + id: firstID, + settings: RulerSettings(unit: .pixels), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 200, y: 300), + horizontalLength: 320, + verticalLength: 180 + ) + ), + RulerInstanceState( + id: secondID, + settings: RulerSettings(unit: .millimeters), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 400, y: 500), + horizontalLength: 220, + verticalLength: 280 + ) + ), + ], + activeRulerID: firstID + ) + + XCTAssertEqual(manager.activeController?.state.id, firstID) + } + + func testAppDelegateRestoresSavedRulerSetBeforeShowingDefaults() { + withRestoredRulerSetState { + let id = UUID(uuidString: "2D1A252A-E2AA-4BB8-9142-80F87802CFA3")! + let state = RulerInstanceState( + id: id, + settings: RulerSettings(unit: .inches, zeroCorner: .bottomRight), + visibility: RulerWingVisibility(horizontal: false, vertical: true), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 500, y: 600), + horizontalLength: 320, + verticalLength: 240 + ) + ) + prefs.saveRulerSetState(rulers: [state], activeRulerID: id) + let appDelegate = AppDelegate() + defer { + for controller in appDelegate.rulerManager.controllers { + controller.hide() + } + } + + appDelegate.restoreSavedRulers() + + XCTAssertEqual(appDelegate.rulerManager.controllers.map(\.state.id), [id]) + XCTAssertEqual(appDelegate.rulerManager.activeController?.state.id, id) + XCTAssertEqual(appDelegate.rulerManager.activeController?.state.settings.unit, .inches) + XCTAssertFalse(appDelegate.rulerManager.activeController?.state.isWingVisible(.horizontal) ?? true) + XCTAssertTrue(appDelegate.rulerManager.activeController?.state.isWingVisible(.vertical) ?? false) + } + } + + func testUITestResetClearsSavedRulerSetState() { + withRestoredRulerPreferences { + withRestoredRulerSetState { + prefs.saveRulerSetState( + rulers: [RulerInstanceState.createFromDefaults()], + activeRulerID: nil + ) + + UITestSupport.prepareForLaunch().resetApplicationState() + + XCTAssertNil(UserDefaults.standard.data(forKey: Prefs.rulerSetStateKey)) + } + } + } + func testZeroCornerRawValuesPreservePersistedOrder() { XCTAssertEqual(ZeroCorner.topLeft.rawValue, 0) XCTAssertEqual(ZeroCorner.topRight.rawValue, 1) @@ -2948,6 +3087,21 @@ final class RulerCoreTests: XCTestCase { try test() } + + private func withRestoredRulerSetState(_ test: () throws -> Void) rethrows { + let defaults = UserDefaults.standard + let previousState = defaults.object(forKey: Prefs.rulerSetStateKey) + + defer { + if let previousState = previousState { + defaults.set(previousState, forKey: Prefs.rulerSetStateKey) + } else { + defaults.removeObject(forKey: Prefs.rulerSetStateKey) + } + } + + try test() + } } private final class ChildAttachingRulerWindow: RulerWindow { From 7057889b24b8dad873c74bb119c8cbe09ae06216 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 22:30:12 -0400 Subject: [PATCH 2/2] Persist active ruler reset immediately --- Free Ruler/AppDelegate.swift | 7 +--- Free Ruler/GroupedRulerWindow.swift | 10 +++++ FreeRulerTests/RulerCoreTests.swift | 65 ++++++++++++++++------------- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 6596b47..0956b4e 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -844,12 +844,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @IBAction func resetRulerPositions(_ sender: Any) { if let controller = rulerManager.activeController { - controller.state.settings.zeroCorner = Prefs.defaultZeroCorner - controller.state.layout = RulerLayoutState.defaults( - zeroCorner: Prefs.defaultZeroCorner - ) - controller.state.visibility = RulerWingVisibility() - controller.show() + controller.resetPosition() updateDisplay() updateMouseTickTimer() return diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 6a7dfe5..e260023 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1292,6 +1292,16 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi notifyStateChanged() } + func resetPosition() { + state.settings.zeroCorner = Prefs.defaultZeroCorner + state.layout = RulerLayoutState.defaults( + zeroCorner: Prefs.defaultZeroCorner + ) + state.visibility = RulerWingVisibility() + show() + notifyStateChanged() + } + func drawMouseTick(at mouseLoc: NSPoint) { if groupedWindow.isRuleVisible(.horizontal) { groupedWindow.horizontalRule.drawMouseTick(at: mouseLoc) diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index d76af1f..ce3b7e0 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -2691,35 +2691,44 @@ final class RulerCoreTests: XCTestCase { func testManagedFlipAndResetUseActiveRulerWithoutChangingDefaults() { withRestoredRulerPreferences { - prefs.zeroCorner = .topRight - let appDelegate = AppDelegate() - let first = appDelegate.rulerManager.createRuler( - defaults: RulerSettings(zeroCorner: .bottomLeft) - ) - let second = appDelegate.rulerManager.createRuler( - defaults: RulerSettings(zeroCorner: .topLeft) - ) - defer { - first.hide() - second.hide() - } - - second.setWing(.vertical, isVisible: false) - appDelegate.rulerManager.markActive(second) - appDelegate.flipRulers(along: .horizontal) - - XCTAssertEqual(second.state.settings.zeroCorner, .topRight) - XCTAssertEqual(second.groupedWindow.horizontalRule.zeroCorner, .topRight) - XCTAssertEqual(first.state.settings.zeroCorner, .bottomLeft) - XCTAssertEqual(prefs.zeroCorner, .topRight) - - appDelegate.resetRulerPositions(self) + withRestoredRulerSetState { + prefs.zeroCorner = .topRight + let appDelegate = AppDelegate() + let first = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(zeroCorner: .bottomLeft) + ) + let second = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(zeroCorner: .topLeft) + ) + defer { + first.hide() + second.hide() + } - XCTAssertEqual(second.state.settings.zeroCorner, Prefs.defaultZeroCorner) - XCTAssertTrue(second.state.isWingVisible(.horizontal)) - XCTAssertTrue(second.state.isWingVisible(.vertical)) - XCTAssertEqual(first.state.settings.zeroCorner, .bottomLeft) - XCTAssertEqual(prefs.zeroCorner, .topRight) + second.setWing(.vertical, isVisible: false) + appDelegate.rulerManager.markActive(second) + appDelegate.flipRulers(along: .horizontal) + + XCTAssertEqual(second.state.settings.zeroCorner, .topRight) + XCTAssertEqual(second.groupedWindow.horizontalRule.zeroCorner, .topRight) + XCTAssertEqual(first.state.settings.zeroCorner, .bottomLeft) + XCTAssertEqual(prefs.zeroCorner, .topRight) + + appDelegate.resetRulerPositions(self) + + XCTAssertEqual(second.state.settings.zeroCorner, Prefs.defaultZeroCorner) + XCTAssertTrue(second.state.isWingVisible(.horizontal)) + XCTAssertTrue(second.state.isWingVisible(.vertical)) + XCTAssertEqual(first.state.settings.zeroCorner, .bottomLeft) + XCTAssertEqual(prefs.zeroCorner, .topRight) + + let restoredState = prefs.loadRulerSetState() + let savedSecond = restoredState?.rulers.first { $0.id == second.state.id } + XCTAssertEqual(restoredState?.activeRulerID, second.state.id) + XCTAssertEqual(savedSecond?.settings.zeroCorner, Prefs.defaultZeroCorner) + XCTAssertTrue(savedSecond?.visibility.showsHorizontal ?? false) + XCTAssertTrue(savedSecond?.visibility.showsVertical ?? false) + } } }