@arach/lattices 0.1.0 → 0.2.1

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 (39) hide show
  1. package/README.md +101 -90
  2. package/app/Sources/ActionRow.swift +61 -0
  3. package/app/Sources/App.swift +1 -40
  4. package/app/Sources/AppDelegate.swift +154 -24
  5. package/app/Sources/CheatSheetHUD.swift +1 -0
  6. package/app/Sources/CommandModeState.swift +40 -19
  7. package/app/Sources/CommandModeView.swift +27 -2
  8. package/app/Sources/DaemonServer.swift +8 -0
  9. package/app/Sources/DiagnosticLog.swift +19 -1
  10. package/app/Sources/EventBus.swift +1 -0
  11. package/app/Sources/HotkeyManager.swift +1 -0
  12. package/app/Sources/HotkeyStore.swift +9 -1
  13. package/app/Sources/LatticesApi.swift +210 -0
  14. package/app/Sources/MainView.swift +46 -86
  15. package/app/Sources/MainWindow.swift +13 -0
  16. package/app/Sources/OcrModel.swift +309 -0
  17. package/app/Sources/OcrStore.swift +295 -0
  18. package/app/Sources/OmniSearchState.swift +283 -0
  19. package/app/Sources/OmniSearchView.swift +288 -0
  20. package/app/Sources/OmniSearchWindow.swift +105 -0
  21. package/app/Sources/PaletteCommand.swift +11 -1
  22. package/app/Sources/PermissionChecker.swift +12 -2
  23. package/app/Sources/Preferences.swift +44 -0
  24. package/app/Sources/ScreenMapState.swift +7 -17
  25. package/app/Sources/ScreenMapView.swift +3 -0
  26. package/app/Sources/SettingsView.swift +534 -122
  27. package/app/Sources/Theme.swift +39 -0
  28. package/app/Sources/WindowTiler.swift +59 -56
  29. package/bin/lattices-app.js +23 -7
  30. package/bin/lattices.js +123 -0
  31. package/docs/api.md +390 -249
  32. package/docs/app.md +75 -28
  33. package/docs/concepts.md +45 -136
  34. package/docs/config.md +8 -7
  35. package/docs/layers.md +16 -18
  36. package/docs/ocr.md +185 -0
  37. package/docs/overview.md +39 -34
  38. package/docs/quickstart.md +34 -35
  39. package/package.json +6 -2
@@ -71,16 +71,7 @@ struct MainView: View {
71
71
  Spacer()
72
72
 
73
73
  headerButton(icon: "arrow.up.left.and.arrow.down.right") {
74
- // Dismiss the MenuBarExtra panel immediately
75
- for window in NSApp.windows {
76
- if window is NSPanel, window.isVisible,
77
- !CommandPaletteWindow.shared.isVisible || window.frame.width < 500 {
78
- // MenuBarExtra panels are small (~380px); command palette is 540px
79
- if window.frame.width <= 400 {
80
- window.orderOut(nil)
81
- }
82
- }
83
- }
74
+ (NSApp.delegate as? AppDelegate)?.dismissPopover()
84
75
  MainWindow.shared.show()
85
76
  }
86
77
  headerButton(icon: "arrow.clockwise") { scanner.scan(); inventory.refresh() }
@@ -190,8 +181,8 @@ struct MainView: View {
190
181
  .fill(Palette.border)
191
182
  .frame(height: 0.5)
192
183
 
193
- // Status bar
194
- statusBar
184
+ // Actions footer
185
+ actionsSection
195
186
  }
196
187
  }
197
188
 
@@ -253,96 +244,65 @@ struct MainView: View {
253
244
  }
254
245
  }
255
246
 
256
- // MARK: - Status bar
247
+ // MARK: - Actions footer
257
248
 
258
- private var statusBar: some View {
259
- HStack(spacing: 0) {
260
- // Settings button
261
- Button { SettingsWindowController.shared.show() } label: {
262
- Image(systemName: "gearshape")
263
- .font(.system(size: 10, weight: .medium))
264
- .foregroundColor(Palette.textMuted)
249
+ private var actionsSection: some View {
250
+ VStack(spacing: 0) {
251
+ ActionRow(shortcut: "1", label: "Command Palette", hotkey: hotkeyLabel(.palette), icon: "command", accentColor: Palette.running) {
252
+ CommandPaletteWindow.shared.toggle()
265
253
  }
266
- .buttonStyle(.plain)
267
- .help("Settings")
268
-
269
- // Diagnostics toggle
270
- Button { DiagnosticWindow.shared.toggle() } label: {
271
- HStack(spacing: 3) {
272
- Image(systemName: "stethoscope")
273
- .font(.system(size: 10, weight: .medium))
274
- .foregroundColor(DiagnosticWindow.shared.isVisible ? Palette.running : Palette.textMuted)
275
- if !permChecker.allGranted {
276
- Circle()
277
- .fill(Palette.detach)
278
- .frame(width: 5, height: 5)
279
- }
280
- }
254
+ ActionRow(shortcut: "2", label: "Screen Map", hotkey: hotkeyLabel(.screenMap), icon: "rectangle.3.group") {
255
+ ScreenMapWindowController.shared.toggle()
256
+ }
257
+ ActionRow(shortcut: "3", label: "Desktop Inventory", hotkey: hotkeyLabel(.desktopInventory), icon: "rectangle.split.2x1") {
258
+ CommandModeWindow.shared.toggle()
259
+ }
260
+ ActionRow(shortcut: "4", label: "Window Bezel", hotkey: hotkeyLabel(.bezel), icon: "macwindow") {
261
+ WindowBezel.showBezelForFrontmostWindow()
262
+ }
263
+ ActionRow(shortcut: "5", label: "Cheat Sheet", hotkey: hotkeyLabel(.cheatSheet), icon: "keyboard") {
264
+ CheatSheetHUD.shared.toggle()
265
+ }
266
+ ActionRow(shortcut: "6", label: "Omni Search", hotkey: hotkeyLabel(.omniSearch), icon: "magnifyingglass", accentColor: Palette.running) {
267
+ OmniSearchWindow.shared.toggle()
281
268
  }
282
- .buttonStyle(.plain)
283
- .help(!permChecker.allGranted ? "Permissions missing — open diagnostics" : "Toggle diagnostics")
284
269
 
285
270
  Rectangle()
286
271
  .fill(Palette.border)
287
- .frame(width: 0.5, height: 12)
288
- .padding(.horizontal, 8)
289
-
290
- // Config summary — keys dim, values white
291
- statusLine
292
-
293
- Spacer()
272
+ .frame(height: 0.5)
273
+ .padding(.horizontal, 10)
294
274
 
295
- // Palette hint
296
- Text("\u{2318}\u{21E7}M")
297
- .font(Typo.mono(9))
298
- .foregroundColor(Palette.textMuted)
299
- .help("Command palette (Cmd+Shift+M)")
275
+ ActionRow(shortcut: "S", label: "Settings", icon: "gearshape") {
276
+ SettingsWindowController.shared.show()
277
+ }
278
+ HStack(spacing: 0) {
279
+ ActionRow(shortcut: "D", label: "Diagnostics", icon: "stethoscope") {
280
+ DiagnosticWindow.shared.toggle()
281
+ }
282
+ if !permChecker.allGranted {
283
+ Circle()
284
+ .fill(Palette.detach)
285
+ .frame(width: 6, height: 6)
286
+ .padding(.trailing, 14)
287
+ }
288
+ }
300
289
 
301
290
  Rectangle()
302
291
  .fill(Palette.border)
303
- .frame(width: 0.5, height: 12)
304
- .padding(.horizontal, 6)
292
+ .frame(height: 0.5)
293
+ .padding(.horizontal, 10)
305
294
 
306
- // Quit
307
- Button { NSApp.terminate(nil) } label: {
308
- Image(systemName: "power")
309
- .font(.system(size: 9, weight: .medium))
310
- .foregroundColor(Palette.textMuted)
295
+ ActionRow(shortcut: "Q", label: "Quit", icon: "power", accentColor: Palette.kill) {
296
+ NSApp.terminate(nil)
311
297
  }
312
- .buttonStyle(.plain)
313
- .help("Quit lattices")
314
298
  }
315
- .padding(.horizontal, 14)
316
- .padding(.vertical, 7)
299
+ .padding(.vertical, 4)
317
300
  .background(Palette.surface.opacity(0.4))
318
301
  }
319
302
 
320
- private var statusLine: some View {
321
- HStack(spacing: 3) {
322
- statusPair("terminal", prefs.terminal.rawValue.lowercased())
323
- statusDot
324
- statusPair("mode", prefs.mode.rawValue)
325
- statusDot
326
- statusPair("home", "~/\((prefs.scanRoot as NSString).lastPathComponent)")
327
- }
328
- }
329
-
330
- private func statusPair(_ key: String, _ value: String) -> some View {
331
- HStack(spacing: 3) {
332
- Text(key + ":")
333
- .font(Typo.mono(9))
334
- .foregroundColor(Palette.textMuted)
335
- Text(value)
336
- .font(Typo.mono(9))
337
- .foregroundColor(Palette.text)
338
- }
339
- }
340
-
341
- private var statusDot: some View {
342
- Circle()
343
- .fill(Palette.textMuted)
344
- .frame(width: 2, height: 2)
345
- .padding(.horizontal, 4)
303
+ private func hotkeyLabel(_ action: HotkeyAction) -> String? {
304
+ guard let binding = HotkeyStore.shared.bindings[action] else { return nil }
305
+ return binding.displayParts.joined(separator: "")
346
306
  }
347
307
 
348
308
  // MARK: - Empty state
@@ -7,6 +7,7 @@ final class MainWindow {
7
7
  static let shared = MainWindow()
8
8
 
9
9
  private var window: NSWindow?
10
+ private var keyMonitor: Any?
10
11
 
11
12
  var isVisible: Bool { window?.isVisible ?? false }
12
13
 
@@ -61,10 +62,22 @@ final class MainWindow {
61
62
 
62
63
  window = w
63
64
  AppDelegate.updateActivationPolicy()
65
+
66
+ // Escape key → close
67
+ keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
68
+ guard event.keyCode == 53,
69
+ self?.window?.isKeyWindow == true else { return event }
70
+ self?.close()
71
+ return nil
72
+ }
64
73
  }
65
74
 
66
75
  func close() {
67
76
  window?.orderOut(nil)
77
+ if let monitor = keyMonitor {
78
+ NSEvent.removeMonitor(monitor)
79
+ keyMonitor = nil
80
+ }
68
81
  AppDelegate.updateActivationPolicy()
69
82
  }
70
83
  }
@@ -0,0 +1,309 @@
1
+ import AppKit
2
+ import CryptoKit
3
+ import Vision
4
+
5
+ // MARK: - Data Types
6
+
7
+ struct OcrTextBlock {
8
+ let text: String
9
+ let confidence: Float // 0.0–1.0
10
+ let boundingBox: CGRect // normalized coordinates within window
11
+ }
12
+
13
+ struct OcrWindowResult {
14
+ let wid: UInt32
15
+ let app: String
16
+ let title: String
17
+ let frame: WindowFrame
18
+ let texts: [OcrTextBlock]
19
+ let fullText: String
20
+ let timestamp: Date
21
+ }
22
+
23
+ // MARK: - OCR Scanner
24
+
25
+ final class OcrModel: ObservableObject {
26
+ static let shared = OcrModel()
27
+
28
+ @Published private(set) var results: [UInt32: OcrWindowResult] = [:]
29
+ @Published private(set) var isScanning: Bool = false
30
+ @Published var interval: TimeInterval = 60
31
+ @Published var enabled: Bool = true
32
+
33
+ private var timer: Timer?
34
+ private var deepTimer: Timer?
35
+ private let queue = DispatchQueue(label: "com.arach.lattices.ocr", qos: .background)
36
+ private var imageHashes: [UInt32: Data] = [:]
37
+ private var scanGeneration: Int = 0
38
+
39
+ private let myPid = ProcessInfo.processInfo.processIdentifier
40
+
41
+ private var prefs: Preferences { Preferences.shared }
42
+
43
+ func start(interval: TimeInterval? = nil) {
44
+ guard timer == nil else { return }
45
+ if let interval { self.interval = interval }
46
+ self.interval = prefs.ocrQuickInterval
47
+ self.enabled = prefs.ocrEnabled
48
+ guard enabled else {
49
+ DiagnosticLog.shared.info("OcrModel: disabled by user preference")
50
+ return
51
+ }
52
+ let deepInterval = prefs.ocrDeepInterval
53
+ // Defer initial scan — let the first timer tick handle it (grace period on launch)
54
+ DiagnosticLog.shared.info("OcrModel: starting (quick=\(self.interval)s/\(prefs.ocrQuickLimit)win, deep=\(deepInterval)s/\(prefs.ocrDeepLimit)win)")
55
+ timer = Timer.scheduledTimer(withTimeInterval: self.interval, repeats: true) { [weak self] _ in
56
+ guard let self, self.enabled else { return }
57
+ self.quickScan()
58
+ }
59
+ // Deep scan on a slower cadence
60
+ deepTimer = Timer.scheduledTimer(withTimeInterval: deepInterval, repeats: true) { [weak self] _ in
61
+ guard let self, self.enabled else { return }
62
+ self.scan()
63
+ }
64
+ }
65
+
66
+ func stop() {
67
+ timer?.invalidate()
68
+ timer = nil
69
+ deepTimer?.invalidate()
70
+ deepTimer = nil
71
+ }
72
+
73
+ func setEnabled(_ on: Bool) {
74
+ enabled = on
75
+ prefs.ocrEnabled = on
76
+ if on && timer == nil {
77
+ start()
78
+ } else if !on {
79
+ stop()
80
+ }
81
+ }
82
+
83
+ // MARK: - Scan
84
+
85
+ /// Quick scan: only the topmost frontmost windows (called every 60s)
86
+ func quickScan() {
87
+ scanWithLimit(prefs.ocrQuickLimit)
88
+ }
89
+
90
+ /// Deep scan: all visible windows (called every 2h, or manually via ocr.scan)
91
+ func scan() {
92
+ scanWithLimit(prefs.ocrDeepLimit)
93
+ }
94
+
95
+ private func scanWithLimit(_ limit: Int) {
96
+ guard !isScanning else { return }
97
+ DispatchQueue.main.async { self.isScanning = true }
98
+ scanGeneration += 1
99
+ let generation = scanGeneration
100
+
101
+ queue.async { [weak self] in
102
+ guard let self else { return }
103
+ var windows = self.enumerateWindows()
104
+
105
+ // Cap windows — CGWindowList returns front-to-back order,
106
+ // so prefix gives us the topmost/frontmost windows first
107
+ if windows.count > limit {
108
+ windows = Array(windows.prefix(limit))
109
+ }
110
+
111
+ // For quick scans, merge new results into existing rather than replacing
112
+ let previousResults = self.results
113
+ let fresh: [UInt32: OcrWindowResult] = limit < self.prefs.ocrDeepLimit ? previousResults : [:]
114
+ let newHashes: [UInt32: Data] = [:]
115
+ let totalBlocks = 0
116
+
117
+ self.processNextWindow(
118
+ windows: windows,
119
+ index: 0,
120
+ generation: generation,
121
+ previousResults: previousResults,
122
+ fresh: fresh,
123
+ newHashes: newHashes,
124
+ totalBlocks: totalBlocks,
125
+ changedResults: []
126
+ )
127
+ }
128
+ }
129
+
130
+ /// Process one window at a time, yielding back to the queue between each.
131
+ /// This lets GCD schedule higher-priority work between windows.
132
+ private func processNextWindow(
133
+ windows: [WindowEntry],
134
+ index: Int,
135
+ generation: Int,
136
+ previousResults: [UInt32: OcrWindowResult],
137
+ fresh: [UInt32: OcrWindowResult],
138
+ newHashes: [UInt32: Data],
139
+ totalBlocks: Int,
140
+ changedResults: [OcrWindowResult]
141
+ ) {
142
+ // Stale scan — a newer one started, abandon this one
143
+ guard generation == scanGeneration else {
144
+ DispatchQueue.main.async { self.isScanning = false }
145
+ return
146
+ }
147
+
148
+ // All windows processed — publish results & persist diffs
149
+ guard index < windows.count else {
150
+ self.imageHashes = newHashes
151
+
152
+ if !changedResults.isEmpty {
153
+ OcrStore.shared.insert(results: changedResults)
154
+ }
155
+
156
+ DispatchQueue.main.async {
157
+ self.results = fresh
158
+ self.isScanning = false
159
+ }
160
+
161
+ EventBus.shared.post(.ocrScanComplete(
162
+ windowCount: fresh.count,
163
+ totalBlocks: totalBlocks
164
+ ))
165
+ return
166
+ }
167
+
168
+ var fresh = fresh
169
+ var newHashes = newHashes
170
+ var totalBlocks = totalBlocks
171
+ var changedResults = changedResults
172
+
173
+ let win = windows[index]
174
+
175
+ if let cgImage = CGWindowListCreateImage(
176
+ .null,
177
+ .optionIncludingWindow,
178
+ CGWindowID(win.wid),
179
+ [.boundsIgnoreFraming, .bestResolution]
180
+ ) {
181
+ let hash = imageHash(cgImage)
182
+ newHashes[win.wid] = hash
183
+
184
+ if hash == imageHashes[win.wid], let prev = previousResults[win.wid] {
185
+ // Unchanged — reuse cached result
186
+ fresh[win.wid] = prev
187
+ totalBlocks += prev.texts.count
188
+ } else {
189
+ // Changed — run OCR
190
+ let blocks = recognizeText(in: cgImage)
191
+ let fullText = blocks.map(\.text).joined(separator: "\n")
192
+ totalBlocks += blocks.count
193
+
194
+ let result = OcrWindowResult(
195
+ wid: win.wid,
196
+ app: win.app,
197
+ title: win.title,
198
+ frame: win.frame,
199
+ texts: blocks,
200
+ fullText: fullText,
201
+ timestamp: Date()
202
+ )
203
+ fresh[win.wid] = result
204
+ changedResults.append(result)
205
+ }
206
+ }
207
+
208
+ // Throttle: 100ms delay between windows to reduce CPU bursts
209
+ queue.asyncAfter(deadline: .now() + 0.1) { [weak self] in
210
+ self?.processNextWindow(
211
+ windows: windows,
212
+ index: index + 1,
213
+ generation: generation,
214
+ previousResults: previousResults,
215
+ fresh: fresh,
216
+ newHashes: newHashes,
217
+ totalBlocks: totalBlocks,
218
+ changedResults: changedResults
219
+ )
220
+ }
221
+ }
222
+
223
+ // MARK: - Window Enumeration
224
+
225
+ private func enumerateWindows() -> [WindowEntry] {
226
+ guard let list = CGWindowListCopyWindowInfo(
227
+ [.optionOnScreenOnly, .excludeDesktopElements],
228
+ kCGNullWindowID
229
+ ) as? [[String: Any]] else { return [] }
230
+
231
+ var entries: [WindowEntry] = []
232
+
233
+ for info in list {
234
+ guard let wid = info[kCGWindowNumber as String] as? UInt32,
235
+ let ownerName = info[kCGWindowOwnerName as String] as? String,
236
+ let pid = info[kCGWindowOwnerPID as String] as? Int32,
237
+ let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
238
+ else { continue }
239
+
240
+ // Skip own windows
241
+ guard pid != myPid else { continue }
242
+
243
+ var rect = CGRect.zero
244
+ guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
245
+ rect.width >= 50, rect.height >= 50 else { continue }
246
+
247
+ let title = info[kCGWindowName as String] as? String ?? ""
248
+ let layer = info[kCGWindowLayer as String] as? Int ?? 0
249
+ guard layer == 0 else { continue }
250
+
251
+ let frame = WindowFrame(
252
+ x: Double(rect.origin.x),
253
+ y: Double(rect.origin.y),
254
+ w: Double(rect.width),
255
+ h: Double(rect.height)
256
+ )
257
+
258
+ entries.append(WindowEntry(
259
+ wid: wid,
260
+ app: ownerName,
261
+ pid: pid,
262
+ title: title,
263
+ frame: frame,
264
+ spaceIds: [],
265
+ isOnScreen: true,
266
+ latticesSession: nil
267
+ ))
268
+ }
269
+
270
+ return entries
271
+ }
272
+
273
+ // MARK: - Image Hashing
274
+
275
+ private func imageHash(_ image: CGImage) -> Data {
276
+ guard let dataProvider = image.dataProvider,
277
+ let data = dataProvider.data as Data? else {
278
+ return Data()
279
+ }
280
+ let digest = SHA256.hash(data: data as Data)
281
+ return Data(digest)
282
+ }
283
+
284
+ // MARK: - Vision OCR
285
+
286
+ private func recognizeText(in image: CGImage) -> [OcrTextBlock] {
287
+ let handler = VNImageRequestHandler(cgImage: image, options: [:])
288
+ let request = VNRecognizeTextRequest()
289
+ request.recognitionLevel = prefs.ocrAccuracy == "fast" ? .fast : .accurate
290
+ request.usesLanguageCorrection = true
291
+
292
+ do {
293
+ try handler.perform([request])
294
+ } catch {
295
+ return []
296
+ }
297
+
298
+ guard let observations = request.results else { return [] }
299
+
300
+ return observations.compactMap { obs in
301
+ guard let candidate = obs.topCandidates(1).first else { return nil }
302
+ return OcrTextBlock(
303
+ text: candidate.string,
304
+ confidence: candidate.confidence,
305
+ boundingBox: obs.boundingBox
306
+ )
307
+ }
308
+ }
309
+ }