Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

433 lines
15 KiB
Swift

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