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 dd81390..c62f16a 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: @@ -60,8 +60,36 @@ 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.updateDisplay() + + guard let settingsController = self.rulerSettingsController, + settingsController.window?.isVisible == true else { return } + + if let controller = controller { + settingsController.show(attachedTo: controller, sender: self) + } else { + settingsController.close() + } + } + 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 + }() var timer: Timer? private var timerInterval: TimeInterval? @@ -86,6 +114,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? @@ -93,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) { @@ -165,6 +146,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { configureUpdater() #endif + rulerManager.setApplicationActive(NSApp.isActive) + restoreSavedRulers() showRulers() } @@ -264,29 +247,26 @@ 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() - self.groupedRulerController?.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.groupedRulerController?.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() }, ] } @@ -299,20 +279,24 @@ 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() { - for ruler in rulers { - ruler.rulerWindow.rule.redrawForPreferenceChange() + for controller in rulerManager.controllers { + controller.redrawForPreferenceChange() } - groupedRulerController?.redrawForPreferenceChange() + } + + func redrawDefaultBackedRulers() { + redrawRulers() } func updateFloatRulersMenuItem() { - floatRulersMenuItem?.state = prefs.floatRulers ? .on : .off + floatRulersMenuItem?.state = activeRulerSettings.floatRulers ? .on : .off } func updateGroupRulersMenuItem() { @@ -320,199 +304,108 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func updateRulerShadowMenuItem() { - rulerShadowMenuItem?.state = prefs.rulerShadow ? .on : .off - } - - func createRulersIfNeeded() { - 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: [RulerWindow] - 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() - rulerVisibility.showAll() - applyRulerWindowMode(showRulersIfNeeded: true) + rulerShadowMenuItem?.state = activeRulerSettings.rulerShadow ? .on : .off } - func toggleRuler(orientation: Orientation) { - 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 var activeRulerSettings: RulerSettings { + return rulerManager.activeController?.state.settings ?? RulerSettings(defaults: prefs) } - private func detachRulerWindows() { - for ruler in rulers { - detachRulerWindow(ruler.rulerWindow) - } - } - - private func rulerController(orientation: Orientation) -> RulerController? { - createRulersIfNeeded() - return existingRulerController(orientation: orientation) - } - - private func existingRulerController(orientation: Orientation) -> RulerController? { - return rulers.first { $0.ruler.orientation == orientation } - } + @discardableResult + private func updateActiveRulerSettings(_ update: (inout RulerSettings) -> Void) -> Bool { + guard let controller = rulerManager.activeController else { return false } - private func showRuler(_ ruler: RulerController, updateMode: Bool = true) { - ruler.showWindow(self) - ruler.rulerWindow.orderFrontRegardless() - if updateMode { - applyRulerWindowMode() - } + controller.updateSettings(update) + updateDisplay() + uiTestSupport?.writePreferencesState(activeSettings: controller.state.settings) + return true } - private func detachRulerWindow(_ window: RulerWindow) { - for ruler in rulers { - guard ruler.rulerWindow != window else { continue } - - ruler.rulerWindow.removeChildWindow(window) - window.removeChildWindow(ruler.rulerWindow) - } - } + func createRulersIfNeeded() { + guard !rulerManager.hasRulers else { return } - private var rulerWindowMode: RulerWindowMode { - return prefs.groupRulers ? .grouped : .separate + rulerManager.createRuler() } - private func applyRulerWindowMode(showRulersIfNeeded: Bool = false) { + func showRulers() { createRulersIfNeeded() - detachRulerWindows() - - switch rulerWindowMode { - case .grouped: - showGroupedRulerWindow(showRulersIfNeeded: showRulersIfNeeded) - case .separate: - showSeparateRulerWindows() - } - + rulerManager.showAll() updateMouseTickTimer() } - private func showGroupedRulerWindow(showRulersIfNeeded: Bool) { - guard let groupedRulerController = groupedRulerController, - let horizontalRuler = existingRulerController(orientation: .horizontal), - let verticalRuler = existingRulerController(orientation: .vertical) else { + func restoreSavedRulers() { + if let restoredState = prefs.loadRulerSetState() { + rulerManager.restore( + restoredState.rulers, + activeRulerID: restoredState.activeRulerID + ) return } - guard rulerVisibility.hasVisibleRuler else { - groupedRulerController.hide() - horizontalRuler.rulerWindow.orderOut(self) - verticalRuler.rulerWindow.orderOut(self) - return + if let migratedState = migratedLegacyRulerState() { + rulerManager.restore([migratedState], activeRulerID: migratedState.id) } - - 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 - ) - horizontalRuler.rulerWindow.orderOut(self) - verticalRuler.rulerWindow.orderOut(self) } - 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() - } + private func saveRulerSetState() { + prefs.saveRulerSetState( + rulers: rulerManager.states, + activeRulerID: rulerManager.activeRulerID + ) } - func syncGroupedRulerFramesToRulerWindows(persistAutosave: Bool = false) { - guard let groupedRulerController = groupedRulerController, - let horizontalRuler = existingRulerController(orientation: .horizontal), - let verticalRuler = existingRulerController(orientation: .vertical) else { - return - } + private func migratedLegacyRulerState() -> RulerInstanceState? { + let defaults = Prefs.userDefaults + 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 } - groupedRulerController.syncFrames( - to: horizontalRuler.rulerWindow, - and: verticalRuler.rulerWindow, - persistAutosave: persistAutosave + let settings = RulerSettings(defaults: prefs) + let horizontalFrame = legacyAutosavedFrame( + name: horizontalAutosaveName, + fallback: getDefaultContentRect(orientation: .horizontal, zeroCorner: settings.zeroCorner) + ) + let verticalFrame = legacyAutosavedFrame( + name: verticalAutosaveName, + fallback: getDefaultContentRect(orientation: .vertical, zeroCorner: settings.zeroCorner) ) - } - private var isGroupedRulerVisible: Bool { - return groupedRulerController?.isVisible == true + return RulerInstanceState( + settings: settings, + layout: RulerLayoutState( + horizontalFrame: horizontalFrame, + verticalFrame: verticalFrame, + zeroCorner: settings.zeroCorner + ) + ) } - private func isRulerVisible(_ ruler: RulerController?) -> Bool { - guard let ruler = ruler else { return false } - return rulerVisibility.isVisible(ruler.ruler.orientation) + private func legacyAutosavedFrame(name: String, fallback: NSRect) -> NSRect { + let window = NSWindow( + contentRect: fallback, + styleMask: [.borderless, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + _ = window.setFrameUsingName(NSWindow.FrameAutosaveName(name)) + let frame = window.frame + window.close() + return frame } - private var isRulerFrontmost: Bool { - 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 isGroupedRulerVisible || rulers.contains { $0.rulerWindow.isVisible } - } - - private var canToggleRulerVisibility: Bool { - return isRulerFrontmost || !hasVisibleRuler + return rulerManager.hasVisibleRulers } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -525,10 +418,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidBecomeActive(_ notification: Notification) { - for ruler in rulers { - ruler.foreground() + rulerManager.setApplicationActive(true) + + for controller in rulerManager.controllers { + controller.foreground() } - groupedRulerController?.foreground() mouseTickTimerPolicy.applicationDidBecomeActive() updateMouseTickTimer() @@ -537,10 +431,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidResignActive(_ notification: Notification) { - for ruler in rulers { - ruler.background() + rulerManager.setApplicationActive(false) + + for controller in rulerManager.controllers { + controller.background() } - groupedRulerController?.background() mouseTickTimerPolicy.applicationDidResignActive() updateMouseTickTimer() @@ -549,31 +444,47 @@ 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() + uiTestSupport?.writePreferencesState(activeSettings: controller.state.settings) + showHotkeyBezel( + shouldFloat ? .rulerFloated : .rulerUnfloated, + on: bezelScreen(for: sender) + ) + return + } + prefs.floatRulers = !prefs.floatRulers showHotkeyBezel( - prefs.floatRulers ? .rulersFloated : .rulersUnfloated, + prefs.floatRulers ? .rulerFloated : .rulerUnfloated, on: bezelScreen(for: sender) ) } @@ -583,6 +494,20 @@ 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() + uiTestSupport?.writePreferencesState(activeSettings: controller.state.settings) + showHotkeyBezel( + shouldShowShadow ? .shadowEnabled : .shadowDisabled, + on: bezelScreen(for: sender) + ) + return + } + prefs.rulerShadow = !prefs.rulerShadow showHotkeyBezel( prefs.rulerShadow ? .shadowEnabled : .shadowDisabled, @@ -600,18 +525,39 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + @IBAction func openRulerSettings(_ sender: Any) { + guard let controller = rulerManager.activeController else { return } + + if rulerSettingsController == nil { + rulerSettingsController = RulerSettingsController(rulerController: controller) + } + + rulerSettingsController?.show(attachedTo: controller, sender: sender) + } + + @IBAction func newRuler(_ sender: Any) { + let controller = rulerManager.createRuler() + controller.show() + updateMouseTickTimer() + } + + @IBAction func cycleRulers(_ sender: Any) { + guard rulerManager.cycleActiveRuler() != nil else { return } + + updateDisplay() + } + @IBAction func closeKeyWindow(_ sender: Any) { - if let groupedRulerController = groupedRulerController, - groupedRulerController.groupedWindow.isKeyWindow { - syncGroupedRulerFramesToRulerWindows(persistAutosave: true) - rulerVisibility.hideAll() - applyRulerWindowMode() + if let controller = rulerManager.controller(containing: NSApp.keyWindow) { + rulerManager.close(controller) + updateMouseTickTimer() return } - if let ruler = rulers.first(where: { $0.rulerWindow.isKeyWindow }) { - rulerVisibility.set(ruler.ruler.orientation, isVisible: false) - applyRulerWindowMode() + if rulerManager.hasRulers, + NSApp.keyWindow == nil, + rulerManager.closeActiveRuler() { + updateMouseTickTimer() return } @@ -623,38 +569,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { mouseLoc.x = mouseLoc.x.rounded() mouseLoc.y = mouseLoc.y.rounded() - if prefs.groupRulers, - let groupedRulerController = groupedRulerController, - groupedRulerController.isVisible { - groupedRulerController.align(at: mouseLoc) - syncGroupedRulerFramesToRulerWindows(persistAutosave: true) - return - } + createRulersIfNeeded() - for ruler in rulers { - ruler.alignRuler(at: mouseLoc) + if let controller = rulerManager.activeController { + controller.align(at: mouseLoc) } } @IBAction func resetRulerPositions(_ sender: Any) { createRulersIfNeeded() - prefs.zeroCorner = Prefs.defaultZeroCorner - - // ungroup rulers during reset operation - prefs.groupRulers = false - rulerVisibility.showAll() - for ruler in rulers { - ruler.resetPosition() - showRuler(ruler, updateMode: false) + if let controller = rulerManager.activeController { + controller.resetPosition() + updateDisplay() + updateMouseTickTimer() } - - prefs.groupRulers = Prefs.defaultGroupRulers - applyRulerWindowMode() - } - - @IBAction func showRulers(_ sender: Any) { - showRulers() } @IBAction func toggleHorizontalRuler(_ sender: Any) { @@ -678,78 +607,22 @@ class AppDelegate: NSObject, NSApplicationDelegate { func flipRulers(along orientation: Orientation) { createRulersIfNeeded() - 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 + if let controller = rulerManager.activeController { + let flippedCorner = controller.state.settings.zeroCorner.flipped(along: orientation) + controller.prepareForZeroCornerChange(to: flippedCorner) + controller.redrawForPreferenceChange() + updateDisplay() } - - 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() } - func isRulerWindowShown(_ window: RulerWindow) -> Bool { - return window.isVisible || window.parent != nil || rulers.contains { - $0.rulerWindow.childWindows?.contains(window) == true + private func setUnit(_ unit: Unit) { + if updateActiveRulerSettings({ settings in + settings.unit = unit + }) { + return } - } - - private func zeroPointOffset( - from sourceWindow: RulerWindow?, - to targetWindow: RulerWindow?, - 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 - ) + prefs.unit = unit } func performRulerHotkey( @@ -757,6 +630,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { modifierFlags: NSEvent.ModifierFlags, sender: Any ) -> Bool { + if let controller = sender as? RulerController { + rulerManager.markActive(controller) + } + let keyboardModifiers = modifierFlags .intersection(.deviceIndependentFlagsMask) .subtracting(.capsLock) @@ -774,6 +651,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 { @@ -823,15 +711,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { return rulerController.rulerWindow.screen } - if let groupedRulerController = sender as? GroupedRulerController { - return groupedRulerController.groupedWindow.screen - } - - if groupedRulerController?.groupedWindow.isKeyWindow == true { - return groupedRulerController?.groupedWindow.screen + if let activeController = rulerManager.activeController { + return activeController.rulerWindow.screen } - return rulers.first { $0.rulerWindow.isKeyWindow }?.rulerWindow.screen + return nil } private func unitLabel(_ unit: Unit) -> String { @@ -854,6 +738,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ aNotification: Notification) { closeRulerColorPanel() + saveRulerSetState() prefs.save() } @@ -863,22 +748,41 @@ extension AppDelegate: NSMenuItemValidation { func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { switch menuItem.action { + case #selector(newRuler(_:)): + return true + 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 true case #selector(toggleHorizontalRuler(_:)): - 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 + if let controller = rulerManager.activeController { + 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 !isVisible || controller.state.isWingVisible(.vertical) + } + + menuItem.title = NSLocalizedString( + "Show Horizontal Ruler", + comment: "Menu item title to show the horizontal ruler" + ) + return true case #selector(toggleVerticalRuler(_:)): - 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 - case #selector(showRulers(_:)): - menuItem.title = NSLocalizedString("Show All Rulers", comment: "Menu item title to show all ruler windows") + if let controller = rulerManager.activeController { + 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 !isVisible || controller.state.isWingVisible(.horizontal) + } + + menuItem.title = NSLocalizedString( + "Show Vertical Ruler", + comment: "Menu item title to show the vertical ruler" + ) return true default: return true @@ -926,11 +830,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) } - - groupedRulerController?.setMouseTickDrawingEnabled(isEnabled) } private func updateMouseTickTimer() { @@ -970,18 +872,10 @@ extension AppDelegate { } private func updateMouseLocation() { - var mouseLoc = NSEvent.mouseLocation - mouseLoc.x = mouseLoc.x.rounded() - mouseLoc.y = mouseLoc.y.rounded() - - if let groupedRulerController = groupedRulerController, - groupedRulerController.isVisible { - groupedRulerController.drawMouseTick(at: mouseLoc) - return - } + let mouseLoc = NSEvent.mouseLocation - 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/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index 9813031..d42f67b 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -38,7 +38,8 @@ - + + @@ -79,6 +80,12 @@ + + + + + + @@ -92,10 +99,9 @@ - - + - + @@ -206,32 +212,32 @@ - + - + - + - + + - + - - + - + diff --git a/Free Ruler/Base.lproj/PreferencesController.xib b/Free Ruler/Base.lproj/PreferencesController.xib index ae045ff..9ee6805 100644 --- a/Free Ruler/Base.lproj/PreferencesController.xib +++ b/Free Ruler/Base.lproj/PreferencesController.xib @@ -8,182 +8,76 @@ - - - - - - - - - + + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - + + + + + + + + + + + + + + + + + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - + diff --git a/Free Ruler/Base.lproj/RulerSettingsController.xib b/Free Ruler/Base.lproj/RulerSettingsController.xib new file mode 100644 index 0000000..e129e4b --- /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..9a02d9e --- /dev/null +++ b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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.
diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift deleted file mode 100644 index 80026ef..0000000 --- a/Free Ruler/GroupedRulerWindow.swift +++ /dev/null @@ -1,1546 +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 - - init(frame: NSRect) { - 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(prefs.foregroundOpacity) - title = NSLocalizedString( - "Grouped Rulers", - comment: "Window title for the grouped ruler window" - ) - identifier = NSUserInterfaceItemIdentifier("grouped-ruler-window") - setAccessibilityIdentifier("grouped-ruler-window") - minSize = GroupedRulerLayout.minSize(zeroCorner: prefs.zeroCorner) - maxSize = GroupedRulerLayout.maxSize(zeroCorner: prefs.zeroCorner) - - isOpaque = false - backgroundColor = .clear - isFloatingPanel = prefs.floatRulers - hidesOnDeactivate = false - isMovableByWindowBackground = true - hasShadow = prefs.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 - 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 = prefs.zeroCorner - groupedContentView.needsLayout = true - groupedContentView.layoutSubtreeIfNeeded() - groupedContentView.needsDisplay = true - } - - 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: prefs.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: prefs.zeroCorner, - showsHorizontalRule: groupedContentView.showsHorizontalRule, - showsVerticalRule: groupedContentView.showsVerticalRule - ) - maxSize = GroupedRulerLayout.maxSize( - zeroCorner: prefs.zeroCorner, - showsHorizontalRule: groupedContentView.showsHorizontalRule, - showsVerticalRule: groupedContentView.showsVerticalRule - ) - } -} - -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) - } - - 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 - - private var keyListener: Any? - private var mouseInteraction: RulerMouseInteractionState! - private var isMouseTickDrawingEnabled = true - - var isLeftMouseButtonPressed = { - return NSEvent.pressedMouseButtons & 1 == 1 - } - - var preferencesWindowOpen = false { - didSet { - updateIsFloatingPanel() - if !preferencesWindowOpen { - opacity = prefs.foregroundOpacity - } - } - } - - var opacity = prefs.foregroundOpacity { - didSet { - groupedWindow.alphaValue = windowAlphaValue(opacity) - } - } - - init(frame: NSRect) { - groupedWindow = GroupedRulerWindow(frame: frame) - super.init(window: groupedWindow) - - createObservers() - subscribeToPrefs() - - groupedWindow.delegate = self - groupedWindow.nextResponder = self - mouseInteraction = RulerMouseInteractionState(owner: self) { [weak self] event in - return self?.mouseIsInsideRuler(with: event) ?? 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( - horizontalFrame: NSRect, - verticalFrame: NSRect, - showsHorizontalRule: Bool, - showsVerticalRule: Bool - ) { - let layout = GroupedRulerLayout.joined( - horizontalFrame: horizontalFrame, - verticalFrame: verticalFrame, - zeroCorner: prefs.zeroCorner - ) - groupedWindow.setVisibleRules( - horizontal: showsHorizontalRule, - vertical: showsVerticalRule - ) - updateMouseTickDrawingVisibility() - groupedWindow.setFrame( - layout.visibleFrame( - showsHorizontalRule: showsHorizontalRule, - showsVerticalRule: showsVerticalRule - ), - display: false - ) - groupedWindow.updateLayoutForCurrentZeroCorner() - showWindow(self) - groupedWindow.orderFrontRegardless() - } - - func hide() { - groupedWindow.orderOut(self) - } - - 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: prefs.zeroCorner - ) - - groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) - groupedWindow.updateLayoutForCurrentZeroCorner() - } - - 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 - ) - - groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) - } - - func foreground() { - opacity = prefs.foregroundOpacity - } - - func background() { - opacity = prefs.backgroundOpacity - } - - func updateIsFloatingPanel() { - groupedWindow.isFloatingPanel = preferencesWindowOpen ? false : prefs.floatRulers - } - - func updateHasShadow() { - groupedWindow.hasShadow = prefs.rulerShadow - } - - func redrawForPreferenceChange() { - groupedWindow.redrawForPreferenceChange() - } - - 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 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: prefs.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: prefs.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: prefs.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) - mouseInteraction.windowDidEndLiveResize() - } - - func windowWillMove(_ notification: Notification) { - mouseInteraction.windowWillMove() - } - - func windowDidMove(_ notification: Notification) { - groupedWindow.invalidateShadow() - syncRulerWindowFrames( - persistAutosave: mouseInteraction.shouldPersistFrameAutosaveOnWindowMove( - isLeftMouseButtonPressed: isLeftMouseButtonPressed() - ) - ) - mouseInteraction.windowDidMove(isLeftMouseButtonPressed: isLeftMouseButtonPressed()) - } - - func windowDidBecomeKey(_ notification: Notification) { - 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) - } - - override func mouseUp(with event: NSEvent) { - finishMouseDrag(with: event) - } - - func finishMouseDrag(with event: NSEvent) { - if mouseInteraction.finishMouseDrag(with: event) { - syncRulerWindowFrames(persistAutosave: true) - } - } - - 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() { - 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 - } - } -} -#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/HorizontalRule.swift b/Free Ruler/HorizontalRule.swift index a526f77..161e523 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) @@ -119,7 +120,11 @@ class HorizontalRule: RuleView { let windowPoint = window.convertPoint(fromScreen: mouseLoc) let viewPoint = convert(windowPoint, from: nil) - mouseTickX = viewPoint.x + mouseTickX = mouseTickX(forLocalMouseX: viewPoint.x) + } + + func mouseTickX(forLocalMouseX localMouseX: CGFloat) -> CGFloat { + return localMouseX.rounded() } func drawMouseTick(_ mouseTickX: CGFloat) { @@ -131,7 +136,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 +151,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) @@ -280,9 +285,9 @@ class HorizontalRule: RuleView { switch growthDirection { case .positive: - return mouseTickX + return max(0, mouseTickX - 1) case .negative: - return rulerWidth - mouseTickX + return max(0, rulerWidth - mouseTickX) } } @@ -294,7 +299,7 @@ class HorizontalRule: RuleView { case .positive: return mouseTickX case .negative: - return mouseTickX - 1 + return mouseTickX } } diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 71f2872..b1993b7 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -43,14 +43,290 @@ } } }, - "Grouped Rulers" : { - "comment" : "Window title for the grouped ruler window", + "Color" : { + "comment" : "Label for the active ruler color setting", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Grouped Rulers" + "value" : "Color" + } + } + } + }, + "Ruler" : { + "comment" : "Window title for a ruler window", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruler" + } + } + } + }, + "Ruler Settings" : { + "comment" : "Window title for the active ruler settings panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruler Settings" + } + } + } + }, + "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", + "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 ruler above other applications" : { + "comment" : "Checkbox title for whether the active ruler floats above other apps", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lineal über anderen Programmen schweben lassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Float ruler above other applications" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regla flotante sobre otras aplicaciones" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kelluta viivainta 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" : "显示尺子阴影" } } } @@ -223,32 +499,32 @@ } } }, - "HotkeyBezel.RulersFloated" : { - "comment" : "Hotkey status bezel text indicating rulers now float above other windows", + "HotkeyBezel.RulerFloated" : { + "comment" : "Hotkey status bezel text indicating the ruler now floats above other windows", "extractionState" : "manual", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Lineale schweben" + "value" : "Lineal schwebt" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Rulers floated" + "value" : "Ruler floated" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Reglas flotantes" + "value" : "Regla flotante" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Viivaimet päällimmäisinä" + "value" : "Viivain päällimmäisenä" } }, "ja" : { @@ -307,32 +583,32 @@ } } }, - "HotkeyBezel.RulersUnfloated" : { - "comment" : "Hotkey status bezel text indicating rulers no longer float above other windows", + "HotkeyBezel.RulerUnfloated" : { + "comment" : "Hotkey status bezel text indicating the ruler no longer floats above other windows", "extractionState" : "manual", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Lineale schweben nicht" + "value" : "Lineal schwebt nicht" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Rulers unfloated" + "value" : "Ruler unfloated" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Reglas no flotantes" + "value" : "Regla no flotante" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Viivaimet eivät ole päällimmäisinä" + "value" : "Viivain ei ole päällimmäisenä" } }, "ja" : { @@ -611,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/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index feb9d3c..83ed714 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 @@ -37,6 +38,9 @@ private func setColorPickingIgnoresAlpha(_ ignoresAlpha: Bool) { class RulerColorWell: NSColorWell { + var colorDidChange: ((RulerColorWell) -> Void)? + var colorPanelPresenter: ((RulerColorWell, NSColorPanel) -> Void)? + override func awakeFromNib() { super.awakeFromNib() configureForOpaqueColors() @@ -44,24 +48,60 @@ 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 + } 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() + openColorPanel() + } + private func openColorPanel() { let colorPanel = NSColorPanel.shared + guard !colorPanel.isVisible else { closeRulerColorPanel() return } + activeRulerColorWell = self colorPanel.animationBehavior = .none 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() } @@ -88,24 +128,532 @@ class RulerColorWell: NSColorWell { } -class PreferencesController: NSWindowController, NSWindowDelegate, NotificationPoster { +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 - var observers: [NSKeyValueObservation] = [] - private var colorPanelObserver: NSObjectProtocol? + 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) +} + +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) + func rulerSettingsControlsDidChangeRulerColor(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidResetRulerColor(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeForegroundOpacity(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeBackgroundOpacity(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeFloatRulers(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeRulerShadow(_ controlsView: RulerSettingsControlsView) +} + +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 rulerColorWell: NSColorWell! - @IBOutlet weak var resetRulerColorButton: NSButton! - @IBOutlet weak var floatRulersCheckbox: NSButton! - @IBOutlet weak var groupRulersCheckbox: NSButton! @IBOutlet weak var rulerShadowCheckbox: NSButton! + private var dimensionScreen: NSScreen? + + var selectedUnit: Unit { + return Unit(rawValue: unitSegmentedControl.selectedSegment) ?? .pixels + } + + var selectedHorizontalLength: CGFloat { + return rulerPixelLength( + fromDimensionValue: CGFloat(dimensionWidthField.doubleValue), + unit: selectedUnit, + screen: dimensionScreen + ) + } + + var selectedVerticalLength: CGFloat { + return rulerPixelLength( + fromDimensionValue: CGFloat(dimensionHeightField.doubleValue), + unit: selectedUnit, + screen: dimensionScreen + ) + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + loadContentView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + loadContentView() + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if performRulerSettingsKeyEquivalent(with: event) { + return true + } + + return super.performKeyEquivalent(with: event) + } + + func configureForPreferences() { + 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", + backgroundSliderIdentifier: "ruler-background-opacity-slider", + foregroundLabelIdentifier: "ruler-foreground-opacity-label", + backgroundLabelIdentifier: "ruler-background-opacity-label", + floatCheckboxIdentifier: "float-rulers-checkbox", + shadowCheckboxIdentifier: "ruler-shadow-checkbox" + ) + configureCheckboxKeyEquivalents(float: "", shadow: "") + rulerColorWell.colorPanelPresenter = nil + } + + func configureForRulerSettings() { + 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", + 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" + ) + configureCheckboxKeyEquivalents(float: "f", shadow: "s") + } + + func update( + unit: Unit, + horizontalLength: CGFloat? = nil, + verticalLength: CGFloat? = nil, + dimensionScreen: NSScreen? = nil, + rulerColor: NSColor, + foregroundOpacity: Int, + backgroundOpacity: Int, + floatRulers: Bool, + rulerShadow: Bool, + isEnabled: Bool = true + ) { + self.dimensionScreen = dimensionScreen + unitSegmentedControl.selectedSegment = unit.rawValue + unitSegmentedControl.isEnabled = isEnabled + + updateDimensions( + unit: unit, + horizontalLength: horizontalLength, + verticalLength: verticalLength, + dimensionScreen: dimensionScreen + ) + dimensionWidthField.isEnabled = isEnabled + dimensionHeightField.isEnabled = isEnabled + + 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 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 + ) + } + } + + 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() + } + + 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() { + 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 + guard let self = self else { return } + self.setRulerColor(self.rulerColorWell as Any) + } + 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( + unitSegmentedControlIdentifier: String, + widthFieldIdentifier: String, + heightFieldIdentifier: String, + colorWellIdentifier: String, + resetButtonIdentifier: String, + foregroundSliderIdentifier: String, + backgroundSliderIdentifier: String, + foregroundLabelIdentifier: String, + backgroundLabelIdentifier: String, + 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) + + 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 configureCheckboxKeyEquivalents(float: String, shadow: String) { + floatRulersCheckbox.keyEquivalent = float + floatRulersCheckbox.keyEquivalentModifierMask = [] + rulerShadowCheckbox.keyEquivalent = shadow + rulerShadowCheckbox.keyEquivalentModifierMask = [] + } + + private func configureKeyViewLoop() { + unitSegmentedControl.nextKeyView = dimensionWidthField + dimensionWidthField.nextKeyView = dimensionHeightField + dimensionHeightField.nextKeyView = rulerColorWell + rulerColorWell.nextKeyView = resetRulerColorButton.isHidden + ? foregroundOpacitySlider + : resetRulerColorButton + resetRulerColorButton.nextKeyView = foregroundOpacitySlider + foregroundOpacitySlider.nextKeyView = backgroundOpacitySlider + backgroundOpacitySlider.nextKeyView = floatRulersCheckbox + floatRulersCheckbox.nextKeyView = rulerShadowCheckbox + rulerShadowCheckbox.nextKeyView = unitSegmentedControl + } + + private func configureDimensionField(_ field: NSTextField) { + let formatter = NumberFormatter() + formatter.allowsFloats = true + formatter.minimum = 0 + formatter.maximum = 4000 + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 3 + formatter.numberStyle = .decimal + + field.formatter = formatter + field.alignment = .right + field.bezelStyle = .roundedBezel + field.delegate = self + field.target = self + field.action = #selector(setDimensions(_:)) + } + + 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 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) + } + + @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 unitSegmentedControl: NSSegmentedControl { + return settingsControlsView.unitSegmentedControl + } + + var dimensionWidthField: NSTextField { + return settingsControlsView.dimensionWidthField + } + + var dimensionHeightField: NSTextField { + return settingsControlsView.dimensionHeightField + } + + 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" } @@ -115,20 +663,14 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP window?.delegate = self window?.identifier = NSUserInterfaceItemIdentifier("preferences-window") + window?.setAccessibilityIdentifier("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") - window?.initialFirstResponder = rulerColorWell - configureResetRulerColorButton() + settingsControlsView.delegate = self + settingsControlsView.configureForPreferences() + window?.initialFirstResponder = unitSegmentedControl + resetFactoryDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-factory-defaults-button") + resetFactoryDefaultsButton.setAccessibilityIdentifier("reset-factory-defaults-button") subscribeToPrefs() subscribeToColorPanel() @@ -148,7 +690,7 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP configureOpaqueColorPicking() window?.makeKeyAndOrderFront(sender) - window?.makeFirstResponder(rulerColorWell) + window?.makeFirstResponder(unitSegmentedControl) window?.center() } @@ -162,6 +704,16 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP func subscribeToPrefs() { 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() + }, + prefs.observe(\Prefs.defaultVerticalLength, options: .new) { prefs, changed in + self.updateDimensionFields() + }, prefs.observe(\Prefs.foregroundOpacity, options: .new) { prefs, changed in self.updateForegroundSlider() }, @@ -171,9 +723,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() }, @@ -183,6 +732,18 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP ] } + @IBAction func setUnit(_ sender: Any) { + prefs.unit = settingsControlsView.selectedUnit + } + + @IBAction func setDimensions(_ sender: Any) { + let horizontalLength = settingsControlsView.selectedHorizontalLength + let verticalLength = settingsControlsView.selectedVerticalLength + + prefs.defaultHorizontalLength = Double(horizontalLength) + prefs.defaultVerticalLength = Double(verticalLength) + } + @IBAction func setForegroundOpacity(_ sender: Any) { prefs.foregroundOpacity = foregroundOpacitySlider.integerValue } @@ -192,9 +753,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 } @@ -204,14 +762,36 @@ 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( + unit: prefs.unit, + horizontalLength: prefs.effectiveDefaultHorizontalLength(), + verticalLength: prefs.effectiveDefaultVerticalLength(), + dimensionScreen: window?.screen ?? NSScreen.main, + rulerColor: prefs.rulerColor, + foregroundOpacity: prefs.foregroundOpacity, + backgroundOpacity: prefs.backgroundOpacity, + floatRulers: prefs.floatRulers, + rulerShadow: prefs.rulerShadow + ) + } + + func updateUnitSegmentedControl() { + unitSegmentedControl.selectedSegment = prefs.unit.rawValue + } + + func updateDimensionFields() { + settingsControlsView.updateDimensions( + unit: prefs.unit, + horizontalLength: prefs.effectiveDefaultHorizontalLength(), + verticalLength: prefs.effectiveDefaultVerticalLength(), + dimensionScreen: window?.screen ?? NSScreen.main + ) } func updateForegroundSlider() { @@ -234,36 +814,474 @@ 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() { - let resetRulerColorLabel = NSLocalizedString( - "Reset ruler color", - comment: "Tooltip and accessibility label for the button that restores the default ruler color" + 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 } + + prefs.rulerColor = colorPanel.color + } + +} + +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) + } + + 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 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: RulerController? + private var colorPanelObserver: NSObjectProtocol? + private var didConfigureWindow = false + + @IBOutlet weak var settingsControlsView: RulerSettingsControlsView! + @IBOutlet weak var resetDefaultsButton: NSButton! + @IBOutlet weak var setDefaultsButton: NSButton! + + var rulerColorWell: RulerColorWell { + 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 + } + + 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: RulerController? { + return rulerController + } + + override var windowNibName: String { + return "RulerSettingsController" + } + + init(rulerController: RulerController) { + self.rulerController = rulerController + super.init(window: nil) + loadWindow() + configureWindowIfNeeded() + } + + required init?(coder: NSCoder) { + nil + } + + 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 = unitSegmentedControl + 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") + 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() + } + + deinit { + if let colorPanelObserver = colorPanelObserver { + NotificationCenter.default.removeObserver(colorPanelObserver) + } + } + + override func showWindow(_ sender: Any?) { + detachWindowIfNeeded() + configureOpaqueColorPicking() + updateView() + window?.makeKeyAndOrderFront(sender) + window?.makeFirstResponder(unitSegmentedControl) + window?.center() + } + + func show(attachedTo controller: RulerController, sender: Any?) { + updateRulerController(controller) + guard let settingsWindow = window else { return } + + configureOpaqueColorPicking() + + if settingsWindow.parent === controller.rulerWindow { + position(settingsWindow, attachedTo: controller) + settingsWindow.orderFront(sender) + settingsWindow.makeKey() + settingsWindow.makeFirstResponder(unitSegmentedControl) + return + } + + detachWindowIfNeeded() + + guard controller.rulerWindow.isVisible else { + showWindow(sender) + return + } + + if settingsWindow.isVisible { + settingsWindow.orderOut(sender) + } + + position(settingsWindow, attachedTo: controller) + controller.rulerWindow.addChildWindow(settingsWindow, ordered: .above) + settingsWindow.orderFront(sender) + settingsWindow.makeKey() + settingsWindow.makeFirstResponder(unitSegmentedControl) + } + + override func close() { + detachWindowIfNeeded() + super.close() + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + true + } + + func windowWillClose(_ notification: Notification) { + detachWindowIfNeeded() + closeSheetColorControls() + } + + func updateRulerController(_ controller: RulerController) { + rulerController = controller + updateView() + } + + @objc func setUnit(_ sender: Any) { + applySettings { settings in + settings.unit = settingsControlsView.selectedUnit + } + updateView() + } + + @objc func setDimensions(_ sender: Any) { + let horizontalLength = settingsControlsView.selectedHorizontalLength + let verticalLength = settingsControlsView.selectedVerticalLength + + rulerController?.updateDimensions( + horizontalLength: horizontalLength, + verticalLength: verticalLength + ) + 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) + } + + @objc func resetRulerColor(_ sender: Any) { + applyRulerColor(Prefs.defaultRulerFillColor) + } + + @IBAction func resetToDefault(_ sender: Any) { + let defaultSettings = RulerSettings(defaults: prefs) + 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 controller = rulerController else { return } + + prefs.applyDefaults(from: controller.state.settings, layout: controller.state.layout) + } + + func updateView() { + configureWindowIfNeeded() + guard isWindowLoaded, + settingsControlsView != nil else { return } + + let currentSettings = rulerController?.state.settings + let hasRuler = rulerController != nil + + settingsControlsView.update( + unit: currentSettings?.unit ?? Prefs.defaultUnit, + horizontalLength: rulerController?.state.layout.horizontalLength, + verticalLength: rulerController?.state.layout.verticalLength, + dimensionScreen: rulerController?.rulerWindow.screen ?? window?.screen ?? NSScreen.main, + 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 + ) + resetDefaultsButton.isEnabled = hasRuler + setDefaultsButton.isEnabled = hasRuler + repositionAttachedWindowsIfNeeded() + } + + 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 + } + + 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 ) - 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) + 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) + } + updateView() + } + + private func repositionAttachedWindowsIfNeeded() { + guard let controller = rulerController, + let settingsWindow = window, + settingsWindow.isVisible, + settingsWindow.parent === controller.rulerWindow 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) } private func subscribeToColorPanel() { @@ -279,15 +1297,106 @@ 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 + applyRulerColor(colorPanel.color) + } + + private func position(_ settingsWindow: NSWindow, attachedTo controller: RulerController) { + let settingsSize = settingsWindow.frame.size + let frame = settingsFrame( + size: settingsSize, + zeroPoint: controller.rulerWindow.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 } + + 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() { + if let foregroundOpacity = rulerController?.state.settings.foregroundOpacity { + rulerController?.opacity = foregroundOpacity + } + settingsControlsView.deactivateColorWell() + closeRulerColorPanel() + } +} + +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) + } + + 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 + if let parentWindow = colorPanel.parent { + parentWindow.removeChildWindow(colorPanel) + } colorPanel.animationBehavior = .none colorPanel.setTarget(nil) colorPanel.setAction(nil) diff --git a/Free Ruler/Prefs.swift b/Free Ruler/Prefs.swift index 2ec2b4e..77e91c0 100644 --- a/Free Ruler/Prefs.swift +++ b/Free Ruler/Prefs.swift @@ -21,8 +21,19 @@ let prefs = Prefs.shared class Prefs: NSObject { + private struct UserDefaultsConfiguration { + let defaults: UserDefaults + let persistentDomainName: String? + } + // MARK: - shared singleton instance - static let shared = Prefs() + static let shared = Prefs(defaults: userDefaultsConfiguration.defaults) + static var userDefaults: UserDefaults { + return shared.defaults + } + static var userDefaultsPersistentDomainName: String? { + return userDefaultsConfiguration.persistentDomainName + } // MARK: - public properties @objc dynamic var floatRulers : Bool @@ -32,6 +43,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 @@ -41,7 +54,31 @@ class Prefs: NSObject { // MARK: - private implementation - private let defaults = UserDefaults.standard + private let defaults: UserDefaults + private static let userDefaultsConfiguration: UserDefaultsConfiguration = { + guard isRunningHostedUnitTests else { + return UserDefaultsConfiguration( + defaults: .standard, + persistentDomainName: Bundle.main.bundleIdentifier + ) + } + + let suiteName = "com.pascal.freeruler.unit-tests" + guard let defaults = UserDefaults(suiteName: suiteName) else { + return UserDefaultsConfiguration( + defaults: .standard, + persistentDomainName: Bundle.main.bundleIdentifier + ) + } + + defaults.removePersistentDomain(forName: suiteName) + return UserDefaultsConfiguration(defaults: defaults, persistentDomainName: suiteName) + }() + private static var isRunningHostedUnitTests: Bool { + let environment = ProcessInfo.processInfo.environment + return environment["XCTestConfigurationFilePath"] != nil + && environment["FREE_RULER_UI_TESTS"] == nil + } private static let defaultRulerColor = #colorLiteral(red: 0.9764705896, green: 0.850980401, blue: 0.5490196347, alpha: 1) private static let defaultRulerColorData: Data? = { guard let data = archivedColorData(defaultRulerColor) else { @@ -54,12 +91,14 @@ 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, + "defaultHorizontalLength": unsetDefaultRulerLength, + "defaultVerticalLength": unsetDefaultRulerLength, "zeroCorner": defaultZeroCorner.rawValue ] @@ -70,7 +109,8 @@ class Prefs: NSObject { return values } - private override init() { + private init(defaults: UserDefaults) { + self.defaults = defaults defaults.register(defaults: Prefs.defaultValues) floatRulers = defaults.bool(forKey: "floatRulers") @@ -80,6 +120,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 +162,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") }, @@ -129,6 +177,16 @@ class Prefs: NSObject { } extension Prefs { + static let rulerSetStateKey = "rulerSetState" + + static var defaultUnit: Unit { + return .pixels + } + + static var unsetDefaultRulerLength: Double { + return 0 + } + static var defaultZeroCorner: ZeroCorner { return .topLeft } @@ -136,9 +194,77 @@ 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 + return false + } + + func applyDefaults(from settings: RulerSettings, layout: RulerLayoutState? = nil) { + unit = settings.unit + rulerColor = settings.rulerColor + foregroundOpacity = settings.foregroundOpacity + backgroundOpacity = settings.backgroundOpacity + floatRulers = settings.floatRulers + rulerShadow = settings.rulerShadow + zeroCorner = settings.zeroCorner + + if let layout = layout { + defaultHorizontalLength = Double(layout.horizontalLength) + defaultVerticalLength = Double(layout.verticalLength) + } + } + + func resetRulerDefaultsToFactoryDefaults() { + unit = Self.defaultUnit + rulerColor = Self.defaultRulerFillColor + foregroundOpacity = Self.defaultForegroundOpacity + backgroundOpacity = Self.defaultBackgroundOpacity + 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 { @@ -189,4 +315,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 } + + defaults.set(data, forKey: Self.rulerSetStateKey) + } + + func loadRulerSetState() -> StoredRulerSetState? { + guard let data = defaults.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() { + defaults.removeObject(forKey: Self.rulerSetStateKey) + } } diff --git a/Free Ruler/ResizeHandleView.swift b/Free Ruler/ResizeHandleView.swift index 2707660..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 } @@ -129,7 +133,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/Free Ruler/RuleView.swift b/Free Ruler/RuleView.swift index ea1b1ee..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 } @@ -506,6 +539,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 +554,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/Free Ruler/Ruler.swift b/Free Ruler/Ruler.swift index 234aa29..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 @@ -68,6 +72,373 @@ 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) + } + + 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 + } + + 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(), + horizontalLength: CGFloat? = nil, + verticalLength: CGFloat? = nil + ) -> RulerLayoutState { + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + + return RulerLayoutState( + 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) -> RulerWindowLayout { + return RulerWindowLayout.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, + horizontalLength: prefs.customDefaultHorizontalLength, + verticalLength: prefs.customDefaultVerticalLength + ) + ) + } + + 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 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 @@ -164,12 +535,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 @@ -271,15 +647,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: @@ -297,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 019e072..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: RulerWindow - var otherWindow: RulerWindow? - - 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 = RulerWindow(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: RulerWindow?, 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/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.. RulerWindowLayout { + 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 + ) -> RulerWindowLayout { + 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 + ) -> RulerWindowLayout { + 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 RulerWindowLayout( + 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 RulerWindowLayout { + 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 rulerContentView: RulerContentView + 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 = RulerWindowHorizontalRule( + frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness) + ) + verticalRule = RulerWindowVerticalRule( + frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300) + ) + rulerContentView = RulerContentView( + 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("ruler-window") + setAccessibilityIdentifier("ruler-window") + minSize = RulerWindowLayout.minSize(zeroCorner: settings.zeroCorner) + maxSize = RulerWindowLayout.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 + rulerContentView.nextResponder = self - rule.nextResponder = self - self.contentView = rule + contentView = rulerContentView + apply(settings: settings) + updateLayoutForCurrentZeroCorner() } override var canBecomeKey: Bool { @@ -55,6 +335,16 @@ class RulerWindow: NSPanel { set {} } + override func setFrame(_ frameRect: NSRect, display flag: Bool) { + super.setFrame(frameRect, display: flag) + updateRulerContentFrame() + } + + override func setContentSize(_ size: NSSize) { + super.setContentSize(size) + updateRulerContentFrame() + } + override func mouseDown(with event: NSEvent) { nextResponder?.mouseDown(with: event) super.mouseDown(with: event) @@ -69,42 +359,148 @@ 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() + updateRulerContentFrame() + rulerContentView.zeroCorner = settings.zeroCorner + rulerContentView.needsLayout = true + rulerContentView.layoutSubtreeIfNeeded() + rulerContentView.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 + rulerContentView.color = RulerColors(customFill: settings.rulerColor) + updateLayoutForCurrentZeroCorner() + } + + func redrawForPreferenceChange() { + updateLayoutForCurrentZeroCorner() + horizontalRule.redrawForPreferenceChange() + verticalRule.redrawForPreferenceChange() + } + + func screenFrame(for orientation: Orientation) -> NSRect { + return convertToScreen(rulerContentView.localFrame(for: orientation)) + } + + func visibleFrame(in layout: RulerWindowLayout) -> NSRect { + return layout.visibleFrame( + showsHorizontalRule: rulerContentView.showsHorizontalRule, + showsVerticalRule: rulerContentView.showsVerticalRule + ) + } + + func setVisibleRules(horizontal: Bool, vertical: Bool) { + rulerContentView.showsHorizontalRule = horizontal + rulerContentView.showsVerticalRule = vertical + updateSizeConstraintsForVisibleRules() + rulerContentView.needsLayout = true + rulerContentView.layoutSubtreeIfNeeded() + } + + func isRuleVisible(_ orientation: Orientation) -> Bool { + switch orientation { + case .horizontal: + return rulerContentView.showsHorizontalRule + case .vertical: + return rulerContentView.showsVerticalRule + } + } + + func isEmptyCorner(atWindowPoint windowPoint: NSPoint) -> Bool { + let contentPoint = rulerContentView.convert(windowPoint, from: nil) + return rulerContentView.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 + } + + var drawsActiveBorder: Bool { + get { return rulerContentView.drawsActiveBorder } + set { rulerContentView.drawsActiveBorder = newValue } + } + private var leftMouseButtonIsPressed: Bool { return NSEvent.pressedMouseButtons & 1 == 1 } -} + private func updateRulerContentFrame() { + guard contentView === rulerContentView else { return } + + rulerContentView.frame = NSRect(origin: .zero, size: frame.size) + rulerContentView.needsLayout = true + rulerContentView.layoutSubtreeIfNeeded() + } -private func getTitle(for orientation: Orientation) -> String { - switch orientation { - case .horizontal: - return NSLocalizedString( - "Horizontal Ruler", - comment: "Window title for the horizontal ruler" + private func updateSizeConstraintsForVisibleRules() { + minSize = RulerWindowLayout.minSize( + zeroCorner: settings.zeroCorner, + showsHorizontalRule: rulerContentView.showsHorizontalRule, + showsVerticalRule: rulerContentView.showsVerticalRule ) - case .vertical: - return NSLocalizedString( - "Vertical Ruler", - comment: "Window title for the vertical ruler" + maxSize = RulerWindowLayout.maxSize( + zeroCorner: settings.zeroCorner, + showsHorizontalRule: rulerContentView.showsHorizontalRule, + showsVerticalRule: rulerContentView.showsVerticalRule ) } } -private func getIdentifier(for orientation: Orientation) -> String { - switch orientation { - case .horizontal: - return "horizontal-ruler-window" - case .vertical: - return "vertical-ruler-window" +extension RulerWindow: RulerContextMenuActivating { + func activateForRulerContextMenu() { + makeKey() + (nextResponder as? RulerController)?.activateForRulerContextMenu() } } -private func getRuleIdentifier(for orientation: Orientation) -> String { - switch orientation { - case .horizontal: - return "horizontal-ruler-view" - case .vertical: - return "vertical-ruler-view" +private final class RulerWindowHorizontalRule: HorizontalRule { + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(NSSize(width: newSize.width, height: Ruler.thickness)) + } +} + +private final class RulerWindowVerticalRule: VerticalRule { + override var rulerWidth: CGFloat { + return Ruler.thickness + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(NSSize(width: Ruler.thickness, height: newSize.height)) } } @@ -150,5 +546,1499 @@ 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 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 + } + } + + var showsHorizontalRule = true { + didSet { + needsDisplay = true + } + } + + var showsVerticalRule = true { + didSet { + needsDisplay = true + } + } + + 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(inset: inset) + case (true, false): + return visibleBoundsBorderPath(inset: inset) + case (false, true): + return visibleBoundsBorderPath(inset: inset) + case (false, false): + return NSBezierPath() + } + } + + private func lShapedBorderPath(inset: CGFloat) -> NSBezierPath { + return rulerWindowLShapedPath( + in: bounds, + zeroCorner: zeroCorner, + inset: inset + ) + } + + private func visibleBoundsBorderPath(inset: CGFloat) -> NSBezierPath { + return NSBezierPath(rect: bounds.insetBy( + dx: inset, + dy: inset + )) + } +} + +private final class RulerWindowZeroLabelsView: 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 RulerContentView: 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: RulerWindowZeroLabelsView + private let borderView = RulerWindowBorderView(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 + } + } + + var drawsActiveBorder = false { + didSet { + borderView.drawsActiveBorder = drawsActiveBorder + } + } + + init( + frame frameRect: NSRect, + horizontalRule: HorizontalRule, + verticalRule: VerticalRule + ) { + self.horizontalRule = horizontalRule + self.verticalRule = verticalRule + self.zeroLabelsView = RulerWindowZeroLabelsView( + 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 = 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) + 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 = RulerWindowLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) + return ruleFrame(for: orientation, in: bounds, layout: layout) + } + + private func cornerFrame() -> NSRect { + return RulerWindowLayout + .layout(groupFrame: bounds, zeroCorner: zeroCorner) + .emptyCornerFrame(zeroCorner: zeroCorner) + } + + private func ruleFrame( + for orientation: Orientation, + in bounds: NSRect, + layout: RulerWindowLayout + ) -> 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 = RulerWindowLayout.layout(groupFrame: bounds, zeroCorner: zeroCorner) + + switch (showsHorizontalRule, showsVerticalRule) { + case (true, true): + return rulerWindowLShapedPath(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 RulerController: NSWindowController, NSWindowDelegate, NotificationObserver { + var observers: [NSKeyValueObservation] = [] + var notificationObservers: [NSObjectProtocol] = [] + + let rulerWindow: RulerWindow + var state: RulerInstanceState + 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! + 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 { + rulerWindow.alphaValue = windowAlphaValue(opacity) + } + } + + convenience init(frame: NSRect) { + let layout = RulerWindowLayout.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) + rulerWindow = RulerWindow( + frame: layout.visibleFrame( + showsHorizontalRule: state.visibility.showsHorizontal, + showsVerticalRule: state.visibility.showsVertical + ), + settings: state.settings + ) + super.init(window: rulerWindow) + + opacity = state.settings.foregroundOpacity + createObservers() + subscribeToPrefs() + + rulerWindow.delegate = self + rulerWindow.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 rulerWindow.isVisible + } + + func show() { + applyStateToWindow(display: false) + showWindow(self) + rulerWindow.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() { + rulerWindow.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 align(at point: NSPoint) { + 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 + ) + + rulerWindow.setFrame(rulerWindow.visibleFrame(in: layout), display: true) + rulerWindow.updateLayoutForCurrentZeroCorner() + captureStateFromWindow() + } + + func prepareForZeroCornerChange(to zeroCorner: ZeroCorner) { + 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, + zeroCorner: zeroCorner + ) + + state.settings.zeroCorner = zeroCorner + rulerWindow.apply(settings: state.settings) + rulerWindow.alphaValue = windowAlphaValue(opacity) + updateIsFloatingPanel() + updateHasShadow() + rulerWindow.setFrame(rulerWindow.visibleFrame(in: layout), display: true) + captureStateFromWindow() + notifyStateChanged() + } + + func foreground() { + opacity = state.settings.foregroundOpacity + } + + func background() { + opacity = state.settings.backgroundOpacity + } + + func updateIsFloatingPanel() { + rulerWindow.isFloatingPanel = preferencesWindowOpen ? false : state.settings.floatRulers + } + + func updateHasShadow() { + rulerWindow.hasShadow = state.settings.rulerShadow + } + + func redrawForPreferenceChange() { + rulerWindow.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: rulerWindow.zeroPoint(), + horizontalLength: min(max(horizontalLength, minHorizontalLength), maxHorizontalLength), + verticalLength: min(max(verticalLength, minVerticalLength), maxVerticalLength) + ) + applyStateToWindow(display: true) + notifyStateChanged() + } + + func move(to frame: NSRect) { + rulerWindow.setFrame(frame, display: false) + captureStateFromWindow() + } + + func captureCurrentState() { + 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 rulerWindow.isRuleVisible(.horizontal) { + rulerWindow.horizontalRule.drawMouseTick(at: mouseLoc) + } + if rulerWindow.isRuleVisible(.vertical) { + rulerWindow.verticalRule.drawMouseTick(at: mouseLoc) + } + } + + func setMouseTickDrawingEnabled(_ isEnabled: Bool) { + isMouseTickDrawingEnabled = isEnabled + updateMouseTickDrawingVisibility() + } + + private func updateMouseTickDrawingVisibility() { + 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) + rulerWindow.apply(settings: state.settings) + rulerWindow.alphaValue = windowAlphaValue(opacity) + updateIsFloatingPanel() + updateHasShadow() + rulerWindow.setVisibleRules( + horizontal: state.visibility.showsHorizontal, + vertical: state.visibility.showsVertical + ) + updateMouseTickDrawingVisibility() + rulerWindow.setFrame( + layout.visibleFrame( + showsHorizontalRule: state.visibility.showsHorizontal, + showsVerticalRule: state.visibility.showsVertical + ), + display: display + ) + rulerWindow.updateLayoutForCurrentZeroCorner() + } + + private func captureStateFromWindow() { + var horizontalLength = state.layout.horizontalLength + var verticalLength = state.layout.verticalLength + + if rulerWindow.isRuleVisible(.horizontal) { + horizontalLength = rulerWindow.screenFrame(for: .horizontal).width + } + if rulerWindow.isRuleVisible(.vertical) { + verticalLength = rulerWindow.screenFrame(for: .vertical).height + } + + state.layout = RulerLayoutState( + zeroPoint: rulerWindow.zeroPoint(), + horizontalLength: horizontalLength, + verticalLength: verticalLength + ) + state.visibility = RulerWingVisibility( + horizontal: rulerWindow.isRuleVisible(.horizontal), + vertical: rulerWindow.isRuleVisible(.vertical) + ) + notifyStateChanged() + } + + private func notifyStateChanged() { + onStateChanged?(self) + } + + func windowWillStartLiveResize(_ notification: Notification) { + mouseInteraction.windowWillStartLiveResize() + } + + func windowDidEndLiveResize(_ notification: Notification) { + captureStateFromWindow() + mouseInteraction.windowDidEndLiveResize() + } + + func windowWillMove(_ notification: Notification) { + mouseInteraction.windowWillMove() + } + + func windowDidMove(_ notification: Notification) { + rulerWindow.invalidateShadow() + 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) { + captureStateFromWindow() + onDragFinished?(self) + } + } + + func activateForRulerContextMenu() { + onBecameActive?(self) + } + + override func mouseMoved(with event: NSEvent) { + mouseInteraction.mouseMoved(with: event) + } + + private func mouseIsInsideRuler(with event: NSEvent) -> Bool { + return orientation(at: event) != nil + || rulerWindow.isEmptyCorner(atWindowPoint: event.locationInWindow) + } + + private func orientation(at event: NSEvent) -> Orientation? { + let horizontalLocation = rulerWindow.horizontalRule.convert(event.locationInWindow, from: nil) + let verticalLocation = rulerWindow.verticalRule.convert(event.locationInWindow, from: nil) + + if rulerWindow.isRuleVisible(.horizontal), + rulerWindow.horizontalRule.bounds.contains(horizontalLocation) { + return .horizontal + } + + if rulerWindow.isRuleVisible(.vertical), + rulerWindow.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 RulerController { + 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 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 + } + } +} + +final class RulerManager { + typealias ControllerFactory = (RulerInstanceState) -> RulerController + + private struct GroupedDragState { + let draggedRulerID: UUID + let framesByRulerID: [UUID: NSRect] + let attachedRulerIDs: Set + } + + private let controllerFactory: ControllerFactory + private(set) var controllers: [RulerController] = [] + private(set) var activeRulerID: UUID? + var onActiveControllerChanged: ((RulerController?) -> Void)? + var onStateChanged: ((RulerManager) -> Void)? + private var isApplicationActive = true + private var groupedDragState: GroupedDragState? + private var isApplyingGroupedDrag = false + + init( + initialStates: [RulerInstanceState] = [], + controllerFactory: @escaping ControllerFactory = { RulerController(state: $0) } + ) { + self.controllerFactory = controllerFactory + restore(initialStates) + } + + var hasRulers: Bool { + return !controllers.isEmpty + } + + var hasVisibleRulers: Bool { + return controllers.contains { $0.isVisible } + } + + 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.rulerWindow.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() + ) -> RulerController { + let defaultState = RulerInstanceState.createFromDefaults( + defaults: defaults, + screenFrame: screenFrame + ) + let state = staggeredState(from: defaultState) + + return addRuler(state: state) + } + + @discardableResult + func addRuler(state: RulerInstanceState) -> RulerController { + 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.rulerWindow.drawsActiveBorder = false + 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.rulerWindow.makeKey() + } + } + + @discardableResult + func cycleActiveRuler() -> RulerController? { + 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.rulerWindow.orderFrontRegardless() + nextController.rulerWindow.makeKey() + return nextController + } + + func setApplicationActive(_ isApplicationActive: Bool) { + guard self.isApplicationActive != isApplicationActive else { return } + + self.isApplicationActive = isApplicationActive + updateActiveRulerBorders() + } + + @discardableResult + func closeActiveRuler() -> Bool { + guard let activeController = activeController else { return false } + + close(activeController) + return true + } + + func close(_ controller: RulerController) { + let wasActiveController = activeRulerID == controller.state.id + + controller.hide() + controllers.removeAll { $0 === controller } + + if wasActiveController { + activeRulerID = controllers.last?.state.id + } + + updateActiveRulerBorders() + + if wasActiveController { + onActiveControllerChanged?(activeController) + } + + notifyStateChanged() + } + + func markActive(_ controller: RulerController) { + guard controllers.contains(where: { $0 === controller }) else { return } + + activeRulerID = controller.state.id + updateActiveRulerBorders() + onActiveControllerChanged?(controller) + notifyStateChanged() + } + + func beginGroupedDrag(from controller: RulerController) { + if let groupedDragState = groupedDragState { + detachGroupedDragFollowers(groupedDragState) + } + + guard prefs.groupRulers, + controllers.contains(where: { $0 === controller }) else { + groupedDragState = nil + return + } + + let visibleControllers = controllers.filter(\.isVisible) + groupedDragState = GroupedDragState( + draggedRulerID: controller.state.id, + framesByRulerID: Dictionary( + uniqueKeysWithValues: visibleControllers + .map { ($0.state.id, $0.rulerWindow.frame) } + ), + attachedRulerIDs: attachGroupedDragFollowers( + to: controller, + visibleControllers: visibleControllers + ) + ) + } + + func syncGroupedDrag(from controller: RulerController) { + 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.rulerWindow.frame.minX - originalDraggedFrame.minX, + height: controller.rulerWindow.frame.minY - originalDraggedFrame.minY + ) + guard offset.width != 0 || offset.height != 0 else { return } + + isApplyingGroupedDrag = true + defer { + isApplyingGroupedDrag = false + } + + var movedFollower = false + for otherController in controllers where otherController !== controller && otherController.isVisible { + guard var frame = groupedDragState.framesByRulerID[otherController.state.id] else { continue } + guard !rulerMovesWithGroupedDragParent( + otherController, + draggedController: controller, + groupedDragState: groupedDragState + ) else { + continue + } + + frame.origin.x += offset.width + frame.origin.y += offset.height + otherController.move(to: frame) + movedFollower = true + } + + if movedFollower { + notifyStateChanged() + } + } + + func finishGroupedDrag(from controller: RulerController) { + guard let groupedDragState = groupedDragState else { return } + guard groupedDragState.draggedRulerID == controller.state.id else { + detachGroupedDragFollowers(groupedDragState) + self.groupedDragState = nil + return + } + + syncGroupedDrag(from: controller) + detachGroupedDragFollowers(from: controller, groupedDragState: groupedDragState) + captureGroupedDragFollowerStates(excluding: controller, groupedDragState: groupedDragState) + self.groupedDragState = nil + notifyStateChanged() + } + + func controller(containing window: NSWindow?) -> RulerController? { + guard let window = window else { return nil } + + return controllers.first { $0.rulerWindow === window } + } + + func controller(id: UUID) -> RulerController? { + return controllers.first { $0.state.id == id } + } + + private func configure(_ controller: RulerController) { + 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 attachGroupedDragFollowers( + to draggedController: RulerController, + visibleControllers: [RulerController] + ) -> Set { + let draggedWindow = draggedController.rulerWindow + var attachedRulerIDs = Set() + + for followerController in visibleControllers where followerController !== draggedController { + let followerWindow = followerController.rulerWindow + guard followerWindow.parent == nil else { continue } + + draggedWindow.addChildWindow(followerWindow, ordered: .below) + attachedRulerIDs.insert(followerController.state.id) + } + + return attachedRulerIDs + } + + private func detachGroupedDragFollowers(_ groupedDragState: GroupedDragState) { + guard let draggedController = controller(id: groupedDragState.draggedRulerID) else { return } + + detachGroupedDragFollowers(from: draggedController, groupedDragState: groupedDragState) + } + + private func detachGroupedDragFollowers( + from draggedController: RulerController, + groupedDragState: GroupedDragState + ) { + let draggedWindow = draggedController.rulerWindow + + for rulerID in groupedDragState.attachedRulerIDs { + guard let followerController = controller(id: rulerID), + followerController.rulerWindow.parent === draggedWindow else { continue } + + draggedWindow.removeChildWindow(followerController.rulerWindow) + } + } + + private func rulerMovesWithGroupedDragParent( + _ followerController: RulerController, + draggedController: RulerController, + groupedDragState: GroupedDragState + ) -> Bool { + return groupedDragState.attachedRulerIDs.contains(followerController.state.id) + || followerController.rulerWindow.parent === draggedController.rulerWindow + } + + private func captureGroupedDragFollowerStates( + excluding draggedController: RulerController, + groupedDragState: GroupedDragState + ) { + for followerController in controllers where followerController !== draggedController { + guard groupedDragState.framesByRulerID[followerController.state.id] != nil else { continue } + + followerController.captureCurrentState() + } + } + + 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) + } + + private func updateActiveRulerBorders() { + for controller in controllers { + controller.rulerWindow.drawsActiveBorder = isApplicationActive + && controller.state.id == activeRulerID + } + } +} +#endif + +private func rulerWindowLShapedPath( + in bounds: NSRect, + zeroCorner: ZeroCorner, + inset: CGFloat +) -> NSBezierPath { + 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 + 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/UITestSupport+App.swift b/Free Ruler/UITestSupport+App.swift index 09bb087..b8168b2 100644 --- a/Free Ruler/UITestSupport+App.swift +++ b/Free Ruler/UITestSupport+App.swift @@ -2,7 +2,7 @@ import Foundation extension UITestSupport { func resetApplicationState() { - let defaults = UserDefaults.standard + let defaults = Prefs.userDefaults [ "groupRulers", "floatRulers", @@ -12,12 +12,13 @@ extension UITestSupport { "rulerColor", "unit", "zeroCorner", + Prefs.rulerSetStateKey, "NSWindow Frame horizontal-ruler", "NSWindow Frame vertical-ruler", "NSWindow Frame preferencesWindow", ].forEach(defaults.removeObject(forKey:)) - prefs.groupRulers = true + prefs.groupRulers = Prefs.defaultGroupRulers prefs.floatRulers = true prefs.rulerShadow = false prefs.foregroundOpacity = 90 @@ -27,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/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/Free Ruler/VerticalRule.swift b/Free Ruler/VerticalRule.swift index 8125b5e..c2169c1 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) @@ -124,7 +125,11 @@ class VerticalRule: RuleView { let windowPoint = window.convertPoint(fromScreen: mouseLoc) let viewPoint = convert(windowPoint, from: nil) - mouseTickY = viewPoint.y + mouseTickY = mouseTickY(forLocalMouseY: viewPoint.y) + } + + func mouseTickY(forLocalMouseY localMouseY: CGFloat) -> CGFloat { + return localMouseY.rounded() } func drawMouseTick(_ mouseTickY: CGFloat) { @@ -136,7 +141,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 +154,7 @@ class VerticalRule: RuleView { switch growthDirection { case .positive: - zeroTickY = bounds.minY + zeroTickY = bounds.minY + 1 case .negative: zeroTickY = bounds.maxY } @@ -324,7 +329,7 @@ class VerticalRule: RuleView { case .positive: return mouseTickY case .negative: - return rulerHeight - mouseTickY + return max(0, rulerHeight - mouseTickY - 1) } } @@ -334,7 +339,7 @@ class VerticalRule: RuleView { ) -> CGFloat { switch growthDirection { case .positive: - return mouseTickY + 1 + return mouseTickY case .negative: return mouseTickY } diff --git a/Free Ruler/de.lproj/MainMenu.strings b/Free Ruler/de.lproj/MainMenu.strings index 397ec7e..3196c8a 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"; @@ -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"; */ @@ -38,8 +38,8 @@ /* Class = "NSMenuItem"; title = "Free Ruler Help"; ObjectID = "FKE-Sm-Kum"; */ "FKE-Sm-Kum.title" = "„Free Ruler“-Hilfe"; -/* Class = "NSMenuItem"; title = "Float Rulers"; ObjectID = "GDK-AC-uC8"; */ -"GDK-AC-uC8.title" = "Oben schweben"; +/* Class = "NSMenuItem"; title = "Float Ruler"; ObjectID = "GDK-AC-uC8"; */ +"GDK-AC-uC8.title" = "Lineal schweben lassen"; /* Class = "NSMenuItem"; title = "Flip Horizontal"; ObjectID = "GZl-Zd-Ad4"; */ "GZl-Zd-Ad4.title" = "Horizontal spiegeln"; @@ -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"; @@ -151,3 +151,9 @@ /* 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/de.lproj/PreferencesController.strings b/Free Ruler/de.lproj/PreferencesController.strings index edf83f4..e5d6979 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"; */ +/* Class = "NSWindow"; title = "Free Ruler Settings"; 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..0bc5606 --- /dev/null +++ b/Free Ruler/de.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,21 @@ + +/* 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 = "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"; + +/* Class = "NSButtonCell"; title = "Float ruler above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "Lineal über anderen Programmen schweben lassen"; + +/* 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/MainMenu.strings b/Free Ruler/es.lproj/MainMenu.strings index 1be4959..5a5dd52 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"; @@ -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"; @@ -41,8 +41,8 @@ /* Class = "NSMenuItem"; title = "Free Ruler Help"; ObjectID = "FKE-Sm-Kum"; */ "FKE-Sm-Kum.title" = "Ayuda de Free Ruler"; -/* Class = "NSMenuItem"; title = "Float Rulers"; ObjectID = "GDK-AC-uC8"; */ -"GDK-AC-uC8.title" = "Reglas flotantes"; +/* Class = "NSMenuItem"; title = "Float Ruler"; ObjectID = "GDK-AC-uC8"; */ +"GDK-AC-uC8.title" = "Regla flotante"; /* Class = "NSMenuItem"; title = "Flip Horizontal"; ObjectID = "GZl-Zd-Ad4"; */ "GZl-Zd-Ad4.title" = "Voltear horizontalmente"; @@ -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"; @@ -148,3 +148,9 @@ /* 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/es.lproj/PreferencesController.strings b/Free Ruler/es.lproj/PreferencesController.strings index 8edf07b..bc760d0 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 = "NSWindow"; title = "Free Ruler Settings"; ObjectID = "F0z-JX-Cv5"; */ +"F0z-JX-Cv5.title" = "Ajustes de Free Ruler"; -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "Opacidad del primer plano"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "Ajustes predeterminados para reglas nuevas"; -/* 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 = "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..96abe8f --- /dev/null +++ b/Free Ruler/es.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,21 @@ + +/* 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 = "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"; + +/* Class = "NSButtonCell"; title = "Float ruler above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "Regla flotante sobre otras aplicaciones"; + +/* 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/MainMenu.strings b/Free Ruler/fi.lproj/MainMenu.strings index d81c26d..d084dcb 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"; @@ -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"; */ @@ -38,8 +38,8 @@ /* Class = "NSMenuItem"; title = "Free Ruler Help"; ObjectID = "FKE-Sm-Kum"; */ "FKE-Sm-Kum.title" = "Free Rulerin ohje"; -/* Class = "NSMenuItem"; title = "Float Rulers"; ObjectID = "GDK-AC-uC8"; */ -"GDK-AC-uC8.title" = "Kelluta viivaimet"; +/* Class = "NSMenuItem"; title = "Float Ruler"; ObjectID = "GDK-AC-uC8"; */ +"GDK-AC-uC8.title" = "Kelluta viivain"; /* Class = "NSMenuItem"; title = "Flip Horizontal"; ObjectID = "GZl-Zd-Ad4"; */ "GZl-Zd-Ad4.title" = "Käännä vaakasuunnassa"; @@ -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"; @@ -151,3 +151,9 @@ /* 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/fi.lproj/PreferencesController.strings b/Free Ruler/fi.lproj/PreferencesController.strings index b00f2a9..5d6be25 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"; */ +/* Class = "NSWindow"; title = "Free Ruler Settings"; 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..11e0e72 --- /dev/null +++ b/Free Ruler/fi.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,21 @@ + +/* 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 = "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"; + +/* Class = "NSButtonCell"; title = "Float ruler above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "Kelluta viivainta muiden sovellusten päällä"; + +/* 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/MainMenu.strings b/Free Ruler/ja.lproj/MainMenu.strings index 01e477a..ad529bd 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" = "定規をグループ化"; @@ -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" = "閉じる"; @@ -41,7 +41,7 @@ /* Class = "NSMenuItem"; title = "Free Ruler Help"; ObjectID = "FKE-Sm-Kum"; */ "FKE-Sm-Kum.title" = "Free Rulerヘルプ"; -/* Class = "NSMenuItem"; title = "Float Rulers"; ObjectID = "GDK-AC-uC8"; */ +/* Class = "NSMenuItem"; title = "Float Ruler"; ObjectID = "GDK-AC-uC8"; */ "GDK-AC-uC8.title" = "定規を常に手前に表示"; /* Class = "NSMenuItem"; title = "Flip Horizontal"; ObjectID = "GZl-Zd-Ad4"; */ @@ -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"; */ @@ -148,3 +148,9 @@ /* 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/ja.lproj/PreferencesController.strings b/Free Ruler/ja.lproj/PreferencesController.strings index 3bf4c18..7635f86 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 = "NSWindow"; title = "Free Ruler Settings"; ObjectID = "F0z-JX-Cv5"; */ +"F0z-JX-Cv5.title" = "Free Rulerの設定"; -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "前景の不透明度"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.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 = "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..b086b8d --- /dev/null +++ b/Free Ruler/ja.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,21 @@ + +/* 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 = "Dimensions"; ObjectID = "RSV-dimensions-cell"; */ +"RSV-dimensions-cell.title" = "寸法"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "前景の不透明度"; + +/* Class = "NSButtonCell"; title = "Float ruler 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" = "定規の影を表示"; + +/* Class = "NSTextFieldCell"; title = "Unit"; ObjectID = "RSV-unit-cell"; */ +"RSV-unit-cell.title" = "単位"; diff --git a/Free Ruler/zh-hans.lproj/MainMenu.strings b/Free Ruler/zh-hans.lproj/MainMenu.strings index 3b14cd0..8febd1d 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" = "组合尺子"; @@ -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" = "关闭"; @@ -38,7 +38,7 @@ /* Class = "NSMenuItem"; title = "Free Ruler Help"; ObjectID = "FKE-Sm-Kum"; */ "FKE-Sm-Kum.title" = "Free Ruler 帮助"; -/* Class = "NSMenuItem"; title = "Float Rulers"; ObjectID = "GDK-AC-uC8"; */ +/* Class = "NSMenuItem"; title = "Float Ruler"; ObjectID = "GDK-AC-uC8"; */ "GDK-AC-uC8.title" = "悬浮尺子"; /* Class = "NSMenuItem"; title = "Flip Horizontal"; ObjectID = "GZl-Zd-Ad4"; */ @@ -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"; */ @@ -151,3 +151,9 @@ /* 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" = "尺子设置…"; diff --git a/Free Ruler/zh-hans.lproj/PreferencesController.strings b/Free Ruler/zh-hans.lproj/PreferencesController.strings index ffc9f81..38bf47b 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 = "NSWindow"; title = "Free Ruler Settings"; ObjectID = "F0z-JX-Cv5"; */ +"F0z-JX-Cv5.title" = "Free Ruler 设置"; -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "前景不透明度"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.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 = "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..a04bf04 --- /dev/null +++ b/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,21 @@ + +/* 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 = "Dimensions"; ObjectID = "RSV-dimensions-cell"; */ +"RSV-dimensions-cell.title" = "尺寸"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "前景不透明度"; + +/* Class = "NSButtonCell"; title = "Float ruler 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" = "显示尺子阴影"; + +/* Class = "NSTextFieldCell"; title = "Unit"; ObjectID = "RSV-unit-cell"; */ +"RSV-unit-cell.title" = "单位"; diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index c305746..69cc98f 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -11,6 +11,10 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(windowAlphaValue(100), 1.0) } + func testUnitTestsUseIsolatedUserDefaults() { + XCTAssertFalse(Prefs.userDefaults === UserDefaults.standard) + } + func testRulerStoresOrientationFrameAndAutosaveName() { let frame = NSRect(x: 10, y: 20, width: 300, height: 40) let ruler = Ruler(.horizontal, frame: frame, name: "test-ruler") @@ -20,6 +24,1413 @@ 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 + 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) + 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 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 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) + + manager.setApplicationActive(false) + + XCTAssertFalse(first.rulerWindow.drawsActiveBorder) + XCTAssertFalse(second.rulerWindow.drawsActiveBorder) + + manager.setApplicationActive(true) + + XCTAssertTrue(first.rulerWindow.drawsActiveBorder) + XCTAssertFalse(second.rulerWindow.drawsActiveBorder) + + XCTAssertTrue(manager.closeActiveRuler()) + + XCTAssertTrue(second.rulerWindow.drawsActiveBorder) + } + + 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 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.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 + movedFirstFrame.origin.y += dragOffset.height + + appDelegate.rulerManager.beginGroupedDrag(from: first) + XCTAssertTrue(first.rulerWindow.childWindows?.contains(second.rulerWindow) ?? false) + XCTAssertTrue(second.rulerWindow.parent === first.rulerWindow) + XCTAssertFalse(first.rulerWindow.childWindows?.contains(hidden.rulerWindow) ?? false) + + first.move(to: movedFirstFrame) + appDelegate.rulerManager.syncGroupedDrag(from: first) + appDelegate.rulerManager.finishGroupedDrag(from: first) + + 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.rulerWindow.screenFrame(for: .horizontal), + for: .horizontal + ) + ) + XCTAssertFalse(first.rulerWindow.childWindows?.contains(second.rulerWindow) ?? false) + XCTAssertNil(second.rulerWindow.parent) + } + } + } + + 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 + 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.rulerWindow.horizontalRule.menu(for: mouseEvent( + type: .rightMouseDown, + location: .zero, + windowNumber: first.rulerWindow.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")! + 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].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 testRulerControllerAppliesPerRulerSettingsToWindowAndRules() { + 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 = RulerController( + state: RulerInstanceState( + settings: settings, + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 200, y: 300), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + defer { + controller.hide() + } + + 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.rulerWindow.alphaValue, 0.31, accuracy: 0.0001) + } + + func testRulerControllerIgnoresDefaultPreferenceChanges() { + withRestoredRulerPreferences { + let color = NSColor(deviceRed: 0.2, green: 0.3, blue: 0.7, alpha: 1) + let controller = RulerController( + 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.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) + } + } + + 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 = RulerController( + state: RulerInstanceState( + settings: RulerSettings( + unit: .inches, + 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), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + settingsController.unitSegmentedControl.selectedSegment = Unit.millimeters.rawValue + settingsController.setUnit(settingsController.unitSegmentedControl) + + XCTAssertEqual(controller.state.settings.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.rulerWindow.zeroPoint() + 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, expectedHorizontalLength, accuracy: 0.0001) + XCTAssertEqual(controller.state.layout.verticalLength, expectedVerticalLength, 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) + XCTAssertEqual(prefs.defaultVerticalLength, 280) + + 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.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) + + settingsController.foregroundOpacitySlider.integerValue = 65 + settingsController.setForegroundOpacity(settingsController.foregroundOpacitySlider) + + XCTAssertEqual(controller.state.settings.foregroundOpacity, 65) + XCTAssertEqual(controller.rulerWindow.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.rulerWindow.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.rulerWindow.isFloatingPanel) + XCTAssertTrue(settingsController.floatRulersCheckbox.state == .on) + XCTAssertTrue(prefs.floatRulers) + + settingsController.rulerShadowCheckbox.state = .on + settingsController.setRulerShadow(settingsController.rulerShadowCheckbox) + + XCTAssertTrue(controller.state.settings.rulerShadow) + 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.rulerWindow.horizontalRule.color.fill, equals: Prefs.defaultRulerFillColor) + assertColor(prefs.rulerColor, equals: defaultColor) + XCTAssertTrue(settingsController.resetRulerColorButton.isHidden) + } + } + + 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 + prefs.defaultHorizontalLength = 500 + prefs.defaultVerticalLength = 400 + + let rulerColor = NSColor(deviceRed: 0.72, green: 0.24, blue: 0.44, alpha: 1) + let controller = RulerController( + 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) + XCTAssertEqual(prefs.defaultHorizontalLength, 260) + XCTAssertEqual(prefs.defaultVerticalLength, 180) + } + } + + 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 + prefs.defaultHorizontalLength = 320 + prefs.defaultVerticalLength = 220 + + let controller = RulerController( + 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(controller.state.layout.horizontalLength, 320) + XCTAssertEqual(controller.state.layout.verticalLength, 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) + XCTAssertEqual(controller.rulerWindow.alphaValue, windowAlphaValue(88), accuracy: 0.0001) + XCTAssertEqual(prefs.foregroundOpacity, 88) + } + } + + func testRulerSettingsControllerAppliesColorPanelChangesToActiveRuler() { + let controller = RulerController( + 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.rulerWindow.horizontalRule.color.fill, equals: normalizedColor) + assertColor(settingsController.rulerColorWell.color, equals: normalizedColor) + } + + func testRulerSettingsControllerCheckboxKeyEquivalentsToggleFloatAndShadow() { + let controller = RulerController( + 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 + 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 = true + prefs.rulerShadow = true + prefs.zeroCorner = .bottomRight + prefs.defaultHorizontalLength = 333 + prefs.defaultVerticalLength = 222 + + 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(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.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, 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 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() + + controlsView.update( + unit: .pixels, + horizontalLength: 260, + verticalLength: 180, + rulerColor: Prefs.defaultRulerFillColor, + foregroundOpacity: 90, + backgroundOpacity: 50, + floatRulers: true, + 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.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, + floatRulers: true, + rulerShadow: false + ) + + XCTAssertTrue(controlsView.rulerColorWell.nextKeyView === controlsView.resetRulerColorButton) + XCTAssertTrue(controlsView.resetRulerColorButton.nextKeyView === controlsView.foregroundOpacitySlider) + } + + func testRulerSettingsControllerPresentsAsAttachedSheetOnRulerWindow() { + let controller = RulerController( + 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 + } + XCTAssertTrue(controller.rulerWindow.childWindows?.contains(settingsWindow) ?? false) + 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 = RulerController( + 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.rulerWindow.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 = RulerController( + 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 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 = RulerController( + 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.rulerWindow.alphaValue, 0.35, accuracy: 0.0001) + + settingsController.close() + + XCTAssertEqual(controller.rulerWindow.alphaValue, 0.8, accuracy: 0.0001) + } + + func testRulerSettingsControllerTitlebarCloseClosesAttachedSheet() { + let controller = RulerController( + 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 + } + + XCTAssertTrue(settingsWindow.styleMask.contains(.closable)) + + settingsWindow.performClose(self) + + XCTAssertFalse(controller.rulerWindow.childWindows?.contains(settingsWindow) ?? false) + 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.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.rulerWindow.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 + 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 { + 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 + 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.rulerWindow.horizontalRule.unit, .pixels) + XCTAssertEqual(existing.rulerWindow.horizontalRule.zeroCorner, .topLeft) + assertColor( + 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.rulerWindow.horizontalRule.unit, .millimeters) + XCTAssertEqual(createdAfterDefaultsChange.rulerWindow.horizontalRule.zeroCorner, .topRight) + assertColor( + createdAfterDefaultsChange.rulerWindow.horizontalRule.color.fill, + equals: NSColor(deviceRed: 0.8, green: 0.7, blue: 0.2, alpha: 1) + ) + } + } + + 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 { + Prefs.userDefaults.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) + Prefs.userDefaults.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(Prefs.userDefaults.data(forKey: Prefs.rulerSetStateKey)) + } + } + } + func testZeroCornerRawValuesPreservePersistedOrder() { XCTAssertEqual(ZeroCorner.topLeft.rawValue, 0) XCTAssertEqual(ZeroCorner.topRight.rawValue, 1) @@ -48,19 +1459,31 @@ final class RulerCoreTests: XCTestCase { prefs.zeroCorner = .bottomRight XCTAssertEqual( - UserDefaults.standard.integer(forKey: "zeroCorner"), + Prefs.userDefaults.integer(forKey: "zeroCorner"), ZeroCorner.bottomRight.rawValue ) prefs.zeroCorner = .topRight XCTAssertEqual( - UserDefaults.standard.integer(forKey: "zeroCorner"), + Prefs.userDefaults.integer(forKey: "zeroCorner"), ZeroCorner.topRight.rawValue ) } } + func testGroupRulersDefaultsOffAndPersistsToUserDefaults() { + withRestoredRulerPreferences { + XCTAssertFalse(Prefs.defaultGroupRulers) + + prefs.groupRulers = true + XCTAssertTrue(Prefs.userDefaults.bool(forKey: "groupRulers")) + + prefs.groupRulers = false + XCTAssertFalse(Prefs.userDefaults.bool(forKey: "groupRulers")) + } + } + func testZeroCornerGeometryDerivesOrientationTraits() { let cases: [ ( @@ -269,7 +1692,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) @@ -287,12 +1710,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 ) @@ -314,13 +1737,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 @@ -347,8 +1770,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) @@ -373,39 +1796,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 @@ -421,8 +1844,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, @@ -431,19 +1854,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 @@ -457,12 +1880,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, @@ -471,61 +1894,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) + XCTAssertFalse(controller.rulerWindow.isRuleVisible(.horizontal)) + controller.rulerWindow.orderOut(self) } } - func testGroupedRulerControllerSyncsHiddenLegToVisibleZeroPoint() { - withRestoredZeroCornerPreference { - prefs.zeroCorner = .topLeft - let horizontalWindow = RulerWindow( - Ruler(.horizontal, frame: NSRect(x: 200, y: 299, width: 320, height: Ruler.thickness)) - ) - let verticalWindow = RulerWindow( - 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) - } - } - - 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 @@ -542,32 +1927,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 @@ -585,22 +1970,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) } } @@ -641,42 +2026,48 @@ final class RulerCoreTests: XCTestCase { } func testHorizontalRuleDrawingHelpersFollowZeroCornerGeometry() { - let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - XCTAssertEqual( - rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .positive), - 50 - ) - XCTAssertEqual( - rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative), - 249 - ) - XCTAssertEqual( - rule.mouseTickLineX(forTickX: 1, growthDirection: .positive), - 1 - ) - XCTAssertEqual( - rule.mouseTickLineX(forTickX: 299, growthDirection: .negative), - 298 - ) + XCTAssertEqual(rule.mouseNumber(forTickX: 51, rulerWidth: 300), 50) + XCTAssertEqual( + rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .positive), + 50 + ) + XCTAssertEqual( + rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative), + 249 + ) + XCTAssertEqual( + rule.mouseTickLineX(forTickX: 1, growthDirection: .positive), + 1 + ) + XCTAssertEqual( + rule.mouseTickLineX(forTickX: 299, growthDirection: .negative), + 299 + ) + XCTAssertEqual(rule.mouseTickX(forLocalMouseX: 50.49), 50) + XCTAssertEqual(rule.mouseTickX(forLocalMouseX: 50.5), 51) - let bottomTick = rule.tickLine(forX: 50, length: 10, rulerHeight: 40, tickSide: .bottom) - XCTAssertEqual(bottomTick.start, CGPoint(x: 50, y: 1)) - XCTAssertEqual(bottomTick.end, CGPoint(x: 50, y: 10)) + let bottomTick = rule.tickLine(forX: 50, length: 10, rulerHeight: 40, tickSide: .bottom) + XCTAssertEqual(bottomTick.start, CGPoint(x: 50, y: 1)) + XCTAssertEqual(bottomTick.end, CGPoint(x: 50, y: 10)) - let topTick = rule.tickLine(forX: 250, length: 10, rulerHeight: 40, tickSide: .top) - XCTAssertEqual(topTick.start, CGPoint(x: 250, y: 39)) - XCTAssertEqual(topTick.end, CGPoint(x: 250, y: 30)) + let topTick = rule.tickLine(forX: 250, length: 10, rulerHeight: 40, tickSide: .top) + XCTAssertEqual(topTick.start, CGPoint(x: 250, y: 39)) + XCTAssertEqual(topTick.end, CGPoint(x: 250, y: 30)) - XCTAssertEqual( - rule.tickLabelRect( - forX: 250, - labelSize: NSSize(width: 50, height: 20), - rulerHeight: 40, - tickSide: .top - ), - CGRect(x: 225.5, y: 19, width: 50, height: 20) - ) + XCTAssertEqual( + rule.tickLabelRect( + forX: 250, + labelSize: NSSize(width: 50, height: 20), + rulerHeight: 40, + tickSide: .top + ), + CGRect(x: 225.5, y: 19, width: 50, height: 20) + ) + } } func testHorizontalRuleMouseAndUnitLabelsMirrorForRightZeroCorner() { @@ -685,6 +2076,13 @@ final class RulerCoreTests: XCTestCase { 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: rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative) + 1, + 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) @@ -709,8 +2107,10 @@ final class RulerCoreTests: XCTestCase { ) XCTAssertEqual( rule.mouseTickLineY(forTickY: 1, growthDirection: .positive), - 2 + 1 ) + XCTAssertEqual(rule.mouseTickY(forLocalMouseY: 50.49), 50) + XCTAssertEqual(rule.mouseTickY(forLocalMouseY: 50.5), 51) let rightTick = rule.tickLine(forY: 250, length: 10, rulerWidth: 40, tickSide: .right) XCTAssertEqual(rightTick.start, CGPoint(x: 39, y: 250)) @@ -733,10 +2133,27 @@ final class RulerCoreTests: XCTestCase { func testVerticalRuleMouseAndUnitLabelsMirrorForBottomZeroCorner() { withRestoredZeroCornerPreference { + prefs.zeroCorner = .topRight + let topZeroRule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + XCTAssertEqual( + topZeroRule.mouseNumber( + forTickY: topZeroRule.tickY(forOffset: 50, rulerHeight: 300, growthDirection: .negative) - 1, + rulerHeight: 300 + ), + 50 + ) + 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: rule.tickY(forOffset: 50, rulerHeight: 300, growthDirection: .positive) - 1, + 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) @@ -1163,6 +2580,37 @@ final class RulerCoreTests: XCTestCase { }, is: .crosshair) } + func testRulerCrosshairCursorUsesAliasedBitmapImage() throws { + let cursor = RulerCursorController.CursorStyle.crosshair.nsCursor + + XCTAssertEqual(cursor.image.size, NSSize(width: 17, height: 17)) + XCTAssertEqual(cursor.hotSpot, NSPoint(x: 8.5, y: 8.5)) + XCTAssertFalse(cursor.image.isTemplate) + + let bitmap = try XCTUnwrap( + cursor.image.representations.compactMap { $0 as? NSBitmapImageRep }.first + ) + XCTAssertEqual(bitmap.pixelsWide, 34) + XCTAssertEqual(bitmap.pixelsHigh, 34) + assertPixel(atX: 16, y: 16, in: bitmap, equals: .black) + assertPixel(atX: 17, y: 17, in: bitmap, equals: .black) + assertPixel(atX: 2, y: 14, in: bitmap, equals: .white) + assertPixel(atX: 14, y: 14, in: bitmap, equals: .white) + assertPixel(atX: 0, y: 0, in: bitmap, equals: .clear) + + for y in 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) @@ -1695,9 +3143,11 @@ 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) - 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") } @@ -1733,15 +3183,17 @@ final class RulerCoreTests: XCTestCase { XCTAssertTrue(window.isMovableByWindowBackground) } - func testResizeHandleDetachesChildWindowsAttachedWhileBecomingKey() { - let childWindow = RulerWindow( - 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) @@ -1770,9 +3222,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 = RulerWindow(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") } @@ -1820,135 +3271,91 @@ 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 - )! + func testResizeHandleDragUsesRuleZeroCornerOverride() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + + let horizontalInitialFrame = NSRect(x: 100, y: 200, width: 300, height: Ruler.thickness) + let horizontalWindow = oneWingRulerWindow( + orientation: .horizontal, + frame: horizontalInitialFrame, + settings: RulerSettings(zeroCorner: .topRight) + ) + defer { horizontalWindow.close() } + guard let horizontalResizeHandle = resizeHandle(in: horizontalWindow.horizontalRule) else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } - draggedController.mouseDown(with: mouseDownEvent) - groupedChildController.windowDidMove( - Notification(name: NSWindow.didMoveNotification, object: groupedChildController.rulerWindow) + let horizontalStartLocation = horizontalResizeHandle.convert( + NSPoint(x: horizontalResizeHandle.bounds.minX + 1, y: horizontalResizeHandle.bounds.midY), + to: nil ) - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + 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 = oneWingRulerWindow( + orientation: .vertical, + frame: verticalInitialFrame, + settings: RulerSettings(zeroCorner: .bottomLeft) + ) + defer { verticalWindow.close() } + guard let verticalResizeHandle = resizeHandle(in: verticalWindow.verticalRule) else { + return XCTFail("Expected vertical ruler to install a resize handle") + } - XCTAssertFalse(draggedController.rulerWindow.rule.showMouseTick) - XCTAssertFalse(groupedChildController.rulerWindow.rule.showMouseTick) + 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 testGroupedRulerHotkeysToggleLegVisibilityWithoutUngrouping() { + func testPrimaryRulerHotkeysToggleWingVisibilityWithoutLegacyWindows() { withRestoredZeroCornerPreference { let previousGroupRulers = prefs.groupRulers defer { prefs.groupRulers = previousGroupRulers } @@ -1965,234 +3372,279 @@ 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) - XCTAssertFalse(appDelegate.rulers.first { $0.ruler.orientation == .horizontal }?.rulerWindow.isVisible ?? true) - XCTAssertFalse(appDelegate.rulers.first { $0.ruler.orientation == .vertical }?.rulerWindow.isVisible ?? true) - groupedWindow?.orderOut(self) + XCTAssertFalse(rulerWindow?.isRuleVisible(.horizontal) ?? true) + XCTAssertTrue(rulerWindow?.isRuleVisible(.vertical) ?? false) + rulerWindow?.orderOut(self) } } - func testUngroupedHorizontalFlipDoesNotMoveRulerWindows() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } - - prefs.zeroCorner = .topLeft + func testManagedGroupHotkeyTogglesGroupedDraggingMode() { + withRestoredRulerPreferences { 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] + let controller = appDelegate.rulerManager.createRuler() + defer { + controller.hide() + } - appDelegate.flipRulers(along: .horizontal) + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_G, + modifierFlags: [], + sender: controller + ) + ) + XCTAssertTrue(prefs.groupRulers) - 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)) + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_G, + modifierFlags: [], + sender: controller + ) + ) + XCTAssertFalse(prefs.groupRulers) } } - func testGroupedHorizontalFlipMovesVerticalRulerToPreserveZeroPointOffset() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } + 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) - 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)) + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_Grave, + modifierFlags: .command, + sender: first ) - appDelegate.rulers = [verticalController, horizontalController] + ) - appDelegate.flipRulers(along: .horizontal) + XCTAssertTrue(appDelegate.rulerManager.activeController === second) + } - 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 testManagedWingHotkeysAffectOnlyActiveRuler() { + let appDelegate = AppDelegate() + let first = appDelegate.rulerManager.createRuler() + let second = appDelegate.rulerManager.createRuler() + defer { + first.hide() + second.hide() } - } - func testGroupedVerticalFlipMovesHorizontalRulerToPreserveZeroPointOffset() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } + appDelegate.rulerManager.markActive(first) - 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)) + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_H, + modifierFlags: [], + sender: first ) - 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)) - } + XCTAssertFalse(first.rulerWindow.isRuleVisible(.horizontal)) + XCTAssertTrue(first.rulerWindow.isRuleVisible(.vertical)) + XCTAssertTrue(second.rulerWindow.isRuleVisible(.horizontal)) + XCTAssertTrue(second.rulerWindow.isRuleVisible(.vertical)) } - func testGroupedFlipDoesNotShowHiddenRulerWindows() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } - - prefs.zeroCorner = .topLeft - prefs.groupRulers = true + func testManagedCommandsApplySettingsToActiveRulerOnly() { + withRestoredRulerPreferences { + prefs.unit = .pixels + prefs.floatRulers = true + prefs.rulerShadow = false let appDelegate = AppDelegate() - let horizontalController = RulerController( - ruler: Ruler(.horizontal, frame: NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) + let first = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(unit: .pixels, floatRulers: true, rulerShadow: false) ) - let verticalController = RulerController( - ruler: Ruler(.vertical, frame: NSRect(x: 61, y: 140, width: Ruler.thickness, height: 160)) + let second = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(unit: .millimeters, floatRulers: true, rulerShadow: false) ) - 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) + defer { + first.hide() + second.hide() + } - XCTAssertFalse(horizontalController.rulerWindow.isVisible) - XCTAssertFalse(verticalController.rulerWindow.isVisible) - XCTAssertEqual(horizontalController.rulerWindow.frame, horizontalFrame) - XCTAssertEqual(verticalController.rulerWindow.frame, verticalFrame) + 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.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) + XCTAssertEqual(prefs.unit, .pixels) + XCTAssertTrue(prefs.floatRulers) + XCTAssertFalse(prefs.rulerShadow) } } - 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 + func testManagedFlipAndResetUseActiveRulerWithoutChangingDefaults() { + withRestoredRulerPreferences { + withRestoredRulerSetState { + prefs.zeroCorner = .topRight + let appDelegate = AppDelegate() + let first = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(zeroCorner: .bottomLeft) ) - ) - XCTAssertEqual(prefs.zeroCorner, .topRight) - - XCTAssertTrue( - appDelegate.performRulerHotkey( - keyCode: kVK_ANSI_V, - modifierFlags: .shift, - sender: verticalController + let second = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(zeroCorner: .topLeft) ) - ) - XCTAssertEqual(prefs.zeroCorner, .bottomRight) + 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.rulerWindow.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) + } } } - func testShiftHotkeysIgnoreCapsLock() { - withRestoredZeroCornerPreference { - let previousGroupRulers = prefs.groupRulers - defer { prefs.groupRulers = previousGroupRulers } + func testManagedWingCommandsDoNotHideLastVisibleWing() { + let appDelegate = AppDelegate() + let controller = appDelegate.rulerManager.createRuler() + defer { + controller.hide() + } - 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] + appDelegate.rulerManager.markActive(controller) + controller.setWing(.vertical, isVisible: false) + appDelegate.toggleHorizontalRuler(self) - XCTAssertTrue( - appDelegate.performRulerHotkey( - keyCode: kVK_ANSI_H, - modifierFlags: [.shift, .capsLock], - sender: horizontalController - ) - ) - XCTAssertEqual(prefs.zeroCorner, .topRight) + 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: "" + ) + 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 testNonShiftModifiedRulerHotkeysAreIgnored() { + func testShiftHotkeysFlipActiveRulerOrigin() { let appDelegate = AppDelegate() + let controller = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(zeroCorner: .topLeft) + ) + defer { + controller.hide() + } - XCTAssertFalse( + XCTAssertTrue( appDelegate.performRulerHotkey( keyCode: kVK_ANSI_H, - modifierFlags: .option, - sender: self + modifierFlags: .shift, + sender: controller ) ) - } + XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) - 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)) + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_V, + modifierFlags: .shift, + sender: controller ) + ) + XCTAssertEqual(controller.state.settings.zeroCorner, .bottomRight) + } - 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 testShiftHotkeysIgnoreCapsLock() { + let appDelegate = AppDelegate() + let controller = appDelegate.rulerManager.createRuler( + defaults: RulerSettings(zeroCorner: .topLeft) + ) + defer { + controller.hide() } - } - 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)) + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_H, + modifierFlags: [.shift, .capsLock], + sender: controller ) + ) + XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) + } - horizontalController.resetPosition() - verticalController.resetPosition() + func testNonShiftModifiedRulerHotkeysAreIgnored() { + let appDelegate = AppDelegate() - let geometry = ZeroCornerGeometry(zeroCorner: .topRight) - XCTAssertEqual( - geometry.zeroPoint(in: horizontalController.rulerWindow.frame, for: .horizontal), - geometry.zeroPoint(in: verticalController.rulerWindow.frame, for: .vertical) + XCTAssertFalse( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_H, + modifierFlags: .option, + sender: self ) - } + ) } private func mouseEvent( @@ -2215,82 +3667,235 @@ final class RulerCoreTests: XCTestCase { } private func withRestoredRulerColorPreference(_ test: () throws -> Void) rethrows { - let defaults = UserDefaults.standard let previousColor = prefs.rulerColor - let domainName = Bundle.main.bundleIdentifier - let previousDomainValue = domainName - .flatMap { defaults.persistentDomain(forName: $0)?["rulerColor"] } + let previousDomainValue = persistentPreferenceValue(forKey: "rulerColor") defer { prefs.rulerColor = previousColor - - if let domainName = domainName { - var domain = defaults.persistentDomain(forName: domainName) ?? [:] - if let previousDomainValue = previousDomainValue { - domain["rulerColor"] = previousDomainValue - } else { - domain.removeValue(forKey: "rulerColor") - } - defaults.setPersistentDomain(domain, forName: domainName) - } else { - if previousDomainValue == nil { - defaults.removeObject(forKey: "rulerColor") - } - } + restorePersistentPreferenceValue(previousDomainValue, forKey: "rulerColor") } try test() } private func withRestoredZeroCornerPreference(_ test: () throws -> Void) rethrows { - let defaults = UserDefaults.standard let previousZeroCorner = prefs.zeroCorner - let domainName = Bundle.main.bundleIdentifier - let previousDomainValue = domainName - .flatMap { defaults.persistentDomain(forName: $0)?["zeroCorner"] } - let previousStandardValue = defaults.object(forKey: "zeroCorner") + let previousDomainValue = persistentPreferenceValue(forKey: "zeroCorner") defer { prefs.zeroCorner = previousZeroCorner + restorePersistentPreferenceValue(previousDomainValue, forKey: "zeroCorner") + } - if let domainName = domainName { - var domain = defaults.persistentDomain(forName: domainName) ?? [:] - if let previousDomainValue = previousDomainValue { - domain["zeroCorner"] = previousDomainValue - } else { - domain.removeValue(forKey: "zeroCorner") - } - defaults.setPersistentDomain(domain, forName: domainName) + 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 previousGroupRulers = prefs.groupRulers + let previousRulerShadow = prefs.rulerShadow + let previousZeroCorner = prefs.zeroCorner + let previousDefaultHorizontalLength = prefs.defaultHorizontalLength + let previousDefaultVerticalLength = prefs.defaultVerticalLength + + defer { + prefs.unit = previousUnit + prefs.rulerColor = previousColor + prefs.foregroundOpacity = previousForegroundOpacity + prefs.backgroundOpacity = previousBackgroundOpacity + prefs.floatRulers = previousFloatRulers + prefs.groupRulers = previousGroupRulers + prefs.rulerShadow = previousRulerShadow + prefs.zeroCorner = previousZeroCorner + prefs.defaultHorizontalLength = previousDefaultHorizontalLength + prefs.defaultVerticalLength = previousDefaultVerticalLength + } + + 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 assertRulerSettingsColorPanelAttachesToSettingsPanel( + zeroCorner: ZeroCorner, + openingColorPanel: (RulerSettingsController, NSWindow) -> Void, + file: StaticString = #filePath, + line: UInt = #line + ) { + let controller = RulerController( + 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 { - if let previousStandardValue = previousStandardValue { - defaults.set(previousStandardValue, forKey: "zeroCorner") - } else { - defaults.removeObject(forKey: "zeroCorner") - } + 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 } - try test() + 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 final class ChildAttachingRulerWindow: RulerWindow { - private let childWindowToAttach: NSWindow + private func withRestoredRulerSetState(_ test: () throws -> Void) rethrows { + let previousState = persistentPreferenceValue(forKey: Prefs.rulerSetStateKey) + + defer { + restorePersistentPreferenceValue(previousState, forKey: Prefs.rulerSetStateKey) + } - init(ruler: Ruler, childWindow: NSWindow) { - self.childWindowToAttach = childWindow - super.init(ruler: ruler) + try test() } - override func makeKey() { - super.makeKey() - addChildWindow(childWindowToAttach, ordered: .below) + private func persistentPreferenceValue(forKey key: String) -> Any? { + let defaults = Prefs.userDefaults + guard let domainName = Prefs.userDefaultsPersistentDomainName else { + return defaults.object(forKey: key) + } + + return defaults.persistentDomain(forName: domainName)?[key] } -} -private final class TestableFlipAppDelegate: AppDelegate { - override func isRulerWindowShown(_ window: RulerWindow) -> Bool { - return true + private func restorePersistentPreferenceValue(_ value: Any?, forKey key: String) { + let defaults = Prefs.userDefaults + guard let domainName = Prefs.userDefaultsPersistentDomainName else { + if let value = value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + return + } + + var domain = defaults.persistentDomain(forName: domainName) ?? [:] + if let value = value { + domain[key] = value + } else { + domain.removeValue(forKey: key) + } + defaults.setPersistentDomain(domain, forName: domainName) } } @@ -2302,14 +3907,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 @@ -2320,7 +3925,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 @@ -2385,6 +4009,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/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/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png b/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png index a825cbb..0db648c 100644 Binary files a/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png and b/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png differ diff --git a/FreeRulerUITests/FreeRulerUITests.swift b/FreeRulerUITests/FreeRulerUITests.swift index 13257b6..4f91c00 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,30 +44,30 @@ 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)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) horizontalRuler.click() app.typeKey("v", modifierFlags: []) 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)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) app.typeKey("v", modifierFlags: []) XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) @@ -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)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } - 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)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) + + 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(waitForPreference("groupRulers", equals: true)) + 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(waitForPreference("groupRulers", equals: false)) + 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(waitForPreference("groupRulers", equals: true)) + 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,54 +201,57 @@ 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)) horizontalRuler.click() app.typeKey("f", modifierFlags: []) - XCTAssertTrue(waitForHotkeyBezel("Rulers unfloated")) + XCTAssertTrue(waitForHotkeyBezel("Ruler unfloated")) app.typeKey("f", modifierFlags: []) - XCTAssertTrue(waitForHotkeyBezel("Rulers floated")) + XCTAssertTrue(waitForHotkeyBezel("Ruler floated")) app.typeKey("s", modifierFlags: []) XCTAssertTrue(waitForHotkeyBezel("Shadow enabled")) @@ -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,15 +390,8 @@ 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)) @@ -407,13 +399,22 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } + private func isolateVerticalWing() { + verticalRuler.click() + app.typeKey("h", modifierFlags: []) + + XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) + XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) + } + 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)) + XCTAssertTrue(waitForPreference("groupRulers", equals: false)) } private func assertCursorSequence(on ruler: XCUIElement, label: String) { 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).