@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,71 @@
1
+ import Foundation
2
+
3
+ struct WindowEntry: Codable, Identifiable {
4
+ let wid: UInt32
5
+ let app: String
6
+ let pid: Int32
7
+ let title: String
8
+ let frame: WindowFrame
9
+ let spaceIds: [Int]
10
+ let isOnScreen: Bool
11
+ let latticesSession: String?
12
+
13
+ var id: UInt32 { wid }
14
+ }
15
+
16
+ struct WindowFrame: Codable, Equatable {
17
+ let x: Double
18
+ let y: Double
19
+ let w: Double
20
+ let h: Double
21
+ }
22
+
23
+ // MARK: - Desktop Inventory Snapshot
24
+
25
+ struct DesktopInventorySnapshot {
26
+ let displays: [DisplayInfo]
27
+ let timestamp: Date
28
+
29
+ struct DisplayInfo: Identifiable {
30
+ let id: String // display UUID or index
31
+ let name: String // e.g. "Built-in Retina", "LG UltraFine"
32
+ let resolution: (w: Int, h: Int)
33
+ let visibleFrame: (w: Int, h: Int)
34
+ let isMain: Bool
35
+ let spaceCount: Int
36
+ let currentSpaceIndex: Int
37
+ let spaces: [SpaceGroup]
38
+ }
39
+
40
+ struct SpaceGroup: Identifiable {
41
+ let id: Int // CGS space ID
42
+ let index: Int // 1-based index within display
43
+ let isCurrent: Bool
44
+ let apps: [AppGroup]
45
+ }
46
+
47
+ struct AppGroup: Identifiable {
48
+ let id: String // unique key (spaceId-appName)
49
+ let appName: String
50
+ let windows: [InventoryWindowInfo]
51
+ }
52
+
53
+ struct InventoryWindowInfo: Identifiable {
54
+ let id: UInt32 // CGWindowID
55
+ let pid: Int32 // owner PID for AX operations
56
+ let title: String
57
+ let frame: WindowFrame
58
+ let tilePosition: TilePosition?
59
+ let isLattices: Bool
60
+ let latticesSession: String?
61
+ let spaceIndex: Int? // 1-based space index within display
62
+ let isOnScreen: Bool // on current space
63
+ var inventoryPath: InventoryPath?
64
+ var appName: String? // owner app name for filtering
65
+ }
66
+
67
+ /// Flat list of all windows across all displays/spaces/apps
68
+ var allWindows: [InventoryWindowInfo] {
69
+ displays.flatMap { $0.spaces.flatMap { $0.apps.flatMap { $0.windows } } }
70
+ }
71
+ }
@@ -0,0 +1,271 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ // MARK: - Log Store
5
+
6
+ final class DiagnosticLog: ObservableObject {
7
+ static let shared = DiagnosticLog()
8
+
9
+ struct Entry: Identifiable {
10
+ let id = UUID()
11
+ let time: Date
12
+ let message: String
13
+ let level: Level
14
+
15
+ enum Level { case info, success, warning, error }
16
+
17
+ var icon: String {
18
+ switch level {
19
+ case .info: return "›"
20
+ case .success: return "✓"
21
+ case .warning: return "⚠"
22
+ case .error: return "✗"
23
+ }
24
+ }
25
+ }
26
+
27
+ @Published var entries: [Entry] = []
28
+ private let maxEntries = 80
29
+
30
+ func log(_ message: String, level: Entry.Level = .info) {
31
+ let entry = Entry(time: Date(), message: message, level: level)
32
+ DispatchQueue.main.async {
33
+ self.entries.append(entry)
34
+ if self.entries.count > self.maxEntries {
35
+ self.entries.removeFirst(self.entries.count - self.maxEntries)
36
+ }
37
+ }
38
+ }
39
+
40
+ func info(_ msg: String) { log(msg, level: .info) }
41
+ func success(_ msg: String) { log(msg, level: .success) }
42
+ func warn(_ msg: String) { log(msg, level: .warning) }
43
+ func error(_ msg: String) { log(msg, level: .error) }
44
+ func clear() { DispatchQueue.main.async { self.entries.removeAll() } }
45
+
46
+ // MARK: - Per-Action Timing
47
+
48
+ struct TimedAction {
49
+ let label: String
50
+ let start: Date
51
+ }
52
+
53
+ func startTimed(_ label: String) -> TimedAction {
54
+ info("▸ \(label)")
55
+ return TimedAction(label: label, start: Date())
56
+ }
57
+
58
+ func finish(_ action: TimedAction) {
59
+ let ms = Date().timeIntervalSince(action.start) * 1000
60
+ success("▸ \(action.label) — \(String(format: "%.0f", ms))ms")
61
+ }
62
+ }
63
+
64
+ // MARK: - Diagnostic Window
65
+
66
+ final class DiagnosticWindow {
67
+ static let shared = DiagnosticWindow()
68
+
69
+ private var window: NSWindow?
70
+ private var keyMonitor: Any?
71
+ private let log = DiagnosticLog.shared
72
+
73
+ var isVisible: Bool { window?.isVisible ?? false }
74
+
75
+ func toggle() {
76
+ if let w = window, w.isVisible {
77
+ dismiss()
78
+ } else {
79
+ show()
80
+ }
81
+ }
82
+
83
+ func dismiss() {
84
+ window?.orderOut(nil)
85
+ if let monitor = keyMonitor {
86
+ NSEvent.removeMonitor(monitor)
87
+ keyMonitor = nil
88
+ }
89
+ }
90
+
91
+ func show() {
92
+ if let w = window {
93
+ w.orderFrontRegardless()
94
+ return
95
+ }
96
+
97
+ let view = DiagnosticOverlayView()
98
+
99
+ let hosting = NSHostingController(rootView: view)
100
+ let screen = NSScreen.main
101
+ let screenFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080)
102
+ let panelWidth: CGFloat = 480
103
+ let panelHeight: CGFloat = max(600, floor(screenFrame.height * 0.55))
104
+ hosting.preferredContentSize = NSSize(width: panelWidth, height: panelHeight)
105
+
106
+ let w = NSPanel(
107
+ contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
108
+ styleMask: [.titled, .closable, .resizable, .utilityWindow, .nonactivatingPanel],
109
+ backing: .buffered,
110
+ defer: false
111
+ )
112
+ w.contentViewController = hosting
113
+ w.title = "Lattices Diagnostics"
114
+ w.titlebarAppearsTransparent = true
115
+ w.isMovableByWindowBackground = true
116
+ w.level = .floating
117
+ w.isOpaque = false
118
+ w.backgroundColor = NSColor(red: 0.1, green: 0.1, blue: 0.12, alpha: 1.0)
119
+ w.hasShadow = true
120
+ w.alphaValue = 1.0
121
+ w.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
122
+
123
+ // Position: right edge, vertically centered
124
+ let x = screenFrame.maxX - panelWidth - 12
125
+ let y = screenFrame.minY + floor((screenFrame.height - panelHeight) / 2)
126
+ w.setFrameOrigin(NSPoint(x: x, y: y))
127
+
128
+ w.orderFrontRegardless()
129
+ window = w
130
+
131
+ // Escape key → dismiss
132
+ keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
133
+ guard event.keyCode == 53,
134
+ let win = self?.window,
135
+ event.window === win || win.isKeyWindow else { return event }
136
+ self?.dismiss()
137
+ return nil
138
+ }
139
+
140
+ // Startup log
141
+ let diag = DiagnosticLog.shared
142
+ diag.info("Diagnostics opened")
143
+ diag.info("Terminal: \(Preferences.shared.terminal.rawValue) (\(Preferences.shared.terminal.bundleId))")
144
+ diag.info("Installed: \(Terminal.installed.map(\.rawValue).joined(separator: ", "))")
145
+
146
+ // Show running sessions
147
+ let task = Process()
148
+ task.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/tmux")
149
+ task.arguments = ["list-sessions", "-F", "#{session_name}"]
150
+ let pipe = Pipe()
151
+ task.standardOutput = pipe
152
+ task.standardError = FileHandle.nullDevice
153
+ try? task.run()
154
+ task.waitUntilExit()
155
+ let sessions = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "none"
156
+ diag.info("tmux sessions: \(sessions)")
157
+ }
158
+ }
159
+
160
+ // MARK: - SwiftUI Overlay
161
+
162
+ struct DiagnosticOverlayView: View {
163
+ @StateObject private var log = DiagnosticLog.shared
164
+ @State private var autoScroll = true
165
+ @State private var refreshTick = 0
166
+
167
+ private static let timeFmt: DateFormatter = {
168
+ let f = DateFormatter()
169
+ f.dateFormat = "HH:mm:ss.SSS"
170
+ return f
171
+ }()
172
+
173
+ // Fallback timer to catch any missed updates
174
+ private let refreshTimer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
175
+
176
+ var body: some View {
177
+ VStack(spacing: 0) {
178
+ // Header
179
+ HStack {
180
+ Text("DIAGNOSTICS")
181
+ .font(.system(size: 10, weight: .bold, design: .monospaced))
182
+ .foregroundColor(.green.opacity(0.8))
183
+ Spacer()
184
+ let _ = refreshTick // force re-render on timer
185
+ Text("\(log.entries.count) events")
186
+ .font(.system(size: 9, design: .monospaced))
187
+ .foregroundColor(.white.opacity(0.4))
188
+ Button("Copy") {
189
+ let text = log.entries.map { entry in
190
+ let t = Self.timeFmt.string(from: entry.time)
191
+ return "\(t) \(entry.icon) \(entry.message)"
192
+ }.joined(separator: "\n")
193
+ NSPasteboard.general.clearContents()
194
+ NSPasteboard.general.setString(text, forType: .string)
195
+ }
196
+ .font(.system(size: 9, design: .monospaced))
197
+ .foregroundColor(.white.opacity(0.5))
198
+ .buttonStyle(.plain)
199
+ Button("Clear") { log.clear() }
200
+ .font(.system(size: 9, design: .monospaced))
201
+ .foregroundColor(.white.opacity(0.5))
202
+ .buttonStyle(.plain)
203
+ }
204
+ .padding(.horizontal, 10)
205
+ .padding(.vertical, 6)
206
+ .background(Color.black.opacity(0.3))
207
+ .onReceive(refreshTimer) { _ in refreshTick += 1 }
208
+
209
+ // Log entries
210
+ ScrollViewReader { proxy in
211
+ ScrollView {
212
+ LazyVStack(alignment: .leading, spacing: 1) {
213
+ ForEach(log.entries) { entry in
214
+ logRow(entry)
215
+ .id(entry.id)
216
+ }
217
+ }
218
+ .padding(.horizontal, 8)
219
+ .padding(.vertical, 4)
220
+ }
221
+ .onChange(of: log.entries.count) { _ in
222
+ if autoScroll, let last = log.entries.last {
223
+ withAnimation(.easeOut(duration: 0.1)) {
224
+ proxy.scrollTo(last.id, anchor: .bottom)
225
+ }
226
+ }
227
+ }
228
+ }
229
+ }
230
+ .frame(minWidth: 420, idealWidth: 480, minHeight: 400, idealHeight: 600)
231
+ .background(Color.black.opacity(0.75))
232
+ }
233
+
234
+ private func logRow(_ entry: DiagnosticLog.Entry) -> some View {
235
+ HStack(alignment: .top, spacing: 6) {
236
+ Text(Self.timeFmt.string(from: entry.time))
237
+ .font(.system(size: 9, design: .monospaced))
238
+ .foregroundColor(.white.opacity(0.3))
239
+
240
+ Text(entry.icon)
241
+ .font(.system(size: 9, design: .monospaced))
242
+ .foregroundColor(iconColor(entry.level))
243
+ .frame(width: 10)
244
+
245
+ Text(entry.message)
246
+ .font(.system(size: 10, design: .monospaced))
247
+ .foregroundColor(textColor(entry.level))
248
+ .lineLimit(3)
249
+ .fixedSize(horizontal: false, vertical: true)
250
+ }
251
+ .padding(.vertical, 1)
252
+ }
253
+
254
+ private func iconColor(_ level: DiagnosticLog.Entry.Level) -> Color {
255
+ switch level {
256
+ case .info: return .white.opacity(0.5)
257
+ case .success: return .green
258
+ case .warning: return .yellow
259
+ case .error: return .red
260
+ }
261
+ }
262
+
263
+ private func textColor(_ level: DiagnosticLog.Entry.Level) -> Color {
264
+ switch level {
265
+ case .info: return .white.opacity(0.7)
266
+ case .success: return .green.opacity(0.9)
267
+ case .warning: return .yellow.opacity(0.9)
268
+ case .error: return .red.opacity(0.9)
269
+ }
270
+ }
271
+ }
@@ -0,0 +1,30 @@
1
+ import Foundation
2
+
3
+ enum ModelEvent {
4
+ case windowsChanged(windows: [WindowEntry], added: [UInt32], removed: [UInt32])
5
+ case tmuxChanged(sessions: [TmuxSession])
6
+ case layerSwitched(index: Int)
7
+ case processesChanged(interesting: [Int])
8
+ case ocrScanComplete(windowCount: Int, totalBlocks: Int)
9
+ }
10
+
11
+ final class EventBus {
12
+ static let shared = EventBus()
13
+ private var handlers: [(ModelEvent) -> Void] = []
14
+ private let lock = NSLock()
15
+
16
+ func subscribe(_ handler: @escaping (ModelEvent) -> Void) {
17
+ lock.lock()
18
+ handlers.append(handler)
19
+ lock.unlock()
20
+ }
21
+
22
+ func post(_ event: ModelEvent) {
23
+ lock.lock()
24
+ let copy = handlers
25
+ lock.unlock()
26
+ for handler in copy {
27
+ handler(event)
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,254 @@
1
+ import Carbon
2
+ import AppKit
3
+ import Foundation
4
+
5
+ /// Global callback registry keyed by hotkey ID
6
+ private var hotkeyCallbacks: [UInt32: () -> Void] = [:]
7
+
8
+ /// Whether the global Carbon event handler has been installed
9
+ private var eventHandlerInstalled = false
10
+
11
+ class HotkeyManager {
12
+ static let shared = HotkeyManager()
13
+ private var hotKeyRefs: [UInt32: EventHotKeyRef] = [:]
14
+
15
+ private func ensureEventHandler() {
16
+ guard !eventHandlerInstalled else { return }
17
+ eventHandlerInstalled = true
18
+
19
+ var eventType = EventTypeSpec(
20
+ eventClass: OSType(kEventClassKeyboard),
21
+ eventKind: UInt32(kEventHotKeyPressed)
22
+ )
23
+
24
+ InstallEventHandler(
25
+ GetApplicationEventTarget(),
26
+ { (_: EventHandlerCallRef?, event: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in
27
+ guard let event else { return OSStatus(eventNotHandledErr) }
28
+ var hotkeyID = EventHotKeyID()
29
+ GetEventParameter(
30
+ event,
31
+ EventParamName(kEventParamDirectObject),
32
+ EventParamType(typeEventHotKeyID),
33
+ nil,
34
+ MemoryLayout<EventHotKeyID>.size,
35
+ nil,
36
+ &hotkeyID
37
+ )
38
+ hotkeyCallbacks[hotkeyID.id]?()
39
+ return noErr
40
+ },
41
+ 1,
42
+ &eventType,
43
+ nil,
44
+ nil
45
+ )
46
+ }
47
+
48
+ /// Register Cmd+Shift+M as the global hotkey (palette toggle)
49
+ func register(callback: @escaping () -> Void) {
50
+ ensureEventHandler()
51
+
52
+ let id: UInt32 = 1
53
+ hotkeyCallbacks[id] = callback
54
+
55
+ let hotKeyID = EventHotKeyID(
56
+ signature: OSType(0x444D5558), // "DMUX"
57
+ id: id
58
+ )
59
+
60
+ var ref: EventHotKeyRef?
61
+ RegisterEventHotKey(
62
+ 46, // 'M'
63
+ UInt32(cmdKey | shiftKey),
64
+ hotKeyID,
65
+ GetApplicationEventTarget(),
66
+ 0,
67
+ &ref
68
+ )
69
+ if let ref { hotKeyRefs[id] = ref }
70
+ }
71
+
72
+ /// Register Hyper+1 (Cmd+Ctrl+Option+Shift+1) for command mode
73
+ func registerCommandMode(callback: @escaping () -> Void) {
74
+ ensureEventHandler()
75
+ let id: UInt32 = 200
76
+ hotkeyCallbacks[id] = callback
77
+ let hotKeyID = EventHotKeyID(
78
+ signature: OSType(0x444D5558), // "DMUX"
79
+ id: id
80
+ )
81
+ var ref: EventHotKeyRef?
82
+ RegisterEventHotKey(
83
+ 18, // '1' key
84
+ UInt32(cmdKey | controlKey | optionKey | shiftKey), // Hyper
85
+ hotKeyID,
86
+ GetApplicationEventTarget(),
87
+ 0,
88
+ &ref
89
+ )
90
+ if let ref { hotKeyRefs[id] = ref }
91
+ }
92
+
93
+ /// Register Hyper+2 (Cmd+Ctrl+Option+Shift+2) for bezel mode
94
+ func registerBezelHotkey(callback: @escaping () -> Void) {
95
+ ensureEventHandler()
96
+ let id: UInt32 = 201
97
+ hotkeyCallbacks[id] = callback
98
+ let hotKeyID = EventHotKeyID(
99
+ signature: OSType(0x444D5558), // "DMUX"
100
+ id: id
101
+ )
102
+ var ref: EventHotKeyRef?
103
+ RegisterEventHotKey(
104
+ 19, // '2' key
105
+ UInt32(cmdKey | controlKey | optionKey | shiftKey), // Hyper
106
+ hotKeyID,
107
+ GetApplicationEventTarget(),
108
+ 0,
109
+ &ref
110
+ )
111
+ if let ref { hotKeyRefs[id] = ref }
112
+ }
113
+
114
+ /// Register Cmd+Option+1/2/3... hotkeys for layer switching
115
+ func registerLayerHotkeys(count: Int, callback: @escaping (Int) -> Void) {
116
+ ensureEventHandler()
117
+
118
+ // Key codes for number keys 1-9
119
+ let keyCodes: [UInt32] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
120
+ let limit = min(count, keyCodes.count)
121
+
122
+ for i in 0..<limit {
123
+ let id: UInt32 = 101 + UInt32(i)
124
+
125
+ // Unregister existing if re-registering
126
+ if let existing = hotKeyRefs[id] {
127
+ UnregisterEventHotKey(existing)
128
+ hotKeyRefs.removeValue(forKey: id)
129
+ }
130
+
131
+ let layerIndex = i
132
+ hotkeyCallbacks[id] = { callback(layerIndex) }
133
+
134
+ let hotKeyID = EventHotKeyID(
135
+ signature: OSType(0x444D5558), // "DMUX"
136
+ id: id
137
+ )
138
+
139
+ var ref: EventHotKeyRef?
140
+ RegisterEventHotKey(
141
+ keyCodes[i],
142
+ UInt32(cmdKey | optionKey),
143
+ hotKeyID,
144
+ GetApplicationEventTarget(),
145
+ 0,
146
+ &ref
147
+ )
148
+ if let ref { hotKeyRefs[id] = ref }
149
+ }
150
+ }
151
+
152
+ /// Register a single global hotkey with a given ID, key code, and Carbon modifier mask
153
+ func registerSingle(id: UInt32, keyCode: UInt32, modifiers: UInt32, callback: @escaping () -> Void) {
154
+ ensureEventHandler()
155
+
156
+ if let existing = hotKeyRefs[id] {
157
+ UnregisterEventHotKey(existing)
158
+ hotKeyRefs.removeValue(forKey: id)
159
+ }
160
+
161
+ hotkeyCallbacks[id] = callback
162
+
163
+ let hotKeyID = EventHotKeyID(
164
+ signature: OSType(0x444D5558), // "DMUX"
165
+ id: id
166
+ )
167
+
168
+ var ref: EventHotKeyRef?
169
+ let status = RegisterEventHotKey(
170
+ keyCode,
171
+ modifiers,
172
+ hotKeyID,
173
+ GetApplicationEventTarget(),
174
+ 0,
175
+ &ref
176
+ )
177
+ if let ref {
178
+ hotKeyRefs[id] = ref
179
+ } else {
180
+ DiagnosticLog.shared.warn("HotkeyManager: failed to register id=\(id) keyCode=\(keyCode) mods=\(modifiers) status=\(status)")
181
+ }
182
+ }
183
+
184
+ /// Unregister all global hotkeys and clear callbacks
185
+ func unregisterAll() {
186
+ for (id, ref) in hotKeyRefs {
187
+ UnregisterEventHotKey(ref)
188
+ hotkeyCallbacks.removeValue(forKey: id)
189
+ }
190
+ hotKeyRefs.removeAll()
191
+ }
192
+
193
+ /// Register Ctrl+Option window tiling hotkeys (Magnet-style)
194
+ func registerTileHotkeys() {
195
+ let mods = UInt32(controlKey | optionKey)
196
+
197
+ // Ctrl+Option+← → left
198
+ registerSingle(id: 300, keyCode: 123, modifiers: mods) {
199
+ WindowTiler.tileFrontmostViaAX(to: .left)
200
+ }
201
+ // Ctrl+Option+→ → right
202
+ registerSingle(id: 301, keyCode: 124, modifiers: mods) {
203
+ WindowTiler.tileFrontmostViaAX(to: .right)
204
+ }
205
+ // Ctrl+Option+Return → maximize
206
+ registerSingle(id: 302, keyCode: 36, modifiers: mods) {
207
+ WindowTiler.tileFrontmostViaAX(to: .maximize)
208
+ }
209
+ // Ctrl+Option+C → center
210
+ registerSingle(id: 303, keyCode: 8, modifiers: mods) {
211
+ WindowTiler.tileFrontmostViaAX(to: .center)
212
+ }
213
+ // Ctrl+Option+U → top-left
214
+ registerSingle(id: 304, keyCode: 32, modifiers: mods) {
215
+ WindowTiler.tileFrontmostViaAX(to: .topLeft)
216
+ }
217
+ // Ctrl+Option+I → top-right
218
+ registerSingle(id: 305, keyCode: 34, modifiers: mods) {
219
+ WindowTiler.tileFrontmostViaAX(to: .topRight)
220
+ }
221
+ // Ctrl+Option+J → bottom-left
222
+ registerSingle(id: 306, keyCode: 38, modifiers: mods) {
223
+ WindowTiler.tileFrontmostViaAX(to: .bottomLeft)
224
+ }
225
+ // Ctrl+Option+K → bottom-right
226
+ registerSingle(id: 307, keyCode: 40, modifiers: mods) {
227
+ WindowTiler.tileFrontmostViaAX(to: .bottomRight)
228
+ }
229
+ // Ctrl+Option+↑ → top
230
+ registerSingle(id: 308, keyCode: 126, modifiers: mods) {
231
+ WindowTiler.tileFrontmostViaAX(to: .top)
232
+ }
233
+ // Ctrl+Option+↓ → bottom
234
+ registerSingle(id: 309, keyCode: 125, modifiers: mods) {
235
+ WindowTiler.tileFrontmostViaAX(to: .bottom)
236
+ }
237
+ // Ctrl+Option+D → distribute visible windows
238
+ registerSingle(id: 310, keyCode: 2, modifiers: mods) {
239
+ WindowTiler.distributeVisible()
240
+ }
241
+ // Ctrl+Option+1 → left third
242
+ registerSingle(id: 311, keyCode: 18, modifiers: mods) {
243
+ WindowTiler.tileFrontmostViaAX(to: .leftThird)
244
+ }
245
+ // Ctrl+Option+2 → center third
246
+ registerSingle(id: 312, keyCode: 19, modifiers: mods) {
247
+ WindowTiler.tileFrontmostViaAX(to: .centerThird)
248
+ }
249
+ // Ctrl+Option+3 → right third
250
+ registerSingle(id: 313, keyCode: 20, modifiers: mods) {
251
+ WindowTiler.tileFrontmostViaAX(to: .rightThird)
252
+ }
253
+ }
254
+ }