diff --git a/Free Ruler.xcodeproj/project.pbxproj b/Free Ruler.xcodeproj/project.pbxproj index 077354e..8debbed 100644 --- a/Free Ruler.xcodeproj/project.pbxproj +++ b/Free Ruler.xcodeproj/project.pbxproj @@ -32,13 +32,15 @@ 50B1D3532D05D00100B1D135 /* RulerCursorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3522D05D00100B1D135 /* RulerCursorController.swift */; }; 50B1D3552D05E00000B1D138 /* RulerTickLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3542D05E00000B1D138 /* RulerTickLayout.swift */; }; 50B1D3592D06000100B1D139 /* AppIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3582D06000100B1D139 /* AppIconRenderer.swift */; }; - 50B1D3602D06000600B1D139 /* AppIconGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D35F2D06000600B1D139 /* AppIconGenerator.swift */; }; 50B1D35D2D06000400B1D139 /* AppIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3582D06000100B1D139 /* AppIconRenderer.swift */; }; + 50B1D3602D06000600B1D139 /* AppIconGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D35F2D06000600B1D139 /* AppIconGenerator.swift */; }; 50B1D3612D06000700B1D139 /* AppIconGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D35F2D06000600B1D139 /* AppIconGenerator.swift */; }; 50B1D3652D06010100B1D139 /* AppStoreScreenshotPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3642D06010100B1D139 /* AppStoreScreenshotPreview.swift */; }; 50B1D3662D06010200B1D139 /* AppStoreScreenshotPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3642D06010100B1D139 /* AppStoreScreenshotPreview.swift */; }; 50B1D3672D06100000B1D13A /* ResizeHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3682D06100000B1D13A /* ResizeHandleView.swift */; }; 50B1D3692D06100000B1D13A /* ResizeHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D3682D06100000B1D13A /* ResizeHandleView.swift */; }; + 50B1D36A2D06110000B1D13B /* UnitLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D36C2D06110000B1D13B /* UnitLabelView.swift */; }; + 50B1D36B2D06110000B1D13B /* UnitLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B1D36C2D06110000B1D13B /* UnitLabelView.swift */; }; 50C6D891228BDBAD0091F19E /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50C6D890228BDBAD0091F19E /* Images.xcassets */; }; 50D7BEE7227D42FD0008B95E /* RulerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D7BEE6227D42FD0008B95E /* RulerController.swift */; }; 50D7BEE9227D43270008B95E /* Ruler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D7BEE8227D43270008B95E /* Ruler.swift */; }; @@ -118,6 +120,7 @@ 50B1D35F2D06000600B1D139 /* AppIconGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconGenerator.swift; sourceTree = ""; }; 50B1D3642D06010100B1D139 /* AppStoreScreenshotPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreScreenshotPreview.swift; sourceTree = ""; }; 50B1D3682D06100000B1D13A /* ResizeHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResizeHandleView.swift; sourceTree = ""; }; + 50B1D36C2D06110000B1D13B /* UnitLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitLabelView.swift; sourceTree = ""; }; 50C6D890228BDBAD0091F19E /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 50D7BEE6227D42FD0008B95E /* RulerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RulerController.swift; sourceTree = ""; }; 50D7BEE8227D43270008B95E /* Ruler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Ruler.swift; sourceTree = ""; }; @@ -139,7 +142,7 @@ 8F629823243003EA004F9099 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/MainMenu.strings; sourceTree = ""; }; 8F629825243003F6004F9099 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/PreferencesController.xib; sourceTree = ""; }; 8F629828243003FF004F9099 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/PreferencesController.strings; sourceTree = ""; }; - AB053EE93E1DC9AF341A8D4F /* Free Ruler.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = "Free Ruler.app"; path = "Free Ruler.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + AB053EE93E1DC9AF341A8D4F /* Free Ruler.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Free Ruler.app"; sourceTree = BUILT_PRODUCTS_DIR; }; B894A5002BBFE61A005A3B6F /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/MainMenu.strings; sourceTree = ""; }; B894A5012BBFE61A005A3B6F /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/PreferencesController.strings; sourceTree = ""; }; D9DBE8A12C791B1600A42589 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/MainMenu.strings; sourceTree = ""; }; @@ -252,6 +255,7 @@ 50D7BEEA227D432E0008B95E /* RulerWindow.swift */, 50D7BEEC227D5C810008B95E /* RuleView.swift */, 50B1D3682D06100000B1D13A /* ResizeHandleView.swift */, + 50B1D36C2D06110000B1D13B /* UnitLabelView.swift */, 50B1D3542D05E00000B1D138 /* RulerTickLayout.swift */, 6F41029E22607DC900F06A10 /* HorizontalRule.swift */, 5012CAAC226AB09000BD9565 /* VerticalRule.swift */, @@ -479,6 +483,7 @@ 6F4102892260712F00F06A10 /* AppDelegate.swift in Sources */, 50D7BEED227D5C810008B95E /* RuleView.swift in Sources */, 50B1D3672D06100000B1D13A /* ResizeHandleView.swift in Sources */, + 50B1D36A2D06110000B1D13B /* UnitLabelView.swift in Sources */, 50D7BEE9227D43270008B95E /* Ruler.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -504,6 +509,7 @@ 23E2B80975F07733EB6CF5DB /* AppDelegate.swift in Sources */, 9A082BBC4A583513B0858C41 /* RuleView.swift in Sources */, 50B1D3692D06100000B1D13A /* ResizeHandleView.swift in Sources */, + 50B1D36B2D06110000B1D13B /* UnitLabelView.swift in Sources */, E6BC6663BAF0D11D7A9D2195 /* Ruler.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index c4a70e4..bdfc898 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: @@ -127,7 +145,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { #endif showRulers() - } #if DEBUG @@ -218,6 +235,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { prefs.observe(\Prefs.rulerColor, options: .new) { prefs, changed in self.redrawRulers() }, + prefs.observe(\Prefs.zeroCorner, options: .new) { prefs, changed in + self.redrawRulers() + }, ] } @@ -507,7 +527,108 @@ class AppDelegate: NSObject, NSApplicationDelegate { toggleRuler(orientation: .vertical) } - func performRulerHotkey(keyCode: Int, sender: Any) -> Bool { + @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) { + createRulersIfNeeded() + + let oldGeometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let flippedCorner = prefs.zeroCorner.flipped(along: orientation) + let flippedRuler = existingRulerController(orientation: orientation) + let otherOrientation: Orientation = orientation == .horizontal ? .vertical : .horizontal + let otherRuler = existingRulerController(orientation: otherOrientation) + let zeroPointOffset = zeroPointOffset( + from: flippedRuler?.rulerWindow, + to: otherRuler?.rulerWindow, + geometry: oldGeometry + ) + + prefs.zeroCorner = flippedCorner + + guard prefs.groupRulers, + let flippedWindow = flippedRuler?.rulerWindow, + let otherWindow = otherRuler?.rulerWindow, + isRulerWindowShown(otherWindow), + let zeroPointOffset = zeroPointOffset else { return } + + let newGeometry = ZeroCornerGeometry(zeroCorner: flippedCorner) + let flippedZeroPoint = newGeometry.zeroPoint(in: flippedWindow.frame, for: orientation) + let targetOtherZeroPoint = NSPoint( + x: flippedZeroPoint.x + zeroPointOffset.width, + y: flippedZeroPoint.y + zeroPointOffset.height + ) + let otherFrame = newGeometry.frame( + for: otherOrientation, + zeroPoint: targetOtherZeroPoint, + size: otherWindow.frame.size + ) + + detachRulerWindows() + otherWindow.setFrame(otherFrame, display: true) + updateRulerGrouping() + } + + func isRulerWindowShown(_ window: RulerWindow) -> Bool { + return window.isVisible || window.parent != nil || rulers.contains { + $0.rulerWindow.childWindows?.contains(window) == true + } + } + + private func zeroPointOffset( + from sourceWindow: RulerWindow?, + to targetWindow: RulerWindow?, + geometry: ZeroCornerGeometry + ) -> NSSize? { + guard let sourceWindow = sourceWindow, + let targetWindow = targetWindow else { return nil } + + let sourceZeroPoint = geometry.zeroPoint( + in: sourceWindow.frame, + for: sourceWindow.ruler.orientation + ) + let targetZeroPoint = geometry.zeroPoint( + in: targetWindow.frame, + for: targetWindow.ruler.orientation + ) + + return NSSize( + width: targetZeroPoint.x - sourceZeroPoint.x, + height: targetZeroPoint.y - sourceZeroPoint.y + ) + } + + 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) @@ -542,6 +663,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 e4a1c3f..8404be9 100644 --- a/Free Ruler/Base.lproj/MainMenu.xib +++ b/Free Ruler/Base.lproj/MainMenu.xib @@ -193,9 +193,15 @@ + + + + + + diff --git a/Free Ruler/FreeRuler.help/Contents/Info.plist b/Free Ruler/FreeRuler.help/Contents/Info.plist index aadccca..64d11a3 100644 --- a/Free Ruler/FreeRuler.help/Contents/Info.plist +++ b/Free Ruler/FreeRuler.help/Contents/Info.plist @@ -13,11 +13,11 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1 + 2 CFBundleSignature hbwr CFBundleVersion - 1 + 2 HPDBookAccessPath FreeRuler.html HPDBookIconPath @@ -25,7 +25,7 @@ HPDBookIndexPath English.lproj.helpindex HPDBookKBProduct - freeruler1 + freeruler2 HPDBookTitle Free Ruler Help HPDBookType diff --git a/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/English.lproj.helpindex b/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/English.lproj.helpindex index 34a8ac1..69d34f7 100644 Binary files a/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/English.lproj.helpindex and b/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/English.lproj.helpindex differ 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..8ca3634 100644 --- a/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html +++ b/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj/FreeRuler.html @@ -1,37 +1,107 @@ + Free Ruler Help - - - - - + + + + + + + - + + +

+ Free Ruler Help +

-

- Free Ruler Help -

+

+ Keyboard Shortcuts +

-

- Keyboard Shortcuts -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
HHide or show the horizontal ruler
VHide or show the vertical ruler
HFlip the horizontal ruler origin
VFlip the vertical ruler origin
FFloat/unfloat rulers above other windows
GGroup/ungroup rulers
SShow/hide ruler shadows
OOrient rulers at mouse location
UCycle units: pixels, millimeters, and inches
RReset ruler positions to default
,Open Preferences
- - - - - - - - - - -
FFloat/unfloat rulers above other windows
GGroup/ungroup rulers
SShow/hide ruler shadows
OOrient rulers at mouse location
UCycle units: pixels, millimeters, and inches
RReset ruler positions to default
,Open Preferences
+

+ Features +

+ +
    +
  • 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.
  • +
+ diff --git a/Free Ruler/FreeRuler.help/Contents/Resources/shrd/freeruler-help-icon-bb4e930f489c.png b/Free Ruler/FreeRuler.help/Contents/Resources/shrd/freeruler-help-icon-bb4e930f489c.png new file mode 100644 index 0000000..8b22a07 Binary files /dev/null and b/Free Ruler/FreeRuler.help/Contents/Resources/shrd/freeruler-help-icon-bb4e930f489c.png differ diff --git a/Free Ruler/FreeRuler.help/Contents/Resources/shrd/freeruler.png b/Free Ruler/FreeRuler.help/Contents/Resources/shrd/freeruler.png deleted file mode 100644 index 00a07ea..0000000 Binary files a/Free Ruler/FreeRuler.help/Contents/Resources/shrd/freeruler.png and /dev/null differ diff --git a/Free Ruler/FreeRuler.help/Contents/Resources/shrd/styles.css b/Free Ruler/FreeRuler.help/Contents/Resources/shrd/styles.css index be13974..7d56f79 100644 --- a/Free Ruler/FreeRuler.help/Contents/Resources/shrd/styles.css +++ b/Free Ruler/FreeRuler.help/Contents/Resources/shrd/styles.css @@ -3,8 +3,12 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif; } +h2 { + margin-top: 40px; +} + .title-page-header { - background-image: url(./freeruler.png); + background-image: var(--free-ruler-help-icon); background-size: 200px 200px; background-repeat: no-repeat; padding-top: 220px; @@ -20,3 +24,12 @@ body { text-align: left; padding-left: 10px; } + +.features { + margin-top: 0; + padding-left: 22px; +} + +.features li { + margin-bottom: 8px; +} diff --git a/Free Ruler/HorizontalRule.swift b/Free Ruler/HorizontalRule.swift index 7badb98..0138012 100644 --- a/Free Ruler/HorizontalRule.swift +++ b/Free Ruler/HorizontalRule.swift @@ -6,17 +6,21 @@ class HorizontalRule: RuleView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) + installUnitLabel(for: .horizontal) installResizeHandle(for: .horizontal) } required init?(coder: NSCoder) { super.init(coder: coder) + installUnitLabel(for: .horizontal) installResizeHandle(for: .horizontal) } var mouseTickX: CGFloat = 0 { didSet { if mouseTickX != oldValue { + updateResizeHandleVisibility() + updateUnitLabelVisibility() needsDisplay = true } } @@ -35,13 +39,13 @@ class HorizontalRule: RuleView { let height = dirtyRect.height let path = NSBezierPath() let tickLayout = RulerTickLayout(unit: unit, screen: screen) - let geometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) let tickSide = geometry.tickSide(for: .horizontal) let growthDirection = geometry.growthDirection(for: .horizontal) let labelWidth: CGFloat = 50 let labelHeight: CGFloat = 20 - // TODO: refactor this to use label.size() logic (see func drawUnitLabel) + // TODO: refactor this to use measured label sizes. // substract two so ticks don't overlap with border // subtract from this range so width var is accurate @@ -94,9 +98,7 @@ class HorizontalRule: RuleView { color.ticks.setStroke() path.stroke() - if !showMouseTick || mouseTickX < 0 || mouseTickX > 26 { - drawUnitLabel() - } + updateUnitLabelVisibility() // Draw the MouseTick & number if showMouseTick && mouseTickX > 0 && mouseTickX < self.windowWidth { @@ -115,9 +117,11 @@ class HorizontalRule: RuleView { func drawMouseTick(_ mouseTickX: CGFloat) { let mouseTick = NSBezierPath() let height: CGFloat = 40 + let growthDirection = ZeroCornerGeometry(zeroCorner: zeroCorner).growthDirection(for: .horizontal) + let lineX = mouseTickLineX(forTickX: mouseTickX, growthDirection: growthDirection) - mouseTick.move(to: CGPoint(x: mouseTickX, y: 0)) - mouseTick.line(to: CGPoint(x: mouseTickX, y: height)) + mouseTick.move(to: CGPoint(x: lineX, y: 0)) + mouseTick.line(to: CGPoint(x: lineX, y: height)) mouseTick.transform(using: transformer) @@ -137,65 +141,52 @@ class HorizontalRule: RuleView { let labelSize = label.size() let labelRect = mouseNumberLabelRect( - number: mouseTickX, + tickX: mouseTickX, labelSize: labelSize, rulerSize: CGSize(width: width, height: height) ) + guard NSGraphicsContext.current != nil else { return } label.draw( with: labelRect, context: nil ) } - func mouseNumberLabelRect(number: CGFloat, labelSize: CGSize, rulerSize: CGSize) -> CGRect { - let labelOffset: CGFloat = 5 - let tickSide = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).tickSide(for: .horizontal) - - let rightPosition = number + labelOffset - let leftPosition = number - labelOffset - labelSize.width - var minLabelLeft = labelOffset - var maxLabelRight = rulerSize.width - labelOffset + func mouseNumberLabelRect(tickX: CGFloat, labelSize: CGSize, rulerSize: CGSize) -> CGRect { + return MouseTickLabelLayout.labelFrame( + labelSize: labelSize, + rulerSize: rulerSize, + orientation: .horizontal, + zeroCorner: zeroCorner, + tickPosition: tickX, + resizeHandleFrame: resizeHandleExclusionFrame, + unitLabelFrame: unitLabelFrame + ) + } - if let resizeHandleExclusionFrame = resizeHandleExclusionFrame { - if resizeHandleExclusionFrame.midX < rulerSize.width / 2 { - minLabelLeft = max( - minLabelLeft, - resizeHandleExclusionFrame.maxX + mouseTickLabelResizeHandleSpacing - ) - } else { - maxLabelRight = min( - maxLabelRight, - resizeHandleExclusionFrame.minX - mouseTickLabelResizeHandleSpacing - ) - } + override func updateUnitLabelVisibility() { + guard showMouseTick, + mouseTickX >= bounds.minX, + mouseTickX <= bounds.maxX, + let frame = unitLabelFrame else { + setUnitLabelHidden(false) + return } - let pinnedRightPosition = maxLabelRight - labelSize.width - let rightLabelX = max(min(rightPosition, pinnedRightPosition), minLabelLeft) - let leftLabelX = max(min(leftPosition, pinnedRightPosition), minLabelLeft) - let labelX = number < rightLabelX ? rightLabelX : leftLabelX - - return CGRect( - x: labelX, - y: tickSide == .bottom ? rulerSize.height - labelSize.height : 0, - width: labelSize.width, - height: labelSize.height - ) + setUnitLabelHidden(frame.minX <= mouseTickX && mouseTickX <= frame.maxX) } - func drawUnitLabel() { - let attributes = labelAttributes(alignment: .left, foregroundColor: color.ticks) - - let unitlabel = self.getUnitLabel() - let label = NSAttributedString(string: unitlabel, attributes: attributes) - let labelSize = label.size() - let labelRect = unitLabelRect(labelSize: labelSize, rulerSize: bounds.size) + override func updateResizeHandleVisibility() { + guard showMouseTick, + mouseTickX >= bounds.minX, + mouseTickX <= bounds.maxX, + let frame = resizeHandleExclusionFrame else { + setResizeHandleObscured(false) + return + } - label.draw( - with: labelRect, - context: nil - ) + setResizeHandleObscured(frame.minX <= mouseTickX && mouseTickX <= frame.maxX) } func tickX( @@ -235,6 +226,7 @@ class HorizontalRule: RuleView { tickSide: RulerSide ) -> CGRect { let labelOffset: CGFloat = 13 + let textHeight: CGFloat = 8 let labelX: CGFloat = x - (labelSize.width / 2) + 0.5 let labelY: CGFloat @@ -242,7 +234,7 @@ class HorizontalRule: RuleView { case .bottom: labelY = labelOffset case .top: - labelY = rulerHeight - labelOffset - labelSize.height + labelY = rulerHeight - labelOffset - textHeight case .left, .right: assertionFailure("Horizontal ruler labels must be placed on a horizontal side") labelY = labelOffset @@ -252,7 +244,7 @@ class HorizontalRule: RuleView { } func mouseNumber(forTickX mouseTickX: CGFloat, rulerWidth: CGFloat) -> CGFloat { - let growthDirection = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).growthDirection(for: .horizontal) + let growthDirection = ZeroCornerGeometry(zeroCorner: zeroCorner).growthDirection(for: .horizontal) switch growthDirection { case .positive: @@ -262,31 +254,25 @@ class HorizontalRule: RuleView { } } - func unitLabelRect(labelSize: NSSize, rulerSize: NSSize) -> CGRect { - let geometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) - let tickSide = geometry.tickSide(for: .horizontal) - let growthDirection = geometry.growthDirection(for: .horizontal) - let x: CGFloat - let y: CGFloat - + func mouseTickLineX( + forTickX mouseTickX: CGFloat, + growthDirection: RulerGrowthDirection + ) -> CGFloat { switch growthDirection { case .positive: - x = 10 + return mouseTickX case .negative: - x = rulerSize.width - labelSize.width - 10 - } - - switch tickSide { - case .bottom: - y = rulerSize.height - labelSize.height - case .top: - y = 0 - case .left, .right: - assertionFailure("Horizontal unit label must be placed on a horizontal side") - y = rulerSize.height - labelSize.height + return mouseTickX - 1 } + } - return CGRect(x: x, y: y, width: labelSize.width, height: labelSize.height) + func unitLabelRect(labelSize: NSSize, rulerSize: NSSize) -> CGRect { + return UnitLabelView.labelFrame( + labelSize: labelSize, + rulerSize: rulerSize, + orientation: .horizontal, + zeroCorner: zeroCorner + ) } } diff --git a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_128x128.png b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_128x128.png index 450d8db..355771c 100644 Binary files a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_128x128.png and b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_128x128@2x.png index 7ef96b3..4c15f1e 100644 Binary files a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_128x128@2x.png and b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_16x16.png b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_16x16.png index 8bfc861..0acd983 100644 Binary files a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_16x16.png and b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_16x16@2x.png index f439161..bce2405 100644 Binary files a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_16x16@2x.png and b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_256x256.png b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_256x256.png index 7ef96b3..4c15f1e 100644 Binary files a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_256x256.png and b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_256x256@2x.png index 3fa9c08..8b22a07 100644 Binary files a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_256x256@2x.png and b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_32x32.png b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_32x32.png index f439161..bce2405 100644 Binary files a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_32x32.png and b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_32x32@2x.png index 1eb0b1b..f54c572 100644 Binary files a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_32x32@2x.png and b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_512x512.png b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_512x512.png index 3fa9c08..8b22a07 100644 Binary files a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_512x512.png and b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_512x512@2x.png index 5de3230..a215d8f 100644 Binary files a/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_512x512@2x.png and b/Free Ruler/Images.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/Free Ruler/Localizable.xcstrings b/Free Ruler/Localizable.xcstrings index 83af3b7..848d695 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,58 @@ } } }, + "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" : "垂直原点:%@" + } + } + } + }, + "Mouse Tick Label Offsets (mouseX: %@, mouseY: %@)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Mouse Tick Label Offsets (mouseX: %1$@, mouseY: %2$@)" + } + } + } + }, "Reset ruler color" : { "comment" : "Tooltip and accessibility label for the button that restores the default ruler color", "extractionState" : "manual", @@ -801,4 +1063,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Free Ruler/ResizeHandleView.swift b/Free Ruler/ResizeHandleView.swift index 73857be..fa0e161 100644 --- a/Free Ruler/ResizeHandleView.swift +++ b/Free Ruler/ResizeHandleView.swift @@ -7,6 +7,12 @@ import SwiftUI final class ResizeHandleView: NSView { var color: RulerColors + var zeroCorner = prefs.zeroCorner { + didSet { + needsDisplay = true + } + } + private let orientation: Orientation private var trackingArea: NSTrackingArea? private var dragInitialMouseLocation: NSPoint? @@ -154,15 +160,24 @@ final class ResizeHandleView: NSView { } func frame(in bounds: NSRect) -> NSRect { - let resizeSide = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).resizeSide(for: orientation) + return frame(in: bounds, zeroCorner: zeroCorner) + } + + func frame(in bounds: NSRect, zeroCorner: ZeroCorner) -> NSRect { + let placement = ZeroCornerGeometry(zeroCorner: zeroCorner) + .resizeHandlePlacement(for: orientation) + let gripFrame = gripFrame(in: bounds, placement: placement) + + return slotFrame(for: gripFrame, in: bounds, placement: placement) + } + private func gripFrame(in bounds: NSRect, placement: RulerCornerPlacement) -> NSRect { switch orientation { case .horizontal: - let topY = bounds.maxY - horizontalYOffset - let bottomY = topY - length + let bottomY: CGFloat let firstX: CGFloat - switch resizeSide { + switch placement.xSide { case .left: firstX = bounds.minX + horizontalXOffset + 1 case .right: @@ -178,6 +193,16 @@ final class ResizeHandleView: NSView { - 1 } + switch placement.ySide { + case .top: + bottomY = bounds.maxY - horizontalYOffset - length + case .bottom: + bottomY = bounds.minY + horizontalYOffset + case .left, .right: + assertionFailure("Horizontal resize handle must be placed on a vertical side") + bottomY = bounds.maxY - horizontalYOffset - length + } + return NSRect( x: firstX - backgroundPadding, y: bottomY - backgroundPadding, @@ -185,11 +210,20 @@ final class ResizeHandleView: NSView { height: length + (backgroundPadding * 2) ) case .vertical: - let rightX = bounds.minX + verticalXOffset + length - let leftX = rightX - length + let leftX: CGFloat let firstY: CGFloat - switch resizeSide { + switch placement.xSide { + case .left: + leftX = bounds.minX + verticalXOffset + case .right: + leftX = bounds.maxX - verticalXOffset - length + case .top, .bottom: + assertionFailure("Vertical resize handle must be placed on a horizontal side") + leftX = bounds.minX + verticalXOffset + } + + switch placement.ySide { case .top: firstY = bounds.maxY - verticalYOffset @@ -211,9 +245,49 @@ final class ResizeHandleView: NSView { } } + private func slotFrame( + for gripFrame: NSRect, + in bounds: NSRect, + placement: RulerCornerPlacement + ) -> NSRect { + let x: CGFloat + let y: CGFloat + let width: CGFloat + let height: CGFloat + + switch placement.xSide { + case .left: + x = bounds.minX + width = gripFrame.maxX - bounds.minX + case .right: + x = gripFrame.minX + width = bounds.maxX - gripFrame.minX + case .top, .bottom: + assertionFailure("Resize handle slot must be anchored to a horizontal side") + x = gripFrame.minX + width = gripFrame.width + } + + switch placement.ySide { + case .top: + y = gripFrame.minY + height = bounds.maxY - gripFrame.minY + case .bottom: + y = bounds.minY + height = gripFrame.maxY - bounds.minY + case .left, .right: + assertionFailure("Resize handle slot must be anchored to a vertical side") + y = gripFrame.minY + height = gripFrame.height + } + + return NSRect(x: x, y: y, width: width, height: height) + } + private func drawBackground() { + let gripRect = gripRect(in: bounds) let path = NSBezierPath( - roundedRect: bounds, + roundedRect: gripRect, xRadius: backgroundBorderRadius, yRadius: backgroundBorderRadius ) @@ -223,38 +297,85 @@ final class ResizeHandleView: NSView { } private func drawGripLines() { + let gripRect = gripRect(in: bounds) + switch orientation { case .horizontal: for index in 0.. NSRect { + let placement = ZeroCornerGeometry(zeroCorner: zeroCorner) + .resizeHandlePlacement(for: orientation) + let gripSize = self.gripSize() + let x: CGFloat + let y: CGFloat + + switch placement.xSide { + case .left: + x = bounds.maxX - gripSize.width + case .right: + x = bounds.minX + case .top, .bottom: + assertionFailure("Resize handle grip must be anchored to a horizontal side") + x = bounds.minX + } + + switch placement.ySide { + case .top: + y = bounds.minY + case .bottom: + y = bounds.maxY - gripSize.height + case .left, .right: + assertionFailure("Resize handle grip must be anchored to a vertical side") + y = bounds.minY + } + + return NSRect(origin: NSPoint(x: x, y: y), size: gripSize) + } + + private func gripSize() -> NSSize { + switch orientation { + case .horizontal: + return NSSize( + width: CGFloat(lineCount - 1) * lineSpacing + 2 + (backgroundPadding * 2), + height: length + (backgroundPadding * 2) + ) + case .vertical: + return NSSize( + width: length + (backgroundPadding * 2), + height: CGFloat(lineCount - 1) * lineSpacing + 2 + (backgroundPadding * 2) + ) + } + } + private func strokeLine(from start: CGPoint, to end: CGPoint, color: NSColor) { let path = NSBezierPath() path.lineWidth = 1 diff --git a/Free Ruler/RuleView.swift b/Free Ruler/RuleView.swift index 8f0afb8..1e23324 100644 --- a/Free Ruler/RuleView.swift +++ b/Free Ruler/RuleView.swift @@ -45,16 +45,346 @@ struct RulerColors { } } +struct MouseTickLabelLayout { + private static let longestTickLength: CGFloat = 10 + private static let unitLabelFlipPadding: CGFloat = 3 + + private struct Offsets { + let topInset: CGFloat + let bottomInset: CGFloat + let leftInset: CGFloat + let rightInset: CGFloat + let tickLabelSpacing: CGFloat + } + + static func labelFrame( + labelSize: NSSize, + rulerSize: NSSize, + orientation: Orientation, + zeroCorner: ZeroCorner, + tickPosition: CGFloat, + resizeHandleFrame: NSRect? = nil, + unitLabelFrame: NSRect? = nil + ) -> NSRect { + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + let placement = geometry.unitLabelPlacement(for: orientation) + let offsets = offsets(for: orientation, placement: placement) + let x: CGFloat + let y: CGFloat + + switch (orientation, placement.xSide, placement.ySide) { + case (.horizontal, .left, .top), (.horizontal, .right, .top): + x = horizontalX( + forTickX: tickPosition, + labelSize: labelSize, + rulerSize: rulerSize, + zeroCorner: zeroCorner, + resizeHandleFrame: resizeHandleFrame, + unitLabelFrame: unitLabelFrame, + offsets: offsets + ) + y = rulerSize.height - labelSize.height - offsets.topInset + case (.horizontal, .left, .bottom), (.horizontal, .right, .bottom): + x = horizontalX( + forTickX: tickPosition, + labelSize: labelSize, + rulerSize: rulerSize, + zeroCorner: zeroCorner, + resizeHandleFrame: resizeHandleFrame, + unitLabelFrame: unitLabelFrame, + offsets: offsets + ) + y = offsets.bottomInset + case (.vertical, .left, .top), (.vertical, .left, .bottom): + x = verticalX( + labelSize: labelSize, + rulerSize: rulerSize, + tickSide: geometry.tickSide(for: .vertical), + placement: placement, + offsets: offsets + ) + y = verticalY( + forTickY: tickPosition, + labelSize: labelSize, + rulerSize: rulerSize, + zeroCorner: zeroCorner, + resizeHandleFrame: resizeHandleFrame, + unitLabelFrame: unitLabelFrame, + offsets: offsets + ) + case (.vertical, .right, .top), (.vertical, .right, .bottom): + x = verticalX( + labelSize: labelSize, + rulerSize: rulerSize, + tickSide: geometry.tickSide(for: .vertical), + placement: placement, + offsets: offsets + ) + y = verticalY( + forTickY: tickPosition, + labelSize: labelSize, + rulerSize: rulerSize, + zeroCorner: zeroCorner, + resizeHandleFrame: resizeHandleFrame, + unitLabelFrame: unitLabelFrame, + offsets: offsets + ) + case (_, _, _): + assertionFailure("Mouse tick label must be anchored to left/right and top/bottom sides") + x = offsets.leftInset + y = offsets.bottomInset + } + + return NSRect(x: x, y: y, width: labelSize.width, height: labelSize.height) + } + + static func labelBackgroundFrame( + labelSize: NSSize, + rulerSize: NSSize, + orientation: Orientation, + zeroCorner: ZeroCorner, + tickPosition: CGFloat, + resizeHandleFrame: NSRect? = nil, + unitLabelFrame: NSRect? = nil + ) -> NSRect { + guard orientation == .vertical else { + return labelFrame( + labelSize: labelSize, + rulerSize: rulerSize, + orientation: orientation, + zeroCorner: zeroCorner, + tickPosition: tickPosition, + resizeHandleFrame: resizeHandleFrame, + unitLabelFrame: unitLabelFrame + ) + } + + let labelFrame = labelFrame( + labelSize: labelSize, + rulerSize: rulerSize, + orientation: orientation, + zeroCorner: zeroCorner, + tickPosition: tickPosition, + resizeHandleFrame: resizeHandleFrame, + unitLabelFrame: unitLabelFrame + ) + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + + return verticalLabelLaneFrame( + rulerSize: rulerSize, + tickSide: geometry.tickSide(for: .vertical), + y: labelFrame.minY, + height: labelFrame.height + ).union(labelFrame) + } + + private static func offsets( + for orientation: Orientation, + placement: RulerCornerPlacement + ) -> Offsets { + switch (orientation, placement.xSide, placement.ySide) { + case (.horizontal, .left, .top): + return Offsets(topInset: 2, bottomInset: 2, leftInset: 0, rightInset: 0, tickLabelSpacing: 5) + case (.horizontal, .right, .top): + return Offsets(topInset: 2, bottomInset: 2, leftInset: 0, rightInset: 0, tickLabelSpacing: 5) + case (.horizontal, .left, .bottom): + return Offsets(topInset: 2, bottomInset: 7, leftInset: 5, rightInset: 0, tickLabelSpacing: 5) + case (.horizontal, .right, .bottom): + return Offsets(topInset: 2, bottomInset: 7, leftInset: 5, rightInset: 0, tickLabelSpacing: 5) + case (.vertical, .left, .top): + return Offsets(topInset: 2, bottomInset: 2, leftInset: 7, rightInset: 7, tickLabelSpacing: 4) + case (.vertical, .right, .top): + return Offsets(topInset: 2, bottomInset: 2, leftInset: 7, rightInset: 7, tickLabelSpacing: 4) + case (.vertical, .left, .bottom): + return Offsets(topInset: 2, bottomInset: 2, leftInset: 7, rightInset: 7, tickLabelSpacing: 4) + case (.vertical, .right, .bottom): + return Offsets(topInset: 2, bottomInset: 2, leftInset: 7, rightInset: 7, tickLabelSpacing: 4) + case (_, _, _): + assertionFailure("Mouse tick label offsets require left/right and top/bottom placement") + return Offsets(topInset: 2, bottomInset: 2, leftInset: 5, rightInset: 5, tickLabelSpacing: 5) + } + } + + private static func horizontalX( + forTickX tickX: CGFloat, + labelSize: NSSize, + rulerSize: NSSize, + zeroCorner: ZeroCorner, + resizeHandleFrame: NSRect?, + unitLabelFrame: NSRect?, + offsets: Offsets + ) -> CGFloat { + let minLabelX = offsets.leftInset + let maxLabelX = rulerSize.width - labelSize.width - offsets.rightInset + + let rightX = tickX + offsets.tickLabelSpacing + let leftX = tickX - offsets.tickLabelSpacing - labelSize.width + let preferredX = horizontalLabelFitsPreferredSide( + labelX: rightX, + labelSize: labelSize, + maxLabelX: maxLabelX, + zeroCorner: zeroCorner, + resizeHandleFrame: resizeHandleFrame, + unitLabelFrame: unitLabelFrame + ) ? rightX : leftX + + return clamp(preferredX, lowerBound: minLabelX, upperBound: maxLabelX) + } + + private static func horizontalLabelFitsPreferredSide( + labelX: CGFloat, + labelSize: NSSize, + maxLabelX: CGFloat, + zeroCorner: ZeroCorner, + resizeHandleFrame: NSRect?, + unitLabelFrame: NSRect? + ) -> Bool { + guard labelX <= maxLabelX else { return false } + + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + if let unitLabelFrame = unitLabelFrame, + geometry.unitLabelPlacement(for: .horizontal).xSide == .right, + labelX + labelSize.width > unitLabelFrame.minX - unitLabelFlipPadding { + return false + } + + guard let resizeHandleFrame = resizeHandleFrame else { return true } + + switch geometry.resizeSide(for: .horizontal) { + case .left: + return true + case .right: + return labelX + labelSize.width <= resizeHandleFrame.minX + case .top, .bottom: + assertionFailure("Horizontal resize side must be left or right") + return true + } + } + + private static func verticalY( + forTickY tickY: CGFloat, + labelSize: NSSize, + rulerSize: NSSize, + zeroCorner: ZeroCorner, + resizeHandleFrame: NSRect?, + unitLabelFrame: NSRect?, + offsets: Offsets + ) -> CGFloat { + let minLabelY = offsets.bottomInset + let maxLabelY = rulerSize.height - labelSize.height - offsets.topInset + + let belowTickY = tickY - offsets.tickLabelSpacing - labelSize.height + let aboveTickY = tickY + offsets.tickLabelSpacing + let preferredY = verticalLabelFitsPreferredSide( + labelY: belowTickY, + labelSize: labelSize, + minLabelY: minLabelY, + zeroCorner: zeroCorner, + resizeHandleFrame: resizeHandleFrame, + unitLabelFrame: unitLabelFrame + ) ? belowTickY : aboveTickY + + return clamp(preferredY, lowerBound: minLabelY, upperBound: maxLabelY) + } + + private static func verticalX( + labelSize: NSSize, + rulerSize: NSSize, + tickSide: RulerSide, + placement: RulerCornerPlacement, + offsets: Offsets + ) -> CGFloat { + let laneFrame = verticalLabelLaneFrame( + rulerSize: rulerSize, + tickSide: tickSide, + y: 0, + height: labelSize.height + ) + + switch placement.xSide { + case .left: + return laneFrame.minX + offsets.leftInset + case .right: + return laneFrame.maxX - labelSize.width - offsets.rightInset + case .top, .bottom: + assertionFailure("Vertical mouse tick labels must be padded from a left or right lane edge") + return laneFrame.minX + offsets.leftInset + } + } + + private static func verticalLabelLaneFrame( + rulerSize: NSSize, + tickSide: RulerSide, + y: CGFloat, + height: CGFloat + ) -> NSRect { + let laneWidth = max(0, rulerSize.width - longestTickLength) + + switch tickSide { + case .right: + return NSRect(x: 0, y: y, width: laneWidth, height: height) + case .left: + return NSRect(x: rulerSize.width - laneWidth, y: y, width: laneWidth, height: height) + case .top, .bottom: + assertionFailure("Vertical mouse tick label lane must follow a vertical tick side") + return NSRect(x: 0, y: y, width: laneWidth, height: height) + } + } + + private static func verticalLabelFitsPreferredSide( + labelY: CGFloat, + labelSize: NSSize, + minLabelY: CGFloat, + zeroCorner: ZeroCorner, + resizeHandleFrame: NSRect?, + unitLabelFrame: NSRect? + ) -> Bool { + guard labelY >= minLabelY else { return false } + + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + if let unitLabelFrame = unitLabelFrame, + geometry.unitLabelPlacement(for: .vertical).ySide == .bottom, + labelY < unitLabelFrame.maxY + unitLabelFlipPadding { + return false + } + + guard let resizeHandleFrame = resizeHandleFrame else { return true } + + switch geometry.resizeSide(for: .vertical) { + case .bottom: + return labelY >= resizeHandleFrame.maxY + case .top: + return true + case .left, .right: + assertionFailure("Vertical resize side must be top or bottom") + return true + } + } + + private static func clamp( + _ value: CGFloat, + lowerBound: CGFloat, + upperBound: CGFloat + ) -> CGFloat { + guard lowerBound <= upperBound else { + return lowerBound + } + + return min(max(value, lowerBound), upperBound) + } +} + class RuleView: NSView { var color = RulerColors() { didSet { resizeHandleView?.color = color resizeHandleView?.needsDisplay = true + updateUnitLabelFrame() } } - let mouseTickLabelResizeHandleSpacing: CGFloat = 8 private var resizeHandleView: ResizeHandleView? + private var unitLabelView: UnitLabelView? var trackingArea: NSTrackingArea? let trackingAreaOptions: NSTrackingArea.Options = [ @@ -105,6 +435,9 @@ class RuleView: NSView { override func layout() { super.layout() updateResizeHandleFrame() + updateUnitLabelFrame() + updateResizeHandleVisibility() + updateUnitLabelVisibility() } func installResizeHandle(for orientation: Orientation) { @@ -114,6 +447,14 @@ class RuleView: NSView { updateResizeHandleFrame() } + func installUnitLabel(for orientation: Orientation) { + let view = UnitLabelView(orientation: orientation, label: unitLabel()) + addSubview(view) + unitLabelView = view + updateUnitLabelFrame() + updateUnitLabelVisibility() + } + func drawMouseTick(at mouseLoc: NSPoint) { // required override // TODO: is there a better way to do this, maybe via a protocol? @@ -122,21 +463,34 @@ class RuleView: NSView { } func redrawForPreferenceChange() { + updateResizeHandleFrame() + updateUnitLabelFrame() + updateResizeHandleVisibility() + updateUnitLabelVisibility() setNeedsDisplay(visibleRect) resizeHandleView?.needsDisplay = true + unitLabelView?.needsDisplay = true + } + + func installWindowBorder() { + wantsLayer = true + layer?.borderColor = CGColor(gray: 0, alpha: 0.5) + layer?.borderWidth = 1.0 } var windowWidth: CGFloat { - return self.window?.frame.width ?? 0 + return self.window?.frame.width ?? bounds.width } var windowHeight: CGFloat { - return self.window?.frame.height ?? 0 + return self.window?.frame.height ?? bounds.height } var showMouseTick: Bool = true { didSet { if showMouseTick != oldValue { + updateResizeHandleVisibility() + updateUnitLabelVisibility() needsDisplay = true } } @@ -153,10 +507,18 @@ class RuleView: NSView { prefs.unit } + var zeroCorner: ZeroCorner { + prefs.zeroCorner + } + var resizeHandleExclusionFrame: NSRect? { return resizeHandleView?.frame } + var unitLabelFrame: NSRect? { + return unitLabelView?.frame + } + func getUnitLabel() -> String { switch unit { case .pixels: @@ -198,10 +560,42 @@ class RuleView: NSView { } } + func setUnitLabelHidden(_ isHidden: Bool) { + unitLabelView?.isHidden = isHidden + } + + func setResizeHandleObscured(_ isObscured: Bool) { + resizeHandleView?.alphaValue = isObscured ? 0 : 1 + } + + func updateResizeHandleVisibility() { + setResizeHandleObscured(false) + } + + func updateUnitLabelVisibility() { + setUnitLabelHidden(false) + } + private func updateResizeHandleFrame() { guard let resizeHandleView = resizeHandleView else { return } - resizeHandleView.frame = resizeHandleView.frame(in: bounds) + resizeHandleView.zeroCorner = zeroCorner + resizeHandleView.frame = resizeHandleView.frame(in: bounds, zeroCorner: zeroCorner) + } + + private func updateUnitLabelFrame() { + guard let unitLabelView = unitLabelView else { return } + + unitLabelView.zeroCorner = zeroCorner + unitLabelView.label = unitLabel() + unitLabelView.frame = unitLabelView.frame(in: bounds, zeroCorner: zeroCorner) + } + + private func unitLabel() -> NSAttributedString { + return NSAttributedString( + string: getUnitLabel(), + attributes: labelAttributes(alignment: .left, foregroundColor: color.ticks) + ) } } @@ -267,43 +661,331 @@ extension NSColor { } #if DEBUG -private struct RuleViewPreview: NSViewRepresentable { +private let mouseTickLabelPreviewRulerLength: CGFloat = 260 +private let mouseTickLabelPreviewMouseRange = -10...(mouseTickLabelPreviewRulerLength + 10) + +private struct MouseTickRulePreview: NSViewRepresentable { let orientation: Orientation + let zeroCorner: ZeroCorner + let mouseX: CGFloat + let mouseY: CGFloat + let rulerLength: CGFloat func makeNSView(context: Context) -> RuleView { let view: RuleView switch orientation { case .horizontal: - view = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 320, height: Ruler.thickness)) + view = MouseTickPreviewHorizontalRule( + frame: NSRect(x: 0, y: 0, width: rulerLength, height: Ruler.thickness) + ) case .vertical: - view = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 320)) + view = MouseTickPreviewVerticalRule( + frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: rulerLength) + ) } - view.showMouseTick = false - view.wantsLayer = true - view.layer?.borderColor = CGColor(gray: 0, alpha: 0.5) - view.layer?.borderWidth = 1 + view.color = RulerColors( + customFill: NSColor(calibratedRed: 0.94, green: 0.82, blue: 0.54, alpha: 1) + ) + view.showMouseTick = true + view.installWindowBorder() + view.setAccessibilityElement(false) + configure(view) return view } func updateNSView(_ view: RuleView, context: Context) { - view.showMouseTick = false + configure(view) + } + + private func configure(_ view: RuleView) { + switch view { + case let horizontalRule as MouseTickPreviewHorizontalRule: + horizontalRule.previewZeroCorner = zeroCorner + horizontalRule.mouseTickX = localMousePosition(for: mouseX) + case let verticalRule as MouseTickPreviewVerticalRule: + verticalRule.previewZeroCorner = zeroCorner + verticalRule.mouseTickY = localMousePosition(for: mouseY) + default: + assertionFailure("Mouse tick preview must host a concrete preview ruler") + } + + view.redrawForPreferenceChange() + view.layoutSubtreeIfNeeded() view.needsDisplay = true } + + private func localMousePosition(for measurement: CGFloat) -> CGFloat { + let growthDirection = ZeroCornerGeometry(zeroCorner: zeroCorner) + .growthDirection(for: orientation) + + switch growthDirection { + case .positive: + return measurement + case .negative: + return rulerLength - measurement + } + } } -struct RuleView_Previews: PreviewProvider { - static var previews: some View { - HStack(alignment: .top, spacing: 24) { - RuleViewPreview(orientation: .horizontal) - .frame(width: 320, height: Ruler.thickness) +private final class MouseTickPreviewHorizontalRule: HorizontalRule { + var previewZeroCorner: ZeroCorner = .topLeft + + override var unit: Unit { + .pixels + } + + override var zeroCorner: ZeroCorner { + previewZeroCorner + } - RuleViewPreview(orientation: .vertical) - .frame(width: Ruler.thickness, height: 320) + override var windowWidth: CGFloat { + bounds.width + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(bounds) + } +} + +private final class MouseTickPreviewVerticalRule: VerticalRule { + var previewZeroCorner: ZeroCorner = .topLeft + + override var unit: Unit { + .pixels + } + + override var zeroCorner: ZeroCorner { + previewZeroCorner + } + + override var windowHeight: CGFloat { + bounds.height + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(bounds) + } +} + +private struct MouseTickLabelOffsetPreview: View { + let mouseX: CGFloat + let mouseY: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + MouseTickLabelOffsetPreviewHeading(mouseX: mouseX, mouseY: mouseY) + + MouseTickLabelOffsetPreviewGrid( + mouseX: mouseX, + mouseY: mouseY + ) + } + .padding() + } +} + +private struct MouseTickLabelOffsetPreviewHeading: View { + let mouseX: CGFloat + let mouseY: CGFloat + + var body: some View { + Text("Mouse Tick Label Offsets (mouseX: \(Int(mouseX)), mouseY: \(Int(mouseY)))") + .font(.headline) + } +} + +private struct MouseTickLabelOffsetPreviewGrid: View { + let mouseX: CGFloat + let mouseY: CGFloat + + private let cellSize = CGSize( + width: mouseTickLabelPreviewRulerLength + Ruler.thickness, + height: mouseTickLabelPreviewRulerLength + Ruler.thickness + ) + private let cases: [(name: String, zeroCorner: ZeroCorner)] = [ + ("Top Left", .topLeft), + ("Top Right", .topRight), + ("Bottom Left", .bottomLeft), + ("Bottom Right", .bottomRight), + ] + + var body: some View { + LazyVGrid( + columns: [ + GridItem(.fixed(cellSize.width), spacing: 24), + GridItem(.fixed(cellSize.width), spacing: 24), + ], + alignment: .leading, + spacing: 22 + ) { + ForEach(cases, id: \.name) { testCase in + cornerPreview(name: testCase.name, zeroCorner: testCase.zeroCorner) + } + } + } + + private func cornerPreview(name: String, zeroCorner: ZeroCorner) -> some View { + MouseTickLabelCornerPreview( + name: name, + zeroCorner: zeroCorner, + rulerLength: mouseTickLabelPreviewRulerLength, + mouseX: mouseX, + mouseY: mouseY + ) + } +} + +private struct MouseTickLabelCornerPreview: View { + let name: String + let zeroCorner: ZeroCorner + let rulerLength: CGFloat + let mouseX: CGFloat + let mouseY: CGFloat + + private var thickness: CGFloat { + Ruler.thickness + } + + var body: some View { + VStack(alignment: .center, spacing: 8) { + Text(name) + .font(.caption) + + ZStack(alignment: .topLeading) { + MouseTickRulePreview( + orientation: .horizontal, + zeroCorner: zeroCorner, + mouseX: mouseX, + mouseY: mouseY, + rulerLength: rulerLength + ) + .frame(width: rulerLength, height: thickness) + .position(x: horizontalCenter.x, y: horizontalCenter.y) + + MouseTickRulePreview( + orientation: .vertical, + zeroCorner: zeroCorner, + mouseX: mouseX, + mouseY: mouseY, + rulerLength: rulerLength + ) + .frame(width: thickness, height: rulerLength) + .position(x: verticalCenter.x, y: verticalCenter.y) + } + .frame( + width: rulerLength + thickness, + height: rulerLength + thickness + ) + } + } + + private var horizontalCenter: CGPoint { + switch zeroCorner { + case .topLeft: + return CGPoint(x: thickness + (rulerLength / 2), y: thickness / 2) + case .topRight: + return CGPoint(x: rulerLength / 2, y: thickness / 2) + case .bottomLeft: + return CGPoint(x: thickness + (rulerLength / 2), y: rulerLength + (thickness / 2)) + case .bottomRight: + return CGPoint(x: rulerLength / 2, y: rulerLength + (thickness / 2)) + } + } + + private var verticalCenter: CGPoint { + switch zeroCorner { + case .topLeft: + return CGPoint(x: thickness / 2, y: thickness + (rulerLength / 2)) + case .topRight: + return CGPoint(x: rulerLength + (thickness / 2), y: thickness + (rulerLength / 2)) + case .bottomLeft: + return CGPoint(x: thickness / 2, y: rulerLength / 2) + case .bottomRight: + return CGPoint(x: rulerLength + (thickness / 2), y: rulerLength / 2) + } + } +} + +private struct MouseTickLabelOffsetPreviewControls: View { + @State private var mouseX: CGFloat = 36 + @State private var mouseY: CGFloat = 200 + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + MouseTickLabelOffsetPreviewHeading(mouseX: mouseX, mouseY: mouseY) + + ZStack(alignment: .top) { + MouseTickLabelOffsetPreviewGrid( + mouseX: mouseX, + mouseY: mouseY + ) + + MouseTickLabelOffsetSliderPanel(mouseX: $mouseX, mouseY: $mouseY) + .frame(width: 320) + .padding(.top, 126) + } } .padding() - .previewLayout(.sizeThatFits) - .previewDisplayName("Rulers") + } +} + +private struct MouseTickLabelOffsetSliderPanel: View { + @Binding var mouseX: CGFloat + @Binding var mouseY: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + MouseTickLabelOffsetSlider(title: "X", value: $mouseX) + MouseTickLabelOffsetSlider(title: "Y", value: $mouseY) + } + } +} + +private struct MouseTickLabelOffsetSlider: View { + let title: String + @Binding var value: CGFloat + + var body: some View { + HStack(spacing: 8) { + Text(title) + .font(.caption) + .frame(width: 12, alignment: .leading) + + Slider( + value: $value, + in: mouseTickLabelPreviewMouseRange, + step: 1 + ) + + TextField(title, value: editableValue, format: .number.precision(.fractionLength(0))) + .font(.caption.monospacedDigit()) + .multilineTextAlignment(.trailing) + .frame(width: 44) + .textFieldStyle(.roundedBorder) + } + } + + private var editableValue: Binding { + Binding( + get: { Double(value) }, + set: { newValue in + guard newValue.isFinite else { return } + + value = min( + max(CGFloat(newValue).rounded(), mouseTickLabelPreviewMouseRange.lowerBound), + mouseTickLabelPreviewMouseRange.upperBound + ) + } + ) + } +} + +struct RuleView_Previews: PreviewProvider { + static var previews: some View { + MouseTickLabelOffsetPreviewControls() + .previewLayout(.sizeThatFits) + .previewDisplayName("Mouse Tick Labels") } } #endif diff --git a/Free Ruler/Ruler.swift b/Free Ruler/Ruler.swift index f3b4c10..abb60ea 100644 --- a/Free Ruler/Ruler.swift +++ b/Free Ruler/Ruler.swift @@ -12,6 +12,29 @@ enum Orientation: String { case bottomRight = 3 } +extension ZeroCorner { + func flipped(along orientation: Orientation) -> ZeroCorner { + switch (self, orientation) { + case (.topLeft, .horizontal): + return .topRight + case (.topRight, .horizontal): + return .topLeft + case (.bottomLeft, .horizontal): + return .bottomRight + case (.bottomRight, .horizontal): + return .bottomLeft + case (.topLeft, .vertical): + return .bottomLeft + case (.topRight, .vertical): + return .bottomRight + case (.bottomLeft, .vertical): + return .topLeft + case (.bottomRight, .vertical): + return .topRight + } + } +} + enum RulerGrowthDirection: Equatable { case positive case negative @@ -22,6 +45,24 @@ enum RulerSide: Equatable { case right case bottom case left + + var opposite: RulerSide { + switch self { + case .top: + return .bottom + case .right: + return .left + case .bottom: + return .top + case .left: + return .right + } + } +} + +struct RulerCornerPlacement: Equatable { + let xSide: RulerSide + let ySide: RulerSide } struct ZeroCornerGeometry { @@ -60,6 +101,36 @@ struct ZeroCornerGeometry { } } + func resizeHandlePlacement(for orientation: Orientation) -> RulerCornerPlacement { + switch orientation { + case .horizontal: + return RulerCornerPlacement( + xSide: resizeSide(for: orientation), + ySide: tickSide(for: orientation).opposite + ) + case .vertical: + return RulerCornerPlacement( + xSide: tickSide(for: orientation).opposite, + ySide: resizeSide(for: orientation) + ) + } + } + + func unitLabelPlacement(for orientation: Orientation) -> RulerCornerPlacement { + switch orientation { + case .horizontal: + return RulerCornerPlacement( + xSide: resizeSide(for: orientation).opposite, + ySide: tickSide(for: orientation).opposite + ) + case .vertical: + return RulerCornerPlacement( + xSide: tickSide(for: orientation).opposite, + ySide: resizeSide(for: orientation).opposite + ) + } + } + func zeroPoint(in frame: NSRect, for orientation: Orientation) -> NSPoint { switch orientation { case .horizontal: 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/Free Ruler/RulerWindow.swift b/Free Ruler/RulerWindow.swift index 3cd534c..2fd0383 100644 --- a/Free Ruler/RulerWindow.swift +++ b/Free Ruler/RulerWindow.swift @@ -38,9 +38,7 @@ class RulerWindow: NSPanel { self.isMovableByWindowBackground = true self.hasShadow = prefs.rulerShadow - rule.wantsLayer = true - rule.layer?.borderColor = CGColor(gray: 0, alpha: 0.5) - rule.layer?.borderWidth = 1.0 + rule.installWindowBorder() rule.setAccessibilityElement(true) rule.setAccessibilityIdentifier(getRuleIdentifier(for: ruler.orientation)) diff --git a/Free Ruler/UnitLabelView.swift b/Free Ruler/UnitLabelView.swift new file mode 100644 index 0000000..3413e49 --- /dev/null +++ b/Free Ruler/UnitLabelView.swift @@ -0,0 +1,152 @@ +import Cocoa + +final class UnitLabelView: NSView { + private struct Padding { + let top: CGFloat + let bottom: CGFloat + let left: CGFloat + let right: CGFloat + } + + private static let padding = Padding(top: 4, bottom: 9, left: 8, right: 8) + private static let descenderSafetyPadding: CGFloat = 2 + + var label: NSAttributedString { + didSet { + needsDisplay = true + } + } + var zeroCorner = prefs.zeroCorner { + didSet { + needsDisplay = true + } + } + + private let orientation: Orientation + + init(orientation: Orientation, label: NSAttributedString) { + self.orientation = orientation + self.label = label + super.init(frame: .zero) + + setAccessibilityElement(false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented. Use init(orientation:label:)") + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let placement = ZeroCornerGeometry(zeroCorner: zeroCorner) + .unitLabelPlacement(for: orientation) + let labelRect = Self.labelDrawRect( + labelSize: Self.labelSize(for: label), + bounds: bounds, + placement: placement + ) + + label.draw(with: labelRect, context: nil) + } + + func frame(in bounds: NSRect) -> NSRect { + return frame(in: bounds, zeroCorner: zeroCorner) + } + + func frame(in bounds: NSRect, zeroCorner: ZeroCorner) -> NSRect { + return Self.labelFrame( + labelSize: Self.labelSize(for: label), + rulerSize: bounds.size, + orientation: orientation, + zeroCorner: zeroCorner + ) + } + + static func labelSize(for label: NSAttributedString) -> NSSize { + var size = label.size() + guard label.length > 0, + let font = label.attribute(.font, at: 0, effectiveRange: nil) as? NSFont else { + return size + } + + size.height = ceil(font.ascender - font.descender + font.leading) + return size + } + + static func labelFrame( + labelSize: NSSize, + rulerSize: NSSize, + orientation: Orientation, + zeroCorner: ZeroCorner + ) -> NSRect { + let placement = ZeroCornerGeometry(zeroCorner: zeroCorner) + .unitLabelPlacement(for: orientation) + let x: CGFloat + let y: CGFloat + let width: CGFloat + let height: CGFloat + + switch placement.xSide { + case .left: + x = 0 + width = Self.padding.left + labelSize.width + case .right: + x = rulerSize.width - labelSize.width - Self.padding.right + width = labelSize.width + Self.padding.right + case .top, .bottom: + assertionFailure("Unit label must be anchored to a horizontal side") + x = 0 + width = Self.padding.left + labelSize.width + } + + switch placement.ySide { + case .top: + y = rulerSize.height - labelSize.height - Self.padding.top + height = labelSize.height + Self.padding.top + case .bottom: + y = 0 + height = Self.padding.bottom + labelSize.height + case .left, .right: + assertionFailure("Unit label must be anchored to a vertical side") + y = 0 + height = Self.padding.bottom + labelSize.height + } + + return NSRect(x: x, y: y, width: width, height: height) + } + + static func labelDrawRect( + labelSize: NSSize, + bounds: NSRect, + placement: RulerCornerPlacement + ) -> NSRect { + let x: CGFloat + let y: CGFloat + + switch placement.xSide { + case .left: + x = bounds.maxX - labelSize.width + case .right: + x = bounds.minX + case .top, .bottom: + assertionFailure("Unit label must be anchored to a horizontal side") + x = bounds.minX + } + + switch placement.ySide { + case .top: + y = bounds.minY + min( + Self.descenderSafetyPadding, + max(0, bounds.height - labelSize.height) + ) + case .bottom: + y = bounds.maxY - labelSize.height + case .left, .right: + assertionFailure("Unit label must be anchored to a vertical side") + y = bounds.minY + } + + return NSRect(x: x, y: y, width: labelSize.width, height: labelSize.height) + } +} diff --git a/Free Ruler/VerticalRule.swift b/Free Ruler/VerticalRule.swift index 1e2a7d3..27bc377 100644 --- a/Free Ruler/VerticalRule.swift +++ b/Free Ruler/VerticalRule.swift @@ -6,17 +6,21 @@ class VerticalRule: RuleView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) + installUnitLabel(for: .vertical) installResizeHandle(for: .vertical) } required init?(coder: NSCoder) { super.init(coder: coder) + installUnitLabel(for: .vertical) installResizeHandle(for: .vertical) } var mouseTickY: CGFloat = 0 { didSet { if mouseTickY != oldValue { + updateResizeHandleVisibility() + updateUnitLabelVisibility() needsDisplay = true } } @@ -33,7 +37,7 @@ class VerticalRule: RuleView { let height = dirtyRect.height let path = NSBezierPath() let tickLayout = RulerTickLayout(unit: unit, screen: screen) - let geometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) let tickSide = geometry.tickSide(for: .vertical) let growthDirection = geometry.growthDirection(for: .vertical) let attrs = labelAttributes( @@ -43,7 +47,7 @@ class VerticalRule: RuleView { let labelWidth: CGFloat = 50 let labelHeight: CGFloat = 20 - // TODO: refactor this to use label.size() logic (see func drawUnitLabel) + // TODO: refactor this to use measured label sizes. // substract two so ticks don't overlap with border // substract from this range so we can use the height var for position calculations @@ -96,9 +100,7 @@ class VerticalRule: RuleView { color.ticks.setStroke() path.stroke() - if !showMouseTick || self.windowHeight - mouseTickY < 0 || windowHeight - mouseTickY > 18 { - drawUnitLabel() - } + updateUnitLabelVisibility() // Draw the MouseTick & number if showMouseTick && mouseTickY >= 1 && mouseTickY < windowHeight { @@ -117,9 +119,11 @@ class VerticalRule: RuleView { let mouseTick = NSBezierPath() let width: CGFloat = 40 let startX: CGFloat = 0 + let growthDirection = ZeroCornerGeometry(zeroCorner: zeroCorner).growthDirection(for: .vertical) + let lineY = mouseTickLineY(forTickY: mouseTickY, growthDirection: growthDirection) - mouseTick.move(to: CGPoint(x: startX, y: mouseTickY)) - mouseTick.line(to: CGPoint(x: width, y: mouseTickY)) + mouseTick.move(to: CGPoint(x: startX, y: lineY)) + mouseTick.line(to: CGPoint(x: width, y: lineY)) mouseTick.transform(using: transformer) @@ -138,12 +142,19 @@ class VerticalRule: RuleView { let labelSize = label.size() let labelRect = mouseNumberLabelRect( - number: height - mouseTickY, + tickY: mouseTickY, + labelSize: labelSize, + rulerSize: CGSize(width: self.frame.width, height: height) + ) + let backgroundRect = mouseNumberLabelBackgroundRect( + tickY: mouseTickY, labelSize: labelSize, - rulerHeight: height + rulerSize: CGSize(width: self.frame.width, height: height) ) + + guard NSGraphicsContext.current != nil else { return } color.fill.setFill() - labelRect.fill() + backgroundRect.fill() label.draw( with: labelRect, @@ -152,38 +163,52 @@ class VerticalRule: RuleView { ) } - func mouseNumberLabelRect(number: CGFloat, labelSize: CGSize, rulerHeight: CGFloat) -> CGRect { - let labelOffset: CGFloat = 2 - let tickSide = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).tickSide(for: .vertical) - - // Offset the bottom position until text can be centered vertically in the label rect. - let bottomPosition = number + 7 - let topPosition = number - labelOffset - labelSize.height - let enoughRoomToTheBottom = bottomPosition + labelSize.height < rulerHeight - labelOffset - let labelY = enoughRoomToTheBottom ? bottomPosition : topPosition - let labelX: CGFloat = tickSide == .right ? 7 : 11 - var labelRect = CGRect(x: labelX, y: rulerHeight - (labelY + labelSize.height), width: 22, height: 15) - - if let resizeHandleExclusionFrame = resizeHandleExclusionFrame { - let minLabelBottom = resizeHandleExclusionFrame.maxY + mouseTickLabelResizeHandleSpacing - labelRect.origin.y = max(labelRect.origin.y, minLabelBottom) - } + func mouseNumberLabelRect(tickY: CGFloat, labelSize: CGSize, rulerSize: CGSize) -> CGRect { + return MouseTickLabelLayout.labelFrame( + labelSize: labelSize, + rulerSize: rulerSize, + orientation: .vertical, + zeroCorner: zeroCorner, + tickPosition: tickY, + resizeHandleFrame: resizeHandleExclusionFrame, + unitLabelFrame: unitLabelFrame + ) + } - return labelRect + func mouseNumberLabelBackgroundRect(tickY: CGFloat, labelSize: CGSize, rulerSize: CGSize) -> CGRect { + return MouseTickLabelLayout.labelBackgroundFrame( + labelSize: labelSize, + rulerSize: rulerSize, + orientation: .vertical, + zeroCorner: zeroCorner, + tickPosition: tickY, + resizeHandleFrame: resizeHandleExclusionFrame, + unitLabelFrame: unitLabelFrame + ) } - func drawUnitLabel() { - let attributes = labelAttributes(alignment: .left, foregroundColor: color.ticks) + override func updateUnitLabelVisibility() { + guard showMouseTick, + mouseTickY >= bounds.minY, + mouseTickY <= bounds.maxY, + let frame = unitLabelFrame else { + setUnitLabelHidden(false) + return + } - let unitlabel = self.getUnitLabel() - let label = NSAttributedString(string: unitlabel, attributes: attributes) - let labelSize = label.size() - let labelRect = unitLabelRect(labelSize: labelSize, rulerSize: bounds.size) + setUnitLabelHidden(frame.minY <= mouseTickY && mouseTickY <= frame.maxY) + } - label.draw( - with: labelRect, - context: nil - ) + override func updateResizeHandleVisibility() { + guard showMouseTick, + mouseTickY >= bounds.minY, + mouseTickY <= bounds.maxY, + let frame = resizeHandleExclusionFrame else { + setResizeHandleObscured(false) + return + } + + setResizeHandleObscured(frame.minY <= mouseTickY && mouseTickY <= frame.maxY) } func tickY( @@ -245,7 +270,7 @@ class VerticalRule: RuleView { } func mouseNumber(forTickY mouseTickY: CGFloat, rulerHeight: CGFloat) -> CGFloat { - let growthDirection = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).growthDirection(for: .vertical) + let growthDirection = ZeroCornerGeometry(zeroCorner: zeroCorner).growthDirection(for: .vertical) switch growthDirection { case .positive: @@ -255,31 +280,25 @@ class VerticalRule: RuleView { } } - func unitLabelRect(labelSize: NSSize, rulerSize: NSSize) -> CGRect { - let geometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) - let tickSide = geometry.tickSide(for: .vertical) - let growthDirection = geometry.growthDirection(for: .vertical) - let x: CGFloat - let y: CGFloat - - switch tickSide { - case .right: - x = 8 - case .left: - x = rulerSize.width - labelSize.width - 8 - case .top, .bottom: - assertionFailure("Vertical unit label must be placed on a vertical side") - x = 8 - } - + func mouseTickLineY( + forTickY mouseTickY: CGFloat, + growthDirection: RulerGrowthDirection + ) -> CGFloat { switch growthDirection { case .positive: - y = 2 + return mouseTickY + 1 case .negative: - y = rulerSize.height - labelSize.height - 2 + return mouseTickY } + } - return CGRect(x: x, y: y, width: labelSize.width, height: labelSize.height) + func unitLabelRect(labelSize: NSSize, rulerSize: NSSize) -> CGRect { + return UnitLabelView.labelFrame( + labelSize: labelSize, + rulerSize: rulerSize, + orientation: .vertical, + zeroCorner: zeroCorner + ) } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index bb23050..10a2472 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -1,4 +1,5 @@ import AppKit +import Carbon.HIToolbox import XCTest @testable import Free_Ruler @@ -114,6 +115,18 @@ final class RulerCoreTests: XCTestCase { } } + func testZeroCornerFlipsAlongSelectedAxis() { + XCTAssertEqual(ZeroCorner.topLeft.flipped(along: .horizontal), .topRight) + XCTAssertEqual(ZeroCorner.topRight.flipped(along: .horizontal), .topLeft) + XCTAssertEqual(ZeroCorner.bottomLeft.flipped(along: .horizontal), .bottomRight) + XCTAssertEqual(ZeroCorner.bottomRight.flipped(along: .horizontal), .bottomLeft) + + XCTAssertEqual(ZeroCorner.topLeft.flipped(along: .vertical), .bottomLeft) + XCTAssertEqual(ZeroCorner.topRight.flipped(along: .vertical), .bottomRight) + XCTAssertEqual(ZeroCorner.bottomLeft.flipped(along: .vertical), .topLeft) + XCTAssertEqual(ZeroCorner.bottomRight.flipped(along: .vertical), .topRight) + } + func testZeroCornerGeometryPlacesFramesAroundSharedZeroPoint() { let zeroPoint = NSPoint(x: 200, y: 300) let horizontalSize = NSSize(width: 120, height: Ruler.thickness) @@ -242,6 +255,14 @@ final class RulerCoreTests: XCTestCase { rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative), 250 ) + XCTAssertEqual( + rule.mouseTickLineX(forTickX: 1, growthDirection: .positive), + 1 + ) + XCTAssertEqual( + rule.mouseTickLineX(forTickX: 299, growthDirection: .negative), + 298 + ) let bottomTick = rule.tickLine(forX: 50, length: 10, rulerHeight: 40, tickSide: .bottom) XCTAssertEqual(bottomTick.start, CGPoint(x: 50, y: 1)) @@ -258,7 +279,7 @@ final class RulerCoreTests: XCTestCase { rulerHeight: 40, tickSide: .top ), - CGRect(x: 225.5, y: 7, width: 50, height: 20) + CGRect(x: 225.5, y: 19, width: 50, height: 20) ) } @@ -266,23 +287,11 @@ final class RulerCoreTests: XCTestCase { withRestoredZeroCornerPreference { prefs.zeroCorner = .bottomRight let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { - return XCTFail("Expected horizontal ruler to install a resize handle") - } XCTAssertEqual(rule.mouseNumber(forTickX: 260, rulerWidth: 300), 40) - XCTAssertEqual( - rule.mouseNumberLabelRect( - number: resizeHandleFrame.maxX - 1, - labelSize: NSSize(width: 30, height: 10), - rulerSize: rule.bounds.size - ).minX, - resizeHandleFrame.maxX + rule.mouseTickLabelResizeHandleSpacing, - accuracy: 0.0001 - ) XCTAssertEqual( rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 300, height: 40)), - CGRect(x: 278, y: 0, width: 12, height: 10) + CGRect(x: 280, y: 0, width: 20, height: 19) ) } } @@ -298,6 +307,14 @@ final class RulerCoreTests: XCTestCase { rule.tickY(forOffset: 50, rulerHeight: 300, growthDirection: .positive), 50 ) + XCTAssertEqual( + rule.mouseTickLineY(forTickY: 299, growthDirection: .negative), + 299 + ) + XCTAssertEqual( + rule.mouseTickLineY(forTickY: 1, growthDirection: .positive), + 2 + ) let rightTick = rule.tickLine(forY: 250, length: 10, rulerWidth: 40, tickSide: .right) XCTAssertEqual(rightTick.start, CGPoint(x: 39, y: 250)) @@ -326,7 +343,409 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(rule.mouseNumber(forTickY: 40, rulerHeight: 300), 40) XCTAssertEqual( rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 40, height: 300)), - CGRect(x: 20, y: 2, width: 12, height: 10) + CGRect(x: 20, y: 0, width: 20, height: 19) + ) + } + } + + func testMouseNumberLabelFramesFollowZeroCornerPlacement() { + withRestoredZeroCornerPreference { + 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 labelSize = NSSize(width: 20, height: 10) + let cases: [ + ( + zeroCorner: ZeroCorner, + horizontalRect: CGRect, + verticalRect: CGRect, + verticalBackgroundRect: CGRect + ) + ] = [ + ( + .topLeft, + CGRect(x: 155, y: 28, width: 20, height: 10), + CGRect(x: 7, y: 136, width: 20, height: 10), + CGRect(x: 0, y: 136, width: 30, height: 10) + ), + ( + .topRight, + CGRect(x: 155, y: 28, width: 20, height: 10), + CGRect(x: 13, y: 136, width: 20, height: 10), + CGRect(x: 10, y: 136, width: 30, height: 10) + ), + ( + .bottomLeft, + CGRect(x: 155, y: 7, width: 20, height: 10), + CGRect(x: 7, y: 136, width: 20, height: 10), + CGRect(x: 0, y: 136, width: 30, height: 10) + ), + ( + .bottomRight, + CGRect(x: 155, y: 7, width: 20, height: 10), + CGRect(x: 13, y: 136, width: 20, height: 10), + CGRect(x: 10, y: 136, width: 30, height: 10) + ), + ] + + for testCase in cases { + prefs.zeroCorner = testCase.zeroCorner + horizontalRule.redrawForPreferenceChange() + verticalRule.redrawForPreferenceChange() + + XCTAssertEqual( + horizontalRule.mouseNumberLabelRect( + tickX: 150, + labelSize: labelSize, + rulerSize: horizontalRule.bounds.size + ), + testCase.horizontalRect, + "\(testCase.zeroCorner) horizontal mouse number label" + ) + XCTAssertEqual( + verticalRule.mouseNumberLabelRect( + tickY: 150, + labelSize: labelSize, + rulerSize: verticalRule.bounds.size + ), + testCase.verticalRect, + "\(testCase.zeroCorner) vertical mouse number label" + ) + XCTAssertEqual( + verticalRule.mouseNumberLabelBackgroundRect( + tickY: 150, + labelSize: labelSize, + rulerSize: verticalRule.bounds.size + ), + testCase.verticalBackgroundRect, + "\(testCase.zeroCorner) vertical mouse number label background" + ) + } + } + } + + func testVerticalMouseNumberLabelBackgroundCoversWideLabels() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topRight + + let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let wideLabelSize = NSSize(width: 32, height: 10) + let labelRect = rule.mouseNumberLabelRect( + tickY: 150, + labelSize: wideLabelSize, + rulerSize: rule.bounds.size + ) + let backgroundRect = rule.mouseNumberLabelBackgroundRect( + tickY: 150, + labelSize: wideLabelSize, + rulerSize: rule.bounds.size + ) + + XCTAssertTrue(backgroundRect.contains(labelRect)) + XCTAssertLessThan(labelRect.minX, 10) + XCTAssertEqual(backgroundRect.minX, labelRect.minX, accuracy: 0.0001) + XCTAssertEqual(backgroundRect.maxX, rule.bounds.maxX, accuracy: 0.0001) + } + } + + func testResizeHandlesAreVisuallyObscuredFromLeadingEdgeToRulerEnd() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + 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)) + guard let horizontalHandle = horizontalRule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } + guard let verticalHandle = verticalRule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected vertical ruler to install a resize handle") + } + + horizontalRule.mouseTickX = horizontalHandle.frame.midX + XCTAssertFalse(horizontalHandle.isHidden) + XCTAssertEqual(horizontalHandle.alphaValue, 0) + + horizontalRule.mouseTickX = horizontalHandle.frame.maxX + XCTAssertEqual(horizontalHandle.alphaValue, 0) + + horizontalRule.mouseTickX = horizontalHandle.frame.minX - 1 + XCTAssertEqual(horizontalHandle.alphaValue, 1) + + verticalRule.mouseTickY = verticalHandle.frame.midY + XCTAssertFalse(verticalHandle.isHidden) + XCTAssertEqual(verticalHandle.alphaValue, 0) + + verticalRule.mouseTickY = verticalHandle.frame.minY + XCTAssertEqual(verticalHandle.alphaValue, 0) + + verticalRule.mouseTickY = verticalHandle.frame.maxY + 1 + XCTAssertEqual(verticalHandle.alphaValue, 1) + + verticalRule.mouseTickY = verticalHandle.frame.midY + verticalRule.showMouseTick = false + XCTAssertEqual(verticalHandle.alphaValue, 1) + + prefs.zeroCorner = .topRight + let leftEdgeRule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + guard let leftEdgeHandle = leftEdgeRule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } + + leftEdgeRule.mouseTickX = leftEdgeHandle.frame.maxX + XCTAssertEqual(leftEdgeHandle.alphaValue, 0) + + leftEdgeRule.mouseTickX = leftEdgeHandle.frame.maxX + 1 + XCTAssertEqual(leftEdgeHandle.alphaValue, 1) + + leftEdgeRule.mouseTickX = leftEdgeHandle.frame.minX + XCTAssertEqual(leftEdgeHandle.alphaValue, 0) + + prefs.zeroCorner = .bottomLeft + let topEdgeRule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + guard let topEdgeHandle = topEdgeRule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected vertical ruler to install a resize handle") + } + + topEdgeRule.mouseTickY = topEdgeHandle.frame.maxY + XCTAssertEqual(topEdgeHandle.alphaValue, 0) + + topEdgeRule.mouseTickY = topEdgeHandle.frame.minY - 1 + XCTAssertEqual(topEdgeHandle.alphaValue, 1) + } + } + + func testUnitLabelsFollowNearOppositeCornerFromZeroCorner() { + withRestoredZeroCornerPreference { + 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 horizontalSize = NSSize(width: 12, height: 10) + let verticalSize = NSSize(width: 12, height: 10) + let cases: [ + ( + zeroCorner: ZeroCorner, + horizontalUnitRect: CGRect, + verticalUnitRect: CGRect + ) + ] = [ + (.topLeft, CGRect(x: 0, y: 26, width: 20, height: 14), CGRect(x: 0, y: 286, width: 20, height: 14)), + (.topRight, CGRect(x: 280, y: 26, width: 20, height: 14), CGRect(x: 20, y: 286, width: 20, height: 14)), + (.bottomLeft, CGRect(x: 0, y: 0, width: 20, height: 19), CGRect(x: 0, y: 0, width: 20, height: 19)), + (.bottomRight, CGRect(x: 280, y: 0, width: 20, height: 19), CGRect(x: 20, y: 0, width: 20, height: 19)), + ] + + for testCase in cases { + prefs.zeroCorner = testCase.zeroCorner + + XCTAssertEqual( + horizontalRule.unitLabelRect(labelSize: horizontalSize, rulerSize: horizontalRule.bounds.size), + testCase.horizontalUnitRect, + "\(testCase.zeroCorner) horizontal unit label" + ) + XCTAssertEqual( + verticalRule.unitLabelRect(labelSize: verticalSize, rulerSize: verticalRule.bounds.size), + testCase.verticalUnitRect, + "\(testCase.zeroCorner) vertical unit label" + ) + } + } + } + + func testUnitLabelSubviewsFollowNearOppositeCornerAfterZeroCornerChange() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + 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)) + prefs.zeroCorner = .bottomRight + + horizontalRule.redrawForPreferenceChange() + verticalRule.redrawForPreferenceChange() + + let horizontalLabelSize = unitLabelSize(for: horizontalRule) + let verticalLabelSize = unitLabelSize(for: verticalRule) + + XCTAssertEqual( + horizontalRule.unitLabelFrame, + horizontalRule.unitLabelRect(labelSize: horizontalLabelSize, rulerSize: horizontalRule.bounds.size) + ) + XCTAssertEqual( + verticalRule.unitLabelFrame, + verticalRule.unitLabelRect(labelSize: verticalLabelSize, rulerSize: verticalRule.bounds.size) + ) + } + } + + func testChildViewGeometryUsesRuleZeroCornerOverride() throws { + try withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + let rule = TestableZeroCornerHorizontalRule( + frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness) + ) + rule.testZeroCorner = .bottomRight + + rule.redrawForPreferenceChange() + + let unitLabel = try XCTUnwrap(rule.subviews.first { $0 is UnitLabelView } as? UnitLabelView) + let resizeHandle = try XCTUnwrap( + rule.subviews.first { $0 is ResizeHandleView } as? ResizeHandleView + ) + let labelSize = unitLabelSize(for: rule) + + XCTAssertEqual(unitLabel.zeroCorner, .bottomRight) + XCTAssertEqual(resizeHandle.zeroCorner, .bottomRight) + XCTAssertEqual( + rule.unitLabelFrame, + UnitLabelView.labelFrame( + labelSize: labelSize, + rulerSize: rule.bounds.size, + orientation: .horizontal, + zeroCorner: .bottomRight + ) + ) + } + } + + func testUnitLabelsAreHiddenFromInnerEdgeToZero() throws { + try withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + let leftZeroRule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + let leftZeroLabel = try XCTUnwrap(leftZeroRule.subviews.first { $0 is UnitLabelView }) + let leftZeroFrame = try XCTUnwrap(leftZeroRule.unitLabelFrame) + + leftZeroRule.mouseTickX = leftZeroFrame.maxX + XCTAssertTrue(leftZeroLabel.isHidden) + leftZeroRule.mouseTickX = leftZeroFrame.maxX + 1 + XCTAssertFalse(leftZeroLabel.isHidden) + leftZeroRule.mouseTickX = leftZeroRule.bounds.minX + XCTAssertTrue(leftZeroLabel.isHidden) + + prefs.zeroCorner = .topRight + let rightZeroRule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + let rightZeroLabel = try XCTUnwrap(rightZeroRule.subviews.first { $0 is UnitLabelView }) + let rightZeroFrame = try XCTUnwrap(rightZeroRule.unitLabelFrame) + + rightZeroRule.mouseTickX = rightZeroFrame.minX + XCTAssertTrue(rightZeroLabel.isHidden) + rightZeroRule.mouseTickX = rightZeroFrame.minX - 1 + XCTAssertFalse(rightZeroLabel.isHidden) + rightZeroRule.mouseTickX = rightZeroRule.bounds.maxX + XCTAssertTrue(rightZeroLabel.isHidden) + + prefs.zeroCorner = .topLeft + let topZeroRule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let topZeroLabel = try XCTUnwrap(topZeroRule.subviews.first { $0 is UnitLabelView }) + let topZeroFrame = try XCTUnwrap(topZeroRule.unitLabelFrame) + + topZeroRule.mouseTickY = topZeroFrame.minY + XCTAssertTrue(topZeroLabel.isHidden) + topZeroRule.mouseTickY = topZeroFrame.minY - 1 + XCTAssertFalse(topZeroLabel.isHidden) + topZeroRule.mouseTickY = topZeroRule.bounds.maxY + XCTAssertTrue(topZeroLabel.isHidden) + + prefs.zeroCorner = .bottomLeft + let bottomZeroRule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let bottomZeroLabel = try XCTUnwrap(bottomZeroRule.subviews.first { $0 is UnitLabelView }) + let bottomZeroFrame = try XCTUnwrap(bottomZeroRule.unitLabelFrame) + + bottomZeroRule.mouseTickY = bottomZeroFrame.maxY + XCTAssertTrue(bottomZeroLabel.isHidden) + bottomZeroRule.mouseTickY = bottomZeroFrame.maxY + 1 + XCTAssertFalse(bottomZeroLabel.isHidden) + bottomZeroRule.mouseTickY = bottomZeroRule.bounds.minY + XCTAssertTrue(bottomZeroLabel.isHidden) + } + } + + func testUnitLabelBaselineDoesNotMoveBetweenDescenderAndNonDescenderUnits() throws { + try withRestoredZeroCornerPreference { + let previousUnit = prefs.unit + defer { prefs.unit = previousUnit } + + 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)) + + for zeroCorner in [ZeroCorner.topLeft, .bottomRight] { + prefs.zeroCorner = zeroCorner + + prefs.unit = .pixels + horizontalRule.redrawForPreferenceChange() + verticalRule.redrawForPreferenceChange() + let pixelHorizontalFrame = try XCTUnwrap(horizontalRule.unitLabelFrame) + let pixelVerticalFrame = try XCTUnwrap(verticalRule.unitLabelFrame) + + prefs.unit = .millimeters + horizontalRule.redrawForPreferenceChange() + verticalRule.redrawForPreferenceChange() + let millimeterHorizontalFrame = try XCTUnwrap(horizontalRule.unitLabelFrame) + let millimeterVerticalFrame = try XCTUnwrap(verticalRule.unitLabelFrame) + + XCTAssertEqual(pixelHorizontalFrame.minY, millimeterHorizontalFrame.minY, accuracy: 0.0001) + XCTAssertEqual(pixelHorizontalFrame.height, millimeterHorizontalFrame.height, accuracy: 0.0001) + XCTAssertEqual(pixelVerticalFrame.minY, millimeterVerticalFrame.minY, accuracy: 0.0001) + XCTAssertEqual(pixelVerticalFrame.height, millimeterVerticalFrame.height, accuracy: 0.0001) + } + } + } + + func testTopUnitLabelDrawRectLeavesRoomForDescenders() { + let labelSize = NSSize(width: 12, height: 10) + let bounds = NSRect(x: 0, y: 0, width: 20, height: 12) + + let topDrawRect = UnitLabelView.labelDrawRect( + labelSize: labelSize, + bounds: bounds, + placement: RulerCornerPlacement(xSide: .left, ySide: .top) + ) + let bottomDrawRect = UnitLabelView.labelDrawRect( + labelSize: labelSize, + bounds: NSRect(x: 0, y: 0, width: 20, height: 19), + placement: RulerCornerPlacement(xSide: .left, ySide: .bottom) + ) + + XCTAssertEqual(topDrawRect.minY, 2) + XCTAssertLessThanOrEqual(topDrawRect.maxY, bounds.maxY) + XCTAssertEqual(bottomDrawRect.minY, 9) + } + + 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) ) } } @@ -662,6 +1081,148 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(frame.minY, initialFrame.minY) } + func testHorizontalResizeHandleFrameMathClampsLeftEdgeResizesAroundRightEdge() { + let initialFrame = NSRect(x: 10, y: 20, width: 300, height: Ruler.thickness) + + let minFrame = resizedRulerFrame( + orientation: .horizontal, + zeroCorner: .topRight, + initialFrame: initialFrame, + delta: NSSize(width: 250, height: 0), + minSize: NSSize(width: 200, height: Ruler.thickness), + maxSize: NSSize(width: 400, height: Ruler.thickness) + ) + let maxFrame = resizedRulerFrame( + orientation: .horizontal, + zeroCorner: .topRight, + initialFrame: initialFrame, + delta: NSSize(width: -250, height: 0), + minSize: NSSize(width: 200, height: Ruler.thickness), + maxSize: NSSize(width: 400, height: Ruler.thickness) + ) + + XCTAssertEqual(minFrame, NSRect(x: 110, y: 20, width: 200, height: Ruler.thickness)) + XCTAssertEqual(maxFrame, NSRect(x: -90, y: 20, width: 400, height: Ruler.thickness)) + XCTAssertEqual(minFrame.maxX, initialFrame.maxX) + XCTAssertEqual(maxFrame.maxX, initialFrame.maxX) + } + + func testVerticalResizeHandleFrameMathClampsTopEdgeResizesAroundBottomEdge() { + let initialFrame = NSRect(x: 10, y: 20, width: Ruler.thickness, height: 300) + + let minFrame = resizedRulerFrame( + orientation: .vertical, + zeroCorner: .bottomLeft, + initialFrame: initialFrame, + delta: NSSize(width: 0, height: -250), + minSize: NSSize(width: Ruler.thickness, height: 200), + maxSize: NSSize(width: Ruler.thickness, height: 400) + ) + let maxFrame = resizedRulerFrame( + orientation: .vertical, + zeroCorner: .bottomLeft, + initialFrame: initialFrame, + delta: NSSize(width: 0, height: 250), + minSize: NSSize(width: Ruler.thickness, height: 200), + maxSize: NSSize(width: Ruler.thickness, height: 400) + ) + + XCTAssertEqual(minFrame, NSRect(x: 10, y: 20, width: Ruler.thickness, height: 200)) + XCTAssertEqual(maxFrame, NSRect(x: 10, y: 20, width: Ruler.thickness, height: 400)) + XCTAssertEqual(minFrame.minY, initialFrame.minY) + XCTAssertEqual(maxFrame.minY, initialFrame.minY) + } + + func testResizeHandlePositionsFollowFarOppositeCornerFromZeroCorner() { + withRestoredZeroCornerPreference { + let cases: [ + ( + zeroCorner: ZeroCorner, + expectedHorizontalXSide: RulerSide, + expectedHorizontalYSide: RulerSide, + expectedVerticalXSide: RulerSide, + expectedVerticalYSide: RulerSide + ) + ] = [ + (.topLeft, .right, .top, .left, .bottom), + (.topRight, .left, .top, .right, .bottom), + (.bottomLeft, .right, .bottom, .left, .top), + (.bottomRight, .left, .bottom, .right, .top), + ] + + for testCase in cases { + prefs.zeroCorner = testCase.zeroCorner + 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) + ) + + guard let horizontalFrame = horizontalRule.resizeHandleExclusionFrame, + let verticalFrame = verticalRule.resizeHandleExclusionFrame else { + XCTFail("Expected both rulers to install resize handles for \(testCase.zeroCorner)") + continue + } + + switch testCase.expectedHorizontalXSide { + case .left: + XCTAssertLessThan(horizontalFrame.midX, horizontalRule.bounds.midX, "\(testCase.zeroCorner)") + case .right: + XCTAssertGreaterThan(horizontalFrame.midX, horizontalRule.bounds.midX, "\(testCase.zeroCorner)") + case .top, .bottom: + XCTFail("Horizontal resize handle X must be placed on a horizontal side") + } + + switch testCase.expectedHorizontalYSide { + case .top: + XCTAssertGreaterThan(horizontalFrame.midY, horizontalRule.bounds.midY, "\(testCase.zeroCorner)") + case .bottom: + XCTAssertLessThan(horizontalFrame.midY, horizontalRule.bounds.midY, "\(testCase.zeroCorner)") + case .left, .right: + XCTFail("Horizontal resize handle Y must be placed on a vertical side") + } + + switch testCase.expectedVerticalXSide { + case .left: + XCTAssertLessThan(verticalFrame.midX, verticalRule.bounds.midX, "\(testCase.zeroCorner)") + case .right: + XCTAssertGreaterThan(verticalFrame.midX, verticalRule.bounds.midX, "\(testCase.zeroCorner)") + case .top, .bottom: + XCTFail("Vertical resize handle X must be placed on a horizontal side") + } + + switch testCase.expectedVerticalYSide { + case .top: + XCTAssertGreaterThan(verticalFrame.midY, verticalRule.bounds.midY, "\(testCase.zeroCorner)") + case .bottom: + XCTAssertLessThan(verticalFrame.midY, verticalRule.bounds.midY, "\(testCase.zeroCorner)") + case .left, .right: + XCTFail("Vertical resize handle Y must be placed on a vertical side") + } + } + } + } + + func testPreferenceRedrawUpdatesResizeHandleFrameForZeroCornerChanges() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + guard let initialFrame = rule.resizeHandleExclusionFrame else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } + + prefs.zeroCorner = .topRight + rule.redrawForPreferenceChange() + guard let flippedFrame = rule.resizeHandleExclusionFrame else { + return XCTFail("Expected horizontal ruler to keep its resize handle") + } + + XCTAssertGreaterThan(initialFrame.midX, rule.bounds.midX) + XCTAssertLessThan(flippedFrame.midX, rule.bounds.midX) + } + } + func testResizeHandleCursorsUseCustomCenteredImages() { let horizontalCursor = windowResizeCursor(for: .horizontal) let verticalCursor = windowResizeCursor(for: .vertical) @@ -674,84 +1235,248 @@ final class RulerCoreTests: XCTestCase { XCTAssertFalse(verticalCursor.image.isTemplate) } - func testHorizontalResizeHandleFrameIncludesBorderInsetOnLeftEdge() { + func testHorizontalResizeHandleFramePinsToLeftEdgeSlot() { withRestoredZeroCornerPreference { prefs.zeroCorner = .topRight let resizeHandle = ResizeHandleView(orientation: .horizontal, color: RulerColors()) let frame = resizeHandle.frame(in: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - XCTAssertEqual(frame.minX, 4.5) + XCTAssertEqual(frame.minX, 0) } } - func testHorizontalMouseTickLabelStopsBeforeResizeHandle() { - let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { - return XCTFail("Expected horizontal ruler to install a resize handle") + func testHorizontalMouseTickLabelFlipsBeforeResizeHandleWithoutPinning() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + + let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + let labelSize = CGSize(width: 30, height: 10) + let rulerSize = rule.bounds.size + guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } + let tickLabelSpacing: CGFloat = 5 + let mouseTickJustPastRightHandleFit = resizeHandleFrame.minX - labelSize.width - tickLabelSpacing + 1 + let mouseTickNearRightEdge: CGFloat = 290 + + let labelJustPastRightHandleFit = rule.mouseNumberLabelRect( + tickX: mouseTickJustPastRightHandleFit, + labelSize: labelSize, + rulerSize: rulerSize + ) + let labelRect = rule.mouseNumberLabelRect( + tickX: mouseTickNearRightEdge, + labelSize: labelSize, + rulerSize: rulerSize + ) + + XCTAssertLessThan(labelJustPastRightHandleFit.maxX, mouseTickJustPastRightHandleFit) + XCTAssertLessThanOrEqual(labelJustPastRightHandleFit.maxX, resizeHandleFrame.minX) + XCTAssertEqual( + mouseTickJustPastRightHandleFit - labelJustPastRightHandleFit.maxX, + tickLabelSpacing, + accuracy: 0.0001 + ) + XCTAssertLessThan(labelRect.maxX, mouseTickNearRightEdge) + XCTAssertEqual( + mouseTickNearRightEdge - labelRect.maxX, + tickLabelSpacing, + accuracy: 0.0001 + ) } - let labelSize = CGSize(width: 30, height: 10) - let rulerSize = rule.bounds.size - let expectedMaxLabelRight = resizeHandleFrame.minX - rule.mouseTickLabelResizeHandleSpacing - let pinnedLabelX = expectedMaxLabelRight - labelSize.width - let mouseTickBeforePinnedLabel = pinnedLabelX - 2 - - let labelRect = rule.mouseNumberLabelRect( - number: mouseTickBeforePinnedLabel, - labelSize: labelSize, - rulerSize: rulerSize - ) + } - XCTAssertGreaterThan(labelRect.minX, mouseTickBeforePinnedLabel) - XCTAssertEqual( - labelRect.maxX, - expectedMaxLabelRight, - accuracy: 0.0001 - ) + func testHorizontalMouseTickLabelFlipsBeforeRightUnitLabel() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topRight + + let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + let labelSize = CGSize(width: 30, height: 10) + let tickLabelSpacing: CGFloat = 5 + let unitLabelFlipPadding: CGFloat = 3 + guard let unitLabelFrame = rule.unitLabelFrame else { + return XCTFail("Expected horizontal ruler to install a unit label") + } + let mouseTickAtRightUnitFit = unitLabelFrame.minX - unitLabelFlipPadding - labelSize.width - tickLabelSpacing + let mouseTickJustPastRightUnitFit = mouseTickAtRightUnitFit + 1 + + let fittingLabelRect = rule.mouseNumberLabelRect( + tickX: mouseTickAtRightUnitFit, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + let flippedLabelRect = rule.mouseNumberLabelRect( + tickX: mouseTickJustPastRightUnitFit, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + + XCTAssertGreaterThan(fittingLabelRect.minX, mouseTickAtRightUnitFit) + XCTAssertEqual( + fittingLabelRect.maxX, + unitLabelFrame.minX - unitLabelFlipPadding, + accuracy: 0.0001 + ) + XCTAssertLessThan(flippedLabelRect.maxX, mouseTickJustPastRightUnitFit) + XCTAssertEqual( + mouseTickJustPastRightUnitFit - flippedLabelRect.maxX, + tickLabelSpacing, + accuracy: 0.0001 + ) + } } - func testHorizontalMouseTickLabelFlipsBeforeCollidingWithMouseTick() { - let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { - return XCTFail("Expected horizontal ruler to install a resize handle") + func testHorizontalMouseTickLabelStaysOnPreferredSideNearLeftResizeEnd() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .bottomRight + + let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 600, height: Ruler.thickness)) + let labelSize = CGSize(width: 30, height: 10) + let tickLabelSpacing: CGFloat = 5 + guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } + let mouseTickInsideLeftResizeEnd = resizeHandleFrame.maxX - tickLabelSpacing - 1 + + let labelRect = rule.mouseNumberLabelRect( + tickX: mouseTickInsideLeftResizeEnd, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + + XCTAssertGreaterThan(labelRect.minX, mouseTickInsideLeftResizeEnd) + XCTAssertEqual( + labelRect.minX - mouseTickInsideLeftResizeEnd, + tickLabelSpacing, + accuracy: 0.0001 + ) } - let labelSize = CGSize(width: 30, height: 10) - let rulerSize = rule.bounds.size - let expectedMaxLabelRight = resizeHandleFrame.minX - rule.mouseTickLabelResizeHandleSpacing - let mouseTickInsideResizeHandle = resizeHandleFrame.minX + 1 + } - let labelRect = rule.mouseNumberLabelRect( - number: mouseTickInsideResizeHandle, - labelSize: labelSize, - rulerSize: rulerSize - ) + func testHorizontalMouseTickLabelClampsToRulerStart() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft - XCTAssertGreaterThan(mouseTickInsideResizeHandle, resizeHandleFrame.minX) - XCTAssertLessThan(labelRect.maxX, mouseTickInsideResizeHandle) - XCTAssertEqual( - labelRect.maxX, - expectedMaxLabelRight, - accuracy: 0.0001 - ) + let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + let labelSize = CGSize(width: 30, height: 10) + let rulerSize = rule.bounds.size + let mouseTickNearLeftEdge: CGFloat = -10 + + let labelRect = rule.mouseNumberLabelRect( + tickX: mouseTickNearLeftEdge, + labelSize: labelSize, + rulerSize: rulerSize + ) + + XCTAssertEqual( + labelRect.minX, + 0, + accuracy: 0.0001 + ) + } } - func testVerticalMouseTickLabelStopsBeforeResizeHandle() { - let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) - guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { - return XCTFail("Expected vertical ruler to install a resize handle") + func testVerticalMouseTickLabelStaysOnPreferredSideNearTopResizeEnd() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .bottomLeft + + let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let labelSize = CGSize(width: 20, height: 10) + let tickLabelSpacing: CGFloat = 4 + guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { + return XCTFail("Expected vertical ruler to install a resize handle") + } + let mouseTickInsideTopResizeEnd = resizeHandleFrame.minY + tickLabelSpacing + 1 + + let labelRect = rule.mouseNumberLabelRect( + tickY: mouseTickInsideTopResizeEnd, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + + XCTAssertLessThan(labelRect.maxY, mouseTickInsideTopResizeEnd) + XCTAssertEqual( + mouseTickInsideTopResizeEnd - labelRect.maxY, + tickLabelSpacing, + accuracy: 0.0001 + ) } + } - let labelRect = rule.mouseNumberLabelRect( - number: 290, - labelSize: CGSize(width: 22, height: 10), - rulerHeight: 300 - ) + func testVerticalMouseTickLabelFlipsBeforeBottomUnitLabel() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .bottomLeft - XCTAssertEqual( - labelRect.minY, - resizeHandleFrame.maxY + rule.mouseTickLabelResizeHandleSpacing, - accuracy: 0.0001 - ) + let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let labelSize = CGSize(width: 20, height: 10) + let tickLabelSpacing: CGFloat = 4 + let unitLabelFlipPadding: CGFloat = 3 + guard let unitLabelFrame = rule.unitLabelFrame else { + return XCTFail("Expected vertical ruler to install a unit label") + } + let mouseTickAtBottomUnitFit = unitLabelFrame.maxY + + unitLabelFlipPadding + + tickLabelSpacing + + labelSize.height + let mouseTickJustPastBottomUnitFit = mouseTickAtBottomUnitFit - 1 + + let fittingLabelRect = rule.mouseNumberLabelRect( + tickY: mouseTickAtBottomUnitFit, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + let flippedLabelRect = rule.mouseNumberLabelRect( + tickY: mouseTickJustPastBottomUnitFit, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + + XCTAssertLessThan(fittingLabelRect.maxY, mouseTickAtBottomUnitFit) + XCTAssertEqual( + fittingLabelRect.minY, + unitLabelFrame.maxY + unitLabelFlipPadding, + accuracy: 0.0001 + ) + XCTAssertGreaterThan(flippedLabelRect.minY, mouseTickJustPastBottomUnitFit) + XCTAssertEqual( + flippedLabelRect.minY - mouseTickJustPastBottomUnitFit, + tickLabelSpacing, + accuracy: 0.0001 + ) + } + } + + func testVerticalMouseTickLabelStaysWithinRulerEnds() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + + let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let labelSize = CGSize(width: 20, height: 10) + let rulerSize = rule.bounds.size + + let labelNearBottom = rule.mouseNumberLabelRect( + tickY: 1, + labelSize: labelSize, + rulerSize: rulerSize + ) + XCTAssertEqual( + labelNearBottom.minY, + 5, + accuracy: 0.0001 + ) + + let labelNearTop = rule.mouseNumberLabelRect( + tickY: 299, + labelSize: labelSize, + rulerSize: rulerSize + ) + XCTAssertEqual( + labelNearTop.minY, + 285, + accuracy: 0.0001 + ) + } } func testResizeHandleDisablesWindowBackgroundDraggingDuringResizeDrag() { @@ -826,54 +1551,58 @@ final class RulerCoreTests: XCTestCase { } func testHorizontalResizeHandleDragKeepsLeftAndTopEdgesFixed() { - let initialFrame = NSRect(x: 100, y: 200, width: 300, height: Ruler.thickness) - let ruler = Ruler(.horizontal, frame: initialFrame) - let window = RulerWindow(ruler) - guard let resizeHandle = window.rule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { - return XCTFail("Expected horizontal ruler to install a resize handle") - } - - let startLocation = resizeHandle.convert( - NSPoint(x: resizeHandle.bounds.minX + 1, y: resizeHandle.bounds.midY), - to: nil - ) - let mouseDownEvent = mouseEvent( - type: .leftMouseDown, - location: startLocation, - windowNumber: window.windowNumber, - timestamp: 0 - ) - - resizeHandle.mouseDown(with: mouseDownEvent) + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft - let dragOffsets = [ - NSSize(width: -40, height: 0), - NSSize(width: 80, height: 0), - NSSize(width: 0, height: 30), - NSSize(width: 0, height: -30), - ] + let initialFrame = NSRect(x: 100, y: 200, width: 300, height: Ruler.thickness) + let ruler = Ruler(.horizontal, frame: initialFrame) + let window = RulerWindow(ruler) + guard let resizeHandle = window.rule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } - for (index, offset) in dragOffsets.enumerated() { - let dragEvent = mouseEvent( - type: .leftMouseDragged, - location: NSPoint(x: startLocation.x + offset.width, y: startLocation.y + offset.height), + let startLocation = resizeHandle.convert( + NSPoint(x: resizeHandle.bounds.minX + 1, y: resizeHandle.bounds.midY), + to: nil + ) + let mouseDownEvent = mouseEvent( + type: .leftMouseDown, + location: startLocation, windowNumber: window.windowNumber, - timestamp: TimeInterval(index + 1) * 0.1 + timestamp: 0 ) - resizeHandle.mouseDragged(with: dragEvent) + resizeHandle.mouseDown(with: mouseDownEvent) - XCTAssertEqual(window.frame.minX, initialFrame.minX, "left edge moved for drag offset \(offset)") - XCTAssertEqual(window.frame.maxY, initialFrame.maxY, "top edge moved for drag offset \(offset)") - } + let dragOffsets = [ + NSSize(width: -40, height: 0), + NSSize(width: 80, height: 0), + NSSize(width: 0, height: 30), + NSSize(width: 0, height: -30), + ] - let mouseUpEvent = mouseEvent( - type: .leftMouseUp, - location: startLocation, - windowNumber: window.windowNumber, - timestamp: 1 - ) - resizeHandle.mouseUp(with: mouseUpEvent) + for (index, offset) in dragOffsets.enumerated() { + let dragEvent = mouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: startLocation.x + offset.width, y: startLocation.y + offset.height), + windowNumber: window.windowNumber, + timestamp: TimeInterval(index + 1) * 0.1 + ) + + resizeHandle.mouseDragged(with: dragEvent) + + XCTAssertEqual(window.frame.minX, initialFrame.minX, "left edge moved for drag offset \(offset)") + XCTAssertEqual(window.frame.maxY, initialFrame.maxY, "top edge moved for drag offset \(offset)") + } + + let mouseUpEvent = mouseEvent( + type: .leftMouseUp, + location: startLocation, + windowNumber: window.windowNumber, + timestamp: 1 + ) + resizeHandle.mouseUp(with: mouseUpEvent) + } } func testRulerControllerKeepsMouseTicksHiddenWhileDragging() { @@ -977,6 +1706,226 @@ final class RulerCoreTests: XCTestCase { XCTAssertFalse(groupedChildController.rulerWindow.rule.showMouseTick) } + func testUngroupedHorizontalFlipDoesNotMoveRulerWindows() { + 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: 51, y: 150, width: Ruler.thickness, height: 160)) + ) + appDelegate.rulers = [verticalController, horizontalController] + + appDelegate.flipRulers(along: .horizontal) + + XCTAssertEqual(prefs.zeroCorner, .topRight) + XCTAssertEqual(horizontalController.rulerWindow.frame, NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) + XCTAssertEqual(verticalController.rulerWindow.frame, NSRect(x: 51, y: 150, width: Ruler.thickness, height: 160)) + } + } + + func testGroupedHorizontalFlipMovesVerticalRulerToPreserveZeroPointOffset() { + withRestoredZeroCornerPreference { + let previousGroupRulers = prefs.groupRulers + defer { prefs.groupRulers = previousGroupRulers } + + prefs.zeroCorner = .topLeft + prefs.groupRulers = true + let appDelegate = TestableFlipAppDelegate() + 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: 51, y: 150, width: Ruler.thickness, height: 160)) + ) + appDelegate.rulers = [verticalController, horizontalController] + + appDelegate.flipRulers(along: .horizontal) + + XCTAssertEqual(prefs.zeroCorner, .topRight) + XCTAssertEqual(horizontalController.rulerWindow.frame, NSRect(x: 100, y: 299, width: 120, height: Ruler.thickness)) + XCTAssertEqual(verticalController.rulerWindow.frame, NSRect(x: 210, y: 150, width: Ruler.thickness, height: 160)) + } + } + + func testGroupedVerticalFlipMovesHorizontalRulerToPreserveZeroPointOffset() { + withRestoredZeroCornerPreference { + let previousGroupRulers = prefs.groupRulers + defer { prefs.groupRulers = previousGroupRulers } + + prefs.zeroCorner = .topLeft + prefs.groupRulers = true + let appDelegate = TestableFlipAppDelegate() + 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] + + appDelegate.flipRulers(along: .vertical) + + XCTAssertEqual(prefs.zeroCorner, .bottomLeft) + XCTAssertEqual(verticalController.rulerWindow.frame, NSRect(x: 61, y: 140, width: Ruler.thickness, height: 160)) + XCTAssertEqual(horizontalController.rulerWindow.frame, NSRect(x: 100, y: 101, width: 120, height: Ruler.thickness)) + } + } + + func testGroupedFlipDoesNotShowHiddenRulerWindows() { + withRestoredZeroCornerPreference { + let previousGroupRulers = prefs.groupRulers + defer { prefs.groupRulers = previousGroupRulers } + + prefs.zeroCorner = .topLeft + prefs.groupRulers = true + 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] + let horizontalFrame = horizontalController.rulerWindow.frame + let verticalFrame = verticalController.rulerWindow.frame + + XCTAssertFalse(horizontalController.rulerWindow.isVisible) + XCTAssertFalse(verticalController.rulerWindow.isVisible) + + appDelegate.flipRulers(along: .horizontal) + + XCTAssertFalse(horizontalController.rulerWindow.isVisible) + XCTAssertFalse(verticalController.rulerWindow.isVisible) + XCTAssertEqual(horizontalController.rulerWindow.frame, horizontalFrame) + XCTAssertEqual(verticalController.rulerWindow.frame, verticalFrame) + } + } + + 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, @@ -1070,6 +2019,29 @@ private final class ChildAttachingRulerWindow: RulerWindow { } } +private final class TestableFlipAppDelegate: AppDelegate { + override func isRulerWindowShown(_ window: RulerWindow) -> Bool { + return true + } +} + +private final class TestableZeroCornerHorizontalRule: HorizontalRule { + var testZeroCorner: ZeroCorner = .topLeft + + override var zeroCorner: ZeroCorner { + return testZeroCorner + } +} + +private func unitLabelSize(for rule: RuleView) -> NSSize { + let label = NSAttributedString( + string: rule.getUnitLabel(), + attributes: rule.labelAttributes(alignment: .left, foregroundColor: rule.color.ticks) + ) + + return UnitLabelView.labelSize(for: label) +} + private func assertColor( _ actualColor: NSColor, equals expectedColor: NSColor, diff --git a/package.json b/package.json index fe843f1..070dfa6 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "get:version": "node -p \"require('./package.json').version\"", "get:commits": "git log --pretty=oneline | wc -l", "generate:icons": "scripts/generate-app-icon.sh", + "generate:help": "scripts/generate-help-index.sh", "generate:screenshots": "scripts/generate-app-store-screenshots.sh", "bump:version": "node scripts/release/bump-version.js", "release:version": "node scripts/release/set-version.js", diff --git a/scripts/generate-app-icon.sh b/scripts/generate-app-icon.sh index 03adcca..d34800c 100755 --- a/scripts/generate-app-icon.sh +++ b/scripts/generate-app-icon.sh @@ -4,6 +4,9 @@ set -euo pipefail cd "$(dirname "$0")/.." output_dir="$PWD/Free Ruler/Images.xcassets/AppIcon.appiconset" +help_resource_dir="$PWD/Free Ruler/FreeRuler.help/Contents/Resources" +help_shared_dir="$help_resource_dir/shrd" +help_html="$help_resource_dir/English.lproj/FreeRuler.html" binary="${TMPDIR:-/tmp}/freeruler-generate-app-icon" module_cache="${TMPDIR:-/tmp}/freeruler-generate-app-icon-module-cache" @@ -16,3 +19,21 @@ xcrun swiftc \ -o "$binary" "$binary" "$output_dir" + +icon_hash="$(shasum -a 256 "$output_dir/icon_512x512.png" | awk '{ print substr($1, 1, 12) }')" +help_cache_token="$icon_hash" +help_icon_name="freeruler-help-icon-${help_cache_token}.png" +help_icon="$help_shared_dir/$help_icon_name" + +cp "$output_dir/icon_512x512.png" "$help_icon" + +for stale_icon in "$help_shared_dir"/freeruler-help-icon-*.png; do + if [[ -e "$stale_icon" && "$stale_icon" != "$help_icon" ]]; then + rm "$stale_icon" + fi +done + +HELP_ICON_NAME="$help_icon_name" HELP_CACHE_TOKEN="$help_cache_token" perl -0pi -e \ + 's#href="\.\./shrd/styles\.css(?:\?[^"]*)?"#href="../shrd/styles.css?icon=$ENV{HELP_CACHE_TOKEN}"#; + s#--free-ruler-help-icon: url\("[^"]+"\);#--free-ruler-help-icon: url("../shrd/$ENV{HELP_ICON_NAME}");#' \ + "$help_html" diff --git a/scripts/generate-help-index.sh b/scripts/generate-help-index.sh new file mode 100755 index 0000000..d52c514 --- /dev/null +++ b/scripts/generate-help-index.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +help_lproj_dir="$PWD/Free Ruler/FreeRuler.help/Contents/Resources/English.lproj" +help_index="$help_lproj_dir/English.lproj.helpindex" + +hiutil -I lsm -C -ag -s en -f "$help_index" "$help_lproj_dir"