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