Keeps text prompts and photos in a floating always-on-top panel and
copies them back to the clipboard on click. Double-Shift captures the
selection from any app; sections, search, copy-as-list, merge, and
local JSON persistence mirror shadcn's Copper workflow.
💘 Generated with Crush
Assisted-by: Crush:kimi-k3
144 lines
5.2 KiB
Swift
144 lines
5.2 KiB
Swift
import Cocoa
|
|
import Carbon
|
|
|
|
// Global shortcuts:
|
|
// Left Shift x2 — capture the current selection into the active section
|
|
// Right Shift x2 — show/hide the panel
|
|
// Cmd+Shift+C — capture (fallback, no Accessibility needed)
|
|
// Cmd+Shift+Space — show/hide the panel (fallback)
|
|
//
|
|
// The double-Shift gestures come from a CGEvent tap on flagsChanged, which
|
|
// needs Accessibility access. The Carbon hotkeys work without it.
|
|
final class HotkeyManager {
|
|
static let shared = HotkeyManager()
|
|
|
|
var onCapture: (() -> Void)?
|
|
var onTogglePanel: (() -> Void)?
|
|
|
|
private var eventTap: CFMachPort?
|
|
private var runLoopSource: CFRunLoopSource?
|
|
private var eventHandlerRef: EventHandlerRef?
|
|
private var hotKeyRefs: [EventHotKeyRef?] = []
|
|
private let signature: OSType = 0x50524D50 // "PRMP"
|
|
|
|
private var lastLeftShiftRelease: TimeInterval = 0
|
|
private var lastRightShiftRelease: TimeInterval = 0
|
|
private let doubleTapInterval: TimeInterval = 0.35
|
|
|
|
private init() {}
|
|
|
|
var isListening: Bool { eventTap != nil }
|
|
|
|
func start() {
|
|
startEventTap()
|
|
registerFallbackHotkeys()
|
|
}
|
|
|
|
// MARK: - Double-Shift event tap
|
|
|
|
private func startEventTap() {
|
|
let mask = CGEventMask(1 << CGEventType.flagsChanged.rawValue)
|
|
guard let tap = CGEvent.tapCreate(
|
|
tap: .cgSessionEventTap,
|
|
place: .headInsertEventTap,
|
|
options: .defaultTap,
|
|
eventsOfInterest: mask,
|
|
callback: hotkeyEventCallback,
|
|
userInfo: Unmanaged.passUnretained(self).toOpaque()
|
|
) else { return }
|
|
eventTap = tap
|
|
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
|
runLoopSource = source
|
|
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
|
|
CGEvent.tapEnable(tap: tap, enable: true)
|
|
}
|
|
|
|
fileprivate func handle(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
|
|
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: true) }
|
|
return Unmanaged.passUnretained(event)
|
|
}
|
|
guard type == .flagsChanged else { return Unmanaged.passUnretained(event) }
|
|
|
|
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
|
|
let shiftIsDown = event.flags.contains(.maskShift)
|
|
guard !shiftIsDown else { return Unmanaged.passUnretained(event) } // only count releases
|
|
|
|
let now = ProcessInfo.processInfo.systemUptime
|
|
switch keyCode {
|
|
case 56: // left shift
|
|
if now - lastLeftShiftRelease < doubleTapInterval {
|
|
lastLeftShiftRelease = 0
|
|
fire { $0.onCapture?() }
|
|
} else {
|
|
lastLeftShiftRelease = now
|
|
}
|
|
case 60: // right shift
|
|
if now - lastRightShiftRelease < doubleTapInterval {
|
|
lastRightShiftRelease = 0
|
|
fire { $0.onTogglePanel?() }
|
|
} else {
|
|
lastRightShiftRelease = now
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
return Unmanaged.passUnretained(event)
|
|
}
|
|
|
|
private func fire(_ action: @escaping (HotkeyManager) -> Void) {
|
|
DispatchQueue.main.async { action(self) }
|
|
}
|
|
|
|
// MARK: - Carbon fallback hotkeys
|
|
|
|
private func registerFallbackHotkeys() {
|
|
var eventType = EventTypeSpec(
|
|
eventClass: OSType(kEventClassKeyboard),
|
|
eventKind: UInt32(kEventHotKeyPressed)
|
|
)
|
|
InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in
|
|
var hotKeyID = EventHotKeyID()
|
|
let status = GetEventParameter(
|
|
event,
|
|
EventParamName(kEventParamDirectObject),
|
|
EventParamType(typeEventHotKeyID),
|
|
nil,
|
|
MemoryLayout<EventHotKeyID>.size,
|
|
nil,
|
|
&hotKeyID
|
|
)
|
|
guard status == noErr else { return noErr }
|
|
DispatchQueue.main.async {
|
|
switch hotKeyID.id {
|
|
case 1: HotkeyManager.shared.onTogglePanel?()
|
|
case 2: HotkeyManager.shared.onCapture?()
|
|
default: break
|
|
}
|
|
}
|
|
return noErr
|
|
}, 1, &eventType, nil, &eventHandlerRef)
|
|
|
|
registerHotkey(id: 1, keyCode: UInt32(kVK_Space), modifiers: UInt32(cmdKey | shiftKey))
|
|
registerHotkey(id: 2, keyCode: UInt32(kVK_ANSI_C), modifiers: UInt32(cmdKey | shiftKey))
|
|
}
|
|
|
|
private func registerHotkey(id: UInt32, keyCode: UInt32, modifiers: UInt32) {
|
|
var ref: EventHotKeyRef?
|
|
let hotKeyID = EventHotKeyID(signature: signature, id: id)
|
|
RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref)
|
|
hotKeyRefs.append(ref)
|
|
}
|
|
}
|
|
|
|
private func hotkeyEventCallback(
|
|
proxy: CGEventTapProxy,
|
|
type: CGEventType,
|
|
event: CGEvent,
|
|
refcon: UnsafeMutableRawPointer?
|
|
) -> Unmanaged<CGEvent>? {
|
|
guard let refcon else { return Unmanaged.passUnretained(event) }
|
|
let manager = Unmanaged<HotkeyManager>.fromOpaque(refcon).takeUnretainedValue()
|
|
return manager.handle(proxy: proxy, type: type, event: event)
|
|
}
|