import AppKit import SwiftUI public enum PluginShortcutRecordingResult: Equatable { case accepted case rejected(String) public static func from(errorMessage: String?) -> PluginShortcutRecordingResult { if let errorMessage { return .rejected(errorMessage) } return .accepted } } @MainActor private final class PluginShortcutRecorderDisplayState: ObservableObject { @Published var previewText = PluginKitLocalization.shortcutRecorderPreviewPlaceholder @Published private(set) var showEscHint = false @Published private(set) var conflictMessage: String? @Published private(set) var shakeOffset: CGFloat = 0 @Published private(set) var isShaking = false func triggerShake(conflict: String? = nil) { withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { showEscHint = true if let conflict { conflictMessage = conflict } } isShaking = true let steps: [(CGFloat, Double)] = [ (10, 0.00), (-8, 0.06), (7, 0.12), (-5, 0.18), (3, 0.24), (0, 0.30) ] for (offset, delay) in steps { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in withAnimation(.linear(duration: 0.05)) { self?.shakeOffset = offset } } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.38) { [weak self] in self?.isShaking = false } } } public struct PluginShortcutRecorderField: View { public let displayText: String public let placeholder: String public let isRecording: Bool public let minWidth: CGFloat private var normalizedText: String { if displayText.isEmpty || displayText == "None" { return placeholder } return displayText } private var isPlaceholderVisible: Bool { normalizedText == placeholder } public init( displayText: String, placeholder: String = PluginKitLocalization.defaultShortcutPlaceholder, isRecording: Bool, minWidth: CGFloat = 90 ) { self.displayText = displayText self.placeholder = placeholder self.isRecording = isRecording self.minWidth = minWidth } public var body: some View { Text(normalizedText) .font(PluginSettingsTheme.Typography.monospacedValue) .foregroundStyle(isPlaceholderVisible ? AnyShapeStyle(.tertiary) : AnyShapeStyle(.primary)) .lineLimit(1) .minimumScaleFactor(0.8) .padding(.horizontal, PluginSettingsTheme.Spacing.sectionHeaderContent) .padding(.vertical, PluginSettingsTheme.Spacing.controlCluster - 3) .frame(minWidth: minWidth, alignment: .center) .background( RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.field, style: .continuous) .fill(PluginSettingsTheme.Palette.fieldBackground) ) .overlay( RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.field, style: .continuous) .strokeBorder( isRecording ? Color.accentColor : PluginSettingsTheme.Palette.cardBorder, lineWidth: isRecording ? 1.5 : PluginSettingsTheme.Stroke.standard ) ) } } public struct PluginShortcutRecorder: View { public let title: String public let displayText: String public let placeholder: String public let minWidth: CGFloat public let onRecord: (ShortcutBinding) -> PluginShortcutRecordingResult public let onBeginRecording: (() -> Void)? public let onEndRecording: (() -> Void)? @State private var isPresented = false public init( title: String, displayText: String, placeholder: String = PluginKitLocalization.defaultShortcutPlaceholder, minWidth: CGFloat = 90, onRecord: @escaping (ShortcutBinding) -> PluginShortcutRecordingResult, onBeginRecording: (() -> Void)? = nil, onEndRecording: (() -> Void)? = nil ) { self.title = title self.displayText = displayText self.placeholder = placeholder self.minWidth = minWidth self.onRecord = onRecord self.onBeginRecording = onBeginRecording self.onEndRecording = onEndRecording } public var body: some View { Button { isPresented = true } label: { PluginShortcutRecorderField( displayText: displayText, placeholder: placeholder, isRecording: isPresented, minWidth: minWidth ) } .buttonStyle(.plain) .help(PluginKitLocalization.shortcutRecorderHelp(title: title)) .accessibilityLabel(Text(title)) .background { GeometryReader { proxy in PluginShortcutRecorderPopoverAnchor( isPresented: $isPresented, onRecord: onRecord, onBeginRecording: onBeginRecording, onEndRecording: onEndRecording ) .frame(width: max(proxy.size.width, 1), height: max(proxy.size.height, 1)) .allowsHitTesting(false) } } } } private struct PluginShortcutRecorderPopoverView: View { @ObservedObject var displayState: PluginShortcutRecorderDisplayState var body: some View { VStack(spacing: 0) { Text(displayState.previewText) .font(PluginSettingsTheme.Typography.secondaryLabel) .foregroundStyle(Color.accentColor) .lineLimit(1) .padding(.horizontal, PluginSettingsTheme.Spacing.rowContentControl) .padding(.vertical, PluginSettingsTheme.Spacing.controlCluster) .frame(minWidth: 130, alignment: .center) .background( RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.field, style: .continuous) .fill( displayState.isShaking ? Color.red.opacity(0.18) : PluginSettingsTheme.Palette.recordingBackground ) ) .offset(x: displayState.shakeOffset) if displayState.conflictMessage != nil || displayState.showEscHint { Group { if let message = displayState.conflictMessage { Text(message) .foregroundStyle(.red) } else { Text(PluginKitLocalization.shortcutRecorderEscHint) .foregroundStyle(.secondary) } } .font(PluginSettingsTheme.Typography.statusBadge) .padding(.top, PluginSettingsTheme.Spacing.controlCluster) .transition(.asymmetric( insertion: .opacity.combined(with: .offset(y: -6)), removal: .opacity )) } } .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) .padding(.vertical, PluginSettingsTheme.Spacing.rowContentControl) .frame(minWidth: 160) } } private struct PluginShortcutRecorderPopoverAnchor: NSViewRepresentable { @Binding var isPresented: Bool let onRecord: (ShortcutBinding) -> PluginShortcutRecordingResult var onBeginRecording: (() -> Void)? var onEndRecording: (() -> Void)? func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSView { NSView(frame: .zero) } func updateNSView(_ nsView: NSView, context: Context) { context.coordinator.update( isPresented: isPresented, sourceView: nsView, onRecord: onRecord, onDismiss: { isPresented = false }, onBeginRecording: onBeginRecording, onEndRecording: onEndRecording ) } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.close() } @MainActor final class Coordinator: NSObject, NSPopoverDelegate { private var popover: NSPopover? private var displayState: PluginShortcutRecorderDisplayState? private var keyMonitor: Any? private var committed = false private var wantsPresentation = false private var presentationRetryScheduled = false private var onRecord: ((ShortcutBinding) -> PluginShortcutRecordingResult)? private var onDismiss: (() -> Void)? private var onBeginRecording: (() -> Void)? private var onEndRecording: (() -> Void)? func update( isPresented: Bool, sourceView: NSView, onRecord: @escaping (ShortcutBinding) -> PluginShortcutRecordingResult, onDismiss: @escaping () -> Void, onBeginRecording: (() -> Void)?, onEndRecording: (() -> Void)? ) { wantsPresentation = isPresented self.onRecord = onRecord self.onDismiss = onDismiss self.onBeginRecording = onBeginRecording self.onEndRecording = onEndRecording if isPresented { requestPresentation(from: sourceView) } else if popover != nil { close() } } func close() { wantsPresentation = false guard let popover else { cleanup() return } let dismiss = onDismiss let endRecording = onEndRecording let wasRecording = displayState != nil popover.delegate = nil self.popover = nil popover.close() cleanup() dismiss?() if wasRecording { endRecording?() } } func popoverShouldClose(_ popover: NSPopover) -> Bool { guard !committed else { return true } displayState?.triggerShake() return false } private func requestPresentation(from sourceView: NSView) { guard wantsPresentation, popover == nil else { return } guard sourceView.window != nil, sourceView.bounds.width > 0, sourceView.bounds.height > 0 else { schedulePresentationRetry(from: sourceView) return } present(from: sourceView) } private func schedulePresentationRetry(from sourceView: NSView) { guard !presentationRetryScheduled else { return } presentationRetryScheduled = true DispatchQueue.main.async { [weak self, weak sourceView] in guard let self else { return } self.presentationRetryScheduled = false guard let sourceView else { return } self.requestPresentation(from: sourceView) } } private func present(from sourceView: NSView) { committed = false let state = PluginShortcutRecorderDisplayState() displayState = state onBeginRecording?() let content = PluginShortcutRecorderPopoverView(displayState: state) let controller = NSHostingController(rootView: content) controller.view.layoutSubtreeIfNeeded() let popover = NSPopover() popover.contentViewController = controller popover.contentSize = controller.view.fittingSize popover.behavior = .transient popover.animates = true popover.delegate = self self.popover = popover keyMonitor = NSEvent.addLocalMonitorForEvents( matching: NSEvent.EventTypeMask.keyDown .union(.flagsChanged) .union(.leftMouseDown) .union(.rightMouseDown) .union(.otherMouseDown) .union(.leftMouseUp) .union(.rightMouseUp) .union(.otherMouseUp) .union(.scrollWheel) .union(.mouseEntered) .union(.mouseExited) .union(.mouseMoved) ) { [weak self] event in guard let self else { return event } return self.handleEvent(event) } let anchorBounds = sourceView.bounds popover.show(relativeTo: anchorBounds, of: sourceView, preferredEdge: .maxY) } private func handleEvent(_ event: NSEvent) -> NSEvent? { switch event.type { case .flagsChanged: handleFlagsChanged(event) return event case .keyDown: return handleKey(event) case .leftMouseDown, .rightMouseDown, .otherMouseDown, .leftMouseUp, .rightMouseUp, .otherMouseUp, .scrollWheel, .mouseEntered, .mouseExited, .mouseMoved: return nil default: return event } } private func handleFlagsChanged(_ event: NSEvent) { guard popover?.isShown == true else { return } let modifiers = ShortcutModifiers.from(event.modifierFlags) displayState?.previewText = modifiers.isEmpty ? PluginKitLocalization.shortcutRecorderPreviewPlaceholder : modifiers.symbolString.map { String($0) }.joined(separator: " + ") } private func handleKey(_ event: NSEvent) -> NSEvent? { guard popover?.isShown == true else { return event } let modifiers = ShortcutModifiers.from(event.modifierFlags) if event.keyCode == ShortcutKeyCode.escape, modifiers.isEmpty { close() return nil } let binding = ShortcutBinding(keyCode: event.keyCode, modifiers: modifiers) guard binding.isValid else { return nil } switch onRecord?(binding) ?? .accepted { case .accepted: committed = true close() case let .rejected(message): displayState?.triggerShake(conflict: message) } return nil } private func cleanup() { if let monitor = keyMonitor { NSEvent.removeMonitor(monitor) } keyMonitor = nil displayState = nil committed = false presentationRetryScheduled = false onRecord = nil onDismiss = nil onBeginRecording = nil onEndRecording = nil } } }