KeyboardShortcutConfig.swift 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import AppKit
  2. import SwiftUI
  3. // MARK: - Shortcut Action
  4. /// All configurable keyboard shortcut actions in MixBoard.
  5. enum ShortcutAction: String, CaseIterable, Identifiable, Codable {
  6. // Playback
  7. case playPause = "Play / Pause"
  8. case stop = "Stop"
  9. case nextTrack = "Next Track"
  10. case previousTrack = "Previous Track"
  11. case skipForward = "Skip Forward 10s"
  12. case skipBackward = "Skip Backward 10s"
  13. case toggleShuffle = "Toggle Shuffle"
  14. case toggleRepeat = "Toggle Repeat"
  15. // Mix
  16. case addToMix1 = "Add to Mix 1"
  17. case addToMix2 = "Add to Mix 2"
  18. case addToMix3 = "Add to Mix 3"
  19. // General
  20. case nowPlaying = "Now Playing"
  21. case search = "Search All Playlists"
  22. case newPlaylist = "New Playlist"
  23. case exportToDAW = "Export to DAW"
  24. case importFromiPhone = "Import from iPhone"
  25. var id: String { rawValue }
  26. var group: ShortcutGroup {
  27. switch self {
  28. case .playPause, .stop, .nextTrack, .previousTrack,
  29. .skipForward, .skipBackward, .toggleShuffle, .toggleRepeat:
  30. return .playback
  31. case .addToMix1, .addToMix2, .addToMix3:
  32. return .mix
  33. case .nowPlaying, .search, .newPlaylist, .exportToDAW, .importFromiPhone:
  34. return .general
  35. }
  36. }
  37. }
  38. /// Grouping for display in the Shortcuts settings tab.
  39. enum ShortcutGroup: String, CaseIterable {
  40. case playback = "Playback"
  41. case mix = "Mix"
  42. case general = "General"
  43. var actions: [ShortcutAction] {
  44. ShortcutAction.allCases.filter { $0.group == self }
  45. }
  46. }
  47. // MARK: - Shortcut Binding
  48. /// A stored keyboard shortcut: key + modifier flags.
  49. struct ShortcutBinding: Codable, Equatable {
  50. var key: String // Character or special key: "space", "right", "left", etc.
  51. var command: Bool = false
  52. var shift: Bool = false
  53. var option: Bool = false
  54. var control: Bool = false
  55. /// Convert to SwiftUI `KeyEquivalent`.
  56. var keyEquivalent: KeyEquivalent {
  57. switch key.lowercased() {
  58. case "space": return .space
  59. case "right": return .rightArrow
  60. case "left": return .leftArrow
  61. case "up": return .upArrow
  62. case "down": return .downArrow
  63. case "return": return .return
  64. case "tab": return .tab
  65. case "delete": return .delete
  66. case "escape": return .escape
  67. default:
  68. if let char = key.first {
  69. return KeyEquivalent(char)
  70. }
  71. return KeyEquivalent("?")
  72. }
  73. }
  74. /// Convert to SwiftUI `EventModifiers`.
  75. var eventModifiers: EventModifiers {
  76. var mods: EventModifiers = []
  77. if command { mods.insert(.command) }
  78. if shift { mods.insert(.shift) }
  79. if option { mods.insert(.option) }
  80. if control { mods.insert(.control) }
  81. return mods
  82. }
  83. /// Human-readable display string, e.g. "⇧⌘1".
  84. var displayString: String {
  85. var parts: [String] = []
  86. if control { parts.append("⌃") }
  87. if option { parts.append("⌥") }
  88. if shift { parts.append("⇧") }
  89. if command { parts.append("⌘") }
  90. let keyDisplay: String
  91. switch key.lowercased() {
  92. case "space": keyDisplay = "Space"
  93. case "right": keyDisplay = "→"
  94. case "left": keyDisplay = "←"
  95. case "up": keyDisplay = "↑"
  96. case "down": keyDisplay = "↓"
  97. case "return": keyDisplay = "↩"
  98. case "tab": keyDisplay = "⇥"
  99. case "delete": keyDisplay = "⌫"
  100. case "escape": keyDisplay = "⎋"
  101. default: keyDisplay = key.uppercased()
  102. }
  103. parts.append(keyDisplay)
  104. return parts.joined()
  105. }
  106. /// Build a `ShortcutBinding` from an `NSEvent`.
  107. static func from(_ event: NSEvent) -> ShortcutBinding {
  108. let key: String
  109. switch event.keyCode {
  110. case 49: key = "space"
  111. case 124: key = "right"
  112. case 123: key = "left"
  113. case 126: key = "up"
  114. case 125: key = "down"
  115. case 36: key = "return"
  116. case 48: key = "tab"
  117. case 51: key = "delete"
  118. default:
  119. key = event.charactersIgnoringModifiers?.lowercased() ?? "?"
  120. }
  121. let mods = event.modifierFlags
  122. return ShortcutBinding(
  123. key: key,
  124. command: mods.contains(.command),
  125. shift: mods.contains(.shift),
  126. option: mods.contains(.option),
  127. control: mods.contains(.control)
  128. )
  129. }
  130. }
  131. // MARK: - Config Singleton
  132. /// Manages all keyboard shortcut bindings, persisted to UserDefaults.
  133. final class KeyboardShortcutConfig: ObservableObject {
  134. static let shared = KeyboardShortcutConfig()
  135. @Published var shortcuts: [ShortcutAction: ShortcutBinding] {
  136. didSet { save() }
  137. }
  138. /// Factory defaults.
  139. static let defaultShortcuts: [ShortcutAction: ShortcutBinding] = [
  140. .playPause: ShortcutBinding(key: "space"),
  141. .stop: ShortcutBinding(key: ".", command: true),
  142. .nextTrack: ShortcutBinding(key: "right", command: true),
  143. .previousTrack: ShortcutBinding(key: "left", command: true),
  144. .skipForward: ShortcutBinding(key: "right", command: true, shift: true),
  145. .skipBackward: ShortcutBinding(key: "left", command: true, shift: true),
  146. .toggleShuffle: ShortcutBinding(key: "s", command: true, shift: true),
  147. .toggleRepeat: ShortcutBinding(key: "r", command: true, shift: true),
  148. .addToMix1: ShortcutBinding(key: "1", command: true),
  149. .addToMix2: ShortcutBinding(key: "2", command: true),
  150. .addToMix3: ShortcutBinding(key: "3", command: true),
  151. .nowPlaying: ShortcutBinding(key: "p", command: true, shift: true),
  152. .search: ShortcutBinding(key: "f", command: true),
  153. .newPlaylist: ShortcutBinding(key: "n", command: true, shift: true),
  154. .exportToDAW: ShortcutBinding(key: "e", command: true),
  155. .importFromiPhone: ShortcutBinding(key: "i", command: true, shift: true),
  156. ]
  157. init() {
  158. shortcuts = Self.load()
  159. }
  160. /// Get the binding for an action (with fallback to default).
  161. func binding(for action: ShortcutAction) -> ShortcutBinding {
  162. shortcuts[action] ?? Self.defaultShortcuts[action]!
  163. }
  164. func resetToDefaults() {
  165. shortcuts = Self.defaultShortcuts
  166. }
  167. // MARK: - Persistence
  168. private func save() {
  169. let stringKeyed = Dictionary(uniqueKeysWithValues: shortcuts.map { ($0.key.rawValue, $0.value) })
  170. if let data = try? JSONEncoder().encode(stringKeyed) {
  171. UserDefaults.standard.set(data, forKey: "keyboardShortcuts")
  172. }
  173. }
  174. private static func load() -> [ShortcutAction: ShortcutBinding] {
  175. guard let data = UserDefaults.standard.data(forKey: "keyboardShortcuts"),
  176. let stringKeyed = try? JSONDecoder().decode([String: ShortcutBinding].self, from: data) else {
  177. return defaultShortcuts
  178. }
  179. // Merge with defaults so new actions added in updates get their fallback
  180. var result = defaultShortcuts
  181. for (key, value) in stringKeyed {
  182. if let action = ShortcutAction(rawValue: key) {
  183. result[action] = value
  184. }
  185. }
  186. return result
  187. }
  188. }