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/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index aa26542..7db7dd7 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -79,6 +79,12 @@ + + + + + + @@ -98,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..4632c4a --- /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..0f3b372 --- /dev/null +++ b/Free Ruler/Base.lproj/RulerSettingsControlsView.xib @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html b/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html index 3ad7ef1..ae6c69c 100644 --- a/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html +++ b/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html @@ -11,7 +11,7 @@ } + content="free ruler, rulers, shortcuts, keyboard, multiple rulers, float, shadow, origin, units, color, preferences" /> @@ -34,52 +34,57 @@

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

    -
  • Show horizontal and vertical rulers in pixels, millimeters, or inches.
  • -
  • Resize rulers, move them independently, or keep them grouped together.
  • -
  • Customize the ruler color in Preferences.
  • -
  • Float rulers above other windows and show or hide ruler shadows.
  • -
  • Align rulers at the mouse location, reset them to default positions, or flip their origins.
  • -
  • Show, hide, and reopen rulers from the menu or keyboard.
  • +
  • Create multiple independent rulers, each with horizontal, vertical, or L-shaped wings.
  • +
  • Use pixels, millimeters, or inches independently for each ruler.
  • +
  • Resize, move, align, reset, and flip the active ruler without changing the others.
  • +
  • Float individual rulers above other windows and show or hide their shadows.
  • +
  • Set defaults for new rulers in Preferences.
  • +
  • Restore your ruler set, including positions and visible wings, when Free Ruler opens again.
diff --git a/Free Ruler/GroupedRulerWindow.swift b/Free Ruler/GroupedRulerWindow.swift index e260023..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) } @@ -1264,6 +1275,7 @@ final class GroupedRulerController: NSWindowController, NSWindowDelegate, Notifi updateHasShadow() groupedWindow.setFrame(groupedWindow.visibleFrame(in: layout), display: true) captureStateFromWindow() + notifyStateChanged() } func foreground() { @@ -1497,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/HorizontalRule.swift b/Free Ruler/HorizontalRule.swift index a526f77..33d8576 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) @@ -280,9 +281,9 @@ class HorizontalRule: RuleView { switch growthDirection { case .positive: - return mouseTickX + return max(0, mouseTickX - 1) case .negative: - return rulerWidth - mouseTickX + return max(0, rulerWidth - mouseTickX) } } @@ -294,7 +295,7 @@ class HorizontalRule: RuleView { case .positive: return mouseTickX case .negative: - return mouseTickX - 1 + return mouseTickX } } diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 7d32227..c1652e9 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", @@ -121,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/PreferencesController.swift b/Free Ruler/PreferencesController.swift index d7e8dff..941a7fc 100644 --- a/Free Ruler/PreferencesController.swift +++ b/Free Ruler/PreferencesController.swift @@ -38,6 +38,9 @@ private func setColorPickingIgnoresAlpha(_ ignoresAlpha: Bool) { class RulerColorWell: NSColorWell { + var colorDidChange: ((RulerColorWell) -> Void)? + var colorPanelPresenter: ((RulerColorWell, NSColorPanel) -> Void)? + override func awakeFromNib() { super.awakeFromNib() configureForOpaqueColors() @@ -45,14 +48,45 @@ class RulerColorWell: NSColorWell { override func activate(_ exclusive: Bool) { configureForOpaqueColors() - super.activate(exclusive) + openColorPanel() + configureForOpaqueColors() + } + + override func keyDown(with event: NSEvent) { + guard event.charactersIgnoringModifiers == " " else { + super.keyDown(with: event) + return + } + configureForOpaqueColors() + openColorPanel() + } + + override func takeColorFrom(_ sender: Any?) { + if let colorPanel = sender as? NSColorPanel { + color = colorPanel.color + } else if let colorWell = sender as? NSColorWell { + color = colorWell.color + } else { + super.takeColorFrom(sender) + } + configureForOpaqueColors() + needsDisplay = true + if let colorDidChange = colorDidChange { + colorDidChange(self) + } else { + sendAction(action, to: target) + } } override func mouseDown(with event: NSEvent) { configureForOpaqueColors() + openColorPanel() + } + private func openColorPanel() { let colorPanel = NSColorPanel.shared + guard !colorPanel.isVisible else { closeRulerColorPanel() return @@ -63,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() } @@ -114,24 +152,324 @@ 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() + } + + 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", + resetButtonIdentifier: "reset-ruler-color-button", + foregroundSliderIdentifier: "ruler-foreground-opacity-slider", + backgroundSliderIdentifier: "ruler-background-opacity-slider", + foregroundLabelIdentifier: "ruler-foreground-opacity-label", + backgroundLabelIdentifier: "ruler-background-opacity-label", + floatCheckboxIdentifier: "float-rulers-checkbox", + shadowCheckboxIdentifier: "ruler-shadow-checkbox" + ) + configureCheckboxKeyEquivalents(float: "", shadow: "") + rulerColorWell.colorPanelPresenter = nil + } + + func configureForRulerSettings() { + configureControls( + colorWellIdentifier: "ruler-settings-color-well", + resetButtonIdentifier: "reset-ruler-settings-color-button", + foregroundSliderIdentifier: "ruler-settings-foreground-opacity-slider", + backgroundSliderIdentifier: "ruler-settings-background-opacity-slider", + foregroundLabelIdentifier: "ruler-settings-foreground-opacity-label", + backgroundLabelIdentifier: "ruler-settings-background-opacity-label", + floatCheckboxIdentifier: "ruler-settings-float-rulers-checkbox", + shadowCheckboxIdentifier: "ruler-settings-ruler-shadow-checkbox" + ) + configureCheckboxKeyEquivalents(float: "f", shadow: "s") + } + + func update( + 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 performRulerSettingsKeyEquivalent(with event: NSEvent) -> Bool { + guard event.type == .keyDown, + event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.shift, .capsLock, .function]) + .isEmpty, + let character = event.charactersIgnoringModifiers?.lowercased() else { + return false + } + + switch character { + case floatRulersCheckbox.keyEquivalent.lowercased() where !floatRulersCheckbox.keyEquivalent.isEmpty: + return toggleFloatRulersFromKeyEquivalent() + case rulerShadowCheckbox.keyEquivalent.lowercased() where !rulerShadowCheckbox.keyEquivalent.isEmpty: + return toggleRulerShadowFromKeyEquivalent() + default: + return false + } + } + + func deactivateColorWell() { + rulerColorWell.deactivate() + } + + private func loadContentView() { + guard contentView == nil else { return } + + var topLevelObjects: NSArray? + Bundle.main.loadNibNamed( + "RulerSettingsControlsView", + owner: self, + topLevelObjects: &topLevelObjects + ) + + guard let contentView = contentView else { return } + + contentView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentView) + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.topAnchor.constraint(equalTo: topAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + configureBaseControls() + } + + private func configureBaseControls() { + rulerColorWell.isContinuous = true + rulerColorWell.supportsAlpha = false + rulerColorWell.colorDidChange = { [weak self] _ in + guard let self = self else { return } + self.setRulerColor(self.rulerColorWell as Any) + } + rulerColorWell.target = self + rulerColorWell.action = #selector(setRulerColor(_:)) + + resetRulerColorButton.target = self + resetRulerColorButton.action = #selector(resetRulerColor(_:)) + + foregroundOpacitySlider.minValue = 5 + foregroundOpacitySlider.maxValue = 100 + foregroundOpacitySlider.numberOfTickMarks = 20 + foregroundOpacitySlider.allowsTickMarkValuesOnly = true + foregroundOpacitySlider.tickMarkPosition = .below + foregroundOpacitySlider.isContinuous = true + foregroundOpacitySlider.target = self + foregroundOpacitySlider.action = #selector(setForegroundOpacity(_:)) + + backgroundOpacitySlider.minValue = 5 + backgroundOpacitySlider.maxValue = 100 + backgroundOpacitySlider.numberOfTickMarks = 20 + backgroundOpacitySlider.allowsTickMarkValuesOnly = true + backgroundOpacitySlider.tickMarkPosition = .below + backgroundOpacitySlider.isContinuous = true + backgroundOpacitySlider.target = self + backgroundOpacitySlider.action = #selector(setBackgroundOpacity(_:)) + + floatRulersCheckbox.target = self + floatRulersCheckbox.action = #selector(setFloatRulers(_:)) + + rulerShadowCheckbox.target = self + rulerShadowCheckbox.action = #selector(setRulerShadow(_:)) + + configureKeyViewLoop() + } + + private func configureControls( + 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 configureCheckboxKeyEquivalents(float: String, shadow: String) { + floatRulersCheckbox.keyEquivalent = float + floatRulersCheckbox.keyEquivalentModifierMask = [] + rulerShadowCheckbox.keyEquivalent = shadow + rulerShadowCheckbox.keyEquivalentModifierMask = [] + } + + private func configureKeyViewLoop() { + rulerColorWell.nextKeyView = resetRulerColorButton.isHidden + ? foregroundOpacitySlider + : resetRulerColorButton + resetRulerColorButton.nextKeyView = foregroundOpacitySlider + foregroundOpacitySlider.nextKeyView = backgroundOpacitySlider + backgroundOpacitySlider.nextKeyView = floatRulersCheckbox + floatRulersCheckbox.nextKeyView = rulerShadowCheckbox + 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) + } + + @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 +480,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 +528,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 +546,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 +555,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 +590,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 +615,143 @@ 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 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! + @IBOutlet weak var setDefaultsButton: NSButton! + + var rulerColorWell: RulerColorWell { + return settingsControlsView.rulerColorWell + } + + var resetRulerColorButton: NSButton { + return settingsControlsView.resetRulerColorButton + } - let rulerColorWell: RulerColorWell - let resetRulerColorButton: NSButton - let foregroundOpacitySlider: NSSlider - let backgroundOpacitySlider: NSSlider - let foregroundOpacityLabel: NSTextField - let backgroundOpacityLabel: NSTextField - let floatRulersCheckbox: NSButton - let rulerShadowCheckbox: NSButton - let closeButton: NSButton + var 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() + configureWindowIfNeeded() + } - 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 - ) + required init?(coder: NSCoder) { + nil + } - let window = RulerSettingsController.makeWindow( - rulerColorWell: rulerColorWell, - resetRulerColorButton: resetRulerColorButton, - foregroundOpacitySlider: foregroundOpacitySlider, - backgroundOpacitySlider: backgroundOpacitySlider, - foregroundOpacityLabel: foregroundOpacityLabel, - backgroundOpacityLabel: backgroundOpacityLabel, - floatRulersCheckbox: floatRulersCheckbox, - rulerShadowCheckbox: rulerShadowCheckbox, - closeButton: closeButton - ) + override func windowDidLoad() { + super.windowDidLoad() + + configureWindowIfNeeded() + } - super.init(window: window) + private func configureWindowIfNeeded() { + guard !didConfigureWindow, + isWindowLoaded, + settingsControlsView != nil else { return } - window.delegate = self - window.initialFirstResponder = rulerColorWell - configureControls() + 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.colorPanelPresenter = { [weak self] colorWell, colorPanel in + self?.presentColorPanel(colorPanel, for: colorWell) + } + rulerColorWell.target = self + rulerColorWell.action = #selector(setRulerColor(_:)) + resetDefaultsButton.identifier = NSUserInterfaceItemIdentifier("reset-ruler-settings-to-default-button") + resetDefaultsButton.setAccessibilityIdentifier("reset-ruler-settings-to-default-button") + setDefaultsButton.identifier = NSUserInterfaceItemIdentifier("save-ruler-settings-as-default-button") + setDefaultsButton.setAccessibilityIdentifier("save-ruler-settings-as-default-button") subscribeToColorPanel() updateView() } - required init?(coder: NSCoder) { - nil - } - deinit { if let colorPanelObserver = colorPanelObserver { NotificationCenter.default.removeObserver(colorPanelObserver) @@ -475,216 +856,137 @@ final class RulerSettingsController: NSWindowController, NSWindowDelegate { applyRulerColor(Prefs.defaultRulerFillColor) } - @objc func closeRulerSettings(_ sender: Any) { - close() + @IBAction func resetToDefault(_ sender: Any) { + let defaultSettings = RulerSettings(defaults: prefs) + applySettings { settings in + settings = defaultSettings + } + rulerController?.opacity = defaultSettings.foregroundOpacity + updateView() + } + + @IBAction func setDefaultsForNewRulers(_ sender: Any) { + guard let settings = rulerController?.state.settings else { return } + + prefs.applyDefaults(from: settings) } func updateView() { - let currentColor = rulerController?.state.settings.rulerColor ?? Prefs.defaultRulerFillColor + configureWindowIfNeeded() + guard isWindowLoaded, + settingsControlsView != nil else { return } + 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" - ) + 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 ) - let foregroundLabel = makeLabel( - NSLocalizedString( - "Foreground Opacity", - comment: "Label for the active ruler foreground opacity setting" - ) - ) - let backgroundLabel = makeLabel( - NSLocalizedString( - "Background Opacity", - comment: "Label for the active ruler background opacity setting" - ) - ) - let colorRow = NSStackView(views: [colorLabel, resetRulerColorButton, rulerColorWell]) - let foregroundHeaderRow = NSStackView(views: [foregroundLabel, foregroundOpacityLabel]) - let backgroundHeaderRow = NSStackView(views: [backgroundLabel, backgroundOpacityLabel]) - let closeRow = NSView() - closeRow.addSubview(closeButton) - let contentStack = NSStackView(views: [ - colorRow, - foregroundHeaderRow, - foregroundOpacitySlider, - backgroundHeaderRow, - backgroundOpacitySlider, - floatRulersCheckbox, - rulerShadowCheckbox, - closeRow, - ]) - - for row in [colorRow, foregroundHeaderRow, backgroundHeaderRow] { - row.orientation = .horizontal - row.alignment = .centerY - row.distribution = .fill - row.spacing = 10 - row.translatesAutoresizingMaskIntoConstraints = false - } - - contentStack.orientation = .vertical - contentStack.alignment = .leading - contentStack.distribution = .fill - contentStack.spacing = 8 - contentStack.translatesAutoresizingMaskIntoConstraints = false - - colorLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - foregroundLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - backgroundLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - foregroundOpacityLabel.setContentHuggingPriority(.required, for: .horizontal) - backgroundOpacityLabel.setContentHuggingPriority(.required, for: .horizontal) - rulerColorWell.translatesAutoresizingMaskIntoConstraints = false - resetRulerColorButton.translatesAutoresizingMaskIntoConstraints = false - foregroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false - backgroundOpacitySlider.translatesAutoresizingMaskIntoConstraints = false - floatRulersCheckbox.translatesAutoresizingMaskIntoConstraints = false - rulerShadowCheckbox.translatesAutoresizingMaskIntoConstraints = false - closeButton.translatesAutoresizingMaskIntoConstraints = false - closeRow.translatesAutoresizingMaskIntoConstraints = false - - contentView.addSubview(contentStack) - NSLayoutConstraint.activate([ - colorRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - foregroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - backgroundHeaderRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - closeRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - rulerColorWell.widthAnchor.constraint(equalToConstant: 60), - rulerColorWell.heightAnchor.constraint(equalToConstant: 24), - resetRulerColorButton.widthAnchor.constraint(equalToConstant: 28), - resetRulerColorButton.heightAnchor.constraint(equalToConstant: 28), - foregroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - backgroundOpacitySlider.widthAnchor.constraint(equalTo: contentStack.widthAnchor), - floatRulersCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), - rulerShadowCheckbox.widthAnchor.constraint(lessThanOrEqualTo: contentStack.widthAnchor), - closeButton.trailingAnchor.constraint(equalTo: closeRow.trailingAnchor), - closeButton.topAnchor.constraint(equalTo: closeRow.topAnchor), - closeButton.bottomAnchor.constraint(equalTo: closeRow.bottomAnchor), - closeButton.leadingAnchor.constraint(greaterThanOrEqualTo: closeRow.leadingAnchor), - contentStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - contentStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - contentStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 18), - contentStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18), - ]) - - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 315, height: 270), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.title = NSLocalizedString( - "Ruler Settings", - comment: "Window title for the active ruler settings panel" - ) - window.contentView = contentView - window.identifier = NSUserInterfaceItemIdentifier("ruler-settings-window") - window.setAccessibilityIdentifier("ruler-settings-window") - window.isMovableByWindowBackground = true - window.isReleasedWhenClosed = false - return window + resetDefaultsButton.isEnabled = hasRuler + setDefaultsButton.isEnabled = hasRuler + repositionAttachedWindowsIfNeeded() } - private static func makeLabel(_ title: String) -> NSTextField { - let label = NSTextField(labelWithString: title) - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - return label + func performSettingsKeyEquivalent(with event: NSEvent) -> Bool { + return settingsControlsView.performRulerSettingsKeyEquivalent(with: event) } - private static func makeValueLabel() -> NSTextField { - let label = NSTextField(labelWithString: "") - label.alignment = .right - return label - } + private func configureFloatingPanelWindow() { + guard let settingsWindow = window else { return } + + settingsWindow.styleMask.insert(.utilityWindow) + settingsWindow.animationBehavior = .utilityWindow + + guard let panel = settingsWindow as? RulerSettingsWindow 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 + panel.settingsController = self + panel.isFloatingPanel = true + panel.hidesOnDeactivate = false } - 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 presentColorPanel(_ colorPanel: NSColorPanel, for colorWell: RulerColorWell) { + guard let settingsWindow = window else { + colorPanel.orderFront(colorWell) + return + } - resetRulerColorButton.target = self - resetRulerColorButton.action = #selector(resetRulerColor(_:)) - configureResetRulerColorButtonAppearance( - resetRulerColorButton, - identifier: "reset-ruler-settings-color-button" + 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 + } - 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") + 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) + } - 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") + if colorPanelSize.height <= visibleFrame.height { + topLeftPoint.y = clamp( + topLeftPoint.y, + lower: visibleFrame.minY + colorPanelSize.height, + upper: visibleFrame.maxY + ) + } else { + topLeftPoint.y = visibleFrame.maxY + } - floatRulersCheckbox.target = self - floatRulersCheckbox.action = #selector(setFloatRulers(_:)) - floatRulersCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-float-rulers-checkbox") - floatRulersCheckbox.setAccessibilityIdentifier("ruler-settings-float-rulers-checkbox") + colorPanel.setFrameTopLeftPoint(topLeftPoint) + } - rulerShadowCheckbox.target = self - rulerShadowCheckbox.action = #selector(setRulerShadow(_:)) - rulerShadowCheckbox.identifier = NSUserInterfaceItemIdentifier("ruler-settings-ruler-shadow-checkbox") - rulerShadowCheckbox.setAccessibilityIdentifier("ruler-settings-ruler-shadow-checkbox") + 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 + } - closeButton.target = self - closeButton.action = #selector(closeRulerSettings(_:)) - closeButton.identifier = NSUserInterfaceItemIdentifier("ruler-settings-close-button") - closeButton.setAccessibilityIdentifier("ruler-settings-close-button") + return NSPoint(x: x, y: y) } private func applyRulerColor(_ color: NSColor) { @@ -694,6 +996,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) } @@ -718,42 +1034,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 } @@ -775,14 +1082,43 @@ 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 + if let parentWindow = colorPanel.parent { + parentWindow.removeChildWindow(colorPanel) + } colorPanel.animationBehavior = .none colorPanel.setTarget(nil) colorPanel.setAction(nil) diff --git a/Free Ruler/Prefs.swift b/Free Ruler/Prefs.swift index 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/ResizeHandleView.swift b/Free Ruler/ResizeHandleView.swift index 2707660..703186b 100644 --- a/Free Ruler/ResizeHandleView.swift +++ b/Free Ruler/ResizeHandleView.swift @@ -92,6 +92,10 @@ final class ResizeHandleView: NSView { restoreRulerCursor(with: event) } + override func menu(for event: NSEvent) -> NSMenu? { + return rulerContextMenu(for: self) + } + override var mouseDownCanMoveWindow: Bool { return false } @@ -129,7 +133,7 @@ final class ResizeHandleView: NSView { ) let nextFrame = resizedRulerFrame( orientation: orientation, - zeroCorner: prefs.zeroCorner, + zeroCorner: zeroCorner, initialFrame: dragInitialWindowFrame, delta: delta, minSize: window.minSize, diff --git a/Free Ruler/RuleView.swift b/Free Ruler/RuleView.swift index 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/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.. 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/Free Ruler/VerticalRule.swift b/Free Ruler/VerticalRule.swift index 8125b5e..f395648 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 } @@ -324,7 +325,7 @@ class VerticalRule: RuleView { case .positive: return mouseTickY case .negative: - return rulerHeight - mouseTickY + return max(0, rulerHeight - mouseTickY - 1) } } @@ -334,7 +335,7 @@ class VerticalRule: RuleView { ) -> CGFloat { switch growthDirection { case .positive: - return mouseTickY + 1 + return mouseTickY case .negative: return mouseTickY } diff --git a/Free Ruler/de.lproj/MainMenu.strings b/Free Ruler/de.lproj/MainMenu.strings index 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/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/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/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/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/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/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/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/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" = "尺子设置…"; 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..80d72eb 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")! @@ -381,6 +420,232 @@ 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(controller.opacity, 88) + XCTAssertEqual(controller.groupedWindow.alphaValue, windowAlphaValue(88), accuracy: 0.0001) + XCTAssertEqual(prefs.foregroundOpacity, 88) + } + } + + 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 + 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( @@ -409,6 +674,110 @@ 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( + settings: RulerSettings(), + layout: RulerLayoutState( + zeroPoint: NSPoint(x: 240, y: 320), + horizontalLength: 260, + verticalLength: 180 + ) + ) + ) + let settingsController = RulerSettingsController(rulerController: controller) + defer { + settingsController.close() + controller.hide() + } + + guard let settingsWindow = settingsController.window else { + XCTFail("Expected settings window") + return + } + + XCTAssertTrue(settingsWindow is NSPanel) + XCTAssertTrue(settingsWindow.styleMask.contains(.utilityWindow)) + XCTAssertEqual(settingsWindow.animationBehavior, .utilityWindow) + + let settingsPanel = settingsWindow as? NSPanel + XCTAssertTrue(settingsPanel?.isFloatingPanel ?? false) + XCTAssertFalse(settingsPanel?.hidesOnDeactivate ?? true) + } + + func testRulerSettingsColorPanelAttachesOnRightForLeftZeroCorner() { + assertRulerSettingsColorPanelAttachesToSettingsPanel(zeroCorner: .topLeft) { settingsController, settingsWindow in + settingsController.rulerColorWell.mouseDown( + with: mouseDownEvent(windowNumber: settingsWindow.windowNumber) + ) + } + } + + func testRulerSettingsColorPanelActivatedByKeyboardUsesAnchoredPlacement() { + assertRulerSettingsColorPanelAttachesToSettingsPanel(zeroCorner: .topLeft) { settingsController, _ in + settingsController.rulerColorWell.keyDown( + with: keyDownEvent(characters: " ", keyCode: UInt16(kVK_Space)) + ) + } + } + + func testRulerSettingsColorPanelAttachesOnLeftForRightZeroCorners() { + for zeroCorner in [ZeroCorner.topRight, .bottomRight] { + assertRulerSettingsColorPanelAttachesToSettingsPanel(zeroCorner: zeroCorner) { settingsController, settingsWindow in + settingsController.rulerColorWell.mouseDown( + with: mouseDownEvent(windowNumber: settingsWindow.windowNumber) + ) + } + } + } + func testRulerSettingsControllerRestoresForegroundOpacityWhenClosingSheet() { let controller = GroupedRulerController( state: RulerInstanceState( @@ -441,7 +810,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,12 +834,57 @@ 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) } + 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 @@ -1271,42 +1685,46 @@ final class RulerCoreTests: XCTestCase { } func testHorizontalRuleDrawingHelpersFollowZeroCornerGeometry() { - let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - XCTAssertEqual( - rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .positive), - 50 - ) - XCTAssertEqual( - rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative), - 249 - ) - XCTAssertEqual( - rule.mouseTickLineX(forTickX: 1, growthDirection: .positive), - 1 - ) - XCTAssertEqual( - rule.mouseTickLineX(forTickX: 299, growthDirection: .negative), - 298 - ) + XCTAssertEqual(rule.mouseNumber(forTickX: 51, rulerWidth: 300), 50) + XCTAssertEqual( + rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .positive), + 50 + ) + XCTAssertEqual( + rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative), + 249 + ) + XCTAssertEqual( + rule.mouseTickLineX(forTickX: 1, growthDirection: .positive), + 1 + ) + XCTAssertEqual( + rule.mouseTickLineX(forTickX: 299, growthDirection: .negative), + 299 + ) - let bottomTick = rule.tickLine(forX: 50, length: 10, rulerHeight: 40, tickSide: .bottom) - XCTAssertEqual(bottomTick.start, CGPoint(x: 50, y: 1)) - XCTAssertEqual(bottomTick.end, CGPoint(x: 50, y: 10)) + let bottomTick = rule.tickLine(forX: 50, length: 10, rulerHeight: 40, tickSide: .bottom) + XCTAssertEqual(bottomTick.start, CGPoint(x: 50, y: 1)) + XCTAssertEqual(bottomTick.end, CGPoint(x: 50, y: 10)) - let topTick = rule.tickLine(forX: 250, length: 10, rulerHeight: 40, tickSide: .top) - XCTAssertEqual(topTick.start, CGPoint(x: 250, y: 39)) - XCTAssertEqual(topTick.end, CGPoint(x: 250, y: 30)) + let topTick = rule.tickLine(forX: 250, length: 10, rulerHeight: 40, tickSide: .top) + XCTAssertEqual(topTick.start, CGPoint(x: 250, y: 39)) + XCTAssertEqual(topTick.end, CGPoint(x: 250, y: 30)) - XCTAssertEqual( - rule.tickLabelRect( - forX: 250, - labelSize: NSSize(width: 50, height: 20), - rulerHeight: 40, - tickSide: .top - ), - CGRect(x: 225.5, y: 19, width: 50, height: 20) - ) + XCTAssertEqual( + rule.tickLabelRect( + forX: 250, + labelSize: NSSize(width: 50, height: 20), + rulerHeight: 40, + tickSide: .top + ), + CGRect(x: 225.5, y: 19, width: 50, height: 20) + ) + } } func testHorizontalRuleMouseAndUnitLabelsMirrorForRightZeroCorner() { @@ -1315,6 +1733,13 @@ final class RulerCoreTests: XCTestCase { let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) XCTAssertEqual(rule.mouseNumber(forTickX: 260, rulerWidth: 300), 40) + XCTAssertEqual( + rule.mouseNumber( + forTickX: rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative) + 1, + rulerWidth: 300 + ), + 50 + ) XCTAssertEqual( rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 300, height: 40)), CGRect(x: 280, y: 0, width: 20, height: 19) @@ -1339,7 +1764,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) @@ -1363,10 +1788,27 @@ final class RulerCoreTests: XCTestCase { func testVerticalRuleMouseAndUnitLabelsMirrorForBottomZeroCorner() { withRestoredZeroCornerPreference { + prefs.zeroCorner = .topRight + let topZeroRule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + XCTAssertEqual( + topZeroRule.mouseNumber( + forTickY: topZeroRule.tickY(forOffset: 50, rulerHeight: 300, growthDirection: .negative) - 1, + rulerHeight: 300 + ), + 50 + ) + prefs.zeroCorner = .bottomRight let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) XCTAssertEqual(rule.mouseNumber(forTickY: 40, rulerHeight: 300), 40) + XCTAssertEqual( + rule.mouseNumber( + forTickY: rule.tickY(forOffset: 50, rulerHeight: 300, growthDirection: .positive) - 1, + rulerHeight: 300 + ), + 50 + ) XCTAssertEqual( rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 40, height: 300)), CGRect(x: 20, y: 0, width: 20, height: 19) @@ -1793,6 +2235,37 @@ final class RulerCoreTests: XCTestCase { }, is: .crosshair) } + func testRulerCrosshairCursorUsesAliasedBitmapImage() throws { + let cursor = RulerCursorController.CursorStyle.crosshair.nsCursor + + XCTAssertEqual(cursor.image.size, NSSize(width: 17, height: 17)) + XCTAssertEqual(cursor.hotSpot, NSPoint(x: 8.5, y: 8.5)) + XCTAssertFalse(cursor.image.isTemplate) + + let bitmap = try XCTUnwrap( + cursor.image.representations.compactMap { $0 as? NSBitmapImageRep }.first + ) + XCTAssertEqual(bitmap.pixelsWide, 34) + XCTAssertEqual(bitmap.pixelsHigh, 34) + assertPixel(atX: 16, y: 16, in: bitmap, equals: .black) + assertPixel(atX: 17, y: 17, in: bitmap, equals: .black) + assertPixel(atX: 2, y: 14, in: bitmap, equals: .white) + assertPixel(atX: 14, y: 14, in: bitmap, equals: .white) + assertPixel(atX: 0, y: 0, in: bitmap, equals: .clear) + + for y in 0.. 0 else { continue } + + XCTAssertEqual(color.alphaComponent, 1, accuracy: 0.0001) + XCTAssertEqual(color.redComponent, color.redComponent.rounded(), accuracy: 0.0001) + XCTAssertEqual(color.greenComponent, color.greenComponent.rounded(), accuracy: 0.0001) + XCTAssertEqual(color.blueComponent, color.blueComponent.rounded(), accuracy: 0.0001) + } + } + } + func testMouseTickTimerPolicyRunsOnlyWhenRulersAreVisible() { let policy = MouseTickTimerPolicy(foregroundInterval: 1 / 60, backgroundInterval: 1 / 30) @@ -2450,6 +2923,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)) @@ -3081,6 +3634,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 +3644,7 @@ final class RulerCoreTests: XCTestCase { prefs.foregroundOpacity = previousForegroundOpacity prefs.backgroundOpacity = previousBackgroundOpacity prefs.floatRulers = previousFloatRulers + prefs.groupRulers = previousGroupRulers prefs.rulerShadow = previousRulerShadow prefs.zeroCorner = previousZeroCorner } @@ -3097,6 +3652,147 @@ 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 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) @@ -3224,6 +3920,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 a825cbb..0db648c 100644 Binary files a/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png and b/FreeRulerTests/__Snapshots__/RulerSnapshotTests/ruler-mouse-tick-labels.png differ