cmux/Sources/KeyboardShortcutSettings.swift
Lawrence Chen 4de975e6a4
Add workspace pages in the titlebar (#1030)
* Add workspace pages in the titlebar

* Add workspace pages UI test target entry

* Relax workspace pages UI test titlebar checks

* Use page close button in workspace pages UI test

* Stabilize workspace pages UI test interruptions

* Skip page close confirms in UI tests

* Clean up superseded workspace handoffs

* Tighten page hint UI assertions

---------

Co-authored-by: cmux <cmux@cmuxs-Mac-mini.local>
2026-03-06 21:23:11 -08:00

633 lines
29 KiB
Swift

import AppKit
import SwiftUI
/// Stores customizable keyboard shortcuts (definitions + persistence).
enum KeyboardShortcutSettings {
enum Action: String, CaseIterable, Identifiable {
// Titlebar / primary UI
case toggleSidebar
case newTab
case newWindow
case closeWindow
case openFolder
case sendFeedback
case showNotifications
case jumpToUnread
case triggerFlash
// Navigation
case nextSurface
case prevSurface
case nextSidebarTab
case prevSidebarTab
case renameTab
case renameWorkspace
case closeWorkspace
case newSurface
case toggleTerminalCopyMode
case newPage
case renamePage
case closePage
case nextPage
case previousPage
case selectPage1
case selectPage2
case selectPage3
case selectPage4
case selectPage5
case selectPage6
case selectPage7
case selectPage8
case selectLastPage
// Panes / splits
case focusLeft
case focusRight
case focusUp
case focusDown
case splitRight
case splitDown
case toggleSplitZoom
case splitBrowserRight
case splitBrowserDown
// Panels
case openBrowser
case toggleBrowserDeveloperTools
case showBrowserJavaScriptConsole
var id: String { rawValue }
var label: String {
switch self {
case .toggleSidebar: return String(localized: "shortcut.toggleSidebar.label", defaultValue: "Toggle Sidebar")
case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace")
case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window")
case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window")
case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder")
case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback")
case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications")
case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread")
case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel")
case .nextSurface: return String(localized: "shortcut.nextSurface.label", defaultValue: "Next Surface")
case .prevSurface: return String(localized: "shortcut.previousSurface.label", defaultValue: "Previous Surface")
case .nextSidebarTab: return String(localized: "shortcut.nextWorkspace.label", defaultValue: "Next Workspace")
case .prevSidebarTab: return String(localized: "shortcut.previousWorkspace.label", defaultValue: "Previous Workspace")
case .renameTab: return String(localized: "shortcut.renameTab.label", defaultValue: "Rename Tab")
case .renameWorkspace: return String(localized: "shortcut.renameWorkspace.label", defaultValue: "Rename Workspace")
case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace")
case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface")
case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode")
case .newPage: return String(localized: "shortcut.newPage.label", defaultValue: "New Page")
case .renamePage: return String(localized: "shortcut.renamePage.label", defaultValue: "Rename Page")
case .closePage: return String(localized: "shortcut.closePage.label", defaultValue: "Close Page")
case .nextPage: return String(localized: "shortcut.nextPage.label", defaultValue: "Next Page")
case .previousPage: return String(localized: "shortcut.previousPage.label", defaultValue: "Previous Page")
case .selectPage1: return String(localized: "shortcut.selectPage1.label", defaultValue: "Select Page 1")
case .selectPage2: return String(localized: "shortcut.selectPage2.label", defaultValue: "Select Page 2")
case .selectPage3: return String(localized: "shortcut.selectPage3.label", defaultValue: "Select Page 3")
case .selectPage4: return String(localized: "shortcut.selectPage4.label", defaultValue: "Select Page 4")
case .selectPage5: return String(localized: "shortcut.selectPage5.label", defaultValue: "Select Page 5")
case .selectPage6: return String(localized: "shortcut.selectPage6.label", defaultValue: "Select Page 6")
case .selectPage7: return String(localized: "shortcut.selectPage7.label", defaultValue: "Select Page 7")
case .selectPage8: return String(localized: "shortcut.selectPage8.label", defaultValue: "Select Page 8")
case .selectLastPage: return String(localized: "shortcut.selectLastPage.label", defaultValue: "Select Last Page")
case .focusLeft: return String(localized: "shortcut.focusPaneLeft.label", defaultValue: "Focus Pane Left")
case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right")
case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up")
case .focusDown: return String(localized: "shortcut.focusPaneDown.label", defaultValue: "Focus Pane Down")
case .splitRight: return String(localized: "shortcut.splitRight.label", defaultValue: "Split Right")
case .splitDown: return String(localized: "shortcut.splitDown.label", defaultValue: "Split Down")
case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom")
case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right")
case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down")
case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser")
case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools")
case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console")
}
}
var defaultsKey: String {
switch self {
case .toggleSidebar: return "shortcut.toggleSidebar"
case .newTab: return "shortcut.newTab"
case .newWindow: return "shortcut.newWindow"
case .closeWindow: return "shortcut.closeWindow"
case .openFolder: return "shortcut.openFolder"
case .sendFeedback: return "shortcut.sendFeedback"
case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash"
case .nextSidebarTab: return "shortcut.nextSidebarTab"
case .prevSidebarTab: return "shortcut.prevSidebarTab"
case .renameTab: return "shortcut.renameTab"
case .renameWorkspace: return "shortcut.renameWorkspace"
case .closeWorkspace: return "shortcut.closeWorkspace"
case .focusLeft: return "shortcut.focusLeft"
case .focusRight: return "shortcut.focusRight"
case .focusUp: return "shortcut.focusUp"
case .focusDown: return "shortcut.focusDown"
case .newPage: return "shortcut.newPage"
case .renamePage: return "shortcut.renamePage"
case .closePage: return "shortcut.closePage"
case .nextPage: return "shortcut.nextPage"
case .previousPage: return "shortcut.previousPage"
case .selectPage1: return "shortcut.selectPage1"
case .selectPage2: return "shortcut.selectPage2"
case .selectPage3: return "shortcut.selectPage3"
case .selectPage4: return "shortcut.selectPage4"
case .selectPage5: return "shortcut.selectPage5"
case .selectPage6: return "shortcut.selectPage6"
case .selectPage7: return "shortcut.selectPage7"
case .selectPage8: return "shortcut.selectPage8"
case .selectLastPage: return "shortcut.selectLastPage"
case .splitRight: return "shortcut.splitRight"
case .splitDown: return "shortcut.splitDown"
case .toggleSplitZoom: return "shortcut.toggleSplitZoom"
case .splitBrowserRight: return "shortcut.splitBrowserRight"
case .splitBrowserDown: return "shortcut.splitBrowserDown"
case .nextSurface: return "shortcut.nextSurface"
case .prevSurface: return "shortcut.prevSurface"
case .newSurface: return "shortcut.newSurface"
case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode"
case .openBrowser: return "shortcut.openBrowser"
case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools"
case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole"
}
}
var defaultShortcut: StoredShortcut {
switch self {
case .toggleSidebar:
return StoredShortcut(key: "b", command: true, shift: false, option: false, control: false)
case .newTab:
return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false)
case .newWindow:
return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false)
case .closeWindow:
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
case .openFolder:
return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false)
case .sendFeedback:
return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false)
case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:
return StoredShortcut(key: "u", command: true, shift: true, option: false, control: false)
case .triggerFlash:
return StoredShortcut(key: "h", command: true, shift: true, option: false, control: false)
case .nextSidebarTab:
return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true)
case .prevSidebarTab:
return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true)
case .renameTab:
return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false)
case .renameWorkspace:
return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false)
case .closeWorkspace:
return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false)
case .newPage:
return StoredShortcut(key: "n", command: true, shift: false, option: true, control: false)
case .renamePage:
return StoredShortcut(key: "r", command: true, shift: false, option: true, control: false)
case .closePage:
return StoredShortcut(key: "w", command: true, shift: false, option: true, control: false)
case .nextPage:
return StoredShortcut(key: "]", command: false, shift: false, option: true, control: false)
case .previousPage:
return StoredShortcut(key: "[", command: false, shift: false, option: true, control: false)
case .selectPage1:
return StoredShortcut(key: "1", command: false, shift: false, option: true, control: false)
case .selectPage2:
return StoredShortcut(key: "2", command: false, shift: false, option: true, control: false)
case .selectPage3:
return StoredShortcut(key: "3", command: false, shift: false, option: true, control: false)
case .selectPage4:
return StoredShortcut(key: "4", command: false, shift: false, option: true, control: false)
case .selectPage5:
return StoredShortcut(key: "5", command: false, shift: false, option: true, control: false)
case .selectPage6:
return StoredShortcut(key: "6", command: false, shift: false, option: true, control: false)
case .selectPage7:
return StoredShortcut(key: "7", command: false, shift: false, option: true, control: false)
case .selectPage8:
return StoredShortcut(key: "8", command: false, shift: false, option: true, control: false)
case .selectLastPage:
return StoredShortcut(key: "9", command: false, shift: false, option: true, control: false)
case .focusLeft:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .focusRight:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .focusUp:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .focusDown:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .splitRight:
return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false)
case .splitDown:
return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false)
case .toggleSplitZoom:
return StoredShortcut(key: "\r", command: true, shift: true, option: false, control: false)
case .splitBrowserRight:
return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false)
case .splitBrowserDown:
return StoredShortcut(key: "d", command: true, shift: true, option: true, control: false)
case .nextSurface:
return StoredShortcut(key: "]", command: true, shift: true, option: false, control: false)
case .prevSurface:
return StoredShortcut(key: "[", command: true, shift: true, option: false, control: false)
case .newSurface:
return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
case .toggleTerminalCopyMode:
return StoredShortcut(key: "m", command: true, shift: true, option: false, control: false)
case .openBrowser:
return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false)
case .toggleBrowserDeveloperTools:
// Safari default: Show Web Inspector.
return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false)
case .showBrowserJavaScriptConsole:
// Safari default: Show JavaScript Console.
return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false)
}
}
func tooltip(_ base: String) -> String {
"\(base) (\(KeyboardShortcutSettings.shortcut(for: self).displayString))"
}
}
static func shortcut(for action: Action) -> StoredShortcut {
guard let data = UserDefaults.standard.data(forKey: action.defaultsKey),
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return action.defaultShortcut
}
return shortcut
}
static func setShortcut(_ shortcut: StoredShortcut, for action: Action) {
if let data = try? JSONEncoder().encode(shortcut) {
UserDefaults.standard.set(data, forKey: action.defaultsKey)
}
}
static func resetShortcut(for action: Action) {
UserDefaults.standard.removeObject(forKey: action.defaultsKey)
}
static func resetAll() {
for action in Action.allCases {
resetShortcut(for: action)
}
}
// MARK: - Backwards-Compatible API (call-sites can migrate gradually)
// Keys (used by debug socket command + UI tests)
static let focusLeftKey = Action.focusLeft.defaultsKey
static let focusRightKey = Action.focusRight.defaultsKey
static let focusUpKey = Action.focusUp.defaultsKey
static let focusDownKey = Action.focusDown.defaultsKey
// Defaults (used by settings reset + recorder button initial title)
static let showNotificationsDefault = Action.showNotifications.defaultShortcut
static let jumpToUnreadDefault = Action.jumpToUnread.defaultShortcut
static func showNotificationsShortcut() -> StoredShortcut { shortcut(for: .showNotifications) }
static func setShowNotificationsShortcut(_ shortcut: StoredShortcut) { setShortcut(shortcut, for: .showNotifications) }
static func jumpToUnreadShortcut() -> StoredShortcut { shortcut(for: .jumpToUnread) }
static func setJumpToUnreadShortcut(_ shortcut: StoredShortcut) { setShortcut(shortcut, for: .jumpToUnread) }
static func nextSidebarTabShortcut() -> StoredShortcut { shortcut(for: .nextSidebarTab) }
static func prevSidebarTabShortcut() -> StoredShortcut { shortcut(for: .prevSidebarTab) }
static func renameWorkspaceShortcut() -> StoredShortcut { shortcut(for: .renameWorkspace) }
static func closeWorkspaceShortcut() -> StoredShortcut { shortcut(for: .closeWorkspace) }
static func newPageShortcut() -> StoredShortcut { shortcut(for: .newPage) }
static func renamePageShortcut() -> StoredShortcut { shortcut(for: .renamePage) }
static func closePageShortcut() -> StoredShortcut { shortcut(for: .closePage) }
static func nextPageShortcut() -> StoredShortcut { shortcut(for: .nextPage) }
static func previousPageShortcut() -> StoredShortcut { shortcut(for: .previousPage) }
static func selectPage1Shortcut() -> StoredShortcut { shortcut(for: .selectPage1) }
static func selectPage2Shortcut() -> StoredShortcut { shortcut(for: .selectPage2) }
static func selectPage3Shortcut() -> StoredShortcut { shortcut(for: .selectPage3) }
static func selectPage4Shortcut() -> StoredShortcut { shortcut(for: .selectPage4) }
static func selectPage5Shortcut() -> StoredShortcut { shortcut(for: .selectPage5) }
static func selectPage6Shortcut() -> StoredShortcut { shortcut(for: .selectPage6) }
static func selectPage7Shortcut() -> StoredShortcut { shortcut(for: .selectPage7) }
static func selectPage8Shortcut() -> StoredShortcut { shortcut(for: .selectPage8) }
static func selectLastPageShortcut() -> StoredShortcut { shortcut(for: .selectLastPage) }
static func focusLeftShortcut() -> StoredShortcut { shortcut(for: .focusLeft) }
static func focusRightShortcut() -> StoredShortcut { shortcut(for: .focusRight) }
static func focusUpShortcut() -> StoredShortcut { shortcut(for: .focusUp) }
static func focusDownShortcut() -> StoredShortcut { shortcut(for: .focusDown) }
static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) }
static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) }
static func toggleSplitZoomShortcut() -> StoredShortcut { shortcut(for: .toggleSplitZoom) }
static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) }
static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) }
static func nextSurfaceShortcut() -> StoredShortcut { shortcut(for: .nextSurface) }
static func prevSurfaceShortcut() -> StoredShortcut { shortcut(for: .prevSurface) }
static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) }
static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) }
static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) }
static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) }
}
/// 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("") }
let keyText: String
switch key {
case "\t":
keyText = "TAB"
case "\r":
keyText = ""
default:
keyText = key.uppercased()
}
parts.append(keyText)
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
}
var keyEquivalent: KeyEquivalent? {
switch key {
case "":
return .leftArrow
case "":
return .rightArrow
case "":
return .upArrow
case "":
return .downArrow
case "\t":
return .tab
case "\r":
return KeyEquivalent(Character("\r"))
default:
let lowered = key.lowercased()
guard lowered.count == 1, let character = lowered.first else { return nil }
return KeyEquivalent(character)
}
}
var eventModifiers: EventModifiers {
var modifiers: EventModifiers = []
if command {
modifiers.insert(.command)
}
if shift {
modifiers.insert(.shift)
}
if option {
modifiers.insert(.option)
}
if control {
modifiers.insert(.control)
}
return modifiers
}
var menuItemKeyEquivalent: String? {
switch key {
case "":
guard let scalar = UnicodeScalar(NSLeftArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSRightArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSUpArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSDownArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "\t":
return "\t"
case "\r":
return "\r"
default:
let lowered = key.lowercased()
guard lowered.count == 1 else { return nil }
return lowered
}
}
static func from(event: NSEvent) -> StoredShortcut? {
guard let key = storedKey(from: event) else { return nil }
// Some keys include extra flags depending on the responder chain.
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
let shortcut = StoredShortcut(
key: key,
command: flags.contains(.command),
shift: flags.contains(.shift),
option: flags.contains(.option),
control: flags.contains(.control)
)
// Avoid recording plain typing; require at least one modifier.
if !shortcut.command && !shortcut.shift && !shortcut.option && !shortcut.control {
return nil
}
return shortcut
}
private static func storedKey(from event: NSEvent) -> String? {
// Prefer keyCode mapping so shifted symbol keys (e.g. "}") record as "]".
switch event.keyCode {
case 123: return "" // left arrow
case 124: return "" // right arrow
case 125: return "" // down arrow
case 126: return "" // up arrow
case 48: return "\t" // tab
case 36, 76: return "\r" // return, keypad enter
case 33: return "[" // kVK_ANSI_LeftBracket
case 30: return "]" // kVK_ANSI_RightBracket
case 27: return "-" // kVK_ANSI_Minus
case 24: return "=" // kVK_ANSI_Equal
case 43: return "," // kVK_ANSI_Comma
case 47: return "." // kVK_ANSI_Period
case 44: return "/" // kVK_ANSI_Slash
case 41: return ";" // kVK_ANSI_Semicolon
case 39: return "'" // kVK_ANSI_Quote
case 50: return "`" // kVK_ANSI_Grave
case 42: return "\\" // kVK_ANSI_Backslash
default:
break
}
guard let chars = event.charactersIgnoringModifiers?.lowercased(),
let char = chars.first else {
return nil
}
// Allow letters/numbers; everything else should be handled by keyCode mapping above.
if char.isLetter || char.isNumber {
return String(char)
}
return nil
}
}
/// 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 = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "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
}
// Consume unsupported keys while recording to avoid triggering app shortcuts.
return nil
}
// 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()
}
}