@@ -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