From c33b87cf8210672624854459a74e0656d7bd879b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 2 Jun 2026 17:02:32 +0700 Subject: [PATCH 1/2] refactor(shortcuts)!: rewrite keyboard handling on a single menu-driven authority with hardware key codes --- CHANGELOG.md | 17 + .../TextViewController+FindPanelTarget.swift | 8 + .../TextView/TextView+CopyPaste.swift | 22 + .../CodeEditTextViewTests/CutLineTests.swift | 38 ++ TablePro/Core/KeyboardHandling/KeyCode.swift | 90 +++ .../KeyboardHandling/KeyboardLayout.swift | 70 ++ .../ShortcutConflictResolver.swift | 38 ++ .../SystemHotkeyChecker.swift | 59 ++ TablePro/Models/UI/BoundKey.swift | 257 ++++++++ .../Models/UI/KeyboardShortcutModels.swift | 619 +++++++----------- TablePro/TableProApp.swift | 17 + .../Components/PaginationControlsView.swift | 10 +- TablePro/Views/Editor/EditorEventRouter.swift | 61 +- TablePro/Views/Editor/LineCutCalculator.swift | 42 -- .../Views/Editor/SQLEditorCoordinator.swift | 8 + .../Views/Results/KeyHandlingTableView.swift | 9 +- .../Views/Settings/KeyboardSettingsView.swift | 90 ++- .../Views/Settings/ShortcutRecorderView.swift | 25 +- .../Models/KeyboardShortcutTests.swift | 189 +++++- .../Models/UI/BoundKeyMatchTests.swift | 135 ++++ .../Models/UI/KeyComboMatchTests.swift | 119 ---- .../Views/Editor/LineCutCalculatorTests.swift | 165 ----- docs/features/keyboard-shortcuts.mdx | 20 +- 23 files changed, 1278 insertions(+), 830 deletions(-) create mode 100644 LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CutLineTests.swift create mode 100644 TablePro/Core/KeyboardHandling/KeyboardLayout.swift create mode 100644 TablePro/Core/KeyboardHandling/ShortcutConflictResolver.swift create mode 100644 TablePro/Core/KeyboardHandling/SystemHotkeyChecker.swift create mode 100644 TablePro/Models/UI/BoundKey.swift delete mode 100644 TablePro/Views/Editor/LineCutCalculator.swift create mode 100644 TableProTests/Models/UI/BoundKeyMatchTests.swift delete mode 100644 TableProTests/Models/UI/KeyComboMatchTests.swift delete mode 100644 TableProTests/Views/Editor/LineCutCalculatorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e469c93..ae76673e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Custom keyboard shortcuts now work on non-US keyboard layouts, and shifted symbols like Cmd+[ record correctly. +- The Keyboard settings list is grouped by where shortcuts act (Editor, Data Grid, Navigation, Connections), and each changed shortcut has its own reset button. +- Conflict detection now checks live macOS system shortcuts and the editor's built-in commands, and lets the same key serve the editor and the data grid because focus decides which one runs. +- Show Tables and Show Favorites sidebars moved off Control+1 and Control+2, which switch macOS Spaces, to Cmd+Option+1 and Cmd+Option+2. +- Cmd+N now opens a new connection; Manage Connections keeps its File menu item. +- First Page and Last Page now default to Cmd+Option+Up and Cmd+Option+Down. +- Shortcuts can be bound to function keys (F1 through F12), with or without a modifier. + +### Fixed + +- Custom Copy and Cut shortcuts now take effect in the SQL editor. +- The Delete shortcut in the data grid now follows a custom binding. +- Find Next (Cmd+G) and Find Previous (Cmd+Shift+G) now work in the editor. +- Pagination buttons no longer fire their page shortcut twice. + ## [0.48.0] - 2026-06-02 ### Added diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift index 9631b3bf7..df6bcdb54 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -35,4 +35,12 @@ public extension TextViewController { _ = textView.resignFirstResponder() findViewController?.showFindPanel() } + + func findNext() { + findViewController?.viewModel.moveToNextMatch() + } + + func findPrevious() { + findViewController?.viewModel.moveToPreviousMatch() + } } diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+CopyPaste.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+CopyPaste.swift index 7eb6bec61..dd26a763b 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+CopyPaste.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextView/TextView+CopyPaste.swift @@ -25,6 +25,7 @@ extension TextView { } @objc open func cut(_ sender: AnyObject) { + expandEmptySelectionsToCurrentLine() copy(sender) deleteBackward(sender) } @@ -32,4 +33,25 @@ extension TextView { @objc open func delete(_ sender: AnyObject) { deleteBackward(sender) } + + /// When a selection is empty, a cut removes the whole current line (including + /// its trailing line break), matching Xcode, VS Code, and JetBrains. + private func expandEmptySelectionsToCurrentLine() { + guard !textStorage.string.isEmpty else { return } + let text = textStorage.string as NSString + let ranges = selectionManager.textSelections.map { Self.cutRange(for: $0.range, in: text) } + guard ranges.contains(where: { !$0.isEmpty }) else { return } + selectionManager.setSelectedRanges(ranges) + } + + /// The range a cut should remove for a given selection: the selection itself + /// when non-empty, otherwise the line containing the caret. + static func cutRange(for selectionRange: NSRange, in text: NSString) -> NSRange { + guard selectionRange.isEmpty, + selectionRange.location >= 0, + selectionRange.location <= text.length else { + return selectionRange + } + return text.lineRange(for: NSRange(location: selectionRange.location, length: 0)) + } } diff --git a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CutLineTests.swift b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CutLineTests.swift new file mode 100644 index 000000000..ac270ec20 --- /dev/null +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CutLineTests.swift @@ -0,0 +1,38 @@ +import AppKit +@testable import CodeEditTextView +import Testing + +/// Covers the empty-selection line cut that backs Cmd+X cutting the current +/// line when nothing is selected. +@Suite +struct CutLineTests { + @Test("Empty selection expands to the whole line including the trailing newline") + func emptySelectionExpandsToLine() { + let text = "line1\nline2\nline3" as NSString + let caret = NSRange(location: 8, length: 0) + let range = TextView.cutRange(for: caret, in: text) + #expect(range == text.lineRange(for: caret)) + #expect(text.substring(with: range) == "line2\n") + } + + @Test("Non-empty selection is returned unchanged") + func nonEmptySelectionUnchanged() { + let text = "line1\nline2" as NSString + let selection = NSRange(location: 0, length: 5) + #expect(TextView.cutRange(for: selection, in: text) == selection) + } + + @Test("Caret on the last line without a trailing newline cuts to the end") + func lastLineWithoutTrailingNewline() { + let text = "a\nb" as NSString + let range = TextView.cutRange(for: NSRange(location: 2, length: 0), in: text) + #expect(text.substring(with: range) == "b") + } + + @Test("Out-of-bounds caret is returned unchanged") + func outOfBoundsCaretUnchanged() { + let text = "abc" as NSString + let caret = NSRange(location: 99, length: 0) + #expect(TextView.cutRange(for: caret, in: text) == caret) + } +} diff --git a/TablePro/Core/KeyboardHandling/KeyCode.swift b/TablePro/Core/KeyboardHandling/KeyCode.swift index eb0c08d3d..69e9dea88 100644 --- a/TablePro/Core/KeyboardHandling/KeyCode.swift +++ b/TablePro/Core/KeyboardHandling/KeyCode.swift @@ -124,6 +124,20 @@ public enum KeyCode: UInt16 { case eight = 28 case nine = 25 + // MARK: - Symbol Keys (US ANSI positions) + + case minus = 27 + case equal = 24 + case leftBracket = 33 + case rightBracket = 30 + case semicolon = 41 + case quote = 39 + case comma = 43 + case period = 47 + case slash = 44 + case backslash = 42 + case grave = 50 + // MARK: - Function Keys case f1 = 122 @@ -172,6 +186,82 @@ public enum KeyCode: UInt16 { } } + /// The 1-based function key number (F1 = 1 ... F12 = 12), or nil for non-function keys. + public var functionKeyIndex: Int? { + switch self { + case .f1: return 1 + case .f2: return 2 + case .f3: return 3 + case .f4: return 4 + case .f5: return 5 + case .f6: return 6 + case .f7: return 7 + case .f8: return 8 + case .f9: return 9 + case .f10: return 10 + case .f11: return 11 + case .f12: return 12 + default: return nil + } + } + + /// The character this key produces with no modifiers on a US ANSI layout. + /// Used as a fallback when the live keyboard layout cannot be resolved, and + /// to anchor default shortcuts to a semantic character. Returns nil for + /// non-printable keys (arrows, delete, function keys, etc.). + public var usBaseCharacter: Character? { + switch self { + case .a: return "a" + case .b: return "b" + case .c: return "c" + case .d: return "d" + case .e: return "e" + case .f: return "f" + case .g: return "g" + case .h: return "h" + case .i: return "i" + case .j: return "j" + case .k: return "k" + case .l: return "l" + case .m: return "m" + case .n: return "n" + case .o: return "o" + case .p: return "p" + case .q: return "q" + case .r: return "r" + case .s: return "s" + case .t: return "t" + case .u: return "u" + case .v: return "v" + case .w: return "w" + case .x: return "x" + case .y: return "y" + case .z: return "z" + case .zero: return "0" + case .one: return "1" + case .two: return "2" + case .three: return "3" + case .four: return "4" + case .five: return "5" + case .six: return "6" + case .seven: return "7" + case .eight: return "8" + case .nine: return "9" + case .minus: return "-" + case .equal: return "=" + case .leftBracket: return "[" + case .rightBracket: return "]" + case .semicolon: return ";" + case .quote: return "'" + case .comma: return "," + case .period: return "." + case .slash: return "/" + case .backslash: return "\\" + case .grave: return "`" + default: return nil + } + } + /// Create a KeyCode from an NSEvent public init?(event: NSEvent) { self.init(rawValue: event.keyCode) diff --git a/TablePro/Core/KeyboardHandling/KeyboardLayout.swift b/TablePro/Core/KeyboardHandling/KeyboardLayout.swift new file mode 100644 index 000000000..79a5e3b9c --- /dev/null +++ b/TablePro/Core/KeyboardHandling/KeyboardLayout.swift @@ -0,0 +1,70 @@ +// +// KeyboardLayout.swift +// TablePro +// +// Resolves between hardware key codes and their base (unshifted) characters +// for the active ASCII-capable keyboard layout. Built once and cached. +// + +import AppKit +import Carbon.HIToolbox + +enum KeyboardLayout { + static func baseCharacter(for keyCode: UInt16) -> Character? { + maps.keyCodeToCharacter[keyCode] ?? KeyCode(rawValue: keyCode)?.usBaseCharacter + } + + static func keyCode(for character: Character) -> UInt16? { + let lowered = Character(character.lowercased()) + return maps.characterToKeyCode[lowered] ?? usFallback[lowered] + } + + private static let maps = buildMaps() + + private static let usFallback: [Character: UInt16] = { + var result: [Character: UInt16] = [:] + for raw in UInt16(0)...127 { + guard let character = KeyCode(rawValue: raw)?.usBaseCharacter else { continue } + if result[character] == nil { result[character] = raw } + } + return result + }() + + private static func buildMaps() -> (keyCodeToCharacter: [UInt16: Character], characterToKeyCode: [Character: UInt16]) { + guard let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue(), + let layoutPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { + return ([:], [:]) + } + let layoutData = unsafeBitCast(layoutPointer, to: CFData.self) + guard let bytes = CFDataGetBytePtr(layoutData) else { return ([:], [:]) } + let keyboardLayout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self) + let keyboardType = UInt32(LMGetKbdType()) + + var toCharacter: [UInt16: Character] = [:] + var toKeyCode: [Character: UInt16] = [:] + + for keyCode in UInt16(0)...127 { + var deadKeyState: UInt32 = 0 + var length = 0 + var characters = [UniChar](repeating: 0, count: 4) + let status = UCKeyTranslate( + keyboardLayout, + keyCode, + UInt16(kUCKeyActionDisplay), + 0, + keyboardType, + OptionBits(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + characters.count, + &length, + &characters + ) + guard status == noErr, length == 1 else { continue } + let scalarString = String(utf16CodeUnits: characters, count: length) + guard let character = scalarString.first, !character.isWhitespace, !character.isNewline else { continue } + toCharacter[keyCode] = character + if toKeyCode[character] == nil { toKeyCode[character] = keyCode } + } + return (toCharacter, toKeyCode) + } +} diff --git a/TablePro/Core/KeyboardHandling/ShortcutConflictResolver.swift b/TablePro/Core/KeyboardHandling/ShortcutConflictResolver.swift new file mode 100644 index 000000000..af37afa19 --- /dev/null +++ b/TablePro/Core/KeyboardHandling/ShortcutConflictResolver.swift @@ -0,0 +1,38 @@ +// +// ShortcutConflictResolver.swift +// TablePro +// +// Resolves what a recorded shortcut would collide with: a macOS system +// shortcut, a built-in editor command, or another customizable action in an +// overlapping context. +// + +import Foundation + +enum ShortcutConflict: Equatable { + case none + case systemReserved + case reserved(String) + case otherAction(ShortcutAction) +} + +@MainActor +enum ShortcutConflictResolver { + static func resolve(_ key: BoundKey, for action: ShortcutAction, in settings: KeyboardSettings) -> ShortcutConflict { + guard !key.isCleared else { return .none } + + if SystemHotkeyChecker.shared.isReserved(key) { + return .systemReserved + } + + if let name = ShortcutAction.reservedConflict(for: key, context: action.context) { + return .reserved(name) + } + + if let other = settings.findConflict(for: key, excluding: action) { + return .otherAction(other) + } + + return .none + } +} diff --git a/TablePro/Core/KeyboardHandling/SystemHotkeyChecker.swift b/TablePro/Core/KeyboardHandling/SystemHotkeyChecker.swift new file mode 100644 index 000000000..170dfa07b --- /dev/null +++ b/TablePro/Core/KeyboardHandling/SystemHotkeyChecker.swift @@ -0,0 +1,59 @@ +// +// SystemHotkeyChecker.swift +// TablePro +// +// Reports the user's live, enabled macOS system shortcuts so the recorder can +// refuse combos the system already owns. Reads the same data as +// System Settings > Keyboard > Shortcuts via CopySymbolicHotKeys, so it adapts +// to the user's configuration instead of relying on a hand-maintained list. +// + +import AppKit +import Carbon.HIToolbox + +@MainActor +final class SystemHotkeyChecker { + static let shared = SystemHotkeyChecker() + + private var reserved: Set = [] + private var hasLoaded = false + + private init() {} + + func isReserved(_ key: BoundKey) -> Bool { + guard !key.isCleared else { return false } + if !hasLoaded { reload() } + return reserved.contains(key) + } + + func reload() { + hasLoaded = true + reserved = Self.loadSystemHotkeys() + } + + private static func loadSystemHotkeys() -> Set { + var hotkeysRef: Unmanaged? + guard CopySymbolicHotKeys(&hotkeysRef) == noErr, + let entries = hotkeysRef?.takeRetainedValue() as? [[String: Any]] else { + return [] + } + + var result: Set = [] + for entry in entries { + guard (entry[kHISymbolicHotKeyEnabled as String] as? Bool) == true, + let code = entry[kHISymbolicHotKeyCode as String] as? Int, + let modifiers = entry[kHISymbolicHotKeyModifiers as String] as? Int, + code >= 0, code < Int(UInt16.max) else { + continue + } + result.insert(BoundKey( + keyCode: UInt16(code), + command: modifiers & cmdKey != 0, + shift: modifiers & shiftKey != 0, + option: modifiers & optionKey != 0, + control: modifiers & controlKey != 0 + )) + } + return result + } +} diff --git a/TablePro/Models/UI/BoundKey.swift b/TablePro/Models/UI/BoundKey.swift new file mode 100644 index 000000000..9d644df7b --- /dev/null +++ b/TablePro/Models/UI/BoundKey.swift @@ -0,0 +1,257 @@ +// +// BoundKey.swift +// TablePro +// +// A keyboard shortcut stored as a hardware key code plus modifiers. Storing the +// physical key code (rather than the produced character) makes matching +// layout-independent and avoids the shifted-symbol ambiguity where "[" and "{" +// describe the same physical key. +// + +import AppKit +import SwiftUI + +struct BoundKey: Codable, Equatable, Hashable { + let keyCode: UInt16 + let command: Bool + let shift: Bool + let option: Bool + let control: Bool + + init(keyCode: UInt16, command: Bool = false, shift: Bool = false, option: Bool = false, control: Bool = false) { + self.keyCode = keyCode + self.command = command + self.shift = shift + self.option = option + self.control = control + } + + // MARK: - Recorder Capture + + /// Build from a recorded key event. Requires Command or Control, or one of the + /// bare keys allowed for grid actions (Escape, Delete, Forward Delete, Space). + init?(from event: NSEvent) { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let hasCommand = flags.contains(.command) + let hasControl = flags.contains(.control) + guard hasCommand || hasControl || Self.isBareRecordable(event.keyCode) else { + return nil + } + self.keyCode = event.keyCode + self.command = hasCommand + self.shift = flags.contains(.shift) + self.option = flags.contains(.option) + self.control = hasControl + } + + // MARK: - Default Construction + + /// Build a binding from a base character, resolving it to a key code on the + /// active layout. Used to anchor default shortcuts to a semantic character. + static func character( + _ character: Character, + command: Bool = false, + shift: Bool = false, + option: Bool = false, + control: Bool = false + ) -> BoundKey { + let keyCode = KeyboardLayout.keyCode(for: character) ?? 0xFFFF + return BoundKey(keyCode: keyCode, command: command, shift: shift, option: option, control: control) + } + + /// Build a binding from a named special key (layout-independent). + static func special( + _ key: KeyCode, + command: Bool = false, + shift: Bool = false, + option: Bool = false, + control: Bool = false + ) -> BoundKey { + BoundKey(keyCode: key.rawValue, command: command, shift: shift, option: option, control: control) + } + + // MARK: - Migration + + /// Convert a legacy character-string shortcut (pre-keyCode storage) to a BoundKey. + init?(legacyKey: String, isSpecialKey: Bool, command: Bool, shift: Bool, option: Bool, control: Bool) { + if legacyKey.isEmpty, !command, !shift, !option, !control { + self = .cleared + return + } + let resolvedKeyCode: UInt16? + if isSpecialKey { + resolvedKeyCode = Self.specialKeyByLegacyName[legacyKey]?.rawValue + } else if legacyKey.count == 1, let character = legacyKey.first { + resolvedKeyCode = KeyboardLayout.keyCode(for: character) + } else { + resolvedKeyCode = nil + } + guard let keyCode = resolvedKeyCode else { return nil } + self.init(keyCode: keyCode, command: command, shift: shift, option: option, control: control) + } + + // MARK: - Matching + + func matches(_ event: NSEvent) -> Bool { + guard !isCleared else { return false } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + return event.keyCode == keyCode + && flags.contains(.command) == command + && flags.contains(.shift) == shift + && flags.contains(.option) == option + && flags.contains(.control) == control + } + + var hasModifier: Bool { + command || shift || option || control + } + + /// Function keys (F1-F12) are valid as bare shortcuts: they reach the menu as + /// key-equivalents without a modifier and never collide with typing. + var isFunctionKey: Bool { + KeyCode(rawValue: keyCode)?.functionKeyIndex != nil + } + + // MARK: - SwiftUI Integration + + /// The SwiftUI key equivalent, or nil when the key code has no representable key. + var swiftUIKeyEquivalent: KeyEquivalent? { + if let special = Self.specialKeyEquivalent(for: keyCode) { + return special + } + if let scalar = Self.functionKeyScalar(for: keyCode) { + return KeyEquivalent(Character(scalar)) + } + guard let character = KeyboardLayout.baseCharacter(for: keyCode) 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 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 + } + + // MARK: - Display + + /// Human-readable representation (e.g. "⌘S", "⇧⌘P", "⌥⌫"). + var displayString: String { + guard !isCleared else { return "" } + var parts: [String] = [] + if control { parts.append("⌃") } + if option { parts.append("⌥") } + if shift { parts.append("⇧") } + if command { parts.append("⌘") } + parts.append(displayKey) + return parts.joined() + } + + private var displayKey: String { + if let glyph = Self.specialKeyGlyph(for: keyCode) { + return glyph + } + if let index = KeyCode(rawValue: keyCode)?.functionKeyIndex { + return "F\(index)" + } + if let character = KeyboardLayout.baseCharacter(for: keyCode) { + return String(character).uppercased() + } + return "?" + } + + // MARK: - Cleared Sentinel + + static let cleared = BoundKey(keyCode: 0xFFFF) + + var isCleared: Bool { + keyCode == 0xFFFF + } + + // MARK: - Special Key Tables + + private static let bareRecordableKeyCodes: Set = [ + KeyCode.escape.rawValue, + KeyCode.delete.rawValue, + KeyCode.forwardDelete.rawValue, + KeyCode.space.rawValue + ] + + private static func isBareRecordable(_ keyCode: UInt16) -> Bool { + bareRecordableKeyCodes.contains(keyCode) || KeyCode(rawValue: keyCode)?.functionKeyIndex != nil + } + + private static func functionKeyScalar(for keyCode: UInt16) -> Unicode.Scalar? { + guard let index = KeyCode(rawValue: keyCode)?.functionKeyIndex else { return nil } + return Unicode.Scalar(0xF704 + index - 1) + } + + private static let specialKeyByLegacyName: [String: KeyCode] = [ + "delete": .delete, + "forwardDelete": .forwardDelete, + "escape": .escape, + "return": .return, + "tab": .tab, + "space": .space, + "upArrow": .upArrow, + "downArrow": .downArrow, + "leftArrow": .leftArrow, + "rightArrow": .rightArrow, + "home": .home, + "end": .end, + "pageUp": .pageUp, + "pageDown": .pageDown + ] + + private static func specialKeyEquivalent(for keyCode: UInt16) -> KeyEquivalent? { + switch KeyCode(rawValue: keyCode) { + case .delete: return .delete + case .forwardDelete: return .deleteForward + case .escape: return .escape + case .return, .enter: return .return + case .tab: return .tab + case .space: return .space + case .upArrow: return .upArrow + case .downArrow: return .downArrow + case .leftArrow: return .leftArrow + case .rightArrow: return .rightArrow + case .home: return .home + case .end: return .end + case .pageUp: return .pageUp + case .pageDown: return .pageDown + default: return nil + } + } + + private static func specialKeyGlyph(for keyCode: UInt16) -> String? { + switch KeyCode(rawValue: keyCode) { + case .delete: return "⌫" + case .forwardDelete: return "⌦" + case .escape: return "⎋" + case .return: return "↩" + case .enter: return "⌅" + case .tab: return "⇥" + case .space: return "␣" + case .upArrow: return "↑" + case .downArrow: return "↓" + case .leftArrow: return "←" + case .rightArrow: return "→" + case .home: return "↖" + case .end: return "↘" + case .pageUp: return "⇞" + case .pageDown: return "⇟" + default: return nil + } + } +} diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index fef817698..14a54b781 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -2,31 +2,48 @@ // KeyboardShortcutModels.swift // TablePro // -// Data models for keyboard shortcut customization. +// Data models for keyboard shortcut customization. The binding type itself +// lives in BoundKey.swift. // import AppKit import SwiftUI +// MARK: - Shortcut Context + +/// Where an action can fire. The same physical combo can mean different things +/// in different contexts because the focused responder resolves it (e.g. Cmd+[ +/// is pagination in the grid and indent in the editor). Two actions only +/// conflict when their contexts can be active at the same time. +enum ShortcutContext: String { + case global + case editor + case dataGrid + + func overlaps(_ other: ShortcutContext) -> Bool { + self == .global || other == .global || self == other + } +} + // MARK: - Shortcut Category -/// Categories for organizing keyboard shortcuts in settings +/// Groups shortcuts in the settings list by the surface they act on. enum ShortcutCategory: String, Codable, CaseIterable, Identifiable { - case file - case edit - case view - case tabs - case ai + case editor + case dataGrid + case navigation + case connections + case app var id: String { rawValue } var displayName: String { switch self { - case .file: return String(localized: "File") - case .edit: return String(localized: "Edit") - case .view: return String(localized: "View") - case .tabs: return String(localized: "Tabs") - case .ai: return String(localized: "AI") + case .editor: return String(localized: "Editor & Query") + case .dataGrid: return String(localized: "Data Grid") + case .navigation: return String(localized: "Navigation") + case .connections: return String(localized: "Connections") + case .app: return String(localized: "App") } } } @@ -35,33 +52,28 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable { /// All customizable keyboard shortcut actions enum ShortcutAction: String, Codable, CaseIterable, Identifiable { - // File + // Connections case manageConnections - case newTab + case newConnection case openDatabase - case openFile case switchConnection + + // Editor & Query + case openFile case saveChanges case saveAs - case previewSQL - case closeTab - case refresh case executeQuery case executeAllStatements case cancelQuery case explainQuery case formatQuery - case export - case importData - case quickSwitcher - - // Navigation - case previousPage - case nextPage - case firstPage - case lastPage + case previewSQL + case findNext + case findPrevious + case aiExplainQuery + case aiOptimizeQuery - // Edit + // Data Grid case undo case redo case cut @@ -78,8 +90,18 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case truncateTable case previewFKReference case saveAsFavorite + case previousPage + case nextPage + case firstPage + case lastPage + case refresh + case export + case importData - // View + // Navigation + case newTab + case closeTab + case quickSwitcher case toggleTableBrowser case toggleInspector case toggleFilters @@ -91,37 +113,44 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case focusSidebarSearch case showSidebarTables case showSidebarFavorites - - // Tabs case showPreviousTab case showNextTab - // AI - case aiExplainQuery - case aiOptimizeQuery - var id: String { rawValue } var category: ShortcutCategory { switch self { - case .manageConnections, .newTab, .openDatabase, .openFile, .switchConnection, - .saveChanges, .saveAs, .previewSQL, .closeTab, .refresh, - .executeQuery, .executeAllStatements, .cancelQuery, .explainQuery, .formatQuery, - .export, .importData, .quickSwitcher, - .previousPage, .nextPage, .firstPage, .lastPage, .saveAsFavorite: - return .file - case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .paste, - .delete, .selectAll, .clearSelection, .addRow, - .duplicateRow, .truncateTable, .previewFKReference: - return .edit - case .toggleTableBrowser, .toggleInspector, .toggleFilters, .toggleHistory, - .toggleResults, .previousResultTab, .nextResultTab, .closeResultTab, - .focusSidebarSearch, .showSidebarTables, .showSidebarFavorites: - return .view - case .showPreviousTab, .showNextTab: - return .tabs - case .aiExplainQuery, .aiOptimizeQuery: - return .ai + case .manageConnections, .newConnection, .openDatabase, .switchConnection: + return .connections + case .openFile, .saveChanges, .saveAs, .executeQuery, .executeAllStatements, + .cancelQuery, .explainQuery, .formatQuery, .previewSQL, .findNext, + .findPrevious, .aiExplainQuery, .aiOptimizeQuery: + return .editor + case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson, + .paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow, + .truncateTable, .previewFKReference, .saveAsFavorite, .previousPage, + .nextPage, .firstPage, .lastPage, .refresh, .export, .importData: + return .dataGrid + case .newTab, .closeTab, .quickSwitcher, .toggleTableBrowser, .toggleInspector, + .toggleFilters, .toggleHistory, .toggleResults, .previousResultTab, + .nextResultTab, .closeResultTab, .focusSidebarSearch, .showSidebarTables, + .showSidebarFavorites, .showPreviousTab, .showNextTab: + return .navigation + } + } + + var context: ShortcutContext { + switch self { + case .executeQuery, .executeAllStatements, .cancelQuery, .explainQuery, + .formatQuery, .previewSQL, .findNext, .findPrevious, .aiExplainQuery, + .aiOptimizeQuery: + return .editor + case .previousPage, .nextPage, .firstPage, .lastPage, .addRow, .duplicateRow, + .delete, .truncateTable, .previewFKReference, .saveAsFavorite, + .copyRowsExplicit, .copyWithHeaders, .copyAsJson, .toggleFilters: + return .dataGrid + default: + return .global } } @@ -137,6 +166,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { var displayName: String { switch self { case .manageConnections: return String(localized: "Manage Connections") + case .newConnection: return String(localized: "New Connection") case .executeQuery: return String(localized: "Execute Query") case .executeAllStatements: return String(localized: "Execute All Statements") case .cancelQuery: return String(localized: "Cancel Query") @@ -151,6 +181,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .refresh: return String(localized: "Refresh") case .explainQuery: return String(localized: "Explain Query") case .formatQuery: return String(localized: "Format Query") + case .findNext: return String(localized: "Find Next") + case .findPrevious: return String(localized: "Find Previous") case .export: return String(localized: "Export") case .importData: return String(localized: "Import") case .quickSwitcher: return String(localized: "Quick Switcher") @@ -193,281 +225,116 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { } } -// MARK: - Key Combo - -/// A recorded keyboard shortcut combination -struct KeyCombo: Codable, Equatable, Hashable { - /// The key character (lowercase letter, or special key name like "delete", "escape", "leftArrow", etc.) - let key: String - - /// Whether Command modifier is held - let command: Bool - - /// Whether Shift modifier is held - let shift: Bool - - /// Whether Option modifier is held - let option: Bool - - /// Whether Control modifier is held - let control: Bool - - /// Whether this is a special key (arrow, delete, escape, etc.) rather than a character key - let isSpecialKey: Bool - - init( - key: String, - command: Bool = false, - shift: Bool = false, - option: Bool = false, - control: Bool = false, - isSpecialKey: Bool = false - ) { - self.key = key - self.command = command - self.shift = shift - self.option = option - self.control = control - self.isSpecialKey = isSpecialKey - } - - /// Create a KeyCombo from an NSEvent - init?(from event: NSEvent) { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasCommand = flags.contains(.command) - let hasShift = flags.contains(.shift) - let hasOption = flags.contains(.option) - let hasControl = flags.contains(.control) - - // Require at least Cmd or Control (or special bare keys: escape, delete, space) - let specialKeyCode = Self.specialKeyName(for: event.keyCode) - let isAllowedBareKey = event.keyCode == 53 || event.keyCode == 51 - || event.keyCode == 117 || event.keyCode == 49 - - if !hasCommand && !hasControl && !isAllowedBareKey { - return nil - } - - if let specialName = specialKeyCode { - self.key = specialName - self.isSpecialKey = true - } else if let chars = event.charactersIgnoringModifiers?.lowercased(), !chars.isEmpty { - self.key = chars - self.isSpecialKey = false - } else { - return nil - } - - self.command = hasCommand - self.shift = hasShift - self.option = hasOption - self.control = hasControl - } - - // MARK: - SwiftUI Integration - - /// Convert to SwiftUI KeyEquivalent - var keyEquivalent: KeyEquivalent { - if isSpecialKey { - switch key { - case "delete": return .delete - case "escape": return .escape - case "return": return .return - case "tab": return .tab - case "space": return .space - case "upArrow": return .upArrow - case "downArrow": return .downArrow - case "leftArrow": return .leftArrow - case "rightArrow": return .rightArrow - case "home": return .home - case "end": return .end - case "pageUp": return .pageUp - case "pageDown": return .pageDown - // NSDeleteFunctionKey (0xF728) is always a valid Unicode scalar - // swiftlint:disable:next force_unwrapping - case "forwardDelete": return KeyEquivalent(Character(UnicodeScalar(NSDeleteFunctionKey)!)) - default: - guard key.count == 1 else { return .escape } - return KeyEquivalent(Character(key)) - } - } - return KeyEquivalent(Character(key)) - } - - /// Convert to SwiftUI EventModifiers - 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 hasModifier: Bool { - command || shift || option || control - } - - /// Human-readable display string (e.g. "⌘S", "⇧⌘P") - var displayString: String { - var parts: [String] = [] - if control { parts.append("⌃") } - if option { parts.append("⌥") } - if shift { parts.append("⇧") } - if command { parts.append("⌘") } - parts.append(displayKey) - return parts.joined() - } +// MARK: - Built-in Editor Shortcuts + +extension ShortcutAction { + /// Shortcuts owned by the embedded SQL editor. They are not customizable, but + /// the recorder surfaces them so a user does not silently shadow one with an + /// editor-context binding. + static let editorBuiltIns: [(key: BoundKey, name: String)] = [ + (.character("/", command: true), String(localized: "Toggle Comment")), + (.character("[", command: true), String(localized: "Indent")), + (.character("]", command: true), String(localized: "Outdent")), + (.character("f", command: true), String(localized: "Find")), + (.character("d", command: true, shift: true), String(localized: "Duplicate Line")), + (.character("k", command: true, shift: true), String(localized: "Delete Line")), + (.special(.space, control: true), String(localized: "Show Completions")), + (.special(.upArrow, option: true), String(localized: "Move Line Up")), + (.special(.downArrow, option: true), String(localized: "Move Line Down")) + ] - /// The display representation of the key - private var displayKey: String { - if isSpecialKey { - switch key { - case "delete": return "⌫" - case "forwardDelete": return "⌦" - case "escape": return "⎋" - case "return": return "↩" - case "tab": return "⇥" - case "space": return "␣" - case "upArrow": return "↑" - case "downArrow": return "↓" - case "leftArrow": return "←" - case "rightArrow": return "→" - case "home": return "↖" - case "end": return "↘" - case "pageUp": return "⇞" - case "pageDown": return "⇟" - default: return key.count == 1 ? key.uppercased() : "?" - } + /// App-level shortcuts that are wired directly in the menu and are not + /// customizable: tab selection (Cmd+1 through Cmd+9) and editor zoom. These + /// fire regardless of focus, so a user binding would silently collide. + static let reservedAppShortcuts: [(key: BoundKey, name: String)] = { + var shortcuts: [(key: BoundKey, name: String)] = [ + (.character("=", command: true), String(localized: "Zoom In")), + (.character("-", command: true), String(localized: "Zoom Out")) + ] + for number in 1...9 { + shortcuts.append(( + .character(Character(String(number)), command: true), + String(format: String(localized: "Select Tab %d"), number) + )) } - return key.uppercased() - } - - // MARK: - Special Key Mapping - - /// Map macOS key codes to special key names - private static func specialKeyName(for keyCode: UInt16) -> String? { - switch keyCode { - case 51: return "delete" - case 117: return "forwardDelete" - case 53: return "escape" - case 36: return "return" - case 48: return "tab" - case 49: return "space" - case 126: return "upArrow" - case 125: return "downArrow" - case 123: return "leftArrow" - case 124: return "rightArrow" - case 115: return "home" - case 119: return "end" - case 116: return "pageUp" - case 121: return "pageDown" - default: return nil + return shortcuts + }() + + /// The name of a reserved command this combo would shadow: an app-level menu + /// shortcut (always), or a built-in editor command when the action can fire + /// while the editor is focused. + static func reservedConflict(for key: BoundKey, context: ShortcutContext) -> String? { + if let appName = reservedAppShortcuts.first(where: { $0.key == key })?.name { + return appName } - } - - // MARK: - Event Matching - - /// Check if this combo matches a given NSEvent (for runtime key dispatch) - func matches(_ event: NSEvent) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard command == flags.contains(.command), - shift == flags.contains(.shift), - option == flags.contains(.option), - control == flags.contains(.control) - else { return false } - if isSpecialKey { - return Self.specialKeyName(for: event.keyCode) == key - } - return event.charactersIgnoringModifiers?.lowercased() == key - } - - // MARK: - System Reserved Check - - /// Shortcuts that are reserved by macOS and should not be overridden - static let systemReserved: [KeyCombo] = [ - KeyCombo(key: "q", command: true), // Quit - KeyCombo(key: "h", command: true), // Hide - KeyCombo(key: "m", command: true), // Minimize - KeyCombo(key: ",", command: true), // Settings - KeyCombo(key: "tab", command: true, isSpecialKey: true), // App switcher - KeyCombo(key: "space", command: true, isSpecialKey: true), // Spotlight - KeyCombo(key: "`", command: true), // Window cycling - KeyCombo(key: "escape", command: true, option: true, isSpecialKey: true), // Force Quit - KeyCombo(key: "q", command: true, shift: true), // Logout - KeyCombo(key: "3", command: true, shift: true), // Screenshot full - KeyCombo(key: "4", command: true, shift: true), // Screenshot area - KeyCombo(key: "5", command: true, shift: true), // Screenshot options - KeyCombo(key: "q", command: true, control: true), // Lock Screen - KeyCombo(key: "f", command: true, control: true), // Full Screen - KeyCombo(key: "d", command: true, option: true), // Toggle Dock - KeyCombo(key: "d", command: true, control: true), // Look Up / Define - ] - - /// Check if this combo is reserved by the system - var isSystemReserved: Bool { - Self.systemReserved.contains(self) + guard context == .editor || context == .global else { return nil } + return editorBuiltIns.first(where: { $0.key == key })?.name } } // MARK: - Keyboard Settings -/// User's keyboard shortcut customization settings -/// Only stores overrides — empty dictionary means all defaults +/// User's keyboard shortcut customization settings. +/// Only stores overrides; an empty dictionary means all defaults. struct KeyboardSettings: Codable, Equatable { - /// User-customized shortcuts (action rawValue → KeyCombo) - /// Only contains overrides; missing entries use defaults. - /// Keys are ShortcutAction raw values — if a raw value is renamed in a future version, - /// the old stored key becomes a harmless no-op (never matched by any action). - var shortcuts: [String: KeyCombo] + /// User-customized shortcuts (action rawValue -> BoundKey). + /// Only contains overrides; missing entries use defaults. A renamed action's + /// stale key becomes a harmless no-op (never matched by any action). + var shortcuts: [String: BoundKey] static let `default` = KeyboardSettings(shortcuts: [:]) - init(shortcuts: [String: KeyCombo] = [:]) { + init(shortcuts: [String: BoundKey] = [:]) { self.shortcuts = shortcuts } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - shortcuts = try container.decodeIfPresent([String: KeyCombo].self, forKey: .shortcuts) ?? [:] + if let modern = try? container.decodeIfPresent([String: BoundKey].self, forKey: .shortcuts) { + shortcuts = modern ?? [:] + } else if let legacy = try container.decodeIfPresent([String: LegacyKeyCombo].self, forKey: .shortcuts) { + shortcuts = legacy.compactMapValues { $0.migrated() } + } else { + shortcuts = [:] + } } - /// Get the effective shortcut for an action (user override or default) - /// Returns nil if user explicitly cleared the shortcut - func shortcut(for action: ShortcutAction) -> KeyCombo? { + /// Get the effective shortcut for an action (user override or default). + /// Returns nil if the user explicitly cleared the shortcut. + func shortcut(for action: ShortcutAction) -> BoundKey? { if let override = shortcuts[action.rawValue] { return override } return Self.defaultShortcuts[action] } - /// Check if user has customized the shortcut for an action func isCustomized(_ action: ShortcutAction) -> Bool { shortcuts[action.rawValue] != nil } - /// Find a conflicting action for the given combo, excluding the specified action - func findConflict(for combo: KeyCombo, excluding action: ShortcutAction) -> ShortcutAction? { - for otherAction in ShortcutAction.allCases where otherAction != action { - if shortcut(for: otherAction) == combo { - return otherAction - } + /// Find a conflicting action for the given combo within an overlapping + /// context, excluding the specified action. + func findConflict(for key: BoundKey, excluding action: ShortcutAction) -> ShortcutAction? { + guard !key.isCleared else { return nil } + for other in ShortcutAction.allCases where other != action { + guard action.context.overlaps(other.context), + let otherKey = shortcut(for: other), !otherKey.isCleared, + otherKey == key else { continue } + return other } return nil } - /// Set a shortcut override for an action - mutating func setShortcut(_ combo: KeyCombo, for action: ShortcutAction) { - shortcuts[action.rawValue] = combo + mutating func setShortcut(_ key: BoundKey, for action: ShortcutAction) { + shortcuts[action.rawValue] = key } - /// Clear a shortcut (remove it, action will have no shortcut) + /// Clear a shortcut so the action has no binding. mutating func clearShortcut(for action: ShortcutAction) { - // Store a special "empty" combo to indicate explicitly unassigned - shortcuts[action.rawValue] = KeyCombo.cleared + shortcuts[action.rawValue] = BoundKey.cleared } - /// Reset a specific action to its default shortcut + /// Reset a specific action to its default shortcut. mutating func resetToDefault(for action: ShortcutAction) { shortcuts.removeValue(forKey: action.rawValue) } @@ -476,99 +343,117 @@ struct KeyboardSettings: Codable, Equatable { /// reverting them to their default. Cleared and unknown overrides are kept. func sanitized() -> KeyboardSettings { var cleaned = shortcuts - for (rawValue, combo) in shortcuts { - guard let action = ShortcutAction(rawValue: rawValue), !combo.isCleared else { continue } - if !combo.hasModifier, !action.allowsBareKey { + for (rawValue, key) in shortcuts { + guard let action = ShortcutAction(rawValue: rawValue), !key.isCleared else { continue } + if !key.hasModifier, !action.allowsBareKey, !key.isFunctionKey { cleaned.removeValue(forKey: rawValue) } } return KeyboardSettings(shortcuts: cleaned) } - /// Build a SwiftUI KeyboardShortcut for the given action. - /// Returns nil if the user has cleared (unassigned) the shortcut. + /// Build a SwiftUI KeyboardShortcut for the given action's menu item. + /// Returns nil when the shortcut is cleared, has no representable key, or is a + /// bare (modifier-less) key. Bare keys dispatch through the responder chain in + /// the focused view, not through a global menu key-equivalent. func keyboardShortcut(for action: ShortcutAction) -> KeyboardShortcut? { - guard let combo = shortcut(for: action), !combo.isCleared else { + guard let key = shortcut(for: action), !key.isCleared, key.hasModifier || key.isFunctionKey, + let equivalent = key.swiftUIKeyEquivalent else { return nil } - return KeyboardShortcut(combo.keyEquivalent, modifiers: combo.eventModifiers) + return KeyboardShortcut(equivalent, modifiers: key.eventModifiers) } // MARK: - Default Shortcuts - /// Default shortcuts — applied when user has no overrides - static let defaultShortcuts: [ShortcutAction: KeyCombo] = [ - // File - .manageConnections: KeyCombo(key: "n", command: true), - .executeQuery: KeyCombo(key: "return", command: true, isSpecialKey: true), - .executeAllStatements: KeyCombo(key: "return", command: true, shift: true, isSpecialKey: true), - .cancelQuery: KeyCombo(key: ".", command: true), - .newTab: KeyCombo(key: "t", command: true), - .openDatabase: KeyCombo(key: "k", command: true), - .openFile: KeyCombo(key: "o", command: true), - .switchConnection: KeyCombo(key: "c", command: true, control: true), - .saveChanges: KeyCombo(key: "s", command: true), - .saveAs: KeyCombo(key: "s", command: true, shift: true), - .previewSQL: KeyCombo(key: "p", command: true, shift: true), - .closeTab: KeyCombo(key: "w", command: true), - .refresh: KeyCombo(key: "r", command: true), - .explainQuery: KeyCombo(key: "e", command: true, option: true), - .formatQuery: KeyCombo(key: "l", command: true, shift: true), - .export: KeyCombo(key: "e", command: true, shift: true), - .importData: KeyCombo(key: "i", command: true, shift: true), - .quickSwitcher: KeyCombo(key: "o", command: true, shift: true), - .previousPage: KeyCombo(key: "[", command: true), - .nextPage: KeyCombo(key: "]", command: true), - - // Edit - .undo: KeyCombo(key: "z", command: true), - .redo: KeyCombo(key: "z", command: true, shift: true), - .cut: KeyCombo(key: "x", command: true), - .copy: KeyCombo(key: "c", command: true), - .copyRowsExplicit: KeyCombo(key: "c", command: true, shift: true), - .copyWithHeaders: KeyCombo(key: "c", command: true, option: true), - .copyAsJson: KeyCombo(key: "j", command: true, option: true), - .paste: KeyCombo(key: "v", command: true), - .delete: KeyCombo(key: "delete", command: true, isSpecialKey: true), - .selectAll: KeyCombo(key: "a", command: true), - .clearSelection: KeyCombo(key: "escape", isSpecialKey: true), - .addRow: KeyCombo(key: "n", command: true, shift: true), - .duplicateRow: KeyCombo(key: "d", command: true, shift: true), - .truncateTable: KeyCombo(key: "delete", option: true, isSpecialKey: true), - .previewFKReference: KeyCombo(key: "space", isSpecialKey: true), - .saveAsFavorite: KeyCombo(key: "d", command: true), - - // View - .toggleTableBrowser: KeyCombo(key: "0", command: true), - .toggleInspector: KeyCombo(key: "i", command: true, option: true), - .toggleFilters: KeyCombo(key: "f", command: true), - .toggleHistory: KeyCombo(key: "y", command: true), - .toggleResults: KeyCombo(key: "r", command: true, option: true), - .previousResultTab: KeyCombo(key: "[", command: true, option: true), - .nextResultTab: KeyCombo(key: "]", command: true, option: true), - .closeResultTab: KeyCombo(key: "w", command: true, shift: true), - .focusSidebarSearch: KeyCombo(key: "f", command: true, option: true), - .showSidebarTables: KeyCombo(key: "1", control: true), - .showSidebarFavorites: KeyCombo(key: "2", control: true), - - // Tabs - .showPreviousTab: KeyCombo(key: "[", command: true, shift: true), - .showNextTab: KeyCombo(key: "]", command: true, shift: true), - - // AI - .aiExplainQuery: KeyCombo(key: "l", command: true), - .aiOptimizeQuery: KeyCombo(key: "l", command: true, option: true), + /// Default shortcuts, applied when the user has no override. An action absent + /// from this map has no default and shows as unassigned until the user binds it. + static let defaultShortcuts: [ShortcutAction: BoundKey] = [ + // Connections + .newConnection: .character("n", command: true), + .openDatabase: .character("k", command: true), + .switchConnection: .character("c", command: true, control: true), + + // Editor & Query + .openFile: .character("o", command: true), + .saveChanges: .character("s", command: true), + .saveAs: .character("s", command: true, shift: true), + .executeQuery: .special(.return, command: true), + .executeAllStatements: .special(.return, command: true, shift: true), + .cancelQuery: .character(".", command: true), + .explainQuery: .character("e", command: true, option: true), + .formatQuery: .character("l", command: true, shift: true), + .previewSQL: .character("p", command: true, shift: true), + .findNext: .character("g", command: true), + .findPrevious: .character("g", command: true, shift: true), + .aiExplainQuery: .character("l", command: true), + .aiOptimizeQuery: .character("l", command: true, option: true), + .export: .character("e", command: true, shift: true), + .importData: .character("i", command: true, shift: true), + + // Data Grid + .undo: .character("z", command: true), + .redo: .character("z", command: true, shift: true), + .cut: .character("x", command: true), + .copy: .character("c", command: true), + .copyRowsExplicit: .character("c", command: true, shift: true), + .copyWithHeaders: .character("c", command: true, option: true), + .copyAsJson: .character("j", command: true, option: true), + .paste: .character("v", command: true), + .delete: .special(.delete, command: true), + .selectAll: .character("a", command: true), + .clearSelection: .special(.escape), + .addRow: .character("n", command: true, shift: true), + .duplicateRow: .character("d", command: true, shift: true), + .truncateTable: .special(.delete, option: true), + .previewFKReference: .special(.space), + .saveAsFavorite: .character("d", command: true), + .previousPage: .character("[", command: true), + .nextPage: .character("]", command: true), + .firstPage: .special(.upArrow, command: true, option: true), + .lastPage: .special(.downArrow, command: true, option: true), + .refresh: .character("r", command: true), + + // Navigation + .newTab: .character("t", command: true), + .closeTab: .character("w", command: true), + .quickSwitcher: .character("o", command: true, shift: true), + .toggleTableBrowser: .character("0", command: true), + .toggleInspector: .character("i", command: true, option: true), + .toggleFilters: .character("f", command: true), + .toggleHistory: .character("y", command: true), + .toggleResults: .character("r", command: true, option: true), + .previousResultTab: .character("[", command: true, option: true), + .nextResultTab: .character("]", command: true, option: true), + .closeResultTab: .character("w", command: true, shift: true), + .focusSidebarSearch: .character("f", command: true, option: true), + .showSidebarTables: .character("1", command: true, option: true), + .showSidebarFavorites: .character("2", command: true, option: true), + .showPreviousTab: .character("[", command: true, shift: true), + .showNextTab: .character("]", command: true, shift: true) ] } -// MARK: - KeyCombo Cleared Sentinel +// MARK: - Legacy Migration -extension KeyCombo { - /// Sentinel value representing an explicitly cleared (unassigned) shortcut - static let cleared = KeyCombo(key: "", command: false, shift: false, option: false, control: false, isSpecialKey: false) - - /// Whether this combo represents an explicitly cleared shortcut - var isCleared: Bool { - key.isEmpty && !command && !shift && !option && !control +/// The pre-keyCode stored shape of a shortcut. Used only to migrate persisted +/// settings to BoundKey. +private struct LegacyKeyCombo: Codable { + let key: String + var command = false + var shift = false + var option = false + var control = false + var isSpecialKey = false + + func migrated() -> BoundKey? { + BoundKey( + legacyKey: key, + isSpecialKey: isSpecialKey, + command: command, + shift: shift, + option: option, + control: control + ) } } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index a6444ddf9..d0dde7d56 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -235,6 +235,11 @@ struct AppMenuCommands: Commands { // File menu CommandGroup(replacing: .newItem) { + Button(String(localized: "New Connection...")) { + WindowOpener.shared.openConnectionForm() + } + .optionalKeyboardShortcut(shortcut(for: .newConnection)) + Button("Manage Connections") { WindowOpener.shared.openWelcome() } @@ -531,6 +536,18 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(commandFRoute == .tableFilter ? nil : KeyboardShortcut("f", modifiers: .command)) .disabled(commandFRoute == .tableFilter) + Button(String(localized: "Find Next")) { + EditorEventRouter.shared.findNext() + } + .optionalKeyboardShortcut(shortcut(for: .findNext)) + .disabled(!(actions?.isConnected ?? false)) + + Button(String(localized: "Find Previous")) { + EditorEventRouter.shared.findPrevious() + } + .optionalKeyboardShortcut(shortcut(for: .findPrevious)) + .disabled(!(actions?.isConnected ?? false)) + Divider() Button("Add Row") { diff --git a/TablePro/Views/Components/PaginationControlsView.swift b/TablePro/Views/Components/PaginationControlsView.swift index 89fd83ba6..4abbeda14 100644 --- a/TablePro/Views/Components/PaginationControlsView.swift +++ b/TablePro/Views/Components/PaginationControlsView.swift @@ -129,9 +129,15 @@ struct PaginationControlsView: View { } .buttonStyle(.borderless) .disabled(!enabled || pagination.isLoading) - .help(label) + .help(helpText(label, for: shortcut)) .accessibilityLabel(label) - .optionalKeyboardShortcut(AppSettingsManager.shared.keyboard.keyboardShortcut(for: shortcut)) + } + + private func helpText(_ label: String, for shortcut: ShortcutAction) -> String { + guard let combo = AppSettingsManager.shared.keyboard.shortcut(for: shortcut), !combo.isCleared else { + return label + } + return "\(label) (\(combo.displayString))" } private var pageIndicator: some View { diff --git a/TablePro/Views/Editor/EditorEventRouter.swift b/TablePro/Views/Editor/EditorEventRouter.swift index 4a1acb86d..88b2b57ee 100644 --- a/TablePro/Views/Editor/EditorEventRouter.swift +++ b/TablePro/Views/Editor/EditorEventRouter.swift @@ -22,7 +22,6 @@ internal final class EditorEventRouter { private var editors: [ObjectIdentifier: EditorRef] = [:] private var rightClickMonitor: Any? - private var clipboardMonitor: Any? private init() {} @@ -95,6 +94,16 @@ internal final class EditorEventRouter { coordinator.showFindPanel() } + internal func findNext() { + guard let (coordinator, _) = editor(for: NSApp.keyWindow) else { return } + coordinator.findNext() + } + + internal func findPrevious() { + guard let (coordinator, _) = editor(for: NSApp.keyWindow) else { return } + coordinator.findPrevious() + } + /// Called by the SwiftUI "Clear Selection" menu when its Esc key equivalent fires. /// Routes the keystroke to the active editor's Vim engine if it is in a non-normal /// mode. Returns true when Vim consumed the escape — caller should suppress its @@ -131,14 +140,6 @@ internal final class EditorEventRouter { self.handleRightClick(event) } } - - clipboardMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] nsEvent in - guard let self else { return nsEvent } - nonisolated(unsafe) let event = nsEvent - return MainActor.assumeIsolated { - self.handleKeyDown(event) - } - } } private func removeMonitors() { @@ -146,10 +147,6 @@ internal final class EditorEventRouter { NSEvent.removeMonitor(monitor) rightClickMonitor = nil } - if let monitor = clipboardMonitor { - NSEvent.removeMonitor(monitor) - clipboardMonitor = nil - } } // MARK: - Event Handlers @@ -163,42 +160,4 @@ internal final class EditorEventRouter { coordinator.showContextMenu(for: event, in: textView) return nil } - - private func handleKeyDown(_ event: NSEvent) -> NSEvent? { - guard let (_, textView) = editor(for: event.window), - textView.window?.firstResponder === textView else { - return event - } - - let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard mods.contains(.command), - !mods.contains(.shift), !mods.contains(.option), !mods.contains(.control) else { - return event - } - - let selection = textView.selectedRange() - - switch event.keyCode { - case 8: // Cmd+C - guard selection.length > 0 else { return event } - let text = (textView.string as NSString).substring(with: selection) - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - return nil - case 7: // Cmd+X - guard let result = LineCutCalculator.calculate( - text: textView.string, selection: selection - ) else { - return event - } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(result.clipboardText, forType: .string) - textView.replaceCharacters(in: result.rangeToDelete, with: "") - return nil - default: - break - } - - return event - } } diff --git a/TablePro/Views/Editor/LineCutCalculator.swift b/TablePro/Views/Editor/LineCutCalculator.swift deleted file mode 100644 index 546dce8ee..000000000 --- a/TablePro/Views/Editor/LineCutCalculator.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// LineCutCalculator.swift -// TablePro -// - -import Foundation - -/// Pure logic for resolving a Cmd+X cut operation on the SQL editor's text -/// view. When a selection exists the selection is the cut target; with no -/// selection the entire current line (including its trailing newline, if any) -/// is the cut target — matching the convention used by VS Code, Sublime, -/// JetBrains IDEs, and Xcode's source editor. -enum LineCutCalculator { - struct Result: Equatable { - let rangeToDelete: NSRange - let clipboardText: String - } - - static func calculate(text: String, selection: NSRange) -> Result? { - let nsText = text as NSString - guard nsText.length > 0 else { return nil } - guard selection.location >= 0, - selection.location <= nsText.length, - selection.location + selection.length <= nsText.length else { - return nil - } - - if selection.length > 0 { - return Result( - rangeToDelete: selection, - clipboardText: nsText.substring(with: selection) - ) - } - - let lineRange = nsText.lineRange(for: NSRange(location: selection.location, length: 0)) - guard lineRange.length > 0 else { return nil } - return Result( - rangeToDelete: lineRange, - clipboardText: nsText.substring(with: lineRange) - ) - } -} diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 6a5636658..4f3ddf643 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -521,6 +521,14 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { controller?.showFindPanel() } + func findNext() { + controller?.findNext() + } + + func findPrevious() { + controller?.findPrevious() + } + // MARK: - CodeEditSourceEditor Workarounds /// Reorder FindViewController's subviews so the find panel is on top for hit testing. diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 5e90a694b..050d54120 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -334,7 +334,7 @@ final class KeyHandlingTableView: NSTableView { super.keyDown(with: event) return case .delete, .forwardDelete: - if modifiers.isEmpty || modifiers == .command { + if modifiers.isEmpty || matchesDeleteShortcut(event) { deleteSelectedRowsIfPossible() return } @@ -361,6 +361,13 @@ final class KeyHandlingTableView: NSTableView { interpretKeyEvents([event]) } + private func matchesDeleteShortcut(_ event: NSEvent) -> Bool { + guard let combo = AppSettingsManager.shared.keyboard.shortcut(for: .delete), !combo.isCleared else { + return false + } + return combo.matches(event) + } + private func handleHorizontalArrow(direction: GridSelectionController.Direction, modifiers: NSEvent.ModifierFlags, currentRow: Int) { if modifiers.contains(.shift), let controller = gridSelection, !controller.isEmpty { controller.extendActiveCell(direction: direction, jumpToEdge: modifiers.contains(.command), totalRows: totalRows(), totalColumns: totalDataColumns()) diff --git a/TablePro/Views/Settings/KeyboardSettingsView.swift b/TablePro/Views/Settings/KeyboardSettingsView.swift index bd2c318b3..836322344 100644 --- a/TablePro/Views/Settings/KeyboardSettingsView.swift +++ b/TablePro/Views/Settings/KeyboardSettingsView.swift @@ -15,9 +15,10 @@ struct KeyboardSettingsView: View { @State private var searchText = "" @State private var conflictAlert: ConflictAlertState? @State private var systemReservedAlert: ShortcutAction? + @State private var reservedAlert: ReservedAlertState? @State private var needsModifierAlert: ShortcutAction? - var body: some View { + private var content: some View { VStack(spacing: 0) { NativeSearchField( text: $searchText, @@ -27,7 +28,6 @@ struct KeyboardSettingsView: View { .padding(.top, 16) .padding(.bottom, 8) - // Shortcut list Form { ForEach(ShortcutCategory.allCases) { category in let actions = filteredActions(for: category) @@ -52,6 +52,10 @@ struct KeyboardSettingsView: View { } .formStyle(.grouped) } + } + + var body: some View { + content .alert( String(localized: "Shortcut Conflict"), isPresented: Binding( @@ -64,9 +68,7 @@ struct KeyboardSettingsView: View { } Button(String(localized: "Reassign")) { if let state = conflictAlert { - // Clear the conflicting action's shortcut settings.clearShortcut(for: state.conflictingAction) - // Assign the new combo to the intended action settings.setShortcut(state.combo, for: state.action) } conflictAlert = nil @@ -74,7 +76,12 @@ struct KeyboardSettingsView: View { } message: { if let state = conflictAlert { Text( - "\(state.combo.displayString) is already assigned to \"\(state.conflictingAction.displayName)\". Reassigning will remove it from that action." + String( + format: String(localized: "%@ is already used by \"%@\" in %@. Reassigning removes it from that action."), + state.combo.displayString, + state.conflictingAction.displayName, + state.conflictingAction.category.displayName + ) ) } } @@ -91,6 +98,26 @@ struct KeyboardSettingsView: View { } message: { Text(String(localized: "This shortcut is reserved by macOS and cannot be assigned.")) } + .alert( + String(localized: "Reserved Shortcut"), + isPresented: Binding( + get: { reservedAlert != nil }, + set: { if !$0 { reservedAlert = nil } } + ) + ) { + Button(String(localized: "OK"), role: .cancel) { + reservedAlert = nil + } + } message: { + if let state = reservedAlert { + Text( + String( + format: String(localized: "This shortcut is reserved for \"%@\" and cannot be assigned."), + state.name + ) + ) + } + } .alert( String(localized: "Modifier Key Required"), isPresented: Binding( @@ -104,16 +131,31 @@ struct KeyboardSettingsView: View { } message: { Text(String(localized: "This action needs a modifier key like ⌘ or ⌥. A plain key won't reach the menu reliably.")) } + .onAppear { + SystemHotkeyChecker.shared.reload() + } } // MARK: - Shortcut Row @ViewBuilder private func shortcutRow(for action: ShortcutAction) -> some View { - HStack { + HStack(spacing: 8) { Text(action.displayName) .frame(maxWidth: .infinity, alignment: .leading) + if settings.isCustomized(action) { + Button { + settings.resetToDefault(for: action) + } label: { + Image(systemName: "arrow.uturn.backward") + } + .buttonStyle(.borderless) + .controlSize(.small) + .help(String(localized: "Reset to default")) + .accessibilityLabel(String(localized: "Reset to default")) + } + ShortcutRecorderView( combo: Binding( get: { settings.shortcut(for: action) }, @@ -141,34 +183,34 @@ struct KeyboardSettingsView: View { return categoryActions.filter { $0.displayName.lowercased().contains(query) } } - private func handleRecord(_ combo: KeyCombo, for action: ShortcutAction) { - if combo.isSystemReserved { - systemReservedAlert = action - return - } - - if !combo.hasModifier, !action.allowsBareKey { + private func handleRecord(_ key: BoundKey, for action: ShortcutAction) { + if !key.hasModifier, !action.allowsBareKey, !key.isFunctionKey { needsModifierAlert = action return } - if let conflict = settings.findConflict(for: combo, excluding: action) { - conflictAlert = ConflictAlertState( - action: action, - conflictingAction: conflict, - combo: combo - ) - return + switch ShortcutConflictResolver.resolve(key, for: action, in: settings) { + case .none: + settings.setShortcut(key, for: action) + case .systemReserved: + systemReservedAlert = action + case .reserved(let name): + reservedAlert = ReservedAlertState(action: action, name: name) + case .otherAction(let other): + conflictAlert = ConflictAlertState(action: action, conflictingAction: other, combo: key) } - - settings.setShortcut(combo, for: action) } } -// MARK: - Conflict Alert State +// MARK: - Alert State private struct ConflictAlertState { let action: ShortcutAction let conflictingAction: ShortcutAction - let combo: KeyCombo + let combo: BoundKey +} + +private struct ReservedAlertState { + let action: ShortcutAction + let name: String } diff --git a/TablePro/Views/Settings/ShortcutRecorderView.swift b/TablePro/Views/Settings/ShortcutRecorderView.swift index ac559821c..0e382e919 100644 --- a/TablePro/Views/Settings/ShortcutRecorderView.swift +++ b/TablePro/Views/Settings/ShortcutRecorderView.swift @@ -13,13 +13,13 @@ import SwiftUI /// AppKit NSView that captures keyboard shortcuts via press-to-record interaction final class ShortcutRecorderNSView: NSView { /// Callback when a valid shortcut is recorded - var onRecord: ((KeyCombo) -> Void)? + var onRecord: ((BoundKey) -> Void)? /// Callback when the shortcut is cleared (Delete key while recording) var onClear: (() -> Void)? /// The currently displayed key combo - var currentCombo: KeyCombo? { + var currentCombo: BoundKey? { didSet { needsDisplay = true } } @@ -79,27 +79,20 @@ final class ShortcutRecorderNSView: NSView { override func keyDown(with event: NSEvent) { guard isRecording else { return } - // Escape cancels recording - if event.keyCode == 53 - && !event.modifierFlags.contains(.command) - && !event.modifierFlags.contains(.control) - { + let isBareKey = !event.modifierFlags.contains(.command) && !event.modifierFlags.contains(.control) + + if event.keyCode == KeyCode.escape.rawValue, isBareKey { window?.makeFirstResponder(nil) return } - // Delete/Backspace clears the shortcut - if event.keyCode == 51 - && !event.modifierFlags.contains(.command) - && !event.modifierFlags.contains(.control) - { + if event.keyCode == KeyCode.delete.rawValue, isBareKey { onClear?() window?.makeFirstResponder(nil) return } - // Try to create a KeyCombo from the event - if let combo = KeyCombo(from: event) { + if let combo = BoundKey(from: event) { onRecord?(combo) window?.makeFirstResponder(nil) } else { @@ -224,10 +217,10 @@ final class ShortcutRecorderNSView: NSView { /// SwiftUI wrapper for the AppKit shortcut recorder struct ShortcutRecorderView: NSViewRepresentable { - @Binding var combo: KeyCombo? + @Binding var combo: BoundKey? /// Called when a new combo is recorded (before setting binding) - var onRecord: ((KeyCombo) -> Void)? + var onRecord: ((BoundKey) -> Void)? /// Called when the shortcut is cleared var onClear: (() -> Void)? diff --git a/TableProTests/Models/KeyboardShortcutTests.swift b/TableProTests/Models/KeyboardShortcutTests.swift index db89cf6f8..dd16ba4aa 100644 --- a/TableProTests/Models/KeyboardShortcutTests.swift +++ b/TableProTests/Models/KeyboardShortcutTests.swift @@ -2,9 +2,9 @@ // KeyboardShortcutTests.swift // TableProTests // -// Pins the shortcut customization fixes for #1357: Execute Query / Cancel Query -// are customizable, bare keys are rejected for menu-driven actions, and stale -// bare-key overrides self-heal on load. +// Pins the keyboard shortcut model: keyCode-based defaults, context-scoped +// conflict detection, the built-in editor shortcut registry, bare-key handling, +// and migration from the legacy character-string storage. // import Foundation @@ -15,39 +15,92 @@ import Testing struct ShortcutActionDefaultsTests { @Test("Execute Query default is Cmd+Return") func executeQueryDefault() { - #expect(KeyboardSettings.defaultShortcuts[.executeQuery] == KeyCombo(key: "return", command: true, isSpecialKey: true)) + #expect(KeyboardSettings.defaultShortcuts[.executeQuery] == .special(.return, command: true)) } @Test("Execute All Statements default is Cmd+Shift+Return") func executeAllStatementsDefault() { - #expect( - KeyboardSettings.defaultShortcuts[.executeAllStatements] - == KeyCombo(key: "return", command: true, shift: true, isSpecialKey: true) - ) + #expect(KeyboardSettings.defaultShortcuts[.executeAllStatements] == .special(.return, command: true, shift: true)) } @Test("Cancel Query default is Cmd+.") func cancelQueryDefault() { - #expect(KeyboardSettings.defaultShortcuts[.cancelQuery] == KeyCombo(key: ".", command: true)) + #expect(KeyboardSettings.defaultShortcuts[.cancelQuery] == .character(".", command: true)) } - @Test("Save as Favorite default is Cmd+D") - func saveAsFavoriteDefault() { - #expect(KeyboardSettings.defaultShortcuts[.saveAsFavorite] == KeyCombo(key: "d", command: true)) + @Test("New Connection default is Cmd+N") + func newConnectionDefault() { + #expect(KeyboardSettings.defaultShortcuts[.newConnection] == .character("n", command: true)) + } + + @Test("First and Last Page have defaults") + func paginationEdgeDefaults() { + #expect(KeyboardSettings.defaultShortcuts[.firstPage] != nil) + #expect(KeyboardSettings.defaultShortcuts[.lastPage] != nil) + } + + @Test("Find Next and Find Previous have defaults") + func findDefaults() { + #expect(KeyboardSettings.defaultShortcuts[.findNext] == .character("g", command: true)) + #expect(KeyboardSettings.defaultShortcuts[.findPrevious] == .character("g", command: true, shift: true)) } } -@Suite("System reserved shortcuts") -struct SystemReservedShortcutTests { - @Test("Ctrl+Cmd+D is reserved by macOS for Look Up") - func ctrlCmdDIsReserved() { - #expect(KeyCombo(key: "d", command: true, control: true).isSystemReserved) +@Suite("Default shortcut hygiene") +struct DefaultShortcutHygieneTests { + @Test("No default uses Control without Command") + func noBareControlDefaults() { + for (action, key) in KeyboardSettings.defaultShortcuts where key.control && !key.command { + Issue.record("\(action.rawValue) uses Control without Command: \(key.displayString)") + } + } + + @Test("No two defaults collide within overlapping contexts") + func defaultsAreUniqueWithinContext() { + let entries = Array(KeyboardSettings.defaultShortcuts) + for outer in 0.. KeyboardSettings { + try JSONDecoder().decode(KeyboardSettings.self, from: Data(json.utf8)) + } + + @Test("Legacy character shortcut migrates to its key code") + func migratesCharacterShortcut() throws { + let json = """ + {"shortcuts":{"saveChanges":{"key":"s","command":true,"shift":false,"option":false,"control":false,"isSpecialKey":false}}} + """ + let settings = try decode(json) + #expect(settings.shortcut(for: .saveChanges) == .character("s", command: true)) + } + + @Test("Legacy special shortcut migrates to its key code") + func migratesSpecialShortcut() throws { + let json = """ + {"shortcuts":{"executeQuery":{"key":"return","command":true,"shift":false,"option":false,"control":false,"isSpecialKey":true}}} + """ + let settings = try decode(json) + #expect(settings.shortcut(for: .executeQuery) == .special(.return, command: true)) + } + + @Test("Modern keyCode shortcut decodes unchanged") + func decodesModernShortcut() throws { + let original = KeyboardSettings(shortcuts: [ShortcutAction.toggleHistory.rawValue: .character("y", command: true, shift: true)]) + let data = try JSONEncoder().encode(original) + let roundTripped = try JSONDecoder().decode(KeyboardSettings.self, from: data) + #expect(roundTripped == original) + } + + @Test("Legacy cleared shortcut migrates to the cleared sentinel") + func migratesClearedShortcut() throws { + let json = """ + {"shortcuts":{"executeQuery":{"key":"","command":false,"shift":false,"option":false,"control":false,"isSpecialKey":false}}} + """ + let settings = try decode(json) + #expect(settings.shortcut(for: .executeQuery)?.isCleared == true) } } diff --git a/TableProTests/Models/UI/BoundKeyMatchTests.swift b/TableProTests/Models/UI/BoundKeyMatchTests.swift new file mode 100644 index 000000000..c64614060 --- /dev/null +++ b/TableProTests/Models/UI/BoundKeyMatchTests.swift @@ -0,0 +1,135 @@ +import AppKit +@testable import TablePro +import Testing + +@Suite("BoundKey Event Matching") +struct BoundKeyMatchTests { + // MARK: - Helper + + private func makeEvent( + keyCode: UInt16, + characters: String = "", + modifiers: NSEvent.ModifierFlags = [] + ) -> NSEvent { + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + )! // swiftlint:disable:this force_unwrapping + } + + // MARK: - Modifier Combos + + @Test("Cmd+S matches the correct event") + func cmdSMatches() { + let key = BoundKey(keyCode: KeyCode.s.rawValue, command: true) + let event = makeEvent(keyCode: KeyCode.s.rawValue, characters: "s", modifiers: .command) + #expect(key.matches(event)) + } + + @Test("Cmd+S does not match Cmd+Shift+S") + func cmdSRejectsCmdShiftS() { + let key = BoundKey(keyCode: KeyCode.s.rawValue, command: true) + let event = makeEvent(keyCode: KeyCode.s.rawValue, characters: "s", modifiers: [.command, .shift]) + #expect(!key.matches(event)) + } + + // MARK: - Shifted Symbols (the bug the keyCode model fixes) + + @Test("Cmd+Shift+[ matches by key code even though the event reports the shifted glyph") + func shiftedBracketMatchesByKeyCode() { + let key = BoundKey(keyCode: KeyCode.leftBracket.rawValue, command: true, shift: true) + // charactersIgnoringModifiers applies Shift, so the event reports "{", not "[". + let event = makeEvent(keyCode: KeyCode.leftBracket.rawValue, characters: "{", modifiers: [.command, .shift]) + #expect(key.matches(event)) + } + + // MARK: - Layout Independence + + @Test("Cmd+C matches by key code regardless of the produced character") + func layoutIndependentMatch() { + let key = BoundKey(keyCode: KeyCode.c.rawValue, command: true) + let event = makeEvent(keyCode: KeyCode.c.rawValue, characters: "ç", modifiers: .command) + #expect(key.matches(event)) + } + + // MARK: - Special Keys + + @Test("Cmd+Delete matches the delete key event") + func deleteMatches() { + let key = BoundKey.special(.delete, command: true) + let event = makeEvent(keyCode: KeyCode.delete.rawValue, modifiers: .command) + #expect(key.matches(event)) + } + + @Test("Bare space matches a space key event") + func bareSpaceMatches() { + let key = BoundKey.special(.space) + let event = makeEvent(keyCode: KeyCode.space.rawValue, characters: " ") + #expect(key.matches(event)) + } + + @Test("Bare space does not match Cmd+Space") + func bareSpaceRejectsCmdSpace() { + let key = BoundKey.special(.space) + let event = makeEvent(keyCode: KeyCode.space.rawValue, characters: " ", modifiers: .command) + #expect(!key.matches(event)) + } + + // MARK: - Cleared + + @Test("Cleared combo does not match any event") + func clearedNeverMatches() { + let event = makeEvent(keyCode: KeyCode.space.rawValue, characters: " ") + #expect(!BoundKey.cleared.matches(event)) + } + + // MARK: - Recorder Capture + + @Test("BoundKey(from:) accepts bare space") + func recorderAcceptsBareSpace() { + let event = makeEvent(keyCode: KeyCode.space.rawValue, characters: " ") + let key = BoundKey(from: event) + #expect(key != nil) + #expect(key?.keyCode == KeyCode.space.rawValue) + #expect(key?.command == false) + } + + @Test("BoundKey(from:) rejects a bare letter key") + func recorderRejectsBareLetter() { + let event = makeEvent(keyCode: KeyCode.s.rawValue, characters: "s") + #expect(BoundKey(from: event) == nil) + } + + @Test("BoundKey(from:) records the physical key code") + func recorderCapturesKeyCode() { + let event = makeEvent(keyCode: KeyCode.k.rawValue, characters: "k", modifiers: .command) + let key = BoundKey(from: event) + #expect(key?.keyCode == KeyCode.k.rawValue) + #expect(key?.command == true) + } + + // MARK: - Function Keys + + @Test("BoundKey(from:) accepts a bare function key") + func recorderAcceptsBareFunctionKey() { + let key = BoundKey(from: makeEvent(keyCode: KeyCode.f5.rawValue)) + #expect(key != nil) + #expect(key?.isFunctionKey == true) + #expect(key?.hasModifier == false) + } + + @Test("A function key renders its label and produces a key equivalent") + func functionKeyDisplaysAndRegisters() { + let key = BoundKey(keyCode: KeyCode.f5.rawValue) + #expect(key.displayString == "F5") + #expect(key.swiftUIKeyEquivalent != nil) + } +} diff --git a/TableProTests/Models/UI/KeyComboMatchTests.swift b/TableProTests/Models/UI/KeyComboMatchTests.swift deleted file mode 100644 index 643250312..000000000 --- a/TableProTests/Models/UI/KeyComboMatchTests.swift +++ /dev/null @@ -1,119 +0,0 @@ -import AppKit -import TableProPluginKit -import Testing -@testable import TablePro - -@Suite("KeyCombo Event Matching") -struct KeyComboMatchTests { - - // MARK: - Helper - - private func makeEvent( - keyCode: UInt16, - characters: String = "", - modifiers: NSEvent.ModifierFlags = [] - ) -> NSEvent { - NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: modifiers, - timestamp: 0, - windowNumber: 0, - context: nil, - characters: characters, - charactersIgnoringModifiers: characters, - isARepeat: false, - keyCode: keyCode - )! // swiftlint:disable:this force_unwrapping - } - - // MARK: - Bare Space - - @Test("Bare space combo matches space key event") - func bareSpaceMatches() { - let combo = KeyCombo(key: "space", isSpecialKey: true) - let event = makeEvent(keyCode: 49, characters: " ") - #expect(combo.matches(event)) - } - - @Test("Bare space combo does not match Cmd+Space") - func bareSpaceRejectsCmdSpace() { - let combo = KeyCombo(key: "space", isSpecialKey: true) - let event = makeEvent(keyCode: 49, characters: " ", modifiers: .command) - #expect(!combo.matches(event)) - } - - // MARK: - Modifier Combos - - @Test("Cmd+S matches correct event") - func cmdSMatches() { - let combo = KeyCombo(key: "s", command: true) - let event = makeEvent(keyCode: 1, characters: "s", modifiers: .command) - #expect(combo.matches(event)) - } - - @Test("Cmd+S does not match Cmd+Shift+S") - func cmdSRejectsCmdShiftS() { - let combo = KeyCombo(key: "s", command: true) - let event = makeEvent(keyCode: 1, characters: "s", modifiers: [.command, .shift]) - #expect(!combo.matches(event)) - } - - @Test("Cmd+Shift+S matches correctly") - func cmdShiftSMatches() { - let combo = KeyCombo(key: "s", command: true, shift: true) - let event = makeEvent(keyCode: 1, characters: "s", modifiers: [.command, .shift]) - #expect(combo.matches(event)) - } - - // MARK: - Special Keys - - @Test("Delete combo matches delete key event") - func deleteMatches() { - let combo = KeyCombo(key: "delete", command: true, isSpecialKey: true) - let event = makeEvent(keyCode: 51, modifiers: .command) - #expect(combo.matches(event)) - } - - @Test("Return combo matches return key event") - func returnMatches() { - let combo = KeyCombo(key: "return", command: true, isSpecialKey: true) - let event = makeEvent(keyCode: 36, modifiers: .command) - #expect(combo.matches(event)) - } - - @Test("Special key does not match wrong keyCode") - func specialKeyRejectsWrongCode() { - let combo = KeyCombo(key: "space", isSpecialKey: true) - let event = makeEvent(keyCode: 36, characters: "") // return, not space - #expect(!combo.matches(event)) - } - - // MARK: - Cleared Combo - - @Test("Cleared combo does not match any event") - func clearedComboNeverMatches() { - let combo = KeyCombo.cleared - let event = makeEvent(keyCode: 49, characters: " ") - #expect(!combo.matches(event)) - } - - // MARK: - Bare Space Allowed in Recorder - - @Test("KeyCombo.init(from:) accepts bare space") - func recorderAcceptsBareSpace() { - let event = makeEvent(keyCode: 49, characters: " ") - let combo = KeyCombo(from: event) - #expect(combo != nil) - #expect(combo?.key == "space") - #expect(combo?.isSpecialKey == true) - #expect(combo?.command == false) - } - - @Test("KeyCombo.init(from:) rejects bare letter key") - func recorderRejectsBareLetter() { - let event = makeEvent(keyCode: 1, characters: "s") - let combo = KeyCombo(from: event) - #expect(combo == nil) - } -} diff --git a/TableProTests/Views/Editor/LineCutCalculatorTests.swift b/TableProTests/Views/Editor/LineCutCalculatorTests.swift deleted file mode 100644 index 31c4750ca..000000000 --- a/TableProTests/Views/Editor/LineCutCalculatorTests.swift +++ /dev/null @@ -1,165 +0,0 @@ -// -// LineCutCalculatorTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -@testable import TablePro -import Testing - -@Suite("Line Cut Calculator") -struct LineCutCalculatorTests { - // MARK: - With Selection (existing Cmd+X behavior must not regress) - - @Test("Selection cuts only the selected text") - func selectionCutsSelectedText() { - let result = LineCutCalculator.calculate( - text: "hello world", - selection: NSRange(location: 6, length: 5) - ) - #expect(result == LineCutCalculator.Result( - rangeToDelete: NSRange(location: 6, length: 5), - clipboardText: "world" - )) - } - - @Test("Multi-line selection cuts only the selected substring") - func multiLineSelectionCutsSubstring() { - let result = LineCutCalculator.calculate( - text: "line1\nline2\nline3", - selection: NSRange(location: 3, length: 6) - ) - #expect(result == LineCutCalculator.Result( - rangeToDelete: NSRange(location: 3, length: 6), - clipboardText: "e1\nlin" - )) - } - - // MARK: - No Selection: cut current line (issue #1075) - - @Test("Single line without terminator cuts the entire content") - func singleLineNoTerminatorCutsAll() { - let result = LineCutCalculator.calculate( - text: "select * from users", - selection: NSRange(location: 5, length: 0) - ) - #expect(result == LineCutCalculator.Result( - rangeToDelete: NSRange(location: 0, length: 19), - clipboardText: "select * from users" - )) - } - - @Test("First line of multi-line cuts line plus trailing newline") - func firstLineCutsWithNewline() { - let result = LineCutCalculator.calculate( - text: "line1\nline2\nline3", - selection: NSRange(location: 2, length: 0) - ) - #expect(result == LineCutCalculator.Result( - rangeToDelete: NSRange(location: 0, length: 6), - clipboardText: "line1\n" - )) - } - - @Test("Middle line cuts line plus trailing newline") - func middleLineCutsWithNewline() { - let result = LineCutCalculator.calculate( - text: "line1\nline2\nline3", - selection: NSRange(location: 8, length: 0) - ) - #expect(result == LineCutCalculator.Result( - rangeToDelete: NSRange(location: 6, length: 6), - clipboardText: "line2\n" - )) - } - - @Test("Last line without trailing newline cuts the line text only") - func lastLineNoTerminatorCutsLineOnly() { - let result = LineCutCalculator.calculate( - text: "line1\nline2\nline3", - selection: NSRange(location: 14, length: 0) - ) - #expect(result == LineCutCalculator.Result( - rangeToDelete: NSRange(location: 12, length: 5), - clipboardText: "line3" - )) - } - - @Test("Last line with trailing newline cuts line plus newline") - func lastLineWithTerminatorCutsWithNewline() { - let result = LineCutCalculator.calculate( - text: "line1\nline2\n", - selection: NSRange(location: 8, length: 0) - ) - #expect(result == LineCutCalculator.Result( - rangeToDelete: NSRange(location: 6, length: 6), - clipboardText: "line2\n" - )) - } - - @Test("Cursor at start of line cuts that line") - func cursorAtStartOfLineCutsLine() { - let result = LineCutCalculator.calculate( - text: "line1\nline2\nline3", - selection: NSRange(location: 6, length: 0) - ) - #expect(result == LineCutCalculator.Result( - rangeToDelete: NSRange(location: 6, length: 6), - clipboardText: "line2\n" - )) - } - - @Test("Cursor between line text and trailing newline cuts that line") - func cursorBeforeNewlineCutsLine() { - let result = LineCutCalculator.calculate( - text: "line1\nline2\nline3", - selection: NSRange(location: 5, length: 0) - ) - #expect(result == LineCutCalculator.Result( - rangeToDelete: NSRange(location: 0, length: 6), - clipboardText: "line1\n" - )) - } - - @Test("Cursor on empty line cuts just the newline") - func cursorOnEmptyLineCutsNewline() { - let result = LineCutCalculator.calculate( - text: "line1\n\nline3", - selection: NSRange(location: 6, length: 0) - ) - #expect(result == LineCutCalculator.Result( - rangeToDelete: NSRange(location: 6, length: 1), - clipboardText: "\n" - )) - } - - // MARK: - No-op cases - - @Test("Empty text returns nil") - func emptyTextReturnsNil() { - let result = LineCutCalculator.calculate( - text: "", - selection: NSRange(location: 0, length: 0) - ) - #expect(result == nil) - } - - @Test("Cursor past end of text returns nil") - func cursorOutOfBoundsReturnsNil() { - let result = LineCutCalculator.calculate( - text: "abc", - selection: NSRange(location: 100, length: 0) - ) - #expect(result == nil) - } - - @Test("Cursor at end of buffer with trailing newline returns nil (no line below)") - func cursorAtTrailingEmptyLineReturnsNil() { - let result = LineCutCalculator.calculate( - text: "line1\n", - selection: NSRange(location: 6, length: 0) - ) - #expect(result == nil) - } -} diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 366eb8fc6..da17ae013 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -104,8 +104,10 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut |--------|----------| | Previous page | `Cmd+[` | | Next page | `Cmd+]` | -| First page | Unbound (set in **Settings** > **Keyboard**) | -| Last page | Unbound (set in **Settings** > **Keyboard**) | +| First page | `Cmd+Option+Up` | +| Last page | `Cmd+Option+Down` | + +`Cmd+[` and `Cmd+]` page the data grid. In the SQL editor the same keys indent and outdent the selection, so the binding follows whichever has focus. ### Editing @@ -202,8 +204,8 @@ Inherits the standard `NSDocument` shortcuts. |--------|----------| | Toggle sidebar | `Cmd+0` | | Focus sidebar filter | `Cmd+Option+F` | -| Show Tables sidebar | `Control+1` | -| Show Favorites sidebar | `Control+2` | +| Show Tables sidebar | `Cmd+Option+1` | +| Show Favorites sidebar | `Cmd+Option+2` | | Toggle full screen | `Cmd+Control+F` | | Zoom in | `Cmd+=` | | Zoom out | `Cmd+-` | @@ -341,7 +343,7 @@ Most menu shortcuts are rebindable in **Settings** > **Keyboard**. 5. The shortcut updates immediately in the menu bar -Menu actions need a modifier key (`Cmd`, `Option`, `Control`, or `Shift`). A plain key like `Space` won't reach the menu, so TablePro asks you to add a modifier. Data grid actions that read the key directly, like Preview FK Reference (`Space`) and Cancel edit (`Escape`), are the exception. +Menu actions need a modifier key (`Cmd`, `Option`, `Control`, or `Shift`). A plain key like `Space` won't reach the menu, so TablePro asks you to add a modifier. The exceptions are function keys (`F1` through `F12`), which work on their own, and data grid actions that read the key directly, like Preview FK Reference (`Space`) and Cancel edit (`Escape`). ### Clearing a Shortcut @@ -352,18 +354,20 @@ Menu actions need a modifier key (`Cmd`, `Option`, `Control`, or `Shift`). A pla ### Conflict Detection -If you assign a shortcut already in use, TablePro shows a confirmation dialog: +If you assign a shortcut already used by another action in the same context, TablePro shows a confirmation dialog naming that action and its group: - **Cancel**: keep the existing assignment - **Reassign**: move the shortcut to the new action (clears the previous action's shortcut) +The same key can mean different things in the editor and the data grid because focus decides which one fires, so a grid shortcut and an editor shortcut can share a key without conflicting. + -System-reserved shortcuts (`Cmd+Q`, `Cmd+H`, `Cmd+M`, `Cmd+,`) cannot be reassigned. TablePro warns if you try. +Shortcuts the editor uses internally (like `Cmd+/` to comment or `Cmd+Shift+K` to delete a line) and macOS system shortcuts (read live from System Settings, so `Cmd+Q`, Spotlight, and Mission Control are covered) cannot be assigned. TablePro warns if you try. ### Resetting to Defaults -Click **Reset to Defaults** to restore all shortcuts to their original values. +A reset button (the curved arrow) appears next to any shortcut you have changed; click it to restore that one action. Click **Reset to Defaults** to restore every shortcut at once. Tab switching shortcuts (`Cmd+1` through `Cmd+9`) follow standard macOS convention and cannot be customized. From 55f1d6eee650b4afad446a50c0f2bcb97fba2b7c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 2 Jun 2026 17:42:23 +0700 Subject: [PATCH 2/2] refactor(shortcuts): harden BoundKey.character resolution and restore line-cut edge tests --- .../CodeEditTextViewTests/CutLineTests.swift | 37 +++++++++++++++++++ .../KeyboardHandling/KeyboardLayout.swift | 7 ++++ TablePro/Models/UI/BoundKey.swift | 10 ++++- .../Models/UI/KeyboardShortcutModels.swift | 11 ++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CutLineTests.swift b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CutLineTests.swift index ac270ec20..b347827f7 100644 --- a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CutLineTests.swift +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CutLineTests.swift @@ -22,6 +22,43 @@ struct CutLineTests { #expect(TextView.cutRange(for: selection, in: text) == selection) } + @Test("Multi-line non-empty selection is returned unchanged") + func multiLineSelectionUnchanged() { + let text = "line1\nline2\nline3" as NSString + let selection = NSRange(location: 3, length: 6) + #expect(TextView.cutRange(for: selection, in: text) == selection) + } + + @Test("Caret on the first line cuts the line plus its trailing newline") + func firstLineCutsWithNewline() { + let text = "line1\nline2\nline3" as NSString + let range = TextView.cutRange(for: NSRange(location: 2, length: 0), in: text) + #expect(range == NSRange(location: 0, length: 6)) + #expect(text.substring(with: range) == "line1\n") + } + + @Test("Caret at the start of a middle line cuts that line plus its newline") + func middleLineFromStartCutsWithNewline() { + let text = "line1\nline2\nline3" as NSString + let range = TextView.cutRange(for: NSRange(location: 6, length: 0), in: text) + #expect(range == NSRange(location: 6, length: 6)) + #expect(text.substring(with: range) == "line2\n") + } + + @Test("Caret on an empty line cuts just the newline") + func emptyLineCutsJustNewline() { + let text = "line1\n\nline3" as NSString + let range = TextView.cutRange(for: NSRange(location: 6, length: 0), in: text) + #expect(text.substring(with: range) == "\n") + } + + @Test("Empty text returns an empty range") + func emptyTextReturnsEmptyRange() { + let text = "" as NSString + let range = TextView.cutRange(for: NSRange(location: 0, length: 0), in: text) + #expect(range.length == 0) + } + @Test("Caret on the last line without a trailing newline cuts to the end") func lastLineWithoutTrailingNewline() { let text = "a\nb" as NSString diff --git a/TablePro/Core/KeyboardHandling/KeyboardLayout.swift b/TablePro/Core/KeyboardHandling/KeyboardLayout.swift index 79a5e3b9c..86034169c 100644 --- a/TablePro/Core/KeyboardHandling/KeyboardLayout.swift +++ b/TablePro/Core/KeyboardHandling/KeyboardLayout.swift @@ -5,6 +5,13 @@ // Resolves between hardware key codes and their base (unshifted) characters // for the active ASCII-capable keyboard layout. Built once and cached. // +// The cache is a `static let`, so Swift's one-time initializer builds it +// exactly once even under concurrent first access. Every Carbon call here is a +// pure read (`TISCopyCurrentASCIICapableKeyboardLayoutInputSource` copies, +// `UCKeyTranslate` and `LMGetKbdType` only read), so the build is safe off the +// main thread. This matters because Swift Testing exercises it from parallel +// test tasks; pinning it to the main actor would break those. +// import AppKit import Carbon.HIToolbox diff --git a/TablePro/Models/UI/BoundKey.swift b/TablePro/Models/UI/BoundKey.swift index 9d644df7b..7ae14c74a 100644 --- a/TablePro/Models/UI/BoundKey.swift +++ b/TablePro/Models/UI/BoundKey.swift @@ -48,6 +48,11 @@ struct BoundKey: Codable, Equatable, Hashable { /// Build a binding from a base character, resolving it to a key code on the /// active layout. Used to anchor default shortcuts to a semantic character. + /// A character with no key code on the active layout yields the cleared + /// sentinel, so the binding reads as unassigned rather than silently aliasing + /// some other physical key. Every default and reserved character is ASCII and + /// resolves through the US fallback, so the failure path is a programmer error + /// and trips an assertion in debug. static func character( _ character: Character, command: Bool = false, @@ -55,7 +60,10 @@ struct BoundKey: Codable, Equatable, Hashable { option: Bool = false, control: Bool = false ) -> BoundKey { - let keyCode = KeyboardLayout.keyCode(for: character) ?? 0xFFFF + guard let keyCode = KeyboardLayout.keyCode(for: character) else { + assertionFailure("No key code for '\(character)' on the active keyboard layout") + return .cleared + } return BoundKey(keyCode: keyCode, command: command, shift: shift, option: option, control: control) } diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 14a54b781..22d406df9 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -20,6 +20,13 @@ enum ShortcutContext: String { case editor case dataGrid + /// Two contexts overlap when they can be the active responder at the same + /// time. Non-overlapping contexts (editor vs data grid) may share a combo: + /// the editor's local key monitor consumes the keystroke while it is focused, + /// so the grid's menu key-equivalent only fires when the grid has focus. The + /// conflict resolver guards uniqueness within an overlapping context; it does + /// not stop a user from binding a grid combo that also reaches a global menu + /// item, which focus alone cannot disambiguate. func overlaps(_ other: ShortcutContext) -> Bool { self == .global || other == .global || self == other } @@ -290,6 +297,10 @@ struct KeyboardSettings: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + // BoundKey requires `keyCode` and LegacyKeyCombo requires `key`, and + // neither field exists in the other shape, so the two never decode each + // other's payload. Modern data is tried first; a legacy file fails that + // decode and falls through to migration. if let modern = try? container.decodeIfPresent([String: BoundKey].self, forKey: .shortcuts) { shortcuts = modern ?? [:] } else if let legacy = try container.decodeIfPresent([String: LegacyKeyCombo].self, forKey: .shortcuts) {