From d58b22e535b26273ec12a3990bed001951a943aa Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 01:47:23 -0400 Subject: [PATCH 01/38] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8faa99a..1fe17f6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A ruler application for macOS ### Features -- Horizontal and vertical rulers. +- Multiple rulers, with horizontal and vertical arms. - Choose from units: pixels, millimeters, or inches (press U to cycle). - Float rulers above other applications (press F to toggle). - Move rulers independently or as a group (press G to toggle). From a40c6c546c4bd404417234f1b43866b20a36ff13 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:23:46 -0400 Subject: [PATCH 02/38] 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 { From 6836a94c95b00a383c5a1e0cc0fb53d119be3122 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:28:58 -0400 Subject: [PATCH 03/38] Add multi-ruler manager and lifecycle --- Free Ruler/AppDelegate.swift | 163 ++++++++++++++++-- Free Ruler/GroupedRulerWindow.swift | 254 ++++++++++++++++++++++++++-- FreeRulerTests/RulerCoreTests.swift | 72 ++++++++ 3 files changed, 459 insertions(+), 30 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index dd81390..204472a 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -62,6 +62,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { var rulers: [RulerController] = [] var groupedRulerController: GroupedRulerController? + lazy var rulerManager: RulerManager = { + let manager = RulerManager() + manager.onActiveControllerChanged = { [weak self] controller in + self?.groupedRulerController = controller + } + return manager + }() var timer: Timer? private var timerInterval: TimeInterval? @@ -269,7 +276,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { }, prefs.observe(\Prefs.floatRulers, options: .new) { prefs, changed in self.updateFloatRulersMenuItem() - self.groupedRulerController?.updateIsFloatingPanel() + for controller in self.rulerManager.controllers { + controller.updateIsFloatingPanel() + } + self.legacyGroupedRulerController?.updateIsFloatingPanel() self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.groupRulers, options: .new) { prefs, changed in @@ -279,7 +289,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { }, prefs.observe(\Prefs.rulerShadow, options: .new) { prefs, changed in self.updateRulerShadowMenuItem() - self.groupedRulerController?.updateHasShadow() + for controller in self.rulerManager.controllers { + controller.updateHasShadow() + } + self.legacyGroupedRulerController?.updateHasShadow() self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.rulerColor, options: .new) { prefs, changed in @@ -308,7 +321,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { for ruler in rulers { ruler.rulerWindow.rule.redrawForPreferenceChange() } - groupedRulerController?.redrawForPreferenceChange() + for controller in rulerManager.controllers { + controller.redrawForPreferenceChange() + } + legacyGroupedRulerController?.redrawForPreferenceChange() } func updateFloatRulersMenuItem() { @@ -324,6 +340,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func createRulersIfNeeded() { + guard !rulerManager.hasRulers else { return } + + rulerManager.createRuler() + } + + private func createLegacyRulersIfNeeded() { guard rulers.isEmpty else { return } rulers = [ @@ -346,11 +368,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { func showRulers() { createRulersIfNeeded() - rulerVisibility.showAll() - applyRulerWindowMode(showRulersIfNeeded: true) + rulerManager.showAll() + updateMouseTickTimer() } func toggleRuler(orientation: Orientation) { + if rulerManager.hasRulers { + let controller = rulerManager.activeController ?? rulerManager.createRuler() + controller.toggleWing(orientation) + updateMouseTickTimer() + return + } + guard canToggleRulerVisibility else { return } guard rulerController(orientation: orientation) != nil else { return } @@ -370,7 +399,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func rulerController(orientation: Orientation) -> RulerController? { - createRulersIfNeeded() + createLegacyRulersIfNeeded() return existingRulerController(orientation: orientation) } @@ -400,7 +429,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func applyRulerWindowMode(showRulersIfNeeded: Bool = false) { - createRulersIfNeeded() + if rulerManager.hasRulers { + rulerManager.showAll() + updateMouseTickTimer() + return + } + + createLegacyRulersIfNeeded() detachRulerWindows() switch rulerWindowMode { @@ -494,12 +529,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { return groupedRulerController?.isVisible == true } + private var legacyGroupedRulerController: GroupedRulerController? { + guard let groupedRulerController = groupedRulerController, + !rulerManager.controllers.contains(where: { $0 === groupedRulerController }) else { + return nil + } + + return groupedRulerController + } + private func isRulerVisible(_ ruler: RulerController?) -> Bool { guard let ruler = ruler else { return false } return rulerVisibility.isVisible(ruler.ruler.orientation) } private var isRulerFrontmost: Bool { + if rulerManager.controllers.contains(where: { $0.groupedWindow.isKeyWindow }) { + return true + } + if groupedRulerController?.groupedWindow.isKeyWindow == true { return true } @@ -508,7 +556,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private var hasVisibleRuler: Bool { - return isGroupedRulerVisible || rulers.contains { $0.rulerWindow.isVisible } + return rulerManager.hasVisibleRulers + || isGroupedRulerVisible + || rulers.contains { $0.rulerWindow.isVisible } } private var canToggleRulerVisibility: Bool { @@ -528,7 +578,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { for ruler in rulers { ruler.foreground() } - groupedRulerController?.foreground() + for controller in rulerManager.controllers { + controller.foreground() + } + legacyGroupedRulerController?.foreground() mouseTickTimerPolicy.applicationDidBecomeActive() updateMouseTickTimer() @@ -540,7 +593,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { for ruler in rulers { ruler.background() } - groupedRulerController?.background() + for controller in rulerManager.controllers { + controller.background() + } + legacyGroupedRulerController?.background() mouseTickTimerPolicy.applicationDidResignActive() updateMouseTickTimer() @@ -600,7 +656,26 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + @IBAction func newRuler(_ sender: Any) { + let controller = rulerManager.createRuler() + controller.show() + updateMouseTickTimer() + } + @IBAction func closeKeyWindow(_ sender: Any) { + if let controller = rulerManager.controller(containing: NSApp.keyWindow) { + rulerManager.close(controller) + updateMouseTickTimer() + return + } + + if rulerManager.hasRulers, + NSApp.keyWindow == nil, + rulerManager.closeActiveRuler() { + updateMouseTickTimer() + return + } + if let groupedRulerController = groupedRulerController, groupedRulerController.groupedWindow.isKeyWindow { syncGroupedRulerFramesToRulerWindows(persistAutosave: true) @@ -623,6 +698,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { mouseLoc.x = mouseLoc.x.rounded() mouseLoc.y = mouseLoc.y.rounded() + if let controller = rulerManager.activeController { + controller.align(at: mouseLoc) + return + } + if prefs.groupRulers, let groupedRulerController = groupedRulerController, groupedRulerController.isVisible { @@ -637,7 +717,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @IBAction func resetRulerPositions(_ sender: Any) { - createRulersIfNeeded() + if rulerManager.hasRulers { + prefs.zeroCorner = Prefs.defaultZeroCorner + for controller in rulerManager.controllers { + controller.state.settings.zeroCorner = Prefs.defaultZeroCorner + controller.state.layout = RulerLayoutState.defaults( + zeroCorner: Prefs.defaultZeroCorner + ) + controller.state.visibility = RulerWingVisibility() + controller.show() + } + updateMouseTickTimer() + return + } + + createLegacyRulersIfNeeded() prefs.zeroCorner = Prefs.defaultZeroCorner @@ -676,7 +770,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func flipRulers(along orientation: Orientation) { - createRulersIfNeeded() + if let controller = rulerManager.activeController { + let flippedCorner = prefs.zeroCorner.flipped(along: orientation) + controller.prepareForZeroCornerChange(to: flippedCorner) + controller.state.settings.zeroCorner = flippedCorner + prefs.zeroCorner = flippedCorner + controller.redrawForPreferenceChange() + return + } + + createLegacyRulersIfNeeded() let oldGeometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) let flippedCorner = prefs.zeroCorner.flipped(along: orientation) @@ -757,6 +860,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { modifierFlags: NSEvent.ModifierFlags, sender: Any ) -> Bool { + if let controller = sender as? GroupedRulerController { + rulerManager.markActive(controller) + } + let keyboardModifiers = modifierFlags .intersection(.deviceIndependentFlagsMask) .subtracting(.capsLock) @@ -827,8 +934,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { return groupedRulerController.groupedWindow.screen } - if groupedRulerController?.groupedWindow.isKeyWindow == true { - return groupedRulerController?.groupedWindow.screen + if let activeController = rulerManager.activeController { + return activeController.groupedWindow.screen } return rulers.first { $0.rulerWindow.isKeyWindow }?.rulerWindow.screen @@ -863,15 +970,31 @@ extension AppDelegate: NSMenuItemValidation { func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { switch menuItem.action { + case #selector(newRuler(_:)): + return true case #selector(closeKeyWindow(_:)): return NSApp.keyWindow?.isVisible == true case #selector(toggleHorizontalRuler(_:)): + if let controller = rulerManager.activeController { + menuItem.title = controller.state.isWingVisible(.horizontal) + ? NSLocalizedString("Hide Horizontal Ruler", comment: "Menu item title to hide the horizontal ruler") + : NSLocalizedString("Show Horizontal Ruler", comment: "Menu item title to show the horizontal ruler") + return true + } + let ruler = existingRulerController(orientation: .horizontal) menuItem.title = isRulerVisible(ruler) ? NSLocalizedString("Hide Horizontal Ruler", comment: "Menu item title to hide the horizontal ruler") : NSLocalizedString("Show Horizontal Ruler", comment: "Menu item title to show the horizontal ruler") return canToggleRulerVisibility case #selector(toggleVerticalRuler(_:)): + if let controller = rulerManager.activeController { + menuItem.title = controller.state.isWingVisible(.vertical) + ? NSLocalizedString("Hide Vertical Ruler", comment: "Menu item title to hide the vertical ruler") + : NSLocalizedString("Show Vertical Ruler", comment: "Menu item title to show the vertical ruler") + return true + } + let ruler = existingRulerController(orientation: .vertical) menuItem.title = isRulerVisible(ruler) ? NSLocalizedString("Hide Vertical Ruler", comment: "Menu item title to hide the vertical ruler") @@ -930,7 +1053,10 @@ extension AppDelegate { ruler.rulerWindow.rule.showMouseTick = isEnabled } - groupedRulerController?.setMouseTickDrawingEnabled(isEnabled) + for controller in rulerManager.controllers { + controller.setMouseTickDrawingEnabled(isEnabled) + } + legacyGroupedRulerController?.setMouseTickDrawingEnabled(isEnabled) } private func updateMouseTickTimer() { @@ -974,6 +1100,13 @@ extension AppDelegate { mouseLoc.x = mouseLoc.x.rounded() mouseLoc.y = mouseLoc.y.rounded() + if rulerManager.hasRulers { + for controller in rulerManager.controllers where controller.isVisible { + controller.drawMouseTick(at: mouseLoc) + } + return + } + if let groupedRulerController = groupedRulerController, groupedRulerController.isVisible { groupedRulerController.drawMouseTick(at: mouseLoc) diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 80026ef..0fca3e8 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1069,6 +1069,9 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi var notificationObservers: [NSObjectProtocol] = [] let groupedWindow: GroupedRulerWindow + var state: RulerInstanceState + var onBecameActive: ((GroupedRulerController) -> Void)? + var onStateChanged: ((GroupedRulerController) -> Void)? private var keyListener: Any? private var mouseInteraction: RulerMouseInteractionState! @@ -1093,8 +1096,29 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } } - init(frame: NSRect) { - groupedWindow = GroupedRulerWindow(frame: frame) + convenience init(frame: NSRect) { + let layout = GroupedRulerLayout.layout(groupFrame: frame, zeroCorner: prefs.zeroCorner) + let state = RulerInstanceState( + settings: RulerSettings(defaults: prefs), + layout: RulerLayoutState( + horizontalFrame: layout.horizontalFrame, + verticalFrame: layout.verticalFrame, + zeroCorner: prefs.zeroCorner + ) + ) + + self.init(state: state) + } + + init(state: RulerInstanceState) { + self.state = state + let layout = state.layout.layout(zeroCorner: state.settings.zeroCorner) + groupedWindow = GroupedRulerWindow( + frame: layout.visibleFrame( + showsHorizontalRule: state.visibility.showsHorizontal, + showsVerticalRule: state.visibility.showsVertical + ) + ) super.init(window: groupedWindow) createObservers() @@ -1105,6 +1129,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi mouseInteraction = RulerMouseInteractionState(owner: self) { [weak self] event in return self?.mouseIsInsideRuler(with: event) ?? false } + applyStateToWindow(display: false) } required init?(coder: NSCoder) { @@ -1121,38 +1146,53 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi return groupedWindow.isVisible } + func show() { + applyStateToWindow(display: false) + showWindow(self) + groupedWindow.orderFrontRegardless() + } + func show( horizontalFrame: NSRect, verticalFrame: NSRect, showsHorizontalRule: Bool, showsVerticalRule: Bool ) { - let layout = GroupedRulerLayout.joined( + state.layout = RulerLayoutState( horizontalFrame: horizontalFrame, verticalFrame: verticalFrame, zeroCorner: prefs.zeroCorner ) - groupedWindow.setVisibleRules( + state.settings.zeroCorner = prefs.zeroCorner + state.visibility = RulerWingVisibility( horizontal: showsHorizontalRule, vertical: showsVerticalRule ) - updateMouseTickDrawingVisibility() - groupedWindow.setFrame( - layout.visibleFrame( - showsHorizontalRule: showsHorizontalRule, - showsVerticalRule: showsVerticalRule - ), - display: false - ) - groupedWindow.updateLayoutForCurrentZeroCorner() - showWindow(self) - groupedWindow.orderFrontRegardless() + show() } func hide() { groupedWindow.orderOut(self) } + @discardableResult + func toggleWing(_ orientation: Orientation) -> Bool { + guard state.toggleWing(orientation) else { return false } + + applyStateToWindow(display: true) + notifyStateChanged() + return true + } + + @discardableResult + func setWing(_ orientation: Orientation, isVisible: Bool) -> Bool { + guard state.setWing(orientation, isVisible: isVisible) else { return false } + + applyStateToWindow(display: true) + notifyStateChanged() + return true + } + func syncFrames( to horizontalWindow: RulerWindow, and verticalWindow: RulerWindow, @@ -1181,6 +1221,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) groupedWindow.updateLayoutForCurrentZeroCorner() + captureStateFromWindow() } func prepareForZeroCornerChange(to zeroCorner: ZeroCorner) { @@ -1195,6 +1236,8 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi ) groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) + state.settings.zeroCorner = zeroCorner + captureStateFromWindow() } func foreground() { @@ -1238,6 +1281,51 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi && groupedWindow.isRuleVisible(.vertical) } + private func applyStateToWindow(display: Bool) { + let zeroCorner = state.settings.zeroCorner + let layout = state.layout.layout(zeroCorner: zeroCorner) + groupedWindow.setVisibleRules( + horizontal: state.visibility.showsHorizontal, + vertical: state.visibility.showsVertical + ) + updateMouseTickDrawingVisibility() + groupedWindow.setFrame( + layout.visibleFrame( + showsHorizontalRule: state.visibility.showsHorizontal, + showsVerticalRule: state.visibility.showsVertical + ), + display: display + ) + groupedWindow.updateLayoutForCurrentZeroCorner() + } + + private func captureStateFromWindow() { + var horizontalLength = state.layout.horizontalLength + var verticalLength = state.layout.verticalLength + + if groupedWindow.isRuleVisible(.horizontal) { + horizontalLength = groupedWindow.screenFrame(for: .horizontal).width + } + if groupedWindow.isRuleVisible(.vertical) { + verticalLength = groupedWindow.screenFrame(for: .vertical).height + } + + state.layout = RulerLayoutState( + zeroPoint: groupedWindow.zeroPoint(), + horizontalLength: horizontalLength, + verticalLength: verticalLength + ) + state.visibility = RulerWingVisibility( + horizontal: groupedWindow.isRuleVisible(.horizontal), + vertical: groupedWindow.isRuleVisible(.vertical) + ) + notifyStateChanged() + } + + private func notifyStateChanged() { + onStateChanged?(self) + } + private func syncedRulerFrames( horizontalWindow: RulerWindow, verticalWindow: RulerWindow @@ -1312,6 +1400,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi func windowDidEndLiveResize(_ notification: Notification) { syncRulerWindowFrames(persistAutosave: true) + captureStateFromWindow() mouseInteraction.windowDidEndLiveResize() } @@ -1326,10 +1415,12 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi isLeftMouseButtonPressed: isLeftMouseButtonPressed() ) ) + captureStateFromWindow() mouseInteraction.windowDidMove(isLeftMouseButtonPressed: isLeftMouseButtonPressed()) } func windowDidBecomeKey(_ notification: Notification) { + onBecameActive?(self) startKeyListener() } @@ -1356,6 +1447,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi func finishMouseDrag(with event: NSEvent) { if mouseInteraction.finishMouseDrag(with: event) { syncRulerWindowFrames(persistAutosave: true) + captureStateFromWindow() } } @@ -1471,6 +1563,138 @@ extension GroupedRulerController { } } } + +final class RulerManager { + typealias ControllerFactory = (RulerInstanceState) -> GroupedRulerController + + private let controllerFactory: ControllerFactory + private(set) var controllers: [GroupedRulerController] = [] + private(set) var activeRulerID: UUID? + var onActiveControllerChanged: ((GroupedRulerController?) -> Void)? + + init( + initialStates: [RulerInstanceState] = [], + controllerFactory: @escaping ControllerFactory = { GroupedRulerController(state: $0) } + ) { + self.controllerFactory = controllerFactory + restore(initialStates) + } + + var hasRulers: Bool { + return !controllers.isEmpty + } + + var hasVisibleRulers: Bool { + return controllers.contains { $0.isVisible } + } + + var activeController: GroupedRulerController? { + if let activeRulerID = activeRulerID, + let controller = controllers.first(where: { $0.state.id == activeRulerID }) { + return controller + } + + if let keyController = controllers.first(where: { $0.groupedWindow.isKeyWindow }) { + return keyController + } + + return controllers.last + } + + var states: [RulerInstanceState] { + return controllers.map { $0.state } + } + + @discardableResult + func createRuler( + defaults: RulerSettings = RulerSettings(defaults: prefs), + screenFrame: NSRect = defaultRulerScreenFrame() + ) -> GroupedRulerController { + let state = RulerInstanceState.createFromDefaults( + defaults: defaults, + screenFrame: screenFrame + ) + + return addRuler(state: state) + } + + @discardableResult + func addRuler(state: RulerInstanceState) -> GroupedRulerController { + let controller = controllerFactory(state) + configure(controller) + controllers.append(controller) + markActive(controller) + return controller + } + + func restore(_ states: [RulerInstanceState]) { + for controller in controllers { + controller.hide() + } + + controllers = [] + activeRulerID = nil + onActiveControllerChanged?(nil) + + for state in states where state.hasVisibleWing { + addRuler(state: state) + } + } + + func showAll() { + for controller in controllers { + controller.show() + } + + if let activeController = activeController { + activeController.groupedWindow.makeKey() + } + } + + @discardableResult + func closeActiveRuler() -> Bool { + guard let activeController = activeController else { return false } + + close(activeController) + return true + } + + func close(_ controller: GroupedRulerController) { + controller.hide() + controllers.removeAll { $0 === controller } + + if activeRulerID == controller.state.id { + activeRulerID = controllers.last?.state.id + onActiveControllerChanged?(activeController) + } + } + + func markActive(_ controller: GroupedRulerController) { + guard controllers.contains(where: { $0 === controller }) else { return } + + activeRulerID = controller.state.id + onActiveControllerChanged?(controller) + } + + func controller(containing window: NSWindow?) -> GroupedRulerController? { + guard let window = window else { return nil } + + return controllers.first { $0.groupedWindow === window } + } + + private func configure(_ controller: GroupedRulerController) { + controller.onBecameActive = { [weak self, weak controller] _ in + guard let controller = controller else { return } + self?.markActive(controller) + } + controller.onStateChanged = { [weak self, weak controller] _ in + guard let controller = controller, + self?.activeRulerID == controller.state.id else { return } + + self?.activeRulerID = controller.state.id + } + } +} #endif private func groupedRulerLShapedPath( diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index b6b24ee..12d23e5 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -138,6 +138,78 @@ final class RulerCoreTests: XCTestCase { ) } + func testRulerManagerCreatesTracksActivatesAndClosesRulers() { + let manager = RulerManager() + defer { + for controller in manager.controllers { + controller.hide() + } + } + + let first = manager.createRuler( + defaults: RulerSettings(unit: .pixels), + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + let second = manager.createRuler( + defaults: RulerSettings(unit: .inches), + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + + XCTAssertEqual(manager.controllers.count, 2) + XCTAssertTrue(manager.activeController === second) + + manager.markActive(first) + XCTAssertTrue(manager.activeController === first) + + XCTAssertTrue(manager.closeActiveRuler()) + XCTAssertEqual(manager.controllers.count, 1) + XCTAssertTrue(manager.activeController === second) + XCTAssertEqual(manager.states.map(\.settings.unit), [.inches]) + } + + func testRulerManagerRestoresStatesAndShowsAllControllers() { + let firstID = UUID(uuidString: "F775A858-ED72-4242-B84B-E08B27EE1C9F")! + let secondID = UUID(uuidString: "D922071D-D02B-4DF7-8762-3497D9FD90B4")! + let manager = RulerManager(initialStates: [ + 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: .millimeters), + visibility: RulerWingVisibility(horizontal: false, vertical: true), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 400, y: 500), + horizontalLength: 220, + verticalLength: 280 + ) + ), + ]) + defer { + for controller in manager.controllers { + controller.hide() + } + } + + XCTAssertEqual(manager.controllers.map(\.state.id), [firstID, secondID]) + XCTAssertTrue(manager.activeController === manager.controllers.last) + + manager.showAll() + + XCTAssertTrue(manager.hasVisibleRulers) + XCTAssertTrue(manager.controllers[0].groupedWindow.isRuleVisible(.horizontal)) + XCTAssertFalse(manager.controllers[0].groupedWindow.isRuleVisible(.vertical)) + XCTAssertFalse(manager.controllers[1].groupedWindow.isRuleVisible(.horizontal)) + XCTAssertTrue(manager.controllers[1].groupedWindow.isRuleVisible(.vertical)) + } + func testZeroCornerRawValuesPreservePersistedOrder() { XCTAssertEqual(ZeroCorner.topLeft.rawValue, 0) XCTAssertEqual(ZeroCorner.topRight.rawValue, 1) From 4b03917b1fb8279f1027d8e0265a1ee1b79d58f8 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:31:47 -0400 Subject: [PATCH 04/38] Make grouped ruler windows primary --- Free Ruler/AppDelegate.swift | 13 +++++++ Free Ruler/GroupedRulerWindow.swift | 4 +-- Free Ruler/Localizable.xcstrings | 6 ++-- FreeRulerTests/RulerCoreTests.swift | 53 +++++++++++++++++++++++++++-- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 204472a..f5ce1a4 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -373,6 +373,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func toggleRuler(orientation: Orientation) { + if !rulerManager.hasRulers && rulers.isEmpty { + createRulersIfNeeded() + } + if rulerManager.hasRulers { let controller = rulerManager.activeController ?? rulerManager.createRuler() controller.toggleWing(orientation) @@ -635,6 +639,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @IBAction func toggleGroupRulers(_ sender: Any) { + guard !rulerManager.hasRulers else { return } + prefs.groupRulers = !prefs.groupRulers showGroupRulersHotkeyBezel(on: bezelScreen(for: sender)) } @@ -770,6 +776,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func flipRulers(along orientation: Orientation) { + if !rulerManager.hasRulers && rulers.isEmpty { + createRulersIfNeeded() + } + if let controller = rulerManager.activeController { let flippedCorner = prefs.zeroCorner.flipped(along: orientation) controller.prepareForZeroCornerChange(to: flippedCorner) @@ -893,6 +903,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { case kVK_ANSI_F: toggleFloatRulers(sender) case kVK_ANSI_G: + guard !rulerManager.hasRulers else { return true } toggleGroupRulers(sender) case kVK_ANSI_S: toggleRulerShadow(sender) @@ -974,6 +985,8 @@ extension AppDelegate: NSMenuItemValidation { return true case #selector(closeKeyWindow(_:)): return NSApp.keyWindow?.isVisible == true + case #selector(toggleGroupRulers(_:)): + return !rulerManager.hasRulers case #selector(toggleHorizontalRuler(_:)): if let controller = rulerManager.activeController { menuItem.title = controller.state.isWingVisible(.horizontal) diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 0fca3e8..d85f3fc 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -296,8 +296,8 @@ final class GroupedRulerWindow: NSPanel { alphaValue = windowAlphaValue(prefs.foregroundOpacity) title = NSLocalizedString( - "Grouped Rulers", - comment: "Window title for the grouped ruler window" + "Ruler", + comment: "Window title for a ruler window" ) identifier = NSUserInterfaceItemIdentifier("grouped-ruler-window") setAccessibilityIdentifier("grouped-ruler-window") diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 71f2872..0d6e296 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -43,14 +43,14 @@ } } }, - "Grouped Rulers" : { - "comment" : "Window title for the grouped ruler window", + "Ruler" : { + "comment" : "Window title for a ruler window", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Grouped Rulers" + "value" : "Ruler" } } } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 12d23e5..afcc7bb 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -2138,7 +2138,7 @@ final class RulerCoreTests: XCTestCase { } } - func testGroupedRulerHotkeysToggleLegVisibilityWithoutUngrouping() { + func testPrimaryGroupedRulerHotkeysToggleLegVisibilityWithoutLegacyWindows() { withRestoredZeroCornerPreference { let previousGroupRulers = prefs.groupRulers defer { prefs.groupRulers = previousGroupRulers } @@ -2159,12 +2159,59 @@ final class RulerCoreTests: XCTestCase { XCTAssertTrue(prefs.groupRulers) XCTAssertFalse(groupedWindow?.isRuleVisible(.horizontal) ?? true) XCTAssertTrue(groupedWindow?.isRuleVisible(.vertical) ?? false) - XCTAssertFalse(appDelegate.rulers.first { $0.ruler.orientation == .horizontal }?.rulerWindow.isVisible ?? true) - XCTAssertFalse(appDelegate.rulers.first { $0.ruler.orientation == .vertical }?.rulerWindow.isVisible ?? true) + XCTAssertTrue(appDelegate.rulers.isEmpty) groupedWindow?.orderOut(self) } } + func testManagedGroupHotkeyDoesNotToggleRetiredGroupedMode() { + withRestoredZeroCornerPreference { + let previousGroupRulers = prefs.groupRulers + defer { prefs.groupRulers = previousGroupRulers } + + prefs.groupRulers = true + let appDelegate = AppDelegate() + appDelegate.showRulers() + + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_G, + modifierFlags: [], + sender: appDelegate.groupedRulerController! + ) + ) + + XCTAssertTrue(prefs.groupRulers) + XCTAssertEqual(appDelegate.rulerManager.controllers.count, 1) + appDelegate.groupedRulerController?.hide() + } + } + + func testManagedWingHotkeysAffectOnlyActiveRuler() { + let appDelegate = AppDelegate() + let first = appDelegate.rulerManager.createRuler() + let second = appDelegate.rulerManager.createRuler() + defer { + first.hide() + second.hide() + } + + appDelegate.rulerManager.markActive(first) + + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_H, + modifierFlags: [], + sender: first + ) + ) + + XCTAssertFalse(first.groupedWindow.isRuleVisible(.horizontal)) + XCTAssertTrue(first.groupedWindow.isRuleVisible(.vertical)) + XCTAssertTrue(second.groupedWindow.isRuleVisible(.horizontal)) + XCTAssertTrue(second.groupedWindow.isRuleVisible(.vertical)) + } + func testUngroupedHorizontalFlipDoesNotMoveRulerWindows() { withRestoredZeroCornerPreference { let previousGroupRulers = prefs.groupRulers From 38885d8ac8cd52a433e4410a2b0aba1945750925 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:35:51 -0400 Subject: [PATCH 05/38] Split ruler defaults from live settings --- Free Ruler/AppDelegate.swift | 19 +++-- Free Ruler/GroupedRulerWindow.swift | 83 +++++++++++++------ Free Ruler/RuleView.swift | 11 ++- FreeRulerTests/RulerCoreTests.swift | 123 ++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 35 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index f5ce1a4..9812d29 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -271,14 +271,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { observers = [ prefs.observe(\Prefs.unit, options: .new) { prefs, changed in self.updateUnitMenu() - self.redrawRulers() + self.redrawDefaultBackedRulers() self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.floatRulers, options: .new) { prefs, changed in self.updateFloatRulersMenuItem() - for controller in self.rulerManager.controllers { - controller.updateIsFloatingPanel() - } self.legacyGroupedRulerController?.updateIsFloatingPanel() self.uiTestSupport?.writePreferencesState() }, @@ -289,17 +286,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { }, prefs.observe(\Prefs.rulerShadow, options: .new) { prefs, changed in self.updateRulerShadowMenuItem() - for controller in self.rulerManager.controllers { - controller.updateHasShadow() - } self.legacyGroupedRulerController?.updateHasShadow() self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.rulerColor, options: .new) { prefs, changed in - self.redrawRulers() + self.redrawDefaultBackedRulers() }, prefs.observe(\Prefs.zeroCorner, options: .new) { prefs, changed in - self.redrawRulers() + self.redrawDefaultBackedRulers() }, ] } @@ -327,6 +321,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { legacyGroupedRulerController?.redrawForPreferenceChange() } + func redrawDefaultBackedRulers() { + for ruler in rulers { + ruler.rulerWindow.rule.redrawForPreferenceChange() + } + legacyGroupedRulerController?.redrawForPreferenceChange() + } + func updateFloatRulersMenuItem() { floatRulersMenuItem?.state = prefs.floatRulers ? .on : .off } diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index d85f3fc..5b2ea67 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -267,8 +267,10 @@ final class GroupedRulerWindow: NSPanel { let verticalRule: VerticalRule private let groupedContentView: GroupedRulerContentView + private(set) var settings: RulerSettings - init(frame: NSRect) { + init(frame: NSRect, settings: RulerSettings = RulerSettings(defaults: prefs)) { + self.settings = settings horizontalRule = GroupedHorizontalRule( frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness) ) @@ -294,22 +296,22 @@ final class GroupedRulerWindow: NSPanel { defer: false ) - alphaValue = windowAlphaValue(prefs.foregroundOpacity) + alphaValue = windowAlphaValue(settings.foregroundOpacity) title = NSLocalizedString( "Ruler", comment: "Window title for a ruler window" ) identifier = NSUserInterfaceItemIdentifier("grouped-ruler-window") setAccessibilityIdentifier("grouped-ruler-window") - minSize = GroupedRulerLayout.minSize(zeroCorner: prefs.zeroCorner) - maxSize = GroupedRulerLayout.maxSize(zeroCorner: prefs.zeroCorner) + minSize = GroupedRulerLayout.minSize(zeroCorner: settings.zeroCorner) + maxSize = GroupedRulerLayout.maxSize(zeroCorner: settings.zeroCorner) isOpaque = false backgroundColor = .clear - isFloatingPanel = prefs.floatRulers + isFloatingPanel = settings.floatRulers hidesOnDeactivate = false isMovableByWindowBackground = true - hasShadow = prefs.rulerShadow + hasShadow = settings.rulerShadow horizontalRule.setAccessibilityElement(true) verticalRule.setAccessibilityElement(true) @@ -320,6 +322,7 @@ final class GroupedRulerWindow: NSPanel { groupedContentView.nextResponder = self contentView = groupedContentView + apply(settings: settings) updateLayoutForCurrentZeroCorner() } @@ -371,12 +374,23 @@ final class GroupedRulerWindow: NSPanel { func updateLayoutForCurrentZeroCorner() { updateSizeConstraintsForVisibleRules() updateGroupedContentFrame() - groupedContentView.zeroCorner = prefs.zeroCorner + groupedContentView.zeroCorner = settings.zeroCorner groupedContentView.needsLayout = true groupedContentView.layoutSubtreeIfNeeded() groupedContentView.needsDisplay = true } + func apply(settings: RulerSettings) { + self.settings = settings + alphaValue = windowAlphaValue(settings.foregroundOpacity) + isFloatingPanel = settings.floatRulers + hasShadow = settings.rulerShadow + horizontalRule.settingsOverride = settings + verticalRule.settingsOverride = settings + groupedContentView.color = RulerColors(customFill: settings.rulerColor) + updateLayoutForCurrentZeroCorner() + } + func redrawForPreferenceChange() { updateLayoutForCurrentZeroCorner() horizontalRule.redrawForPreferenceChange() @@ -417,7 +431,7 @@ final class GroupedRulerWindow: NSPanel { } func zeroPoint() -> NSPoint { - let geometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let geometry = ZeroCornerGeometry(zeroCorner: settings.zeroCorner) if isRuleVisible(.horizontal) { return geometry.zeroPoint( @@ -450,12 +464,12 @@ final class GroupedRulerWindow: NSPanel { private func updateSizeConstraintsForVisibleRules() { minSize = GroupedRulerLayout.minSize( - zeroCorner: prefs.zeroCorner, + zeroCorner: settings.zeroCorner, showsHorizontalRule: groupedContentView.showsHorizontalRule, showsVerticalRule: groupedContentView.showsVerticalRule ) maxSize = GroupedRulerLayout.maxSize( - zeroCorner: prefs.zeroCorner, + zeroCorner: settings.zeroCorner, showsHorizontalRule: groupedContentView.showsHorizontalRule, showsVerticalRule: groupedContentView.showsVerticalRule ) @@ -1076,6 +1090,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi private var keyListener: Any? private var mouseInteraction: RulerMouseInteractionState! private var isMouseTickDrawingEnabled = true + private let followsDefaultPreferences: Bool var isLeftMouseButtonPressed = { return NSEvent.pressedMouseButtons & 1 == 1 @@ -1085,12 +1100,12 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi didSet { updateIsFloatingPanel() if !preferencesWindowOpen { - opacity = prefs.foregroundOpacity + opacity = state.settings.foregroundOpacity } } } - var opacity = prefs.foregroundOpacity { + var opacity = 0 { didSet { groupedWindow.alphaValue = windowAlphaValue(opacity) } @@ -1107,20 +1122,27 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi ) ) - self.init(state: state) + self.init(state: state, followsDefaultPreferences: true) } - init(state: RulerInstanceState) { + convenience init(state: RulerInstanceState) { + self.init(state: state, followsDefaultPreferences: false) + } + + private init(state: RulerInstanceState, followsDefaultPreferences: Bool) { self.state = state + self.followsDefaultPreferences = followsDefaultPreferences let layout = state.layout.layout(zeroCorner: state.settings.zeroCorner) groupedWindow = GroupedRulerWindow( frame: layout.visibleFrame( showsHorizontalRule: state.visibility.showsHorizontal, showsVerticalRule: state.visibility.showsVertical - ) + ), + settings: state.settings ) super.init(window: groupedWindow) + opacity = state.settings.foregroundOpacity createObservers() subscribeToPrefs() @@ -1216,7 +1238,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi horizontalLength: horizontalLength, verticalLength: verticalLength, zeroPoint: point, - zeroCorner: prefs.zeroCorner + zeroCorner: state.settings.zeroCorner ) groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) @@ -1235,25 +1257,29 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi zeroCorner: zeroCorner ) - groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) state.settings.zeroCorner = zeroCorner + groupedWindow.apply(settings: state.settings) + groupedWindow.alphaValue = windowAlphaValue(opacity) + updateIsFloatingPanel() + updateHasShadow() + groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) captureStateFromWindow() } func foreground() { - opacity = prefs.foregroundOpacity + opacity = state.settings.foregroundOpacity } func background() { - opacity = prefs.backgroundOpacity + opacity = state.settings.backgroundOpacity } func updateIsFloatingPanel() { - groupedWindow.isFloatingPanel = preferencesWindowOpen ? false : prefs.floatRulers + groupedWindow.isFloatingPanel = preferencesWindowOpen ? false : state.settings.floatRulers } func updateHasShadow() { - groupedWindow.hasShadow = prefs.rulerShadow + groupedWindow.hasShadow = state.settings.rulerShadow } func redrawForPreferenceChange() { @@ -1284,6 +1310,10 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi private func applyStateToWindow(display: Bool) { let zeroCorner = state.settings.zeroCorner let layout = state.layout.layout(zeroCorner: zeroCorner) + groupedWindow.apply(settings: state.settings) + groupedWindow.alphaValue = windowAlphaValue(opacity) + updateIsFloatingPanel() + updateHasShadow() groupedWindow.setVisibleRules( horizontal: state.visibility.showsHorizontal, vertical: state.visibility.showsVertical @@ -1341,7 +1371,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi ) case (true, false): let horizontalFrame = groupedWindow.screenFrame(for: .horizontal) - let zeroPoint = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let zeroPoint = ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner) .zeroPoint(in: horizontalFrame, for: .horizontal) return ( horizontalFrame, @@ -1353,7 +1383,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi ) case (false, true): let verticalFrame = groupedWindow.screenFrame(for: .vertical) - let zeroPoint = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let zeroPoint = ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner) .zeroPoint(in: verticalFrame, for: .vertical) return ( hiddenRuleFrame( @@ -1373,7 +1403,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi zeroPoint: NSPoint, size: NSSize ) -> NSRect { - return ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).frame( + return ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner).frame( for: orientation, zeroPoint: zeroPoint, size: size @@ -1497,6 +1527,11 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } private func subscribeToPrefs() { + guard followsDefaultPreferences else { + observers = [] + return + } + observers = [ prefs.observe(\Prefs.foregroundOpacity, options: .new) { [weak self] prefs, changed in self?.opacity = prefs.foregroundOpacity diff --git a/Free Ruler/RuleView.swift b/Free Ruler/RuleView.swift index ea1b1ee..0651ab3 100644 --- a/Free Ruler/RuleView.swift +++ b/Free Ruler/RuleView.swift @@ -506,6 +506,13 @@ class RuleView: NSView { } } + var settingsOverride: RulerSettings? { + didSet { + color = RulerColors(customFill: settingsOverride?.rulerColor) + redrawForPreferenceChange() + } + } + var screen: NSScreen? { guard let window = window else { return nil @@ -514,11 +521,11 @@ class RuleView: NSView { } var unit: Unit { - prefs.unit + return settingsOverride?.unit ?? prefs.unit } var zeroCorner: ZeroCorner { - prefs.zeroCorner + return settingsOverride?.zeroCorner ?? prefs.zeroCorner } var resizeHandleExclusionFrame: NSRect? { diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index afcc7bb..7c3d200 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -210,6 +210,129 @@ final class RulerCoreTests: XCTestCase { XCTAssertTrue(manager.controllers[1].groupedWindow.isRuleVisible(.vertical)) } + func testManagedGroupedRulerAppliesPerRulerSettingsToWindowAndRules() { + let color = NSColor(deviceRed: 0.1, green: 0.4, blue: 0.8, alpha: 1) + let settings = RulerSettings( + unit: .inches, + rulerColor: color, + foregroundOpacity: 73, + backgroundOpacity: 31, + floatRulers: false, + rulerShadow: true, + zeroCorner: .bottomRight + ) + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: settings, + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 200, y: 300), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + defer { + controller.hide() + } + + XCTAssertEqual(controller.groupedWindow.horizontalRule.unit, .inches) + XCTAssertEqual(controller.groupedWindow.verticalRule.unit, .inches) + XCTAssertEqual(controller.groupedWindow.horizontalRule.zeroCorner, .bottomRight) + XCTAssertEqual(controller.groupedWindow.verticalRule.zeroCorner, .bottomRight) + assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: color) + assertColor(controller.groupedWindow.verticalRule.color.fill, equals: color) + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.73, accuracy: 0.0001) + XCTAssertFalse(controller.groupedWindow.isFloatingPanel) + XCTAssertTrue(controller.groupedWindow.hasShadow) + + controller.background() + + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.31, accuracy: 0.0001) + } + + func testManagedGroupedRulerIgnoresDefaultPreferenceChanges() { + withRestoredRulerPreferences { + let color = NSColor(deviceRed: 0.2, green: 0.3, blue: 0.7, alpha: 1) + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + unit: .inches, + rulerColor: color, + foregroundOpacity: 64, + backgroundOpacity: 28, + floatRulers: false, + rulerShadow: false, + zeroCorner: .bottomLeft + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + defer { + controller.hide() + } + + prefs.unit = .millimeters + prefs.rulerColor = NSColor(deviceRed: 0.9, green: 0.1, blue: 0.2, alpha: 1) + prefs.foregroundOpacity = 12 + prefs.backgroundOpacity = 9 + prefs.floatRulers = true + prefs.rulerShadow = true + prefs.zeroCorner = .topRight + + XCTAssertEqual(controller.state.settings.unit, .inches) + XCTAssertEqual(controller.groupedWindow.horizontalRule.unit, .inches) + XCTAssertEqual(controller.groupedWindow.horizontalRule.zeroCorner, .bottomLeft) + assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: color) + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.64, accuracy: 0.0001) + XCTAssertFalse(controller.groupedWindow.isFloatingPanel) + XCTAssertFalse(controller.groupedWindow.hasShadow) + } + } + + func testRulerManagerCopiesUpdatedDefaultsOnlyForNewRulers() { + withRestoredRulerPreferences { + prefs.unit = .pixels + prefs.rulerColor = NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) + prefs.zeroCorner = .topLeft + let manager = RulerManager() + defer { + for controller in manager.controllers { + controller.hide() + } + } + + let existing = manager.createRuler( + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + + prefs.unit = .millimeters + prefs.rulerColor = NSColor(deviceRed: 0.8, green: 0.7, blue: 0.2, alpha: 1) + prefs.zeroCorner = .topRight + let createdAfterDefaultsChange = manager.createRuler( + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + + XCTAssertEqual(existing.state.settings.unit, .pixels) + XCTAssertEqual(existing.groupedWindow.horizontalRule.unit, .pixels) + XCTAssertEqual(existing.groupedWindow.horizontalRule.zeroCorner, .topLeft) + assertColor( + existing.groupedWindow.horizontalRule.color.fill, + equals: NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) + ) + XCTAssertEqual(createdAfterDefaultsChange.state.settings.unit, .millimeters) + XCTAssertEqual(createdAfterDefaultsChange.groupedWindow.horizontalRule.unit, .millimeters) + XCTAssertEqual(createdAfterDefaultsChange.groupedWindow.horizontalRule.zeroCorner, .topRight) + assertColor( + createdAfterDefaultsChange.groupedWindow.horizontalRule.color.fill, + equals: NSColor(deviceRed: 0.8, green: 0.7, blue: 0.2, alpha: 1) + ) + } + } + func testZeroCornerRawValuesPreservePersistedOrder() { XCTAssertEqual(ZeroCorner.topLeft.rawValue, 0) XCTAssertEqual(ZeroCorner.topRight.rawValue, 1) From 4c45b1f2139293f3f73648ad109f44e9cc72249f Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:53:33 -0400 Subject: [PATCH 06/38] Add active ruler color settings --- Free Ruler/AppDelegate.swift | 27 ++- Free Ruler/Base.lproj/MainMenu.xib | 6 + Free Ruler/GroupedRulerWindow.swift | 6 + Free Ruler/Localizable.xcstrings | 24 +++ Free Ruler/PreferencesController.swift | 236 ++++++++++++++++++++++--- Free Ruler/Ruler.swift | 4 + FreeRulerTests/RulerCoreTests.swift | 48 +++++ 7 files changed, 328 insertions(+), 23 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 9812d29..cbc4946 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -65,7 +65,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { lazy var rulerManager: RulerManager = { let manager = RulerManager() manager.onActiveControllerChanged = { [weak self] controller in - self?.groupedRulerController = controller + guard let self = self else { return } + + self.groupedRulerController = controller + + guard let settingsController = self.rulerSettingsController, + settingsController.window?.isVisible == true else { return } + + if let controller = controller { + settingsController.updateRulerController(controller) + } else { + settingsController.close() + } } return manager }() @@ -93,6 +104,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @IBOutlet weak var alignRulersMenuItem: NSMenuItem! var preferencesController: PreferencesController? = nil + var rulerSettingsController: RulerSettingsController? = nil private let hotkeyBezel = HotkeyBezel() private var uiTestSupport: UITestSupport? private var interfaceStyleObserver: NSObjectProtocol? @@ -663,6 +675,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + @IBAction func openRulerSettings(_ sender: Any) { + guard let controller = rulerManager.activeController else { return } + + if rulerSettingsController == nil { + rulerSettingsController = RulerSettingsController(rulerController: controller) + } + + rulerSettingsController?.updateRulerController(controller) + rulerSettingsController?.showWindow(sender) + } + @IBAction func newRuler(_ sender: Any) { let controller = rulerManager.createRuler() controller.show() @@ -984,6 +1007,8 @@ extension AppDelegate: NSMenuItemValidation { switch menuItem.action { case #selector(newRuler(_:)): return true + case #selector(openRulerSettings(_:)): + return rulerManager.activeController != nil case #selector(closeKeyWindow(_:)): return NSApp.keyWindow?.isVisible == true case #selector(toggleGroupRulers(_:)): diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index 9813031..aa26542 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -98,6 +98,12 @@ + + + + + + diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 5b2ea67..6466054 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1286,6 +1286,12 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi groupedWindow.redrawForPreferenceChange() } + func updateSettings(_ update: (inout RulerSettings) -> Void) { + update(&state.settings) + applyStateToWindow(display: true) + notifyStateChanged() + } + func drawMouseTick(at mouseLoc: NSPoint) { if groupedWindow.isRuleVisible(.horizontal) { groupedWindow.horizontalRule.drawMouseTick(at: mouseLoc) diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 0d6e296..171864b 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -43,6 +43,18 @@ } } }, + "Color" : { + "comment" : "Label for the active ruler color setting", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color" + } + } + } + }, "Ruler" : { "comment" : "Window title for a ruler window", "extractionState" : "manual", @@ -55,6 +67,18 @@ } } }, + "Ruler Settings" : { + "comment" : "Window title for the active ruler settings panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruler Settings" + } + } + } + }, "Hide Horizontal Ruler" : { "comment" : "Menu item title to hide the horizontal ruler", "extractionState" : "manual", diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index feb9d3c..43f4487 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -3,6 +3,7 @@ import Cocoa private let colorPanelOpaqueConfigurationRetryDelays: [TimeInterval] = [0.1, 0.3] private let rulerColorPanelIdentifier = NSUserInterfaceItemIdentifier("ruler-color-panel") private let rulerColorPanelOpaqueAccessibilityValue = "ruler-color-panel-alpha-hidden" +private weak var activeRulerColorWell: RulerColorWell? func configureOpaqueColorPicking() { let colorPanel = NSColorPanel.shared @@ -57,6 +58,7 @@ class RulerColorWell: NSColorWell { return } + activeRulerColorWell = self colorPanel.animationBehavior = .none colorPanel.color = color colorPanel.setTarget(self) @@ -88,6 +90,30 @@ class RulerColorWell: NSColorWell { } +private func configureResetRulerColorButtonAppearance(_ button: NSButton, identifier: String) { + let resetRulerColorLabel = NSLocalizedString( + "Reset ruler color", + comment: "Tooltip and accessibility label for the button that restores the default ruler color" + ) + let symbol = NSImage( + systemSymbolName: "arrow.counterclockwise", + accessibilityDescription: resetRulerColorLabel + )?.withSymbolConfiguration( + NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) + ) ?? NSImage() + symbol.isTemplate = true + + button.image = symbol + button.isBordered = false + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.contentTintColor = .secondaryLabelColor + button.toolTip = resetRulerColorLabel + button.identifier = NSUserInterfaceItemIdentifier(identifier) + button.setAccessibilityIdentifier(identifier) + button.setAccessibilityLabel(resetRulerColorLabel) +} + class PreferencesController: NSWindowController, NSWindowDelegate, NotificationPoster { var observers: [NSKeyValueObservation] = [] @@ -243,27 +269,7 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP } private func configureResetRulerColorButton() { - let resetRulerColorLabel = NSLocalizedString( - "Reset ruler color", - comment: "Tooltip and accessibility label for the button that restores the default ruler color" - ) - let symbol = NSImage( - systemSymbolName: "arrow.counterclockwise", - accessibilityDescription: resetRulerColorLabel - )?.withSymbolConfiguration( - NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) - ) ?? NSImage() - symbol.isTemplate = true - - resetRulerColorButton.image = symbol - resetRulerColorButton.isBordered = false - resetRulerColorButton.imagePosition = .imageOnly - resetRulerColorButton.imageScaling = .scaleProportionallyDown - resetRulerColorButton.contentTintColor = .secondaryLabelColor - resetRulerColorButton.toolTip = resetRulerColorLabel - resetRulerColorButton.identifier = NSUserInterfaceItemIdentifier("reset-ruler-color-button") - resetRulerColorButton.setAccessibilityIdentifier("reset-ruler-color-button") - resetRulerColorButton.setAccessibilityLabel(resetRulerColorLabel) + configureResetRulerColorButtonAppearance(resetRulerColorButton, identifier: "reset-ruler-color-button") } private func subscribeToColorPanel() { @@ -279,14 +285,200 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP private func updateRulerColorFromColorPanel(_ notification: Notification) { guard window?.isVisible == true, let colorPanel = notification.object as? NSColorPanel, - colorPanel.isVisible else { return } + colorPanel.isVisible, + activeRulerColorWell === rulerColorWell else { return } prefs.rulerColor = colorPanel.color } } +final class RulerSettingsController: NSWindowController, NSWindowDelegate { + + private weak var rulerController: GroupedRulerController? + private var colorPanelObserver: NSObjectProtocol? + private var hasCenteredWindow = false + + private let colorLabel: NSTextField + let rulerColorWell: RulerColorWell + let resetRulerColorButton: NSButton + + var currentRulerController: GroupedRulerController? { + return rulerController + } + + init(rulerController: GroupedRulerController) { + self.rulerController = rulerController + + colorLabel = NSTextField( + labelWithString: NSLocalizedString( + "Color", + comment: "Label for the active ruler color setting" + ) + ) + rulerColorWell = RulerColorWell(frame: NSRect(x: 0, y: 0, width: 64, height: 30)) + resetRulerColorButton = NSButton(frame: .zero) + + let window = RulerSettingsController.makeWindow( + colorLabel: colorLabel, + rulerColorWell: rulerColorWell, + resetRulerColorButton: resetRulerColorButton + ) + + super.init(window: window) + + window.delegate = self + window.initialFirstResponder = rulerColorWell + configureControls() + subscribeToColorPanel() + updateView() + } + + required init?(coder: NSCoder) { + nil + } + + deinit { + if let colorPanelObserver = colorPanelObserver { + NotificationCenter.default.removeObserver(colorPanelObserver) + } + } + + override func showWindow(_ sender: Any?) { + configureOpaqueColorPicking() + updateView() + window?.makeKeyAndOrderFront(sender) + window?.makeFirstResponder(rulerColorWell) + + if !hasCenteredWindow { + window?.center() + hasCenteredWindow = true + } + } + + func windowWillClose(_ notification: Notification) { + rulerColorWell.deactivate() + closeRulerColorPanel() + } + + func updateRulerController(_ controller: GroupedRulerController) { + rulerController = controller + updateView() + } + + @objc func setRulerColor(_ sender: Any) { + applyRulerColor(rulerColorWell.color) + } + + @objc func resetRulerColor(_ sender: Any) { + applyRulerColor(Prefs.defaultRulerFillColor) + } + + func updateView() { + let currentColor = rulerController?.state.settings.rulerColor ?? Prefs.defaultRulerFillColor + let hasRuler = rulerController != nil + + rulerColorWell.supportsAlpha = false + rulerColorWell.color = currentColor + rulerColorWell.isEnabled = hasRuler + resetRulerColorButton.isEnabled = hasRuler + resetRulerColorButton.isHidden = Prefs.colorsMatch(currentColor, Prefs.defaultRulerFillColor) + } + + private static func makeWindow( + colorLabel: NSTextField, + rulerColorWell: RulerColorWell, + resetRulerColorButton: NSButton + ) -> NSPanel { + let contentView = NSView() + let row = NSStackView(views: [colorLabel, rulerColorWell, resetRulerColorButton]) + row.orientation = .horizontal + row.alignment = .centerY + row.distribution = .fill + row.spacing = 12 + row.translatesAutoresizingMaskIntoConstraints = false + + colorLabel.alignment = .right + colorLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + rulerColorWell.translatesAutoresizingMaskIntoConstraints = false + resetRulerColorButton.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(row) + NSLayoutConstraint.activate([ + colorLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 68), + rulerColorWell.widthAnchor.constraint(equalToConstant: 64), + rulerColorWell.heightAnchor.constraint(equalToConstant: 30), + resetRulerColorButton.widthAnchor.constraint(equalToConstant: 24), + resetRulerColorButton.heightAnchor.constraint(equalToConstant: 24), + row.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + row.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + row.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 18), + row.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18), + ]) + + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 72), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = NSLocalizedString( + "Ruler Settings", + comment: "Window title for the active ruler settings panel" + ) + window.contentView = contentView + window.identifier = NSUserInterfaceItemIdentifier("ruler-settings-window") + window.setAccessibilityIdentifier("ruler-settings-window") + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + return window + } + + private func configureControls() { + rulerColorWell.isContinuous = true + rulerColorWell.supportsAlpha = false + rulerColorWell.identifier = NSUserInterfaceItemIdentifier("ruler-settings-color-well") + rulerColorWell.setAccessibilityIdentifier("ruler-settings-color-well") + rulerColorWell.target = self + rulerColorWell.action = #selector(setRulerColor(_:)) + + resetRulerColorButton.target = self + resetRulerColorButton.action = #selector(resetRulerColor(_:)) + configureResetRulerColorButtonAppearance( + resetRulerColorButton, + identifier: "reset-ruler-settings-color-button" + ) + } + + private func applyRulerColor(_ color: NSColor) { + rulerController?.updateSettings { settings in + settings.setRulerColor(color) + } + updateView() + } + + private func subscribeToColorPanel() { + colorPanelObserver = NotificationCenter.default.addObserver( + forName: NSColorPanel.colorDidChangeNotification, + object: NSColorPanel.shared, + queue: .main + ) { [weak self] notification in + self?.updateRulerColorFromColorPanel(notification) + } + } + + private func updateRulerColorFromColorPanel(_ notification: Notification) { + guard window?.isVisible == true, + let colorPanel = notification.object as? NSColorPanel, + colorPanel.isVisible, + activeRulerColorWell === rulerColorWell else { return } + + applyRulerColor(colorPanel.color) + } +} + func closeRulerColorPanel() { + activeRulerColorWell = nil let colorPanel = NSColorPanel.shared colorPanel.animationBehavior = .none colorPanel.setTarget(nil) diff --git a/Free Ruler/Ruler.swift b/Free Ruler/Ruler.swift index 82b8f4d..914ad86 100644 --- a/Free Ruler/Ruler.swift +++ b/Free Ruler/Ruler.swift @@ -157,6 +157,10 @@ struct RulerSettings: Equatable, Codable { try container.encode(zeroCorner.rawValue, forKey: .zeroCorner) } + mutating func setRulerColor(_ color: NSColor) { + rulerColor = RulerSettings.normalizedColor(color) + } + private static func normalizedColor(_ color: NSColor) -> NSColor { guard let rgbColor = color.usingColorSpace(.deviceRGB) else { return Prefs.defaultRulerFillColor diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 7c3d200..54bcefb 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -293,6 +293,54 @@ final class RulerCoreTests: XCTestCase { } } + func testRulerSettingsControllerUpdatesRulerColorWithoutChangingDefaults() { + withRestoredRulerPreferences { + let defaultColor = NSColor(deviceRed: 0.15, green: 0.25, blue: 0.35, alpha: 1) + prefs.rulerColor = defaultColor + + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + rulerColor: NSColor(deviceRed: 0.4, green: 0.5, blue: 0.6, alpha: 1) + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + settingsController.rulerColorWell.color = NSColor( + deviceRed: 0.8, + green: 0.2, + blue: 0.4, + alpha: 0.35 + ) + settingsController.setRulerColor(settingsController.rulerColorWell) + + let normalizedColor = NSColor(deviceRed: 0.8, green: 0.2, blue: 0.4, alpha: 1) + assertColor(controller.state.settings.rulerColor, equals: normalizedColor) + assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: normalizedColor) + assertColor(controller.groupedWindow.verticalRule.color.fill, equals: normalizedColor) + assertColor(settingsController.rulerColorWell.color, equals: normalizedColor) + assertColor(prefs.rulerColor, equals: defaultColor) + XCTAssertFalse(settingsController.resetRulerColorButton.isHidden) + + settingsController.resetRulerColor(settingsController.resetRulerColorButton) + + assertColor(controller.state.settings.rulerColor, equals: Prefs.defaultRulerFillColor) + assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: Prefs.defaultRulerFillColor) + assertColor(prefs.rulerColor, equals: defaultColor) + XCTAssertTrue(settingsController.resetRulerColorButton.isHidden) + } + } + func testRulerManagerCopiesUpdatedDefaultsOnlyForNewRulers() { withRestoredRulerPreferences { prefs.unit = .pixels From e8263da430e714f690c7ffeb6dccdef454b33457 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 02:13:17 -0400 Subject: [PATCH 07/38] Expand active ruler settings sheet --- Free Ruler/AppDelegate.swift | 5 +- Free Ruler/Localizable.xcstrings | 210 +++++++++++++++++ Free Ruler/PreferencesController.swift | 304 +++++++++++++++++++++---- FreeRulerTests/RulerCoreTests.swift | 67 +++++- 4 files changed, 542 insertions(+), 44 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index cbc4946..ba5aed8 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -73,7 +73,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { settingsController.window?.isVisible == true else { return } if let controller = controller { - settingsController.updateRulerController(controller) + settingsController.show(attachedTo: controller, sender: self) } else { settingsController.close() } @@ -682,8 +682,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { rulerSettingsController = RulerSettingsController(rulerController: controller) } - rulerSettingsController?.updateRulerController(controller) - rulerSettingsController?.showWindow(sender) + rulerSettingsController?.show(attachedTo: controller, sender: sender) } @IBAction func newRuler(_ sender: Any) { diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 171864b..17f6583 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -79,6 +79,216 @@ } } }, + "Ruler Color" : { + "comment" : "Label for the active ruler color setting", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linealfarbe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruler Color" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color de la regla" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viivaimen väri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "定規の色" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尺子颜色" + } + } + } + }, + "Foreground Opacity" : { + "comment" : "Label for the active ruler foreground opacity setting", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deckkraft im Vordergrund" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foreground Opacity" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opacidad del primer plano" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peittävyys edustalla" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "前景の不透明度" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前景不透明度" + } + } + } + }, + "Background Opacity" : { + "comment" : "Label for the active ruler background opacity setting", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deckkraft im Hintergrund" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Background Opacity" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opacidad del fondo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peittävyys taustalla" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "背景の不透明度" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "背景不透明度" + } + } + } + }, + "Float rulers above other applications" : { + "comment" : "Checkbox title for whether the active ruler floats above other apps", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lineale schweben über anderen Programmen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Float rulers above other applications" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reglas flotantes sobre otras aplicaciones" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kelluta viivaimia muiden sovellusten päällä" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "定規を常に手前に表示" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "让标尺窗口悬浮在其他应用程序之上" + } + } + } + }, + "Show ruler shadow" : { + "comment" : "Checkbox title for whether the active ruler draws a window shadow", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linealschatten anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show ruler shadow" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar sombra de la regla" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Näytä viivainten varjot" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "定規の影を表示" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示尺子阴影" + } + } + } + }, "Hide Horizontal Ruler" : { "comment" : "Menu item title to hide the horizontal ruler", "extractionState" : "manual", diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index 43f4487..dcfb30b 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -297,11 +297,15 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { private weak var rulerController: GroupedRulerController? private var colorPanelObserver: NSObjectProtocol? - private var hasCenteredWindow = false - private let colorLabel: NSTextField let rulerColorWell: RulerColorWell let resetRulerColorButton: NSButton + let foregroundOpacitySlider: NSSlider + let backgroundOpacitySlider: NSSlider + let foregroundOpacityLabel: NSTextField + let backgroundOpacityLabel: NSTextField + let floatRulersCheckbox: NSButton + let rulerShadowCheckbox: NSButton var currentRulerController: GroupedRulerController? { return rulerController @@ -310,19 +314,38 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { init(rulerController: GroupedRulerController) { self.rulerController = rulerController - colorLabel = NSTextField( - labelWithString: NSLocalizedString( - "Color", - comment: "Label for the active ruler color setting" - ) - ) rulerColorWell = RulerColorWell(frame: NSRect(x: 0, y: 0, width: 64, height: 30)) resetRulerColorButton = NSButton(frame: .zero) + foregroundOpacitySlider = RulerSettingsController.makeOpacitySlider() + backgroundOpacitySlider = RulerSettingsController.makeOpacitySlider() + foregroundOpacityLabel = RulerSettingsController.makeValueLabel() + backgroundOpacityLabel = RulerSettingsController.makeValueLabel() + floatRulersCheckbox = NSButton( + checkboxWithTitle: NSLocalizedString( + "Float rulers above other applications", + comment: "Checkbox title for whether the active ruler floats above other apps" + ), + target: nil, + action: nil + ) + rulerShadowCheckbox = NSButton( + checkboxWithTitle: NSLocalizedString( + "Show ruler shadow", + comment: "Checkbox title for whether the active ruler draws a window shadow" + ), + target: nil, + action: nil + ) let window = RulerSettingsController.makeWindow( - colorLabel: colorLabel, rulerColorWell: rulerColorWell, - resetRulerColorButton: resetRulerColorButton + resetRulerColorButton: resetRulerColorButton, + foregroundOpacitySlider: foregroundOpacitySlider, + backgroundOpacitySlider: backgroundOpacitySlider, + foregroundOpacityLabel: foregroundOpacityLabel, + backgroundOpacityLabel: backgroundOpacityLabel, + floatRulersCheckbox: floatRulersCheckbox, + rulerShadowCheckbox: rulerShadowCheckbox ) super.init(window: window) @@ -345,20 +368,61 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } override func showWindow(_ sender: Any?) { + endSheetIfNeeded() configureOpaqueColorPicking() updateView() window?.makeKeyAndOrderFront(sender) window?.makeFirstResponder(rulerColorWell) + window?.center() + } + + func show(attachedTo controller: GroupedRulerController, sender: Any?) { + updateRulerController(controller) + guard let settingsWindow = window else { return } + + configureOpaqueColorPicking() + + if settingsWindow.sheetParent === controller.groupedWindow { + settingsWindow.makeFirstResponder(rulerColorWell) + return + } + + endSheetIfNeeded() + + guard controller.groupedWindow.isVisible else { + showWindow(sender) + return + } + + if settingsWindow.isVisible { + settingsWindow.orderOut(sender) + } - if !hasCenteredWindow { - window?.center() - hasCenteredWindow = true + controller.groupedWindow.beginSheet(settingsWindow) { [weak self] _ in + self?.closeSheetColorControls() } + settingsWindow.makeFirstResponder(rulerColorWell) + } + + override func close() { + guard let settingsWindow = window, + settingsWindow.sheetParent != nil else { + super.close() + return + } + + endSheetIfNeeded() + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + guard sender.sheetParent != nil else { return true } + + endSheetIfNeeded() + return false } func windowWillClose(_ notification: Notification) { - rulerColorWell.deactivate() - closeRulerColorPanel() + closeSheetColorControls() } func updateRulerController(_ controller: GroupedRulerController) { @@ -366,6 +430,36 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { updateView() } + @objc func setForegroundOpacity(_ sender: Any) { + applySettings { settings in + settings.foregroundOpacity = foregroundOpacitySlider.integerValue + } + rulerController?.opacity = foregroundOpacitySlider.integerValue + updateView() + } + + @objc func setBackgroundOpacity(_ sender: Any) { + applySettings { settings in + settings.backgroundOpacity = backgroundOpacitySlider.integerValue + } + rulerController?.opacity = backgroundOpacitySlider.integerValue + updateView() + } + + @objc func setFloatRulers(_ sender: Any) { + applySettings { settings in + settings.floatRulers = floatRulersCheckbox.state == .on + } + updateView() + } + + @objc func setRulerShadow(_ sender: Any) { + applySettings { settings in + settings.rulerShadow = rulerShadowCheckbox.state == .on + } + updateView() + } + @objc func setRulerColor(_ sender: Any) { applyRulerColor(rulerColorWell.color) } @@ -376,6 +470,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { func updateView() { let currentColor = rulerController?.state.settings.rulerColor ?? Prefs.defaultRulerFillColor + let currentSettings = rulerController?.state.settings let hasRuler = rulerController != nil rulerColorWell.supportsAlpha = false @@ -383,42 +478,109 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { rulerColorWell.isEnabled = hasRuler resetRulerColorButton.isEnabled = hasRuler resetRulerColorButton.isHidden = Prefs.colorsMatch(currentColor, Prefs.defaultRulerFillColor) + foregroundOpacitySlider.isEnabled = hasRuler + backgroundOpacitySlider.isEnabled = hasRuler + floatRulersCheckbox.isEnabled = hasRuler + rulerShadowCheckbox.isEnabled = hasRuler + + foregroundOpacitySlider.integerValue = currentSettings?.foregroundOpacity ?? 90 + backgroundOpacitySlider.integerValue = currentSettings?.backgroundOpacity ?? 50 + foregroundOpacityLabel.stringValue = "\(foregroundOpacitySlider.integerValue)%" + backgroundOpacityLabel.stringValue = "\(backgroundOpacitySlider.integerValue)%" + floatRulersCheckbox.state = currentSettings?.floatRulers == true ? .on : .off + rulerShadowCheckbox.state = currentSettings?.rulerShadow == true ? .on : .off } private static func makeWindow( - colorLabel: NSTextField, rulerColorWell: RulerColorWell, - resetRulerColorButton: NSButton + resetRulerColorButton: NSButton, + foregroundOpacitySlider: NSSlider, + backgroundOpacitySlider: NSSlider, + foregroundOpacityLabel: NSTextField, + backgroundOpacityLabel: NSTextField, + floatRulersCheckbox: NSButton, + rulerShadowCheckbox: NSButton ) -> NSPanel { let contentView = NSView() - let row = NSStackView(views: [colorLabel, rulerColorWell, resetRulerColorButton]) - row.orientation = .horizontal - row.alignment = .centerY - row.distribution = .fill - row.spacing = 12 - row.translatesAutoresizingMaskIntoConstraints = false - - colorLabel.alignment = .right - colorLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + let colorLabel = makeLabel( + NSLocalizedString( + "Ruler Color", + comment: "Label for the active ruler color setting" + ) + ) + let foregroundLabel = makeLabel( + NSLocalizedString( + "Foreground Opacity", + comment: "Label for the active ruler foreground opacity setting" + ) + ) + let backgroundLabel = makeLabel( + NSLocalizedString( + "Background Opacity", + comment: "Label for the active ruler background opacity setting" + ) + ) + let colorRow = NSStackView(views: [colorLabel, resetRulerColorButton, rulerColorWell]) + let foregroundHeaderRow = NSStackView(views: [foregroundLabel, foregroundOpacityLabel]) + let backgroundHeaderRow = NSStackView(views: [backgroundLabel, backgroundOpacityLabel]) + let contentStack = NSStackView(views: [ + colorRow, + foregroundHeaderRow, + foregroundOpacitySlider, + backgroundHeaderRow, + backgroundOpacitySlider, + floatRulersCheckbox, + rulerShadowCheckbox, + ]) + + for row in [colorRow, foregroundHeaderRow, backgroundHeaderRow] { + row.orientation = .horizontal + row.alignment = .centerY + row.distribution = .fill + row.spacing = 10 + row.translatesAutoresizingMaskIntoConstraints = false + } + + contentStack.orientation = .vertical + contentStack.alignment = .leading + contentStack.distribution = .fill + contentStack.spacing = 8 + contentStack.translatesAutoresizingMaskIntoConstraints = false + + colorLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + foregroundLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + backgroundLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + foregroundOpacityLabel.setContentHuggingPriority(.required, for: .horizontal) + backgroundOpacityLabel.setContentHuggingPriority(.required, for: .horizontal) rulerColorWell.translatesAutoresizingMaskIntoConstraints = false resetRulerColorButton.translatesAutoresizingMaskIntoConstraints = false + foregroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false + backgroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false + floatRulersCheckbox.translatesAutoresizingMaskIntoConstraints = false + rulerShadowCheckbox.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(row) + contentView.addSubview(contentStack) NSLayoutConstraint.activate([ - colorLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 68), - rulerColorWell.widthAnchor.constraint(equalToConstant: 64), - rulerColorWell.heightAnchor.constraint(equalToConstant: 30), - resetRulerColorButton.widthAnchor.constraint(equalToConstant: 24), - resetRulerColorButton.heightAnchor.constraint(equalToConstant: 24), - row.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - row.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - row.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 18), - row.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18), + colorRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + foregroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + backgroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + rulerColorWell.widthAnchor.constraint(equalToConstant: 60), + rulerColorWell.heightAnchor.constraint(equalToConstant: 24), + resetRulerColorButton.widthAnchor.constraint(equalToConstant: 28), + resetRulerColorButton.heightAnchor.constraint(equalToConstant: 28), + foregroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + backgroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + floatRulersCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), + rulerShadowCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), + contentStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + contentStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + contentStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 18), + contentStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18), ]) let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 72), - styleMask: [.titled, .closable, .utilityWindow], + contentRect: NSRect(x: 0, y: 0, width: 315, height: 232), + styleMask: [.titled, .closable], backing: .buffered, defer: false ) @@ -434,6 +596,30 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { return window } + private static func makeLabel(_ title: String) -> NSTextField { + let label = NSTextField(labelWithString: title) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return label + } + + private static func makeValueLabel() -> NSTextField { + let label = NSTextField(labelWithString: "") + label.alignment = .right + return label + } + + private static func makeOpacitySlider() -> NSSlider { + let slider = NSSlider(frame: .zero) + slider.minValue = 5 + slider.maxValue = 100 + slider.doubleValue = 50 + slider.numberOfTickMarks = 20 + slider.allowsTickMarkValuesOnly = true + slider.tickMarkPosition = .below + slider.isContinuous = true + return slider + } + private func configureControls() { rulerColorWell.isContinuous = true rulerColorWell.supportsAlpha = false @@ -448,15 +634,43 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { resetRulerColorButton, identifier: "reset-ruler-settings-color-button" ) + + foregroundOpacitySlider.target = self + foregroundOpacitySlider.action = #selector(setForegroundOpacity(_:)) + foregroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier("ruler-settings-foreground-opacity-slider") + foregroundOpacitySlider.setAccessibilityIdentifier("ruler-settings-foreground-opacity-slider") + foregroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier("ruler-settings-foreground-opacity-label") + foregroundOpacityLabel.setAccessibilityIdentifier("ruler-settings-foreground-opacity-label") + + backgroundOpacitySlider.target = self + backgroundOpacitySlider.action = #selector(setBackgroundOpacity(_:)) + backgroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier("ruler-settings-background-opacity-slider") + backgroundOpacitySlider.setAccessibilityIdentifier("ruler-settings-background-opacity-slider") + backgroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier("ruler-settings-background-opacity-label") + backgroundOpacityLabel.setAccessibilityIdentifier("ruler-settings-background-opacity-label") + + floatRulersCheckbox.target = self + floatRulersCheckbox.action = #selector(setFloatRulers(_:)) + floatRulersCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-float-rulers-checkbox") + floatRulersCheckbox.setAccessibilityIdentifier("ruler-settings-float-rulers-checkbox") + + rulerShadowCheckbox.target = self + rulerShadowCheckbox.action = #selector(setRulerShadow(_:)) + rulerShadowCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-ruler-shadow-checkbox") + rulerShadowCheckbox.setAccessibilityIdentifier("ruler-settings-ruler-shadow-checkbox") } private func applyRulerColor(_ color: NSColor) { - rulerController?.updateSettings { settings in + applySettings { settings in settings.setRulerColor(color) } updateView() } + private func applySettings(_ update: (inout RulerSettings) -> Void) { + rulerController?.updateSettings(update) + } + private func subscribeToColorPanel() { colorPanelObserver = NotificationCenter.default.addObserver( forName: NSColorPanel.colorDidChangeNotification, @@ -475,6 +689,18 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { applyRulerColor(colorPanel.color) } + + private func endSheetIfNeeded() { + guard let settingsWindow = window, + let parentWindow = settingsWindow.sheetParent else { return } + + parentWindow.endSheet(settingsWindow) + } + + private func closeSheetColorControls() { + rulerColorWell.deactivate() + closeRulerColorPanel() + } } func closeRulerColorPanel() { diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 54bcefb..f58d738 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -293,15 +293,23 @@ final class RulerCoreTests: XCTestCase { } } - func testRulerSettingsControllerUpdatesRulerColorWithoutChangingDefaults() { + func testRulerSettingsControllerUpdatesRulerSettingsWithoutChangingDefaults() { withRestoredRulerPreferences { let defaultColor = NSColor(deviceRed: 0.15, green: 0.25, blue: 0.35, alpha: 1) prefs.rulerColor = defaultColor + prefs.foregroundOpacity = 90 + prefs.backgroundOpacity = 50 + prefs.floatRulers = true + prefs.rulerShadow = false let controller = GroupedRulerController( state: RulerInstanceState( settings: RulerSettings( - rulerColor: NSColor(deviceRed: 0.4, green: 0.5, blue: 0.6, alpha: 1) + rulerColor: NSColor(deviceRed: 0.4, green: 0.5, blue: 0.6, alpha: 1), + foregroundOpacity: 80, + backgroundOpacity: 45, + floatRulers: false, + rulerShadow: false ), layout: RulerLayoutState( zeroPoint: NSPoint(x: 240, y: 320), @@ -332,6 +340,38 @@ final class RulerCoreTests: XCTestCase { assertColor(prefs.rulerColor, equals: defaultColor) XCTAssertFalse(settingsController.resetRulerColorButton.isHidden) + settingsController.foregroundOpacitySlider.integerValue = 65 + settingsController.setForegroundOpacity(settingsController.foregroundOpacitySlider) + + XCTAssertEqual(controller.state.settings.foregroundOpacity, 65) + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.65, accuracy: 0.0001) + XCTAssertEqual(settingsController.foregroundOpacityLabel.stringValue, "65%") + XCTAssertEqual(prefs.foregroundOpacity, 90) + + settingsController.backgroundOpacitySlider.integerValue = 35 + settingsController.setBackgroundOpacity(settingsController.backgroundOpacitySlider) + + XCTAssertEqual(controller.state.settings.backgroundOpacity, 35) + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.35, accuracy: 0.0001) + XCTAssertEqual(settingsController.backgroundOpacityLabel.stringValue, "35%") + XCTAssertEqual(prefs.backgroundOpacity, 50) + + settingsController.floatRulersCheckbox.state = .on + settingsController.setFloatRulers(settingsController.floatRulersCheckbox) + + XCTAssertTrue(controller.state.settings.floatRulers) + XCTAssertTrue(controller.groupedWindow.isFloatingPanel) + XCTAssertTrue(settingsController.floatRulersCheckbox.state == .on) + XCTAssertTrue(prefs.floatRulers) + + settingsController.rulerShadowCheckbox.state = .on + settingsController.setRulerShadow(settingsController.rulerShadowCheckbox) + + XCTAssertTrue(controller.state.settings.rulerShadow) + XCTAssertTrue(controller.groupedWindow.hasShadow) + XCTAssertTrue(settingsController.rulerShadowCheckbox.state == .on) + XCTAssertFalse(prefs.rulerShadow) + settingsController.resetRulerColor(settingsController.resetRulerColorButton) assertColor(controller.state.settings.rulerColor, equals: Prefs.defaultRulerFillColor) @@ -341,6 +381,29 @@ final class RulerCoreTests: XCTestCase { } } + func testRulerSettingsControllerPresentsAsSheetOnRulerWindow() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + controller.show() + settingsController.show(attachedTo: controller, sender: self) + + XCTAssertTrue(settingsController.window?.sheetParent === controller.groupedWindow) + } + func testRulerManagerCopiesUpdatedDefaultsOnlyForNewRulers() { withRestoredRulerPreferences { prefs.unit = .pixels From 1ad58542e30de1e5743c54128bb8b360a71a47cc Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 22:28:50 -0400 Subject: [PATCH 08/38] Address ruler settings review feedback --- Free Ruler/PreferencesController.swift | 3 +++ Free Ruler/de.lproj/MainMenu.strings | 3 +++ Free Ruler/es.lproj/MainMenu.strings | 3 +++ Free Ruler/fi.lproj/MainMenu.strings | 3 +++ Free Ruler/ja.lproj/MainMenu.strings | 3 +++ Free Ruler/zh-hans.lproj/MainMenu.strings | 3 +++ FreeRulerTests/RulerCoreTests.swift | 32 +++++++++++++++++++++++ 7 files changed, 50 insertions(+) diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index dcfb30b..0688f95 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -698,6 +698,9 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } private func closeSheetColorControls() { + if let foregroundOpacity = rulerController?.state.settings.foregroundOpacity { + rulerController?.opacity = foregroundOpacity + } rulerColorWell.deactivate() closeRulerColorPanel() } diff --git a/Free Ruler/de.lproj/MainMenu.strings b/Free Ruler/de.lproj/MainMenu.strings index 397ec7e..2420849 100644 --- a/Free Ruler/de.lproj/MainMenu.strings +++ b/Free Ruler/de.lproj/MainMenu.strings @@ -151,3 +151,6 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "Nächste Einheit auswählen"; + +/* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ +"rSt-Tg-232.title" = "Lineal-Einstellungen…"; diff --git a/Free Ruler/es.lproj/MainMenu.strings b/Free Ruler/es.lproj/MainMenu.strings index 1be4959..dd331e8 100644 --- a/Free Ruler/es.lproj/MainMenu.strings +++ b/Free Ruler/es.lproj/MainMenu.strings @@ -148,3 +148,6 @@ /* Class = "NSMenuItem"; title = "Copy"; ObjectID = "x3v-GG-iWU"; */ "x3v-GG-iWU.title" = "Copiar"; + +/* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ +"rSt-Tg-232.title" = "Ajustes de regla…"; diff --git a/Free Ruler/fi.lproj/MainMenu.strings b/Free Ruler/fi.lproj/MainMenu.strings index d81c26d..d88d824 100644 --- a/Free Ruler/fi.lproj/MainMenu.strings +++ b/Free Ruler/fi.lproj/MainMenu.strings @@ -151,3 +151,6 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "Vaihda yksikköä"; + +/* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ +"rSt-Tg-232.title" = "Viivaimen asetukset…"; diff --git a/Free Ruler/ja.lproj/MainMenu.strings b/Free Ruler/ja.lproj/MainMenu.strings index 01e477a..f851bc9 100644 --- a/Free Ruler/ja.lproj/MainMenu.strings +++ b/Free Ruler/ja.lproj/MainMenu.strings @@ -148,3 +148,6 @@ /* Class = "NSMenu"; title = "Unit"; ObjectID = "z2p-dA-zcS"; */ "z2p-dA-zcS.title" = "単位"; + +/* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ +"rSt-Tg-232.title" = "定規設定…"; diff --git a/Free Ruler/zh-hans.lproj/MainMenu.strings b/Free Ruler/zh-hans.lproj/MainMenu.strings index 3b14cd0..9b679a4 100644 --- a/Free Ruler/zh-hans.lproj/MainMenu.strings +++ b/Free Ruler/zh-hans.lproj/MainMenu.strings @@ -151,3 +151,6 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "循环单位"; + +/* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ +"rSt-Tg-232.title" = "尺子设置…"; diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index f58d738..b0c2df7 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -404,6 +404,38 @@ final class RulerCoreTests: XCTestCase { XCTAssertTrue(settingsController.window?.sheetParent === controller.groupedWindow) } + func testRulerSettingsControllerRestoresForegroundOpacityWhenClosingSheet() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + foregroundOpacity: 80, + backgroundOpacity: 45 + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + controller.show() + settingsController.show(attachedTo: controller, sender: self) + settingsController.backgroundOpacitySlider.integerValue = 35 + settingsController.setBackgroundOpacity(settingsController.backgroundOpacitySlider) + + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.35, accuracy: 0.0001) + + settingsController.close() + + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.8, accuracy: 0.0001) + } + func testRulerManagerCopiesUpdatedDefaultsOnlyForNewRulers() { withRestoredRulerPreferences { prefs.unit = .pixels From 3e77e84c179dab5817172dd1092b3b21f2d22eb7 Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 18 Jun 2026 22:19:34 -0400 Subject: [PATCH 09/38] Attach ruler settings panel without modal dimming --- Free Ruler/Localizable.xcstrings | 42 +++++++++ Free Ruler/PreferencesController.swift | 122 ++++++++++++++++++++----- FreeRulerTests/RulerCoreTests.swift | 39 +++++++- 3 files changed, 177 insertions(+), 26 deletions(-) diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 17f6583..7d32227 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -43,6 +43,48 @@ } } }, + "Close" : { + "comment" : "Button title for closing the active ruler settings panel", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sulje" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "閉じる" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭" + } + } + } + }, "Color" : { "comment" : "Label for the active ruler color setting", "extractionState" : "manual", diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index 0688f95..d7e8dff 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -306,6 +306,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { let backgroundOpacityLabel: NSTextField let floatRulersCheckbox: NSButton let rulerShadowCheckbox: NSButton + let closeButton: NSButton var currentRulerController: GroupedRulerController? { return rulerController @@ -336,6 +337,14 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { target: nil, action: nil ) + closeButton = NSButton( + title: NSLocalizedString( + "Close", + comment: "Button title for closing the active ruler settings panel" + ), + target: nil, + action: nil + ) let window = RulerSettingsController.makeWindow( rulerColorWell: rulerColorWell, @@ -345,7 +354,8 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { foregroundOpacityLabel: foregroundOpacityLabel, backgroundOpacityLabel: backgroundOpacityLabel, floatRulersCheckbox: floatRulersCheckbox, - rulerShadowCheckbox: rulerShadowCheckbox + rulerShadowCheckbox: rulerShadowCheckbox, + closeButton: closeButton ) super.init(window: window) @@ -368,7 +378,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } override func showWindow(_ sender: Any?) { - endSheetIfNeeded() + detachWindowIfNeeded() configureOpaqueColorPicking() updateView() window?.makeKeyAndOrderFront(sender) @@ -382,12 +392,15 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { configureOpaqueColorPicking() - if settingsWindow.sheetParent === controller.groupedWindow { + if settingsWindow.parent === controller.groupedWindow { + position(settingsWindow, attachedTo: controller) + settingsWindow.orderFront(sender) + settingsWindow.makeKey() settingsWindow.makeFirstResponder(rulerColorWell) return } - endSheetIfNeeded() + detachWindowIfNeeded() guard controller.groupedWindow.isVisible else { showWindow(sender) @@ -398,30 +411,24 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { settingsWindow.orderOut(sender) } - controller.groupedWindow.beginSheet(settingsWindow) { [weak self] _ in - self?.closeSheetColorControls() - } + position(settingsWindow, attachedTo: controller) + controller.groupedWindow.addChildWindow(settingsWindow, ordered: .above) + settingsWindow.orderFront(sender) + settingsWindow.makeKey() settingsWindow.makeFirstResponder(rulerColorWell) } override func close() { - guard let settingsWindow = window, - settingsWindow.sheetParent != nil else { - super.close() - return - } - - endSheetIfNeeded() + detachWindowIfNeeded() + super.close() } func windowShouldClose(_ sender: NSWindow) -> Bool { - guard sender.sheetParent != nil else { return true } - - endSheetIfNeeded() - return false + true } func windowWillClose(_ notification: Notification) { + detachWindowIfNeeded() closeSheetColorControls() } @@ -468,6 +475,10 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { applyRulerColor(Prefs.defaultRulerFillColor) } + @objc func closeRulerSettings(_ sender: Any) { + close() + } + func updateView() { let currentColor = rulerController?.state.settings.rulerColor ?? Prefs.defaultRulerFillColor let currentSettings = rulerController?.state.settings @@ -499,7 +510,8 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { foregroundOpacityLabel: NSTextField, backgroundOpacityLabel: NSTextField, floatRulersCheckbox: NSButton, - rulerShadowCheckbox: NSButton + rulerShadowCheckbox: NSButton, + closeButton: NSButton ) -> NSPanel { let contentView = NSView() let colorLabel = makeLabel( @@ -523,6 +535,8 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { let colorRow = NSStackView(views: [colorLabel, resetRulerColorButton, rulerColorWell]) let foregroundHeaderRow = NSStackView(views: [foregroundLabel, foregroundOpacityLabel]) let backgroundHeaderRow = NSStackView(views: [backgroundLabel, backgroundOpacityLabel]) + let closeRow = NSView() + closeRow.addSubview(closeButton) let contentStack = NSStackView(views: [ colorRow, foregroundHeaderRow, @@ -531,6 +545,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { backgroundOpacitySlider, floatRulersCheckbox, rulerShadowCheckbox, + closeRow, ]) for row in [colorRow, foregroundHeaderRow, backgroundHeaderRow] { @@ -558,12 +573,15 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { backgroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false floatRulersCheckbox.translatesAutoresizingMaskIntoConstraints = false rulerShadowCheckbox.translatesAutoresizingMaskIntoConstraints = false + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeRow.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(contentStack) NSLayoutConstraint.activate([ colorRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), foregroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), backgroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + closeRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), rulerColorWell.widthAnchor.constraint(equalToConstant: 60), rulerColorWell.heightAnchor.constraint(equalToConstant: 24), resetRulerColorButton.widthAnchor.constraint(equalToConstant: 28), @@ -572,6 +590,10 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { backgroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), floatRulersCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), rulerShadowCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), + closeButton.trailingAnchor.constraint(equalTo: closeRow.trailingAnchor), + closeButton.topAnchor.constraint(equalTo: closeRow.topAnchor), + closeButton.bottomAnchor.constraint(equalTo: closeRow.bottomAnchor), + closeButton.leadingAnchor.constraint(greaterThanOrEqualTo: closeRow.leadingAnchor), contentStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), contentStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), contentStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 18), @@ -579,7 +601,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { ]) let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 315, height: 232), + contentRect: NSRect(x: 0, y: 0, width: 315, height: 270), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -658,6 +680,11 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { rulerShadowCheckbox.action = #selector(setRulerShadow(_:)) rulerShadowCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-ruler-shadow-checkbox") rulerShadowCheckbox.setAccessibilityIdentifier("ruler-settings-ruler-shadow-checkbox") + + closeButton.target = self + closeButton.action = #selector(closeRulerSettings(_:)) + closeButton.identifier = NSUserInterfaceItemIdentifier("ruler-settings-close-button") + closeButton.setAccessibilityIdentifier("ruler-settings-close-button") } private func applyRulerColor(_ color: NSColor) { @@ -690,11 +717,58 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { applyRulerColor(colorPanel.color) } - private func endSheetIfNeeded() { - guard let settingsWindow = window, - let parentWindow = settingsWindow.sheetParent else { return } + private func position(_ settingsWindow: NSWindow, attachedTo controller: GroupedRulerController) { + let parentFrame = controller.groupedWindow.frame + let settingsSize = settingsWindow.frame.size + let margin: CGFloat = 12 + let frame: NSRect - parentWindow.endSheet(settingsWindow) + if controller.state.visibility.showsHorizontal { + let horizontalFrame = controller.groupedWindow.screenFrame(for: .horizontal) + let x = clamp( + horizontalFrame.midX - settingsSize.width / 2, + lower: parentFrame.minX + margin, + upper: parentFrame.maxX - settingsSize.width - margin + ) + let belowY = horizontalFrame.minY - settingsSize.height - margin + let aboveY = horizontalFrame.maxY + margin + let y = belowY >= parentFrame.minY + margin + ? belowY + : min(aboveY, parentFrame.maxY - settingsSize.height - margin) + frame = NSRect(origin: NSPoint(x: x, y: y), size: settingsSize) + } else { + let verticalFrame = controller.groupedWindow.screenFrame(for: .vertical) + let y = clamp( + verticalFrame.midY - settingsSize.height / 2, + lower: parentFrame.minY + margin, + upper: parentFrame.maxY - settingsSize.height - margin + ) + let rightX = verticalFrame.maxX + margin + let leftX = verticalFrame.minX - settingsSize.width - margin + let x = rightX + settingsSize.width <= parentFrame.maxX - margin + ? rightX + : max(leftX, parentFrame.minX + margin) + frame = NSRect(origin: NSPoint(x: x, y: y), size: settingsSize) + } + + settingsWindow.setFrame(frame, display: true) + } + + private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { + guard lower <= upper else { return lower } + + return min(max(value, lower), upper) + } + + private func detachWindowIfNeeded() { + guard let settingsWindow = window else { return } + + if let sheetParent = settingsWindow.sheetParent { + sheetParent.endSheet(settingsWindow) + } + if let parentWindow = settingsWindow.parent { + parentWindow.removeChildWindow(settingsWindow) + } } private func closeSheetColorControls() { diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index b0c2df7..a1af795 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -381,7 +381,7 @@ final class RulerCoreTests: XCTestCase { } } - func testRulerSettingsControllerPresentsAsSheetOnRulerWindow() { + func testRulerSettingsControllerPresentsAsAttachedSheetOnRulerWindow() { let controller = GroupedRulerController( state: RulerInstanceState( settings: RulerSettings(), @@ -401,7 +401,12 @@ final class RulerCoreTests: XCTestCase { controller.show() settingsController.show(attachedTo: controller, sender: self) - XCTAssertTrue(settingsController.window?.sheetParent === controller.groupedWindow) + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + XCTAssertTrue(controller.groupedWindow.childWindows?.contains(settingsWindow) ?? false) + XCTAssertNil(settingsWindow.sheetParent) } func testRulerSettingsControllerRestoresForegroundOpacityWhenClosingSheet() { @@ -436,6 +441,36 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(controller.groupedWindow.alphaValue, 0.8, accuracy: 0.0001) } + func testRulerSettingsControllerCloseButtonClosesAttachedSheet() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + controller.show() + settingsController.show(attachedTo: controller, sender: self) + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + + settingsController.closeButton.performClick(self) + + XCTAssertFalse(controller.groupedWindow.childWindows?.contains(settingsWindow) ?? false) + XCTAssertFalse(settingsWindow.isVisible) + } + func testRulerManagerCopiesUpdatedDefaultsOnlyForNewRulers() { withRestoredRulerPreferences { prefs.unit = .pixels From 73ce894cd00074cb459adf37393b44d13306e886 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:38:12 -0400 Subject: [PATCH 10/38] Route ruler commands to active ruler --- Free Ruler/AppDelegate.swift | 115 +++++++++++++++++++-------- FreeRulerTests/RulerCoreTests.swift | 116 ++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 31 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index ba5aed8..49029fd 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -68,6 +68,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard let self = self else { return } self.groupedRulerController = controller + self.updateDisplay() guard let settingsController = self.rulerSettingsController, settingsController.window?.isVisible == true else { return } @@ -318,9 +319,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func updateUnitMenu() { - pixelsMenuItem?.state = prefs.unit == .pixels ? .on : .off - millimetersMenuItem?.state = prefs.unit == .millimeters ? .on : .off - inchesMenuItem?.state = prefs.unit == .inches ? .on : .off + let unit = activeRulerSettings.unit + pixelsMenuItem?.state = unit == .pixels ? .on : .off + millimetersMenuItem?.state = unit == .millimeters ? .on : .off + inchesMenuItem?.state = unit == .inches ? .on : .off } func redrawRulers() { @@ -341,7 +343,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func updateFloatRulersMenuItem() { - floatRulersMenuItem?.state = prefs.floatRulers ? .on : .off + floatRulersMenuItem?.state = activeRulerSettings.floatRulers ? .on : .off } func updateGroupRulersMenuItem() { @@ -349,7 +351,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func updateRulerShadowMenuItem() { - rulerShadowMenuItem?.state = prefs.rulerShadow ? .on : .off + rulerShadowMenuItem?.state = activeRulerSettings.rulerShadow ? .on : .off + } + + private var activeRulerSettings: RulerSettings { + return rulerManager.activeController?.state.settings ?? RulerSettings(defaults: prefs) + } + + @discardableResult + private func updateActiveRulerSettings(_ update: (inout RulerSettings) -> Void) -> Bool { + guard let controller = rulerManager.activeController else { return false } + + controller.updateSettings(update) + updateDisplay() + return true } func createRulersIfNeeded() { @@ -393,6 +408,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if rulerManager.hasRulers { let controller = rulerManager.activeController ?? rulerManager.createRuler() controller.toggleWing(orientation) + updateDisplay() updateMouseTickTimer() return } @@ -622,28 +638,43 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @IBAction func setUnitPixels(_ sender: Any) { - prefs.unit = .pixels + setUnit(.pixels) } @IBAction func setUnitMillimetres(_ sender: Any) { - prefs.unit = .millimeters + setUnit(.millimeters) } @IBAction func setUnitInches(_ sender: Any) { - prefs.unit = .inches + setUnit(.inches) } @IBAction func cycleUnits(_ sender: Any) { - switch prefs.unit { + let nextUnit: Unit + switch activeRulerSettings.unit { case .pixels: - prefs.unit = .millimeters + nextUnit = .millimeters case .millimeters: - prefs.unit = .inches + nextUnit = .inches case .inches: - prefs.unit = .pixels + nextUnit = .pixels } - showHotkeyBezel(format: .unitsFormat, unitLabel(prefs.unit), on: bezelScreen(for: sender)) + setUnit(nextUnit) + showHotkeyBezel(format: .unitsFormat, unitLabel(nextUnit), on: bezelScreen(for: sender)) } @IBAction func toggleFloatRulers(_ sender: Any) { + if let controller = rulerManager.activeController { + let shouldFloat = !controller.state.settings.floatRulers + controller.updateSettings { settings in + settings.floatRulers = shouldFloat + } + updateFloatRulersMenuItem() + showHotkeyBezel( + shouldFloat ? .rulersFloated : .rulersUnfloated, + on: bezelScreen(for: sender) + ) + return + } + prefs.floatRulers = !prefs.floatRulers showHotkeyBezel( prefs.floatRulers ? .rulersFloated : .rulersUnfloated, @@ -658,6 +689,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { showGroupRulersHotkeyBezel(on: bezelScreen(for: sender)) } @IBAction func toggleRulerShadow(_ sender: Any) { + if let controller = rulerManager.activeController { + let shouldShowShadow = !controller.state.settings.rulerShadow + controller.updateSettings { settings in + settings.rulerShadow = shouldShowShadow + } + updateRulerShadowMenuItem() + showHotkeyBezel( + shouldShowShadow ? .shadowEnabled : .shadowDisabled, + on: bezelScreen(for: sender) + ) + return + } + prefs.rulerShadow = !prefs.rulerShadow showHotkeyBezel( prefs.rulerShadow ? .shadowEnabled : .shadowDisabled, @@ -746,16 +790,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @IBAction func resetRulerPositions(_ sender: Any) { - if rulerManager.hasRulers { - prefs.zeroCorner = Prefs.defaultZeroCorner - for controller in rulerManager.controllers { - controller.state.settings.zeroCorner = Prefs.defaultZeroCorner - controller.state.layout = RulerLayoutState.defaults( - zeroCorner: Prefs.defaultZeroCorner - ) - controller.state.visibility = RulerWingVisibility() - controller.show() - } + 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() + updateDisplay() updateMouseTickTimer() return } @@ -804,11 +846,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } if let controller = rulerManager.activeController { - let flippedCorner = prefs.zeroCorner.flipped(along: orientation) + let flippedCorner = controller.state.settings.zeroCorner.flipped(along: orientation) controller.prepareForZeroCornerChange(to: flippedCorner) - controller.state.settings.zeroCorner = flippedCorner - prefs.zeroCorner = flippedCorner controller.redrawForPreferenceChange() + updateDisplay() return } @@ -859,6 +900,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { applyRulerWindowMode() } + private func setUnit(_ unit: Unit) { + if updateActiveRulerSettings({ settings in + settings.unit = unit + }) { + return + } + + prefs.unit = unit + } + func isRulerWindowShown(_ window: RulerWindow) -> Bool { return window.isVisible || window.parent != nil || rulers.contains { $0.rulerWindow.childWindows?.contains(window) == true @@ -1009,15 +1060,16 @@ extension AppDelegate: NSMenuItemValidation { case #selector(openRulerSettings(_:)): return rulerManager.activeController != nil case #selector(closeKeyWindow(_:)): - return NSApp.keyWindow?.isVisible == true + return rulerManager.activeController != nil || NSApp.keyWindow?.isVisible == true case #selector(toggleGroupRulers(_:)): return !rulerManager.hasRulers case #selector(toggleHorizontalRuler(_:)): if let controller = rulerManager.activeController { - menuItem.title = controller.state.isWingVisible(.horizontal) + let isVisible = controller.state.isWingVisible(.horizontal) + menuItem.title = isVisible ? NSLocalizedString("Hide Horizontal Ruler", comment: "Menu item title to hide the horizontal ruler") : NSLocalizedString("Show Horizontal Ruler", comment: "Menu item title to show the horizontal ruler") - return true + return !isVisible || controller.state.isWingVisible(.vertical) } let ruler = existingRulerController(orientation: .horizontal) @@ -1027,10 +1079,11 @@ extension AppDelegate: NSMenuItemValidation { return canToggleRulerVisibility case #selector(toggleVerticalRuler(_:)): if let controller = rulerManager.activeController { - menuItem.title = controller.state.isWingVisible(.vertical) + let isVisible = controller.state.isWingVisible(.vertical) + menuItem.title = isVisible ? NSLocalizedString("Hide Vertical Ruler", comment: "Menu item title to hide the vertical ruler") : NSLocalizedString("Show Vertical Ruler", comment: "Menu item title to show the vertical ruler") - return true + return !isVisible || controller.state.isWingVisible(.horizontal) } let ruler = existingRulerController(orientation: .vertical) diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index a1af795..e2fdcd3 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -2513,6 +2513,122 @@ final class RulerCoreTests: XCTestCase { XCTAssertTrue(second.groupedWindow.isRuleVisible(.vertical)) } + func testManagedCommandsApplySettingsToActiveRulerOnly() { + withRestoredRulerPreferences { + prefs.unit = .pixels + prefs.floatRulers = true + prefs.rulerShadow = false + let appDelegate = AppDelegate() + let first = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(unit: .pixels, floatRulers: true, rulerShadow: false) + ) + let second = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(unit: .millimeters, floatRulers: true, rulerShadow: false) + ) + defer { + first.hide() + second.hide() + } + + appDelegate.rulerManager.markActive(first) + appDelegate.setUnitInches(self) + appDelegate.toggleFloatRulers(self) + appDelegate.toggleRulerShadow(self) + + XCTAssertEqual(first.state.settings.unit, .inches) + XCTAssertFalse(first.state.settings.floatRulers) + XCTAssertTrue(first.state.settings.rulerShadow) + XCTAssertEqual(first.groupedWindow.horizontalRule.unit, .inches) + XCTAssertFalse(first.groupedWindow.isFloatingPanel) + XCTAssertTrue(first.groupedWindow.hasShadow) + XCTAssertEqual(second.state.settings.unit, .millimeters) + XCTAssertTrue(second.state.settings.floatRulers) + XCTAssertFalse(second.state.settings.rulerShadow) + XCTAssertEqual(prefs.unit, .pixels) + XCTAssertTrue(prefs.floatRulers) + XCTAssertFalse(prefs.rulerShadow) + } + } + + 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) + + 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) + } + } + + func testManagedWingCommandsDoNotHideLastVisibleWing() { + let appDelegate = AppDelegate() + let controller = appDelegate.rulerManager.createRuler() + defer { + controller.hide() + } + + appDelegate.rulerManager.markActive(controller) + controller.setWing(.vertical, isVisible: false) + appDelegate.toggleHorizontalRuler(self) + + XCTAssertTrue(controller.state.isWingVisible(.horizontal)) + XCTAssertFalse(controller.state.isWingVisible(.vertical)) + } + + func testManagedMenuValidationReflectsActiveRulerState() { + let appDelegate = AppDelegate() + let controller = appDelegate.rulerManager.createRuler() + defer { + controller.hide() + } + appDelegate.rulerManager.markActive(controller) + controller.setWing(.vertical, isVisible: false) + + let closeItem = NSMenuItem( + title: "", + action: #selector(AppDelegate.closeKeyWindow(_:)), + keyEquivalent: "" + ) + let horizontalItem = NSMenuItem( + title: "", + action: #selector(AppDelegate.toggleHorizontalRuler(_:)), + keyEquivalent: "" + ) + let verticalItem = NSMenuItem( + title: "", + action: #selector(AppDelegate.toggleVerticalRuler(_:)), + keyEquivalent: "" + ) + + XCTAssertTrue(appDelegate.validateMenuItem(closeItem)) + XCTAssertFalse(appDelegate.validateMenuItem(horizontalItem)) + XCTAssertTrue(appDelegate.validateMenuItem(verticalItem)) + } + func testUngroupedHorizontalFlipDoesNotMoveRulerWindows() { withRestoredZeroCornerPreference { let previousGroupRulers = prefs.groupRulers From 4c63dc9a1c3fee9c9b0d9947ede5dd31826ec619 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:41:29 -0400 Subject: [PATCH 11/38] 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 12/38] 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) + } } } From 0b0b9f6e8d14db947afe52d766f273574a6b1fbe Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:43:21 -0400 Subject: [PATCH 13/38] Polish multiple ruler integration --- Free Ruler/Base.lproj/MainMenu.xib | 6 +++ .../Resources/English.lproj/FreeRuler.html | 45 ++++++++++--------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index aa26542..07161ce 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -79,6 +79,12 @@ + + + + + + diff --git a/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html b/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html index 3ad7ef1..ae6c69c 100644 --- a/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html +++ b/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html @@ -11,7 +11,7 @@ } + content="free ruler, rulers, shortcuts, keyboard, multiple rulers, float, shadow, origin, units, color, preferences" /> @@ -34,52 +34,57 @@

H - Hide or show the horizontal ruler + Hide or show the active ruler's horizontal wing V - Hide or show the vertical ruler + Hide or show the active ruler's vertical wing ⇧ H - Flip the horizontal ruler origin + Flip the active ruler's horizontal origin ⇧ V - Flip the vertical ruler origin + Flip the active ruler's vertical origin - - F - Float/unfloat rulers above other windows + ⌘ + N + Create another ruler + + + ⌘ + W + Close the active ruler - G - Group/ungroup rulers + F + Float or unfloat the active ruler above other windows S - Show/hide ruler shadows + Show or hide the active ruler's shadow O - Orient rulers at mouse location + Orient the active ruler at the mouse location U - Cycle units: pixels, millimeters, and inches + Cycle the active ruler's units: pixels, millimeters, and inches ⌘ R - Reset ruler positions to default + Reset the active ruler to its default position ⌘ @@ -94,12 +99,12 @@

    -
  • Show horizontal and vertical rulers in pixels, millimeters, or inches.
  • -
  • Resize rulers, move them independently, or keep them grouped together.
  • -
  • Customize the ruler color in Preferences.
  • -
  • Float rulers above other windows and show or hide ruler shadows.
  • -
  • Align rulers at the mouse location, reset them to default positions, or flip their origins.
  • -
  • Show, hide, and reopen rulers from the menu or keyboard.
  • +
  • Create multiple independent rulers, each with horizontal, vertical, or L-shaped wings.
  • +
  • Use pixels, millimeters, or inches independently for each ruler.
  • +
  • Resize, move, align, reset, and flip the active ruler without changing the others.
  • +
  • Float individual rulers above other windows and show or hide their shadows.
  • +
  • Set defaults for new rulers in Preferences.
  • +
  • Restore your ruler set, including positions and visible wings, when Free Ruler opens again.
From d67031c17047891ca333461500b25e057508115f Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 22:31:03 -0400 Subject: [PATCH 14/38] Localize new ruler menu item --- Free Ruler/de.lproj/MainMenu.strings | 3 +++ Free Ruler/es.lproj/MainMenu.strings | 3 +++ Free Ruler/fi.lproj/MainMenu.strings | 3 +++ Free Ruler/ja.lproj/MainMenu.strings | 3 +++ Free Ruler/zh-hans.lproj/MainMenu.strings | 3 +++ 5 files changed, 15 insertions(+) diff --git a/Free Ruler/de.lproj/MainMenu.strings b/Free Ruler/de.lproj/MainMenu.strings index 2420849..8e8010d 100644 --- a/Free Ruler/de.lproj/MainMenu.strings +++ b/Free Ruler/de.lproj/MainMenu.strings @@ -152,5 +152,8 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "Nächste Einheit auswählen"; +/* Class = "NSMenuItem"; title = "New Ruler"; ObjectID = "rWt-KM-qSf"; */ +"rWt-KM-qSf.title" = "Neues Lineal"; + /* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ "rSt-Tg-232.title" = "Lineal-Einstellungen…"; diff --git a/Free Ruler/es.lproj/MainMenu.strings b/Free Ruler/es.lproj/MainMenu.strings index dd331e8..6ff7d35 100644 --- a/Free Ruler/es.lproj/MainMenu.strings +++ b/Free Ruler/es.lproj/MainMenu.strings @@ -149,5 +149,8 @@ /* Class = "NSMenuItem"; title = "Copy"; ObjectID = "x3v-GG-iWU"; */ "x3v-GG-iWU.title" = "Copiar"; +/* Class = "NSMenuItem"; title = "New Ruler"; ObjectID = "rWt-KM-qSf"; */ +"rWt-KM-qSf.title" = "Nueva regla"; + /* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ "rSt-Tg-232.title" = "Ajustes de regla…"; diff --git a/Free Ruler/fi.lproj/MainMenu.strings b/Free Ruler/fi.lproj/MainMenu.strings index d88d824..36741dd 100644 --- a/Free Ruler/fi.lproj/MainMenu.strings +++ b/Free Ruler/fi.lproj/MainMenu.strings @@ -152,5 +152,8 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "Vaihda yksikköä"; +/* Class = "NSMenuItem"; title = "New Ruler"; ObjectID = "rWt-KM-qSf"; */ +"rWt-KM-qSf.title" = "Uusi viivain"; + /* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ "rSt-Tg-232.title" = "Viivaimen asetukset…"; diff --git a/Free Ruler/ja.lproj/MainMenu.strings b/Free Ruler/ja.lproj/MainMenu.strings index f851bc9..653914e 100644 --- a/Free Ruler/ja.lproj/MainMenu.strings +++ b/Free Ruler/ja.lproj/MainMenu.strings @@ -149,5 +149,8 @@ /* Class = "NSMenu"; title = "Unit"; ObjectID = "z2p-dA-zcS"; */ "z2p-dA-zcS.title" = "単位"; +/* Class = "NSMenuItem"; title = "New Ruler"; ObjectID = "rWt-KM-qSf"; */ +"rWt-KM-qSf.title" = "新規定規"; + /* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ "rSt-Tg-232.title" = "定規設定…"; diff --git a/Free Ruler/zh-hans.lproj/MainMenu.strings b/Free Ruler/zh-hans.lproj/MainMenu.strings index 9b679a4..9cd0f65 100644 --- a/Free Ruler/zh-hans.lproj/MainMenu.strings +++ b/Free Ruler/zh-hans.lproj/MainMenu.strings @@ -152,5 +152,8 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "循环单位"; +/* Class = "NSMenuItem"; title = "New Ruler"; ObjectID = "rWt-KM-qSf"; */ +"rWt-KM-qSf.title" = "新建尺子"; + /* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ "rSt-Tg-232.title" = "尺子设置…"; From 17ad4589673c668119fb193cf7fd535fffa7974d Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 18 Jun 2026 23:43:41 -0400 Subject: [PATCH 15/38] Implement Ruler Settings UI and Localization Updates --- Free Ruler/Base.lproj/MainMenu.xib | 4 +- .../Base.lproj/PreferencesController.xib | 183 +---- .../Base.lproj/RulerSettingsController.xib | 69 ++ .../Base.lproj/RulerSettingsControlsView.xib | 117 +++ Free Ruler/Localizable.xcstrings | 42 -- Free Ruler/PreferencesController.swift | 699 ++++++++++-------- Free Ruler/Prefs.swift | 53 +- .../de.lproj/PreferencesController.strings | 29 +- .../de.lproj/RulerSettingsController.strings | 9 + .../RulerSettingsControlsView.strings | 15 + .../es.lproj/PreferencesController.strings | 29 +- .../es.lproj/RulerSettingsController.strings | 9 + .../RulerSettingsControlsView.strings | 15 + .../fi.lproj/PreferencesController.strings | 29 +- .../fi.lproj/RulerSettingsController.strings | 9 + .../RulerSettingsControlsView.strings | 15 + .../ja.lproj/PreferencesController.strings | 29 +- .../ja.lproj/RulerSettingsController.strings | 9 + .../RulerSettingsControlsView.strings | 15 + .../PreferencesController.strings | 29 +- .../RulerSettingsController.strings | 9 + .../RulerSettingsControlsView.strings | 15 + FreeRulerTests/RulerCoreTests.swift | 169 ++++- 23 files changed, 974 insertions(+), 627 deletions(-) create mode 100644 Free Ruler/Base.lproj/RulerSettingsController.xib create mode 100644 Free Ruler/Base.lproj/RulerSettingsControlsView.xib create mode 100644 Free Ruler/de.lproj/RulerSettingsController.strings create mode 100644 Free Ruler/de.lproj/RulerSettingsControlsView.strings create mode 100644 Free Ruler/es.lproj/RulerSettingsController.strings create mode 100644 Free Ruler/es.lproj/RulerSettingsControlsView.strings create mode 100644 Free Ruler/fi.lproj/RulerSettingsController.strings create mode 100644 Free Ruler/fi.lproj/RulerSettingsControlsView.strings create mode 100644 Free Ruler/ja.lproj/RulerSettingsController.strings create mode 100644 Free Ruler/ja.lproj/RulerSettingsControlsView.strings create mode 100644 Free Ruler/zh-hans.lproj/RulerSettingsController.strings create mode 100644 Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index 07161ce..7db7dd7 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -104,8 +104,8 @@
- - + + diff --git a/Free Ruler/Base.lproj/PreferencesController.xib b/Free Ruler/Base.lproj/PreferencesController.xib index ae045ff..869d872 100644 --- a/Free Ruler/Base.lproj/PreferencesController.xib +++ b/Free Ruler/Base.lproj/PreferencesController.xib @@ -8,15 +8,8 @@ - - - - - - - - - + + @@ -24,166 +17,60 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + - + diff --git a/Free Ruler/Base.lproj/RulerSettingsController.xib b/Free Ruler/Base.lproj/RulerSettingsController.xib new file mode 100644 index 0000000..4011c5c --- /dev/null +++ b/Free Ruler/Base.lproj/RulerSettingsController.xib @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib new file mode 100644 index 0000000..072377a --- /dev/null +++ b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 7d32227..17f6583 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -43,48 +43,6 @@ } } }, - "Close" : { - "comment" : "Button title for closing the active ruler settings panel", - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Schließen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cerrar" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sulje" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "閉じる" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "关闭" - } - } - } - }, "Color" : { "comment" : "Label for the active ruler color setting", "extractionState" : "manual", diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index d7e8dff..ff95829 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -114,24 +114,266 @@ private func configureResetRulerColorButtonAppearance(_ button: NSButton, identi button.setAccessibilityLabel(resetRulerColorLabel) } -class PreferencesController: NSWindowController, NSWindowDelegate, NotificationPoster { +protocol RulerSettingsControlsViewDelegate: AnyObject { + func rulerSettingsControlsDidChangeRulerColor(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidResetRulerColor(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeForegroundOpacity(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeBackgroundOpacity(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeFloatRulers(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeRulerShadow(_ controlsView: RulerSettingsControlsView) +} - var observers: [NSKeyValueObservation] = [] - private var colorPanelObserver: NSObjectProtocol? +final class RulerSettingsControlsView: NSView { + weak var delegate: RulerSettingsControlsViewDelegate? + + @IBOutlet var contentView: NSView! + @IBOutlet weak var rulerColorWell: RulerColorWell! + @IBOutlet weak var resetRulerColorButton: NSButton! @IBOutlet weak var foregroundOpacitySlider: NSSlider! @IBOutlet weak var backgroundOpacitySlider: NSSlider! - @IBOutlet weak var foregroundOpacityLabel: NSTextField! @IBOutlet weak var backgroundOpacityLabel: NSTextField! - - @IBOutlet weak var rulerColorWell: NSColorWell! - @IBOutlet weak var resetRulerColorButton: NSButton! - @IBOutlet weak var floatRulersCheckbox: NSButton! - @IBOutlet weak var groupRulersCheckbox: NSButton! @IBOutlet weak var rulerShadowCheckbox: NSButton! + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + loadContentView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + loadContentView() + } + + func configureForPreferences() { + configureControls( + colorWellIdentifier: "ruler-color-well", + resetButtonIdentifier: "reset-ruler-color-button", + foregroundSliderIdentifier: "ruler-foreground-opacity-slider", + backgroundSliderIdentifier: "ruler-background-opacity-slider", + foregroundLabelIdentifier: "ruler-foreground-opacity-label", + backgroundLabelIdentifier: "ruler-background-opacity-label", + floatCheckboxIdentifier: "float-rulers-checkbox", + shadowCheckboxIdentifier: "ruler-shadow-checkbox" + ) + } + + func configureForRulerSettings() { + configureControls( + colorWellIdentifier: "ruler-settings-color-well", + resetButtonIdentifier: "reset-ruler-settings-color-button", + foregroundSliderIdentifier: "ruler-settings-foreground-opacity-slider", + backgroundSliderIdentifier: "ruler-settings-background-opacity-slider", + foregroundLabelIdentifier: "ruler-settings-foreground-opacity-label", + backgroundLabelIdentifier: "ruler-settings-background-opacity-label", + floatCheckboxIdentifier: "ruler-settings-float-rulers-checkbox", + shadowCheckboxIdentifier: "ruler-settings-ruler-shadow-checkbox" + ) + } + + func update( + rulerColor: NSColor, + foregroundOpacity: Int, + backgroundOpacity: Int, + floatRulers: Bool, + rulerShadow: Bool, + isEnabled: Bool = true + ) { + rulerColorWell.supportsAlpha = false + rulerColorWell.color = rulerColor + rulerColorWell.isEnabled = isEnabled + + resetRulerColorButton.isEnabled = isEnabled + resetRulerColorButton.isHidden = Prefs.colorsMatch(rulerColor, Prefs.defaultRulerFillColor) + + foregroundOpacitySlider.integerValue = foregroundOpacity + foregroundOpacitySlider.isEnabled = isEnabled + foregroundOpacityLabel.stringValue = "\(foregroundOpacity)%" + + backgroundOpacitySlider.integerValue = backgroundOpacity + backgroundOpacitySlider.isEnabled = isEnabled + backgroundOpacityLabel.stringValue = "\(backgroundOpacity)%" + + floatRulersCheckbox.state = floatRulers ? .on : .off + floatRulersCheckbox.isEnabled = isEnabled + + rulerShadowCheckbox.state = rulerShadow ? .on : .off + rulerShadowCheckbox.isEnabled = isEnabled + + configureKeyViewLoop() + } + + func deactivateColorWell() { + rulerColorWell.deactivate() + } + + private func loadContentView() { + guard contentView == nil else { return } + + var topLevelObjects: NSArray? + Bundle.main.loadNibNamed( + "RulerSettingsControlsView", + owner: self, + topLevelObjects: &topLevelObjects + ) + + guard let contentView = contentView else { return } + + contentView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentView) + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.topAnchor.constraint(equalTo: topAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + configureBaseControls() + } + + private func configureBaseControls() { + rulerColorWell.isContinuous = true + rulerColorWell.supportsAlpha = false + rulerColorWell.target = self + rulerColorWell.action = #selector(setRulerColor(_:)) + + resetRulerColorButton.target = self + resetRulerColorButton.action = #selector(resetRulerColor(_:)) + + foregroundOpacitySlider.minValue = 5 + foregroundOpacitySlider.maxValue = 100 + foregroundOpacitySlider.numberOfTickMarks = 20 + foregroundOpacitySlider.allowsTickMarkValuesOnly = true + foregroundOpacitySlider.tickMarkPosition = .below + foregroundOpacitySlider.isContinuous = true + foregroundOpacitySlider.target = self + foregroundOpacitySlider.action = #selector(setForegroundOpacity(_:)) + + backgroundOpacitySlider.minValue = 5 + backgroundOpacitySlider.maxValue = 100 + backgroundOpacitySlider.numberOfTickMarks = 20 + backgroundOpacitySlider.allowsTickMarkValuesOnly = true + backgroundOpacitySlider.tickMarkPosition = .below + backgroundOpacitySlider.isContinuous = true + backgroundOpacitySlider.target = self + backgroundOpacitySlider.action = #selector(setBackgroundOpacity(_:)) + + floatRulersCheckbox.target = self + floatRulersCheckbox.action = #selector(setFloatRulers(_:)) + + rulerShadowCheckbox.target = self + rulerShadowCheckbox.action = #selector(setRulerShadow(_:)) + + configureKeyViewLoop() + } + + private func configureControls( + colorWellIdentifier: String, + resetButtonIdentifier: String, + foregroundSliderIdentifier: String, + backgroundSliderIdentifier: String, + foregroundLabelIdentifier: String, + backgroundLabelIdentifier: String, + floatCheckboxIdentifier: String, + shadowCheckboxIdentifier: String + ) { + rulerColorWell.identifier = NSUserInterfaceItemIdentifier(colorWellIdentifier) + rulerColorWell.setAccessibilityIdentifier(colorWellIdentifier) + configureResetRulerColorButtonAppearance(resetRulerColorButton, identifier: resetButtonIdentifier) + + foregroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier(foregroundSliderIdentifier) + foregroundOpacitySlider.setAccessibilityIdentifier(foregroundSliderIdentifier) + foregroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier(foregroundLabelIdentifier) + foregroundOpacityLabel.setAccessibilityIdentifier(foregroundLabelIdentifier) + + backgroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier(backgroundSliderIdentifier) + backgroundOpacitySlider.setAccessibilityIdentifier(backgroundSliderIdentifier) + backgroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier(backgroundLabelIdentifier) + backgroundOpacityLabel.setAccessibilityIdentifier(backgroundLabelIdentifier) + + floatRulersCheckbox.identifier = NSUserInterfaceItemIdentifier(floatCheckboxIdentifier) + floatRulersCheckbox.setAccessibilityIdentifier(floatCheckboxIdentifier) + rulerShadowCheckbox.identifier = NSUserInterfaceItemIdentifier(shadowCheckboxIdentifier) + rulerShadowCheckbox.setAccessibilityIdentifier(shadowCheckboxIdentifier) + } + + private func configureKeyViewLoop() { + rulerColorWell.nextKeyView = resetRulerColorButton.isHidden + ? foregroundOpacitySlider + : resetRulerColorButton + resetRulerColorButton.nextKeyView = foregroundOpacitySlider + foregroundOpacitySlider.nextKeyView = backgroundOpacitySlider + backgroundOpacitySlider.nextKeyView = floatRulersCheckbox + floatRulersCheckbox.nextKeyView = rulerShadowCheckbox + rulerShadowCheckbox.nextKeyView = rulerColorWell + } + + @objc private func setRulerColor(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeRulerColor(self) + } + + @objc private func resetRulerColor(_ sender: Any) { + delegate?.rulerSettingsControlsDidResetRulerColor(self) + } + + @objc private func setForegroundOpacity(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeForegroundOpacity(self) + } + + @objc private func setBackgroundOpacity(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeBackgroundOpacity(self) + } + + @objc private func setFloatRulers(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeFloatRulers(self) + } + + @objc private func setRulerShadow(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeRulerShadow(self) + } +} + +class PreferencesController: NSWindowController, NSWindowDelegate, NotificationPoster { + + var observers: [NSKeyValueObservation] = [] + private var colorPanelObserver: NSObjectProtocol? + + @IBOutlet weak var settingsControlsView: RulerSettingsControlsView! + @IBOutlet weak var resetFactoryDefaultsButton: NSButton! + + var foregroundOpacitySlider: NSSlider { + return settingsControlsView.foregroundOpacitySlider + } + + var backgroundOpacitySlider: NSSlider { + return settingsControlsView.backgroundOpacitySlider + } + + var foregroundOpacityLabel: NSTextField { + return settingsControlsView.foregroundOpacityLabel + } + + var backgroundOpacityLabel: NSTextField { + return settingsControlsView.backgroundOpacityLabel + } + + var rulerColorWell: RulerColorWell { + return settingsControlsView.rulerColorWell + } + + var resetRulerColorButton: NSButton { + return settingsControlsView.resetRulerColorButton + } + + var floatRulersCheckbox: NSButton { + return settingsControlsView.floatRulersCheckbox + } + + var rulerShadowCheckbox: NSButton { + return settingsControlsView.rulerShadowCheckbox + } + override var windowNibName: String { return "PreferencesController" } @@ -142,19 +384,12 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP window?.delegate = self window?.identifier = NSUserInterfaceItemIdentifier("preferences-window") window?.isMovableByWindowBackground = true - floatRulersCheckbox.identifier = NSUserInterfaceItemIdentifier("float-rulers-checkbox") - floatRulersCheckbox.setAccessibilityIdentifier("float-rulers-checkbox") - groupRulersCheckbox.identifier = NSUserInterfaceItemIdentifier("group-rulers-checkbox") - groupRulersCheckbox.setAccessibilityIdentifier("group-rulers-checkbox") - rulerShadowCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-shadow-checkbox") - rulerShadowCheckbox.setAccessibilityIdentifier("ruler-shadow-checkbox") configureOpaqueColorPicking() - rulerColorWell.isContinuous = true - rulerColorWell.supportsAlpha = false - rulerColorWell.identifier = NSUserInterfaceItemIdentifier("ruler-color-well") - rulerColorWell.setAccessibilityIdentifier("ruler-color-well") + settingsControlsView.delegate = self + settingsControlsView.configureForPreferences() window?.initialFirstResponder = rulerColorWell - configureResetRulerColorButton() + resetFactoryDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-factory-defaults-button") + resetFactoryDefaultsButton.setAccessibilityIdentifier("reset-factory-defaults-button") subscribeToPrefs() subscribeToColorPanel() @@ -197,9 +432,6 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP prefs.observe(\Prefs.floatRulers, options: .new) { prefs, changed in self.updateFloatRulersCheckbox() }, - prefs.observe(\Prefs.groupRulers, options: .new) { prefs, changed in - self.updateGroupRulersCheckbox() - }, prefs.observe(\Prefs.rulerShadow, options: .new) { prefs, changed in self.updateRulerShadowCheckbox() }, @@ -218,9 +450,6 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP @IBAction func setFloatRulers(_ sender: Any) { prefs.floatRulers = floatRulersCheckbox.state == .on } - @IBAction func setGroupRulers(_ sender: Any) { - prefs.groupRulers = groupRulersCheckbox.state == .on - } @IBAction func setRulerShadow(_ sender: Any) { prefs.rulerShadow = rulerShadowCheckbox.state == .on } @@ -230,14 +459,19 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP @IBAction func resetRulerColor(_ sender: Any) { prefs.rulerColor = Prefs.defaultRulerFillColor } + @IBAction func resetToFactoryDefaults(_ sender: Any) { + prefs.resetRulerDefaultsToFactoryDefaults() + updateView() + } func updateView() { - updateForegroundSlider() - updateBackgroundSlider() - updateRulerColorWell() - updateFloatRulersCheckbox() - updateGroupRulersCheckbox() - updateRulerShadowCheckbox() + settingsControlsView.update( + rulerColor: prefs.rulerColor, + foregroundOpacity: prefs.foregroundOpacity, + backgroundOpacity: prefs.backgroundOpacity, + floatRulers: prefs.floatRulers, + rulerShadow: prefs.rulerShadow + ) } func updateForegroundSlider() { @@ -260,18 +494,10 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP floatRulersCheckbox.state = prefs.floatRulers ? .on : .off } - func updateGroupRulersCheckbox() { - groupRulersCheckbox.state = prefs.groupRulers ? .on : .off - } - func updateRulerShadowCheckbox() { rulerShadowCheckbox.state = prefs.rulerShadow ? .on : .off } - private func configureResetRulerColorButton() { - configureResetRulerColorButtonAppearance(resetRulerColorButton, identifier: "reset-ruler-color-button") - } - private func subscribeToColorPanel() { colorPanelObserver = NotificationCenter.default.addObserver( forName: NSColorPanel.colorDidChangeNotification, @@ -293,84 +519,110 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP } +extension PreferencesController: RulerSettingsControlsViewDelegate { + func rulerSettingsControlsDidChangeRulerColor(_ controlsView: RulerSettingsControlsView) { + setRulerColor(controlsView.rulerColorWell as Any) + } + + func rulerSettingsControlsDidResetRulerColor(_ controlsView: RulerSettingsControlsView) { + resetRulerColor(controlsView.resetRulerColorButton as Any) + } + + func rulerSettingsControlsDidChangeForegroundOpacity(_ controlsView: RulerSettingsControlsView) { + setForegroundOpacity(controlsView.foregroundOpacitySlider as Any) + } + + func rulerSettingsControlsDidChangeBackgroundOpacity(_ controlsView: RulerSettingsControlsView) { + setBackgroundOpacity(controlsView.backgroundOpacitySlider as Any) + } + + func rulerSettingsControlsDidChangeFloatRulers(_ controlsView: RulerSettingsControlsView) { + setFloatRulers(controlsView.floatRulersCheckbox as Any) + } + + func rulerSettingsControlsDidChangeRulerShadow(_ controlsView: RulerSettingsControlsView) { + setRulerShadow(controlsView.rulerShadowCheckbox as Any) + } +} + final class RulerSettingsController: NSWindowController, NSWindowDelegate { private weak var rulerController: GroupedRulerController? private var colorPanelObserver: NSObjectProtocol? - let rulerColorWell: RulerColorWell - let resetRulerColorButton: NSButton - let foregroundOpacitySlider: NSSlider - let backgroundOpacitySlider: NSSlider - let foregroundOpacityLabel: NSTextField - let backgroundOpacityLabel: NSTextField - let floatRulersCheckbox: NSButton - let rulerShadowCheckbox: NSButton - let closeButton: NSButton + @IBOutlet weak var settingsControlsView: RulerSettingsControlsView! + @IBOutlet weak var resetDefaultsButton: NSButton! + @IBOutlet weak var setDefaultsButton: NSButton! + + var rulerColorWell: RulerColorWell { + return settingsControlsView.rulerColorWell + } + + var resetRulerColorButton: NSButton { + return settingsControlsView.resetRulerColorButton + } + + var foregroundOpacitySlider: NSSlider { + return settingsControlsView.foregroundOpacitySlider + } + + var backgroundOpacitySlider: NSSlider { + return settingsControlsView.backgroundOpacitySlider + } + + var foregroundOpacityLabel: NSTextField { + return settingsControlsView.foregroundOpacityLabel + } + + var backgroundOpacityLabel: NSTextField { + return settingsControlsView.backgroundOpacityLabel + } + + var floatRulersCheckbox: NSButton { + return settingsControlsView.floatRulersCheckbox + } + + var rulerShadowCheckbox: NSButton { + return settingsControlsView.rulerShadowCheckbox + } var currentRulerController: GroupedRulerController? { return rulerController } + override var windowNibName: String { + return "RulerSettingsController" + } + init(rulerController: GroupedRulerController) { self.rulerController = rulerController + super.init(window: nil) + loadWindow() + } - rulerColorWell = RulerColorWell(frame: NSRect(x: 0, y: 0, width: 64, height: 30)) - resetRulerColorButton = NSButton(frame: .zero) - foregroundOpacitySlider = RulerSettingsController.makeOpacitySlider() - backgroundOpacitySlider = RulerSettingsController.makeOpacitySlider() - foregroundOpacityLabel = RulerSettingsController.makeValueLabel() - backgroundOpacityLabel = RulerSettingsController.makeValueLabel() - floatRulersCheckbox = NSButton( - checkboxWithTitle: NSLocalizedString( - "Float rulers above other applications", - comment: "Checkbox title for whether the active ruler floats above other apps" - ), - target: nil, - action: nil - ) - rulerShadowCheckbox = NSButton( - checkboxWithTitle: NSLocalizedString( - "Show ruler shadow", - comment: "Checkbox title for whether the active ruler draws a window shadow" - ), - target: nil, - action: nil - ) - closeButton = NSButton( - title: NSLocalizedString( - "Close", - comment: "Button title for closing the active ruler settings panel" - ), - target: nil, - action: nil - ) - - let window = RulerSettingsController.makeWindow( - rulerColorWell: rulerColorWell, - resetRulerColorButton: resetRulerColorButton, - foregroundOpacitySlider: foregroundOpacitySlider, - backgroundOpacitySlider: backgroundOpacitySlider, - foregroundOpacityLabel: foregroundOpacityLabel, - backgroundOpacityLabel: backgroundOpacityLabel, - floatRulersCheckbox: floatRulersCheckbox, - rulerShadowCheckbox: rulerShadowCheckbox, - closeButton: closeButton - ) + required init?(coder: NSCoder) { + nil + } - super.init(window: window) + override func windowDidLoad() { + super.windowDidLoad() - window.delegate = self - window.initialFirstResponder = rulerColorWell - configureControls() + window?.delegate = self + window?.identifier = NSUserInterfaceItemIdentifier("ruler-settings-window") + window?.setAccessibilityIdentifier("ruler-settings-window") + window?.isMovableByWindowBackground = true + window?.isReleasedWhenClosed = false + window?.initialFirstResponder = rulerColorWell + settingsControlsView.delegate = self + settingsControlsView.configureForRulerSettings() + resetDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-ruler-settings-to-default-button") + resetDefaultsButton.setAccessibilityIdentifier("reset-ruler-settings-to-default-button") + setDefaultsButton.identifier = NSUserInterfaceItemIdentifier("save-ruler-settings-as-default-button") + setDefaultsButton.setAccessibilityIdentifier("save-ruler-settings-as-default-button") subscribeToColorPanel() updateView() } - required init?(coder: NSCoder) { - nil - } - deinit { if let colorPanelObserver = colorPanelObserver { NotificationCenter.default.removeObserver(colorPanelObserver) @@ -475,216 +727,33 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { applyRulerColor(Prefs.defaultRulerFillColor) } - @objc func closeRulerSettings(_ sender: Any) { - close() - } - - func updateView() { - let currentColor = rulerController?.state.settings.rulerColor ?? Prefs.defaultRulerFillColor - let currentSettings = rulerController?.state.settings - let hasRuler = rulerController != nil - - rulerColorWell.supportsAlpha = false - rulerColorWell.color = currentColor - rulerColorWell.isEnabled = hasRuler - resetRulerColorButton.isEnabled = hasRuler - resetRulerColorButton.isHidden = Prefs.colorsMatch(currentColor, Prefs.defaultRulerFillColor) - foregroundOpacitySlider.isEnabled = hasRuler - backgroundOpacitySlider.isEnabled = hasRuler - floatRulersCheckbox.isEnabled = hasRuler - rulerShadowCheckbox.isEnabled = hasRuler - - foregroundOpacitySlider.integerValue = currentSettings?.foregroundOpacity ?? 90 - backgroundOpacitySlider.integerValue = currentSettings?.backgroundOpacity ?? 50 - foregroundOpacityLabel.stringValue = "\(foregroundOpacitySlider.integerValue)%" - backgroundOpacityLabel.stringValue = "\(backgroundOpacitySlider.integerValue)%" - floatRulersCheckbox.state = currentSettings?.floatRulers == true ? .on : .off - rulerShadowCheckbox.state = currentSettings?.rulerShadow == true ? .on : .off - } - - private static func makeWindow( - rulerColorWell: RulerColorWell, - resetRulerColorButton: NSButton, - foregroundOpacitySlider: NSSlider, - backgroundOpacitySlider: NSSlider, - foregroundOpacityLabel: NSTextField, - backgroundOpacityLabel: NSTextField, - floatRulersCheckbox: NSButton, - rulerShadowCheckbox: NSButton, - closeButton: NSButton - ) -> NSPanel { - let contentView = NSView() - let colorLabel = makeLabel( - NSLocalizedString( - "Ruler Color", - comment: "Label for the active ruler color setting" - ) - ) - let foregroundLabel = makeLabel( - NSLocalizedString( - "Foreground Opacity", - comment: "Label for the active ruler foreground opacity setting" - ) - ) - let backgroundLabel = makeLabel( - NSLocalizedString( - "Background Opacity", - comment: "Label for the active ruler background opacity setting" - ) - ) - let colorRow = NSStackView(views: [colorLabel, resetRulerColorButton, rulerColorWell]) - let foregroundHeaderRow = NSStackView(views: [foregroundLabel, foregroundOpacityLabel]) - let backgroundHeaderRow = NSStackView(views: [backgroundLabel, backgroundOpacityLabel]) - let closeRow = NSView() - closeRow.addSubview(closeButton) - let contentStack = NSStackView(views: [ - colorRow, - foregroundHeaderRow, - foregroundOpacitySlider, - backgroundHeaderRow, - backgroundOpacitySlider, - floatRulersCheckbox, - rulerShadowCheckbox, - closeRow, - ]) - - for row in [colorRow, foregroundHeaderRow, backgroundHeaderRow] { - row.orientation = .horizontal - row.alignment = .centerY - row.distribution = .fill - row.spacing = 10 - row.translatesAutoresizingMaskIntoConstraints = false + @IBAction func resetToDefault(_ sender: Any) { + applySettings { settings in + settings = RulerSettings(defaults: prefs) } - - contentStack.orientation = .vertical - contentStack.alignment = .leading - contentStack.distribution = .fill - contentStack.spacing = 8 - contentStack.translatesAutoresizingMaskIntoConstraints = false - - colorLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - foregroundLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - backgroundLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - foregroundOpacityLabel.setContentHuggingPriority(.required, for: .horizontal) - backgroundOpacityLabel.setContentHuggingPriority(.required, for: .horizontal) - rulerColorWell.translatesAutoresizingMaskIntoConstraints = false - resetRulerColorButton.translatesAutoresizingMaskIntoConstraints = false - foregroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false - backgroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false - floatRulersCheckbox.translatesAutoresizingMaskIntoConstraints = false - rulerShadowCheckbox.translatesAutoresizingMaskIntoConstraints = false - closeButton.translatesAutoresizingMaskIntoConstraints = false - closeRow.translatesAutoresizingMaskIntoConstraints = false - - contentView.addSubview(contentStack) - NSLayoutConstraint.activate([ - colorRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - foregroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - backgroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - closeRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - rulerColorWell.widthAnchor.constraint(equalToConstant: 60), - rulerColorWell.heightAnchor.constraint(equalToConstant: 24), - resetRulerColorButton.widthAnchor.constraint(equalToConstant: 28), - resetRulerColorButton.heightAnchor.constraint(equalToConstant: 28), - foregroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - backgroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - floatRulersCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), - rulerShadowCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), - closeButton.trailingAnchor.constraint(equalTo: closeRow.trailingAnchor), - closeButton.topAnchor.constraint(equalTo: closeRow.topAnchor), - closeButton.bottomAnchor.constraint(equalTo: closeRow.bottomAnchor), - closeButton.leadingAnchor.constraint(greaterThanOrEqualTo: closeRow.leadingAnchor), - contentStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - contentStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - contentStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 18), - contentStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18), - ]) - - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 315, height: 270), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.title = NSLocalizedString( - "Ruler Settings", - comment: "Window title for the active ruler settings panel" - ) - window.contentView = contentView - window.identifier = NSUserInterfaceItemIdentifier("ruler-settings-window") - window.setAccessibilityIdentifier("ruler-settings-window") - window.isMovableByWindowBackground = true - window.isReleasedWhenClosed = false - return window - } - - private static func makeLabel(_ title: String) -> NSTextField { - let label = NSTextField(labelWithString: title) - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - return label + updateView() } - private static func makeValueLabel() -> NSTextField { - let label = NSTextField(labelWithString: "") - label.alignment = .right - return label - } + @IBAction func setDefaultsForNewRulers(_ sender: Any) { + guard let settings = rulerController?.state.settings else { return } - private static func makeOpacitySlider() -> NSSlider { - let slider = NSSlider(frame: .zero) - slider.minValue = 5 - slider.maxValue = 100 - slider.doubleValue = 50 - slider.numberOfTickMarks = 20 - slider.allowsTickMarkValuesOnly = true - slider.tickMarkPosition = .below - slider.isContinuous = true - return slider + prefs.applyDefaults(from: settings) } - private func configureControls() { - rulerColorWell.isContinuous = true - rulerColorWell.supportsAlpha = false - rulerColorWell.identifier = NSUserInterfaceItemIdentifier("ruler-settings-color-well") - rulerColorWell.setAccessibilityIdentifier("ruler-settings-color-well") - rulerColorWell.target = self - rulerColorWell.action = #selector(setRulerColor(_:)) + func updateView() { + let currentSettings = rulerController?.state.settings + let hasRuler = rulerController != nil - resetRulerColorButton.target = self - resetRulerColorButton.action = #selector(resetRulerColor(_:)) - configureResetRulerColorButtonAppearance( - resetRulerColorButton, - identifier: "reset-ruler-settings-color-button" + settingsControlsView.update( + rulerColor: currentSettings?.rulerColor ?? Prefs.defaultRulerFillColor, + foregroundOpacity: currentSettings?.foregroundOpacity ?? Prefs.defaultForegroundOpacity, + backgroundOpacity: currentSettings?.backgroundOpacity ?? Prefs.defaultBackgroundOpacity, + floatRulers: currentSettings?.floatRulers ?? Prefs.defaultFloatRulers, + rulerShadow: currentSettings?.rulerShadow ?? Prefs.defaultRulerShadow, + isEnabled: hasRuler ) - - foregroundOpacitySlider.target = self - foregroundOpacitySlider.action = #selector(setForegroundOpacity(_:)) - foregroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier("ruler-settings-foreground-opacity-slider") - foregroundOpacitySlider.setAccessibilityIdentifier("ruler-settings-foreground-opacity-slider") - foregroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier("ruler-settings-foreground-opacity-label") - foregroundOpacityLabel.setAccessibilityIdentifier("ruler-settings-foreground-opacity-label") - - backgroundOpacitySlider.target = self - backgroundOpacitySlider.action = #selector(setBackgroundOpacity(_:)) - backgroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier("ruler-settings-background-opacity-slider") - backgroundOpacitySlider.setAccessibilityIdentifier("ruler-settings-background-opacity-slider") - backgroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier("ruler-settings-background-opacity-label") - backgroundOpacityLabel.setAccessibilityIdentifier("ruler-settings-background-opacity-label") - - floatRulersCheckbox.target = self - floatRulersCheckbox.action = #selector(setFloatRulers(_:)) - floatRulersCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-float-rulers-checkbox") - floatRulersCheckbox.setAccessibilityIdentifier("ruler-settings-float-rulers-checkbox") - - rulerShadowCheckbox.target = self - rulerShadowCheckbox.action = #selector(setRulerShadow(_:)) - rulerShadowCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-ruler-shadow-checkbox") - rulerShadowCheckbox.setAccessibilityIdentifier("ruler-settings-ruler-shadow-checkbox") - - closeButton.target = self - closeButton.action = #selector(closeRulerSettings(_:)) - closeButton.identifier = NSUserInterfaceItemIdentifier("ruler-settings-close-button") - closeButton.setAccessibilityIdentifier("ruler-settings-close-button") + resetDefaultsButton.isEnabled = hasRuler + setDefaultsButton.isEnabled = hasRuler } private func applyRulerColor(_ color: NSColor) { @@ -775,11 +844,37 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { if let foregroundOpacity = rulerController?.state.settings.foregroundOpacity { rulerController?.opacity = foregroundOpacity } - rulerColorWell.deactivate() + settingsControlsView.deactivateColorWell() closeRulerColorPanel() } } +extension RulerSettingsController: RulerSettingsControlsViewDelegate { + func rulerSettingsControlsDidChangeRulerColor(_ controlsView: RulerSettingsControlsView) { + setRulerColor(controlsView.rulerColorWell as Any) + } + + func rulerSettingsControlsDidResetRulerColor(_ controlsView: RulerSettingsControlsView) { + resetRulerColor(controlsView.resetRulerColorButton as Any) + } + + func rulerSettingsControlsDidChangeForegroundOpacity(_ controlsView: RulerSettingsControlsView) { + setForegroundOpacity(controlsView.foregroundOpacitySlider as Any) + } + + func rulerSettingsControlsDidChangeBackgroundOpacity(_ controlsView: RulerSettingsControlsView) { + setBackgroundOpacity(controlsView.backgroundOpacitySlider as Any) + } + + func rulerSettingsControlsDidChangeFloatRulers(_ controlsView: RulerSettingsControlsView) { + setFloatRulers(controlsView.floatRulersCheckbox as Any) + } + + func rulerSettingsControlsDidChangeRulerShadow(_ controlsView: RulerSettingsControlsView) { + setRulerShadow(controlsView.rulerShadowCheckbox as Any) + } +} + func closeRulerColorPanel() { activeRulerColorWell = nil let colorPanel = NSColorPanel.shared diff --git a/Free Ruler/Prefs.swift b/Free Ruler/Prefs.swift index d9a9f8e..4918514 100644 --- a/Free Ruler/Prefs.swift +++ b/Free Ruler/Prefs.swift @@ -54,12 +54,12 @@ class Prefs: NSObject { private static var defaultValues: [String: Any] { var values: [String: Any] = [ - "groupRulers": true, - "floatRulers": true, - "rulerShadow": false, - "foregroundOpacity": 90, - "backgroundOpacity": 50, - "unit": Unit.pixels.rawValue, + "groupRulers": defaultGroupRulers, + "floatRulers": defaultFloatRulers, + "rulerShadow": defaultRulerShadow, + "foregroundOpacity": defaultForegroundOpacity, + "backgroundOpacity": defaultBackgroundOpacity, + "unit": defaultUnit.rawValue, "zeroCorner": defaultZeroCorner.rawValue ] @@ -131,6 +131,10 @@ class Prefs: NSObject { extension Prefs { static let rulerSetStateKey = "rulerSetState" + static var defaultUnit: Unit { + return .pixels + } + static var defaultZeroCorner: ZeroCorner { return .topLeft } @@ -138,11 +142,48 @@ extension Prefs { static var defaultRulerFillColor: NSColor { return defaultRulerColor } + + static var defaultForegroundOpacity: Int { + return 90 + } + + static var defaultBackgroundOpacity: Int { + return 50 + } + + static var defaultFloatRulers: Bool { + return true + } + + static var defaultRulerShadow: Bool { + return false + } static var defaultGroupRulers: Bool { return true } + func applyDefaults(from settings: RulerSettings) { + unit = settings.unit + rulerColor = settings.rulerColor + foregroundOpacity = settings.foregroundOpacity + backgroundOpacity = settings.backgroundOpacity + floatRulers = settings.floatRulers + rulerShadow = settings.rulerShadow + zeroCorner = settings.zeroCorner + } + + func resetRulerDefaultsToFactoryDefaults() { + unit = Self.defaultUnit + rulerColor = Self.defaultRulerFillColor + foregroundOpacity = Self.defaultForegroundOpacity + backgroundOpacity = Self.defaultBackgroundOpacity + floatRulers = Self.defaultFloatRulers + rulerShadow = Self.defaultRulerShadow + groupRulers = Self.defaultGroupRulers + zeroCorner = Self.defaultZeroCorner + } + static func rulerFillColor(fromArchivedData data: Data?) -> NSColor { return normalizedRulerColor(unarchiveColor(data) ?? defaultRulerColor) } diff --git a/Free Ruler/de.lproj/PreferencesController.strings b/Free Ruler/de.lproj/PreferencesController.strings index edf83f4..6317fd3 100644 --- a/Free Ruler/de.lproj/PreferencesController.strings +++ b/Free Ruler/de.lproj/PreferencesController.strings @@ -1,30 +1,9 @@ -/* Class = "NSButtonCell"; title = "Reset Rulers"; ObjectID = "10f-9L-qca"; */ -"10f-9L-qca.title" = "Lineale zurücksetzen"; - -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "Deckkraft im Vordergrund"; - /* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "„Free Ruler“-Einstellungen"; -/* Class = "NSButtonCell"; title = "Group rulers"; ObjectID = "N2Y-8B-L9c"; */ -"N2Y-8B-L9c.title" = "Lineale gruppieren"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "VXi-Ch-Jf5"; */ -"VXi-Ch-Jf5.title" = "Label"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "Vqd-CI-vmd"; */ -"Vqd-CI-vmd.title" = "Label"; - -/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "cRb-8z-VZj"; */ -"cRb-8z-VZj.title" = "Deckkraft im Hintergrund"; - -/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "ydA-TR-Gwu"; */ -"ydA-TR-Gwu.title" = "Linealfarbe"; - -/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "l53-85-hoA"; */ -"l53-85-hoA.title" = "Linealschatten anzeigen"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "Standardeinstellungen für neue Lineale"; -/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "yPM-Cw-Qsi"; */ -"yPM-Cw-Qsi.title" = "Lineale schweben über anderen Programmen"; +/* Class = "NSButtonCell"; title = "Reset to factory defaults"; ObjectID = "PREF-factory-cell"; */ +"PREF-factory-cell.title" = "Auf Werkseinstellungen zurücksetzen"; diff --git a/Free Ruler/de.lproj/RulerSettingsController.strings b/Free Ruler/de.lproj/RulerSettingsController.strings new file mode 100644 index 0000000..87067f6 --- /dev/null +++ b/Free Ruler/de.lproj/RulerSettingsController.strings @@ -0,0 +1,9 @@ + +/* Class = "NSButtonCell"; title = "Reset to default"; ObjectID = "RSET-reset-defaults-cell"; */ +"RSET-reset-defaults-cell.title" = "Auf Standard zurücksetzen"; + +/* Class = "NSButtonCell"; title = "Save as default"; ObjectID = "RSET-save-defaults-cell"; */ +"RSET-save-defaults-cell.title" = "Als Standard sichern"; + +/* Class = "NSWindow"; title = "Ruler Settings"; ObjectID = "RSET-window"; */ +"RSET-window.title" = "Lineal-Einstellungen"; diff --git a/Free Ruler/de.lproj/RulerSettingsControlsView.strings b/Free Ruler/de.lproj/RulerSettingsControlsView.strings new file mode 100644 index 0000000..3bb8da3 --- /dev/null +++ b/Free Ruler/de.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,15 @@ + +/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "RSV-bg-cell"; */ +"RSV-bg-cell.title" = "Deckkraft im Hintergrund"; + +/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ +"RSV-color-cell.title" = "Linealfarbe"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "Deckkraft im Vordergrund"; + +/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "Lineale schweben über anderen Programmen"; + +/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ +"RSV-shadow-cell.title" = "Linealschatten anzeigen"; diff --git a/Free Ruler/es.lproj/PreferencesController.strings b/Free Ruler/es.lproj/PreferencesController.strings index 8edf07b..3854183 100644 --- a/Free Ruler/es.lproj/PreferencesController.strings +++ b/Free Ruler/es.lproj/PreferencesController.strings @@ -1,30 +1,9 @@ -/* Class = "NSButtonCell"; title = "Reset Rulers"; ObjectID = "10f-9L-qca"; */ -"10f-9L-qca.title" = "Restablecer reglas"; - -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "Opacidad del primer plano"; - /* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "Preferencias de Free Ruler"; -/* Class = "NSButtonCell"; title = "Group rulers"; ObjectID = "N2Y-8B-L9c"; */ -"N2Y-8B-L9c.title" = "Agrupar reglas"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "VXi-Ch-Jf5"; */ -"VXi-Ch-Jf5.title" = "Etiqueta"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "Vqd-CI-vmd"; */ -"Vqd-CI-vmd.title" = "Etiqueta"; - -/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "cRb-8z-VZj"; */ -"cRb-8z-VZj.title" = "Opacidad del fondo"; - -/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "ydA-TR-Gwu"; */ -"ydA-TR-Gwu.title" = "Color de la regla"; - -/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "l53-85-hoA"; */ -"l53-85-hoA.title" = "Mostrar sombra de la regla"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "Ajustes predeterminados para reglas nuevas"; -/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "yPM-Cw-Qsi"; */ -"yPM-Cw-Qsi.title" = "Reglas flotantes sobre otras aplicaciones"; +/* Class = "NSButtonCell"; title = "Reset to factory defaults"; ObjectID = "PREF-factory-cell"; */ +"PREF-factory-cell.title" = "Restablecer valores de fábrica"; diff --git a/Free Ruler/es.lproj/RulerSettingsController.strings b/Free Ruler/es.lproj/RulerSettingsController.strings new file mode 100644 index 0000000..775ece1 --- /dev/null +++ b/Free Ruler/es.lproj/RulerSettingsController.strings @@ -0,0 +1,9 @@ + +/* Class = "NSButtonCell"; title = "Reset to default"; ObjectID = "RSET-reset-defaults-cell"; */ +"RSET-reset-defaults-cell.title" = "Restablecer predeterminado"; + +/* Class = "NSButtonCell"; title = "Save as default"; ObjectID = "RSET-save-defaults-cell"; */ +"RSET-save-defaults-cell.title" = "Guardar como predeterminado"; + +/* Class = "NSWindow"; title = "Ruler Settings"; ObjectID = "RSET-window"; */ +"RSET-window.title" = "Ajustes de regla"; diff --git a/Free Ruler/es.lproj/RulerSettingsControlsView.strings b/Free Ruler/es.lproj/RulerSettingsControlsView.strings new file mode 100644 index 0000000..6196a4d --- /dev/null +++ b/Free Ruler/es.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,15 @@ + +/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "RSV-bg-cell"; */ +"RSV-bg-cell.title" = "Opacidad del fondo"; + +/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ +"RSV-color-cell.title" = "Color de la regla"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "Opacidad del primer plano"; + +/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "Reglas flotantes sobre otras aplicaciones"; + +/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ +"RSV-shadow-cell.title" = "Mostrar sombra de la regla"; diff --git a/Free Ruler/fi.lproj/PreferencesController.strings b/Free Ruler/fi.lproj/PreferencesController.strings index b00f2a9..98833ca 100644 --- a/Free Ruler/fi.lproj/PreferencesController.strings +++ b/Free Ruler/fi.lproj/PreferencesController.strings @@ -1,30 +1,9 @@ -/* Class = "NSButtonCell"; title = "Reset Rulers"; ObjectID = "10f-9L-qca"; */ -"10f-9L-qca.title" = "Nollaa viivaimet"; - -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "Peittävyys edustalla"; - /* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "Free Rulerin asetukset"; -/* Class = "NSButtonCell"; title = "Group rulers"; ObjectID = "N2Y-8B-L9c"; */ -"N2Y-8B-L9c.title" = "Ryhmitä viivaimet"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "VXi-Ch-Jf5"; */ -"VXi-Ch-Jf5.title" = "Label"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "Vqd-CI-vmd"; */ -"Vqd-CI-vmd.title" = "Label"; - -/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "cRb-8z-VZj"; */ -"cRb-8z-VZj.title" = "Peittävyys taustalla"; - -/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "ydA-TR-Gwu"; */ -"ydA-TR-Gwu.title" = "Viivaimen väri"; - -/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "l53-85-hoA"; */ -"l53-85-hoA.title" = "Näytä viivainten varjot"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "Uusien viivainten oletusasetukset"; -/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "yPM-Cw-Qsi"; */ -"yPM-Cw-Qsi.title" = "Kelluta viivaimia muiden sovellusten päällä"; +/* Class = "NSButtonCell"; title = "Reset to factory defaults"; ObjectID = "PREF-factory-cell"; */ +"PREF-factory-cell.title" = "Palauta tehdasasetukset"; diff --git a/Free Ruler/fi.lproj/RulerSettingsController.strings b/Free Ruler/fi.lproj/RulerSettingsController.strings new file mode 100644 index 0000000..046df8b --- /dev/null +++ b/Free Ruler/fi.lproj/RulerSettingsController.strings @@ -0,0 +1,9 @@ + +/* Class = "NSButtonCell"; title = "Reset to default"; ObjectID = "RSET-reset-defaults-cell"; */ +"RSET-reset-defaults-cell.title" = "Palauta oletus"; + +/* Class = "NSButtonCell"; title = "Save as default"; ObjectID = "RSET-save-defaults-cell"; */ +"RSET-save-defaults-cell.title" = "Tallenna oletukseksi"; + +/* Class = "NSWindow"; title = "Ruler Settings"; ObjectID = "RSET-window"; */ +"RSET-window.title" = "Viivaimen asetukset"; diff --git a/Free Ruler/fi.lproj/RulerSettingsControlsView.strings b/Free Ruler/fi.lproj/RulerSettingsControlsView.strings new file mode 100644 index 0000000..84adb4e --- /dev/null +++ b/Free Ruler/fi.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,15 @@ + +/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "RSV-bg-cell"; */ +"RSV-bg-cell.title" = "Peittävyys taustalla"; + +/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ +"RSV-color-cell.title" = "Viivaimen väri"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "Peittävyys edustalla"; + +/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "Kelluta viivaimia muiden sovellusten päällä"; + +/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ +"RSV-shadow-cell.title" = "Näytä viivainten varjot"; diff --git a/Free Ruler/ja.lproj/PreferencesController.strings b/Free Ruler/ja.lproj/PreferencesController.strings index 3bf4c18..4ac6800 100644 --- a/Free Ruler/ja.lproj/PreferencesController.strings +++ b/Free Ruler/ja.lproj/PreferencesController.strings @@ -1,30 +1,9 @@ -/* Class = "NSButtonCell"; title = "Reset Rulers"; ObjectID = "10f-9L-qca"; */ -"10f-9L-qca.title" = "定規をリセット"; - -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "前景の不透明度"; - /* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "Free Rulerの環境設定"; -/* Class = "NSButtonCell"; title = "Group rulers"; ObjectID = "N2Y-8B-L9c"; */ -"N2Y-8B-L9c.title" = "定規をグループ化"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "VXi-Ch-Jf5"; */ -"VXi-Ch-Jf5.title" = "ラベル"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "Vqd-CI-vmd"; */ -"Vqd-CI-vmd.title" = "ラベル"; - -/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "cRb-8z-VZj"; */ -"cRb-8z-VZj.title" = "背景の不透明度"; - -/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "ydA-TR-Gwu"; */ -"ydA-TR-Gwu.title" = "定規の色"; - -/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "l53-85-hoA"; */ -"l53-85-hoA.title" = "定規の影を表示"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "新しい定規のデフォルト設定"; -/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "yPM-Cw-Qsi"; */ -"yPM-Cw-Qsi.title" = "定規を常に手前に表示"; +/* Class = "NSButtonCell"; title = "Reset to factory defaults"; ObjectID = "PREF-factory-cell"; */ +"PREF-factory-cell.title" = "工場出荷時のデフォルトにリセット"; diff --git a/Free Ruler/ja.lproj/RulerSettingsController.strings b/Free Ruler/ja.lproj/RulerSettingsController.strings new file mode 100644 index 0000000..ac916f8 --- /dev/null +++ b/Free Ruler/ja.lproj/RulerSettingsController.strings @@ -0,0 +1,9 @@ + +/* Class = "NSButtonCell"; title = "Reset to default"; ObjectID = "RSET-reset-defaults-cell"; */ +"RSET-reset-defaults-cell.title" = "デフォルトにリセット"; + +/* Class = "NSButtonCell"; title = "Save as default"; ObjectID = "RSET-save-defaults-cell"; */ +"RSET-save-defaults-cell.title" = "デフォルトとして保存"; + +/* Class = "NSWindow"; title = "Ruler Settings"; ObjectID = "RSET-window"; */ +"RSET-window.title" = "定規設定"; diff --git a/Free Ruler/ja.lproj/RulerSettingsControlsView.strings b/Free Ruler/ja.lproj/RulerSettingsControlsView.strings new file mode 100644 index 0000000..8a0334d --- /dev/null +++ b/Free Ruler/ja.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,15 @@ + +/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "RSV-bg-cell"; */ +"RSV-bg-cell.title" = "背景の不透明度"; + +/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ +"RSV-color-cell.title" = "定規の色"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "前景の不透明度"; + +/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "定規を常に手前に表示"; + +/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ +"RSV-shadow-cell.title" = "定規の影を表示"; diff --git a/Free Ruler/zh-hans.lproj/PreferencesController.strings b/Free Ruler/zh-hans.lproj/PreferencesController.strings index ffc9f81..49c2a5d 100644 --- a/Free Ruler/zh-hans.lproj/PreferencesController.strings +++ b/Free Ruler/zh-hans.lproj/PreferencesController.strings @@ -1,30 +1,9 @@ -/* Class = "NSButtonCell"; title = "Reset Rulers"; ObjectID = "10f-9L-qca"; */ -"10f-9L-qca.title" = "重置尺子"; - -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "前景不透明度"; - /* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "Free Ruler 偏好设置"; -/* Class = "NSButtonCell"; title = "Group rulers"; ObjectID = "N2Y-8B-L9c"; */ -"N2Y-8B-L9c.title" = "组合尺子"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "VXi-Ch-Jf5"; */ -"VXi-Ch-Jf5.title" = "标签"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "Vqd-CI-vmd"; */ -"Vqd-CI-vmd.title" = "标签"; - -/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "cRb-8z-VZj"; */ -"cRb-8z-VZj.title" = "背景不透明度"; - -/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "ydA-TR-Gwu"; */ -"ydA-TR-Gwu.title" = "尺子颜色"; - -/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "l53-85-hoA"; */ -"l53-85-hoA.title" = "显示尺子阴影"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "新尺子的默认设置"; -/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "yPM-Cw-Qsi"; */ -"yPM-Cw-Qsi.title" = "让标尺窗口悬浮在其他应用程序之上"; +/* Class = "NSButtonCell"; title = "Reset to factory defaults"; ObjectID = "PREF-factory-cell"; */ +"PREF-factory-cell.title" = "恢复出厂默认设置"; diff --git a/Free Ruler/zh-hans.lproj/RulerSettingsController.strings b/Free Ruler/zh-hans.lproj/RulerSettingsController.strings new file mode 100644 index 0000000..c4303e3 --- /dev/null +++ b/Free Ruler/zh-hans.lproj/RulerSettingsController.strings @@ -0,0 +1,9 @@ + +/* Class = "NSButtonCell"; title = "Reset to default"; ObjectID = "RSET-reset-defaults-cell"; */ +"RSET-reset-defaults-cell.title" = "重置为默认"; + +/* Class = "NSButtonCell"; title = "Save as default"; ObjectID = "RSET-save-defaults-cell"; */ +"RSET-save-defaults-cell.title" = "保存为默认"; + +/* Class = "NSWindow"; title = "Ruler Settings"; ObjectID = "RSET-window"; */ +"RSET-window.title" = "尺子设置"; diff --git a/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings b/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings new file mode 100644 index 0000000..2f85d90 --- /dev/null +++ b/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,15 @@ + +/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "RSV-bg-cell"; */ +"RSV-bg-cell.title" = "背景不透明度"; + +/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ +"RSV-color-cell.title" = "尺子颜色"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "前景不透明度"; + +/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "让标尺窗口悬浮在其他应用程序之上"; + +/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ +"RSV-shadow-cell.title" = "显示尺子阴影"; diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index ce3b7e0..3cf88af 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -381,6 +381,167 @@ final class RulerCoreTests: XCTestCase { } } + func testRulerSettingsControllerSetsDefaultsForNewRulers() { + withRestoredRulerPreferences { + prefs.unit = .pixels + prefs.rulerColor = NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) + prefs.foregroundOpacity = 90 + prefs.backgroundOpacity = 50 + prefs.floatRulers = true + prefs.rulerShadow = false + prefs.zeroCorner = .topLeft + + let rulerColor = NSColor(deviceRed: 0.72, green: 0.24, blue: 0.44, alpha: 1) + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + unit: .inches, + rulerColor: rulerColor, + foregroundOpacity: 63, + backgroundOpacity: 37, + floatRulers: false, + rulerShadow: true, + zeroCorner: .bottomRight + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + settingsController.setDefaultsForNewRulers(settingsController.setDefaultsButton as Any) + + XCTAssertEqual(prefs.unit, .inches) + assertColor(prefs.rulerColor, equals: rulerColor) + XCTAssertEqual(prefs.foregroundOpacity, 63) + XCTAssertEqual(prefs.backgroundOpacity, 37) + XCTAssertFalse(prefs.floatRulers) + XCTAssertTrue(prefs.rulerShadow) + XCTAssertEqual(prefs.zeroCorner, .bottomRight) + } + } + + func testRulerSettingsControllerResetsRulerToDefaults() { + withRestoredRulerPreferences { + let defaultColor = NSColor(deviceRed: 0.15, green: 0.25, blue: 0.35, alpha: 1) + prefs.unit = .millimeters + prefs.rulerColor = defaultColor + prefs.foregroundOpacity = 88 + prefs.backgroundOpacity = 44 + prefs.floatRulers = true + prefs.rulerShadow = false + prefs.zeroCorner = .topRight + + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + unit: .inches, + rulerColor: NSColor(deviceRed: 0.8, green: 0.2, blue: 0.4, alpha: 1), + foregroundOpacity: 63, + backgroundOpacity: 37, + floatRulers: false, + rulerShadow: true, + zeroCorner: .bottomLeft + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + settingsController.resetToDefault(settingsController.resetDefaultsButton as Any) + + XCTAssertEqual(controller.state.settings.unit, .millimeters) + assertColor(controller.state.settings.rulerColor, equals: defaultColor) + XCTAssertEqual(controller.state.settings.foregroundOpacity, 88) + XCTAssertEqual(controller.state.settings.backgroundOpacity, 44) + XCTAssertTrue(controller.state.settings.floatRulers) + XCTAssertFalse(controller.state.settings.rulerShadow) + XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) + XCTAssertEqual(settingsController.foregroundOpacityLabel.stringValue, "88%") + XCTAssertEqual(settingsController.backgroundOpacityLabel.stringValue, "44%") + XCTAssertEqual(prefs.foregroundOpacity, 88) + } + } + + func testPreferencesControllerResetsDefaultsToFactoryDefaults() { + withRestoredRulerPreferences { + prefs.unit = .inches + prefs.rulerColor = NSColor(deviceRed: 0.7, green: 0.3, blue: 0.2, alpha: 1) + prefs.foregroundOpacity = 42 + prefs.backgroundOpacity = 21 + prefs.floatRulers = false + prefs.groupRulers = false + prefs.rulerShadow = true + prefs.zeroCorner = .bottomRight + + let preferencesController = PreferencesController() + preferencesController.loadWindow() + defer { + preferencesController.close() + } + + preferencesController.resetToFactoryDefaults(self) + + XCTAssertEqual(prefs.unit, Prefs.defaultUnit) + assertColor(prefs.rulerColor, equals: Prefs.defaultRulerFillColor) + XCTAssertEqual(prefs.foregroundOpacity, Prefs.defaultForegroundOpacity) + XCTAssertEqual(prefs.backgroundOpacity, Prefs.defaultBackgroundOpacity) + XCTAssertEqual(prefs.floatRulers, Prefs.defaultFloatRulers) + XCTAssertEqual(prefs.groupRulers, Prefs.defaultGroupRulers) + XCTAssertEqual(prefs.rulerShadow, Prefs.defaultRulerShadow) + XCTAssertEqual(prefs.zeroCorner, Prefs.defaultZeroCorner) + XCTAssertEqual(preferencesController.foregroundOpacityLabel.stringValue, "\(Prefs.defaultForegroundOpacity)%") + XCTAssertEqual(preferencesController.backgroundOpacityLabel.stringValue, "\(Prefs.defaultBackgroundOpacity)%") + XCTAssertEqual(preferencesController.floatRulersCheckbox.state, .on) + XCTAssertEqual(preferencesController.rulerShadowCheckbox.state, .off) + } + } + + func testRulerSettingsControlsKeyViewLoopFollowsVisibleControls() { + let controlsView = RulerSettingsControlsView(frame: NSRect(x: 0, y: 0, width: 315, height: 224)) + controlsView.configureForRulerSettings() + + controlsView.update( + rulerColor: Prefs.defaultRulerFillColor, + foregroundOpacity: 90, + backgroundOpacity: 50, + floatRulers: true, + rulerShadow: false + ) + + XCTAssertTrue(controlsView.rulerColorWell.nextKeyView === controlsView.foregroundOpacitySlider) + XCTAssertTrue(controlsView.foregroundOpacitySlider.nextKeyView === controlsView.backgroundOpacitySlider) + XCTAssertTrue(controlsView.backgroundOpacitySlider.nextKeyView === controlsView.floatRulersCheckbox) + XCTAssertTrue(controlsView.floatRulersCheckbox.nextKeyView === controlsView.rulerShadowCheckbox) + XCTAssertTrue(controlsView.rulerShadowCheckbox.nextKeyView === controlsView.rulerColorWell) + + controlsView.update( + rulerColor: NSColor(deviceRed: 0.6, green: 0.3, blue: 0.2, alpha: 1), + foregroundOpacity: 90, + backgroundOpacity: 50, + floatRulers: true, + rulerShadow: false + ) + + XCTAssertTrue(controlsView.rulerColorWell.nextKeyView === controlsView.resetRulerColorButton) + XCTAssertTrue(controlsView.resetRulerColorButton.nextKeyView === controlsView.foregroundOpacitySlider) + } + func testRulerSettingsControllerPresentsAsAttachedSheetOnRulerWindow() { let controller = GroupedRulerController( state: RulerInstanceState( @@ -441,7 +602,7 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(controller.groupedWindow.alphaValue, 0.8, accuracy: 0.0001) } - func testRulerSettingsControllerCloseButtonClosesAttachedSheet() { + func testRulerSettingsControllerTitlebarCloseClosesAttachedSheet() { let controller = GroupedRulerController( state: RulerInstanceState( settings: RulerSettings(), @@ -465,7 +626,9 @@ final class RulerCoreTests: XCTestCase { return } - settingsController.closeButton.performClick(self) + XCTAssertTrue(settingsWindow.styleMask.contains(.closable)) + + settingsWindow.performClose(self) XCTAssertFalse(controller.groupedWindow.childWindows?.contains(settingsWindow) ?? false) XCTAssertFalse(settingsWindow.isVisible) @@ -3081,6 +3244,7 @@ final class RulerCoreTests: XCTestCase { let previousForegroundOpacity = prefs.foregroundOpacity let previousBackgroundOpacity = prefs.backgroundOpacity let previousFloatRulers = prefs.floatRulers + let previousGroupRulers = prefs.groupRulers let previousRulerShadow = prefs.rulerShadow let previousZeroCorner = prefs.zeroCorner @@ -3090,6 +3254,7 @@ final class RulerCoreTests: XCTestCase { prefs.foregroundOpacity = previousForegroundOpacity prefs.backgroundOpacity = previousBackgroundOpacity prefs.floatRulers = previousFloatRulers + prefs.groupRulers = previousGroupRulers prefs.rulerShadow = previousRulerShadow prefs.zeroCorner = previousZeroCorner } From 2f0b000b1aef4092c433c869d23f0b4d33c64bae Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 00:12:12 -0400 Subject: [PATCH 16/38] Add color change handling and key equivalent support in Ruler Settings --- .../Base.lproj/RulerSettingsController.xib | 10 +- .../Base.lproj/RulerSettingsControlsView.xib | 2 +- Free Ruler/PreferencesController.swift | 127 ++++++++++++++++++ FreeRulerTests/RulerCoreTests.swift | 109 +++++++++++++++ 4 files changed, 242 insertions(+), 6 deletions(-) diff --git a/Free Ruler/Base.lproj/RulerSettingsController.xib b/Free Ruler/Base.lproj/RulerSettingsController.xib index 4011c5c..4632c4a 100644 --- a/Free Ruler/Base.lproj/RulerSettingsController.xib +++ b/Free Ruler/Base.lproj/RulerSettingsController.xib @@ -16,16 +16,16 @@ - + - + - + - + - + diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index ff95829..a24065d 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -38,6 +38,8 @@ private func setColorPickingIgnoresAlpha(_ ignoresAlpha: Bool) { class RulerColorWell: NSColorWell { + var colorDidChange: ((RulerColorWell) -> Void)? + override func awakeFromNib() { super.awakeFromNib() configureForOpaqueColors() @@ -49,6 +51,23 @@ class RulerColorWell: NSColorWell { configureForOpaqueColors() } + override func takeColorFrom(_ sender: Any?) { + if let colorPanel = sender as? NSColorPanel { + color = colorPanel.color + } else if let colorWell = sender as? NSColorWell { + color = colorWell.color + } else { + super.takeColorFrom(sender) + } + configureForOpaqueColors() + needsDisplay = true + if let colorDidChange = colorDidChange { + colorDidChange(self) + } else { + sendAction(action, to: target) + } + } + override func mouseDown(with event: NSEvent) { configureForOpaqueColors() @@ -147,6 +166,14 @@ final class RulerSettingsControlsView: NSView { loadContentView() } + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if performRulerSettingsKeyEquivalent(with: event) { + return true + } + + return super.performKeyEquivalent(with: event) + } + func configureForPreferences() { configureControls( colorWellIdentifier: "ruler-color-well", @@ -158,6 +185,7 @@ final class RulerSettingsControlsView: NSView { floatCheckboxIdentifier: "float-rulers-checkbox", shadowCheckboxIdentifier: "ruler-shadow-checkbox" ) + configureCheckboxKeyEquivalents(float: "", shadow: "") } func configureForRulerSettings() { @@ -171,6 +199,7 @@ final class RulerSettingsControlsView: NSView { floatCheckboxIdentifier: "ruler-settings-float-rulers-checkbox", shadowCheckboxIdentifier: "ruler-settings-ruler-shadow-checkbox" ) + configureCheckboxKeyEquivalents(float: "f", shadow: "s") } func update( @@ -205,6 +234,26 @@ final class RulerSettingsControlsView: NSView { configureKeyViewLoop() } + func performRulerSettingsKeyEquivalent(with event: NSEvent) -> Bool { + guard event.type == .keyDown, + event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.shift, .capsLock, .function]) + .isEmpty, + let character = event.charactersIgnoringModifiers?.lowercased() else { + return false + } + + switch character { + case floatRulersCheckbox.keyEquivalent.lowercased() where !floatRulersCheckbox.keyEquivalent.isEmpty: + return toggleFloatRulersFromKeyEquivalent() + case rulerShadowCheckbox.keyEquivalent.lowercased() where !rulerShadowCheckbox.keyEquivalent.isEmpty: + return toggleRulerShadowFromKeyEquivalent() + default: + return false + } + } + func deactivateColorWell() { rulerColorWell.deactivate() } @@ -235,6 +284,10 @@ final class RulerSettingsControlsView: NSView { private func configureBaseControls() { rulerColorWell.isContinuous = true rulerColorWell.supportsAlpha = false + rulerColorWell.colorDidChange = { [weak self] _ in + guard let self = self else { return } + self.setRulerColor(self.rulerColorWell as Any) + } rulerColorWell.target = self rulerColorWell.action = #selector(setRulerColor(_:)) @@ -298,6 +351,13 @@ final class RulerSettingsControlsView: NSView { rulerShadowCheckbox.setAccessibilityIdentifier(shadowCheckboxIdentifier) } + private func configureCheckboxKeyEquivalents(float: String, shadow: String) { + floatRulersCheckbox.keyEquivalent = float + floatRulersCheckbox.keyEquivalentModifierMask = [] + rulerShadowCheckbox.keyEquivalent = shadow + rulerShadowCheckbox.keyEquivalentModifierMask = [] + } + private func configureKeyViewLoop() { rulerColorWell.nextKeyView = resetRulerColorButton.isHidden ? foregroundOpacitySlider @@ -309,6 +369,22 @@ final class RulerSettingsControlsView: NSView { rulerShadowCheckbox.nextKeyView = rulerColorWell } + private func toggleFloatRulersFromKeyEquivalent() -> Bool { + guard floatRulersCheckbox.isEnabled else { return false } + + floatRulersCheckbox.state = floatRulersCheckbox.state == .on ? .off : .on + setFloatRulers(floatRulersCheckbox as Any) + return true + } + + private func toggleRulerShadowFromKeyEquivalent() -> Bool { + guard rulerShadowCheckbox.isEnabled else { return false } + + rulerShadowCheckbox.state = rulerShadowCheckbox.state == .on ? .off : .on + setRulerShadow(rulerShadowCheckbox as Any) + return true + } + @objc private func setRulerColor(_ sender: Any) { delegate?.rulerSettingsControlsDidChangeRulerColor(self) } @@ -545,10 +621,27 @@ extension PreferencesController: RulerSettingsControlsViewDelegate { } } +final class RulerSettingsWindow: NSPanel { + weak var settingsController: RulerSettingsController? + + override var canBecomeKey: Bool { + return true + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if settingsController?.performSettingsKeyEquivalent(with: event) == true { + return true + } + + return super.performKeyEquivalent(with: event) + } +} + final class RulerSettingsController: NSWindowController, NSWindowDelegate { private weak var rulerController: GroupedRulerController? private var colorPanelObserver: NSObjectProtocol? + private var didConfigureWindow = false @IBOutlet weak var settingsControlsView: RulerSettingsControlsView! @IBOutlet weak var resetDefaultsButton: NSButton! @@ -598,6 +691,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { self.rulerController = rulerController super.init(window: nil) loadWindow() + configureWindowIfNeeded() } required init?(coder: NSCoder) { @@ -607,14 +701,26 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { override func windowDidLoad() { super.windowDidLoad() + configureWindowIfNeeded() + } + + private func configureWindowIfNeeded() { + guard !didConfigureWindow, + isWindowLoaded, + settingsControlsView != nil else { return } + + didConfigureWindow = true window?.delegate = self window?.identifier = NSUserInterfaceItemIdentifier("ruler-settings-window") window?.setAccessibilityIdentifier("ruler-settings-window") window?.isMovableByWindowBackground = true window?.isReleasedWhenClosed = false window?.initialFirstResponder = rulerColorWell + configureFloatingPanelWindow() settingsControlsView.delegate = self settingsControlsView.configureForRulerSettings() + rulerColorWell.target = self + rulerColorWell.action = #selector(setRulerColor(_:)) resetDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-ruler-settings-to-default-button") resetDefaultsButton.setAccessibilityIdentifier("reset-ruler-settings-to-default-button") setDefaultsButton.identifier = NSUserInterfaceItemIdentifier("save-ruler-settings-as-default-button") @@ -741,6 +847,10 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } func updateView() { + configureWindowIfNeeded() + guard isWindowLoaded, + settingsControlsView != nil else { return } + let currentSettings = rulerController?.state.settings let hasRuler = rulerController != nil @@ -756,6 +866,23 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { setDefaultsButton.isEnabled = hasRuler } + func performSettingsKeyEquivalent(with event: NSEvent) -> Bool { + return settingsControlsView.performRulerSettingsKeyEquivalent(with: event) + } + + private func configureFloatingPanelWindow() { + guard let settingsWindow = window else { return } + + settingsWindow.styleMask.insert(.utilityWindow) + settingsWindow.animationBehavior = .utilityWindow + + guard let panel = settingsWindow as? RulerSettingsWindow else { return } + + panel.settingsController = self + panel.isFloatingPanel = true + panel.hidesOnDeactivate = false + } + private func applyRulerColor(_ color: NSColor) { applySettings { settings in settings.setRulerColor(color) diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 3cf88af..1dbbf70 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -478,6 +478,69 @@ final class RulerCoreTests: XCTestCase { } } + func testRulerSettingsControllerAppliesColorPanelChangesToActiveRuler() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + rulerColor: NSColor(deviceRed: 0.2, green: 0.3, blue: 0.4, alpha: 1) + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + closeRulerColorPanel() + } + + let selectedColor = NSColor(deviceRed: 0.7, green: 0.1, blue: 0.5, alpha: 0.35) + NSColorPanel.shared.color = selectedColor + settingsController.rulerColorWell.takeColorFrom(NSColorPanel.shared) + + let normalizedColor = NSColor(deviceRed: 0.7, green: 0.1, blue: 0.5, alpha: 1) + assertColor(controller.state.settings.rulerColor, equals: normalizedColor) + assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: normalizedColor) + assertColor(settingsController.rulerColorWell.color, equals: normalizedColor) + } + + func testRulerSettingsControllerCheckboxKeyEquivalentsToggleFloatAndShadow() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(floatRulers: false, rulerShadow: false), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + let floatEvent = keyDownEvent(characters: "f", keyCode: 3) + let shadowEvent = keyDownEvent(characters: "s", keyCode: 1) + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + + XCTAssertTrue(settingsWindow.performKeyEquivalent(with: floatEvent)) + XCTAssertTrue(controller.state.settings.floatRulers) + XCTAssertTrue(settingsController.floatRulersCheckbox.state == .on) + + XCTAssertTrue(settingsWindow.performKeyEquivalent(with: shadowEvent)) + XCTAssertTrue(controller.state.settings.rulerShadow) + XCTAssertTrue(settingsController.rulerShadowCheckbox.state == .on) + } + func testPreferencesControllerResetsDefaultsToFactoryDefaults() { withRestoredRulerPreferences { prefs.unit = .inches @@ -570,6 +633,37 @@ final class RulerCoreTests: XCTestCase { XCTAssertNil(settingsWindow.sheetParent) } + func testRulerSettingsControllerUsesFloatingUtilityPanelStyle() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + + XCTAssertTrue(settingsWindow is NSPanel) + XCTAssertTrue(settingsWindow.styleMask.contains(.utilityWindow)) + XCTAssertEqual(settingsWindow.animationBehavior, .utilityWindow) + + let settingsPanel = settingsWindow as? NSPanel + XCTAssertTrue(settingsPanel?.isFloatingPanel ?? false) + XCTAssertFalse(settingsPanel?.hidesOnDeactivate ?? true) + } + func testRulerSettingsControllerRestoresForegroundOpacityWhenClosingSheet() { let controller = GroupedRulerController( state: RulerInstanceState( @@ -3262,6 +3356,21 @@ final class RulerCoreTests: XCTestCase { try test() } + private func keyDownEvent(characters: String, keyCode: UInt16) -> NSEvent { + return NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + )! + } + private func withRestoredRulerSetState(_ test: () throws -> Void) rethrows { let defaults = UserDefaults.standard let previousState = defaults.object(forKey: Prefs.rulerSetStateKey) From f129bb5f533f225ce63a643caa9cdd589b261c24 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 00:35:55 -0400 Subject: [PATCH 17/38] Enhance ruler settings functionality with color panel attachment and state management updates --- Free Ruler/AppDelegate.swift | 13 +- Free Ruler/GroupedRulerWindow.swift | 1 + Free Ruler/PreferencesController.swift | 178 ++++++++++++++---- FreeRulerTests/RulerCoreTests.swift | 242 +++++++++++++++++++++++++ 4 files changed, 399 insertions(+), 35 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 0956b4e..7f7ed70 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -79,8 +79,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { settingsController.close() } } - manager.onStateChanged = { [weak self] _ in - self?.saveRulerSetState() + manager.onStateChanged = { [weak self] manager in + guard let self = self else { return } + + self.saveRulerSetState() + + let activeController = manager.activeController + guard let settingsController = self.rulerSettingsController, + settingsController.currentRulerController === activeController, + settingsController.window?.isVisible == true else { return } + + settingsController.updateView() } return manager }() diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index e260023..57366ce 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1264,6 +1264,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi updateHasShadow() groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) captureStateFromWindow() + notifyStateChanged() } func foreground() { diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index a24065d..96c3481 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -39,6 +39,7 @@ private func setColorPickingIgnoresAlpha(_ ignoresAlpha: Bool) { class RulerColorWell: NSColorWell { var colorDidChange: ((RulerColorWell) -> Void)? + var colorPanelPresenter: ((RulerColorWell, NSColorPanel) -> Void)? override func awakeFromNib() { super.awakeFromNib() @@ -47,10 +48,20 @@ class RulerColorWell: NSColorWell { override func activate(_ exclusive: Bool) { configureForOpaqueColors() - super.activate(exclusive) + openColorPanel() configureForOpaqueColors() } + override func keyDown(with event: NSEvent) { + guard event.charactersIgnoringModifiers == " " else { + super.keyDown(with: event) + return + } + + configureForOpaqueColors() + openColorPanel() + } + override func takeColorFrom(_ sender: Any?) { if let colorPanel = sender as? NSColorPanel { color = colorPanel.color @@ -70,8 +81,12 @@ class RulerColorWell: NSColorWell { override func mouseDown(with event: NSEvent) { configureForOpaqueColors() + openColorPanel() + } + private func openColorPanel() { let colorPanel = NSColorPanel.shared + guard !colorPanel.isVisible else { closeRulerColorPanel() return @@ -82,7 +97,11 @@ class RulerColorWell: NSColorWell { colorPanel.color = color colorPanel.setTarget(self) colorPanel.setAction(#selector(takeColorFrom(_:))) - colorPanel.orderFront(self) + if let colorPanelPresenter = colorPanelPresenter { + colorPanelPresenter(self, colorPanel) + } else { + colorPanel.orderFront(self) + } configureForOpaqueColors() configureOpaqueColorPickingAfterPanelUpdates() } @@ -186,6 +205,7 @@ final class RulerSettingsControlsView: NSView { shadowCheckboxIdentifier: "ruler-shadow-checkbox" ) configureCheckboxKeyEquivalents(float: "", shadow: "") + rulerColorWell.colorPanelPresenter = nil } func configureForRulerSettings() { @@ -719,6 +739,9 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { configureFloatingPanelWindow() settingsControlsView.delegate = self settingsControlsView.configureForRulerSettings() + rulerColorWell.colorPanelPresenter = { [weak self] colorWell, colorPanel in + self?.presentColorPanel(colorPanel, for: colorWell) + } rulerColorWell.target = self rulerColorWell.action = #selector(setRulerColor(_:)) resetDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-ruler-settings-to-default-button") @@ -864,6 +887,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { ) resetDefaultsButton.isEnabled = hasRuler setDefaultsButton.isEnabled = hasRuler + repositionAttachedWindowsIfNeeded() } func performSettingsKeyEquivalent(with event: NSEvent) -> Bool { @@ -883,6 +907,86 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { panel.hidesOnDeactivate = false } + func presentColorPanel(_ colorPanel: NSColorPanel, for colorWell: RulerColorWell) { + guard let settingsWindow = window else { + colorPanel.orderFront(colorWell) + return + } + + if let parentWindow = colorPanel.parent, parentWindow !== settingsWindow { + parentWindow.removeChildWindow(colorPanel) + } + + position(colorPanel, attachedTo: settingsWindow) + + if colorPanel.parent == nil { + settingsWindow.addChildWindow(colorPanel, ordered: .above) + } + + colorPanel.orderFront(colorWell) + } + + private func position(_ colorPanel: NSColorPanel, attachedTo settingsWindow: NSWindow) { + let margin: CGFloat = 8 + let zeroCorner = rulerController?.state.settings.zeroCorner ?? Prefs.defaultZeroCorner + let colorPanelSize = colorPanel.frame.size + let defaultTopLeft = colorPanelTopLeftPoint( + for: colorPanelSize, + attachedTo: settingsWindow.frame, + zeroCorner: zeroCorner, + margin: margin + ) + guard let visibleFrame = settingsWindow.screen?.visibleFrame ?? colorPanel.screen?.visibleFrame else { + colorPanel.setFrameTopLeftPoint(defaultTopLeft) + return + } + + var topLeftPoint = defaultTopLeft + if topLeftPoint.x < visibleFrame.minX { + topLeftPoint.x = min(settingsWindow.frame.maxX + margin, visibleFrame.maxX - colorPanelSize.width) + } else if topLeftPoint.x + colorPanelSize.width > visibleFrame.maxX { + topLeftPoint.x = max(settingsWindow.frame.minX - colorPanelSize.width - margin, visibleFrame.minX) + } + + if colorPanelSize.height <= visibleFrame.height { + topLeftPoint.y = clamp( + topLeftPoint.y, + lower: visibleFrame.minY + colorPanelSize.height, + upper: visibleFrame.maxY + ) + } else { + topLeftPoint.y = visibleFrame.maxY + } + + colorPanel.setFrameTopLeftPoint(topLeftPoint) + } + + private func colorPanelTopLeftPoint( + for colorPanelSize: NSSize, + attachedTo settingsFrame: NSRect, + zeroCorner: ZeroCorner, + margin: CGFloat + ) -> NSPoint { + let x: CGFloat + let y: CGFloat + + switch zeroCorner { + case .topLeft, .bottomLeft: + x = settingsFrame.maxX + margin + case .topRight, .bottomRight: + x = settingsFrame.minX - colorPanelSize.width - margin + } + + switch zeroCorner { + case .topLeft, .topRight: + y = settingsFrame.maxY + case .bottomLeft, .bottomRight: + y = settingsFrame.minY + colorPanelSize.height + } + + return NSPoint(x: x, y: y) + } + private func applyRulerColor(_ color: NSColor) { applySettings { settings in settings.setRulerColor(color) @@ -890,6 +994,20 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { updateView() } + private func repositionAttachedWindowsIfNeeded() { + guard let controller = rulerController, + let settingsWindow = window, + settingsWindow.isVisible, + settingsWindow.parent === controller.groupedWindow else { return } + + position(settingsWindow, attachedTo: controller) + + let colorPanel = NSColorPanel.shared + if colorPanel.parent === settingsWindow { + position(colorPanel, attachedTo: settingsWindow) + } + } + private func applySettings(_ update: (inout RulerSettings) -> Void) { rulerController?.updateSettings(update) } @@ -914,42 +1032,33 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } private func position(_ settingsWindow: NSWindow, attachedTo controller: GroupedRulerController) { - let parentFrame = controller.groupedWindow.frame let settingsSize = settingsWindow.frame.size - let margin: CGFloat = 12 - let frame: NSRect - - if controller.state.visibility.showsHorizontal { - let horizontalFrame = controller.groupedWindow.screenFrame(for: .horizontal) - let x = clamp( - horizontalFrame.midX - settingsSize.width / 2, - lower: parentFrame.minX + margin, - upper: parentFrame.maxX - settingsSize.width - margin - ) - let belowY = horizontalFrame.minY - settingsSize.height - margin - let aboveY = horizontalFrame.maxY + margin - let y = belowY >= parentFrame.minY + margin - ? belowY - : min(aboveY, parentFrame.maxY - settingsSize.height - margin) - frame = NSRect(origin: NSPoint(x: x, y: y), size: settingsSize) - } else { - let verticalFrame = controller.groupedWindow.screenFrame(for: .vertical) - let y = clamp( - verticalFrame.midY - settingsSize.height / 2, - lower: parentFrame.minY + margin, - upper: parentFrame.maxY - settingsSize.height - margin - ) - let rightX = verticalFrame.maxX + margin - let leftX = verticalFrame.minX - settingsSize.width - margin - let x = rightX + settingsSize.width <= parentFrame.maxX - margin - ? rightX - : max(leftX, parentFrame.minX + margin) - frame = NSRect(origin: NSPoint(x: x, y: y), size: settingsSize) - } + let frame = settingsFrame( + size: settingsSize, + zeroPoint: controller.groupedWindow.zeroPoint(), + zeroCorner: controller.state.settings.zeroCorner + ) settingsWindow.setFrame(frame, display: true) } + private func settingsFrame(size: NSSize, zeroPoint: NSPoint, zeroCorner: ZeroCorner) -> NSRect { + let origin: NSPoint + + switch zeroCorner { + case .topLeft: + origin = NSPoint(x: zeroPoint.x, y: zeroPoint.y - size.height) + case .topRight: + origin = NSPoint(x: zeroPoint.x - size.width, y: zeroPoint.y - size.height) + case .bottomLeft: + origin = zeroPoint + case .bottomRight: + origin = NSPoint(x: zeroPoint.x - size.width, y: zeroPoint.y) + } + + return NSRect(origin: origin, size: size) + } + private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { guard lower <= upper else { return lower } @@ -1005,6 +1114,9 @@ extension RulerSettingsController: RulerSettingsControlsViewDelegate { func closeRulerColorPanel() { activeRulerColorWell = nil let colorPanel = NSColorPanel.shared + if let parentWindow = colorPanel.parent { + parentWindow.removeChildWindow(colorPanel) + } colorPanel.animationBehavior = .none colorPanel.setTarget(nil) colorPanel.setAction(nil) diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 1dbbf70..da891a5 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -633,6 +633,53 @@ final class RulerCoreTests: XCTestCase { XCTAssertNil(settingsWindow.sheetParent) } + func testRulerSettingsControllerAnchorsPanelCornerToRulerZeroPoint() { + let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900) + let zeroPoint = NSPoint(x: visibleFrame.midX, y: visibleFrame.midY) + + for zeroCorner in [ZeroCorner.topLeft, .topRight, .bottomLeft, .bottomRight] { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(zeroCorner: zeroCorner), + layout: RulerLayoutState( + zeroPoint: zeroPoint, + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + controller.show() + settingsController.show(attachedTo: controller, sender: self) + + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + + let rulerZeroPoint = controller.groupedWindow.zeroPoint() + switch zeroCorner { + case .topLeft: + XCTAssertEqual(settingsWindow.frame.minX, rulerZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.maxY, rulerZeroPoint.y, accuracy: 1) + case .topRight: + XCTAssertEqual(settingsWindow.frame.maxX, rulerZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.maxY, rulerZeroPoint.y, accuracy: 1) + case .bottomLeft: + XCTAssertEqual(settingsWindow.frame.minX, rulerZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.minY, rulerZeroPoint.y, accuracy: 1) + case .bottomRight: + XCTAssertEqual(settingsWindow.frame.maxX, rulerZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.minY, rulerZeroPoint.y, accuracy: 1) + } + } + } + func testRulerSettingsControllerUsesFloatingUtilityPanelStyle() { let controller = GroupedRulerController( state: RulerInstanceState( @@ -664,6 +711,32 @@ final class RulerCoreTests: XCTestCase { XCTAssertFalse(settingsPanel?.hidesOnDeactivate ?? true) } + func testRulerSettingsColorPanelAttachesOnRightForLeftZeroCorner() { + assertRulerSettingsColorPanelAttachesToSettingsPanel(zeroCorner: .topLeft) { settingsController, settingsWindow in + settingsController.rulerColorWell.mouseDown( + with: mouseDownEvent(windowNumber: settingsWindow.windowNumber) + ) + } + } + + func testRulerSettingsColorPanelActivatedByKeyboardUsesAnchoredPlacement() { + assertRulerSettingsColorPanelAttachesToSettingsPanel(zeroCorner: .topLeft) { settingsController, _ in + settingsController.rulerColorWell.keyDown( + with: keyDownEvent(characters: " ", keyCode: UInt16(kVK_Space)) + ) + } + } + + func testRulerSettingsColorPanelAttachesOnLeftForRightZeroCorners() { + for zeroCorner in [ZeroCorner.topRight, .bottomRight] { + assertRulerSettingsColorPanelAttachesToSettingsPanel(zeroCorner: zeroCorner) { settingsController, settingsWindow in + settingsController.rulerColorWell.mouseDown( + with: mouseDownEvent(windowNumber: settingsWindow.windowNumber) + ) + } + } + } + func testRulerSettingsControllerRestoresForegroundOpacityWhenClosingSheet() { let controller = GroupedRulerController( state: RulerInstanceState( @@ -728,6 +801,49 @@ final class RulerCoreTests: XCTestCase { XCTAssertFalse(settingsWindow.isVisible) } + func testRulerSettingsControllerReanchorsWhenRulerZeroCornerChanges() { + withRestoredRulerPreferences { + withRestoredRulerSetState { + let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900) + let zeroPoint = NSPoint(x: visibleFrame.midX, y: visibleFrame.midY) + let appDelegate = AppDelegate() + let controller = appDelegate.rulerManager.addRuler( + state: RulerInstanceState( + settings: RulerSettings(zeroCorner: .topLeft), + layout: RulerLayoutState( + zeroPoint: zeroPoint, + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + defer { + appDelegate.rulerSettingsController?.close() + controller.hide() + } + + controller.show() + appDelegate.openRulerSettings(self) + + guard let settingsWindow = appDelegate.rulerSettingsController?.window else { + XCTFail("Expected settings window") + return + } + + let initialZeroPoint = controller.groupedWindow.zeroPoint() + XCTAssertEqual(settingsWindow.frame.minX, initialZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.maxY, initialZeroPoint.y, accuracy: 1) + + appDelegate.flipRulers(along: .horizontal) + + let flippedZeroPoint = controller.groupedWindow.zeroPoint() + XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) + XCTAssertEqual(settingsWindow.frame.maxX, flippedZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.maxY, flippedZeroPoint.y, accuracy: 1) + } + } + } + func testRulerManagerCopiesUpdatedDefaultsOnlyForNewRulers() { withRestoredRulerPreferences { prefs.unit = .pixels @@ -3371,6 +3487,132 @@ final class RulerCoreTests: XCTestCase { )! } + private func assertRulerSettingsColorPanelAttachesToSettingsPanel( + zeroCorner: ZeroCorner, + openingColorPanel: (RulerSettingsController, NSWindow) -> Void, + file: StaticString = #filePath, + line: UInt = #line + ) { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(zeroCorner: zeroCorner), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + let colorPanel = NSColorPanel.shared + closeRulerColorPanel() + let originalColorPanelFrame = colorPanel.frame + defer { + settingsController.close() + controller.hide() + closeRulerColorPanel() + colorPanel.setFrame(originalColorPanelFrame, display: false) + } + + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window", file: file, line: line) + return + } + + let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900) + settingsWindow.setFrame( + NSRect( + x: visibleFrame.minX + 60, + y: visibleFrame.maxY - 340, + width: 320, + height: 300 + ), + display: false + ) + settingsWindow.orderFront(self) + + let colorPanelSize = colorPanel.frame.size + let expectedFrame = settingsWindow.screen?.visibleFrame ?? colorPanel.screen?.visibleFrame + let expectedX: CGFloat + let expectedMaxY: CGFloat + if let expectedFrame = expectedFrame { + var expectedTopLeft = expectedColorPanelTopLeftPoint( + colorPanelSize: colorPanelSize, + settingsFrame: settingsWindow.frame, + zeroCorner: zeroCorner + ) + if expectedTopLeft.x < expectedFrame.minX { + expectedTopLeft.x = min(settingsWindow.frame.maxX + 8, expectedFrame.maxX - colorPanelSize.width) + } else if expectedTopLeft.x + colorPanelSize.width > expectedFrame.maxX { + expectedTopLeft.x = max(settingsWindow.frame.minX - colorPanelSize.width - 8, expectedFrame.minX) + } + if colorPanelSize.height <= expectedFrame.height { + expectedTopLeft.y = min( + max(expectedTopLeft.y, expectedFrame.minY + colorPanelSize.height), + expectedFrame.maxY + ) + } else { + expectedTopLeft.y = expectedFrame.maxY + } + expectedX = expectedTopLeft.x + expectedMaxY = expectedTopLeft.y + } else { + let expectedTopLeft = expectedColorPanelTopLeftPoint( + colorPanelSize: colorPanelSize, + settingsFrame: settingsWindow.frame, + zeroCorner: zeroCorner + ) + expectedX = expectedTopLeft.x + expectedMaxY = expectedTopLeft.y + } + + openingColorPanel(settingsController, settingsWindow) + + XCTAssertTrue(colorPanel.parent === settingsWindow, file: file, line: line) + XCTAssertTrue(settingsWindow.childWindows?.contains(colorPanel) ?? false, file: file, line: line) + XCTAssertEqual(colorPanel.frame.minX, expectedX, accuracy: 1, file: file, line: line) + XCTAssertEqual(colorPanel.frame.maxY, expectedMaxY, accuracy: 1, file: file, line: line) + } + + private func expectedColorPanelTopLeftPoint( + colorPanelSize: NSSize, + settingsFrame: NSRect, + zeroCorner: ZeroCorner + ) -> NSPoint { + let x: CGFloat + let y: CGFloat + + switch zeroCorner { + case .topLeft, .bottomLeft: + x = settingsFrame.maxX + 8 + case .topRight, .bottomRight: + x = settingsFrame.minX - colorPanelSize.width - 8 + } + + switch zeroCorner { + case .topLeft, .topRight: + y = settingsFrame.maxY + case .bottomLeft, .bottomRight: + y = settingsFrame.minY + colorPanelSize.height + } + + return NSPoint(x: x, y: y) + } + + private func mouseDownEvent(windowNumber: Int) -> NSEvent { + return NSEvent.mouseEvent( + with: .leftMouseDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + )! + } + private func withRestoredRulerSetState(_ test: () throws -> Void) rethrows { let defaults = UserDefaults.standard let previousState = defaults.object(forKey: Prefs.rulerSetStateKey) From 2163c346424d7dfec37061531dda3139da34be4c Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 00:58:21 -0400 Subject: [PATCH 18/38] Refactor mouse tick transformations in Horizontal and Vertical rules for improved accuracy and consistency; update related test cases to reflect changes. --- .../Base.lproj/RulerSettingsControlsView.xib | 2 +- Free Ruler/HorizontalRule.swift | 9 +++--- Free Ruler/VerticalRule.swift | 9 +++--- FreeRulerTests/RulerCoreTests.swift | 26 ++++++++++++++---- .../ruler-mouse-tick-labels.png | Bin 14006 -> 13956 bytes 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib index 79721e9..7ca5a8d 100644 --- a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib +++ b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib @@ -44,7 +44,7 @@ - + diff --git a/Free Ruler/HorizontalRule.swift b/Free Ruler/HorizontalRule.swift index a526f77..498ffb7 100644 --- a/Free Ruler/HorizontalRule.swift +++ b/Free Ruler/HorizontalRule.swift @@ -3,6 +3,7 @@ import Cocoa class HorizontalRule: RuleView { let transformer = AffineTransform(translationByX: 0.5, byY: 0) + let mouseTickTransformer = AffineTransform(translationByX: -0.5, byY: 0) override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -131,7 +132,7 @@ class HorizontalRule: RuleView { mouseTick.move(to: CGPoint(x: lineX, y: 0)) mouseTick.line(to: CGPoint(x: lineX, y: height)) - mouseTick.transform(using: transformer) + mouseTick.transform(using: mouseTickTransformer) color.mouseTick.setStroke() mouseTick.stroke() @@ -146,7 +147,7 @@ class HorizontalRule: RuleView { case .positive: zeroTickX = bounds.minX case .negative: - zeroTickX = bounds.maxX + zeroTickX = bounds.maxX - 1 } let lineX = mouseTickLineX(forTickX: zeroTickX, growthDirection: growthDirection) @@ -282,7 +283,7 @@ class HorizontalRule: RuleView { case .positive: return mouseTickX case .negative: - return rulerWidth - mouseTickX + return max(0, rulerWidth - mouseTickX - 1) } } @@ -294,7 +295,7 @@ class HorizontalRule: RuleView { case .positive: return mouseTickX case .negative: - return mouseTickX - 1 + return mouseTickX } } diff --git a/Free Ruler/VerticalRule.swift b/Free Ruler/VerticalRule.swift index 8125b5e..74560d4 100644 --- a/Free Ruler/VerticalRule.swift +++ b/Free Ruler/VerticalRule.swift @@ -3,6 +3,7 @@ import Cocoa class VerticalRule: RuleView { let transformer = AffineTransform(translationByX: 0, byY: -0.5) + let mouseTickTransformer = AffineTransform(translationByX: 0, byY: 0.5) override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -136,7 +137,7 @@ class VerticalRule: RuleView { mouseTick.move(to: CGPoint(x: startX, y: lineY)) mouseTick.line(to: CGPoint(x: rulerWidth, y: lineY)) - mouseTick.transform(using: transformer) + mouseTick.transform(using: mouseTickTransformer) color.mouseTick.setStroke() mouseTick.stroke() @@ -149,7 +150,7 @@ class VerticalRule: RuleView { switch growthDirection { case .positive: - zeroTickY = bounds.minY + zeroTickY = bounds.minY + 1 case .negative: zeroTickY = bounds.maxY } @@ -322,7 +323,7 @@ class VerticalRule: RuleView { switch growthDirection { case .positive: - return mouseTickY + return max(0, mouseTickY - 1) case .negative: return rulerHeight - mouseTickY } @@ -334,7 +335,7 @@ class VerticalRule: RuleView { ) -> CGFloat { switch growthDirection { case .positive: - return mouseTickY + 1 + return mouseTickY case .negative: return mouseTickY } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index da891a5..6cbeb9b 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -1660,7 +1660,7 @@ final class RulerCoreTests: XCTestCase { ) XCTAssertEqual( rule.mouseTickLineX(forTickX: 299, growthDirection: .negative), - 298 + 299 ) let bottomTick = rule.tickLine(forX: 50, length: 10, rulerHeight: 40, tickSide: .bottom) @@ -1687,7 +1687,14 @@ final class RulerCoreTests: XCTestCase { prefs.zeroCorner = .bottomRight let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - XCTAssertEqual(rule.mouseNumber(forTickX: 260, rulerWidth: 300), 40) + XCTAssertEqual(rule.mouseNumber(forTickX: 259, rulerWidth: 300), 40) + XCTAssertEqual( + rule.mouseNumber( + forTickX: rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative), + rulerWidth: 300 + ), + 50 + ) XCTAssertEqual( rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 300, height: 40)), CGRect(x: 280, y: 0, width: 20, height: 19) @@ -1712,7 +1719,7 @@ final class RulerCoreTests: XCTestCase { ) XCTAssertEqual( rule.mouseTickLineY(forTickY: 1, growthDirection: .positive), - 2 + 1 ) let rightTick = rule.tickLine(forY: 250, length: 10, rulerWidth: 40, tickSide: .right) @@ -1739,7 +1746,14 @@ final class RulerCoreTests: XCTestCase { prefs.zeroCorner = .bottomRight let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) - XCTAssertEqual(rule.mouseNumber(forTickY: 40, rulerHeight: 300), 40) + XCTAssertEqual(rule.mouseNumber(forTickY: 41, rulerHeight: 300), 40) + XCTAssertEqual( + rule.mouseNumber( + forTickY: rule.tickY(forOffset: 50, rulerHeight: 300, growthDirection: .positive), + rulerHeight: 300 + ), + 50 + ) XCTAssertEqual( rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 40, height: 300)), CGRect(x: 20, y: 0, width: 20, height: 19) @@ -1954,8 +1968,8 @@ final class RulerCoreTests: XCTestCase { let verticalRule = VerticalRule( frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300) ) - let horizontalNumber = horizontalRule.mouseNumber(forTickX: 260, rulerWidth: 300) - let verticalNumber = verticalRule.mouseNumber(forTickY: 40, rulerHeight: 300) + let horizontalNumber = horizontalRule.mouseNumber(forTickX: 259, rulerWidth: 300) + let verticalNumber = verticalRule.mouseNumber(forTickY: 41, rulerHeight: 300) let horizontalDpmm = horizontalRule.screen?.dpmm.width ?? NSScreen.defaultDpmm let verticalDpmm = verticalRule.screen?.dpmm.width ?? NSScreen.defaultDpmm let horizontalDpi = horizontalRule.screen?.dpi.width ?? NSScreen.defaultDpi diff --git a/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png b/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png index a825cbb3656e3af26a5d49f61ab35cdf743f22f5..ff23f589ca62d17d8cf5741d4e6c613d7c4efac6 100644 GIT binary patch literal 13956 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 Date: Fri, 19 Jun 2026 01:21:55 -0400 Subject: [PATCH 19/38] Refactor mouse tick calculations in Horizontal and Vertical rules for improved accuracy; introduce custom crosshair cursor with bitmap image representation and update related tests. --- Free Ruler/HorizontalRule.swift | 4 +- Free Ruler/RulerCursorController.swift | 92 +++++++++++- Free Ruler/VerticalRule.swift | 4 +- FreeRulerTests/RulerCoreTests.swift | 137 +++++++++++++----- .../ruler-mouse-tick-labels.png | Bin 13956 -> 14022 bytes 5 files changed, 194 insertions(+), 43 deletions(-) diff --git a/Free Ruler/HorizontalRule.swift b/Free Ruler/HorizontalRule.swift index 498ffb7..33d8576 100644 --- a/Free Ruler/HorizontalRule.swift +++ b/Free Ruler/HorizontalRule.swift @@ -281,9 +281,9 @@ class HorizontalRule: RuleView { switch growthDirection { case .positive: - return mouseTickX + return max(0, mouseTickX - 1) case .negative: - return max(0, rulerWidth - mouseTickX - 1) + return max(0, rulerWidth - mouseTickX) } } diff --git a/Free Ruler/RulerCursorController.swift b/Free Ruler/RulerCursorController.swift index 58bba53..50fbdd1 100644 --- a/Free Ruler/RulerCursorController.swift +++ b/Free Ruler/RulerCursorController.swift @@ -25,7 +25,7 @@ final class RulerCursorController { case .arrow: return .arrow case .crosshair: - return .crosshair + return RulerCrosshairCursor.cursor case .openHand: return .openHand case .closedHand: @@ -117,3 +117,93 @@ private extension RulerCursorController.CursorStyle { UITestSupport.current?.writeCursorState(uiTestStateValue) } } + +private enum RulerCrosshairCursor { + static let cursor = NSCursor(image: image, hotSpot: hotSpot) + + private static let imageSize = NSSize(width: 17, height: 17) + private static let pixelScale = 2 + private static let pixelSize = Int(imageSize.width) * pixelScale + private static let hotSpot = NSPoint(x: 8.5, y: 8.5) + private static let outlineRange = 14...19 + private static let strokeRange = 16...17 + private static let outlineExtent = 2...31 + private static let strokeExtent = 4...29 + + private static let image: NSImage = { + let image = NSImage(size: imageSize) + + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: pixelSize, + pixelsHigh: pixelSize, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bitmapFormat: [.alphaNonpremultiplied], + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + image.isTemplate = false + return image + } + + bitmap.size = imageSize + drawCrosshair(in: bitmap) + image.addRepresentation(bitmap) + image.isTemplate = false + + return image + }() + + private static func drawCrosshair(in bitmap: NSBitmapImageRep) { + for offset in outlineExtent { + for crosshairPixel in outlineRange { + setPixel(.white, atX: offset, y: crosshairPixel, in: bitmap) + setPixel(.white, atX: crosshairPixel, y: offset, in: bitmap) + } + } + + for offset in strokeExtent { + for crosshairPixel in strokeRange { + setPixel(.black, atX: offset, y: crosshairPixel, in: bitmap) + setPixel(.black, atX: crosshairPixel, y: offset, in: bitmap) + } + } + } + + private enum Pixel { + case black + case white + + var rgba: (red: UInt8, green: UInt8, blue: UInt8, alpha: UInt8) { + switch self { + case .black: + return (0, 0, 0, 255) + case .white: + return (255, 255, 255, 255) + } + } + } + + private static func setPixel( + _ pixel: Pixel, + atX x: Int, + y: Int, + in bitmap: NSBitmapImageRep + ) { + guard (0.. 0 else { continue } + + XCTAssertEqual(color.alphaComponent, 1, accuracy: 0.0001) + XCTAssertEqual(color.redComponent, color.redComponent.rounded(), accuracy: 0.0001) + XCTAssertEqual(color.greenComponent, color.greenComponent.rounded(), accuracy: 0.0001) + XCTAssertEqual(color.blueComponent, color.blueComponent.rounded(), accuracy: 0.0001) + } + } + } + func testMouseTickTimerPolicyRunsOnlyWhenRulersAreVisible() { let policy = MouseTickTimerPolicy(foregroundInterval: 1 / 60, backgroundInterval: 1 / 30) @@ -3754,6 +3799,22 @@ private func assertColor( XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.0001, file: file, line: line) } +private func assertPixel( + atX x: Int, + y: Int, + in bitmap: NSBitmapImageRep, + equals expectedColor: NSColor, + file: StaticString = #filePath, + line: UInt = #line +) { + guard let actualColor = bitmap.colorAt(x: x, y: y) else { + XCTFail("Missing pixel at \(x), \(y)", file: file, line: line) + return + } + + assertColor(actualColor, equals: expectedColor, file: file, line: line) +} + private func relativeLuminance( _ color: NSColor, file: StaticString = #filePath, diff --git a/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png b/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png index ff23f589ca62d17d8cf5741d4e6c613d7c4efac6..0db648c43cf593c1185605699a5a95339c510fc7 100644 GIT binary patch literal 14022 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 f_&DDMUi*(@sPxkPE`u(DN7BBw?yeXW` literal 13956 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 Date: Fri, 19 Jun 2026 01:31:16 -0400 Subject: [PATCH 20/38] Update ResizeHandleView to use zeroCorner parameter directly; add test for resizing behavior with zeroCorner override in RulerCoreTests. --- Free Ruler/ResizeHandleView.swift | 2 +- FreeRulerTests/RulerCoreTests.swift | 80 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/Free Ruler/ResizeHandleView.swift b/Free Ruler/ResizeHandleView.swift index 2707660..08429ba 100644 --- a/Free Ruler/ResizeHandleView.swift +++ b/Free Ruler/ResizeHandleView.swift @@ -129,7 +129,7 @@ final class ResizeHandleView: NSView { ) let nextFrame = resizedRulerFrame( orientation: orientation, - zeroCorner: prefs.zeroCorner, + zeroCorner: zeroCorner, initialFrame: dragInitialWindowFrame, delta: delta, minSize: window.minSize, diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index f6345e1..1464fec 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -2882,6 +2882,86 @@ final class RulerCoreTests: XCTestCase { } } + func testResizeHandleDragUsesRuleZeroCornerOverride() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + + let horizontalInitialFrame = NSRect(x: 100, y: 200, width: 300, height: Ruler.thickness) + let horizontalWindow = RulerWindow(Ruler(.horizontal, frame: horizontalInitialFrame)) + defer { horizontalWindow.close() } + horizontalWindow.rule.settingsOverride = RulerSettings(zeroCorner: .topRight) + guard let horizontalResizeHandle = horizontalWindow.rule.subviews + .first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } + + let horizontalStartLocation = horizontalResizeHandle.convert( + NSPoint(x: horizontalResizeHandle.bounds.minX + 1, y: horizontalResizeHandle.bounds.midY), + to: nil + ) + horizontalResizeHandle.mouseDown(with: mouseEvent( + type: .leftMouseDown, + location: horizontalStartLocation, + windowNumber: horizontalWindow.windowNumber, + timestamp: 0 + )) + horizontalResizeHandle.mouseDragged(with: mouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: horizontalStartLocation.x + 50, y: horizontalStartLocation.y), + windowNumber: horizontalWindow.windowNumber, + timestamp: 0.1 + )) + + XCTAssertEqual(horizontalWindow.frame.maxX, horizontalInitialFrame.maxX) + XCTAssertEqual(horizontalWindow.frame.minX, horizontalInitialFrame.minX + 50) + XCTAssertEqual(horizontalWindow.frame.width, horizontalInitialFrame.width - 50) + + horizontalResizeHandle.mouseUp(with: mouseEvent( + type: .leftMouseUp, + location: horizontalStartLocation, + windowNumber: horizontalWindow.windowNumber, + timestamp: 0.2 + )) + + let verticalInitialFrame = NSRect(x: 300, y: 200, width: Ruler.thickness, height: 300) + let verticalWindow = RulerWindow(Ruler(.vertical, frame: verticalInitialFrame)) + defer { verticalWindow.close() } + verticalWindow.rule.settingsOverride = RulerSettings(zeroCorner: .bottomLeft) + guard let verticalResizeHandle = verticalWindow.rule.subviews + .first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected vertical ruler to install a resize handle") + } + + let verticalStartLocation = verticalResizeHandle.convert( + NSPoint(x: verticalResizeHandle.bounds.midX, y: verticalResizeHandle.bounds.midY), + to: nil + ) + verticalResizeHandle.mouseDown(with: mouseEvent( + type: .leftMouseDown, + location: verticalStartLocation, + windowNumber: verticalWindow.windowNumber, + timestamp: 0.3 + )) + verticalResizeHandle.mouseDragged(with: mouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: verticalStartLocation.x, y: verticalStartLocation.y - 50), + windowNumber: verticalWindow.windowNumber, + timestamp: 0.4 + )) + + XCTAssertEqual(verticalWindow.frame.minY, verticalInitialFrame.minY) + XCTAssertEqual(verticalWindow.frame.maxY, verticalInitialFrame.maxY - 50) + XCTAssertEqual(verticalWindow.frame.height, verticalInitialFrame.height - 50) + + verticalResizeHandle.mouseUp(with: mouseEvent( + type: .leftMouseUp, + location: verticalStartLocation, + windowNumber: verticalWindow.windowNumber, + timestamp: 0.5 + )) + } + } + func testRulerControllerKeepsMouseTicksHiddenWhileDragging() { withInstalledAppDelegate { appDelegate in let ruler = Ruler(.horizontal, frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) From 7e1dc6e050a5990bd46aa67cd10f1d613eee8a7b Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 01:43:45 -0400 Subject: [PATCH 21/38] Add context menu support for rulers with localization for settings; implement activation logic in relevant classes and tests. --- Free Ruler/GroupedRulerWindow.swift | 15 +++++++++++ Free Ruler/Localizable.xcstrings | 42 +++++++++++++++++++++++++++++ Free Ruler/ResizeHandleView.swift | 4 +++ Free Ruler/RuleView.swift | 33 +++++++++++++++++++++++ Free Ruler/RulerWindow.swift | 6 +++++ Free Ruler/UnitLabelView.swift | 4 +++ FreeRulerTests/RulerCoreTests.swift | 39 +++++++++++++++++++++++++++ 7 files changed, 143 insertions(+) diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 57366ce..2dc1be2 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -476,6 +476,13 @@ final class GroupedRulerWindow: NSPanel { } } +extension GroupedRulerWindow: RulerContextMenuActivating { + func activateForRulerContextMenu() { + makeKey() + (nextResponder as? GroupedRulerController)?.activateForRulerContextMenu() + } +} + private final class GroupedHorizontalRule: HorizontalRule { override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(NSSize(width: newSize.width, height: Ruler.thickness)) @@ -931,6 +938,10 @@ final class GroupedRulerContentView: NSView { nextResponder?.mouseMoved(with: event) } + override func menu(for event: NSEvent) -> NSMenu? { + return rulerContextMenu(for: self) + } + func containsEmptyCorner(_ point: NSPoint) -> Bool { return showsHorizontalRule && showsVerticalRule && cornerFrame().contains(point) } @@ -1498,6 +1509,10 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } } + func activateForRulerContextMenu() { + onBecameActive?(self) + } + override func mouseMoved(with event: NSEvent) { mouseInteraction.mouseMoved(with: event) } diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 17f6583..c1652e9 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -79,6 +79,48 @@ } } }, + "ContextMenu.RulerSettings" : { + "comment" : "Context menu item title to open the active ruler settings panel", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lineal-Einstellungen…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruler Settings…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes de regla…" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viivaimen asetukset…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "定規設定…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尺子设置…" + } + } + } + }, "Ruler Color" : { "comment" : "Label for the active ruler color setting", "extractionState" : "manual", diff --git a/Free Ruler/ResizeHandleView.swift b/Free Ruler/ResizeHandleView.swift index 08429ba..703186b 100644 --- a/Free Ruler/ResizeHandleView.swift +++ b/Free Ruler/ResizeHandleView.swift @@ -92,6 +92,10 @@ final class ResizeHandleView: NSView { restoreRulerCursor(with: event) } + override func menu(for event: NSEvent) -> NSMenu? { + return rulerContextMenu(for: self) + } + override var mouseDownCanMoveWindow: Bool { return false } diff --git a/Free Ruler/RuleView.swift b/Free Ruler/RuleView.swift index 0651ab3..12c2946 100644 --- a/Free Ruler/RuleView.swift +++ b/Free Ruler/RuleView.swift @@ -4,6 +4,35 @@ import Cocoa import SwiftUI #endif +let rulerSettingsContextMenuItemIdentifier = NSUserInterfaceItemIdentifier("ruler-settings-context-menu-item") + +protocol RulerContextMenuActivating: AnyObject { + func activateForRulerContextMenu() +} + +func rulerSettingsContextMenuTitle() -> String { + return NSLocalizedString( + "ContextMenu.RulerSettings", + value: "Ruler Settings…", + comment: "Context menu item title to open the active ruler settings panel" + ) +} + +func rulerContextMenu(for view: NSView) -> NSMenu { + (view.window as? RulerContextMenuActivating)?.activateForRulerContextMenu() + + let menu = NSMenu() + let item = NSMenuItem( + title: rulerSettingsContextMenuTitle(), + action: #selector(AppDelegate.openRulerSettings(_:)), + keyEquivalent: "" + ) + item.identifier = rulerSettingsContextMenuItemIdentifier + item.target = NSApp.delegate + menu.addItem(item) + return menu +} + struct RulerColors { var customFill: NSColor? = nil @@ -418,6 +447,10 @@ class RuleView: NSView { nextResponder?.mouseMoved(with: event) } + override func menu(for event: NSEvent) -> NSMenu? { + return rulerContextMenu(for: self) + } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { return true } diff --git a/Free Ruler/RulerWindow.swift b/Free Ruler/RulerWindow.swift index 2fd0383..8ddfe98 100644 --- a/Free Ruler/RulerWindow.swift +++ b/Free Ruler/RulerWindow.swift @@ -75,6 +75,12 @@ class RulerWindow: NSPanel { } +extension RulerWindow: RulerContextMenuActivating { + func activateForRulerContextMenu() { + makeKey() + } +} + private func getTitle(for orientation: Orientation) -> String { switch orientation { case .horizontal: diff --git a/Free Ruler/UnitLabelView.swift b/Free Ruler/UnitLabelView.swift index 9a8a6e9..250f330 100644 --- a/Free Ruler/UnitLabelView.swift +++ b/Free Ruler/UnitLabelView.swift @@ -50,6 +50,10 @@ final class UnitLabelView: NSView { label.draw(with: labelRect, context: nil) } + override func menu(for event: NSEvent) -> NSMenu? { + return rulerContextMenu(for: self) + } + func frame(in bounds: NSRect) -> NSRect { return frame(in: bounds, zeroCorner: zeroCorner) } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 1464fec..92080e2 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -167,6 +167,45 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(manager.states.map(\.settings.unit), [.inches]) } + func testRulerContextMenuActivatesClickedRulerAndShowsSettingsCommand() { + withInstalledAppDelegate { appDelegate in + let manager = appDelegate.rulerManager + defer { + appDelegate.rulerSettingsController?.close() + for controller in manager.controllers { + controller.hide() + } + } + + let first = manager.createRuler( + defaults: RulerSettings(unit: .pixels), + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + let second = manager.createRuler( + defaults: RulerSettings(unit: .inches), + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + manager.markActive(second) + + let menu = first.groupedWindow.horizontalRule.menu(for: mouseEvent( + type: .rightMouseDown, + location: .zero, + windowNumber: first.groupedWindow.windowNumber, + timestamp: 0 + )) + + XCTAssertTrue(manager.activeController === first) + XCTAssertEqual(menu?.items.count, 1) + + let item = menu?.items.first + XCTAssertEqual(item?.identifier, rulerSettingsContextMenuItemIdentifier) + XCTAssertEqual(item?.title, rulerSettingsContextMenuTitle()) + XCTAssertEqual(item?.action, #selector(AppDelegate.openRulerSettings(_:))) + XCTAssertEqual(item?.keyEquivalent, "") + XCTAssertTrue(item?.target === appDelegate) + } + } + func testRulerManagerRestoresStatesAndShowsAllControllers() { let firstID = UUID(uuidString: "F775A858-ED72-4242-B84B-E08B27EE1C9F")! let secondID = UUID(uuidString: "D922071D-D02B-4DF7-8762-3497D9FD90B4")! From 7103afd155e4cbfaedb66421da18b4397d9ab7dc Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 11:19:07 -0400 Subject: [PATCH 22/38] Refactor resetToDefault method to utilize a defaultSettings instance; adjust opacity handling and update UI elements accordingly. Modify RulerSettingsControlsView.xib for improved layout of text fields. Enhance RulerCoreTests to validate opacity settings and window alpha values. --- Free Ruler/Base.lproj/RulerSettingsControlsView.xib | 4 ++-- Free Ruler/PreferencesController.swift | 4 +++- FreeRulerTests/RulerCoreTests.swift | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib index 7ca5a8d..0f3b372 100644 --- a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib +++ b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib @@ -58,7 +58,7 @@ - + @@ -81,7 +81,7 @@ - + diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index 96c3481..941a7fc 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -857,9 +857,11 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } @IBAction func resetToDefault(_ sender: Any) { + let defaultSettings = RulerSettings(defaults: prefs) applySettings { settings in - settings = RulerSettings(defaults: prefs) + settings = defaultSettings } + rulerController?.opacity = defaultSettings.foregroundOpacity updateView() } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 92080e2..80d72eb 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -513,6 +513,8 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) XCTAssertEqual(settingsController.foregroundOpacityLabel.stringValue, "88%") XCTAssertEqual(settingsController.backgroundOpacityLabel.stringValue, "44%") + XCTAssertEqual(controller.opacity, 88) + XCTAssertEqual(controller.groupedWindow.alphaValue, windowAlphaValue(88), accuracy: 0.0001) XCTAssertEqual(prefs.foregroundOpacity, 88) } } From c952dd27940a5e95d1061eac193c1f6e5a90b31f Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 12:47:27 -0400 Subject: [PATCH 23/38] Add ruler dimensions and unit settings to RulerSettingsControlsView; implement updateDimensions method in GroupedRulerController. Enhance localization for dimensions and unit labels. Update preferences to store default ruler dimensions. Adjust UI layout in Preferences and Ruler Settings windows for better usability. --- .../Base.lproj/PreferencesController.xib | 12 +- .../Base.lproj/RulerSettingsController.xib | 6 +- .../Base.lproj/RulerSettingsControlsView.xib | 4 +- Free Ruler/GroupedRulerWindow.swift | 15 + Free Ruler/Localizable.xcstrings | 84 +++++ Free Ruler/PreferencesController.swift | 319 +++++++++++++++++- Free Ruler/Prefs.swift | 49 ++- Free Ruler/Ruler.swift | 46 ++- FreeRulerTests/RulerCoreTests.swift | 102 +++++- 9 files changed, 605 insertions(+), 32 deletions(-) diff --git a/Free Ruler/Base.lproj/PreferencesController.xib b/Free Ruler/Base.lproj/PreferencesController.xib index 869d872..be53076 100644 --- a/Free Ruler/Base.lproj/PreferencesController.xib +++ b/Free Ruler/Base.lproj/PreferencesController.xib @@ -17,14 +17,14 @@ - + - + - + @@ -32,13 +32,13 @@ - + - + - + diff --git a/Free Ruler/Base.lproj/RulerSettingsController.xib b/Free Ruler/Base.lproj/RulerSettingsController.xib index 4632c4a..4af3cf0 100644 --- a/Free Ruler/Base.lproj/RulerSettingsController.xib +++ b/Free Ruler/Base.lproj/RulerSettingsController.xib @@ -18,14 +18,14 @@ - + - + - + - + diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 2dc1be2..88dcf98 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1304,6 +1304,21 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi notifyStateChanged() } + func updateDimensions(horizontalLength: CGFloat, verticalLength: CGFloat) { + let minHorizontalLength = getMinSize(ruler: Ruler(.horizontal)).width + let maxHorizontalLength = getMaxSize(ruler: Ruler(.horizontal)).width + let minVerticalLength = getMinSize(ruler: Ruler(.vertical)).height + let maxVerticalLength = getMaxSize(ruler: Ruler(.vertical)).height + + state.layout = RulerLayoutState( + zeroPoint: groupedWindow.zeroPoint(), + horizontalLength: min(max(horizontalLength, minHorizontalLength), maxHorizontalLength), + verticalLength: min(max(verticalLength, minVerticalLength), maxVerticalLength) + ) + applyStateToWindow(display: true) + notifyStateChanged() + } + func resetPosition() { state.settings.zeroCorner = Prefs.defaultZeroCorner state.layout = RulerLayoutState.defaults( diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index c1652e9..1cf1b8e 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -1013,6 +1013,90 @@ } } }, + "RulerSettingsControls.Dimensions" : { + "comment" : "Label for active ruler width and height fields", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abmessungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dimensions" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dimensiones" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "寸法" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尺寸" + } + } + } + }, + "RulerSettingsControls.Unit" : { + "comment" : "Label for the active ruler measurement unit setting", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einheit" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unidad" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yksikkö" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "単位" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "单位" + } + } + } + }, "Unit.Inches.Abbreviation" : { "comment" : "Inches unit abbreviation", "extractionState" : "manual", diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index 941a7fc..e08d821 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -153,6 +153,8 @@ private func configureResetRulerColorButtonAppearance(_ button: NSButton, identi } protocol RulerSettingsControlsViewDelegate: AnyObject { + func rulerSettingsControlsDidChangeUnit(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeDimensions(_ controlsView: RulerSettingsControlsView) func rulerSettingsControlsDidChangeRulerColor(_ controlsView: RulerSettingsControlsView) func rulerSettingsControlsDidResetRulerColor(_ controlsView: RulerSettingsControlsView) func rulerSettingsControlsDidChangeForegroundOpacity(_ controlsView: RulerSettingsControlsView) @@ -161,7 +163,7 @@ protocol RulerSettingsControlsViewDelegate: AnyObject { func rulerSettingsControlsDidChangeRulerShadow(_ controlsView: RulerSettingsControlsView) } -final class RulerSettingsControlsView: NSView { +final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { weak var delegate: RulerSettingsControlsViewDelegate? @@ -175,6 +177,64 @@ final class RulerSettingsControlsView: NSView { @IBOutlet weak var floatRulersCheckbox: NSButton! @IBOutlet weak var rulerShadowCheckbox: NSButton! + let unitLabel = NSTextField(labelWithString: NSLocalizedString( + "RulerSettingsControls.Unit", + value: "Unit", + comment: "Label for the active ruler measurement unit setting" + )) + let unitSegmentedControl = NSSegmentedControl( + labels: [ + NSLocalizedString( + "Unit.Pixels.Abbreviation", + value: "px", + comment: "Pixels unit abbreviation" + ), + NSLocalizedString( + "Unit.Millimeters.Abbreviation", + value: "mm", + comment: "Millimeters unit abbreviation" + ), + NSLocalizedString( + "Unit.Inches.Abbreviation", + value: "in", + comment: "Inches unit abbreviation" + ), + ], + trackingMode: .selectOne, + target: nil, + action: nil + ) + let dimensionsLabel = NSTextField(labelWithString: NSLocalizedString( + "RulerSettingsControls.Dimensions", + value: "Dimensions", + comment: "Label for active ruler width and height fields" + )) + let dimensionWidthField = NSTextField() + let dimensionsSeparatorLabel = NSTextField(labelWithString: "x") + let dimensionHeightField = NSTextField() + + private var showsDimensions = true + private var dimensionControls: [NSView] { + return [ + dimensionsLabel, + dimensionWidthField, + dimensionsSeparatorLabel, + dimensionHeightField, + ] + } + + var selectedUnit: Unit { + return Unit(rawValue: unitSegmentedControl.selectedSegment) ?? .pixels + } + + var selectedHorizontalLength: CGFloat { + return CGFloat(dimensionWidthField.doubleValue) + } + + var selectedVerticalLength: CGFloat { + return CGFloat(dimensionHeightField.doubleValue) + } + override init(frame frameRect: NSRect) { super.init(frame: frameRect) loadContentView() @@ -194,7 +254,12 @@ final class RulerSettingsControlsView: NSView { } func configureForPreferences() { + showsDimensions = true + updateDimensionsVisibility() configureControls( + unitSegmentedControlIdentifier: "ruler-unit-segmented-control", + widthFieldIdentifier: "ruler-width-field", + heightFieldIdentifier: "ruler-height-field", colorWellIdentifier: "ruler-color-well", resetButtonIdentifier: "reset-ruler-color-button", foregroundSliderIdentifier: "ruler-foreground-opacity-slider", @@ -209,7 +274,12 @@ final class RulerSettingsControlsView: NSView { } func configureForRulerSettings() { + showsDimensions = true + updateDimensionsVisibility() configureControls( + unitSegmentedControlIdentifier: "ruler-settings-unit-segmented-control", + widthFieldIdentifier: "ruler-settings-width-field", + heightFieldIdentifier: "ruler-settings-height-field", colorWellIdentifier: "ruler-settings-color-well", resetButtonIdentifier: "reset-ruler-settings-color-button", foregroundSliderIdentifier: "ruler-settings-foreground-opacity-slider", @@ -223,6 +293,9 @@ final class RulerSettingsControlsView: NSView { } func update( + unit: Unit, + horizontalLength: CGFloat? = nil, + verticalLength: CGFloat? = nil, rulerColor: NSColor, foregroundOpacity: Int, backgroundOpacity: Int, @@ -230,6 +303,18 @@ final class RulerSettingsControlsView: NSView { rulerShadow: Bool, isEnabled: Bool = true ) { + unitSegmentedControl.selectedSegment = unit.rawValue + unitSegmentedControl.isEnabled = isEnabled + + if let horizontalLength = horizontalLength { + dimensionWidthField.integerValue = Int(horizontalLength.rounded()) + } + if let verticalLength = verticalLength { + dimensionHeightField.integerValue = Int(verticalLength.rounded()) + } + dimensionWidthField.isEnabled = isEnabled + dimensionHeightField.isEnabled = isEnabled + rulerColorWell.supportsAlpha = false rulerColorWell.color = rulerColor rulerColorWell.isEnabled = isEnabled @@ -254,6 +339,12 @@ final class RulerSettingsControlsView: NSView { configureKeyViewLoop() } + override func layout() { + super.layout() + + layoutTopControls() + } + func performRulerSettingsKeyEquivalent(with event: NSEvent) -> Bool { guard event.type == .keyDown, event.modifierFlags @@ -298,10 +389,27 @@ final class RulerSettingsControlsView: NSView { contentView.topAnchor.constraint(equalTo: topAnchor), contentView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + installTopControls() configureBaseControls() } + private func installTopControls() { + for control in [unitLabel, unitSegmentedControl] + dimensionControls { + control.translatesAutoresizingMaskIntoConstraints = true + contentView.addSubview(control) + } + updateDimensionsVisibility() + } + private func configureBaseControls() { + unitSegmentedControl.target = self + unitSegmentedControl.action = #selector(setUnit(_:)) + unitSegmentedControl.segmentStyle = .rounded + + dimensionsSeparatorLabel.alignment = .center + configureDimensionField(dimensionWidthField) + configureDimensionField(dimensionHeightField) + rulerColorWell.isContinuous = true rulerColorWell.supportsAlpha = false rulerColorWell.colorDidChange = { [weak self] _ in @@ -342,6 +450,9 @@ final class RulerSettingsControlsView: NSView { } private func configureControls( + unitSegmentedControlIdentifier: String, + widthFieldIdentifier: String, + heightFieldIdentifier: String, colorWellIdentifier: String, resetButtonIdentifier: String, foregroundSliderIdentifier: String, @@ -351,6 +462,13 @@ final class RulerSettingsControlsView: NSView { floatCheckboxIdentifier: String, shadowCheckboxIdentifier: String ) { + unitSegmentedControl.identifier = NSUserInterfaceItemIdentifier(unitSegmentedControlIdentifier) + unitSegmentedControl.setAccessibilityIdentifier(unitSegmentedControlIdentifier) + dimensionWidthField.identifier = NSUserInterfaceItemIdentifier(widthFieldIdentifier) + dimensionWidthField.setAccessibilityIdentifier(widthFieldIdentifier) + dimensionHeightField.identifier = NSUserInterfaceItemIdentifier(heightFieldIdentifier) + dimensionHeightField.setAccessibilityIdentifier(heightFieldIdentifier) + rulerColorWell.identifier = NSUserInterfaceItemIdentifier(colorWellIdentifier) rulerColorWell.setAccessibilityIdentifier(colorWellIdentifier) configureResetRulerColorButtonAppearance(resetRulerColorButton, identifier: resetButtonIdentifier) @@ -379,6 +497,9 @@ final class RulerSettingsControlsView: NSView { } private func configureKeyViewLoop() { + unitSegmentedControl.nextKeyView = showsDimensions ? dimensionWidthField : rulerColorWell + dimensionWidthField.nextKeyView = dimensionHeightField + dimensionHeightField.nextKeyView = rulerColorWell rulerColorWell.nextKeyView = resetRulerColorButton.isHidden ? foregroundOpacitySlider : resetRulerColorButton @@ -386,7 +507,78 @@ final class RulerSettingsControlsView: NSView { foregroundOpacitySlider.nextKeyView = backgroundOpacitySlider backgroundOpacitySlider.nextKeyView = floatRulersCheckbox floatRulersCheckbox.nextKeyView = rulerShadowCheckbox - rulerShadowCheckbox.nextKeyView = rulerColorWell + rulerShadowCheckbox.nextKeyView = unitSegmentedControl + } + + private func configureDimensionField(_ field: NSTextField) { + let formatter = NumberFormatter() + formatter.allowsFloats = false + formatter.minimum = 0 + formatter.maximum = 4000 + formatter.numberStyle = .none + + field.formatter = formatter + field.alignment = .right + field.bezelStyle = .roundedBezel + field.delegate = self + field.target = self + field.action = #selector(setDimensions(_:)) + } + + private func updateDimensionsVisibility() { + for control in dimensionControls { + control.isHidden = !showsDimensions + } + needsLayout = true + configureKeyViewLoop() + } + + private func layoutTopControls() { + guard contentView != nil else { return } + + let labelX: CGFloat = 15 + let labelWidth: CGFloat = 150 + let labelHeight: CGFloat = 16 + let rightMargin: CGFloat = 21 + let contentWidth = contentView.bounds.width + let controlRight = contentWidth - rightMargin + + let unitControlWidth: CGFloat = 108 + let unitY: CGFloat = showsDimensions ? 285 : 249 + unitLabel.frame = NSRect(x: labelX, y: unitY, width: labelWidth, height: labelHeight) + unitSegmentedControl.frame = NSRect( + x: controlRight - unitControlWidth, + y: unitY - 4, + width: unitControlWidth, + height: 24 + ) + + let dimensionsY: CGFloat = 249 + let fieldWidth: CGFloat = 56 + let separatorWidth: CGFloat = 18 + let heightFieldX = controlRight - fieldWidth + let separatorX = heightFieldX - separatorWidth + let widthFieldX = separatorX - fieldWidth + + dimensionsLabel.frame = NSRect(x: labelX, y: dimensionsY, width: labelWidth, height: labelHeight) + dimensionWidthField.frame = NSRect( + x: widthFieldX, + y: dimensionsY - 4, + width: fieldWidth, + height: 24 + ) + dimensionsSeparatorLabel.frame = NSRect( + x: separatorX, + y: dimensionsY, + width: separatorWidth, + height: labelHeight + ) + dimensionHeightField.frame = NSRect( + x: heightFieldX, + y: dimensionsY - 4, + width: fieldWidth, + height: 24 + ) } private func toggleFloatRulersFromKeyEquivalent() -> Bool { @@ -405,6 +597,21 @@ final class RulerSettingsControlsView: NSView { return true } + @objc private func setUnit(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeUnit(self) + } + + @objc private func setDimensions(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeDimensions(self) + } + + func controlTextDidEndEditing(_ notification: Notification) { + guard let field = notification.object as? NSTextField, + field === dimensionWidthField || field === dimensionHeightField else { return } + + setDimensions(field as Any) + } + @objc private func setRulerColor(_ sender: Any) { delegate?.rulerSettingsControlsDidChangeRulerColor(self) } @@ -442,6 +649,18 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP return settingsControlsView.foregroundOpacitySlider } + var unitSegmentedControl: NSSegmentedControl { + return settingsControlsView.unitSegmentedControl + } + + var dimensionWidthField: NSTextField { + return settingsControlsView.dimensionWidthField + } + + var dimensionHeightField: NSTextField { + return settingsControlsView.dimensionHeightField + } + var backgroundOpacitySlider: NSSlider { return settingsControlsView.backgroundOpacitySlider } @@ -483,7 +702,7 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP configureOpaqueColorPicking() settingsControlsView.delegate = self settingsControlsView.configureForPreferences() - window?.initialFirstResponder = rulerColorWell + window?.initialFirstResponder = unitSegmentedControl resetFactoryDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-factory-defaults-button") resetFactoryDefaultsButton.setAccessibilityIdentifier("reset-factory-defaults-button") @@ -505,7 +724,7 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP configureOpaqueColorPicking() window?.makeKeyAndOrderFront(sender) - window?.makeFirstResponder(rulerColorWell) + window?.makeFirstResponder(unitSegmentedControl) window?.center() } @@ -519,6 +738,15 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP func subscribeToPrefs() { observers = [ + prefs.observe(\Prefs.unit, options: .new) { prefs, changed in + self.updateUnitSegmentedControl() + }, + prefs.observe(\Prefs.defaultHorizontalLength, options: .new) { prefs, changed in + self.updateDimensionFields() + }, + prefs.observe(\Prefs.defaultVerticalLength, options: .new) { prefs, changed in + self.updateDimensionFields() + }, prefs.observe(\Prefs.foregroundOpacity, options: .new) { prefs, changed in self.updateForegroundSlider() }, @@ -537,6 +765,15 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP ] } + @IBAction func setUnit(_ sender: Any) { + prefs.unit = settingsControlsView.selectedUnit + } + + @IBAction func setDimensions(_ sender: Any) { + prefs.defaultHorizontalLength = Double(settingsControlsView.selectedHorizontalLength) + prefs.defaultVerticalLength = Double(settingsControlsView.selectedVerticalLength) + } + @IBAction func setForegroundOpacity(_ sender: Any) { prefs.foregroundOpacity = foregroundOpacitySlider.integerValue } @@ -562,6 +799,9 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP func updateView() { settingsControlsView.update( + unit: prefs.unit, + horizontalLength: prefs.effectiveDefaultHorizontalLength(), + verticalLength: prefs.effectiveDefaultVerticalLength(), rulerColor: prefs.rulerColor, foregroundOpacity: prefs.foregroundOpacity, backgroundOpacity: prefs.backgroundOpacity, @@ -570,6 +810,15 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP ) } + func updateUnitSegmentedControl() { + unitSegmentedControl.selectedSegment = prefs.unit.rawValue + } + + func updateDimensionFields() { + dimensionWidthField.integerValue = Int(prefs.effectiveDefaultHorizontalLength().rounded()) + dimensionHeightField.integerValue = Int(prefs.effectiveDefaultVerticalLength().rounded()) + } + func updateForegroundSlider() { foregroundOpacitySlider.integerValue = prefs.foregroundOpacity foregroundOpacityLabel.stringValue = "\(prefs.foregroundOpacity)%" @@ -616,6 +865,14 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP } extension PreferencesController: RulerSettingsControlsViewDelegate { + func rulerSettingsControlsDidChangeUnit(_ controlsView: RulerSettingsControlsView) { + setUnit(controlsView.unitSegmentedControl as Any) + } + + func rulerSettingsControlsDidChangeDimensions(_ controlsView: RulerSettingsControlsView) { + setDimensions(controlsView.dimensionWidthField as Any) + } + func rulerSettingsControlsDidChangeRulerColor(_ controlsView: RulerSettingsControlsView) { setRulerColor(controlsView.rulerColorWell as Any) } @@ -671,6 +928,18 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { return settingsControlsView.rulerColorWell } + var unitSegmentedControl: NSSegmentedControl { + return settingsControlsView.unitSegmentedControl + } + + var dimensionWidthField: NSTextField { + return settingsControlsView.dimensionWidthField + } + + var dimensionHeightField: NSTextField { + return settingsControlsView.dimensionHeightField + } + var resetRulerColorButton: NSButton { return settingsControlsView.resetRulerColorButton } @@ -735,7 +1004,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { window?.setAccessibilityIdentifier("ruler-settings-window") window?.isMovableByWindowBackground = true window?.isReleasedWhenClosed = false - window?.initialFirstResponder = rulerColorWell + window?.initialFirstResponder = unitSegmentedControl configureFloatingPanelWindow() settingsControlsView.delegate = self settingsControlsView.configureForRulerSettings() @@ -763,7 +1032,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { configureOpaqueColorPicking() updateView() window?.makeKeyAndOrderFront(sender) - window?.makeFirstResponder(rulerColorWell) + window?.makeFirstResponder(unitSegmentedControl) window?.center() } @@ -777,7 +1046,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { position(settingsWindow, attachedTo: controller) settingsWindow.orderFront(sender) settingsWindow.makeKey() - settingsWindow.makeFirstResponder(rulerColorWell) + settingsWindow.makeFirstResponder(unitSegmentedControl) return } @@ -796,7 +1065,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { controller.groupedWindow.addChildWindow(settingsWindow, ordered: .above) settingsWindow.orderFront(sender) settingsWindow.makeKey() - settingsWindow.makeFirstResponder(rulerColorWell) + settingsWindow.makeFirstResponder(unitSegmentedControl) } override func close() { @@ -818,6 +1087,21 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { updateView() } + @objc func setUnit(_ sender: Any) { + applySettings { settings in + settings.unit = settingsControlsView.selectedUnit + } + updateView() + } + + @objc func setDimensions(_ sender: Any) { + rulerController?.updateDimensions( + horizontalLength: settingsControlsView.selectedHorizontalLength, + verticalLength: settingsControlsView.selectedVerticalLength + ) + updateView() + } + @objc func setForegroundOpacity(_ sender: Any) { applySettings { settings in settings.foregroundOpacity = foregroundOpacitySlider.integerValue @@ -861,14 +1145,18 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { applySettings { settings in settings = defaultSettings } + rulerController?.updateDimensions( + horizontalLength: prefs.effectiveDefaultHorizontalLength(), + verticalLength: prefs.effectiveDefaultVerticalLength() + ) rulerController?.opacity = defaultSettings.foregroundOpacity updateView() } @IBAction func setDefaultsForNewRulers(_ sender: Any) { - guard let settings = rulerController?.state.settings else { return } + guard let controller = rulerController else { return } - prefs.applyDefaults(from: settings) + prefs.applyDefaults(from: controller.state.settings, layout: controller.state.layout) } func updateView() { @@ -880,6 +1168,9 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { let hasRuler = rulerController != nil settingsControlsView.update( + unit: currentSettings?.unit ?? Prefs.defaultUnit, + horizontalLength: rulerController?.state.layout.horizontalLength, + verticalLength: rulerController?.state.layout.verticalLength, rulerColor: currentSettings?.rulerColor ?? Prefs.defaultRulerFillColor, foregroundOpacity: currentSettings?.foregroundOpacity ?? Prefs.defaultForegroundOpacity, backgroundOpacity: currentSettings?.backgroundOpacity ?? Prefs.defaultBackgroundOpacity, @@ -1088,6 +1379,14 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } extension RulerSettingsController: RulerSettingsControlsViewDelegate { + func rulerSettingsControlsDidChangeUnit(_ controlsView: RulerSettingsControlsView) { + setUnit(controlsView.unitSegmentedControl as Any) + } + + func rulerSettingsControlsDidChangeDimensions(_ controlsView: RulerSettingsControlsView) { + setDimensions(controlsView.dimensionWidthField as Any) + } + func rulerSettingsControlsDidChangeRulerColor(_ controlsView: RulerSettingsControlsView) { setRulerColor(controlsView.rulerColorWell as Any) } diff --git a/Free Ruler/Prefs.swift b/Free Ruler/Prefs.swift index 4918514..8551c43 100644 --- a/Free Ruler/Prefs.swift +++ b/Free Ruler/Prefs.swift @@ -32,6 +32,8 @@ class Prefs: NSObject { @objc dynamic var backgroundOpacity : Int @objc dynamic var rulerColor : NSColor @objc dynamic var unit : Unit + @objc dynamic var defaultHorizontalLength: Double + @objc dynamic var defaultVerticalLength: Double @objc dynamic var zeroCorner : ZeroCorner // MARK: - public save method @@ -60,6 +62,8 @@ class Prefs: NSObject { "foregroundOpacity": defaultForegroundOpacity, "backgroundOpacity": defaultBackgroundOpacity, "unit": defaultUnit.rawValue, + "defaultHorizontalLength": unsetDefaultRulerLength, + "defaultVerticalLength": unsetDefaultRulerLength, "zeroCorner": defaultZeroCorner.rawValue ] @@ -80,6 +84,8 @@ class Prefs: NSObject { backgroundOpacity = defaults.integer(forKey: "backgroundOpacity") rulerColor = Prefs.rulerFillColor(fromArchivedData: defaults.data(forKey: "rulerColor")) unit = Unit(rawValue: defaults.integer(forKey: "unit")) ?? .pixels + defaultHorizontalLength = defaults.double(forKey: "defaultHorizontalLength") + defaultVerticalLength = defaults.double(forKey: "defaultVerticalLength") zeroCorner = Prefs.zeroCorner(fromRawValue: defaults.integer(forKey: "zeroCorner")) super.init() @@ -120,6 +126,12 @@ class Prefs: NSObject { observe(\Prefs.unit, options: .new) { prefs, changed in self.defaults.set(prefs.unit.rawValue, forKey: "unit") }, + observe(\Prefs.defaultHorizontalLength, options: .new) { prefs, changed in + self.defaults.set(prefs.defaultHorizontalLength, forKey: "defaultHorizontalLength") + }, + observe(\Prefs.defaultVerticalLength, options: .new) { prefs, changed in + self.defaults.set(prefs.defaultVerticalLength, forKey: "defaultVerticalLength") + }, observe(\Prefs.zeroCorner, options: .new) { prefs, changed in self.defaults.set(prefs.zeroCorner.rawValue, forKey: "zeroCorner") }, @@ -135,6 +147,10 @@ extension Prefs { return .pixels } + static var unsetDefaultRulerLength: Double { + return 0 + } + static var defaultZeroCorner: ZeroCorner { return .topLeft } @@ -163,7 +179,7 @@ extension Prefs { return true } - func applyDefaults(from settings: RulerSettings) { + func applyDefaults(from settings: RulerSettings, layout: RulerLayoutState? = nil) { unit = settings.unit rulerColor = settings.rulerColor foregroundOpacity = settings.foregroundOpacity @@ -171,6 +187,11 @@ extension Prefs { floatRulers = settings.floatRulers rulerShadow = settings.rulerShadow zeroCorner = settings.zeroCorner + + if let layout = layout { + defaultHorizontalLength = Double(layout.horizontalLength) + defaultVerticalLength = Double(layout.verticalLength) + } } func resetRulerDefaultsToFactoryDefaults() { @@ -181,9 +202,35 @@ extension Prefs { floatRulers = Self.defaultFloatRulers rulerShadow = Self.defaultRulerShadow groupRulers = Self.defaultGroupRulers + defaultHorizontalLength = Self.unsetDefaultRulerLength + defaultVerticalLength = Self.unsetDefaultRulerLength zeroCorner = Self.defaultZeroCorner } + func effectiveDefaultHorizontalLength(screenFrame: NSRect = defaultRulerScreenFrame()) -> CGFloat { + guard defaultHorizontalLength > Self.unsetDefaultRulerLength else { + return RulerLayoutState.defaultLengths(screenFrame: screenFrame).horizontal + } + + return CGFloat(defaultHorizontalLength) + } + + func effectiveDefaultVerticalLength(screenFrame: NSRect = defaultRulerScreenFrame()) -> CGFloat { + guard defaultVerticalLength > Self.unsetDefaultRulerLength else { + return RulerLayoutState.defaultLengths(screenFrame: screenFrame).vertical + } + + return CGFloat(defaultVerticalLength) + } + + var customDefaultHorizontalLength: CGFloat? { + return defaultHorizontalLength > Self.unsetDefaultRulerLength ? CGFloat(defaultHorizontalLength) : nil + } + + var customDefaultVerticalLength: CGFloat? { + return defaultVerticalLength > Self.unsetDefaultRulerLength ? CGFloat(defaultVerticalLength) : nil + } + static func rulerFillColor(fromArchivedData data: Data?) -> NSColor { return normalizedRulerColor(unarchiveColor(data) ?? defaultRulerColor) } diff --git a/Free Ruler/Ruler.swift b/Free Ruler/Ruler.swift index 28052b5..9a8ea47 100644 --- a/Free Ruler/Ruler.swift +++ b/Free Ruler/Ruler.swift @@ -304,17 +304,40 @@ struct RulerLayoutState: Equatable, Codable { static func defaults( zeroCorner: ZeroCorner, - screenFrame: NSRect = defaultRulerScreenFrame() + screenFrame: NSRect = defaultRulerScreenFrame(), + horizontalLength: CGFloat? = nil, + verticalLength: CGFloat? = nil ) -> RulerLayoutState { let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) return RulerLayoutState( - horizontalFrame: geometry.defaultFrame(for: .horizontal, screenFrame: screenFrame), - verticalFrame: geometry.defaultFrame(for: .vertical, screenFrame: screenFrame), + horizontalFrame: geometry.defaultFrame( + for: .horizontal, + screenFrame: screenFrame, + horizontalLength: horizontalLength, + verticalLength: verticalLength + ), + verticalFrame: geometry.defaultFrame( + for: .vertical, + screenFrame: screenFrame, + horizontalLength: horizontalLength, + verticalLength: verticalLength + ), zeroCorner: zeroCorner ) } + static func defaultLengths(screenFrame: NSRect = defaultRulerScreenFrame()) -> ( + horizontal: CGFloat, + vertical: CGFloat + ) { + let horizontalLength = screenFrame.width / 2 + let aspectRatio = screenFrame.width / screenFrame.height + let verticalLength = horizontalLength / aspectRatio + + return (horizontalLength, verticalLength) + } + func layout(zeroCorner: ZeroCorner) -> GroupedRulerLayout { return GroupedRulerLayout.layout( horizontalLength: horizontalLength, @@ -353,7 +376,9 @@ struct RulerInstanceState: Identifiable, Equatable, Codable { settings: defaults, layout: RulerLayoutState.defaults( zeroCorner: defaults.zeroCorner, - screenFrame: screenFrame + screenFrame: screenFrame, + horizontalLength: prefs.customDefaultHorizontalLength, + verticalLength: prefs.customDefaultVerticalLength ) ) } @@ -506,12 +531,17 @@ struct ZeroCornerGeometry { } } - func defaultFrame(for orientation: Orientation, screenFrame: NSRect) -> NSRect { + func defaultFrame( + for orientation: Orientation, + screenFrame: NSRect, + horizontalLength customHorizontalLength: CGFloat? = nil, + verticalLength customVerticalLength: CGFloat? = nil + ) -> NSRect { let xOffset: CGFloat = 30 let yOffset: CGFloat = 50 - let horizontalLength = screenFrame.width / 2 - let aspectRatio = screenFrame.width / screenFrame.height - let verticalLength = horizontalLength / aspectRatio + let defaultLengths = RulerLayoutState.defaultLengths(screenFrame: screenFrame) + let horizontalLength = customHorizontalLength ?? defaultLengths.horizontal + let verticalLength = customVerticalLength ?? defaultLengths.vertical let topLeftZeroPoint = NSPoint( x: screenFrame.minX + xOffset + Ruler.thickness - borderCompensation, y: screenFrame.maxY - yOffset - Ruler.thickness + borderCompensation diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 80d72eb..4bac00f 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -29,6 +29,8 @@ final class RulerCoreTests: XCTestCase { prefs.floatRulers = false prefs.rulerShadow = true prefs.zeroCorner = .bottomRight + prefs.defaultHorizontalLength = Prefs.unsetDefaultRulerLength + prefs.defaultVerticalLength = Prefs.unsetDefaultRulerLength let id = UUID(uuidString: "B74A48A7-235A-43DB-8C01-A7D8F44B1976")! let screenFrame = NSRect(x: 0, y: 0, width: 1000, height: 800) @@ -335,15 +337,19 @@ final class RulerCoreTests: XCTestCase { func testRulerSettingsControllerUpdatesRulerSettingsWithoutChangingDefaults() { withRestoredRulerPreferences { let defaultColor = NSColor(deviceRed: 0.15, green: 0.25, blue: 0.35, alpha: 1) + prefs.unit = .pixels prefs.rulerColor = defaultColor prefs.foregroundOpacity = 90 prefs.backgroundOpacity = 50 prefs.floatRulers = true prefs.rulerShadow = false + prefs.defaultHorizontalLength = 640 + prefs.defaultVerticalLength = 280 let controller = GroupedRulerController( state: RulerInstanceState( settings: RulerSettings( + unit: .inches, rulerColor: NSColor(deviceRed: 0.4, green: 0.5, blue: 0.6, alpha: 1), foregroundOpacity: 80, backgroundOpacity: 45, @@ -363,6 +369,31 @@ final class RulerCoreTests: XCTestCase { controller.hide() } + settingsController.unitSegmentedControl.selectedSegment = Unit.millimeters.rawValue + settingsController.setUnit(settingsController.unitSegmentedControl) + + XCTAssertEqual(controller.state.settings.unit, .millimeters) + XCTAssertEqual(controller.groupedWindow.horizontalRule.unit, .millimeters) + XCTAssertEqual(controller.groupedWindow.verticalRule.unit, .millimeters) + XCTAssertEqual(settingsController.unitSegmentedControl.selectedSegment, Unit.millimeters.rawValue) + XCTAssertEqual(prefs.unit, .pixels) + + let zeroPointBeforeDimensionChange = controller.groupedWindow.zeroPoint() + settingsController.dimensionWidthField.integerValue = 320 + settingsController.dimensionHeightField.integerValue = 240 + settingsController.setDimensions(settingsController.dimensionWidthField) + + XCTAssertEqual(controller.state.layout.horizontalLength, 320) + XCTAssertEqual(controller.state.layout.verticalLength, 240) + XCTAssertEqual(controller.groupedWindow.screenFrame(for: .horizontal).width, 320) + XCTAssertEqual(controller.groupedWindow.screenFrame(for: .vertical).height, 240) + XCTAssertEqual(controller.groupedWindow.zeroPoint().x, zeroPointBeforeDimensionChange.x, accuracy: 0.0001) + XCTAssertEqual(controller.groupedWindow.zeroPoint().y, zeroPointBeforeDimensionChange.y, accuracy: 0.0001) + XCTAssertEqual(settingsController.dimensionWidthField.integerValue, 320) + XCTAssertEqual(settingsController.dimensionHeightField.integerValue, 240) + XCTAssertEqual(prefs.defaultHorizontalLength, 640) + XCTAssertEqual(prefs.defaultVerticalLength, 280) + settingsController.rulerColorWell.color = NSColor( deviceRed: 0.8, green: 0.2, @@ -429,6 +460,8 @@ final class RulerCoreTests: XCTestCase { prefs.floatRulers = true prefs.rulerShadow = false prefs.zeroCorner = .topLeft + prefs.defaultHorizontalLength = 500 + prefs.defaultVerticalLength = 400 let rulerColor = NSColor(deviceRed: 0.72, green: 0.24, blue: 0.44, alpha: 1) let controller = GroupedRulerController( @@ -464,6 +497,8 @@ final class RulerCoreTests: XCTestCase { XCTAssertFalse(prefs.floatRulers) XCTAssertTrue(prefs.rulerShadow) XCTAssertEqual(prefs.zeroCorner, .bottomRight) + XCTAssertEqual(prefs.defaultHorizontalLength, 260) + XCTAssertEqual(prefs.defaultVerticalLength, 180) } } @@ -477,6 +512,8 @@ final class RulerCoreTests: XCTestCase { prefs.floatRulers = true prefs.rulerShadow = false prefs.zeroCorner = .topRight + prefs.defaultHorizontalLength = 320 + prefs.defaultVerticalLength = 220 let controller = GroupedRulerController( state: RulerInstanceState( @@ -511,6 +548,10 @@ final class RulerCoreTests: XCTestCase { XCTAssertTrue(controller.state.settings.floatRulers) XCTAssertFalse(controller.state.settings.rulerShadow) XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) + XCTAssertEqual(controller.state.layout.horizontalLength, 320) + XCTAssertEqual(controller.state.layout.verticalLength, 220) + XCTAssertEqual(settingsController.dimensionWidthField.integerValue, 320) + XCTAssertEqual(settingsController.dimensionHeightField.integerValue, 220) XCTAssertEqual(settingsController.foregroundOpacityLabel.stringValue, "88%") XCTAssertEqual(settingsController.backgroundOpacityLabel.stringValue, "44%") XCTAssertEqual(controller.opacity, 88) @@ -592,6 +633,8 @@ final class RulerCoreTests: XCTestCase { prefs.groupRulers = false prefs.rulerShadow = true prefs.zeroCorner = .bottomRight + prefs.defaultHorizontalLength = 333 + prefs.defaultVerticalLength = 222 let preferencesController = PreferencesController() preferencesController.loadWindow() @@ -609,18 +652,55 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(prefs.groupRulers, Prefs.defaultGroupRulers) XCTAssertEqual(prefs.rulerShadow, Prefs.defaultRulerShadow) XCTAssertEqual(prefs.zeroCorner, Prefs.defaultZeroCorner) + XCTAssertEqual(prefs.defaultHorizontalLength, Prefs.unsetDefaultRulerLength) + XCTAssertEqual(prefs.defaultVerticalLength, Prefs.unsetDefaultRulerLength) XCTAssertEqual(preferencesController.foregroundOpacityLabel.stringValue, "\(Prefs.defaultForegroundOpacity)%") XCTAssertEqual(preferencesController.backgroundOpacityLabel.stringValue, "\(Prefs.defaultBackgroundOpacity)%") + XCTAssertEqual( + preferencesController.dimensionWidthField.integerValue, + Int(RulerLayoutState.defaultLengths().horizontal.rounded()) + ) + XCTAssertEqual( + preferencesController.dimensionHeightField.integerValue, + Int(RulerLayoutState.defaultLengths().vertical.rounded()) + ) XCTAssertEqual(preferencesController.floatRulersCheckbox.state, .on) XCTAssertEqual(preferencesController.rulerShadowCheckbox.state, .off) } } + func testPreferencesControllerUpdatesDefaultUnitAndDimensions() { + withRestoredRulerPreferences { + prefs.unit = .pixels + prefs.defaultHorizontalLength = 500 + prefs.defaultVerticalLength = 400 + + let preferencesController = PreferencesController() + preferencesController.loadWindow() + defer { + preferencesController.close() + } + + preferencesController.unitSegmentedControl.selectedSegment = Unit.inches.rawValue + preferencesController.setUnit(preferencesController.unitSegmentedControl) + preferencesController.dimensionWidthField.integerValue = 360 + preferencesController.dimensionHeightField.integerValue = 240 + preferencesController.setDimensions(preferencesController.dimensionWidthField) + + XCTAssertEqual(prefs.unit, .inches) + XCTAssertEqual(prefs.defaultHorizontalLength, 360) + XCTAssertEqual(prefs.defaultVerticalLength, 240) + } + } + func testRulerSettingsControlsKeyViewLoopFollowsVisibleControls() { - let controlsView = RulerSettingsControlsView(frame: NSRect(x: 0, y: 0, width: 315, height: 224)) + let controlsView = RulerSettingsControlsView(frame: NSRect(x: 0, y: 0, width: 315, height: 320)) controlsView.configureForRulerSettings() controlsView.update( + unit: .pixels, + horizontalLength: 260, + verticalLength: 180, rulerColor: Prefs.defaultRulerFillColor, foregroundOpacity: 90, backgroundOpacity: 50, @@ -628,13 +708,19 @@ final class RulerCoreTests: XCTestCase { rulerShadow: false ) + XCTAssertTrue(controlsView.unitSegmentedControl.nextKeyView === controlsView.dimensionWidthField) + XCTAssertTrue(controlsView.dimensionWidthField.nextKeyView === controlsView.dimensionHeightField) + XCTAssertTrue(controlsView.dimensionHeightField.nextKeyView === controlsView.rulerColorWell) XCTAssertTrue(controlsView.rulerColorWell.nextKeyView === controlsView.foregroundOpacitySlider) XCTAssertTrue(controlsView.foregroundOpacitySlider.nextKeyView === controlsView.backgroundOpacitySlider) XCTAssertTrue(controlsView.backgroundOpacitySlider.nextKeyView === controlsView.floatRulersCheckbox) XCTAssertTrue(controlsView.floatRulersCheckbox.nextKeyView === controlsView.rulerShadowCheckbox) - XCTAssertTrue(controlsView.rulerShadowCheckbox.nextKeyView === controlsView.rulerColorWell) + XCTAssertTrue(controlsView.rulerShadowCheckbox.nextKeyView === controlsView.unitSegmentedControl) controlsView.update( + unit: .pixels, + horizontalLength: 260, + verticalLength: 180, rulerColor: NSColor(deviceRed: 0.6, green: 0.3, blue: 0.2, alpha: 1), foregroundOpacity: 90, backgroundOpacity: 50, @@ -890,6 +976,8 @@ final class RulerCoreTests: XCTestCase { prefs.unit = .pixels prefs.rulerColor = NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) prefs.zeroCorner = .topLeft + prefs.defaultHorizontalLength = 260 + prefs.defaultVerticalLength = 180 let manager = RulerManager() defer { for controller in manager.controllers { @@ -904,11 +992,15 @@ final class RulerCoreTests: XCTestCase { prefs.unit = .millimeters prefs.rulerColor = NSColor(deviceRed: 0.8, green: 0.7, blue: 0.2, alpha: 1) prefs.zeroCorner = .topRight + prefs.defaultHorizontalLength = 320 + prefs.defaultVerticalLength = 240 let createdAfterDefaultsChange = manager.createRuler( screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) ) XCTAssertEqual(existing.state.settings.unit, .pixels) + XCTAssertEqual(existing.state.layout.horizontalLength, 260) + XCTAssertEqual(existing.state.layout.verticalLength, 180) XCTAssertEqual(existing.groupedWindow.horizontalRule.unit, .pixels) XCTAssertEqual(existing.groupedWindow.horizontalRule.zeroCorner, .topLeft) assertColor( @@ -916,6 +1008,8 @@ final class RulerCoreTests: XCTestCase { equals: NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) ) XCTAssertEqual(createdAfterDefaultsChange.state.settings.unit, .millimeters) + XCTAssertEqual(createdAfterDefaultsChange.state.layout.horizontalLength, 320) + XCTAssertEqual(createdAfterDefaultsChange.state.layout.verticalLength, 240) XCTAssertEqual(createdAfterDefaultsChange.groupedWindow.horizontalRule.unit, .millimeters) XCTAssertEqual(createdAfterDefaultsChange.groupedWindow.horizontalRule.zeroCorner, .topRight) assertColor( @@ -3637,6 +3731,8 @@ final class RulerCoreTests: XCTestCase { let previousGroupRulers = prefs.groupRulers let previousRulerShadow = prefs.rulerShadow let previousZeroCorner = prefs.zeroCorner + let previousDefaultHorizontalLength = prefs.defaultHorizontalLength + let previousDefaultVerticalLength = prefs.defaultVerticalLength defer { prefs.unit = previousUnit @@ -3647,6 +3743,8 @@ final class RulerCoreTests: XCTestCase { prefs.groupRulers = previousGroupRulers prefs.rulerShadow = previousRulerShadow prefs.zeroCorner = previousZeroCorner + prefs.defaultHorizontalLength = previousDefaultHorizontalLength + prefs.defaultVerticalLength = previousDefaultVerticalLength } try test() From 6e074003ce2cab13bfddb83fce00d78d4f8a0273 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 13:29:33 -0400 Subject: [PATCH 24/38] Implement dimension conversion methods in RulerSettingsControlsView; update dimension handling in PreferencesController and associated tests. Enhance user input handling for dimensions in various units, ensuring accurate conversions and display. --- Free Ruler/PreferencesController.swift | 121 +++++++++++++++++++++---- FreeRulerTests/RulerCoreTests.swift | 89 +++++++++++++++--- 2 files changed, 180 insertions(+), 30 deletions(-) diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index e08d821..1852e80 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -152,6 +152,44 @@ private func configureResetRulerColorButtonAppearance(_ button: NSButton, identi button.setAccessibilityLabel(resetRulerColorLabel) } +private func rulerDimensionValue(fromPixelLength pixelLength: CGFloat, unit: Unit, screen: NSScreen?) -> CGFloat { + switch unit { + case .pixels: + return pixelLength + case .millimeters: + return pixelLength / (screen?.dpmm.width ?? NSScreen.defaultDpmm) + case .inches: + return pixelLength / (screen?.dpi.width ?? NSScreen.defaultDpi) + } +} + +private func rulerPixelLength(fromDimensionValue dimensionValue: CGFloat, unit: Unit, screen: NSScreen?) -> CGFloat { + let pixelLength: CGFloat + switch unit { + case .pixels: + pixelLength = dimensionValue + case .millimeters: + pixelLength = dimensionValue * (screen?.dpmm.width ?? NSScreen.defaultDpmm) + case .inches: + pixelLength = dimensionValue * (screen?.dpi.width ?? NSScreen.defaultDpi) + } + + return pixelLength.rounded() +} + +private func rulerDimensionString(fromPixelLength pixelLength: CGFloat, unit: Unit, screen: NSScreen?) -> String { + let value = rulerDimensionValue(fromPixelLength: pixelLength, unit: unit, screen: screen) + + switch unit { + case .pixels: + return "\(Int(value.rounded()))" + case .millimeters: + return String(format: "%.1f", value) + case .inches: + return String(format: "%.3f", value) + } +} + protocol RulerSettingsControlsViewDelegate: AnyObject { func rulerSettingsControlsDidChangeUnit(_ controlsView: RulerSettingsControlsView) func rulerSettingsControlsDidChangeDimensions(_ controlsView: RulerSettingsControlsView) @@ -214,6 +252,7 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { let dimensionHeightField = NSTextField() private var showsDimensions = true + private var dimensionScreen: NSScreen? private var dimensionControls: [NSView] { return [ dimensionsLabel, @@ -228,11 +267,19 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { } var selectedHorizontalLength: CGFloat { - return CGFloat(dimensionWidthField.doubleValue) + return rulerPixelLength( + fromDimensionValue: CGFloat(dimensionWidthField.doubleValue), + unit: selectedUnit, + screen: dimensionScreen + ) } var selectedVerticalLength: CGFloat { - return CGFloat(dimensionHeightField.doubleValue) + return rulerPixelLength( + fromDimensionValue: CGFloat(dimensionHeightField.doubleValue), + unit: selectedUnit, + screen: dimensionScreen + ) } override init(frame frameRect: NSRect) { @@ -296,6 +343,7 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { unit: Unit, horizontalLength: CGFloat? = nil, verticalLength: CGFloat? = nil, + dimensionScreen: NSScreen? = nil, rulerColor: NSColor, foregroundOpacity: Int, backgroundOpacity: Int, @@ -303,15 +351,16 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { rulerShadow: Bool, isEnabled: Bool = true ) { + self.dimensionScreen = dimensionScreen unitSegmentedControl.selectedSegment = unit.rawValue unitSegmentedControl.isEnabled = isEnabled - if let horizontalLength = horizontalLength { - dimensionWidthField.integerValue = Int(horizontalLength.rounded()) - } - if let verticalLength = verticalLength { - dimensionHeightField.integerValue = Int(verticalLength.rounded()) - } + updateDimensions( + unit: unit, + horizontalLength: horizontalLength, + verticalLength: verticalLength, + dimensionScreen: dimensionScreen + ) dimensionWidthField.isEnabled = isEnabled dimensionHeightField.isEnabled = isEnabled @@ -339,6 +388,31 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { configureKeyViewLoop() } + func updateDimensions( + unit: Unit, + horizontalLength: CGFloat? = nil, + verticalLength: CGFloat? = nil, + dimensionScreen: NSScreen? = nil + ) { + self.dimensionScreen = dimensionScreen + unitSegmentedControl.selectedSegment = unit.rawValue + + if let horizontalLength = horizontalLength { + dimensionWidthField.stringValue = rulerDimensionString( + fromPixelLength: horizontalLength, + unit: unit, + screen: dimensionScreen + ) + } + if let verticalLength = verticalLength { + dimensionHeightField.stringValue = rulerDimensionString( + fromPixelLength: verticalLength, + unit: unit, + screen: dimensionScreen + ) + } + } + override func layout() { super.layout() @@ -512,10 +586,12 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { private func configureDimensionField(_ field: NSTextField) { let formatter = NumberFormatter() - formatter.allowsFloats = false + formatter.allowsFloats = true formatter.minimum = 0 formatter.maximum = 4000 - formatter.numberStyle = .none + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 3 + formatter.numberStyle = .decimal field.formatter = formatter field.alignment = .right @@ -740,6 +816,7 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP observers = [ prefs.observe(\Prefs.unit, options: .new) { prefs, changed in self.updateUnitSegmentedControl() + self.updateDimensionFields() }, prefs.observe(\Prefs.defaultHorizontalLength, options: .new) { prefs, changed in self.updateDimensionFields() @@ -770,8 +847,11 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP } @IBAction func setDimensions(_ sender: Any) { - prefs.defaultHorizontalLength = Double(settingsControlsView.selectedHorizontalLength) - prefs.defaultVerticalLength = Double(settingsControlsView.selectedVerticalLength) + let horizontalLength = settingsControlsView.selectedHorizontalLength + let verticalLength = settingsControlsView.selectedVerticalLength + + prefs.defaultHorizontalLength = Double(horizontalLength) + prefs.defaultVerticalLength = Double(verticalLength) } @IBAction func setForegroundOpacity(_ sender: Any) { @@ -802,6 +882,7 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP unit: prefs.unit, horizontalLength: prefs.effectiveDefaultHorizontalLength(), verticalLength: prefs.effectiveDefaultVerticalLength(), + dimensionScreen: window?.screen ?? NSScreen.main, rulerColor: prefs.rulerColor, foregroundOpacity: prefs.foregroundOpacity, backgroundOpacity: prefs.backgroundOpacity, @@ -815,8 +896,12 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP } func updateDimensionFields() { - dimensionWidthField.integerValue = Int(prefs.effectiveDefaultHorizontalLength().rounded()) - dimensionHeightField.integerValue = Int(prefs.effectiveDefaultVerticalLength().rounded()) + settingsControlsView.updateDimensions( + unit: prefs.unit, + horizontalLength: prefs.effectiveDefaultHorizontalLength(), + verticalLength: prefs.effectiveDefaultVerticalLength(), + dimensionScreen: window?.screen ?? NSScreen.main + ) } func updateForegroundSlider() { @@ -1095,9 +1180,12 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } @objc func setDimensions(_ sender: Any) { + let horizontalLength = settingsControlsView.selectedHorizontalLength + let verticalLength = settingsControlsView.selectedVerticalLength + rulerController?.updateDimensions( - horizontalLength: settingsControlsView.selectedHorizontalLength, - verticalLength: settingsControlsView.selectedVerticalLength + horizontalLength: horizontalLength, + verticalLength: verticalLength ) updateView() } @@ -1171,6 +1259,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { unit: currentSettings?.unit ?? Prefs.defaultUnit, horizontalLength: rulerController?.state.layout.horizontalLength, verticalLength: rulerController?.state.layout.verticalLength, + dimensionScreen: rulerController?.groupedWindow.screen ?? window?.screen ?? NSScreen.main, rulerColor: currentSettings?.rulerColor ?? Prefs.defaultRulerFillColor, foregroundOpacity: currentSettings?.foregroundOpacity ?? Prefs.defaultForegroundOpacity, backgroundOpacity: currentSettings?.backgroundOpacity ?? Prefs.defaultBackgroundOpacity, diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 4bac00f..c1a9109 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -378,19 +378,23 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(settingsController.unitSegmentedControl.selectedSegment, Unit.millimeters.rawValue) XCTAssertEqual(prefs.unit, .pixels) + let enteredWidthMillimeters: CGFloat = 100 + let enteredHeightMillimeters: CGFloat = 80 let zeroPointBeforeDimensionChange = controller.groupedWindow.zeroPoint() - settingsController.dimensionWidthField.integerValue = 320 - settingsController.dimensionHeightField.integerValue = 240 + settingsController.dimensionWidthField.stringValue = "\(enteredWidthMillimeters)" + settingsController.dimensionHeightField.stringValue = "\(enteredHeightMillimeters)" + let expectedHorizontalLength = settingsController.settingsControlsView.selectedHorizontalLength + let expectedVerticalLength = settingsController.settingsControlsView.selectedVerticalLength settingsController.setDimensions(settingsController.dimensionWidthField) - XCTAssertEqual(controller.state.layout.horizontalLength, 320) - XCTAssertEqual(controller.state.layout.verticalLength, 240) - XCTAssertEqual(controller.groupedWindow.screenFrame(for: .horizontal).width, 320) - XCTAssertEqual(controller.groupedWindow.screenFrame(for: .vertical).height, 240) + XCTAssertEqual(controller.state.layout.horizontalLength, expectedHorizontalLength, accuracy: 0.0001) + XCTAssertEqual(controller.state.layout.verticalLength, expectedVerticalLength, accuracy: 0.0001) + XCTAssertEqual(controller.groupedWindow.screenFrame(for: .horizontal).width, expectedHorizontalLength, accuracy: 0.0001) + XCTAssertEqual(controller.groupedWindow.screenFrame(for: .vertical).height, expectedVerticalLength, accuracy: 0.0001) XCTAssertEqual(controller.groupedWindow.zeroPoint().x, zeroPointBeforeDimensionChange.x, accuracy: 0.0001) XCTAssertEqual(controller.groupedWindow.zeroPoint().y, zeroPointBeforeDimensionChange.y, accuracy: 0.0001) - XCTAssertEqual(settingsController.dimensionWidthField.integerValue, 320) - XCTAssertEqual(settingsController.dimensionHeightField.integerValue, 240) + XCTAssertEqual(settingsController.dimensionWidthField.doubleValue, Double(enteredWidthMillimeters), accuracy: 0.15) + XCTAssertEqual(settingsController.dimensionHeightField.doubleValue, Double(enteredHeightMillimeters), accuracy: 0.15) XCTAssertEqual(prefs.defaultHorizontalLength, 640) XCTAssertEqual(prefs.defaultVerticalLength, 280) @@ -550,8 +554,8 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) XCTAssertEqual(controller.state.layout.horizontalLength, 320) XCTAssertEqual(controller.state.layout.verticalLength, 220) - XCTAssertEqual(settingsController.dimensionWidthField.integerValue, 320) - XCTAssertEqual(settingsController.dimensionHeightField.integerValue, 220) + XCTAssertEqual(settingsController.settingsControlsView.selectedHorizontalLength, 320, accuracy: 0.1) + XCTAssertEqual(settingsController.settingsControlsView.selectedVerticalLength, 220, accuracy: 0.1) XCTAssertEqual(settingsController.foregroundOpacityLabel.stringValue, "88%") XCTAssertEqual(settingsController.backgroundOpacityLabel.stringValue, "44%") XCTAssertEqual(controller.opacity, 88) @@ -683,16 +687,73 @@ final class RulerCoreTests: XCTestCase { preferencesController.unitSegmentedControl.selectedSegment = Unit.inches.rawValue preferencesController.setUnit(preferencesController.unitSegmentedControl) - preferencesController.dimensionWidthField.integerValue = 360 - preferencesController.dimensionHeightField.integerValue = 240 + preferencesController.dimensionWidthField.stringValue = "6" + preferencesController.dimensionHeightField.stringValue = "4" + let expectedHorizontalLength = preferencesController.settingsControlsView.selectedHorizontalLength + let expectedVerticalLength = preferencesController.settingsControlsView.selectedVerticalLength preferencesController.setDimensions(preferencesController.dimensionWidthField) XCTAssertEqual(prefs.unit, .inches) - XCTAssertEqual(prefs.defaultHorizontalLength, 360) - XCTAssertEqual(prefs.defaultVerticalLength, 240) + XCTAssertEqual(prefs.defaultHorizontalLength, Double(expectedHorizontalLength), accuracy: 0.0001) + XCTAssertEqual(prefs.defaultVerticalLength, Double(expectedVerticalLength), accuracy: 0.0001) } } + func testRulerSettingsControlsConvertDimensionsForSelectedUnit() { + let controlsView = RulerSettingsControlsView(frame: NSRect(x: 0, y: 0, width: 315, height: 320)) + controlsView.configureForRulerSettings() + + controlsView.update( + unit: .millimeters, + horizontalLength: 100 * NSScreen.defaultDpmm, + verticalLength: 80 * NSScreen.defaultDpmm, + dimensionScreen: nil, + rulerColor: Prefs.defaultRulerFillColor, + foregroundOpacity: 90, + backgroundOpacity: 50, + floatRulers: true, + rulerShadow: false + ) + + XCTAssertEqual(controlsView.dimensionWidthField.doubleValue, 100, accuracy: 0.0001) + XCTAssertEqual(controlsView.dimensionHeightField.doubleValue, 80, accuracy: 0.0001) + + controlsView.dimensionWidthField.stringValue = "25.5" + controlsView.dimensionHeightField.stringValue = "12.5" + + XCTAssertEqual( + controlsView.selectedHorizontalLength, + (25.5 * NSScreen.defaultDpmm).rounded(), + accuracy: 0.0001 + ) + XCTAssertEqual( + controlsView.selectedVerticalLength, + (12.5 * NSScreen.defaultDpmm).rounded(), + accuracy: 0.0001 + ) + + controlsView.update( + unit: .inches, + horizontalLength: 6 * NSScreen.defaultDpi, + verticalLength: 4.25 * NSScreen.defaultDpi, + dimensionScreen: nil, + rulerColor: Prefs.defaultRulerFillColor, + foregroundOpacity: 90, + backgroundOpacity: 50, + floatRulers: true, + rulerShadow: false + ) + + XCTAssertEqual(controlsView.dimensionWidthField.doubleValue, 6, accuracy: 0.0001) + XCTAssertEqual(controlsView.dimensionHeightField.doubleValue, 4.25, accuracy: 0.0001) + + controlsView.dimensionWidthField.stringValue = "3.5" + controlsView.dimensionHeightField.stringValue = "2.75" + + XCTAssertEqual(controlsView.selectedHorizontalLength, 3.5 * NSScreen.defaultDpi, accuracy: 0.0001) + XCTAssertEqual(controlsView.selectedVerticalLength, 2.75 * NSScreen.defaultDpi, accuracy: 0.0001) + } + func testRulerSettingsControlsKeyViewLoopFollowsVisibleControls() { let controlsView = RulerSettingsControlsView(frame: NSRect(x: 0, y: 0, width: 315, height: 320)) controlsView.configureForRulerSettings() From f3632f1744cc94d5905477eac3c3616adc9f2ecb Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 15:18:00 -0400 Subject: [PATCH 25/38] Stagger new rulers from occupied defaults (#253) * Stagger new rulers from occupied defaults * Fix new ruler stagger direction --- Free Ruler/GroupedRulerWindow.swift | 15 ++++++++++++++- FreeRulerTests/RulerCoreTests.swift | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 88dcf98..2ed0e64 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1693,10 +1693,11 @@ final class RulerManager { defaults: RulerSettings = RulerSettings(defaults: prefs), screenFrame: NSRect = defaultRulerScreenFrame() ) -> GroupedRulerController { - let state = RulerInstanceState.createFromDefaults( + let defaultState = RulerInstanceState.createFromDefaults( defaults: defaults, screenFrame: screenFrame ) + let state = staggeredState(from: defaultState) return addRuler(state: state) } @@ -1793,6 +1794,18 @@ final class RulerManager { } } + private func staggeredState(from defaultState: RulerInstanceState) -> RulerInstanceState { + var state = defaultState + let offset = Ruler.thickness / 2 + + while controllers.contains(where: { $0.state.layout.zeroPoint == state.layout.zeroPoint }) { + state.layout.zeroPoint.x += offset + state.layout.zeroPoint.y -= offset + } + + return state + } + private func notifyStateChanged() { onStateChanged?(self) } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index c1a9109..2a6d339 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -169,6 +169,26 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(manager.states.map(\.settings.unit), [.inches]) } + func testRulerManagerStaggersNewRulersWhenDefaultPositionIsOccupied() { + let manager = RulerManager() + defer { + for controller in manager.controllers { + controller.hide() + } + } + + let screenFrame = NSRect(x: 0, y: 0, width: 1000, height: 800) + let first = manager.createRuler(screenFrame: screenFrame) + let second = manager.createRuler(screenFrame: screenFrame) + let third = manager.createRuler(screenFrame: screenFrame) + let staggerOffset = Ruler.thickness / 2 + + XCTAssertEqual(second.state.layout.zeroPoint.x, first.state.layout.zeroPoint.x + staggerOffset) + XCTAssertEqual(second.state.layout.zeroPoint.y, first.state.layout.zeroPoint.y - staggerOffset) + XCTAssertEqual(third.state.layout.zeroPoint.x, first.state.layout.zeroPoint.x + (staggerOffset * 2)) + XCTAssertEqual(third.state.layout.zeroPoint.y, first.state.layout.zeroPoint.y - (staggerOffset * 2)) + } + func testRulerContextMenuActivatesClickedRulerAndShowsSettingsCommand() { withInstalledAppDelegate { appDelegate in let manager = appDelegate.rulerManager From 336de1f49196897438e96c4321675d9313398ba5 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 15:19:06 -0400 Subject: [PATCH 26/38] Drag visible rulers together when grouped (#254) --- Free Ruler/AppDelegate.swift | 6 +- Free Ruler/Base.lproj/MainMenu.xib | 12 ++-- Free Ruler/GroupedRulerWindow.swift | 83 ++++++++++++++++++++++ Free Ruler/de.lproj/MainMenu.strings | 8 +-- Free Ruler/es.lproj/MainMenu.strings | 8 +-- Free Ruler/fi.lproj/MainMenu.strings | 8 +-- Free Ruler/ja.lproj/MainMenu.strings | 6 +- Free Ruler/zh-hans.lproj/MainMenu.strings | 6 +- FreeRulerTests/RulerCoreTests.swift | 85 ++++++++++++++++++++--- 9 files changed, 182 insertions(+), 40 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 7f7ed70..a6ae4af 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -525,7 +525,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func applyRulerWindowMode(showRulersIfNeeded: Bool = false) { if rulerManager.hasRulers { - rulerManager.showAll() updateMouseTickTimer() return } @@ -745,8 +744,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @IBAction func toggleGroupRulers(_ sender: Any) { - guard !rulerManager.hasRulers else { return } - prefs.groupRulers = !prefs.groupRulers showGroupRulersHotkeyBezel(on: bezelScreen(for: sender)) } @@ -1034,7 +1031,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { case kVK_ANSI_F: toggleFloatRulers(sender) case kVK_ANSI_G: - guard !rulerManager.hasRulers else { return true } toggleGroupRulers(sender) case kVK_ANSI_S: toggleRulerShadow(sender) @@ -1120,7 +1116,7 @@ extension AppDelegate: NSMenuItemValidation { case #selector(closeKeyWindow(_:)): return rulerManager.activeController != nil || NSApp.keyWindow?.isVisible == true case #selector(toggleGroupRulers(_:)): - return !rulerManager.hasRulers + return true case #selector(toggleHorizontalRuler(_:)): if let controller = rulerManager.activeController { let isVisible = controller.state.isWingVisible(.horizontal) diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index 7db7dd7..45df119 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -218,16 +218,16 @@
- + - + - + - + @@ -237,13 +237,13 @@ - + - + diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 2ed0e64..7615371 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1096,6 +1096,9 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi let groupedWindow: GroupedRulerWindow var state: RulerInstanceState var onBecameActive: ((GroupedRulerController) -> Void)? + var onDragStarted: ((GroupedRulerController) -> Void)? + var onDragged: ((GroupedRulerController) -> Void)? + var onDragFinished: ((GroupedRulerController) -> Void)? var onStateChanged: ((GroupedRulerController) -> Void)? private var keyListener: Any? @@ -1319,6 +1322,11 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi notifyStateChanged() } + func move(to frame: NSRect) { + groupedWindow.setFrame(frame, display: false) + captureStateFromWindow() + } + func resetPosition() { state.settings.zeroCorner = Prefs.defaultZeroCorner state.layout = RulerLayoutState.defaults( @@ -1489,6 +1497,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi ) ) captureStateFromWindow() + onDragged?(self) mouseInteraction.windowDidMove(isLeftMouseButtonPressed: isLeftMouseButtonPressed()) } @@ -1511,6 +1520,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi override func mouseDown(with event: NSEvent) { mouseInteraction.mouseDown(with: event) + onDragStarted?(self) } override func mouseUp(with event: NSEvent) { @@ -1521,6 +1531,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi if mouseInteraction.finishMouseDrag(with: event) { syncRulerWindowFrames(persistAutosave: true) captureStateFromWindow() + onDragFinished?(self) } } @@ -1649,11 +1660,18 @@ extension GroupedRulerController { final class RulerManager { typealias ControllerFactory = (RulerInstanceState) -> GroupedRulerController + private struct GroupedDragState { + let draggedRulerID: UUID + let framesByRulerID: [UUID: NSRect] + } + private let controllerFactory: ControllerFactory private(set) var controllers: [GroupedRulerController] = [] private(set) var activeRulerID: UUID? var onActiveControllerChanged: ((GroupedRulerController?) -> Void)? var onStateChanged: ((RulerManager) -> Void)? + private var groupedDragState: GroupedDragState? + private var isApplyingGroupedDrag = false init( initialStates: [RulerInstanceState] = [], @@ -1770,6 +1788,59 @@ final class RulerManager { notifyStateChanged() } + func beginGroupedDrag(from controller: GroupedRulerController) { + guard prefs.groupRulers, + controllers.contains(where: { $0 === controller }) else { + groupedDragState = nil + return + } + + groupedDragState = GroupedDragState( + draggedRulerID: controller.state.id, + framesByRulerID: Dictionary( + uniqueKeysWithValues: controllers + .filter(\.isVisible) + .map { ($0.state.id, $0.groupedWindow.frame) } + ) + ) + } + + func syncGroupedDrag(from controller: GroupedRulerController) { + guard prefs.groupRulers, + !isApplyingGroupedDrag, + let groupedDragState = groupedDragState, + groupedDragState.draggedRulerID == controller.state.id, + let originalDraggedFrame = groupedDragState.framesByRulerID[controller.state.id] else { + return + } + + let offset = NSSize( + width: controller.groupedWindow.frame.minX - originalDraggedFrame.minX, + height: controller.groupedWindow.frame.minY - originalDraggedFrame.minY + ) + guard offset.width != 0 || offset.height != 0 else { return } + + isApplyingGroupedDrag = true + defer { + isApplyingGroupedDrag = false + } + + for otherController in controllers where otherController !== controller && otherController.isVisible { + guard var frame = groupedDragState.framesByRulerID[otherController.state.id] else { continue } + + frame.origin.x += offset.width + frame.origin.y += offset.height + otherController.move(to: frame) + } + + notifyStateChanged() + } + + func finishGroupedDrag(from controller: GroupedRulerController) { + syncGroupedDrag(from: controller) + groupedDragState = nil + } + func controller(containing window: NSWindow?) -> GroupedRulerController? { guard let window = window else { return nil } @@ -1785,6 +1856,18 @@ final class RulerManager { guard let controller = controller else { return } self?.markActive(controller) } + controller.onDragStarted = { [weak self, weak controller] _ in + guard let controller = controller else { return } + self?.beginGroupedDrag(from: controller) + } + controller.onDragged = { [weak self, weak controller] _ in + guard let controller = controller else { return } + self?.syncGroupedDrag(from: controller) + } + controller.onDragFinished = { [weak self, weak controller] _ in + guard let controller = controller else { return } + self?.finishGroupedDrag(from: controller) + } controller.onStateChanged = { [weak self, weak controller] _ in guard let controller = controller, self?.activeRulerID == controller.state.id else { return } diff --git a/Free Ruler/de.lproj/MainMenu.strings b/Free Ruler/de.lproj/MainMenu.strings index 8e8010d..bf8c692 100644 --- a/Free Ruler/de.lproj/MainMenu.strings +++ b/Free Ruler/de.lproj/MainMenu.strings @@ -14,8 +14,8 @@ /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */ "6dh-zS-Vam.title" = "Wiederholen"; -/* Class = "NSMenuItem"; title = "Reset Rulers"; ObjectID = "6ph-5N-O9R"; */ -"6ph-5N-O9R.title" = "Lineale zurücksetzen"; +/* Class = "NSMenuItem"; title = "Reset Ruler Position"; ObjectID = "6ph-5N-O9R"; */ +"6ph-5N-O9R.title" = "Linealposition zurücksetzen"; /* Class = "NSMenuItem"; title = "Group Rulers"; ObjectID = "7Ga-Fb-LLc"; */ "7Ga-Fb-LLc.title" = "Lineale gruppieren"; @@ -116,8 +116,8 @@ /* Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5"; */ "hz9-B4-Xy5.title" = "Dienste"; -/* Class = "NSMenuItem"; title = "Align Rulers at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ -"iKV-uW-hwy.title" = "Lineale an Zeigerposition ausrichten"; +/* Class = "NSMenuItem"; title = "Align Ruler at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ +"iKV-uW-hwy.title" = "Lineal an Zeigerposition ausrichten"; /* Class = "NSMenuItem"; title = "Delete"; ObjectID = "pa3-QI-u2k"; */ "pa3-QI-u2k.title" = "Löschen"; diff --git a/Free Ruler/es.lproj/MainMenu.strings b/Free Ruler/es.lproj/MainMenu.strings index 6ff7d35..da2f752 100644 --- a/Free Ruler/es.lproj/MainMenu.strings +++ b/Free Ruler/es.lproj/MainMenu.strings @@ -17,8 +17,8 @@ /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */ "6dh-zS-Vam.title" = "Rehacer"; -/* Class = "NSMenuItem"; title = "Reset Rulers"; ObjectID = "6ph-5N-O9R"; */ -"6ph-5N-O9R.title" = "Restablecer reglas"; +/* Class = "NSMenuItem"; title = "Reset Ruler Position"; ObjectID = "6ph-5N-O9R"; */ +"6ph-5N-O9R.title" = "Restablecer posición de la regla"; /* Class = "NSMenuItem"; title = "Group Rulers"; ObjectID = "7Ga-Fb-LLc"; */ "7Ga-Fb-LLc.title" = "Agrupar reglas"; @@ -122,8 +122,8 @@ /* Class = "NSMenuItem"; title = "Unit"; ObjectID = "iDP-2z-irv"; */ "iDP-2z-irv.title" = "Unidad"; -/* Class = "NSMenuItem"; title = "Align Rulers at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ -"iKV-uW-hwy.title" = "Alinear reglas con el ratón"; +/* Class = "NSMenuItem"; title = "Align Ruler at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ +"iKV-uW-hwy.title" = "Alinear regla con el ratón"; /* Class = "NSMenuItem"; title = "Inches"; ObjectID = "lt1-Hj-2TR"; */ "lt1-Hj-2TR.title" = "Pulgadas"; diff --git a/Free Ruler/fi.lproj/MainMenu.strings b/Free Ruler/fi.lproj/MainMenu.strings index 36741dd..75be73a 100644 --- a/Free Ruler/fi.lproj/MainMenu.strings +++ b/Free Ruler/fi.lproj/MainMenu.strings @@ -14,8 +14,8 @@ /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */ "6dh-zS-Vam.title" = "Tee sittenkin"; -/* Class = "NSMenuItem"; title = "Reset Rulers"; ObjectID = "6ph-5N-O9R"; */ -"6ph-5N-O9R.title" = "Nollaa viivaimet"; +/* Class = "NSMenuItem"; title = "Reset Ruler Position"; ObjectID = "6ph-5N-O9R"; */ +"6ph-5N-O9R.title" = "Nollaa viivaimen sijainti"; /* Class = "NSMenuItem"; title = "Group Rulers"; ObjectID = "7Ga-Fb-LLc"; */ "7Ga-Fb-LLc.title" = "Ryhmitä viivaimet"; @@ -116,8 +116,8 @@ /* Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5"; */ "hz9-B4-Xy5.title" = "Palvelut"; -/* Class = "NSMenuItem"; title = "Align Rulers at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ -"iKV-uW-hwy.title" = "Kohdista viivaimet hiiren sijaintiin"; +/* Class = "NSMenuItem"; title = "Align Ruler at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ +"iKV-uW-hwy.title" = "Kohdista viivain hiiren sijaintiin"; /* Class = "NSMenuItem"; title = "Delete"; ObjectID = "pa3-QI-u2k"; */ "pa3-QI-u2k.title" = "Poista"; diff --git a/Free Ruler/ja.lproj/MainMenu.strings b/Free Ruler/ja.lproj/MainMenu.strings index 653914e..4e24ca3 100644 --- a/Free Ruler/ja.lproj/MainMenu.strings +++ b/Free Ruler/ja.lproj/MainMenu.strings @@ -17,8 +17,8 @@ /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */ "6dh-zS-Vam.title" = "やり直し"; -/* Class = "NSMenuItem"; title = "Reset Rulers"; ObjectID = "6ph-5N-O9R"; */ -"6ph-5N-O9R.title" = "定規をリセット"; +/* Class = "NSMenuItem"; title = "Reset Ruler Position"; ObjectID = "6ph-5N-O9R"; */ +"6ph-5N-O9R.title" = "定規の位置をリセット"; /* Class = "NSMenuItem"; title = "Group Rulers"; ObjectID = "7Ga-Fb-LLc"; */ "7Ga-Fb-LLc.title" = "定規をグループ化"; @@ -122,7 +122,7 @@ /* Class = "NSMenuItem"; title = "Unit"; ObjectID = "iDP-2z-irv"; */ "iDP-2z-irv.title" = "単位"; -/* Class = "NSMenuItem"; title = "Align Rulers at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ +/* Class = "NSMenuItem"; title = "Align Ruler at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ "iKV-uW-hwy.title" = "マウスの位置に定規を移動"; /* Class = "NSMenuItem"; title = "Inches"; ObjectID = "lt1-Hj-2TR"; */ diff --git a/Free Ruler/zh-hans.lproj/MainMenu.strings b/Free Ruler/zh-hans.lproj/MainMenu.strings index 9cd0f65..c536982 100644 --- a/Free Ruler/zh-hans.lproj/MainMenu.strings +++ b/Free Ruler/zh-hans.lproj/MainMenu.strings @@ -14,8 +14,8 @@ /* Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam"; */ "6dh-zS-Vam.title" = "重做"; -/* Class = "NSMenuItem"; title = "Reset Rulers"; ObjectID = "6ph-5N-O9R"; */ -"6ph-5N-O9R.title" = "重置尺子"; +/* Class = "NSMenuItem"; title = "Reset Ruler Position"; ObjectID = "6ph-5N-O9R"; */ +"6ph-5N-O9R.title" = "重置尺子位置"; /* Class = "NSMenuItem"; title = "Group Rulers"; ObjectID = "7Ga-Fb-LLc"; */ "7Ga-Fb-LLc.title" = "组合尺子"; @@ -116,7 +116,7 @@ /* Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5"; */ "hz9-B4-Xy5.title" = "服务"; -/* Class = "NSMenuItem"; title = "Align Rulers at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ +/* Class = "NSMenuItem"; title = "Align Ruler at Mouse Location"; ObjectID = "iKV-uW-hwy"; */ "iKV-uW-hwy.title" = "将尺子与鼠标位置对齐"; /* Class = "NSMenuItem"; title = "Delete"; ObjectID = "pa3-QI-u2k"; */ diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 2a6d339..594574c 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -189,6 +189,57 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(third.state.layout.zeroPoint.y, first.state.layout.zeroPoint.y - (staggerOffset * 2)) } + func testRulerManagerMovesVisibleRulersTogetherDuringGroupedDrag() { + withRestoredRulerPreferences { + withRestoredRulerSetState { + prefs.groupRulers = true + prefs.zeroCorner = .topLeft + let appDelegate = AppDelegate() + let first = appDelegate.rulerManager.createRuler( + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + let second = appDelegate.rulerManager.createRuler( + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + let hidden = appDelegate.rulerManager.createRuler( + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + defer { + first.hide() + second.hide() + hidden.hide() + } + first.show() + second.show() + + let firstFrame = first.groupedWindow.frame + let secondFrame = second.groupedWindow.frame + let hiddenFrame = hidden.groupedWindow.frame + let dragOffset = NSSize(width: 37, height: -24) + var movedFirstFrame = firstFrame + movedFirstFrame.origin.x += dragOffset.width + movedFirstFrame.origin.y += dragOffset.height + + appDelegate.rulerManager.beginGroupedDrag(from: first) + first.move(to: movedFirstFrame) + appDelegate.rulerManager.syncGroupedDrag(from: first) + appDelegate.rulerManager.finishGroupedDrag(from: first) + + XCTAssertEqual(first.groupedWindow.frame, movedFirstFrame) + XCTAssertEqual(second.groupedWindow.frame.minX, secondFrame.minX + dragOffset.width) + XCTAssertEqual(second.groupedWindow.frame.minY, secondFrame.minY + dragOffset.height) + XCTAssertEqual(hidden.groupedWindow.frame, hiddenFrame) + XCTAssertEqual( + second.state.layout.zeroPoint, + ZeroCornerGeometry(zeroCorner: second.state.settings.zeroCorner).zeroPoint( + in: second.groupedWindow.screenFrame(for: .horizontal), + for: .horizontal + ) + ) + } + } + } + func testRulerContextMenuActivatesClickedRulerAndShowsSettingsCommand() { withInstalledAppDelegate { appDelegate in let manager = appDelegate.rulerManager @@ -3332,26 +3383,32 @@ final class RulerCoreTests: XCTestCase { } } - func testManagedGroupHotkeyDoesNotToggleRetiredGroupedMode() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } - - prefs.groupRulers = true + func testManagedGroupHotkeyTogglesGroupedDraggingMode() { + withRestoredRulerPreferences { + prefs.groupRulers = false let appDelegate = AppDelegate() - appDelegate.showRulers() + let controller = appDelegate.rulerManager.createRuler() + defer { + controller.hide() + } XCTAssertTrue( appDelegate.performRulerHotkey( keyCode: kVK_ANSI_G, modifierFlags: [], - sender: appDelegate.groupedRulerController! + sender: controller ) ) - XCTAssertTrue(prefs.groupRulers) - XCTAssertEqual(appDelegate.rulerManager.controllers.count, 1) - appDelegate.groupedRulerController?.hide() + + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_G, + modifierFlags: [], + sender: controller + ) + ) + XCTAssertFalse(prefs.groupRulers) } } @@ -3499,10 +3556,16 @@ final class RulerCoreTests: XCTestCase { action: #selector(AppDelegate.toggleVerticalRuler(_:)), keyEquivalent: "" ) + let groupItem = NSMenuItem( + title: "", + action: #selector(AppDelegate.toggleGroupRulers(_:)), + keyEquivalent: "" + ) XCTAssertTrue(appDelegate.validateMenuItem(closeItem)) XCTAssertFalse(appDelegate.validateMenuItem(horizontalItem)) XCTAssertTrue(appDelegate.validateMenuItem(verticalItem)) + XCTAssertTrue(appDelegate.validateMenuItem(groupItem)) } func testUngroupedHorizontalFlipDoesNotMoveRulerWindows() { From 593087f793b45bdd796b38be96103a5cd196b7e8 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 15:29:39 -0400 Subject: [PATCH 27/38] Add ruler cycling command (#255) --- Free Ruler/AppDelegate.swift | 24 +++++++++++----- Free Ruler/Base.lproj/MainMenu.xib | 6 ---- Free Ruler/GroupedRulerWindow.swift | 18 ++++++++++++ Free Ruler/Localizable.xcstrings | 42 --------------------------- FreeRulerTests/RulerCoreTests.swift | 44 +++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 55 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index a6ae4af..560f2b4 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -794,6 +794,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { updateMouseTickTimer() } + @IBAction func cycleRulers(_ sender: Any) { + guard rulerManager.cycleActiveRuler() != nil else { return } + + updateDisplay() + } + @IBAction func closeKeyWindow(_ sender: Any) { if let controller = rulerManager.controller(containing: NSApp.keyWindow) { rulerManager.close(controller) @@ -872,10 +878,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { applyRulerWindowMode() } - @IBAction func showRulers(_ sender: Any) { - showRulers() - } - @IBAction func toggleHorizontalRuler(_ sender: Any) { toggleRuler(orientation: .horizontal) } @@ -1019,6 +1021,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { return true } + if keyboardModifiers == .command { + switch keyCode { + case kVK_ANSI_Grave: + cycleRulers(sender) + default: + return false + } + + return true + } + guard keyboardModifiers.isEmpty else { return false } switch keyCode { @@ -1145,9 +1158,6 @@ extension AppDelegate: NSMenuItemValidation { ? NSLocalizedString("Hide Vertical Ruler", comment: "Menu item title to hide the vertical ruler") : NSLocalizedString("Show Vertical Ruler", comment: "Menu item title to show the vertical ruler") return canToggleRulerVisibility - case #selector(showRulers(_:)): - menuItem.title = NSLocalizedString("Show All Rulers", comment: "Menu item title to show all ruler windows") - return true default: return true } diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index 45df119..22d6e07 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -98,12 +98,6 @@ - - - - - - diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 7615371..21f81a1 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1760,6 +1760,24 @@ final class RulerManager { } } + @discardableResult + func cycleActiveRuler() -> GroupedRulerController? { + let visibleControllers = controllers.filter(\.isVisible) + guard !visibleControllers.isEmpty else { return nil } + + let activeID = activeController?.state.id + let activeIndex = activeID.flatMap { activeID in + visibleControllers.firstIndex { $0.state.id == activeID } + } + let nextIndex = activeIndex.map { ($0 + 1) % visibleControllers.count } ?? 0 + let nextController = visibleControllers[nextIndex] + + markActive(nextController) + nextController.groupedWindow.orderFrontRegardless() + nextController.groupedWindow.makeKey() + return nextController + } + @discardableResult func closeActiveRuler() -> Bool { guard let activeController = activeController else { return false } diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 1cf1b8e..b6c2d2a 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -887,48 +887,6 @@ } } }, - "Show All Rulers" : { - "comment" : "Menu item title to show all ruler windows", - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alle Lineale anzeigen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show All Rulers" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mostrar todas las reglas" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Näytä kaikki viivaimet" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "すべての定規を表示" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "显示所有标尺" - } - } - } - }, "Show Horizontal Ruler" : { "comment" : "Menu item title to show the horizontal ruler", "extractionState" : "manual", diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 594574c..d4cae43 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -240,6 +240,27 @@ final class RulerCoreTests: XCTestCase { } } + func testRulerManagerCyclesVisibleRulers() { + let manager = RulerManager() + let first = manager.createRuler() + let second = manager.createRuler() + let hidden = manager.createRuler() + defer { + first.hide() + second.hide() + hidden.hide() + } + first.show() + second.show() + manager.markActive(first) + + XCTAssertTrue(manager.cycleActiveRuler() === second) + XCTAssertTrue(manager.activeController === second) + + XCTAssertTrue(manager.cycleActiveRuler() === first) + XCTAssertTrue(manager.activeController === first) + } + func testRulerContextMenuActivatesClickedRulerAndShowsSettingsCommand() { withInstalledAppDelegate { appDelegate in let manager = appDelegate.rulerManager @@ -3412,6 +3433,29 @@ final class RulerCoreTests: XCTestCase { } } + func testCommandGraveCyclesManagedRulers() { + let appDelegate = AppDelegate() + let first = appDelegate.rulerManager.createRuler() + let second = appDelegate.rulerManager.createRuler() + defer { + first.hide() + second.hide() + } + first.show() + second.show() + appDelegate.rulerManager.markActive(first) + + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_Grave, + modifierFlags: .command, + sender: first + ) + ) + + XCTAssertTrue(appDelegate.rulerManager.activeController === second) + } + func testManagedWingHotkeysAffectOnlyActiveRuler() { let appDelegate = AppDelegate() let first = appDelegate.rulerManager.createRuler() From 15807205b5c99e3890a13de9984a472505f8528e Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 15:31:46 -0400 Subject: [PATCH 28/38] Swap settings shortcuts (#256) --- Free Ruler/Base.lproj/MainMenu.xib | 4 ++-- Free Ruler/Base.lproj/PreferencesController.xib | 2 +- Free Ruler/de.lproj/MainMenu.strings | 2 +- Free Ruler/de.lproj/PreferencesController.strings | 2 +- Free Ruler/es.lproj/MainMenu.strings | 4 ++-- Free Ruler/es.lproj/PreferencesController.strings | 4 ++-- Free Ruler/fi.lproj/MainMenu.strings | 2 +- Free Ruler/fi.lproj/PreferencesController.strings | 2 +- Free Ruler/ja.lproj/MainMenu.strings | 4 ++-- Free Ruler/ja.lproj/PreferencesController.strings | 4 ++-- Free Ruler/zh-hans.lproj/MainMenu.strings | 4 ++-- Free Ruler/zh-hans.lproj/PreferencesController.strings | 4 ++-- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index 22d6e07..aaf685b 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -38,7 +38,8 @@ - + + @@ -99,7 +100,6 @@ - diff --git a/Free Ruler/Base.lproj/PreferencesController.xib b/Free Ruler/Base.lproj/PreferencesController.xib index be53076..eadc90a 100644 --- a/Free Ruler/Base.lproj/PreferencesController.xib +++ b/Free Ruler/Base.lproj/PreferencesController.xib @@ -15,7 +15,7 @@ - + diff --git a/Free Ruler/de.lproj/MainMenu.strings b/Free Ruler/de.lproj/MainMenu.strings index bf8c692..3e127d3 100644 --- a/Free Ruler/de.lproj/MainMenu.strings +++ b/Free Ruler/de.lproj/MainMenu.strings @@ -26,7 +26,7 @@ /* Class = "NSMenu"; title = "Main Menu"; ObjectID = "AYu-sK-qS6"; */ "AYu-sK-qS6.title" = "Main Menu"; -/* Class = "NSMenuItem"; title = "Preferences…"; ObjectID = "BOF-NM-1cW"; */ +/* Class = "NSMenuItem"; title = "Settings…"; ObjectID = "BOF-NM-1cW"; */ "BOF-NM-1cW.title" = "Einstellungen …"; /* Class = "NSMenuItem"; title = "Close"; ObjectID = "DVo-aG-piG"; */ diff --git a/Free Ruler/de.lproj/PreferencesController.strings b/Free Ruler/de.lproj/PreferencesController.strings index 6317fd3..e5d6979 100644 --- a/Free Ruler/de.lproj/PreferencesController.strings +++ b/Free Ruler/de.lproj/PreferencesController.strings @@ -1,5 +1,5 @@ -/* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ +/* Class = "NSWindow"; title = "Free Ruler Settings"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "„Free Ruler“-Einstellungen"; /* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ diff --git a/Free Ruler/es.lproj/MainMenu.strings b/Free Ruler/es.lproj/MainMenu.strings index da2f752..9dbe8c0 100644 --- a/Free Ruler/es.lproj/MainMenu.strings +++ b/Free Ruler/es.lproj/MainMenu.strings @@ -29,8 +29,8 @@ /* Class = "NSMenuItem"; title = "Millimeters"; ObjectID = "B6Y-Hi-AkN"; */ "B6Y-Hi-AkN.title" = "Milímetros"; -/* Class = "NSMenuItem"; title = "Preferences…"; ObjectID = "BOF-NM-1cW"; */ -"BOF-NM-1cW.title" = "Preferencias…"; +/* Class = "NSMenuItem"; title = "Settings…"; ObjectID = "BOF-NM-1cW"; */ +"BOF-NM-1cW.title" = "Ajustes…"; /* Class = "NSMenuItem"; title = "Close"; ObjectID = "DVo-aG-piG"; */ "DVo-aG-piG.title" = "Cerrar"; diff --git a/Free Ruler/es.lproj/PreferencesController.strings b/Free Ruler/es.lproj/PreferencesController.strings index 3854183..bc760d0 100644 --- a/Free Ruler/es.lproj/PreferencesController.strings +++ b/Free Ruler/es.lproj/PreferencesController.strings @@ -1,6 +1,6 @@ -/* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ -"F0z-JX-Cv5.title" = "Preferencias de Free Ruler"; +/* Class = "NSWindow"; title = "Free Ruler Settings"; ObjectID = "F0z-JX-Cv5"; */ +"F0z-JX-Cv5.title" = "Ajustes de Free Ruler"; /* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ "PREF-defaults-header-cell.title" = "Ajustes predeterminados para reglas nuevas"; diff --git a/Free Ruler/fi.lproj/MainMenu.strings b/Free Ruler/fi.lproj/MainMenu.strings index 75be73a..4e44c85 100644 --- a/Free Ruler/fi.lproj/MainMenu.strings +++ b/Free Ruler/fi.lproj/MainMenu.strings @@ -26,7 +26,7 @@ /* Class = "NSMenu"; title = "Main Menu"; ObjectID = "AYu-sK-qS6"; */ "AYu-sK-qS6.title" = "Päävalikko"; -/* Class = "NSMenuItem"; title = "Preferences…"; ObjectID = "BOF-NM-1cW"; */ +/* Class = "NSMenuItem"; title = "Settings…"; ObjectID = "BOF-NM-1cW"; */ "BOF-NM-1cW.title" = "Asetukset…"; /* Class = "NSMenuItem"; title = "Close"; ObjectID = "DVo-aG-piG"; */ diff --git a/Free Ruler/fi.lproj/PreferencesController.strings b/Free Ruler/fi.lproj/PreferencesController.strings index 98833ca..5d6be25 100644 --- a/Free Ruler/fi.lproj/PreferencesController.strings +++ b/Free Ruler/fi.lproj/PreferencesController.strings @@ -1,5 +1,5 @@ -/* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ +/* Class = "NSWindow"; title = "Free Ruler Settings"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "Free Rulerin asetukset"; /* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ diff --git a/Free Ruler/ja.lproj/MainMenu.strings b/Free Ruler/ja.lproj/MainMenu.strings index 4e24ca3..2bc7ca1 100644 --- a/Free Ruler/ja.lproj/MainMenu.strings +++ b/Free Ruler/ja.lproj/MainMenu.strings @@ -29,8 +29,8 @@ /* Class = "NSMenuItem"; title = "Millimeters"; ObjectID = "B6Y-Hi-AkN"; */ "B6Y-Hi-AkN.title" = "ミリメートル"; -/* Class = "NSMenuItem"; title = "Preferences…"; ObjectID = "BOF-NM-1cW"; */ -"BOF-NM-1cW.title" = "環境設定..."; +/* Class = "NSMenuItem"; title = "Settings…"; ObjectID = "BOF-NM-1cW"; */ +"BOF-NM-1cW.title" = "設定..."; /* Class = "NSMenuItem"; title = "Close"; ObjectID = "DVo-aG-piG"; */ "DVo-aG-piG.title" = "閉じる"; diff --git a/Free Ruler/ja.lproj/PreferencesController.strings b/Free Ruler/ja.lproj/PreferencesController.strings index 4ac6800..7635f86 100644 --- a/Free Ruler/ja.lproj/PreferencesController.strings +++ b/Free Ruler/ja.lproj/PreferencesController.strings @@ -1,6 +1,6 @@ -/* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ -"F0z-JX-Cv5.title" = "Free Rulerの環境設定"; +/* Class = "NSWindow"; title = "Free Ruler Settings"; ObjectID = "F0z-JX-Cv5"; */ +"F0z-JX-Cv5.title" = "Free Rulerの設定"; /* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ "PREF-defaults-header-cell.title" = "新しい定規のデフォルト設定"; diff --git a/Free Ruler/zh-hans.lproj/MainMenu.strings b/Free Ruler/zh-hans.lproj/MainMenu.strings index c536982..2e16cd2 100644 --- a/Free Ruler/zh-hans.lproj/MainMenu.strings +++ b/Free Ruler/zh-hans.lproj/MainMenu.strings @@ -26,8 +26,8 @@ /* Class = "NSMenu"; title = "Main Menu"; ObjectID = "AYu-sK-qS6"; */ "AYu-sK-qS6.title" = "Main Menu"; -/* Class = "NSMenuItem"; title = "Preferences…"; ObjectID = "BOF-NM-1cW"; */ -"BOF-NM-1cW.title" = "偏好设置…"; +/* Class = "NSMenuItem"; title = "Settings…"; ObjectID = "BOF-NM-1cW"; */ +"BOF-NM-1cW.title" = "设置…"; /* Class = "NSMenuItem"; title = "Close"; ObjectID = "DVo-aG-piG"; */ "DVo-aG-piG.title" = "关闭"; diff --git a/Free Ruler/zh-hans.lproj/PreferencesController.strings b/Free Ruler/zh-hans.lproj/PreferencesController.strings index 49c2a5d..38bf47b 100644 --- a/Free Ruler/zh-hans.lproj/PreferencesController.strings +++ b/Free Ruler/zh-hans.lproj/PreferencesController.strings @@ -1,6 +1,6 @@ -/* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ -"F0z-JX-Cv5.title" = "Free Ruler 偏好设置"; +/* Class = "NSWindow"; title = "Free Ruler Settings"; ObjectID = "F0z-JX-Cv5"; */ +"F0z-JX-Cv5.title" = "Free Ruler 设置"; /* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ "PREF-defaults-header-cell.title" = "新尺子的默认设置"; From 6e76cff80eb19b203971a80c8a52c7d531dff18b Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 15:32:29 -0400 Subject: [PATCH 29/38] Rename primary ruler window (#257) --- Free Ruler/AppDelegate.swift | 14 +- Free Ruler/GroupedRulerWindow.swift | 1986 --------------------------- Free Ruler/LegacyRulerWindow.swift | 160 +++ Free Ruler/RulerController.swift | 8 +- Free Ruler/RulerWindow.swift | 1926 +++++++++++++++++++++++++- FreeRulerTests/RulerCoreTests.swift | 18 +- 6 files changed, 2056 insertions(+), 2056 deletions(-) delete mode 100644 Free Ruler/GroupedRulerWindow.swift create mode 100644 Free Ruler/LegacyRulerWindow.swift diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 560f2b4..50fe4ca 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -395,7 +395,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { ] // let rulers know about each other - // TODO: provide each ruler with otherRulers: [RulerWindow] + // TODO: provide each ruler with otherRulers: [LegacyRulerWindow] rulers[0].otherWindow = rulers[1].rulerWindow rulers[1].otherWindow = rulers[0].rulerWindow @@ -443,10 +443,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard hasLegacyAutosave else { return nil } let settings = RulerSettings(defaults: prefs) - let horizontalWindow = RulerWindow( + let horizontalWindow = LegacyRulerWindow( ruler: Ruler(.horizontal, name: horizontalAutosaveName) ) - let verticalWindow = RulerWindow( + let verticalWindow = LegacyRulerWindow( ruler: Ruler(.vertical, name: verticalAutosaveName) ) _ = horizontalWindow.setFrameUsingName(NSWindow.FrameAutosaveName(horizontalAutosaveName)) @@ -510,7 +510,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - private func detachRulerWindow(_ window: RulerWindow) { + private func detachRulerWindow(_ window: LegacyRulerWindow) { for ruler in rulers { guard ruler.rulerWindow != window else { continue } @@ -966,15 +966,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { prefs.unit = unit } - func isRulerWindowShown(_ window: RulerWindow) -> Bool { + func isRulerWindowShown(_ window: LegacyRulerWindow) -> Bool { return window.isVisible || window.parent != nil || rulers.contains { $0.rulerWindow.childWindows?.contains(window) == true } } private func zeroPointOffset( - from sourceWindow: RulerWindow?, - to targetWindow: RulerWindow?, + from sourceWindow: LegacyRulerWindow?, + to targetWindow: LegacyRulerWindow?, geometry: ZeroCornerGeometry ) -> NSSize? { guard let sourceWindow = sourceWindow, diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift deleted file mode 100644 index 21f81a1..0000000 --- a/Free Ruler/GroupedRulerWindow.swift +++ /dev/null @@ -1,1986 +0,0 @@ -import Cocoa -import Carbon.HIToolbox - -struct GroupedRulerLayout: Equatable { - let groupFrame: NSRect - let horizontalFrame: NSRect - let verticalFrame: NSRect - - static func joined( - horizontalFrame: NSRect, - verticalFrame: NSRect, - zeroCorner: ZeroCorner - ) -> GroupedRulerLayout { - let zeroPoint = ZeroCornerGeometry(zeroCorner: zeroCorner) - .zeroPoint(in: horizontalFrame, for: .horizontal) - - return layout( - horizontalLength: horizontalFrame.width, - verticalLength: verticalFrame.height, - zeroPoint: zeroPoint, - zeroCorner: zeroCorner - ) - } - - static func layout( - groupFrame: NSRect, - zeroCorner: ZeroCorner - ) -> GroupedRulerLayout { - let zeroPoint = zeroPoint(in: groupFrame, zeroCorner: zeroCorner) - let horizontalLength = length( - in: groupFrame, - from: zeroPoint, - along: .horizontal, - zeroCorner: zeroCorner - ) - let verticalLength = length( - in: groupFrame, - from: zeroPoint, - along: .vertical, - zeroCorner: zeroCorner - ) - - return layout( - horizontalLength: horizontalLength, - verticalLength: verticalLength, - zeroPoint: zeroPoint, - zeroCorner: zeroCorner - ) - } - - static func layout( - horizontalLength: CGFloat, - verticalLength: CGFloat, - zeroPoint: NSPoint, - zeroCorner: ZeroCorner - ) -> GroupedRulerLayout { - let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) - let horizontalFrame = geometry.frame( - for: .horizontal, - zeroPoint: zeroPoint, - size: NSSize(width: horizontalLength, height: Ruler.thickness) - ) - let verticalFrame = geometry.frame( - for: .vertical, - zeroPoint: zeroPoint, - size: NSSize(width: Ruler.thickness, height: verticalLength) - ) - - return GroupedRulerLayout( - groupFrame: horizontalFrame.union(verticalFrame), - horizontalFrame: horizontalFrame, - verticalFrame: verticalFrame - ) - } - - static func minSize(zeroCorner: ZeroCorner) -> NSSize { - return size( - horizontalLength: getMinSize(ruler: Ruler(.horizontal)).width, - verticalLength: getMinSize(ruler: Ruler(.vertical)).height, - zeroCorner: zeroCorner, - showsHorizontalRule: true, - showsVerticalRule: true - ) - } - - static func minSize( - zeroCorner: ZeroCorner, - showsHorizontalRule: Bool, - showsVerticalRule: Bool - ) -> NSSize { - return size( - horizontalLength: getMinSize(ruler: Ruler(.horizontal)).width, - verticalLength: getMinSize(ruler: Ruler(.vertical)).height, - zeroCorner: zeroCorner, - showsHorizontalRule: showsHorizontalRule, - showsVerticalRule: showsVerticalRule - ) - } - - static func maxSize(zeroCorner: ZeroCorner) -> NSSize { - return size( - horizontalLength: getMaxSize(ruler: Ruler(.horizontal)).width, - verticalLength: getMaxSize(ruler: Ruler(.vertical)).height, - zeroCorner: zeroCorner, - showsHorizontalRule: true, - showsVerticalRule: true - ) - } - - static func maxSize( - zeroCorner: ZeroCorner, - showsHorizontalRule: Bool, - showsVerticalRule: Bool - ) -> NSSize { - return size( - horizontalLength: getMaxSize(ruler: Ruler(.horizontal)).width, - verticalLength: getMaxSize(ruler: Ruler(.vertical)).height, - zeroCorner: zeroCorner, - showsHorizontalRule: showsHorizontalRule, - showsVerticalRule: showsVerticalRule - ) - } - - func localFrame(for orientation: Orientation) -> NSRect { - let frame: NSRect - switch orientation { - case .horizontal: - frame = horizontalFrame - case .vertical: - frame = verticalFrame - } - - return NSRect( - x: frame.minX - groupFrame.minX, - y: frame.minY - groupFrame.minY, - width: frame.width, - height: frame.height - ) - } - - func visibleFrame( - showsHorizontalRule: Bool, - showsVerticalRule: Bool - ) -> NSRect { - switch (showsHorizontalRule, showsVerticalRule) { - case (true, true): - return groupFrame - case (true, false): - return horizontalFrame - case (false, true): - return verticalFrame - case (false, false): - return .zero - } - } - - private static func zeroPoint( - in groupFrame: NSRect, - zeroCorner: ZeroCorner - ) -> NSPoint { - let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) - let x: CGFloat - let y: CGFloat - - switch geometry.horizontalZeroSide { - case .left: - x = groupFrame.minX + Ruler.thickness - ZeroCornerGeometry.borderCompensation - case .right: - x = groupFrame.maxX - Ruler.thickness - } - - switch geometry.verticalZeroSide { - case .top: - y = groupFrame.maxY - Ruler.thickness + ZeroCornerGeometry.borderCompensation - case .bottom: - y = groupFrame.minY + Ruler.thickness - ZeroCornerGeometry.borderCompensation - } - - return NSPoint(x: x, y: y) - } - - private static func length( - in groupFrame: NSRect, - from zeroPoint: NSPoint, - along orientation: Orientation, - zeroCorner: ZeroCorner - ) -> CGFloat { - let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) - - switch orientation { - case .horizontal: - switch geometry.horizontalZeroSide { - case .left: - return max(0, groupFrame.maxX - zeroPoint.x) - case .right: - return max(0, zeroPoint.x - groupFrame.minX) - } - case .vertical: - switch geometry.verticalZeroSide { - case .top: - return max(0, zeroPoint.y - groupFrame.minY) - case .bottom: - return max(0, groupFrame.maxY - zeroPoint.y) - } - } - } - - private static func size( - horizontalLength: CGFloat, - verticalLength: CGFloat, - zeroCorner: ZeroCorner, - showsHorizontalRule: Bool, - showsVerticalRule: Bool - ) -> NSSize { - let layout = layout( - horizontalLength: horizontalLength, - verticalLength: verticalLength, - zeroPoint: .zero, - zeroCorner: zeroCorner - ) - - return layout.visibleFrame( - showsHorizontalRule: showsHorizontalRule, - showsVerticalRule: showsVerticalRule - ).size - } -} - -private extension GroupedRulerLayout { - func emptyCornerFrame(zeroCorner: ZeroCorner) -> NSRect { - let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) - let x: CGFloat - let width: CGFloat - let y: CGFloat - let height: CGFloat - - switch geometry.horizontalZeroSide { - case .left: - x = groupFrame.minX - width = horizontalFrame.minX - groupFrame.minX - case .right: - x = horizontalFrame.maxX - width = groupFrame.maxX - horizontalFrame.maxX - } - - switch geometry.verticalZeroSide { - case .top: - y = verticalFrame.maxY - height = groupFrame.maxY - verticalFrame.maxY - case .bottom: - y = groupFrame.minY - height = verticalFrame.minY - groupFrame.minY - } - - return NSRect( - x: x, - y: y, - width: width, - height: height - ) - } -} - -#if !SNAPSHOT_GENERATOR -final class GroupedRulerWindow: NSPanel { - let horizontalRule: HorizontalRule - let verticalRule: VerticalRule - - private let groupedContentView: GroupedRulerContentView - private(set) var settings: RulerSettings - - init(frame: NSRect, settings: RulerSettings = RulerSettings(defaults: prefs)) { - self.settings = settings - horizontalRule = GroupedHorizontalRule( - frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness) - ) - verticalRule = GroupedVerticalRule( - frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300) - ) - groupedContentView = GroupedRulerContentView( - frame: NSRect(origin: .zero, size: frame.size), - horizontalRule: horizontalRule, - verticalRule: verticalRule - ) - - let styleMask: NSWindow.StyleMask = [ - .borderless, - .resizable, - .fullSizeContentView, - ] - - super.init( - contentRect: frame, - styleMask: styleMask, - backing: .buffered, - defer: false - ) - - alphaValue = windowAlphaValue(settings.foregroundOpacity) - title = NSLocalizedString( - "Ruler", - comment: "Window title for a ruler window" - ) - identifier = NSUserInterfaceItemIdentifier("grouped-ruler-window") - setAccessibilityIdentifier("grouped-ruler-window") - minSize = GroupedRulerLayout.minSize(zeroCorner: settings.zeroCorner) - maxSize = GroupedRulerLayout.maxSize(zeroCorner: settings.zeroCorner) - - isOpaque = false - backgroundColor = .clear - isFloatingPanel = settings.floatRulers - hidesOnDeactivate = false - isMovableByWindowBackground = true - hasShadow = settings.rulerShadow - - horizontalRule.setAccessibilityElement(true) - verticalRule.setAccessibilityElement(true) - horizontalRule.setAccessibilityIdentifier("horizontal-ruler-view") - verticalRule.setAccessibilityIdentifier("vertical-ruler-view") - horizontalRule.nextResponder = self - verticalRule.nextResponder = self - groupedContentView.nextResponder = self - - contentView = groupedContentView - apply(settings: settings) - updateLayoutForCurrentZeroCorner() - } - - override var canBecomeKey: Bool { - return true - } - - override var acceptsMouseMovedEvents: Bool { - get { return true } - set {} - } - - override func setFrame(_ frameRect: NSRect, display flag: Bool) { - super.setFrame(frameRect, display: flag) - updateGroupedContentFrame() - } - - override func setContentSize(_ size: NSSize) { - super.setContentSize(size) - updateGroupedContentFrame() - } - - override func mouseDown(with event: NSEvent) { - nextResponder?.mouseDown(with: event) - super.mouseDown(with: event) - - if !leftMouseButtonIsPressed { - (nextResponder as? GroupedRulerController)?.finishMouseDrag(with: event) - } - } - - override func mouseUp(with event: NSEvent) { - nextResponder?.mouseUp(with: event) - super.mouseUp(with: event) - } - - override func mouseEntered(with event: NSEvent) { - nextResponder?.mouseEntered(with: event) - } - - override func mouseExited(with event: NSEvent) { - nextResponder?.mouseExited(with: event) - } - - override func mouseMoved(with event: NSEvent) { - nextResponder?.mouseMoved(with: event) - } - - func updateLayoutForCurrentZeroCorner() { - updateSizeConstraintsForVisibleRules() - updateGroupedContentFrame() - groupedContentView.zeroCorner = settings.zeroCorner - groupedContentView.needsLayout = true - groupedContentView.layoutSubtreeIfNeeded() - groupedContentView.needsDisplay = true - } - - func apply(settings: RulerSettings) { - self.settings = settings - alphaValue = windowAlphaValue(settings.foregroundOpacity) - isFloatingPanel = settings.floatRulers - hasShadow = settings.rulerShadow - horizontalRule.settingsOverride = settings - verticalRule.settingsOverride = settings - groupedContentView.color = RulerColors(customFill: settings.rulerColor) - updateLayoutForCurrentZeroCorner() - } - - func redrawForPreferenceChange() { - updateLayoutForCurrentZeroCorner() - horizontalRule.redrawForPreferenceChange() - verticalRule.redrawForPreferenceChange() - } - - func screenFrame(for orientation: Orientation) -> NSRect { - return convertToScreen(groupedContentView.localFrame(for: orientation)) - } - - func visibleFrame(in layout: GroupedRulerLayout) -> NSRect { - return layout.visibleFrame( - showsHorizontalRule: groupedContentView.showsHorizontalRule, - showsVerticalRule: groupedContentView.showsVerticalRule - ) - } - - func setVisibleRules(horizontal: Bool, vertical: Bool) { - groupedContentView.showsHorizontalRule = horizontal - groupedContentView.showsVerticalRule = vertical - updateSizeConstraintsForVisibleRules() - groupedContentView.needsLayout = true - groupedContentView.layoutSubtreeIfNeeded() - } - - func isRuleVisible(_ orientation: Orientation) -> Bool { - switch orientation { - case .horizontal: - return groupedContentView.showsHorizontalRule - case .vertical: - return groupedContentView.showsVerticalRule - } - } - - func isEmptyCorner(atWindowPoint windowPoint: NSPoint) -> Bool { - let contentPoint = groupedContentView.convert(windowPoint, from: nil) - return groupedContentView.containsEmptyCorner(contentPoint) - } - - func zeroPoint() -> NSPoint { - let geometry = ZeroCornerGeometry(zeroCorner: settings.zeroCorner) - - if isRuleVisible(.horizontal) { - return geometry.zeroPoint( - in: screenFrame(for: .horizontal), - for: .horizontal - ) - } - - if isRuleVisible(.vertical) { - return geometry.zeroPoint( - in: screenFrame(for: .vertical), - for: .vertical - ) - } - - return frame.origin - } - - private var leftMouseButtonIsPressed: Bool { - return NSEvent.pressedMouseButtons & 1 == 1 - } - - private func updateGroupedContentFrame() { - guard contentView === groupedContentView else { return } - - groupedContentView.frame = NSRect(origin: .zero, size: frame.size) - groupedContentView.needsLayout = true - groupedContentView.layoutSubtreeIfNeeded() - } - - private func updateSizeConstraintsForVisibleRules() { - minSize = GroupedRulerLayout.minSize( - zeroCorner: settings.zeroCorner, - showsHorizontalRule: groupedContentView.showsHorizontalRule, - showsVerticalRule: groupedContentView.showsVerticalRule - ) - maxSize = GroupedRulerLayout.maxSize( - zeroCorner: settings.zeroCorner, - showsHorizontalRule: groupedContentView.showsHorizontalRule, - showsVerticalRule: groupedContentView.showsVerticalRule - ) - } -} - -extension GroupedRulerWindow: RulerContextMenuActivating { - func activateForRulerContextMenu() { - makeKey() - (nextResponder as? GroupedRulerController)?.activateForRulerContextMenu() - } -} - -private final class GroupedHorizontalRule: HorizontalRule { - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(NSSize(width: newSize.width, height: Ruler.thickness)) - } -} - -private final class GroupedVerticalRule: VerticalRule { - override var rulerWidth: CGFloat { - return Ruler.thickness - } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(NSSize(width: Ruler.thickness, height: newSize.height)) - } -} - -extension GroupedRulerWindow { - private enum Distance: CGFloat { - case aLittle = 1 - case aLot = 10 - } - - func moveHorizontally(by pixels: CGFloat) { - var position = frame.origin - position.x = position.x + pixels - setFrameOrigin(position) - } - - func moveVertically(by pixels: CGFloat) { - var position = frame.origin - position.y = position.y + pixels - setFrameOrigin(position) - } - - private func distance(withShift: Bool) -> CGFloat { - let dist = withShift ? Distance.aLot : Distance.aLittle - return dist.rawValue - } - - func nudgeLeft(withShift shiftPressed: Bool) { - let dist = distance(withShift: shiftPressed) - moveHorizontally(by: dist * -1) - } - - func nudgeRight(withShift shiftPressed: Bool) { - let dist = distance(withShift: shiftPressed) - moveHorizontally(by: dist) - } - - func nudgeDown(withShift shiftPressed: Bool) { - let dist = distance(withShift: shiftPressed) - moveVertically(by: dist * -1) - } - - func nudgeUp(withShift shiftPressed: Bool) { - let dist = distance(withShift: shiftPressed) - moveVertically(by: dist) - } -} -#endif - -private final class RulerClipView: NSView { - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - layer?.masksToBounds = true - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented. Use init(frame:)") - } - - override var isOpaque: Bool { - return false - } - - override var mouseDownCanMoveWindow: Bool { - return true - } -} - -private final class GroupedRulerBorderView: RulerBorderView { - var zeroCorner = prefs.zeroCorner { - didSet { - needsDisplay = true - } - } - - var showsHorizontalRule = true { - didSet { - needsDisplay = true - } - } - - var showsVerticalRule = true { - didSet { - needsDisplay = true - } - } - - override func borderPath(in bounds: NSRect) -> NSBezierPath { - switch (showsHorizontalRule, showsVerticalRule) { - case (true, true): - return lShapedBorderPath() - case (true, false): - return visibleBoundsBorderPath() - case (false, true): - return visibleBoundsBorderPath() - case (false, false): - return NSBezierPath() - } - } - - private func lShapedBorderPath() -> NSBezierPath { - return groupedRulerLShapedPath( - in: bounds, - zeroCorner: zeroCorner, - inset: Self.borderCenterInset - ) - } - - private func visibleBoundsBorderPath() -> NSBezierPath { - return NSBezierPath(rect: bounds.insetBy( - dx: Self.borderCenterInset, - dy: Self.borderCenterInset - )) - } -} - -private final class GroupedRulerZeroLabelsView: NSView { - private let horizontalRule: HorizontalRule - private let verticalRule: VerticalRule - private let zeroLabel = "0" - private let zeroLabelSize = NSSize(width: 50, height: 20) - - var color = RulerColors() { - didSet { - needsDisplay = true - } - } - - var zeroCorner = prefs.zeroCorner { - didSet { - needsDisplay = true - } - } - - var horizontalRuleFrame: NSRect = .zero { - didSet { - needsDisplay = true - } - } - - var verticalRuleFrame: NSRect = .zero { - didSet { - needsDisplay = true - } - } - - var showsHorizontalRule = true { - didSet { - needsDisplay = true - } - } - - var showsVerticalRule = true { - didSet { - needsDisplay = true - } - } - - init(horizontalRule: HorizontalRule, verticalRule: VerticalRule) { - self.horizontalRule = horizontalRule - self.verticalRule = verticalRule - super.init(frame: .zero) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented. Use init(horizontalRule:verticalRule:)") - } - - override var isOpaque: Bool { - return false - } - - override func hitTest(_ point: NSPoint) -> NSView? { - return nil - } - - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - - guard showsHorizontalRule && showsVerticalRule else { return } - - if showsHorizontalRule { - drawHorizontalZeroLabel() - } - if showsVerticalRule { - drawVerticalZeroLabel() - } - } - - private func drawHorizontalZeroLabel() { - let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) - let growthDirection = geometry.growthDirection(for: .horizontal) - let zeroTickX: CGFloat - - switch growthDirection { - case .positive: - zeroTickX = horizontalRule.bounds.minX - case .negative: - zeroTickX = horizontalRule.bounds.maxX - } - - let lineX = horizontalRule.mouseTickLineX( - forTickX: zeroTickX, - growthDirection: growthDirection - ) - let labelRect = horizontalRule.tickLabelRect( - forX: lineX, - labelSize: zeroLabelSize, - rulerHeight: horizontalRule.bounds.height, - tickSide: geometry.horizontalTickSide - ).offsetBy(dx: horizontalRuleFrame.minX, dy: horizontalRuleFrame.minY) - let attributes = labelAttributes(alignment: .center) - - zeroLabel.draw( - with: labelRect, - attributes: attributes, - context: nil - ) - } - - private func drawVerticalZeroLabel() { - let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) - let growthDirection = geometry.growthDirection(for: .vertical) - let zeroTickY: CGFloat - - switch growthDirection { - case .positive: - zeroTickY = verticalRule.bounds.minY - case .negative: - zeroTickY = verticalRule.bounds.maxY - } - - let lineY = verticalRule.mouseTickLineY( - forTickY: zeroTickY, - growthDirection: growthDirection - ) - let labelRect = verticalRule.tickLabelRect( - forY: lineY, - labelSize: zeroLabelSize, - rulerWidth: verticalRule.rulerWidth, - tickSide: geometry.verticalTickSide - ).offsetBy(dx: verticalRuleFrame.minX, dy: verticalRuleFrame.minY) - let attributes = labelAttributes( - alignment: geometry.verticalTickSide == .right ? .right : .left - ) - - zeroLabel.draw( - with: labelRect, - attributes: attributes, - context: nil - ) - } - - private func labelAttributes(alignment: NSTextAlignment) -> [NSAttributedString.Key: Any] { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.alignment = alignment - let font = NSFont(name: "HelveticaNeue", size: 10) ?? .systemFont(ofSize: 10) - - return [ - .font: font, - .paragraphStyle: paragraphStyle, - .foregroundColor: color.numbers, - ] - } -} - -final class GroupedRulerContentView: NSView { - let horizontalRule: HorizontalRule - let verticalRule: VerticalRule - private let horizontalHost = RulerClipView(frame: .zero) - private let verticalHost = RulerClipView(frame: .zero) - private let unitLabelView = UnitLabelView( - orientation: .horizontal, - label: NSAttributedString(string: "") - ) - private let zeroLabelsView: GroupedRulerZeroLabelsView - private let borderView = GroupedRulerBorderView(frame: .zero) - private var cornerTrackingArea: NSTrackingArea? - - var showsHorizontalRule = true { - didSet { - guard showsHorizontalRule != oldValue else { return } - updateRuleVisibility() - } - } - - var showsVerticalRule = true { - didSet { - guard showsVerticalRule != oldValue else { return } - updateRuleVisibility() - } - } - - var color = RulerColors() { - didSet { - zeroLabelsView.color = color - updateUnitLabel() - needsDisplay = true - } - } - - var zeroCorner = prefs.zeroCorner { - didSet { - zeroLabelsView.zeroCorner = zeroCorner - horizontalRule.needsDisplay = true - verticalRule.needsDisplay = true - needsLayout = true - needsDisplay = true - } - } - - init( - frame frameRect: NSRect, - horizontalRule: HorizontalRule, - verticalRule: VerticalRule - ) { - self.horizontalRule = horizontalRule - self.verticalRule = verticalRule - self.zeroLabelsView = GroupedRulerZeroLabelsView( - horizontalRule: horizontalRule, - verticalRule: verticalRule - ) - super.init(frame: frameRect) - - autoresizesSubviews = false - horizontalHost.autoresizingMask = [] - verticalHost.autoresizingMask = [] - horizontalRule.autoresizingMask = [] - verticalRule.autoresizingMask = [] - unitLabelView.autoresizingMask = [] - zeroLabelsView.autoresizingMask = [] - borderView.autoresizingMask = [] - horizontalRule.drawsBackground = false - verticalRule.drawsBackground = false - horizontalRule.showsUnitLabel = false - verticalRule.showsUnitLabel = false - horizontalRule.showsZeroTick = true - verticalRule.showsZeroTick = true - horizontalHost.addSubview(horizontalRule) - verticalHost.addSubview(verticalRule) - addSubview(horizontalHost) - addSubview(verticalHost) - addSubview(unitLabelView) - addSubview(zeroLabelsView) - addSubview(borderView) - zeroLabelsView.color = color - zeroLabelsView.zeroCorner = zeroCorner - updateRuleVisibility() - updateUnitLabel() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented. Use init(frame:horizontalRule:verticalRule:)") - } - - override var isOpaque: Bool { - return false - } - - override var mouseDownCanMoveWindow: Bool { - return true - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - rebuildCornerTrackingArea() - } - - override func resetCursorRects() { - super.resetCursorRects() - - if showsHorizontalRule && showsVerticalRule { - addCursorRect(cornerFrame(), cursor: .openHand) - } - } - - override func layout() { - super.layout() - - let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) - let cornerFrame = layout.emptyCornerFrame(zeroCorner: zeroCorner) - setFrame(ruleFrame(for: .horizontal, in: bounds, layout: layout), for: horizontalHost) - setFrame(horizontalHost.bounds, for: horizontalRule) - setFrame(ruleFrame(for: .vertical, in: bounds, layout: layout), for: verticalHost) - setFrame(verticalHost.bounds, for: verticalRule) - updateUnitLabel() - setFrame(unitLabelFrame(in: cornerFrame), for: unitLabelView) - setFrame(bounds, for: zeroLabelsView) - zeroLabelsView.horizontalRuleFrame = horizontalHost.frame - zeroLabelsView.verticalRuleFrame = verticalHost.frame - zeroLabelsView.showsHorizontalRule = showsHorizontalRule - zeroLabelsView.showsVerticalRule = showsVerticalRule - setFrame(bounds, for: borderView) - borderView.zeroCorner = zeroCorner - borderView.showsHorizontalRule = showsHorizontalRule - borderView.showsVerticalRule = showsVerticalRule - horizontalRule.needsDisplay = true - verticalRule.needsDisplay = true - window?.invalidateCursorRects(for: self) - rebuildCornerTrackingArea() - } - - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - - color.fill.setFill() - rulerFillPath().fill() - } - - override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point) else { return nil } - - if let hitView = super.hitTest(point), - hitView !== self, - hitView !== unitLabelView { - return hitView - } - - return containsEmptyCorner(point) ? self : nil - } - - override func mouseEntered(with event: NSEvent) { - nextResponder?.mouseEntered(with: event) - } - - override func mouseExited(with event: NSEvent) { - nextResponder?.mouseExited(with: event) - } - - override func mouseDown(with event: NSEvent) { - nextResponder?.mouseDown(with: event) - } - - override func mouseUp(with event: NSEvent) { - nextResponder?.mouseUp(with: event) - } - - override func mouseMoved(with event: NSEvent) { - nextResponder?.mouseMoved(with: event) - } - - override func menu(for event: NSEvent) -> NSMenu? { - return rulerContextMenu(for: self) - } - - func containsEmptyCorner(_ point: NSPoint) -> Bool { - return showsHorizontalRule && showsVerticalRule && cornerFrame().contains(point) - } - - func localFrame(for orientation: Orientation) -> NSRect { - let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) - return ruleFrame(for: orientation, in: bounds, layout: layout) - } - - private func cornerFrame() -> NSRect { - return GroupedRulerLayout - .layout(groupFrame: bounds, zeroCorner: zeroCorner) - .emptyCornerFrame(zeroCorner: zeroCorner) - } - - private func ruleFrame( - for orientation: Orientation, - in bounds: NSRect, - layout: GroupedRulerLayout - ) -> NSRect { - switch (showsHorizontalRule, showsVerticalRule) { - case (true, true): - return layout.localFrame(for: orientation) - case (true, false): - return orientation == .horizontal ? bounds : .zero - case (false, true): - return orientation == .vertical ? bounds : .zero - case (false, false): - return .zero - } - } - - private func rebuildCornerTrackingArea() { - if let cornerTrackingArea = cornerTrackingArea { - removeTrackingArea(cornerTrackingArea) - self.cornerTrackingArea = nil - } - - let frame = cornerFrame() - guard showsHorizontalRule && showsVerticalRule, - frame.width > 0, - frame.height > 0 else { return } - - let trackingArea = NSTrackingArea( - rect: frame, - options: [ - .activeAlways, - .mouseEnteredAndExited, - .mouseMoved, - ], - owner: self, - userInfo: nil - ) - addTrackingArea(trackingArea) - cornerTrackingArea = trackingArea - } - - private func setFrame(_ frame: NSRect, for view: NSView) { - view.setFrameOrigin(frame.origin) - view.setFrameSize(frame.size) - } - - private func updateRuleVisibility() { - rebuildSubviews() - updateRuleViewLabels() - needsLayout = true - needsDisplay = true - } - - private func rebuildSubviews() { - for view in [horizontalHost, verticalHost, unitLabelView, zeroLabelsView, borderView] { - view.removeFromSuperview() - } - - if showsHorizontalRule { - addSubview(horizontalHost) - } - if showsVerticalRule { - addSubview(verticalHost) - } - - addSubview(unitLabelView) - addSubview(zeroLabelsView) - addSubview(borderView) - } - - private func updateRuleViewLabels() { - let showsBothRules = showsHorizontalRule && showsVerticalRule - unitLabelView.isHidden = !showsBothRules - zeroLabelsView.isHidden = !showsBothRules - horizontalRule.showsUnitLabel = showsHorizontalRule && !showsVerticalRule - verticalRule.showsUnitLabel = showsVerticalRule && !showsHorizontalRule - horizontalRule.showsZeroTick = showsHorizontalRule - verticalRule.showsZeroTick = showsVerticalRule - } - - private func rulerFillPath() -> NSBezierPath { - let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) - - switch (showsHorizontalRule, showsVerticalRule) { - case (true, true): - return groupedRulerLShapedPath(in: bounds, zeroCorner: zeroCorner, inset: 0) - case (true, false): - return NSBezierPath(rect: ruleFrame(for: .horizontal, in: bounds, layout: layout)) - case (false, true): - return NSBezierPath(rect: ruleFrame(for: .vertical, in: bounds, layout: layout)) - case (false, false): - return NSBezierPath() - } - } - - private func unitLabelFrame(in cornerFrame: NSRect) -> NSRect { - let labelFrame = unitLabelView.frame(in: NSRect(origin: .zero, size: cornerFrame.size)) - return NSRect( - x: cornerFrame.minX + labelFrame.minX, - y: cornerFrame.minY + labelFrame.minY, - width: labelFrame.width, - height: labelFrame.height - ) - } - - private func updateUnitLabel() { - unitLabelView.zeroCorner = zeroCorner - unitLabelView.label = NSAttributedString( - string: unitLabelString(), - attributes: unitLabelAttributes() - ) - } - - private func unitLabelString() -> String { - return horizontalRule.getUnitLabel() - } - - private func unitLabelAttributes() -> [NSAttributedString.Key: Any] { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.alignment = .left - let font = NSFont(name: "HelveticaNeue", size: 10) ?? .systemFont(ofSize: 10) - - return [ - .font: font, - .paragraphStyle: paragraphStyle, - .foregroundColor: color.ticks, - ] - } -} - -#if !SNAPSHOT_GENERATOR -final class GroupedRulerController: NSWindowController, NSWindowDelegate, NotificationObserver { - var observers: [NSKeyValueObservation] = [] - var notificationObservers: [NSObjectProtocol] = [] - - let groupedWindow: GroupedRulerWindow - var state: RulerInstanceState - var onBecameActive: ((GroupedRulerController) -> Void)? - var onDragStarted: ((GroupedRulerController) -> Void)? - var onDragged: ((GroupedRulerController) -> Void)? - var onDragFinished: ((GroupedRulerController) -> Void)? - var onStateChanged: ((GroupedRulerController) -> Void)? - - private var keyListener: Any? - private var mouseInteraction: RulerMouseInteractionState! - private var isMouseTickDrawingEnabled = true - private let followsDefaultPreferences: Bool - - var isLeftMouseButtonPressed = { - return NSEvent.pressedMouseButtons & 1 == 1 - } - - var preferencesWindowOpen = false { - didSet { - updateIsFloatingPanel() - if !preferencesWindowOpen { - opacity = state.settings.foregroundOpacity - } - } - } - - var opacity = 0 { - didSet { - groupedWindow.alphaValue = windowAlphaValue(opacity) - } - } - - convenience init(frame: NSRect) { - let layout = GroupedRulerLayout.layout(groupFrame: frame, zeroCorner: prefs.zeroCorner) - let state = RulerInstanceState( - settings: RulerSettings(defaults: prefs), - layout: RulerLayoutState( - horizontalFrame: layout.horizontalFrame, - verticalFrame: layout.verticalFrame, - zeroCorner: prefs.zeroCorner - ) - ) - - self.init(state: state, followsDefaultPreferences: true) - } - - convenience init(state: RulerInstanceState) { - self.init(state: state, followsDefaultPreferences: false) - } - - private init(state: RulerInstanceState, followsDefaultPreferences: Bool) { - self.state = state - self.followsDefaultPreferences = followsDefaultPreferences - let layout = state.layout.layout(zeroCorner: state.settings.zeroCorner) - groupedWindow = GroupedRulerWindow( - frame: layout.visibleFrame( - showsHorizontalRule: state.visibility.showsHorizontal, - showsVerticalRule: state.visibility.showsVertical - ), - settings: state.settings - ) - super.init(window: groupedWindow) - - opacity = state.settings.foregroundOpacity - createObservers() - subscribeToPrefs() - - groupedWindow.delegate = self - groupedWindow.nextResponder = self - mouseInteraction = RulerMouseInteractionState(owner: self) { [weak self] event in - return self?.mouseIsInsideRuler(with: event) ?? false - } - applyStateToWindow(display: false) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented. Use init(frame:)") - } - - deinit { - mouseInteraction?.invalidate() - removeObservers(¬ificationObservers) - stopKeyListener() - } - - var isVisible: Bool { - return groupedWindow.isVisible - } - - func show() { - applyStateToWindow(display: false) - showWindow(self) - groupedWindow.orderFrontRegardless() - } - - func show( - horizontalFrame: NSRect, - verticalFrame: NSRect, - showsHorizontalRule: Bool, - showsVerticalRule: Bool - ) { - state.layout = RulerLayoutState( - horizontalFrame: horizontalFrame, - verticalFrame: verticalFrame, - zeroCorner: prefs.zeroCorner - ) - state.settings.zeroCorner = prefs.zeroCorner - state.visibility = RulerWingVisibility( - horizontal: showsHorizontalRule, - vertical: showsVerticalRule - ) - show() - } - - func hide() { - groupedWindow.orderOut(self) - } - - @discardableResult - func toggleWing(_ orientation: Orientation) -> Bool { - guard state.toggleWing(orientation) else { return false } - - applyStateToWindow(display: true) - notifyStateChanged() - return true - } - - @discardableResult - func setWing(_ orientation: Orientation, isVisible: Bool) -> Bool { - guard state.setWing(orientation, isVisible: isVisible) else { return false } - - applyStateToWindow(display: true) - notifyStateChanged() - return true - } - - func syncFrames( - to horizontalWindow: RulerWindow, - and verticalWindow: RulerWindow, - persistAutosave: Bool = false - ) { - guard isVisible else { return } - - let frames = syncedRulerFrames( - horizontalWindow: horizontalWindow, - verticalWindow: verticalWindow - ) - - syncFrame(frames.horizontal, to: horizontalWindow, persistAutosave: persistAutosave) - syncFrame(frames.vertical, to: verticalWindow, persistAutosave: persistAutosave) - } - - func align(at point: NSPoint) { - let horizontalLength = groupedWindow.screenFrame(for: .horizontal).width - let verticalLength = groupedWindow.screenFrame(for: .vertical).height - let layout = GroupedRulerLayout.layout( - horizontalLength: horizontalLength, - verticalLength: verticalLength, - zeroPoint: point, - zeroCorner: state.settings.zeroCorner - ) - - groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) - groupedWindow.updateLayoutForCurrentZeroCorner() - captureStateFromWindow() - } - - func prepareForZeroCornerChange(to zeroCorner: ZeroCorner) { - let zeroPoint = groupedWindow.zeroPoint() - let horizontalLength = groupedWindow.screenFrame(for: .horizontal).width - let verticalLength = groupedWindow.screenFrame(for: .vertical).height - let layout = GroupedRulerLayout.layout( - horizontalLength: horizontalLength, - verticalLength: verticalLength, - zeroPoint: zeroPoint, - zeroCorner: zeroCorner - ) - - state.settings.zeroCorner = zeroCorner - groupedWindow.apply(settings: state.settings) - groupedWindow.alphaValue = windowAlphaValue(opacity) - updateIsFloatingPanel() - updateHasShadow() - groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) - captureStateFromWindow() - notifyStateChanged() - } - - func foreground() { - opacity = state.settings.foregroundOpacity - } - - func background() { - opacity = state.settings.backgroundOpacity - } - - func updateIsFloatingPanel() { - groupedWindow.isFloatingPanel = preferencesWindowOpen ? false : state.settings.floatRulers - } - - func updateHasShadow() { - groupedWindow.hasShadow = state.settings.rulerShadow - } - - func redrawForPreferenceChange() { - groupedWindow.redrawForPreferenceChange() - } - - func updateSettings(_ update: (inout RulerSettings) -> Void) { - update(&state.settings) - applyStateToWindow(display: true) - notifyStateChanged() - } - - func updateDimensions(horizontalLength: CGFloat, verticalLength: CGFloat) { - let minHorizontalLength = getMinSize(ruler: Ruler(.horizontal)).width - let maxHorizontalLength = getMaxSize(ruler: Ruler(.horizontal)).width - let minVerticalLength = getMinSize(ruler: Ruler(.vertical)).height - let maxVerticalLength = getMaxSize(ruler: Ruler(.vertical)).height - - state.layout = RulerLayoutState( - zeroPoint: groupedWindow.zeroPoint(), - horizontalLength: min(max(horizontalLength, minHorizontalLength), maxHorizontalLength), - verticalLength: min(max(verticalLength, minVerticalLength), maxVerticalLength) - ) - applyStateToWindow(display: true) - notifyStateChanged() - } - - func move(to frame: NSRect) { - groupedWindow.setFrame(frame, display: false) - captureStateFromWindow() - } - - 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) - } - if groupedWindow.isRuleVisible(.vertical) { - groupedWindow.verticalRule.drawMouseTick(at: mouseLoc) - } - } - - func setMouseTickDrawingEnabled(_ isEnabled: Bool) { - isMouseTickDrawingEnabled = isEnabled - updateMouseTickDrawingVisibility() - } - - private func updateMouseTickDrawingVisibility() { - groupedWindow.horizontalRule.showMouseTick = isMouseTickDrawingEnabled - && groupedWindow.isRuleVisible(.horizontal) - groupedWindow.verticalRule.showMouseTick = isMouseTickDrawingEnabled - && groupedWindow.isRuleVisible(.vertical) - } - - private func applyStateToWindow(display: Bool) { - let zeroCorner = state.settings.zeroCorner - let layout = state.layout.layout(zeroCorner: zeroCorner) - groupedWindow.apply(settings: state.settings) - groupedWindow.alphaValue = windowAlphaValue(opacity) - updateIsFloatingPanel() - updateHasShadow() - groupedWindow.setVisibleRules( - horizontal: state.visibility.showsHorizontal, - vertical: state.visibility.showsVertical - ) - updateMouseTickDrawingVisibility() - groupedWindow.setFrame( - layout.visibleFrame( - showsHorizontalRule: state.visibility.showsHorizontal, - showsVerticalRule: state.visibility.showsVertical - ), - display: display - ) - groupedWindow.updateLayoutForCurrentZeroCorner() - } - - private func captureStateFromWindow() { - var horizontalLength = state.layout.horizontalLength - var verticalLength = state.layout.verticalLength - - if groupedWindow.isRuleVisible(.horizontal) { - horizontalLength = groupedWindow.screenFrame(for: .horizontal).width - } - if groupedWindow.isRuleVisible(.vertical) { - verticalLength = groupedWindow.screenFrame(for: .vertical).height - } - - state.layout = RulerLayoutState( - zeroPoint: groupedWindow.zeroPoint(), - horizontalLength: horizontalLength, - verticalLength: verticalLength - ) - state.visibility = RulerWingVisibility( - horizontal: groupedWindow.isRuleVisible(.horizontal), - vertical: groupedWindow.isRuleVisible(.vertical) - ) - notifyStateChanged() - } - - private func notifyStateChanged() { - onStateChanged?(self) - } - - private func syncedRulerFrames( - horizontalWindow: RulerWindow, - verticalWindow: RulerWindow - ) -> (horizontal: NSRect, vertical: NSRect) { - let showsHorizontalRule = groupedWindow.isRuleVisible(.horizontal) - let showsVerticalRule = groupedWindow.isRuleVisible(.vertical) - - switch (showsHorizontalRule, showsVerticalRule) { - case (true, true): - return ( - groupedWindow.screenFrame(for: .horizontal), - groupedWindow.screenFrame(for: .vertical) - ) - case (true, false): - let horizontalFrame = groupedWindow.screenFrame(for: .horizontal) - let zeroPoint = ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner) - .zeroPoint(in: horizontalFrame, for: .horizontal) - return ( - horizontalFrame, - hiddenRuleFrame( - orientation: .vertical, - zeroPoint: zeroPoint, - size: verticalWindow.frame.size - ) - ) - case (false, true): - let verticalFrame = groupedWindow.screenFrame(for: .vertical) - let zeroPoint = ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner) - .zeroPoint(in: verticalFrame, for: .vertical) - return ( - hiddenRuleFrame( - orientation: .horizontal, - zeroPoint: zeroPoint, - size: horizontalWindow.frame.size - ), - verticalFrame - ) - case (false, false): - return (horizontalWindow.frame, verticalWindow.frame) - } - } - - private func hiddenRuleFrame( - orientation: Orientation, - zeroPoint: NSPoint, - size: NSSize - ) -> NSRect { - return ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner).frame( - for: orientation, - zeroPoint: zeroPoint, - size: size - ) - } - - private func syncFrame( - _ frame: NSRect, - to window: RulerWindow, - persistAutosave: Bool - ) { - window.setFrame(frame, display: false) - - guard persistAutosave else { return } - - if let frameAutosaveName = window.ruler.name { - window.saveFrame(usingName: NSWindow.FrameAutosaveName(frameAutosaveName)) - } - } - - func windowWillStartLiveResize(_ notification: Notification) { - mouseInteraction.windowWillStartLiveResize() - } - - func windowDidEndLiveResize(_ notification: Notification) { - syncRulerWindowFrames(persistAutosave: true) - captureStateFromWindow() - mouseInteraction.windowDidEndLiveResize() - } - - func windowWillMove(_ notification: Notification) { - mouseInteraction.windowWillMove() - } - - func windowDidMove(_ notification: Notification) { - groupedWindow.invalidateShadow() - syncRulerWindowFrames( - persistAutosave: mouseInteraction.shouldPersistFrameAutosaveOnWindowMove( - isLeftMouseButtonPressed: isLeftMouseButtonPressed() - ) - ) - captureStateFromWindow() - onDragged?(self) - mouseInteraction.windowDidMove(isLeftMouseButtonPressed: isLeftMouseButtonPressed()) - } - - func windowDidBecomeKey(_ notification: Notification) { - onBecameActive?(self) - startKeyListener() - } - - func windowDidResignKey(_ notification: Notification) { - stopKeyListener() - } - - override func mouseEntered(with event: NSEvent) { - mouseInteraction.mouseEntered(with: event) - } - - override func mouseExited(with event: NSEvent) { - mouseInteraction.mouseExited(with: event) - } - - override func mouseDown(with event: NSEvent) { - mouseInteraction.mouseDown(with: event) - onDragStarted?(self) - } - - override func mouseUp(with event: NSEvent) { - finishMouseDrag(with: event) - } - - func finishMouseDrag(with event: NSEvent) { - if mouseInteraction.finishMouseDrag(with: event) { - syncRulerWindowFrames(persistAutosave: true) - captureStateFromWindow() - onDragFinished?(self) - } - } - - func activateForRulerContextMenu() { - onBecameActive?(self) - } - - override func mouseMoved(with event: NSEvent) { - mouseInteraction.mouseMoved(with: event) - } - - private var appDelegate: AppDelegate? { - return NSApp.delegate as? AppDelegate - } - - private func syncRulerWindowFrames(persistAutosave: Bool = false) { - appDelegate?.syncGroupedRulerFramesToRulerWindows(persistAutosave: persistAutosave) - } - - private func mouseIsInsideRuler(with event: NSEvent) -> Bool { - return orientation(at: event) != nil - || groupedWindow.isEmptyCorner(atWindowPoint: event.locationInWindow) - } - - private func orientation(at event: NSEvent) -> Orientation? { - let horizontalLocation = groupedWindow.horizontalRule.convert(event.locationInWindow, from: nil) - let verticalLocation = groupedWindow.verticalRule.convert(event.locationInWindow, from: nil) - - if groupedWindow.isRuleVisible(.horizontal), - groupedWindow.horizontalRule.bounds.contains(horizontalLocation) { - return .horizontal - } - - if groupedWindow.isRuleVisible(.vertical), - groupedWindow.verticalRule.bounds.contains(verticalLocation) { - return .vertical - } - - return nil - } - - private func createObservers() { - notificationObservers = [ - addObserver(.preferencesWindowOpened) { [weak self] _ in - self?.preferencesWindowOpen = true - }, - addObserver(.preferencesWindowClosed) { [weak self] _ in - self?.preferencesWindowOpen = false - }, - ] - } - - private func subscribeToPrefs() { - guard followsDefaultPreferences else { - observers = [] - return - } - - observers = [ - prefs.observe(\Prefs.foregroundOpacity, options: .new) { [weak self] prefs, changed in - self?.opacity = prefs.foregroundOpacity - }, - prefs.observe(\Prefs.backgroundOpacity, options: .new) { [weak self] prefs, changed in - self?.opacity = prefs.backgroundOpacity - }, - prefs.observe(\Prefs.floatRulers, options: .new) { [weak self] prefs, changed in - self?.updateIsFloatingPanel() - }, - prefs.observe(\Prefs.rulerShadow, options: .new) { [weak self] prefs, changed in - self?.updateHasShadow() - }, - ] - } -} - -// MARK: KeyListener - -extension GroupedRulerController { - func startKeyListener() { - keyListener = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] in - guard let self = self else { return $0 } - return self.onKeyDown(with: $0) - } - } - - func stopKeyListener() { - if let keyListener = keyListener { - NSEvent.removeMonitor(keyListener) - self.keyListener = nil - } - } - - func onKeyDown(with event: NSEvent) -> NSEvent? { - let shift = event.modifierFlags.contains(.shift) - let keyboardModifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - - if groupedWindow.isKeyWindow, - let appDelegate = NSApp.delegate as? AppDelegate, - appDelegate.performRulerHotkey( - keyCode: Int(event.keyCode), - modifierFlags: keyboardModifiers, - sender: self - ) { - return nil - } - - switch Int(event.keyCode) { - case kVK_LeftArrow: - groupedWindow.nudgeLeft(withShift: shift) - return nil - case kVK_RightArrow: - groupedWindow.nudgeRight(withShift: shift) - return nil - case kVK_UpArrow: - groupedWindow.nudgeUp(withShift: shift) - return nil - case kVK_DownArrow: - groupedWindow.nudgeDown(withShift: shift) - return nil - default: - return event - } - } -} - -final class RulerManager { - typealias ControllerFactory = (RulerInstanceState) -> GroupedRulerController - - private struct GroupedDragState { - let draggedRulerID: UUID - let framesByRulerID: [UUID: NSRect] - } - - private let controllerFactory: ControllerFactory - private(set) var controllers: [GroupedRulerController] = [] - private(set) var activeRulerID: UUID? - var onActiveControllerChanged: ((GroupedRulerController?) -> Void)? - var onStateChanged: ((RulerManager) -> Void)? - private var groupedDragState: GroupedDragState? - private var isApplyingGroupedDrag = false - - init( - initialStates: [RulerInstanceState] = [], - controllerFactory: @escaping ControllerFactory = { GroupedRulerController(state: $0) } - ) { - self.controllerFactory = controllerFactory - restore(initialStates) - } - - var hasRulers: Bool { - return !controllers.isEmpty - } - - var hasVisibleRulers: Bool { - return controllers.contains { $0.isVisible } - } - - var activeController: GroupedRulerController? { - if let activeRulerID = activeRulerID, - let controller = controllers.first(where: { $0.state.id == activeRulerID }) { - return controller - } - - if let keyController = controllers.first(where: { $0.groupedWindow.isKeyWindow }) { - return keyController - } - - return controllers.last - } - - var states: [RulerInstanceState] { - return controllers.map { $0.state } - } - - @discardableResult - func createRuler( - defaults: RulerSettings = RulerSettings(defaults: prefs), - screenFrame: NSRect = defaultRulerScreenFrame() - ) -> GroupedRulerController { - let defaultState = RulerInstanceState.createFromDefaults( - defaults: defaults, - screenFrame: screenFrame - ) - let state = staggeredState(from: defaultState) - - return addRuler(state: state) - } - - @discardableResult - func addRuler(state: RulerInstanceState) -> GroupedRulerController { - let controller = controllerFactory(state) - configure(controller) - controllers.append(controller) - markActive(controller) - return controller - } - - func restore(_ states: [RulerInstanceState], activeRulerID restoredActiveRulerID: UUID? = nil) { - for controller in controllers { - controller.hide() - } - - controllers = [] - activeRulerID = nil - onActiveControllerChanged?(nil) - - for state in states where state.hasVisibleWing { - addRuler(state: state) - } - - if let restoredActiveRulerID = restoredActiveRulerID, - let restoredActiveController = controller(id: restoredActiveRulerID) { - markActive(restoredActiveController) - } - - notifyStateChanged() - } - - func showAll() { - for controller in controllers { - controller.show() - } - - if let activeController = activeController { - activeController.groupedWindow.makeKey() - } - } - - @discardableResult - func cycleActiveRuler() -> GroupedRulerController? { - let visibleControllers = controllers.filter(\.isVisible) - guard !visibleControllers.isEmpty else { return nil } - - let activeID = activeController?.state.id - let activeIndex = activeID.flatMap { activeID in - visibleControllers.firstIndex { $0.state.id == activeID } - } - let nextIndex = activeIndex.map { ($0 + 1) % visibleControllers.count } ?? 0 - let nextController = visibleControllers[nextIndex] - - markActive(nextController) - nextController.groupedWindow.orderFrontRegardless() - nextController.groupedWindow.makeKey() - return nextController - } - - @discardableResult - func closeActiveRuler() -> Bool { - guard let activeController = activeController else { return false } - - close(activeController) - return true - } - - func close(_ controller: GroupedRulerController) { - controller.hide() - controllers.removeAll { $0 === controller } - - if activeRulerID == controller.state.id { - activeRulerID = controllers.last?.state.id - onActiveControllerChanged?(activeController) - } - - notifyStateChanged() - } - - func markActive(_ controller: GroupedRulerController) { - guard controllers.contains(where: { $0 === controller }) else { return } - - activeRulerID = controller.state.id - onActiveControllerChanged?(controller) - notifyStateChanged() - } - - func beginGroupedDrag(from controller: GroupedRulerController) { - guard prefs.groupRulers, - controllers.contains(where: { $0 === controller }) else { - groupedDragState = nil - return - } - - groupedDragState = GroupedDragState( - draggedRulerID: controller.state.id, - framesByRulerID: Dictionary( - uniqueKeysWithValues: controllers - .filter(\.isVisible) - .map { ($0.state.id, $0.groupedWindow.frame) } - ) - ) - } - - func syncGroupedDrag(from controller: GroupedRulerController) { - guard prefs.groupRulers, - !isApplyingGroupedDrag, - let groupedDragState = groupedDragState, - groupedDragState.draggedRulerID == controller.state.id, - let originalDraggedFrame = groupedDragState.framesByRulerID[controller.state.id] else { - return - } - - let offset = NSSize( - width: controller.groupedWindow.frame.minX - originalDraggedFrame.minX, - height: controller.groupedWindow.frame.minY - originalDraggedFrame.minY - ) - guard offset.width != 0 || offset.height != 0 else { return } - - isApplyingGroupedDrag = true - defer { - isApplyingGroupedDrag = false - } - - for otherController in controllers where otherController !== controller && otherController.isVisible { - guard var frame = groupedDragState.framesByRulerID[otherController.state.id] else { continue } - - frame.origin.x += offset.width - frame.origin.y += offset.height - otherController.move(to: frame) - } - - notifyStateChanged() - } - - func finishGroupedDrag(from controller: GroupedRulerController) { - syncGroupedDrag(from: controller) - groupedDragState = nil - } - - func controller(containing window: NSWindow?) -> GroupedRulerController? { - guard let window = window else { return nil } - - 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 } - self?.markActive(controller) - } - controller.onDragStarted = { [weak self, weak controller] _ in - guard let controller = controller else { return } - self?.beginGroupedDrag(from: controller) - } - controller.onDragged = { [weak self, weak controller] _ in - guard let controller = controller else { return } - self?.syncGroupedDrag(from: controller) - } - controller.onDragFinished = { [weak self, weak controller] _ in - guard let controller = controller else { return } - self?.finishGroupedDrag(from: controller) - } - controller.onStateChanged = { [weak self, weak controller] _ in - guard let controller = controller, - self?.activeRulerID == controller.state.id else { return } - - self?.activeRulerID = controller.state.id - self?.notifyStateChanged() - } - } - - private func staggeredState(from defaultState: RulerInstanceState) -> RulerInstanceState { - var state = defaultState - let offset = Ruler.thickness / 2 - - while controllers.contains(where: { $0.state.layout.zeroPoint == state.layout.zeroPoint }) { - state.layout.zeroPoint.x += offset - state.layout.zeroPoint.y -= offset - } - - return state - } - - private func notifyStateChanged() { - onStateChanged?(self) - } -} -#endif - -private func groupedRulerLShapedPath( - in bounds: NSRect, - zeroCorner: ZeroCorner, - inset: CGFloat -) -> NSBezierPath { - let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) - let horizontalFrame = layout.localFrame(for: .horizontal) - let verticalFrame = layout.localFrame(for: .vertical) - let minX = bounds.minX + inset - let maxX = bounds.maxX - inset - let minY = bounds.minY + inset - let maxY = bounds.maxY - inset - let path = NSBezierPath() - let points: [NSPoint] - - switch zeroCorner { - case .topLeft: - let innerX = verticalFrame.maxX - inset - let innerY = horizontalFrame.minY + inset - points = [ - NSPoint(x: minX, y: minY), - NSPoint(x: innerX, y: minY), - NSPoint(x: innerX, y: innerY), - NSPoint(x: maxX, y: innerY), - NSPoint(x: maxX, y: maxY), - NSPoint(x: minX, y: maxY), - ] - case .topRight: - let innerX = verticalFrame.minX + inset - let innerY = horizontalFrame.minY + inset - points = [ - NSPoint(x: minX, y: innerY), - NSPoint(x: innerX, y: innerY), - NSPoint(x: innerX, y: minY), - NSPoint(x: maxX, y: minY), - NSPoint(x: maxX, y: maxY), - NSPoint(x: minX, y: maxY), - ] - case .bottomLeft: - let innerX = verticalFrame.maxX - inset - let innerY = horizontalFrame.maxY - inset - points = [ - NSPoint(x: minX, y: minY), - NSPoint(x: maxX, y: minY), - NSPoint(x: maxX, y: innerY), - NSPoint(x: innerX, y: innerY), - NSPoint(x: innerX, y: maxY), - NSPoint(x: minX, y: maxY), - ] - case .bottomRight: - let innerX = verticalFrame.minX + inset - let innerY = horizontalFrame.maxY - inset - points = [ - NSPoint(x: minX, y: minY), - NSPoint(x: maxX, y: minY), - NSPoint(x: maxX, y: maxY), - NSPoint(x: innerX, y: maxY), - NSPoint(x: innerX, y: innerY), - NSPoint(x: minX, y: innerY), - ] - } - - guard let firstPoint = points.first else { return path } - - path.move(to: firstPoint) - for point in points.dropFirst() { - path.line(to: point) - } - path.close() - return path -} diff --git a/Free Ruler/LegacyRulerWindow.swift b/Free Ruler/LegacyRulerWindow.swift new file mode 100644 index 0000000..f7a8386 --- /dev/null +++ b/Free Ruler/LegacyRulerWindow.swift @@ -0,0 +1,160 @@ +import Cocoa + +class LegacyRulerWindow: NSPanel { + + var ruler: Ruler + var rule: RuleView + + convenience init(_ ruler: Ruler) { + self.init(ruler: ruler) + } + + init(ruler: Ruler) { + self.ruler = ruler + self.rule = getRulerView(ruler: ruler) + + let styleMask: NSWindow.StyleMask = [ + .borderless, + .resizable, + .fullSizeContentView, + ] + + super.init( + contentRect: ruler.frame, + styleMask: styleMask, + backing: .buffered, + defer: false + ) + + self.alphaValue = windowAlphaValue(prefs.foregroundOpacity) + self.title = getTitle(for: ruler.orientation) + self.identifier = NSUserInterfaceItemIdentifier(getIdentifier(for: ruler.orientation)) + self.setAccessibilityIdentifier(getIdentifier(for: ruler.orientation)) + self.minSize = getMinSize(ruler: ruler) + self.maxSize = getMaxSize(ruler: ruler) + + self.isFloatingPanel = prefs.floatRulers + self.hidesOnDeactivate = false + self.isMovableByWindowBackground = true + self.hasShadow = prefs.rulerShadow + + rule.installWindowBorder() + rule.setAccessibilityElement(true) + rule.setAccessibilityIdentifier(getRuleIdentifier(for: ruler.orientation)) + + rule.nextResponder = self + self.contentView = rule + } + + override var canBecomeKey: Bool { + return true + } + + override var acceptsMouseMovedEvents: Bool { + get { return true } + set {} + } + + override func mouseDown(with event: NSEvent) { + nextResponder?.mouseDown(with: event) + super.mouseDown(with: event) + + if !leftMouseButtonIsPressed { + (nextResponder as? RulerController)?.finishMouseDrag(with: event) + } + } + + override func mouseUp(with event: NSEvent) { + nextResponder?.mouseUp(with: event) + super.mouseUp(with: event) + } + + private var leftMouseButtonIsPressed: Bool { + return NSEvent.pressedMouseButtons & 1 == 1 + } + +} + +extension LegacyRulerWindow: RulerContextMenuActivating { + func activateForRulerContextMenu() { + makeKey() + } +} + +private func getTitle(for orientation: Orientation) -> String { + switch orientation { + case .horizontal: + return NSLocalizedString( + "Horizontal Ruler", + comment: "Window title for the horizontal ruler" + ) + case .vertical: + return NSLocalizedString( + "Vertical Ruler", + comment: "Window title for the vertical ruler" + ) + } +} + +private func getIdentifier(for orientation: Orientation) -> String { + switch orientation { + case .horizontal: + return "horizontal-ruler-window" + case .vertical: + return "vertical-ruler-window" + } +} + +private func getRuleIdentifier(for orientation: Orientation) -> String { + switch orientation { + case .horizontal: + return "horizontal-ruler-view" + case .vertical: + return "vertical-ruler-view" + } +} + +extension LegacyRulerWindow { + private enum Distance: CGFloat { + case aLittle = 1 + case aLot = 10 + } + + func moveHorizontally(by pixels: CGFloat) { + var position = frame.origin + position.x = position.x + pixels + setFrameOrigin(position) + } + + func moveVertically(by pixels: CGFloat) { + var position = frame.origin + position.y = position.y + pixels + setFrameOrigin(position) + } + + private func distance(withShift: Bool) -> CGFloat { + let dist = withShift ? Distance.aLot : Distance.aLittle + return dist.rawValue + } + + func nudgeLeft(withShift shiftPressed: Bool) { + let dist = distance(withShift: shiftPressed) + moveHorizontally(by: dist * -1) + } + + func nudgeRight(withShift shiftPressed: Bool) { + let dist = distance(withShift: shiftPressed) + moveHorizontally(by: dist) + } + + func nudgeDown(withShift shiftPressed: Bool) { + let dist = distance(withShift: shiftPressed) + moveVertically(by: dist * -1) + } + + func nudgeUp(withShift shiftPressed: Bool) { + let dist = distance(withShift: shiftPressed) + moveVertically(by: dist) + } + +} diff --git a/Free Ruler/RulerController.swift b/Free Ruler/RulerController.swift index 019e072..0bfb1e5 100644 --- a/Free Ruler/RulerController.swift +++ b/Free Ruler/RulerController.swift @@ -9,8 +9,8 @@ class RulerController: NSWindowController, NSWindowDelegate, NotificationObserve let ruler: Ruler - let rulerWindow: RulerWindow - var otherWindow: RulerWindow? + let rulerWindow: LegacyRulerWindow + var otherWindow: LegacyRulerWindow? var keyListener: Any? private var mouseInteraction: RulerMouseInteractionState! @@ -40,7 +40,7 @@ class RulerController: NSWindowController, NSWindowDelegate, NotificationObserve init(ruler: Ruler) { self.ruler = ruler - self.rulerWindow = RulerWindow(ruler) + self.rulerWindow = LegacyRulerWindow(ruler) super.init(window: self.rulerWindow) @@ -209,7 +209,7 @@ class RulerController: NSWindowController, NSWindowDelegate, NotificationObserve } } - private func alignRuler(window: RulerWindow?, at point: NSPoint) { + private func alignRuler(window: LegacyRulerWindow?, at point: NSPoint) { guard let window = window else { return } let frame = window.frame diff --git a/Free Ruler/RulerWindow.swift b/Free Ruler/RulerWindow.swift index 8ddfe98..77e848a 100644 --- a/Free Ruler/RulerWindow.swift +++ b/Free Ruler/RulerWindow.swift @@ -1,17 +1,287 @@ import Cocoa +import Carbon.HIToolbox -class RulerWindow: NSPanel { +struct GroupedRulerLayout: Equatable { + let groupFrame: NSRect + let horizontalFrame: NSRect + let verticalFrame: NSRect - var ruler: Ruler - var rule: RuleView + static func joined( + horizontalFrame: NSRect, + verticalFrame: NSRect, + zeroCorner: ZeroCorner + ) -> GroupedRulerLayout { + let zeroPoint = ZeroCornerGeometry(zeroCorner: zeroCorner) + .zeroPoint(in: horizontalFrame, for: .horizontal) - convenience init(_ ruler: Ruler) { - self.init(ruler: ruler) + return layout( + horizontalLength: horizontalFrame.width, + verticalLength: verticalFrame.height, + zeroPoint: zeroPoint, + zeroCorner: zeroCorner + ) + } + + static func layout( + groupFrame: NSRect, + zeroCorner: ZeroCorner + ) -> GroupedRulerLayout { + let zeroPoint = zeroPoint(in: groupFrame, zeroCorner: zeroCorner) + let horizontalLength = length( + in: groupFrame, + from: zeroPoint, + along: .horizontal, + zeroCorner: zeroCorner + ) + let verticalLength = length( + in: groupFrame, + from: zeroPoint, + along: .vertical, + zeroCorner: zeroCorner + ) + + return layout( + horizontalLength: horizontalLength, + verticalLength: verticalLength, + zeroPoint: zeroPoint, + zeroCorner: zeroCorner + ) + } + + static func layout( + horizontalLength: CGFloat, + verticalLength: CGFloat, + zeroPoint: NSPoint, + zeroCorner: ZeroCorner + ) -> GroupedRulerLayout { + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + let horizontalFrame = geometry.frame( + for: .horizontal, + zeroPoint: zeroPoint, + size: NSSize(width: horizontalLength, height: Ruler.thickness) + ) + let verticalFrame = geometry.frame( + for: .vertical, + zeroPoint: zeroPoint, + size: NSSize(width: Ruler.thickness, height: verticalLength) + ) + + return GroupedRulerLayout( + groupFrame: horizontalFrame.union(verticalFrame), + horizontalFrame: horizontalFrame, + verticalFrame: verticalFrame + ) + } + + static func minSize(zeroCorner: ZeroCorner) -> NSSize { + return size( + horizontalLength: getMinSize(ruler: Ruler(.horizontal)).width, + verticalLength: getMinSize(ruler: Ruler(.vertical)).height, + zeroCorner: zeroCorner, + showsHorizontalRule: true, + showsVerticalRule: true + ) + } + + static func minSize( + zeroCorner: ZeroCorner, + showsHorizontalRule: Bool, + showsVerticalRule: Bool + ) -> NSSize { + return size( + horizontalLength: getMinSize(ruler: Ruler(.horizontal)).width, + verticalLength: getMinSize(ruler: Ruler(.vertical)).height, + zeroCorner: zeroCorner, + showsHorizontalRule: showsHorizontalRule, + showsVerticalRule: showsVerticalRule + ) + } + + static func maxSize(zeroCorner: ZeroCorner) -> NSSize { + return size( + horizontalLength: getMaxSize(ruler: Ruler(.horizontal)).width, + verticalLength: getMaxSize(ruler: Ruler(.vertical)).height, + zeroCorner: zeroCorner, + showsHorizontalRule: true, + showsVerticalRule: true + ) + } + + static func maxSize( + zeroCorner: ZeroCorner, + showsHorizontalRule: Bool, + showsVerticalRule: Bool + ) -> NSSize { + return size( + horizontalLength: getMaxSize(ruler: Ruler(.horizontal)).width, + verticalLength: getMaxSize(ruler: Ruler(.vertical)).height, + zeroCorner: zeroCorner, + showsHorizontalRule: showsHorizontalRule, + showsVerticalRule: showsVerticalRule + ) + } + + func localFrame(for orientation: Orientation) -> NSRect { + let frame: NSRect + switch orientation { + case .horizontal: + frame = horizontalFrame + case .vertical: + frame = verticalFrame + } + + return NSRect( + x: frame.minX - groupFrame.minX, + y: frame.minY - groupFrame.minY, + width: frame.width, + height: frame.height + ) + } + + func visibleFrame( + showsHorizontalRule: Bool, + showsVerticalRule: Bool + ) -> NSRect { + switch (showsHorizontalRule, showsVerticalRule) { + case (true, true): + return groupFrame + case (true, false): + return horizontalFrame + case (false, true): + return verticalFrame + case (false, false): + return .zero + } + } + + private static func zeroPoint( + in groupFrame: NSRect, + zeroCorner: ZeroCorner + ) -> NSPoint { + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + let x: CGFloat + let y: CGFloat + + switch geometry.horizontalZeroSide { + case .left: + x = groupFrame.minX + Ruler.thickness - ZeroCornerGeometry.borderCompensation + case .right: + x = groupFrame.maxX - Ruler.thickness + } + + switch geometry.verticalZeroSide { + case .top: + y = groupFrame.maxY - Ruler.thickness + ZeroCornerGeometry.borderCompensation + case .bottom: + y = groupFrame.minY + Ruler.thickness - ZeroCornerGeometry.borderCompensation + } + + return NSPoint(x: x, y: y) + } + + private static func length( + in groupFrame: NSRect, + from zeroPoint: NSPoint, + along orientation: Orientation, + zeroCorner: ZeroCorner + ) -> CGFloat { + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + + switch orientation { + case .horizontal: + switch geometry.horizontalZeroSide { + case .left: + return max(0, groupFrame.maxX - zeroPoint.x) + case .right: + return max(0, zeroPoint.x - groupFrame.minX) + } + case .vertical: + switch geometry.verticalZeroSide { + case .top: + return max(0, zeroPoint.y - groupFrame.minY) + case .bottom: + return max(0, groupFrame.maxY - zeroPoint.y) + } + } + } + + private static func size( + horizontalLength: CGFloat, + verticalLength: CGFloat, + zeroCorner: ZeroCorner, + showsHorizontalRule: Bool, + showsVerticalRule: Bool + ) -> NSSize { + let layout = layout( + horizontalLength: horizontalLength, + verticalLength: verticalLength, + zeroPoint: .zero, + zeroCorner: zeroCorner + ) + + return layout.visibleFrame( + showsHorizontalRule: showsHorizontalRule, + showsVerticalRule: showsVerticalRule + ).size } +} + +private extension GroupedRulerLayout { + func emptyCornerFrame(zeroCorner: ZeroCorner) -> NSRect { + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + let x: CGFloat + let width: CGFloat + let y: CGFloat + let height: CGFloat + + switch geometry.horizontalZeroSide { + case .left: + x = groupFrame.minX + width = horizontalFrame.minX - groupFrame.minX + case .right: + x = horizontalFrame.maxX + width = groupFrame.maxX - horizontalFrame.maxX + } + + switch geometry.verticalZeroSide { + case .top: + y = verticalFrame.maxY + height = groupFrame.maxY - verticalFrame.maxY + case .bottom: + y = groupFrame.minY + height = verticalFrame.minY - groupFrame.minY + } + + return NSRect( + x: x, + y: y, + width: width, + height: height + ) + } +} + +#if !SNAPSHOT_GENERATOR +final class RulerWindow: NSPanel { + let horizontalRule: HorizontalRule + let verticalRule: VerticalRule + + private let groupedContentView: GroupedRulerContentView + private(set) var settings: RulerSettings - init(ruler: Ruler) { - self.ruler = ruler - self.rule = getRulerView(ruler: ruler) + init(frame: NSRect, settings: RulerSettings = RulerSettings(defaults: prefs)) { + self.settings = settings + horizontalRule = GroupedHorizontalRule( + frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness) + ) + verticalRule = GroupedVerticalRule( + frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300) + ) + groupedContentView = GroupedRulerContentView( + frame: NSRect(origin: .zero, size: frame.size), + horizontalRule: horizontalRule, + verticalRule: verticalRule + ) let styleMask: NSWindow.StyleMask = [ .borderless, @@ -20,30 +290,40 @@ class RulerWindow: NSPanel { ] super.init( - contentRect: ruler.frame, + contentRect: frame, styleMask: styleMask, backing: .buffered, defer: false ) - self.alphaValue = windowAlphaValue(prefs.foregroundOpacity) - self.title = getTitle(for: ruler.orientation) - self.identifier = NSUserInterfaceItemIdentifier(getIdentifier(for: ruler.orientation)) - self.setAccessibilityIdentifier(getIdentifier(for: ruler.orientation)) - self.minSize = getMinSize(ruler: ruler) - self.maxSize = getMaxSize(ruler: ruler) + alphaValue = windowAlphaValue(settings.foregroundOpacity) + title = NSLocalizedString( + "Ruler", + comment: "Window title for a ruler window" + ) + identifier = NSUserInterfaceItemIdentifier("grouped-ruler-window") + setAccessibilityIdentifier("grouped-ruler-window") + minSize = GroupedRulerLayout.minSize(zeroCorner: settings.zeroCorner) + maxSize = GroupedRulerLayout.maxSize(zeroCorner: settings.zeroCorner) - self.isFloatingPanel = prefs.floatRulers - self.hidesOnDeactivate = false - self.isMovableByWindowBackground = true - self.hasShadow = prefs.rulerShadow + isOpaque = false + backgroundColor = .clear + isFloatingPanel = settings.floatRulers + hidesOnDeactivate = false + isMovableByWindowBackground = true + hasShadow = settings.rulerShadow - rule.installWindowBorder() - rule.setAccessibilityElement(true) - rule.setAccessibilityIdentifier(getRuleIdentifier(for: ruler.orientation)) + horizontalRule.setAccessibilityElement(true) + verticalRule.setAccessibilityElement(true) + horizontalRule.setAccessibilityIdentifier("horizontal-ruler-view") + verticalRule.setAccessibilityIdentifier("vertical-ruler-view") + horizontalRule.nextResponder = self + verticalRule.nextResponder = self + groupedContentView.nextResponder = self - rule.nextResponder = self - self.contentView = rule + contentView = groupedContentView + apply(settings: settings) + updateLayoutForCurrentZeroCorner() } override var canBecomeKey: Bool { @@ -55,12 +335,22 @@ class RulerWindow: NSPanel { set {} } + override func setFrame(_ frameRect: NSRect, display flag: Bool) { + super.setFrame(frameRect, display: flag) + updateGroupedContentFrame() + } + + override func setContentSize(_ size: NSSize) { + super.setContentSize(size) + updateGroupedContentFrame() + } + override func mouseDown(with event: NSEvent) { nextResponder?.mouseDown(with: event) super.mouseDown(with: event) if !leftMouseButtonIsPressed { - (nextResponder as? RulerController)?.finishMouseDrag(with: event) + (nextResponder as? GroupedRulerController)?.finishMouseDrag(with: event) } } @@ -69,48 +359,143 @@ class RulerWindow: NSPanel { super.mouseUp(with: event) } + override func mouseEntered(with event: NSEvent) { + nextResponder?.mouseEntered(with: event) + } + + override func mouseExited(with event: NSEvent) { + nextResponder?.mouseExited(with: event) + } + + override func mouseMoved(with event: NSEvent) { + nextResponder?.mouseMoved(with: event) + } + + func updateLayoutForCurrentZeroCorner() { + updateSizeConstraintsForVisibleRules() + updateGroupedContentFrame() + groupedContentView.zeroCorner = settings.zeroCorner + groupedContentView.needsLayout = true + groupedContentView.layoutSubtreeIfNeeded() + groupedContentView.needsDisplay = true + } + + func apply(settings: RulerSettings) { + self.settings = settings + alphaValue = windowAlphaValue(settings.foregroundOpacity) + isFloatingPanel = settings.floatRulers + hasShadow = settings.rulerShadow + horizontalRule.settingsOverride = settings + verticalRule.settingsOverride = settings + groupedContentView.color = RulerColors(customFill: settings.rulerColor) + updateLayoutForCurrentZeroCorner() + } + + func redrawForPreferenceChange() { + updateLayoutForCurrentZeroCorner() + horizontalRule.redrawForPreferenceChange() + verticalRule.redrawForPreferenceChange() + } + + func screenFrame(for orientation: Orientation) -> NSRect { + return convertToScreen(groupedContentView.localFrame(for: orientation)) + } + + func visibleFrame(in layout: GroupedRulerLayout) -> NSRect { + return layout.visibleFrame( + showsHorizontalRule: groupedContentView.showsHorizontalRule, + showsVerticalRule: groupedContentView.showsVerticalRule + ) + } + + func setVisibleRules(horizontal: Bool, vertical: Bool) { + groupedContentView.showsHorizontalRule = horizontal + groupedContentView.showsVerticalRule = vertical + updateSizeConstraintsForVisibleRules() + groupedContentView.needsLayout = true + groupedContentView.layoutSubtreeIfNeeded() + } + + func isRuleVisible(_ orientation: Orientation) -> Bool { + switch orientation { + case .horizontal: + return groupedContentView.showsHorizontalRule + case .vertical: + return groupedContentView.showsVerticalRule + } + } + + func isEmptyCorner(atWindowPoint windowPoint: NSPoint) -> Bool { + let contentPoint = groupedContentView.convert(windowPoint, from: nil) + return groupedContentView.containsEmptyCorner(contentPoint) + } + + func zeroPoint() -> NSPoint { + let geometry = ZeroCornerGeometry(zeroCorner: settings.zeroCorner) + + if isRuleVisible(.horizontal) { + return geometry.zeroPoint( + in: screenFrame(for: .horizontal), + for: .horizontal + ) + } + + if isRuleVisible(.vertical) { + return geometry.zeroPoint( + in: screenFrame(for: .vertical), + for: .vertical + ) + } + + return frame.origin + } + private var leftMouseButtonIsPressed: Bool { return NSEvent.pressedMouseButtons & 1 == 1 } + private func updateGroupedContentFrame() { + guard contentView === groupedContentView else { return } + + groupedContentView.frame = NSRect(origin: .zero, size: frame.size) + groupedContentView.needsLayout = true + groupedContentView.layoutSubtreeIfNeeded() + } + + private func updateSizeConstraintsForVisibleRules() { + minSize = GroupedRulerLayout.minSize( + zeroCorner: settings.zeroCorner, + showsHorizontalRule: groupedContentView.showsHorizontalRule, + showsVerticalRule: groupedContentView.showsVerticalRule + ) + maxSize = GroupedRulerLayout.maxSize( + zeroCorner: settings.zeroCorner, + showsHorizontalRule: groupedContentView.showsHorizontalRule, + showsVerticalRule: groupedContentView.showsVerticalRule + ) + } } extension RulerWindow: RulerContextMenuActivating { func activateForRulerContextMenu() { makeKey() + (nextResponder as? GroupedRulerController)?.activateForRulerContextMenu() } } -private func getTitle(for orientation: Orientation) -> String { - switch orientation { - case .horizontal: - return NSLocalizedString( - "Horizontal Ruler", - comment: "Window title for the horizontal ruler" - ) - case .vertical: - return NSLocalizedString( - "Vertical Ruler", - comment: "Window title for the vertical ruler" - ) +private final class GroupedHorizontalRule: HorizontalRule { + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(NSSize(width: newSize.width, height: Ruler.thickness)) } } -private func getIdentifier(for orientation: Orientation) -> String { - switch orientation { - case .horizontal: - return "horizontal-ruler-window" - case .vertical: - return "vertical-ruler-window" +private final class GroupedVerticalRule: VerticalRule { + override var rulerWidth: CGFloat { + return Ruler.thickness } -} -private func getRuleIdentifier(for orientation: Orientation) -> String { - switch orientation { - case .horizontal: - return "horizontal-ruler-view" - case .vertical: - return "vertical-ruler-view" + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(NSSize(width: Ruler.thickness, height: newSize.height)) } } @@ -156,5 +541,1446 @@ extension RulerWindow { let dist = distance(withShift: shiftPressed) moveVertically(by: dist) } +} +#endif + +private final class RulerClipView: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.masksToBounds = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented. Use init(frame:)") + } + + override var isOpaque: Bool { + return false + } + + override var mouseDownCanMoveWindow: Bool { + return true + } +} + +private final class GroupedRulerBorderView: RulerBorderView { + var zeroCorner = prefs.zeroCorner { + didSet { + needsDisplay = true + } + } + + var showsHorizontalRule = true { + didSet { + needsDisplay = true + } + } + + var showsVerticalRule = true { + didSet { + needsDisplay = true + } + } + override func borderPath(in bounds: NSRect) -> NSBezierPath { + switch (showsHorizontalRule, showsVerticalRule) { + case (true, true): + return lShapedBorderPath() + case (true, false): + return visibleBoundsBorderPath() + case (false, true): + return visibleBoundsBorderPath() + case (false, false): + return NSBezierPath() + } + } + + private func lShapedBorderPath() -> NSBezierPath { + return groupedRulerLShapedPath( + in: bounds, + zeroCorner: zeroCorner, + inset: Self.borderCenterInset + ) + } + + private func visibleBoundsBorderPath() -> NSBezierPath { + return NSBezierPath(rect: bounds.insetBy( + dx: Self.borderCenterInset, + dy: Self.borderCenterInset + )) + } +} + +private final class GroupedRulerZeroLabelsView: NSView { + private let horizontalRule: HorizontalRule + private let verticalRule: VerticalRule + private let zeroLabel = "0" + private let zeroLabelSize = NSSize(width: 50, height: 20) + + var color = RulerColors() { + didSet { + needsDisplay = true + } + } + + var zeroCorner = prefs.zeroCorner { + didSet { + needsDisplay = true + } + } + + var horizontalRuleFrame: NSRect = .zero { + didSet { + needsDisplay = true + } + } + + var verticalRuleFrame: NSRect = .zero { + didSet { + needsDisplay = true + } + } + + var showsHorizontalRule = true { + didSet { + needsDisplay = true + } + } + + var showsVerticalRule = true { + didSet { + needsDisplay = true + } + } + + init(horizontalRule: HorizontalRule, verticalRule: VerticalRule) { + self.horizontalRule = horizontalRule + self.verticalRule = verticalRule + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented. Use init(horizontalRule:verticalRule:)") + } + + override var isOpaque: Bool { + return false + } + + override func hitTest(_ point: NSPoint) -> NSView? { + return nil + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + guard showsHorizontalRule && showsVerticalRule else { return } + + if showsHorizontalRule { + drawHorizontalZeroLabel() + } + if showsVerticalRule { + drawVerticalZeroLabel() + } + } + + private func drawHorizontalZeroLabel() { + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + let growthDirection = geometry.growthDirection(for: .horizontal) + let zeroTickX: CGFloat + + switch growthDirection { + case .positive: + zeroTickX = horizontalRule.bounds.minX + case .negative: + zeroTickX = horizontalRule.bounds.maxX + } + + let lineX = horizontalRule.mouseTickLineX( + forTickX: zeroTickX, + growthDirection: growthDirection + ) + let labelRect = horizontalRule.tickLabelRect( + forX: lineX, + labelSize: zeroLabelSize, + rulerHeight: horizontalRule.bounds.height, + tickSide: geometry.horizontalTickSide + ).offsetBy(dx: horizontalRuleFrame.minX, dy: horizontalRuleFrame.minY) + let attributes = labelAttributes(alignment: .center) + + zeroLabel.draw( + with: labelRect, + attributes: attributes, + context: nil + ) + } + + private func drawVerticalZeroLabel() { + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + let growthDirection = geometry.growthDirection(for: .vertical) + let zeroTickY: CGFloat + + switch growthDirection { + case .positive: + zeroTickY = verticalRule.bounds.minY + case .negative: + zeroTickY = verticalRule.bounds.maxY + } + + let lineY = verticalRule.mouseTickLineY( + forTickY: zeroTickY, + growthDirection: growthDirection + ) + let labelRect = verticalRule.tickLabelRect( + forY: lineY, + labelSize: zeroLabelSize, + rulerWidth: verticalRule.rulerWidth, + tickSide: geometry.verticalTickSide + ).offsetBy(dx: verticalRuleFrame.minX, dy: verticalRuleFrame.minY) + let attributes = labelAttributes( + alignment: geometry.verticalTickSide == .right ? .right : .left + ) + + zeroLabel.draw( + with: labelRect, + attributes: attributes, + context: nil + ) + } + + private func labelAttributes(alignment: NSTextAlignment) -> [NSAttributedString.Key: Any] { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = alignment + let font = NSFont(name: "HelveticaNeue", size: 10) ?? .systemFont(ofSize: 10) + + return [ + .font: font, + .paragraphStyle: paragraphStyle, + .foregroundColor: color.numbers, + ] + } +} + +final class GroupedRulerContentView: NSView { + let horizontalRule: HorizontalRule + let verticalRule: VerticalRule + private let horizontalHost = RulerClipView(frame: .zero) + private let verticalHost = RulerClipView(frame: .zero) + private let unitLabelView = UnitLabelView( + orientation: .horizontal, + label: NSAttributedString(string: "") + ) + private let zeroLabelsView: GroupedRulerZeroLabelsView + private let borderView = GroupedRulerBorderView(frame: .zero) + private var cornerTrackingArea: NSTrackingArea? + + var showsHorizontalRule = true { + didSet { + guard showsHorizontalRule != oldValue else { return } + updateRuleVisibility() + } + } + + var showsVerticalRule = true { + didSet { + guard showsVerticalRule != oldValue else { return } + updateRuleVisibility() + } + } + + var color = RulerColors() { + didSet { + zeroLabelsView.color = color + updateUnitLabel() + needsDisplay = true + } + } + + var zeroCorner = prefs.zeroCorner { + didSet { + zeroLabelsView.zeroCorner = zeroCorner + horizontalRule.needsDisplay = true + verticalRule.needsDisplay = true + needsLayout = true + needsDisplay = true + } + } + + init( + frame frameRect: NSRect, + horizontalRule: HorizontalRule, + verticalRule: VerticalRule + ) { + self.horizontalRule = horizontalRule + self.verticalRule = verticalRule + self.zeroLabelsView = GroupedRulerZeroLabelsView( + horizontalRule: horizontalRule, + verticalRule: verticalRule + ) + super.init(frame: frameRect) + + autoresizesSubviews = false + horizontalHost.autoresizingMask = [] + verticalHost.autoresizingMask = [] + horizontalRule.autoresizingMask = [] + verticalRule.autoresizingMask = [] + unitLabelView.autoresizingMask = [] + zeroLabelsView.autoresizingMask = [] + borderView.autoresizingMask = [] + horizontalRule.drawsBackground = false + verticalRule.drawsBackground = false + horizontalRule.showsUnitLabel = false + verticalRule.showsUnitLabel = false + horizontalRule.showsZeroTick = true + verticalRule.showsZeroTick = true + horizontalHost.addSubview(horizontalRule) + verticalHost.addSubview(verticalRule) + addSubview(horizontalHost) + addSubview(verticalHost) + addSubview(unitLabelView) + addSubview(zeroLabelsView) + addSubview(borderView) + zeroLabelsView.color = color + zeroLabelsView.zeroCorner = zeroCorner + updateRuleVisibility() + updateUnitLabel() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented. Use init(frame:horizontalRule:verticalRule:)") + } + + override var isOpaque: Bool { + return false + } + + override var mouseDownCanMoveWindow: Bool { + return true + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + rebuildCornerTrackingArea() + } + + override func resetCursorRects() { + super.resetCursorRects() + + if showsHorizontalRule && showsVerticalRule { + addCursorRect(cornerFrame(), cursor: .openHand) + } + } + + override func layout() { + super.layout() + + let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) + let cornerFrame = layout.emptyCornerFrame(zeroCorner: zeroCorner) + setFrame(ruleFrame(for: .horizontal, in: bounds, layout: layout), for: horizontalHost) + setFrame(horizontalHost.bounds, for: horizontalRule) + setFrame(ruleFrame(for: .vertical, in: bounds, layout: layout), for: verticalHost) + setFrame(verticalHost.bounds, for: verticalRule) + updateUnitLabel() + setFrame(unitLabelFrame(in: cornerFrame), for: unitLabelView) + setFrame(bounds, for: zeroLabelsView) + zeroLabelsView.horizontalRuleFrame = horizontalHost.frame + zeroLabelsView.verticalRuleFrame = verticalHost.frame + zeroLabelsView.showsHorizontalRule = showsHorizontalRule + zeroLabelsView.showsVerticalRule = showsVerticalRule + setFrame(bounds, for: borderView) + borderView.zeroCorner = zeroCorner + borderView.showsHorizontalRule = showsHorizontalRule + borderView.showsVerticalRule = showsVerticalRule + horizontalRule.needsDisplay = true + verticalRule.needsDisplay = true + window?.invalidateCursorRects(for: self) + rebuildCornerTrackingArea() + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + color.fill.setFill() + rulerFillPath().fill() + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + + if let hitView = super.hitTest(point), + hitView !== self, + hitView !== unitLabelView { + return hitView + } + + return containsEmptyCorner(point) ? self : nil + } + + override func mouseEntered(with event: NSEvent) { + nextResponder?.mouseEntered(with: event) + } + + override func mouseExited(with event: NSEvent) { + nextResponder?.mouseExited(with: event) + } + + override func mouseDown(with event: NSEvent) { + nextResponder?.mouseDown(with: event) + } + + override func mouseUp(with event: NSEvent) { + nextResponder?.mouseUp(with: event) + } + + override func mouseMoved(with event: NSEvent) { + nextResponder?.mouseMoved(with: event) + } + + override func menu(for event: NSEvent) -> NSMenu? { + return rulerContextMenu(for: self) + } + + func containsEmptyCorner(_ point: NSPoint) -> Bool { + return showsHorizontalRule && showsVerticalRule && cornerFrame().contains(point) + } + + func localFrame(for orientation: Orientation) -> NSRect { + let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) + return ruleFrame(for: orientation, in: bounds, layout: layout) + } + + private func cornerFrame() -> NSRect { + return GroupedRulerLayout + .layout(groupFrame: bounds, zeroCorner: zeroCorner) + .emptyCornerFrame(zeroCorner: zeroCorner) + } + + private func ruleFrame( + for orientation: Orientation, + in bounds: NSRect, + layout: GroupedRulerLayout + ) -> NSRect { + switch (showsHorizontalRule, showsVerticalRule) { + case (true, true): + return layout.localFrame(for: orientation) + case (true, false): + return orientation == .horizontal ? bounds : .zero + case (false, true): + return orientation == .vertical ? bounds : .zero + case (false, false): + return .zero + } + } + + private func rebuildCornerTrackingArea() { + if let cornerTrackingArea = cornerTrackingArea { + removeTrackingArea(cornerTrackingArea) + self.cornerTrackingArea = nil + } + + let frame = cornerFrame() + guard showsHorizontalRule && showsVerticalRule, + frame.width > 0, + frame.height > 0 else { return } + + let trackingArea = NSTrackingArea( + rect: frame, + options: [ + .activeAlways, + .mouseEnteredAndExited, + .mouseMoved, + ], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + cornerTrackingArea = trackingArea + } + + private func setFrame(_ frame: NSRect, for view: NSView) { + view.setFrameOrigin(frame.origin) + view.setFrameSize(frame.size) + } + + private func updateRuleVisibility() { + rebuildSubviews() + updateRuleViewLabels() + needsLayout = true + needsDisplay = true + } + + private func rebuildSubviews() { + for view in [horizontalHost, verticalHost, unitLabelView, zeroLabelsView, borderView] { + view.removeFromSuperview() + } + + if showsHorizontalRule { + addSubview(horizontalHost) + } + if showsVerticalRule { + addSubview(verticalHost) + } + + addSubview(unitLabelView) + addSubview(zeroLabelsView) + addSubview(borderView) + } + + private func updateRuleViewLabels() { + let showsBothRules = showsHorizontalRule && showsVerticalRule + unitLabelView.isHidden = !showsBothRules + zeroLabelsView.isHidden = !showsBothRules + horizontalRule.showsUnitLabel = showsHorizontalRule && !showsVerticalRule + verticalRule.showsUnitLabel = showsVerticalRule && !showsHorizontalRule + horizontalRule.showsZeroTick = showsHorizontalRule + verticalRule.showsZeroTick = showsVerticalRule + } + + private func rulerFillPath() -> NSBezierPath { + let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) + + switch (showsHorizontalRule, showsVerticalRule) { + case (true, true): + return groupedRulerLShapedPath(in: bounds, zeroCorner: zeroCorner, inset: 0) + case (true, false): + return NSBezierPath(rect: ruleFrame(for: .horizontal, in: bounds, layout: layout)) + case (false, true): + return NSBezierPath(rect: ruleFrame(for: .vertical, in: bounds, layout: layout)) + case (false, false): + return NSBezierPath() + } + } + + private func unitLabelFrame(in cornerFrame: NSRect) -> NSRect { + let labelFrame = unitLabelView.frame(in: NSRect(origin: .zero, size: cornerFrame.size)) + return NSRect( + x: cornerFrame.minX + labelFrame.minX, + y: cornerFrame.minY + labelFrame.minY, + width: labelFrame.width, + height: labelFrame.height + ) + } + + private func updateUnitLabel() { + unitLabelView.zeroCorner = zeroCorner + unitLabelView.label = NSAttributedString( + string: unitLabelString(), + attributes: unitLabelAttributes() + ) + } + + private func unitLabelString() -> String { + return horizontalRule.getUnitLabel() + } + + private func unitLabelAttributes() -> [NSAttributedString.Key: Any] { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .left + let font = NSFont(name: "HelveticaNeue", size: 10) ?? .systemFont(ofSize: 10) + + return [ + .font: font, + .paragraphStyle: paragraphStyle, + .foregroundColor: color.ticks, + ] + } +} + +#if !SNAPSHOT_GENERATOR +final class GroupedRulerController: NSWindowController, NSWindowDelegate, NotificationObserver { + var observers: [NSKeyValueObservation] = [] + var notificationObservers: [NSObjectProtocol] = [] + + let groupedWindow: RulerWindow + var state: RulerInstanceState + var onBecameActive: ((GroupedRulerController) -> Void)? + var onDragStarted: ((GroupedRulerController) -> Void)? + var onDragged: ((GroupedRulerController) -> Void)? + var onDragFinished: ((GroupedRulerController) -> Void)? + var onStateChanged: ((GroupedRulerController) -> Void)? + + private var keyListener: Any? + private var mouseInteraction: RulerMouseInteractionState! + private var isMouseTickDrawingEnabled = true + private let followsDefaultPreferences: Bool + + var isLeftMouseButtonPressed = { + return NSEvent.pressedMouseButtons & 1 == 1 + } + + var preferencesWindowOpen = false { + didSet { + updateIsFloatingPanel() + if !preferencesWindowOpen { + opacity = state.settings.foregroundOpacity + } + } + } + + var opacity = 0 { + didSet { + groupedWindow.alphaValue = windowAlphaValue(opacity) + } + } + + convenience init(frame: NSRect) { + let layout = GroupedRulerLayout.layout(groupFrame: frame, zeroCorner: prefs.zeroCorner) + let state = RulerInstanceState( + settings: RulerSettings(defaults: prefs), + layout: RulerLayoutState( + horizontalFrame: layout.horizontalFrame, + verticalFrame: layout.verticalFrame, + zeroCorner: prefs.zeroCorner + ) + ) + + self.init(state: state, followsDefaultPreferences: true) + } + + convenience init(state: RulerInstanceState) { + self.init(state: state, followsDefaultPreferences: false) + } + + private init(state: RulerInstanceState, followsDefaultPreferences: Bool) { + self.state = state + self.followsDefaultPreferences = followsDefaultPreferences + let layout = state.layout.layout(zeroCorner: state.settings.zeroCorner) + groupedWindow = RulerWindow( + frame: layout.visibleFrame( + showsHorizontalRule: state.visibility.showsHorizontal, + showsVerticalRule: state.visibility.showsVertical + ), + settings: state.settings + ) + super.init(window: groupedWindow) + + opacity = state.settings.foregroundOpacity + createObservers() + subscribeToPrefs() + + groupedWindow.delegate = self + groupedWindow.nextResponder = self + mouseInteraction = RulerMouseInteractionState(owner: self) { [weak self] event in + return self?.mouseIsInsideRuler(with: event) ?? false + } + applyStateToWindow(display: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented. Use init(frame:)") + } + + deinit { + mouseInteraction?.invalidate() + removeObservers(¬ificationObservers) + stopKeyListener() + } + + var isVisible: Bool { + return groupedWindow.isVisible + } + + func show() { + applyStateToWindow(display: false) + showWindow(self) + groupedWindow.orderFrontRegardless() + } + + func show( + horizontalFrame: NSRect, + verticalFrame: NSRect, + showsHorizontalRule: Bool, + showsVerticalRule: Bool + ) { + state.layout = RulerLayoutState( + horizontalFrame: horizontalFrame, + verticalFrame: verticalFrame, + zeroCorner: prefs.zeroCorner + ) + state.settings.zeroCorner = prefs.zeroCorner + state.visibility = RulerWingVisibility( + horizontal: showsHorizontalRule, + vertical: showsVerticalRule + ) + show() + } + + func hide() { + groupedWindow.orderOut(self) + } + + @discardableResult + func toggleWing(_ orientation: Orientation) -> Bool { + guard state.toggleWing(orientation) else { return false } + + applyStateToWindow(display: true) + notifyStateChanged() + return true + } + + @discardableResult + func setWing(_ orientation: Orientation, isVisible: Bool) -> Bool { + guard state.setWing(orientation, isVisible: isVisible) else { return false } + + applyStateToWindow(display: true) + notifyStateChanged() + return true + } + + func syncFrames( + to horizontalWindow: LegacyRulerWindow, + and verticalWindow: LegacyRulerWindow, + persistAutosave: Bool = false + ) { + guard isVisible else { return } + + let frames = syncedRulerFrames( + horizontalWindow: horizontalWindow, + verticalWindow: verticalWindow + ) + + syncFrame(frames.horizontal, to: horizontalWindow, persistAutosave: persistAutosave) + syncFrame(frames.vertical, to: verticalWindow, persistAutosave: persistAutosave) + } + + func align(at point: NSPoint) { + let horizontalLength = groupedWindow.screenFrame(for: .horizontal).width + let verticalLength = groupedWindow.screenFrame(for: .vertical).height + let layout = GroupedRulerLayout.layout( + horizontalLength: horizontalLength, + verticalLength: verticalLength, + zeroPoint: point, + zeroCorner: state.settings.zeroCorner + ) + + groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) + groupedWindow.updateLayoutForCurrentZeroCorner() + captureStateFromWindow() + } + + func prepareForZeroCornerChange(to zeroCorner: ZeroCorner) { + let zeroPoint = groupedWindow.zeroPoint() + let horizontalLength = groupedWindow.screenFrame(for: .horizontal).width + let verticalLength = groupedWindow.screenFrame(for: .vertical).height + let layout = GroupedRulerLayout.layout( + horizontalLength: horizontalLength, + verticalLength: verticalLength, + zeroPoint: zeroPoint, + zeroCorner: zeroCorner + ) + + state.settings.zeroCorner = zeroCorner + groupedWindow.apply(settings: state.settings) + groupedWindow.alphaValue = windowAlphaValue(opacity) + updateIsFloatingPanel() + updateHasShadow() + groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) + captureStateFromWindow() + notifyStateChanged() + } + + func foreground() { + opacity = state.settings.foregroundOpacity + } + + func background() { + opacity = state.settings.backgroundOpacity + } + + func updateIsFloatingPanel() { + groupedWindow.isFloatingPanel = preferencesWindowOpen ? false : state.settings.floatRulers + } + + func updateHasShadow() { + groupedWindow.hasShadow = state.settings.rulerShadow + } + + func redrawForPreferenceChange() { + groupedWindow.redrawForPreferenceChange() + } + + func updateSettings(_ update: (inout RulerSettings) -> Void) { + update(&state.settings) + applyStateToWindow(display: true) + notifyStateChanged() + } + + func updateDimensions(horizontalLength: CGFloat, verticalLength: CGFloat) { + let minHorizontalLength = getMinSize(ruler: Ruler(.horizontal)).width + let maxHorizontalLength = getMaxSize(ruler: Ruler(.horizontal)).width + let minVerticalLength = getMinSize(ruler: Ruler(.vertical)).height + let maxVerticalLength = getMaxSize(ruler: Ruler(.vertical)).height + + state.layout = RulerLayoutState( + zeroPoint: groupedWindow.zeroPoint(), + horizontalLength: min(max(horizontalLength, minHorizontalLength), maxHorizontalLength), + verticalLength: min(max(verticalLength, minVerticalLength), maxVerticalLength) + ) + applyStateToWindow(display: true) + notifyStateChanged() + } + + func move(to frame: NSRect) { + groupedWindow.setFrame(frame, display: false) + captureStateFromWindow() + } + + 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) + } + if groupedWindow.isRuleVisible(.vertical) { + groupedWindow.verticalRule.drawMouseTick(at: mouseLoc) + } + } + + func setMouseTickDrawingEnabled(_ isEnabled: Bool) { + isMouseTickDrawingEnabled = isEnabled + updateMouseTickDrawingVisibility() + } + + private func updateMouseTickDrawingVisibility() { + groupedWindow.horizontalRule.showMouseTick = isMouseTickDrawingEnabled + && groupedWindow.isRuleVisible(.horizontal) + groupedWindow.verticalRule.showMouseTick = isMouseTickDrawingEnabled + && groupedWindow.isRuleVisible(.vertical) + } + + private func applyStateToWindow(display: Bool) { + let zeroCorner = state.settings.zeroCorner + let layout = state.layout.layout(zeroCorner: zeroCorner) + groupedWindow.apply(settings: state.settings) + groupedWindow.alphaValue = windowAlphaValue(opacity) + updateIsFloatingPanel() + updateHasShadow() + groupedWindow.setVisibleRules( + horizontal: state.visibility.showsHorizontal, + vertical: state.visibility.showsVertical + ) + updateMouseTickDrawingVisibility() + groupedWindow.setFrame( + layout.visibleFrame( + showsHorizontalRule: state.visibility.showsHorizontal, + showsVerticalRule: state.visibility.showsVertical + ), + display: display + ) + groupedWindow.updateLayoutForCurrentZeroCorner() + } + + private func captureStateFromWindow() { + var horizontalLength = state.layout.horizontalLength + var verticalLength = state.layout.verticalLength + + if groupedWindow.isRuleVisible(.horizontal) { + horizontalLength = groupedWindow.screenFrame(for: .horizontal).width + } + if groupedWindow.isRuleVisible(.vertical) { + verticalLength = groupedWindow.screenFrame(for: .vertical).height + } + + state.layout = RulerLayoutState( + zeroPoint: groupedWindow.zeroPoint(), + horizontalLength: horizontalLength, + verticalLength: verticalLength + ) + state.visibility = RulerWingVisibility( + horizontal: groupedWindow.isRuleVisible(.horizontal), + vertical: groupedWindow.isRuleVisible(.vertical) + ) + notifyStateChanged() + } + + private func notifyStateChanged() { + onStateChanged?(self) + } + + private func syncedRulerFrames( + horizontalWindow: LegacyRulerWindow, + verticalWindow: LegacyRulerWindow + ) -> (horizontal: NSRect, vertical: NSRect) { + let showsHorizontalRule = groupedWindow.isRuleVisible(.horizontal) + let showsVerticalRule = groupedWindow.isRuleVisible(.vertical) + + switch (showsHorizontalRule, showsVerticalRule) { + case (true, true): + return ( + groupedWindow.screenFrame(for: .horizontal), + groupedWindow.screenFrame(for: .vertical) + ) + case (true, false): + let horizontalFrame = groupedWindow.screenFrame(for: .horizontal) + let zeroPoint = ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner) + .zeroPoint(in: horizontalFrame, for: .horizontal) + return ( + horizontalFrame, + hiddenRuleFrame( + orientation: .vertical, + zeroPoint: zeroPoint, + size: verticalWindow.frame.size + ) + ) + case (false, true): + let verticalFrame = groupedWindow.screenFrame(for: .vertical) + let zeroPoint = ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner) + .zeroPoint(in: verticalFrame, for: .vertical) + return ( + hiddenRuleFrame( + orientation: .horizontal, + zeroPoint: zeroPoint, + size: horizontalWindow.frame.size + ), + verticalFrame + ) + case (false, false): + return (horizontalWindow.frame, verticalWindow.frame) + } + } + + private func hiddenRuleFrame( + orientation: Orientation, + zeroPoint: NSPoint, + size: NSSize + ) -> NSRect { + return ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner).frame( + for: orientation, + zeroPoint: zeroPoint, + size: size + ) + } + + private func syncFrame( + _ frame: NSRect, + to window: LegacyRulerWindow, + persistAutosave: Bool + ) { + window.setFrame(frame, display: false) + + guard persistAutosave else { return } + + if let frameAutosaveName = window.ruler.name { + window.saveFrame(usingName: NSWindow.FrameAutosaveName(frameAutosaveName)) + } + } + + func windowWillStartLiveResize(_ notification: Notification) { + mouseInteraction.windowWillStartLiveResize() + } + + func windowDidEndLiveResize(_ notification: Notification) { + syncRulerWindowFrames(persistAutosave: true) + captureStateFromWindow() + mouseInteraction.windowDidEndLiveResize() + } + + func windowWillMove(_ notification: Notification) { + mouseInteraction.windowWillMove() + } + + func windowDidMove(_ notification: Notification) { + groupedWindow.invalidateShadow() + syncRulerWindowFrames( + persistAutosave: mouseInteraction.shouldPersistFrameAutosaveOnWindowMove( + isLeftMouseButtonPressed: isLeftMouseButtonPressed() + ) + ) + captureStateFromWindow() + onDragged?(self) + mouseInteraction.windowDidMove(isLeftMouseButtonPressed: isLeftMouseButtonPressed()) + } + + func windowDidBecomeKey(_ notification: Notification) { + onBecameActive?(self) + startKeyListener() + } + + func windowDidResignKey(_ notification: Notification) { + stopKeyListener() + } + + override func mouseEntered(with event: NSEvent) { + mouseInteraction.mouseEntered(with: event) + } + + override func mouseExited(with event: NSEvent) { + mouseInteraction.mouseExited(with: event) + } + + override func mouseDown(with event: NSEvent) { + mouseInteraction.mouseDown(with: event) + onDragStarted?(self) + } + + override func mouseUp(with event: NSEvent) { + finishMouseDrag(with: event) + } + + func finishMouseDrag(with event: NSEvent) { + if mouseInteraction.finishMouseDrag(with: event) { + syncRulerWindowFrames(persistAutosave: true) + captureStateFromWindow() + onDragFinished?(self) + } + } + + func activateForRulerContextMenu() { + onBecameActive?(self) + } + + override func mouseMoved(with event: NSEvent) { + mouseInteraction.mouseMoved(with: event) + } + + private var appDelegate: AppDelegate? { + return NSApp.delegate as? AppDelegate + } + + private func syncRulerWindowFrames(persistAutosave: Bool = false) { + appDelegate?.syncGroupedRulerFramesToRulerWindows(persistAutosave: persistAutosave) + } + + private func mouseIsInsideRuler(with event: NSEvent) -> Bool { + return orientation(at: event) != nil + || groupedWindow.isEmptyCorner(atWindowPoint: event.locationInWindow) + } + + private func orientation(at event: NSEvent) -> Orientation? { + let horizontalLocation = groupedWindow.horizontalRule.convert(event.locationInWindow, from: nil) + let verticalLocation = groupedWindow.verticalRule.convert(event.locationInWindow, from: nil) + + if groupedWindow.isRuleVisible(.horizontal), + groupedWindow.horizontalRule.bounds.contains(horizontalLocation) { + return .horizontal + } + + if groupedWindow.isRuleVisible(.vertical), + groupedWindow.verticalRule.bounds.contains(verticalLocation) { + return .vertical + } + + return nil + } + + private func createObservers() { + notificationObservers = [ + addObserver(.preferencesWindowOpened) { [weak self] _ in + self?.preferencesWindowOpen = true + }, + addObserver(.preferencesWindowClosed) { [weak self] _ in + self?.preferencesWindowOpen = false + }, + ] + } + + private func subscribeToPrefs() { + guard followsDefaultPreferences else { + observers = [] + return + } + + observers = [ + prefs.observe(\Prefs.foregroundOpacity, options: .new) { [weak self] prefs, changed in + self?.opacity = prefs.foregroundOpacity + }, + prefs.observe(\Prefs.backgroundOpacity, options: .new) { [weak self] prefs, changed in + self?.opacity = prefs.backgroundOpacity + }, + prefs.observe(\Prefs.floatRulers, options: .new) { [weak self] prefs, changed in + self?.updateIsFloatingPanel() + }, + prefs.observe(\Prefs.rulerShadow, options: .new) { [weak self] prefs, changed in + self?.updateHasShadow() + }, + ] + } +} + +// MARK: KeyListener + +extension GroupedRulerController { + func startKeyListener() { + keyListener = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] in + guard let self = self else { return $0 } + return self.onKeyDown(with: $0) + } + } + + func stopKeyListener() { + if let keyListener = keyListener { + NSEvent.removeMonitor(keyListener) + self.keyListener = nil + } + } + + func onKeyDown(with event: NSEvent) -> NSEvent? { + let shift = event.modifierFlags.contains(.shift) + let keyboardModifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + + if groupedWindow.isKeyWindow, + let appDelegate = NSApp.delegate as? AppDelegate, + appDelegate.performRulerHotkey( + keyCode: Int(event.keyCode), + modifierFlags: keyboardModifiers, + sender: self + ) { + return nil + } + + switch Int(event.keyCode) { + case kVK_LeftArrow: + groupedWindow.nudgeLeft(withShift: shift) + return nil + case kVK_RightArrow: + groupedWindow.nudgeRight(withShift: shift) + return nil + case kVK_UpArrow: + groupedWindow.nudgeUp(withShift: shift) + return nil + case kVK_DownArrow: + groupedWindow.nudgeDown(withShift: shift) + return nil + default: + return event + } + } +} + +final class RulerManager { + typealias ControllerFactory = (RulerInstanceState) -> GroupedRulerController + + private struct GroupedDragState { + let draggedRulerID: UUID + let framesByRulerID: [UUID: NSRect] + } + + private let controllerFactory: ControllerFactory + private(set) var controllers: [GroupedRulerController] = [] + private(set) var activeRulerID: UUID? + var onActiveControllerChanged: ((GroupedRulerController?) -> Void)? + var onStateChanged: ((RulerManager) -> Void)? + private var groupedDragState: GroupedDragState? + private var isApplyingGroupedDrag = false + + init( + initialStates: [RulerInstanceState] = [], + controllerFactory: @escaping ControllerFactory = { GroupedRulerController(state: $0) } + ) { + self.controllerFactory = controllerFactory + restore(initialStates) + } + + var hasRulers: Bool { + return !controllers.isEmpty + } + + var hasVisibleRulers: Bool { + return controllers.contains { $0.isVisible } + } + + var activeController: GroupedRulerController? { + if let activeRulerID = activeRulerID, + let controller = controllers.first(where: { $0.state.id == activeRulerID }) { + return controller + } + + if let keyController = controllers.first(where: { $0.groupedWindow.isKeyWindow }) { + return keyController + } + + return controllers.last + } + + var states: [RulerInstanceState] { + return controllers.map { $0.state } + } + + @discardableResult + func createRuler( + defaults: RulerSettings = RulerSettings(defaults: prefs), + screenFrame: NSRect = defaultRulerScreenFrame() + ) -> GroupedRulerController { + let defaultState = RulerInstanceState.createFromDefaults( + defaults: defaults, + screenFrame: screenFrame + ) + let state = staggeredState(from: defaultState) + + return addRuler(state: state) + } + + @discardableResult + func addRuler(state: RulerInstanceState) -> GroupedRulerController { + let controller = controllerFactory(state) + configure(controller) + controllers.append(controller) + markActive(controller) + return controller + } + + func restore(_ states: [RulerInstanceState], activeRulerID restoredActiveRulerID: UUID? = nil) { + for controller in controllers { + controller.hide() + } + + controllers = [] + activeRulerID = nil + onActiveControllerChanged?(nil) + + for state in states where state.hasVisibleWing { + addRuler(state: state) + } + + if let restoredActiveRulerID = restoredActiveRulerID, + let restoredActiveController = controller(id: restoredActiveRulerID) { + markActive(restoredActiveController) + } + + notifyStateChanged() + } + + func showAll() { + for controller in controllers { + controller.show() + } + + if let activeController = activeController { + activeController.groupedWindow.makeKey() + } + } + + @discardableResult + func cycleActiveRuler() -> GroupedRulerController? { + let visibleControllers = controllers.filter(\.isVisible) + guard !visibleControllers.isEmpty else { return nil } + + let activeID = activeController?.state.id + let activeIndex = activeID.flatMap { activeID in + visibleControllers.firstIndex { $0.state.id == activeID } + } + let nextIndex = activeIndex.map { ($0 + 1) % visibleControllers.count } ?? 0 + let nextController = visibleControllers[nextIndex] + + markActive(nextController) + nextController.groupedWindow.orderFrontRegardless() + nextController.groupedWindow.makeKey() + return nextController + } + + @discardableResult + func closeActiveRuler() -> Bool { + guard let activeController = activeController else { return false } + + close(activeController) + return true + } + + func close(_ controller: GroupedRulerController) { + controller.hide() + controllers.removeAll { $0 === controller } + + if activeRulerID == controller.state.id { + activeRulerID = controllers.last?.state.id + onActiveControllerChanged?(activeController) + } + + notifyStateChanged() + } + + func markActive(_ controller: GroupedRulerController) { + guard controllers.contains(where: { $0 === controller }) else { return } + + activeRulerID = controller.state.id + onActiveControllerChanged?(controller) + notifyStateChanged() + } + + func beginGroupedDrag(from controller: GroupedRulerController) { + guard prefs.groupRulers, + controllers.contains(where: { $0 === controller }) else { + groupedDragState = nil + return + } + + groupedDragState = GroupedDragState( + draggedRulerID: controller.state.id, + framesByRulerID: Dictionary( + uniqueKeysWithValues: controllers + .filter(\.isVisible) + .map { ($0.state.id, $0.groupedWindow.frame) } + ) + ) + } + + func syncGroupedDrag(from controller: GroupedRulerController) { + guard prefs.groupRulers, + !isApplyingGroupedDrag, + let groupedDragState = groupedDragState, + groupedDragState.draggedRulerID == controller.state.id, + let originalDraggedFrame = groupedDragState.framesByRulerID[controller.state.id] else { + return + } + + let offset = NSSize( + width: controller.groupedWindow.frame.minX - originalDraggedFrame.minX, + height: controller.groupedWindow.frame.minY - originalDraggedFrame.minY + ) + guard offset.width != 0 || offset.height != 0 else { return } + + isApplyingGroupedDrag = true + defer { + isApplyingGroupedDrag = false + } + + for otherController in controllers where otherController !== controller && otherController.isVisible { + guard var frame = groupedDragState.framesByRulerID[otherController.state.id] else { continue } + + frame.origin.x += offset.width + frame.origin.y += offset.height + otherController.move(to: frame) + } + + notifyStateChanged() + } + + func finishGroupedDrag(from controller: GroupedRulerController) { + syncGroupedDrag(from: controller) + groupedDragState = nil + } + + func controller(containing window: NSWindow?) -> GroupedRulerController? { + guard let window = window else { return nil } + + 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 } + self?.markActive(controller) + } + controller.onDragStarted = { [weak self, weak controller] _ in + guard let controller = controller else { return } + self?.beginGroupedDrag(from: controller) + } + controller.onDragged = { [weak self, weak controller] _ in + guard let controller = controller else { return } + self?.syncGroupedDrag(from: controller) + } + controller.onDragFinished = { [weak self, weak controller] _ in + guard let controller = controller else { return } + self?.finishGroupedDrag(from: controller) + } + controller.onStateChanged = { [weak self, weak controller] _ in + guard let controller = controller, + self?.activeRulerID == controller.state.id else { return } + + self?.activeRulerID = controller.state.id + self?.notifyStateChanged() + } + } + + private func staggeredState(from defaultState: RulerInstanceState) -> RulerInstanceState { + var state = defaultState + let offset = Ruler.thickness / 2 + + while controllers.contains(where: { $0.state.layout.zeroPoint == state.layout.zeroPoint }) { + state.layout.zeroPoint.x += offset + state.layout.zeroPoint.y -= offset + } + + return state + } + + private func notifyStateChanged() { + onStateChanged?(self) + } +} +#endif + +private func groupedRulerLShapedPath( + in bounds: NSRect, + zeroCorner: ZeroCorner, + inset: CGFloat +) -> NSBezierPath { + let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) + let horizontalFrame = layout.localFrame(for: .horizontal) + let verticalFrame = layout.localFrame(for: .vertical) + let minX = bounds.minX + inset + let maxX = bounds.maxX - inset + let minY = bounds.minY + inset + let maxY = bounds.maxY - inset + let path = NSBezierPath() + let points: [NSPoint] + + switch zeroCorner { + case .topLeft: + let innerX = verticalFrame.maxX - inset + let innerY = horizontalFrame.minY + inset + points = [ + NSPoint(x: minX, y: minY), + NSPoint(x: innerX, y: minY), + NSPoint(x: innerX, y: innerY), + NSPoint(x: maxX, y: innerY), + NSPoint(x: maxX, y: maxY), + NSPoint(x: minX, y: maxY), + ] + case .topRight: + let innerX = verticalFrame.minX + inset + let innerY = horizontalFrame.minY + inset + points = [ + NSPoint(x: minX, y: innerY), + NSPoint(x: innerX, y: innerY), + NSPoint(x: innerX, y: minY), + NSPoint(x: maxX, y: minY), + NSPoint(x: maxX, y: maxY), + NSPoint(x: minX, y: maxY), + ] + case .bottomLeft: + let innerX = verticalFrame.maxX - inset + let innerY = horizontalFrame.maxY - inset + points = [ + NSPoint(x: minX, y: minY), + NSPoint(x: maxX, y: minY), + NSPoint(x: maxX, y: innerY), + NSPoint(x: innerX, y: innerY), + NSPoint(x: innerX, y: maxY), + NSPoint(x: minX, y: maxY), + ] + case .bottomRight: + let innerX = verticalFrame.minX + inset + let innerY = horizontalFrame.maxY - inset + points = [ + NSPoint(x: minX, y: minY), + NSPoint(x: maxX, y: minY), + NSPoint(x: maxX, y: maxY), + NSPoint(x: innerX, y: maxY), + NSPoint(x: innerX, y: innerY), + NSPoint(x: minX, y: innerY), + ] + } + + guard let firstPoint = points.first else { return path } + + path.move(to: firstPoint) + for point in points.dropFirst() { + path.line(to: point) + } + path.close() + return path } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index d4cae43..22602c7 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -1775,10 +1775,10 @@ final class RulerCoreTests: XCTestCase { func testGroupedRulerControllerSyncsHiddenLegToVisibleZeroPoint() { withRestoredZeroCornerPreference { prefs.zeroCorner = .topLeft - let horizontalWindow = RulerWindow( + let horizontalWindow = LegacyRulerWindow( Ruler(.horizontal, frame: NSRect(x: 200, y: 299, width: 320, height: Ruler.thickness)) ) - let verticalWindow = RulerWindow( + let verticalWindow = LegacyRulerWindow( Ruler(.vertical, frame: NSRect(x: 161, y: 120, width: Ruler.thickness, height: 180)) ) let controller = GroupedRulerController( @@ -3046,7 +3046,7 @@ final class RulerCoreTests: XCTestCase { func testResizeHandleDisablesWindowBackgroundDraggingDuringResizeDrag() { let ruler = Ruler(.horizontal, frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - let window = RulerWindow(ruler) + let window = LegacyRulerWindow(ruler) guard let resizeHandle = window.rule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { return XCTFail("Expected horizontal ruler to install a resize handle") } @@ -3084,7 +3084,7 @@ final class RulerCoreTests: XCTestCase { } func testResizeHandleDetachesChildWindowsAttachedWhileBecomingKey() { - let childWindow = RulerWindow( + let childWindow = LegacyRulerWindow( Ruler(.vertical, frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) ) let window = ChildAttachingRulerWindow( @@ -3121,7 +3121,7 @@ final class RulerCoreTests: XCTestCase { let initialFrame = NSRect(x: 100, y: 200, width: 300, height: Ruler.thickness) let ruler = Ruler(.horizontal, frame: initialFrame) - let window = RulerWindow(ruler) + let window = LegacyRulerWindow(ruler) guard let resizeHandle = window.rule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { return XCTFail("Expected horizontal ruler to install a resize handle") } @@ -3175,7 +3175,7 @@ final class RulerCoreTests: XCTestCase { prefs.zeroCorner = .topLeft let horizontalInitialFrame = NSRect(x: 100, y: 200, width: 300, height: Ruler.thickness) - let horizontalWindow = RulerWindow(Ruler(.horizontal, frame: horizontalInitialFrame)) + let horizontalWindow = LegacyRulerWindow(Ruler(.horizontal, frame: horizontalInitialFrame)) defer { horizontalWindow.close() } horizontalWindow.rule.settingsOverride = RulerSettings(zeroCorner: .topRight) guard let horizontalResizeHandle = horizontalWindow.rule.subviews @@ -3212,7 +3212,7 @@ final class RulerCoreTests: XCTestCase { )) let verticalInitialFrame = NSRect(x: 300, y: 200, width: Ruler.thickness, height: 300) - let verticalWindow = RulerWindow(Ruler(.vertical, frame: verticalInitialFrame)) + let verticalWindow = LegacyRulerWindow(Ruler(.vertical, frame: verticalInitialFrame)) defer { verticalWindow.close() } verticalWindow.rule.settingsOverride = RulerSettings(zeroCorner: .bottomLeft) guard let verticalResizeHandle = verticalWindow.rule.subviews @@ -4095,7 +4095,7 @@ final class RulerCoreTests: XCTestCase { } } -private final class ChildAttachingRulerWindow: RulerWindow { +private final class ChildAttachingRulerWindow: LegacyRulerWindow { private let childWindowToAttach: NSWindow init(ruler: Ruler, childWindow: NSWindow) { @@ -4110,7 +4110,7 @@ private final class ChildAttachingRulerWindow: RulerWindow { } private final class TestableFlipAppDelegate: AppDelegate { - override func isRulerWindowShown(_ window: RulerWindow) -> Bool { + override func isRulerWindowShown(_ window: LegacyRulerWindow) -> Bool { return true } } From 0fde7f35471c4369dd904fbfaf6a5ffc104076ee Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 15:34:41 -0400 Subject: [PATCH 30/38] Constrain ruler settings controls (#258) * Constrain ruler settings controls * Move ruler settings controls into xib * Update layout and dimensions in Preferences and Ruler Settings UI; adjust frame sizes and positions for better alignment and usability across various views. * Update ruler settings layout test * Refine layout of RulerSettingsControlsView by adjusting button positioning and adding constraints for improved alignment and responsiveness. --- .../Base.lproj/PreferencesController.xib | 14 +- .../Base.lproj/RulerSettingsController.xib | 2 +- .../Base.lproj/RulerSettingsControlsView.xib | 173 ++++++++++++++---- Free Ruler/Localizable.xcstrings | 84 --------- Free Ruler/PreferencesController.swift | 131 +------------ .../RulerSettingsControlsView.strings | 6 + .../RulerSettingsControlsView.strings | 6 + .../RulerSettingsControlsView.strings | 6 + .../RulerSettingsControlsView.strings | 6 + .../RulerSettingsControlsView.strings | 6 + FreeRulerTests/RulerCoreTests.swift | 70 +++++++ 11 files changed, 256 insertions(+), 248 deletions(-) diff --git a/Free Ruler/Base.lproj/PreferencesController.xib b/Free Ruler/Base.lproj/PreferencesController.xib index eadc90a..1c0a9fb 100644 --- a/Free Ruler/Base.lproj/PreferencesController.xib +++ b/Free Ruler/Base.lproj/PreferencesController.xib @@ -17,14 +17,14 @@ - - + + - + - + @@ -32,13 +32,13 @@ - + - + - + diff --git a/Free Ruler/Base.lproj/RulerSettingsController.xib b/Free Ruler/Base.lproj/RulerSettingsController.xib index 4af3cf0..e129e4b 100644 --- a/Free Ruler/Base.lproj/RulerSettingsController.xib +++ b/Free Ruler/Base.lproj/RulerSettingsController.xib @@ -19,7 +19,7 @@ - + diff --git a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib index cb04815..e21eba1 100644 --- a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib +++ b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib @@ -10,108 +10,211 @@ + + + + + + + + + - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + - - - + + - - - + + - - - + + - - - + + - - - + + - - - + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index b6c2d2a..6245bef 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -971,90 +971,6 @@ } } }, - "RulerSettingsControls.Dimensions" : { - "comment" : "Label for active ruler width and height fields", - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abmessungen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dimensions" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dimensiones" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mitat" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "寸法" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "尺寸" - } - } - } - }, - "RulerSettingsControls.Unit" : { - "comment" : "Label for the active ruler measurement unit setting", - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Einheit" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unit" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unidad" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yksikkö" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "単位" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "单位" - } - } - } - }, "Unit.Inches.Abbreviation" : { "comment" : "Inches unit abbreviation", "extractionState" : "manual", diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index 1852e80..153da90 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -206,61 +206,25 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { weak var delegate: RulerSettingsControlsViewDelegate? @IBOutlet var contentView: NSView! + @IBOutlet weak var unitLabel: NSTextField! + @IBOutlet weak var unitSegmentedControl: NSSegmentedControl! + @IBOutlet weak var dimensionsLabel: NSTextField! + @IBOutlet weak var dimensionWidthField: NSTextField! + @IBOutlet weak var dimensionsSeparatorLabel: NSTextField! + @IBOutlet weak var dimensionHeightField: NSTextField! + @IBOutlet weak var rulerColorLabel: NSTextField! @IBOutlet weak var rulerColorWell: RulerColorWell! @IBOutlet weak var resetRulerColorButton: NSButton! + @IBOutlet weak var foregroundOpacityTitleLabel: NSTextField! @IBOutlet weak var foregroundOpacitySlider: NSSlider! + @IBOutlet weak var backgroundOpacityTitleLabel: NSTextField! @IBOutlet weak var backgroundOpacitySlider: NSSlider! @IBOutlet weak var foregroundOpacityLabel: NSTextField! @IBOutlet weak var backgroundOpacityLabel: NSTextField! @IBOutlet weak var floatRulersCheckbox: NSButton! @IBOutlet weak var rulerShadowCheckbox: NSButton! - let unitLabel = NSTextField(labelWithString: NSLocalizedString( - "RulerSettingsControls.Unit", - value: "Unit", - comment: "Label for the active ruler measurement unit setting" - )) - let unitSegmentedControl = NSSegmentedControl( - labels: [ - NSLocalizedString( - "Unit.Pixels.Abbreviation", - value: "px", - comment: "Pixels unit abbreviation" - ), - NSLocalizedString( - "Unit.Millimeters.Abbreviation", - value: "mm", - comment: "Millimeters unit abbreviation" - ), - NSLocalizedString( - "Unit.Inches.Abbreviation", - value: "in", - comment: "Inches unit abbreviation" - ), - ], - trackingMode: .selectOne, - target: nil, - action: nil - ) - let dimensionsLabel = NSTextField(labelWithString: NSLocalizedString( - "RulerSettingsControls.Dimensions", - value: "Dimensions", - comment: "Label for active ruler width and height fields" - )) - let dimensionWidthField = NSTextField() - let dimensionsSeparatorLabel = NSTextField(labelWithString: "x") - let dimensionHeightField = NSTextField() - - private var showsDimensions = true private var dimensionScreen: NSScreen? - private var dimensionControls: [NSView] { - return [ - dimensionsLabel, - dimensionWidthField, - dimensionsSeparatorLabel, - dimensionHeightField, - ] - } var selectedUnit: Unit { return Unit(rawValue: unitSegmentedControl.selectedSegment) ?? .pixels @@ -301,8 +265,6 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { } func configureForPreferences() { - showsDimensions = true - updateDimensionsVisibility() configureControls( unitSegmentedControlIdentifier: "ruler-unit-segmented-control", widthFieldIdentifier: "ruler-width-field", @@ -321,8 +283,6 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { } func configureForRulerSettings() { - showsDimensions = true - updateDimensionsVisibility() configureControls( unitSegmentedControlIdentifier: "ruler-settings-unit-segmented-control", widthFieldIdentifier: "ruler-settings-width-field", @@ -413,12 +373,6 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { } } - override func layout() { - super.layout() - - layoutTopControls() - } - func performRulerSettingsKeyEquivalent(with event: NSEvent) -> Bool { guard event.type == .keyDown, event.modifierFlags @@ -463,18 +417,9 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { contentView.topAnchor.constraint(equalTo: topAnchor), contentView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) - installTopControls() configureBaseControls() } - private func installTopControls() { - for control in [unitLabel, unitSegmentedControl] + dimensionControls { - control.translatesAutoresizingMaskIntoConstraints = true - contentView.addSubview(control) - } - updateDimensionsVisibility() - } - private func configureBaseControls() { unitSegmentedControl.target = self unitSegmentedControl.action = #selector(setUnit(_:)) @@ -571,7 +516,7 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { } private func configureKeyViewLoop() { - unitSegmentedControl.nextKeyView = showsDimensions ? dimensionWidthField : rulerColorWell + unitSegmentedControl.nextKeyView = dimensionWidthField dimensionWidthField.nextKeyView = dimensionHeightField dimensionHeightField.nextKeyView = rulerColorWell rulerColorWell.nextKeyView = resetRulerColorButton.isHidden @@ -601,62 +546,6 @@ final class RulerSettingsControlsView: NSView, NSTextFieldDelegate { field.action = #selector(setDimensions(_:)) } - private func updateDimensionsVisibility() { - for control in dimensionControls { - control.isHidden = !showsDimensions - } - needsLayout = true - configureKeyViewLoop() - } - - private func layoutTopControls() { - guard contentView != nil else { return } - - let labelX: CGFloat = 15 - let labelWidth: CGFloat = 150 - let labelHeight: CGFloat = 16 - let rightMargin: CGFloat = 21 - let contentWidth = contentView.bounds.width - let controlRight = contentWidth - rightMargin - - let unitControlWidth: CGFloat = 108 - let unitY: CGFloat = showsDimensions ? 285 : 249 - unitLabel.frame = NSRect(x: labelX, y: unitY, width: labelWidth, height: labelHeight) - unitSegmentedControl.frame = NSRect( - x: controlRight - unitControlWidth, - y: unitY - 4, - width: unitControlWidth, - height: 24 - ) - - let dimensionsY: CGFloat = 249 - let fieldWidth: CGFloat = 56 - let separatorWidth: CGFloat = 18 - let heightFieldX = controlRight - fieldWidth - let separatorX = heightFieldX - separatorWidth - let widthFieldX = separatorX - fieldWidth - - dimensionsLabel.frame = NSRect(x: labelX, y: dimensionsY, width: labelWidth, height: labelHeight) - dimensionWidthField.frame = NSRect( - x: widthFieldX, - y: dimensionsY - 4, - width: fieldWidth, - height: 24 - ) - dimensionsSeparatorLabel.frame = NSRect( - x: separatorX, - y: dimensionsY, - width: separatorWidth, - height: labelHeight - ) - dimensionHeightField.frame = NSRect( - x: heightFieldX, - y: dimensionsY - 4, - width: fieldWidth, - height: 24 - ) - } - private func toggleFloatRulersFromKeyEquivalent() -> Bool { guard floatRulersCheckbox.isEnabled else { return false } diff --git a/Free Ruler/de.lproj/RulerSettingsControlsView.strings b/Free Ruler/de.lproj/RulerSettingsControlsView.strings index 3bb8da3..4a94f2a 100644 --- a/Free Ruler/de.lproj/RulerSettingsControlsView.strings +++ b/Free Ruler/de.lproj/RulerSettingsControlsView.strings @@ -5,6 +5,9 @@ /* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ "RSV-color-cell.title" = "Linealfarbe"; +/* Class = "NSTextFieldCell"; title = "Dimensions"; ObjectID = "RSV-dimensions-cell"; */ +"RSV-dimensions-cell.title" = "Abmessungen"; + /* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ "RSV-fg-cell.title" = "Deckkraft im Vordergrund"; @@ -13,3 +16,6 @@ /* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ "RSV-shadow-cell.title" = "Linealschatten anzeigen"; + +/* Class = "NSTextFieldCell"; title = "Unit"; ObjectID = "RSV-unit-cell"; */ +"RSV-unit-cell.title" = "Einheit"; diff --git a/Free Ruler/es.lproj/RulerSettingsControlsView.strings b/Free Ruler/es.lproj/RulerSettingsControlsView.strings index 6196a4d..35ffc26 100644 --- a/Free Ruler/es.lproj/RulerSettingsControlsView.strings +++ b/Free Ruler/es.lproj/RulerSettingsControlsView.strings @@ -5,6 +5,9 @@ /* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ "RSV-color-cell.title" = "Color de la regla"; +/* Class = "NSTextFieldCell"; title = "Dimensions"; ObjectID = "RSV-dimensions-cell"; */ +"RSV-dimensions-cell.title" = "Dimensiones"; + /* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ "RSV-fg-cell.title" = "Opacidad del primer plano"; @@ -13,3 +16,6 @@ /* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ "RSV-shadow-cell.title" = "Mostrar sombra de la regla"; + +/* Class = "NSTextFieldCell"; title = "Unit"; ObjectID = "RSV-unit-cell"; */ +"RSV-unit-cell.title" = "Unidad"; diff --git a/Free Ruler/fi.lproj/RulerSettingsControlsView.strings b/Free Ruler/fi.lproj/RulerSettingsControlsView.strings index 84adb4e..d2cc0ad 100644 --- a/Free Ruler/fi.lproj/RulerSettingsControlsView.strings +++ b/Free Ruler/fi.lproj/RulerSettingsControlsView.strings @@ -5,6 +5,9 @@ /* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ "RSV-color-cell.title" = "Viivaimen väri"; +/* Class = "NSTextFieldCell"; title = "Dimensions"; ObjectID = "RSV-dimensions-cell"; */ +"RSV-dimensions-cell.title" = "Mitat"; + /* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ "RSV-fg-cell.title" = "Peittävyys edustalla"; @@ -13,3 +16,6 @@ /* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ "RSV-shadow-cell.title" = "Näytä viivainten varjot"; + +/* Class = "NSTextFieldCell"; title = "Unit"; ObjectID = "RSV-unit-cell"; */ +"RSV-unit-cell.title" = "Yksikkö"; diff --git a/Free Ruler/ja.lproj/RulerSettingsControlsView.strings b/Free Ruler/ja.lproj/RulerSettingsControlsView.strings index 8a0334d..21fb77e 100644 --- a/Free Ruler/ja.lproj/RulerSettingsControlsView.strings +++ b/Free Ruler/ja.lproj/RulerSettingsControlsView.strings @@ -5,6 +5,9 @@ /* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ "RSV-color-cell.title" = "定規の色"; +/* Class = "NSTextFieldCell"; title = "Dimensions"; ObjectID = "RSV-dimensions-cell"; */ +"RSV-dimensions-cell.title" = "寸法"; + /* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ "RSV-fg-cell.title" = "前景の不透明度"; @@ -13,3 +16,6 @@ /* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ "RSV-shadow-cell.title" = "定規の影を表示"; + +/* Class = "NSTextFieldCell"; title = "Unit"; ObjectID = "RSV-unit-cell"; */ +"RSV-unit-cell.title" = "単位"; diff --git a/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings b/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings index 2f85d90..5452440 100644 --- a/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings +++ b/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings @@ -5,6 +5,9 @@ /* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ "RSV-color-cell.title" = "尺子颜色"; +/* Class = "NSTextFieldCell"; title = "Dimensions"; ObjectID = "RSV-dimensions-cell"; */ +"RSV-dimensions-cell.title" = "尺寸"; + /* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ "RSV-fg-cell.title" = "前景不透明度"; @@ -13,3 +16,6 @@ /* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ "RSV-shadow-cell.title" = "显示尺子阴影"; + +/* Class = "NSTextFieldCell"; title = "Unit"; ObjectID = "RSV-unit-cell"; */ +"RSV-unit-cell.title" = "单位"; diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 22602c7..b3b531a 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -846,6 +846,76 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(controlsView.selectedVerticalLength, 2.75 * NSScreen.defaultDpi, accuracy: 0.0001) } + func testRulerSettingsControlsLayoutUsesSharedInsetsAndAlignedRows() { + let controlsView = RulerSettingsControlsView(frame: NSRect(x: 0, y: 0, width: 315, height: 320)) + controlsView.configureForRulerSettings() + + controlsView.update( + unit: .pixels, + horizontalLength: 260, + verticalLength: 180, + rulerColor: Prefs.defaultRulerFillColor, + foregroundOpacity: 90, + backgroundOpacity: 50, + floatRulers: true, + rulerShadow: false + ) + controlsView.layoutSubtreeIfNeeded() + controlsView.contentView.layoutSubtreeIfNeeded() + + let leftAlignedControls: [NSView] = [ + controlsView.unitLabel, + controlsView.dimensionsLabel, + controlsView.rulerColorLabel, + controlsView.foregroundOpacityTitleLabel, + controlsView.foregroundOpacitySlider, + controlsView.backgroundOpacityTitleLabel, + controlsView.backgroundOpacitySlider, + controlsView.floatRulersCheckbox, + controlsView.rulerShadowCheckbox, + ] + let rightAlignedControls: [NSView] = [ + controlsView.unitSegmentedControl, + controlsView.dimensionHeightField, + controlsView.rulerColorWell, + controlsView.foregroundOpacityLabel, + controlsView.foregroundOpacitySlider, + controlsView.backgroundOpacityLabel, + controlsView.backgroundOpacitySlider, + ] + func alignmentRect(_ view: NSView) -> NSRect { + return view.alignmentRect(forFrame: view.frame) + } + func firstBaselineY(_ view: NSView) -> CGFloat { + return view.frame.maxY - view.firstBaselineOffsetFromTop + } + + let expectedInset: CGFloat = 15 + let baselineAccuracy: CGFloat = 1 + let expectedLeftInset = controlsView.contentView.bounds.minX + expectedInset + let expectedRightEdge = controlsView.contentView.bounds.maxX - expectedInset + let unitTopInset = controlsView.contentView.bounds.maxY + - alignmentRect(controlsView.unitSegmentedControl).maxY + let unitToDimensionsSpacing = alignmentRect(controlsView.unitSegmentedControl).minY + - alignmentRect(controlsView.dimensionHeightField).maxY + + XCTAssertEqual(unitTopInset, expectedInset, accuracy: 0.5) + for control in leftAlignedControls { + XCTAssertEqual(alignmentRect(control).minX, expectedLeftInset, accuracy: 0.5) + } + for control in rightAlignedControls { + XCTAssertEqual(alignmentRect(control).maxX, expectedRightEdge, accuracy: 0.5) + } + XCTAssertEqual(unitToDimensionsSpacing, expectedInset, accuracy: 0.5) + XCTAssertEqual(firstBaselineY(controlsView.unitLabel), firstBaselineY(controlsView.unitSegmentedControl), accuracy: baselineAccuracy) + XCTAssertEqual(firstBaselineY(controlsView.dimensionsLabel), firstBaselineY(controlsView.dimensionWidthField), accuracy: baselineAccuracy) + XCTAssertEqual(firstBaselineY(controlsView.dimensionWidthField), firstBaselineY(controlsView.dimensionsSeparatorLabel), accuracy: baselineAccuracy) + XCTAssertEqual(firstBaselineY(controlsView.dimensionsSeparatorLabel), firstBaselineY(controlsView.dimensionHeightField), accuracy: baselineAccuracy) + XCTAssertEqual(alignmentRect(controlsView.rulerColorLabel).midY, alignmentRect(controlsView.rulerColorWell).midY, accuracy: 0.5) + XCTAssertEqual(firstBaselineY(controlsView.foregroundOpacityTitleLabel), firstBaselineY(controlsView.foregroundOpacityLabel), accuracy: baselineAccuracy) + XCTAssertEqual(firstBaselineY(controlsView.backgroundOpacityTitleLabel), firstBaselineY(controlsView.backgroundOpacityLabel), accuracy: baselineAccuracy) + } + func testRulerSettingsControlsKeyViewLoopFollowsVisibleControls() { let controlsView = RulerSettingsControlsView(frame: NSRect(x: 0, y: 0, width: 315, height: 320)) controlsView.configureForRulerSettings() From 3363dc3eac7f3b412b4ad6b63829a657bd0feec0 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 21:45:48 -0400 Subject: [PATCH 31/38] Refactor: Remove LegacyRulerWindow and related constructs (#259) * Refactor ruler management by replacing GroupedRulerController with RulerController, updating related window handling and layout structures. Rename grouped ruler references to align with new architecture, enhancing clarity and maintainability. Remove LegacyRulerWindow and adjust associated tests for consistency. * Add preference state writing and accessibility identifier for preferences window * Implemented writing of active settings state in UITestSupport for better UI testing. * Added accessibility identifier to the preferences window for improved accessibility. * Updated UITests to reflect changes in ruler window references and ensure consistent behavior across tests. --- AGENTS.md | 5 +- Free Ruler/AppDelegate.swift | 493 ++----------- Free Ruler/AppStoreScreenshotPreview.swift | 50 +- Free Ruler/LegacyRulerWindow.swift | 160 ----- Free Ruler/PreferencesController.swift | 25 +- Free Ruler/Ruler.swift | 17 +- Free Ruler/RulerController.swift | 290 -------- Free Ruler/RulerWindow.swift | 413 +++++------ Free Ruler/UITestSupport+App.swift | 6 +- FreeRulerTests/RulerCoreTests.swift | 772 ++++++--------------- FreeRulerTests/RulerSnapshotTests.swift | 28 +- FreeRulerUITests/FreeRulerUITests.swift | 171 ++--- 12 files changed, 581 insertions(+), 1849 deletions(-) delete mode 100644 Free Ruler/LegacyRulerWindow.swift delete mode 100644 Free Ruler/RulerController.swift diff --git a/AGENTS.md b/AGENTS.md index 4ce80c5..4860476 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,9 +18,8 @@ The main app target lives in `Free Ruler/` and the Xcode project is hotkey behavior, timer policy wiring, and UI test state reset. - `Ruler.swift`: ruler model plus default placement and min/max size helpers. -- `RulerController.swift`, `RulerWindow.swift`, `RuleView.swift`, - `HorizontalRule.swift`, `VerticalRule.swift`: ruler window/view behavior and - drawing. +- `RulerWindow.swift`, `RuleView.swift`, `HorizontalRule.swift`, + `VerticalRule.swift`: ruler window/controller/view behavior and drawing. - `Prefs.swift`: persisted app preferences. - `Base.lproj/*.xib`: AppKit interface files for menus/preferences. - `*.lproj/*.strings` and `Localizable.xcstrings`: diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 50fe4ca..0767702 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -60,14 +60,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { var observers: [NSKeyValueObservation] = [] - var rulers: [RulerController] = [] - var groupedRulerController: GroupedRulerController? lazy var rulerManager: RulerManager = { let manager = RulerManager() manager.onActiveControllerChanged = { [weak self] controller in guard let self = self else { return } - self.groupedRulerController = controller self.updateDisplay() guard let settingsController = self.rulerSettingsController, @@ -125,54 +122,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var updaterController: SPUStandardUpdaterController? #endif - private enum RulerWindowMode { - case grouped - case separate - } - - private struct RulerVisibility { - var horizontal = true - var vertical = true - - var hasVisibleRuler: Bool { - return horizontal || vertical - } - - mutating func showAll() { - horizontal = true - vertical = true - } - - mutating func hideAll() { - horizontal = false - vertical = false - } - - mutating func toggle(_ orientation: Orientation) { - set(orientation, isVisible: !isVisible(orientation)) - } - - mutating func set(_ orientation: Orientation, isVisible: Bool) { - switch orientation { - case .horizontal: - horizontal = isVisible - case .vertical: - vertical = isVisible - } - } - - func isVisible(_ orientation: Orientation) -> Bool { - switch orientation { - case .horizontal: - return horizontal - case .vertical: - return vertical - } - } - } - - private var rulerVisibility = RulerVisibility() - // MARK: - Lifecycle func applicationDidFinishLaunching(_ aNotification: Notification) { @@ -302,17 +251,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { }, prefs.observe(\Prefs.floatRulers, options: .new) { prefs, changed in self.updateFloatRulersMenuItem() - self.legacyGroupedRulerController?.updateIsFloatingPanel() self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.groupRulers, options: .new) { prefs, changed in self.updateGroupRulersMenuItem() - self.applyRulerWindowMode() self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.rulerShadow, options: .new) { prefs, changed in self.updateRulerShadowMenuItem() - self.legacyGroupedRulerController?.updateHasShadow() self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.rulerColor, options: .new) { prefs, changed in @@ -339,20 +285,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func redrawRulers() { - for ruler in rulers { - ruler.rulerWindow.rule.redrawForPreferenceChange() - } for controller in rulerManager.controllers { controller.redrawForPreferenceChange() } - legacyGroupedRulerController?.redrawForPreferenceChange() } func redrawDefaultBackedRulers() { - for ruler in rulers { - ruler.rulerWindow.rule.redrawForPreferenceChange() - } - legacyGroupedRulerController?.redrawForPreferenceChange() + redrawRulers() } func updateFloatRulersMenuItem() { @@ -377,6 +316,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { controller.updateSettings(update) updateDisplay() + uiTestSupport?.writePreferencesState(activeSettings: controller.state.settings) return true } @@ -386,27 +326,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { rulerManager.createRuler() } - private func createLegacyRulersIfNeeded() { - guard rulers.isEmpty else { return } - - rulers = [ - RulerController(Ruler(.vertical, name: "vertical-ruler")), - RulerController(Ruler(.horizontal, name: "horizontal-ruler")), - ] - - // let rulers know about each other - // TODO: provide each ruler with otherRulers: [LegacyRulerWindow] - rulers[0].otherWindow = rulers[1].rulerWindow - rulers[1].otherWindow = rulers[0].rulerWindow - - let groupedFrame = GroupedRulerLayout.joined( - horizontalFrame: rulers[1].rulerWindow.frame, - verticalFrame: rulers[0].rulerWindow.frame, - zeroCorner: prefs.zeroCorner - ).groupFrame - groupedRulerController = GroupedRulerController(frame: groupedFrame) - } - func showRulers() { createRulersIfNeeded() rulerManager.showAll() @@ -443,220 +362,49 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard hasLegacyAutosave else { return nil } let settings = RulerSettings(defaults: prefs) - let horizontalWindow = LegacyRulerWindow( - ruler: Ruler(.horizontal, name: horizontalAutosaveName) + let horizontalFrame = legacyAutosavedFrame( + name: horizontalAutosaveName, + fallback: getDefaultContentRect(orientation: .horizontal, zeroCorner: settings.zeroCorner) ) - let verticalWindow = LegacyRulerWindow( - ruler: Ruler(.vertical, name: verticalAutosaveName) + let verticalFrame = legacyAutosavedFrame( + name: verticalAutosaveName, + fallback: getDefaultContentRect(orientation: .vertical, zeroCorner: settings.zeroCorner) ) - _ = horizontalWindow.setFrameUsingName(NSWindow.FrameAutosaveName(horizontalAutosaveName)) - _ = verticalWindow.setFrameUsingName(NSWindow.FrameAutosaveName(verticalAutosaveName)) return RulerInstanceState( settings: settings, layout: RulerLayoutState( - horizontalFrame: horizontalWindow.frame, - verticalFrame: verticalWindow.frame, + horizontalFrame: horizontalFrame, + verticalFrame: verticalFrame, zeroCorner: settings.zeroCorner ) ) } - func toggleRuler(orientation: Orientation) { - if !rulerManager.hasRulers && rulers.isEmpty { - createRulersIfNeeded() - } - - if rulerManager.hasRulers { - let controller = rulerManager.activeController ?? rulerManager.createRuler() - controller.toggleWing(orientation) - updateDisplay() - updateMouseTickTimer() - return - } - - guard canToggleRulerVisibility else { return } - guard rulerController(orientation: orientation) != nil else { return } - - if prefs.groupRulers { - syncGroupedRulerFramesToRulerWindows(persistAutosave: true) - } - - let shouldShowRulersIfNeeded = !hasVisibleRuler - rulerVisibility.toggle(orientation) - applyRulerWindowMode(showRulersIfNeeded: shouldShowRulersIfNeeded) - } - - private func detachRulerWindows() { - for ruler in rulers { - detachRulerWindow(ruler.rulerWindow) - } - } - - private func rulerController(orientation: Orientation) -> RulerController? { - createLegacyRulersIfNeeded() - return existingRulerController(orientation: orientation) - } - - private func existingRulerController(orientation: Orientation) -> RulerController? { - return rulers.first { $0.ruler.orientation == orientation } - } - - private func showRuler(_ ruler: RulerController, updateMode: Bool = true) { - ruler.showWindow(self) - ruler.rulerWindow.orderFrontRegardless() - if updateMode { - applyRulerWindowMode() - } - } - - private func detachRulerWindow(_ window: LegacyRulerWindow) { - for ruler in rulers { - guard ruler.rulerWindow != window else { continue } - - ruler.rulerWindow.removeChildWindow(window) - window.removeChildWindow(ruler.rulerWindow) - } - } - - private var rulerWindowMode: RulerWindowMode { - return prefs.groupRulers ? .grouped : .separate - } - - private func applyRulerWindowMode(showRulersIfNeeded: Bool = false) { - if rulerManager.hasRulers { - updateMouseTickTimer() - return - } - - createLegacyRulersIfNeeded() - detachRulerWindows() - - switch rulerWindowMode { - case .grouped: - showGroupedRulerWindow(showRulersIfNeeded: showRulersIfNeeded) - case .separate: - showSeparateRulerWindows() - } - - updateMouseTickTimer() - } - - private func showGroupedRulerWindow(showRulersIfNeeded: Bool) { - guard let groupedRulerController = groupedRulerController, - let horizontalRuler = existingRulerController(orientation: .horizontal), - let verticalRuler = existingRulerController(orientation: .vertical) else { - return - } - - guard rulerVisibility.hasVisibleRuler else { - groupedRulerController.hide() - horizontalRuler.rulerWindow.orderOut(self) - verticalRuler.rulerWindow.orderOut(self) - return - } - - let shouldShowGroupedRuler = showRulersIfNeeded - || groupedRulerController.isVisible - || horizontalRuler.rulerWindow.isVisible - || verticalRuler.rulerWindow.isVisible - - guard shouldShowGroupedRuler else { return } - - let horizontalFrame = groupedRulerController.isVisible - && groupedRulerController.groupedWindow.isRuleVisible(.horizontal) - ? groupedRulerController.groupedWindow.screenFrame(for: .horizontal) - : horizontalRuler.rulerWindow.frame - let verticalFrame = groupedRulerController.isVisible - && groupedRulerController.groupedWindow.isRuleVisible(.vertical) - ? groupedRulerController.groupedWindow.screenFrame(for: .vertical) - : verticalRuler.rulerWindow.frame - - groupedRulerController.show( - horizontalFrame: horizontalFrame, - verticalFrame: verticalFrame, - showsHorizontalRule: rulerVisibility.horizontal, - showsVerticalRule: rulerVisibility.vertical + private func legacyAutosavedFrame(name: String, fallback: NSRect) -> NSRect { + let window = NSWindow( + contentRect: fallback, + styleMask: [.borderless, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false ) - horizontalRuler.rulerWindow.orderOut(self) - verticalRuler.rulerWindow.orderOut(self) + _ = window.setFrameUsingName(NSWindow.FrameAutosaveName(name)) + let frame = window.frame + window.close() + return frame } - private func showSeparateRulerWindows() { - guard let groupedRulerController = groupedRulerController else { - return - } - - if groupedRulerController.isVisible { - syncGroupedRulerFramesToRulerWindows(persistAutosave: true) - groupedRulerController.hide() - } - - for ruler in rulers { - if rulerVisibility.isVisible(ruler.ruler.orientation) { - showRuler(ruler, updateMode: false) - } else { - ruler.rulerWindow.orderOut(self) - } - } - - for ruler in rulers { - ruler.updateChildWindow() - } - } - - func syncGroupedRulerFramesToRulerWindows(persistAutosave: Bool = false) { - guard let groupedRulerController = groupedRulerController, - let horizontalRuler = existingRulerController(orientation: .horizontal), - let verticalRuler = existingRulerController(orientation: .vertical) else { - return - } - - groupedRulerController.syncFrames( - to: horizontalRuler.rulerWindow, - and: verticalRuler.rulerWindow, - persistAutosave: persistAutosave - ) - } - - private var isGroupedRulerVisible: Bool { - return groupedRulerController?.isVisible == true - } - - private var legacyGroupedRulerController: GroupedRulerController? { - guard let groupedRulerController = groupedRulerController, - !rulerManager.controllers.contains(where: { $0 === groupedRulerController }) else { - return nil - } - - return groupedRulerController - } - - private func isRulerVisible(_ ruler: RulerController?) -> Bool { - guard let ruler = ruler else { return false } - return rulerVisibility.isVisible(ruler.ruler.orientation) - } - - private var isRulerFrontmost: Bool { - if rulerManager.controllers.contains(where: { $0.groupedWindow.isKeyWindow }) { - return true - } - - if groupedRulerController?.groupedWindow.isKeyWindow == true { - return true - } + func toggleRuler(orientation: Orientation) { + createRulersIfNeeded() - return rulers.contains { $0.rulerWindow.isKeyWindow } + let controller = rulerManager.activeController ?? rulerManager.createRuler() + controller.toggleWing(orientation) + updateDisplay() + updateMouseTickTimer() } private var hasVisibleRuler: Bool { return rulerManager.hasVisibleRulers - || isGroupedRulerVisible - || rulers.contains { $0.rulerWindow.isVisible } - } - - private var canToggleRulerVisibility: Bool { - return isRulerFrontmost || !hasVisibleRuler } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -669,13 +417,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidBecomeActive(_ notification: Notification) { - for ruler in rulers { - ruler.foreground() - } for controller in rulerManager.controllers { controller.foreground() } - legacyGroupedRulerController?.foreground() mouseTickTimerPolicy.applicationDidBecomeActive() updateMouseTickTimer() @@ -684,13 +428,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidResignActive(_ notification: Notification) { - for ruler in rulers { - ruler.background() - } for controller in rulerManager.controllers { controller.background() } - legacyGroupedRulerController?.background() mouseTickTimerPolicy.applicationDidResignActive() updateMouseTickTimer() @@ -729,6 +469,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { settings.floatRulers = shouldFloat } updateFloatRulersMenuItem() + uiTestSupport?.writePreferencesState(activeSettings: controller.state.settings) showHotkeyBezel( shouldFloat ? .rulersFloated : .rulersUnfloated, on: bezelScreen(for: sender) @@ -754,6 +495,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { settings.rulerShadow = shouldShowShadow } updateRulerShadowMenuItem() + uiTestSupport?.writePreferencesState(activeSettings: controller.state.settings) showHotkeyBezel( shouldShowShadow ? .shadowEnabled : .shadowDisabled, on: bezelScreen(for: sender) @@ -814,20 +556,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { return } - if let groupedRulerController = groupedRulerController, - groupedRulerController.groupedWindow.isKeyWindow { - syncGroupedRulerFramesToRulerWindows(persistAutosave: true) - rulerVisibility.hideAll() - applyRulerWindowMode() - return - } - - if let ruler = rulers.first(where: { $0.rulerWindow.isKeyWindow }) { - rulerVisibility.set(ruler.ruler.orientation, isVisible: false) - applyRulerWindowMode() - return - } - NSApp.keyWindow?.performClose(sender) } @@ -836,46 +564,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { mouseLoc.x = mouseLoc.x.rounded() mouseLoc.y = mouseLoc.y.rounded() + createRulersIfNeeded() + if let controller = rulerManager.activeController { controller.align(at: mouseLoc) - return - } - - if prefs.groupRulers, - let groupedRulerController = groupedRulerController, - groupedRulerController.isVisible { - groupedRulerController.align(at: mouseLoc) - syncGroupedRulerFramesToRulerWindows(persistAutosave: true) - return - } - - for ruler in rulers { - ruler.alignRuler(at: mouseLoc) } } @IBAction func resetRulerPositions(_ sender: Any) { + createRulersIfNeeded() + if let controller = rulerManager.activeController { controller.resetPosition() updateDisplay() updateMouseTickTimer() - return - } - - createLegacyRulersIfNeeded() - - prefs.zeroCorner = Prefs.defaultZeroCorner - - // ungroup rulers during reset operation - prefs.groupRulers = false - rulerVisibility.showAll() - for ruler in rulers { - ruler.resetPosition() - showRuler(ruler, updateMode: false) } - - prefs.groupRulers = Prefs.defaultGroupRulers - applyRulerWindowMode() } @IBAction func toggleHorizontalRuler(_ sender: Any) { @@ -897,63 +600,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func flipRulers(along orientation: Orientation) { - if !rulerManager.hasRulers && rulers.isEmpty { - createRulersIfNeeded() - } + createRulersIfNeeded() if let controller = rulerManager.activeController { let flippedCorner = controller.state.settings.zeroCorner.flipped(along: orientation) controller.prepareForZeroCornerChange(to: flippedCorner) controller.redrawForPreferenceChange() updateDisplay() - return - } - - createLegacyRulersIfNeeded() - - let oldGeometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) - let flippedCorner = prefs.zeroCorner.flipped(along: orientation) - let flippedRuler = existingRulerController(orientation: orientation) - let otherOrientation: Orientation = orientation == .horizontal ? .vertical : .horizontal - let otherRuler = existingRulerController(orientation: otherOrientation) - let zeroPointOffset = zeroPointOffset( - from: flippedRuler?.rulerWindow, - to: otherRuler?.rulerWindow, - geometry: oldGeometry - ) - - if prefs.groupRulers, - let groupedRulerController = groupedRulerController, - groupedRulerController.isVisible { - groupedRulerController.prepareForZeroCornerChange(to: flippedCorner) - prefs.zeroCorner = flippedCorner - syncGroupedRulerFramesToRulerWindows(persistAutosave: true) - return } - - prefs.zeroCorner = flippedCorner - - guard prefs.groupRulers, - let flippedWindow = flippedRuler?.rulerWindow, - let otherWindow = otherRuler?.rulerWindow, - isRulerWindowShown(otherWindow), - let zeroPointOffset = zeroPointOffset else { return } - - let newGeometry = ZeroCornerGeometry(zeroCorner: flippedCorner) - let flippedZeroPoint = newGeometry.zeroPoint(in: flippedWindow.frame, for: orientation) - let targetOtherZeroPoint = NSPoint( - x: flippedZeroPoint.x + zeroPointOffset.width, - y: flippedZeroPoint.y + zeroPointOffset.height - ) - let otherFrame = newGeometry.frame( - for: otherOrientation, - zeroPoint: targetOtherZeroPoint, - size: otherWindow.frame.size - ) - - detachRulerWindows() - otherWindow.setFrame(otherFrame, display: true) - applyRulerWindowMode() } private func setUnit(_ unit: Unit) { @@ -966,41 +620,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { prefs.unit = unit } - func isRulerWindowShown(_ window: LegacyRulerWindow) -> Bool { - return window.isVisible || window.parent != nil || rulers.contains { - $0.rulerWindow.childWindows?.contains(window) == true - } - } - - private func zeroPointOffset( - from sourceWindow: LegacyRulerWindow?, - to targetWindow: LegacyRulerWindow?, - geometry: ZeroCornerGeometry - ) -> NSSize? { - guard let sourceWindow = sourceWindow, - let targetWindow = targetWindow else { return nil } - - let sourceZeroPoint = geometry.zeroPoint( - in: sourceWindow.frame, - for: sourceWindow.ruler.orientation - ) - let targetZeroPoint = geometry.zeroPoint( - in: targetWindow.frame, - for: targetWindow.ruler.orientation - ) - - return NSSize( - width: targetZeroPoint.x - sourceZeroPoint.x, - height: targetZeroPoint.y - sourceZeroPoint.y - ) - } - func performRulerHotkey( keyCode: Int, modifierFlags: NSEvent.ModifierFlags, sender: Any ) -> Bool { - if let controller = sender as? GroupedRulerController { + if let controller = sender as? RulerController { rulerManager.markActive(controller) } @@ -1081,15 +706,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { return rulerController.rulerWindow.screen } - if let groupedRulerController = sender as? GroupedRulerController { - return groupedRulerController.groupedWindow.screen - } - if let activeController = rulerManager.activeController { - return activeController.groupedWindow.screen + return activeController.rulerWindow.screen } - return rulers.first { $0.rulerWindow.isKeyWindow }?.rulerWindow.screen + return nil } private func unitLabel(_ unit: Unit) -> String { @@ -1139,11 +760,11 @@ extension AppDelegate: NSMenuItemValidation { return !isVisible || controller.state.isWingVisible(.vertical) } - let ruler = existingRulerController(orientation: .horizontal) - menuItem.title = isRulerVisible(ruler) - ? NSLocalizedString("Hide Horizontal Ruler", comment: "Menu item title to hide the horizontal ruler") - : NSLocalizedString("Show Horizontal Ruler", comment: "Menu item title to show the horizontal ruler") - return canToggleRulerVisibility + menuItem.title = NSLocalizedString( + "Show Horizontal Ruler", + comment: "Menu item title to show the horizontal ruler" + ) + return true case #selector(toggleVerticalRuler(_:)): if let controller = rulerManager.activeController { let isVisible = controller.state.isWingVisible(.vertical) @@ -1153,11 +774,11 @@ extension AppDelegate: NSMenuItemValidation { return !isVisible || controller.state.isWingVisible(.horizontal) } - let ruler = existingRulerController(orientation: .vertical) - menuItem.title = isRulerVisible(ruler) - ? NSLocalizedString("Hide Vertical Ruler", comment: "Menu item title to hide the vertical ruler") - : NSLocalizedString("Show Vertical Ruler", comment: "Menu item title to show the vertical ruler") - return canToggleRulerVisibility + menuItem.title = NSLocalizedString( + "Show Vertical Ruler", + comment: "Menu item title to show the vertical ruler" + ) + return true default: return true } @@ -1204,14 +825,9 @@ extension AppDelegate { } private func setMouseTickDrawingEnabled(_ isEnabled: Bool) { - for ruler in rulers { - ruler.rulerWindow.rule.showMouseTick = isEnabled - } - for controller in rulerManager.controllers { controller.setMouseTickDrawingEnabled(isEnabled) } - legacyGroupedRulerController?.setMouseTickDrawingEnabled(isEnabled) } private func updateMouseTickTimer() { @@ -1255,21 +871,8 @@ extension AppDelegate { mouseLoc.x = mouseLoc.x.rounded() mouseLoc.y = mouseLoc.y.rounded() - if rulerManager.hasRulers { - for controller in rulerManager.controllers where controller.isVisible { - controller.drawMouseTick(at: mouseLoc) - } - return - } - - if let groupedRulerController = groupedRulerController, - groupedRulerController.isVisible { - groupedRulerController.drawMouseTick(at: mouseLoc) - return - } - - for ruler in rulers { - ruler.rulerWindow.rule.drawMouseTick(at: mouseLoc) + for controller in rulerManager.controllers where controller.isVisible { + controller.drawMouseTick(at: mouseLoc) } } diff --git a/Free Ruler/AppStoreScreenshotPreview.swift b/Free Ruler/AppStoreScreenshotPreview.swift index 69596be..a5377da 100644 --- a/Free Ruler/AppStoreScreenshotPreview.swift +++ b/Free Ruler/AppStoreScreenshotPreview.swift @@ -1417,7 +1417,7 @@ private final class AppStoreScreenshotScenarioNSView: NSView { } } - private static func makeMeasureGroupedRulerPlacement() -> AppStoreViewPlacement { + private static func makeMeasureRulerWindowPlacement() -> AppStoreViewPlacement { let horizontalBoundsSize = NSSize( width: AppStoreMeasureScreenshotLayout.horizontalRulerLength / AppStoreMeasureScreenshotLayout.rulerScale, height: Ruler.thickness @@ -1436,7 +1436,7 @@ private final class AppStoreScreenshotScenarioNSView: NSView { frame: NSRect(origin: .zero, size: verticalBoundsSize) ) let color = RulerColors(customFill: Prefs.defaultRulerFillColor) - let groupedView = GroupedRulerContentView( + let rulerWindowView = RulerContentView( frame: NSRect(origin: .zero, size: boundsSize), horizontalRule: horizontalRule, verticalRule: verticalRule @@ -1446,19 +1446,19 @@ private final class AppStoreScreenshotScenarioNSView: NSView { verticalRule.color = color horizontalRule.showMouseTick = false verticalRule.showMouseTick = false - groupedView.color = color - groupedView.zeroCorner = .topLeft - groupedView.needsLayout = true - groupedView.layoutSubtreeIfNeeded() + rulerWindowView.color = color + rulerWindowView.zeroCorner = .topLeft + rulerWindowView.needsLayout = true + rulerWindowView.layoutSubtreeIfNeeded() return AppStoreViewPlacement( - view: groupedView, + view: rulerWindowView, frame: AppStoreMeasureScreenshotLayout.groupedRulerRect, boundsSize: boundsSize ) } - private static func makeGroupsGroupedRulerPlacement() -> AppStoreViewPlacement { + private static func makeGroupsRulerWindowPlacement() -> AppStoreViewPlacement { let horizontalBoundsSize = AppStoreGroupsScreenshotLayout.horizontalBoundsSize let verticalBoundsSize = AppStoreGroupsScreenshotLayout.verticalBoundsSize let boundsSize = AppStoreGroupsScreenshotLayout.groupedBoundsSize @@ -1471,7 +1471,7 @@ private final class AppStoreScreenshotScenarioNSView: NSView { frame: NSRect(origin: .zero, size: verticalBoundsSize) ) let color = RulerColors(customFill: AppStoreGroupsScreenshotLayout.rulerColor) - let groupedView = GroupedRulerContentView( + let rulerWindowView = RulerContentView( frame: NSRect(origin: .zero, size: boundsSize), horizontalRule: horizontalRule, verticalRule: verticalRule @@ -1481,20 +1481,20 @@ private final class AppStoreScreenshotScenarioNSView: NSView { verticalRule.color = color horizontalRule.showMouseTick = false verticalRule.showMouseTick = false - groupedView.color = color - groupedView.alphaValue = AppStoreGroupsScreenshotLayout.rulerOpacity - groupedView.zeroCorner = .topLeft - groupedView.needsLayout = true - groupedView.layoutSubtreeIfNeeded() + rulerWindowView.color = color + rulerWindowView.alphaValue = AppStoreGroupsScreenshotLayout.rulerOpacity + rulerWindowView.zeroCorner = .topLeft + rulerWindowView.needsLayout = true + rulerWindowView.layoutSubtreeIfNeeded() return AppStoreViewPlacement( - view: groupedView, + view: rulerWindowView, frame: AppStoreGroupsScreenshotLayout.groupedRulerRect, boundsSize: boundsSize ) } - private static func makeFlipGroupedRulerPlacements() -> [AppStoreViewPlacement] { + private static func makeFlipRulerWindowPlacements() -> [AppStoreViewPlacement] { AppStoreFlipScreenshotLayout.rulerSets.map { rulerSet in let horizontalBoundsSize = AppStoreFlipScreenshotLayout.horizontalBoundsSize(for: rulerSet) let verticalBoundsSize = AppStoreFlipScreenshotLayout.verticalBoundsSize(for: rulerSet) @@ -1510,7 +1510,7 @@ private final class AppStoreScreenshotScenarioNSView: NSView { zeroCorner: rulerSet.zeroCorner ) let color = RulerColors(customFill: rulerSet.fillColor) - let groupedView = GroupedRulerContentView( + let rulerWindowView = RulerContentView( frame: NSRect(origin: .zero, size: boundsSize), horizontalRule: horizontalRule, verticalRule: verticalRule @@ -1520,13 +1520,13 @@ private final class AppStoreScreenshotScenarioNSView: NSView { verticalRule.color = color horizontalRule.showMouseTick = false verticalRule.showMouseTick = false - groupedView.color = color - groupedView.zeroCorner = rulerSet.zeroCorner - groupedView.needsLayout = true - groupedView.layoutSubtreeIfNeeded() + rulerWindowView.color = color + rulerWindowView.zeroCorner = rulerSet.zeroCorner + rulerWindowView.needsLayout = true + rulerWindowView.layoutSubtreeIfNeeded() return AppStoreViewPlacement( - view: groupedView, + view: rulerWindowView, frame: rulerSet.groupedFrame(rulerScale: AppStoreFlipScreenshotLayout.rulerScale), boundsSize: boundsSize ) @@ -1547,11 +1547,11 @@ private final class AppStoreScreenshotScenarioNSView: NSView { ) -> [AppStoreViewPlacement] { switch screen.scenario { case .measure: - return [makeMeasureGroupedRulerPlacement()] + return [makeMeasureRulerWindowPlacement()] case .groups: - return [makeGroupsGroupedRulerPlacement()] + return [makeGroupsRulerWindowPlacement()] case .flipRulers: - return makeFlipGroupedRulerPlacements() + return makeFlipRulerWindowPlacements() case .units, .colors: return [] case .preferences: diff --git a/Free Ruler/LegacyRulerWindow.swift b/Free Ruler/LegacyRulerWindow.swift deleted file mode 100644 index f7a8386..0000000 --- a/Free Ruler/LegacyRulerWindow.swift +++ /dev/null @@ -1,160 +0,0 @@ -import Cocoa - -class LegacyRulerWindow: NSPanel { - - var ruler: Ruler - var rule: RuleView - - convenience init(_ ruler: Ruler) { - self.init(ruler: ruler) - } - - init(ruler: Ruler) { - self.ruler = ruler - self.rule = getRulerView(ruler: ruler) - - let styleMask: NSWindow.StyleMask = [ - .borderless, - .resizable, - .fullSizeContentView, - ] - - super.init( - contentRect: ruler.frame, - styleMask: styleMask, - backing: .buffered, - defer: false - ) - - self.alphaValue = windowAlphaValue(prefs.foregroundOpacity) - self.title = getTitle(for: ruler.orientation) - self.identifier = NSUserInterfaceItemIdentifier(getIdentifier(for: ruler.orientation)) - self.setAccessibilityIdentifier(getIdentifier(for: ruler.orientation)) - self.minSize = getMinSize(ruler: ruler) - self.maxSize = getMaxSize(ruler: ruler) - - self.isFloatingPanel = prefs.floatRulers - self.hidesOnDeactivate = false - self.isMovableByWindowBackground = true - self.hasShadow = prefs.rulerShadow - - rule.installWindowBorder() - rule.setAccessibilityElement(true) - rule.setAccessibilityIdentifier(getRuleIdentifier(for: ruler.orientation)) - - rule.nextResponder = self - self.contentView = rule - } - - override var canBecomeKey: Bool { - return true - } - - override var acceptsMouseMovedEvents: Bool { - get { return true } - set {} - } - - override func mouseDown(with event: NSEvent) { - nextResponder?.mouseDown(with: event) - super.mouseDown(with: event) - - if !leftMouseButtonIsPressed { - (nextResponder as? RulerController)?.finishMouseDrag(with: event) - } - } - - override func mouseUp(with event: NSEvent) { - nextResponder?.mouseUp(with: event) - super.mouseUp(with: event) - } - - private var leftMouseButtonIsPressed: Bool { - return NSEvent.pressedMouseButtons & 1 == 1 - } - -} - -extension LegacyRulerWindow: RulerContextMenuActivating { - func activateForRulerContextMenu() { - makeKey() - } -} - -private func getTitle(for orientation: Orientation) -> String { - switch orientation { - case .horizontal: - return NSLocalizedString( - "Horizontal Ruler", - comment: "Window title for the horizontal ruler" - ) - case .vertical: - return NSLocalizedString( - "Vertical Ruler", - comment: "Window title for the vertical ruler" - ) - } -} - -private func getIdentifier(for orientation: Orientation) -> String { - switch orientation { - case .horizontal: - return "horizontal-ruler-window" - case .vertical: - return "vertical-ruler-window" - } -} - -private func getRuleIdentifier(for orientation: Orientation) -> String { - switch orientation { - case .horizontal: - return "horizontal-ruler-view" - case .vertical: - return "vertical-ruler-view" - } -} - -extension LegacyRulerWindow { - private enum Distance: CGFloat { - case aLittle = 1 - case aLot = 10 - } - - func moveHorizontally(by pixels: CGFloat) { - var position = frame.origin - position.x = position.x + pixels - setFrameOrigin(position) - } - - func moveVertically(by pixels: CGFloat) { - var position = frame.origin - position.y = position.y + pixels - setFrameOrigin(position) - } - - private func distance(withShift: Bool) -> CGFloat { - let dist = withShift ? Distance.aLot : Distance.aLittle - return dist.rawValue - } - - func nudgeLeft(withShift shiftPressed: Bool) { - let dist = distance(withShift: shiftPressed) - moveHorizontally(by: dist * -1) - } - - func nudgeRight(withShift shiftPressed: Bool) { - let dist = distance(withShift: shiftPressed) - moveHorizontally(by: dist) - } - - func nudgeDown(withShift shiftPressed: Bool) { - let dist = distance(withShift: shiftPressed) - moveVertically(by: dist * -1) - } - - func nudgeUp(withShift shiftPressed: Bool) { - let dist = distance(withShift: shiftPressed) - moveVertically(by: dist) - } - -} diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index 153da90..83ed714 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -663,6 +663,7 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP window?.delegate = self window?.identifier = NSUserInterfaceItemIdentifier("preferences-window") + window?.setAccessibilityIdentifier("preferences-window") window?.isMovableByWindowBackground = true configureOpaqueColorPicking() settingsControlsView.delegate = self @@ -890,7 +891,7 @@ final class RulerSettingsWindow: NSPanel { final class RulerSettingsController: NSWindowController, NSWindowDelegate { - private weak var rulerController: GroupedRulerController? + private weak var rulerController: RulerController? private var colorPanelObserver: NSObjectProtocol? private var didConfigureWindow = false @@ -942,7 +943,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { return settingsControlsView.rulerShadowCheckbox } - var currentRulerController: GroupedRulerController? { + var currentRulerController: RulerController? { return rulerController } @@ -950,7 +951,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { return "RulerSettingsController" } - init(rulerController: GroupedRulerController) { + init(rulerController: RulerController) { self.rulerController = rulerController super.init(window: nil) loadWindow() @@ -1010,13 +1011,13 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { window?.center() } - func show(attachedTo controller: GroupedRulerController, sender: Any?) { + func show(attachedTo controller: RulerController, sender: Any?) { updateRulerController(controller) guard let settingsWindow = window else { return } configureOpaqueColorPicking() - if settingsWindow.parent === controller.groupedWindow { + if settingsWindow.parent === controller.rulerWindow { position(settingsWindow, attachedTo: controller) settingsWindow.orderFront(sender) settingsWindow.makeKey() @@ -1026,7 +1027,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { detachWindowIfNeeded() - guard controller.groupedWindow.isVisible else { + guard controller.rulerWindow.isVisible else { showWindow(sender) return } @@ -1036,7 +1037,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } position(settingsWindow, attachedTo: controller) - controller.groupedWindow.addChildWindow(settingsWindow, ordered: .above) + controller.rulerWindow.addChildWindow(settingsWindow, ordered: .above) settingsWindow.orderFront(sender) settingsWindow.makeKey() settingsWindow.makeFirstResponder(unitSegmentedControl) @@ -1056,7 +1057,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { closeSheetColorControls() } - func updateRulerController(_ controller: GroupedRulerController) { + func updateRulerController(_ controller: RulerController) { rulerController = controller updateView() } @@ -1148,7 +1149,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { unit: currentSettings?.unit ?? Prefs.defaultUnit, horizontalLength: rulerController?.state.layout.horizontalLength, verticalLength: rulerController?.state.layout.verticalLength, - dimensionScreen: rulerController?.groupedWindow.screen ?? window?.screen ?? NSScreen.main, + dimensionScreen: rulerController?.rulerWindow.screen ?? window?.screen ?? NSScreen.main, rulerColor: currentSettings?.rulerColor ?? Prefs.defaultRulerFillColor, foregroundOpacity: currentSettings?.foregroundOpacity ?? Prefs.defaultForegroundOpacity, backgroundOpacity: currentSettings?.backgroundOpacity ?? Prefs.defaultBackgroundOpacity, @@ -1269,7 +1270,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { guard let controller = rulerController, let settingsWindow = window, settingsWindow.isVisible, - settingsWindow.parent === controller.groupedWindow else { return } + settingsWindow.parent === controller.rulerWindow else { return } position(settingsWindow, attachedTo: controller) @@ -1302,11 +1303,11 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { applyRulerColor(colorPanel.color) } - private func position(_ settingsWindow: NSWindow, attachedTo controller: GroupedRulerController) { + private func position(_ settingsWindow: NSWindow, attachedTo controller: RulerController) { let settingsSize = settingsWindow.frame.size let frame = settingsFrame( size: settingsSize, - zeroPoint: controller.groupedWindow.zeroPoint(), + zeroPoint: controller.rulerWindow.zeroPoint(), zeroCorner: controller.state.settings.zeroCorner ) diff --git a/Free Ruler/Ruler.swift b/Free Ruler/Ruler.swift index 9a8ea47..ce20c69 100644 --- a/Free Ruler/Ruler.swift +++ b/Free Ruler/Ruler.swift @@ -1,5 +1,9 @@ import Cocoa +func windowAlphaValue(_ value: Int) -> CGFloat { + return CGFloat(value) / 100.0 +} + enum Orientation: String { case horizontal case vertical @@ -338,8 +342,8 @@ struct RulerLayoutState: Equatable, Codable { return (horizontalLength, verticalLength) } - func layout(zeroCorner: ZeroCorner) -> GroupedRulerLayout { - return GroupedRulerLayout.layout( + func layout(zeroCorner: ZeroCorner) -> RulerWindowLayout { + return RulerWindowLayout.layout( horizontalLength: horizontalLength, verticalLength: verticalLength, zeroPoint: zeroPoint, @@ -671,12 +675,3 @@ func getMaxSize(ruler: Ruler) -> NSSize { return NSSize(width: 40, height: 4000) } } - -func getRulerView(ruler: Ruler) -> RuleView { - switch ruler.orientation { - case .horizontal: - return HorizontalRule(frame: ruler.frame) - case .vertical: - return VerticalRule(frame: ruler.frame) - } -} diff --git a/Free Ruler/RulerController.swift b/Free Ruler/RulerController.swift deleted file mode 100644 index 0bfb1e5..0000000 --- a/Free Ruler/RulerController.swift +++ /dev/null @@ -1,290 +0,0 @@ -import Cocoa -import Carbon.HIToolbox // For key constants - - -class RulerController: NSWindowController, NSWindowDelegate, NotificationObserver { - - var observers: [NSKeyValueObservation] = [] - var notificationObservers: [NSObjectProtocol] = [] - - let ruler: Ruler - - let rulerWindow: LegacyRulerWindow - var otherWindow: LegacyRulerWindow? - - var keyListener: Any? - private var mouseInteraction: RulerMouseInteractionState! - var isLeftMouseButtonPressed = { - return NSEvent.pressedMouseButtons & 1 == 1 - } - - var preferencesWindowOpen = false { - didSet { - updateIsFloatingPanel() - // reset opacity to foreground in case they modified background opacity last - if !preferencesWindowOpen { - opacity = prefs.foregroundOpacity - } - } - } - - var opacity = prefs.foregroundOpacity { - didSet { - rulerWindow.alphaValue = windowAlphaValue(opacity) - } - } - - convenience init(_ ruler: Ruler) { - self.init(ruler: ruler) - } - - init(ruler: Ruler) { - self.ruler = ruler - self.rulerWindow = LegacyRulerWindow(ruler) - - super.init(window: self.rulerWindow) - - createObservers() - subscribeToPrefs() - - rulerWindow.delegate = self - rulerWindow.nextResponder = self - mouseInteraction = RulerMouseInteractionState(owner: self) { [weak self] event in - return self?.isMouseInsideRuler(with: event) ?? false - } - - if let windowFrameAutosaveName = ruler.name { - self.windowFrameAutosaveName = windowFrameAutosaveName - } - - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented. Use init(ruler: Ruler)") - } - - deinit { - mouseInteraction?.invalidate() - removeObservers(¬ificationObservers) - } - - func createObservers() { - notificationObservers = [ - addObserver(.preferencesWindowOpened) { [weak self] _ in - self?.preferencesWindowOpen = true - }, - addObserver(.preferencesWindowClosed) { [weak self] _ in - self?.preferencesWindowOpen = false - }, - ] - } - - func windowWillStartLiveResize(_ notification: Notification) { - mouseInteraction.windowWillStartLiveResize() - } - - func windowDidEndLiveResize(_ notification: Notification) { - mouseInteraction.windowDidEndLiveResize() - } - - func windowWillMove(_ notification: Notification) { - mouseInteraction.windowWillMove() - } - - func windowDidMove(_ notification: Notification) { - rulerWindow.invalidateShadow() - mouseInteraction.windowDidMove(isLeftMouseButtonPressed: isLeftMouseButtonPressed()) - } - - func windowDidBecomeKey(_ notification: Notification) { - updateChildWindow() - startKeyListener() - } - - func windowDidResignKey(_ notification: Notification) { - updateChildWindow() - stopKeyListener() - } - - override func mouseEntered(with event: NSEvent) { - mouseInteraction.mouseEntered(with: event) - } - - override func mouseExited(with event: NSEvent) { - mouseInteraction.mouseExited(with: event) - } - - override func mouseDown(with event: NSEvent) { - mouseInteraction.mouseDown(with: event) - } - - override func mouseUp(with event: NSEvent) { - finishMouseDrag(with: event) - } - - func finishMouseDrag(with event: NSEvent) { - mouseInteraction.finishMouseDrag(with: event) - } - - override func mouseMoved(with event: NSEvent) { - mouseInteraction.mouseMoved(with: event) - } - - func disableMouseTicks() { - mouseInteraction.disableMouseTicks() - } - - func enableMouseTicks() { - mouseInteraction.enableMouseTicks() - } - - private func isMouseInsideRuler(with event: NSEvent) -> Bool { - let location = rulerWindow.rule.convert(event.locationInWindow, from: nil) - return rulerWindow.rule.bounds.contains(location) - } - - func onChangeGrouped() { - updateChildWindow() - } - - func updateChildWindow() { - guard let otherWindow = otherWindow else { return } - - if prefs.groupRulers && rulerWindow.isKeyWindow { - rulerWindow.addChildWindow(otherWindow, ordered: .below) - } else { - rulerWindow.removeChildWindow(otherWindow) - } - } - - func updateIsFloatingPanel() { - // never float while preferences window is open - if preferencesWindowOpen { - rulerWindow.isFloatingPanel = false - } else { - rulerWindow.isFloatingPanel = prefs.floatRulers - } - } - - func foreground() { - opacity = prefs.foregroundOpacity - } - func background() { - opacity = prefs.backgroundOpacity - } - - func subscribeToPrefs() { - observers = [ - prefs.observe(\Prefs.foregroundOpacity, options: .new) { prefs, changed in - self.opacity = prefs.foregroundOpacity - }, - prefs.observe(\Prefs.backgroundOpacity, options: .new) { prefs, changed in - self.opacity = prefs.backgroundOpacity - }, - prefs.observe(\Prefs.floatRulers, options: .new) { prefs, changed in - self.updateIsFloatingPanel() - }, - prefs.observe(\Prefs.groupRulers, options: .new) { prefs, changed in - self.updateChildWindow() - }, - prefs.observe(\Prefs.rulerShadow, options: .new) { prefs, changed in - self.rulerWindow.hasShadow = prefs.rulerShadow - }, - ] - } - - func alignRuler(at point: NSPoint) { - // only key window controller should respond to this command - guard rulerWindow.isKeyWindow else { return } - - if prefs.groupRulers { - // if grouped, ungroup rulers, move both, regroup - prefs.groupRulers = false - alignRuler(window: rulerWindow, at: point) - alignRuler(window: otherWindow, at: point) - prefs.groupRulers = true - } else { - // if not groups, just move key window - alignRuler(window: rulerWindow, at: point) - } - } - - private func alignRuler(window: LegacyRulerWindow?, at point: NSPoint) { - guard let window = window else { return } - - let frame = window.frame - let rect = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).frame( - for: window.ruler.orientation, - zeroPoint: point, - size: frame.size - ) - - window.setFrame(rect, display: false) - } - - func resetPosition() { - let frame = getDefaultContentRect(orientation: ruler.orientation, zeroCorner: prefs.zeroCorner) - rulerWindow.setFrame(frame, display: true) - } - -} - -// MARK: KeyListener - -extension RulerController { - - func startKeyListener() { - self.keyListener = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] in - guard let self = self else { return $0 } - return self.onKeyDown(with: $0) - } - } - - func stopKeyListener() { - if let keyListener = self.keyListener { - NSEvent.removeMonitor(keyListener) - self.keyListener = nil - } - } - - // Return nil if the event was handled here. - func onKeyDown(with event: NSEvent) -> NSEvent? { - // print(ruler.orientation, "onKeyDown") - - let shift = event.modifierFlags.contains(.shift) - let keyboardModifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - - if rulerWindow.isKeyWindow, - let appDelegate = NSApp.delegate as? AppDelegate, - appDelegate.performRulerHotkey( - keyCode: Int(event.keyCode), - modifierFlags: keyboardModifiers, - sender: self - ) { - return nil - } - - switch Int(event.keyCode) { - case kVK_LeftArrow: - rulerWindow.nudgeLeft(withShift: shift) - return nil - case kVK_RightArrow: - rulerWindow.nudgeRight(withShift: shift) - return nil - case kVK_UpArrow: - rulerWindow.nudgeUp(withShift: shift) - return nil - case kVK_DownArrow: - rulerWindow.nudgeDown(withShift: shift) - return nil - default: - return event - } - } - -} - -// helper to convert opacity Int to window.alphaValue -func windowAlphaValue(_ value: Int) -> CGFloat { - return CGFloat(value) / 100.0 -} diff --git a/Free Ruler/RulerWindow.swift b/Free Ruler/RulerWindow.swift index 77e848a..f034402 100644 --- a/Free Ruler/RulerWindow.swift +++ b/Free Ruler/RulerWindow.swift @@ -1,7 +1,7 @@ import Cocoa import Carbon.HIToolbox -struct GroupedRulerLayout: Equatable { +struct RulerWindowLayout: Equatable { let groupFrame: NSRect let horizontalFrame: NSRect let verticalFrame: NSRect @@ -10,7 +10,7 @@ struct GroupedRulerLayout: Equatable { horizontalFrame: NSRect, verticalFrame: NSRect, zeroCorner: ZeroCorner - ) -> GroupedRulerLayout { + ) -> RulerWindowLayout { let zeroPoint = ZeroCornerGeometry(zeroCorner: zeroCorner) .zeroPoint(in: horizontalFrame, for: .horizontal) @@ -25,7 +25,7 @@ struct GroupedRulerLayout: Equatable { static func layout( groupFrame: NSRect, zeroCorner: ZeroCorner - ) -> GroupedRulerLayout { + ) -> RulerWindowLayout { let zeroPoint = zeroPoint(in: groupFrame, zeroCorner: zeroCorner) let horizontalLength = length( in: groupFrame, @@ -53,7 +53,7 @@ struct GroupedRulerLayout: Equatable { verticalLength: CGFloat, zeroPoint: NSPoint, zeroCorner: ZeroCorner - ) -> GroupedRulerLayout { + ) -> RulerWindowLayout { let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) let horizontalFrame = geometry.frame( for: .horizontal, @@ -66,7 +66,7 @@ struct GroupedRulerLayout: Equatable { size: NSSize(width: Ruler.thickness, height: verticalLength) ) - return GroupedRulerLayout( + return RulerWindowLayout( groupFrame: horizontalFrame.union(verticalFrame), horizontalFrame: horizontalFrame, verticalFrame: verticalFrame @@ -226,7 +226,7 @@ struct GroupedRulerLayout: Equatable { } } -private extension GroupedRulerLayout { +private extension RulerWindowLayout { func emptyCornerFrame(zeroCorner: ZeroCorner) -> NSRect { let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) let x: CGFloat @@ -266,18 +266,18 @@ final class RulerWindow: NSPanel { let horizontalRule: HorizontalRule let verticalRule: VerticalRule - private let groupedContentView: GroupedRulerContentView + private let rulerContentView: RulerContentView private(set) var settings: RulerSettings init(frame: NSRect, settings: RulerSettings = RulerSettings(defaults: prefs)) { self.settings = settings - horizontalRule = GroupedHorizontalRule( + horizontalRule = RulerWindowHorizontalRule( frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness) ) - verticalRule = GroupedVerticalRule( + verticalRule = RulerWindowVerticalRule( frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300) ) - groupedContentView = GroupedRulerContentView( + rulerContentView = RulerContentView( frame: NSRect(origin: .zero, size: frame.size), horizontalRule: horizontalRule, verticalRule: verticalRule @@ -301,10 +301,10 @@ final class RulerWindow: NSPanel { "Ruler", comment: "Window title for a ruler window" ) - identifier = NSUserInterfaceItemIdentifier("grouped-ruler-window") - setAccessibilityIdentifier("grouped-ruler-window") - minSize = GroupedRulerLayout.minSize(zeroCorner: settings.zeroCorner) - maxSize = GroupedRulerLayout.maxSize(zeroCorner: settings.zeroCorner) + identifier = NSUserInterfaceItemIdentifier("ruler-window") + setAccessibilityIdentifier("ruler-window") + minSize = RulerWindowLayout.minSize(zeroCorner: settings.zeroCorner) + maxSize = RulerWindowLayout.maxSize(zeroCorner: settings.zeroCorner) isOpaque = false backgroundColor = .clear @@ -319,9 +319,9 @@ final class RulerWindow: NSPanel { verticalRule.setAccessibilityIdentifier("vertical-ruler-view") horizontalRule.nextResponder = self verticalRule.nextResponder = self - groupedContentView.nextResponder = self + rulerContentView.nextResponder = self - contentView = groupedContentView + contentView = rulerContentView apply(settings: settings) updateLayoutForCurrentZeroCorner() } @@ -337,12 +337,12 @@ final class RulerWindow: NSPanel { override func setFrame(_ frameRect: NSRect, display flag: Bool) { super.setFrame(frameRect, display: flag) - updateGroupedContentFrame() + updateRulerContentFrame() } override func setContentSize(_ size: NSSize) { super.setContentSize(size) - updateGroupedContentFrame() + updateRulerContentFrame() } override func mouseDown(with event: NSEvent) { @@ -350,7 +350,7 @@ final class RulerWindow: NSPanel { super.mouseDown(with: event) if !leftMouseButtonIsPressed { - (nextResponder as? GroupedRulerController)?.finishMouseDrag(with: event) + (nextResponder as? RulerController)?.finishMouseDrag(with: event) } } @@ -373,11 +373,11 @@ final class RulerWindow: NSPanel { func updateLayoutForCurrentZeroCorner() { updateSizeConstraintsForVisibleRules() - updateGroupedContentFrame() - groupedContentView.zeroCorner = settings.zeroCorner - groupedContentView.needsLayout = true - groupedContentView.layoutSubtreeIfNeeded() - groupedContentView.needsDisplay = true + updateRulerContentFrame() + rulerContentView.zeroCorner = settings.zeroCorner + rulerContentView.needsLayout = true + rulerContentView.layoutSubtreeIfNeeded() + rulerContentView.needsDisplay = true } func apply(settings: RulerSettings) { @@ -387,7 +387,7 @@ final class RulerWindow: NSPanel { hasShadow = settings.rulerShadow horizontalRule.settingsOverride = settings verticalRule.settingsOverride = settings - groupedContentView.color = RulerColors(customFill: settings.rulerColor) + rulerContentView.color = RulerColors(customFill: settings.rulerColor) updateLayoutForCurrentZeroCorner() } @@ -398,36 +398,36 @@ final class RulerWindow: NSPanel { } func screenFrame(for orientation: Orientation) -> NSRect { - return convertToScreen(groupedContentView.localFrame(for: orientation)) + return convertToScreen(rulerContentView.localFrame(for: orientation)) } - func visibleFrame(in layout: GroupedRulerLayout) -> NSRect { + func visibleFrame(in layout: RulerWindowLayout) -> NSRect { return layout.visibleFrame( - showsHorizontalRule: groupedContentView.showsHorizontalRule, - showsVerticalRule: groupedContentView.showsVerticalRule + showsHorizontalRule: rulerContentView.showsHorizontalRule, + showsVerticalRule: rulerContentView.showsVerticalRule ) } func setVisibleRules(horizontal: Bool, vertical: Bool) { - groupedContentView.showsHorizontalRule = horizontal - groupedContentView.showsVerticalRule = vertical + rulerContentView.showsHorizontalRule = horizontal + rulerContentView.showsVerticalRule = vertical updateSizeConstraintsForVisibleRules() - groupedContentView.needsLayout = true - groupedContentView.layoutSubtreeIfNeeded() + rulerContentView.needsLayout = true + rulerContentView.layoutSubtreeIfNeeded() } func isRuleVisible(_ orientation: Orientation) -> Bool { switch orientation { case .horizontal: - return groupedContentView.showsHorizontalRule + return rulerContentView.showsHorizontalRule case .vertical: - return groupedContentView.showsVerticalRule + return rulerContentView.showsVerticalRule } } func isEmptyCorner(atWindowPoint windowPoint: NSPoint) -> Bool { - let contentPoint = groupedContentView.convert(windowPoint, from: nil) - return groupedContentView.containsEmptyCorner(contentPoint) + let contentPoint = rulerContentView.convert(windowPoint, from: nil) + return rulerContentView.containsEmptyCorner(contentPoint) } func zeroPoint() -> NSPoint { @@ -454,24 +454,24 @@ final class RulerWindow: NSPanel { return NSEvent.pressedMouseButtons & 1 == 1 } - private func updateGroupedContentFrame() { - guard contentView === groupedContentView else { return } + private func updateRulerContentFrame() { + guard contentView === rulerContentView else { return } - groupedContentView.frame = NSRect(origin: .zero, size: frame.size) - groupedContentView.needsLayout = true - groupedContentView.layoutSubtreeIfNeeded() + rulerContentView.frame = NSRect(origin: .zero, size: frame.size) + rulerContentView.needsLayout = true + rulerContentView.layoutSubtreeIfNeeded() } private func updateSizeConstraintsForVisibleRules() { - minSize = GroupedRulerLayout.minSize( + minSize = RulerWindowLayout.minSize( zeroCorner: settings.zeroCorner, - showsHorizontalRule: groupedContentView.showsHorizontalRule, - showsVerticalRule: groupedContentView.showsVerticalRule + showsHorizontalRule: rulerContentView.showsHorizontalRule, + showsVerticalRule: rulerContentView.showsVerticalRule ) - maxSize = GroupedRulerLayout.maxSize( + maxSize = RulerWindowLayout.maxSize( zeroCorner: settings.zeroCorner, - showsHorizontalRule: groupedContentView.showsHorizontalRule, - showsVerticalRule: groupedContentView.showsVerticalRule + showsHorizontalRule: rulerContentView.showsHorizontalRule, + showsVerticalRule: rulerContentView.showsVerticalRule ) } } @@ -479,17 +479,17 @@ final class RulerWindow: NSPanel { extension RulerWindow: RulerContextMenuActivating { func activateForRulerContextMenu() { makeKey() - (nextResponder as? GroupedRulerController)?.activateForRulerContextMenu() + (nextResponder as? RulerController)?.activateForRulerContextMenu() } } -private final class GroupedHorizontalRule: HorizontalRule { +private final class RulerWindowHorizontalRule: HorizontalRule { override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(NSSize(width: newSize.width, height: Ruler.thickness)) } } -private final class GroupedVerticalRule: VerticalRule { +private final class RulerWindowVerticalRule: VerticalRule { override var rulerWidth: CGFloat { return Ruler.thickness } @@ -565,7 +565,7 @@ private final class RulerClipView: NSView { } } -private final class GroupedRulerBorderView: RulerBorderView { +private final class RulerWindowBorderView: RulerBorderView { var zeroCorner = prefs.zeroCorner { didSet { needsDisplay = true @@ -598,7 +598,7 @@ private final class GroupedRulerBorderView: RulerBorderView { } private func lShapedBorderPath() -> NSBezierPath { - return groupedRulerLShapedPath( + return rulerWindowLShapedPath( in: bounds, zeroCorner: zeroCorner, inset: Self.borderCenterInset @@ -613,7 +613,7 @@ private final class GroupedRulerBorderView: RulerBorderView { } } -private final class GroupedRulerZeroLabelsView: NSView { +private final class RulerWindowZeroLabelsView: NSView { private let horizontalRule: HorizontalRule private let verticalRule: VerticalRule private let zeroLabel = "0" @@ -763,7 +763,7 @@ private final class GroupedRulerZeroLabelsView: NSView { } } -final class GroupedRulerContentView: NSView { +final class RulerContentView: NSView { let horizontalRule: HorizontalRule let verticalRule: VerticalRule private let horizontalHost = RulerClipView(frame: .zero) @@ -772,8 +772,8 @@ final class GroupedRulerContentView: NSView { orientation: .horizontal, label: NSAttributedString(string: "") ) - private let zeroLabelsView: GroupedRulerZeroLabelsView - private let borderView = GroupedRulerBorderView(frame: .zero) + private let zeroLabelsView: RulerWindowZeroLabelsView + private let borderView = RulerWindowBorderView(frame: .zero) private var cornerTrackingArea: NSTrackingArea? var showsHorizontalRule = true { @@ -815,7 +815,7 @@ final class GroupedRulerContentView: NSView { ) { self.horizontalRule = horizontalRule self.verticalRule = verticalRule - self.zeroLabelsView = GroupedRulerZeroLabelsView( + self.zeroLabelsView = RulerWindowZeroLabelsView( horizontalRule: horizontalRule, verticalRule: verticalRule ) @@ -876,7 +876,7 @@ final class GroupedRulerContentView: NSView { override func layout() { super.layout() - let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) + let layout = RulerWindowLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) let cornerFrame = layout.emptyCornerFrame(zeroCorner: zeroCorner) setFrame(ruleFrame(for: .horizontal, in: bounds, layout: layout), for: horizontalHost) setFrame(horizontalHost.bounds, for: horizontalRule) @@ -947,12 +947,12 @@ final class GroupedRulerContentView: NSView { } func localFrame(for orientation: Orientation) -> NSRect { - let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) + let layout = RulerWindowLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) return ruleFrame(for: orientation, in: bounds, layout: layout) } private func cornerFrame() -> NSRect { - return GroupedRulerLayout + return RulerWindowLayout .layout(groupFrame: bounds, zeroCorner: zeroCorner) .emptyCornerFrame(zeroCorner: zeroCorner) } @@ -960,7 +960,7 @@ final class GroupedRulerContentView: NSView { private func ruleFrame( for orientation: Orientation, in bounds: NSRect, - layout: GroupedRulerLayout + layout: RulerWindowLayout ) -> NSRect { switch (showsHorizontalRule, showsVerticalRule) { case (true, true): @@ -1039,11 +1039,11 @@ final class GroupedRulerContentView: NSView { } private func rulerFillPath() -> NSBezierPath { - let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) + let layout = RulerWindowLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) switch (showsHorizontalRule, showsVerticalRule) { case (true, true): - return groupedRulerLShapedPath(in: bounds, zeroCorner: zeroCorner, inset: 0) + return rulerWindowLShapedPath(in: bounds, zeroCorner: zeroCorner, inset: 0) case (true, false): return NSBezierPath(rect: ruleFrame(for: .horizontal, in: bounds, layout: layout)) case (false, true): @@ -1089,17 +1089,17 @@ final class GroupedRulerContentView: NSView { } #if !SNAPSHOT_GENERATOR -final class GroupedRulerController: NSWindowController, NSWindowDelegate, NotificationObserver { +final class RulerController: NSWindowController, NSWindowDelegate, NotificationObserver { var observers: [NSKeyValueObservation] = [] var notificationObservers: [NSObjectProtocol] = [] - let groupedWindow: RulerWindow + let rulerWindow: RulerWindow var state: RulerInstanceState - var onBecameActive: ((GroupedRulerController) -> Void)? - var onDragStarted: ((GroupedRulerController) -> Void)? - var onDragged: ((GroupedRulerController) -> Void)? - var onDragFinished: ((GroupedRulerController) -> Void)? - var onStateChanged: ((GroupedRulerController) -> Void)? + var onBecameActive: ((RulerController) -> Void)? + var onDragStarted: ((RulerController) -> Void)? + var onDragged: ((RulerController) -> Void)? + var onDragFinished: ((RulerController) -> Void)? + var onStateChanged: ((RulerController) -> Void)? private var keyListener: Any? private var mouseInteraction: RulerMouseInteractionState! @@ -1121,12 +1121,12 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi var opacity = 0 { didSet { - groupedWindow.alphaValue = windowAlphaValue(opacity) + rulerWindow.alphaValue = windowAlphaValue(opacity) } } convenience init(frame: NSRect) { - let layout = GroupedRulerLayout.layout(groupFrame: frame, zeroCorner: prefs.zeroCorner) + let layout = RulerWindowLayout.layout(groupFrame: frame, zeroCorner: prefs.zeroCorner) let state = RulerInstanceState( settings: RulerSettings(defaults: prefs), layout: RulerLayoutState( @@ -1147,21 +1147,21 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi self.state = state self.followsDefaultPreferences = followsDefaultPreferences let layout = state.layout.layout(zeroCorner: state.settings.zeroCorner) - groupedWindow = RulerWindow( + rulerWindow = RulerWindow( frame: layout.visibleFrame( showsHorizontalRule: state.visibility.showsHorizontal, showsVerticalRule: state.visibility.showsVertical ), settings: state.settings ) - super.init(window: groupedWindow) + super.init(window: rulerWindow) opacity = state.settings.foregroundOpacity createObservers() subscribeToPrefs() - groupedWindow.delegate = self - groupedWindow.nextResponder = self + rulerWindow.delegate = self + rulerWindow.nextResponder = self mouseInteraction = RulerMouseInteractionState(owner: self) { [weak self] event in return self?.mouseIsInsideRuler(with: event) ?? false } @@ -1179,13 +1179,13 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } var isVisible: Bool { - return groupedWindow.isVisible + return rulerWindow.isVisible } func show() { applyStateToWindow(display: false) showWindow(self) - groupedWindow.orderFrontRegardless() + rulerWindow.orderFrontRegardless() } func show( @@ -1208,7 +1208,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } func hide() { - groupedWindow.orderOut(self) + rulerWindow.orderOut(self) } @discardableResult @@ -1229,42 +1229,26 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi return true } - func syncFrames( - to horizontalWindow: LegacyRulerWindow, - and verticalWindow: LegacyRulerWindow, - persistAutosave: Bool = false - ) { - guard isVisible else { return } - - let frames = syncedRulerFrames( - horizontalWindow: horizontalWindow, - verticalWindow: verticalWindow - ) - - syncFrame(frames.horizontal, to: horizontalWindow, persistAutosave: persistAutosave) - syncFrame(frames.vertical, to: verticalWindow, persistAutosave: persistAutosave) - } - func align(at point: NSPoint) { - let horizontalLength = groupedWindow.screenFrame(for: .horizontal).width - let verticalLength = groupedWindow.screenFrame(for: .vertical).height - let layout = GroupedRulerLayout.layout( + let horizontalLength = rulerWindow.screenFrame(for: .horizontal).width + let verticalLength = rulerWindow.screenFrame(for: .vertical).height + let layout = RulerWindowLayout.layout( horizontalLength: horizontalLength, verticalLength: verticalLength, zeroPoint: point, zeroCorner: state.settings.zeroCorner ) - groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) - groupedWindow.updateLayoutForCurrentZeroCorner() + rulerWindow.setFrame(rulerWindow.visibleFrame(in: layout), display: true) + rulerWindow.updateLayoutForCurrentZeroCorner() captureStateFromWindow() } func prepareForZeroCornerChange(to zeroCorner: ZeroCorner) { - let zeroPoint = groupedWindow.zeroPoint() - let horizontalLength = groupedWindow.screenFrame(for: .horizontal).width - let verticalLength = groupedWindow.screenFrame(for: .vertical).height - let layout = GroupedRulerLayout.layout( + let zeroPoint = rulerWindow.zeroPoint() + let horizontalLength = rulerWindow.screenFrame(for: .horizontal).width + let verticalLength = rulerWindow.screenFrame(for: .vertical).height + let layout = RulerWindowLayout.layout( horizontalLength: horizontalLength, verticalLength: verticalLength, zeroPoint: zeroPoint, @@ -1272,11 +1256,11 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi ) state.settings.zeroCorner = zeroCorner - groupedWindow.apply(settings: state.settings) - groupedWindow.alphaValue = windowAlphaValue(opacity) + rulerWindow.apply(settings: state.settings) + rulerWindow.alphaValue = windowAlphaValue(opacity) updateIsFloatingPanel() updateHasShadow() - groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) + rulerWindow.setFrame(rulerWindow.visibleFrame(in: layout), display: true) captureStateFromWindow() notifyStateChanged() } @@ -1290,15 +1274,15 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } func updateIsFloatingPanel() { - groupedWindow.isFloatingPanel = preferencesWindowOpen ? false : state.settings.floatRulers + rulerWindow.isFloatingPanel = preferencesWindowOpen ? false : state.settings.floatRulers } func updateHasShadow() { - groupedWindow.hasShadow = state.settings.rulerShadow + rulerWindow.hasShadow = state.settings.rulerShadow } func redrawForPreferenceChange() { - groupedWindow.redrawForPreferenceChange() + rulerWindow.redrawForPreferenceChange() } func updateSettings(_ update: (inout RulerSettings) -> Void) { @@ -1314,7 +1298,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi let maxVerticalLength = getMaxSize(ruler: Ruler(.vertical)).height state.layout = RulerLayoutState( - zeroPoint: groupedWindow.zeroPoint(), + zeroPoint: rulerWindow.zeroPoint(), horizontalLength: min(max(horizontalLength, minHorizontalLength), maxHorizontalLength), verticalLength: min(max(verticalLength, minVerticalLength), maxVerticalLength) ) @@ -1323,7 +1307,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } func move(to frame: NSRect) { - groupedWindow.setFrame(frame, display: false) + rulerWindow.setFrame(frame, display: false) captureStateFromWindow() } @@ -1338,11 +1322,11 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } func drawMouseTick(at mouseLoc: NSPoint) { - if groupedWindow.isRuleVisible(.horizontal) { - groupedWindow.horizontalRule.drawMouseTick(at: mouseLoc) + if rulerWindow.isRuleVisible(.horizontal) { + rulerWindow.horizontalRule.drawMouseTick(at: mouseLoc) } - if groupedWindow.isRuleVisible(.vertical) { - groupedWindow.verticalRule.drawMouseTick(at: mouseLoc) + if rulerWindow.isRuleVisible(.vertical) { + rulerWindow.verticalRule.drawMouseTick(at: mouseLoc) } } @@ -1352,53 +1336,53 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } private func updateMouseTickDrawingVisibility() { - groupedWindow.horizontalRule.showMouseTick = isMouseTickDrawingEnabled - && groupedWindow.isRuleVisible(.horizontal) - groupedWindow.verticalRule.showMouseTick = isMouseTickDrawingEnabled - && groupedWindow.isRuleVisible(.vertical) + rulerWindow.horizontalRule.showMouseTick = isMouseTickDrawingEnabled + && rulerWindow.isRuleVisible(.horizontal) + rulerWindow.verticalRule.showMouseTick = isMouseTickDrawingEnabled + && rulerWindow.isRuleVisible(.vertical) } private func applyStateToWindow(display: Bool) { let zeroCorner = state.settings.zeroCorner let layout = state.layout.layout(zeroCorner: zeroCorner) - groupedWindow.apply(settings: state.settings) - groupedWindow.alphaValue = windowAlphaValue(opacity) + rulerWindow.apply(settings: state.settings) + rulerWindow.alphaValue = windowAlphaValue(opacity) updateIsFloatingPanel() updateHasShadow() - groupedWindow.setVisibleRules( + rulerWindow.setVisibleRules( horizontal: state.visibility.showsHorizontal, vertical: state.visibility.showsVertical ) updateMouseTickDrawingVisibility() - groupedWindow.setFrame( + rulerWindow.setFrame( layout.visibleFrame( showsHorizontalRule: state.visibility.showsHorizontal, showsVerticalRule: state.visibility.showsVertical ), display: display ) - groupedWindow.updateLayoutForCurrentZeroCorner() + rulerWindow.updateLayoutForCurrentZeroCorner() } private func captureStateFromWindow() { var horizontalLength = state.layout.horizontalLength var verticalLength = state.layout.verticalLength - if groupedWindow.isRuleVisible(.horizontal) { - horizontalLength = groupedWindow.screenFrame(for: .horizontal).width + if rulerWindow.isRuleVisible(.horizontal) { + horizontalLength = rulerWindow.screenFrame(for: .horizontal).width } - if groupedWindow.isRuleVisible(.vertical) { - verticalLength = groupedWindow.screenFrame(for: .vertical).height + if rulerWindow.isRuleVisible(.vertical) { + verticalLength = rulerWindow.screenFrame(for: .vertical).height } state.layout = RulerLayoutState( - zeroPoint: groupedWindow.zeroPoint(), + zeroPoint: rulerWindow.zeroPoint(), horizontalLength: horizontalLength, verticalLength: verticalLength ) state.visibility = RulerWingVisibility( - horizontal: groupedWindow.isRuleVisible(.horizontal), - vertical: groupedWindow.isRuleVisible(.vertical) + horizontal: rulerWindow.isRuleVisible(.horizontal), + vertical: rulerWindow.isRuleVisible(.vertical) ) notifyStateChanged() } @@ -1407,80 +1391,11 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi onStateChanged?(self) } - private func syncedRulerFrames( - horizontalWindow: LegacyRulerWindow, - verticalWindow: LegacyRulerWindow - ) -> (horizontal: NSRect, vertical: NSRect) { - let showsHorizontalRule = groupedWindow.isRuleVisible(.horizontal) - let showsVerticalRule = groupedWindow.isRuleVisible(.vertical) - - switch (showsHorizontalRule, showsVerticalRule) { - case (true, true): - return ( - groupedWindow.screenFrame(for: .horizontal), - groupedWindow.screenFrame(for: .vertical) - ) - case (true, false): - let horizontalFrame = groupedWindow.screenFrame(for: .horizontal) - let zeroPoint = ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner) - .zeroPoint(in: horizontalFrame, for: .horizontal) - return ( - horizontalFrame, - hiddenRuleFrame( - orientation: .vertical, - zeroPoint: zeroPoint, - size: verticalWindow.frame.size - ) - ) - case (false, true): - let verticalFrame = groupedWindow.screenFrame(for: .vertical) - let zeroPoint = ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner) - .zeroPoint(in: verticalFrame, for: .vertical) - return ( - hiddenRuleFrame( - orientation: .horizontal, - zeroPoint: zeroPoint, - size: horizontalWindow.frame.size - ), - verticalFrame - ) - case (false, false): - return (horizontalWindow.frame, verticalWindow.frame) - } - } - - private func hiddenRuleFrame( - orientation: Orientation, - zeroPoint: NSPoint, - size: NSSize - ) -> NSRect { - return ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner).frame( - for: orientation, - zeroPoint: zeroPoint, - size: size - ) - } - - private func syncFrame( - _ frame: NSRect, - to window: LegacyRulerWindow, - persistAutosave: Bool - ) { - window.setFrame(frame, display: false) - - guard persistAutosave else { return } - - if let frameAutosaveName = window.ruler.name { - window.saveFrame(usingName: NSWindow.FrameAutosaveName(frameAutosaveName)) - } - } - func windowWillStartLiveResize(_ notification: Notification) { mouseInteraction.windowWillStartLiveResize() } func windowDidEndLiveResize(_ notification: Notification) { - syncRulerWindowFrames(persistAutosave: true) captureStateFromWindow() mouseInteraction.windowDidEndLiveResize() } @@ -1490,12 +1405,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } func windowDidMove(_ notification: Notification) { - groupedWindow.invalidateShadow() - syncRulerWindowFrames( - persistAutosave: mouseInteraction.shouldPersistFrameAutosaveOnWindowMove( - isLeftMouseButtonPressed: isLeftMouseButtonPressed() - ) - ) + rulerWindow.invalidateShadow() captureStateFromWindow() onDragged?(self) mouseInteraction.windowDidMove(isLeftMouseButtonPressed: isLeftMouseButtonPressed()) @@ -1529,7 +1439,6 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi func finishMouseDrag(with event: NSEvent) { if mouseInteraction.finishMouseDrag(with: event) { - syncRulerWindowFrames(persistAutosave: true) captureStateFromWindow() onDragFinished?(self) } @@ -1543,30 +1452,22 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi mouseInteraction.mouseMoved(with: event) } - private var appDelegate: AppDelegate? { - return NSApp.delegate as? AppDelegate - } - - private func syncRulerWindowFrames(persistAutosave: Bool = false) { - appDelegate?.syncGroupedRulerFramesToRulerWindows(persistAutosave: persistAutosave) - } - private func mouseIsInsideRuler(with event: NSEvent) -> Bool { return orientation(at: event) != nil - || groupedWindow.isEmptyCorner(atWindowPoint: event.locationInWindow) + || rulerWindow.isEmptyCorner(atWindowPoint: event.locationInWindow) } private func orientation(at event: NSEvent) -> Orientation? { - let horizontalLocation = groupedWindow.horizontalRule.convert(event.locationInWindow, from: nil) - let verticalLocation = groupedWindow.verticalRule.convert(event.locationInWindow, from: nil) + let horizontalLocation = rulerWindow.horizontalRule.convert(event.locationInWindow, from: nil) + let verticalLocation = rulerWindow.verticalRule.convert(event.locationInWindow, from: nil) - if groupedWindow.isRuleVisible(.horizontal), - groupedWindow.horizontalRule.bounds.contains(horizontalLocation) { + if rulerWindow.isRuleVisible(.horizontal), + rulerWindow.horizontalRule.bounds.contains(horizontalLocation) { return .horizontal } - if groupedWindow.isRuleVisible(.vertical), - groupedWindow.verticalRule.bounds.contains(verticalLocation) { + if rulerWindow.isRuleVisible(.vertical), + rulerWindow.verticalRule.bounds.contains(verticalLocation) { return .vertical } @@ -1609,7 +1510,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi // MARK: KeyListener -extension GroupedRulerController { +extension RulerController { func startKeyListener() { keyListener = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] in guard let self = self else { return $0 } @@ -1628,7 +1529,7 @@ extension GroupedRulerController { let shift = event.modifierFlags.contains(.shift) let keyboardModifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if groupedWindow.isKeyWindow, + if rulerWindow.isKeyWindow, let appDelegate = NSApp.delegate as? AppDelegate, appDelegate.performRulerHotkey( keyCode: Int(event.keyCode), @@ -1640,16 +1541,16 @@ extension GroupedRulerController { switch Int(event.keyCode) { case kVK_LeftArrow: - groupedWindow.nudgeLeft(withShift: shift) + rulerWindow.nudgeLeft(withShift: shift) return nil case kVK_RightArrow: - groupedWindow.nudgeRight(withShift: shift) + rulerWindow.nudgeRight(withShift: shift) return nil case kVK_UpArrow: - groupedWindow.nudgeUp(withShift: shift) + rulerWindow.nudgeUp(withShift: shift) return nil case kVK_DownArrow: - groupedWindow.nudgeDown(withShift: shift) + rulerWindow.nudgeDown(withShift: shift) return nil default: return event @@ -1658,7 +1559,7 @@ extension GroupedRulerController { } final class RulerManager { - typealias ControllerFactory = (RulerInstanceState) -> GroupedRulerController + typealias ControllerFactory = (RulerInstanceState) -> RulerController private struct GroupedDragState { let draggedRulerID: UUID @@ -1666,16 +1567,16 @@ final class RulerManager { } private let controllerFactory: ControllerFactory - private(set) var controllers: [GroupedRulerController] = [] + private(set) var controllers: [RulerController] = [] private(set) var activeRulerID: UUID? - var onActiveControllerChanged: ((GroupedRulerController?) -> Void)? + var onActiveControllerChanged: ((RulerController?) -> Void)? var onStateChanged: ((RulerManager) -> Void)? private var groupedDragState: GroupedDragState? private var isApplyingGroupedDrag = false init( initialStates: [RulerInstanceState] = [], - controllerFactory: @escaping ControllerFactory = { GroupedRulerController(state: $0) } + controllerFactory: @escaping ControllerFactory = { RulerController(state: $0) } ) { self.controllerFactory = controllerFactory restore(initialStates) @@ -1689,13 +1590,13 @@ final class RulerManager { return controllers.contains { $0.isVisible } } - var activeController: GroupedRulerController? { + var activeController: RulerController? { if let activeRulerID = activeRulerID, let controller = controllers.first(where: { $0.state.id == activeRulerID }) { return controller } - if let keyController = controllers.first(where: { $0.groupedWindow.isKeyWindow }) { + if let keyController = controllers.first(where: { $0.rulerWindow.isKeyWindow }) { return keyController } @@ -1710,7 +1611,7 @@ final class RulerManager { func createRuler( defaults: RulerSettings = RulerSettings(defaults: prefs), screenFrame: NSRect = defaultRulerScreenFrame() - ) -> GroupedRulerController { + ) -> RulerController { let defaultState = RulerInstanceState.createFromDefaults( defaults: defaults, screenFrame: screenFrame @@ -1721,7 +1622,7 @@ final class RulerManager { } @discardableResult - func addRuler(state: RulerInstanceState) -> GroupedRulerController { + func addRuler(state: RulerInstanceState) -> RulerController { let controller = controllerFactory(state) configure(controller) controllers.append(controller) @@ -1756,12 +1657,12 @@ final class RulerManager { } if let activeController = activeController { - activeController.groupedWindow.makeKey() + activeController.rulerWindow.makeKey() } } @discardableResult - func cycleActiveRuler() -> GroupedRulerController? { + func cycleActiveRuler() -> RulerController? { let visibleControllers = controllers.filter(\.isVisible) guard !visibleControllers.isEmpty else { return nil } @@ -1773,8 +1674,8 @@ final class RulerManager { let nextController = visibleControllers[nextIndex] markActive(nextController) - nextController.groupedWindow.orderFrontRegardless() - nextController.groupedWindow.makeKey() + nextController.rulerWindow.orderFrontRegardless() + nextController.rulerWindow.makeKey() return nextController } @@ -1786,7 +1687,7 @@ final class RulerManager { return true } - func close(_ controller: GroupedRulerController) { + func close(_ controller: RulerController) { controller.hide() controllers.removeAll { $0 === controller } @@ -1798,7 +1699,7 @@ final class RulerManager { notifyStateChanged() } - func markActive(_ controller: GroupedRulerController) { + func markActive(_ controller: RulerController) { guard controllers.contains(where: { $0 === controller }) else { return } activeRulerID = controller.state.id @@ -1806,7 +1707,7 @@ final class RulerManager { notifyStateChanged() } - func beginGroupedDrag(from controller: GroupedRulerController) { + func beginGroupedDrag(from controller: RulerController) { guard prefs.groupRulers, controllers.contains(where: { $0 === controller }) else { groupedDragState = nil @@ -1818,12 +1719,12 @@ final class RulerManager { framesByRulerID: Dictionary( uniqueKeysWithValues: controllers .filter(\.isVisible) - .map { ($0.state.id, $0.groupedWindow.frame) } + .map { ($0.state.id, $0.rulerWindow.frame) } ) ) } - func syncGroupedDrag(from controller: GroupedRulerController) { + func syncGroupedDrag(from controller: RulerController) { guard prefs.groupRulers, !isApplyingGroupedDrag, let groupedDragState = groupedDragState, @@ -1833,8 +1734,8 @@ final class RulerManager { } let offset = NSSize( - width: controller.groupedWindow.frame.minX - originalDraggedFrame.minX, - height: controller.groupedWindow.frame.minY - originalDraggedFrame.minY + width: controller.rulerWindow.frame.minX - originalDraggedFrame.minX, + height: controller.rulerWindow.frame.minY - originalDraggedFrame.minY ) guard offset.width != 0 || offset.height != 0 else { return } @@ -1854,22 +1755,22 @@ final class RulerManager { notifyStateChanged() } - func finishGroupedDrag(from controller: GroupedRulerController) { + func finishGroupedDrag(from controller: RulerController) { syncGroupedDrag(from: controller) groupedDragState = nil } - func controller(containing window: NSWindow?) -> GroupedRulerController? { + func controller(containing window: NSWindow?) -> RulerController? { guard let window = window else { return nil } - return controllers.first { $0.groupedWindow === window } + return controllers.first { $0.rulerWindow === window } } - func controller(id: UUID) -> GroupedRulerController? { + func controller(id: UUID) -> RulerController? { return controllers.first { $0.state.id == id } } - private func configure(_ controller: GroupedRulerController) { + private func configure(_ controller: RulerController) { controller.onBecameActive = { [weak self, weak controller] _ in guard let controller = controller else { return } self?.markActive(controller) @@ -1913,12 +1814,12 @@ final class RulerManager { } #endif -private func groupedRulerLShapedPath( +private func rulerWindowLShapedPath( in bounds: NSRect, zeroCorner: ZeroCorner, inset: CGFloat ) -> NSBezierPath { - let layout = GroupedRulerLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) + let layout = RulerWindowLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) let horizontalFrame = layout.localFrame(for: .horizontal) let verticalFrame = layout.localFrame(for: .vertical) let minX = bounds.minX + inset diff --git a/Free Ruler/UITestSupport+App.swift b/Free Ruler/UITestSupport+App.swift index dede0be..5d2765b 100644 --- a/Free Ruler/UITestSupport+App.swift +++ b/Free Ruler/UITestSupport+App.swift @@ -28,8 +28,12 @@ extension UITestSupport { prefs.zeroCorner = Prefs.defaultZeroCorner } - func writePreferencesState() { + func writePreferencesState(activeSettings: RulerSettings? = nil) { + let activeSettings = activeSettings ?? RulerSettings(defaults: prefs) let state = [ + "activeFloatRulers": boolStateValue(activeSettings.floatRulers), + "activeRulerShadow": boolStateValue(activeSettings.rulerShadow), + "activeUnit": unitStateValue(activeSettings.unit), "floatRulers": boolStateValue(prefs.floatRulers), "groupRulers": boolStateValue(prefs.groupRulers), "rulerShadow": boolStateValue(prefs.rulerShadow), diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index b3b531a..49710df 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -212,9 +212,9 @@ final class RulerCoreTests: XCTestCase { first.show() second.show() - let firstFrame = first.groupedWindow.frame - let secondFrame = second.groupedWindow.frame - let hiddenFrame = hidden.groupedWindow.frame + let firstFrame = first.rulerWindow.frame + let secondFrame = second.rulerWindow.frame + let hiddenFrame = hidden.rulerWindow.frame let dragOffset = NSSize(width: 37, height: -24) var movedFirstFrame = firstFrame movedFirstFrame.origin.x += dragOffset.width @@ -225,14 +225,14 @@ final class RulerCoreTests: XCTestCase { appDelegate.rulerManager.syncGroupedDrag(from: first) appDelegate.rulerManager.finishGroupedDrag(from: first) - XCTAssertEqual(first.groupedWindow.frame, movedFirstFrame) - XCTAssertEqual(second.groupedWindow.frame.minX, secondFrame.minX + dragOffset.width) - XCTAssertEqual(second.groupedWindow.frame.minY, secondFrame.minY + dragOffset.height) - XCTAssertEqual(hidden.groupedWindow.frame, hiddenFrame) + XCTAssertEqual(first.rulerWindow.frame, movedFirstFrame) + XCTAssertEqual(second.rulerWindow.frame.minX, secondFrame.minX + dragOffset.width) + XCTAssertEqual(second.rulerWindow.frame.minY, secondFrame.minY + dragOffset.height) + XCTAssertEqual(hidden.rulerWindow.frame, hiddenFrame) XCTAssertEqual( second.state.layout.zeroPoint, ZeroCornerGeometry(zeroCorner: second.state.settings.zeroCorner).zeroPoint( - in: second.groupedWindow.screenFrame(for: .horizontal), + in: second.rulerWindow.screenFrame(for: .horizontal), for: .horizontal ) ) @@ -281,10 +281,10 @@ final class RulerCoreTests: XCTestCase { ) manager.markActive(second) - let menu = first.groupedWindow.horizontalRule.menu(for: mouseEvent( + let menu = first.rulerWindow.horizontalRule.menu(for: mouseEvent( type: .rightMouseDown, location: .zero, - windowNumber: first.groupedWindow.windowNumber, + windowNumber: first.rulerWindow.windowNumber, timestamp: 0 )) @@ -337,13 +337,13 @@ final class RulerCoreTests: XCTestCase { manager.showAll() XCTAssertTrue(manager.hasVisibleRulers) - XCTAssertTrue(manager.controllers[0].groupedWindow.isRuleVisible(.horizontal)) - XCTAssertFalse(manager.controllers[0].groupedWindow.isRuleVisible(.vertical)) - XCTAssertFalse(manager.controllers[1].groupedWindow.isRuleVisible(.horizontal)) - XCTAssertTrue(manager.controllers[1].groupedWindow.isRuleVisible(.vertical)) + XCTAssertTrue(manager.controllers[0].rulerWindow.isRuleVisible(.horizontal)) + XCTAssertFalse(manager.controllers[0].rulerWindow.isRuleVisible(.vertical)) + XCTAssertFalse(manager.controllers[1].rulerWindow.isRuleVisible(.horizontal)) + XCTAssertTrue(manager.controllers[1].rulerWindow.isRuleVisible(.vertical)) } - func testManagedGroupedRulerAppliesPerRulerSettingsToWindowAndRules() { + func testRulerControllerAppliesPerRulerSettingsToWindowAndRules() { let color = NSColor(deviceRed: 0.1, green: 0.4, blue: 0.8, alpha: 1) let settings = RulerSettings( unit: .inches, @@ -354,7 +354,7 @@ final class RulerCoreTests: XCTestCase { rulerShadow: true, zeroCorner: .bottomRight ) - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: settings, layout: RulerLayoutState( @@ -368,25 +368,25 @@ final class RulerCoreTests: XCTestCase { controller.hide() } - XCTAssertEqual(controller.groupedWindow.horizontalRule.unit, .inches) - XCTAssertEqual(controller.groupedWindow.verticalRule.unit, .inches) - XCTAssertEqual(controller.groupedWindow.horizontalRule.zeroCorner, .bottomRight) - XCTAssertEqual(controller.groupedWindow.verticalRule.zeroCorner, .bottomRight) - assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: color) - assertColor(controller.groupedWindow.verticalRule.color.fill, equals: color) - XCTAssertEqual(controller.groupedWindow.alphaValue, 0.73, accuracy: 0.0001) - XCTAssertFalse(controller.groupedWindow.isFloatingPanel) - XCTAssertTrue(controller.groupedWindow.hasShadow) + XCTAssertEqual(controller.rulerWindow.horizontalRule.unit, .inches) + XCTAssertEqual(controller.rulerWindow.verticalRule.unit, .inches) + XCTAssertEqual(controller.rulerWindow.horizontalRule.zeroCorner, .bottomRight) + XCTAssertEqual(controller.rulerWindow.verticalRule.zeroCorner, .bottomRight) + assertColor(controller.rulerWindow.horizontalRule.color.fill, equals: color) + assertColor(controller.rulerWindow.verticalRule.color.fill, equals: color) + XCTAssertEqual(controller.rulerWindow.alphaValue, 0.73, accuracy: 0.0001) + XCTAssertFalse(controller.rulerWindow.isFloatingPanel) + XCTAssertTrue(controller.rulerWindow.hasShadow) controller.background() - XCTAssertEqual(controller.groupedWindow.alphaValue, 0.31, accuracy: 0.0001) + XCTAssertEqual(controller.rulerWindow.alphaValue, 0.31, accuracy: 0.0001) } - func testManagedGroupedRulerIgnoresDefaultPreferenceChanges() { + func testRulerControllerIgnoresDefaultPreferenceChanges() { withRestoredRulerPreferences { let color = NSColor(deviceRed: 0.2, green: 0.3, blue: 0.7, alpha: 1) - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings( unit: .inches, @@ -417,12 +417,12 @@ final class RulerCoreTests: XCTestCase { prefs.zeroCorner = .topRight XCTAssertEqual(controller.state.settings.unit, .inches) - XCTAssertEqual(controller.groupedWindow.horizontalRule.unit, .inches) - XCTAssertEqual(controller.groupedWindow.horizontalRule.zeroCorner, .bottomLeft) - assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: color) - XCTAssertEqual(controller.groupedWindow.alphaValue, 0.64, accuracy: 0.0001) - XCTAssertFalse(controller.groupedWindow.isFloatingPanel) - XCTAssertFalse(controller.groupedWindow.hasShadow) + XCTAssertEqual(controller.rulerWindow.horizontalRule.unit, .inches) + XCTAssertEqual(controller.rulerWindow.horizontalRule.zeroCorner, .bottomLeft) + assertColor(controller.rulerWindow.horizontalRule.color.fill, equals: color) + XCTAssertEqual(controller.rulerWindow.alphaValue, 0.64, accuracy: 0.0001) + XCTAssertFalse(controller.rulerWindow.isFloatingPanel) + XCTAssertFalse(controller.rulerWindow.hasShadow) } } @@ -438,7 +438,7 @@ final class RulerCoreTests: XCTestCase { prefs.defaultHorizontalLength = 640 prefs.defaultVerticalLength = 280 - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings( unit: .inches, @@ -465,14 +465,14 @@ final class RulerCoreTests: XCTestCase { settingsController.setUnit(settingsController.unitSegmentedControl) XCTAssertEqual(controller.state.settings.unit, .millimeters) - XCTAssertEqual(controller.groupedWindow.horizontalRule.unit, .millimeters) - XCTAssertEqual(controller.groupedWindow.verticalRule.unit, .millimeters) + XCTAssertEqual(controller.rulerWindow.horizontalRule.unit, .millimeters) + XCTAssertEqual(controller.rulerWindow.verticalRule.unit, .millimeters) XCTAssertEqual(settingsController.unitSegmentedControl.selectedSegment, Unit.millimeters.rawValue) XCTAssertEqual(prefs.unit, .pixels) let enteredWidthMillimeters: CGFloat = 100 let enteredHeightMillimeters: CGFloat = 80 - let zeroPointBeforeDimensionChange = controller.groupedWindow.zeroPoint() + let zeroPointBeforeDimensionChange = controller.rulerWindow.zeroPoint() settingsController.dimensionWidthField.stringValue = "\(enteredWidthMillimeters)" settingsController.dimensionHeightField.stringValue = "\(enteredHeightMillimeters)" let expectedHorizontalLength = settingsController.settingsControlsView.selectedHorizontalLength @@ -481,10 +481,10 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(controller.state.layout.horizontalLength, expectedHorizontalLength, accuracy: 0.0001) XCTAssertEqual(controller.state.layout.verticalLength, expectedVerticalLength, accuracy: 0.0001) - XCTAssertEqual(controller.groupedWindow.screenFrame(for: .horizontal).width, expectedHorizontalLength, accuracy: 0.0001) - XCTAssertEqual(controller.groupedWindow.screenFrame(for: .vertical).height, expectedVerticalLength, accuracy: 0.0001) - XCTAssertEqual(controller.groupedWindow.zeroPoint().x, zeroPointBeforeDimensionChange.x, accuracy: 0.0001) - XCTAssertEqual(controller.groupedWindow.zeroPoint().y, zeroPointBeforeDimensionChange.y, accuracy: 0.0001) + XCTAssertEqual(controller.rulerWindow.screenFrame(for: .horizontal).width, expectedHorizontalLength, accuracy: 0.0001) + XCTAssertEqual(controller.rulerWindow.screenFrame(for: .vertical).height, expectedVerticalLength, accuracy: 0.0001) + XCTAssertEqual(controller.rulerWindow.zeroPoint().x, zeroPointBeforeDimensionChange.x, accuracy: 0.0001) + XCTAssertEqual(controller.rulerWindow.zeroPoint().y, zeroPointBeforeDimensionChange.y, accuracy: 0.0001) XCTAssertEqual(settingsController.dimensionWidthField.doubleValue, Double(enteredWidthMillimeters), accuracy: 0.15) XCTAssertEqual(settingsController.dimensionHeightField.doubleValue, Double(enteredHeightMillimeters), accuracy: 0.15) XCTAssertEqual(prefs.defaultHorizontalLength, 640) @@ -500,8 +500,8 @@ final class RulerCoreTests: XCTestCase { let normalizedColor = NSColor(deviceRed: 0.8, green: 0.2, blue: 0.4, alpha: 1) assertColor(controller.state.settings.rulerColor, equals: normalizedColor) - assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: normalizedColor) - assertColor(controller.groupedWindow.verticalRule.color.fill, equals: normalizedColor) + assertColor(controller.rulerWindow.horizontalRule.color.fill, equals: normalizedColor) + assertColor(controller.rulerWindow.verticalRule.color.fill, equals: normalizedColor) assertColor(settingsController.rulerColorWell.color, equals: normalizedColor) assertColor(prefs.rulerColor, equals: defaultColor) XCTAssertFalse(settingsController.resetRulerColorButton.isHidden) @@ -510,7 +510,7 @@ final class RulerCoreTests: XCTestCase { settingsController.setForegroundOpacity(settingsController.foregroundOpacitySlider) XCTAssertEqual(controller.state.settings.foregroundOpacity, 65) - XCTAssertEqual(controller.groupedWindow.alphaValue, 0.65, accuracy: 0.0001) + XCTAssertEqual(controller.rulerWindow.alphaValue, 0.65, accuracy: 0.0001) XCTAssertEqual(settingsController.foregroundOpacityLabel.stringValue, "65%") XCTAssertEqual(prefs.foregroundOpacity, 90) @@ -518,7 +518,7 @@ final class RulerCoreTests: XCTestCase { settingsController.setBackgroundOpacity(settingsController.backgroundOpacitySlider) XCTAssertEqual(controller.state.settings.backgroundOpacity, 35) - XCTAssertEqual(controller.groupedWindow.alphaValue, 0.35, accuracy: 0.0001) + XCTAssertEqual(controller.rulerWindow.alphaValue, 0.35, accuracy: 0.0001) XCTAssertEqual(settingsController.backgroundOpacityLabel.stringValue, "35%") XCTAssertEqual(prefs.backgroundOpacity, 50) @@ -526,7 +526,7 @@ final class RulerCoreTests: XCTestCase { settingsController.setFloatRulers(settingsController.floatRulersCheckbox) XCTAssertTrue(controller.state.settings.floatRulers) - XCTAssertTrue(controller.groupedWindow.isFloatingPanel) + XCTAssertTrue(controller.rulerWindow.isFloatingPanel) XCTAssertTrue(settingsController.floatRulersCheckbox.state == .on) XCTAssertTrue(prefs.floatRulers) @@ -534,14 +534,14 @@ final class RulerCoreTests: XCTestCase { settingsController.setRulerShadow(settingsController.rulerShadowCheckbox) XCTAssertTrue(controller.state.settings.rulerShadow) - XCTAssertTrue(controller.groupedWindow.hasShadow) + XCTAssertTrue(controller.rulerWindow.hasShadow) XCTAssertTrue(settingsController.rulerShadowCheckbox.state == .on) XCTAssertFalse(prefs.rulerShadow) settingsController.resetRulerColor(settingsController.resetRulerColorButton) assertColor(controller.state.settings.rulerColor, equals: Prefs.defaultRulerFillColor) - assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: Prefs.defaultRulerFillColor) + assertColor(controller.rulerWindow.horizontalRule.color.fill, equals: Prefs.defaultRulerFillColor) assertColor(prefs.rulerColor, equals: defaultColor) XCTAssertTrue(settingsController.resetRulerColorButton.isHidden) } @@ -560,7 +560,7 @@ final class RulerCoreTests: XCTestCase { prefs.defaultVerticalLength = 400 let rulerColor = NSColor(deviceRed: 0.72, green: 0.24, blue: 0.44, alpha: 1) - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings( unit: .inches, @@ -611,7 +611,7 @@ final class RulerCoreTests: XCTestCase { prefs.defaultHorizontalLength = 320 prefs.defaultVerticalLength = 220 - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings( unit: .inches, @@ -651,13 +651,13 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(settingsController.foregroundOpacityLabel.stringValue, "88%") XCTAssertEqual(settingsController.backgroundOpacityLabel.stringValue, "44%") XCTAssertEqual(controller.opacity, 88) - XCTAssertEqual(controller.groupedWindow.alphaValue, windowAlphaValue(88), accuracy: 0.0001) + XCTAssertEqual(controller.rulerWindow.alphaValue, windowAlphaValue(88), accuracy: 0.0001) XCTAssertEqual(prefs.foregroundOpacity, 88) } } func testRulerSettingsControllerAppliesColorPanelChangesToActiveRuler() { - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings( rulerColor: NSColor(deviceRed: 0.2, green: 0.3, blue: 0.4, alpha: 1) @@ -682,12 +682,12 @@ final class RulerCoreTests: XCTestCase { let normalizedColor = NSColor(deviceRed: 0.7, green: 0.1, blue: 0.5, alpha: 1) assertColor(controller.state.settings.rulerColor, equals: normalizedColor) - assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: normalizedColor) + assertColor(controller.rulerWindow.horizontalRule.color.fill, equals: normalizedColor) assertColor(settingsController.rulerColorWell.color, equals: normalizedColor) } func testRulerSettingsControllerCheckboxKeyEquivalentsToggleFloatAndShadow() { - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings(floatRulers: false, rulerShadow: false), layout: RulerLayoutState( @@ -956,7 +956,7 @@ final class RulerCoreTests: XCTestCase { } func testRulerSettingsControllerPresentsAsAttachedSheetOnRulerWindow() { - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings(), layout: RulerLayoutState( @@ -979,7 +979,7 @@ final class RulerCoreTests: XCTestCase { XCTFail("Expected settings window") return } - XCTAssertTrue(controller.groupedWindow.childWindows?.contains(settingsWindow) ?? false) + XCTAssertTrue(controller.rulerWindow.childWindows?.contains(settingsWindow) ?? false) XCTAssertNil(settingsWindow.sheetParent) } @@ -988,7 +988,7 @@ final class RulerCoreTests: XCTestCase { let zeroPoint = NSPoint(x: visibleFrame.midX, y: visibleFrame.midY) for zeroCorner in [ZeroCorner.topLeft, .topRight, .bottomLeft, .bottomRight] { - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings(zeroCorner: zeroCorner), layout: RulerLayoutState( @@ -1012,7 +1012,7 @@ final class RulerCoreTests: XCTestCase { return } - let rulerZeroPoint = controller.groupedWindow.zeroPoint() + let rulerZeroPoint = controller.rulerWindow.zeroPoint() switch zeroCorner { case .topLeft: XCTAssertEqual(settingsWindow.frame.minX, rulerZeroPoint.x, accuracy: 1) @@ -1031,7 +1031,7 @@ final class RulerCoreTests: XCTestCase { } func testRulerSettingsControllerUsesFloatingUtilityPanelStyle() { - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings(), layout: RulerLayoutState( @@ -1088,7 +1088,7 @@ final class RulerCoreTests: XCTestCase { } func testRulerSettingsControllerRestoresForegroundOpacityWhenClosingSheet() { - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings( foregroundOpacity: 80, @@ -1112,15 +1112,15 @@ final class RulerCoreTests: XCTestCase { settingsController.backgroundOpacitySlider.integerValue = 35 settingsController.setBackgroundOpacity(settingsController.backgroundOpacitySlider) - XCTAssertEqual(controller.groupedWindow.alphaValue, 0.35, accuracy: 0.0001) + XCTAssertEqual(controller.rulerWindow.alphaValue, 0.35, accuracy: 0.0001) settingsController.close() - XCTAssertEqual(controller.groupedWindow.alphaValue, 0.8, accuracy: 0.0001) + XCTAssertEqual(controller.rulerWindow.alphaValue, 0.8, accuracy: 0.0001) } func testRulerSettingsControllerTitlebarCloseClosesAttachedSheet() { - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings(), layout: RulerLayoutState( @@ -1147,7 +1147,7 @@ final class RulerCoreTests: XCTestCase { settingsWindow.performClose(self) - XCTAssertFalse(controller.groupedWindow.childWindows?.contains(settingsWindow) ?? false) + XCTAssertFalse(controller.rulerWindow.childWindows?.contains(settingsWindow) ?? false) XCTAssertFalse(settingsWindow.isVisible) } @@ -1180,13 +1180,13 @@ final class RulerCoreTests: XCTestCase { return } - let initialZeroPoint = controller.groupedWindow.zeroPoint() + let initialZeroPoint = controller.rulerWindow.zeroPoint() XCTAssertEqual(settingsWindow.frame.minX, initialZeroPoint.x, accuracy: 1) XCTAssertEqual(settingsWindow.frame.maxY, initialZeroPoint.y, accuracy: 1) appDelegate.flipRulers(along: .horizontal) - let flippedZeroPoint = controller.groupedWindow.zeroPoint() + let flippedZeroPoint = controller.rulerWindow.zeroPoint() XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) XCTAssertEqual(settingsWindow.frame.maxX, flippedZeroPoint.x, accuracy: 1) XCTAssertEqual(settingsWindow.frame.maxY, flippedZeroPoint.y, accuracy: 1) @@ -1224,19 +1224,19 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(existing.state.settings.unit, .pixels) XCTAssertEqual(existing.state.layout.horizontalLength, 260) XCTAssertEqual(existing.state.layout.verticalLength, 180) - XCTAssertEqual(existing.groupedWindow.horizontalRule.unit, .pixels) - XCTAssertEqual(existing.groupedWindow.horizontalRule.zeroCorner, .topLeft) + XCTAssertEqual(existing.rulerWindow.horizontalRule.unit, .pixels) + XCTAssertEqual(existing.rulerWindow.horizontalRule.zeroCorner, .topLeft) assertColor( - existing.groupedWindow.horizontalRule.color.fill, + existing.rulerWindow.horizontalRule.color.fill, equals: NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) ) XCTAssertEqual(createdAfterDefaultsChange.state.settings.unit, .millimeters) XCTAssertEqual(createdAfterDefaultsChange.state.layout.horizontalLength, 320) XCTAssertEqual(createdAfterDefaultsChange.state.layout.verticalLength, 240) - XCTAssertEqual(createdAfterDefaultsChange.groupedWindow.horizontalRule.unit, .millimeters) - XCTAssertEqual(createdAfterDefaultsChange.groupedWindow.horizontalRule.zeroCorner, .topRight) + XCTAssertEqual(createdAfterDefaultsChange.rulerWindow.horizontalRule.unit, .millimeters) + XCTAssertEqual(createdAfterDefaultsChange.rulerWindow.horizontalRule.zeroCorner, .topRight) assertColor( - createdAfterDefaultsChange.groupedWindow.horizontalRule.color.fill, + createdAfterDefaultsChange.rulerWindow.horizontalRule.color.fill, equals: NSColor(deviceRed: 0.8, green: 0.7, blue: 0.2, alpha: 1) ) } @@ -1630,7 +1630,7 @@ final class RulerCoreTests: XCTestCase { } } - func testGroupedRulerLayoutJoinsSeparateRulersWithoutChangingRuleFrames() { + func testRulerWindowLayoutJoinsSeparateRulersWithoutChangingRuleFrames() { let zeroPoint = NSPoint(x: 200, y: 300) let horizontalSize = NSSize(width: 120, height: Ruler.thickness) let verticalSize = NSSize(width: Ruler.thickness, height: 160) @@ -1648,12 +1648,12 @@ final class RulerCoreTests: XCTestCase { size: verticalSize ) - let layout = GroupedRulerLayout.joined( + let layout = RulerWindowLayout.joined( horizontalFrame: horizontalFrame, verticalFrame: verticalFrame, zeroCorner: zeroCorner ) - let roundTrippedLayout = GroupedRulerLayout.layout( + let roundTrippedLayout = RulerWindowLayout.layout( groupFrame: layout.groupFrame, zeroCorner: zeroCorner ) @@ -1675,13 +1675,13 @@ final class RulerCoreTests: XCTestCase { } } - func testGroupedRulerContentViewLaysOutLegsAndHitTestsCorner() { + func testRulerContentViewLaysOutLegsAndHitTestsCorner() { let contentSize = NSSize(width: 260, height: 220) for zeroCorner in [ZeroCorner.topLeft, .topRight, .bottomLeft, .bottomRight] { - let view = groupedContentView(size: contentSize, zeroCorner: zeroCorner) - let layout = GroupedRulerLayout.layout(groupFrame: view.bounds, zeroCorner: zeroCorner) - let emptyCornerPoint = pointInsideEmptyGroupedCorner( + let view = rulerContentView(size: contentSize, zeroCorner: zeroCorner) + let layout = RulerWindowLayout.layout(groupFrame: view.bounds, zeroCorner: zeroCorner) + let emptyCornerPoint = pointInsideEmptyRulerWindowCorner( horizontalFrame: layout.localFrame(for: Orientation.horizontal), verticalFrame: layout.localFrame(for: Orientation.vertical), bounds: view.bounds @@ -1708,8 +1708,8 @@ final class RulerCoreTests: XCTestCase { } } - func testGroupedRulerContentViewRestoresStandaloneLabelsWhenOnlyOneLegIsVisible() { - let view = groupedContentView(size: NSSize(width: 260, height: 220), zeroCorner: .topLeft) + func testRulerContentViewRestoresStandaloneLabelsWhenOnlyOneLegIsVisible() { + let view = rulerContentView(size: NSSize(width: 260, height: 220), zeroCorner: .topLeft) XCTAssertFalse(view.horizontalRule.showsUnitLabel) XCTAssertFalse(view.verticalRule.showsUnitLabel) @@ -1734,39 +1734,39 @@ final class RulerCoreTests: XCTestCase { XCTAssertTrue(view.verticalRule.showsZeroTick) } - func testGroupedRulerControllerEnablesMouseTicksOnlyForVisibleLegs() { + func testRulerControllerEnablesMouseTicksOnlyForVisibleLegs() { withRestoredZeroCornerPreference { prefs.zeroCorner = .topLeft - let controller = GroupedRulerController( + let controller = RulerController( frame: NSRect(x: 100, y: 100, width: 260, height: 220) ) - controller.groupedWindow.setVisibleRules(horizontal: true, vertical: false) + controller.rulerWindow.setVisibleRules(horizontal: true, vertical: false) controller.setMouseTickDrawingEnabled(true) - XCTAssertTrue(controller.groupedWindow.horizontalRule.showMouseTick) - XCTAssertFalse(controller.groupedWindow.verticalRule.showMouseTick) + XCTAssertTrue(controller.rulerWindow.horizontalRule.showMouseTick) + XCTAssertFalse(controller.rulerWindow.verticalRule.showMouseTick) - controller.groupedWindow.setVisibleRules(horizontal: false, vertical: true) + controller.rulerWindow.setVisibleRules(horizontal: false, vertical: true) controller.setMouseTickDrawingEnabled(true) - XCTAssertFalse(controller.groupedWindow.horizontalRule.showMouseTick) - XCTAssertTrue(controller.groupedWindow.verticalRule.showMouseTick) + XCTAssertFalse(controller.rulerWindow.horizontalRule.showMouseTick) + XCTAssertTrue(controller.rulerWindow.verticalRule.showMouseTick) controller.setMouseTickDrawingEnabled(false) - XCTAssertFalse(controller.groupedWindow.horizontalRule.showMouseTick) - XCTAssertFalse(controller.groupedWindow.verticalRule.showMouseTick) + XCTAssertFalse(controller.rulerWindow.horizontalRule.showMouseTick) + XCTAssertFalse(controller.rulerWindow.verticalRule.showMouseTick) } } - func testGroupedRulerControllerRestoresMouseTicksWhenHiddenLegReappears() { + func testRulerControllerRestoresMouseTicksWhenHiddenLegReappears() { withRestoredZeroCornerPreference { prefs.zeroCorner = .topLeft let horizontalFrame = NSRect(x: 200, y: 299, width: 320, height: Ruler.thickness) let verticalFrame = NSRect(x: 161, y: 120, width: Ruler.thickness, height: 180) - let controller = GroupedRulerController( - frame: GroupedRulerLayout.joined( + let controller = RulerController( + frame: RulerWindowLayout.joined( horizontalFrame: horizontalFrame, verticalFrame: verticalFrame, zeroCorner: .topLeft @@ -1782,8 +1782,8 @@ final class RulerCoreTests: XCTestCase { controller.setMouseTickDrawingEnabled(false) controller.setMouseTickDrawingEnabled(true) - XCTAssertTrue(controller.groupedWindow.horizontalRule.showMouseTick) - XCTAssertFalse(controller.groupedWindow.verticalRule.showMouseTick) + XCTAssertTrue(controller.rulerWindow.horizontalRule.showMouseTick) + XCTAssertFalse(controller.rulerWindow.verticalRule.showMouseTick) controller.show( horizontalFrame: horizontalFrame, @@ -1792,19 +1792,19 @@ final class RulerCoreTests: XCTestCase { showsVerticalRule: true ) - XCTAssertTrue(controller.groupedWindow.horizontalRule.showMouseTick) - XCTAssertTrue(controller.groupedWindow.verticalRule.showMouseTick) - controller.groupedWindow.orderOut(self) + XCTAssertTrue(controller.rulerWindow.horizontalRule.showMouseTick) + XCTAssertTrue(controller.rulerWindow.verticalRule.showMouseTick) + controller.rulerWindow.orderOut(self) } } - func testGroupedRulerControllerShrinksWindowToOnlyVisibleLeg() { + func testRulerControllerShrinksWindowToOnlyVisibleLeg() { withRestoredZeroCornerPreference { prefs.zeroCorner = .topLeft let horizontalFrame = NSRect(x: 200, y: 299, width: 320, height: Ruler.thickness) let verticalFrame = NSRect(x: 161, y: 120, width: Ruler.thickness, height: 180) - let controller = GroupedRulerController( - frame: GroupedRulerLayout.joined( + let controller = RulerController( + frame: RulerWindowLayout.joined( horizontalFrame: horizontalFrame, verticalFrame: verticalFrame, zeroCorner: .topLeft @@ -1818,12 +1818,12 @@ final class RulerCoreTests: XCTestCase { showsVerticalRule: false ) - XCTAssertEqual(controller.groupedWindow.frame, horizontalFrame) + XCTAssertEqual(controller.rulerWindow.frame, horizontalFrame) XCTAssertEqual( - controller.groupedWindow.screenFrame(for: .horizontal), + controller.rulerWindow.screenFrame(for: .horizontal), horizontalFrame ) - XCTAssertFalse(controller.groupedWindow.isRuleVisible(.vertical)) + XCTAssertFalse(controller.rulerWindow.isRuleVisible(.vertical)) controller.show( horizontalFrame: horizontalFrame, @@ -1832,61 +1832,23 @@ final class RulerCoreTests: XCTestCase { showsVerticalRule: true ) - XCTAssertEqual(controller.groupedWindow.frame, verticalFrame) + XCTAssertEqual(controller.rulerWindow.frame, verticalFrame) XCTAssertEqual( - controller.groupedWindow.screenFrame(for: .vertical), + controller.rulerWindow.screenFrame(for: .vertical), verticalFrame ) - XCTAssertFalse(controller.groupedWindow.isRuleVisible(.horizontal)) - controller.groupedWindow.orderOut(self) - } - } - - func testGroupedRulerControllerSyncsHiddenLegToVisibleZeroPoint() { - withRestoredZeroCornerPreference { - prefs.zeroCorner = .topLeft - let horizontalWindow = LegacyRulerWindow( - Ruler(.horizontal, frame: NSRect(x: 200, y: 299, width: 320, height: Ruler.thickness)) - ) - let verticalWindow = LegacyRulerWindow( - Ruler(.vertical, frame: NSRect(x: 161, y: 120, width: Ruler.thickness, height: 180)) - ) - let controller = GroupedRulerController( - frame: GroupedRulerLayout.joined( - horizontalFrame: horizontalWindow.frame, - verticalFrame: verticalWindow.frame, - zeroCorner: .topLeft - ).groupFrame - ) - - controller.show( - horizontalFrame: horizontalWindow.frame, - verticalFrame: verticalWindow.frame, - showsHorizontalRule: false, - showsVerticalRule: true - ) - controller.groupedWindow.setFrameOrigin(NSPoint(x: 300, y: 200)) - - controller.syncFrames(to: horizontalWindow, and: verticalWindow) - - let geometry = ZeroCornerGeometry(zeroCorner: .topLeft) - XCTAssertEqual(verticalWindow.frame, controller.groupedWindow.frame) - XCTAssertEqual(horizontalWindow.frame.size, NSSize(width: 320, height: Ruler.thickness)) - XCTAssertEqual( - geometry.zeroPoint(in: horizontalWindow.frame, for: .horizontal), - geometry.zeroPoint(in: verticalWindow.frame, for: .vertical) - ) - controller.groupedWindow.orderOut(self) + XCTAssertFalse(controller.rulerWindow.isRuleVisible(.horizontal)) + controller.rulerWindow.orderOut(self) } } - func testGroupedRulerControllerAlignsOnlyVisibleLegWithoutExpandingWindow() { + func testRulerControllerAlignsOnlyVisibleLegWithoutExpandingWindow() { withRestoredZeroCornerPreference { prefs.zeroCorner = .topLeft let horizontalFrame = NSRect(x: 200, y: 299, width: 320, height: Ruler.thickness) let verticalFrame = NSRect(x: 161, y: 120, width: Ruler.thickness, height: 180) - let controller = GroupedRulerController( - frame: GroupedRulerLayout.joined( + let controller = RulerController( + frame: RulerWindowLayout.joined( horizontalFrame: horizontalFrame, verticalFrame: verticalFrame, zeroCorner: .topLeft @@ -1903,32 +1865,32 @@ final class RulerCoreTests: XCTestCase { controller.align(at: targetZeroPoint) - let expectedFrame = GroupedRulerLayout.layout( + let expectedFrame = RulerWindowLayout.layout( horizontalLength: 0, verticalLength: verticalFrame.height, zeroPoint: targetZeroPoint, zeroCorner: .topLeft ).visibleFrame(showsHorizontalRule: false, showsVerticalRule: true) - XCTAssertEqual(controller.groupedWindow.frame, expectedFrame) - XCTAssertEqual(controller.groupedWindow.frame.size, verticalFrame.size) + XCTAssertEqual(controller.rulerWindow.frame, expectedFrame) + XCTAssertEqual(controller.rulerWindow.frame.size, verticalFrame.size) XCTAssertEqual( ZeroCornerGeometry(zeroCorner: .topLeft).zeroPoint( - in: controller.groupedWindow.screenFrame(for: .vertical), + in: controller.rulerWindow.screenFrame(for: .vertical), for: .vertical ), targetZeroPoint ) - controller.groupedWindow.orderOut(self) + controller.rulerWindow.orderOut(self) } } - func testGroupedRulerControllerFlipsOnlyVisibleLegWithoutExpandingWindow() { + func testRulerControllerFlipsOnlyVisibleLegWithoutExpandingWindow() { withRestoredZeroCornerPreference { prefs.zeroCorner = .topLeft let horizontalFrame = NSRect(x: 200, y: 299, width: 320, height: Ruler.thickness) let verticalFrame = NSRect(x: 161, y: 120, width: Ruler.thickness, height: 180) - let controller = GroupedRulerController( - frame: GroupedRulerLayout.joined( + let controller = RulerController( + frame: RulerWindowLayout.joined( horizontalFrame: horizontalFrame, verticalFrame: verticalFrame, zeroCorner: .topLeft @@ -1946,22 +1908,22 @@ final class RulerCoreTests: XCTestCase { controller.prepareForZeroCornerChange(to: .topRight) - let expectedFrame = GroupedRulerLayout.layout( + let expectedFrame = RulerWindowLayout.layout( horizontalLength: 0, verticalLength: verticalFrame.height, zeroPoint: oldZeroPoint, zeroCorner: .topRight ).visibleFrame(showsHorizontalRule: false, showsVerticalRule: true) - XCTAssertEqual(controller.groupedWindow.frame, expectedFrame) - XCTAssertEqual(controller.groupedWindow.frame.size, verticalFrame.size) + XCTAssertEqual(controller.rulerWindow.frame, expectedFrame) + XCTAssertEqual(controller.rulerWindow.frame.size, verticalFrame.size) XCTAssertEqual( ZeroCornerGeometry(zeroCorner: .topRight).zeroPoint( - in: controller.groupedWindow.frame, + in: controller.rulerWindow.frame, for: .vertical ), oldZeroPoint ) - controller.groupedWindow.orderOut(self) + controller.rulerWindow.orderOut(self) } } @@ -3115,9 +3077,11 @@ final class RulerCoreTests: XCTestCase { } func testResizeHandleDisablesWindowBackgroundDraggingDuringResizeDrag() { - let ruler = Ruler(.horizontal, frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - let window = LegacyRulerWindow(ruler) - guard let resizeHandle = window.rule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + let window = oneWingRulerWindow( + orientation: .horizontal, + frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness) + ) + guard let resizeHandle = resizeHandle(in: window.horizontalRule) else { return XCTFail("Expected horizontal ruler to install a resize handle") } @@ -3153,15 +3117,17 @@ final class RulerCoreTests: XCTestCase { XCTAssertTrue(window.isMovableByWindowBackground) } - func testResizeHandleDetachesChildWindowsAttachedWhileBecomingKey() { - let childWindow = LegacyRulerWindow( - Ruler(.vertical, frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + func testResizeHandleDetachesChildWindowsDuringResizeDrag() { + let childWindow = oneWingRulerWindow( + orientation: .vertical, + frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300) ) - let window = ChildAttachingRulerWindow( - ruler: Ruler(.horizontal, frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)), - childWindow: childWindow + let window = oneWingRulerWindow( + orientation: .horizontal, + frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness) ) - guard let resizeHandle = window.rule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + window.addChildWindow(childWindow, ordered: .below) + guard let resizeHandle = resizeHandle(in: window.horizontalRule) else { return XCTFail("Expected horizontal ruler to install a resize handle") } let location = resizeHandle.convert(NSPoint(x: 1, y: 1), to: nil) @@ -3190,9 +3156,8 @@ final class RulerCoreTests: XCTestCase { prefs.zeroCorner = .topLeft let initialFrame = NSRect(x: 100, y: 200, width: 300, height: Ruler.thickness) - let ruler = Ruler(.horizontal, frame: initialFrame) - let window = LegacyRulerWindow(ruler) - guard let resizeHandle = window.rule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + let window = oneWingRulerWindow(orientation: .horizontal, frame: initialFrame) + guard let resizeHandle = resizeHandle(in: window.horizontalRule) else { return XCTFail("Expected horizontal ruler to install a resize handle") } @@ -3245,11 +3210,13 @@ final class RulerCoreTests: XCTestCase { prefs.zeroCorner = .topLeft let horizontalInitialFrame = NSRect(x: 100, y: 200, width: 300, height: Ruler.thickness) - let horizontalWindow = LegacyRulerWindow(Ruler(.horizontal, frame: horizontalInitialFrame)) + let horizontalWindow = oneWingRulerWindow( + orientation: .horizontal, + frame: horizontalInitialFrame, + settings: RulerSettings(zeroCorner: .topRight) + ) defer { horizontalWindow.close() } - horizontalWindow.rule.settingsOverride = RulerSettings(zeroCorner: .topRight) - guard let horizontalResizeHandle = horizontalWindow.rule.subviews - .first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + guard let horizontalResizeHandle = resizeHandle(in: horizontalWindow.horizontalRule) else { return XCTFail("Expected horizontal ruler to install a resize handle") } @@ -3282,11 +3249,13 @@ final class RulerCoreTests: XCTestCase { )) let verticalInitialFrame = NSRect(x: 300, y: 200, width: Ruler.thickness, height: 300) - let verticalWindow = LegacyRulerWindow(Ruler(.vertical, frame: verticalInitialFrame)) + let verticalWindow = oneWingRulerWindow( + orientation: .vertical, + frame: verticalInitialFrame, + settings: RulerSettings(zeroCorner: .bottomLeft) + ) defer { verticalWindow.close() } - verticalWindow.rule.settingsOverride = RulerSettings(zeroCorner: .bottomLeft) - guard let verticalResizeHandle = verticalWindow.rule.subviews - .first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + guard let verticalResizeHandle = resizeHandle(in: verticalWindow.verticalRule) else { return XCTFail("Expected vertical ruler to install a resize handle") } @@ -3320,135 +3289,7 @@ final class RulerCoreTests: XCTestCase { } } - func testRulerControllerKeepsMouseTicksHiddenWhileDragging() { - withInstalledAppDelegate { appDelegate in - let ruler = Ruler(.horizontal, frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - let controller = RulerController(ruler: ruler) - appDelegate.rulers = [controller] - let mouseDownEvent = NSEvent.mouseEvent( - with: .leftMouseDown, - location: NSPoint(x: 10, y: 10), - modifierFlags: [], - timestamp: 0, - windowNumber: controller.rulerWindow.windowNumber, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 1 - )! - let mouseUpEvent = NSEvent.mouseEvent( - with: .leftMouseUp, - location: NSPoint(x: 10, y: 10), - modifierFlags: [], - timestamp: 0.1, - windowNumber: controller.rulerWindow.windowNumber, - context: nil, - eventNumber: 1, - clickCount: 1, - pressure: 0 - )! - - controller.mouseDown(with: mouseDownEvent) - controller.windowDidMove(Notification(name: NSWindow.didMoveNotification, object: controller.rulerWindow)) - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - - XCTAssertFalse(controller.rulerWindow.rule.showMouseTick) - - controller.mouseUp(with: mouseUpEvent) - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - - XCTAssertFalse(controller.rulerWindow.rule.showMouseTick) - - controller.mouseExited(with: mouseUpEvent) - - XCTAssertTrue(controller.rulerWindow.rule.showMouseTick) - } - } - - func testRulerControllerResumesMouseTicksWhenWindowDragLoopEnds() { - withInstalledAppDelegate { appDelegate in - let controller = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - ) - let otherController = RulerController( - ruler: Ruler(.vertical, frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) - ) - controller.otherWindow = otherController.rulerWindow - appDelegate.rulers = [controller, otherController] - let mouseDownEvent = NSEvent.mouseEvent( - with: .leftMouseDown, - location: NSPoint(x: 10, y: 10), - modifierFlags: [], - timestamp: 0, - windowNumber: controller.rulerWindow.windowNumber, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 1 - )! - let mouseUpOutsideEvent = NSEvent.mouseEvent( - with: .leftMouseUp, - location: NSPoint(x: -10, y: -10), - modifierFlags: [], - timestamp: 0.1, - windowNumber: controller.rulerWindow.windowNumber, - context: nil, - eventNumber: 1, - clickCount: 1, - pressure: 0 - )! - - controller.mouseDown(with: mouseDownEvent) - controller.windowDidMove(Notification(name: NSWindow.didMoveNotification, object: controller.rulerWindow)) - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - - XCTAssertFalse(controller.rulerWindow.rule.showMouseTick) - XCTAssertFalse(otherController.rulerWindow.rule.showMouseTick) - - controller.finishMouseDrag(with: mouseUpOutsideEvent) - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - - XCTAssertTrue(controller.rulerWindow.rule.showMouseTick) - XCTAssertTrue(otherController.rulerWindow.rule.showMouseTick) - } - } - - func testGroupedChildMoveDoesNotResumeMouseTicksDuringDrag() { - withInstalledAppDelegate { appDelegate in - let draggedController = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - ) - let groupedChildController = RulerController( - ruler: Ruler(.vertical, frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) - ) - appDelegate.rulers = [draggedController, groupedChildController] - draggedController.otherWindow = groupedChildController.rulerWindow - groupedChildController.otherWindow = draggedController.rulerWindow - groupedChildController.isLeftMouseButtonPressed = { true } - let mouseDownEvent = NSEvent.mouseEvent( - with: .leftMouseDown, - location: NSPoint(x: 10, y: 10), - modifierFlags: [], - timestamp: 0, - windowNumber: draggedController.rulerWindow.windowNumber, - context: nil, - eventNumber: 0, - clickCount: 1, - pressure: 1 - )! - - draggedController.mouseDown(with: mouseDownEvent) - groupedChildController.windowDidMove( - Notification(name: NSWindow.didMoveNotification, object: groupedChildController.rulerWindow) - ) - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - - XCTAssertFalse(draggedController.rulerWindow.rule.showMouseTick) - XCTAssertFalse(groupedChildController.rulerWindow.rule.showMouseTick) - } - } - - func testPrimaryGroupedRulerHotkeysToggleLegVisibilityWithoutLegacyWindows() { + func testPrimaryRulerHotkeysToggleWingVisibilityWithoutLegacyWindows() { withRestoredZeroCornerPreference { let previousGroupRulers = prefs.groupRulers defer { prefs.groupRulers = previousGroupRulers } @@ -3465,12 +3306,11 @@ final class RulerCoreTests: XCTestCase { ) ) - let groupedWindow = appDelegate.groupedRulerController?.groupedWindow + let rulerWindow = appDelegate.rulerManager.activeController?.rulerWindow XCTAssertTrue(prefs.groupRulers) - XCTAssertFalse(groupedWindow?.isRuleVisible(.horizontal) ?? true) - XCTAssertTrue(groupedWindow?.isRuleVisible(.vertical) ?? false) - XCTAssertTrue(appDelegate.rulers.isEmpty) - groupedWindow?.orderOut(self) + XCTAssertFalse(rulerWindow?.isRuleVisible(.horizontal) ?? true) + XCTAssertTrue(rulerWindow?.isRuleVisible(.vertical) ?? false) + rulerWindow?.orderOut(self) } } @@ -3545,10 +3385,10 @@ final class RulerCoreTests: XCTestCase { ) ) - XCTAssertFalse(first.groupedWindow.isRuleVisible(.horizontal)) - XCTAssertTrue(first.groupedWindow.isRuleVisible(.vertical)) - XCTAssertTrue(second.groupedWindow.isRuleVisible(.horizontal)) - XCTAssertTrue(second.groupedWindow.isRuleVisible(.vertical)) + XCTAssertFalse(first.rulerWindow.isRuleVisible(.horizontal)) + XCTAssertTrue(first.rulerWindow.isRuleVisible(.vertical)) + XCTAssertTrue(second.rulerWindow.isRuleVisible(.horizontal)) + XCTAssertTrue(second.rulerWindow.isRuleVisible(.vertical)) } func testManagedCommandsApplySettingsToActiveRulerOnly() { @@ -3576,9 +3416,9 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(first.state.settings.unit, .inches) XCTAssertFalse(first.state.settings.floatRulers) XCTAssertTrue(first.state.settings.rulerShadow) - XCTAssertEqual(first.groupedWindow.horizontalRule.unit, .inches) - XCTAssertFalse(first.groupedWindow.isFloatingPanel) - XCTAssertTrue(first.groupedWindow.hasShadow) + XCTAssertEqual(first.rulerWindow.horizontalRule.unit, .inches) + XCTAssertFalse(first.rulerWindow.isFloatingPanel) + XCTAssertTrue(first.rulerWindow.hasShadow) XCTAssertEqual(second.state.settings.unit, .millimeters) XCTAssertTrue(second.state.settings.floatRulers) XCTAssertFalse(second.state.settings.rulerShadow) @@ -3609,7 +3449,7 @@ final class RulerCoreTests: XCTestCase { appDelegate.flipRulers(along: .horizontal) XCTAssertEqual(second.state.settings.zeroCorner, .topRight) - XCTAssertEqual(second.groupedWindow.horizontalRule.zeroCorner, .topRight) + XCTAssertEqual(second.rulerWindow.horizontalRule.zeroCorner, .topRight) XCTAssertEqual(first.state.settings.zeroCorner, .bottomLeft) XCTAssertEqual(prefs.zeroCorner, .topRight) @@ -3682,166 +3522,51 @@ final class RulerCoreTests: XCTestCase { XCTAssertTrue(appDelegate.validateMenuItem(groupItem)) } - func testUngroupedHorizontalFlipDoesNotMoveRulerWindows() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } - - prefs.zeroCorner = .topLeft - prefs.groupRulers = false - let appDelegate = AppDelegate() - let horizontalController = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) - ) - let verticalController = RulerController( - ruler: Ruler(.vertical, frame: NSRect(x: 51, y: 150, width: Ruler.thickness, height: 160)) - ) - appDelegate.rulers = [verticalController, horizontalController] - - appDelegate.flipRulers(along: .horizontal) - - XCTAssertEqual(prefs.zeroCorner, .topRight) - XCTAssertEqual(horizontalController.rulerWindow.frame, NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) - XCTAssertEqual(verticalController.rulerWindow.frame, NSRect(x: 51, y: 150, width: Ruler.thickness, height: 160)) - } - } - - func testGroupedHorizontalFlipMovesVerticalRulerToPreserveZeroPointOffset() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } - - prefs.zeroCorner = .topLeft - prefs.groupRulers = true - let appDelegate = TestableFlipAppDelegate() - let horizontalController = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) - ) - let verticalController = RulerController( - ruler: Ruler(.vertical, frame: NSRect(x: 51, y: 150, width: Ruler.thickness, height: 160)) - ) - appDelegate.rulers = [verticalController, horizontalController] - - appDelegate.flipRulers(along: .horizontal) - - XCTAssertEqual(prefs.zeroCorner, .topRight) - XCTAssertEqual(horizontalController.rulerWindow.frame, NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) - XCTAssertEqual(verticalController.rulerWindow.frame, NSRect(x: 210, y: 150, width: Ruler.thickness, height: 160)) - } - } - - func testGroupedVerticalFlipMovesHorizontalRulerToPreserveZeroPointOffset() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } - - prefs.zeroCorner = .topLeft - prefs.groupRulers = true - let appDelegate = TestableFlipAppDelegate() - let horizontalController = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) - ) - let verticalController = RulerController( - ruler: Ruler(.vertical, frame: NSRect(x: 61, y: 140, width: Ruler.thickness, height: 160)) - ) - appDelegate.rulers = [verticalController, horizontalController] - - appDelegate.flipRulers(along: .vertical) - - XCTAssertEqual(prefs.zeroCorner, .bottomLeft) - XCTAssertEqual(verticalController.rulerWindow.frame, NSRect(x: 61, y: 140, width: Ruler.thickness, height: 160)) - XCTAssertEqual(horizontalController.rulerWindow.frame, NSRect(x: 100, y: 101, width: 120, height: Ruler.thickness)) - } - } - - func testGroupedFlipDoesNotShowHiddenRulerWindows() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } - - prefs.zeroCorner = .topLeft - prefs.groupRulers = true - let appDelegate = AppDelegate() - let horizontalController = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) - ) - let verticalController = RulerController( - ruler: Ruler(.vertical, frame: NSRect(x: 61, y: 140, width: Ruler.thickness, height: 160)) - ) - appDelegate.rulers = [verticalController, horizontalController] - let horizontalFrame = horizontalController.rulerWindow.frame - let verticalFrame = verticalController.rulerWindow.frame - - XCTAssertFalse(horizontalController.rulerWindow.isVisible) - XCTAssertFalse(verticalController.rulerWindow.isVisible) - - appDelegate.flipRulers(along: .horizontal) - - XCTAssertFalse(horizontalController.rulerWindow.isVisible) - XCTAssertFalse(verticalController.rulerWindow.isVisible) - XCTAssertEqual(horizontalController.rulerWindow.frame, horizontalFrame) - XCTAssertEqual(verticalController.rulerWindow.frame, verticalFrame) + func testShiftHotkeysFlipActiveRulerOrigin() { + let appDelegate = AppDelegate() + let controller = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(zeroCorner: .topLeft) + ) + defer { + controller.hide() } - } - func testShiftHotkeysFlipRulerOrigins() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } - - prefs.zeroCorner = .topLeft - prefs.groupRulers = false - let appDelegate = AppDelegate() - let horizontalController = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) - ) - let verticalController = RulerController( - ruler: Ruler(.vertical, frame: NSRect(x: 61, y: 140, width: Ruler.thickness, height: 160)) - ) - appDelegate.rulers = [verticalController, horizontalController] - - XCTAssertTrue( - appDelegate.performRulerHotkey( - keyCode: kVK_ANSI_H, - modifierFlags: .shift, - sender: horizontalController - ) + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_H, + modifierFlags: .shift, + sender: controller ) - XCTAssertEqual(prefs.zeroCorner, .topRight) + ) + XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) - XCTAssertTrue( - appDelegate.performRulerHotkey( - keyCode: kVK_ANSI_V, - modifierFlags: .shift, - sender: verticalController - ) + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_V, + modifierFlags: .shift, + sender: controller ) - XCTAssertEqual(prefs.zeroCorner, .bottomRight) - } + ) + XCTAssertEqual(controller.state.settings.zeroCorner, .bottomRight) } func testShiftHotkeysIgnoreCapsLock() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } - - prefs.zeroCorner = .topLeft - prefs.groupRulers = false - let appDelegate = AppDelegate() - let horizontalController = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) - ) - appDelegate.rulers = [horizontalController] + let appDelegate = AppDelegate() + let controller = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(zeroCorner: .topLeft) + ) + defer { + controller.hide() + } - XCTAssertTrue( - appDelegate.performRulerHotkey( - keyCode: kVK_ANSI_H, - modifierFlags: [.shift, .capsLock], - sender: horizontalController - ) + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_H, + modifierFlags: [.shift, .capsLock], + sender: controller ) - XCTAssertEqual(prefs.zeroCorner, .topRight) - } + ) + XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) } func testNonShiftModifiedRulerHotkeysAreIgnored() { @@ -3856,52 +3581,6 @@ final class RulerCoreTests: XCTestCase { ) } - func testResetPositionUsesCurrentZeroCorner() { - withRestoredZeroCornerPreference { - prefs.zeroCorner = .bottomRight - let horizontalController = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 10, y: 20, width: 300, height: Ruler.thickness)) - ) - let verticalController = RulerController( - ruler: Ruler(.vertical, frame: NSRect(x: 10, y: 20, width: Ruler.thickness, height: 300)) - ) - - horizontalController.resetPosition() - verticalController.resetPosition() - - XCTAssertEqual( - horizontalController.rulerWindow.frame, - getDefaultContentRect(orientation: .horizontal, zeroCorner: .bottomRight) - ) - XCTAssertEqual( - verticalController.rulerWindow.frame, - getDefaultContentRect(orientation: .vertical, zeroCorner: .bottomRight) - ) - XCTAssertEqual(prefs.zeroCorner, .bottomRight) - } - } - - func testResetPositionKeepsFlippedDefaultRulersOnSharedZeroPoint() { - withRestoredZeroCornerPreference { - prefs.zeroCorner = .topRight - let horizontalController = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 10, y: 20, width: 300, height: Ruler.thickness)) - ) - let verticalController = RulerController( - ruler: Ruler(.vertical, frame: NSRect(x: 10, y: 20, width: Ruler.thickness, height: 300)) - ) - - horizontalController.resetPosition() - verticalController.resetPosition() - - let geometry = ZeroCornerGeometry(zeroCorner: .topRight) - XCTAssertEqual( - geometry.zeroPoint(in: horizontalController.rulerWindow.frame, for: .horizontal), - geometry.zeroPoint(in: verticalController.rulerWindow.frame, for: .vertical) - ) - } - } - private func mouseEvent( type: NSEvent.EventType, location: NSPoint, @@ -4029,7 +3708,7 @@ final class RulerCoreTests: XCTestCase { file: StaticString = #filePath, line: UInt = #line ) { - let controller = GroupedRulerController( + let controller = RulerController( state: RulerInstanceState( settings: RulerSettings(zeroCorner: zeroCorner), layout: RulerLayoutState( @@ -4165,26 +3844,6 @@ final class RulerCoreTests: XCTestCase { } } -private final class ChildAttachingRulerWindow: LegacyRulerWindow { - private let childWindowToAttach: NSWindow - - init(ruler: Ruler, childWindow: NSWindow) { - self.childWindowToAttach = childWindow - super.init(ruler: ruler) - } - - override func makeKey() { - super.makeKey() - addChildWindow(childWindowToAttach, ordered: .below) - } -} - -private final class TestableFlipAppDelegate: AppDelegate { - override func isRulerWindowShown(_ window: LegacyRulerWindow) -> Bool { - return true - } -} - private final class TestableZeroCornerHorizontalRule: HorizontalRule { var testZeroCorner: ZeroCorner = .topLeft @@ -4193,14 +3852,14 @@ private final class TestableZeroCornerHorizontalRule: HorizontalRule { } } -private func groupedContentView(size: NSSize, zeroCorner: ZeroCorner) -> GroupedRulerContentView { +private func rulerContentView(size: NSSize, zeroCorner: ZeroCorner) -> RulerContentView { let horizontalRule = HorizontalRule( frame: NSRect(x: 0, y: 0, width: 120, height: Ruler.thickness) ) let verticalRule = VerticalRule( frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 160) ) - let view = GroupedRulerContentView( + let view = RulerContentView( frame: NSRect(origin: .zero, size: size), horizontalRule: horizontalRule, verticalRule: verticalRule @@ -4211,7 +3870,26 @@ private func groupedContentView(size: NSSize, zeroCorner: ZeroCorner) -> Grouped return view } -private func pointInsideEmptyGroupedCorner( +private func oneWingRulerWindow( + orientation: Orientation, + frame: NSRect, + settings: RulerSettings = RulerSettings() +) -> RulerWindow { + let window = RulerWindow(frame: frame, settings: settings) + window.setVisibleRules( + horizontal: orientation == .horizontal, + vertical: orientation == .vertical + ) + window.setFrame(frame, display: false) + window.updateLayoutForCurrentZeroCorner() + return window +} + +private func resizeHandle(in rule: RuleView) -> ResizeHandleView? { + return rule.subviews.first { $0 is ResizeHandleView } as? ResizeHandleView +} + +private func pointInsideEmptyRulerWindowCorner( horizontalFrame: NSRect, verticalFrame: NSRect, bounds: NSRect diff --git a/FreeRulerTests/RulerSnapshotTests.swift b/FreeRulerTests/RulerSnapshotTests.swift index b554dc1..b6d9a1d 100644 --- a/FreeRulerTests/RulerSnapshotTests.swift +++ b/FreeRulerTests/RulerSnapshotTests.swift @@ -24,10 +24,10 @@ final class RulerSnapshotTests: XCTestCase { ) } - func testGroupedWindowLayoutRenderingMatchesSnapshot() throws { + func testRulerWindowLayoutRenderingMatchesSnapshot() throws { try assertSnapshot( named: "grouped-ruler-window-layout", - view: RulerSnapshotFactory.groupedWindowLayoutSnapshotView() + view: RulerSnapshotFactory.rulerWindowLayoutSnapshotView() ) } @@ -222,15 +222,15 @@ enum RulerSnapshotFactory { return canvas } - static func groupedWindowLayoutSnapshotView() -> NSView { + static func rulerWindowLayoutSnapshotView() -> NSView { let margin: CGFloat = 24 - let groupedSize = NSSize(width: 620, height: 360) + let rulerWindowSize = NSSize(width: 620, height: 360) let canvas = SnapshotCanvasView( frame: NSRect( x: 0, y: 0, - width: groupedSize.width + (margin * 2), - height: groupedSize.height + (margin * 2) + width: rulerWindowSize.width + (margin * 2), + height: rulerWindowSize.height + (margin * 2) ) ) canvas.backgroundColor = NSColor(deviceWhite: 0.18, alpha: 1) @@ -249,17 +249,17 @@ enum RulerSnapshotFactory { configure(horizontalRule, fill: SnapshotColors.lightRuler, showsMouseTick: false) configure(verticalRule, fill: SnapshotColors.lightRuler, showsMouseTick: false) - let groupedView = GroupedRulerContentView( - frame: NSRect(origin: NSPoint(x: margin, y: margin), size: groupedSize), + let rulerWindowView = RulerContentView( + frame: NSRect(origin: NSPoint(x: margin, y: margin), size: rulerWindowSize), horizontalRule: horizontalRule, verticalRule: verticalRule ) - groupedView.zeroCorner = zeroCorner - groupedView.color = RulerColors(customFill: SnapshotColors.lightRuler) - groupedView.layoutSubtreeIfNeeded() - groupedView.needsDisplay = true + rulerWindowView.zeroCorner = zeroCorner + rulerWindowView.color = RulerColors(customFill: SnapshotColors.lightRuler) + rulerWindowView.layoutSubtreeIfNeeded() + rulerWindowView.needsDisplay = true - canvas.addSubview(groupedView) + canvas.addSubview(rulerWindowView) return canvas } @@ -269,7 +269,7 @@ enum RulerSnapshotFactory { let snapshots: [(name: String, view: NSView)] = [ ("ruler-zero-corners", zeroCornerSnapshotView()), ("ruler-mouse-tick-labels", mouseTickLabelSnapshotView()), - ("grouped-ruler-window-layout", groupedWindowLayoutSnapshotView()), + ("grouped-ruler-window-layout", rulerWindowLayoutSnapshotView()), ] for snapshot in snapshots { diff --git a/FreeRulerUITests/FreeRulerUITests.swift b/FreeRulerUITests/FreeRulerUITests.swift index 13257b6..66345c9 100644 --- a/FreeRulerUITests/FreeRulerUITests.swift +++ b/FreeRulerUITests/FreeRulerUITests.swift @@ -27,7 +27,7 @@ final class FreeRulerUITests: XCTestCase { } func testRulerVisibilityKeyboardCommands() { - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 3)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) @@ -35,7 +35,7 @@ final class FreeRulerUITests: XCTestCase { app.typeKey("h", modifierFlags: []) XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) XCTAssertTrue(verticalRuler.exists) - XCTAssertTrue(groupedRuler.exists) + XCTAssertTrue(rulerWindow.exists) app.typeKey("h", modifierFlags: []) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) @@ -44,14 +44,14 @@ final class FreeRulerUITests: XCTestCase { app.typeKey("v", modifierFlags: []) XCTAssertTrue(verticalRuler.waitForNonExistence(timeout: 2)) XCTAssertTrue(horizontalRuler.exists) - XCTAssertTrue(groupedRuler.exists) + XCTAssertTrue(rulerWindow.exists) app.typeKey("v", modifierFlags: []) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) } - func testGroupedRulerToggleHidesRequestedLegWithoutUngrouping() { - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 3)) + func testRulerWindowToggleHidesRequestedWingWithoutChangingGroupedDragging() { + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(waitForPreference("groupRulers", equals: true)) @@ -61,11 +61,11 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForNonExistence(timeout: 2)) - XCTAssertTrue(groupedRuler.exists) + XCTAssertTrue(rulerWindow.exists) assertFrame( - groupedRuler.frame, + rulerWindow.frame, matches: horizontalRuler.frame, - message: "Grouped window should shrink to the visible horizontal ruler frame" + message: "Ruler window should shrink to the visible horizontal ruler frame" ) XCTAssertTrue(waitForPreference("groupRulers", equals: true)) @@ -77,50 +77,56 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) - XCTAssertTrue(groupedRuler.exists) + XCTAssertTrue(rulerWindow.exists) assertFrame( - groupedRuler.frame, + rulerWindow.frame, matches: verticalRuler.frame, - message: "Grouped window should shrink to the visible vertical ruler frame" + message: "Ruler window should shrink to the visible vertical ruler frame" ) XCTAssertTrue(waitForPreference("groupRulers", equals: true)) } - func testGroupRulersKeyboardCommandUngroupsOnFirstAttempt() { - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 3)) + func testGroupRulersKeyboardCommandTogglesGroupedDraggingWithoutChangingRulerWindow() { + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + let originalFrame = rulerWindow.frame + horizontalRuler.click() app.typeKey("g", modifierFlags: []) XCTAssertTrue(waitForPreference("groupRulers", equals: false)) - XCTAssertTrue(groupedRuler.waitForNonExistence(timeout: 2)) - XCTAssertTrue(horizontalRulerWindow.waitForExistence(timeout: 2)) - XCTAssertTrue(verticalRulerWindow.waitForExistence(timeout: 2)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 2)) + XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) + XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) + assertFrame( + rulerWindow.frame, + matches: originalFrame, + message: "Toggling grouped dragging should not replace or resize the ruler window" + ) app.typeKey("g", modifierFlags: []) XCTAssertTrue(waitForPreference("groupRulers", equals: true)) - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 2)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 2)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) verticalRuler.click() app.typeKey("g", modifierFlags: []) XCTAssertTrue(waitForPreference("groupRulers", equals: false)) - XCTAssertTrue(groupedRuler.waitForNonExistence(timeout: 2)) - XCTAssertTrue(horizontalRulerWindow.waitForExistence(timeout: 2)) - XCTAssertTrue(verticalRulerWindow.waitForExistence(timeout: 2)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 2)) + XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) + XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) } func testPreferencesCloseWithCommandW() { - app.typeKey(",", modifierFlags: .command) + openPreferencesShortcut() - let preferences = app.windows["Free Ruler Preferences"] - XCTAssertTrue(preferences.waitForExistence(timeout: 3)) + XCTAssertTrue(preferencesWindow.waitForExistence(timeout: 3)) app.typeKey("w", modifierFlags: .command) - XCTAssertTrue(preferences.waitForNonExistence(timeout: 2)) + XCTAssertTrue(preferencesWindow.waitForNonExistence(timeout: 2)) } func testRulerColorPanelHidesOpacityControl() { @@ -153,26 +159,26 @@ final class FreeRulerUITests: XCTestCase { app.launch() app.activate() - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 3)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(colorPanel.waitForNonExistence(timeout: 2)) } func testRulerCloseWithCommandW() { - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 3)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - groupedRuler.click() + rulerWindow.click() app.typeKey("w", modifierFlags: .command) - XCTAssertTrue(groupedRuler.waitForNonExistence(timeout: 2)) + XCTAssertTrue(rulerWindow.waitForNonExistence(timeout: 2)) XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForNonExistence(timeout: 2)) } - func testHiddenRulersCanBeRestoredAndResetRestoresVisibility() { - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 3)) + func testLastVisibleWingStaysVisibleAndResetRestoresBothWings() { + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) @@ -182,12 +188,11 @@ final class FreeRulerUITests: XCTestCase { verticalRuler.click() app.typeKey("v", modifierFlags: []) - XCTAssertTrue(verticalRuler.waitForNonExistence(timeout: 2)) - XCTAssertTrue(groupedRuler.waitForNonExistence(timeout: 2)) + XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 2)) app.typeKey("h", modifierFlags: []) - app.typeKey("v", modifierFlags: []) - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 2)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 2)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) @@ -196,45 +201,48 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) app.typeKey("r", modifierFlags: .command) - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 2)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 2)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) } func testFloatShadowAndUnitKeyboardCommands() { - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 3)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - XCTAssertTrue(waitForPreference("floatRulers", equals: true)) - XCTAssertTrue(waitForPreference("rulerShadow", equals: false)) + XCTAssertTrue(waitForPreference("activeFloatRulers", equals: true)) + XCTAssertTrue(waitForPreference("activeRulerShadow", equals: false)) horizontalRuler.click() app.typeKey("f", modifierFlags: []) - XCTAssertTrue(waitForPreference("floatRulers", equals: false)) + XCTAssertTrue(waitForPreference("activeFloatRulers", equals: false)) app.typeKey("f", modifierFlags: []) - XCTAssertTrue(waitForPreference("floatRulers", equals: true)) + XCTAssertTrue(waitForPreference("activeFloatRulers", equals: true)) app.typeKey("s", modifierFlags: []) - XCTAssertTrue(waitForPreference("rulerShadow", equals: true)) + XCTAssertTrue(waitForPreference("activeRulerShadow", equals: true)) app.typeKey("s", modifierFlags: []) - XCTAssertTrue(waitForPreference("rulerShadow", equals: false)) + XCTAssertTrue(waitForPreference("activeRulerShadow", equals: false)) XCTAssertEqual(horizontalRulerView.value as? String, "px") app.typeKey("u", modifierFlags: []) + XCTAssertTrue(waitForPreference("activeUnit", equals: "mm")) XCTAssertEqual(horizontalRulerView.value as? String, "mm") app.typeKey("u", modifierFlags: []) + XCTAssertTrue(waitForPreference("activeUnit", equals: "in")) XCTAssertEqual(horizontalRulerView.value as? String, "in") app.typeKey("u", modifierFlags: []) + XCTAssertTrue(waitForPreference("activeUnit", equals: "px")) XCTAssertEqual(horizontalRulerView.value as? String, "px") } func testOptionHotkeysShowStatusBezel() { - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 3)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) @@ -268,7 +276,7 @@ final class FreeRulerUITests: XCTestCase { } func testAlignRulersAtMouseLocationKeyboardCommand() { - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 3)) + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) @@ -282,44 +290,39 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(verticalRuler.waitForFrameChange(from: originalVerticalFrame, timeout: 2)) } - func testRulerCursorsForGroupedAndUngroupedScenarios() { - XCTAssertTrue(groupedRuler.waitForExistence(timeout: 3)) + func testRulerCursorsForVisibleWings() { + XCTAssertTrue(rulerWindow.waitForExistence(timeout: 3)) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) - XCTContext.runActivity(named: "ungrouped horizontal ruler cursor") { _ in + XCTContext.runActivity(named: "horizontal-only ruler cursor") { _ in resetRulerCursorScenario() - isolateHorizontalRulerByUngroupingWithVerticalToggle() + isolateHorizontalWing() - assertCursorSequence(on: horizontalRuler, label: "ungrouped horizontal ruler") + assertCursorSequence(on: horizontalRuler, label: "horizontal-only ruler") } - XCTContext.runActivity(named: "ungrouped vertical ruler cursor") { _ in + XCTContext.runActivity(named: "vertical-only ruler cursor") { _ in resetRulerCursorScenario() - isolateHorizontalRulerByUngroupingWithVerticalToggle() - - app.typeKey("h", modifierFlags: []) - XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) - app.typeKey("v", modifierFlags: []) - XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) + isolateVerticalWing() - assertCursorSequence(on: verticalRuler, label: "ungrouped vertical ruler") + assertCursorSequence(on: verticalRuler, label: "vertical-only ruler") } - XCTContext.runActivity(named: "grouped cursor with horizontal key ruler") { _ in + XCTContext.runActivity(named: "both wings visible with horizontal key ruler") { _ in resetRulerCursorScenario() horizontalRuler.click() - assertCursorSequence(on: horizontalRulerView, label: "grouped key horizontal ruler") - assertCursorSequence(on: verticalRulerView, label: "grouped child vertical ruler") + assertCursorSequence(on: horizontalRulerView, label: "key horizontal ruler") + assertCursorSequence(on: verticalRulerView, label: "vertical ruler") } - XCTContext.runActivity(named: "grouped cursor with vertical key ruler") { _ in + XCTContext.runActivity(named: "both wings visible with vertical key ruler") { _ in resetRulerCursorScenario() verticalRuler.click() - assertCursorSequence(on: verticalRulerView, label: "grouped key vertical ruler") - assertCursorSequence(on: horizontalRulerView, label: "grouped child horizontal ruler") + assertCursorSequence(on: verticalRulerView, label: "key vertical ruler") + assertCursorSequence(on: horizontalRulerView, label: "horizontal ruler") } } @@ -331,16 +334,8 @@ final class FreeRulerUITests: XCTestCase { verticalRulerView } - private var groupedRuler: XCUIElement { - app.dialogs["grouped-ruler-window"] - } - - private var horizontalRulerWindow: XCUIElement { - app.dialogs["horizontal-ruler-window"] - } - - private var verticalRulerWindow: XCUIElement { - app.dialogs["vertical-ruler-window"] + private var rulerWindow: XCUIElement { + app.dialogs["ruler-window"] } private var horizontalRulerView: XCUIElement { @@ -352,7 +347,7 @@ final class FreeRulerUITests: XCTestCase { } private var preferencesWindow: XCUIElement { - app.windows["Free Ruler Preferences"] + app.windows["preferences-window"] } private var rulerColorWell: XCUIElement { @@ -373,11 +368,15 @@ final class FreeRulerUITests: XCTestCase { private func openPreferences() { if !preferencesWindow.exists { - app.typeKey(",", modifierFlags: .command) + openPreferencesShortcut() XCTAssertTrue(preferencesWindow.waitForExistence(timeout: 3)) } } + private func openPreferencesShortcut() { + app.typeKey(",", modifierFlags: [.command, .option]) + } + private func openRulerColorPanel() { openPreferences() @@ -391,26 +390,28 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(colorPanel.waitForExistence(timeout: 3)) } - private func isolateHorizontalRulerByUngroupingWithVerticalToggle() { + private func isolateHorizontalWing() { horizontalRuler.click() - app.typeKey("g", modifierFlags: []) - - XCTAssertTrue(waitForPreference("groupRulers", equals: false)) - XCTAssertTrue(groupedRuler.waitForNonExistence(timeout: 2)) - XCTAssertTrue(horizontalRulerWindow.waitForExistence(timeout: 2)) - XCTAssertTrue(verticalRulerWindow.waitForExistence(timeout: 2)) - app.typeKey("v", modifierFlags: []) XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) XCTAssertTrue(verticalRuler.waitForNonExistence(timeout: 2)) - XCTAssertTrue(waitForPreference("groupRulers", equals: false)) + XCTAssertTrue(waitForPreference("groupRulers", equals: true)) + } + + private func isolateVerticalWing() { + verticalRuler.click() + app.typeKey("h", modifierFlags: []) + + XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) + XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) + XCTAssertTrue(waitForPreference("groupRulers", equals: true)) } private func resetRulerCursorScenario() { app.typeKey("r", modifierFlags: .command) - XCTAssertTrue(groupedRuler.waitForVisibleFrame(timeout: 1)) + XCTAssertTrue(rulerWindow.waitForVisibleFrame(timeout: 1)) XCTAssertTrue(horizontalRuler.waitForVisibleFrame(timeout: 1)) XCTAssertTrue(verticalRuler.waitForVisibleFrame(timeout: 1)) XCTAssertTrue(waitForPreference("groupRulers", equals: true)) From 438a59bdb57fc9ee7dee6fbbe9e06f61ff096f73 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 21:46:47 -0400 Subject: [PATCH 32/38] Add active border drawing to RulerWindow and RulerContentView (#261) * Introduced `drawsActiveBorder` property to `RulerWindow` and `RulerContentView` to manage the visibility of an active border. * Updated `RulerWindowBorderView` to draw an active border based on the new property. * Implemented logic in `RulerManager` to ensure only the active ruler displays the active border. * Added unit test to verify active border behavior for active and inactive rulers. --- Free Ruler/RulerWindow.swift | 74 +++++++++++++++++++++++++---- FreeRulerTests/RulerCoreTests.swift | 30 ++++++++++++ 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/Free Ruler/RulerWindow.swift b/Free Ruler/RulerWindow.swift index f034402..29053d1 100644 --- a/Free Ruler/RulerWindow.swift +++ b/Free Ruler/RulerWindow.swift @@ -450,6 +450,11 @@ final class RulerWindow: NSPanel { return frame.origin } + var drawsActiveBorder: Bool { + get { return rulerContentView.drawsActiveBorder } + set { rulerContentView.drawsActiveBorder = newValue } + } + private var leftMouseButtonIsPressed: Bool { return NSEvent.pressedMouseButtons & 1 == 1 } @@ -566,6 +571,9 @@ private final class RulerClipView: NSView { } private final class RulerWindowBorderView: RulerBorderView { + private static let activeBorderCenterInset = borderCenterInset + borderWidth + private static let activeBorderColor = NSColor(calibratedWhite: 0, alpha: 0.25) + var zeroCorner = prefs.zeroCorner { didSet { needsDisplay = true @@ -584,31 +592,58 @@ private final class RulerWindowBorderView: RulerBorderView { } } + var drawsActiveBorder = false { + didSet { + needsDisplay = true + } + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + guard drawsActiveBorder else { return } + + let path = activeBorderPath(in: bounds) + path.lineWidth = Self.borderWidth + path.lineJoinStyle = .miter + path.lineCapStyle = .butt + Self.activeBorderColor.setStroke() + path.stroke() + } + override func borderPath(in bounds: NSRect) -> NSBezierPath { + return panelBorderPath(in: bounds, inset: Self.borderCenterInset) + } + + private func activeBorderPath(in bounds: NSRect) -> NSBezierPath { + return panelBorderPath(in: bounds, inset: Self.activeBorderCenterInset) + } + + private func panelBorderPath(in bounds: NSRect, inset: CGFloat) -> NSBezierPath { switch (showsHorizontalRule, showsVerticalRule) { case (true, true): - return lShapedBorderPath() + return lShapedBorderPath(inset: inset) case (true, false): - return visibleBoundsBorderPath() + return visibleBoundsBorderPath(inset: inset) case (false, true): - return visibleBoundsBorderPath() + return visibleBoundsBorderPath(inset: inset) case (false, false): return NSBezierPath() } } - private func lShapedBorderPath() -> NSBezierPath { + private func lShapedBorderPath(inset: CGFloat) -> NSBezierPath { return rulerWindowLShapedPath( in: bounds, zeroCorner: zeroCorner, - inset: Self.borderCenterInset + inset: inset ) } - private func visibleBoundsBorderPath() -> NSBezierPath { + private func visibleBoundsBorderPath(inset: CGFloat) -> NSBezierPath { return NSBezierPath(rect: bounds.insetBy( - dx: Self.borderCenterInset, - dy: Self.borderCenterInset + dx: inset, + dy: inset )) } } @@ -808,6 +843,12 @@ final class RulerContentView: NSView { } } + var drawsActiveBorder = false { + didSet { + borderView.drawsActiveBorder = drawsActiveBorder + } + } + init( frame frameRect: NSRect, horizontalRule: HorizontalRule, @@ -1632,6 +1673,7 @@ final class RulerManager { func restore(_ states: [RulerInstanceState], activeRulerID restoredActiveRulerID: UUID? = nil) { for controller in controllers { + controller.rulerWindow.drawsActiveBorder = false controller.hide() } @@ -1688,11 +1730,18 @@ final class RulerManager { } func close(_ controller: RulerController) { + let wasActiveController = activeRulerID == controller.state.id + controller.hide() controllers.removeAll { $0 === controller } - if activeRulerID == controller.state.id { + if wasActiveController { activeRulerID = controllers.last?.state.id + } + + updateActiveRulerBorders() + + if wasActiveController { onActiveControllerChanged?(activeController) } @@ -1703,6 +1752,7 @@ final class RulerManager { guard controllers.contains(where: { $0 === controller }) else { return } activeRulerID = controller.state.id + updateActiveRulerBorders() onActiveControllerChanged?(controller) notifyStateChanged() } @@ -1811,6 +1861,12 @@ final class RulerManager { private func notifyStateChanged() { onStateChanged?(self) } + + private func updateActiveRulerBorders() { + for controller in controllers { + controller.rulerWindow.drawsActiveBorder = controller.state.id == activeRulerID + } + } } #endif diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 49710df..f826779 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -169,6 +169,36 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(manager.states.map(\.settings.unit), [.inches]) } + func testRulerManagerDrawsActiveBorderOnlyOnActiveRuler() { + let manager = RulerManager() + defer { + for controller in manager.controllers { + controller.hide() + } + } + + let first = manager.createRuler( + defaults: RulerSettings(unit: .pixels), + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + let second = manager.createRuler( + defaults: RulerSettings(unit: .inches), + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + + XCTAssertFalse(first.rulerWindow.drawsActiveBorder) + XCTAssertTrue(second.rulerWindow.drawsActiveBorder) + + manager.markActive(first) + + XCTAssertTrue(first.rulerWindow.drawsActiveBorder) + XCTAssertFalse(second.rulerWindow.drawsActiveBorder) + + XCTAssertTrue(manager.closeActiveRuler()) + + XCTAssertTrue(second.rulerWindow.drawsActiveBorder) + } + func testRulerManagerStaggersNewRulersWhenDefaultPositionIsOccupied() { let manager = RulerManager() defer { From b96ed5cdfc8e36749a226600d978b4b1677b92b8 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 22:26:41 -0400 Subject: [PATCH 33/38] Update localization keys and UI labels for ruler functionality * Changed localization keys from plural to singular for better accuracy: "RulersFloated" to "RulerFloated" and "RulersUnfloated" to "RulerUnfloated". * Updated UI labels and comments in multiple languages to reflect the singular form. * Adjusted related test cases to ensure consistency with the new localization keys. --- Free Ruler/AppDelegate.swift | 16 ++++----- Free Ruler/Base.lproj/MainMenu.xib | 14 ++++---- .../Base.lproj/RulerSettingsControlsView.xib | 2 +- Free Ruler/Localizable.xcstrings | 34 +++++++++---------- Free Ruler/de.lproj/MainMenu.strings | 4 +-- .../RulerSettingsControlsView.strings | 4 +-- Free Ruler/es.lproj/MainMenu.strings | 4 +-- .../RulerSettingsControlsView.strings | 4 +-- Free Ruler/fi.lproj/MainMenu.strings | 4 +-- .../RulerSettingsControlsView.strings | 4 +-- Free Ruler/ja.lproj/MainMenu.strings | 2 +- .../RulerSettingsControlsView.strings | 2 +- Free Ruler/zh-hans.lproj/MainMenu.strings | 2 +- .../RulerSettingsControlsView.strings | 2 +- FreeRulerUITests/FreeRulerUITests.swift | 4 +-- 15 files changed, 51 insertions(+), 51 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 0767702..4918791 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -8,8 +8,8 @@ import Sparkle #endif private enum HotkeyBezelLocalizationKey: String { - case rulersFloated = "HotkeyBezel.RulersFloated" - case rulersUnfloated = "HotkeyBezel.RulersUnfloated" + case rulerFloated = "HotkeyBezel.RulerFloated" + case rulerUnfloated = "HotkeyBezel.RulerUnfloated" case rulersGrouped = "HotkeyBezel.RulersGrouped" case rulersUngrouped = "HotkeyBezel.RulersUngrouped" case shadowEnabled = "HotkeyBezel.ShadowEnabled" @@ -27,10 +27,10 @@ private enum HotkeyBezelLocalizationKey: String { private var comment: String { switch self { - case .rulersFloated: - return "Hotkey status bezel text indicating rulers now float above other windows" - case .rulersUnfloated: - return "Hotkey status bezel text indicating rulers no longer float above other windows" + case .rulerFloated: + return "Hotkey status bezel text indicating the ruler now floats above other windows" + case .rulerUnfloated: + return "Hotkey status bezel text indicating the ruler no longer floats above other windows" case .rulersGrouped: return "Hotkey status bezel text indicating rulers are grouped" case .rulersUngrouped: @@ -471,7 +471,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { updateFloatRulersMenuItem() uiTestSupport?.writePreferencesState(activeSettings: controller.state.settings) showHotkeyBezel( - shouldFloat ? .rulersFloated : .rulersUnfloated, + shouldFloat ? .rulerFloated : .rulerUnfloated, on: bezelScreen(for: sender) ) return @@ -479,7 +479,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { prefs.floatRulers = !prefs.floatRulers showHotkeyBezel( - prefs.floatRulers ? .rulersFloated : .rulersUnfloated, + prefs.floatRulers ? .rulerFloated : .rulerUnfloated, on: bezelScreen(for: sender) ) } diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index aaf685b..d42f67b 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -212,13 +212,7 @@ - - - - - - - + @@ -231,6 +225,12 @@ + + + + + + diff --git a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib index e21eba1..9a02d9e 100644 --- a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib +++ b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib @@ -163,7 +163,7 @@