@arach/lattices 0.1.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 (64) hide show
  1. package/README.md +157 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/App.swift +49 -0
  5. package/app/Sources/AppDelegate.swift +104 -0
  6. package/app/Sources/AppShellView.swift +62 -0
  7. package/app/Sources/AppTypeClassifier.swift +70 -0
  8. package/app/Sources/AppWindowShell.swift +63 -0
  9. package/app/Sources/CheatSheetHUD.swift +331 -0
  10. package/app/Sources/CommandModeState.swift +1341 -0
  11. package/app/Sources/CommandModeView.swift +1380 -0
  12. package/app/Sources/CommandModeWindow.swift +192 -0
  13. package/app/Sources/CommandPaletteView.swift +307 -0
  14. package/app/Sources/CommandPaletteWindow.swift +134 -0
  15. package/app/Sources/DaemonProtocol.swift +101 -0
  16. package/app/Sources/DaemonServer.swift +406 -0
  17. package/app/Sources/DesktopModel.swift +121 -0
  18. package/app/Sources/DesktopModelTypes.swift +71 -0
  19. package/app/Sources/DiagnosticLog.swift +253 -0
  20. package/app/Sources/EventBus.swift +29 -0
  21. package/app/Sources/HotkeyManager.swift +249 -0
  22. package/app/Sources/HotkeyStore.swift +330 -0
  23. package/app/Sources/InventoryManager.swift +35 -0
  24. package/app/Sources/InventoryPath.swift +43 -0
  25. package/app/Sources/KeyRecorderView.swift +210 -0
  26. package/app/Sources/LatticesApi.swift +915 -0
  27. package/app/Sources/MainView.swift +507 -0
  28. package/app/Sources/MainWindow.swift +70 -0
  29. package/app/Sources/OrphanRow.swift +129 -0
  30. package/app/Sources/PaletteCommand.swift +409 -0
  31. package/app/Sources/PermissionChecker.swift +115 -0
  32. package/app/Sources/Preferences.swift +48 -0
  33. package/app/Sources/ProcessModel.swift +199 -0
  34. package/app/Sources/ProcessQuery.swift +151 -0
  35. package/app/Sources/Project.swift +28 -0
  36. package/app/Sources/ProjectRow.swift +368 -0
  37. package/app/Sources/ProjectScanner.swift +121 -0
  38. package/app/Sources/ScreenMapState.swift +2397 -0
  39. package/app/Sources/ScreenMapView.swift +2817 -0
  40. package/app/Sources/ScreenMapWindowController.swift +89 -0
  41. package/app/Sources/SessionManager.swift +72 -0
  42. package/app/Sources/SettingsView.swift +641 -0
  43. package/app/Sources/SettingsWindow.swift +20 -0
  44. package/app/Sources/TabGroupRow.swift +178 -0
  45. package/app/Sources/Terminal.swift +259 -0
  46. package/app/Sources/TerminalQuery.swift +156 -0
  47. package/app/Sources/TerminalSynthesizer.swift +200 -0
  48. package/app/Sources/Theme.swift +124 -0
  49. package/app/Sources/TilePickerView.swift +209 -0
  50. package/app/Sources/TmuxModel.swift +53 -0
  51. package/app/Sources/TmuxQuery.swift +81 -0
  52. package/app/Sources/WindowTiler.swift +1752 -0
  53. package/app/Sources/WorkspaceManager.swift +434 -0
  54. package/bin/daemon-client.js +187 -0
  55. package/bin/lattices-app.js +205 -0
  56. package/bin/lattices.js +1295 -0
  57. package/docs/api.md +707 -0
  58. package/docs/app.md +250 -0
  59. package/docs/concepts.md +225 -0
  60. package/docs/config.md +234 -0
  61. package/docs/layers.md +317 -0
  62. package/docs/overview.md +74 -0
  63. package/docs/quickstart.md +82 -0
  64. package/package.json +38 -0
@@ -0,0 +1,330 @@
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
+ // Layers
22
+ case layer1, layer2, layer3, layer4, layer5, layer6, layer7, layer8, layer9
23
+ // Tiling
24
+ case tileLeft, tileRight, tileMaximize, tileCenter
25
+ case tileTopLeft, tileTopRight, tileBottomLeft, tileBottomRight
26
+ case tileTop, tileBottom, tileDistribute
27
+ case tileLeftThird, tileCenterThird, tileRightThird
28
+
29
+ var label: String {
30
+ switch self {
31
+ case .palette: return "Command Palette"
32
+ case .screenMap: return "Screen Map"
33
+ case .bezel: return "Window Bezel"
34
+ case .cheatSheet: return "Cheat Sheet"
35
+ case .layer1: return "Layer 1"
36
+ case .layer2: return "Layer 2"
37
+ case .layer3: return "Layer 3"
38
+ case .layer4: return "Layer 4"
39
+ case .layer5: return "Layer 5"
40
+ case .layer6: return "Layer 6"
41
+ case .layer7: return "Layer 7"
42
+ case .layer8: return "Layer 8"
43
+ case .layer9: return "Layer 9"
44
+ case .tileLeft: return "Tile Left"
45
+ case .tileRight: return "Tile Right"
46
+ case .tileMaximize: return "Maximize"
47
+ case .tileCenter: return "Center"
48
+ case .tileTopLeft: return "Top Left"
49
+ case .tileTopRight: return "Top Right"
50
+ case .tileBottomLeft: return "Bottom Left"
51
+ case .tileBottomRight: return "Bottom Right"
52
+ case .tileTop: return "Top Half"
53
+ case .tileBottom: return "Bottom Half"
54
+ case .tileDistribute: return "Distribute"
55
+ case .tileLeftThird: return "Left Third"
56
+ case .tileCenterThird: return "Center Third"
57
+ case .tileRightThird: return "Right Third"
58
+ }
59
+ }
60
+
61
+ var group: HotkeyGroup {
62
+ switch self {
63
+ case .palette, .screenMap, .bezel, .cheatSheet: return .app
64
+ case .layer1, .layer2, .layer3, .layer4, .layer5,
65
+ .layer6, .layer7, .layer8, .layer9: return .layers
66
+ default: return .tiling
67
+ }
68
+ }
69
+
70
+ var carbonID: UInt32 {
71
+ switch self {
72
+ case .palette: return 1
73
+ case .screenMap: return 200
74
+ case .bezel: return 201
75
+ case .cheatSheet: return 202
76
+ case .layer1: return 101
77
+ case .layer2: return 102
78
+ case .layer3: return 103
79
+ case .layer4: return 104
80
+ case .layer5: return 105
81
+ case .layer6: return 106
82
+ case .layer7: return 107
83
+ case .layer8: return 108
84
+ case .layer9: return 109
85
+ case .tileLeft: return 300
86
+ case .tileRight: return 301
87
+ case .tileMaximize: return 302
88
+ case .tileCenter: return 303
89
+ case .tileTopLeft: return 304
90
+ case .tileTopRight: return 305
91
+ case .tileBottomLeft: return 306
92
+ case .tileBottomRight: return 307
93
+ case .tileTop: return 308
94
+ case .tileBottom: return 309
95
+ case .tileDistribute: return 310
96
+ case .tileLeftThird: return 311
97
+ case .tileCenterThird: return 312
98
+ case .tileRightThird: return 313
99
+ }
100
+ }
101
+
102
+ static var layerActions: [HotkeyAction] {
103
+ [.layer1, .layer2, .layer3, .layer4, .layer5, .layer6, .layer7, .layer8, .layer9]
104
+ }
105
+ }
106
+
107
+ // MARK: - KeyBinding
108
+
109
+ struct KeyBinding: Codable, Equatable {
110
+ let keyCode: UInt32
111
+ let carbonModifiers: UInt32
112
+ var displayParts: [String]
113
+
114
+ static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 {
115
+ var mods: UInt32 = 0
116
+ if flags.contains(.command) { mods |= UInt32(cmdKey) }
117
+ if flags.contains(.shift) { mods |= UInt32(shiftKey) }
118
+ if flags.contains(.option) { mods |= UInt32(optionKey) }
119
+ if flags.contains(.control) { mods |= UInt32(controlKey) }
120
+ return mods
121
+ }
122
+
123
+ static func displayParts(keyCode: UInt32, carbonModifiers: UInt32) -> [String] {
124
+ var parts: [String] = []
125
+ if carbonModifiers & UInt32(controlKey) != 0 { parts.append("Ctrl") }
126
+ if carbonModifiers & UInt32(optionKey) != 0 { parts.append("Option") }
127
+ if carbonModifiers & UInt32(shiftKey) != 0 { parts.append("Shift") }
128
+ if carbonModifiers & UInt32(cmdKey) != 0 { parts.append("Cmd") }
129
+ parts.append(keyName(for: keyCode))
130
+ return parts
131
+ }
132
+
133
+ static func keyName(for keyCode: UInt32) -> String {
134
+ let names: [UInt32: String] = [
135
+ 0: "A", 1: "S", 2: "D", 3: "F", 4: "H", 5: "G", 6: "Z", 7: "X",
136
+ 8: "C", 9: "V", 11: "B", 12: "Q", 13: "W", 14: "E", 15: "R",
137
+ 16: "Y", 17: "T", 18: "1", 19: "2", 20: "3", 21: "4", 22: "6",
138
+ 23: "5", 24: "=", 25: "9", 26: "7", 27: "-", 28: "8", 29: "0",
139
+ 30: "]", 31: "O", 32: "U", 33: "[", 34: "I", 35: "P",
140
+ 36: "Return", 37: "L", 38: "J", 39: "'", 40: "K", 41: ";",
141
+ 42: "\\", 43: ",", 44: "/", 45: "N", 46: "M", 47: ".",
142
+ 48: "Tab", 49: "Space", 50: "`", 51: "Delete",
143
+ 53: "Escape",
144
+ 65: ".", // numpad
145
+ 67: "*", // numpad
146
+ 69: "+", // numpad
147
+ 71: "Clear", // numpad
148
+ 75: "/", // numpad
149
+ 76: "Enter", // numpad
150
+ 78: "-", // numpad
151
+ 82: "0", 83: "1", 84: "2", 85: "3", 86: "4", // numpad
152
+ 87: "5", 88: "6", 89: "7", 91: "8", 92: "9", // numpad
153
+ 96: "F5", 97: "F6", 98: "F7", 99: "F3", 100: "F8",
154
+ 101: "F9", 103: "F11", 105: "F13", 107: "F14",
155
+ 109: "F10", 111: "F12", 113: "F15", 114: "Help",
156
+ 115: "Home", 116: "PgUp", 117: "Del", 118: "F4",
157
+ 119: "End", 120: "F2", 121: "PgDn", 122: "F1",
158
+ 123: "\u{2190}", // left arrow
159
+ 124: "\u{2192}", // right arrow
160
+ 125: "\u{2193}", // down arrow
161
+ 126: "\u{2191}", // up arrow
162
+ ]
163
+ return names[keyCode] ?? "Key\(keyCode)"
164
+ }
165
+ }
166
+
167
+ // MARK: - HotkeyStore
168
+
169
+ class HotkeyStore: ObservableObject {
170
+ static let shared = HotkeyStore()
171
+
172
+ @Published var bindings: [HotkeyAction: KeyBinding]
173
+ private var callbacks: [HotkeyAction: () -> Void] = [:]
174
+
175
+ static let defaultBindings: [HotkeyAction: KeyBinding] = {
176
+ var d = [HotkeyAction: KeyBinding]()
177
+ let hyper = UInt32(cmdKey | controlKey | optionKey | shiftKey)
178
+ let cmdShift = UInt32(cmdKey | shiftKey)
179
+ let cmdOpt = UInt32(cmdKey | optionKey)
180
+ let ctrlOpt = UInt32(controlKey | optionKey)
181
+
182
+ func bind(_ action: HotkeyAction, _ keyCode: UInt32, _ mods: UInt32) {
183
+ d[action] = KeyBinding(
184
+ keyCode: keyCode,
185
+ carbonModifiers: mods,
186
+ displayParts: KeyBinding.displayParts(keyCode: keyCode, carbonModifiers: mods)
187
+ )
188
+ }
189
+
190
+ // App
191
+ bind(.palette, 46, cmdShift) // Cmd+Shift+M
192
+ bind(.screenMap, 18, hyper) // Hyper+1
193
+ bind(.bezel, 19, hyper) // Hyper+2
194
+ bind(.cheatSheet, 20, hyper) // Hyper+3
195
+
196
+ // Layers: Cmd+Option+1-9
197
+ let layerKeyCodes: [UInt32] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
198
+ for (i, action) in HotkeyAction.layerActions.enumerated() {
199
+ bind(action, layerKeyCodes[i], cmdOpt)
200
+ }
201
+
202
+ // Tiling: Ctrl+Option+...
203
+ bind(.tileLeft, 123, ctrlOpt) // ←
204
+ bind(.tileRight, 124, ctrlOpt) // →
205
+ bind(.tileMaximize, 36, ctrlOpt) // Return
206
+ bind(.tileCenter, 8, ctrlOpt) // C
207
+ bind(.tileTopLeft, 32, ctrlOpt) // U
208
+ bind(.tileTopRight, 34, ctrlOpt) // I
209
+ bind(.tileBottomLeft, 38, ctrlOpt) // J
210
+ bind(.tileBottomRight, 40, ctrlOpt) // K
211
+ bind(.tileTop, 126, ctrlOpt) // ↑
212
+ bind(.tileBottom, 125, ctrlOpt) // ↓
213
+ bind(.tileDistribute, 2, ctrlOpt) // D
214
+ bind(.tileLeftThird, 18, ctrlOpt) // 1
215
+ bind(.tileCenterThird, 19, ctrlOpt) // 2
216
+ bind(.tileRightThird, 20, ctrlOpt) // 3
217
+
218
+ return d
219
+ }()
220
+
221
+ private init() {
222
+ // Start with defaults
223
+ var merged = Self.defaultBindings
224
+
225
+ // Layer 2: UserDefaults overrides
226
+ let ud = UserDefaults.standard
227
+ for action in HotkeyAction.allCases {
228
+ let key = "hotkey.\(action.rawValue)"
229
+ if let data = ud.data(forKey: key),
230
+ let binding = try? JSONDecoder().decode(KeyBinding.self, from: data) {
231
+ merged[action] = binding
232
+ }
233
+ }
234
+
235
+ // Layer 3: ~/.lattices/hotkeys.json overrides
236
+ let jsonPath = NSHomeDirectory() + "/.lattices/hotkeys.json"
237
+ if let data = FileManager.default.contents(atPath: jsonPath),
238
+ let overrides = try? JSONDecoder().decode([String: KeyBinding].self, from: data) {
239
+ for (rawValue, binding) in overrides {
240
+ if let action = HotkeyAction(rawValue: rawValue) {
241
+ merged[action] = binding
242
+ }
243
+ }
244
+ }
245
+
246
+ self.bindings = merged
247
+ }
248
+
249
+ // MARK: - Registration
250
+
251
+ func register(action: HotkeyAction, callback: @escaping () -> Void) {
252
+ callbacks[action] = callback
253
+ guard let binding = bindings[action] else { return }
254
+ HotkeyManager.shared.registerSingle(
255
+ id: action.carbonID,
256
+ keyCode: binding.keyCode,
257
+ modifiers: binding.carbonModifiers,
258
+ callback: callback
259
+ )
260
+ }
261
+
262
+ // MARK: - Update
263
+
264
+ func updateBinding(for action: HotkeyAction, to binding: KeyBinding) {
265
+ bindings[action] = binding
266
+
267
+ // Persist to UserDefaults
268
+ if let data = try? JSONEncoder().encode(binding) {
269
+ UserDefaults.standard.set(data, forKey: "hotkey.\(action.rawValue)")
270
+ }
271
+
272
+ // Re-register if we have a callback
273
+ if let callback = callbacks[action] {
274
+ HotkeyManager.shared.registerSingle(
275
+ id: action.carbonID,
276
+ keyCode: binding.keyCode,
277
+ modifiers: binding.carbonModifiers,
278
+ callback: callback
279
+ )
280
+ }
281
+ }
282
+
283
+ // MARK: - Reset
284
+
285
+ func resetBinding(for action: HotkeyAction) {
286
+ guard let defaultBinding = Self.defaultBindings[action] else { return }
287
+ UserDefaults.standard.removeObject(forKey: "hotkey.\(action.rawValue)")
288
+ bindings[action] = defaultBinding
289
+
290
+ if let callback = callbacks[action] {
291
+ HotkeyManager.shared.registerSingle(
292
+ id: action.carbonID,
293
+ keyCode: defaultBinding.keyCode,
294
+ modifiers: defaultBinding.carbonModifiers,
295
+ callback: callback
296
+ )
297
+ }
298
+ }
299
+
300
+ func resetAll() {
301
+ for action in HotkeyAction.allCases {
302
+ UserDefaults.standard.removeObject(forKey: "hotkey.\(action.rawValue)")
303
+ }
304
+ bindings = Self.defaultBindings
305
+
306
+ // Re-register all with stored callbacks
307
+ for (action, callback) in callbacks {
308
+ guard let binding = bindings[action] else { continue }
309
+ HotkeyManager.shared.registerSingle(
310
+ id: action.carbonID,
311
+ keyCode: binding.keyCode,
312
+ modifiers: binding.carbonModifiers,
313
+ callback: callback
314
+ )
315
+ }
316
+ }
317
+
318
+ // MARK: - Conflict detection
319
+
320
+ func conflicts(for action: HotkeyAction, with binding: KeyBinding) -> HotkeyAction? {
321
+ for (existingAction, existingBinding) in bindings {
322
+ if existingAction != action &&
323
+ existingBinding.keyCode == binding.keyCode &&
324
+ existingBinding.carbonModifiers == binding.carbonModifiers {
325
+ return existingAction
326
+ }
327
+ }
328
+ return nil
329
+ }
330
+ }
@@ -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
+ }