Skip to content

Commit

Permalink
Adding cycle selection on repeated shortcut press feature
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
Alexis Gaziello committed Jan 8, 2025
1 parent 2255498 commit 1894244
Show file tree
Hide file tree
Showing 10 changed files with 518 additions and 155 deletions.
12 changes: 12 additions & 0 deletions Maccy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -483,6 +486,9 @@
DAFE2DD9268A521A00990986 /* String+Shortened.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Shortened.swift"; sourceTree = "<group>"; };
DAFE2DE8268A9B1B00990986 /* HistoryItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItemTests.swift; sourceTree = "<group>"; };
DAFEF0B7249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcuts.Name+Shortcuts.swift"; sourceTree = "<group>"; };
FE5B372B2D26274E00A9BC20 /* OpenShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenShortcut.swift; sourceTree = "<group>"; };
FE5B372F2D26BC6C00A9BC20 /* OpenShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenShortcutUITests.swift; sourceTree = "<group>"; };
FED48BDB2D2A1AF800FC57AF /* BaseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTest.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -568,6 +574,8 @@
isa = PBXGroup;
children = (
DA0EE7B8204657830025FC60 /* MaccyUITests.swift */,
FED48BDB2D2A1AF800FC57AF /* BaseTest.swift */,
FE5B372F2D26BC6C00A9BC20 /* OpenShortcutUITests.swift */,
);
path = MaccyUITests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -952,6 +961,8 @@
buildActionMask = 2147483647;
files = (
DA0EE7B9204657830025FC60 /* MaccyUITests.swift in Sources */,
FE5B37302D26BC6C00A9BC20 /* OpenShortcutUITests.swift in Sources */,
FED48BDC2D2A1AF800FC57AF /* BaseTest.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -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 */,
Expand Down
4 changes: 2 additions & 2 deletions Maccy/Observables/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Maccy/Observables/History.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]) ?? []

Expand Down
10 changes: 8 additions & 2 deletions Maccy/Observables/Popup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,28 @@ 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)
}
}

func toggle(at popupPosition: PopupPosition = Defaults[.popupPosition]) {
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()
}

Expand Down
224 changes: 224 additions & 0 deletions Maccy/OpenShortcut.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
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<OpenShortcutCallbackContext>.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 function

/// The low-level callback for keyboard events.
private func openShortcutCallback(
proxy: CGEventTapProxy,
eventType: CGEventType,
event: CGEvent,
userInfo: UnsafeMutableRawPointer?
) -> Unmanaged<CGEvent>? {

guard let userInfo = userInfo else {
NSLog("Error: Missing userInfo in cycleSelectionCallback")
return Unmanaged.passRetained(event)
}

let context = Unmanaged<OpenShortcutCallbackContext>
.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)
}

let eventFlags = parseFlags(event.flags)

switch eventType {
case .keyDown:
// 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 is not open, open it
if !popup.isOpen() {
manager.mode = .opening
popup.open(height: popup.height)
return nil
}

// If the user presses again in opening mode, switch to cycle mode
if manager.mode == .opening {
manager.mode = .cycle
// Next 'if' will highlight next item and then return nil
}

// In cycle mode, just highlight the next item
if manager.mode == .cycle {
AppState.shared.highlightNext()
return nil
}

// Otherwise, if the popup is open and we are in normal mode, close it
if popup.isOpen() {
popup.close()
return nil
}

case .flagsChanged:
// 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
}

default:
break
}

// 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) }
}
}
12 changes: 10 additions & 2 deletions Maccy/Settings/GeneralSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
2 changes: 1 addition & 1 deletion Maccy/Settings/en.lproj/GeneralSettings.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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:";
Expand Down
Loading

0 comments on commit 1894244

Please sign in to comment.