diff --git a/CHANGELOG.md b/CHANGELOG.md index 97190b22a..81933a60c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Space key toggles FK preview popover on selected cell, rebindable in Settings > Keyboard (#648) + ## [0.29.0] - 2026-04-09 ### Added diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 3d938a90f..aa94297be 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -65,6 +65,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case addRow case duplicateRow case truncateTable + case previewFKReference // View case toggleTableBrowser @@ -96,7 +97,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { return .file case .undo, .redo, .cut, .copy, .copyWithHeaders, .copyAsJson, .paste, .delete, .selectAll, .clearSelection, .addRow, - .duplicateRow, .truncateTable: + .duplicateRow, .truncateTable, .previewFKReference: return .edit case .toggleTableBrowser, .toggleInspector, .toggleFilters, .toggleHistory, .toggleResults, .previousResultTab, .nextResultTab, .closeResultTab: @@ -138,6 +139,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .addRow: return String(localized: "Add Row") case .duplicateRow: return String(localized: "Duplicate Row") case .truncateTable: return String(localized: "Truncate Table") + case .previewFKReference: return String(localized: "Preview FK Reference") case .toggleTableBrowser: return String(localized: "Toggle Table Browser") case .toggleInspector: return String(localized: "Toggle Inspector") case .toggleFilters: return String(localized: "Toggle Filters") @@ -202,11 +204,12 @@ struct KeyCombo: Codable, Equatable, Hashable { let hasOption = flags.contains(.option) let hasControl = flags.contains(.control) - // Require at least Cmd or Control (or escape/delete which work without modifiers) + // Require at least Cmd or Control (or special bare keys: escape, delete, space) let specialKeyCode = Self.specialKeyName(for: event.keyCode) - let isEscapeOrDelete = event.keyCode == 53 || event.keyCode == 51 || event.keyCode == 117 + let isAllowedBareKey = event.keyCode == 53 || event.keyCode == 51 + || event.keyCode == 117 || event.keyCode == 49 - if !hasCommand && !hasControl && !isEscapeOrDelete { + if !hasCommand && !hasControl && !isAllowedBareKey { return nil } @@ -322,6 +325,22 @@ struct KeyCombo: Codable, Equatable, Hashable { } } + // 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 @@ -443,6 +462,7 @@ struct KeyboardSettings: Codable, Equatable { .addRow: KeyCombo(key: "i", command: true), .duplicateRow: KeyCombo(key: "d", command: true), .truncateTable: KeyCombo(key: "delete", option: true, isSpecialKey: true), + .previewFKReference: KeyCombo(key: "space", isSpecialKey: true), // View .toggleTableBrowser: KeyCombo(key: "b", command: true), diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 734812994..8aa7c89e2 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -25624,6 +25624,9 @@ } } } + }, + "Preview FK Reference" : { + }, "Preview MQL" : { "extractionState" : "stale", diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index ae1d2f98e..ab71c77da 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -226,6 +226,12 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .explainQuery)) .disabled(!(actions?.isConnected ?? false) || !(actions?.hasQueryText ?? false)) + Button(String(localized: "Preview FK Reference")) { + actions?.previewFKReference() + } + .optionalKeyboardShortcut(shortcut(for: .previewFKReference)) + .disabled(!(actions?.isConnected ?? false)) + Divider() Button(String(localized: "Export Connections...")) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 4a2718c35..7c2e8f1c7 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -125,6 +125,22 @@ extension MainContentCoordinator { } } + /// Toggle FK preview for the currently focused cell in the data grid. + /// Called from the menu command system (Settings > Keyboard rebindable). + func toggleFKPreviewForFocusedCell() { + guard let tableView = NSApp.keyWindow?.firstResponder as? KeyHandlingTableView, + let coordinator = tableView.coordinator, + tableView.selectedRow >= 0, + tableView.focusedColumn >= 1 + else { return } + coordinator.toggleForeignKeyPreview( + tableView: tableView, + row: tableView.selectedRow, + column: tableView.focusedColumn, + columnIndex: tableView.focusedColumn - 1 + ) + } + private func applyFKFilter(_ filter: TableFilter, for tableName: String) { applyFilters([filter]) updateFilterState(filter, for: tableName) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 78e3a535c..68c33ab33 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -563,6 +563,10 @@ final class MainContentCommandActions { coordinator?.runExplainQuery() } + func previewFKReference() { + coordinator?.toggleFKPreviewForFocusedCell() + } + func exportTables() { coordinator?.openExportDialog() } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 1a024a3f9..5a9c175b6 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -35,6 +35,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var rowViewProvider: ((NSTableView, Int, TableViewCoordinator) -> NSTableRowView)? var emptySpaceMenu: (() -> NSMenu?)? var onNavigateFK: ((String, ForeignKeyInfo) -> Void)? + weak var activeFKPreviewPopover: NSPopover? var getVisualState: ((Int) -> RowVisualState)? var dropdownColumns: Set? var typePickerColumns: Set? @@ -254,6 +255,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData onHideColumn = nil onShowAllColumns = nil onNavigateFK = nil + activeFKPreviewPopover?.close() + activeFKPreviewPopover = nil rowViewProvider = nil emptySpaceMenu = nil getVisualState = nil diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 75b6ae572..2c1c080a9 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -74,6 +74,15 @@ extension TableViewCoordinator { } } + func toggleForeignKeyPreview(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { + if let popover = activeFKPreviewPopover, popover.isShown { + popover.close() + activeFKPreviewPopover = nil + return + } + showForeignKeyPreview(tableView: tableView, row: row, column: column, columnIndex: columnIndex) + } + func showForeignKeyPreview(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard columnIndex >= 0, columnIndex < rowProvider.columns.count else { return } let columnName = rowProvider.columns[columnIndex] @@ -83,7 +92,7 @@ extension TableViewCoordinator { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) - PopoverPresenter.show( + let popover = PopoverPresenter.show( relativeTo: cellRect, of: tableView, contentSize: NSSize(width: 380, height: 400) @@ -101,6 +110,7 @@ extension TableViewCoordinator { onDismiss: dismiss ) } + activeFKPreviewPopover = popover } func showJSONEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { diff --git a/TablePro/Views/Results/KeyHandlingTableView.swift b/TablePro/Views/Results/KeyHandlingTableView.swift index 6d71a94d5..9d7db0a7a 100644 --- a/TablePro/Views/Results/KeyHandlingTableView.swift +++ b/TablePro/Views/Results/KeyHandlingTableView.swift @@ -243,9 +243,12 @@ final class KeyHandlingTableView: NSTableView { break } - // Cmd+Return: preview referenced FK row - if key == .return && modifiers.contains(.command) && selectedRow >= 0 && focusedColumn >= 1 { - coordinator?.showForeignKeyPreview( + // FK preview: dispatch from user-configurable shortcut (default: Space) + if let fkCombo = AppSettingsManager.shared.keyboard.shortcut(for: .previewFKReference), + !fkCombo.isCleared, + fkCombo.matches(event), + selectedRow >= 0, focusedColumn >= 1 { + coordinator?.toggleForeignKeyPreview( tableView: self, row: selectedRow, column: focusedColumn, columnIndex: focusedColumn - 1 ) return diff --git a/TableProTests/Models/UI/KeyComboMatchTests.swift b/TableProTests/Models/UI/KeyComboMatchTests.swift new file mode 100644 index 000000000..d62271f62 --- /dev/null +++ b/TableProTests/Models/UI/KeyComboMatchTests.swift @@ -0,0 +1,118 @@ +import AppKit +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/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 2cd4efe6c..440c9447c 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -106,7 +106,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Action | Shortcut | |--------|----------| | Edit cell | `Enter` or `F2` | -| Preview FK reference | `Cmd+Enter` | +| Preview FK reference | `Space` | | Cancel edit | `Escape` | | Delete row | `Delete` or `Backspace` | | Commit changes | `Cmd+S` |