diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index a82c284..64ceae0 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -34,6 +34,12 @@ private enum HotkeyBezelLocalizationKey: String { case rulersUngrouped = "HotkeyBezel.RulersUngrouped" case shadowEnabled = "HotkeyBezel.ShadowEnabled" case shadowDisabled = "HotkeyBezel.ShadowDisabled" + case horizontalOriginFormat = "HotkeyBezel.HorizontalOriginFormat" + case verticalOriginFormat = "HotkeyBezel.VerticalOriginFormat" + case originLeft = "HotkeyBezel.OriginLeft" + case originRight = "HotkeyBezel.OriginRight" + case originTop = "HotkeyBezel.OriginTop" + case originBottom = "HotkeyBezel.OriginBottom" case unitsFormat = "HotkeyBezel.UnitsFormat" case pixelsUnit = "Unit.Pixels.Abbreviation" case millimetersUnit = "Unit.Millimeters.Abbreviation" @@ -57,6 +63,18 @@ private enum HotkeyBezelLocalizationKey: String { return "Hotkey status bezel text indicating ruler shadow is enabled" case .shadowDisabled: return "Hotkey status bezel text indicating ruler shadow is disabled" + case .horizontalOriginFormat: + return "Hotkey status bezel format for the horizontal ruler origin side" + case .verticalOriginFormat: + return "Hotkey status bezel format for the vertical ruler origin side" + case .originLeft: + return "Hotkey status bezel value for a ruler origin on the left" + case .originRight: + return "Hotkey status bezel value for a ruler origin on the right" + case .originTop: + return "Hotkey status bezel value for a ruler origin at the top" + case .originBottom: + return "Hotkey status bezel value for a ruler origin at the bottom" case .unitsFormat: return "Hotkey status bezel format for the selected measurement unit" case .pixelsUnit: @@ -512,10 +530,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { @IBAction func flipHorizontalRuler(_ sender: Any) { flipRulers(along: .horizontal) + showHorizontalOriginHotkeyBezel(on: bezelScreen(for: sender)) } @IBAction func flipVerticalRuler(_ sender: Any) { flipRulers(along: .vertical) + showVerticalOriginHotkeyBezel(on: bezelScreen(for: sender)) } func flipRulers(along orientation: Orientation) { @@ -586,7 +606,30 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) } - func performRulerHotkey(keyCode: Int, sender: Any) -> Bool { + func performRulerHotkey( + keyCode: Int, + modifierFlags: NSEvent.ModifierFlags, + sender: Any + ) -> Bool { + let keyboardModifiers = modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting(.capsLock) + + if keyboardModifiers == .shift { + switch keyCode { + case kVK_ANSI_H: + flipHorizontalRuler(sender) + case kVK_ANSI_V: + flipVerticalRuler(sender) + default: + return false + } + + return true + } + + guard keyboardModifiers.isEmpty else { return false } + switch keyCode { case kVK_ANSI_H: toggleHorizontalRuler(sender) @@ -621,6 +664,40 @@ class AppDelegate: NSObject, NSApplicationDelegate { showHotkeyBezel(prefs.groupRulers ? .rulersGrouped : .rulersUngrouped, on: screen) } + private func showHorizontalOriginHotkeyBezel(on screen: NSScreen?) { + switch prefs.zeroCorner { + case .topLeft, .bottomLeft: + showHotkeyBezel( + format: .horizontalOriginFormat, + HotkeyBezelLocalizationKey.originLeft.localizedString, + on: screen + ) + case .topRight, .bottomRight: + showHotkeyBezel( + format: .horizontalOriginFormat, + HotkeyBezelLocalizationKey.originRight.localizedString, + on: screen + ) + } + } + + private func showVerticalOriginHotkeyBezel(on screen: NSScreen?) { + switch prefs.zeroCorner { + case .topLeft, .topRight: + showHotkeyBezel( + format: .verticalOriginFormat, + HotkeyBezelLocalizationKey.originTop.localizedString, + on: screen + ) + case .bottomLeft, .bottomRight: + showHotkeyBezel( + format: .verticalOriginFormat, + HotkeyBezelLocalizationKey.originBottom.localizedString, + on: screen + ) + } + } + private func bezelScreen(for sender: Any) -> NSScreen? { if let rulerController = sender as? RulerController { return rulerController.rulerWindow.screen diff --git a/Free Ruler/Base.lproj/MainMenu.xib b/Free Ruler/Base.lproj/MainMenu.xib index 0078575..e224d9a 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -194,13 +194,13 @@ - + - + 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 1b81ba8..f3fe3ee 100644 --- a/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html +++ b/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html @@ -28,6 +28,8 @@

SShow/hide ruler shadows OOrient rulers at mouse location UCycle units: pixels, millimeters, and inches + ⇧HFlip the horizontal ruler origin + ⇧VFlip the vertical ruler origin ⌘RReset ruler positions to default ⌘,Open Preferences diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 83af3b7..3921413 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -169,6 +169,216 @@ } } }, + "HotkeyBezel.HorizontalOriginFormat" : { + "comment" : "Hotkey status bezel format for the horizontal ruler origin side", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Horizontaler Ursprung: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Horizontal origin: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Origen horizontal: %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vaaka-alkupiste: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "水平原点: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "水平原点:%@" + } + } + } + }, + "HotkeyBezel.OriginBottom" : { + "comment" : "Hotkey status bezel value for a ruler origin at the bottom", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "unten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bottom" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abajo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "alas" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "下" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "底部" + } + } + } + }, + "HotkeyBezel.OriginLeft" : { + "comment" : "Hotkey status bezel value for a ruler origin on the left", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "links" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Left" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "izquierda" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "vasen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "左" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "左侧" + } + } + } + }, + "HotkeyBezel.OriginRight" : { + "comment" : "Hotkey status bezel value for a ruler origin on the right", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "rechts" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Right" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "derecha" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "oikea" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "右" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "右侧" + } + } + } + }, + "HotkeyBezel.OriginTop" : { + "comment" : "Hotkey status bezel value for a ruler origin at the top", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "oben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Top" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "arriba" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ylös" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "上" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "顶部" + } + } + } + }, "HotkeyBezel.RulersFloated" : { "comment" : "Hotkey status bezel text indicating rulers now float above other windows", "extractionState" : "manual", @@ -463,6 +673,48 @@ } } }, + "HotkeyBezel.VerticalOriginFormat" : { + "comment" : "Hotkey status bezel format for the vertical ruler origin side", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vertikaler Ursprung: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vertical origin: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Origen vertical: %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pystyalkupiste: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "垂直原点: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "垂直原点:%@" + } + } + } + }, "Reset ruler color" : { "comment" : "Tooltip and accessibility label for the button that restores the default ruler color", "extractionState" : "manual", diff --git a/Free Ruler/RulerController.swift b/Free Ruler/RulerController.swift index e5eaf39..be4328c 100644 --- a/Free Ruler/RulerController.swift +++ b/Free Ruler/RulerController.swift @@ -286,10 +286,13 @@ extension RulerController { let shift = event.modifierFlags.contains(.shift) let keyboardModifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if keyboardModifiers.isEmpty, - rulerWindow.isKeyWindow, + if rulerWindow.isKeyWindow, let appDelegate = NSApp.delegate as? AppDelegate, - appDelegate.performRulerHotkey(keyCode: Int(event.keyCode), sender: self) { + appDelegate.performRulerHotkey( + keyCode: Int(event.keyCode), + modifierFlags: keyboardModifiers, + sender: self + ) { return nil } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index bdff80f..ce19fa2 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -1,4 +1,5 @@ import AppKit +import Carbon.HIToolbox import XCTest @testable import Free_Ruler @@ -343,6 +344,51 @@ final class RulerCoreTests: XCTestCase { } } + func testMouseNumberLabelsRespectUnitsAfterZeroCornerFlip() { + withRestoredZeroCornerPreference { + let previousUnit = prefs.unit + defer { prefs.unit = previousUnit } + + prefs.zeroCorner = .bottomRight + let horizontalRule = HorizontalRule( + frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness) + ) + let verticalRule = VerticalRule( + frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300) + ) + let horizontalNumber = horizontalRule.mouseNumber(forTickX: 260, rulerWidth: 300) + let verticalNumber = verticalRule.mouseNumber(forTickY: 40, rulerHeight: 300) + let horizontalDpmm = horizontalRule.screen?.dpmm.width ?? NSScreen.defaultDpmm + let verticalDpmm = verticalRule.screen?.dpmm.width ?? NSScreen.defaultDpmm + let horizontalDpi = horizontalRule.screen?.dpi.width ?? NSScreen.defaultDpi + let verticalDpi = verticalRule.screen?.dpi.width ?? NSScreen.defaultDpi + + prefs.unit = .pixels + XCTAssertEqual(horizontalRule.getMouseNumberLabel(horizontalNumber), "40") + XCTAssertEqual(verticalRule.getMouseNumberLabel(verticalNumber), "40") + + prefs.unit = .millimeters + XCTAssertEqual( + horizontalRule.getMouseNumberLabel(horizontalNumber), + String(format: "%.1f", horizontalNumber / horizontalDpmm) + ) + XCTAssertEqual( + verticalRule.getMouseNumberLabel(verticalNumber), + String(format: "%.1f", verticalNumber / verticalDpmm) + ) + + prefs.unit = .inches + XCTAssertEqual( + horizontalRule.getMouseNumberLabel(horizontalNumber), + String(format: "%.3f", horizontalNumber / horizontalDpi) + ) + XCTAssertEqual( + verticalRule.getMouseNumberLabel(verticalNumber), + String(format: "%.3f", verticalNumber / verticalDpi) + ) + } + } + func testRulerColorsDefaultToOriginalFillColor() { withRestoredRulerColorPreference { prefs.rulerColor = Prefs.defaultRulerFillColor @@ -1193,6 +1239,124 @@ final class RulerCoreTests: XCTestCase { } } + func testShiftHotkeysFlipRulerOrigins() { + withRestoredZeroCornerPreference { + let previousGroupRulers = prefs.groupRulers + defer { prefs.groupRulers = previousGroupRulers } + + prefs.zeroCorner = .topLeft + prefs.groupRulers = false + let appDelegate = AppDelegate() + let horizontalController = RulerController( + ruler: Ruler(.horizontal, frame: NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) + ) + let verticalController = RulerController( + ruler: Ruler(.vertical, frame: NSRect(x: 61, y: 140, width: Ruler.thickness, height: 160)) + ) + appDelegate.rulers = [verticalController, horizontalController] + + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_H, + modifierFlags: .shift, + sender: horizontalController + ) + ) + XCTAssertEqual(prefs.zeroCorner, .topRight) + + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_V, + modifierFlags: .shift, + sender: verticalController + ) + ) + XCTAssertEqual(prefs.zeroCorner, .bottomRight) + } + } + + func testShiftHotkeysIgnoreCapsLock() { + withRestoredZeroCornerPreference { + let previousGroupRulers = prefs.groupRulers + defer { prefs.groupRulers = previousGroupRulers } + + prefs.zeroCorner = .topLeft + prefs.groupRulers = false + let appDelegate = AppDelegate() + let horizontalController = RulerController( + ruler: Ruler(.horizontal, frame: NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) + ) + appDelegate.rulers = [horizontalController] + + XCTAssertTrue( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_H, + modifierFlags: [.shift, .capsLock], + sender: horizontalController + ) + ) + XCTAssertEqual(prefs.zeroCorner, .topRight) + } + } + + func testNonShiftModifiedRulerHotkeysAreIgnored() { + let appDelegate = AppDelegate() + + XCTAssertFalse( + appDelegate.performRulerHotkey( + keyCode: kVK_ANSI_H, + modifierFlags: .option, + sender: self + ) + ) + } + + func testResetPositionUsesCurrentZeroCorner() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .bottomRight + let horizontalController = RulerController( + ruler: Ruler(.horizontal, frame: NSRect(x: 10, y: 20, width: 300, height: Ruler.thickness)) + ) + let verticalController = RulerController( + ruler: Ruler(.vertical, frame: NSRect(x: 10, y: 20, width: Ruler.thickness, height: 300)) + ) + + horizontalController.resetPosition() + verticalController.resetPosition() + + XCTAssertEqual( + horizontalController.rulerWindow.frame, + getDefaultContentRect(orientation: .horizontal, zeroCorner: .bottomRight) + ) + XCTAssertEqual( + verticalController.rulerWindow.frame, + getDefaultContentRect(orientation: .vertical, zeroCorner: .bottomRight) + ) + XCTAssertEqual(prefs.zeroCorner, .bottomRight) + } + } + + func testResetPositionKeepsFlippedDefaultRulersOnSharedZeroPoint() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topRight + let horizontalController = RulerController( + ruler: Ruler(.horizontal, frame: NSRect(x: 10, y: 20, width: 300, height: Ruler.thickness)) + ) + let verticalController = RulerController( + ruler: Ruler(.vertical, frame: NSRect(x: 10, y: 20, width: Ruler.thickness, height: 300)) + ) + + horizontalController.resetPosition() + verticalController.resetPosition() + + let geometry = ZeroCornerGeometry(zeroCorner: .topRight) + XCTAssertEqual( + geometry.zeroPoint(in: horizontalController.rulerWindow.frame, for: .horizontal), + geometry.zeroPoint(in: verticalController.rulerWindow.frame, for: .vertical) + ) + } + } + private func mouseEvent( type: NSEvent.EventType, location: NSPoint,