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 @@
| S | Show/hide ruler shadows |
| O | Orient rulers at mouse location |
| U | Cycle units: pixels, millimeters, and inches |
+ | ⇧ | H | Flip the horizontal ruler origin |
+ | ⇧ | V | Flip the vertical ruler origin |
| ⌘ | R | Reset 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,