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..c81cef97 --- /dev/null +++ b/Maccy/OpenShortcut.swift @@ -0,0 +1,228 @@ +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 + /// In this 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 { + + var mode: OpenShortcutMode = .normal + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var callbackContextPtr: UnsafeMutableRawPointer? + + init?(_ shortcut: KeyboardShortcuts.Shortcut) { + + 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) + + let context = OpenShortcutCallbackContext( + keyCode: keyCode, + modifiers: modifiers + ) + + self.callbackContextPtr = UnsafeMutableRawPointer( + Unmanaged.passRetained(context).toOpaque() + ) + + 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 + + let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + self.runLoopSource = runLoopSource + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + + CGEvent.tapEnable(tap: eventTap, enable: true) + } + + deinit { + if let eventTap = eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + CFMachPortInvalidate(eventTap) + } + eventTap = nil + + if let runLoopSource = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + } + runLoopSource = nil + + 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: + return Unmanaged.passRetained(event) + } +} + +// MARK: - Flag Parsing & Helpers + +private func parseFlags(_ flags: CGEventFlags) -> UInt64 { + return UInt64(flags.rawValue) & UInt64(NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue) +} + +private func isKeyCode(_ event: CGEvent, matching keyCode: Int) -> Bool { + return event.getIntegerValueField(.keyboardEventKeycode) == keyCode +} + +private func isModifiers(_ eventFlags: UInt64, matching modifiers: UInt64) -> Bool { + return (eventFlags & modifiers) == modifiers +} + +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..e13ceacd --- /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 + C). + let cDown = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true)! + cDown.flags = [.maskCommand, .maskShift] + cDown.post(tap: .cghidEventTap) + + assertPopupAppeared() + + // Press C 1 more time while keeping the modifier keys pressed + 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 + C). + let cDown = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true)! + cDown.flags = [.maskCommand, .maskShift] + cDown.post(tap: .cghidEventTap) + + assertPopupAppeared() + + // Press C 2 more times while keeping the modifier keys pressed + 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() + } +}