From 3747153068b0410d7a39de3ec36d1433e98b874a Mon Sep 17 00:00:00 2001 From: Alexis Gaziello Date: Wed, 8 Jan 2025 17:46:24 +0100 Subject: [PATCH] Adding cycle selection on repeated shortcut press feature - OpenShortcut.swift is where most of the logic is stored. We use CGEvent insteadq of NSEvent.addGlobalMonitorForEvents because the latter doesn't allow taking over the event, which can lead to weird behavior. Note: right now this leads to a mix of NSEvent and CGEvent in the code which is not ideal. - Added tests for new feature - Split class MaccyUITests into BaseTest to re-use code --- Maccy.xcodeproj/project.pbxproj | 12 + Maccy/Observables/AppState.swift | 4 +- Maccy/Observables/History.swift | 4 +- Maccy/Observables/Popup.swift | 10 +- Maccy/OpenShortcut.swift | 248 ++++++++++++++++++ Maccy/Settings/GeneralSettingsPane.swift | 12 +- .../Settings/en.lproj/GeneralSettings.strings | 2 +- MaccyUITests/BaseTest.swift | 157 +++++++++++ MaccyUITests/MaccyUITests.swift | 148 +---------- MaccyUITests/OpenShortcutUITests.swift | 100 +++++++ 10 files changed, 542 insertions(+), 155 deletions(-) create mode 100644 Maccy/OpenShortcut.swift create mode 100644 MaccyUITests/BaseTest.swift create mode 100644 MaccyUITests/OpenShortcutUITests.swift diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index 65184e10..a95a4eb4 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -125,6 +125,9 @@ DAFE2DDA268A521B00990986 /* String+Shortened.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE2DD9268A521A00990986 /* String+Shortened.swift */; }; DAFE2DE9268A9B1B00990986 /* HistoryItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE2DE8268A9B1B00990986 /* HistoryItemTests.swift */; }; DAFEF0B8249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFEF0B7249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift */; }; + FE5B372C2D26274E00A9BC20 /* OpenShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5B372B2D26274E00A9BC20 /* OpenShortcut.swift */; }; + FE5B37302D26BC6C00A9BC20 /* OpenShortcutUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5B372F2D26BC6C00A9BC20 /* OpenShortcutUITests.swift */; }; + FED48BDC2D2A1AF800FC57AF /* BaseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED48BDB2D2A1AF800FC57AF /* BaseTest.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -483,6 +486,9 @@ DAFE2DD9268A521A00990986 /* String+Shortened.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Shortened.swift"; sourceTree = ""; }; DAFE2DE8268A9B1B00990986 /* HistoryItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItemTests.swift; sourceTree = ""; }; DAFEF0B7249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcuts.Name+Shortcuts.swift"; sourceTree = ""; }; + FE5B372B2D26274E00A9BC20 /* OpenShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenShortcut.swift; sourceTree = ""; }; + FE5B372F2D26BC6C00A9BC20 /* OpenShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenShortcutUITests.swift; sourceTree = ""; }; + FED48BDB2D2A1AF800FC57AF /* BaseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTest.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -568,6 +574,8 @@ isa = PBXGroup; children = ( DA0EE7B8204657830025FC60 /* MaccyUITests.swift */, + FED48BDB2D2A1AF800FC57AF /* BaseTest.swift */, + FE5B372F2D26BC6C00A9BC20 /* OpenShortcutUITests.swift */, ); path = MaccyUITests; sourceTree = ""; @@ -740,6 +748,7 @@ DA20FA712B082DD600056DD5 /* Notifier.swift */, DA689FC72C1D15140009B887 /* PinsPosition.swift */, DA689FC52C1D14F10009B887 /* PopupPosition.swift */, + FE5B372B2D26274E00A9BC20 /* OpenShortcut.swift */, DAC14123232367B200FCFA30 /* Search.swift */, 2F1A79BF2C6DFB7800C98EBD /* SearchVisibility.swift */, DAA5ACC92C1BEE8A00B58513 /* SoftwareUpdater.swift */, @@ -952,6 +961,8 @@ buildActionMask = 2147483647; files = ( DA0EE7B9204657830025FC60 /* MaccyUITests.swift in Sources */, + FE5B37302D26BC6C00A9BC20 /* OpenShortcutUITests.swift in Sources */, + FED48BDC2D2A1AF800FC57AF /* BaseTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -993,6 +1004,7 @@ DA689FC82C1D15140009B887 /* PinsPosition.swift in Sources */, DA13D7D92C1A223E00FA9E23 /* Get.swift in Sources */, DA1969182C3F327500258481 /* SearchFieldView.swift in Sources */, + FE5B372C2D26274E00A9BC20 /* OpenShortcut.swift in Sources */, DAE8F5D42C43262B00851CA9 /* Popup.swift in Sources */, DA13D7DA2C1A223E00FA9E23 /* Clear.swift in Sources */, DA13D7DB2C1A223E00FA9E23 /* Delete.swift in Sources */, diff --git a/Maccy/Observables/AppState.swift b/Maccy/Observables/AppState.swift index 1b7c0c23..95f5a24f 100644 --- a/Maccy/Observables/AppState.swift +++ b/Maccy/Observables/AppState.swift @@ -66,9 +66,9 @@ class AppState: Sendable { } @MainActor - func select() { + func select(flags: NSEvent.ModifierFlags? = nil) { if let item = history.selectedItem, history.items.contains(item) { - history.select(item) + history.select(item, flags: flags) } else if let item = footer.selectedItem { if item.confirmation != nil { item.showConfirmation = true diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index 13aad48f..dee1fdfd 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -216,12 +216,12 @@ class History { // swiftlint:disable:this type_body_length } @MainActor - func select(_ item: HistoryItemDecorator?) { + func select(_ item: HistoryItemDecorator?, flags: NSEvent.ModifierFlags? = nil) { guard let item else { return } - let modifierFlags = NSApp.currentEvent?.modifierFlags + let modifierFlags = flags ?? NSApp.currentEvent?.modifierFlags .intersection(.deviceIndependentFlagsMask) .subtracting([.capsLock, .numericPad, .function]) ?? [] diff --git a/Maccy/Observables/Popup.swift b/Maccy/Observables/Popup.swift index 3452ef9d..018b553a 100644 --- a/Maccy/Observables/Popup.swift +++ b/Maccy/Observables/Popup.swift @@ -12,10 +12,11 @@ class Popup { var headerHeight: CGFloat = 0 var pinnedItemsHeight: CGFloat = 0 var footerHeight: CGFloat = 0 + var openShortcutManager: OpenShortcutManager? init() { - KeyboardShortcuts.onKeyUp(for: .popup) { - self.toggle() + if let shortcut = KeyboardShortcuts.getShortcut(for: .popup) { + openShortcutManager = OpenShortcutManager(shortcut) } } @@ -23,11 +24,16 @@ class Popup { AppState.shared.appDelegate?.panel.toggle(height: height, at: popupPosition) } + func isOpen() -> Bool { + return AppState.shared.appDelegate?.panel.isPresented ?? false + } + func open(height: CGFloat, at popupPosition: PopupPosition = Defaults[.popupPosition]) { AppState.shared.appDelegate?.panel.open(height: height, at: popupPosition) } func close() { + self.openShortcutManager?.mode = .normal // reset AppState.shared.appDelegate?.panel.close() } diff --git a/Maccy/OpenShortcut.swift b/Maccy/OpenShortcut.swift new file mode 100644 index 00000000..acb266b8 --- /dev/null +++ b/Maccy/OpenShortcut.swift @@ -0,0 +1,248 @@ +import AppKit +import KeyboardShortcuts + +// MARK: - Shortcut Popup Mode + +enum OpenShortcutMode { + /// Default - Shortcut will toggle the popup + case normal + /// Transition state when the shortcut is first pressed and we don't know whether we are in "normal" or "cycle" mode. + case opening + /// Cycle mode, every additional press of the main key will cycle to the next item in the paste history list. + /// Releasing the modifier keys will accept selection and close the popup + case cycle +} + +// MARK: - Shortcut manager + +/// Manages the popup action that cycles through clipboard history items. +final class OpenShortcutManager { + /// Tracks whether we are in `.normal`, `.opening`, or `.cycle` modes. + var mode: OpenShortcutMode = .normal + + /// Reference to the event tap. + private var eventTap: CFMachPort? + /// The event tap's run loop source. + private var runLoopSource: CFRunLoopSource? + /// Pointer to callback context data. + private var callbackContextPtr: UnsafeMutableRawPointer? + + init?(_ shortcut: KeyboardShortcuts.Shortcut) { + // Shortcut is defined by keycode & modifiers + let keyCode: Int = shortcut.carbonKeyCode + let modifiers: UInt64 = UInt64(shortcut.modifiers.rawValue) + + // Events we want to capture + let eventMask: CGEventMask = (1 << CGEventType.keyDown.rawValue) + | (1 << CGEventType.flagsChanged.rawValue) + + // Create a context object for passing data to the callback + let context = OpenShortcutCallbackContext( + keyCode: keyCode, + modifiers: modifiers + ) + + // Retain and convert to an opaque pointer + self.callbackContextPtr = UnsafeMutableRawPointer( + Unmanaged.passRetained(context).toOpaque() + ) + + // Create the event tap + guard let eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: openShortcutCallback, + userInfo: callbackContextPtr + ) else { + NSLog("Failed to create event tap.") + return nil + } + self.eventTap = eventTap + + // Create a run loop source for the tap and add it to the current run loop + let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + self.runLoopSource = runLoopSource + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + + // Enable the event tap + CGEvent.tapEnable(tap: eventTap, enable: true) + } + + deinit { + // Disable and invalidate the event tap if it exists + if let eventTap = eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + CFMachPortInvalidate(eventTap) + } + eventTap = nil + + // Remove the run loop source if it was added + if let runLoopSource = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + } + runLoopSource = nil + + // Release the retained context + if let contextPtr = callbackContextPtr { + Unmanaged.fromOpaque(contextPtr).release() + } + callbackContextPtr = nil + } +} + +// MARK: - Shortcut callback context + +/// Holds info we need inside the event callback function. +private class OpenShortcutCallbackContext { + let keyCode: Int + let modifiers: UInt64 + + init(keyCode: Int, modifiers: UInt64) { + self.keyCode = keyCode + self.modifiers = modifiers + } +} + +// MARK: - Shortcut callback functions + +private func handleKeyDown( + event: CGEvent, + context: OpenShortcutCallbackContext, + manager: OpenShortcutManager +) -> Unmanaged? { + + let popup = AppState.shared.popup + let eventFlags = parseFlags(event.flags) + + // Check if this is the designated shortcut (key + modifiers) or return + if !isKeyCode(event, matching: context.keyCode) || !isModifiers(eventFlags, matching: context.modifiers) { + return Unmanaged.passRetained(event) + } + + if !popup.isOpen() { + manager.mode = .opening + popup.open(height: popup.height) + return nil + } + + if manager.mode == .opening { + manager.mode = .cycle + // Next 'if' will highlight next item and then return nil + } + + if manager.mode == .cycle { + AppState.shared.highlightNext() + return nil + } + + if popup.isOpen() { + popup.close() + return nil + } + + return Unmanaged.passRetained(event) +} + +private func handleFlagsChanged( + event: CGEvent, + context: OpenShortcutCallbackContext, + manager: OpenShortcutManager +) -> Unmanaged? { + let eventFlags = parseFlags(event.flags) + + // If we are in cycle mode, releasing modifiers triggers a selection + if manager.mode == .cycle && !isModifiers(eventFlags, matching: context.modifiers) { + DispatchQueue.main.async { + AppState.shared.select(flags: NSEvent.ModifierFlags(event.flags)) + } + return nil + } + + // Otherwise if in opening mode, enter normal mode + if manager.mode == .opening { + manager.mode = .normal + return nil + } + + return Unmanaged.passRetained(event) +} + +/// The low-level callback for keyboard events. +private func openShortcutCallback( + proxy: CGEventTapProxy, + eventType: CGEventType, + event: CGEvent, + userInfo: UnsafeMutableRawPointer? +) -> Unmanaged? { + + guard let userInfo = userInfo else { + NSLog("Error: Missing userInfo in cycleSelectionCallback") + return Unmanaged.passRetained(event) + } + + let context = Unmanaged + .fromOpaque(userInfo) + .takeUnretainedValue() + + let popup = AppState.shared.popup + guard let manager = popup.openShortcutManager else { + NSLog("Error: Missing cycleSelection reference in cycleSelectionCallback") + return Unmanaged.passRetained(event) + } + + switch eventType { + case .keyDown: + return handleKeyDown( + event: event, + context: context, + manager: manager + ) + case .flagsChanged: + return handleFlagsChanged( + event: event, + context: context, + manager: manager + ) + default: + // Pass any unhandled events on + return Unmanaged.passRetained(event) + } +} + +// MARK: - Flag Parsing & Helpers + +/// Mask for device-independent modifier flags. +private let deviceIndependentFlagsMask: UInt64 = +UInt64(NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) + +/// Extracts device-independent modifier bits from `CGEventFlags`. +private func parseFlags(_ flags: CGEventFlags) -> UInt64 { + return UInt64(flags.rawValue) & deviceIndependentFlagsMask +} + +/// Returns `true` if the event's keycode matches the specified code. +private func isKeyCode(_ event: CGEvent, matching keyCode: Int) -> Bool { + return event.getIntegerValueField(.keyboardEventKeycode) == keyCode +} + +/// Returns `true` if `eventFlags` contain at least the given `modifiers`. +private func isModifiers(_ eventFlags: UInt64, matching modifiers: UInt64) -> Bool { + return (eventFlags & modifiers) == modifiers +} + +/// Converts `CGEventFlags` to `NSEvent.ModifierFlags`. +private extension NSEvent.ModifierFlags { + init(_ flags: CGEventFlags) { + self = [] + if flags.contains(.maskAlphaShift) { insert(.capsLock) } + if flags.contains(.maskShift) { insert(.shift) } + if flags.contains(.maskControl) { insert(.control) } + if flags.contains(.maskAlternate) { insert(.option) } + if flags.contains(.maskCommand) { insert(.command) } + if flags.contains(.maskNumericPad) { insert(.numericPad) } + if flags.contains(.maskHelp) { insert(.help) } + if flags.contains(.maskSecondaryFn) { insert(.function) } + } +} diff --git a/Maccy/Settings/GeneralSettingsPane.swift b/Maccy/Settings/GeneralSettingsPane.swift index 5275cc9d..b51a5ef2 100644 --- a/Maccy/Settings/GeneralSettingsPane.swift +++ b/Maccy/Settings/GeneralSettingsPane.swift @@ -33,9 +33,17 @@ struct GeneralSettingsPane: View { } Settings.Section(label: { Text("Open", tableName: "GeneralSettings") }) { - KeyboardShortcuts.Recorder(for: .popup) - .help(Text("OpenTooltip", tableName: "GeneralSettings")) + KeyboardShortcuts.Recorder(for: .popup) { newShortcut in + guard let shortcut = newShortcut else { + AppState.shared.popup.openShortcutManager = nil + return + } + + AppState.shared.popup.openShortcutManager = OpenShortcutManager(shortcut) + } + .help(Text("OpenTooltip", tableName: "GeneralSettings")) } + Settings.Section(label: { Text("Pin", tableName: "GeneralSettings") }) { KeyboardShortcuts.Recorder(for: .pin) .help(Text("PinTooltip", tableName: "GeneralSettings")) diff --git a/Maccy/Settings/en.lproj/GeneralSettings.strings b/Maccy/Settings/en.lproj/GeneralSettings.strings index 43fef02a..ce201968 100644 --- a/Maccy/Settings/en.lproj/GeneralSettings.strings +++ b/Maccy/Settings/en.lproj/GeneralSettings.strings @@ -3,7 +3,7 @@ "CheckForUpdates" = "Check for updates automatically"; "CheckNow" = "Check now"; "Open" = "Open:"; -"OpenTooltip" = "Global shortcut key to open application.\nDefault: ⇧⌘C."; +"OpenTooltip" = "Global shortcut key to open application.\nA repeated press of the main key while holding modifiers will select the next item in the list. In this mode, releasing modifier keys will confirm selection and close the popup.\nDefault: ⇧⌘C."; "Pin" = "Pin:"; "PinTooltip" = "Shortcut key to pin history item.\nDefault: ⌥P."; "Delete" = "Delete:"; diff --git a/MaccyUITests/BaseTest.swift b/MaccyUITests/BaseTest.swift new file mode 100644 index 00000000..3e82b60d --- /dev/null +++ b/MaccyUITests/BaseTest.swift @@ -0,0 +1,157 @@ +import Carbon +import XCTest + +class BaseTest: XCTestCase { + let app = XCUIApplication() + let pasteboard = NSPasteboard.general + + override func setUp() { + super.setUp() + app.launchArguments.append("enable-testing") + app.launch() + } + + override func tearDown() { + super.tearDown() + app.terminate() + } + + func popUpWithHotkey() { + simulatePopupHotkey() + assertPopupAppeared() + } + + func popUpWithMouse() { + app.statusItems.firstMatch.click() + assertPopupAppeared() + } + + func simulatePopupHotkey() { + let commandDown = CGEvent( + keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Command), keyDown: true)! + let commandUp = CGEvent( + keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Command), keyDown: false)! + let shiftDown = CGEvent( + keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Shift), keyDown: true)! + let shiftUp = CGEvent( + keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Shift), keyDown: false)! + shiftDown.flags = [.maskCommand] + shiftUp.flags = [.maskCommand] + let cDown = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true)! + let cUp = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: false)! + cDown.flags = [.maskCommand, .maskShift] + cUp.flags = [.maskCommand, .maskShift] + commandDown.post(tap: .cghidEventTap) + shiftDown.post(tap: .cghidEventTap) + cDown.post(tap: .cghidEventTap) + cUp.post(tap: .cghidEventTap) + shiftUp.post(tap: .cghidEventTap) + commandUp.post(tap: .cghidEventTap) + } + + func assertPopupAppeared() { + if !app.staticTexts.firstMatch.waitForExistence(timeout: 3) { + XCTFail("Maccy did not pop up") + } + } + + func assertPopupDismissed() { + if !app.staticTexts.firstMatch.waitForNonExistence(timeout: 3) { + XCTFail("Maccy did not dismiss") + } + } + + // Default interval for Maccy to check clipboard is 1 second + func waitTillClipboardCheck() { + usleep(1_500_000) + } + + func hover(_ element: XCUIElement) { + element.hover() + usleep(20000) + } + + func search(_ string: String) { + // NOTE: app.typeText is broken in Sonoma and causes some + // Chars to be submitted with a .command mask (e.g. 'p', 'k' or 'j') + string.forEach { + app.typeKey("\($0)", modifierFlags: []) + } + waitForSearch() + } + + func waitForSearch() { + // NOTE: This is a hack and is flaky. + // Ideally we should wait for a proper condition to detect that search has settled down. + usleep(500000) // wait for search throttle + } + + func assertExists(_ element: XCUIElement) { + expectation(for: NSPredicate(format: "exists = 1"), evaluatedWith: element) + waitForExpectations(timeout: 3) + } + + func assertNotExists(_ element: XCUIElement) { + expectation(for: NSPredicate(format: "exists = 0"), evaluatedWith: element) + waitForExpectations(timeout: 3) + } + + func assertNotVisible(_ element: XCUIElement) { + expectation( + for: NSPredicate(format: "(exists = 0) || (isHittable = 0)"), evaluatedWith: element) + waitForExpectations(timeout: 3) + } + + func assertPasteboardDataEquals( + _ expected: Data?, forType: NSPasteboard.PasteboardType = .string + ) { + let predicate = NSPredicate { (object, _) -> Bool in + guard let copy = object as? Data else { + return false + } + + return self.pasteboard.data(forType: forType) == copy + } + expectation(for: predicate, evaluatedWith: expected) + waitForExpectations(timeout: 3) + } + + func assertPasteboardDataCountEquals( + _ expected: Int, forType: NSPasteboard.PasteboardType = .string + ) { + let predicate = NSPredicate { (object, _) -> Bool in + guard let count = object as? Int else { + return false + } + + return self.pasteboard.data(forType: forType)!.count == count + } + expectation(for: predicate, evaluatedWith: expected) + waitForExpectations(timeout: 3) + } + + func assertPasteboardStringEquals( + _ expected: String?, forType: NSPasteboard.PasteboardType = .string + ) { + let predicate = NSPredicate { (object, _) -> Bool in + guard let copy = object as? String else { + return false + } + + return self.pasteboard.string(forType: forType) == copy + } + expectation(for: predicate, evaluatedWith: expected) + waitForExpectations(timeout: 3) + } + + func assertSearchFieldValue(_ string: String) { + XCTAssertEqual(app.textFields.firstMatch.value as? String, string) + } + + func confirmClear() { + let button = app.dialogs.firstMatch.buttons["Clear"].firstMatch + expectation(for: NSPredicate(format: "isHittable = 1"), evaluatedWith: button) + waitForExpectations(timeout: 3) + button.click() + } +} diff --git a/MaccyUITests/MaccyUITests.swift b/MaccyUITests/MaccyUITests.swift index 6f1fa241..c050b1ff 100644 --- a/MaccyUITests/MaccyUITests.swift +++ b/MaccyUITests/MaccyUITests.swift @@ -1,11 +1,8 @@ import Carbon import XCTest -// swiftlint:disable file_length // swiftlint:disable type_body_length -class MaccyUITests: XCTestCase { - let app = XCUIApplication() - let pasteboard = NSPasteboard.general +class MaccyUITests: BaseTest { let copy1 = UUID().uuidString let copy2 = UUID().uuidString @@ -46,18 +43,11 @@ class MaccyUITests: XCTestCase { override func setUp() { super.setUp() - app.launchArguments.append("enable-testing") - app.launch() copyToClipboard(copy2) copyToClipboard(copy1) } - override func tearDown() { - super.tearDown() - app.terminate() - } - func testPopupWithHotkey() throws { popUpWithHotkey() assertExists(items[copy1]) @@ -338,45 +328,6 @@ class MaccyUITests: XCTestCase { assertExists(items["foo bar"]) } - private func popUpWithHotkey() { - simulatePopupHotkey() - waitUntilPoppedUp() - } - - private func popUpWithMouse() { - app.statusItems.firstMatch.click() - waitUntilPoppedUp() - } - - private func simulatePopupHotkey() { - let commandDown = CGEvent( - keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Command), keyDown: true)! - let commandUp = CGEvent( - keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Command), keyDown: false)! - let shiftDown = CGEvent( - keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Shift), keyDown: true)! - let shiftUp = CGEvent( - keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Shift), keyDown: false)! - shiftDown.flags = [.maskCommand] - shiftUp.flags = [.maskCommand] - let cDown = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true)! - let cUp = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: false)! - cDown.flags = [.maskCommand, .maskShift] - cUp.flags = [.maskCommand, .maskShift] - commandDown.post(tap: .cghidEventTap) - shiftDown.post(tap: .cghidEventTap) - cDown.post(tap: .cghidEventTap) - cUp.post(tap: .cghidEventTap) - shiftUp.post(tap: .cghidEventTap) - commandUp.post(tap: .cghidEventTap) - } - - private func waitUntilPoppedUp() { - if !app.staticTexts.firstMatch.waitForExistence(timeout: 3) { - XCTFail("Maccy did not pop up") - } - } - private func copyToClipboard(_ content: String) { pasteboard.clearContents() pasteboard.setString(content, forType: .string) @@ -404,105 +355,10 @@ class MaccyUITests: XCTestCase { waitTillClipboardCheck() } - // Default interval for Maccy to check clipboard is 1 second - private func waitTillClipboardCheck() { - usleep(1_500_000) - } - - private func pin(_ title: String) { + func pin(_ title: String) { hover(items[title].firstMatch) app.typeKey("p", modifierFlags: [.option]) usleep(1_500_000) } - - private func hover(_ element: XCUIElement) { - element.hover() - usleep(20000) - } - - private func search(_ string: String) { - // NOTE: app.typeText is broken in Sonoma and causes some - // Chars to be submitted with a .command mask (e.g. 'p', 'k' or 'j') - string.forEach { - app.typeKey("\($0)", modifierFlags: []) - } - waitForSearch() - } - - private func waitForSearch() { - // NOTE: This is a hack and is flaky. - // Ideally we should wait for a proper condition to detect that search has settled down. - usleep(500000) // wait for search throttle - } - - private func assertExists(_ element: XCUIElement) { - expectation(for: NSPredicate(format: "exists = 1"), evaluatedWith: element) - waitForExpectations(timeout: 3) - } - - private func assertNotExists(_ element: XCUIElement) { - expectation(for: NSPredicate(format: "exists = 0"), evaluatedWith: element) - waitForExpectations(timeout: 3) - } - - private func assertNotVisible(_ element: XCUIElement) { - expectation( - for: NSPredicate(format: "(exists = 0) || (isHittable = 0)"), evaluatedWith: element) - waitForExpectations(timeout: 3) - } - - private func assertPasteboardDataEquals( - _ expected: Data?, forType: NSPasteboard.PasteboardType = .string - ) { - let predicate = NSPredicate { (object, _) -> Bool in - guard let copy = object as? Data else { - return false - } - - return self.pasteboard.data(forType: forType) == copy - } - expectation(for: predicate, evaluatedWith: expected) - waitForExpectations(timeout: 3) - } - - private func assertPasteboardDataCountEquals( - _ expected: Int, forType: NSPasteboard.PasteboardType = .string - ) { - let predicate = NSPredicate { (object, _) -> Bool in - guard let count = object as? Int else { - return false - } - - return self.pasteboard.data(forType: forType)!.count == count - } - expectation(for: predicate, evaluatedWith: expected) - waitForExpectations(timeout: 3) - } - - private func assertPasteboardStringEquals( - _ expected: String?, forType: NSPasteboard.PasteboardType = .string - ) { - let predicate = NSPredicate { (object, _) -> Bool in - guard let copy = object as? String else { - return false - } - - return self.pasteboard.string(forType: forType) == copy - } - expectation(for: predicate, evaluatedWith: expected) - waitForExpectations(timeout: 3) - } - - private func assertSearchFieldValue(_ string: String) { - XCTAssertEqual(app.textFields.firstMatch.value as? String, string) - } - - private func confirmClear() { - let button = app.dialogs.firstMatch.buttons["Clear"].firstMatch - expectation(for: NSPredicate(format: "isHittable = 1"), evaluatedWith: button) - waitForExpectations(timeout: 3) - button.click() - } } // swiftlint:enable type_body_length -// swiftlint:enable file_length diff --git a/MaccyUITests/OpenShortcutUITests.swift b/MaccyUITests/OpenShortcutUITests.swift new file mode 100644 index 00000000..9b26e139 --- /dev/null +++ b/MaccyUITests/OpenShortcutUITests.swift @@ -0,0 +1,100 @@ +import Carbon +import XCTest + +class OpenShortcutUITests: BaseTest { + + let copy1 = UUID().uuidString + let copy2 = UUID().uuidString + let copy3 = UUID().uuidString + + override func setUp() { + super.setUp() + + copyToClipboard(copy3) + copyToClipboard(copy2) + copyToClipboard(copy1) + } + + func testOpenAndClose() throws { + // Simulate the popup hotkey press (Cmd + Shift + C). + let cDown = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true)! + cDown.flags = [.maskCommand, .maskShift] + cDown.post(tap: .cghidEventTap) + + assertPopupAppeared() + + // Release the 'C' key but keep the popup open. + let cUp = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: false)! + cUp.flags = [.maskCommand, .maskShift] + cUp.post(tap: .cghidEventTap) + + assertPopupAppeared() + + // Release the 'Shift' key and assert that the popup remains open - "normal" mode. + let shiftUp = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Shift), keyDown: false)! + shiftUp.flags = [.maskCommand] // Command remains active, Shift released + shiftUp.post(tap: .cghidEventTap) + + assertPopupAppeared() + + // Release the 'CMD' key and assert that the popup remains open - "normal" mode. + let commandUp = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Command), keyDown: false)! + commandUp.flags = [] + commandUp.post(tap: .cghidEventTap) + + assertPopupAppeared() + + // Press shortcut again and assert the window closes + cDown.flags = [.maskCommand, .maskShift] + cDown.post(tap: .cghidEventTap) + + assertPopupDismissed() + } + + func testOpenAndSelectSecondItem() throws { + // Simulate the popup hotkey press (Cmd + Shift + V). + let cDown = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true)! + cDown.flags = [.maskCommand, .maskShift] + cDown.post(tap: .cghidEventTap) + + // Wait for the popup to appear. + assertPopupAppeared() + + cDown.post(tap: .cghidEventTap) + + // Release the 'Shift' key and assert that the popup closes. + let shiftUp = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Shift), keyDown: false)! + shiftUp.flags = [.maskCommand] // Command remains active, Shift released + shiftUp.post(tap: .cghidEventTap) + + assertPopupDismissed() + assertPasteboardStringEquals(copy2) + } + + func testOpenAndSelectThirdItem() throws { + // Simulate the popup hotkey press (Cmd + Shift + V). + let cDown = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true)! + cDown.flags = [.maskCommand, .maskShift] + cDown.post(tap: .cghidEventTap) + + // Wait for the popup to appear. + assertPopupAppeared() + + cDown.post(tap: .cghidEventTap) + cDown.post(tap: .cghidEventTap) + + // Release the 'Shift' key and assert that the popup closes. + let shiftUp = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Shift), keyDown: false)! + shiftUp.flags = [.maskCommand] // Command remains active, Shift released + shiftUp.post(tap: .cghidEventTap) + + assertPopupDismissed() + assertPasteboardStringEquals(copy3) + } + + private func copyToClipboard(_ content: String) { + pasteboard.clearContents() + pasteboard.setString(content, forType: .string) + waitTillClipboardCheck() + } +}