Initial commit: a Copper-style panel for prompts and photos
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
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user