From a40c6c546c4bd404417234f1b43866b20a36ff13 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:23:46 -0400 Subject: [PATCH] Define first-class ruler instance state --- Free Ruler/Ruler.swift | 315 +++++++++++++++++++++++++++- FreeRulerTests/RulerCoreTests.swift | 140 +++++++++++++ 2 files changed, 451 insertions(+), 4 deletions(-) diff --git a/Free Ruler/Ruler.swift b/Free Ruler/Ruler.swift index 234aa29..82b8f4d 100644 --- a/Free Ruler/Ruler.swift +++ b/Free Ruler/Ruler.swift @@ -68,6 +68,311 @@ enum RulerVerticalSide: Equatable { } } +struct RulerSettings: Equatable, Codable { + var unit: Unit + var rulerColor: NSColor + var foregroundOpacity: Int + var backgroundOpacity: Int + var floatRulers: Bool + var rulerShadow: Bool + var zeroCorner: ZeroCorner + + init( + unit: Unit = .pixels, + rulerColor: NSColor = Prefs.defaultRulerFillColor, + foregroundOpacity: Int = 90, + backgroundOpacity: Int = 50, + floatRulers: Bool = true, + rulerShadow: Bool = false, + zeroCorner: ZeroCorner = Prefs.defaultZeroCorner + ) { + self.unit = unit + self.rulerColor = RulerSettings.normalizedColor(rulerColor) + self.foregroundOpacity = foregroundOpacity + self.backgroundOpacity = backgroundOpacity + self.floatRulers = floatRulers + self.rulerShadow = rulerShadow + self.zeroCorner = zeroCorner + } + + init(defaults: Prefs = prefs) { + self.init( + unit: defaults.unit, + rulerColor: defaults.rulerColor, + foregroundOpacity: defaults.foregroundOpacity, + backgroundOpacity: defaults.backgroundOpacity, + floatRulers: defaults.floatRulers, + rulerShadow: defaults.rulerShadow, + zeroCorner: defaults.zeroCorner + ) + } + + static func == (lhs: RulerSettings, rhs: RulerSettings) -> Bool { + return lhs.unit == rhs.unit + && Prefs.colorsMatch(lhs.rulerColor, rhs.rulerColor) + && lhs.foregroundOpacity == rhs.foregroundOpacity + && lhs.backgroundOpacity == rhs.backgroundOpacity + && lhs.floatRulers == rhs.floatRulers + && lhs.rulerShadow == rhs.rulerShadow + && lhs.zeroCorner == rhs.zeroCorner + } + + private enum CodingKeys: String, CodingKey { + case unit + case rulerColor + case foregroundOpacity + case backgroundOpacity + case floatRulers + case rulerShadow + case zeroCorner + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let unitRawValue = try container.decodeIfPresent(Int.self, forKey: .unit) ?? Unit.pixels.rawValue + let zeroCornerRawValue = try container.decodeIfPresent(Int.self, forKey: .zeroCorner) + ?? Prefs.defaultZeroCorner.rawValue + let colorComponents = try container.decodeIfPresent(RulerColorComponents.self, forKey: .rulerColor) + + self.init( + unit: Unit(rawValue: unitRawValue) ?? .pixels, + rulerColor: colorComponents?.color ?? Prefs.defaultRulerFillColor, + foregroundOpacity: try container.decodeIfPresent(Int.self, forKey: .foregroundOpacity) ?? 90, + backgroundOpacity: try container.decodeIfPresent(Int.self, forKey: .backgroundOpacity) ?? 50, + floatRulers: try container.decodeIfPresent(Bool.self, forKey: .floatRulers) ?? true, + rulerShadow: try container.decodeIfPresent(Bool.self, forKey: .rulerShadow) ?? false, + zeroCorner: Prefs.zeroCorner(fromRawValue: zeroCornerRawValue) + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(unit.rawValue, forKey: .unit) + try container.encode(RulerColorComponents(color: rulerColor), forKey: .rulerColor) + try container.encode(foregroundOpacity, forKey: .foregroundOpacity) + try container.encode(backgroundOpacity, forKey: .backgroundOpacity) + try container.encode(floatRulers, forKey: .floatRulers) + try container.encode(rulerShadow, forKey: .rulerShadow) + try container.encode(zeroCorner.rawValue, forKey: .zeroCorner) + } + + private static func normalizedColor(_ color: NSColor) -> NSColor { + guard let rgbColor = color.usingColorSpace(.deviceRGB) else { + return Prefs.defaultRulerFillColor + } + + return NSColor( + deviceRed: rgbColor.redComponent, + green: rgbColor.greenComponent, + blue: rgbColor.blueComponent, + alpha: 1 + ) + } +} + +private struct RulerColorComponents: Equatable, Codable { + let red: CGFloat + let green: CGFloat + let blue: CGFloat + let alpha: CGFloat + + init(color: NSColor) { + let rgbColor = color.usingColorSpace(.deviceRGB) ?? Prefs.defaultRulerFillColor + + red = rgbColor.redComponent + green = rgbColor.greenComponent + blue = rgbColor.blueComponent + alpha = rgbColor.alphaComponent + } + + var color: NSColor { + return NSColor( + deviceRed: red, + green: green, + blue: blue, + alpha: alpha + ) + } +} + +struct RulerWingVisibility: Equatable, Codable { + private(set) var showsHorizontal: Bool + private(set) var showsVertical: Bool + + init(horizontal: Bool = true, vertical: Bool = true) { + if horizontal || vertical { + showsHorizontal = horizontal + showsVertical = vertical + } else { + showsHorizontal = true + showsVertical = true + } + } + + var hasVisibleWing: Bool { + return showsHorizontal || showsVertical + } + + func isVisible(_ orientation: Orientation) -> Bool { + switch orientation { + case .horizontal: + return showsHorizontal + case .vertical: + return showsVertical + } + } + + @discardableResult + mutating func toggle(_ orientation: Orientation) -> Bool { + return set(orientation, isVisible: !isVisible(orientation)) + } + + @discardableResult + mutating func set(_ orientation: Orientation, isVisible: Bool) -> Bool { + guard canSet(orientation, isVisible: isVisible) else { return false } + + switch orientation { + case .horizontal: + showsHorizontal = isVisible + case .vertical: + showsVertical = isVisible + } + + return true + } + + private func canSet(_ orientation: Orientation, isVisible: Bool) -> Bool { + guard !isVisible, self.isVisible(orientation) else { return true } + + switch orientation { + case .horizontal: + return showsVertical + case .vertical: + return showsHorizontal + } + } + + private enum CodingKeys: String, CodingKey { + case showsHorizontal + case showsVertical + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.init( + horizontal: try container.decodeIfPresent(Bool.self, forKey: .showsHorizontal) ?? true, + vertical: try container.decodeIfPresent(Bool.self, forKey: .showsVertical) ?? true + ) + } +} + +struct RulerLayoutState: Equatable, Codable { + var zeroPoint: NSPoint + var horizontalLength: CGFloat + var verticalLength: CGFloat + + init( + zeroPoint: NSPoint, + horizontalLength: CGFloat, + verticalLength: CGFloat + ) { + self.zeroPoint = zeroPoint + self.horizontalLength = max(0, horizontalLength) + self.verticalLength = max(0, verticalLength) + } + + init( + horizontalFrame: NSRect, + verticalFrame: NSRect, + zeroCorner: ZeroCorner + ) { + self.init( + zeroPoint: ZeroCornerGeometry(zeroCorner: zeroCorner).zeroPoint( + in: horizontalFrame, + for: .horizontal + ), + horizontalLength: horizontalFrame.width, + verticalLength: verticalFrame.height + ) + } + + static func defaults( + zeroCorner: ZeroCorner, + screenFrame: NSRect = defaultRulerScreenFrame() + ) -> RulerLayoutState { + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + + return RulerLayoutState( + horizontalFrame: geometry.defaultFrame(for: .horizontal, screenFrame: screenFrame), + verticalFrame: geometry.defaultFrame(for: .vertical, screenFrame: screenFrame), + zeroCorner: zeroCorner + ) + } + + func layout(zeroCorner: ZeroCorner) -> GroupedRulerLayout { + return GroupedRulerLayout.layout( + horizontalLength: horizontalLength, + verticalLength: verticalLength, + zeroPoint: zeroPoint, + zeroCorner: zeroCorner + ) + } +} + +struct RulerInstanceState: Identifiable, Equatable, Codable { + var id: UUID + var settings: RulerSettings + var visibility: RulerWingVisibility + var layout: RulerLayoutState + + init( + id: UUID = UUID(), + settings: RulerSettings, + visibility: RulerWingVisibility = RulerWingVisibility(), + layout: RulerLayoutState + ) { + self.id = id + self.settings = settings + self.visibility = visibility + self.layout = layout + } + + static func createFromDefaults( + id: UUID = UUID(), + defaults: RulerSettings = RulerSettings(defaults: prefs), + screenFrame: NSRect = defaultRulerScreenFrame() + ) -> RulerInstanceState { + return RulerInstanceState( + id: id, + settings: defaults, + layout: RulerLayoutState.defaults( + zeroCorner: defaults.zeroCorner, + screenFrame: screenFrame + ) + ) + } + + var hasVisibleWing: Bool { + return visibility.hasVisibleWing + } + + func isWingVisible(_ orientation: Orientation) -> Bool { + return visibility.isVisible(orientation) + } + + @discardableResult + mutating func toggleWing(_ orientation: Orientation) -> Bool { + return visibility.toggle(orientation) + } + + @discardableResult + mutating func setWing(_ orientation: Orientation, isVisible: Bool) -> Bool { + return visibility.set(orientation, isVisible: isVisible) + } +} + struct RulerCornerPlacement: Equatable { let xSide: RulerHorizontalSide let ySide: RulerVerticalSide @@ -271,15 +576,17 @@ func getDefaultContentRect(orientation: Orientation) -> NSRect { } func getDefaultContentRect(orientation: Orientation, zeroCorner: ZeroCorner) -> NSRect { - let fallbackScreenFrame = NSRect(x: 0, y: 0, width: 1000, height: 800) - let screenFrame = NSScreen.main?.frame ?? fallbackScreenFrame - return ZeroCornerGeometry(zeroCorner: zeroCorner).defaultFrame( for: orientation, - screenFrame: screenFrame + screenFrame: defaultRulerScreenFrame() ) } +func defaultRulerScreenFrame() -> NSRect { + let fallbackScreenFrame = NSRect(x: 0, y: 0, width: 1000, height: 800) + return NSScreen.main?.frame ?? fallbackScreenFrame +} + func getMinSize(ruler: Ruler) -> NSSize { switch ruler.orientation { case .horizontal: diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index c305746..b6b24ee 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -20,6 +20,124 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(ruler.name, "test-ruler") } + func testRulerInstanceCreationCopiesDefaultsWithoutControllers() { + withRestoredRulerPreferences { + prefs.unit = .inches + prefs.rulerColor = NSColor(deviceRed: 0.25, green: 0.5, blue: 0.75, alpha: 0.4) + prefs.foregroundOpacity = 82 + prefs.backgroundOpacity = 38 + prefs.floatRulers = false + prefs.rulerShadow = true + prefs.zeroCorner = .bottomRight + + let id = UUID(uuidString: "B74A48A7-235A-43DB-8C01-A7D8F44B1976")! + let screenFrame = NSRect(x: 0, y: 0, width: 1000, height: 800) + let state = RulerInstanceState.createFromDefaults( + id: id, + screenFrame: screenFrame + ) + + XCTAssertEqual(state.id, id) + XCTAssertEqual(state.settings.unit, .inches) + assertColor( + state.settings.rulerColor, + equals: NSColor(deviceRed: 0.25, green: 0.5, blue: 0.75, alpha: 1) + ) + XCTAssertEqual(state.settings.foregroundOpacity, 82) + XCTAssertEqual(state.settings.backgroundOpacity, 38) + XCTAssertFalse(state.settings.floatRulers) + XCTAssertTrue(state.settings.rulerShadow) + XCTAssertEqual(state.settings.zeroCorner, .bottomRight) + XCTAssertTrue(state.isWingVisible(.horizontal)) + XCTAssertTrue(state.isWingVisible(.vertical)) + XCTAssertEqual(state.layout.horizontalLength, 500) + XCTAssertEqual(state.layout.verticalLength, 400) + } + } + + func testRulerWingVisibilityPreservesAtLeastOneVisibleWing() { + var visibility = RulerWingVisibility(horizontal: true, vertical: false) + + XCTAssertFalse(visibility.set(.horizontal, isVisible: false)) + XCTAssertTrue(visibility.showsHorizontal) + XCTAssertFalse(visibility.showsVertical) + + XCTAssertTrue(visibility.set(.vertical, isVisible: true)) + XCTAssertTrue(visibility.set(.horizontal, isVisible: false)) + XCTAssertFalse(visibility.showsHorizontal) + XCTAssertTrue(visibility.showsVertical) + + XCTAssertFalse(visibility.toggle(.vertical)) + XCTAssertFalse(visibility.showsHorizontal) + XCTAssertTrue(visibility.showsVertical) + + let decodedFallback = RulerWingVisibility(horizontal: false, vertical: false) + XCTAssertTrue(decodedFallback.showsHorizontal) + XCTAssertTrue(decodedFallback.showsVertical) + } + + func testRulerInstanceStateStoresHorizontalOnlyVerticalOnlyAndBothWingRulers() { + let settings = RulerSettings(zeroCorner: .topLeft) + let layout = RulerLayoutState( + zeroPoint: NSPoint(x: 200, y: 300), + horizontalLength: 320, + verticalLength: 180 + ) + let both = RulerInstanceState( + settings: settings, + visibility: RulerWingVisibility(horizontal: true, vertical: true), + layout: layout + ) + let horizontalOnly = RulerInstanceState( + settings: settings, + visibility: RulerWingVisibility(horizontal: true, vertical: false), + layout: layout + ) + let verticalOnly = RulerInstanceState( + settings: settings, + visibility: RulerWingVisibility(horizontal: false, vertical: true), + layout: layout + ) + + XCTAssertTrue(both.isWingVisible(.horizontal)) + XCTAssertTrue(both.isWingVisible(.vertical)) + XCTAssertTrue(horizontalOnly.isWingVisible(.horizontal)) + XCTAssertFalse(horizontalOnly.isWingVisible(.vertical)) + XCTAssertFalse(verticalOnly.isWingVisible(.horizontal)) + XCTAssertTrue(verticalOnly.isWingVisible(.vertical)) + } + + func testRulerInstanceStateRoundTripsThroughJSON() throws { + let id = UUID(uuidString: "CBAB5338-CB56-42C5-9B76-F7B7B57D8013")! + let state = RulerInstanceState( + id: id, + settings: RulerSettings( + unit: .millimeters, + rulerColor: NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1), + foregroundOpacity: 70, + backgroundOpacity: 25, + floatRulers: false, + rulerShadow: true, + zeroCorner: .bottomLeft + ), + visibility: RulerWingVisibility(horizontal: false, vertical: true), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 120, y: 440), + horizontalLength: 640, + verticalLength: 260 + ) + ) + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(RulerInstanceState.self, from: data) + + XCTAssertEqual(decoded, state) + assertColor( + decoded.settings.rulerColor, + equals: NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) + ) + } + func testZeroCornerRawValuesPreservePersistedOrder() { XCTAssertEqual(ZeroCorner.topLeft.rawValue, 0) XCTAssertEqual(ZeroCorner.topRight.rawValue, 1) @@ -2272,6 +2390,28 @@ final class RulerCoreTests: XCTestCase { try test() } + + private func withRestoredRulerPreferences(_ test: () throws -> Void) rethrows { + let previousUnit = prefs.unit + let previousColor = prefs.rulerColor + let previousForegroundOpacity = prefs.foregroundOpacity + let previousBackgroundOpacity = prefs.backgroundOpacity + let previousFloatRulers = prefs.floatRulers + let previousRulerShadow = prefs.rulerShadow + let previousZeroCorner = prefs.zeroCorner + + defer { + prefs.unit = previousUnit + prefs.rulerColor = previousColor + prefs.foregroundOpacity = previousForegroundOpacity + prefs.backgroundOpacity = previousBackgroundOpacity + prefs.floatRulers = previousFloatRulers + prefs.rulerShadow = previousRulerShadow + prefs.zeroCorner = previousZeroCorner + } + + try test() + } } private final class ChildAttachingRulerWindow: RulerWindow {