diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index f5ce1a4..ba5aed8 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -65,7 +65,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { lazy var rulerManager: RulerManager = { let manager = RulerManager() manager.onActiveControllerChanged = { [weak self] controller in - self?.groupedRulerController = controller + guard let self = self else { return } + + self.groupedRulerController = controller + + guard let settingsController = self.rulerSettingsController, + settingsController.window?.isVisible == true else { return } + + if let controller = controller { + settingsController.show(attachedTo: controller, sender: self) + } else { + settingsController.close() + } } return manager }() @@ -93,6 +104,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @IBOutlet weak var alignRulersMenuItem: NSMenuItem! var preferencesController: PreferencesController? = nil + var rulerSettingsController: RulerSettingsController? = nil private let hotkeyBezel = HotkeyBezel() private var uiTestSupport: UITestSupport? private var interfaceStyleObserver: NSObjectProtocol? @@ -271,14 +283,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { observers = [ prefs.observe(\Prefs.unit, options: .new) { prefs, changed in self.updateUnitMenu() - self.redrawRulers() + self.redrawDefaultBackedRulers() self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.floatRulers, options: .new) { prefs, changed in self.updateFloatRulersMenuItem() - for controller in self.rulerManager.controllers { - controller.updateIsFloatingPanel() - } self.legacyGroupedRulerController?.updateIsFloatingPanel() self.uiTestSupport?.writePreferencesState() }, @@ -289,17 +298,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { }, prefs.observe(\Prefs.rulerShadow, options: .new) { prefs, changed in self.updateRulerShadowMenuItem() - for controller in self.rulerManager.controllers { - controller.updateHasShadow() - } self.legacyGroupedRulerController?.updateHasShadow() self.uiTestSupport?.writePreferencesState() }, prefs.observe(\Prefs.rulerColor, options: .new) { prefs, changed in - self.redrawRulers() + self.redrawDefaultBackedRulers() }, prefs.observe(\Prefs.zeroCorner, options: .new) { prefs, changed in - self.redrawRulers() + self.redrawDefaultBackedRulers() }, ] } @@ -327,6 +333,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { legacyGroupedRulerController?.redrawForPreferenceChange() } + func redrawDefaultBackedRulers() { + for ruler in rulers { + ruler.rulerWindow.rule.redrawForPreferenceChange() + } + legacyGroupedRulerController?.redrawForPreferenceChange() + } + func updateFloatRulersMenuItem() { floatRulersMenuItem?.state = prefs.floatRulers ? .on : .off } @@ -662,6 +675,16 @@ 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() @@ -983,6 +1006,8 @@ extension AppDelegate: NSMenuItemValidation { switch menuItem.action { case #selector(newRuler(_:)): return true + case #selector(openRulerSettings(_:)): + return rulerManager.activeController != nil case #selector(closeKeyWindow(_:)): return NSApp.keyWindow?.isVisible == true case #selector(toggleGroupRulers(_:)): diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index 9813031..aa26542 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -98,6 +98,12 @@ + + + + + + diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index d85f3fc..6466054 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -267,8 +267,10 @@ final class GroupedRulerWindow: NSPanel { let verticalRule: VerticalRule private let groupedContentView: GroupedRulerContentView + private(set) var settings: RulerSettings - init(frame: NSRect) { + init(frame: NSRect, settings: RulerSettings = RulerSettings(defaults: prefs)) { + self.settings = settings horizontalRule = GroupedHorizontalRule( frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness) ) @@ -294,22 +296,22 @@ final class GroupedRulerWindow: NSPanel { defer: false ) - alphaValue = windowAlphaValue(prefs.foregroundOpacity) + alphaValue = windowAlphaValue(settings.foregroundOpacity) title = NSLocalizedString( "Ruler", comment: "Window title for a ruler window" ) identifier = NSUserInterfaceItemIdentifier("grouped-ruler-window") setAccessibilityIdentifier("grouped-ruler-window") - minSize = GroupedRulerLayout.minSize(zeroCorner: prefs.zeroCorner) - maxSize = GroupedRulerLayout.maxSize(zeroCorner: prefs.zeroCorner) + minSize = GroupedRulerLayout.minSize(zeroCorner: settings.zeroCorner) + maxSize = GroupedRulerLayout.maxSize(zeroCorner: settings.zeroCorner) isOpaque = false backgroundColor = .clear - isFloatingPanel = prefs.floatRulers + isFloatingPanel = settings.floatRulers hidesOnDeactivate = false isMovableByWindowBackground = true - hasShadow = prefs.rulerShadow + hasShadow = settings.rulerShadow horizontalRule.setAccessibilityElement(true) verticalRule.setAccessibilityElement(true) @@ -320,6 +322,7 @@ final class GroupedRulerWindow: NSPanel { groupedContentView.nextResponder = self contentView = groupedContentView + apply(settings: settings) updateLayoutForCurrentZeroCorner() } @@ -371,12 +374,23 @@ final class GroupedRulerWindow: NSPanel { func updateLayoutForCurrentZeroCorner() { updateSizeConstraintsForVisibleRules() updateGroupedContentFrame() - groupedContentView.zeroCorner = prefs.zeroCorner + groupedContentView.zeroCorner = settings.zeroCorner groupedContentView.needsLayout = true groupedContentView.layoutSubtreeIfNeeded() groupedContentView.needsDisplay = true } + func apply(settings: RulerSettings) { + self.settings = settings + alphaValue = windowAlphaValue(settings.foregroundOpacity) + isFloatingPanel = settings.floatRulers + hasShadow = settings.rulerShadow + horizontalRule.settingsOverride = settings + verticalRule.settingsOverride = settings + groupedContentView.color = RulerColors(customFill: settings.rulerColor) + updateLayoutForCurrentZeroCorner() + } + func redrawForPreferenceChange() { updateLayoutForCurrentZeroCorner() horizontalRule.redrawForPreferenceChange() @@ -417,7 +431,7 @@ final class GroupedRulerWindow: NSPanel { } func zeroPoint() -> NSPoint { - let geometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let geometry = ZeroCornerGeometry(zeroCorner: settings.zeroCorner) if isRuleVisible(.horizontal) { return geometry.zeroPoint( @@ -450,12 +464,12 @@ final class GroupedRulerWindow: NSPanel { private func updateSizeConstraintsForVisibleRules() { minSize = GroupedRulerLayout.minSize( - zeroCorner: prefs.zeroCorner, + zeroCorner: settings.zeroCorner, showsHorizontalRule: groupedContentView.showsHorizontalRule, showsVerticalRule: groupedContentView.showsVerticalRule ) maxSize = GroupedRulerLayout.maxSize( - zeroCorner: prefs.zeroCorner, + zeroCorner: settings.zeroCorner, showsHorizontalRule: groupedContentView.showsHorizontalRule, showsVerticalRule: groupedContentView.showsVerticalRule ) @@ -1076,6 +1090,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi private var keyListener: Any? private var mouseInteraction: RulerMouseInteractionState! private var isMouseTickDrawingEnabled = true + private let followsDefaultPreferences: Bool var isLeftMouseButtonPressed = { return NSEvent.pressedMouseButtons & 1 == 1 @@ -1085,12 +1100,12 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi didSet { updateIsFloatingPanel() if !preferencesWindowOpen { - opacity = prefs.foregroundOpacity + opacity = state.settings.foregroundOpacity } } } - var opacity = prefs.foregroundOpacity { + var opacity = 0 { didSet { groupedWindow.alphaValue = windowAlphaValue(opacity) } @@ -1107,20 +1122,27 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi ) ) - self.init(state: state) + self.init(state: state, followsDefaultPreferences: true) + } + + convenience init(state: RulerInstanceState) { + self.init(state: state, followsDefaultPreferences: false) } - init(state: RulerInstanceState) { + private init(state: RulerInstanceState, followsDefaultPreferences: Bool) { self.state = state + self.followsDefaultPreferences = followsDefaultPreferences let layout = state.layout.layout(zeroCorner: state.settings.zeroCorner) groupedWindow = GroupedRulerWindow( frame: layout.visibleFrame( showsHorizontalRule: state.visibility.showsHorizontal, showsVerticalRule: state.visibility.showsVertical - ) + ), + settings: state.settings ) super.init(window: groupedWindow) + opacity = state.settings.foregroundOpacity createObservers() subscribeToPrefs() @@ -1216,7 +1238,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi horizontalLength: horizontalLength, verticalLength: verticalLength, zeroPoint: point, - zeroCorner: prefs.zeroCorner + zeroCorner: state.settings.zeroCorner ) groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) @@ -1235,31 +1257,41 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi zeroCorner: zeroCorner ) - groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) state.settings.zeroCorner = zeroCorner + groupedWindow.apply(settings: state.settings) + groupedWindow.alphaValue = windowAlphaValue(opacity) + updateIsFloatingPanel() + updateHasShadow() + groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) captureStateFromWindow() } func foreground() { - opacity = prefs.foregroundOpacity + opacity = state.settings.foregroundOpacity } func background() { - opacity = prefs.backgroundOpacity + opacity = state.settings.backgroundOpacity } func updateIsFloatingPanel() { - groupedWindow.isFloatingPanel = preferencesWindowOpen ? false : prefs.floatRulers + groupedWindow.isFloatingPanel = preferencesWindowOpen ? false : state.settings.floatRulers } func updateHasShadow() { - groupedWindow.hasShadow = prefs.rulerShadow + groupedWindow.hasShadow = state.settings.rulerShadow } func redrawForPreferenceChange() { groupedWindow.redrawForPreferenceChange() } + func updateSettings(_ update: (inout RulerSettings) -> Void) { + update(&state.settings) + applyStateToWindow(display: true) + notifyStateChanged() + } + func drawMouseTick(at mouseLoc: NSPoint) { if groupedWindow.isRuleVisible(.horizontal) { groupedWindow.horizontalRule.drawMouseTick(at: mouseLoc) @@ -1284,6 +1316,10 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi private func applyStateToWindow(display: Bool) { let zeroCorner = state.settings.zeroCorner let layout = state.layout.layout(zeroCorner: zeroCorner) + groupedWindow.apply(settings: state.settings) + groupedWindow.alphaValue = windowAlphaValue(opacity) + updateIsFloatingPanel() + updateHasShadow() groupedWindow.setVisibleRules( horizontal: state.visibility.showsHorizontal, vertical: state.visibility.showsVertical @@ -1341,7 +1377,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi ) case (true, false): let horizontalFrame = groupedWindow.screenFrame(for: .horizontal) - let zeroPoint = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let zeroPoint = ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner) .zeroPoint(in: horizontalFrame, for: .horizontal) return ( horizontalFrame, @@ -1353,7 +1389,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi ) case (false, true): let verticalFrame = groupedWindow.screenFrame(for: .vertical) - let zeroPoint = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let zeroPoint = ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner) .zeroPoint(in: verticalFrame, for: .vertical) return ( hiddenRuleFrame( @@ -1373,7 +1409,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi zeroPoint: NSPoint, size: NSSize ) -> NSRect { - return ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).frame( + return ZeroCornerGeometry(zeroCorner: state.settings.zeroCorner).frame( for: orientation, zeroPoint: zeroPoint, size: size @@ -1497,6 +1533,11 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } private func subscribeToPrefs() { + guard followsDefaultPreferences else { + observers = [] + return + } + observers = [ prefs.observe(\Prefs.foregroundOpacity, options: .new) { [weak self] prefs, changed in self?.opacity = prefs.foregroundOpacity diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 0d6e296..7d32227 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -43,6 +43,60 @@ } } }, + "Close" : { + "comment" : "Button title for closing the active ruler settings panel", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sulje" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "閉じる" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭" + } + } + } + }, + "Color" : { + "comment" : "Label for the active ruler color setting", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color" + } + } + } + }, "Ruler" : { "comment" : "Window title for a ruler window", "extractionState" : "manual", @@ -55,6 +109,228 @@ } } }, + "Ruler Settings" : { + "comment" : "Window title for the active ruler settings panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruler Settings" + } + } + } + }, + "Ruler Color" : { + "comment" : "Label for the active ruler color setting", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linealfarbe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruler Color" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color de la regla" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viivaimen väri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "定規の色" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尺子颜色" + } + } + } + }, + "Foreground Opacity" : { + "comment" : "Label for the active ruler foreground opacity setting", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deckkraft im Vordergrund" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foreground Opacity" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opacidad del primer plano" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peittävyys edustalla" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "前景の不透明度" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前景不透明度" + } + } + } + }, + "Background Opacity" : { + "comment" : "Label for the active ruler background opacity setting", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deckkraft im Hintergrund" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Background Opacity" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opacidad del fondo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peittävyys taustalla" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "背景の不透明度" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "背景不透明度" + } + } + } + }, + "Float rulers above other applications" : { + "comment" : "Checkbox title for whether the active ruler floats above other apps", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lineale schweben über anderen Programmen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Float rulers above other applications" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reglas flotantes sobre otras aplicaciones" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kelluta viivaimia muiden sovellusten päällä" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "定規を常に手前に表示" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "让标尺窗口悬浮在其他应用程序之上" + } + } + } + }, + "Show ruler shadow" : { + "comment" : "Checkbox title for whether the active ruler draws a window shadow", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linealschatten anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show ruler shadow" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar sombra de la regla" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Näytä viivainten varjot" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "定規の影を表示" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示尺子阴影" + } + } + } + }, "Hide Horizontal Ruler" : { "comment" : "Menu item title to hide the horizontal ruler", "extractionState" : "manual", diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index feb9d3c..d7e8dff 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -3,6 +3,7 @@ import Cocoa private let colorPanelOpaqueConfigurationRetryDelays: [TimeInterval] = [0.1, 0.3] private let rulerColorPanelIdentifier = NSUserInterfaceItemIdentifier("ruler-color-panel") private let rulerColorPanelOpaqueAccessibilityValue = "ruler-color-panel-alpha-hidden" +private weak var activeRulerColorWell: RulerColorWell? func configureOpaqueColorPicking() { let colorPanel = NSColorPanel.shared @@ -57,6 +58,7 @@ class RulerColorWell: NSColorWell { return } + activeRulerColorWell = self colorPanel.animationBehavior = .none colorPanel.color = color colorPanel.setTarget(self) @@ -88,6 +90,30 @@ class RulerColorWell: NSColorWell { } +private func configureResetRulerColorButtonAppearance(_ button: NSButton, identifier: String) { + let resetRulerColorLabel = NSLocalizedString( + "Reset ruler color", + comment: "Tooltip and accessibility label for the button that restores the default ruler color" + ) + let symbol = NSImage( + systemSymbolName: "arrow.counterclockwise", + accessibilityDescription: resetRulerColorLabel + )?.withSymbolConfiguration( + NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) + ) ?? NSImage() + symbol.isTemplate = true + + button.image = symbol + button.isBordered = false + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.contentTintColor = .secondaryLabelColor + button.toolTip = resetRulerColorLabel + button.identifier = NSUserInterfaceItemIdentifier(identifier) + button.setAccessibilityIdentifier(identifier) + button.setAccessibilityLabel(resetRulerColorLabel) +} + class PreferencesController: NSWindowController, NSWindowDelegate, NotificationPoster { var observers: [NSKeyValueObservation] = [] @@ -243,27 +269,7 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP } private func configureResetRulerColorButton() { - let resetRulerColorLabel = NSLocalizedString( - "Reset ruler color", - comment: "Tooltip and accessibility label for the button that restores the default ruler color" - ) - let symbol = NSImage( - systemSymbolName: "arrow.counterclockwise", - accessibilityDescription: resetRulerColorLabel - )?.withSymbolConfiguration( - NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) - ) ?? NSImage() - symbol.isTemplate = true - - resetRulerColorButton.image = symbol - resetRulerColorButton.isBordered = false - resetRulerColorButton.imagePosition = .imageOnly - resetRulerColorButton.imageScaling = .scaleProportionallyDown - resetRulerColorButton.contentTintColor = .secondaryLabelColor - resetRulerColorButton.toolTip = resetRulerColorLabel - resetRulerColorButton.identifier = NSUserInterfaceItemIdentifier("reset-ruler-color-button") - resetRulerColorButton.setAccessibilityIdentifier("reset-ruler-color-button") - resetRulerColorButton.setAccessibilityLabel(resetRulerColorLabel) + configureResetRulerColorButtonAppearance(resetRulerColorButton, identifier: "reset-ruler-color-button") } private func subscribeToColorPanel() { @@ -279,14 +285,503 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP private func updateRulerColorFromColorPanel(_ notification: Notification) { guard window?.isVisible == true, let colorPanel = notification.object as? NSColorPanel, - colorPanel.isVisible else { return } + colorPanel.isVisible, + activeRulerColorWell === rulerColorWell else { return } prefs.rulerColor = colorPanel.color } } +final class RulerSettingsController: NSWindowController, NSWindowDelegate { + + private weak var rulerController: GroupedRulerController? + private var colorPanelObserver: NSObjectProtocol? + + let rulerColorWell: RulerColorWell + let resetRulerColorButton: NSButton + let foregroundOpacitySlider: NSSlider + let backgroundOpacitySlider: NSSlider + let foregroundOpacityLabel: NSTextField + let backgroundOpacityLabel: NSTextField + let floatRulersCheckbox: NSButton + let rulerShadowCheckbox: NSButton + let closeButton: NSButton + + var currentRulerController: GroupedRulerController? { + return rulerController + } + + init(rulerController: GroupedRulerController) { + self.rulerController = rulerController + + rulerColorWell = RulerColorWell(frame: NSRect(x: 0, y: 0, width: 64, height: 30)) + resetRulerColorButton = NSButton(frame: .zero) + foregroundOpacitySlider = RulerSettingsController.makeOpacitySlider() + backgroundOpacitySlider = RulerSettingsController.makeOpacitySlider() + foregroundOpacityLabel = RulerSettingsController.makeValueLabel() + backgroundOpacityLabel = RulerSettingsController.makeValueLabel() + floatRulersCheckbox = NSButton( + checkboxWithTitle: NSLocalizedString( + "Float rulers above other applications", + comment: "Checkbox title for whether the active ruler floats above other apps" + ), + target: nil, + action: nil + ) + rulerShadowCheckbox = NSButton( + checkboxWithTitle: NSLocalizedString( + "Show ruler shadow", + comment: "Checkbox title for whether the active ruler draws a window shadow" + ), + target: nil, + action: nil + ) + closeButton = NSButton( + title: NSLocalizedString( + "Close", + comment: "Button title for closing the active ruler settings panel" + ), + target: nil, + action: nil + ) + + let window = RulerSettingsController.makeWindow( + rulerColorWell: rulerColorWell, + resetRulerColorButton: resetRulerColorButton, + foregroundOpacitySlider: foregroundOpacitySlider, + backgroundOpacitySlider: backgroundOpacitySlider, + foregroundOpacityLabel: foregroundOpacityLabel, + backgroundOpacityLabel: backgroundOpacityLabel, + floatRulersCheckbox: floatRulersCheckbox, + rulerShadowCheckbox: rulerShadowCheckbox, + closeButton: closeButton + ) + + super.init(window: window) + + window.delegate = self + window.initialFirstResponder = rulerColorWell + configureControls() + subscribeToColorPanel() + updateView() + } + + required init?(coder: NSCoder) { + nil + } + + deinit { + if let colorPanelObserver = colorPanelObserver { + NotificationCenter.default.removeObserver(colorPanelObserver) + } + } + + override func showWindow(_ sender: Any?) { + detachWindowIfNeeded() + configureOpaqueColorPicking() + updateView() + window?.makeKeyAndOrderFront(sender) + window?.makeFirstResponder(rulerColorWell) + window?.center() + } + + func show(attachedTo controller: GroupedRulerController, sender: Any?) { + updateRulerController(controller) + guard let settingsWindow = window else { return } + + configureOpaqueColorPicking() + + if settingsWindow.parent === controller.groupedWindow { + position(settingsWindow, attachedTo: controller) + settingsWindow.orderFront(sender) + settingsWindow.makeKey() + settingsWindow.makeFirstResponder(rulerColorWell) + return + } + + detachWindowIfNeeded() + + guard controller.groupedWindow.isVisible else { + showWindow(sender) + return + } + + if settingsWindow.isVisible { + settingsWindow.orderOut(sender) + } + + position(settingsWindow, attachedTo: controller) + controller.groupedWindow.addChildWindow(settingsWindow, ordered: .above) + settingsWindow.orderFront(sender) + settingsWindow.makeKey() + settingsWindow.makeFirstResponder(rulerColorWell) + } + + override func close() { + detachWindowIfNeeded() + super.close() + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + true + } + + func windowWillClose(_ notification: Notification) { + detachWindowIfNeeded() + closeSheetColorControls() + } + + func updateRulerController(_ controller: GroupedRulerController) { + rulerController = controller + 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) + } + + @objc func closeRulerSettings(_ sender: Any) { + close() + } + + func updateView() { + let currentColor = rulerController?.state.settings.rulerColor ?? Prefs.defaultRulerFillColor + let currentSettings = rulerController?.state.settings + let hasRuler = rulerController != nil + + rulerColorWell.supportsAlpha = false + rulerColorWell.color = currentColor + rulerColorWell.isEnabled = hasRuler + resetRulerColorButton.isEnabled = hasRuler + resetRulerColorButton.isHidden = Prefs.colorsMatch(currentColor, Prefs.defaultRulerFillColor) + foregroundOpacitySlider.isEnabled = hasRuler + backgroundOpacitySlider.isEnabled = hasRuler + floatRulersCheckbox.isEnabled = hasRuler + rulerShadowCheckbox.isEnabled = hasRuler + + foregroundOpacitySlider.integerValue = currentSettings?.foregroundOpacity ?? 90 + backgroundOpacitySlider.integerValue = currentSettings?.backgroundOpacity ?? 50 + foregroundOpacityLabel.stringValue = "\(foregroundOpacitySlider.integerValue)%" + backgroundOpacityLabel.stringValue = "\(backgroundOpacitySlider.integerValue)%" + floatRulersCheckbox.state = currentSettings?.floatRulers == true ? .on : .off + rulerShadowCheckbox.state = currentSettings?.rulerShadow == true ? .on : .off + } + + private static func makeWindow( + rulerColorWell: RulerColorWell, + resetRulerColorButton: NSButton, + foregroundOpacitySlider: NSSlider, + backgroundOpacitySlider: NSSlider, + foregroundOpacityLabel: NSTextField, + backgroundOpacityLabel: NSTextField, + floatRulersCheckbox: NSButton, + rulerShadowCheckbox: NSButton, + closeButton: NSButton + ) -> NSPanel { + let contentView = NSView() + let colorLabel = makeLabel( + NSLocalizedString( + "Ruler Color", + comment: "Label for the active ruler color setting" + ) + ) + let foregroundLabel = makeLabel( + NSLocalizedString( + "Foreground Opacity", + comment: "Label for the active ruler foreground opacity setting" + ) + ) + let backgroundLabel = makeLabel( + NSLocalizedString( + "Background Opacity", + comment: "Label for the active ruler background opacity setting" + ) + ) + let colorRow = NSStackView(views: [colorLabel, resetRulerColorButton, rulerColorWell]) + let foregroundHeaderRow = NSStackView(views: [foregroundLabel, foregroundOpacityLabel]) + let backgroundHeaderRow = NSStackView(views: [backgroundLabel, backgroundOpacityLabel]) + let closeRow = NSView() + closeRow.addSubview(closeButton) + let contentStack = NSStackView(views: [ + colorRow, + foregroundHeaderRow, + foregroundOpacitySlider, + backgroundHeaderRow, + backgroundOpacitySlider, + floatRulersCheckbox, + rulerShadowCheckbox, + closeRow, + ]) + + for row in [colorRow, foregroundHeaderRow, backgroundHeaderRow] { + row.orientation = .horizontal + row.alignment = .centerY + row.distribution = .fill + row.spacing = 10 + row.translatesAutoresizingMaskIntoConstraints = false + } + + contentStack.orientation = .vertical + contentStack.alignment = .leading + contentStack.distribution = .fill + contentStack.spacing = 8 + contentStack.translatesAutoresizingMaskIntoConstraints = false + + colorLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + foregroundLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + backgroundLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + foregroundOpacityLabel.setContentHuggingPriority(.required, for: .horizontal) + backgroundOpacityLabel.setContentHuggingPriority(.required, for: .horizontal) + rulerColorWell.translatesAutoresizingMaskIntoConstraints = false + resetRulerColorButton.translatesAutoresizingMaskIntoConstraints = false + foregroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false + backgroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false + floatRulersCheckbox.translatesAutoresizingMaskIntoConstraints = false + rulerShadowCheckbox.translatesAutoresizingMaskIntoConstraints = false + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeRow.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(contentStack) + NSLayoutConstraint.activate([ + colorRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + foregroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + backgroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + closeRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + rulerColorWell.widthAnchor.constraint(equalToConstant: 60), + rulerColorWell.heightAnchor.constraint(equalToConstant: 24), + resetRulerColorButton.widthAnchor.constraint(equalToConstant: 28), + resetRulerColorButton.heightAnchor.constraint(equalToConstant: 28), + foregroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + backgroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), + floatRulersCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), + rulerShadowCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), + closeButton.trailingAnchor.constraint(equalTo: closeRow.trailingAnchor), + closeButton.topAnchor.constraint(equalTo: closeRow.topAnchor), + closeButton.bottomAnchor.constraint(equalTo: closeRow.bottomAnchor), + closeButton.leadingAnchor.constraint(greaterThanOrEqualTo: closeRow.leadingAnchor), + contentStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + contentStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + contentStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 18), + contentStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18), + ]) + + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 315, height: 270), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = NSLocalizedString( + "Ruler Settings", + comment: "Window title for the active ruler settings panel" + ) + window.contentView = contentView + window.identifier = NSUserInterfaceItemIdentifier("ruler-settings-window") + window.setAccessibilityIdentifier("ruler-settings-window") + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + return window + } + + private static func makeLabel(_ title: String) -> NSTextField { + let label = NSTextField(labelWithString: title) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return label + } + + private static func makeValueLabel() -> NSTextField { + let label = NSTextField(labelWithString: "") + label.alignment = .right + return label + } + + private static func makeOpacitySlider() -> NSSlider { + let slider = NSSlider(frame: .zero) + slider.minValue = 5 + slider.maxValue = 100 + slider.doubleValue = 50 + slider.numberOfTickMarks = 20 + slider.allowsTickMarkValuesOnly = true + slider.tickMarkPosition = .below + slider.isContinuous = true + return slider + } + + private func configureControls() { + rulerColorWell.isContinuous = true + rulerColorWell.supportsAlpha = false + rulerColorWell.identifier = NSUserInterfaceItemIdentifier("ruler-settings-color-well") + rulerColorWell.setAccessibilityIdentifier("ruler-settings-color-well") + rulerColorWell.target = self + rulerColorWell.action = #selector(setRulerColor(_:)) + + resetRulerColorButton.target = self + resetRulerColorButton.action = #selector(resetRulerColor(_:)) + configureResetRulerColorButtonAppearance( + resetRulerColorButton, + identifier: "reset-ruler-settings-color-button" + ) + + foregroundOpacitySlider.target = self + foregroundOpacitySlider.action = #selector(setForegroundOpacity(_:)) + foregroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier("ruler-settings-foreground-opacity-slider") + foregroundOpacitySlider.setAccessibilityIdentifier("ruler-settings-foreground-opacity-slider") + foregroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier("ruler-settings-foreground-opacity-label") + foregroundOpacityLabel.setAccessibilityIdentifier("ruler-settings-foreground-opacity-label") + + backgroundOpacitySlider.target = self + backgroundOpacitySlider.action = #selector(setBackgroundOpacity(_:)) + backgroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier("ruler-settings-background-opacity-slider") + backgroundOpacitySlider.setAccessibilityIdentifier("ruler-settings-background-opacity-slider") + backgroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier("ruler-settings-background-opacity-label") + backgroundOpacityLabel.setAccessibilityIdentifier("ruler-settings-background-opacity-label") + + floatRulersCheckbox.target = self + floatRulersCheckbox.action = #selector(setFloatRulers(_:)) + floatRulersCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-float-rulers-checkbox") + floatRulersCheckbox.setAccessibilityIdentifier("ruler-settings-float-rulers-checkbox") + + rulerShadowCheckbox.target = self + rulerShadowCheckbox.action = #selector(setRulerShadow(_:)) + rulerShadowCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-ruler-shadow-checkbox") + rulerShadowCheckbox.setAccessibilityIdentifier("ruler-settings-ruler-shadow-checkbox") + + closeButton.target = self + closeButton.action = #selector(closeRulerSettings(_:)) + closeButton.identifier = NSUserInterfaceItemIdentifier("ruler-settings-close-button") + closeButton.setAccessibilityIdentifier("ruler-settings-close-button") + } + + private func applyRulerColor(_ color: NSColor) { + applySettings { settings in + settings.setRulerColor(color) + } + updateView() + } + + private func applySettings(_ update: (inout RulerSettings) -> Void) { + rulerController?.updateSettings(update) + } + + private func subscribeToColorPanel() { + colorPanelObserver = NotificationCenter.default.addObserver( + forName: NSColorPanel.colorDidChangeNotification, + object: NSColorPanel.shared, + queue: .main + ) { [weak self] notification in + self?.updateRulerColorFromColorPanel(notification) + } + } + + private func updateRulerColorFromColorPanel(_ notification: Notification) { + guard window?.isVisible == true, + let colorPanel = notification.object as? NSColorPanel, + colorPanel.isVisible, + activeRulerColorWell === rulerColorWell else { return } + + applyRulerColor(colorPanel.color) + } + + private func position(_ settingsWindow: NSWindow, attachedTo controller: GroupedRulerController) { + let parentFrame = controller.groupedWindow.frame + let settingsSize = settingsWindow.frame.size + let margin: CGFloat = 12 + let frame: NSRect + + if controller.state.visibility.showsHorizontal { + let horizontalFrame = controller.groupedWindow.screenFrame(for: .horizontal) + let x = clamp( + horizontalFrame.midX - settingsSize.width / 2, + lower: parentFrame.minX + margin, + upper: parentFrame.maxX - settingsSize.width - margin + ) + let belowY = horizontalFrame.minY - settingsSize.height - margin + let aboveY = horizontalFrame.maxY + margin + let y = belowY >= parentFrame.minY + margin + ? belowY + : min(aboveY, parentFrame.maxY - settingsSize.height - margin) + frame = NSRect(origin: NSPoint(x: x, y: y), size: settingsSize) + } else { + let verticalFrame = controller.groupedWindow.screenFrame(for: .vertical) + let y = clamp( + verticalFrame.midY - settingsSize.height / 2, + lower: parentFrame.minY + margin, + upper: parentFrame.maxY - settingsSize.height - margin + ) + let rightX = verticalFrame.maxX + margin + let leftX = verticalFrame.minX - settingsSize.width - margin + let x = rightX + settingsSize.width <= parentFrame.maxX - margin + ? rightX + : max(leftX, parentFrame.minX + margin) + frame = NSRect(origin: NSPoint(x: x, y: y), size: settingsSize) + } + + settingsWindow.setFrame(frame, display: true) + } + + private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { + guard lower <= upper else { return lower } + + return min(max(value, lower), upper) + } + + private func detachWindowIfNeeded() { + guard let settingsWindow = window else { return } + + if let sheetParent = settingsWindow.sheetParent { + sheetParent.endSheet(settingsWindow) + } + if let parentWindow = settingsWindow.parent { + parentWindow.removeChildWindow(settingsWindow) + } + } + + private func closeSheetColorControls() { + if let foregroundOpacity = rulerController?.state.settings.foregroundOpacity { + rulerController?.opacity = foregroundOpacity + } + rulerColorWell.deactivate() + closeRulerColorPanel() + } +} + func closeRulerColorPanel() { + activeRulerColorWell = nil let colorPanel = NSColorPanel.shared colorPanel.animationBehavior = .none colorPanel.setTarget(nil) diff --git a/Free Ruler/RuleView.swift b/Free Ruler/RuleView.swift index ea1b1ee..0651ab3 100644 --- a/Free Ruler/RuleView.swift +++ b/Free Ruler/RuleView.swift @@ -506,6 +506,13 @@ class RuleView: NSView { } } + var settingsOverride: RulerSettings? { + didSet { + color = RulerColors(customFill: settingsOverride?.rulerColor) + redrawForPreferenceChange() + } + } + var screen: NSScreen? { guard let window = window else { return nil @@ -514,11 +521,11 @@ class RuleView: NSView { } var unit: Unit { - prefs.unit + return settingsOverride?.unit ?? prefs.unit } var zeroCorner: ZeroCorner { - prefs.zeroCorner + return settingsOverride?.zeroCorner ?? prefs.zeroCorner } var resizeHandleExclusionFrame: NSRect? { diff --git a/Free Ruler/Ruler.swift b/Free Ruler/Ruler.swift index 82b8f4d..914ad86 100644 --- a/Free Ruler/Ruler.swift +++ b/Free Ruler/Ruler.swift @@ -157,6 +157,10 @@ struct RulerSettings: Equatable, Codable { try container.encode(zeroCorner.rawValue, forKey: .zeroCorner) } + mutating func setRulerColor(_ color: NSColor) { + rulerColor = RulerSettings.normalizedColor(color) + } + private static func normalizedColor(_ color: NSColor) -> NSColor { guard let rgbColor = color.usingColorSpace(.deviceRGB) else { return Prefs.defaultRulerFillColor diff --git a/Free Ruler/de.lproj/MainMenu.strings b/Free Ruler/de.lproj/MainMenu.strings index 397ec7e..2420849 100644 --- a/Free Ruler/de.lproj/MainMenu.strings +++ b/Free Ruler/de.lproj/MainMenu.strings @@ -151,3 +151,6 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "Nächste Einheit auswählen"; + +/* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ +"rSt-Tg-232.title" = "Lineal-Einstellungen…"; diff --git a/Free Ruler/es.lproj/MainMenu.strings b/Free Ruler/es.lproj/MainMenu.strings index 1be4959..dd331e8 100644 --- a/Free Ruler/es.lproj/MainMenu.strings +++ b/Free Ruler/es.lproj/MainMenu.strings @@ -148,3 +148,6 @@ /* Class = "NSMenuItem"; title = "Copy"; ObjectID = "x3v-GG-iWU"; */ "x3v-GG-iWU.title" = "Copiar"; + +/* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ +"rSt-Tg-232.title" = "Ajustes de regla…"; diff --git a/Free Ruler/fi.lproj/MainMenu.strings b/Free Ruler/fi.lproj/MainMenu.strings index d81c26d..d88d824 100644 --- a/Free Ruler/fi.lproj/MainMenu.strings +++ b/Free Ruler/fi.lproj/MainMenu.strings @@ -151,3 +151,6 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "Vaihda yksikköä"; + +/* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ +"rSt-Tg-232.title" = "Viivaimen asetukset…"; diff --git a/Free Ruler/ja.lproj/MainMenu.strings b/Free Ruler/ja.lproj/MainMenu.strings index 01e477a..f851bc9 100644 --- a/Free Ruler/ja.lproj/MainMenu.strings +++ b/Free Ruler/ja.lproj/MainMenu.strings @@ -148,3 +148,6 @@ /* Class = "NSMenu"; title = "Unit"; ObjectID = "z2p-dA-zcS"; */ "z2p-dA-zcS.title" = "単位"; + +/* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ +"rSt-Tg-232.title" = "定規設定…"; diff --git a/Free Ruler/zh-hans.lproj/MainMenu.strings b/Free Ruler/zh-hans.lproj/MainMenu.strings index 3b14cd0..9b679a4 100644 --- a/Free Ruler/zh-hans.lproj/MainMenu.strings +++ b/Free Ruler/zh-hans.lproj/MainMenu.strings @@ -151,3 +151,6 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "循环单位"; + +/* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ +"rSt-Tg-232.title" = "尺子设置…"; diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index afcc7bb..a1af795 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -210,6 +210,307 @@ final class RulerCoreTests: XCTestCase { XCTAssertTrue(manager.controllers[1].groupedWindow.isRuleVisible(.vertical)) } + func testManagedGroupedRulerAppliesPerRulerSettingsToWindowAndRules() { + let color = NSColor(deviceRed: 0.1, green: 0.4, blue: 0.8, alpha: 1) + let settings = RulerSettings( + unit: .inches, + rulerColor: color, + foregroundOpacity: 73, + backgroundOpacity: 31, + floatRulers: false, + rulerShadow: true, + zeroCorner: .bottomRight + ) + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: settings, + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 200, y: 300), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + defer { + controller.hide() + } + + XCTAssertEqual(controller.groupedWindow.horizontalRule.unit, .inches) + XCTAssertEqual(controller.groupedWindow.verticalRule.unit, .inches) + XCTAssertEqual(controller.groupedWindow.horizontalRule.zeroCorner, .bottomRight) + XCTAssertEqual(controller.groupedWindow.verticalRule.zeroCorner, .bottomRight) + assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: color) + assertColor(controller.groupedWindow.verticalRule.color.fill, equals: color) + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.73, accuracy: 0.0001) + XCTAssertFalse(controller.groupedWindow.isFloatingPanel) + XCTAssertTrue(controller.groupedWindow.hasShadow) + + controller.background() + + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.31, accuracy: 0.0001) + } + + func testManagedGroupedRulerIgnoresDefaultPreferenceChanges() { + withRestoredRulerPreferences { + let color = NSColor(deviceRed: 0.2, green: 0.3, blue: 0.7, alpha: 1) + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + unit: .inches, + rulerColor: color, + foregroundOpacity: 64, + backgroundOpacity: 28, + floatRulers: false, + rulerShadow: false, + zeroCorner: .bottomLeft + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + defer { + controller.hide() + } + + prefs.unit = .millimeters + prefs.rulerColor = NSColor(deviceRed: 0.9, green: 0.1, blue: 0.2, alpha: 1) + prefs.foregroundOpacity = 12 + prefs.backgroundOpacity = 9 + prefs.floatRulers = true + prefs.rulerShadow = true + prefs.zeroCorner = .topRight + + XCTAssertEqual(controller.state.settings.unit, .inches) + XCTAssertEqual(controller.groupedWindow.horizontalRule.unit, .inches) + XCTAssertEqual(controller.groupedWindow.horizontalRule.zeroCorner, .bottomLeft) + assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: color) + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.64, accuracy: 0.0001) + XCTAssertFalse(controller.groupedWindow.isFloatingPanel) + XCTAssertFalse(controller.groupedWindow.hasShadow) + } + } + + func testRulerSettingsControllerUpdatesRulerSettingsWithoutChangingDefaults() { + withRestoredRulerPreferences { + let defaultColor = NSColor(deviceRed: 0.15, green: 0.25, blue: 0.35, alpha: 1) + prefs.rulerColor = defaultColor + prefs.foregroundOpacity = 90 + prefs.backgroundOpacity = 50 + prefs.floatRulers = true + prefs.rulerShadow = false + + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + rulerColor: NSColor(deviceRed: 0.4, green: 0.5, blue: 0.6, alpha: 1), + 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.rulerColorWell.color = NSColor( + deviceRed: 0.8, + green: 0.2, + blue: 0.4, + alpha: 0.35 + ) + settingsController.setRulerColor(settingsController.rulerColorWell) + + let normalizedColor = NSColor(deviceRed: 0.8, green: 0.2, blue: 0.4, alpha: 1) + assertColor(controller.state.settings.rulerColor, equals: normalizedColor) + assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: normalizedColor) + assertColor(controller.groupedWindow.verticalRule.color.fill, equals: normalizedColor) + assertColor(settingsController.rulerColorWell.color, equals: normalizedColor) + assertColor(prefs.rulerColor, equals: defaultColor) + XCTAssertFalse(settingsController.resetRulerColorButton.isHidden) + + settingsController.foregroundOpacitySlider.integerValue = 65 + settingsController.setForegroundOpacity(settingsController.foregroundOpacitySlider) + + XCTAssertEqual(controller.state.settings.foregroundOpacity, 65) + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.65, accuracy: 0.0001) + XCTAssertEqual(settingsController.foregroundOpacityLabel.stringValue, "65%") + XCTAssertEqual(prefs.foregroundOpacity, 90) + + settingsController.backgroundOpacitySlider.integerValue = 35 + settingsController.setBackgroundOpacity(settingsController.backgroundOpacitySlider) + + XCTAssertEqual(controller.state.settings.backgroundOpacity, 35) + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.35, accuracy: 0.0001) + XCTAssertEqual(settingsController.backgroundOpacityLabel.stringValue, "35%") + XCTAssertEqual(prefs.backgroundOpacity, 50) + + settingsController.floatRulersCheckbox.state = .on + settingsController.setFloatRulers(settingsController.floatRulersCheckbox) + + XCTAssertTrue(controller.state.settings.floatRulers) + XCTAssertTrue(controller.groupedWindow.isFloatingPanel) + XCTAssertTrue(settingsController.floatRulersCheckbox.state == .on) + XCTAssertTrue(prefs.floatRulers) + + settingsController.rulerShadowCheckbox.state = .on + settingsController.setRulerShadow(settingsController.rulerShadowCheckbox) + + XCTAssertTrue(controller.state.settings.rulerShadow) + XCTAssertTrue(controller.groupedWindow.hasShadow) + XCTAssertTrue(settingsController.rulerShadowCheckbox.state == .on) + XCTAssertFalse(prefs.rulerShadow) + + settingsController.resetRulerColor(settingsController.resetRulerColorButton) + + assertColor(controller.state.settings.rulerColor, equals: Prefs.defaultRulerFillColor) + assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: Prefs.defaultRulerFillColor) + assertColor(prefs.rulerColor, equals: defaultColor) + XCTAssertTrue(settingsController.resetRulerColorButton.isHidden) + } + } + + func testRulerSettingsControllerPresentsAsAttachedSheetOnRulerWindow() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + controller.show() + settingsController.show(attachedTo: controller, sender: self) + + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + XCTAssertTrue(controller.groupedWindow.childWindows?.contains(settingsWindow) ?? false) + XCTAssertNil(settingsWindow.sheetParent) + } + + func testRulerSettingsControllerRestoresForegroundOpacityWhenClosingSheet() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + foregroundOpacity: 80, + backgroundOpacity: 45 + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + controller.show() + settingsController.show(attachedTo: controller, sender: self) + settingsController.backgroundOpacitySlider.integerValue = 35 + settingsController.setBackgroundOpacity(settingsController.backgroundOpacitySlider) + + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.35, accuracy: 0.0001) + + settingsController.close() + + XCTAssertEqual(controller.groupedWindow.alphaValue, 0.8, accuracy: 0.0001) + } + + func testRulerSettingsControllerCloseButtonClosesAttachedSheet() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + controller.show() + settingsController.show(attachedTo: controller, sender: self) + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + + settingsController.closeButton.performClick(self) + + XCTAssertFalse(controller.groupedWindow.childWindows?.contains(settingsWindow) ?? false) + XCTAssertFalse(settingsWindow.isVisible) + } + + func testRulerManagerCopiesUpdatedDefaultsOnlyForNewRulers() { + withRestoredRulerPreferences { + prefs.unit = .pixels + prefs.rulerColor = NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) + prefs.zeroCorner = .topLeft + let manager = RulerManager() + defer { + for controller in manager.controllers { + controller.hide() + } + } + + let existing = manager.createRuler( + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + + prefs.unit = .millimeters + prefs.rulerColor = NSColor(deviceRed: 0.8, green: 0.7, blue: 0.2, alpha: 1) + prefs.zeroCorner = .topRight + let createdAfterDefaultsChange = manager.createRuler( + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + + XCTAssertEqual(existing.state.settings.unit, .pixels) + XCTAssertEqual(existing.groupedWindow.horizontalRule.unit, .pixels) + XCTAssertEqual(existing.groupedWindow.horizontalRule.zeroCorner, .topLeft) + assertColor( + existing.groupedWindow.horizontalRule.color.fill, + equals: NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) + ) + XCTAssertEqual(createdAfterDefaultsChange.state.settings.unit, .millimeters) + XCTAssertEqual(createdAfterDefaultsChange.groupedWindow.horizontalRule.unit, .millimeters) + XCTAssertEqual(createdAfterDefaultsChange.groupedWindow.horizontalRule.zeroCorner, .topRight) + assertColor( + createdAfterDefaultsChange.groupedWindow.horizontalRule.color.fill, + equals: NSColor(deviceRed: 0.8, green: 0.7, blue: 0.2, alpha: 1) + ) + } + } + func testZeroCornerRawValuesPreservePersistedOrder() { XCTAssertEqual(ZeroCorner.topLeft.rawValue, 0) XCTAssertEqual(ZeroCorner.topRight.rawValue, 1)