* Add React Grab inject button to browser toolbar Adds a toolbar button (cursor click icon) that injects the react-grab script (unpkg.com/react-grab/dist/index.global.js) into the current page. Hover over React elements and Cmd+C to copy component context (file, component name, line number) for AI agents. Button highlights when active, resets on navigation. * Auto-activate selection mode on React Grab inject First click: injects the script and auto-activates selection mode via the react-grab:init event. Subsequent clicks toggle selection mode on/off via window.__REACT_GRAB__.toggle(). * Bridge React Grab state back to Swift via WKScriptMessageHandler Register a cmux-bridge plugin after injecting react-grab that posts state changes back to Swift via webkit.messageHandlers. The button now highlights accent color only when selection mode is actually active (not just when the script is loaded), and deactivates when the user exits selection mode via Escape or the react-grab toolbar. * Fetch react-grab script via URLSession to bypass CSP Sites like vercel.com block loading external scripts via CSP headers. Fetch the script with URLSession (not subject to page CSP), cache it, and inject inline via evaluateJavaScript. Also guard against duplicate injection on repeated clicks. * Prefetch react-grab script on first browser panel init Kick off a low-priority background fetch of the react-grab script when the first BrowserPanel is created. The script is cached statically so clicking the button is instant. * Eliminate react-grab button and callback lag Three changes: 1. Fire-and-forget: use evaluateJavaScript with completionHandler instead of await, so button taps return immediately. 2. Single JS payload: combine bootstrap listener + script source into one evaluateJavaScript call (one IPC round-trip, not two). 3. Dedupe state callbacks: only post webkit message when isActive actually changes, not on every hover/drag state update. * Fix duplicate state callback on react-grab toggle toggleReactGrab was sending an explicit postMessage AND the plugin's onStateChange hook was firing too, causing two @Published updates per toggle. Remove the explicit postMessage since the plugin hook handles it. Also add dlog instrumentation for debugging. * Add Cmd+Shift+G shortcut for React Grab (configurable) - Add toggleReactGrab to KeyboardShortcutSettings with Cmd+Shift+G default - Add View menu item with customizable shortcut - Add command palette entry (searchable as "react grab" or "inspect element") - Simplify button to use toggleOrInjectReactGrab, remove local state tracking * Fix Codex review findings: pin version, verify hash, fix retry and state 1. Pin react-grab to exact version (0.1.29) with SHA-256 integrity check. Script is verified before evaluation to prevent supply-chain attacks via compromised CDN responses. 2. Clear prefetchTask on failure so subsequent attempts retry the download instead of reusing a permanently failed task. 3. Remove premature isReactGrabActive=true. State is now only set by the onStateChange message handler callback after confirmed initialization, or explicitly reset on evaluation error. * Extract React Grab into own file, make version configurable Move all react-grab logic (settings, script loader, message handler, BrowserPanel extension) into Sources/Panels/ReactGrab.swift. Add a "React Grab Version" text field in Settings > Browser that lets the user pin which npm version is fetched. Only versions with a known SHA-256 integrity hash in ReactGrabSettings.knownHashes are accepted. The cache invalidates when the configured version changes. --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
650 lines
28 KiB
Swift
650 lines
28 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// Stores customizable keyboard shortcuts (definitions + persistence).
|
|
enum KeyboardShortcutSettings {
|
|
static let didChangeNotification = Notification.Name("cmux.keyboardShortcutSettingsDidChange")
|
|
static let actionUserInfoKey = "action"
|
|
|
|
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 selectSurfaceByNumber
|
|
case nextSidebarTab
|
|
case prevSidebarTab
|
|
case selectWorkspaceByNumber
|
|
case renameTab
|
|
case renameWorkspace
|
|
case closeWorkspace
|
|
case newSurface
|
|
case toggleTerminalCopyMode
|
|
|
|
// 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
|
|
case toggleReactGrab
|
|
|
|
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 .selectSurfaceByNumber: return String(localized: "shortcut.selectSurfaceByNumber.label", defaultValue: "Select Surface 1…9")
|
|
case .nextSidebarTab: return String(localized: "shortcut.nextWorkspace.label", defaultValue: "Next Workspace")
|
|
case .prevSidebarTab: return String(localized: "shortcut.previousWorkspace.label", defaultValue: "Previous Workspace")
|
|
case .selectWorkspaceByNumber: return String(localized: "shortcut.selectWorkspaceByNumber.label", defaultValue: "Select Workspace 1…9")
|
|
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 .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")
|
|
case .toggleReactGrab: return String(localized: "shortcut.toggleReactGrab.label", defaultValue: "Toggle React Grab")
|
|
}
|
|
}
|
|
|
|
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 .selectWorkspaceByNumber: return "shortcut.selectWorkspaceByNumber"
|
|
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 .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 .selectSurfaceByNumber: return "shortcut.selectSurfaceByNumber"
|
|
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"
|
|
case .toggleReactGrab: return "shortcut.toggleReactGrab"
|
|
}
|
|
}
|
|
|
|
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 .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 .selectSurfaceByNumber:
|
|
return StoredShortcut(key: "1", command: false, shift: false, option: false, control: true)
|
|
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 .selectWorkspaceByNumber:
|
|
return StoredShortcut(key: "1", command: true, shift: false, 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)
|
|
case .toggleReactGrab:
|
|
return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false)
|
|
}
|
|
}
|
|
|
|
func tooltip(_ base: String) -> String {
|
|
"\(base) (\(displayedShortcutString(for: KeyboardShortcutSettings.shortcut(for: self))))"
|
|
}
|
|
|
|
var usesNumberedDigitMatching: Bool {
|
|
switch self {
|
|
case .selectSurfaceByNumber, .selectWorkspaceByNumber:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func displayedShortcutString(for shortcut: StoredShortcut) -> String {
|
|
if usesNumberedDigitMatching {
|
|
return shortcut.modifierDisplayString + "1…9"
|
|
}
|
|
return shortcut.displayString
|
|
}
|
|
|
|
func normalizedRecordedShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? {
|
|
guard usesNumberedDigitMatching else { return shortcut }
|
|
guard let digit = Int(shortcut.key), (1...9).contains(digit) else {
|
|
return nil
|
|
}
|
|
return StoredShortcut(
|
|
key: "1",
|
|
command: shortcut.command,
|
|
shift: shortcut.shift,
|
|
option: shortcut.option,
|
|
control: shortcut.control
|
|
)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
let storedShortcut: StoredShortcut
|
|
if let normalizedShortcut = action.normalizedRecordedShortcut(shortcut) {
|
|
storedShortcut = normalizedShortcut
|
|
} else if action.usesNumberedDigitMatching {
|
|
return
|
|
} else {
|
|
storedShortcut = shortcut
|
|
}
|
|
|
|
if let data = try? JSONEncoder().encode(storedShortcut) {
|
|
UserDefaults.standard.set(data, forKey: action.defaultsKey)
|
|
}
|
|
postDidChangeNotification(action: action)
|
|
}
|
|
|
|
static func resetShortcut(for action: Action) {
|
|
UserDefaults.standard.removeObject(forKey: action.defaultsKey)
|
|
postDidChangeNotification(action: action)
|
|
}
|
|
|
|
static func resetAll() {
|
|
for action in Action.allCases {
|
|
UserDefaults.standard.removeObject(forKey: action.defaultsKey)
|
|
}
|
|
postDidChangeNotification()
|
|
}
|
|
|
|
private static func postDidChangeNotification(
|
|
action: Action? = nil,
|
|
center: NotificationCenter = .default
|
|
) {
|
|
var userInfo: [AnyHashable: Any] = [:]
|
|
if let action {
|
|
userInfo[actionUserInfoKey] = action.rawValue
|
|
}
|
|
center.post(
|
|
name: didChangeNotification,
|
|
object: nil,
|
|
userInfo: userInfo.isEmpty ? nil : userInfo
|
|
)
|
|
}
|
|
|
|
// 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 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 selectSurfaceByNumberShortcut() -> StoredShortcut { shortcut(for: .selectSurfaceByNumber) }
|
|
static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) }
|
|
static func selectWorkspaceByNumberShortcut() -> StoredShortcut { shortcut(for: .selectWorkspaceByNumber) }
|
|
|
|
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 {
|
|
modifierDisplayString + keyDisplayString
|
|
}
|
|
|
|
var modifierDisplayString: String {
|
|
var parts: [String] = []
|
|
if control { parts.append("⌃") }
|
|
if option { parts.append("⌥") }
|
|
if shift { parts.append("⇧") }
|
|
if command { parts.append("⌘") }
|
|
return parts.joined()
|
|
}
|
|
|
|
var keyDisplayString: String {
|
|
switch key {
|
|
case "\t":
|
|
return "TAB"
|
|
case "\r":
|
|
return "↩"
|
|
default:
|
|
return key.uppercased()
|
|
}
|
|
}
|
|
|
|
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
|
|
var displayString: (StoredShortcut) -> String = { $0.displayString }
|
|
var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 }
|
|
@State private var isRecording = false
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(label)
|
|
|
|
Spacer()
|
|
|
|
ShortcutRecorderButton(
|
|
shortcut: $shortcut,
|
|
isRecording: $isRecording,
|
|
displayString: displayString,
|
|
transformRecordedShortcut: transformRecordedShortcut
|
|
)
|
|
.frame(width: 120)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ShortcutRecorderButton: NSViewRepresentable {
|
|
@Binding var shortcut: StoredShortcut
|
|
@Binding var isRecording: Bool
|
|
let displayString: (StoredShortcut) -> String
|
|
let transformRecordedShortcut: (StoredShortcut) -> StoredShortcut?
|
|
|
|
func makeNSView(context: Context) -> ShortcutRecorderNSButton {
|
|
let button = ShortcutRecorderNSButton()
|
|
button.shortcut = shortcut
|
|
button.displayString = displayString
|
|
button.transformRecordedShortcut = transformRecordedShortcut
|
|
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.displayString = displayString
|
|
nsView.transformRecordedShortcut = transformRecordedShortcut
|
|
nsView.updateTitle()
|
|
}
|
|
}
|
|
|
|
private class ShortcutRecorderNSButton: NSButton {
|
|
var shortcut: StoredShortcut = KeyboardShortcutSettings.showNotificationsDefault
|
|
var displayString: (StoredShortcut) -> String = { $0.displayString }
|
|
var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 }
|
|
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 = displayString(shortcut)
|
|
}
|
|
}
|
|
|
|
@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) {
|
|
guard let transformedShortcut = self.transformRecordedShortcut(newShortcut) else {
|
|
NSSound.beep()
|
|
return nil
|
|
}
|
|
self.shortcut = transformedShortcut
|
|
self.onShortcutRecorded?(transformedShortcut)
|
|
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()
|
|
}
|
|
}
|