From 0b0b9f6e8d14db947afe52d766f273574a6b1fbe Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 01:43:21 -0400 Subject: [PATCH 01/10] Polish multiple ruler integration --- Free Ruler/Base.lproj/MainMenu.xib | 6 +++ .../Resources/English.lproj/FreeRuler.html | 45 ++++++++++--------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index aa26542..07161ce 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -79,6 +79,12 @@ + + + + + + diff --git a/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html b/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html index 3ad7ef1..ae6c69c 100644 --- a/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html +++ b/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html @@ -11,7 +11,7 @@ } + content="free ruler, rulers, shortcuts, keyboard, multiple rulers, float, shadow, origin, units, color, preferences" /> @@ -34,52 +34,57 @@

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

    -
  • Show horizontal and vertical rulers in pixels, millimeters, or inches.
  • -
  • Resize rulers, move them independently, or keep them grouped together.
  • -
  • Customize the ruler color in Preferences.
  • -
  • Float rulers above other windows and show or hide ruler shadows.
  • -
  • Align rulers at the mouse location, reset them to default positions, or flip their origins.
  • -
  • Show, hide, and reopen rulers from the menu or keyboard.
  • +
  • Create multiple independent rulers, each with horizontal, vertical, or L-shaped wings.
  • +
  • Use pixels, millimeters, or inches independently for each ruler.
  • +
  • Resize, move, align, reset, and flip the active ruler without changing the others.
  • +
  • Float individual rulers above other windows and show or hide their shadows.
  • +
  • Set defaults for new rulers in Preferences.
  • +
  • Restore your ruler set, including positions and visible wings, when Free Ruler opens again.
From d67031c17047891ca333461500b25e057508115f Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 17 Jun 2026 22:31:03 -0400 Subject: [PATCH 02/10] Localize new ruler menu item --- Free Ruler/de.lproj/MainMenu.strings | 3 +++ Free Ruler/es.lproj/MainMenu.strings | 3 +++ Free Ruler/fi.lproj/MainMenu.strings | 3 +++ Free Ruler/ja.lproj/MainMenu.strings | 3 +++ Free Ruler/zh-hans.lproj/MainMenu.strings | 3 +++ 5 files changed, 15 insertions(+) diff --git a/Free Ruler/de.lproj/MainMenu.strings b/Free Ruler/de.lproj/MainMenu.strings index 2420849..8e8010d 100644 --- a/Free Ruler/de.lproj/MainMenu.strings +++ b/Free Ruler/de.lproj/MainMenu.strings @@ -152,5 +152,8 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "Nächste Einheit auswählen"; +/* Class = "NSMenuItem"; title = "New Ruler"; ObjectID = "rWt-KM-qSf"; */ +"rWt-KM-qSf.title" = "Neues Lineal"; + /* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ "rSt-Tg-232.title" = "Lineal-Einstellungen…"; diff --git a/Free Ruler/es.lproj/MainMenu.strings b/Free Ruler/es.lproj/MainMenu.strings index dd331e8..6ff7d35 100644 --- a/Free Ruler/es.lproj/MainMenu.strings +++ b/Free Ruler/es.lproj/MainMenu.strings @@ -149,5 +149,8 @@ /* Class = "NSMenuItem"; title = "Copy"; ObjectID = "x3v-GG-iWU"; */ "x3v-GG-iWU.title" = "Copiar"; +/* Class = "NSMenuItem"; title = "New Ruler"; ObjectID = "rWt-KM-qSf"; */ +"rWt-KM-qSf.title" = "Nueva regla"; + /* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ "rSt-Tg-232.title" = "Ajustes de regla…"; diff --git a/Free Ruler/fi.lproj/MainMenu.strings b/Free Ruler/fi.lproj/MainMenu.strings index d88d824..36741dd 100644 --- a/Free Ruler/fi.lproj/MainMenu.strings +++ b/Free Ruler/fi.lproj/MainMenu.strings @@ -152,5 +152,8 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "Vaihda yksikköä"; +/* Class = "NSMenuItem"; title = "New Ruler"; ObjectID = "rWt-KM-qSf"; */ +"rWt-KM-qSf.title" = "Uusi viivain"; + /* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ "rSt-Tg-232.title" = "Viivaimen asetukset…"; diff --git a/Free Ruler/ja.lproj/MainMenu.strings b/Free Ruler/ja.lproj/MainMenu.strings index f851bc9..653914e 100644 --- a/Free Ruler/ja.lproj/MainMenu.strings +++ b/Free Ruler/ja.lproj/MainMenu.strings @@ -149,5 +149,8 @@ /* Class = "NSMenu"; title = "Unit"; ObjectID = "z2p-dA-zcS"; */ "z2p-dA-zcS.title" = "単位"; +/* Class = "NSMenuItem"; title = "New Ruler"; ObjectID = "rWt-KM-qSf"; */ +"rWt-KM-qSf.title" = "新規定規"; + /* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ "rSt-Tg-232.title" = "定規設定…"; diff --git a/Free Ruler/zh-hans.lproj/MainMenu.strings b/Free Ruler/zh-hans.lproj/MainMenu.strings index 9b679a4..9cd0f65 100644 --- a/Free Ruler/zh-hans.lproj/MainMenu.strings +++ b/Free Ruler/zh-hans.lproj/MainMenu.strings @@ -152,5 +152,8 @@ /* Class = "NSMenuItem"; title = "Cycle Units"; ObjectID = "2nm-aL-kZd"; */ "2nm-aL-kZd.title" = "循环单位"; +/* Class = "NSMenuItem"; title = "New Ruler"; ObjectID = "rWt-KM-qSf"; */ +"rWt-KM-qSf.title" = "新建尺子"; + /* Class = "NSMenuItem"; title = "Ruler Settings…"; ObjectID = "rSt-Tg-232"; */ "rSt-Tg-232.title" = "尺子设置…"; From 17ad4589673c668119fb193cf7fd535fffa7974d Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 18 Jun 2026 23:43:41 -0400 Subject: [PATCH 03/10] Implement Ruler Settings UI and Localization Updates --- Free Ruler/Base.lproj/MainMenu.xib | 4 +- .../Base.lproj/PreferencesController.xib | 183 +---- .../Base.lproj/RulerSettingsController.xib | 69 ++ .../Base.lproj/RulerSettingsControlsView.xib | 117 +++ Free Ruler/Localizable.xcstrings | 42 -- Free Ruler/PreferencesController.swift | 699 ++++++++++-------- Free Ruler/Prefs.swift | 53 +- .../de.lproj/PreferencesController.strings | 29 +- .../de.lproj/RulerSettingsController.strings | 9 + .../RulerSettingsControlsView.strings | 15 + .../es.lproj/PreferencesController.strings | 29 +- .../es.lproj/RulerSettingsController.strings | 9 + .../RulerSettingsControlsView.strings | 15 + .../fi.lproj/PreferencesController.strings | 29 +- .../fi.lproj/RulerSettingsController.strings | 9 + .../RulerSettingsControlsView.strings | 15 + .../ja.lproj/PreferencesController.strings | 29 +- .../ja.lproj/RulerSettingsController.strings | 9 + .../RulerSettingsControlsView.strings | 15 + .../PreferencesController.strings | 29 +- .../RulerSettingsController.strings | 9 + .../RulerSettingsControlsView.strings | 15 + FreeRulerTests/RulerCoreTests.swift | 169 ++++- 23 files changed, 974 insertions(+), 627 deletions(-) create mode 100644 Free Ruler/Base.lproj/RulerSettingsController.xib create mode 100644 Free Ruler/Base.lproj/RulerSettingsControlsView.xib create mode 100644 Free Ruler/de.lproj/RulerSettingsController.strings create mode 100644 Free Ruler/de.lproj/RulerSettingsControlsView.strings create mode 100644 Free Ruler/es.lproj/RulerSettingsController.strings create mode 100644 Free Ruler/es.lproj/RulerSettingsControlsView.strings create mode 100644 Free Ruler/fi.lproj/RulerSettingsController.strings create mode 100644 Free Ruler/fi.lproj/RulerSettingsControlsView.strings create mode 100644 Free Ruler/ja.lproj/RulerSettingsController.strings create mode 100644 Free Ruler/ja.lproj/RulerSettingsControlsView.strings create mode 100644 Free Ruler/zh-hans.lproj/RulerSettingsController.strings create mode 100644 Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index 07161ce..7db7dd7 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -104,8 +104,8 @@
- - + + diff --git a/Free Ruler/Base.lproj/PreferencesController.xib b/Free Ruler/Base.lproj/PreferencesController.xib index ae045ff..869d872 100644 --- a/Free Ruler/Base.lproj/PreferencesController.xib +++ b/Free Ruler/Base.lproj/PreferencesController.xib @@ -8,15 +8,8 @@ - - - - - - - - - + + @@ -24,166 +17,60 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + - + diff --git a/Free Ruler/Base.lproj/RulerSettingsController.xib b/Free Ruler/Base.lproj/RulerSettingsController.xib new file mode 100644 index 0000000..4011c5c --- /dev/null +++ b/Free Ruler/Base.lproj/RulerSettingsController.xib @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib new file mode 100644 index 0000000..072377a --- /dev/null +++ b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 7d32227..17f6583 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -43,48 +43,6 @@ } } }, - "Close" : { - "comment" : "Button title for closing the active ruler settings panel", - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Schließen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cerrar" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sulje" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "閉じる" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "关闭" - } - } - } - }, "Color" : { "comment" : "Label for the active ruler color setting", "extractionState" : "manual", diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index d7e8dff..ff95829 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -114,24 +114,266 @@ private func configureResetRulerColorButtonAppearance(_ button: NSButton, identi button.setAccessibilityLabel(resetRulerColorLabel) } -class PreferencesController: NSWindowController, NSWindowDelegate, NotificationPoster { +protocol RulerSettingsControlsViewDelegate: AnyObject { + func rulerSettingsControlsDidChangeRulerColor(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidResetRulerColor(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeForegroundOpacity(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeBackgroundOpacity(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeFloatRulers(_ controlsView: RulerSettingsControlsView) + func rulerSettingsControlsDidChangeRulerShadow(_ controlsView: RulerSettingsControlsView) +} - var observers: [NSKeyValueObservation] = [] - private var colorPanelObserver: NSObjectProtocol? +final class RulerSettingsControlsView: NSView { + weak var delegate: RulerSettingsControlsViewDelegate? + + @IBOutlet var contentView: NSView! + @IBOutlet weak var rulerColorWell: RulerColorWell! + @IBOutlet weak var resetRulerColorButton: NSButton! @IBOutlet weak var foregroundOpacitySlider: NSSlider! @IBOutlet weak var backgroundOpacitySlider: NSSlider! - @IBOutlet weak var foregroundOpacityLabel: NSTextField! @IBOutlet weak var backgroundOpacityLabel: NSTextField! - - @IBOutlet weak var rulerColorWell: NSColorWell! - @IBOutlet weak var resetRulerColorButton: NSButton! - @IBOutlet weak var floatRulersCheckbox: NSButton! - @IBOutlet weak var groupRulersCheckbox: NSButton! @IBOutlet weak var rulerShadowCheckbox: NSButton! + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + loadContentView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + loadContentView() + } + + func configureForPreferences() { + configureControls( + colorWellIdentifier: "ruler-color-well", + resetButtonIdentifier: "reset-ruler-color-button", + foregroundSliderIdentifier: "ruler-foreground-opacity-slider", + backgroundSliderIdentifier: "ruler-background-opacity-slider", + foregroundLabelIdentifier: "ruler-foreground-opacity-label", + backgroundLabelIdentifier: "ruler-background-opacity-label", + floatCheckboxIdentifier: "float-rulers-checkbox", + shadowCheckboxIdentifier: "ruler-shadow-checkbox" + ) + } + + func configureForRulerSettings() { + configureControls( + colorWellIdentifier: "ruler-settings-color-well", + resetButtonIdentifier: "reset-ruler-settings-color-button", + foregroundSliderIdentifier: "ruler-settings-foreground-opacity-slider", + backgroundSliderIdentifier: "ruler-settings-background-opacity-slider", + foregroundLabelIdentifier: "ruler-settings-foreground-opacity-label", + backgroundLabelIdentifier: "ruler-settings-background-opacity-label", + floatCheckboxIdentifier: "ruler-settings-float-rulers-checkbox", + shadowCheckboxIdentifier: "ruler-settings-ruler-shadow-checkbox" + ) + } + + func update( + rulerColor: NSColor, + foregroundOpacity: Int, + backgroundOpacity: Int, + floatRulers: Bool, + rulerShadow: Bool, + isEnabled: Bool = true + ) { + rulerColorWell.supportsAlpha = false + rulerColorWell.color = rulerColor + rulerColorWell.isEnabled = isEnabled + + resetRulerColorButton.isEnabled = isEnabled + resetRulerColorButton.isHidden = Prefs.colorsMatch(rulerColor, Prefs.defaultRulerFillColor) + + foregroundOpacitySlider.integerValue = foregroundOpacity + foregroundOpacitySlider.isEnabled = isEnabled + foregroundOpacityLabel.stringValue = "\(foregroundOpacity)%" + + backgroundOpacitySlider.integerValue = backgroundOpacity + backgroundOpacitySlider.isEnabled = isEnabled + backgroundOpacityLabel.stringValue = "\(backgroundOpacity)%" + + floatRulersCheckbox.state = floatRulers ? .on : .off + floatRulersCheckbox.isEnabled = isEnabled + + rulerShadowCheckbox.state = rulerShadow ? .on : .off + rulerShadowCheckbox.isEnabled = isEnabled + + configureKeyViewLoop() + } + + func deactivateColorWell() { + rulerColorWell.deactivate() + } + + private func loadContentView() { + guard contentView == nil else { return } + + var topLevelObjects: NSArray? + Bundle.main.loadNibNamed( + "RulerSettingsControlsView", + owner: self, + topLevelObjects: &topLevelObjects + ) + + guard let contentView = contentView else { return } + + contentView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentView) + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.topAnchor.constraint(equalTo: topAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + configureBaseControls() + } + + private func configureBaseControls() { + rulerColorWell.isContinuous = true + rulerColorWell.supportsAlpha = false + rulerColorWell.target = self + rulerColorWell.action = #selector(setRulerColor(_:)) + + resetRulerColorButton.target = self + resetRulerColorButton.action = #selector(resetRulerColor(_:)) + + foregroundOpacitySlider.minValue = 5 + foregroundOpacitySlider.maxValue = 100 + foregroundOpacitySlider.numberOfTickMarks = 20 + foregroundOpacitySlider.allowsTickMarkValuesOnly = true + foregroundOpacitySlider.tickMarkPosition = .below + foregroundOpacitySlider.isContinuous = true + foregroundOpacitySlider.target = self + foregroundOpacitySlider.action = #selector(setForegroundOpacity(_:)) + + backgroundOpacitySlider.minValue = 5 + backgroundOpacitySlider.maxValue = 100 + backgroundOpacitySlider.numberOfTickMarks = 20 + backgroundOpacitySlider.allowsTickMarkValuesOnly = true + backgroundOpacitySlider.tickMarkPosition = .below + backgroundOpacitySlider.isContinuous = true + backgroundOpacitySlider.target = self + backgroundOpacitySlider.action = #selector(setBackgroundOpacity(_:)) + + floatRulersCheckbox.target = self + floatRulersCheckbox.action = #selector(setFloatRulers(_:)) + + rulerShadowCheckbox.target = self + rulerShadowCheckbox.action = #selector(setRulerShadow(_:)) + + configureKeyViewLoop() + } + + private func configureControls( + colorWellIdentifier: String, + resetButtonIdentifier: String, + foregroundSliderIdentifier: String, + backgroundSliderIdentifier: String, + foregroundLabelIdentifier: String, + backgroundLabelIdentifier: String, + floatCheckboxIdentifier: String, + shadowCheckboxIdentifier: String + ) { + rulerColorWell.identifier = NSUserInterfaceItemIdentifier(colorWellIdentifier) + rulerColorWell.setAccessibilityIdentifier(colorWellIdentifier) + configureResetRulerColorButtonAppearance(resetRulerColorButton, identifier: resetButtonIdentifier) + + foregroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier(foregroundSliderIdentifier) + foregroundOpacitySlider.setAccessibilityIdentifier(foregroundSliderIdentifier) + foregroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier(foregroundLabelIdentifier) + foregroundOpacityLabel.setAccessibilityIdentifier(foregroundLabelIdentifier) + + backgroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier(backgroundSliderIdentifier) + backgroundOpacitySlider.setAccessibilityIdentifier(backgroundSliderIdentifier) + backgroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier(backgroundLabelIdentifier) + backgroundOpacityLabel.setAccessibilityIdentifier(backgroundLabelIdentifier) + + floatRulersCheckbox.identifier = NSUserInterfaceItemIdentifier(floatCheckboxIdentifier) + floatRulersCheckbox.setAccessibilityIdentifier(floatCheckboxIdentifier) + rulerShadowCheckbox.identifier = NSUserInterfaceItemIdentifier(shadowCheckboxIdentifier) + rulerShadowCheckbox.setAccessibilityIdentifier(shadowCheckboxIdentifier) + } + + private func configureKeyViewLoop() { + rulerColorWell.nextKeyView = resetRulerColorButton.isHidden + ? foregroundOpacitySlider + : resetRulerColorButton + resetRulerColorButton.nextKeyView = foregroundOpacitySlider + foregroundOpacitySlider.nextKeyView = backgroundOpacitySlider + backgroundOpacitySlider.nextKeyView = floatRulersCheckbox + floatRulersCheckbox.nextKeyView = rulerShadowCheckbox + rulerShadowCheckbox.nextKeyView = rulerColorWell + } + + @objc private func setRulerColor(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeRulerColor(self) + } + + @objc private func resetRulerColor(_ sender: Any) { + delegate?.rulerSettingsControlsDidResetRulerColor(self) + } + + @objc private func setForegroundOpacity(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeForegroundOpacity(self) + } + + @objc private func setBackgroundOpacity(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeBackgroundOpacity(self) + } + + @objc private func setFloatRulers(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeFloatRulers(self) + } + + @objc private func setRulerShadow(_ sender: Any) { + delegate?.rulerSettingsControlsDidChangeRulerShadow(self) + } +} + +class PreferencesController: NSWindowController, NSWindowDelegate, NotificationPoster { + + var observers: [NSKeyValueObservation] = [] + private var colorPanelObserver: NSObjectProtocol? + + @IBOutlet weak var settingsControlsView: RulerSettingsControlsView! + @IBOutlet weak var resetFactoryDefaultsButton: NSButton! + + var foregroundOpacitySlider: NSSlider { + return settingsControlsView.foregroundOpacitySlider + } + + var backgroundOpacitySlider: NSSlider { + return settingsControlsView.backgroundOpacitySlider + } + + var foregroundOpacityLabel: NSTextField { + return settingsControlsView.foregroundOpacityLabel + } + + var backgroundOpacityLabel: NSTextField { + return settingsControlsView.backgroundOpacityLabel + } + + var rulerColorWell: RulerColorWell { + return settingsControlsView.rulerColorWell + } + + var resetRulerColorButton: NSButton { + return settingsControlsView.resetRulerColorButton + } + + var floatRulersCheckbox: NSButton { + return settingsControlsView.floatRulersCheckbox + } + + var rulerShadowCheckbox: NSButton { + return settingsControlsView.rulerShadowCheckbox + } + override var windowNibName: String { return "PreferencesController" } @@ -142,19 +384,12 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP window?.delegate = self window?.identifier = NSUserInterfaceItemIdentifier("preferences-window") window?.isMovableByWindowBackground = true - floatRulersCheckbox.identifier = NSUserInterfaceItemIdentifier("float-rulers-checkbox") - floatRulersCheckbox.setAccessibilityIdentifier("float-rulers-checkbox") - groupRulersCheckbox.identifier = NSUserInterfaceItemIdentifier("group-rulers-checkbox") - groupRulersCheckbox.setAccessibilityIdentifier("group-rulers-checkbox") - rulerShadowCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-shadow-checkbox") - rulerShadowCheckbox.setAccessibilityIdentifier("ruler-shadow-checkbox") configureOpaqueColorPicking() - rulerColorWell.isContinuous = true - rulerColorWell.supportsAlpha = false - rulerColorWell.identifier = NSUserInterfaceItemIdentifier("ruler-color-well") - rulerColorWell.setAccessibilityIdentifier("ruler-color-well") + settingsControlsView.delegate = self + settingsControlsView.configureForPreferences() window?.initialFirstResponder = rulerColorWell - configureResetRulerColorButton() + resetFactoryDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-factory-defaults-button") + resetFactoryDefaultsButton.setAccessibilityIdentifier("reset-factory-defaults-button") subscribeToPrefs() subscribeToColorPanel() @@ -197,9 +432,6 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP prefs.observe(\Prefs.floatRulers, options: .new) { prefs, changed in self.updateFloatRulersCheckbox() }, - prefs.observe(\Prefs.groupRulers, options: .new) { prefs, changed in - self.updateGroupRulersCheckbox() - }, prefs.observe(\Prefs.rulerShadow, options: .new) { prefs, changed in self.updateRulerShadowCheckbox() }, @@ -218,9 +450,6 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP @IBAction func setFloatRulers(_ sender: Any) { prefs.floatRulers = floatRulersCheckbox.state == .on } - @IBAction func setGroupRulers(_ sender: Any) { - prefs.groupRulers = groupRulersCheckbox.state == .on - } @IBAction func setRulerShadow(_ sender: Any) { prefs.rulerShadow = rulerShadowCheckbox.state == .on } @@ -230,14 +459,19 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP @IBAction func resetRulerColor(_ sender: Any) { prefs.rulerColor = Prefs.defaultRulerFillColor } + @IBAction func resetToFactoryDefaults(_ sender: Any) { + prefs.resetRulerDefaultsToFactoryDefaults() + updateView() + } func updateView() { - updateForegroundSlider() - updateBackgroundSlider() - updateRulerColorWell() - updateFloatRulersCheckbox() - updateGroupRulersCheckbox() - updateRulerShadowCheckbox() + settingsControlsView.update( + rulerColor: prefs.rulerColor, + foregroundOpacity: prefs.foregroundOpacity, + backgroundOpacity: prefs.backgroundOpacity, + floatRulers: prefs.floatRulers, + rulerShadow: prefs.rulerShadow + ) } func updateForegroundSlider() { @@ -260,18 +494,10 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP floatRulersCheckbox.state = prefs.floatRulers ? .on : .off } - func updateGroupRulersCheckbox() { - groupRulersCheckbox.state = prefs.groupRulers ? .on : .off - } - func updateRulerShadowCheckbox() { rulerShadowCheckbox.state = prefs.rulerShadow ? .on : .off } - private func configureResetRulerColorButton() { - configureResetRulerColorButtonAppearance(resetRulerColorButton, identifier: "reset-ruler-color-button") - } - private func subscribeToColorPanel() { colorPanelObserver = NotificationCenter.default.addObserver( forName: NSColorPanel.colorDidChangeNotification, @@ -293,84 +519,110 @@ class PreferencesController: NSWindowController, NSWindowDelegate, NotificationP } +extension PreferencesController: RulerSettingsControlsViewDelegate { + func rulerSettingsControlsDidChangeRulerColor(_ controlsView: RulerSettingsControlsView) { + setRulerColor(controlsView.rulerColorWell as Any) + } + + func rulerSettingsControlsDidResetRulerColor(_ controlsView: RulerSettingsControlsView) { + resetRulerColor(controlsView.resetRulerColorButton as Any) + } + + func rulerSettingsControlsDidChangeForegroundOpacity(_ controlsView: RulerSettingsControlsView) { + setForegroundOpacity(controlsView.foregroundOpacitySlider as Any) + } + + func rulerSettingsControlsDidChangeBackgroundOpacity(_ controlsView: RulerSettingsControlsView) { + setBackgroundOpacity(controlsView.backgroundOpacitySlider as Any) + } + + func rulerSettingsControlsDidChangeFloatRulers(_ controlsView: RulerSettingsControlsView) { + setFloatRulers(controlsView.floatRulersCheckbox as Any) + } + + func rulerSettingsControlsDidChangeRulerShadow(_ controlsView: RulerSettingsControlsView) { + setRulerShadow(controlsView.rulerShadowCheckbox as Any) + } +} + final class RulerSettingsController: NSWindowController, NSWindowDelegate { private weak var rulerController: GroupedRulerController? private var colorPanelObserver: NSObjectProtocol? - let rulerColorWell: RulerColorWell - let resetRulerColorButton: NSButton - let foregroundOpacitySlider: NSSlider - let backgroundOpacitySlider: NSSlider - let foregroundOpacityLabel: NSTextField - let backgroundOpacityLabel: NSTextField - let floatRulersCheckbox: NSButton - let rulerShadowCheckbox: NSButton - let closeButton: NSButton + @IBOutlet weak var settingsControlsView: RulerSettingsControlsView! + @IBOutlet weak var resetDefaultsButton: NSButton! + @IBOutlet weak var setDefaultsButton: NSButton! + + var rulerColorWell: RulerColorWell { + return settingsControlsView.rulerColorWell + } + + var resetRulerColorButton: NSButton { + return settingsControlsView.resetRulerColorButton + } + + var foregroundOpacitySlider: NSSlider { + return settingsControlsView.foregroundOpacitySlider + } + + var backgroundOpacitySlider: NSSlider { + return settingsControlsView.backgroundOpacitySlider + } + + var foregroundOpacityLabel: NSTextField { + return settingsControlsView.foregroundOpacityLabel + } + + var backgroundOpacityLabel: NSTextField { + return settingsControlsView.backgroundOpacityLabel + } + + var floatRulersCheckbox: NSButton { + return settingsControlsView.floatRulersCheckbox + } + + var rulerShadowCheckbox: NSButton { + return settingsControlsView.rulerShadowCheckbox + } var currentRulerController: GroupedRulerController? { return rulerController } + override var windowNibName: String { + return "RulerSettingsController" + } + init(rulerController: GroupedRulerController) { self.rulerController = rulerController + super.init(window: nil) + loadWindow() + } - rulerColorWell = RulerColorWell(frame: NSRect(x: 0, y: 0, width: 64, height: 30)) - resetRulerColorButton = NSButton(frame: .zero) - foregroundOpacitySlider = RulerSettingsController.makeOpacitySlider() - backgroundOpacitySlider = RulerSettingsController.makeOpacitySlider() - foregroundOpacityLabel = RulerSettingsController.makeValueLabel() - backgroundOpacityLabel = RulerSettingsController.makeValueLabel() - floatRulersCheckbox = NSButton( - checkboxWithTitle: NSLocalizedString( - "Float rulers above other applications", - comment: "Checkbox title for whether the active ruler floats above other apps" - ), - target: nil, - action: nil - ) - rulerShadowCheckbox = NSButton( - checkboxWithTitle: NSLocalizedString( - "Show ruler shadow", - comment: "Checkbox title for whether the active ruler draws a window shadow" - ), - target: nil, - action: nil - ) - closeButton = NSButton( - title: NSLocalizedString( - "Close", - comment: "Button title for closing the active ruler settings panel" - ), - target: nil, - action: nil - ) - - let window = RulerSettingsController.makeWindow( - rulerColorWell: rulerColorWell, - resetRulerColorButton: resetRulerColorButton, - foregroundOpacitySlider: foregroundOpacitySlider, - backgroundOpacitySlider: backgroundOpacitySlider, - foregroundOpacityLabel: foregroundOpacityLabel, - backgroundOpacityLabel: backgroundOpacityLabel, - floatRulersCheckbox: floatRulersCheckbox, - rulerShadowCheckbox: rulerShadowCheckbox, - closeButton: closeButton - ) + required init?(coder: NSCoder) { + nil + } - super.init(window: window) + override func windowDidLoad() { + super.windowDidLoad() - window.delegate = self - window.initialFirstResponder = rulerColorWell - configureControls() + window?.delegate = self + window?.identifier = NSUserInterfaceItemIdentifier("ruler-settings-window") + window?.setAccessibilityIdentifier("ruler-settings-window") + window?.isMovableByWindowBackground = true + window?.isReleasedWhenClosed = false + window?.initialFirstResponder = rulerColorWell + settingsControlsView.delegate = self + settingsControlsView.configureForRulerSettings() + resetDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-ruler-settings-to-default-button") + resetDefaultsButton.setAccessibilityIdentifier("reset-ruler-settings-to-default-button") + setDefaultsButton.identifier = NSUserInterfaceItemIdentifier("save-ruler-settings-as-default-button") + setDefaultsButton.setAccessibilityIdentifier("save-ruler-settings-as-default-button") subscribeToColorPanel() updateView() } - required init?(coder: NSCoder) { - nil - } - deinit { if let colorPanelObserver = colorPanelObserver { NotificationCenter.default.removeObserver(colorPanelObserver) @@ -475,216 +727,33 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { applyRulerColor(Prefs.defaultRulerFillColor) } - @objc func closeRulerSettings(_ sender: Any) { - close() - } - - func updateView() { - let currentColor = rulerController?.state.settings.rulerColor ?? Prefs.defaultRulerFillColor - let currentSettings = rulerController?.state.settings - let hasRuler = rulerController != nil - - rulerColorWell.supportsAlpha = false - rulerColorWell.color = currentColor - rulerColorWell.isEnabled = hasRuler - resetRulerColorButton.isEnabled = hasRuler - resetRulerColorButton.isHidden = Prefs.colorsMatch(currentColor, Prefs.defaultRulerFillColor) - foregroundOpacitySlider.isEnabled = hasRuler - backgroundOpacitySlider.isEnabled = hasRuler - floatRulersCheckbox.isEnabled = hasRuler - rulerShadowCheckbox.isEnabled = hasRuler - - foregroundOpacitySlider.integerValue = currentSettings?.foregroundOpacity ?? 90 - backgroundOpacitySlider.integerValue = currentSettings?.backgroundOpacity ?? 50 - foregroundOpacityLabel.stringValue = "\(foregroundOpacitySlider.integerValue)%" - backgroundOpacityLabel.stringValue = "\(backgroundOpacitySlider.integerValue)%" - floatRulersCheckbox.state = currentSettings?.floatRulers == true ? .on : .off - rulerShadowCheckbox.state = currentSettings?.rulerShadow == true ? .on : .off - } - - private static func makeWindow( - rulerColorWell: RulerColorWell, - resetRulerColorButton: NSButton, - foregroundOpacitySlider: NSSlider, - backgroundOpacitySlider: NSSlider, - foregroundOpacityLabel: NSTextField, - backgroundOpacityLabel: NSTextField, - floatRulersCheckbox: NSButton, - rulerShadowCheckbox: NSButton, - closeButton: NSButton - ) -> NSPanel { - let contentView = NSView() - let colorLabel = makeLabel( - NSLocalizedString( - "Ruler Color", - comment: "Label for the active ruler color setting" - ) - ) - let foregroundLabel = makeLabel( - NSLocalizedString( - "Foreground Opacity", - comment: "Label for the active ruler foreground opacity setting" - ) - ) - let backgroundLabel = makeLabel( - NSLocalizedString( - "Background Opacity", - comment: "Label for the active ruler background opacity setting" - ) - ) - let colorRow = NSStackView(views: [colorLabel, resetRulerColorButton, rulerColorWell]) - let foregroundHeaderRow = NSStackView(views: [foregroundLabel, foregroundOpacityLabel]) - let backgroundHeaderRow = NSStackView(views: [backgroundLabel, backgroundOpacityLabel]) - let closeRow = NSView() - closeRow.addSubview(closeButton) - let contentStack = NSStackView(views: [ - colorRow, - foregroundHeaderRow, - foregroundOpacitySlider, - backgroundHeaderRow, - backgroundOpacitySlider, - floatRulersCheckbox, - rulerShadowCheckbox, - closeRow, - ]) - - for row in [colorRow, foregroundHeaderRow, backgroundHeaderRow] { - row.orientation = .horizontal - row.alignment = .centerY - row.distribution = .fill - row.spacing = 10 - row.translatesAutoresizingMaskIntoConstraints = false + @IBAction func resetToDefault(_ sender: Any) { + applySettings { settings in + settings = RulerSettings(defaults: prefs) } - - contentStack.orientation = .vertical - contentStack.alignment = .leading - contentStack.distribution = .fill - contentStack.spacing = 8 - contentStack.translatesAutoresizingMaskIntoConstraints = false - - colorLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - foregroundLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - backgroundLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - foregroundOpacityLabel.setContentHuggingPriority(.required, for: .horizontal) - backgroundOpacityLabel.setContentHuggingPriority(.required, for: .horizontal) - rulerColorWell.translatesAutoresizingMaskIntoConstraints = false - resetRulerColorButton.translatesAutoresizingMaskIntoConstraints = false - foregroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false - backgroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false - floatRulersCheckbox.translatesAutoresizingMaskIntoConstraints = false - rulerShadowCheckbox.translatesAutoresizingMaskIntoConstraints = false - closeButton.translatesAutoresizingMaskIntoConstraints = false - closeRow.translatesAutoresizingMaskIntoConstraints = false - - contentView.addSubview(contentStack) - NSLayoutConstraint.activate([ - colorRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - foregroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - backgroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - closeRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - rulerColorWell.widthAnchor.constraint(equalToConstant: 60), - rulerColorWell.heightAnchor.constraint(equalToConstant: 24), - resetRulerColorButton.widthAnchor.constraint(equalToConstant: 28), - resetRulerColorButton.heightAnchor.constraint(equalToConstant: 28), - foregroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - backgroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - floatRulersCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), - rulerShadowCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), - closeButton.trailingAnchor.constraint(equalTo: closeRow.trailingAnchor), - closeButton.topAnchor.constraint(equalTo: closeRow.topAnchor), - closeButton.bottomAnchor.constraint(equalTo: closeRow.bottomAnchor), - closeButton.leadingAnchor.constraint(greaterThanOrEqualTo: closeRow.leadingAnchor), - contentStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - contentStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - contentStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 18), - contentStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18), - ]) - - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 315, height: 270), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.title = NSLocalizedString( - "Ruler Settings", - comment: "Window title for the active ruler settings panel" - ) - window.contentView = contentView - window.identifier = NSUserInterfaceItemIdentifier("ruler-settings-window") - window.setAccessibilityIdentifier("ruler-settings-window") - window.isMovableByWindowBackground = true - window.isReleasedWhenClosed = false - return window - } - - private static func makeLabel(_ title: String) -> NSTextField { - let label = NSTextField(labelWithString: title) - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - return label + updateView() } - private static func makeValueLabel() -> NSTextField { - let label = NSTextField(labelWithString: "") - label.alignment = .right - return label - } + @IBAction func setDefaultsForNewRulers(_ sender: Any) { + guard let settings = rulerController?.state.settings else { return } - private static func makeOpacitySlider() -> NSSlider { - let slider = NSSlider(frame: .zero) - slider.minValue = 5 - slider.maxValue = 100 - slider.doubleValue = 50 - slider.numberOfTickMarks = 20 - slider.allowsTickMarkValuesOnly = true - slider.tickMarkPosition = .below - slider.isContinuous = true - return slider + prefs.applyDefaults(from: settings) } - private func configureControls() { - rulerColorWell.isContinuous = true - rulerColorWell.supportsAlpha = false - rulerColorWell.identifier = NSUserInterfaceItemIdentifier("ruler-settings-color-well") - rulerColorWell.setAccessibilityIdentifier("ruler-settings-color-well") - rulerColorWell.target = self - rulerColorWell.action = #selector(setRulerColor(_:)) + func updateView() { + let currentSettings = rulerController?.state.settings + let hasRuler = rulerController != nil - resetRulerColorButton.target = self - resetRulerColorButton.action = #selector(resetRulerColor(_:)) - configureResetRulerColorButtonAppearance( - resetRulerColorButton, - identifier: "reset-ruler-settings-color-button" + settingsControlsView.update( + rulerColor: currentSettings?.rulerColor ?? Prefs.defaultRulerFillColor, + foregroundOpacity: currentSettings?.foregroundOpacity ?? Prefs.defaultForegroundOpacity, + backgroundOpacity: currentSettings?.backgroundOpacity ?? Prefs.defaultBackgroundOpacity, + floatRulers: currentSettings?.floatRulers ?? Prefs.defaultFloatRulers, + rulerShadow: currentSettings?.rulerShadow ?? Prefs.defaultRulerShadow, + isEnabled: hasRuler ) - - foregroundOpacitySlider.target = self - foregroundOpacitySlider.action = #selector(setForegroundOpacity(_:)) - foregroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier("ruler-settings-foreground-opacity-slider") - foregroundOpacitySlider.setAccessibilityIdentifier("ruler-settings-foreground-opacity-slider") - foregroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier("ruler-settings-foreground-opacity-label") - foregroundOpacityLabel.setAccessibilityIdentifier("ruler-settings-foreground-opacity-label") - - backgroundOpacitySlider.target = self - backgroundOpacitySlider.action = #selector(setBackgroundOpacity(_:)) - backgroundOpacitySlider.identifier = NSUserInterfaceItemIdentifier("ruler-settings-background-opacity-slider") - backgroundOpacitySlider.setAccessibilityIdentifier("ruler-settings-background-opacity-slider") - backgroundOpacityLabel.identifier = NSUserInterfaceItemIdentifier("ruler-settings-background-opacity-label") - backgroundOpacityLabel.setAccessibilityIdentifier("ruler-settings-background-opacity-label") - - floatRulersCheckbox.target = self - floatRulersCheckbox.action = #selector(setFloatRulers(_:)) - floatRulersCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-float-rulers-checkbox") - floatRulersCheckbox.setAccessibilityIdentifier("ruler-settings-float-rulers-checkbox") - - rulerShadowCheckbox.target = self - rulerShadowCheckbox.action = #selector(setRulerShadow(_:)) - rulerShadowCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-ruler-shadow-checkbox") - rulerShadowCheckbox.setAccessibilityIdentifier("ruler-settings-ruler-shadow-checkbox") - - closeButton.target = self - closeButton.action = #selector(closeRulerSettings(_:)) - closeButton.identifier = NSUserInterfaceItemIdentifier("ruler-settings-close-button") - closeButton.setAccessibilityIdentifier("ruler-settings-close-button") + resetDefaultsButton.isEnabled = hasRuler + setDefaultsButton.isEnabled = hasRuler } private func applyRulerColor(_ color: NSColor) { @@ -775,11 +844,37 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { if let foregroundOpacity = rulerController?.state.settings.foregroundOpacity { rulerController?.opacity = foregroundOpacity } - rulerColorWell.deactivate() + settingsControlsView.deactivateColorWell() closeRulerColorPanel() } } +extension RulerSettingsController: RulerSettingsControlsViewDelegate { + func rulerSettingsControlsDidChangeRulerColor(_ controlsView: RulerSettingsControlsView) { + setRulerColor(controlsView.rulerColorWell as Any) + } + + func rulerSettingsControlsDidResetRulerColor(_ controlsView: RulerSettingsControlsView) { + resetRulerColor(controlsView.resetRulerColorButton as Any) + } + + func rulerSettingsControlsDidChangeForegroundOpacity(_ controlsView: RulerSettingsControlsView) { + setForegroundOpacity(controlsView.foregroundOpacitySlider as Any) + } + + func rulerSettingsControlsDidChangeBackgroundOpacity(_ controlsView: RulerSettingsControlsView) { + setBackgroundOpacity(controlsView.backgroundOpacitySlider as Any) + } + + func rulerSettingsControlsDidChangeFloatRulers(_ controlsView: RulerSettingsControlsView) { + setFloatRulers(controlsView.floatRulersCheckbox as Any) + } + + func rulerSettingsControlsDidChangeRulerShadow(_ controlsView: RulerSettingsControlsView) { + setRulerShadow(controlsView.rulerShadowCheckbox as Any) + } +} + func closeRulerColorPanel() { activeRulerColorWell = nil let colorPanel = NSColorPanel.shared diff --git a/Free Ruler/Prefs.swift b/Free Ruler/Prefs.swift index d9a9f8e..4918514 100644 --- a/Free Ruler/Prefs.swift +++ b/Free Ruler/Prefs.swift @@ -54,12 +54,12 @@ class Prefs: NSObject { private static var defaultValues: [String: Any] { var values: [String: Any] = [ - "groupRulers": true, - "floatRulers": true, - "rulerShadow": false, - "foregroundOpacity": 90, - "backgroundOpacity": 50, - "unit": Unit.pixels.rawValue, + "groupRulers": defaultGroupRulers, + "floatRulers": defaultFloatRulers, + "rulerShadow": defaultRulerShadow, + "foregroundOpacity": defaultForegroundOpacity, + "backgroundOpacity": defaultBackgroundOpacity, + "unit": defaultUnit.rawValue, "zeroCorner": defaultZeroCorner.rawValue ] @@ -131,6 +131,10 @@ class Prefs: NSObject { extension Prefs { static let rulerSetStateKey = "rulerSetState" + static var defaultUnit: Unit { + return .pixels + } + static var defaultZeroCorner: ZeroCorner { return .topLeft } @@ -138,11 +142,48 @@ extension Prefs { static var defaultRulerFillColor: NSColor { return defaultRulerColor } + + static var defaultForegroundOpacity: Int { + return 90 + } + + static var defaultBackgroundOpacity: Int { + return 50 + } + + static var defaultFloatRulers: Bool { + return true + } + + static var defaultRulerShadow: Bool { + return false + } static var defaultGroupRulers: Bool { return true } + func applyDefaults(from settings: RulerSettings) { + unit = settings.unit + rulerColor = settings.rulerColor + foregroundOpacity = settings.foregroundOpacity + backgroundOpacity = settings.backgroundOpacity + floatRulers = settings.floatRulers + rulerShadow = settings.rulerShadow + zeroCorner = settings.zeroCorner + } + + func resetRulerDefaultsToFactoryDefaults() { + unit = Self.defaultUnit + rulerColor = Self.defaultRulerFillColor + foregroundOpacity = Self.defaultForegroundOpacity + backgroundOpacity = Self.defaultBackgroundOpacity + floatRulers = Self.defaultFloatRulers + rulerShadow = Self.defaultRulerShadow + groupRulers = Self.defaultGroupRulers + zeroCorner = Self.defaultZeroCorner + } + static func rulerFillColor(fromArchivedData data: Data?) -> NSColor { return normalizedRulerColor(unarchiveColor(data) ?? defaultRulerColor) } diff --git a/Free Ruler/de.lproj/PreferencesController.strings b/Free Ruler/de.lproj/PreferencesController.strings index edf83f4..6317fd3 100644 --- a/Free Ruler/de.lproj/PreferencesController.strings +++ b/Free Ruler/de.lproj/PreferencesController.strings @@ -1,30 +1,9 @@ -/* Class = "NSButtonCell"; title = "Reset Rulers"; ObjectID = "10f-9L-qca"; */ -"10f-9L-qca.title" = "Lineale zurücksetzen"; - -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "Deckkraft im Vordergrund"; - /* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "„Free Ruler“-Einstellungen"; -/* Class = "NSButtonCell"; title = "Group rulers"; ObjectID = "N2Y-8B-L9c"; */ -"N2Y-8B-L9c.title" = "Lineale gruppieren"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "VXi-Ch-Jf5"; */ -"VXi-Ch-Jf5.title" = "Label"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "Vqd-CI-vmd"; */ -"Vqd-CI-vmd.title" = "Label"; - -/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "cRb-8z-VZj"; */ -"cRb-8z-VZj.title" = "Deckkraft im Hintergrund"; - -/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "ydA-TR-Gwu"; */ -"ydA-TR-Gwu.title" = "Linealfarbe"; - -/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "l53-85-hoA"; */ -"l53-85-hoA.title" = "Linealschatten anzeigen"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "Standardeinstellungen für neue Lineale"; -/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "yPM-Cw-Qsi"; */ -"yPM-Cw-Qsi.title" = "Lineale schweben über anderen Programmen"; +/* Class = "NSButtonCell"; title = "Reset to factory defaults"; ObjectID = "PREF-factory-cell"; */ +"PREF-factory-cell.title" = "Auf Werkseinstellungen zurücksetzen"; diff --git a/Free Ruler/de.lproj/RulerSettingsController.strings b/Free Ruler/de.lproj/RulerSettingsController.strings new file mode 100644 index 0000000..87067f6 --- /dev/null +++ b/Free Ruler/de.lproj/RulerSettingsController.strings @@ -0,0 +1,9 @@ + +/* Class = "NSButtonCell"; title = "Reset to default"; ObjectID = "RSET-reset-defaults-cell"; */ +"RSET-reset-defaults-cell.title" = "Auf Standard zurücksetzen"; + +/* Class = "NSButtonCell"; title = "Save as default"; ObjectID = "RSET-save-defaults-cell"; */ +"RSET-save-defaults-cell.title" = "Als Standard sichern"; + +/* Class = "NSWindow"; title = "Ruler Settings"; ObjectID = "RSET-window"; */ +"RSET-window.title" = "Lineal-Einstellungen"; diff --git a/Free Ruler/de.lproj/RulerSettingsControlsView.strings b/Free Ruler/de.lproj/RulerSettingsControlsView.strings new file mode 100644 index 0000000..3bb8da3 --- /dev/null +++ b/Free Ruler/de.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,15 @@ + +/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "RSV-bg-cell"; */ +"RSV-bg-cell.title" = "Deckkraft im Hintergrund"; + +/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ +"RSV-color-cell.title" = "Linealfarbe"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "Deckkraft im Vordergrund"; + +/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "Lineale schweben über anderen Programmen"; + +/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ +"RSV-shadow-cell.title" = "Linealschatten anzeigen"; diff --git a/Free Ruler/es.lproj/PreferencesController.strings b/Free Ruler/es.lproj/PreferencesController.strings index 8edf07b..3854183 100644 --- a/Free Ruler/es.lproj/PreferencesController.strings +++ b/Free Ruler/es.lproj/PreferencesController.strings @@ -1,30 +1,9 @@ -/* Class = "NSButtonCell"; title = "Reset Rulers"; ObjectID = "10f-9L-qca"; */ -"10f-9L-qca.title" = "Restablecer reglas"; - -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "Opacidad del primer plano"; - /* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "Preferencias de Free Ruler"; -/* Class = "NSButtonCell"; title = "Group rulers"; ObjectID = "N2Y-8B-L9c"; */ -"N2Y-8B-L9c.title" = "Agrupar reglas"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "VXi-Ch-Jf5"; */ -"VXi-Ch-Jf5.title" = "Etiqueta"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "Vqd-CI-vmd"; */ -"Vqd-CI-vmd.title" = "Etiqueta"; - -/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "cRb-8z-VZj"; */ -"cRb-8z-VZj.title" = "Opacidad del fondo"; - -/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "ydA-TR-Gwu"; */ -"ydA-TR-Gwu.title" = "Color de la regla"; - -/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "l53-85-hoA"; */ -"l53-85-hoA.title" = "Mostrar sombra de la regla"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "Ajustes predeterminados para reglas nuevas"; -/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "yPM-Cw-Qsi"; */ -"yPM-Cw-Qsi.title" = "Reglas flotantes sobre otras aplicaciones"; +/* Class = "NSButtonCell"; title = "Reset to factory defaults"; ObjectID = "PREF-factory-cell"; */ +"PREF-factory-cell.title" = "Restablecer valores de fábrica"; diff --git a/Free Ruler/es.lproj/RulerSettingsController.strings b/Free Ruler/es.lproj/RulerSettingsController.strings new file mode 100644 index 0000000..775ece1 --- /dev/null +++ b/Free Ruler/es.lproj/RulerSettingsController.strings @@ -0,0 +1,9 @@ + +/* Class = "NSButtonCell"; title = "Reset to default"; ObjectID = "RSET-reset-defaults-cell"; */ +"RSET-reset-defaults-cell.title" = "Restablecer predeterminado"; + +/* Class = "NSButtonCell"; title = "Save as default"; ObjectID = "RSET-save-defaults-cell"; */ +"RSET-save-defaults-cell.title" = "Guardar como predeterminado"; + +/* Class = "NSWindow"; title = "Ruler Settings"; ObjectID = "RSET-window"; */ +"RSET-window.title" = "Ajustes de regla"; diff --git a/Free Ruler/es.lproj/RulerSettingsControlsView.strings b/Free Ruler/es.lproj/RulerSettingsControlsView.strings new file mode 100644 index 0000000..6196a4d --- /dev/null +++ b/Free Ruler/es.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,15 @@ + +/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "RSV-bg-cell"; */ +"RSV-bg-cell.title" = "Opacidad del fondo"; + +/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ +"RSV-color-cell.title" = "Color de la regla"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "Opacidad del primer plano"; + +/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "Reglas flotantes sobre otras aplicaciones"; + +/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ +"RSV-shadow-cell.title" = "Mostrar sombra de la regla"; diff --git a/Free Ruler/fi.lproj/PreferencesController.strings b/Free Ruler/fi.lproj/PreferencesController.strings index b00f2a9..98833ca 100644 --- a/Free Ruler/fi.lproj/PreferencesController.strings +++ b/Free Ruler/fi.lproj/PreferencesController.strings @@ -1,30 +1,9 @@ -/* Class = "NSButtonCell"; title = "Reset Rulers"; ObjectID = "10f-9L-qca"; */ -"10f-9L-qca.title" = "Nollaa viivaimet"; - -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "Peittävyys edustalla"; - /* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "Free Rulerin asetukset"; -/* Class = "NSButtonCell"; title = "Group rulers"; ObjectID = "N2Y-8B-L9c"; */ -"N2Y-8B-L9c.title" = "Ryhmitä viivaimet"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "VXi-Ch-Jf5"; */ -"VXi-Ch-Jf5.title" = "Label"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "Vqd-CI-vmd"; */ -"Vqd-CI-vmd.title" = "Label"; - -/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "cRb-8z-VZj"; */ -"cRb-8z-VZj.title" = "Peittävyys taustalla"; - -/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "ydA-TR-Gwu"; */ -"ydA-TR-Gwu.title" = "Viivaimen väri"; - -/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "l53-85-hoA"; */ -"l53-85-hoA.title" = "Näytä viivainten varjot"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "Uusien viivainten oletusasetukset"; -/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "yPM-Cw-Qsi"; */ -"yPM-Cw-Qsi.title" = "Kelluta viivaimia muiden sovellusten päällä"; +/* Class = "NSButtonCell"; title = "Reset to factory defaults"; ObjectID = "PREF-factory-cell"; */ +"PREF-factory-cell.title" = "Palauta tehdasasetukset"; diff --git a/Free Ruler/fi.lproj/RulerSettingsController.strings b/Free Ruler/fi.lproj/RulerSettingsController.strings new file mode 100644 index 0000000..046df8b --- /dev/null +++ b/Free Ruler/fi.lproj/RulerSettingsController.strings @@ -0,0 +1,9 @@ + +/* Class = "NSButtonCell"; title = "Reset to default"; ObjectID = "RSET-reset-defaults-cell"; */ +"RSET-reset-defaults-cell.title" = "Palauta oletus"; + +/* Class = "NSButtonCell"; title = "Save as default"; ObjectID = "RSET-save-defaults-cell"; */ +"RSET-save-defaults-cell.title" = "Tallenna oletukseksi"; + +/* Class = "NSWindow"; title = "Ruler Settings"; ObjectID = "RSET-window"; */ +"RSET-window.title" = "Viivaimen asetukset"; diff --git a/Free Ruler/fi.lproj/RulerSettingsControlsView.strings b/Free Ruler/fi.lproj/RulerSettingsControlsView.strings new file mode 100644 index 0000000..84adb4e --- /dev/null +++ b/Free Ruler/fi.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,15 @@ + +/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "RSV-bg-cell"; */ +"RSV-bg-cell.title" = "Peittävyys taustalla"; + +/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ +"RSV-color-cell.title" = "Viivaimen väri"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "Peittävyys edustalla"; + +/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "Kelluta viivaimia muiden sovellusten päällä"; + +/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ +"RSV-shadow-cell.title" = "Näytä viivainten varjot"; diff --git a/Free Ruler/ja.lproj/PreferencesController.strings b/Free Ruler/ja.lproj/PreferencesController.strings index 3bf4c18..4ac6800 100644 --- a/Free Ruler/ja.lproj/PreferencesController.strings +++ b/Free Ruler/ja.lproj/PreferencesController.strings @@ -1,30 +1,9 @@ -/* Class = "NSButtonCell"; title = "Reset Rulers"; ObjectID = "10f-9L-qca"; */ -"10f-9L-qca.title" = "定規をリセット"; - -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "前景の不透明度"; - /* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "Free Rulerの環境設定"; -/* Class = "NSButtonCell"; title = "Group rulers"; ObjectID = "N2Y-8B-L9c"; */ -"N2Y-8B-L9c.title" = "定規をグループ化"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "VXi-Ch-Jf5"; */ -"VXi-Ch-Jf5.title" = "ラベル"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "Vqd-CI-vmd"; */ -"Vqd-CI-vmd.title" = "ラベル"; - -/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "cRb-8z-VZj"; */ -"cRb-8z-VZj.title" = "背景の不透明度"; - -/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "ydA-TR-Gwu"; */ -"ydA-TR-Gwu.title" = "定規の色"; - -/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "l53-85-hoA"; */ -"l53-85-hoA.title" = "定規の影を表示"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "新しい定規のデフォルト設定"; -/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "yPM-Cw-Qsi"; */ -"yPM-Cw-Qsi.title" = "定規を常に手前に表示"; +/* Class = "NSButtonCell"; title = "Reset to factory defaults"; ObjectID = "PREF-factory-cell"; */ +"PREF-factory-cell.title" = "工場出荷時のデフォルトにリセット"; diff --git a/Free Ruler/ja.lproj/RulerSettingsController.strings b/Free Ruler/ja.lproj/RulerSettingsController.strings new file mode 100644 index 0000000..ac916f8 --- /dev/null +++ b/Free Ruler/ja.lproj/RulerSettingsController.strings @@ -0,0 +1,9 @@ + +/* Class = "NSButtonCell"; title = "Reset to default"; ObjectID = "RSET-reset-defaults-cell"; */ +"RSET-reset-defaults-cell.title" = "デフォルトにリセット"; + +/* Class = "NSButtonCell"; title = "Save as default"; ObjectID = "RSET-save-defaults-cell"; */ +"RSET-save-defaults-cell.title" = "デフォルトとして保存"; + +/* Class = "NSWindow"; title = "Ruler Settings"; ObjectID = "RSET-window"; */ +"RSET-window.title" = "定規設定"; diff --git a/Free Ruler/ja.lproj/RulerSettingsControlsView.strings b/Free Ruler/ja.lproj/RulerSettingsControlsView.strings new file mode 100644 index 0000000..8a0334d --- /dev/null +++ b/Free Ruler/ja.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,15 @@ + +/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "RSV-bg-cell"; */ +"RSV-bg-cell.title" = "背景の不透明度"; + +/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ +"RSV-color-cell.title" = "定規の色"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "前景の不透明度"; + +/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "定規を常に手前に表示"; + +/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ +"RSV-shadow-cell.title" = "定規の影を表示"; diff --git a/Free Ruler/zh-hans.lproj/PreferencesController.strings b/Free Ruler/zh-hans.lproj/PreferencesController.strings index ffc9f81..49c2a5d 100644 --- a/Free Ruler/zh-hans.lproj/PreferencesController.strings +++ b/Free Ruler/zh-hans.lproj/PreferencesController.strings @@ -1,30 +1,9 @@ -/* Class = "NSButtonCell"; title = "Reset Rulers"; ObjectID = "10f-9L-qca"; */ -"10f-9L-qca.title" = "重置尺子"; - -/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "BgV-9N-IVn"; */ -"BgV-9N-IVn.title" = "前景不透明度"; - /* Class = "NSWindow"; title = "Free Ruler Preferences"; ObjectID = "F0z-JX-Cv5"; */ "F0z-JX-Cv5.title" = "Free Ruler 偏好设置"; -/* Class = "NSButtonCell"; title = "Group rulers"; ObjectID = "N2Y-8B-L9c"; */ -"N2Y-8B-L9c.title" = "组合尺子"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "VXi-Ch-Jf5"; */ -"VXi-Ch-Jf5.title" = "标签"; - -/* Class = "NSTextFieldCell"; title = "Label"; ObjectID = "Vqd-CI-vmd"; */ -"Vqd-CI-vmd.title" = "标签"; - -/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "cRb-8z-VZj"; */ -"cRb-8z-VZj.title" = "背景不透明度"; - -/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "ydA-TR-Gwu"; */ -"ydA-TR-Gwu.title" = "尺子颜色"; - -/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "l53-85-hoA"; */ -"l53-85-hoA.title" = "显示尺子阴影"; +/* Class = "NSTextFieldCell"; title = "Default settings for new rulers"; ObjectID = "PREF-defaults-header-cell"; */ +"PREF-defaults-header-cell.title" = "新尺子的默认设置"; -/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "yPM-Cw-Qsi"; */ -"yPM-Cw-Qsi.title" = "让标尺窗口悬浮在其他应用程序之上"; +/* Class = "NSButtonCell"; title = "Reset to factory defaults"; ObjectID = "PREF-factory-cell"; */ +"PREF-factory-cell.title" = "恢复出厂默认设置"; diff --git a/Free Ruler/zh-hans.lproj/RulerSettingsController.strings b/Free Ruler/zh-hans.lproj/RulerSettingsController.strings new file mode 100644 index 0000000..c4303e3 --- /dev/null +++ b/Free Ruler/zh-hans.lproj/RulerSettingsController.strings @@ -0,0 +1,9 @@ + +/* Class = "NSButtonCell"; title = "Reset to default"; ObjectID = "RSET-reset-defaults-cell"; */ +"RSET-reset-defaults-cell.title" = "重置为默认"; + +/* Class = "NSButtonCell"; title = "Save as default"; ObjectID = "RSET-save-defaults-cell"; */ +"RSET-save-defaults-cell.title" = "保存为默认"; + +/* Class = "NSWindow"; title = "Ruler Settings"; ObjectID = "RSET-window"; */ +"RSET-window.title" = "尺子设置"; diff --git a/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings b/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings new file mode 100644 index 0000000..2f85d90 --- /dev/null +++ b/Free Ruler/zh-hans.lproj/RulerSettingsControlsView.strings @@ -0,0 +1,15 @@ + +/* Class = "NSTextFieldCell"; title = "Background Opacity"; ObjectID = "RSV-bg-cell"; */ +"RSV-bg-cell.title" = "背景不透明度"; + +/* Class = "NSTextFieldCell"; title = "Ruler Color"; ObjectID = "RSV-color-cell"; */ +"RSV-color-cell.title" = "尺子颜色"; + +/* Class = "NSTextFieldCell"; title = "Foreground Opacity"; ObjectID = "RSV-fg-cell"; */ +"RSV-fg-cell.title" = "前景不透明度"; + +/* Class = "NSButtonCell"; title = "Float rulers above other applications"; ObjectID = "RSV-float-cell"; */ +"RSV-float-cell.title" = "让标尺窗口悬浮在其他应用程序之上"; + +/* Class = "NSButtonCell"; title = "Show ruler shadow"; ObjectID = "RSV-shadow-cell"; */ +"RSV-shadow-cell.title" = "显示尺子阴影"; diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index ce3b7e0..3cf88af 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -381,6 +381,167 @@ final class RulerCoreTests: XCTestCase { } } + func testRulerSettingsControllerSetsDefaultsForNewRulers() { + withRestoredRulerPreferences { + prefs.unit = .pixels + prefs.rulerColor = NSColor(deviceRed: 0.1, green: 0.2, blue: 0.3, alpha: 1) + prefs.foregroundOpacity = 90 + prefs.backgroundOpacity = 50 + prefs.floatRulers = true + prefs.rulerShadow = false + prefs.zeroCorner = .topLeft + + let rulerColor = NSColor(deviceRed: 0.72, green: 0.24, blue: 0.44, alpha: 1) + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + unit: .inches, + rulerColor: rulerColor, + foregroundOpacity: 63, + backgroundOpacity: 37, + floatRulers: false, + rulerShadow: true, + zeroCorner: .bottomRight + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + settingsController.setDefaultsForNewRulers(settingsController.setDefaultsButton as Any) + + XCTAssertEqual(prefs.unit, .inches) + assertColor(prefs.rulerColor, equals: rulerColor) + XCTAssertEqual(prefs.foregroundOpacity, 63) + XCTAssertEqual(prefs.backgroundOpacity, 37) + XCTAssertFalse(prefs.floatRulers) + XCTAssertTrue(prefs.rulerShadow) + XCTAssertEqual(prefs.zeroCorner, .bottomRight) + } + } + + func testRulerSettingsControllerResetsRulerToDefaults() { + withRestoredRulerPreferences { + let defaultColor = NSColor(deviceRed: 0.15, green: 0.25, blue: 0.35, alpha: 1) + prefs.unit = .millimeters + prefs.rulerColor = defaultColor + prefs.foregroundOpacity = 88 + prefs.backgroundOpacity = 44 + prefs.floatRulers = true + prefs.rulerShadow = false + prefs.zeroCorner = .topRight + + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + unit: .inches, + rulerColor: NSColor(deviceRed: 0.8, green: 0.2, blue: 0.4, alpha: 1), + foregroundOpacity: 63, + backgroundOpacity: 37, + floatRulers: false, + rulerShadow: true, + zeroCorner: .bottomLeft + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + settingsController.resetToDefault(settingsController.resetDefaultsButton as Any) + + XCTAssertEqual(controller.state.settings.unit, .millimeters) + assertColor(controller.state.settings.rulerColor, equals: defaultColor) + XCTAssertEqual(controller.state.settings.foregroundOpacity, 88) + XCTAssertEqual(controller.state.settings.backgroundOpacity, 44) + XCTAssertTrue(controller.state.settings.floatRulers) + XCTAssertFalse(controller.state.settings.rulerShadow) + XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) + XCTAssertEqual(settingsController.foregroundOpacityLabel.stringValue, "88%") + XCTAssertEqual(settingsController.backgroundOpacityLabel.stringValue, "44%") + XCTAssertEqual(prefs.foregroundOpacity, 88) + } + } + + func testPreferencesControllerResetsDefaultsToFactoryDefaults() { + withRestoredRulerPreferences { + prefs.unit = .inches + prefs.rulerColor = NSColor(deviceRed: 0.7, green: 0.3, blue: 0.2, alpha: 1) + prefs.foregroundOpacity = 42 + prefs.backgroundOpacity = 21 + prefs.floatRulers = false + prefs.groupRulers = false + prefs.rulerShadow = true + prefs.zeroCorner = .bottomRight + + let preferencesController = PreferencesController() + preferencesController.loadWindow() + defer { + preferencesController.close() + } + + preferencesController.resetToFactoryDefaults(self) + + XCTAssertEqual(prefs.unit, Prefs.defaultUnit) + assertColor(prefs.rulerColor, equals: Prefs.defaultRulerFillColor) + XCTAssertEqual(prefs.foregroundOpacity, Prefs.defaultForegroundOpacity) + XCTAssertEqual(prefs.backgroundOpacity, Prefs.defaultBackgroundOpacity) + XCTAssertEqual(prefs.floatRulers, Prefs.defaultFloatRulers) + XCTAssertEqual(prefs.groupRulers, Prefs.defaultGroupRulers) + XCTAssertEqual(prefs.rulerShadow, Prefs.defaultRulerShadow) + XCTAssertEqual(prefs.zeroCorner, Prefs.defaultZeroCorner) + XCTAssertEqual(preferencesController.foregroundOpacityLabel.stringValue, "\(Prefs.defaultForegroundOpacity)%") + XCTAssertEqual(preferencesController.backgroundOpacityLabel.stringValue, "\(Prefs.defaultBackgroundOpacity)%") + XCTAssertEqual(preferencesController.floatRulersCheckbox.state, .on) + XCTAssertEqual(preferencesController.rulerShadowCheckbox.state, .off) + } + } + + func testRulerSettingsControlsKeyViewLoopFollowsVisibleControls() { + let controlsView = RulerSettingsControlsView(frame: NSRect(x: 0, y: 0, width: 315, height: 224)) + controlsView.configureForRulerSettings() + + controlsView.update( + rulerColor: Prefs.defaultRulerFillColor, + foregroundOpacity: 90, + backgroundOpacity: 50, + floatRulers: true, + rulerShadow: false + ) + + XCTAssertTrue(controlsView.rulerColorWell.nextKeyView === controlsView.foregroundOpacitySlider) + XCTAssertTrue(controlsView.foregroundOpacitySlider.nextKeyView === controlsView.backgroundOpacitySlider) + XCTAssertTrue(controlsView.backgroundOpacitySlider.nextKeyView === controlsView.floatRulersCheckbox) + XCTAssertTrue(controlsView.floatRulersCheckbox.nextKeyView === controlsView.rulerShadowCheckbox) + XCTAssertTrue(controlsView.rulerShadowCheckbox.nextKeyView === controlsView.rulerColorWell) + + controlsView.update( + rulerColor: NSColor(deviceRed: 0.6, green: 0.3, blue: 0.2, alpha: 1), + foregroundOpacity: 90, + backgroundOpacity: 50, + floatRulers: true, + rulerShadow: false + ) + + XCTAssertTrue(controlsView.rulerColorWell.nextKeyView === controlsView.resetRulerColorButton) + XCTAssertTrue(controlsView.resetRulerColorButton.nextKeyView === controlsView.foregroundOpacitySlider) + } + func testRulerSettingsControllerPresentsAsAttachedSheetOnRulerWindow() { let controller = GroupedRulerController( state: RulerInstanceState( @@ -441,7 +602,7 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(controller.groupedWindow.alphaValue, 0.8, accuracy: 0.0001) } - func testRulerSettingsControllerCloseButtonClosesAttachedSheet() { + func testRulerSettingsControllerTitlebarCloseClosesAttachedSheet() { let controller = GroupedRulerController( state: RulerInstanceState( settings: RulerSettings(), @@ -465,7 +626,9 @@ final class RulerCoreTests: XCTestCase { return } - settingsController.closeButton.performClick(self) + XCTAssertTrue(settingsWindow.styleMask.contains(.closable)) + + settingsWindow.performClose(self) XCTAssertFalse(controller.groupedWindow.childWindows?.contains(settingsWindow) ?? false) XCTAssertFalse(settingsWindow.isVisible) @@ -3081,6 +3244,7 @@ final class RulerCoreTests: XCTestCase { let previousForegroundOpacity = prefs.foregroundOpacity let previousBackgroundOpacity = prefs.backgroundOpacity let previousFloatRulers = prefs.floatRulers + let previousGroupRulers = prefs.groupRulers let previousRulerShadow = prefs.rulerShadow let previousZeroCorner = prefs.zeroCorner @@ -3090,6 +3254,7 @@ final class RulerCoreTests: XCTestCase { prefs.foregroundOpacity = previousForegroundOpacity prefs.backgroundOpacity = previousBackgroundOpacity prefs.floatRulers = previousFloatRulers + prefs.groupRulers = previousGroupRulers prefs.rulerShadow = previousRulerShadow prefs.zeroCorner = previousZeroCorner } From 2f0b000b1aef4092c433c869d23f0b4d33c64bae Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 00:12:12 -0400 Subject: [PATCH 04/10] Add color change handling and key equivalent support in Ruler Settings --- .../Base.lproj/RulerSettingsController.xib | 10 +- .../Base.lproj/RulerSettingsControlsView.xib | 2 +- Free Ruler/PreferencesController.swift | 127 ++++++++++++++++++ FreeRulerTests/RulerCoreTests.swift | 109 +++++++++++++++ 4 files changed, 242 insertions(+), 6 deletions(-) diff --git a/Free Ruler/Base.lproj/RulerSettingsController.xib b/Free Ruler/Base.lproj/RulerSettingsController.xib index 4011c5c..4632c4a 100644 --- a/Free Ruler/Base.lproj/RulerSettingsController.xib +++ b/Free Ruler/Base.lproj/RulerSettingsController.xib @@ -16,16 +16,16 @@ - + - + - + - + - + diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index ff95829..a24065d 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -38,6 +38,8 @@ private func setColorPickingIgnoresAlpha(_ ignoresAlpha: Bool) { class RulerColorWell: NSColorWell { + var colorDidChange: ((RulerColorWell) -> Void)? + override func awakeFromNib() { super.awakeFromNib() configureForOpaqueColors() @@ -49,6 +51,23 @@ class RulerColorWell: NSColorWell { configureForOpaqueColors() } + override func takeColorFrom(_ sender: Any?) { + if let colorPanel = sender as? NSColorPanel { + color = colorPanel.color + } else if let colorWell = sender as? NSColorWell { + color = colorWell.color + } else { + super.takeColorFrom(sender) + } + configureForOpaqueColors() + needsDisplay = true + if let colorDidChange = colorDidChange { + colorDidChange(self) + } else { + sendAction(action, to: target) + } + } + override func mouseDown(with event: NSEvent) { configureForOpaqueColors() @@ -147,6 +166,14 @@ final class RulerSettingsControlsView: NSView { loadContentView() } + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if performRulerSettingsKeyEquivalent(with: event) { + return true + } + + return super.performKeyEquivalent(with: event) + } + func configureForPreferences() { configureControls( colorWellIdentifier: "ruler-color-well", @@ -158,6 +185,7 @@ final class RulerSettingsControlsView: NSView { floatCheckboxIdentifier: "float-rulers-checkbox", shadowCheckboxIdentifier: "ruler-shadow-checkbox" ) + configureCheckboxKeyEquivalents(float: "", shadow: "") } func configureForRulerSettings() { @@ -171,6 +199,7 @@ final class RulerSettingsControlsView: NSView { floatCheckboxIdentifier: "ruler-settings-float-rulers-checkbox", shadowCheckboxIdentifier: "ruler-settings-ruler-shadow-checkbox" ) + configureCheckboxKeyEquivalents(float: "f", shadow: "s") } func update( @@ -205,6 +234,26 @@ final class RulerSettingsControlsView: NSView { configureKeyViewLoop() } + func performRulerSettingsKeyEquivalent(with event: NSEvent) -> Bool { + guard event.type == .keyDown, + event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.shift, .capsLock, .function]) + .isEmpty, + let character = event.charactersIgnoringModifiers?.lowercased() else { + return false + } + + switch character { + case floatRulersCheckbox.keyEquivalent.lowercased() where !floatRulersCheckbox.keyEquivalent.isEmpty: + return toggleFloatRulersFromKeyEquivalent() + case rulerShadowCheckbox.keyEquivalent.lowercased() where !rulerShadowCheckbox.keyEquivalent.isEmpty: + return toggleRulerShadowFromKeyEquivalent() + default: + return false + } + } + func deactivateColorWell() { rulerColorWell.deactivate() } @@ -235,6 +284,10 @@ final class RulerSettingsControlsView: NSView { private func configureBaseControls() { rulerColorWell.isContinuous = true rulerColorWell.supportsAlpha = false + rulerColorWell.colorDidChange = { [weak self] _ in + guard let self = self else { return } + self.setRulerColor(self.rulerColorWell as Any) + } rulerColorWell.target = self rulerColorWell.action = #selector(setRulerColor(_:)) @@ -298,6 +351,13 @@ final class RulerSettingsControlsView: NSView { rulerShadowCheckbox.setAccessibilityIdentifier(shadowCheckboxIdentifier) } + private func configureCheckboxKeyEquivalents(float: String, shadow: String) { + floatRulersCheckbox.keyEquivalent = float + floatRulersCheckbox.keyEquivalentModifierMask = [] + rulerShadowCheckbox.keyEquivalent = shadow + rulerShadowCheckbox.keyEquivalentModifierMask = [] + } + private func configureKeyViewLoop() { rulerColorWell.nextKeyView = resetRulerColorButton.isHidden ? foregroundOpacitySlider @@ -309,6 +369,22 @@ final class RulerSettingsControlsView: NSView { rulerShadowCheckbox.nextKeyView = rulerColorWell } + private func toggleFloatRulersFromKeyEquivalent() -> Bool { + guard floatRulersCheckbox.isEnabled else { return false } + + floatRulersCheckbox.state = floatRulersCheckbox.state == .on ? .off : .on + setFloatRulers(floatRulersCheckbox as Any) + return true + } + + private func toggleRulerShadowFromKeyEquivalent() -> Bool { + guard rulerShadowCheckbox.isEnabled else { return false } + + rulerShadowCheckbox.state = rulerShadowCheckbox.state == .on ? .off : .on + setRulerShadow(rulerShadowCheckbox as Any) + return true + } + @objc private func setRulerColor(_ sender: Any) { delegate?.rulerSettingsControlsDidChangeRulerColor(self) } @@ -545,10 +621,27 @@ extension PreferencesController: RulerSettingsControlsViewDelegate { } } +final class RulerSettingsWindow: NSPanel { + weak var settingsController: RulerSettingsController? + + override var canBecomeKey: Bool { + return true + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if settingsController?.performSettingsKeyEquivalent(with: event) == true { + return true + } + + return super.performKeyEquivalent(with: event) + } +} + final class RulerSettingsController: NSWindowController, NSWindowDelegate { private weak var rulerController: GroupedRulerController? private var colorPanelObserver: NSObjectProtocol? + private var didConfigureWindow = false @IBOutlet weak var settingsControlsView: RulerSettingsControlsView! @IBOutlet weak var resetDefaultsButton: NSButton! @@ -598,6 +691,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { self.rulerController = rulerController super.init(window: nil) loadWindow() + configureWindowIfNeeded() } required init?(coder: NSCoder) { @@ -607,14 +701,26 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { override func windowDidLoad() { super.windowDidLoad() + configureWindowIfNeeded() + } + + private func configureWindowIfNeeded() { + guard !didConfigureWindow, + isWindowLoaded, + settingsControlsView != nil else { return } + + didConfigureWindow = true window?.delegate = self window?.identifier = NSUserInterfaceItemIdentifier("ruler-settings-window") window?.setAccessibilityIdentifier("ruler-settings-window") window?.isMovableByWindowBackground = true window?.isReleasedWhenClosed = false window?.initialFirstResponder = rulerColorWell + configureFloatingPanelWindow() settingsControlsView.delegate = self settingsControlsView.configureForRulerSettings() + rulerColorWell.target = self + rulerColorWell.action = #selector(setRulerColor(_:)) resetDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-ruler-settings-to-default-button") resetDefaultsButton.setAccessibilityIdentifier("reset-ruler-settings-to-default-button") setDefaultsButton.identifier = NSUserInterfaceItemIdentifier("save-ruler-settings-as-default-button") @@ -741,6 +847,10 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } func updateView() { + configureWindowIfNeeded() + guard isWindowLoaded, + settingsControlsView != nil else { return } + let currentSettings = rulerController?.state.settings let hasRuler = rulerController != nil @@ -756,6 +866,23 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { setDefaultsButton.isEnabled = hasRuler } + func performSettingsKeyEquivalent(with event: NSEvent) -> Bool { + return settingsControlsView.performRulerSettingsKeyEquivalent(with: event) + } + + private func configureFloatingPanelWindow() { + guard let settingsWindow = window else { return } + + settingsWindow.styleMask.insert(.utilityWindow) + settingsWindow.animationBehavior = .utilityWindow + + guard let panel = settingsWindow as? RulerSettingsWindow else { return } + + panel.settingsController = self + panel.isFloatingPanel = true + panel.hidesOnDeactivate = false + } + private func applyRulerColor(_ color: NSColor) { applySettings { settings in settings.setRulerColor(color) diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 3cf88af..1dbbf70 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -478,6 +478,69 @@ final class RulerCoreTests: XCTestCase { } } + func testRulerSettingsControllerAppliesColorPanelChangesToActiveRuler() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings( + rulerColor: NSColor(deviceRed: 0.2, green: 0.3, blue: 0.4, alpha: 1) + ), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + closeRulerColorPanel() + } + + let selectedColor = NSColor(deviceRed: 0.7, green: 0.1, blue: 0.5, alpha: 0.35) + NSColorPanel.shared.color = selectedColor + settingsController.rulerColorWell.takeColorFrom(NSColorPanel.shared) + + let normalizedColor = NSColor(deviceRed: 0.7, green: 0.1, blue: 0.5, alpha: 1) + assertColor(controller.state.settings.rulerColor, equals: normalizedColor) + assertColor(controller.groupedWindow.horizontalRule.color.fill, equals: normalizedColor) + assertColor(settingsController.rulerColorWell.color, equals: normalizedColor) + } + + func testRulerSettingsControllerCheckboxKeyEquivalentsToggleFloatAndShadow() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(floatRulers: false, rulerShadow: false), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + let floatEvent = keyDownEvent(characters: "f", keyCode: 3) + let shadowEvent = keyDownEvent(characters: "s", keyCode: 1) + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + + XCTAssertTrue(settingsWindow.performKeyEquivalent(with: floatEvent)) + XCTAssertTrue(controller.state.settings.floatRulers) + XCTAssertTrue(settingsController.floatRulersCheckbox.state == .on) + + XCTAssertTrue(settingsWindow.performKeyEquivalent(with: shadowEvent)) + XCTAssertTrue(controller.state.settings.rulerShadow) + XCTAssertTrue(settingsController.rulerShadowCheckbox.state == .on) + } + func testPreferencesControllerResetsDefaultsToFactoryDefaults() { withRestoredRulerPreferences { prefs.unit = .inches @@ -570,6 +633,37 @@ final class RulerCoreTests: XCTestCase { XCTAssertNil(settingsWindow.sheetParent) } + func testRulerSettingsControllerUsesFloatingUtilityPanelStyle() { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + + XCTAssertTrue(settingsWindow is NSPanel) + XCTAssertTrue(settingsWindow.styleMask.contains(.utilityWindow)) + XCTAssertEqual(settingsWindow.animationBehavior, .utilityWindow) + + let settingsPanel = settingsWindow as? NSPanel + XCTAssertTrue(settingsPanel?.isFloatingPanel ?? false) + XCTAssertFalse(settingsPanel?.hidesOnDeactivate ?? true) + } + func testRulerSettingsControllerRestoresForegroundOpacityWhenClosingSheet() { let controller = GroupedRulerController( state: RulerInstanceState( @@ -3262,6 +3356,21 @@ final class RulerCoreTests: XCTestCase { try test() } + private func keyDownEvent(characters: String, keyCode: UInt16) -> NSEvent { + return NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + )! + } + private func withRestoredRulerSetState(_ test: () throws -> Void) rethrows { let defaults = UserDefaults.standard let previousState = defaults.object(forKey: Prefs.rulerSetStateKey) From f129bb5f533f225ce63a643caa9cdd589b261c24 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 00:35:55 -0400 Subject: [PATCH 05/10] Enhance ruler settings functionality with color panel attachment and state management updates --- Free Ruler/AppDelegate.swift | 13 +- Free Ruler/GroupedRulerWindow.swift | 1 + Free Ruler/PreferencesController.swift | 178 ++++++++++++++---- FreeRulerTests/RulerCoreTests.swift | 242 +++++++++++++++++++++++++ 4 files changed, 399 insertions(+), 35 deletions(-) diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 0956b4e..7f7ed70 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -79,8 +79,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { settingsController.close() } } - manager.onStateChanged = { [weak self] _ in - self?.saveRulerSetState() + manager.onStateChanged = { [weak self] manager in + guard let self = self else { return } + + self.saveRulerSetState() + + let activeController = manager.activeController + guard let settingsController = self.rulerSettingsController, + settingsController.currentRulerController === activeController, + settingsController.window?.isVisible == true else { return } + + settingsController.updateView() } return manager }() diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index e260023..57366ce 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -1264,6 +1264,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi updateHasShadow() groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) captureStateFromWindow() + notifyStateChanged() } func foreground() { diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index a24065d..96c3481 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -39,6 +39,7 @@ private func setColorPickingIgnoresAlpha(_ ignoresAlpha: Bool) { class RulerColorWell: NSColorWell { var colorDidChange: ((RulerColorWell) -> Void)? + var colorPanelPresenter: ((RulerColorWell, NSColorPanel) -> Void)? override func awakeFromNib() { super.awakeFromNib() @@ -47,10 +48,20 @@ class RulerColorWell: NSColorWell { override func activate(_ exclusive: Bool) { configureForOpaqueColors() - super.activate(exclusive) + openColorPanel() configureForOpaqueColors() } + override func keyDown(with event: NSEvent) { + guard event.charactersIgnoringModifiers == " " else { + super.keyDown(with: event) + return + } + + configureForOpaqueColors() + openColorPanel() + } + override func takeColorFrom(_ sender: Any?) { if let colorPanel = sender as? NSColorPanel { color = colorPanel.color @@ -70,8 +81,12 @@ class RulerColorWell: NSColorWell { override func mouseDown(with event: NSEvent) { configureForOpaqueColors() + openColorPanel() + } + private func openColorPanel() { let colorPanel = NSColorPanel.shared + guard !colorPanel.isVisible else { closeRulerColorPanel() return @@ -82,7 +97,11 @@ class RulerColorWell: NSColorWell { colorPanel.color = color colorPanel.setTarget(self) colorPanel.setAction(#selector(takeColorFrom(_:))) - colorPanel.orderFront(self) + if let colorPanelPresenter = colorPanelPresenter { + colorPanelPresenter(self, colorPanel) + } else { + colorPanel.orderFront(self) + } configureForOpaqueColors() configureOpaqueColorPickingAfterPanelUpdates() } @@ -186,6 +205,7 @@ final class RulerSettingsControlsView: NSView { shadowCheckboxIdentifier: "ruler-shadow-checkbox" ) configureCheckboxKeyEquivalents(float: "", shadow: "") + rulerColorWell.colorPanelPresenter = nil } func configureForRulerSettings() { @@ -719,6 +739,9 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { configureFloatingPanelWindow() settingsControlsView.delegate = self settingsControlsView.configureForRulerSettings() + rulerColorWell.colorPanelPresenter = { [weak self] colorWell, colorPanel in + self?.presentColorPanel(colorPanel, for: colorWell) + } rulerColorWell.target = self rulerColorWell.action = #selector(setRulerColor(_:)) resetDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-ruler-settings-to-default-button") @@ -864,6 +887,7 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { ) resetDefaultsButton.isEnabled = hasRuler setDefaultsButton.isEnabled = hasRuler + repositionAttachedWindowsIfNeeded() } func performSettingsKeyEquivalent(with event: NSEvent) -> Bool { @@ -883,6 +907,86 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { panel.hidesOnDeactivate = false } + func presentColorPanel(_ colorPanel: NSColorPanel, for colorWell: RulerColorWell) { + guard let settingsWindow = window else { + colorPanel.orderFront(colorWell) + return + } + + if let parentWindow = colorPanel.parent, parentWindow !== settingsWindow { + parentWindow.removeChildWindow(colorPanel) + } + + position(colorPanel, attachedTo: settingsWindow) + + if colorPanel.parent == nil { + settingsWindow.addChildWindow(colorPanel, ordered: .above) + } + + colorPanel.orderFront(colorWell) + } + + private func position(_ colorPanel: NSColorPanel, attachedTo settingsWindow: NSWindow) { + let margin: CGFloat = 8 + let zeroCorner = rulerController?.state.settings.zeroCorner ?? Prefs.defaultZeroCorner + let colorPanelSize = colorPanel.frame.size + let defaultTopLeft = colorPanelTopLeftPoint( + for: colorPanelSize, + attachedTo: settingsWindow.frame, + zeroCorner: zeroCorner, + margin: margin + ) + guard let visibleFrame = settingsWindow.screen?.visibleFrame ?? colorPanel.screen?.visibleFrame else { + colorPanel.setFrameTopLeftPoint(defaultTopLeft) + return + } + + var topLeftPoint = defaultTopLeft + if topLeftPoint.x < visibleFrame.minX { + topLeftPoint.x = min(settingsWindow.frame.maxX + margin, visibleFrame.maxX - colorPanelSize.width) + } else if topLeftPoint.x + colorPanelSize.width > visibleFrame.maxX { + topLeftPoint.x = max(settingsWindow.frame.minX - colorPanelSize.width - margin, visibleFrame.minX) + } + + if colorPanelSize.height <= visibleFrame.height { + topLeftPoint.y = clamp( + topLeftPoint.y, + lower: visibleFrame.minY + colorPanelSize.height, + upper: visibleFrame.maxY + ) + } else { + topLeftPoint.y = visibleFrame.maxY + } + + colorPanel.setFrameTopLeftPoint(topLeftPoint) + } + + private func colorPanelTopLeftPoint( + for colorPanelSize: NSSize, + attachedTo settingsFrame: NSRect, + zeroCorner: ZeroCorner, + margin: CGFloat + ) -> NSPoint { + let x: CGFloat + let y: CGFloat + + switch zeroCorner { + case .topLeft, .bottomLeft: + x = settingsFrame.maxX + margin + case .topRight, .bottomRight: + x = settingsFrame.minX - colorPanelSize.width - margin + } + + switch zeroCorner { + case .topLeft, .topRight: + y = settingsFrame.maxY + case .bottomLeft, .bottomRight: + y = settingsFrame.minY + colorPanelSize.height + } + + return NSPoint(x: x, y: y) + } + private func applyRulerColor(_ color: NSColor) { applySettings { settings in settings.setRulerColor(color) @@ -890,6 +994,20 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { updateView() } + private func repositionAttachedWindowsIfNeeded() { + guard let controller = rulerController, + let settingsWindow = window, + settingsWindow.isVisible, + settingsWindow.parent === controller.groupedWindow else { return } + + position(settingsWindow, attachedTo: controller) + + let colorPanel = NSColorPanel.shared + if colorPanel.parent === settingsWindow { + position(colorPanel, attachedTo: settingsWindow) + } + } + private func applySettings(_ update: (inout RulerSettings) -> Void) { rulerController?.updateSettings(update) } @@ -914,42 +1032,33 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } private func position(_ settingsWindow: NSWindow, attachedTo controller: GroupedRulerController) { - let parentFrame = controller.groupedWindow.frame let settingsSize = settingsWindow.frame.size - let margin: CGFloat = 12 - let frame: NSRect - - if controller.state.visibility.showsHorizontal { - let horizontalFrame = controller.groupedWindow.screenFrame(for: .horizontal) - let x = clamp( - horizontalFrame.midX - settingsSize.width / 2, - lower: parentFrame.minX + margin, - upper: parentFrame.maxX - settingsSize.width - margin - ) - let belowY = horizontalFrame.minY - settingsSize.height - margin - let aboveY = horizontalFrame.maxY + margin - let y = belowY >= parentFrame.minY + margin - ? belowY - : min(aboveY, parentFrame.maxY - settingsSize.height - margin) - frame = NSRect(origin: NSPoint(x: x, y: y), size: settingsSize) - } else { - let verticalFrame = controller.groupedWindow.screenFrame(for: .vertical) - let y = clamp( - verticalFrame.midY - settingsSize.height / 2, - lower: parentFrame.minY + margin, - upper: parentFrame.maxY - settingsSize.height - margin - ) - let rightX = verticalFrame.maxX + margin - let leftX = verticalFrame.minX - settingsSize.width - margin - let x = rightX + settingsSize.width <= parentFrame.maxX - margin - ? rightX - : max(leftX, parentFrame.minX + margin) - frame = NSRect(origin: NSPoint(x: x, y: y), size: settingsSize) - } + let frame = settingsFrame( + size: settingsSize, + zeroPoint: controller.groupedWindow.zeroPoint(), + zeroCorner: controller.state.settings.zeroCorner + ) settingsWindow.setFrame(frame, display: true) } + private func settingsFrame(size: NSSize, zeroPoint: NSPoint, zeroCorner: ZeroCorner) -> NSRect { + let origin: NSPoint + + switch zeroCorner { + case .topLeft: + origin = NSPoint(x: zeroPoint.x, y: zeroPoint.y - size.height) + case .topRight: + origin = NSPoint(x: zeroPoint.x - size.width, y: zeroPoint.y - size.height) + case .bottomLeft: + origin = zeroPoint + case .bottomRight: + origin = NSPoint(x: zeroPoint.x - size.width, y: zeroPoint.y) + } + + return NSRect(origin: origin, size: size) + } + private func clamp(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { guard lower <= upper else { return lower } @@ -1005,6 +1114,9 @@ extension RulerSettingsController: RulerSettingsControlsViewDelegate { func closeRulerColorPanel() { activeRulerColorWell = nil let colorPanel = NSColorPanel.shared + if let parentWindow = colorPanel.parent { + parentWindow.removeChildWindow(colorPanel) + } colorPanel.animationBehavior = .none colorPanel.setTarget(nil) colorPanel.setAction(nil) diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 1dbbf70..da891a5 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -633,6 +633,53 @@ final class RulerCoreTests: XCTestCase { XCTAssertNil(settingsWindow.sheetParent) } + func testRulerSettingsControllerAnchorsPanelCornerToRulerZeroPoint() { + let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900) + let zeroPoint = NSPoint(x: visibleFrame.midX, y: visibleFrame.midY) + + for zeroCorner in [ZeroCorner.topLeft, .topRight, .bottomLeft, .bottomRight] { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(zeroCorner: zeroCorner), + layout: RulerLayoutState( + zeroPoint: zeroPoint, + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + controller.show() + settingsController.show(attachedTo: controller, sender: self) + + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + + let rulerZeroPoint = controller.groupedWindow.zeroPoint() + switch zeroCorner { + case .topLeft: + XCTAssertEqual(settingsWindow.frame.minX, rulerZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.maxY, rulerZeroPoint.y, accuracy: 1) + case .topRight: + XCTAssertEqual(settingsWindow.frame.maxX, rulerZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.maxY, rulerZeroPoint.y, accuracy: 1) + case .bottomLeft: + XCTAssertEqual(settingsWindow.frame.minX, rulerZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.minY, rulerZeroPoint.y, accuracy: 1) + case .bottomRight: + XCTAssertEqual(settingsWindow.frame.maxX, rulerZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.minY, rulerZeroPoint.y, accuracy: 1) + } + } + } + func testRulerSettingsControllerUsesFloatingUtilityPanelStyle() { let controller = GroupedRulerController( state: RulerInstanceState( @@ -664,6 +711,32 @@ final class RulerCoreTests: XCTestCase { XCTAssertFalse(settingsPanel?.hidesOnDeactivate ?? true) } + func testRulerSettingsColorPanelAttachesOnRightForLeftZeroCorner() { + assertRulerSettingsColorPanelAttachesToSettingsPanel(zeroCorner: .topLeft) { settingsController, settingsWindow in + settingsController.rulerColorWell.mouseDown( + with: mouseDownEvent(windowNumber: settingsWindow.windowNumber) + ) + } + } + + func testRulerSettingsColorPanelActivatedByKeyboardUsesAnchoredPlacement() { + assertRulerSettingsColorPanelAttachesToSettingsPanel(zeroCorner: .topLeft) { settingsController, _ in + settingsController.rulerColorWell.keyDown( + with: keyDownEvent(characters: " ", keyCode: UInt16(kVK_Space)) + ) + } + } + + func testRulerSettingsColorPanelAttachesOnLeftForRightZeroCorners() { + for zeroCorner in [ZeroCorner.topRight, .bottomRight] { + assertRulerSettingsColorPanelAttachesToSettingsPanel(zeroCorner: zeroCorner) { settingsController, settingsWindow in + settingsController.rulerColorWell.mouseDown( + with: mouseDownEvent(windowNumber: settingsWindow.windowNumber) + ) + } + } + } + func testRulerSettingsControllerRestoresForegroundOpacityWhenClosingSheet() { let controller = GroupedRulerController( state: RulerInstanceState( @@ -728,6 +801,49 @@ final class RulerCoreTests: XCTestCase { XCTAssertFalse(settingsWindow.isVisible) } + func testRulerSettingsControllerReanchorsWhenRulerZeroCornerChanges() { + withRestoredRulerPreferences { + withRestoredRulerSetState { + let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900) + let zeroPoint = NSPoint(x: visibleFrame.midX, y: visibleFrame.midY) + let appDelegate = AppDelegate() + let controller = appDelegate.rulerManager.addRuler( + state: RulerInstanceState( + settings: RulerSettings(zeroCorner: .topLeft), + layout: RulerLayoutState( + zeroPoint: zeroPoint, + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + defer { + appDelegate.rulerSettingsController?.close() + controller.hide() + } + + controller.show() + appDelegate.openRulerSettings(self) + + guard let settingsWindow = appDelegate.rulerSettingsController?.window else { + XCTFail("Expected settings window") + return + } + + let initialZeroPoint = controller.groupedWindow.zeroPoint() + XCTAssertEqual(settingsWindow.frame.minX, initialZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.maxY, initialZeroPoint.y, accuracy: 1) + + appDelegate.flipRulers(along: .horizontal) + + let flippedZeroPoint = controller.groupedWindow.zeroPoint() + XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) + XCTAssertEqual(settingsWindow.frame.maxX, flippedZeroPoint.x, accuracy: 1) + XCTAssertEqual(settingsWindow.frame.maxY, flippedZeroPoint.y, accuracy: 1) + } + } + } + func testRulerManagerCopiesUpdatedDefaultsOnlyForNewRulers() { withRestoredRulerPreferences { prefs.unit = .pixels @@ -3371,6 +3487,132 @@ final class RulerCoreTests: XCTestCase { )! } + private func assertRulerSettingsColorPanelAttachesToSettingsPanel( + zeroCorner: ZeroCorner, + openingColorPanel: (RulerSettingsController, NSWindow) -> Void, + file: StaticString = #filePath, + line: UInt = #line + ) { + let controller = GroupedRulerController( + state: RulerInstanceState( + settings: RulerSettings(zeroCorner: zeroCorner), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + let colorPanel = NSColorPanel.shared + closeRulerColorPanel() + let originalColorPanelFrame = colorPanel.frame + defer { + settingsController.close() + controller.hide() + closeRulerColorPanel() + colorPanel.setFrame(originalColorPanelFrame, display: false) + } + + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window", file: file, line: line) + return + } + + let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900) + settingsWindow.setFrame( + NSRect( + x: visibleFrame.minX + 60, + y: visibleFrame.maxY - 340, + width: 320, + height: 300 + ), + display: false + ) + settingsWindow.orderFront(self) + + let colorPanelSize = colorPanel.frame.size + let expectedFrame = settingsWindow.screen?.visibleFrame ?? colorPanel.screen?.visibleFrame + let expectedX: CGFloat + let expectedMaxY: CGFloat + if let expectedFrame = expectedFrame { + var expectedTopLeft = expectedColorPanelTopLeftPoint( + colorPanelSize: colorPanelSize, + settingsFrame: settingsWindow.frame, + zeroCorner: zeroCorner + ) + if expectedTopLeft.x < expectedFrame.minX { + expectedTopLeft.x = min(settingsWindow.frame.maxX + 8, expectedFrame.maxX - colorPanelSize.width) + } else if expectedTopLeft.x + colorPanelSize.width > expectedFrame.maxX { + expectedTopLeft.x = max(settingsWindow.frame.minX - colorPanelSize.width - 8, expectedFrame.minX) + } + if colorPanelSize.height <= expectedFrame.height { + expectedTopLeft.y = min( + max(expectedTopLeft.y, expectedFrame.minY + colorPanelSize.height), + expectedFrame.maxY + ) + } else { + expectedTopLeft.y = expectedFrame.maxY + } + expectedX = expectedTopLeft.x + expectedMaxY = expectedTopLeft.y + } else { + let expectedTopLeft = expectedColorPanelTopLeftPoint( + colorPanelSize: colorPanelSize, + settingsFrame: settingsWindow.frame, + zeroCorner: zeroCorner + ) + expectedX = expectedTopLeft.x + expectedMaxY = expectedTopLeft.y + } + + openingColorPanel(settingsController, settingsWindow) + + XCTAssertTrue(colorPanel.parent === settingsWindow, file: file, line: line) + XCTAssertTrue(settingsWindow.childWindows?.contains(colorPanel) ?? false, file: file, line: line) + XCTAssertEqual(colorPanel.frame.minX, expectedX, accuracy: 1, file: file, line: line) + XCTAssertEqual(colorPanel.frame.maxY, expectedMaxY, accuracy: 1, file: file, line: line) + } + + private func expectedColorPanelTopLeftPoint( + colorPanelSize: NSSize, + settingsFrame: NSRect, + zeroCorner: ZeroCorner + ) -> NSPoint { + let x: CGFloat + let y: CGFloat + + switch zeroCorner { + case .topLeft, .bottomLeft: + x = settingsFrame.maxX + 8 + case .topRight, .bottomRight: + x = settingsFrame.minX - colorPanelSize.width - 8 + } + + switch zeroCorner { + case .topLeft, .topRight: + y = settingsFrame.maxY + case .bottomLeft, .bottomRight: + y = settingsFrame.minY + colorPanelSize.height + } + + return NSPoint(x: x, y: y) + } + + private func mouseDownEvent(windowNumber: Int) -> NSEvent { + return NSEvent.mouseEvent( + with: .leftMouseDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + )! + } + private func withRestoredRulerSetState(_ test: () throws -> Void) rethrows { let defaults = UserDefaults.standard let previousState = defaults.object(forKey: Prefs.rulerSetStateKey) From 2163c346424d7dfec37061531dda3139da34be4c Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 00:58:21 -0400 Subject: [PATCH 06/10] Refactor mouse tick transformations in Horizontal and Vertical rules for improved accuracy and consistency; update related test cases to reflect changes. --- .../Base.lproj/RulerSettingsControlsView.xib | 2 +- Free Ruler/HorizontalRule.swift | 9 +++--- Free Ruler/VerticalRule.swift | 9 +++--- FreeRulerTests/RulerCoreTests.swift | 26 ++++++++++++++---- .../ruler-mouse-tick-labels.png | Bin 14006 -> 13956 bytes 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib index 79721e9..7ca5a8d 100644 --- a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib +++ b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib @@ -44,7 +44,7 @@ - + diff --git a/Free Ruler/HorizontalRule.swift b/Free Ruler/HorizontalRule.swift index a526f77..498ffb7 100644 --- a/Free Ruler/HorizontalRule.swift +++ b/Free Ruler/HorizontalRule.swift @@ -3,6 +3,7 @@ import Cocoa class HorizontalRule: RuleView { let transformer = AffineTransform(translationByX: 0.5, byY: 0) + let mouseTickTransformer = AffineTransform(translationByX: -0.5, byY: 0) override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -131,7 +132,7 @@ class HorizontalRule: RuleView { mouseTick.move(to: CGPoint(x: lineX, y: 0)) mouseTick.line(to: CGPoint(x: lineX, y: height)) - mouseTick.transform(using: transformer) + mouseTick.transform(using: mouseTickTransformer) color.mouseTick.setStroke() mouseTick.stroke() @@ -146,7 +147,7 @@ class HorizontalRule: RuleView { case .positive: zeroTickX = bounds.minX case .negative: - zeroTickX = bounds.maxX + zeroTickX = bounds.maxX - 1 } let lineX = mouseTickLineX(forTickX: zeroTickX, growthDirection: growthDirection) @@ -282,7 +283,7 @@ class HorizontalRule: RuleView { case .positive: return mouseTickX case .negative: - return rulerWidth - mouseTickX + return max(0, rulerWidth - mouseTickX - 1) } } @@ -294,7 +295,7 @@ class HorizontalRule: RuleView { case .positive: return mouseTickX case .negative: - return mouseTickX - 1 + return mouseTickX } } diff --git a/Free Ruler/VerticalRule.swift b/Free Ruler/VerticalRule.swift index 8125b5e..74560d4 100644 --- a/Free Ruler/VerticalRule.swift +++ b/Free Ruler/VerticalRule.swift @@ -3,6 +3,7 @@ import Cocoa class VerticalRule: RuleView { let transformer = AffineTransform(translationByX: 0, byY: -0.5) + let mouseTickTransformer = AffineTransform(translationByX: 0, byY: 0.5) override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -136,7 +137,7 @@ class VerticalRule: RuleView { mouseTick.move(to: CGPoint(x: startX, y: lineY)) mouseTick.line(to: CGPoint(x: rulerWidth, y: lineY)) - mouseTick.transform(using: transformer) + mouseTick.transform(using: mouseTickTransformer) color.mouseTick.setStroke() mouseTick.stroke() @@ -149,7 +150,7 @@ class VerticalRule: RuleView { switch growthDirection { case .positive: - zeroTickY = bounds.minY + zeroTickY = bounds.minY + 1 case .negative: zeroTickY = bounds.maxY } @@ -322,7 +323,7 @@ class VerticalRule: RuleView { switch growthDirection { case .positive: - return mouseTickY + return max(0, mouseTickY - 1) case .negative: return rulerHeight - mouseTickY } @@ -334,7 +335,7 @@ class VerticalRule: RuleView { ) -> CGFloat { switch growthDirection { case .positive: - return mouseTickY + 1 + return mouseTickY case .negative: return mouseTickY } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index da891a5..6cbeb9b 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -1660,7 +1660,7 @@ final class RulerCoreTests: XCTestCase { ) XCTAssertEqual( rule.mouseTickLineX(forTickX: 299, growthDirection: .negative), - 298 + 299 ) let bottomTick = rule.tickLine(forX: 50, length: 10, rulerHeight: 40, tickSide: .bottom) @@ -1687,7 +1687,14 @@ final class RulerCoreTests: XCTestCase { prefs.zeroCorner = .bottomRight let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - XCTAssertEqual(rule.mouseNumber(forTickX: 260, rulerWidth: 300), 40) + XCTAssertEqual(rule.mouseNumber(forTickX: 259, rulerWidth: 300), 40) + XCTAssertEqual( + rule.mouseNumber( + forTickX: rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative), + rulerWidth: 300 + ), + 50 + ) XCTAssertEqual( rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 300, height: 40)), CGRect(x: 280, y: 0, width: 20, height: 19) @@ -1712,7 +1719,7 @@ final class RulerCoreTests: XCTestCase { ) XCTAssertEqual( rule.mouseTickLineY(forTickY: 1, growthDirection: .positive), - 2 + 1 ) let rightTick = rule.tickLine(forY: 250, length: 10, rulerWidth: 40, tickSide: .right) @@ -1739,7 +1746,14 @@ final class RulerCoreTests: XCTestCase { prefs.zeroCorner = .bottomRight let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) - XCTAssertEqual(rule.mouseNumber(forTickY: 40, rulerHeight: 300), 40) + XCTAssertEqual(rule.mouseNumber(forTickY: 41, rulerHeight: 300), 40) + XCTAssertEqual( + rule.mouseNumber( + forTickY: rule.tickY(forOffset: 50, rulerHeight: 300, growthDirection: .positive), + rulerHeight: 300 + ), + 50 + ) XCTAssertEqual( rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 40, height: 300)), CGRect(x: 20, y: 0, width: 20, height: 19) @@ -1954,8 +1968,8 @@ final class RulerCoreTests: XCTestCase { let verticalRule = VerticalRule( frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300) ) - let horizontalNumber = horizontalRule.mouseNumber(forTickX: 260, rulerWidth: 300) - let verticalNumber = verticalRule.mouseNumber(forTickY: 40, rulerHeight: 300) + let horizontalNumber = horizontalRule.mouseNumber(forTickX: 259, rulerWidth: 300) + let verticalNumber = verticalRule.mouseNumber(forTickY: 41, rulerHeight: 300) let horizontalDpmm = horizontalRule.screen?.dpmm.width ?? NSScreen.defaultDpmm let verticalDpmm = verticalRule.screen?.dpmm.width ?? NSScreen.defaultDpmm let horizontalDpi = horizontalRule.screen?.dpi.width ?? NSScreen.defaultDpi diff --git a/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png b/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png index a825cbb3656e3af26a5d49f61ab35cdf743f22f5..ff23f589ca62d17d8cf5741d4e6c613d7c4efac6 100644 GIT binary patch literal 13956 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 Date: Fri, 19 Jun 2026 01:21:55 -0400 Subject: [PATCH 07/10] Refactor mouse tick calculations in Horizontal and Vertical rules for improved accuracy; introduce custom crosshair cursor with bitmap image representation and update related tests. --- Free Ruler/HorizontalRule.swift | 4 +- Free Ruler/RulerCursorController.swift | 92 +++++++++++- Free Ruler/VerticalRule.swift | 4 +- FreeRulerTests/RulerCoreTests.swift | 137 +++++++++++++----- .../ruler-mouse-tick-labels.png | Bin 13956 -> 14022 bytes 5 files changed, 194 insertions(+), 43 deletions(-) diff --git a/Free Ruler/HorizontalRule.swift b/Free Ruler/HorizontalRule.swift index 498ffb7..33d8576 100644 --- a/Free Ruler/HorizontalRule.swift +++ b/Free Ruler/HorizontalRule.swift @@ -281,9 +281,9 @@ class HorizontalRule: RuleView { switch growthDirection { case .positive: - return mouseTickX + return max(0, mouseTickX - 1) case .negative: - return max(0, rulerWidth - mouseTickX - 1) + return max(0, rulerWidth - mouseTickX) } } diff --git a/Free Ruler/RulerCursorController.swift b/Free Ruler/RulerCursorController.swift index 58bba53..50fbdd1 100644 --- a/Free Ruler/RulerCursorController.swift +++ b/Free Ruler/RulerCursorController.swift @@ -25,7 +25,7 @@ final class RulerCursorController { case .arrow: return .arrow case .crosshair: - return .crosshair + return RulerCrosshairCursor.cursor case .openHand: return .openHand case .closedHand: @@ -117,3 +117,93 @@ private extension RulerCursorController.CursorStyle { UITestSupport.current?.writeCursorState(uiTestStateValue) } } + +private enum RulerCrosshairCursor { + static let cursor = NSCursor(image: image, hotSpot: hotSpot) + + private static let imageSize = NSSize(width: 17, height: 17) + private static let pixelScale = 2 + private static let pixelSize = Int(imageSize.width) * pixelScale + private static let hotSpot = NSPoint(x: 8.5, y: 8.5) + private static let outlineRange = 14...19 + private static let strokeRange = 16...17 + private static let outlineExtent = 2...31 + private static let strokeExtent = 4...29 + + private static let image: NSImage = { + let image = NSImage(size: imageSize) + + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: pixelSize, + pixelsHigh: pixelSize, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bitmapFormat: [.alphaNonpremultiplied], + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { + image.isTemplate = false + return image + } + + bitmap.size = imageSize + drawCrosshair(in: bitmap) + image.addRepresentation(bitmap) + image.isTemplate = false + + return image + }() + + private static func drawCrosshair(in bitmap: NSBitmapImageRep) { + for offset in outlineExtent { + for crosshairPixel in outlineRange { + setPixel(.white, atX: offset, y: crosshairPixel, in: bitmap) + setPixel(.white, atX: crosshairPixel, y: offset, in: bitmap) + } + } + + for offset in strokeExtent { + for crosshairPixel in strokeRange { + setPixel(.black, atX: offset, y: crosshairPixel, in: bitmap) + setPixel(.black, atX: crosshairPixel, y: offset, in: bitmap) + } + } + } + + private enum Pixel { + case black + case white + + var rgba: (red: UInt8, green: UInt8, blue: UInt8, alpha: UInt8) { + switch self { + case .black: + return (0, 0, 0, 255) + case .white: + return (255, 255, 255, 255) + } + } + } + + private static func setPixel( + _ pixel: Pixel, + atX x: Int, + y: Int, + in bitmap: NSBitmapImageRep + ) { + guard (0.. 0 else { continue } + + XCTAssertEqual(color.alphaComponent, 1, accuracy: 0.0001) + XCTAssertEqual(color.redComponent, color.redComponent.rounded(), accuracy: 0.0001) + XCTAssertEqual(color.greenComponent, color.greenComponent.rounded(), accuracy: 0.0001) + XCTAssertEqual(color.blueComponent, color.blueComponent.rounded(), accuracy: 0.0001) + } + } + } + func testMouseTickTimerPolicyRunsOnlyWhenRulersAreVisible() { let policy = MouseTickTimerPolicy(foregroundInterval: 1 / 60, backgroundInterval: 1 / 30) @@ -3754,6 +3799,22 @@ private func assertColor( XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.0001, file: file, line: line) } +private func assertPixel( + atX x: Int, + y: Int, + in bitmap: NSBitmapImageRep, + equals expectedColor: NSColor, + file: StaticString = #filePath, + line: UInt = #line +) { + guard let actualColor = bitmap.colorAt(x: x, y: y) else { + XCTFail("Missing pixel at \(x), \(y)", file: file, line: line) + return + } + + assertColor(actualColor, equals: expectedColor, file: file, line: line) +} + private func relativeLuminance( _ color: NSColor, file: StaticString = #filePath, diff --git a/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png b/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png index ff23f589ca62d17d8cf5741d4e6c613d7c4efac6..0db648c43cf593c1185605699a5a95339c510fc7 100644 GIT binary patch literal 14022 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 f_&DDMUi*(@sPxkPE`u(DN7BBw?yeXW` literal 13956 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 Date: Fri, 19 Jun 2026 01:31:16 -0400 Subject: [PATCH 08/10] Update ResizeHandleView to use zeroCorner parameter directly; add test for resizing behavior with zeroCorner override in RulerCoreTests. --- Free Ruler/ResizeHandleView.swift | 2 +- FreeRulerTests/RulerCoreTests.swift | 80 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/Free Ruler/ResizeHandleView.swift b/Free Ruler/ResizeHandleView.swift index 2707660..08429ba 100644 --- a/Free Ruler/ResizeHandleView.swift +++ b/Free Ruler/ResizeHandleView.swift @@ -129,7 +129,7 @@ final class ResizeHandleView: NSView { ) let nextFrame = resizedRulerFrame( orientation: orientation, - zeroCorner: prefs.zeroCorner, + zeroCorner: zeroCorner, initialFrame: dragInitialWindowFrame, delta: delta, minSize: window.minSize, diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index f6345e1..1464fec 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -2882,6 +2882,86 @@ final class RulerCoreTests: XCTestCase { } } + func testResizeHandleDragUsesRuleZeroCornerOverride() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + + let horizontalInitialFrame = NSRect(x: 100, y: 200, width: 300, height: Ruler.thickness) + let horizontalWindow = RulerWindow(Ruler(.horizontal, frame: horizontalInitialFrame)) + defer { horizontalWindow.close() } + horizontalWindow.rule.settingsOverride = RulerSettings(zeroCorner: .topRight) + guard let horizontalResizeHandle = horizontalWindow.rule.subviews + .first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } + + let horizontalStartLocation = horizontalResizeHandle.convert( + NSPoint(x: horizontalResizeHandle.bounds.minX + 1, y: horizontalResizeHandle.bounds.midY), + to: nil + ) + horizontalResizeHandle.mouseDown(with: mouseEvent( + type: .leftMouseDown, + location: horizontalStartLocation, + windowNumber: horizontalWindow.windowNumber, + timestamp: 0 + )) + horizontalResizeHandle.mouseDragged(with: mouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: horizontalStartLocation.x + 50, y: horizontalStartLocation.y), + windowNumber: horizontalWindow.windowNumber, + timestamp: 0.1 + )) + + XCTAssertEqual(horizontalWindow.frame.maxX, horizontalInitialFrame.maxX) + XCTAssertEqual(horizontalWindow.frame.minX, horizontalInitialFrame.minX + 50) + XCTAssertEqual(horizontalWindow.frame.width, horizontalInitialFrame.width - 50) + + horizontalResizeHandle.mouseUp(with: mouseEvent( + type: .leftMouseUp, + location: horizontalStartLocation, + windowNumber: horizontalWindow.windowNumber, + timestamp: 0.2 + )) + + let verticalInitialFrame = NSRect(x: 300, y: 200, width: Ruler.thickness, height: 300) + let verticalWindow = RulerWindow(Ruler(.vertical, frame: verticalInitialFrame)) + defer { verticalWindow.close() } + verticalWindow.rule.settingsOverride = RulerSettings(zeroCorner: .bottomLeft) + guard let verticalResizeHandle = verticalWindow.rule.subviews + .first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected vertical ruler to install a resize handle") + } + + let verticalStartLocation = verticalResizeHandle.convert( + NSPoint(x: verticalResizeHandle.bounds.midX, y: verticalResizeHandle.bounds.midY), + to: nil + ) + verticalResizeHandle.mouseDown(with: mouseEvent( + type: .leftMouseDown, + location: verticalStartLocation, + windowNumber: verticalWindow.windowNumber, + timestamp: 0.3 + )) + verticalResizeHandle.mouseDragged(with: mouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: verticalStartLocation.x, y: verticalStartLocation.y - 50), + windowNumber: verticalWindow.windowNumber, + timestamp: 0.4 + )) + + XCTAssertEqual(verticalWindow.frame.minY, verticalInitialFrame.minY) + XCTAssertEqual(verticalWindow.frame.maxY, verticalInitialFrame.maxY - 50) + XCTAssertEqual(verticalWindow.frame.height, verticalInitialFrame.height - 50) + + verticalResizeHandle.mouseUp(with: mouseEvent( + type: .leftMouseUp, + location: verticalStartLocation, + windowNumber: verticalWindow.windowNumber, + timestamp: 0.5 + )) + } + } + func testRulerControllerKeepsMouseTicksHiddenWhileDragging() { withInstalledAppDelegate { appDelegate in let ruler = Ruler(.horizontal, frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) From 7e1dc6e050a5990bd46aa67cd10f1d613eee8a7b Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 01:43:45 -0400 Subject: [PATCH 09/10] Add context menu support for rulers with localization for settings; implement activation logic in relevant classes and tests. --- Free Ruler/GroupedRulerWindow.swift | 15 +++++++++++ Free Ruler/Localizable.xcstrings | 42 +++++++++++++++++++++++++++++ Free Ruler/ResizeHandleView.swift | 4 +++ Free Ruler/RuleView.swift | 33 +++++++++++++++++++++++ Free Ruler/RulerWindow.swift | 6 +++++ Free Ruler/UnitLabelView.swift | 4 +++ FreeRulerTests/RulerCoreTests.swift | 39 +++++++++++++++++++++++++++ 7 files changed, 143 insertions(+) diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index 57366ce..2dc1be2 100644 --- a/Free Ruler/GroupedRulerWindow.swift +++ b/Free Ruler/GroupedRulerWindow.swift @@ -476,6 +476,13 @@ final class GroupedRulerWindow: NSPanel { } } +extension GroupedRulerWindow: RulerContextMenuActivating { + func activateForRulerContextMenu() { + makeKey() + (nextResponder as? GroupedRulerController)?.activateForRulerContextMenu() + } +} + private final class GroupedHorizontalRule: HorizontalRule { override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(NSSize(width: newSize.width, height: Ruler.thickness)) @@ -931,6 +938,10 @@ final class GroupedRulerContentView: NSView { nextResponder?.mouseMoved(with: event) } + override func menu(for event: NSEvent) -> NSMenu? { + return rulerContextMenu(for: self) + } + func containsEmptyCorner(_ point: NSPoint) -> Bool { return showsHorizontalRule && showsVerticalRule && cornerFrame().contains(point) } @@ -1498,6 +1509,10 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi } } + func activateForRulerContextMenu() { + onBecameActive?(self) + } + override func mouseMoved(with event: NSEvent) { mouseInteraction.mouseMoved(with: event) } diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 17f6583..c1652e9 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -79,6 +79,48 @@ } } }, + "ContextMenu.RulerSettings" : { + "comment" : "Context menu item title to open the active ruler settings panel", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lineal-Einstellungen…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruler Settings…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes de regla…" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viivaimen asetukset…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "定規設定…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尺子设置…" + } + } + } + }, "Ruler Color" : { "comment" : "Label for the active ruler color setting", "extractionState" : "manual", diff --git a/Free Ruler/ResizeHandleView.swift b/Free Ruler/ResizeHandleView.swift index 08429ba..703186b 100644 --- a/Free Ruler/ResizeHandleView.swift +++ b/Free Ruler/ResizeHandleView.swift @@ -92,6 +92,10 @@ final class ResizeHandleView: NSView { restoreRulerCursor(with: event) } + override func menu(for event: NSEvent) -> NSMenu? { + return rulerContextMenu(for: self) + } + override var mouseDownCanMoveWindow: Bool { return false } diff --git a/Free Ruler/RuleView.swift b/Free Ruler/RuleView.swift index 0651ab3..12c2946 100644 --- a/Free Ruler/RuleView.swift +++ b/Free Ruler/RuleView.swift @@ -4,6 +4,35 @@ import Cocoa import SwiftUI #endif +let rulerSettingsContextMenuItemIdentifier = NSUserInterfaceItemIdentifier("ruler-settings-context-menu-item") + +protocol RulerContextMenuActivating: AnyObject { + func activateForRulerContextMenu() +} + +func rulerSettingsContextMenuTitle() -> String { + return NSLocalizedString( + "ContextMenu.RulerSettings", + value: "Ruler Settings…", + comment: "Context menu item title to open the active ruler settings panel" + ) +} + +func rulerContextMenu(for view: NSView) -> NSMenu { + (view.window as? RulerContextMenuActivating)?.activateForRulerContextMenu() + + let menu = NSMenu() + let item = NSMenuItem( + title: rulerSettingsContextMenuTitle(), + action: #selector(AppDelegate.openRulerSettings(_:)), + keyEquivalent: "" + ) + item.identifier = rulerSettingsContextMenuItemIdentifier + item.target = NSApp.delegate + menu.addItem(item) + return menu +} + struct RulerColors { var customFill: NSColor? = nil @@ -418,6 +447,10 @@ class RuleView: NSView { nextResponder?.mouseMoved(with: event) } + override func menu(for event: NSEvent) -> NSMenu? { + return rulerContextMenu(for: self) + } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { return true } diff --git a/Free Ruler/RulerWindow.swift b/Free Ruler/RulerWindow.swift index 2fd0383..8ddfe98 100644 --- a/Free Ruler/RulerWindow.swift +++ b/Free Ruler/RulerWindow.swift @@ -75,6 +75,12 @@ class RulerWindow: NSPanel { } +extension RulerWindow: RulerContextMenuActivating { + func activateForRulerContextMenu() { + makeKey() + } +} + private func getTitle(for orientation: Orientation) -> String { switch orientation { case .horizontal: diff --git a/Free Ruler/UnitLabelView.swift b/Free Ruler/UnitLabelView.swift index 9a8a6e9..250f330 100644 --- a/Free Ruler/UnitLabelView.swift +++ b/Free Ruler/UnitLabelView.swift @@ -50,6 +50,10 @@ final class UnitLabelView: NSView { label.draw(with: labelRect, context: nil) } + override func menu(for event: NSEvent) -> NSMenu? { + return rulerContextMenu(for: self) + } + func frame(in bounds: NSRect) -> NSRect { return frame(in: bounds, zeroCorner: zeroCorner) } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 1464fec..92080e2 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -167,6 +167,45 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(manager.states.map(\.settings.unit), [.inches]) } + func testRulerContextMenuActivatesClickedRulerAndShowsSettingsCommand() { + withInstalledAppDelegate { appDelegate in + let manager = appDelegate.rulerManager + defer { + appDelegate.rulerSettingsController?.close() + for controller in manager.controllers { + controller.hide() + } + } + + let first = manager.createRuler( + defaults: RulerSettings(unit: .pixels), + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + let second = manager.createRuler( + defaults: RulerSettings(unit: .inches), + screenFrame: NSRect(x: 0, y: 0, width: 1000, height: 800) + ) + manager.markActive(second) + + let menu = first.groupedWindow.horizontalRule.menu(for: mouseEvent( + type: .rightMouseDown, + location: .zero, + windowNumber: first.groupedWindow.windowNumber, + timestamp: 0 + )) + + XCTAssertTrue(manager.activeController === first) + XCTAssertEqual(menu?.items.count, 1) + + let item = menu?.items.first + XCTAssertEqual(item?.identifier, rulerSettingsContextMenuItemIdentifier) + XCTAssertEqual(item?.title, rulerSettingsContextMenuTitle()) + XCTAssertEqual(item?.action, #selector(AppDelegate.openRulerSettings(_:))) + XCTAssertEqual(item?.keyEquivalent, "") + XCTAssertTrue(item?.target === appDelegate) + } + } + func testRulerManagerRestoresStatesAndShowsAllControllers() { let firstID = UUID(uuidString: "F775A858-ED72-4242-B84B-E08B27EE1C9F")! let secondID = UUID(uuidString: "D922071D-D02B-4DF7-8762-3497D9FD90B4")! From 7103afd155e4cbfaedb66421da18b4397d9ab7dc Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 19 Jun 2026 11:19:07 -0400 Subject: [PATCH 10/10] Refactor resetToDefault method to utilize a defaultSettings instance; adjust opacity handling and update UI elements accordingly. Modify RulerSettingsControlsView.xib for improved layout of text fields. Enhance RulerCoreTests to validate opacity settings and window alpha values. --- Free Ruler/Base.lproj/RulerSettingsControlsView.xib | 4 ++-- Free Ruler/PreferencesController.swift | 4 +++- FreeRulerTests/RulerCoreTests.swift | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib index 7ca5a8d..0f3b372 100644 --- a/Free Ruler/Base.lproj/RulerSettingsControlsView.xib +++ b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib @@ -58,7 +58,7 @@ - + @@ -81,7 +81,7 @@ - + diff --git a/Free Ruler/PreferencesController.swift b/Free Ruler/PreferencesController.swift index 96c3481..941a7fc 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -857,9 +857,11 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { } @IBAction func resetToDefault(_ sender: Any) { + let defaultSettings = RulerSettings(defaults: prefs) applySettings { settings in - settings = RulerSettings(defaults: prefs) + settings = defaultSettings } + rulerController?.opacity = defaultSettings.foregroundOpacity updateView() } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 92080e2..80d72eb 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -513,6 +513,8 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(controller.state.settings.zeroCorner, .topRight) XCTAssertEqual(settingsController.foregroundOpacityLabel.stringValue, "88%") XCTAssertEqual(settingsController.backgroundOpacityLabel.stringValue, "44%") + XCTAssertEqual(controller.opacity, 88) + XCTAssertEqual(controller.groupedWindow.alphaValue, windowAlphaValue(88), accuracy: 0.0001) XCTAssertEqual(prefs.foregroundOpacity, 88) } }