@arach/lattices 0.1.0 → 0.2.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 (39) hide show
  1. package/README.md +28 -28
  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
@@ -92,6 +92,41 @@ struct GlassCard: ViewModifier {
92
92
  }
93
93
  }
94
94
 
95
+ struct LiquidGlassCard: ViewModifier {
96
+ func body(content: Content) -> some View {
97
+ content
98
+ .background(
99
+ ZStack {
100
+ // Base: translucent dark fill
101
+ RoundedRectangle(cornerRadius: 10)
102
+ .fill(Color.white.opacity(0.04))
103
+
104
+ // Subtle gradient: brighter at top edge for "glass reflection"
105
+ RoundedRectangle(cornerRadius: 10)
106
+ .fill(
107
+ LinearGradient(
108
+ colors: [Color.white.opacity(0.06), Color.clear],
109
+ startPoint: .top,
110
+ endPoint: .center
111
+ )
112
+ )
113
+
114
+ // Border: top-bright, bottom-dark for depth
115
+ RoundedRectangle(cornerRadius: 10)
116
+ .strokeBorder(
117
+ LinearGradient(
118
+ colors: [Color.white.opacity(0.12), Color.white.opacity(0.04)],
119
+ startPoint: .top,
120
+ endPoint: .bottom
121
+ ),
122
+ lineWidth: 0.5
123
+ )
124
+ }
125
+ )
126
+ .shadow(color: Color.black.opacity(0.2), radius: 8, y: 4)
127
+ }
128
+ }
129
+
95
130
  struct AngularButton: ViewModifier {
96
131
  let color: Color
97
132
  var filled: Bool = true
@@ -118,6 +153,10 @@ extension View {
118
153
  modifier(GlassCard(isHovered: hovered))
119
154
  }
120
155
 
156
+ func liquidGlass() -> some View {
157
+ modifier(LiquidGlassCard())
158
+ }
159
+
121
160
  func angularButton(_ color: Color, filled: Bool = true) -> some View {
122
161
  modifier(AngularButton(color: color, filled: filled))
123
162
  }
@@ -5,6 +5,24 @@ import CoreGraphics
5
5
  @_silgen_name("_AXUIElementGetWindow")
6
6
  func _AXUIElementGetWindow(_ element: AXUIElement, _ windowID: UnsafeMutablePointer<CGWindowID>) -> AXError
7
7
 
8
+ // MARK: - SkyLight Private APIs (instant window moves, no animation)
9
+ // Loaded at runtime via dlsym — graceful fallback if unavailable.
10
+
11
+ private let skyLight: UnsafeMutableRawPointer? = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_NOW)
12
+
13
+ private typealias SLSMainConnectionIDFunc = @convention(c) () -> Int32
14
+ private typealias SLSMoveWindowFunc = @convention(c) (Int32, UInt32, UnsafeMutablePointer<CGPoint>) -> CGError
15
+
16
+ private let _SLSMainConnectionID: SLSMainConnectionIDFunc? = {
17
+ guard let sl = skyLight, let sym = dlsym(sl, "SLSMainConnectionID") else { return nil }
18
+ return unsafeBitCast(sym, to: SLSMainConnectionIDFunc.self)
19
+ }()
20
+
21
+ private let _SLSMoveWindow: SLSMoveWindowFunc? = {
22
+ guard let sl = skyLight, let sym = dlsym(sl, "SLSMoveWindow") else { return nil }
23
+ return unsafeBitCast(sym, to: SLSMoveWindowFunc.self)
24
+ }()
25
+
8
26
  // MARK: - Window Highlight Overlay
9
27
 
10
28
  final class WindowHighlight {
@@ -1626,47 +1644,30 @@ enum WindowTiler {
1626
1644
  /// Tile the frontmost window of any app to a position using AX API.
1627
1645
  /// Works for any application (Finder, Chrome, etc.), not just terminals.
1628
1646
  static func tileFrontmostViaAX(to position: TilePosition) {
1629
- let diag = DiagnosticLog.shared
1630
- let t = diag.startTimed("tileFrontmostViaAX: \(position.rawValue)")
1631
-
1632
1647
  // 1. Get frontmost application
1633
- guard let frontApp = NSWorkspace.shared.frontmostApplication else {
1634
- diag.warn("tileFrontmostViaAX: no frontmost application")
1635
- diag.finish(t)
1636
- return
1637
- }
1638
-
1639
- // 2. Skip if Lattices
1640
- if frontApp.bundleIdentifier == "com.arach.lattices" {
1641
- diag.info("tileFrontmostViaAX: skipping Lattices")
1642
- diag.finish(t)
1643
- return
1644
- }
1648
+ guard let frontApp = NSWorkspace.shared.frontmostApplication,
1649
+ frontApp.bundleIdentifier != "com.arach.lattices" else { return }
1645
1650
 
1646
- let pid = frontApp.processIdentifier
1647
- let appRef = AXUIElementCreateApplication(pid)
1651
+ let appRef = AXUIElementCreateApplication(frontApp.processIdentifier)
1648
1652
 
1649
- // 3. Get focused window
1653
+ // 2. Get focused window
1650
1654
  var focusedRef: CFTypeRef?
1651
- let err = AXUIElementCopyAttributeValue(appRef, kAXFocusedWindowAttribute as CFString, &focusedRef)
1652
- guard err == .success, let axWindow = focusedRef else {
1653
- diag.warn("tileFrontmostViaAX: no focused window (AX error \(err.rawValue))")
1654
- diag.finish(t)
1655
- return
1656
- }
1655
+ guard AXUIElementCopyAttributeValue(appRef, kAXFocusedWindowAttribute as CFString, &focusedRef) == .success,
1656
+ let axWindow = focusedRef else { return }
1657
1657
 
1658
- // 4. Read current position → find containing screen
1658
+ let win = axWindow as! AXUIElement
1659
+
1660
+ // 3. Read current position → find containing screen (AX coords: top-left origin)
1659
1661
  var posRef: CFTypeRef?
1660
1662
  var sizeRef: CFTypeRef?
1661
- AXUIElementCopyAttributeValue(axWindow as! AXUIElement, kAXPositionAttribute as CFString, &posRef)
1662
- AXUIElementCopyAttributeValue(axWindow as! AXUIElement, kAXSizeAttribute as CFString, &sizeRef)
1663
+ AXUIElementCopyAttributeValue(win, kAXPositionAttribute as CFString, &posRef)
1664
+ AXUIElementCopyAttributeValue(win, kAXSizeAttribute as CFString, &sizeRef)
1663
1665
 
1664
1666
  var currentPos = CGPoint.zero
1665
1667
  var currentSize = CGSize.zero
1666
1668
  if let pv = posRef { AXValueGetValue(pv as! AXValue, .cgPoint, &currentPos) }
1667
1669
  if let sv = sizeRef { AXValueGetValue(sv as! AXValue, .cgSize, &currentSize) }
1668
1670
 
1669
- // Find screen containing window center (AX uses top-left origin)
1670
1671
  let primaryHeight = NSScreen.screens.first?.frame.height ?? 1080
1671
1672
  let nsCenterX = currentPos.x + currentSize.width / 2
1672
1673
  let nsCenterY = primaryHeight - (currentPos.y + currentSize.height / 2)
@@ -1674,40 +1675,42 @@ enum WindowTiler {
1674
1675
  $0.frame.contains(NSPoint(x: nsCenterX, y: nsCenterY))
1675
1676
  }) ?? NSScreen.main ?? NSScreen.screens[0]
1676
1677
 
1677
- // 5. Compute target frame
1678
+ // 4. Compute target frame (AX/CGS coords: top-left origin)
1678
1679
  let targetFrame = tileFrame(for: position, on: screen)
1679
-
1680
- // 6. Double-set: size → pos → size → pos
1681
1680
  var newPos = CGPoint(x: targetFrame.origin.x, y: targetFrame.origin.y)
1682
1681
  var newSize = CGSize(width: targetFrame.width, height: targetFrame.height)
1683
1682
 
1684
- let win = axWindow as! AXUIElement
1685
- if let sv = AXValueCreate(.cgSize, &newSize) {
1686
- AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
1687
- }
1688
- if let pv = AXValueCreate(.cgPoint, &newPos) {
1689
- AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
1690
- }
1691
- if let sv = AXValueCreate(.cgSize, &newSize) {
1692
- AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
1693
- }
1694
- if let pv = AXValueCreate(.cgPoint, &newPos) {
1695
- AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
1696
- }
1683
+ // 5. Try SkyLight fast path (instant, no animation)
1684
+ var wid: CGWindowID = 0
1685
+ let hasSkyLight = _SLSMainConnectionID != nil
1686
+ && _SLSMoveWindow != nil
1687
+ && _AXUIElementGetWindow(win, &wid) == .success
1688
+ && wid != 0
1697
1689
 
1698
- // 7. Flash highlight
1699
- let nsFrame = NSRect(
1700
- x: targetFrame.origin.x,
1701
- y: primaryHeight - targetFrame.origin.y - targetFrame.height,
1702
- width: targetFrame.width,
1703
- height: targetFrame.height
1704
- )
1705
- DispatchQueue.main.async {
1706
- WindowHighlight.shared.flash(frame: nsFrame, duration: 0.6)
1707
- }
1690
+ if hasSkyLight {
1691
+ let cid = _SLSMainConnectionID!()
1708
1692
 
1709
- diag.success("tileFrontmostViaAX: tiled \(frontApp.localizedName ?? "?") to \(position.rawValue)")
1710
- diag.finish(t)
1693
+ // Resize via AX (app must handle its own content layout)
1694
+ if let sv = AXValueCreate(.cgSize, &newSize) {
1695
+ AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
1696
+ }
1697
+
1698
+ // Move via SkyLight (instant, no animation)
1699
+ if _SLSMoveWindow!(cid, UInt32(wid), &newPos) != .success {
1700
+ // SLS move failed — fall back to AX position
1701
+ if let pv = AXValueCreate(.cgPoint, &newPos) {
1702
+ AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
1703
+ }
1704
+ }
1705
+ } else {
1706
+ // 6. AX fallback: size then position
1707
+ if let sv = AXValueCreate(.cgSize, &newSize) {
1708
+ AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
1709
+ }
1710
+ if let pv = AXValueCreate(.cgPoint, &newPos) {
1711
+ AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
1712
+ }
1713
+ }
1711
1714
  }
1712
1715
 
1713
1716
  // MARK: - Private
@@ -19,7 +19,22 @@ const ASSET_NAME = "Lattices-macos-arm64";
19
19
 
20
20
  function isRunning() {
21
21
  try {
22
- execSync("pgrep -f Lattices.app", { stdio: "pipe" });
22
+ execSync("pgrep -x Lattices", { stdio: "pipe" });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function quit() {
30
+ try {
31
+ execSync("pkill -x Lattices", { stdio: "pipe" });
32
+ // Wait briefly for process to exit
33
+ try { execSync("sleep 0.5", { stdio: "pipe" }); } catch {}
34
+ // Force kill if still running
35
+ if (isRunning()) {
36
+ execSync("pkill -9 -x Lattices", { stdio: "pipe" });
37
+ }
23
38
  return true;
24
39
  } catch {
25
40
  return false;
@@ -78,8 +93,8 @@ function buildFromSource() {
78
93
  try {
79
94
  // Prefer a real signing identity for stable TCC grants; fall back to ad-hoc with fixed identifier
80
95
  const identities = execSync("security find-identity -v -p codesigning", { stdio: "pipe" }).toString();
81
- const devId = identities.match(/"(Apple Development:[^"]+)"/)?.[1]
82
- || identities.match(/"(Developer ID Application:[^"]+)"/)?.[1];
96
+ const devId = identities.match(/"(Developer ID Application:[^"]+)"/)?.[1]
97
+ || identities.match(/"(Apple Development:[^"]+)"/)?.[1];
83
98
  const signArg = devId ? `'${devId}'` : "-";
84
99
  execSync(
85
100
  `codesign --force --sign ${signArg} --identifier com.arach.lattices '${bundlePath}'`,
@@ -89,6 +104,8 @@ function buildFromSource() {
89
104
  // Non-fatal — app still works, just permissions won't persist across rebuilds
90
105
  console.log("Warning: code signing failed — permissions may not persist across rebuilds.");
91
106
  }
107
+ // Update bundle timestamp so Finder shows the correct modified date
108
+ try { execSync(`touch '${bundlePath}'`, { stdio: "pipe" }); } catch {}
92
109
  console.log("Build complete.");
93
110
  return true;
94
111
  }
@@ -181,15 +198,14 @@ if (cmd === "build") {
181
198
  }
182
199
  buildFromSource();
183
200
  } else if (cmd === "quit") {
184
- try {
185
- execSync("pkill -f Lattices.app", { stdio: "pipe" });
201
+ if (quit()) {
186
202
  console.log("lattices app stopped.");
187
- } catch {
203
+ } else {
188
204
  console.log("lattices app is not running.");
189
205
  }
190
206
  } else if (cmd === "restart") {
191
207
  // Quit → rebuild → relaunch
192
- try { execSync("pkill -f Lattices.app", { stdio: "pipe" }); } catch {}
208
+ quit();
193
209
  if (!hasSwift()) {
194
210
  console.error("Swift is required. Install with: xcode-select --install");
195
211
  process.exit(1);
package/bin/lattices.js CHANGED
@@ -867,6 +867,122 @@ async function daemonStatusInventory() {
867
867
  }
868
868
  }
869
869
 
870
+ // ── OCR commands ──────────────────────────────────────────────────────
871
+
872
+ async function ocrCommand(sub, ...rest) {
873
+ const { daemonCall } = await getDaemonClient();
874
+
875
+ if (!sub || sub === "snapshot" || sub === "ls") {
876
+ // Default: show latest OCR snapshot
877
+ try {
878
+ const results = await daemonCall("ocr.snapshot", null, 5000);
879
+ if (!results.length) {
880
+ console.log("No OCR results yet. The first scan runs ~60s after launch.");
881
+ return;
882
+ }
883
+ console.log(`\x1b[1mOCR Snapshot\x1b[0m (${results.length} windows)\n`);
884
+ for (const r of results) {
885
+ const age = Math.round((Date.now() / 1000) - r.timestamp);
886
+ const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.floor(age / 60)}m ago` : `${Math.floor(age / 3600)}h ago`;
887
+ const lines = (r.fullText || "").split("\n").filter(Boolean);
888
+ const preview = lines.slice(0, 3).map(l => l.length > 80 ? l.slice(0, 77) + "..." : l);
889
+ console.log(` \x1b[1m${r.app}\x1b[0m wid:${r.wid} \x1b[90m${ageStr}\x1b[0m`);
890
+ console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
891
+ if (preview.length) {
892
+ for (const line of preview) {
893
+ console.log(` \x1b[90m${line}\x1b[0m`);
894
+ }
895
+ if (lines.length > 3) {
896
+ console.log(` \x1b[90m… ${lines.length - 3} more lines\x1b[0m`);
897
+ }
898
+ } else {
899
+ console.log(` \x1b[90m(no text detected)\x1b[0m`);
900
+ }
901
+ console.log();
902
+ }
903
+ } catch {
904
+ console.log("Daemon not running. Start with: lattices app");
905
+ }
906
+ return;
907
+ }
908
+
909
+ if (sub === "search") {
910
+ const query = rest.join(" ");
911
+ if (!query) {
912
+ console.log("Usage: lattices ocr search <query>");
913
+ return;
914
+ }
915
+ try {
916
+ const results = await daemonCall("ocr.search", { query }, 5000);
917
+ if (!results.length) {
918
+ console.log(`No OCR matches for "${query}".`);
919
+ return;
920
+ }
921
+ console.log(`\x1b[1mOCR Search\x1b[0m "${query}" (${results.length} matches)\n`);
922
+ for (const r of results) {
923
+ const snippet = r.snippet || r.fullText?.slice(0, 120) || "";
924
+ console.log(` \x1b[1m${r.app}\x1b[0m wid:${r.wid}`);
925
+ console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
926
+ console.log(` ${snippet}`);
927
+ console.log();
928
+ }
929
+ } catch (e) {
930
+ console.log(`Error: ${e.message}`);
931
+ }
932
+ return;
933
+ }
934
+
935
+ if (sub === "scan") {
936
+ try {
937
+ console.log("Triggering OCR scan...");
938
+ await daemonCall("ocr.scan", null, 30000);
939
+ console.log("Scan complete.");
940
+ } catch (e) {
941
+ console.log(`Error: ${e.message}`);
942
+ }
943
+ return;
944
+ }
945
+
946
+ if (sub === "history") {
947
+ const wid = parseInt(rest[0], 10);
948
+ if (isNaN(wid)) {
949
+ console.log("Usage: lattices ocr history <wid>");
950
+ return;
951
+ }
952
+ try {
953
+ const results = await daemonCall("ocr.history", { wid }, 5000);
954
+ if (!results.length) {
955
+ console.log(`No OCR history for wid:${wid}.`);
956
+ return;
957
+ }
958
+ console.log(`\x1b[1mOCR History\x1b[0m wid:${wid} (${results.length} entries)\n`);
959
+ for (const r of results) {
960
+ const ts = new Date(r.timestamp * 1000).toLocaleTimeString();
961
+ const lines = (r.fullText || "").split("\n").filter(Boolean);
962
+ const preview = lines.slice(0, 2).map(l => l.length > 80 ? l.slice(0, 77) + "..." : l);
963
+ console.log(` \x1b[90m${ts}\x1b[0m \x1b[1m${r.app}\x1b[0m — "${r.title}"`);
964
+ for (const line of preview) {
965
+ console.log(` \x1b[90m${line}\x1b[0m`);
966
+ }
967
+ console.log();
968
+ }
969
+ } catch (e) {
970
+ console.log(`Error: ${e.message}`);
971
+ }
972
+ return;
973
+ }
974
+
975
+ // Unknown subcommand
976
+ console.log(`lattices ocr — Screen text recognition
977
+
978
+ Usage:
979
+ lattices ocr Show latest OCR snapshot (all windows)
980
+ lattices ocr search <q> Full-text search across all scanned windows
981
+ lattices ocr scan Trigger an immediate scan
982
+ lattices ocr history <id> Show OCR timeline for a window (by wid)
983
+ `);
984
+ }
985
+
870
986
  function printUsage() {
871
987
  console.log(`lattices — Claude Code + dev server in tmux
872
988
 
@@ -886,6 +1002,10 @@ Usage:
886
1002
  lattices tile <position> Tile the frontmost window (left, right, top, etc.)
887
1003
  lattices distribute Smart-grid all visible windows (daemon required)
888
1004
  lattices layer [index] List layers or switch to a layer (daemon required)
1005
+ lattices ocr Show latest OCR snapshot (all windows)
1006
+ lattices ocr search <q> Full-text search screen text
1007
+ lattices ocr scan Trigger an immediate scan
1008
+ lattices ocr history <wid> OCR timeline for a specific window
889
1009
  lattices daemon status Show daemon status
890
1010
  lattices app Launch the menu bar companion app
891
1011
  lattices app build Rebuild the menu bar app
@@ -1268,6 +1388,9 @@ switch (command) {
1268
1388
  case "layers":
1269
1389
  await layerCommand(args[1]);
1270
1390
  break;
1391
+ case "ocr":
1392
+ await ocrCommand(args[1], ...args.slice(2));
1393
+ break;
1271
1394
  case "daemon":
1272
1395
  if (args[1] === "status") {
1273
1396
  await daemonStatusCommand();