@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
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<picture>
|
|
2
|
+
<img alt="lattices" src="site/public/og.png" />
|
|
3
|
+
</picture>
|
|
4
|
+
|
|
5
|
+
# lattices
|
|
6
|
+
|
|
7
|
+
A workspace control plane for macOS. Manage persistent terminal sessions,
|
|
8
|
+
tile and organize your windows, and index the text on your screen — all
|
|
9
|
+
controllable from the CLI or a 30-method daemon API.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install -g @lattices/cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
# Launch the menu bar app
|
|
21
|
+
lattices app
|
|
22
|
+
|
|
23
|
+
# Open the command palette from anywhere
|
|
24
|
+
# Cmd+Shift+M
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Persistent terminal sessions
|
|
28
|
+
|
|
29
|
+
Declare your dev environment in a `.lattices.json`: which panes, which
|
|
30
|
+
commands, what layout. Lattices builds it, runs it, and keeps it alive.
|
|
31
|
+
Close your laptop, reboot, come back a week later — your editor, dev
|
|
32
|
+
server, and test runner are exactly where you left them.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
cd my-project && lattices
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
No config? It reads your `package.json` and picks the right dev command
|
|
39
|
+
automatically.
|
|
40
|
+
|
|
41
|
+
### Configuration
|
|
42
|
+
|
|
43
|
+
Drop a `.lattices.json` in your project root:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"ensure": true,
|
|
48
|
+
"panes": [
|
|
49
|
+
{ "name": "claude", "cmd": "claude", "size": 60 },
|
|
50
|
+
{ "name": "server", "cmd": "pnpm dev" },
|
|
51
|
+
{ "name": "tests", "cmd": "pnpm test --watch" }
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Layouts
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
2 panes 3+ panes
|
|
60
|
+
|
|
61
|
+
┌──────────┬───────┐ ┌──────────┬───────┐
|
|
62
|
+
│ claude │server │ │ claude │server │
|
|
63
|
+
│ (60%) │(40%) │ │ (60%) ├───────┤
|
|
64
|
+
└──────────┴───────┘ │ │tests │
|
|
65
|
+
└──────────┴───────┘
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Workspace layers
|
|
69
|
+
|
|
70
|
+
Group projects into switchable contexts. `Cmd+Option+1` tiles your
|
|
71
|
+
frontend and API side by side. `Cmd+Option+2` for the mobile stack.
|
|
72
|
+
Sessions stay alive across switches.
|
|
73
|
+
|
|
74
|
+
### Tab groups
|
|
75
|
+
|
|
76
|
+
Bundle related repos as tabs in one session. Each tab gets its own
|
|
77
|
+
pane layout from its `.lattices.json`.
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
lattices group talkie # Launch iOS, macOS, Web, API as tabs
|
|
81
|
+
lattices tab talkie iOS # Switch to the iOS tab
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Window tiling and awareness
|
|
85
|
+
|
|
86
|
+
A native menu bar app tracks every window across all your monitors.
|
|
87
|
+
Tile with hotkeys, organize into switchable layers, snap to grids.
|
|
88
|
+
|
|
89
|
+
It reads your windows too — extracting text from UI elements every
|
|
90
|
+
60 seconds and running Vision OCR on background windows every 2 hours.
|
|
91
|
+
Everything is searchable.
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
lattices scan # View current screen text
|
|
95
|
+
lattices scan search "error" # Search across all indexed text
|
|
96
|
+
lattices scan recent # Browse scan history
|
|
97
|
+
lattices scan deep # Trigger a Vision OCR scan now
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## A programmable desktop
|
|
101
|
+
|
|
102
|
+
The menu bar app runs a daemon with 30 RPC methods and 5 real-time
|
|
103
|
+
events over WebSocket. Anything you can do from the app, an agent or
|
|
104
|
+
script can do over the API.
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
import { daemonCall } from '@lattices/cli/daemon-client'
|
|
108
|
+
|
|
109
|
+
const windows = await daemonCall('windows.list')
|
|
110
|
+
await daemonCall('session.launch', { path: '/Users/you/dev/frontend' })
|
|
111
|
+
await daemonCall('window.tile', { session: 'frontend-a1b2c3', position: 'left' })
|
|
112
|
+
|
|
113
|
+
// Read the screen
|
|
114
|
+
await daemonCall('ocr.scan')
|
|
115
|
+
const errors = await daemonCall('ocr.search', { query: 'error OR failed' })
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Claude Code skills, MCP servers, or your own scripts can drive your
|
|
119
|
+
desktop the same way you do.
|
|
120
|
+
|
|
121
|
+
## CLI
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
lattices Create or reattach to session
|
|
125
|
+
lattices init Generate .lattices.json
|
|
126
|
+
lattices ls List active sessions
|
|
127
|
+
lattices kill [name] Kill a session
|
|
128
|
+
lattices tile <position> Tile frontmost window
|
|
129
|
+
lattices group [id] Launch or attach a tab group
|
|
130
|
+
lattices tab <group> [tab] Switch tab within a group
|
|
131
|
+
lattices scan View current screen text
|
|
132
|
+
lattices scan search <q> Search indexed text
|
|
133
|
+
lattices scan recent [n] Browse scan history
|
|
134
|
+
lattices scan deep Trigger Vision OCR now
|
|
135
|
+
lattices app Launch the menu bar app
|
|
136
|
+
lattices help Show help
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Requirements
|
|
140
|
+
|
|
141
|
+
- macOS 13.0+
|
|
142
|
+
- Node.js 18+
|
|
143
|
+
|
|
144
|
+
### Optional
|
|
145
|
+
|
|
146
|
+
- tmux for persistent terminal sessions (`brew install tmux`)
|
|
147
|
+
- Swift 5.9+ to build the menu bar app from source
|
|
148
|
+
|
|
149
|
+
## Docs
|
|
150
|
+
|
|
151
|
+
[lattices.dev/docs](https://lattices.dev/docs/overview)
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleIdentifier</key>
|
|
6
|
+
<string>com.arach.lattices</string>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>Lattices</string>
|
|
9
|
+
<key>CFBundleExecutable</key>
|
|
10
|
+
<string>Lattices</string>
|
|
11
|
+
<key>CFBundleIconFile</key>
|
|
12
|
+
<string>AppIcon</string>
|
|
13
|
+
<key>CFBundlePackageType</key>
|
|
14
|
+
<string>APPL</string>
|
|
15
|
+
<key>CFBundleVersion</key>
|
|
16
|
+
<string>1</string>
|
|
17
|
+
<key>CFBundleShortVersionString</key>
|
|
18
|
+
<string>0.1.0</string>
|
|
19
|
+
<key>LSUIElement</key>
|
|
20
|
+
<true/>
|
|
21
|
+
<key>NSSupportsAutomaticTermination</key>
|
|
22
|
+
<true/>
|
|
23
|
+
</dict>
|
|
24
|
+
</plist>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
// Private API: get CGWindowID from an AXUIElement (already declared in WindowTiler.swift)
|
|
4
|
+
// We reuse the same _AXUIElementGetWindow binding.
|
|
5
|
+
|
|
6
|
+
struct AXTextResult {
|
|
7
|
+
let wid: UInt32
|
|
8
|
+
let texts: [String]
|
|
9
|
+
let fullText: String
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
final class AccessibilityTextExtractor {
|
|
13
|
+
static let timeoutSeconds: TimeInterval = 0.2
|
|
14
|
+
static let maxDepth: Int = 4
|
|
15
|
+
static let maxChildrenPerNode: Int = 30
|
|
16
|
+
|
|
17
|
+
/// Extract text from a window's AX element tree.
|
|
18
|
+
/// Returns nil if AX fails or yields fewer than `minChars` characters.
|
|
19
|
+
func extract(pid: Int32, wid: UInt32, minChars: Int = 12) -> AXTextResult? {
|
|
20
|
+
let appRef = AXUIElementCreateApplication(pid)
|
|
21
|
+
|
|
22
|
+
// Find the AXUIElement matching this wid
|
|
23
|
+
var windowsRef: CFTypeRef?
|
|
24
|
+
let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
|
|
25
|
+
guard err == .success, let axWindows = windowsRef as? [AXUIElement] else { return nil }
|
|
26
|
+
|
|
27
|
+
var targetWindow: AXUIElement?
|
|
28
|
+
for axWin in axWindows {
|
|
29
|
+
var winID: CGWindowID = 0
|
|
30
|
+
if _AXUIElementGetWindow(axWin, &winID) == .success, winID == CGWindowID(wid) {
|
|
31
|
+
targetWindow = axWin
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
guard let window = targetWindow else { return nil }
|
|
37
|
+
|
|
38
|
+
let deadline = Date().addingTimeInterval(Self.timeoutSeconds)
|
|
39
|
+
var collected: [String] = []
|
|
40
|
+
|
|
41
|
+
walkChildren(element: window, depth: 0, deadline: deadline, collected: &collected)
|
|
42
|
+
|
|
43
|
+
let fullText = collected.joined(separator: "\n")
|
|
44
|
+
guard fullText.count >= minChars else { return nil }
|
|
45
|
+
|
|
46
|
+
return AXTextResult(wid: wid, texts: collected, fullText: fullText)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - Tree Walker
|
|
50
|
+
|
|
51
|
+
private func walkChildren(
|
|
52
|
+
element: AXUIElement,
|
|
53
|
+
depth: Int,
|
|
54
|
+
deadline: Date,
|
|
55
|
+
collected: inout [String]
|
|
56
|
+
) {
|
|
57
|
+
guard depth < Self.maxDepth, Date() < deadline else { return }
|
|
58
|
+
|
|
59
|
+
// Extract text attributes from this element
|
|
60
|
+
extractText(from: element, into: &collected)
|
|
61
|
+
|
|
62
|
+
// Get children — prefer visible children, fall back to all children
|
|
63
|
+
var childrenRef: CFTypeRef?
|
|
64
|
+
var gotChildren = false
|
|
65
|
+
|
|
66
|
+
let visErr = AXUIElementCopyAttributeValue(element, kAXVisibleChildrenAttribute as CFString, &childrenRef)
|
|
67
|
+
if visErr == .success, let children = childrenRef as? [AXUIElement], !children.isEmpty {
|
|
68
|
+
gotChildren = true
|
|
69
|
+
let capped = children.prefix(Self.maxChildrenPerNode)
|
|
70
|
+
for child in capped {
|
|
71
|
+
guard Date() < deadline else { return }
|
|
72
|
+
walkChildren(element: child, depth: depth + 1, deadline: deadline, collected: &collected)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if !gotChildren {
|
|
77
|
+
childrenRef = nil
|
|
78
|
+
let childErr = AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef)
|
|
79
|
+
if childErr == .success, let children = childrenRef as? [AXUIElement] {
|
|
80
|
+
let capped = children.prefix(Self.maxChildrenPerNode)
|
|
81
|
+
for child in capped {
|
|
82
|
+
guard Date() < deadline else { return }
|
|
83
|
+
walkChildren(element: child, depth: depth + 1, deadline: deadline, collected: &collected)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func extractText(from element: AXUIElement, into collected: inout [String]) {
|
|
90
|
+
// kAXValueAttribute — text field contents, labels, etc.
|
|
91
|
+
var valueRef: CFTypeRef?
|
|
92
|
+
if AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &valueRef) == .success,
|
|
93
|
+
let str = valueRef as? String, !str.isEmpty, str.count > 1 {
|
|
94
|
+
collected.append(str)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// kAXTitleAttribute — window/button titles
|
|
98
|
+
var titleRef: CFTypeRef?
|
|
99
|
+
if AXUIElementCopyAttributeValue(element, kAXTitleAttribute as CFString, &titleRef) == .success,
|
|
100
|
+
let str = titleRef as? String, !str.isEmpty, str.count > 1 {
|
|
101
|
+
collected.append(str)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// kAXDescriptionAttribute — accessible descriptions
|
|
105
|
+
var descRef: CFTypeRef?
|
|
106
|
+
if AXUIElementCopyAttributeValue(element, kAXDescriptionAttribute as CFString, &descRef) == .success,
|
|
107
|
+
let str = descRef as? String, !str.isEmpty, str.count > 1 {
|
|
108
|
+
collected.append(str)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// A single action row with shortcut badge, label, optional icon, and hotkey hint.
|
|
4
|
+
struct ActionRow: View {
|
|
5
|
+
let shortcut: String
|
|
6
|
+
let label: String
|
|
7
|
+
var hotkey: String? = nil
|
|
8
|
+
var icon: String? = nil
|
|
9
|
+
var accentColor: Color = Palette.textDim
|
|
10
|
+
var action: () -> Void
|
|
11
|
+
|
|
12
|
+
@State private var isHovered = false
|
|
13
|
+
|
|
14
|
+
var body: some View {
|
|
15
|
+
Button(action: action) {
|
|
16
|
+
HStack(spacing: 10) {
|
|
17
|
+
// Shortcut badge
|
|
18
|
+
Text(shortcut)
|
|
19
|
+
.font(Typo.monoBold(10))
|
|
20
|
+
.foregroundColor(accentColor)
|
|
21
|
+
.frame(width: 18, height: 18)
|
|
22
|
+
.background(
|
|
23
|
+
RoundedRectangle(cornerRadius: 4)
|
|
24
|
+
.fill(accentColor.opacity(0.12))
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// Icon
|
|
28
|
+
if let icon {
|
|
29
|
+
Image(systemName: icon)
|
|
30
|
+
.font(.system(size: 11, weight: .medium))
|
|
31
|
+
.foregroundColor(isHovered ? Palette.text : Palette.textDim)
|
|
32
|
+
.frame(width: 14)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Label
|
|
36
|
+
Text(label)
|
|
37
|
+
.font(Typo.mono(12))
|
|
38
|
+
.foregroundColor(isHovered ? Palette.text : Palette.textDim)
|
|
39
|
+
.lineLimit(1)
|
|
40
|
+
|
|
41
|
+
Spacer()
|
|
42
|
+
|
|
43
|
+
// Hotkey
|
|
44
|
+
if let hotkey {
|
|
45
|
+
Text(hotkey)
|
|
46
|
+
.font(Typo.mono(10))
|
|
47
|
+
.foregroundColor(Palette.textMuted)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
.padding(.horizontal, 10)
|
|
51
|
+
.padding(.vertical, 6)
|
|
52
|
+
.background(
|
|
53
|
+
RoundedRectangle(cornerRadius: 5)
|
|
54
|
+
.fill(isHovered ? Palette.surfaceHov : Color.clear)
|
|
55
|
+
)
|
|
56
|
+
.contentShape(Rectangle())
|
|
57
|
+
}
|
|
58
|
+
.buttonStyle(.plain)
|
|
59
|
+
.onHover { isHovered = $0 }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
/// Manages the NSStatusItem (menu bar icon), left-click popover, and right-click context menu.
|
|
5
|
+
/// Replaces the previous SwiftUI MenuBarExtra approach for full click-event control.
|
|
6
|
+
class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|
7
|
+
|
|
8
|
+
private var statusItem: NSStatusItem!
|
|
9
|
+
private var popover: NSPopover?
|
|
10
|
+
private var contextMenu: NSMenu!
|
|
11
|
+
|
|
12
|
+
/// 3×3 grid icon for the menu bar — L-shape bright, rest dim (template for auto light/dark)
|
|
13
|
+
private static let menuBarIcon: NSImage = {
|
|
14
|
+
let size: CGFloat = 18
|
|
15
|
+
let img = NSImage(size: NSSize(width: size, height: size), flipped: true) { _ in
|
|
16
|
+
let pad: CGFloat = 2
|
|
17
|
+
let gap: CGFloat = 1.5
|
|
18
|
+
let cellSize = (size - 2 * pad - 2 * gap) / 3
|
|
19
|
+
|
|
20
|
+
let solidCells: Set<Int> = [0, 3, 6, 7, 8]
|
|
21
|
+
|
|
22
|
+
for row in 0..<3 {
|
|
23
|
+
for col in 0..<3 {
|
|
24
|
+
let idx = row * 3 + col
|
|
25
|
+
let x = pad + CGFloat(col) * (cellSize + gap)
|
|
26
|
+
let y = pad + CGFloat(row) * (cellSize + gap)
|
|
27
|
+
let rect = NSRect(x: x, y: y, width: cellSize, height: cellSize)
|
|
28
|
+
|
|
29
|
+
if solidCells.contains(idx) {
|
|
30
|
+
NSColor.black.setFill()
|
|
31
|
+
} else {
|
|
32
|
+
NSColor.black.withAlphaComponent(0.25).setFill()
|
|
33
|
+
}
|
|
34
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: 0.8, yRadius: 0.8)
|
|
35
|
+
path.fill()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
img.isTemplate = true
|
|
41
|
+
return img
|
|
42
|
+
}()
|
|
43
|
+
|
|
44
|
+
/// Toggle between .accessory (hidden from Dock/Cmd+Tab) and .regular (visible)
|
|
45
|
+
/// based on whether any managed windows are open.
|
|
46
|
+
static func updateActivationPolicy() {
|
|
47
|
+
let hasVisibleWindow =
|
|
48
|
+
CommandModeWindow.shared.isVisible ||
|
|
49
|
+
CommandPaletteWindow.shared.isVisible ||
|
|
50
|
+
MainWindow.shared.isVisible ||
|
|
51
|
+
ScreenMapWindowController.shared.isVisible ||
|
|
52
|
+
OmniSearchWindow.shared.isVisible
|
|
53
|
+
let desired: NSApplication.ActivationPolicy = hasVisibleWindow ? .regular : .accessory
|
|
54
|
+
if NSApp.activationPolicy() != desired {
|
|
55
|
+
NSApp.setActivationPolicy(desired)
|
|
56
|
+
if desired == .regular {
|
|
57
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
63
|
+
NSApp.setActivationPolicy(.accessory)
|
|
64
|
+
NSApp.appearance = NSAppearance(named: .darkAqua)
|
|
65
|
+
|
|
66
|
+
// --- Status item ---
|
|
67
|
+
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
68
|
+
if let button = statusItem.button {
|
|
69
|
+
button.image = Self.menuBarIcon
|
|
70
|
+
button.action = #selector(statusItemClicked(_:))
|
|
71
|
+
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
|
72
|
+
button.target = self
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- Context menu (right-click) ---
|
|
76
|
+
contextMenu = buildContextMenu()
|
|
77
|
+
|
|
78
|
+
// --- Hotkey registration ---
|
|
79
|
+
let scanner = ProjectScanner.shared
|
|
80
|
+
CommandPaletteWindow.shared.configure(scanner: scanner)
|
|
81
|
+
|
|
82
|
+
let store = HotkeyStore.shared
|
|
83
|
+
store.register(action: .palette) { CommandPaletteWindow.shared.toggle() }
|
|
84
|
+
store.register(action: .screenMap) { ScreenMapWindowController.shared.toggle() }
|
|
85
|
+
store.register(action: .bezel) { WindowBezel.showBezelForFrontmostWindow() }
|
|
86
|
+
store.register(action: .cheatSheet) { CheatSheetHUD.shared.toggle() }
|
|
87
|
+
store.register(action: .desktopInventory) { CommandModeWindow.shared.toggle() }
|
|
88
|
+
store.register(action: .omniSearch) { OmniSearchWindow.shared.toggle() }
|
|
89
|
+
|
|
90
|
+
// Layer-switching hotkeys
|
|
91
|
+
let workspace = WorkspaceManager.shared
|
|
92
|
+
let layerCount = (workspace.config?.layers ?? []).count
|
|
93
|
+
for (i, action) in HotkeyAction.layerActions.prefix(layerCount).enumerated() {
|
|
94
|
+
let index = i
|
|
95
|
+
store.register(action: action) {
|
|
96
|
+
workspace.tileLayer(index: index, launch: true, force: true)
|
|
97
|
+
EventBus.shared.post(.layerSwitched(index: index))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Tiling hotkeys
|
|
102
|
+
let tileMap: [(HotkeyAction, TilePosition)] = [
|
|
103
|
+
(.tileLeft, .left), (.tileRight, .right),
|
|
104
|
+
(.tileMaximize, .maximize), (.tileCenter, .center),
|
|
105
|
+
(.tileTopLeft, .topLeft), (.tileTopRight, .topRight),
|
|
106
|
+
(.tileBottomLeft, .bottomLeft), (.tileBottomRight, .bottomRight),
|
|
107
|
+
(.tileTop, .top), (.tileBottom, .bottom),
|
|
108
|
+
(.tileLeftThird, .leftThird), (.tileCenterThird, .centerThird),
|
|
109
|
+
(.tileRightThird, .rightThird),
|
|
110
|
+
]
|
|
111
|
+
for (action, position) in tileMap {
|
|
112
|
+
store.register(action: action) { WindowTiler.tileFrontmostViaAX(to: position) }
|
|
113
|
+
}
|
|
114
|
+
store.register(action: .tileDistribute) { WindowTiler.distributeVisible() }
|
|
115
|
+
|
|
116
|
+
// Check macOS permissions (Accessibility, Screen Recording)
|
|
117
|
+
PermissionChecker.shared.check()
|
|
118
|
+
|
|
119
|
+
// Start daemon services
|
|
120
|
+
let diag = DiagnosticLog.shared
|
|
121
|
+
let tBoot = diag.startTimed("Daemon services boot")
|
|
122
|
+
OcrStore.shared.open()
|
|
123
|
+
DesktopModel.shared.start()
|
|
124
|
+
OcrModel.shared.start()
|
|
125
|
+
TmuxModel.shared.start()
|
|
126
|
+
ProcessModel.shared.start()
|
|
127
|
+
LatticesApi.setup()
|
|
128
|
+
DaemonServer.shared.start()
|
|
129
|
+
diag.finish(tBoot)
|
|
130
|
+
|
|
131
|
+
// --diagnostics flag: auto-open diagnostics panel on launch
|
|
132
|
+
if CommandLine.arguments.contains("--diagnostics") {
|
|
133
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
134
|
+
DiagnosticWindow.shared.show()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --screen-map flag: auto-open screen map on launch
|
|
139
|
+
if CommandLine.arguments.contains("--screen-map") {
|
|
140
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
141
|
+
ScreenMapWindowController.shared.show()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// MARK: - Status item click handler
|
|
147
|
+
|
|
148
|
+
@objc private func statusItemClicked(_ sender: Any?) {
|
|
149
|
+
guard let event = NSApp.currentEvent, let button = statusItem.button else { return }
|
|
150
|
+
|
|
151
|
+
if event.type == .rightMouseUp {
|
|
152
|
+
// Right-click → context menu
|
|
153
|
+
contextMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button)
|
|
154
|
+
} else {
|
|
155
|
+
// Left-click → toggle popover
|
|
156
|
+
if let shown = popover, shown.isShown {
|
|
157
|
+
shown.performClose(sender)
|
|
158
|
+
} else {
|
|
159
|
+
let p = makePopover()
|
|
160
|
+
p.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
|
161
|
+
p.contentViewController?.view.window?.makeKey()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Dismiss the popover programmatically (e.g. from the pop-out button).
|
|
167
|
+
func dismissPopover() {
|
|
168
|
+
popover?.performClose(nil)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Create a fresh popover each time so the SwiftUI view tree isn't kept alive
|
|
172
|
+
/// when the popover is closed — prevents continuous CPU usage from @Published updates.
|
|
173
|
+
private func makePopover() -> NSPopover {
|
|
174
|
+
let t = DiagnosticLog.shared.startTimed("makePopover")
|
|
175
|
+
let p = NSPopover()
|
|
176
|
+
p.contentViewController = NSHostingController(rootView: MainView(scanner: ProjectScanner.shared))
|
|
177
|
+
p.behavior = .transient
|
|
178
|
+
p.contentSize = NSSize(width: 380, height: 520)
|
|
179
|
+
p.appearance = NSAppearance(named: .darkAqua)
|
|
180
|
+
p.delegate = self
|
|
181
|
+
popover = p
|
|
182
|
+
DiagnosticLog.shared.finish(t)
|
|
183
|
+
return p
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func popoverDidClose(_ notification: Notification) {
|
|
187
|
+
// Tear down the SwiftUI view tree so observed models stop driving re-renders
|
|
188
|
+
popover?.contentViewController = nil
|
|
189
|
+
popover = nil
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// MARK: - Context menu
|
|
193
|
+
|
|
194
|
+
private func buildContextMenu() -> NSMenu {
|
|
195
|
+
let menu = NSMenu()
|
|
196
|
+
|
|
197
|
+
let actions: [(String, String, Selector)] = [
|
|
198
|
+
("Command Palette", "⌘⇧M", #selector(menuCommandPalette)),
|
|
199
|
+
("Screen Map", "", #selector(menuScreenMap)),
|
|
200
|
+
("Desktop Inventory", "", #selector(menuDesktopInventory)),
|
|
201
|
+
("Window Bezel", "", #selector(menuWindowBezel)),
|
|
202
|
+
("Cheat Sheet", "", #selector(menuCheatSheet)),
|
|
203
|
+
("Omni Search", "", #selector(menuOmniSearch)),
|
|
204
|
+
]
|
|
205
|
+
for (title, shortcut, action) in actions {
|
|
206
|
+
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
|
207
|
+
item.target = self
|
|
208
|
+
if !shortcut.isEmpty {
|
|
209
|
+
// Display-only; the actual hotkey is global
|
|
210
|
+
}
|
|
211
|
+
menu.addItem(item)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
menu.addItem(.separator())
|
|
215
|
+
|
|
216
|
+
let settings = NSMenuItem(title: "Settings…", action: #selector(menuSettings), keyEquivalent: ",")
|
|
217
|
+
settings.target = self
|
|
218
|
+
menu.addItem(settings)
|
|
219
|
+
|
|
220
|
+
let diag = NSMenuItem(title: "Diagnostics", action: #selector(menuDiagnostics), keyEquivalent: "")
|
|
221
|
+
diag.target = self
|
|
222
|
+
menu.addItem(diag)
|
|
223
|
+
|
|
224
|
+
menu.addItem(.separator())
|
|
225
|
+
|
|
226
|
+
let quit = NSMenuItem(title: "Quit Lattices", action: #selector(menuQuit), keyEquivalent: "q")
|
|
227
|
+
quit.target = self
|
|
228
|
+
menu.addItem(quit)
|
|
229
|
+
|
|
230
|
+
return menu
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@objc private func menuCommandPalette() { CommandPaletteWindow.shared.toggle() }
|
|
234
|
+
@objc private func menuScreenMap() { ScreenMapWindowController.shared.toggle() }
|
|
235
|
+
@objc private func menuDesktopInventory() { CommandModeWindow.shared.toggle() }
|
|
236
|
+
@objc private func menuWindowBezel() { WindowBezel.showBezelForFrontmostWindow() }
|
|
237
|
+
@objc private func menuCheatSheet() { CheatSheetHUD.shared.toggle() }
|
|
238
|
+
@objc private func menuOmniSearch() { OmniSearchWindow.shared.toggle() }
|
|
239
|
+
@objc private func menuSettings() { SettingsWindowController.shared.show() }
|
|
240
|
+
@objc private func menuDiagnostics() { DiagnosticWindow.shared.toggle() }
|
|
241
|
+
@objc private func menuQuit() { NSApp.terminate(nil) }
|
|
242
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
// MARK: - Navigation Pages
|
|
4
|
+
|
|
5
|
+
enum AppPage: String, CaseIterable {
|
|
6
|
+
case screenMap
|
|
7
|
+
case settings
|
|
8
|
+
case docs
|
|
9
|
+
|
|
10
|
+
var label: String {
|
|
11
|
+
switch self {
|
|
12
|
+
case .screenMap: return "Screen Map"
|
|
13
|
+
case .settings: return "Settings"
|
|
14
|
+
case .docs: return "Docs"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var icon: String {
|
|
19
|
+
switch self {
|
|
20
|
+
case .screenMap: return "rectangle.3.group"
|
|
21
|
+
case .settings: return "gearshape"
|
|
22
|
+
case .docs: return "book"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// MARK: - App Shell View
|
|
28
|
+
|
|
29
|
+
struct AppShellView: View {
|
|
30
|
+
@ObservedObject var controller: ScreenMapController
|
|
31
|
+
@ObservedObject var windowController = ScreenMapWindowController.shared
|
|
32
|
+
|
|
33
|
+
var body: some View {
|
|
34
|
+
contentArea
|
|
35
|
+
.background(Palette.bg)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// MARK: - Content Area
|
|
39
|
+
|
|
40
|
+
@ViewBuilder
|
|
41
|
+
private var contentArea: some View {
|
|
42
|
+
switch windowController.activePage {
|
|
43
|
+
case .screenMap:
|
|
44
|
+
ScreenMapView(controller: controller, onNavigate: { page in
|
|
45
|
+
windowController.activePage = page
|
|
46
|
+
})
|
|
47
|
+
case .settings:
|
|
48
|
+
SettingsContentView(
|
|
49
|
+
prefs: Preferences.shared,
|
|
50
|
+
scanner: ProjectScanner.shared,
|
|
51
|
+
onBack: { windowController.activePage = .screenMap; controller.enter() }
|
|
52
|
+
)
|
|
53
|
+
case .docs:
|
|
54
|
+
SettingsContentView(
|
|
55
|
+
page: .docs,
|
|
56
|
+
prefs: Preferences.shared,
|
|
57
|
+
scanner: ProjectScanner.shared,
|
|
58
|
+
onBack: { windowController.activePage = .screenMap; controller.enter() }
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|