@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
@@ -261,7 +261,9 @@ final class CommandModeState: ObservableObject {
261
261
  let matchesApp = win.appName?.lowercased().contains(query) ?? false
262
262
  let matchesTitle = win.title.lowercased().contains(query)
263
263
  let matchesLattices = win.latticesSession?.lowercased().contains(query) ?? false
264
- if !matchesApp && !matchesTitle && !matchesLattices { return false }
264
+ let matchesOcr = OcrModel.shared.results[win.id]?.fullText
265
+ .lowercased().contains(query) ?? false
266
+ if !matchesApp && !matchesTitle && !matchesLattices && !matchesOcr { return false }
265
267
  }
266
268
 
267
269
  return true
@@ -305,6 +307,37 @@ final class CommandModeState: ObservableObject {
305
307
  filteredSnapshot?.allWindows ?? []
306
308
  }
307
309
 
310
+ var ocrMatchSnippets: [UInt32: String] {
311
+ guard isSearching, !searchQuery.isEmpty else { return [:] }
312
+ let query = searchQuery.lowercased()
313
+ let ocrResults = OcrModel.shared.results
314
+ var snippets: [UInt32: String] = [:]
315
+ for win in flatWindowList {
316
+ // Only show snippet if match came from OCR, not title/app
317
+ let matchesApp = win.appName?.lowercased().contains(query) ?? false
318
+ let matchesTitle = win.title.lowercased().contains(query)
319
+ let matchesLattices = win.latticesSession?.lowercased().contains(query) ?? false
320
+ if matchesApp || matchesTitle || matchesLattices { continue }
321
+ if let ocr = ocrResults[win.id],
322
+ let range = ocr.fullText.lowercased().range(of: query) {
323
+ snippets[win.id] = Self.extractSnippet(from: ocr.fullText, around: range)
324
+ }
325
+ }
326
+ return snippets
327
+ }
328
+
329
+ private static func extractSnippet(from text: String, around range: Range<String.Index>, maxLen: Int = 80) -> String {
330
+ let half = max(0, (maxLen - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
331
+ let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
332
+ let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
333
+ var s = String(text[start..<end])
334
+ .replacingOccurrences(of: "\n", with: " ")
335
+ .trimmingCharacters(in: .whitespaces)
336
+ if start > text.startIndex { s = "…" + s }
337
+ if end < text.endIndex { s += "…" }
338
+ return s
339
+ }
340
+
308
341
  func enter() {
309
342
  inventory = buildInventory()
310
343
  chords = buildChords()
@@ -404,20 +437,8 @@ final class CommandModeState: ObservableObject {
404
437
  }
405
438
 
406
439
  switch keyCode {
407
- case 53: // Escape
408
- if isSearching {
409
- deactivateSearch()
410
- return true
411
- }
412
- if !selectedWindowIds.isEmpty {
413
- clearSelection()
414
- return true
415
- }
416
- // No selection — back to chord view
417
- desktopMode = .browsing
418
- activePreset = nil
419
- phase = .inventory
420
- onPanelResize?(chordPanelSize.0, chordPanelSize.1)
440
+ case 53: // Escape — always dismiss
441
+ onDismiss?()
421
442
  return true
422
443
 
423
444
  case 126: // ↑
@@ -560,8 +581,8 @@ final class CommandModeState: ObservableObject {
560
581
 
561
582
  private func handleTilingKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
562
583
  switch keyCode {
563
- case 53: // Escape back to browsing
564
- desktopMode = .browsing
584
+ case 53: // Escape always dismiss
585
+ onDismiss?()
565
586
  return true
566
587
 
567
588
  case 123: tileSelectedWindow(to: .left); return true // ←
@@ -593,8 +614,8 @@ final class CommandModeState: ObservableObject {
593
614
 
594
615
  private func handleGridPreviewKey(_ keyCode: UInt16) -> Bool {
595
616
  switch keyCode {
596
- case 53: // Escape back to browsing
597
- desktopMode = .browsing
617
+ case 53: // Escape always dismiss
618
+ onDismiss?()
598
619
  return true
599
620
 
600
621
  case 36, 1: // Enter or s → apply the layout
@@ -282,13 +282,15 @@ struct CommandModeView: View {
282
282
  Image(systemName: "magnifyingglass")
283
283
  .font(.system(size: 11))
284
284
  .foregroundColor(Palette.textDim)
285
- TextField("Search windows...", text: $state.searchQuery)
285
+ TextField("Search windows & content…", text: $state.searchQuery)
286
286
  .textFieldStyle(.plain)
287
287
  .font(Typo.mono(12))
288
288
  .foregroundColor(Palette.text)
289
289
  .focused($isSearchFieldFocused)
290
290
  if !state.searchQuery.isEmpty {
291
- Text("\(state.flatWindowList.count) matches")
291
+ let total = state.flatWindowList.count
292
+ let ocrCount = state.ocrMatchSnippets.count
293
+ Text(ocrCount > 0 ? "\(total) matches (\(ocrCount) by content)" : "\(total) matches")
292
294
  .font(Typo.mono(9))
293
295
  .foregroundColor(Palette.textMuted)
294
296
  }
@@ -442,6 +444,7 @@ struct CommandModeView: View {
442
444
  VStack(alignment: .leading, spacing: 0) {
443
445
  if appGroup.windows.count == 1, let win = appGroup.windows.first {
444
446
  inventoryRow(window: win, appLabel: appGroup.appName)
447
+ ocrSnippetRow(for: win.id)
445
448
  if state.isSelected(win.id), let path = win.inventoryPath {
446
449
  inventoryPathLabel(path)
447
450
  }
@@ -454,6 +457,7 @@ struct CommandModeView: View {
454
457
  .padding(.bottom, 1)
455
458
  ForEach(appGroup.windows) { win in
456
459
  inventoryRow(window: win, indented: true)
460
+ ocrSnippetRow(for: win.id)
457
461
  if state.isSelected(win.id), let path = win.inventoryPath {
458
462
  inventoryPathLabel(path)
459
463
  }
@@ -471,6 +475,24 @@ struct CommandModeView: View {
471
475
  .padding(.vertical, 2)
472
476
  }
473
477
 
478
+ @ViewBuilder
479
+ private func ocrSnippetRow(for windowId: UInt32) -> some View {
480
+ if let snippet = state.ocrMatchSnippets[windowId] {
481
+ HStack(spacing: 4) {
482
+ Image(systemName: "text.magnifyingglass")
483
+ .font(.system(size: 7))
484
+ .foregroundColor(Palette.textMuted)
485
+ Text(snippet)
486
+ .font(Typo.mono(9).italic())
487
+ .foregroundColor(Palette.textMuted)
488
+ .lineLimit(1)
489
+ .truncationMode(.tail)
490
+ }
491
+ .padding(.horizontal, 28)
492
+ .padding(.vertical, 1)
493
+ }
494
+ }
495
+
474
496
  /// Unified inventory row — handles both single-app rows (with appLabel) and
475
497
  /// sub-rows under a multi-window app header (with indented).
476
498
  private func inventoryRow(
@@ -1278,6 +1300,9 @@ struct CommandModeView: View {
1278
1300
  private func installKeyHandler() {
1279
1301
  eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
1280
1302
  guard state.phase == .inventory || state.phase == .desktopInventory else { return event }
1303
+ // Only handle keys when our panel is the key window
1304
+ guard let panel = CommandModeWindow.shared.panelWindow,
1305
+ panel.isKeyWindow else { return event }
1281
1306
  let consumed = state.handleKey(event.keyCode, modifiers: event.modifierFlags)
1282
1307
  return consumed ? nil : event
1283
1308
  }
@@ -386,6 +386,14 @@ final class DaemonServer: ObservableObject {
386
386
  "pids": .array(interesting.map { .int($0) })
387
387
  ])
388
388
  )
389
+ case .ocrScanComplete(let windowCount, let totalBlocks):
390
+ daemonEvent = DaemonEvent(
391
+ event: "ocr.scanComplete",
392
+ data: .object([
393
+ "windowCount": .int(windowCount),
394
+ "totalBlocks": .int(totalBlocks)
395
+ ])
396
+ )
389
397
  }
390
398
  broadcast(daemonEvent)
391
399
  }
@@ -67,18 +67,27 @@ final class DiagnosticWindow {
67
67
  static let shared = DiagnosticWindow()
68
68
 
69
69
  private var window: NSWindow?
70
+ private var keyMonitor: Any?
70
71
  private let log = DiagnosticLog.shared
71
72
 
72
73
  var isVisible: Bool { window?.isVisible ?? false }
73
74
 
74
75
  func toggle() {
75
76
  if let w = window, w.isVisible {
76
- w.orderOut(nil)
77
+ dismiss()
77
78
  } else {
78
79
  show()
79
80
  }
80
81
  }
81
82
 
83
+ func dismiss() {
84
+ window?.orderOut(nil)
85
+ if let monitor = keyMonitor {
86
+ NSEvent.removeMonitor(monitor)
87
+ keyMonitor = nil
88
+ }
89
+ }
90
+
82
91
  func show() {
83
92
  if let w = window {
84
93
  w.orderFrontRegardless()
@@ -119,6 +128,15 @@ final class DiagnosticWindow {
119
128
  w.orderFrontRegardless()
120
129
  window = w
121
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
+
122
140
  // Startup log
123
141
  let diag = DiagnosticLog.shared
124
142
  diag.info("Diagnostics opened")
@@ -5,6 +5,7 @@ enum ModelEvent {
5
5
  case tmuxChanged(sessions: [TmuxSession])
6
6
  case layerSwitched(index: Int)
7
7
  case processesChanged(interesting: [Int])
8
+ case ocrScanComplete(windowCount: Int, totalBlocks: Int)
8
9
  }
9
10
 
10
11
  final class EventBus {
@@ -1,5 +1,6 @@
1
1
  import Carbon
2
2
  import AppKit
3
+ import Foundation
3
4
 
4
5
  /// Global callback registry keyed by hotkey ID
5
6
  private var hotkeyCallbacks: [UInt32: () -> Void] = [:]
@@ -18,6 +18,8 @@ enum HotkeyAction: String, CaseIterable, Codable {
18
18
  case screenMap
19
19
  case bezel
20
20
  case cheatSheet
21
+ case desktopInventory
22
+ case omniSearch
21
23
  // Layers
22
24
  case layer1, layer2, layer3, layer4, layer5, layer6, layer7, layer8, layer9
23
25
  // Tiling
@@ -32,6 +34,8 @@ enum HotkeyAction: String, CaseIterable, Codable {
32
34
  case .screenMap: return "Screen Map"
33
35
  case .bezel: return "Window Bezel"
34
36
  case .cheatSheet: return "Cheat Sheet"
37
+ case .desktopInventory: return "Desktop Inventory"
38
+ case .omniSearch: return "Omni Search"
35
39
  case .layer1: return "Layer 1"
36
40
  case .layer2: return "Layer 2"
37
41
  case .layer3: return "Layer 3"
@@ -60,7 +64,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
60
64
 
61
65
  var group: HotkeyGroup {
62
66
  switch self {
63
- case .palette, .screenMap, .bezel, .cheatSheet: return .app
67
+ case .palette, .screenMap, .bezel, .cheatSheet, .desktopInventory, .omniSearch: return .app
64
68
  case .layer1, .layer2, .layer3, .layer4, .layer5,
65
69
  .layer6, .layer7, .layer8, .layer9: return .layers
66
70
  default: return .tiling
@@ -73,6 +77,8 @@ enum HotkeyAction: String, CaseIterable, Codable {
73
77
  case .screenMap: return 200
74
78
  case .bezel: return 201
75
79
  case .cheatSheet: return 202
80
+ case .desktopInventory: return 203
81
+ case .omniSearch: return 204
76
82
  case .layer1: return 101
77
83
  case .layer2: return 102
78
84
  case .layer3: return 103
@@ -192,6 +198,8 @@ class HotkeyStore: ObservableObject {
192
198
  bind(.screenMap, 18, hyper) // Hyper+1
193
199
  bind(.bezel, 19, hyper) // Hyper+2
194
200
  bind(.cheatSheet, 20, hyper) // Hyper+3
201
+ bind(.desktopInventory, 21, hyper) // Hyper+4
202
+ bind(.omniSearch, 23, hyper) // Hyper+5
195
203
 
196
204
  // Layers: Cmd+Option+1-9
197
205
  let layerKeyCodes: [UInt32] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
@@ -244,6 +244,36 @@ final class LatticesApi {
244
244
  Field(name: "displayName", type: "string", required: true, description: "Best display name (session > tab title > tty)"),
245
245
  ]))
246
246
 
247
+ api.model(ApiModel(name: "OcrResult", fields: [
248
+ Field(name: "wid", type: "int", required: true, description: "Window ID"),
249
+ Field(name: "app", type: "string", required: true, description: "Application name"),
250
+ Field(name: "title", type: "string", required: true, description: "Window title"),
251
+ Field(name: "frame", type: "Frame", required: true, description: "Window frame"),
252
+ Field(name: "fullText", type: "string", required: true, description: "All recognized text"),
253
+ Field(name: "blocks", type: "[OcrBlock]", required: true, description: "Individual text blocks with position/confidence"),
254
+ Field(name: "timestamp", type: "double", required: true, description: "Scan timestamp (Unix)"),
255
+ ]))
256
+
257
+ api.model(ApiModel(name: "OcrBlock", fields: [
258
+ Field(name: "text", type: "string", required: true, description: "Recognized text"),
259
+ Field(name: "confidence", type: "double", required: true, description: "Recognition confidence 0-1"),
260
+ Field(name: "x", type: "double", required: true, description: "Normalized bounding box x"),
261
+ Field(name: "y", type: "double", required: true, description: "Normalized bounding box y"),
262
+ Field(name: "w", type: "double", required: true, description: "Normalized bounding box width"),
263
+ Field(name: "h", type: "double", required: true, description: "Normalized bounding box height"),
264
+ ]))
265
+
266
+ api.model(ApiModel(name: "OcrSearchResult", fields: [
267
+ Field(name: "id", type: "int", required: true, description: "Database row ID"),
268
+ Field(name: "wid", type: "int", required: true, description: "Window ID"),
269
+ Field(name: "app", type: "string", required: true, description: "Application name"),
270
+ Field(name: "title", type: "string", required: true, description: "Window title"),
271
+ Field(name: "frame", type: "Frame", required: true, description: "Window frame at scan time"),
272
+ Field(name: "fullText", type: "string", required: true, description: "Full recognized text"),
273
+ Field(name: "snippet", type: "string", required: true, description: "Highlighted snippet (FTS5)"),
274
+ Field(name: "timestamp", type: "double", required: true, description: "Scan timestamp (Unix)"),
275
+ ]))
276
+
247
277
  api.model(ApiModel(name: "DaemonStatus", fields: [
248
278
  Field(name: "uptime", type: "double", required: true, description: "Seconds since daemon started"),
249
279
  Field(name: "clientCount", type: "int", required: true, description: "Connected WebSocket clients"),
@@ -283,6 +313,63 @@ final class LatticesApi {
283
313
  }
284
314
  ))
285
315
 
316
+ api.register(Endpoint(
317
+ method: "windows.search",
318
+ description: "Search windows by title, app, and OCR content",
319
+ access: .read,
320
+ params: [
321
+ Param(name: "query", type: "string", required: true, description: "Search text"),
322
+ Param(name: "ocr", type: "bool", required: false, description: "Include OCR content (default true)"),
323
+ Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
324
+ ],
325
+ returns: .array(model: "Window"),
326
+ handler: { params in
327
+ guard let query = params?["query"]?.stringValue?.lowercased(), !query.isEmpty else {
328
+ throw RouterError.missingParam("query")
329
+ }
330
+ let includeOcr = params?["ocr"]?.boolValue ?? true
331
+ let limit = params?["limit"]?.intValue ?? 50
332
+ let ocrResults = OcrModel.shared.results
333
+
334
+ var matches: [JSON] = []
335
+ for entry in DesktopModel.shared.allWindows() {
336
+ let matchesApp = entry.app.lowercased().contains(query)
337
+ let matchesTitle = entry.title.lowercased().contains(query)
338
+ let matchesSession = entry.latticesSession?.lowercased().contains(query) ?? false
339
+ let ocrText = includeOcr ? ocrResults[entry.wid]?.fullText : nil
340
+ let matchesOcrContent = ocrText?.lowercased().contains(query) ?? false
341
+
342
+ if matchesApp || matchesTitle || matchesSession || matchesOcrContent {
343
+ var obj = Encoders.window(entry)
344
+ if matchesOcrContent, let text = ocrText,
345
+ let range = text.lowercased().range(of: query) {
346
+ // Extract snippet around match
347
+ let half = max(0, (80 - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
348
+ let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
349
+ let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
350
+ var snippet = String(text[start..<end])
351
+ .replacingOccurrences(of: "\n", with: " ")
352
+ .trimmingCharacters(in: .whitespaces)
353
+ if start > text.startIndex { snippet = "…" + snippet }
354
+ if end < text.endIndex { snippet += "…" }
355
+ if case .object(var dict) = obj {
356
+ dict["ocrSnippet"] = .string(snippet)
357
+ dict["matchSource"] = .string("ocr")
358
+ obj = .object(dict)
359
+ }
360
+ } else if case .object(var dict) = obj {
361
+ let source = matchesTitle ? "title" : matchesApp ? "app" : "session"
362
+ dict["matchSource"] = .string(source)
363
+ obj = .object(dict)
364
+ }
365
+ matches.append(obj)
366
+ if matches.count >= limit { break }
367
+ }
368
+ }
369
+ return .array(matches)
370
+ }
371
+ ))
372
+
286
373
  api.register(Endpoint(
287
374
  method: "tmux.sessions",
288
375
  description: "List all tmux sessions with child process enrichment",
@@ -486,6 +573,85 @@ final class LatticesApi {
486
573
  }
487
574
  ))
488
575
 
576
+ // ── Endpoints: OCR ─────────────────────────────────────
577
+
578
+ api.register(Endpoint(
579
+ method: "ocr.snapshot",
580
+ description: "Get the latest OCR scan results for all on-screen windows",
581
+ access: .read,
582
+ params: [],
583
+ returns: .array(model: "OcrResult"),
584
+ handler: { _ in
585
+ let results = OcrModel.shared.results
586
+ return .array(results.values.map { Encoders.ocrResult($0) })
587
+ }
588
+ ))
589
+
590
+ api.register(Endpoint(
591
+ method: "ocr.search",
592
+ description: "Search OCR text across all windows (queries persistent SQLite FTS5 index by default)",
593
+ access: .read,
594
+ params: [
595
+ Param(name: "query", type: "string", required: true, description: "Search text (FTS5 query syntax)"),
596
+ Param(name: "app", type: "string", required: false, description: "Filter by app name"),
597
+ Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
598
+ Param(name: "live", type: "bool", required: false, description: "Search in-memory snapshot instead of history (default false)"),
599
+ ],
600
+ returns: .array(model: "OcrSearchResult"),
601
+ handler: { params in
602
+ guard let query = params?["query"]?.stringValue else {
603
+ throw RouterError.missingParam("query")
604
+ }
605
+ let app = params?["app"]?.stringValue
606
+ let limit = params?["limit"]?.intValue ?? 50
607
+ let live = params?["live"]?.boolValue ?? false
608
+
609
+ if live {
610
+ // In-memory snapshot search (original behavior)
611
+ var results = Array(OcrModel.shared.results.values)
612
+ let q = query.lowercased()
613
+ results = results.filter { $0.fullText.lowercased().contains(q) }
614
+ if let app { results = results.filter { $0.app == app } }
615
+ return .array(results.prefix(limit).map { Encoders.ocrResult($0) })
616
+ }
617
+
618
+ // Persistent FTS5 search
619
+ let results = OcrStore.shared.search(query: query, app: app, limit: limit)
620
+ return .array(results.map { Encoders.ocrSearchResult($0) })
621
+ }
622
+ ))
623
+
624
+ api.register(Endpoint(
625
+ method: "ocr.history",
626
+ description: "Get OCR content timeline for a specific window",
627
+ access: .read,
628
+ params: [
629
+ Param(name: "wid", type: "uint32", required: true, description: "Window ID"),
630
+ Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
631
+ ],
632
+ returns: .array(model: "OcrSearchResult"),
633
+ handler: { params in
634
+ guard let wid = params?["wid"]?.uint32Value else {
635
+ throw RouterError.missingParam("wid")
636
+ }
637
+ let limit = params?["limit"]?.intValue ?? 50
638
+ let results = OcrStore.shared.history(wid: wid, limit: limit)
639
+ return .array(results.map { Encoders.ocrSearchResult($0) })
640
+ }
641
+ ))
642
+
643
+ api.register(Endpoint(
644
+ method: "ocr.scan",
645
+ description: "Trigger an immediate OCR scan",
646
+ access: .mutate,
647
+ params: [],
648
+ returns: .ok,
649
+ handler: { _ in
650
+ OcrModel.shared.scan()
651
+ return .object(["ok": .bool(true)])
652
+ }
653
+ ))
654
+
489
655
  // ── Endpoints: Mutations ────────────────────────────────
490
656
 
491
657
  api.register(Endpoint(
@@ -857,6 +1023,50 @@ enum Encoders {
857
1023
  return .object(obj)
858
1024
  }
859
1025
 
1026
+ static func ocrResult(_ r: OcrWindowResult) -> JSON {
1027
+ .object([
1028
+ "wid": .int(Int(r.wid)),
1029
+ "app": .string(r.app),
1030
+ "title": .string(r.title),
1031
+ "frame": .object([
1032
+ "x": .double(r.frame.x),
1033
+ "y": .double(r.frame.y),
1034
+ "w": .double(r.frame.w),
1035
+ "h": .double(r.frame.h)
1036
+ ]),
1037
+ "fullText": .string(r.fullText),
1038
+ "blocks": .array(r.texts.map { block in
1039
+ .object([
1040
+ "text": .string(block.text),
1041
+ "confidence": .double(Double(block.confidence)),
1042
+ "x": .double(block.boundingBox.origin.x),
1043
+ "y": .double(block.boundingBox.origin.y),
1044
+ "w": .double(block.boundingBox.size.width),
1045
+ "h": .double(block.boundingBox.size.height)
1046
+ ])
1047
+ }),
1048
+ "timestamp": .double(r.timestamp.timeIntervalSince1970)
1049
+ ])
1050
+ }
1051
+
1052
+ static func ocrSearchResult(_ r: OcrSearchResult) -> JSON {
1053
+ .object([
1054
+ "id": .int(Int(r.id)),
1055
+ "wid": .int(Int(r.wid)),
1056
+ "app": .string(r.app),
1057
+ "title": .string(r.title),
1058
+ "frame": .object([
1059
+ "x": .double(r.frame.x),
1060
+ "y": .double(r.frame.y),
1061
+ "w": .double(r.frame.w),
1062
+ "h": .double(r.frame.h)
1063
+ ]),
1064
+ "fullText": .string(r.fullText),
1065
+ "snippet": .string(r.snippet),
1066
+ "timestamp": .double(r.timestamp.timeIntervalSince1970)
1067
+ ])
1068
+ }
1069
+
860
1070
  static func enrichedSession(_ s: TmuxSession) -> JSON {
861
1071
  let pm = ProcessModel.shared
862
1072
  return .object([