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 48ed894..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 } } @@ -32,26 +36,38 @@ class HorizontalRule: RuleView { let attrs = labelAttributes(alignment: .center, foregroundColor: color.numbers) let width = dirtyRect.width + let height = dirtyRect.height let path = NSBezierPath() let tickLayout = RulerTickLayout(unit: unit, screen: screen) + 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 - let labelOffset: CGFloat = 13 // offset of label from bottom edge of ruler - // 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 for i in 1...Int((width - 2) / tickLayout.tickScale) { - let pos = CGFloat(i) * tickLayout.tickScale + let offset = CGFloat(i) * tickLayout.tickScale + let pos = tickX( + forOffset: offset, + rulerWidth: width, + growthDirection: growthDirection + ) if i.isMultiple(of: tickLayout.largeTicks) { - path.move(to: CGPoint(x: pos, y: 1)) - path.line(to: CGPoint(x: pos, y: 10)) + let tickLine = tickLine(forX: pos, length: 10, rulerHeight: height, tickSide: tickSide) + path.move(to: tickLine.start) + path.line(to: tickLine.end) let label = String(i / tickLayout.textScale) - let labelX: CGFloat = pos - (labelWidth / 2) + 0.5 // half-pixel nudge /shrug - let labelY: CGFloat = labelOffset - let labelRect = CGRect(x: labelX, y: labelY, width: labelWidth, height: labelHeight) + let labelRect = tickLabelRect( + forX: pos, + labelSize: NSSize(width: labelWidth, height: labelHeight), + rulerHeight: height, + tickSide: tickSide + ) label.draw( with: labelRect, @@ -61,16 +77,19 @@ class HorizontalRule: RuleView { } else if i.isMultiple(of: tickLayout.mediumTicks) { - path.move(to: CGPoint(x: pos, y: 1)) - path.line(to: CGPoint(x: pos, y: 8)) + let tickLine = tickLine(forX: pos, length: 8, rulerHeight: height, tickSide: tickSide) + path.move(to: tickLine.start) + path.line(to: tickLine.end) } else if i.isMultiple(of: tickLayout.smallTicks) { - path.move(to: CGPoint(x: pos, y: 1)) - path.line(to: CGPoint(x: pos, y: 5)) + let tickLine = tickLine(forX: pos, length: 5, rulerHeight: height, tickSide: tickSide) + path.move(to: tickLine.start) + path.line(to: tickLine.end) } else if let tinyTicks = tickLayout.tinyTicks, i.isMultiple(of: tinyTicks) { - path.move(to: CGPoint(x: pos, y: 1)) - path.line(to: CGPoint(x: pos, y: 3)) + let tickLine = tickLine(forX: pos, length: 3, rulerHeight: height, tickSide: tickSide) + path.move(to: tickLine.start) + path.line(to: tickLine.end) } } @@ -79,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 { @@ -100,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) @@ -111,7 +130,7 @@ class HorizontalRule: RuleView { } func drawMouseNumber(_ mouseTickX: CGFloat) { - let number = mouseTickX + let number = mouseNumber(forTickX: mouseTickX, rulerWidth: self.frame.width) let width = self.frame.width let height = self.frame.height @@ -122,56 +141,137 @@ class HorizontalRule: RuleView { let labelSize = label.size() let labelRect = mouseNumberLabelRect( - number: number, + 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 + 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 + ) + } - let rightPosition = number + labelOffset - let leftPosition = number - labelOffset - labelSize.width - var maxLabelRight = rulerSize.width - labelOffset + override func updateUnitLabelVisibility() { + guard showMouseTick, + mouseTickX >= bounds.minX, + mouseTickX <= bounds.maxX, + let frame = unitLabelFrame else { + setUnitLabelHidden(false) + return + } - if let resizeHandleExclusionFrame = resizeHandleExclusionFrame { - maxLabelRight = min( - maxLabelRight, - resizeHandleExclusionFrame.minX - mouseTickLabelResizeHandleSpacing - ) + setUnitLabelHidden(frame.minX <= mouseTickX && mouseTickX <= frame.maxX) + } + + override func updateResizeHandleVisibility() { + guard showMouseTick, + mouseTickX >= bounds.minX, + mouseTickX <= bounds.maxX, + let frame = resizeHandleExclusionFrame else { + setResizeHandleObscured(false) + return } - let pinnedRightPosition = maxLabelRight - labelSize.width - let rightLabelX = min(rightPosition, pinnedRightPosition) - let leftLabelX = min(leftPosition, pinnedRightPosition) - let labelX = number < rightLabelX ? rightLabelX : leftLabelX + setResizeHandleObscured(frame.minX <= mouseTickX && mouseTickX <= frame.maxX) + } - return CGRect( - x: labelX, - y: rulerSize.height - labelSize.height, - width: labelSize.width, - height: labelSize.height - ) + func tickX( + forOffset offset: CGFloat, + rulerWidth: CGFloat, + growthDirection: RulerGrowthDirection + ) -> CGFloat { + switch growthDirection { + case .positive: + return offset + case .negative: + return rulerWidth - offset + } } - func drawUnitLabel() { - let attributes = labelAttributes(alignment: .left, foregroundColor: color.ticks) + func tickLine( + forX x: CGFloat, + length: CGFloat, + rulerHeight: CGFloat, + tickSide: RulerSide + ) -> (start: CGPoint, end: CGPoint) { + switch tickSide { + case .bottom: + return (CGPoint(x: x, y: 1), CGPoint(x: x, y: length)) + case .top: + return (CGPoint(x: x, y: rulerHeight - 1), CGPoint(x: x, y: rulerHeight - length)) + case .left, .right: + assertionFailure("Horizontal ruler ticks must be placed on a horizontal side") + return (CGPoint(x: x, y: 1), CGPoint(x: x, y: length)) + } + } - let unitlabel = self.getUnitLabel() - let label = NSAttributedString(string: unitlabel, attributes: attributes) - let height = self.frame.height - let labelSize = label.size() - let labelRect = CGRect(x: 10, y: height - labelSize.height, width: labelSize.width, height: labelSize.height) + func tickLabelRect( + forX x: CGFloat, + labelSize: NSSize, + rulerHeight: CGFloat, + tickSide: RulerSide + ) -> CGRect { + let labelOffset: CGFloat = 13 + let textHeight: CGFloat = 8 + let labelX: CGFloat = x - (labelSize.width / 2) + 0.5 + let labelY: CGFloat + + switch tickSide { + case .bottom: + labelY = labelOffset + case .top: + labelY = rulerHeight - labelOffset - textHeight + case .left, .right: + assertionFailure("Horizontal ruler labels must be placed on a horizontal side") + labelY = labelOffset + } - label.draw( - with: labelRect, - context: nil + return CGRect(x: labelX, y: labelY, width: labelSize.width, height: labelSize.height) + } + + func mouseNumber(forTickX mouseTickX: CGFloat, rulerWidth: CGFloat) -> CGFloat { + let growthDirection = ZeroCornerGeometry(zeroCorner: zeroCorner).growthDirection(for: .horizontal) + + switch growthDirection { + case .positive: + return mouseTickX + case .negative: + return rulerWidth - mouseTickX + } + } + + func mouseTickLineX( + forTickX mouseTickX: CGFloat, + growthDirection: RulerGrowthDirection + ) -> CGFloat { + switch growthDirection { + case .positive: + return mouseTickX + case .negative: + return mouseTickX - 1 + } + } + + 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 45c8c9b..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? @@ -118,6 +124,7 @@ final class ResizeHandleView: NSView { ) let nextFrame = resizedRulerFrame( orientation: orientation, + zeroCorner: prefs.zeroCorner, initialFrame: dragInitialWindowFrame, delta: delta, minSize: window.minSize, @@ -153,14 +160,48 @@ final class ResizeHandleView: NSView { } func frame(in bounds: NSRect) -> NSRect { + 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 firstX = bounds.maxX - - horizontalXOffset - - CGFloat(lineCount - 1) * lineSpacing - - 1 + let bottomY: CGFloat + let firstX: CGFloat + + switch placement.xSide { + case .left: + firstX = bounds.minX + horizontalXOffset + 1 + case .right: + firstX = bounds.maxX + - horizontalXOffset + - CGFloat(lineCount - 1) * lineSpacing + - 1 + case .top, .bottom: + assertionFailure("Horizontal resize handle must be placed on a horizontal side") + firstX = bounds.maxX + - horizontalXOffset + - CGFloat(lineCount - 1) * lineSpacing + - 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, @@ -169,9 +210,31 @@ final class ResizeHandleView: NSView { height: length + (backgroundPadding * 2) ) case .vertical: - let rightX = bounds.minX + verticalXOffset + length - let leftX = rightX - length - let firstY = bounds.minY + verticalYOffset + 1 + let leftX: CGFloat + let firstY: CGFloat + + 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 + - CGFloat(lineCount - 1) * lineSpacing + - 1 + case .bottom: + firstY = bounds.minY + verticalYOffset + 1 + case .left, .right: + assertionFailure("Vertical resize handle must be placed on a vertical side") + firstY = bounds.minY + verticalYOffset + 1 + } return NSRect( x: leftX - backgroundPadding, @@ -182,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 ) @@ -194,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 @@ -267,28 +417,59 @@ private func screenLocation(for event: NSEvent, in window: NSWindow) -> NSPoint func resizedRulerFrame( orientation: Orientation, + zeroCorner: ZeroCorner = .topLeft, initialFrame: NSRect, delta: NSSize, minSize: NSSize, maxSize: NSSize ) -> NSRect { + let resizeSide = ZeroCornerGeometry(zeroCorner: zeroCorner).resizeSide(for: orientation) + switch orientation { case .horizontal: - let width = clamp(initialFrame.width + delta.width, minSize.width, maxSize.width) - return NSRect( - x: initialFrame.minX, - y: initialFrame.minY, - width: width, - height: initialFrame.height - ) + switch resizeSide { + case .left: + let width = clamp(initialFrame.width - delta.width, minSize.width, maxSize.width) + return NSRect( + x: initialFrame.maxX - width, + y: initialFrame.minY, + width: width, + height: initialFrame.height + ) + case .right: + let width = clamp(initialFrame.width + delta.width, minSize.width, maxSize.width) + return NSRect( + x: initialFrame.minX, + y: initialFrame.minY, + width: width, + height: initialFrame.height + ) + case .top, .bottom: + assertionFailure("Horizontal ruler resize side must be left or right") + return initialFrame + } case .vertical: - let height = clamp(initialFrame.height - delta.height, minSize.height, maxSize.height) - return NSRect( - x: initialFrame.minX, - y: initialFrame.maxY - height, - width: initialFrame.width, - height: height - ) + switch resizeSide { + case .top: + let height = clamp(initialFrame.height + delta.height, minSize.height, maxSize.height) + return NSRect( + x: initialFrame.minX, + y: initialFrame.minY, + width: initialFrame.width, + height: height + ) + case .bottom: + let height = clamp(initialFrame.height - delta.height, minSize.height, maxSize.height) + return NSRect( + x: initialFrame.minX, + y: initialFrame.maxY - height, + width: initialFrame.width, + height: height + ) + case .left, .right: + assertionFailure("Vertical ruler resize side must be top or bottom") + return initialFrame + } } } 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 7b66982..abb60ea 100644 --- a/Free Ruler/Ruler.swift +++ b/Free Ruler/Ruler.swift @@ -12,6 +12,228 @@ 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 +} + +enum RulerSide: Equatable { + case top + 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 { + let zeroCorner: ZeroCorner + + private let borderCompensation: CGFloat = 1.0 + + init(zeroCorner: ZeroCorner) { + self.zeroCorner = zeroCorner + } + + func growthDirection(for orientation: Orientation) -> RulerGrowthDirection { + switch orientation { + case .horizontal: + return horizontalZeroSide == .left ? .positive : .negative + case .vertical: + return verticalZeroSide == .bottom ? .positive : .negative + } + } + + func tickSide(for orientation: Orientation) -> RulerSide { + switch orientation { + case .horizontal: + return verticalZeroSide == .top ? .bottom : .top + case .vertical: + return horizontalZeroSide == .left ? .right : .left + } + } + + func resizeSide(for orientation: Orientation) -> RulerSide { + switch orientation { + case .horizontal: + return horizontalZeroSide == .left ? .right : .left + case .vertical: + return verticalZeroSide == .top ? .bottom : .top + } + } + + 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: + return NSPoint( + x: horizontalZeroSide == .left ? frame.minX : frame.maxX, + y: verticalZeroSide == .top ? frame.minY + borderCompensation : frame.maxY - borderCompensation + ) + case .vertical: + return NSPoint( + x: horizontalZeroSide == .left ? frame.maxX - borderCompensation : frame.minX, + y: verticalZeroSide == .top ? frame.maxY : frame.minY + ) + } + } + + func frame(for orientation: Orientation, zeroPoint: NSPoint, size: NSSize) -> NSRect { + switch orientation { + case .horizontal: + return NSRect( + x: horizontalZeroSide == .left ? zeroPoint.x : zeroPoint.x - size.width, + y: verticalZeroSide == .top ? zeroPoint.y - borderCompensation : zeroPoint.y - size.height + borderCompensation, + width: size.width, + height: size.height + ) + case .vertical: + return NSRect( + x: horizontalZeroSide == .left ? zeroPoint.x - size.width + borderCompensation : zeroPoint.x, + y: verticalZeroSide == .top ? zeroPoint.y - size.height : zeroPoint.y, + width: size.width, + height: size.height + ) + } + } + + func defaultFrame(for orientation: Orientation, screenFrame: NSRect) -> NSRect { + let xOffset: CGFloat = 30 + let yOffset: CGFloat = 50 + let horizontalLength = screenFrame.width / 2 + let aspectRatio = screenFrame.width / screenFrame.height + let verticalLength = horizontalLength / aspectRatio + let topLeftZeroPoint = NSPoint( + x: screenFrame.minX + xOffset + Ruler.thickness - borderCompensation, + y: screenFrame.maxY - yOffset - Ruler.thickness + borderCompensation + ) + let zeroPoint = zeroPointMatchingSelectedCorner( + topLeftZeroPoint: topLeftZeroPoint, + horizontalLength: horizontalLength, + verticalLength: verticalLength + ) + + switch orientation { + case .horizontal: + return frame( + for: orientation, + zeroPoint: zeroPoint, + size: NSSize(width: horizontalLength, height: Ruler.thickness) + ) + case .vertical: + return frame( + for: orientation, + zeroPoint: zeroPoint, + size: NSSize(width: Ruler.thickness, height: verticalLength) + ) + } + } + + private var horizontalZeroSide: RulerSide { + switch zeroCorner { + case .topLeft, .bottomLeft: + return .left + case .topRight, .bottomRight: + return .right + } + } + + private var verticalZeroSide: RulerSide { + switch zeroCorner { + case .topLeft, .topRight: + return .top + case .bottomLeft, .bottomRight: + return .bottom + } + } + + private func zeroPointMatchingSelectedCorner( + topLeftZeroPoint: NSPoint, + horizontalLength: CGFloat, + verticalLength: CGFloat + ) -> NSPoint { + var point = topLeftZeroPoint + + if horizontalZeroSide == .right { + point.x += horizontalLength + } + + if verticalZeroSide == .bottom { + point.y -= verticalLength + } + + return point + } +} + class Ruler { static let thickness: CGFloat = 40 @@ -46,40 +268,17 @@ class Ruler { // MARK: - Ruler size helpers func getDefaultContentRect(orientation: Orientation) -> NSRect { - var screenWidth: CGFloat = 1000 - var screenHeight: CGFloat = 800 - if let screen = NSScreen.main?.frame { - screenWidth = screen.width - screenHeight = screen.height - } - - let aspectRatio = screenWidth / screenHeight - let xOffset: CGFloat = 30 - let yOffset: CGFloat = 50 - let rulerThickness: CGFloat = 40 - - let horizontalLength = screenWidth / 2 - let verticalLength = horizontalLength / aspectRatio + return getDefaultContentRect(orientation: orientation, zeroCorner: .topLeft) +} - switch orientation { - case .horizontal: - return NSRect( - // offset horizontal by 1px leftward to compensate for ruler border - x: xOffset + rulerThickness - 1.0, - y: screenHeight - yOffset - rulerThickness, - width: horizontalLength, - height: rulerThickness - ) - case .vertical: - return NSRect( - // offset vertical by 1px upward to compensate for ruler border - x: xOffset, - y: screenHeight - yOffset - rulerThickness - verticalLength + 1.0, - width: rulerThickness, - height: verticalLength - ) - } +func getDefaultContentRect(orientation: Orientation, zeroCorner: ZeroCorner) -> NSRect { + let fallbackScreenFrame = NSRect(x: 0, y: 0, width: 1000, height: 800) + let screenFrame = NSScreen.main?.frame ?? fallbackScreenFrame + return ZeroCornerGeometry(zeroCorner: zeroCorner).defaultFrame( + for: orientation, + screenFrame: screenFrame + ) } func getMinSize(ruler: Ruler) -> NSSize { diff --git a/Free Ruler/RulerController.swift b/Free Ruler/RulerController.swift index fd3a46d..be4328c 100644 --- a/Free Ruler/RulerController.swift +++ b/Free Ruler/RulerController.swift @@ -245,32 +245,17 @@ class RulerController: NSWindowController, NSWindowDelegate, NotificationObserve guard let window = window else { return } let frame = window.frame - var x: CGFloat - var y: CGFloat - - switch window.ruler.orientation { - case .horizontal: - // offset horizontal by 1px downward to compensate for ruler border - x = point.x - y = point.y - 1.0 - case .vertical: - // offset vertical by 1px rightward to compensate for ruler border - x = point.x - frame.width + 1.0 - y = point.y - frame.height - } - - let rect = NSRect( - x: x, - y: y, - width: frame.width, - height: frame.height + let rect = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).frame( + for: window.ruler.orientation, + zeroPoint: point, + size: frame.size ) window.setFrame(rect, display: false) } func resetPosition() { - let frame = getDefaultContentRect(orientation: ruler.orientation) + let frame = getDefaultContentRect(orientation: ruler.orientation, zeroCorner: prefs.zeroCorner) rulerWindow.setFrame(frame, display: true) } @@ -301,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 e1245fb..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 } } @@ -29,31 +33,43 @@ class VerticalRule: RuleView { color.fill.setFill() dirtyRect.fill() - let attrs = labelAttributes(alignment: .right, foregroundColor: color.numbers) - let width = dirtyRect.width let height = dirtyRect.height let path = NSBezierPath() let tickLayout = RulerTickLayout(unit: unit, screen: screen) + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + let tickSide = geometry.tickSide(for: .vertical) + let growthDirection = geometry.growthDirection(for: .vertical) + let attrs = labelAttributes( + alignment: tickSide == .right ? .right : .left, + foregroundColor: color.numbers + ) let labelWidth: CGFloat = 50 let labelHeight: CGFloat = 20 - let labelOffset: CGFloat = 13 // offset of label from right edge of ruler - let textHeight: CGFloat = 8 // height of text, used to center the label next to the tick - // 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 for i in 1...Int((height - 2) / tickLayout.tickScale) { - let pos = CGFloat(i) * tickLayout.tickScale + let offset = CGFloat(i) * tickLayout.tickScale + let pos = tickY( + forOffset: offset, + rulerHeight: height, + growthDirection: growthDirection + ) if i.isMultiple(of: tickLayout.largeTicks) { - path.move(to: CGPoint(x: width - 1, y: height - pos)) - path.line(to: CGPoint(x: width - 10, y: height - pos)) + let tickLine = tickLine(forY: pos, length: 10, rulerWidth: width, tickSide: tickSide) + path.move(to: tickLine.start) + path.line(to: tickLine.end) let label = String(i / tickLayout.textScale) - let labelX = width - labelWidth - labelOffset - let labelY = height - pos - (textHeight / 2) - let labelRect = CGRect(x: labelX, y: labelY, width: labelWidth, height: labelHeight) + let labelRect = tickLabelRect( + forY: pos, + labelSize: NSSize(width: labelWidth, height: labelHeight), + rulerWidth: width, + tickSide: tickSide + ) label.draw( with: labelRect, @@ -63,16 +79,19 @@ class VerticalRule: RuleView { } else if i.isMultiple(of: tickLayout.mediumTicks) { - path.move(to: CGPoint(x: width - 1, y: height - pos)) - path.line(to: CGPoint(x: width - 8, y: height - pos)) + let tickLine = tickLine(forY: pos, length: 8, rulerWidth: width, tickSide: tickSide) + path.move(to: tickLine.start) + path.line(to: tickLine.end) } else if i.isMultiple(of: tickLayout.smallTicks) { - path.move(to: CGPoint(x: width - 1, y: height - pos)) - path.line(to: CGPoint(x: width - 5, y: height - pos)) + let tickLine = tickLine(forY: pos, length: 5, rulerWidth: width, tickSide: tickSide) + path.move(to: tickLine.start) + path.line(to: tickLine.end) } else if let tinyTicks = tickLayout.tinyTicks, i.isMultiple(of: tinyTicks) { - path.move(to: CGPoint(x: width - 1, y: height - pos)) - path.line(to: CGPoint(x: width - 3, y: height - pos)) + let tickLine = tickLine(forY: pos, length: 3, rulerWidth: width, tickSide: tickSide) + path.move(to: tickLine.start) + path.line(to: tickLine.end) } } @@ -81,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 { @@ -102,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) @@ -114,7 +133,7 @@ class VerticalRule: RuleView { func drawMouseNumber(_ mouseTickY: CGFloat) { let height = self.frame.height - let number = height - mouseTickY + let number = mouseNumber(forTickY: mouseTickY, rulerHeight: height) let attributes = labelAttributes(alignment: .left, foregroundColor: color.mouseNumber) @@ -122,9 +141,20 @@ class VerticalRule: RuleView { let label = NSAttributedString(string: mouseNumber, attributes: attributes) let labelSize = label.size() - let labelRect = mouseNumberLabelRect(number: number, labelSize: labelSize, rulerHeight: height) + let labelRect = mouseNumberLabelRect( + tickY: mouseTickY, + labelSize: labelSize, + rulerSize: CGSize(width: self.frame.width, height: height) + ) + let backgroundRect = mouseNumberLabelBackgroundRect( + tickY: mouseTickY, + labelSize: labelSize, + 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, @@ -133,36 +163,141 @@ class VerticalRule: RuleView { ) } - func mouseNumberLabelRect(number: CGFloat, labelSize: CGSize, rulerHeight: CGFloat) -> CGRect { - let labelOffset: CGFloat = 2 + 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 + ) + } - // 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 - var labelRect = CGRect(x: 7, y: rulerHeight - (labelY + labelSize.height), width: 22, height: 15) + 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 + ) + } - if let resizeHandleExclusionFrame = resizeHandleExclusionFrame { - let minLabelBottom = resizeHandleExclusionFrame.maxY + mouseTickLabelResizeHandleSpacing - labelRect.origin.y = max(labelRect.origin.y, minLabelBottom) + override func updateUnitLabelVisibility() { + guard showMouseTick, + mouseTickY >= bounds.minY, + mouseTickY <= bounds.maxY, + let frame = unitLabelFrame else { + setUnitLabelHidden(false) + return } - return labelRect + setUnitLabelHidden(frame.minY <= mouseTickY && mouseTickY <= frame.maxY) } - func drawUnitLabel() { - let attributes = labelAttributes(alignment: .left, foregroundColor: color.ticks) + override func updateResizeHandleVisibility() { + guard showMouseTick, + mouseTickY >= bounds.minY, + mouseTickY <= bounds.maxY, + let frame = resizeHandleExclusionFrame else { + setResizeHandleObscured(false) + return + } - let unitlabel = self.getUnitLabel() - let label = NSAttributedString(string: unitlabel, attributes: attributes) - let height = self.frame.height - let labelSize = label.size() - let labelRect = CGRect(x: 8, y: height - labelSize.height - 2, width: labelSize.width, height: labelSize.height) + setResizeHandleObscured(frame.minY <= mouseTickY && mouseTickY <= frame.maxY) + } - label.draw( - with: labelRect, - context: nil + func tickY( + forOffset offset: CGFloat, + rulerHeight: CGFloat, + growthDirection: RulerGrowthDirection + ) -> CGFloat { + switch growthDirection { + case .positive: + return offset + case .negative: + return rulerHeight - offset + } + } + + func tickLine( + forY y: CGFloat, + length: CGFloat, + rulerWidth: CGFloat, + tickSide: RulerSide + ) -> (start: CGPoint, end: CGPoint) { + switch tickSide { + case .right: + return (CGPoint(x: rulerWidth - 1, y: y), CGPoint(x: rulerWidth - length, y: y)) + case .left: + return (CGPoint(x: 1, y: y), CGPoint(x: length, y: y)) + case .top, .bottom: + assertionFailure("Vertical ruler ticks must be placed on a vertical side") + return (CGPoint(x: rulerWidth - 1, y: y), CGPoint(x: rulerWidth - length, y: y)) + } + } + + func tickLabelRect( + forY y: CGFloat, + labelSize: NSSize, + rulerWidth: CGFloat, + tickSide: RulerSide + ) -> CGRect { + let labelOffset: CGFloat = 13 + let textHeight: CGFloat = 8 + let labelX: CGFloat + + switch tickSide { + case .right: + labelX = rulerWidth - labelSize.width - labelOffset + case .left: + labelX = labelOffset + case .top, .bottom: + assertionFailure("Vertical ruler labels must be placed on a vertical side") + labelX = rulerWidth - labelSize.width - labelOffset + } + + return CGRect( + x: labelX, + y: y - (textHeight / 2), + width: labelSize.width, + height: labelSize.height + ) + } + + func mouseNumber(forTickY mouseTickY: CGFloat, rulerHeight: CGFloat) -> CGFloat { + let growthDirection = ZeroCornerGeometry(zeroCorner: zeroCorner).growthDirection(for: .vertical) + + switch growthDirection { + case .positive: + return mouseTickY + case .negative: + return rulerHeight - mouseTickY + } + } + + func mouseTickLineY( + forTickY mouseTickY: CGFloat, + growthDirection: RulerGrowthDirection + ) -> CGFloat { + switch growthDirection { + case .positive: + return mouseTickY + 1 + case .negative: + return mouseTickY + } + } + + 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 05d0138..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 @@ -60,6 +61,153 @@ final class RulerCoreTests: XCTestCase { } } + func testZeroCornerGeometryDerivesOrientationTraits() { + let cases: [ + ( + zeroCorner: ZeroCorner, + horizontalGrowth: RulerGrowthDirection, + verticalGrowth: RulerGrowthDirection, + horizontalTickSide: RulerSide, + verticalTickSide: RulerSide, + horizontalResizeSide: RulerSide, + verticalResizeSide: RulerSide + ) + ] = [ + (.topLeft, .positive, .negative, .bottom, .right, .right, .bottom), + (.topRight, .negative, .negative, .bottom, .left, .left, .bottom), + (.bottomLeft, .positive, .positive, .top, .right, .right, .top), + (.bottomRight, .negative, .positive, .top, .left, .left, .top), + ] + + for testCase in cases { + let geometry = ZeroCornerGeometry(zeroCorner: testCase.zeroCorner) + + XCTAssertEqual( + geometry.growthDirection(for: .horizontal), + testCase.horizontalGrowth, + "\(testCase.zeroCorner) horizontal growth" + ) + XCTAssertEqual( + geometry.growthDirection(for: .vertical), + testCase.verticalGrowth, + "\(testCase.zeroCorner) vertical growth" + ) + XCTAssertEqual( + geometry.tickSide(for: .horizontal), + testCase.horizontalTickSide, + "\(testCase.zeroCorner) horizontal tick side" + ) + XCTAssertEqual( + geometry.tickSide(for: .vertical), + testCase.verticalTickSide, + "\(testCase.zeroCorner) vertical tick side" + ) + XCTAssertEqual( + geometry.resizeSide(for: .horizontal), + testCase.horizontalResizeSide, + "\(testCase.zeroCorner) horizontal resize side" + ) + XCTAssertEqual( + geometry.resizeSide(for: .vertical), + testCase.verticalResizeSide, + "\(testCase.zeroCorner) vertical resize side" + ) + } + } + + 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) + let verticalSize = NSSize(width: Ruler.thickness, height: 160) + let cases: [ + ( + zeroCorner: ZeroCorner, + horizontalFrame: NSRect, + verticalFrame: NSRect + ) + ] = [ + ( + .topLeft, + NSRect(x: 200, y: 299, width: 120, height: Ruler.thickness), + NSRect(x: 161, y: 140, width: Ruler.thickness, height: 160) + ), + ( + .topRight, + NSRect(x: 80, y: 299, width: 120, height: Ruler.thickness), + NSRect(x: 200, y: 140, width: Ruler.thickness, height: 160) + ), + ( + .bottomLeft, + NSRect(x: 200, y: 261, width: 120, height: Ruler.thickness), + NSRect(x: 161, y: 300, width: Ruler.thickness, height: 160) + ), + ( + .bottomRight, + NSRect(x: 80, y: 261, width: 120, height: Ruler.thickness), + NSRect(x: 200, y: 300, width: Ruler.thickness, height: 160) + ), + ] + + for testCase in cases { + let geometry = ZeroCornerGeometry(zeroCorner: testCase.zeroCorner) + let horizontalFrame = geometry.frame( + for: .horizontal, + zeroPoint: zeroPoint, + size: horizontalSize + ) + let verticalFrame = geometry.frame( + for: .vertical, + zeroPoint: zeroPoint, + size: verticalSize + ) + + XCTAssertEqual(horizontalFrame, testCase.horizontalFrame, "\(testCase.zeroCorner) horizontal frame") + XCTAssertEqual(verticalFrame, testCase.verticalFrame, "\(testCase.zeroCorner) vertical frame") + XCTAssertEqual( + geometry.zeroPoint(in: horizontalFrame, for: .horizontal), + zeroPoint, + "\(testCase.zeroCorner) horizontal zero point" + ) + XCTAssertEqual( + geometry.zeroPoint(in: verticalFrame, for: .vertical), + zeroPoint, + "\(testCase.zeroCorner) vertical zero point" + ) + } + } + + func testZeroCornerGeometryDefaultFramesShareAZeroPointForEachCorner() { + let screenFrame = NSRect(x: 0, y: 0, width: 1000, height: 800) + + for zeroCorner in [ZeroCorner.topLeft, .topRight, .bottomLeft, .bottomRight] { + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) + let horizontalFrame = geometry.defaultFrame(for: .horizontal, screenFrame: screenFrame) + let verticalFrame = geometry.defaultFrame(for: .vertical, screenFrame: screenFrame) + + XCTAssertEqual(horizontalFrame.width, 500, "\(zeroCorner) horizontal width") + XCTAssertEqual(horizontalFrame.height, Ruler.thickness, "\(zeroCorner) horizontal height") + XCTAssertEqual(verticalFrame.width, Ruler.thickness, "\(zeroCorner) vertical width") + XCTAssertEqual(verticalFrame.height, 400, "\(zeroCorner) vertical height") + XCTAssertEqual( + geometry.zeroPoint(in: horizontalFrame, for: .horizontal), + geometry.zeroPoint(in: verticalFrame, for: .vertical), + "\(zeroCorner) shared zero point" + ) + } + } + func testMinAndMaxSizesMatchRulerOrientation() { let horizontal = Ruler(.horizontal, frame: NSRect(x: 0, y: 0, width: 300, height: 40)) let vertical = Ruler(.vertical, frame: NSRect(x: 0, y: 0, width: 40, height: 300)) @@ -96,6 +244,512 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(inches.tinyTicks, 1) } + func testHorizontalRuleDrawingHelpersFollowZeroCornerGeometry() { + let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + + XCTAssertEqual( + rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .positive), + 50 + ) + XCTAssertEqual( + rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative), + 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)) + XCTAssertEqual(bottomTick.end, CGPoint(x: 50, y: 10)) + + let topTick = rule.tickLine(forX: 250, length: 10, rulerHeight: 40, tickSide: .top) + XCTAssertEqual(topTick.start, CGPoint(x: 250, y: 39)) + XCTAssertEqual(topTick.end, CGPoint(x: 250, y: 30)) + + XCTAssertEqual( + rule.tickLabelRect( + forX: 250, + labelSize: NSSize(width: 50, height: 20), + rulerHeight: 40, + tickSide: .top + ), + CGRect(x: 225.5, y: 19, width: 50, height: 20) + ) + } + + func testHorizontalRuleMouseAndUnitLabelsMirrorForRightZeroCorner() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .bottomRight + let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + + XCTAssertEqual(rule.mouseNumber(forTickX: 260, rulerWidth: 300), 40) + XCTAssertEqual( + rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 300, height: 40)), + CGRect(x: 280, y: 0, width: 20, height: 19) + ) + } + } + + func testVerticalRuleDrawingHelpersFollowZeroCornerGeometry() { + let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + + XCTAssertEqual( + rule.tickY(forOffset: 50, rulerHeight: 300, growthDirection: .negative), + 250 + ) + XCTAssertEqual( + 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)) + XCTAssertEqual(rightTick.end, CGPoint(x: 30, y: 250)) + + let leftTick = rule.tickLine(forY: 50, length: 10, rulerWidth: 40, tickSide: .left) + XCTAssertEqual(leftTick.start, CGPoint(x: 1, y: 50)) + XCTAssertEqual(leftTick.end, CGPoint(x: 10, y: 50)) + + XCTAssertEqual( + rule.tickLabelRect( + forY: 50, + labelSize: NSSize(width: 50, height: 20), + rulerWidth: 40, + tickSide: .left + ), + CGRect(x: 13, y: 46, width: 50, height: 20) + ) + } + + func testVerticalRuleMouseAndUnitLabelsMirrorForBottomZeroCorner() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .bottomRight + let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + + XCTAssertEqual(rule.mouseNumber(forTickY: 40, rulerHeight: 300), 40) + XCTAssertEqual( + rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 40, height: 300)), + 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) + ) + } + } + func testRulerColorsDefaultToOriginalFillColor() { withRestoredRulerColorPreference { prefs.rulerColor = Prefs.defaultRulerFillColor @@ -147,9 +801,9 @@ final class RulerCoreTests: XCTestCase { } func testDefaultRulerRectsUseExpectedShapeAndOffsets() { - let screen = NSScreen.main?.frame - let screenWidth = screen?.width ?? 1000 - let screenHeight = screen?.height ?? 800 + let screenFrame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1000, height: 800) + let screenWidth = screenFrame.width + let screenHeight = screenFrame.height let horizontalLength = screenWidth / 2 let verticalLength = horizontalLength / (screenWidth / screenHeight) @@ -160,9 +814,9 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(vertical.width, Ruler.thickness) XCTAssertEqual(horizontal.width, horizontalLength, accuracy: 0.0001) XCTAssertEqual(vertical.height, verticalLength, accuracy: 0.0001) - XCTAssertEqual(horizontal.minX, 69.0, accuracy: 0.0001) - XCTAssertEqual(vertical.minX, 30.0, accuracy: 0.0001) - XCTAssertEqual(horizontal.minY, screenHeight - 90.0, accuracy: 0.0001) + XCTAssertEqual(horizontal.minX, screenFrame.minX + 69.0, accuracy: 0.0001) + XCTAssertEqual(vertical.minX, screenFrame.minX + 30.0, accuracy: 0.0001) + XCTAssertEqual(horizontal.minY, screenFrame.maxY - 90.0, accuracy: 0.0001) XCTAssertEqual(vertical.maxY, horizontal.minY + 1.0, accuracy: 0.0001) } @@ -397,6 +1051,178 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(frame.maxY, initialFrame.maxY) } + func testHorizontalResizeHandleFrameMathCanResizeFromLeftEdge() { + let initialFrame = NSRect(x: 10, y: 20, width: 300, height: Ruler.thickness) + let frame = resizedRulerFrame( + orientation: .horizontal, + zeroCorner: .topRight, + initialFrame: initialFrame, + delta: NSSize(width: 50, height: 25), + minSize: NSSize(width: 200, height: Ruler.thickness), + maxSize: NSSize(width: 4000, height: Ruler.thickness) + ) + + XCTAssertEqual(frame, NSRect(x: 60, y: 20, width: 250, height: Ruler.thickness)) + XCTAssertEqual(frame.maxX, initialFrame.maxX) + } + + func testVerticalResizeHandleFrameMathCanResizeFromTopEdge() { + let initialFrame = NSRect(x: 10, y: 20, width: Ruler.thickness, height: 300) + let frame = resizedRulerFrame( + orientation: .vertical, + zeroCorner: .bottomLeft, + initialFrame: initialFrame, + delta: NSSize(width: 25, height: 50), + minSize: NSSize(width: Ruler.thickness, height: 200), + maxSize: NSSize(width: Ruler.thickness, height: 4000) + ) + + XCTAssertEqual(frame, NSRect(x: 10, y: 20, width: Ruler.thickness, height: 350)) + 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) @@ -409,73 +1235,248 @@ final class RulerCoreTests: XCTestCase { XCTAssertFalse(verticalCursor.image.isTemplate) } - 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 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, 0) } - 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 - ) + func testHorizontalMouseTickLabelFlipsBeforeResizeHandleWithoutPinning() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft - XCTAssertGreaterThan(labelRect.minX, mouseTickBeforePinnedLabel) - 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 + 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 + ) + } } - 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 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 + ) } - 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 testHorizontalMouseTickLabelStaysOnPreferredSideNearLeftResizeEnd() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .bottomRight - 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: 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 + ) + } } - 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 testHorizontalMouseTickLabelClampsToRulerStart() { + 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 + let mouseTickNearLeftEdge: CGFloat = -10 + + let labelRect = rule.mouseNumberLabelRect( + tickX: mouseTickNearLeftEdge, + labelSize: labelSize, + rulerSize: rulerSize + ) + + XCTAssertEqual( + labelRect.minX, + 0, + accuracy: 0.0001 + ) } + } - let labelRect = rule.mouseNumberLabelRect( - number: 290, - labelSize: CGSize(width: 22, height: 10), - rulerHeight: 300 - ) + func testVerticalMouseTickLabelStaysOnPreferredSideNearTopResizeEnd() { + 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 + 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 + ) + } + } + + func testVerticalMouseTickLabelFlipsBeforeBottomUnitLabel() { + 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 + 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() { @@ -550,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() { @@ -701,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, @@ -794,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"