diff --git a/Free Ruler.xcodeproj/project.pbxproj b/Free Ruler.xcodeproj/project.pbxproj index a2b8faa..8debbed 100644 --- a/Free Ruler.xcodeproj/project.pbxproj +++ b/Free Ruler.xcodeproj/project.pbxproj @@ -32,8 +32,8 @@ 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 */; }; @@ -142,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 = ""; }; diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index 64ceae0..59a8666 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -145,7 +145,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { #endif showRulers() - } #if DEBUG 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 f3fe3ee..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,39 +1,107 @@ + Free Ruler Help - - - - - + + + + + + + - - -

- Free Ruler Help -

- -

- Keyboard Shortcuts -

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

+ Free Ruler Help +

+ +

+ 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
+ +

+ 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 c731346..0138012 100644 --- a/Free Ruler/HorizontalRule.swift +++ b/Free Ruler/HorizontalRule.swift @@ -19,6 +19,7 @@ class HorizontalRule: RuleView { var mouseTickX: CGFloat = 0 { didSet { if mouseTickX != oldValue { + updateResizeHandleVisibility() updateUnitLabelVisibility() needsDisplay = true } @@ -38,7 +39,7 @@ class HorizontalRule: RuleView { let height = dirtyRect.height let path = NSBezierPath() let tickLayout = RulerTickLayout(unit: unit, screen: screen) - let geometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) let tickSide = geometry.tickSide(for: .horizontal) let growthDirection = geometry.growthDirection(for: .horizontal) @@ -116,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) @@ -138,57 +141,34 @@ class HorizontalRule: RuleView { let labelSize = label.size() let labelRect = mouseNumberLabelRect( - number: mouseTickX, + tickX: mouseTickX, labelSize: labelSize, rulerSize: CGSize(width: width, height: height) ) + guard NSGraphicsContext.current != nil else { return } label.draw( with: labelRect, context: nil ) } - func mouseNumberLabelRect(number: CGFloat, labelSize: CGSize, rulerSize: CGSize) -> CGRect { - let labelOffset: CGFloat = 5 - let tickSide = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).tickSide(for: .horizontal) - - let rightPosition = number + labelOffset - let leftPosition = number - labelOffset - labelSize.width - var minLabelLeft = labelOffset - var maxLabelRight = rulerSize.width - labelOffset - - if let resizeHandleExclusionFrame = resizeHandleExclusionFrame { - if resizeHandleExclusionFrame.midX < rulerSize.width / 2 { - minLabelLeft = max( - minLabelLeft, - resizeHandleExclusionFrame.maxX + mouseTickLabelResizeHandleSpacing - ) - } else { - maxLabelRight = min( - maxLabelRight, - resizeHandleExclusionFrame.minX - mouseTickLabelResizeHandleSpacing - ) - } - } - - let pinnedRightPosition = maxLabelRight - labelSize.width - let rightLabelX = max(min(rightPosition, pinnedRightPosition), minLabelLeft) - let leftLabelX = max(min(leftPosition, pinnedRightPosition), minLabelLeft) - let labelX = number < rightLabelX ? rightLabelX : leftLabelX - - return CGRect( - x: labelX, - y: tickSide == .bottom ? rulerSize.height - labelSize.height : 0, - width: labelSize.width, - height: labelSize.height + 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 ) } override func updateUnitLabelVisibility() { guard showMouseTick, - mouseTickX > 0, - mouseTickX < windowWidth, + mouseTickX >= bounds.minX, + mouseTickX <= bounds.maxX, let frame = unitLabelFrame else { setUnitLabelHidden(false) return @@ -197,6 +177,18 @@ class HorizontalRule: RuleView { 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 + } + + setResizeHandleObscured(frame.minX <= mouseTickX && mouseTickX <= frame.maxX) + } + func tickX( forOffset offset: CGFloat, rulerWidth: CGFloat, @@ -252,7 +244,7 @@ class HorizontalRule: RuleView { } func mouseNumber(forTickX mouseTickX: CGFloat, rulerWidth: CGFloat) -> CGFloat { - let growthDirection = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).growthDirection(for: .horizontal) + let growthDirection = ZeroCornerGeometry(zeroCorner: zeroCorner).growthDirection(for: .horizontal) switch growthDirection { case .positive: @@ -262,12 +254,24 @@ class HorizontalRule: RuleView { } } + 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: prefs.zeroCorner + 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 3921413..848d695 100644 --- a/Free Ruler/Localizable.xcstrings +++ b/Free Ruler/Localizable.xcstrings @@ -715,6 +715,16 @@ } } }, + "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", @@ -1053,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 578a444..fa0e161 100644 --- a/Free Ruler/ResizeHandleView.swift +++ b/Free Ruler/ResizeHandleView.swift @@ -7,6 +7,12 @@ import SwiftUI final class ResizeHandleView: NSView { var color: RulerColors + var zeroCorner = prefs.zeroCorner { + didSet { + needsDisplay = true + } + } + private let orientation: Orientation private var trackingArea: NSTrackingArea? private var dragInitialMouseLocation: NSPoint? @@ -154,9 +160,18 @@ final class ResizeHandleView: NSView { } func frame(in bounds: NSRect) -> NSRect { - let placement = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + 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 bottomY: CGFloat @@ -230,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 ) @@ -242,38 +297,85 @@ final class ResizeHandleView: NSView { } private func drawGripLines() { + let gripRect = gripRect(in: bounds) + switch orientation { case .horizontal: for index in 0.. NSRect { + let placement = ZeroCornerGeometry(zeroCorner: zeroCorner) + .resizeHandlePlacement(for: orientation) + let gripSize = self.gripSize() + let x: CGFloat + let y: CGFloat + + switch placement.xSide { + case .left: + x = bounds.maxX - gripSize.width + case .right: + x = bounds.minX + case .top, .bottom: + assertionFailure("Resize handle grip must be anchored to a horizontal side") + x = bounds.minX + } + + switch placement.ySide { + case .top: + y = bounds.minY + case .bottom: + y = bounds.maxY - gripSize.height + case .left, .right: + assertionFailure("Resize handle grip must be anchored to a vertical side") + y = bounds.minY + } + + return NSRect(origin: NSPoint(x: x, y: y), size: gripSize) + } + + private func gripSize() -> NSSize { + switch orientation { + case .horizontal: + return NSSize( + width: CGFloat(lineCount - 1) * lineSpacing + 2 + (backgroundPadding * 2), + height: length + (backgroundPadding * 2) + ) + case .vertical: + return NSSize( + width: length + (backgroundPadding * 2), + height: CGFloat(lineCount - 1) * lineSpacing + 2 + (backgroundPadding * 2) + ) + } + } + private func strokeLine(from start: CGPoint, to end: CGPoint, color: NSColor) { let path = NSBezierPath() path.lineWidth = 1 diff --git a/Free Ruler/RuleView.swift b/Free Ruler/RuleView.swift index dffff20..1e23324 100644 --- a/Free Ruler/RuleView.swift +++ b/Free Ruler/RuleView.swift @@ -45,6 +45,335 @@ 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() { @@ -54,7 +383,6 @@ class RuleView: NSView { updateUnitLabelFrame() } } - let mouseTickLabelResizeHandleSpacing: CGFloat = 8 private var resizeHandleView: ResizeHandleView? private var unitLabelView: UnitLabelView? @@ -108,6 +436,7 @@ class RuleView: NSView { super.layout() updateResizeHandleFrame() updateUnitLabelFrame() + updateResizeHandleVisibility() updateUnitLabelVisibility() } @@ -136,23 +465,31 @@ 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 } @@ -170,6 +507,10 @@ class RuleView: NSView { prefs.unit } + var zeroCorner: ZeroCorner { + prefs.zeroCorner + } + var resizeHandleExclusionFrame: NSRect? { return resizeHandleView?.frame } @@ -223,6 +564,14 @@ class RuleView: NSView { unitLabelView?.isHidden = isHidden } + func setResizeHandleObscured(_ isObscured: Bool) { + resizeHandleView?.alphaValue = isObscured ? 0 : 1 + } + + func updateResizeHandleVisibility() { + setResizeHandleObscured(false) + } + func updateUnitLabelVisibility() { setUnitLabelHidden(false) } @@ -230,14 +579,16 @@ class RuleView: NSView { 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) + unitLabelView.frame = unitLabelView.frame(in: bounds, zeroCorner: zeroCorner) } private func unitLabel() -> NSAttributedString { @@ -310,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 + } + + 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 + } - RuleViewPreview(orientation: .vertical) - .frame(width: Ruler.thickness, height: 320) + 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/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 index 9c56365..3413e49 100644 --- a/Free Ruler/UnitLabelView.swift +++ b/Free Ruler/UnitLabelView.swift @@ -1,11 +1,26 @@ 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 @@ -24,15 +39,27 @@ final class UnitLabelView: NSView { override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) - label.draw(with: bounds, context: nil) + 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: prefs.zeroCorner + zeroCorner: zeroCorner ) } @@ -55,30 +82,69 @@ final class UnitLabelView: NSView { ) -> NSRect { let placement = ZeroCornerGeometry(zeroCorner: zeroCorner) .unitLabelPlacement(for: orientation) - let topInset: CGFloat = 2 - let bottomInset: CGFloat = 9 - let leftInset: CGFloat = 8 - let rightInset: CGFloat = 8 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.xSide, placement.ySide) { - case (.left, .top): - x = leftInset - y = rulerSize.height - labelSize.height - topInset - case (.right, .top): - x = rulerSize.width - labelSize.width - rightInset - y = rulerSize.height - labelSize.height - topInset - case (.left, .bottom): - x = leftInset - y = bottomInset - case (.right, .bottom): - x = rulerSize.width - labelSize.width - rightInset - y = bottomInset - case (_, _): - assertionFailure("Unit label must be anchored to left/right and top/bottom sides") - x = leftInset - y = topInset + 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 9c71293..27bc377 100644 --- a/Free Ruler/VerticalRule.swift +++ b/Free Ruler/VerticalRule.swift @@ -19,6 +19,7 @@ class VerticalRule: RuleView { var mouseTickY: CGFloat = 0 { didSet { if mouseTickY != oldValue { + updateResizeHandleVisibility() updateUnitLabelVisibility() needsDisplay = true } @@ -36,7 +37,7 @@ class VerticalRule: RuleView { let height = dirtyRect.height let path = NSBezierPath() let tickLayout = RulerTickLayout(unit: unit, screen: screen) - let geometry = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner) + let geometry = ZeroCornerGeometry(zeroCorner: zeroCorner) let tickSide = geometry.tickSide(for: .vertical) let growthDirection = geometry.growthDirection(for: .vertical) let attrs = labelAttributes( @@ -118,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) @@ -139,12 +142,19 @@ class VerticalRule: RuleView { let labelSize = label.size() let labelRect = mouseNumberLabelRect( - number: height - mouseTickY, + tickY: mouseTickY, labelSize: labelSize, - rulerHeight: height + 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, @@ -153,30 +163,34 @@ class VerticalRule: RuleView { ) } - func mouseNumberLabelRect(number: CGFloat, labelSize: CGSize, rulerHeight: CGFloat) -> CGRect { - let labelOffset: CGFloat = 2 - let tickSide = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).tickSide(for: .vertical) - - // Offset the bottom position until text can be centered vertically in the label rect. - let bottomPosition = number + 7 - let topPosition = number - labelOffset - labelSize.height - let enoughRoomToTheBottom = bottomPosition + labelSize.height < rulerHeight - labelOffset - let labelY = enoughRoomToTheBottom ? bottomPosition : topPosition - let labelX: CGFloat = tickSide == .right ? 7 : 11 - var labelRect = CGRect(x: labelX, y: rulerHeight - (labelY + labelSize.height), width: 22, height: 15) - - if let resizeHandleExclusionFrame = resizeHandleExclusionFrame { - let minLabelBottom = resizeHandleExclusionFrame.maxY + mouseTickLabelResizeHandleSpacing - labelRect.origin.y = max(labelRect.origin.y, minLabelBottom) - } + func mouseNumberLabelRect(tickY: CGFloat, labelSize: CGSize, rulerSize: CGSize) -> CGRect { + return MouseTickLabelLayout.labelFrame( + labelSize: labelSize, + rulerSize: rulerSize, + orientation: .vertical, + zeroCorner: zeroCorner, + tickPosition: tickY, + resizeHandleFrame: resizeHandleExclusionFrame, + unitLabelFrame: unitLabelFrame + ) + } - return labelRect + func mouseNumberLabelBackgroundRect(tickY: CGFloat, labelSize: CGSize, rulerSize: CGSize) -> CGRect { + return MouseTickLabelLayout.labelBackgroundFrame( + labelSize: labelSize, + rulerSize: rulerSize, + orientation: .vertical, + zeroCorner: zeroCorner, + tickPosition: tickY, + resizeHandleFrame: resizeHandleExclusionFrame, + unitLabelFrame: unitLabelFrame + ) } override func updateUnitLabelVisibility() { guard showMouseTick, - mouseTickY >= 1, - mouseTickY < windowHeight, + mouseTickY >= bounds.minY, + mouseTickY <= bounds.maxY, let frame = unitLabelFrame else { setUnitLabelHidden(false) return @@ -185,6 +199,18 @@ class VerticalRule: RuleView { setUnitLabelHidden(frame.minY <= mouseTickY && mouseTickY <= frame.maxY) } + override func updateResizeHandleVisibility() { + guard showMouseTick, + mouseTickY >= bounds.minY, + mouseTickY <= bounds.maxY, + let frame = resizeHandleExclusionFrame else { + setResizeHandleObscured(false) + return + } + + setResizeHandleObscured(frame.minY <= mouseTickY && mouseTickY <= frame.maxY) + } + func tickY( forOffset offset: CGFloat, rulerHeight: CGFloat, @@ -244,7 +270,7 @@ class VerticalRule: RuleView { } func mouseNumber(forTickY mouseTickY: CGFloat, rulerHeight: CGFloat) -> CGFloat { - let growthDirection = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).growthDirection(for: .vertical) + let growthDirection = ZeroCornerGeometry(zeroCorner: zeroCorner).growthDirection(for: .vertical) switch growthDirection { case .positive: @@ -254,12 +280,24 @@ class VerticalRule: RuleView { } } + 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: prefs.zeroCorner + zeroCorner: zeroCorner ) } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 421b3be..3ea02c5 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -255,6 +255,14 @@ final class RulerCoreTests: XCTestCase { rule.tickX(forOffset: 50, rulerWidth: 300, growthDirection: .negative), 250 ) + XCTAssertEqual( + rule.mouseTickLineX(forTickX: 1, growthDirection: .positive), + 1 + ) + XCTAssertEqual( + rule.mouseTickLineX(forTickX: 299, growthDirection: .negative), + 298 + ) let bottomTick = rule.tickLine(forX: 50, length: 10, rulerHeight: 40, tickSide: .bottom) XCTAssertEqual(bottomTick.start, CGPoint(x: 50, y: 1)) @@ -279,23 +287,11 @@ final class RulerCoreTests: XCTestCase { withRestoredZeroCornerPreference { prefs.zeroCorner = .bottomRight let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { - return XCTFail("Expected horizontal ruler to install a resize handle") - } XCTAssertEqual(rule.mouseNumber(forTickX: 260, rulerWidth: 300), 40) - XCTAssertEqual( - rule.mouseNumberLabelRect( - number: resizeHandleFrame.maxX - 1, - labelSize: NSSize(width: 30, height: 10), - rulerSize: rule.bounds.size - ).minX, - resizeHandleFrame.maxX + rule.mouseTickLabelResizeHandleSpacing, - accuracy: 0.0001 - ) XCTAssertEqual( rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 300, height: 40)), - CGRect(x: 280, y: 9, width: 12, height: 10) + CGRect(x: 280, y: 0, width: 20, height: 19) ) } } @@ -311,6 +307,14 @@ final class RulerCoreTests: XCTestCase { rule.tickY(forOffset: 50, rulerHeight: 300, growthDirection: .positive), 50 ) + XCTAssertEqual( + rule.mouseTickLineY(forTickY: 299, growthDirection: .negative), + 299 + ) + XCTAssertEqual( + rule.mouseTickLineY(forTickY: 1, growthDirection: .positive), + 2 + ) let rightTick = rule.tickLine(forY: 250, length: 10, rulerWidth: 40, tickSide: .right) XCTAssertEqual(rightTick.start, CGPoint(x: 39, y: 250)) @@ -339,8 +343,172 @@ final class RulerCoreTests: XCTestCase { XCTAssertEqual(rule.mouseNumber(forTickY: 40, rulerHeight: 300), 40) XCTAssertEqual( rule.unitLabelRect(labelSize: NSSize(width: 12, height: 10), rulerSize: NSSize(width: 40, height: 300)), - CGRect(x: 20, y: 9, width: 12, height: 10) + CGRect(x: 20, y: 0, width: 20, height: 19) + ) + } + } + + func testMouseNumberLabelFramesFollowZeroCornerPlacement() { + withRestoredZeroCornerPreference { + let horizontalRule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + let verticalRule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let labelSize = NSSize(width: 20, height: 10) + let cases: [ + ( + zeroCorner: ZeroCorner, + horizontalRect: CGRect, + verticalRect: CGRect, + verticalBackgroundRect: CGRect + ) + ] = [ + ( + .topLeft, + CGRect(x: 155, y: 28, width: 20, height: 10), + CGRect(x: 7, y: 136, width: 20, height: 10), + CGRect(x: 0, y: 136, width: 30, height: 10) + ), + ( + .topRight, + CGRect(x: 155, y: 28, width: 20, height: 10), + CGRect(x: 13, y: 136, width: 20, height: 10), + CGRect(x: 10, y: 136, width: 30, height: 10) + ), + ( + .bottomLeft, + CGRect(x: 155, y: 7, width: 20, height: 10), + CGRect(x: 7, y: 136, width: 20, height: 10), + CGRect(x: 0, y: 136, width: 30, height: 10) + ), + ( + .bottomRight, + CGRect(x: 155, y: 7, width: 20, height: 10), + CGRect(x: 13, y: 136, width: 20, height: 10), + CGRect(x: 10, y: 136, width: 30, height: 10) + ), + ] + + for testCase in cases { + prefs.zeroCorner = testCase.zeroCorner + horizontalRule.redrawForPreferenceChange() + verticalRule.redrawForPreferenceChange() + + XCTAssertEqual( + horizontalRule.mouseNumberLabelRect( + tickX: 150, + labelSize: labelSize, + rulerSize: horizontalRule.bounds.size + ), + testCase.horizontalRect, + "\(testCase.zeroCorner) horizontal mouse number label" + ) + XCTAssertEqual( + verticalRule.mouseNumberLabelRect( + tickY: 150, + labelSize: labelSize, + rulerSize: verticalRule.bounds.size + ), + testCase.verticalRect, + "\(testCase.zeroCorner) vertical mouse number label" + ) + XCTAssertEqual( + verticalRule.mouseNumberLabelBackgroundRect( + tickY: 150, + labelSize: labelSize, + rulerSize: verticalRule.bounds.size + ), + testCase.verticalBackgroundRect, + "\(testCase.zeroCorner) vertical mouse number label background" + ) + } + } + } + + func testVerticalMouseNumberLabelBackgroundCoversWideLabels() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topRight + + let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let wideLabelSize = NSSize(width: 32, height: 10) + let labelRect = rule.mouseNumberLabelRect( + tickY: 150, + labelSize: wideLabelSize, + rulerSize: rule.bounds.size + ) + let backgroundRect = rule.mouseNumberLabelBackgroundRect( + tickY: 150, + labelSize: wideLabelSize, + rulerSize: rule.bounds.size ) + + XCTAssertTrue(backgroundRect.contains(labelRect)) + XCTAssertLessThan(labelRect.minX, 10) + XCTAssertEqual(backgroundRect.minX, labelRect.minX, accuracy: 0.0001) + XCTAssertEqual(backgroundRect.maxX, rule.bounds.maxX, accuracy: 0.0001) + } + } + + func testResizeHandlesAreVisuallyObscuredFromLeadingEdgeToRulerEnd() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + let horizontalRule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + let verticalRule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + guard let horizontalHandle = horizontalRule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } + guard let verticalHandle = verticalRule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected vertical ruler to install a resize handle") + } + + horizontalRule.mouseTickX = horizontalHandle.frame.midX + XCTAssertFalse(horizontalHandle.isHidden) + XCTAssertEqual(horizontalHandle.alphaValue, 0) + + horizontalRule.mouseTickX = horizontalHandle.frame.maxX + XCTAssertEqual(horizontalHandle.alphaValue, 0) + + horizontalRule.mouseTickX = horizontalHandle.frame.minX - 1 + XCTAssertEqual(horizontalHandle.alphaValue, 1) + + verticalRule.mouseTickY = verticalHandle.frame.midY + XCTAssertFalse(verticalHandle.isHidden) + XCTAssertEqual(verticalHandle.alphaValue, 0) + + verticalRule.mouseTickY = verticalHandle.frame.minY + XCTAssertEqual(verticalHandle.alphaValue, 0) + + verticalRule.mouseTickY = verticalHandle.frame.maxY + 1 + XCTAssertEqual(verticalHandle.alphaValue, 1) + + verticalRule.mouseTickY = verticalHandle.frame.midY + verticalRule.showMouseTick = false + XCTAssertEqual(verticalHandle.alphaValue, 1) + + prefs.zeroCorner = .topRight + let leftEdgeRule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + guard let leftEdgeHandle = leftEdgeRule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected horizontal ruler to install a resize handle") + } + + leftEdgeRule.mouseTickX = leftEdgeHandle.frame.maxX + XCTAssertEqual(leftEdgeHandle.alphaValue, 0) + + leftEdgeRule.mouseTickX = leftEdgeHandle.frame.maxX + 1 + XCTAssertEqual(leftEdgeHandle.alphaValue, 1) + + leftEdgeRule.mouseTickX = leftEdgeHandle.frame.minX + XCTAssertEqual(leftEdgeHandle.alphaValue, 0) + + prefs.zeroCorner = .bottomLeft + let topEdgeRule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + guard let topEdgeHandle = topEdgeRule.subviews.first(where: { $0 is ResizeHandleView }) as? ResizeHandleView else { + return XCTFail("Expected vertical ruler to install a resize handle") + } + + topEdgeRule.mouseTickY = topEdgeHandle.frame.maxY + XCTAssertEqual(topEdgeHandle.alphaValue, 0) + + topEdgeRule.mouseTickY = topEdgeHandle.frame.minY - 1 + XCTAssertEqual(topEdgeHandle.alphaValue, 1) } } @@ -357,10 +525,10 @@ final class RulerCoreTests: XCTestCase { verticalUnitRect: CGRect ) ] = [ - (.topLeft, CGRect(x: 8, y: 28, width: 12, height: 10), CGRect(x: 8, y: 288, width: 12, height: 10)), - (.topRight, CGRect(x: 280, y: 28, width: 12, height: 10), CGRect(x: 20, y: 288, width: 12, height: 10)), - (.bottomLeft, CGRect(x: 8, y: 9, width: 12, height: 10), CGRect(x: 8, y: 9, width: 12, height: 10)), - (.bottomRight, CGRect(x: 280, y: 9, width: 12, height: 10), CGRect(x: 20, y: 9, width: 12, height: 10)), + (.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 { @@ -404,6 +572,88 @@ final class RulerCoreTests: XCTestCase { } } + 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 @@ -435,6 +685,26 @@ final class RulerCoreTests: XCTestCase { } } + 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 @@ -964,92 +1234,248 @@ final class RulerCoreTests: XCTestCase { XCTAssertFalse(verticalCursor.image.isTemplate) } - func testHorizontalResizeHandleFrameIncludesBorderInsetOnLeftEdge() { + func testHorizontalResizeHandleFramePinsToLeftEdgeSlot() { withRestoredZeroCornerPreference { prefs.zeroCorner = .topRight let resizeHandle = ResizeHandleView(orientation: .horizontal, color: RulerColors()) let frame = resizeHandle.frame(in: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) - XCTAssertEqual(frame.minX, 4.5) + XCTAssertEqual(frame.minX, 0) } } - func testHorizontalMouseTickLabelStopsBeforeResizeHandle() { + func testHorizontalMouseTickLabelFlipsBeforeResizeHandleWithoutPinning() { withRestoredZeroCornerPreference { prefs.zeroCorner = .topLeft let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + let labelSize = CGSize(width: 30, height: 10) + let rulerSize = rule.bounds.size guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { return XCTFail("Expected horizontal ruler to install a resize handle") } - let 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 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( - number: mouseTickBeforePinnedLabel, + tickX: mouseTickNearRightEdge, labelSize: labelSize, rulerSize: rulerSize ) - XCTAssertGreaterThan(labelRect.minX, mouseTickBeforePinnedLabel) + XCTAssertLessThan(labelJustPastRightHandleFit.maxX, mouseTickJustPastRightHandleFit) + XCTAssertLessThanOrEqual(labelJustPastRightHandleFit.maxX, resizeHandleFrame.minX) XCTAssertEqual( - labelRect.maxX, - expectedMaxLabelRight, + mouseTickJustPastRightHandleFit - labelJustPastRightHandleFit.maxX, + tickLabelSpacing, + accuracy: 0.0001 + ) + XCTAssertLessThan(labelRect.maxX, mouseTickNearRightEdge) + XCTAssertEqual( + mouseTickNearRightEdge - labelRect.maxX, + tickLabelSpacing, accuracy: 0.0001 ) } } - func testHorizontalMouseTickLabelFlipsBeforeCollidingWithMouseTick() { + func testHorizontalMouseTickLabelFlipsBeforeRightUnitLabel() { withRestoredZeroCornerPreference { - prefs.zeroCorner = .topLeft + prefs.zeroCorner = .topRight let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 300, height: Ruler.thickness)) + let labelSize = CGSize(width: 30, height: 10) + let tickLabelSpacing: CGFloat = 5 + let unitLabelFlipPadding: CGFloat = 3 + guard let unitLabelFrame = rule.unitLabelFrame else { + return XCTFail("Expected horizontal ruler to install a unit label") + } + let mouseTickAtRightUnitFit = unitLabelFrame.minX - unitLabelFlipPadding - labelSize.width - tickLabelSpacing + let mouseTickJustPastRightUnitFit = mouseTickAtRightUnitFit + 1 + + let fittingLabelRect = rule.mouseNumberLabelRect( + tickX: mouseTickAtRightUnitFit, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + let flippedLabelRect = rule.mouseNumberLabelRect( + tickX: mouseTickJustPastRightUnitFit, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + + XCTAssertGreaterThan(fittingLabelRect.minX, mouseTickAtRightUnitFit) + XCTAssertEqual( + fittingLabelRect.maxX, + unitLabelFrame.minX - unitLabelFlipPadding, + accuracy: 0.0001 + ) + XCTAssertLessThan(flippedLabelRect.maxX, mouseTickJustPastRightUnitFit) + XCTAssertEqual( + mouseTickJustPastRightUnitFit - flippedLabelRect.maxX, + tickLabelSpacing, + accuracy: 0.0001 + ) + } + } + + func testHorizontalMouseTickLabelStaysOnPreferredSideNearLeftResizeEnd() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .bottomRight + + let rule = HorizontalRule(frame: NSRect(x: 0, y: 0, width: 600, height: Ruler.thickness)) + let labelSize = CGSize(width: 30, height: 10) + let tickLabelSpacing: CGFloat = 5 guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { return XCTFail("Expected horizontal ruler to install a resize handle") } + let mouseTickInsideLeftResizeEnd = resizeHandleFrame.maxX - tickLabelSpacing - 1 + + let labelRect = rule.mouseNumberLabelRect( + tickX: mouseTickInsideLeftResizeEnd, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + + XCTAssertGreaterThan(labelRect.minX, mouseTickInsideLeftResizeEnd) + XCTAssertEqual( + labelRect.minX - mouseTickInsideLeftResizeEnd, + tickLabelSpacing, + accuracy: 0.0001 + ) + } + } + + 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 expectedMaxLabelRight = resizeHandleFrame.minX - rule.mouseTickLabelResizeHandleSpacing - let mouseTickInsideResizeHandle = resizeHandleFrame.minX + 1 + let mouseTickNearLeftEdge: CGFloat = -10 let labelRect = rule.mouseNumberLabelRect( - number: mouseTickInsideResizeHandle, + tickX: mouseTickNearLeftEdge, labelSize: labelSize, rulerSize: rulerSize ) - XCTAssertGreaterThan(mouseTickInsideResizeHandle, resizeHandleFrame.minX) - XCTAssertLessThan(labelRect.maxX, mouseTickInsideResizeHandle) XCTAssertEqual( - labelRect.maxX, - expectedMaxLabelRight, + labelRect.minX, + 0, accuracy: 0.0001 ) } } - func testVerticalMouseTickLabelStopsBeforeResizeHandle() { - let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) - guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { - return XCTFail("Expected vertical ruler to install a resize handle") + func testVerticalMouseTickLabelStaysOnPreferredSideNearTopResizeEnd() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .bottomLeft + + let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let labelSize = CGSize(width: 20, height: 10) + let tickLabelSpacing: CGFloat = 4 + guard let resizeHandleFrame = rule.resizeHandleExclusionFrame else { + return XCTFail("Expected vertical ruler to install a resize handle") + } + let mouseTickInsideTopResizeEnd = resizeHandleFrame.minY + tickLabelSpacing + 1 + + let labelRect = rule.mouseNumberLabelRect( + tickY: mouseTickInsideTopResizeEnd, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + + XCTAssertLessThan(labelRect.maxY, mouseTickInsideTopResizeEnd) + XCTAssertEqual( + mouseTickInsideTopResizeEnd - labelRect.maxY, + tickLabelSpacing, + accuracy: 0.0001 + ) } + } - let labelRect = rule.mouseNumberLabelRect( - number: 290, - labelSize: CGSize(width: 22, height: 10), - rulerHeight: 300 - ) + func testVerticalMouseTickLabelFlipsBeforeBottomUnitLabel() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .bottomLeft - XCTAssertEqual( - labelRect.minY, - resizeHandleFrame.maxY + rule.mouseTickLabelResizeHandleSpacing, - accuracy: 0.0001 - ) + let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let labelSize = CGSize(width: 20, height: 10) + let tickLabelSpacing: CGFloat = 4 + let unitLabelFlipPadding: CGFloat = 3 + guard let unitLabelFrame = rule.unitLabelFrame else { + return XCTFail("Expected vertical ruler to install a unit label") + } + let mouseTickAtBottomUnitFit = unitLabelFrame.maxY + + unitLabelFlipPadding + + tickLabelSpacing + + labelSize.height + let mouseTickJustPastBottomUnitFit = mouseTickAtBottomUnitFit - 1 + + let fittingLabelRect = rule.mouseNumberLabelRect( + tickY: mouseTickAtBottomUnitFit, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + let flippedLabelRect = rule.mouseNumberLabelRect( + tickY: mouseTickJustPastBottomUnitFit, + labelSize: labelSize, + rulerSize: rule.bounds.size + ) + + XCTAssertLessThan(fittingLabelRect.maxY, mouseTickAtBottomUnitFit) + XCTAssertEqual( + fittingLabelRect.minY, + unitLabelFrame.maxY + unitLabelFlipPadding, + accuracy: 0.0001 + ) + XCTAssertGreaterThan(flippedLabelRect.minY, mouseTickJustPastBottomUnitFit) + XCTAssertEqual( + flippedLabelRect.minY - mouseTickJustPastBottomUnitFit, + tickLabelSpacing, + accuracy: 0.0001 + ) + } + } + + func testVerticalMouseTickLabelStaysWithinRulerEnds() { + withRestoredZeroCornerPreference { + prefs.zeroCorner = .topLeft + + let rule = VerticalRule(frame: NSRect(x: 0, y: 0, width: Ruler.thickness, height: 300)) + let labelSize = CGSize(width: 20, height: 10) + let rulerSize = rule.bounds.size + + let labelNearBottom = rule.mouseNumberLabelRect( + tickY: 1, + labelSize: labelSize, + rulerSize: rulerSize + ) + XCTAssertEqual( + labelNearBottom.minY, + 5, + accuracy: 0.0001 + ) + + let labelNearTop = rule.mouseNumberLabelRect( + tickY: 299, + labelSize: labelSize, + rulerSize: rulerSize + ) + XCTAssertEqual( + labelNearTop.minY, + 285, + accuracy: 0.0001 + ) + } } func testResizeHandleDisablesWindowBackgroundDraggingDuringResizeDrag() { @@ -1598,6 +2024,14 @@ private final class TestableFlipAppDelegate: AppDelegate { } } +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(), 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"