cmux/Sources/KeyboardShortcutSettings.swift
Lawrence Chen cf6ae2b72d Add KeyboardShortcutSettings to enable customizable shortcuts
The SettingsView was referencing KeyboardShortcutSettings but the file
was not committed, causing CI build failures.
2026-01-29 19:01:20 -08:00

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