Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- iOS: open DuckDB database files and in-memory DuckDB databases. (#1526)
- Save the current query as a favorite from the SQL editor toolbar.
- Select and copy field names and types in the row Details panel.
- Function-key shortcuts: F5 to refresh, F9 to run a query, F1 to open documentation. F5 and F9 work alongside Cmd+R and Cmd+Return, and all three are assignable in Settings, Keyboard.

### Changed

Expand Down
7 changes: 1 addition & 6 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {

private var hasRunPostLaunchActivation = false

private static var isUITesting: Bool {
ProcessInfo.processInfo.environment["TABLEPRO_UI_TESTING"] == "1"
}

// MARK: - URL & File Open

func applicationWillFinishLaunching(_ notification: Notification) {
Expand Down Expand Up @@ -71,6 +67,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
UNUserNotificationCenter.current().delegate = self
PluginNotificationService.shared.setUp()
ChatToolBootstrap.register()
FunctionKeyShortcutMonitor.shared.start()

NSWorkspace.shared.notificationCenter.addObserver(
self, selector: #selector(handleSystemDidWake),
Expand All @@ -97,14 +94,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {

func applicationDidBecomeActive(_ notification: Notification) {
runPostLaunchActivationIfNeeded()
guard !Self.isUITesting else { return }
SyncCoordinator.shared.syncIfNeeded()
}

private func runPostLaunchActivationIfNeeded() {
guard !hasRunPostLaunchActivation else { return }
hasRunPostLaunchActivation = true
guard !Self.isUITesting else { return }

ConnectionStorage.shared.migratePluginSecureFieldsIfNeeded()
AnalyticsService.shared.startPeriodicHeartbeat()
Expand Down
77 changes: 77 additions & 0 deletions TablePro/Core/KeyboardHandling/FunctionKeyShortcutMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// FunctionKeyShortcutMonitor.swift
// TablePro
//
// Dispatches function-key shortcuts (F1–F12) that can't ride on SwiftUI menu
// key equivalents: secondary bindings whose primary already owns a menu
// shortcut (e.g. F5 alongside ⌘R), and Help actions with no menu shortcut.
//

import AppKit
import Combine
import Foundation

@MainActor
final class FunctionKeyShortcutMonitor {
static let shared = FunctionKeyShortcutMonitor()

private var eventMonitor: Any?

private init() {}

func start() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
return self.handle(event)
}
}

func stop() {
if let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
}
eventMonitor = nil
}

private func handle(_ event: NSEvent) -> NSEvent? {
guard let action = matchedAction(for: event) else { return event }
if NSApp.keyWindow?.firstResponder is ShortcutRecorderNSView {
return event
}
return perform(action) ? nil : event
}

private func matchedAction(for event: NSEvent) -> ShortcutAction? {
let keyboard = AppSettingsManager.shared.keyboard
for action in ShortcutAction.allCases where action.supportsFunctionKeyPrimary {
if let combo = keyboard.shortcut(for: action), combo.isFunctionKey, combo.matches(event) {
return action
}
}
for action in ShortcutAction.allCases where action.supportsFunctionKeyAlternate {
if let combo = keyboard.alternateShortcut(for: action), combo.isFunctionKey, combo.matches(event) {
return action
}
}
return nil
}

private func perform(_ action: ShortcutAction) -> Bool {
switch action {
case .refresh:
AppCommands.shared.refreshData.send(nil)
return true
case .executeQuery:
guard let actions = CommandActionsRegistry.shared.current else { return false }
actions.runQuery()
return true
case .openDocumentation:
guard let url = URL(string: "https://docs.tablepro.app") else { return false }
NSWorkspace.shared.open(url)
return true
default:
return false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ struct RefreshToolbarButton: View {
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.help(String(localized: "Refresh (⌘R)"))
.help(String(localized: "Refresh (⌘R / F5)"))
.disabled(state.connectionState != .connected)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate {
let item = NSToolbarItem(itemIdentifier: Self.inspector)
item.label = String(localized: "Inspector")
item.paletteLabel = String(localized: "Inspector")
item.toolTip = String(localized: "Toggle Inspector (⌘⌥I)")
return item
case Self.dashboard:
return hostingItem(
Expand Down
127 changes: 119 additions & 8 deletions TablePro/Models/UI/KeyboardShortcutModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable {
case view
case tabs
case ai
case help

var id: String { rawValue }

Expand All @@ -27,6 +28,7 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable {
case .view: return String(localized: "View")
case .tabs: return String(localized: "Tabs")
case .ai: return String(localized: "AI")
case .help: return String(localized: "Help")
}
}
}
Expand Down Expand Up @@ -100,6 +102,9 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case aiExplainQuery
case aiOptimizeQuery

// Help
case openDocumentation

var id: String { rawValue }

var category: ShortcutCategory {
Expand All @@ -122,6 +127,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
return .tabs
case .aiExplainQuery, .aiOptimizeQuery:
return .ai
case .openDocumentation:
return .help
}
}

Expand All @@ -134,6 +141,24 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
}
}

var supportsFunctionKeyAlternate: Bool {
switch self {
case .refresh, .executeQuery:
return true
default:
return false
}
}

var supportsFunctionKeyPrimary: Bool {
switch self {
case .openDocumentation:
return true
default:
return false
}
}

var displayName: String {
switch self {
case .manageConnections: return String(localized: "Manage Connections")
Expand Down Expand Up @@ -189,6 +214,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case .showNextTab: return String(localized: "Show Next Tab")
case .aiExplainQuery: return String(localized: "Explain with AI")
case .aiOptimizeQuery: return String(localized: "Optimize with AI")
case .openDocumentation: return String(localized: "Open Documentation")
}
}
}
Expand Down Expand Up @@ -239,10 +265,11 @@ struct KeyCombo: Codable, Equatable, Hashable {
let hasOption = flags.contains(.option)
let hasControl = flags.contains(.control)

// Require at least Cmd or Control (or special bare keys: escape, delete, space)
// Require at least Cmd or Control (or special bare keys: escape, delete, space, function keys)
let specialKeyCode = Self.specialKeyName(for: event.keyCode)
let isAllowedBareKey = event.keyCode == 53 || event.keyCode == 51
|| event.keyCode == 117 || event.keyCode == 49
|| Self.isFunctionKeyName(specialKeyCode)

if !hasCommand && !hasControl && !isAllowedBareKey {
return nil
Expand Down Expand Up @@ -287,6 +314,9 @@ struct KeyCombo: Codable, Equatable, Hashable {
// swiftlint:disable:next force_unwrapping
case "forwardDelete": return KeyEquivalent(Character(UnicodeScalar(NSDeleteFunctionKey)!))
default:
if let scalar = Self.functionKeyScalar(for: key) {
return KeyEquivalent(Character(scalar))
}
guard key.count == 1 else { return .escape }
return KeyEquivalent(Character(key))
}
Expand All @@ -308,6 +338,10 @@ struct KeyCombo: Codable, Equatable, Hashable {
command || shift || option || control
}

var isFunctionKey: Bool {
isSpecialKey && Self.isFunctionKeyName(key)
}

/// Human-readable display string (e.g. "⌘S", "⇧⌘P")
var displayString: String {
var parts: [String] = []
Expand Down Expand Up @@ -337,7 +371,9 @@ struct KeyCombo: Codable, Equatable, Hashable {
case "end": return "↘"
case "pageUp": return "⇞"
case "pageDown": return "⇟"
default: return key.count == 1 ? key.uppercased() : "?"
default:
if isFunctionKey { return key.uppercased() }
return key.count == 1 ? key.uppercased() : "?"
}
}
return key.uppercased()
Expand All @@ -362,10 +398,39 @@ struct KeyCombo: Codable, Equatable, Hashable {
case 119: return "end"
case 116: return "pageUp"
case 121: return "pageDown"
case 122: return "f1"
case 120: return "f2"
case 99: return "f3"
case 118: return "f4"
case 96: return "f5"
case 97: return "f6"
case 98: return "f7"
case 100: return "f8"
case 101: return "f9"
case 109: return "f10"
case 103: return "f11"
case 111: return "f12"
default: return nil
}
}

private static func functionKeyNumber(for key: String) -> Int? {
guard key.hasPrefix("f"), let number = Int(key.dropFirst()), (1...12).contains(number) else {
return nil
}
return number
}

static func isFunctionKeyName(_ key: String?) -> Bool {
guard let key else { return false }
return functionKeyNumber(for: key) != nil
}

private static func functionKeyScalar(for key: String) -> UnicodeScalar? {
guard let number = functionKeyNumber(for: key) else { return nil }
return UnicodeScalar(UInt32(NSF1FunctionKey + (number - 1)))
}

// MARK: - Event Matching

/// Check if this combo matches a given NSEvent (for runtime key dispatch)
Expand Down Expand Up @@ -421,15 +486,21 @@ struct KeyboardSettings: Codable, Equatable {
/// the old stored key becomes a harmless no-op (never matched by any action).
var shortcuts: [String: KeyCombo]

/// User-customized secondary (function-key) bindings (action rawValue → KeyCombo).
/// Only contains overrides; missing entries use `defaultAlternates`.
var alternates: [String: KeyCombo]

static let `default` = KeyboardSettings(shortcuts: [:])

init(shortcuts: [String: KeyCombo] = [:]) {
init(shortcuts: [String: KeyCombo] = [:], alternates: [String: KeyCombo] = [:]) {
self.shortcuts = shortcuts
self.alternates = alternates
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
shortcuts = try container.decodeIfPresent([String: KeyCombo].self, forKey: .shortcuts) ?? [:]
alternates = try container.decodeIfPresent([String: KeyCombo].self, forKey: .alternates) ?? [:]
}

/// Get the effective shortcut for an action (user override or default)
Expand All @@ -446,10 +517,23 @@ struct KeyboardSettings: Codable, Equatable {
shortcuts[action.rawValue] != nil
}

/// Find a conflicting action for the given combo, excluding the specified action
/// Get the effective secondary (function-key) shortcut for an action.
/// Returns nil if there is none or the user explicitly cleared it.
func alternateShortcut(for action: ShortcutAction) -> KeyCombo? {
let combo = alternates[action.rawValue] ?? Self.defaultAlternates[action]
guard let combo, !combo.isCleared else { return nil }
return combo
}

func isAlternateCustomized(_ action: ShortcutAction) -> Bool {
alternates[action.rawValue] != nil
}

/// Find a conflicting action for the given combo, excluding the specified action.
/// Checks both primary and secondary bindings of every other action.
func findConflict(for combo: KeyCombo, excluding action: ShortcutAction) -> ShortcutAction? {
for otherAction in ShortcutAction.allCases where otherAction != action {
if shortcut(for: otherAction) == combo {
if shortcut(for: otherAction) == combo || alternateShortcut(for: otherAction) == combo {
return otherAction
}
}
Expand All @@ -472,23 +556,41 @@ struct KeyboardSettings: Codable, Equatable {
shortcuts.removeValue(forKey: action.rawValue)
}

/// Set a secondary (function-key) shortcut override for an action
mutating func setAlternate(_ combo: KeyCombo, for action: ShortcutAction) {
alternates[action.rawValue] = combo
}

/// Clear a secondary shortcut (action will have no function-key binding)
mutating func clearAlternate(for action: ShortcutAction) {
alternates[action.rawValue] = KeyCombo.cleared
}

/// Reset a secondary shortcut to its default
mutating func resetAlternate(for action: ShortcutAction) {
alternates.removeValue(forKey: action.rawValue)
}

/// Drop overrides that can never dispatch (bare keys on menu-driven actions),
/// 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 {
if !combo.hasModifier, !action.allowsBareKey, !combo.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.
/// Returns nil if the user has cleared (unassigned) the shortcut, or if the
/// binding is a function key. Those dispatch through FunctionKeyShortcutMonitor
/// instead of the menu, since SwiftUI menu items don't reliably register
/// function-key equivalents.
func keyboardShortcut(for action: ShortcutAction) -> KeyboardShortcut? {
guard let combo = shortcut(for: action), !combo.isCleared else {
guard let combo = shortcut(for: action), !combo.isCleared, !combo.isFunctionKey else {
return nil
}
return KeyboardShortcut(combo.keyEquivalent, modifiers: combo.eventModifiers)
Expand Down Expand Up @@ -558,6 +660,15 @@ struct KeyboardSettings: Codable, Equatable {
// AI
.aiExplainQuery: KeyCombo(key: "l", command: true),
.aiOptimizeQuery: KeyCombo(key: "l", command: true, option: true),

// Help
.openDocumentation: KeyCombo(key: "f1", isSpecialKey: true),
]

/// Default secondary (function-key) bindings, dispatched by FunctionKeyShortcutMonitor.
static let defaultAlternates: [ShortcutAction: KeyCombo] = [
.refresh: KeyCombo(key: "f5", isSpecialKey: true),
.executeQuery: KeyCombo(key: "f9", isSpecialKey: true),
]
}

Expand Down
Loading
Loading