diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d9885c1..919f863b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Stale cells after fitting, hiding or reordering a data grid column on a result narrower than the window. (#2446) +- A rebound shortcut silently killing Quit, Minimize, Hide, Settings, Show Toolbar or Enter Full Screen. +- `Ctrl+Cmd+J` accepted in Settings > Keyboard while the SQL editor kept it for Jump to Definition. - Autocomplete keeping an earlier prefix's ordering after the typed word becomes an exact match. (#2444) - Whole MySQL and MariaDB result set fetched before a capped query returned its first rows. (#2427) - KILL sent to a different server when a MySQL or MariaDB connection's host is spelled `localhost`. diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index dc4f68bc6..f248a9e92 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -293,6 +293,7 @@ extension ShortcutAction { (.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")), + (.character("j", command: true, control: true), String(localized: "Jump to Definition")), (.special(.upArrow, shift: true, option: true), String(localized: "Extend Selection to Previous Statement")), (.special(.downArrow, shift: true, option: true), String(localized: "Extend Selection to Next Statement")) ] @@ -315,13 +316,21 @@ extension ShortcutAction { (.special(.rightArrow, option: true), String(localized: "Move Word Right")) ] - /// 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. + /// Every key equivalent a menu builder hardcodes, and so every combo no `ShortcutAction` + /// can be bound to. These fire regardless of focus, and AppKit blanks the loser when two + /// menu items claim one combo, so a binding the recorder let through would silently kill + /// the hardcoded command instead. An entry added to a menu builder belongs here too. 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")) + (.character("-", command: true), String(localized: "Zoom Out")), + (.character(",", command: true), String(localized: "Settings…")), + (.character("h", command: true), String(localized: "Hide TablePro")), + (.character("h", command: true, option: true), String(localized: "Hide Others")), + (.character("q", command: true), String(localized: "Quit TablePro")), + (.character("m", command: true), String(localized: "Minimize")), + (.character("t", command: true, option: true), String(localized: "Show Toolbar")), + (.character("f", command: true, control: true), String(localized: "Enter Full Screen")) ] for number in 1...9 { shortcuts.append(( diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index aaa533b5b..8ef25e6b6 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -72,6 +72,30 @@ struct MainMenuStructureTests { #expect(duplicates.isEmpty, "AppKit blanks the loser when two items claim one combo: \(duplicates)") } + /// A menu builder that hardcodes a key equivalent takes that combo off the table for every + /// `ShortcutAction`, and only `reservedAppShortcuts` tells the recorder so. Nothing else + /// forces the two to agree, so a hardcoded item added without a matching entry ships a + /// binding the recorder accepts and AppKit then blanks. + @Test("Every hardcoded menu key equivalent is reserved against user binding") + func hardcodedKeyEquivalentsAreReserved() { + func canonical(_ key: BoundKey) -> String? { + key.menuKeyEquivalent.map { "\(key.modifierFlags.rawValue)-\($0)" } + } + + let keyboard = KeyboardSettings() + let customizable = Set(ShortcutAction.allCases.compactMap { keyboard.shortcut(for: $0).flatMap(canonical) }) + let reserved = Set(ShortcutAction.reservedAppShortcuts.compactMap { canonical($0.key) }) + + let hardcoded = flatten(buildMenu()) + .filter { !$0.keyEquivalent.isEmpty } + .map { (combo: "\($0.keyEquivalentModifierMask.rawValue)-\($0.keyEquivalent)", title: $0.title) } + .filter { !customizable.contains($0.combo) } + #expect(!hardcoded.isEmpty, "Found no hardcoded menu shortcuts, so this guard would pass vacuously") + + let unreserved = hardcoded.filter { !reserved.contains($0.combo) }.map(\.title) + #expect(unreserved.isEmpty, "Hardcoded menu shortcuts missing from reservedAppShortcuts: \(unreserved)") + } + @Test("Every menu item carries an action") func everyItemHasAnAction() { let dead = flatten(buildMenu())