@lattices/cli 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +155 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/AccessibilityTextExtractor.swift +111 -0
  5. package/app/Sources/ActionRow.swift +61 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +242 -0
  8. package/app/Sources/AppShellView.swift +62 -0
  9. package/app/Sources/AppTypeClassifier.swift +70 -0
  10. package/app/Sources/AppWindowShell.swift +63 -0
  11. package/app/Sources/CheatSheetHUD.swift +332 -0
  12. package/app/Sources/CommandModeState.swift +1362 -0
  13. package/app/Sources/CommandModeView.swift +1405 -0
  14. package/app/Sources/CommandModeWindow.swift +192 -0
  15. package/app/Sources/CommandPaletteView.swift +307 -0
  16. package/app/Sources/CommandPaletteWindow.swift +134 -0
  17. package/app/Sources/DaemonProtocol.swift +101 -0
  18. package/app/Sources/DaemonServer.swift +414 -0
  19. package/app/Sources/DesktopModel.swift +149 -0
  20. package/app/Sources/DesktopModelTypes.swift +71 -0
  21. package/app/Sources/DiagnosticLog.swift +271 -0
  22. package/app/Sources/EventBus.swift +30 -0
  23. package/app/Sources/HotkeyManager.swift +254 -0
  24. package/app/Sources/HotkeyStore.swift +338 -0
  25. package/app/Sources/InventoryManager.swift +35 -0
  26. package/app/Sources/InventoryPath.swift +43 -0
  27. package/app/Sources/KeyRecorderView.swift +210 -0
  28. package/app/Sources/LatticesApi.swift +1234 -0
  29. package/app/Sources/LayerBezel.swift +203 -0
  30. package/app/Sources/MainView.swift +479 -0
  31. package/app/Sources/MainWindow.swift +83 -0
  32. package/app/Sources/OcrModel.swift +430 -0
  33. package/app/Sources/OcrStore.swift +329 -0
  34. package/app/Sources/OmniSearchState.swift +283 -0
  35. package/app/Sources/OmniSearchView.swift +288 -0
  36. package/app/Sources/OmniSearchWindow.swift +105 -0
  37. package/app/Sources/OrphanRow.swift +129 -0
  38. package/app/Sources/PaletteCommand.swift +419 -0
  39. package/app/Sources/PermissionChecker.swift +125 -0
  40. package/app/Sources/Preferences.swift +99 -0
  41. package/app/Sources/ProcessModel.swift +199 -0
  42. package/app/Sources/ProcessQuery.swift +151 -0
  43. package/app/Sources/Project.swift +28 -0
  44. package/app/Sources/ProjectRow.swift +368 -0
  45. package/app/Sources/ProjectScanner.swift +128 -0
  46. package/app/Sources/ScreenMapState.swift +2387 -0
  47. package/app/Sources/ScreenMapView.swift +2820 -0
  48. package/app/Sources/ScreenMapWindowController.swift +89 -0
  49. package/app/Sources/SessionManager.swift +72 -0
  50. package/app/Sources/SettingsView.swift +1064 -0
  51. package/app/Sources/SettingsWindow.swift +20 -0
  52. package/app/Sources/TabGroupRow.swift +178 -0
  53. package/app/Sources/Terminal.swift +259 -0
  54. package/app/Sources/TerminalQuery.swift +156 -0
  55. package/app/Sources/TerminalSynthesizer.swift +200 -0
  56. package/app/Sources/Theme.swift +163 -0
  57. package/app/Sources/TilePickerView.swift +209 -0
  58. package/app/Sources/TmuxModel.swift +53 -0
  59. package/app/Sources/TmuxQuery.swift +81 -0
  60. package/app/Sources/WindowTiler.swift +1778 -0
  61. package/app/Sources/WorkspaceManager.swift +575 -0
  62. package/bin/client.js +4 -0
  63. package/bin/daemon-client.js +187 -0
  64. package/bin/lattices-app.js +221 -0
  65. package/bin/lattices.js +1551 -0
  66. package/docs/api.md +924 -0
  67. package/docs/app.md +297 -0
  68. package/docs/concepts.md +135 -0
  69. package/docs/config.md +245 -0
  70. package/docs/layers.md +410 -0
  71. package/docs/ocr.md +185 -0
  72. package/docs/overview.md +94 -0
  73. package/docs/quickstart.md +75 -0
  74. package/package.json +42 -0
@@ -0,0 +1,338 @@
1
+ import Carbon
2
+ import AppKit
3
+ import Combine
4
+
5
+ // MARK: - HotkeyGroup
6
+
7
+ enum HotkeyGroup: String, CaseIterable {
8
+ case app
9
+ case layers
10
+ case tiling
11
+ }
12
+
13
+ // MARK: - HotkeyAction
14
+
15
+ enum HotkeyAction: String, CaseIterable, Codable {
16
+ // App
17
+ case palette
18
+ case screenMap
19
+ case bezel
20
+ case cheatSheet
21
+ case desktopInventory
22
+ case omniSearch
23
+ // Layers
24
+ case layer1, layer2, layer3, layer4, layer5, layer6, layer7, layer8, layer9
25
+ // Tiling
26
+ case tileLeft, tileRight, tileMaximize, tileCenter
27
+ case tileTopLeft, tileTopRight, tileBottomLeft, tileBottomRight
28
+ case tileTop, tileBottom, tileDistribute
29
+ case tileLeftThird, tileCenterThird, tileRightThird
30
+
31
+ var label: String {
32
+ switch self {
33
+ case .palette: return "Command Palette"
34
+ case .screenMap: return "Screen Map"
35
+ case .bezel: return "Window Bezel"
36
+ case .cheatSheet: return "Cheat Sheet"
37
+ case .desktopInventory: return "Desktop Inventory"
38
+ case .omniSearch: return "Omni Search"
39
+ case .layer1: return "Layer 1"
40
+ case .layer2: return "Layer 2"
41
+ case .layer3: return "Layer 3"
42
+ case .layer4: return "Layer 4"
43
+ case .layer5: return "Layer 5"
44
+ case .layer6: return "Layer 6"
45
+ case .layer7: return "Layer 7"
46
+ case .layer8: return "Layer 8"
47
+ case .layer9: return "Layer 9"
48
+ case .tileLeft: return "Tile Left"
49
+ case .tileRight: return "Tile Right"
50
+ case .tileMaximize: return "Maximize"
51
+ case .tileCenter: return "Center"
52
+ case .tileTopLeft: return "Top Left"
53
+ case .tileTopRight: return "Top Right"
54
+ case .tileBottomLeft: return "Bottom Left"
55
+ case .tileBottomRight: return "Bottom Right"
56
+ case .tileTop: return "Top Half"
57
+ case .tileBottom: return "Bottom Half"
58
+ case .tileDistribute: return "Distribute"
59
+ case .tileLeftThird: return "Left Third"
60
+ case .tileCenterThird: return "Center Third"
61
+ case .tileRightThird: return "Right Third"
62
+ }
63
+ }
64
+
65
+ var group: HotkeyGroup {
66
+ switch self {
67
+ case .palette, .screenMap, .bezel, .cheatSheet, .desktopInventory, .omniSearch: return .app
68
+ case .layer1, .layer2, .layer3, .layer4, .layer5,
69
+ .layer6, .layer7, .layer8, .layer9: return .layers
70
+ default: return .tiling
71
+ }
72
+ }
73
+
74
+ var carbonID: UInt32 {
75
+ switch self {
76
+ case .palette: return 1
77
+ case .screenMap: return 200
78
+ case .bezel: return 201
79
+ case .cheatSheet: return 202
80
+ case .desktopInventory: return 203
81
+ case .omniSearch: return 204
82
+ case .layer1: return 101
83
+ case .layer2: return 102
84
+ case .layer3: return 103
85
+ case .layer4: return 104
86
+ case .layer5: return 105
87
+ case .layer6: return 106
88
+ case .layer7: return 107
89
+ case .layer8: return 108
90
+ case .layer9: return 109
91
+ case .tileLeft: return 300
92
+ case .tileRight: return 301
93
+ case .tileMaximize: return 302
94
+ case .tileCenter: return 303
95
+ case .tileTopLeft: return 304
96
+ case .tileTopRight: return 305
97
+ case .tileBottomLeft: return 306
98
+ case .tileBottomRight: return 307
99
+ case .tileTop: return 308
100
+ case .tileBottom: return 309
101
+ case .tileDistribute: return 310
102
+ case .tileLeftThird: return 311
103
+ case .tileCenterThird: return 312
104
+ case .tileRightThird: return 313
105
+ }
106
+ }
107
+
108
+ static var layerActions: [HotkeyAction] {
109
+ [.layer1, .layer2, .layer3, .layer4, .layer5, .layer6, .layer7, .layer8, .layer9]
110
+ }
111
+ }
112
+
113
+ // MARK: - KeyBinding
114
+
115
+ struct KeyBinding: Codable, Equatable {
116
+ let keyCode: UInt32
117
+ let carbonModifiers: UInt32
118
+ var displayParts: [String]
119
+
120
+ static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 {
121
+ var mods: UInt32 = 0
122
+ if flags.contains(.command) { mods |= UInt32(cmdKey) }
123
+ if flags.contains(.shift) { mods |= UInt32(shiftKey) }
124
+ if flags.contains(.option) { mods |= UInt32(optionKey) }
125
+ if flags.contains(.control) { mods |= UInt32(controlKey) }
126
+ return mods
127
+ }
128
+
129
+ static func displayParts(keyCode: UInt32, carbonModifiers: UInt32) -> [String] {
130
+ var parts: [String] = []
131
+ if carbonModifiers & UInt32(controlKey) != 0 { parts.append("Ctrl") }
132
+ if carbonModifiers & UInt32(optionKey) != 0 { parts.append("Option") }
133
+ if carbonModifiers & UInt32(shiftKey) != 0 { parts.append("Shift") }
134
+ if carbonModifiers & UInt32(cmdKey) != 0 { parts.append("Cmd") }
135
+ parts.append(keyName(for: keyCode))
136
+ return parts
137
+ }
138
+
139
+ static func keyName(for keyCode: UInt32) -> String {
140
+ let names: [UInt32: String] = [
141
+ 0: "A", 1: "S", 2: "D", 3: "F", 4: "H", 5: "G", 6: "Z", 7: "X",
142
+ 8: "C", 9: "V", 11: "B", 12: "Q", 13: "W", 14: "E", 15: "R",
143
+ 16: "Y", 17: "T", 18: "1", 19: "2", 20: "3", 21: "4", 22: "6",
144
+ 23: "5", 24: "=", 25: "9", 26: "7", 27: "-", 28: "8", 29: "0",
145
+ 30: "]", 31: "O", 32: "U", 33: "[", 34: "I", 35: "P",
146
+ 36: "Return", 37: "L", 38: "J", 39: "'", 40: "K", 41: ";",
147
+ 42: "\\", 43: ",", 44: "/", 45: "N", 46: "M", 47: ".",
148
+ 48: "Tab", 49: "Space", 50: "`", 51: "Delete",
149
+ 53: "Escape",
150
+ 65: ".", // numpad
151
+ 67: "*", // numpad
152
+ 69: "+", // numpad
153
+ 71: "Clear", // numpad
154
+ 75: "/", // numpad
155
+ 76: "Enter", // numpad
156
+ 78: "-", // numpad
157
+ 82: "0", 83: "1", 84: "2", 85: "3", 86: "4", // numpad
158
+ 87: "5", 88: "6", 89: "7", 91: "8", 92: "9", // numpad
159
+ 96: "F5", 97: "F6", 98: "F7", 99: "F3", 100: "F8",
160
+ 101: "F9", 103: "F11", 105: "F13", 107: "F14",
161
+ 109: "F10", 111: "F12", 113: "F15", 114: "Help",
162
+ 115: "Home", 116: "PgUp", 117: "Del", 118: "F4",
163
+ 119: "End", 120: "F2", 121: "PgDn", 122: "F1",
164
+ 123: "\u{2190}", // left arrow
165
+ 124: "\u{2192}", // right arrow
166
+ 125: "\u{2193}", // down arrow
167
+ 126: "\u{2191}", // up arrow
168
+ ]
169
+ return names[keyCode] ?? "Key\(keyCode)"
170
+ }
171
+ }
172
+
173
+ // MARK: - HotkeyStore
174
+
175
+ class HotkeyStore: ObservableObject {
176
+ static let shared = HotkeyStore()
177
+
178
+ @Published var bindings: [HotkeyAction: KeyBinding]
179
+ private var callbacks: [HotkeyAction: () -> Void] = [:]
180
+
181
+ static let defaultBindings: [HotkeyAction: KeyBinding] = {
182
+ var d = [HotkeyAction: KeyBinding]()
183
+ let hyper = UInt32(cmdKey | controlKey | optionKey | shiftKey)
184
+ let cmdShift = UInt32(cmdKey | shiftKey)
185
+ let cmdOpt = UInt32(cmdKey | optionKey)
186
+ let ctrlOpt = UInt32(controlKey | optionKey)
187
+
188
+ func bind(_ action: HotkeyAction, _ keyCode: UInt32, _ mods: UInt32) {
189
+ d[action] = KeyBinding(
190
+ keyCode: keyCode,
191
+ carbonModifiers: mods,
192
+ displayParts: KeyBinding.displayParts(keyCode: keyCode, carbonModifiers: mods)
193
+ )
194
+ }
195
+
196
+ // App
197
+ bind(.palette, 46, cmdShift) // Cmd+Shift+M
198
+ bind(.screenMap, 18, hyper) // Hyper+1
199
+ bind(.bezel, 19, hyper) // Hyper+2
200
+ bind(.cheatSheet, 20, hyper) // Hyper+3
201
+ bind(.desktopInventory, 21, hyper) // Hyper+4
202
+ bind(.omniSearch, 23, hyper) // Hyper+5
203
+
204
+ // Layers: Cmd+Option+1-9
205
+ let layerKeyCodes: [UInt32] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
206
+ for (i, action) in HotkeyAction.layerActions.enumerated() {
207
+ bind(action, layerKeyCodes[i], cmdOpt)
208
+ }
209
+
210
+ // Tiling: Ctrl+Option for all
211
+ bind(.tileLeft, 123, ctrlOpt) // Ctrl+Opt+←
212
+ bind(.tileRight, 124, ctrlOpt) // Ctrl+Opt+→
213
+ bind(.tileTop, 126, ctrlOpt) // Ctrl+Opt+↑
214
+ bind(.tileBottom, 125, ctrlOpt) // Ctrl+Opt+↓
215
+ bind(.tileMaximize, 36, ctrlOpt) // Ctrl+Opt+Return
216
+ bind(.tileCenter, 8, ctrlOpt) // Ctrl+Opt+C
217
+ bind(.tileTopLeft, 32, ctrlOpt) // Ctrl+Opt+U
218
+ bind(.tileTopRight, 34, ctrlOpt) // Ctrl+Opt+I
219
+ bind(.tileBottomLeft, 38, ctrlOpt) // Ctrl+Opt+J
220
+ bind(.tileBottomRight, 40, ctrlOpt) // Ctrl+Opt+K
221
+ bind(.tileDistribute, 2, ctrlOpt) // Ctrl+Opt+D
222
+ bind(.tileLeftThird, 18, ctrlOpt) // Ctrl+Opt+1
223
+ bind(.tileCenterThird, 19, ctrlOpt) // Ctrl+Opt+2
224
+ bind(.tileRightThird, 20, ctrlOpt) // Ctrl+Opt+3
225
+
226
+ return d
227
+ }()
228
+
229
+ private init() {
230
+ // Start with defaults
231
+ var merged = Self.defaultBindings
232
+
233
+ // Layer 2: UserDefaults overrides
234
+ let ud = UserDefaults.standard
235
+ for action in HotkeyAction.allCases {
236
+ let key = "hotkey.\(action.rawValue)"
237
+ if let data = ud.data(forKey: key),
238
+ let binding = try? JSONDecoder().decode(KeyBinding.self, from: data) {
239
+ merged[action] = binding
240
+ }
241
+ }
242
+
243
+ // Layer 3: ~/.lattices/hotkeys.json overrides
244
+ let jsonPath = NSHomeDirectory() + "/.lattices/hotkeys.json"
245
+ if let data = FileManager.default.contents(atPath: jsonPath),
246
+ let overrides = try? JSONDecoder().decode([String: KeyBinding].self, from: data) {
247
+ for (rawValue, binding) in overrides {
248
+ if let action = HotkeyAction(rawValue: rawValue) {
249
+ merged[action] = binding
250
+ }
251
+ }
252
+ }
253
+
254
+ self.bindings = merged
255
+ }
256
+
257
+ // MARK: - Registration
258
+
259
+ func register(action: HotkeyAction, callback: @escaping () -> Void) {
260
+ callbacks[action] = callback
261
+ guard let binding = bindings[action] else { return }
262
+ HotkeyManager.shared.registerSingle(
263
+ id: action.carbonID,
264
+ keyCode: binding.keyCode,
265
+ modifiers: binding.carbonModifiers,
266
+ callback: callback
267
+ )
268
+ }
269
+
270
+ // MARK: - Update
271
+
272
+ func updateBinding(for action: HotkeyAction, to binding: KeyBinding) {
273
+ bindings[action] = binding
274
+
275
+ // Persist to UserDefaults
276
+ if let data = try? JSONEncoder().encode(binding) {
277
+ UserDefaults.standard.set(data, forKey: "hotkey.\(action.rawValue)")
278
+ }
279
+
280
+ // Re-register if we have a callback
281
+ if let callback = callbacks[action] {
282
+ HotkeyManager.shared.registerSingle(
283
+ id: action.carbonID,
284
+ keyCode: binding.keyCode,
285
+ modifiers: binding.carbonModifiers,
286
+ callback: callback
287
+ )
288
+ }
289
+ }
290
+
291
+ // MARK: - Reset
292
+
293
+ func resetBinding(for action: HotkeyAction) {
294
+ guard let defaultBinding = Self.defaultBindings[action] else { return }
295
+ UserDefaults.standard.removeObject(forKey: "hotkey.\(action.rawValue)")
296
+ bindings[action] = defaultBinding
297
+
298
+ if let callback = callbacks[action] {
299
+ HotkeyManager.shared.registerSingle(
300
+ id: action.carbonID,
301
+ keyCode: defaultBinding.keyCode,
302
+ modifiers: defaultBinding.carbonModifiers,
303
+ callback: callback
304
+ )
305
+ }
306
+ }
307
+
308
+ func resetAll() {
309
+ for action in HotkeyAction.allCases {
310
+ UserDefaults.standard.removeObject(forKey: "hotkey.\(action.rawValue)")
311
+ }
312
+ bindings = Self.defaultBindings
313
+
314
+ // Re-register all with stored callbacks
315
+ for (action, callback) in callbacks {
316
+ guard let binding = bindings[action] else { continue }
317
+ HotkeyManager.shared.registerSingle(
318
+ id: action.carbonID,
319
+ keyCode: binding.keyCode,
320
+ modifiers: binding.carbonModifiers,
321
+ callback: callback
322
+ )
323
+ }
324
+ }
325
+
326
+ // MARK: - Conflict detection
327
+
328
+ func conflicts(for action: HotkeyAction, with binding: KeyBinding) -> HotkeyAction? {
329
+ for (existingAction, existingBinding) in bindings {
330
+ if existingAction != action &&
331
+ existingBinding.keyCode == binding.keyCode &&
332
+ existingBinding.carbonModifiers == binding.carbonModifiers {
333
+ return existingAction
334
+ }
335
+ }
336
+ return nil
337
+ }
338
+ }
@@ -0,0 +1,35 @@
1
+ import Foundation
2
+
3
+ class InventoryManager: ObservableObject {
4
+ static let shared = InventoryManager()
5
+
6
+ @Published var orphans: [TmuxSession] = []
7
+ @Published var allSessions: [TmuxSession] = []
8
+
9
+ func refresh() {
10
+ // Always query fresh — this is called on explicit user refresh
11
+ let sessions = TmuxQuery.listSessions()
12
+
13
+ // Build set of managed session names
14
+ var managed = Set<String>()
15
+
16
+ // From scanned projects
17
+ for project in ProjectScanner.shared.projects {
18
+ managed.insert(project.sessionName)
19
+ }
20
+
21
+ // From workspace tab groups
22
+ if let groups = WorkspaceManager.shared.config?.groups {
23
+ for group in groups {
24
+ for tab in group.tabs {
25
+ managed.insert(WorkspaceManager.sessionName(for: tab.path))
26
+ }
27
+ }
28
+ }
29
+
30
+ DispatchQueue.main.async {
31
+ self.allSessions = sessions
32
+ self.orphans = sessions.filter { !managed.contains($0.name) }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,43 @@
1
+ import AppKit
2
+
3
+ struct InventoryPath: Equatable {
4
+ let display: String
5
+ let space: String
6
+ let appType: String
7
+ let appName: String
8
+ let windowTitle: String
9
+
10
+ var description: String {
11
+ [display, space, appType, appName, windowTitle]
12
+ .map { sanitize($0) }
13
+ .joined(separator: ".")
14
+ }
15
+
16
+ func matches(pattern: String) -> Bool {
17
+ let segments = description.split(separator: ".").map(String.init)
18
+ let patternSegments = pattern.lowercased().split(separator: ".").map(String.init)
19
+
20
+ for (i, pat) in patternSegments.enumerated() {
21
+ guard i < segments.count else { return false }
22
+ if pat == "*" { continue }
23
+ if !segments[i].hasPrefix(pat) { return false }
24
+ }
25
+ return true
26
+ }
27
+
28
+ static func displayName(for screen: NSScreen, isMain: Bool) -> String {
29
+ if isMain { return "main" }
30
+ return sanitizeStatic(screen.localizedName)
31
+ }
32
+
33
+ private func sanitize(_ s: String) -> String {
34
+ Self.sanitizeStatic(s)
35
+ }
36
+
37
+ private static func sanitizeStatic(_ s: String) -> String {
38
+ s.lowercased()
39
+ .replacingOccurrences(of: "[\\s./\\\\]+", with: "-", options: .regularExpression)
40
+ .replacingOccurrences(of: "[^a-z0-9_-]", with: "", options: .regularExpression)
41
+ .trimmingCharacters(in: CharacterSet(charactersIn: "-"))
42
+ }
43
+ }
@@ -0,0 +1,210 @@
1
+ import SwiftUI
2
+ import Carbon
3
+
4
+ // MARK: - KeyRecorderView
5
+
6
+ struct KeyRecorderView: View {
7
+ let action: HotkeyAction
8
+ @ObservedObject var store: HotkeyStore
9
+
10
+ @State private var isCapturing = false
11
+ @State private var conflictAction: HotkeyAction?
12
+ @State private var pendingBinding: KeyBinding?
13
+ @State private var showConflict = false
14
+
15
+ private var binding: KeyBinding? { store.bindings[action] }
16
+ private var isModified: Bool {
17
+ binding != HotkeyStore.defaultBindings[action]
18
+ }
19
+
20
+ var body: some View {
21
+ HStack(spacing: 8) {
22
+ // Action label
23
+ Text(action.label)
24
+ .font(Typo.caption(11))
25
+ .foregroundColor(Palette.textDim)
26
+ .frame(minWidth: 60, idealWidth: 90, alignment: .trailing)
27
+ .lineLimit(1)
28
+
29
+ // Key badges or capture prompt
30
+ if isCapturing {
31
+ Text("Press shortcut...")
32
+ .font(Typo.mono(11))
33
+ .foregroundColor(Palette.running)
34
+ .frame(minWidth: 80, alignment: .leading)
35
+ } else if let binding = binding {
36
+ HStack(spacing: 4) {
37
+ ForEach(binding.displayParts, id: \.self) { part in
38
+ keyBadge(part)
39
+ }
40
+ }
41
+ .frame(minWidth: 80, alignment: .leading)
42
+ }
43
+
44
+ Spacer()
45
+
46
+ // Edit button
47
+ Button {
48
+ if isCapturing {
49
+ isCapturing = false
50
+ } else {
51
+ isCapturing = true
52
+ }
53
+ } label: {
54
+ Text(isCapturing ? "Cancel" : "Edit")
55
+ .font(Typo.caption(10))
56
+ .foregroundColor(isCapturing ? Palette.kill : Palette.textDim)
57
+ .padding(.horizontal, 8)
58
+ .padding(.vertical, 3)
59
+ .background(
60
+ RoundedRectangle(cornerRadius: 3)
61
+ .fill(Palette.surface)
62
+ .overlay(
63
+ RoundedRectangle(cornerRadius: 3)
64
+ .strokeBorder(Palette.border, lineWidth: 0.5)
65
+ )
66
+ )
67
+ }
68
+ .buttonStyle(.plain)
69
+
70
+ // Reset link (only when modified)
71
+ if isModified {
72
+ Button {
73
+ store.resetBinding(for: action)
74
+ } label: {
75
+ Text("Reset")
76
+ .font(Typo.caption(10))
77
+ .foregroundColor(Palette.detach)
78
+ }
79
+ .buttonStyle(.plain)
80
+ }
81
+ }
82
+ .padding(.vertical, 2)
83
+ .background(
84
+ isCapturing
85
+ ? KeyCaptureOverlay(onCapture: handleCapture, onCancel: { isCapturing = false })
86
+ : nil
87
+ )
88
+ .alert("Shortcut Conflict", isPresented: $showConflict) {
89
+ Button("Replace") {
90
+ if let pending = pendingBinding, let conflict = conflictAction {
91
+ // Remove conflicting binding by resetting it
92
+ store.resetBinding(for: conflict)
93
+ store.updateBinding(for: action, to: pending)
94
+ }
95
+ pendingBinding = nil
96
+ conflictAction = nil
97
+ isCapturing = false
98
+ }
99
+ Button("Cancel", role: .cancel) {
100
+ pendingBinding = nil
101
+ conflictAction = nil
102
+ }
103
+ } message: {
104
+ if let conflict = conflictAction {
105
+ Text("This shortcut is already assigned to \"\(conflict.label)\". Replace it?")
106
+ }
107
+ }
108
+ }
109
+
110
+ private func handleCapture(_ binding: KeyBinding) {
111
+ if let conflict = store.conflicts(for: action, with: binding) {
112
+ pendingBinding = binding
113
+ conflictAction = conflict
114
+ showConflict = true
115
+ } else {
116
+ store.updateBinding(for: action, to: binding)
117
+ isCapturing = false
118
+ }
119
+ }
120
+
121
+ private func keyBadge(_ key: String) -> some View {
122
+ Text(key)
123
+ .font(Typo.geistMonoBold(10))
124
+ .foregroundColor(Palette.text)
125
+ .padding(.horizontal, 6)
126
+ .padding(.vertical, 3)
127
+ .background(
128
+ RoundedRectangle(cornerRadius: 3)
129
+ .fill(Palette.surface)
130
+ .overlay(
131
+ RoundedRectangle(cornerRadius: 3)
132
+ .strokeBorder(Palette.border, lineWidth: 0.5)
133
+ )
134
+ )
135
+ }
136
+ }
137
+
138
+ // MARK: - KeyCaptureOverlay (NSViewRepresentable bridge)
139
+
140
+ struct KeyCaptureOverlay: NSViewRepresentable {
141
+ let onCapture: (KeyBinding) -> Void
142
+ let onCancel: () -> Void
143
+
144
+ func makeNSView(context: Context) -> KeyCaptureNSView {
145
+ let view = KeyCaptureNSView()
146
+ view.onCapture = onCapture
147
+ view.onCancel = onCancel
148
+ return view
149
+ }
150
+
151
+ func updateNSView(_ nsView: KeyCaptureNSView, context: Context) {
152
+ nsView.onCapture = onCapture
153
+ nsView.onCancel = onCancel
154
+ }
155
+ }
156
+
157
+ class KeyCaptureNSView: NSView {
158
+ var onCapture: ((KeyBinding) -> Void)?
159
+ var onCancel: (() -> Void)?
160
+ private var monitor: Any?
161
+
162
+ override func viewDidMoveToWindow() {
163
+ super.viewDidMoveToWindow()
164
+ if window != nil {
165
+ startMonitoring()
166
+ } else {
167
+ stopMonitoring()
168
+ }
169
+ }
170
+
171
+ private func startMonitoring() {
172
+ stopMonitoring()
173
+ monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
174
+ guard let self = self else { return event }
175
+
176
+ // Escape cancels
177
+ if event.keyCode == 53 {
178
+ self.onCancel?()
179
+ return nil
180
+ }
181
+
182
+ // Require at least one modifier
183
+ let mods = event.modifierFlags.intersection([.command, .shift, .option, .control])
184
+ guard !mods.isEmpty else { return nil }
185
+
186
+ let keyCode = UInt32(event.keyCode)
187
+ let carbonMods = KeyBinding.carbonModifiers(from: mods)
188
+ let parts = KeyBinding.displayParts(keyCode: keyCode, carbonModifiers: carbonMods)
189
+
190
+ let binding = KeyBinding(
191
+ keyCode: keyCode,
192
+ carbonModifiers: carbonMods,
193
+ displayParts: parts
194
+ )
195
+ self.onCapture?(binding)
196
+ return nil // swallow the event
197
+ }
198
+ }
199
+
200
+ private func stopMonitoring() {
201
+ if let monitor = monitor {
202
+ NSEvent.removeMonitor(monitor)
203
+ self.monitor = nil
204
+ }
205
+ }
206
+
207
+ deinit {
208
+ stopMonitoring()
209
+ }
210
+ }