diff --git a/Free Ruler/AppDelegate.swift b/Free Ruler/AppDelegate.swift index adf23bf..f7b8954 100644 --- a/Free Ruler/AppDelegate.swift +++ b/Free Ruler/AppDelegate.swift @@ -9,6 +9,23 @@ import Sparkle let env = ProcessInfo.processInfo.environment let UI_TESTS = env["FREE_RULER_UI_TESTS"] != nil +let UI_TEST_CURSOR_STATE_NAME: String? = { + guard UI_TESTS, + let name = env["FREE_RULER_UI_TEST_CURSOR_STATE_NAME"] else { return nil } + + let lastPathComponent = URL(fileURLWithPath: name).lastPathComponent + guard !lastPathComponent.isEmpty, lastPathComponent == name else { return nil } + + return name +}() + +func writeUITestCursorState(_ value: String) { + guard let name = UI_TEST_CURSOR_STATE_NAME else { return } + + let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(name) + try? value.write(to: url, atomically: true, encoding: .utf8) +} private enum HotkeyBezelLocalizationKey: String { case rulersFloated = "HotkeyBezel.RulersFloated" @@ -99,6 +116,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if UI_TESTS { resetStateForUITests() + writeUITestCursorState("none") } subscribeToPrefs() diff --git a/Free Ruler/RuleView.swift b/Free Ruler/RuleView.swift index 06b81bb..79ace76 100644 --- a/Free Ruler/RuleView.swift +++ b/Free Ruler/RuleView.swift @@ -34,6 +34,30 @@ class RuleView: NSView { addTrackingArea(trackingArea!) } + override func mouseEntered(with event: NSEvent) { + nextResponder?.mouseEntered(with: event) + } + + override func mouseExited(with event: NSEvent) { + nextResponder?.mouseExited(with: event) + } + + override func mouseDown(with event: NSEvent) { + nextResponder?.mouseDown(with: event) + } + + override func mouseUp(with event: NSEvent) { + nextResponder?.mouseUp(with: event) + } + + override func mouseMoved(with event: NSEvent) { + nextResponder?.mouseMoved(with: event) + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + return true + } + func drawMouseTick(at mouseLoc: NSPoint) { // required override // TODO: is there a better way to do this, maybe via a protocol? diff --git a/Free Ruler/RulerCursorController.swift b/Free Ruler/RulerCursorController.swift index 9d621f3..6ff2b2e 100644 --- a/Free Ruler/RulerCursorController.swift +++ b/Free Ruler/RulerCursorController.swift @@ -7,6 +7,19 @@ final class RulerCursorController { case openHand case closedHand + var uiTestStateValue: String { + switch self { + case .arrow: + return "arrow" + case .crosshair: + return "crosshair" + case .openHand: + return "open-hand" + case .closedHand: + return "closed-hand" + } + } + var nsCursor: NSCursor { switch self { case .arrow: @@ -83,5 +96,12 @@ final class RulerCursorController { currentCursor = cursor applyCursor(cursor) + cursor.writeUITestStateIfNeeded() + } +} + +private extension RulerCursorController.CursorStyle { + func writeUITestStateIfNeeded() { + writeUITestCursorState(uiTestStateValue) } } diff --git a/Free Ruler/RulerWindow.swift b/Free Ruler/RulerWindow.swift index 383f85f..9a30405 100644 --- a/Free Ruler/RulerWindow.swift +++ b/Free Ruler/RulerWindow.swift @@ -57,6 +57,16 @@ class RulerWindow: NSPanel { set {} } + override func mouseDown(with event: NSEvent) { + nextResponder?.mouseDown(with: event) + super.mouseDown(with: event) + } + + override func mouseUp(with event: NSEvent) { + nextResponder?.mouseUp(with: event) + super.mouseUp(with: event) + } + } private func getTitle(for orientation: Orientation) -> String { diff --git a/FreeRulerTests/RulerCoreTests.swift b/FreeRulerTests/RulerCoreTests.swift index cb1c206..7aaa281 100644 --- a/FreeRulerTests/RulerCoreTests.swift +++ b/FreeRulerTests/RulerCoreTests.swift @@ -131,6 +131,59 @@ final class RulerCoreTests: XCTestCase { ]) } + func testRulerCursorControllerTracksMouseOverMouseDownAndMouseOutActions() { + var appliedCursors: [RulerCursorController.CursorStyle] = [] + let controller = RulerCursorController { appliedCursors.append($0) } + + func assertCursor( + after actionName: String, + _ action: () -> Void, + is expectedCursor: RulerCursorController.CursorStyle, + file: StaticString = #filePath, + line: UInt = #line + ) { + action() + XCTAssertEqual(controller.currentCursor, expectedCursor, actionName, file: file, line: line) + XCTAssertEqual(appliedCursors.last, expectedCursor, actionName, file: file, line: line) + } + + controller.applicationDidBecomeActive() + XCTAssertEqual(controller.currentCursor, .crosshair) + + assertCursor(after: "mouse over ruler", { + controller.mouseEnteredRuler() + }, is: .openHand) + + assertCursor(after: "mouse down inside ruler", { + controller.mouseDownInRuler() + }, is: .closedHand) + + let appliedCursorCountBeforeMouseOut = appliedCursors.count + controller.mouseExitedRuler() + XCTAssertEqual(controller.currentCursor, .closedHand, "mouse out while dragging") + XCTAssertEqual(appliedCursors.count, appliedCursorCountBeforeMouseOut, "mouse out while dragging") + + assertCursor(after: "mouse up outside ruler", { + controller.mouseUpInRuler(mouseIsInsideRuler: false) + }, is: .crosshair) + + assertCursor(after: "mouse over ruler again", { + controller.mouseEnteredRuler() + }, is: .openHand) + + assertCursor(after: "mouse down inside ruler again", { + controller.mouseDownInRuler() + }, is: .closedHand) + + assertCursor(after: "mouse up inside ruler", { + controller.mouseUpInRuler(mouseIsInsideRuler: true) + }, is: .openHand) + + assertCursor(after: "mouse out after release", { + controller.mouseExitedRuler() + }, is: .crosshair) + } + func testMouseTickTimerPolicyRunsOnlyWhenRulersAreVisible() { let policy = MouseTickTimerPolicy(foregroundInterval: 1 / 60, backgroundInterval: 1 / 30) diff --git a/FreeRulerUITests/FreeRulerUITests.swift b/FreeRulerUITests/FreeRulerUITests.swift index 88304a3..f2d8378 100644 --- a/FreeRulerUITests/FreeRulerUITests.swift +++ b/FreeRulerUITests/FreeRulerUITests.swift @@ -1,14 +1,28 @@ import XCTest +import Darwin final class FreeRulerUITests: XCTestCase { private var app: XCUIApplication! + private var cursorStateURL: URL! override func setUpWithError() throws { continueAfterFailure = false + let cursorStateName = "FreeRulerUITests-\(UUID().uuidString).cursor" + let homeDirectory = currentUserHomeDirectory() + cursorStateURL = URL(fileURLWithPath: homeDirectory) + .appendingPathComponent("Library/Containers/com.pascal.freeruler/Data/tmp", isDirectory: true) + .appendingPathComponent(cursorStateName) + try? FileManager.default.createDirectory( + at: cursorStateURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try? FileManager.default.removeItem(at: cursorStateURL) + app = XCUIApplication() app.launchEnvironment["FREE_RULER_UI_TESTS"] = "1" + app.launchEnvironment["FREE_RULER_UI_TEST_CURSOR_STATE_NAME"] = cursorStateName app.launch() app.activate() } @@ -16,6 +30,8 @@ final class FreeRulerUITests: XCTestCase { override func tearDownWithError() throws { app.terminate() app = nil + try? FileManager.default.removeItem(at: cursorStateURL) + cursorStateURL = nil } func testRulerVisibilityKeyboardCommands() { @@ -210,6 +226,46 @@ final class FreeRulerUITests: XCTestCase { XCTAssertTrue(verticalRuler.waitForFrameChange(from: originalVerticalFrame, timeout: 2)) } + func testHorizontalRulerCursorForMouseoverMousedownAndMouseoutActions() { + XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) + XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) + + isolateHorizontalRulerByUngroupingWithVerticalToggle() + + assertCursorSequence(on: horizontalRuler, label: "ungrouped horizontal ruler") + } + + func testVerticalRulerCursorForMouseoverMousedownAndMouseoutActions() { + XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) + XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) + + isolateHorizontalRulerByUngroupingWithVerticalToggle() + + app.typeKey("h", modifierFlags: []) + XCTAssertTrue(horizontalRuler.waitForNonExistence(timeout: 2)) + app.typeKey("v", modifierFlags: []) + XCTAssertTrue(verticalRuler.waitForExistence(timeout: 2)) + + assertCursorSequence(on: verticalRuler, label: "ungrouped vertical ruler") + } + + func testGroupedRulerCursorsForKeyAndChildWindows() { + XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 3)) + XCTAssertTrue(verticalRuler.waitForExistence(timeout: 3)) + + setGroupRulers(true) + XCTAssertTrue(groupRulersEnabledInPreferences()) + closePreferences() + + horizontalRuler.click() + assertCursorSequence(on: horizontalRulerView, label: "grouped key horizontal ruler") + assertCursorSequence(on: verticalRulerView, label: "grouped child vertical ruler") + + verticalRuler.click() + assertCursorSequence(on: verticalRulerView, label: "grouped key vertical ruler") + assertCursorSequence(on: horizontalRulerView, label: "grouped child horizontal ruler") + } + private var horizontalRuler: XCUIElement { app.dialogs["horizontal-ruler-window"] } @@ -222,6 +278,10 @@ final class FreeRulerUITests: XCTestCase { app.otherElements["horizontal-ruler-view"] } + private var verticalRulerView: XCUIElement { + app.otherElements["vertical-ruler-view"] + } + private var preferencesWindow: XCUIElement { app.windows["Free Ruler Preferences"] } @@ -284,6 +344,36 @@ final class FreeRulerUITests: XCTestCase { return rulerShadowCheckbox.isChecked } + private func isolateHorizontalRulerByUngroupingWithVerticalToggle() { + horizontalRuler.click() + app.typeKey("v", modifierFlags: []) + + XCTAssertTrue(horizontalRuler.waitForExistence(timeout: 2)) + XCTAssertTrue(verticalRuler.waitForNonExistence(timeout: 2)) + XCTAssertFalse(groupRulersEnabledInPreferences()) + closePreferences() + } + + private func assertCursorSequence(on ruler: XCUIElement, label: String) { + hover(over: ruler) + assertCursor("open-hand", after: "mouseover \(label)") + + pressAndRelease(in: ruler, assertingCursorDuringPress: "closed-hand") + assertCursor("open-hand", after: "mousedown and mouseup inside \(label)") + + hover(over: pointOutside(ruler)) + assertCursor("crosshair", after: "mouseout \(label)") + + hover(over: ruler) + assertCursor("open-hand", after: "mouseover \(label) again") + + pressAndRelease(in: ruler, assertingCursorDuringPress: "closed-hand") + assertCursor("open-hand", after: "mousedown and mouseup inside \(label) again") + + hover(over: pointOutside(ruler)) + assertCursor("crosshair", after: "mouseout \(label) again") + } + private func waitForHotkeyBezel(_ expectedLabel: String, timeout: TimeInterval = 2) -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -302,6 +392,100 @@ final class FreeRulerUITests: XCTestCase { return (hotkeyBezel.exists && hotkeyBezel.value as? String == expectedLabel) || (hotkeyBezelLabel.exists && hotkeyBezelLabel.label == expectedLabel) } + + private func hover(over element: XCUIElement) { + hover(over: interactionPoint(in: element)) + } + + private func hover(over coordinate: XCUICoordinate) { + coordinate.hover() + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + + private func pressAndRelease(in element: XCUIElement, assertingCursorDuringPress expectedCursor: String) { + let expectation = expectationForCursor(expectedCursor, after: "mousedown inside \(element.identifier)") + let coordinate = interactionPoint(in: element) + coordinate.press(forDuration: 0.4) + wait(for: [expectation], timeout: 1) + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + + private func pointOutside(_ element: XCUIElement) -> XCUICoordinate { + let yOffset = isVerticalRulerElement(element) ? 0.75 : 1.5 + return element.coordinate(withNormalizedOffset: CGVector(dx: 1.5, dy: yOffset)) + } + + private func interactionPoint(in element: XCUIElement) -> XCUICoordinate { + let yOffset = isVerticalRulerElement(element) ? 0.75 : 0.5 + return element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: yOffset)) + } + + private func isVerticalRulerElement(_ element: XCUIElement) -> Bool { + return element.identifier.contains("vertical-ruler") + } + + private func expectationForCursor(_ expectedCursor: String, after action: String) -> XCTestExpectation { + let expectation = expectation(description: "Expected cursor \(expectedCursor) after \(action)") + + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.15) { [cursorStateURL] in + guard let cursorStateURL = cursorStateURL else { return } + + let deadline = Date().addingTimeInterval(1.5) + + while Date() < deadline { + let cursor = try? String(contentsOf: cursorStateURL, encoding: .utf8) + + if cursor == expectedCursor { + expectation.fulfill() + return + } + + Thread.sleep(forTimeInterval: 0.025) + } + } + + return expectation + } + + private func assertCursor( + _ expectedCursor: String, + after action: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue( + waitForCursor(expectedCursor, timeout: 2), + "Expected cursor \(expectedCursor) after \(action); actual cursor was \(readCursorState() ?? "nil")", + file: file, + line: line + ) + } + + private func waitForCursor(_ expectedCursor: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + if readCursorState() == expectedCursor { + return true + } + + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + return readCursorState() == expectedCursor + } + + private func readCursorState() -> String? { + return try? String(contentsOf: cursorStateURL, encoding: .utf8) + } + + private func currentUserHomeDirectory() -> String { + guard let passwd = getpwuid(getuid()) else { + return NSHomeDirectory() + } + + return String(cString: passwd.pointee.pw_dir) + } } private extension XCUIElement {