Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions TablePro/Models/UI/KeyboardShortcutModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case addRow
case duplicateRow
case truncateTable
case previewFKReference

// View
case toggleTableBrowser
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -25624,6 +25624,9 @@
}
}
}
},
"Preview FK Reference" : {

},
"Preview MQL" : {
"extractionState" : "stale",
Expand Down
6 changes: 6 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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...")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,10 @@ final class MainContentCommandActions {
coordinator?.runExplainQuery()
}

func previewFKReference() {
coordinator?.toggleFKPreviewForFocusedCell()
}

func exportTables() {
coordinator?.openExportDialog()
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/Results/DataGridCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>?
var typePickerColumns: Set<Int>?
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion TablePro/Views/Results/Extensions/DataGridView+Popovers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)
Expand All @@ -101,6 +110,7 @@ extension TableViewCoordinator {
onDismiss: dismiss
)
}
activeFKPreviewPopover = popover
}

func showJSONEditorPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) {
Expand Down
9 changes: 6 additions & 3 deletions TablePro/Views/Results/KeyHandlingTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions TableProTests/Models/UI/KeyComboMatchTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion docs/features/keyboard-shortcuts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading