@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.
- package/README.md +155 -0
- package/app/Lattices.app/Contents/Info.plist +24 -0
- package/app/Package.swift +13 -0
- package/app/Sources/AccessibilityTextExtractor.swift +111 -0
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +10 -0
- package/app/Sources/AppDelegate.swift +242 -0
- package/app/Sources/AppShellView.swift +62 -0
- package/app/Sources/AppTypeClassifier.swift +70 -0
- package/app/Sources/AppWindowShell.swift +63 -0
- package/app/Sources/CheatSheetHUD.swift +332 -0
- package/app/Sources/CommandModeState.swift +1362 -0
- package/app/Sources/CommandModeView.swift +1405 -0
- package/app/Sources/CommandModeWindow.swift +192 -0
- package/app/Sources/CommandPaletteView.swift +307 -0
- package/app/Sources/CommandPaletteWindow.swift +134 -0
- package/app/Sources/DaemonProtocol.swift +101 -0
- package/app/Sources/DaemonServer.swift +414 -0
- package/app/Sources/DesktopModel.swift +149 -0
- package/app/Sources/DesktopModelTypes.swift +71 -0
- package/app/Sources/DiagnosticLog.swift +271 -0
- package/app/Sources/EventBus.swift +30 -0
- package/app/Sources/HotkeyManager.swift +254 -0
- package/app/Sources/HotkeyStore.swift +338 -0
- package/app/Sources/InventoryManager.swift +35 -0
- package/app/Sources/InventoryPath.swift +43 -0
- package/app/Sources/KeyRecorderView.swift +210 -0
- package/app/Sources/LatticesApi.swift +1234 -0
- package/app/Sources/LayerBezel.swift +203 -0
- package/app/Sources/MainView.swift +479 -0
- package/app/Sources/MainWindow.swift +83 -0
- package/app/Sources/OcrModel.swift +430 -0
- package/app/Sources/OcrStore.swift +329 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/OrphanRow.swift +129 -0
- package/app/Sources/PaletteCommand.swift +419 -0
- package/app/Sources/PermissionChecker.swift +125 -0
- package/app/Sources/Preferences.swift +99 -0
- package/app/Sources/ProcessModel.swift +199 -0
- package/app/Sources/ProcessQuery.swift +151 -0
- package/app/Sources/Project.swift +28 -0
- package/app/Sources/ProjectRow.swift +368 -0
- package/app/Sources/ProjectScanner.swift +128 -0
- package/app/Sources/ScreenMapState.swift +2387 -0
- package/app/Sources/ScreenMapView.swift +2820 -0
- package/app/Sources/ScreenMapWindowController.swift +89 -0
- package/app/Sources/SessionManager.swift +72 -0
- package/app/Sources/SettingsView.swift +1064 -0
- package/app/Sources/SettingsWindow.swift +20 -0
- package/app/Sources/TabGroupRow.swift +178 -0
- package/app/Sources/Terminal.swift +259 -0
- package/app/Sources/TerminalQuery.swift +156 -0
- package/app/Sources/TerminalSynthesizer.swift +200 -0
- package/app/Sources/Theme.swift +163 -0
- package/app/Sources/TilePickerView.swift +209 -0
- package/app/Sources/TmuxModel.swift +53 -0
- package/app/Sources/TmuxQuery.swift +81 -0
- package/app/Sources/WindowTiler.swift +1778 -0
- package/app/Sources/WorkspaceManager.swift +575 -0
- package/bin/client.js +4 -0
- package/bin/daemon-client.js +187 -0
- package/bin/lattices-app.js +221 -0
- package/bin/lattices.js +1551 -0
- package/docs/api.md +924 -0
- package/docs/app.md +297 -0
- package/docs/concepts.md +135 -0
- package/docs/config.md +245 -0
- package/docs/layers.md +410 -0
- package/docs/ocr.md +185 -0
- package/docs/overview.md +94 -0
- package/docs/quickstart.md +75 -0
- 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
|
+
}
|