From d176e6574337e6d8581f6a63b0d9fdd46a97da56 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 13 Jun 2026 00:45:35 -0400 Subject: [PATCH] Introduce zero corner geometry helpers --- Free Ruler/ResizeHandleView.swift | 98 +++++++++++--- Free Ruler/Ruler.swift | 194 +++++++++++++++++++++++----- Free Ruler/RulerController.swift | 25 +--- FreeRulerTests/RulerCoreTests.swift | 165 +++++++++++++++++++++++ 4 files changed, 411 insertions(+), 71 deletions(-) diff --git a/Free Ruler/ResizeHandleView.swift b/Free Ruler/ResizeHandleView.swift index 45c8c9b..c456f7a 100644 --- a/Free Ruler/ResizeHandleView.swift +++ b/Free Ruler/ResizeHandleView.swift @@ -118,6 +118,7 @@ final class ResizeHandleView: NSView { ) let nextFrame = resizedRulerFrame( orientation: orientation, + zeroCorner: prefs.zeroCorner, initialFrame: dragInitialWindowFrame, delta: delta, minSize: window.minSize, @@ -153,14 +154,29 @@ final class ResizeHandleView: NSView { } func frame(in bounds: NSRect) -> NSRect { + let resizeSide = ZeroCornerGeometry(zeroCorner: prefs.zeroCorner).resizeSide(for: orientation) + switch orientation { case .horizontal: let topY = bounds.maxY - horizontalYOffset let bottomY = topY - length - let firstX = bounds.maxX - - horizontalXOffset - - CGFloat(lineCount - 1) * lineSpacing - - 1 + let firstX: CGFloat + + switch resizeSide { + case .left: + firstX = bounds.minX + horizontalXOffset + 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 + } return NSRect( x: firstX - backgroundPadding, @@ -171,7 +187,20 @@ final class ResizeHandleView: NSView { case .vertical: let rightX = bounds.minX + verticalXOffset + length let leftX = rightX - length - let firstY = bounds.minY + verticalYOffset + 1 + let firstY: CGFloat + + switch resizeSide { + 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, @@ -267,28 +296,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/Ruler.swift b/Free Ruler/Ruler.swift index 7b66982..544e484 100644 --- a/Free Ruler/Ruler.swift +++ b/Free Ruler/Ruler.swift @@ -12,6 +12,157 @@ enum Orientation: String { case bottomRight = 3 } +enum RulerGrowthDirection: Equatable { + case positive + case negative +} + +enum RulerSide: Equatable { + case top + case right + case bottom + case left +} + +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 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 +197,19 @@ 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 + .map { NSRect(origin: .zero, size: $0.frame.size) } + ?? 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..e5eaf39 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) } diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index 3ec0ce1..fd878ad 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -56,6 +56,141 @@ 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 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)) @@ -393,6 +528,36 @@ 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 testResizeHandleCursorsUseCustomCenteredImages() { let horizontalCursor = windowResizeCursor(for: .horizontal) let verticalCursor = windowResizeCursor(for: .vertical)