Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding cycle selection on repeated shortcut press feature #1042

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Maccy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
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 */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -483,6 +484,7 @@
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>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -740,6 +742,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 @@ -993,6 +996,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
234 changes: 234 additions & 0 deletions Maccy/OpenShortcut.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
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<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 functions

private func handleKeyDown(
event: CGEvent,
context: OpenShortcutCallbackContext,
manager: OpenShortcutManager
) -> Unmanaged<CGEvent>? {

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
DispatchQueue.main.async {
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 {
DispatchQueue.main.async {
AppState.shared.highlightNext()
}
return nil
}

if popup.isOpen() {
DispatchQueue.main.async {
popup.close()
}
return nil
}

return Unmanaged.passRetained(event)
}

private func handleFlagsChanged(
event: CGEvent,
context: OpenShortcutCallbackContext,
manager: OpenShortcutManager
) -> Unmanaged<CGEvent>? {
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<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)
}

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) }
}
}
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