Skip to content

Commit fb24aaa

Browse files
committed
feat(keyboard): F5/F9/F1 function-key shortcuts and tooltip improvements
1 parent c24cae1 commit fb24aaa

11 files changed

Lines changed: 388 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- iOS: open DuckDB database files and in-memory DuckDB databases. (#1526)
1717
- Save the current query as a favorite from the SQL editor toolbar.
1818
- Select and copy field names and types in the row Details panel.
19+
- 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.
1920

2021
### Changed
2122

TablePro/AppDelegate.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
1616

1717
private var hasRunPostLaunchActivation = false
1818

19-
private static var isUITesting: Bool {
20-
ProcessInfo.processInfo.environment["TABLEPRO_UI_TESTING"] == "1"
21-
}
22-
2319
// MARK: - URL & File Open
2420

2521
func applicationWillFinishLaunching(_ notification: Notification) {
@@ -71,6 +67,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
7167
UNUserNotificationCenter.current().delegate = self
7268
PluginNotificationService.shared.setUp()
7369
ChatToolBootstrap.register()
70+
FunctionKeyShortcutMonitor.shared.start()
7471

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

9895
func applicationDidBecomeActive(_ notification: Notification) {
9996
runPostLaunchActivationIfNeeded()
100-
guard !Self.isUITesting else { return }
10197
SyncCoordinator.shared.syncIfNeeded()
10298
}
10399

104100
private func runPostLaunchActivationIfNeeded() {
105101
guard !hasRunPostLaunchActivation else { return }
106102
hasRunPostLaunchActivation = true
107-
guard !Self.isUITesting else { return }
108103

109104
ConnectionStorage.shared.migratePluginSecureFieldsIfNeeded()
110105
AnalyticsService.shared.startPeriodicHeartbeat()
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//
2+
// FunctionKeyShortcutMonitor.swift
3+
// TablePro
4+
//
5+
// Dispatches function-key shortcuts (F1–F12) that can't ride on SwiftUI menu
6+
// key equivalents: secondary bindings whose primary already owns a menu
7+
// shortcut (e.g. F5 alongside ⌘R), and Help actions with no menu shortcut.
8+
//
9+
10+
import AppKit
11+
import Combine
12+
import Foundation
13+
14+
@MainActor
15+
final class FunctionKeyShortcutMonitor {
16+
static let shared = FunctionKeyShortcutMonitor()
17+
18+
private var eventMonitor: Any?
19+
20+
private init() {}
21+
22+
func start() {
23+
guard eventMonitor == nil else { return }
24+
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
25+
guard let self else { return event }
26+
return self.handle(event)
27+
}
28+
}
29+
30+
func stop() {
31+
if let eventMonitor {
32+
NSEvent.removeMonitor(eventMonitor)
33+
}
34+
eventMonitor = nil
35+
}
36+
37+
private func handle(_ event: NSEvent) -> NSEvent? {
38+
guard let action = matchedAction(for: event) else { return event }
39+
if NSApp.keyWindow?.firstResponder is ShortcutRecorderNSView {
40+
return event
41+
}
42+
return perform(action) ? nil : event
43+
}
44+
45+
private func matchedAction(for event: NSEvent) -> ShortcutAction? {
46+
let keyboard = AppSettingsManager.shared.keyboard
47+
for action in ShortcutAction.allCases where action.supportsFunctionKeyPrimary {
48+
if let combo = keyboard.shortcut(for: action), combo.isFunctionKey, combo.matches(event) {
49+
return action
50+
}
51+
}
52+
for action in ShortcutAction.allCases where action.supportsFunctionKeyAlternate {
53+
if let combo = keyboard.alternateShortcut(for: action), combo.isFunctionKey, combo.matches(event) {
54+
return action
55+
}
56+
}
57+
return nil
58+
}
59+
60+
private func perform(_ action: ShortcutAction) -> Bool {
61+
switch action {
62+
case .refresh:
63+
AppCommands.shared.refreshData.send(nil)
64+
return true
65+
case .executeQuery:
66+
guard let actions = CommandActionsRegistry.shared.current else { return false }
67+
actions.runQuery()
68+
return true
69+
case .openDocumentation:
70+
guard let url = URL(string: "https://docs.tablepro.app") else { return false }
71+
NSWorkspace.shared.open(url)
72+
return true
73+
default:
74+
return false
75+
}
76+
}
77+
}

TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ struct RefreshToolbarButton: View {
5858
} label: {
5959
Label("Refresh", systemImage: "arrow.clockwise")
6060
}
61-
.help(String(localized: "Refresh (⌘R)"))
61+
.help(String(localized: "Refresh (⌘R / F5)"))
6262
.disabled(state.connectionState != .connected)
6363
}
6464
}

TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate {
182182
let item = NSToolbarItem(itemIdentifier: Self.inspector)
183183
item.label = String(localized: "Inspector")
184184
item.paletteLabel = String(localized: "Inspector")
185+
item.toolTip = String(localized: "Toggle Inspector (⌘⌥I)")
185186
return item
186187
case Self.dashboard:
187188
return hostingItem(

TablePro/Models/UI/KeyboardShortcutModels.swift

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable {
1717
case view
1818
case tabs
1919
case ai
20+
case help
2021

2122
var id: String { rawValue }
2223

@@ -27,6 +28,7 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable {
2728
case .view: return String(localized: "View")
2829
case .tabs: return String(localized: "Tabs")
2930
case .ai: return String(localized: "AI")
31+
case .help: return String(localized: "Help")
3032
}
3133
}
3234
}
@@ -100,6 +102,9 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
100102
case aiExplainQuery
101103
case aiOptimizeQuery
102104

105+
// Help
106+
case openDocumentation
107+
103108
var id: String { rawValue }
104109

105110
var category: ShortcutCategory {
@@ -122,6 +127,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
122127
return .tabs
123128
case .aiExplainQuery, .aiOptimizeQuery:
124129
return .ai
130+
case .openDocumentation:
131+
return .help
125132
}
126133
}
127134

@@ -134,6 +141,24 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
134141
}
135142
}
136143

144+
var supportsFunctionKeyAlternate: Bool {
145+
switch self {
146+
case .refresh, .executeQuery:
147+
return true
148+
default:
149+
return false
150+
}
151+
}
152+
153+
var supportsFunctionKeyPrimary: Bool {
154+
switch self {
155+
case .openDocumentation:
156+
return true
157+
default:
158+
return false
159+
}
160+
}
161+
137162
var displayName: String {
138163
switch self {
139164
case .manageConnections: return String(localized: "Manage Connections")
@@ -189,6 +214,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
189214
case .showNextTab: return String(localized: "Show Next Tab")
190215
case .aiExplainQuery: return String(localized: "Explain with AI")
191216
case .aiOptimizeQuery: return String(localized: "Optimize with AI")
217+
case .openDocumentation: return String(localized: "Open Documentation")
192218
}
193219
}
194220
}
@@ -239,10 +265,11 @@ struct KeyCombo: Codable, Equatable, Hashable {
239265
let hasOption = flags.contains(.option)
240266
let hasControl = flags.contains(.control)
241267

242-
// Require at least Cmd or Control (or special bare keys: escape, delete, space)
268+
// Require at least Cmd or Control (or special bare keys: escape, delete, space, function keys)
243269
let specialKeyCode = Self.specialKeyName(for: event.keyCode)
244270
let isAllowedBareKey = event.keyCode == 53 || event.keyCode == 51
245271
|| event.keyCode == 117 || event.keyCode == 49
272+
|| Self.isFunctionKeyName(specialKeyCode)
246273

247274
if !hasCommand && !hasControl && !isAllowedBareKey {
248275
return nil
@@ -287,6 +314,9 @@ struct KeyCombo: Codable, Equatable, Hashable {
287314
// swiftlint:disable:next force_unwrapping
288315
case "forwardDelete": return KeyEquivalent(Character(UnicodeScalar(NSDeleteFunctionKey)!))
289316
default:
317+
if let scalar = Self.functionKeyScalar(for: key) {
318+
return KeyEquivalent(Character(scalar))
319+
}
290320
guard key.count == 1 else { return .escape }
291321
return KeyEquivalent(Character(key))
292322
}
@@ -308,6 +338,10 @@ struct KeyCombo: Codable, Equatable, Hashable {
308338
command || shift || option || control
309339
}
310340

341+
var isFunctionKey: Bool {
342+
isSpecialKey && Self.isFunctionKeyName(key)
343+
}
344+
311345
/// Human-readable display string (e.g. "⌘S", "⇧⌘P")
312346
var displayString: String {
313347
var parts: [String] = []
@@ -337,7 +371,9 @@ struct KeyCombo: Codable, Equatable, Hashable {
337371
case "end": return ""
338372
case "pageUp": return ""
339373
case "pageDown": return ""
340-
default: return key.count == 1 ? key.uppercased() : "?"
374+
default:
375+
if isFunctionKey { return key.uppercased() }
376+
return key.count == 1 ? key.uppercased() : "?"
341377
}
342378
}
343379
return key.uppercased()
@@ -362,10 +398,39 @@ struct KeyCombo: Codable, Equatable, Hashable {
362398
case 119: return "end"
363399
case 116: return "pageUp"
364400
case 121: return "pageDown"
401+
case 122: return "f1"
402+
case 120: return "f2"
403+
case 99: return "f3"
404+
case 118: return "f4"
405+
case 96: return "f5"
406+
case 97: return "f6"
407+
case 98: return "f7"
408+
case 100: return "f8"
409+
case 101: return "f9"
410+
case 109: return "f10"
411+
case 103: return "f11"
412+
case 111: return "f12"
365413
default: return nil
366414
}
367415
}
368416

417+
private static func functionKeyNumber(for key: String) -> Int? {
418+
guard key.hasPrefix("f"), let number = Int(key.dropFirst()), (1...12).contains(number) else {
419+
return nil
420+
}
421+
return number
422+
}
423+
424+
static func isFunctionKeyName(_ key: String?) -> Bool {
425+
guard let key else { return false }
426+
return functionKeyNumber(for: key) != nil
427+
}
428+
429+
private static func functionKeyScalar(for key: String) -> UnicodeScalar? {
430+
guard let number = functionKeyNumber(for: key) else { return nil }
431+
return UnicodeScalar(UInt32(NSF1FunctionKey + (number - 1)))
432+
}
433+
369434
// MARK: - Event Matching
370435

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

489+
/// User-customized secondary (function-key) bindings (action rawValue → KeyCombo).
490+
/// Only contains overrides; missing entries use `defaultAlternates`.
491+
var alternates: [String: KeyCombo]
492+
424493
static let `default` = KeyboardSettings(shortcuts: [:])
425494

426-
init(shortcuts: [String: KeyCombo] = [:]) {
495+
init(shortcuts: [String: KeyCombo] = [:], alternates: [String: KeyCombo] = [:]) {
427496
self.shortcuts = shortcuts
497+
self.alternates = alternates
428498
}
429499

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

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

449-
/// Find a conflicting action for the given combo, excluding the specified action
520+
/// Get the effective secondary (function-key) shortcut for an action.
521+
/// Returns nil if there is none or the user explicitly cleared it.
522+
func alternateShortcut(for action: ShortcutAction) -> KeyCombo? {
523+
let combo = alternates[action.rawValue] ?? Self.defaultAlternates[action]
524+
guard let combo, !combo.isCleared else { return nil }
525+
return combo
526+
}
527+
528+
func isAlternateCustomized(_ action: ShortcutAction) -> Bool {
529+
alternates[action.rawValue] != nil
530+
}
531+
532+
/// Find a conflicting action for the given combo, excluding the specified action.
533+
/// Checks both primary and secondary bindings of every other action.
450534
func findConflict(for combo: KeyCombo, excluding action: ShortcutAction) -> ShortcutAction? {
451535
for otherAction in ShortcutAction.allCases where otherAction != action {
452-
if shortcut(for: otherAction) == combo {
536+
if shortcut(for: otherAction) == combo || alternateShortcut(for: otherAction) == combo {
453537
return otherAction
454538
}
455539
}
@@ -472,23 +556,41 @@ struct KeyboardSettings: Codable, Equatable {
472556
shortcuts.removeValue(forKey: action.rawValue)
473557
}
474558

559+
/// Set a secondary (function-key) shortcut override for an action
560+
mutating func setAlternate(_ combo: KeyCombo, for action: ShortcutAction) {
561+
alternates[action.rawValue] = combo
562+
}
563+
564+
/// Clear a secondary shortcut (action will have no function-key binding)
565+
mutating func clearAlternate(for action: ShortcutAction) {
566+
alternates[action.rawValue] = KeyCombo.cleared
567+
}
568+
569+
/// Reset a secondary shortcut to its default
570+
mutating func resetAlternate(for action: ShortcutAction) {
571+
alternates.removeValue(forKey: action.rawValue)
572+
}
573+
475574
/// Drop overrides that can never dispatch (bare keys on menu-driven actions),
476575
/// reverting them to their default. Cleared and unknown overrides are kept.
477576
func sanitized() -> KeyboardSettings {
478577
var cleaned = shortcuts
479578
for (rawValue, combo) in shortcuts {
480579
guard let action = ShortcutAction(rawValue: rawValue), !combo.isCleared else { continue }
481-
if !combo.hasModifier, !action.allowsBareKey {
580+
if !combo.hasModifier, !action.allowsBareKey, !combo.isFunctionKey {
482581
cleaned.removeValue(forKey: rawValue)
483582
}
484583
}
485584
return KeyboardSettings(shortcuts: cleaned)
486585
}
487586

488587
/// Build a SwiftUI KeyboardShortcut for the given action.
489-
/// Returns nil if the user has cleared (unassigned) the shortcut.
588+
/// Returns nil if the user has cleared (unassigned) the shortcut, or if the
589+
/// binding is a function key. Those dispatch through FunctionKeyShortcutMonitor
590+
/// instead of the menu, since SwiftUI menu items don't reliably register
591+
/// function-key equivalents.
490592
func keyboardShortcut(for action: ShortcutAction) -> KeyboardShortcut? {
491-
guard let combo = shortcut(for: action), !combo.isCleared else {
593+
guard let combo = shortcut(for: action), !combo.isCleared, !combo.isFunctionKey else {
492594
return nil
493595
}
494596
return KeyboardShortcut(combo.keyEquivalent, modifiers: combo.eventModifiers)
@@ -558,6 +660,15 @@ struct KeyboardSettings: Codable, Equatable {
558660
// AI
559661
.aiExplainQuery: KeyCombo(key: "l", command: true),
560662
.aiOptimizeQuery: KeyCombo(key: "l", command: true, option: true),
663+
664+
// Help
665+
.openDocumentation: KeyCombo(key: "f1", isSpecialKey: true),
666+
]
667+
668+
/// Default secondary (function-key) bindings, dispatched by FunctionKeyShortcutMonitor.
669+
static let defaultAlternates: [ShortcutAction: KeyCombo] = [
670+
.refresh: KeyCombo(key: "f5", isSpecialKey: true),
671+
.executeQuery: KeyCombo(key: "f9", isSpecialKey: true),
561672
]
562673
}
563674

0 commit comments

Comments
 (0)