193 lines
5.9 KiB
Swift
193 lines
5.9 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
/// A clickable field that records a keyboard shortcut when focused.
|
|
/// Click it, press a key combination, and it saves the binding.
|
|
struct HotkeyRecorderView: View {
|
|
let label: String
|
|
@Binding var binding: HotkeyBinding
|
|
|
|
@State private var isRecording = false
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(label)
|
|
.frame(width: 140, alignment: .leading)
|
|
|
|
HotkeyRecorderField(binding: $binding, isRecording: $isRecording)
|
|
.frame(width: 120, height: 24)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(isRecording ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(isRecording ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct OptionalHotkeyRecorderView: View {
|
|
let label: String
|
|
@Binding var binding: HotkeyBinding?
|
|
|
|
@State private var isRecording = false
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(label)
|
|
.frame(width: 140, alignment: .leading)
|
|
|
|
OptionalHotkeyRecorderField(binding: $binding, isRecording: $isRecording)
|
|
.frame(width: 120, height: 24)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(isRecording ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(isRecording ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1)
|
|
)
|
|
|
|
Button("Clear") {
|
|
binding = nil
|
|
}
|
|
.disabled(binding == nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// NSViewRepresentable that captures key events when focused.
|
|
struct HotkeyRecorderField: NSViewRepresentable {
|
|
@Binding var binding: HotkeyBinding
|
|
@Binding var isRecording: Bool
|
|
|
|
func makeNSView(context: Context) -> HotkeyNSView {
|
|
let view = HotkeyNSView()
|
|
view.onKeyRecorded = { newBinding in
|
|
binding = newBinding
|
|
isRecording = false
|
|
}
|
|
view.onFocusChanged = { focused in
|
|
isRecording = focused
|
|
}
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
|
|
nsView.update(currentLabel: binding.displayString, isRecording: isRecording)
|
|
}
|
|
}
|
|
|
|
struct OptionalHotkeyRecorderField: NSViewRepresentable {
|
|
@Binding var binding: HotkeyBinding?
|
|
@Binding var isRecording: Bool
|
|
|
|
func makeNSView(context: Context) -> HotkeyNSView {
|
|
let view = HotkeyNSView()
|
|
view.onKeyRecorded = { newBinding in
|
|
binding = newBinding
|
|
isRecording = false
|
|
}
|
|
view.onFocusChanged = { focused in
|
|
isRecording = focused
|
|
}
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
|
|
nsView.update(currentLabel: binding?.displayString ?? "Not set", isRecording: isRecording)
|
|
}
|
|
}
|
|
|
|
/// The actual NSView that handles key capture.
|
|
class HotkeyNSView: NSView {
|
|
var currentLabel: String = ""
|
|
var showRecording: Bool = false
|
|
var onKeyRecorded: ((HotkeyBinding) -> Void)?
|
|
var onFocusChanged: ((Bool) -> Void)?
|
|
|
|
private let label: NSTextField = {
|
|
let field = NSTextField(labelWithString: "")
|
|
field.alignment = .center
|
|
field.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
|
|
field.translatesAutoresizingMaskIntoConstraints = false
|
|
field.backgroundColor = .clear
|
|
field.isBezeled = false
|
|
field.lineBreakMode = .byTruncatingTail
|
|
return field
|
|
}()
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
setupLabel()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
setupLabel()
|
|
}
|
|
|
|
override var acceptsFirstResponder: Bool { true }
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
updateLabelAppearance()
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
window?.makeFirstResponder(self)
|
|
}
|
|
|
|
override func becomeFirstResponder() -> Bool {
|
|
onFocusChanged?(true)
|
|
return true
|
|
}
|
|
|
|
override func resignFirstResponder() -> Bool {
|
|
onFocusChanged?(false)
|
|
return true
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
guard showRecording else {
|
|
super.keyDown(with: event)
|
|
return
|
|
}
|
|
|
|
let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option]
|
|
let masked = event.modifierFlags.intersection(.deviceIndependentFlagsMask).intersection(relevantFlags)
|
|
|
|
// Require at least one modifier key
|
|
guard !masked.isEmpty else { return }
|
|
|
|
let binding = HotkeyBinding(modifiers: masked.rawValue, keyCode: event.keyCode)
|
|
onKeyRecorded?(binding)
|
|
|
|
// Resign first responder after recording
|
|
window?.makeFirstResponder(nil)
|
|
}
|
|
|
|
private func setupLabel() {
|
|
addSubview(label)
|
|
NSLayoutConstraint.activate([
|
|
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6),
|
|
label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6),
|
|
label.centerYAnchor.constraint(equalTo: centerYAnchor)
|
|
])
|
|
updateLabelAppearance()
|
|
}
|
|
|
|
func update(currentLabel: String, isRecording: Bool) {
|
|
self.currentLabel = currentLabel
|
|
showRecording = isRecording
|
|
updateLabelAppearance()
|
|
}
|
|
|
|
private func updateLabelAppearance() {
|
|
label.stringValue = showRecording ? "Press keys..." : currentLabel
|
|
label.textColor = showRecording ? .controlAccentColor : .labelColor
|
|
}
|
|
}
|