The SettingsView was referencing KeyboardShortcutSettings but the file was not committed, causing CI build failures.
227 lines
6.6 KiB
Swift
227 lines
6.6 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// Stores customizable keyboard shortcuts
|
|
enum KeyboardShortcutSettings {
|
|
static let showNotificationsKey = "shortcut.showNotifications"
|
|
static let jumpToUnreadKey = "shortcut.jumpToUnread"
|
|
|
|
/// Default shortcut: Cmd+Shift+I
|
|
static let showNotificationsDefault = StoredShortcut(key: "i", command: true, shift: true, option: false, control: false)
|
|
/// Default shortcut: Cmd+Shift+U
|
|
static let jumpToUnreadDefault = StoredShortcut(key: "u", command: true, shift: true, option: false, control: false)
|
|
|
|
static func showNotificationsShortcut() -> StoredShortcut {
|
|
guard let data = UserDefaults.standard.data(forKey: showNotificationsKey),
|
|
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
|
return showNotificationsDefault
|
|
}
|
|
return shortcut
|
|
}
|
|
|
|
static func setShowNotificationsShortcut(_ shortcut: StoredShortcut) {
|
|
if let data = try? JSONEncoder().encode(shortcut) {
|
|
UserDefaults.standard.set(data, forKey: showNotificationsKey)
|
|
}
|
|
}
|
|
|
|
static func jumpToUnreadShortcut() -> StoredShortcut {
|
|
guard let data = UserDefaults.standard.data(forKey: jumpToUnreadKey),
|
|
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
|
return jumpToUnreadDefault
|
|
}
|
|
return shortcut
|
|
}
|
|
|
|
static func setJumpToUnreadShortcut(_ shortcut: StoredShortcut) {
|
|
if let data = try? JSONEncoder().encode(shortcut) {
|
|
UserDefaults.standard.set(data, forKey: jumpToUnreadKey)
|
|
}
|
|
}
|
|
|
|
static func resetAll() {
|
|
UserDefaults.standard.removeObject(forKey: showNotificationsKey)
|
|
UserDefaults.standard.removeObject(forKey: jumpToUnreadKey)
|
|
}
|
|
}
|
|
|
|
/// A keyboard shortcut that can be stored in UserDefaults
|
|
struct StoredShortcut: Codable, Equatable {
|
|
var key: String
|
|
var command: Bool
|
|
var shift: Bool
|
|
var option: Bool
|
|
var control: Bool
|
|
|
|
var displayString: String {
|
|
var parts: [String] = []
|
|
if control { parts.append("⌃") }
|
|
if option { parts.append("⌥") }
|
|
if shift { parts.append("⇧") }
|
|
if command { parts.append("⌘") }
|
|
parts.append(key.uppercased())
|
|
return parts.joined()
|
|
}
|
|
|
|
var modifierFlags: NSEvent.ModifierFlags {
|
|
var flags: NSEvent.ModifierFlags = []
|
|
if command { flags.insert(.command) }
|
|
if shift { flags.insert(.shift) }
|
|
if option { flags.insert(.option) }
|
|
if control { flags.insert(.control) }
|
|
return flags
|
|
}
|
|
|
|
static func from(event: NSEvent) -> StoredShortcut? {
|
|
guard let chars = event.charactersIgnoringModifiers?.lowercased(),
|
|
let char = chars.first,
|
|
char.isLetter || char.isNumber else {
|
|
return nil
|
|
}
|
|
|
|
let flags = event.modifierFlags
|
|
return StoredShortcut(
|
|
key: String(char),
|
|
command: flags.contains(.command),
|
|
shift: flags.contains(.shift),
|
|
option: flags.contains(.option),
|
|
control: flags.contains(.control)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// View for recording a keyboard shortcut
|
|
struct KeyboardShortcutRecorder: View {
|
|
let label: String
|
|
@Binding var shortcut: StoredShortcut
|
|
@State private var isRecording = false
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(label)
|
|
|
|
Spacer()
|
|
|
|
ShortcutRecorderButton(shortcut: $shortcut, isRecording: $isRecording)
|
|
.frame(width: 120)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ShortcutRecorderButton: NSViewRepresentable {
|
|
@Binding var shortcut: StoredShortcut
|
|
@Binding var isRecording: Bool
|
|
|
|
func makeNSView(context: Context) -> ShortcutRecorderNSButton {
|
|
let button = ShortcutRecorderNSButton()
|
|
button.shortcut = shortcut
|
|
button.onShortcutRecorded = { newShortcut in
|
|
shortcut = newShortcut
|
|
isRecording = false
|
|
}
|
|
button.onRecordingChanged = { recording in
|
|
isRecording = recording
|
|
}
|
|
return button
|
|
}
|
|
|
|
func updateNSView(_ nsView: ShortcutRecorderNSButton, context: Context) {
|
|
nsView.shortcut = shortcut
|
|
nsView.updateTitle()
|
|
}
|
|
}
|
|
|
|
private class ShortcutRecorderNSButton: NSButton {
|
|
var shortcut: StoredShortcut = KeyboardShortcutSettings.showNotificationsDefault
|
|
var onShortcutRecorded: ((StoredShortcut) -> Void)?
|
|
var onRecordingChanged: ((Bool) -> Void)?
|
|
private var isRecording = false
|
|
private var eventMonitor: Any?
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
setup()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
setup()
|
|
}
|
|
|
|
private func setup() {
|
|
bezelStyle = .rounded
|
|
setButtonType(.momentaryPushIn)
|
|
target = self
|
|
action = #selector(buttonClicked)
|
|
updateTitle()
|
|
}
|
|
|
|
func updateTitle() {
|
|
if isRecording {
|
|
title = "Press shortcut…"
|
|
} else {
|
|
title = shortcut.displayString
|
|
}
|
|
}
|
|
|
|
@objc private func buttonClicked() {
|
|
if isRecording {
|
|
stopRecording()
|
|
} else {
|
|
startRecording()
|
|
}
|
|
}
|
|
|
|
private func startRecording() {
|
|
isRecording = true
|
|
onRecordingChanged?(true)
|
|
updateTitle()
|
|
|
|
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
guard let self = self else { return event }
|
|
|
|
if event.keyCode == 53 { // Escape
|
|
self.stopRecording()
|
|
return nil
|
|
}
|
|
|
|
if let newShortcut = StoredShortcut.from(event: event) {
|
|
self.shortcut = newShortcut
|
|
self.onShortcutRecorded?(newShortcut)
|
|
self.stopRecording()
|
|
return nil
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
// Also stop recording if window loses focus
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(windowResigned),
|
|
name: NSWindow.didResignKeyNotification,
|
|
object: window
|
|
)
|
|
}
|
|
|
|
private func stopRecording() {
|
|
isRecording = false
|
|
onRecordingChanged?(false)
|
|
updateTitle()
|
|
|
|
if let monitor = eventMonitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
eventMonitor = nil
|
|
}
|
|
|
|
NotificationCenter.default.removeObserver(self, name: NSWindow.didResignKeyNotification, object: window)
|
|
}
|
|
|
|
@objc private func windowResigned() {
|
|
stopRecording()
|
|
}
|
|
|
|
deinit {
|
|
stopRecording()
|
|
}
|
|
}
|