@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.
Files changed (74) hide show
  1. package/README.md +155 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/AccessibilityTextExtractor.swift +111 -0
  5. package/app/Sources/ActionRow.swift +61 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +242 -0
  8. package/app/Sources/AppShellView.swift +62 -0
  9. package/app/Sources/AppTypeClassifier.swift +70 -0
  10. package/app/Sources/AppWindowShell.swift +63 -0
  11. package/app/Sources/CheatSheetHUD.swift +332 -0
  12. package/app/Sources/CommandModeState.swift +1362 -0
  13. package/app/Sources/CommandModeView.swift +1405 -0
  14. package/app/Sources/CommandModeWindow.swift +192 -0
  15. package/app/Sources/CommandPaletteView.swift +307 -0
  16. package/app/Sources/CommandPaletteWindow.swift +134 -0
  17. package/app/Sources/DaemonProtocol.swift +101 -0
  18. package/app/Sources/DaemonServer.swift +414 -0
  19. package/app/Sources/DesktopModel.swift +149 -0
  20. package/app/Sources/DesktopModelTypes.swift +71 -0
  21. package/app/Sources/DiagnosticLog.swift +271 -0
  22. package/app/Sources/EventBus.swift +30 -0
  23. package/app/Sources/HotkeyManager.swift +254 -0
  24. package/app/Sources/HotkeyStore.swift +338 -0
  25. package/app/Sources/InventoryManager.swift +35 -0
  26. package/app/Sources/InventoryPath.swift +43 -0
  27. package/app/Sources/KeyRecorderView.swift +210 -0
  28. package/app/Sources/LatticesApi.swift +1234 -0
  29. package/app/Sources/LayerBezel.swift +203 -0
  30. package/app/Sources/MainView.swift +479 -0
  31. package/app/Sources/MainWindow.swift +83 -0
  32. package/app/Sources/OcrModel.swift +430 -0
  33. package/app/Sources/OcrStore.swift +329 -0
  34. package/app/Sources/OmniSearchState.swift +283 -0
  35. package/app/Sources/OmniSearchView.swift +288 -0
  36. package/app/Sources/OmniSearchWindow.swift +105 -0
  37. package/app/Sources/OrphanRow.swift +129 -0
  38. package/app/Sources/PaletteCommand.swift +419 -0
  39. package/app/Sources/PermissionChecker.swift +125 -0
  40. package/app/Sources/Preferences.swift +99 -0
  41. package/app/Sources/ProcessModel.swift +199 -0
  42. package/app/Sources/ProcessQuery.swift +151 -0
  43. package/app/Sources/Project.swift +28 -0
  44. package/app/Sources/ProjectRow.swift +368 -0
  45. package/app/Sources/ProjectScanner.swift +128 -0
  46. package/app/Sources/ScreenMapState.swift +2387 -0
  47. package/app/Sources/ScreenMapView.swift +2820 -0
  48. package/app/Sources/ScreenMapWindowController.swift +89 -0
  49. package/app/Sources/SessionManager.swift +72 -0
  50. package/app/Sources/SettingsView.swift +1064 -0
  51. package/app/Sources/SettingsWindow.swift +20 -0
  52. package/app/Sources/TabGroupRow.swift +178 -0
  53. package/app/Sources/Terminal.swift +259 -0
  54. package/app/Sources/TerminalQuery.swift +156 -0
  55. package/app/Sources/TerminalSynthesizer.swift +200 -0
  56. package/app/Sources/Theme.swift +163 -0
  57. package/app/Sources/TilePickerView.swift +209 -0
  58. package/app/Sources/TmuxModel.swift +53 -0
  59. package/app/Sources/TmuxQuery.swift +81 -0
  60. package/app/Sources/WindowTiler.swift +1778 -0
  61. package/app/Sources/WorkspaceManager.swift +575 -0
  62. package/bin/client.js +4 -0
  63. package/bin/daemon-client.js +187 -0
  64. package/bin/lattices-app.js +221 -0
  65. package/bin/lattices.js +1551 -0
  66. package/docs/api.md +924 -0
  67. package/docs/app.md +297 -0
  68. package/docs/concepts.md +135 -0
  69. package/docs/config.md +245 -0
  70. package/docs/layers.md +410 -0
  71. package/docs/ocr.md +185 -0
  72. package/docs/overview.md +94 -0
  73. package/docs/quickstart.md +75 -0
  74. package/package.json +42 -0
@@ -0,0 +1,192 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// NSPanel subclass that accepts key events and first-click mouse events.
5
+ /// Overrides sendEvent to ensure the panel is key before processing clicks,
6
+ /// which is required for SwiftUI gesture/button handling in .nonactivatingPanel panels.
7
+ private class CommandModePanel: NSPanel {
8
+ override var canBecomeKey: Bool { true }
9
+ override var canBecomeMain: Bool { true }
10
+ override var acceptsMouseMovedEvents: Bool {
11
+ get { true }
12
+ set { super.acceptsMouseMovedEvents = newValue }
13
+ }
14
+
15
+ override func sendEvent(_ event: NSEvent) {
16
+ // Non-activating panels can silently lose key status. Re-assert key
17
+ // and app activation before every mouse-down so SwiftUI Buttons/gestures
18
+ // fire reliably — including the very first click after the panel appears.
19
+ if event.type == .leftMouseDown || event.type == .rightMouseDown {
20
+ if !NSApp.isActive {
21
+ NSApp.activate(ignoringOtherApps: true)
22
+ }
23
+ if !isKeyWindow { makeKey() }
24
+ }
25
+ super.sendEvent(event)
26
+ }
27
+ }
28
+
29
+ /// NSHostingView subclass that accepts first-click events in non-activating panels
30
+ private class FirstClickHostingView<Content: View>: NSHostingView<Content> {
31
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
32
+ override var focusRingType: NSFocusRingType { get { .none } set {} }
33
+ }
34
+
35
+ final class CommandModeWindow {
36
+ static let shared = CommandModeWindow()
37
+
38
+ private var panel: NSPanel?
39
+ private var isOpen = false
40
+
41
+ /// Exposed for event monitor filtering (only handle clicks in this window)
42
+ var panelWindow: NSWindow? { panel }
43
+
44
+ var isVisible: Bool { isOpen }
45
+
46
+ func toggle() {
47
+ if isOpen {
48
+ dismiss()
49
+ } else {
50
+ show()
51
+ }
52
+ }
53
+
54
+ func show() {
55
+ // Always rebuild for fresh state
56
+ dismiss()
57
+ isOpen = true
58
+
59
+ // Dismiss palette if visible
60
+ if CommandPaletteWindow.shared.isVisible {
61
+ CommandPaletteWindow.shared.dismiss()
62
+ }
63
+
64
+ let state = CommandModeState()
65
+ state.onDismiss = { [weak self] in
66
+ self?.dismiss()
67
+ }
68
+ state.onPanelResize = { [weak self] width, height in
69
+ self?.animateResize(width: width, height: height)
70
+ }
71
+ state.enter()
72
+
73
+ // Compute initial size from state phase
74
+ let initialWidth: CGFloat
75
+ let initialHeight: CGFloat
76
+ if state.phase == .desktopInventory {
77
+ let displayCount = max(1, state.desktopSnapshot?.displays.count ?? 1)
78
+ let columnWidth: CGFloat = 480
79
+ initialWidth = CGFloat(displayCount) * columnWidth + CGFloat(displayCount - 1) + 32
80
+ initialHeight = 640
81
+ } else {
82
+ initialWidth = 580; initialHeight = 360
83
+ }
84
+
85
+ let view = CommandModeView(state: state)
86
+ .preferredColorScheme(.dark)
87
+
88
+ let hosting = FirstClickHostingView(rootView: view)
89
+ hosting.translatesAutoresizingMaskIntoConstraints = false
90
+
91
+ let panel = CommandModePanel(
92
+ contentRect: NSRect(x: 0, y: 0, width: initialWidth, height: initialHeight),
93
+ styleMask: [.nonactivatingPanel],
94
+ backing: .buffered,
95
+ defer: false
96
+ )
97
+
98
+ panel.isOpaque = false
99
+ panel.backgroundColor = .clear
100
+ panel.hasShadow = true
101
+ panel.level = .floating
102
+ panel.isMovableByWindowBackground = true
103
+ panel.hidesOnDeactivate = true
104
+ panel.becomesKeyOnlyIfNeeded = false
105
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
106
+ panel.isReleasedWhenClosed = false
107
+
108
+ let cornerRadius: CGFloat = 14
109
+
110
+ let effectView = NSVisualEffectView()
111
+ effectView.blendingMode = .behindWindow
112
+ effectView.material = .popover
113
+ effectView.state = .active
114
+ effectView.wantsLayer = true
115
+ effectView.maskImage = Self.maskImage(cornerRadius: cornerRadius)
116
+
117
+ panel.contentView = effectView
118
+
119
+ effectView.addSubview(hosting)
120
+ NSLayoutConstraint.activate([
121
+ hosting.leadingAnchor.constraint(equalTo: effectView.leadingAnchor),
122
+ hosting.trailingAnchor.constraint(equalTo: effectView.trailingAnchor),
123
+ hosting.topAnchor.constraint(equalTo: effectView.topAnchor),
124
+ hosting.bottomAnchor.constraint(equalTo: effectView.bottomAnchor),
125
+ ])
126
+
127
+ // Center horizontally, slightly above vertical center
128
+ if let screen = NSScreen.main {
129
+ let screenFrame = screen.visibleFrame
130
+ let clampedWidth = min(initialWidth, screenFrame.width * 0.92)
131
+ let clampedHeight = min(initialHeight, screenFrame.height * 0.85)
132
+ let x = screenFrame.midX - clampedWidth / 2
133
+ let y = screenFrame.midY - clampedHeight / 2 + (screenFrame.height * 0.08)
134
+ panel.setFrameOrigin(NSPoint(x: x, y: y))
135
+ }
136
+
137
+ self.panel = panel
138
+
139
+ panel.makeKeyAndOrderFront(nil)
140
+ NSApp.activate(ignoringOtherApps: true)
141
+ AppDelegate.updateActivationPolicy()
142
+ }
143
+
144
+ private func animateResize(width: CGFloat, height: CGFloat) {
145
+ guard let panel = panel, let screen = panel.screen ?? NSScreen.main else { return }
146
+
147
+ let screenFrame = screen.visibleFrame
148
+ // Clamp to screen bounds with margin
149
+ let newWidth = min(width, screenFrame.width * 0.92)
150
+ let newHeight = min(height, screenFrame.height * 0.85)
151
+
152
+ let newX = screenFrame.midX - newWidth / 2
153
+ let newY = screenFrame.midY - newHeight / 2 + (screenFrame.height * 0.08)
154
+
155
+ let newFrame = NSRect(x: newX, y: newY, width: newWidth, height: newHeight)
156
+
157
+ NSAnimationContext.runAnimationGroup { ctx in
158
+ ctx.duration = 0.25
159
+ ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
160
+ panel.animator().setFrame(newFrame, display: true)
161
+ }
162
+ }
163
+
164
+ func dismiss() {
165
+ isOpen = false
166
+ panel?.orderOut(nil)
167
+ panel = nil
168
+ AppDelegate.updateActivationPolicy()
169
+ }
170
+
171
+ /// Stretchable mask image for rounded corners
172
+ private static func maskImage(cornerRadius: CGFloat) -> NSImage {
173
+ let edgeLength = 2.0 * cornerRadius + 1.0
174
+ let maskImage = NSImage(
175
+ size: NSSize(width: edgeLength, height: edgeLength),
176
+ flipped: false
177
+ ) { rect in
178
+ let path = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius)
179
+ NSColor.black.set()
180
+ path.fill()
181
+ return true
182
+ }
183
+ maskImage.capInsets = NSEdgeInsets(
184
+ top: cornerRadius,
185
+ left: cornerRadius,
186
+ bottom: cornerRadius,
187
+ right: cornerRadius
188
+ )
189
+ maskImage.resizingMode = .stretch
190
+ return maskImage
191
+ }
192
+ }
@@ -0,0 +1,307 @@
1
+ import SwiftUI
2
+ import AppKit
3
+
4
+ struct CommandPaletteView: View {
5
+ let commands: [PaletteCommand]
6
+ let onDismiss: () -> Void
7
+
8
+ @State private var query = ""
9
+ @State private var selectedIndex = 0
10
+ @State private var eventMonitor: Any?
11
+ @FocusState private var isSearchFocused: Bool
12
+
13
+ private var filtered: [PaletteCommand] {
14
+ if query.isEmpty { return commands }
15
+ return commands
16
+ .map { ($0, $0.matchScore(query: query)) }
17
+ .filter { $0.1 > 0 }
18
+ .sorted { $0.1 > $1.1 }
19
+ .map(\.0)
20
+ }
21
+
22
+ /// Group commands by category (only used when query is empty)
23
+ private var grouped: [(PaletteCommand.Category, [PaletteCommand])] {
24
+ let items = filtered
25
+ var result: [(PaletteCommand.Category, [PaletteCommand])] = []
26
+ for cat in PaletteCommand.Category.allCases {
27
+ let group = items.filter { $0.category == cat }
28
+ if !group.isEmpty {
29
+ result.append((cat, group))
30
+ }
31
+ }
32
+ return result
33
+ }
34
+
35
+ var body: some View {
36
+ VStack(spacing: 0) {
37
+ // Search field
38
+ searchField
39
+
40
+ Rectangle()
41
+ .fill(Palette.border)
42
+ .frame(height: 0.5)
43
+
44
+ // Results
45
+ ScrollViewReader { proxy in
46
+ ScrollView {
47
+ if query.isEmpty {
48
+ groupedList
49
+ } else {
50
+ flatList
51
+ }
52
+ }
53
+ .onChange(of: selectedIndex) { idx in
54
+ let items = filtered
55
+ if idx >= 0 && idx < items.count {
56
+ proxy.scrollTo(items[idx].id, anchor: .center)
57
+ }
58
+ }
59
+ }
60
+ .frame(minHeight: 280, maxHeight: 360)
61
+
62
+ Rectangle()
63
+ .fill(Palette.border)
64
+ .frame(height: 0.5)
65
+
66
+ // Footer hints
67
+ footer
68
+ }
69
+ .frame(width: 540)
70
+ .background(Palette.bg)
71
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
72
+ .overlay(
73
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
74
+ .strokeBorder(Palette.borderLit, lineWidth: 0.5)
75
+ )
76
+ .onAppear {
77
+ installKeyHandler()
78
+ // Delay focus slightly to ensure the panel is key
79
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
80
+ isSearchFocused = true
81
+ }
82
+ }
83
+ .onDisappear { removeKeyHandler() }
84
+ }
85
+
86
+ // MARK: - Search Field
87
+
88
+ private var searchField: some View {
89
+ HStack(spacing: 10) {
90
+ Image(systemName: "magnifyingglass")
91
+ .foregroundColor(Palette.textMuted)
92
+ .font(.system(size: 14))
93
+
94
+ TextField("Search commands...", text: $query)
95
+ .textFieldStyle(.plain)
96
+ .font(Typo.body(14))
97
+ .foregroundColor(Palette.text)
98
+ .focused($isSearchFocused)
99
+ .onChange(of: query) { _ in selectedIndex = 0 }
100
+ }
101
+ .padding(.horizontal, 16)
102
+ .padding(.vertical, 12)
103
+ }
104
+
105
+ // MARK: - Grouped List (empty query)
106
+
107
+ private var groupedList: some View {
108
+ LazyVStack(alignment: .leading, spacing: 0) {
109
+ ForEach(grouped, id: \.0) { category, items in
110
+ sectionHeader(category)
111
+ ForEach(items) { cmd in
112
+ let idx = flatIndex(of: cmd)
113
+ commandRow(cmd, isSelected: idx == selectedIndex)
114
+ .id(cmd.id)
115
+ .onTapGesture { executeCommand(cmd) }
116
+ }
117
+ }
118
+ }
119
+ .padding(.vertical, 6)
120
+ }
121
+
122
+ // MARK: - Flat List (with query)
123
+
124
+ private var flatList: some View {
125
+ LazyVStack(alignment: .leading, spacing: 0) {
126
+ ForEach(Array(filtered.enumerated()), id: \.element.id) { idx, cmd in
127
+ commandRow(cmd, isSelected: idx == selectedIndex)
128
+ .id(cmd.id)
129
+ .onTapGesture { executeCommand(cmd) }
130
+ }
131
+ }
132
+ .padding(.vertical, 6)
133
+ }
134
+
135
+ // MARK: - Section Header
136
+
137
+ private func sectionHeader(_ category: PaletteCommand.Category) -> some View {
138
+ HStack(spacing: 5) {
139
+ Image(systemName: category.icon)
140
+ .font(.system(size: 9))
141
+ Text(category.rawValue.uppercased())
142
+ .font(Typo.mono(9))
143
+ }
144
+ .foregroundColor(Palette.textMuted)
145
+ .padding(.horizontal, 16)
146
+ .padding(.top, 10)
147
+ .padding(.bottom, 4)
148
+ }
149
+
150
+ // MARK: - Command Row
151
+
152
+ private func commandRow(_ cmd: PaletteCommand, isSelected: Bool) -> some View {
153
+ HStack(spacing: 10) {
154
+ Image(systemName: cmd.icon)
155
+ .font(.system(size: 12))
156
+ .foregroundColor(isSelected ? Palette.text : Palette.textDim)
157
+ .frame(width: 20)
158
+
159
+ VStack(alignment: .leading, spacing: 1) {
160
+ HStack(spacing: 6) {
161
+ Text(cmd.title)
162
+ .font(Typo.body(13))
163
+ .foregroundColor(isSelected ? Palette.text : Palette.text.opacity(0.85))
164
+ .lineLimit(1)
165
+
166
+ if let badge = cmd.badge {
167
+ Text(badge)
168
+ .font(Typo.mono(9))
169
+ .foregroundColor(Palette.running)
170
+ .padding(.horizontal, 5)
171
+ .padding(.vertical, 1)
172
+ .background(
173
+ RoundedRectangle(cornerRadius: 3)
174
+ .fill(Palette.running.opacity(0.12))
175
+ )
176
+ }
177
+ }
178
+
179
+ Text(cmd.subtitle)
180
+ .font(Typo.caption(10))
181
+ .foregroundColor(Palette.textMuted)
182
+ .lineLimit(1)
183
+ }
184
+
185
+ Spacer()
186
+ }
187
+ .padding(.horizontal, 14)
188
+ .padding(.vertical, 7)
189
+ .background(
190
+ RoundedRectangle(cornerRadius: 5)
191
+ .fill(isSelected ? Palette.surface : Color.clear)
192
+ )
193
+ .padding(.horizontal, 6)
194
+ .contentShape(Rectangle())
195
+ }
196
+
197
+ // MARK: - Footer
198
+
199
+ private var footer: some View {
200
+ HStack(spacing: 14) {
201
+ footerHint(keys: ["\u{2191}\u{2193}"], label: "navigate")
202
+ footerHint(keys: ["\u{21A9}"], label: "select")
203
+ footerHint(keys: ["esc"], label: "close")
204
+ Spacer()
205
+ Text("\(filtered.count) command\(filtered.count == 1 ? "" : "s")")
206
+ .font(Typo.mono(9))
207
+ .foregroundColor(Palette.textMuted)
208
+ }
209
+ .padding(.horizontal, 16)
210
+ .padding(.vertical, 8)
211
+ .background(Palette.surface.opacity(0.4))
212
+ }
213
+
214
+ private func footerHint(keys: [String], label: String) -> some View {
215
+ HStack(spacing: 4) {
216
+ ForEach(keys, id: \.self) { key in
217
+ Text(key)
218
+ .font(Typo.mono(9))
219
+ .foregroundColor(Palette.text)
220
+ .padding(.horizontal, 4)
221
+ .padding(.vertical, 2)
222
+ .background(
223
+ RoundedRectangle(cornerRadius: 3)
224
+ .fill(Palette.surface)
225
+ .overlay(
226
+ RoundedRectangle(cornerRadius: 3)
227
+ .strokeBorder(Palette.border, lineWidth: 0.5)
228
+ )
229
+ )
230
+ }
231
+ Text(label)
232
+ .font(Typo.mono(9))
233
+ .foregroundColor(Palette.textMuted)
234
+ }
235
+ }
236
+
237
+ // MARK: - Keyboard Navigation
238
+
239
+ private func installKeyHandler() {
240
+ eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
241
+ let isSearchActive = isSearchFocused
242
+
243
+ switch Int(event.keyCode) {
244
+ case 125: // Down arrow
245
+ moveDown()
246
+ return nil
247
+ case 126: // Up arrow
248
+ moveUp()
249
+ return nil
250
+ case 38 where !isSearchActive: // j (vim down) — only when not typing
251
+ moveDown()
252
+ return nil
253
+ case 40 where !isSearchActive: // k (vim up) — only when not typing
254
+ moveUp()
255
+ return nil
256
+ case 36: // Return
257
+ let items = filtered
258
+ if selectedIndex >= 0 && selectedIndex < items.count {
259
+ executeCommand(items[selectedIndex])
260
+ }
261
+ return nil
262
+ case 53: // Escape
263
+ onDismiss()
264
+ return nil
265
+ default:
266
+ return event
267
+ }
268
+ }
269
+ }
270
+
271
+ private func moveDown() {
272
+ let count = filtered.count
273
+ if count > 0 {
274
+ selectedIndex = min(selectedIndex + 1, count - 1)
275
+ }
276
+ }
277
+
278
+ private func moveUp() {
279
+ selectedIndex = max(selectedIndex - 1, 0)
280
+ }
281
+
282
+ private func removeKeyHandler() {
283
+ if let monitor = eventMonitor {
284
+ NSEvent.removeMonitor(monitor)
285
+ eventMonitor = nil
286
+ }
287
+ }
288
+
289
+ // MARK: - Execution
290
+
291
+ private func executeCommand(_ cmd: PaletteCommand) {
292
+ let action = cmd.action
293
+ onDismiss()
294
+ // Small delay to let the palette dismiss before executing
295
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
296
+ action()
297
+ }
298
+ }
299
+
300
+ // MARK: - Helpers
301
+
302
+ /// Get flat index of a command across all groups (for selection tracking)
303
+ private func flatIndex(of cmd: PaletteCommand) -> Int {
304
+ let items = filtered
305
+ return items.firstIndex(where: { $0.id == cmd.id }) ?? -1
306
+ }
307
+ }
@@ -0,0 +1,134 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// NSPanel subclass that accepts key events even without a titlebar
5
+ private class KeyablePanel: NSPanel {
6
+ override var canBecomeKey: Bool { true }
7
+ override var canBecomeMain: Bool { true }
8
+ }
9
+
10
+ final class CommandPaletteWindow {
11
+ static let shared = CommandPaletteWindow()
12
+
13
+ private var panel: NSPanel?
14
+ private var scanner: ProjectScanner?
15
+
16
+ func configure(scanner: ProjectScanner) {
17
+ self.scanner = scanner
18
+ }
19
+
20
+ var isVisible: Bool { panel?.isVisible ?? false }
21
+
22
+ func toggle() {
23
+ if let p = panel, p.isVisible {
24
+ dismiss()
25
+ } else {
26
+ show()
27
+ }
28
+ }
29
+
30
+ func show() {
31
+ // Always rebuild for fresh command state
32
+ dismiss()
33
+
34
+ guard let scanner = scanner else { return }
35
+
36
+ // Ensure projects are up to date (full scan if list is empty,
37
+ // e.g. palette opened via hotkey before main popover appeared)
38
+ if scanner.projects.isEmpty {
39
+ scanner.scan()
40
+ } else {
41
+ scanner.refreshStatus()
42
+ }
43
+
44
+ let commands = CommandBuilder.build(scanner: scanner)
45
+ let view = CommandPaletteView(commands: commands) { [weak self] in
46
+ self?.dismiss()
47
+ }
48
+ .preferredColorScheme(.dark)
49
+
50
+ let hosting = NSHostingView(rootView: view)
51
+ hosting.translatesAutoresizingMaskIntoConstraints = false
52
+
53
+ let panel = KeyablePanel(
54
+ contentRect: NSRect(x: 0, y: 0, width: 540, height: 440),
55
+ styleMask: [.nonactivatingPanel],
56
+ backing: .buffered,
57
+ defer: false
58
+ )
59
+
60
+ panel.isOpaque = false
61
+ panel.backgroundColor = .clear
62
+ panel.hasShadow = true
63
+ panel.level = .floating
64
+ panel.isMovableByWindowBackground = true
65
+ panel.hidesOnDeactivate = true
66
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
67
+ panel.isReleasedWhenClosed = false
68
+
69
+ // Use NSVisualEffectView as contentView with a maskImage to communicate
70
+ // the rounded shape to the window server (layer.cornerRadius only clips
71
+ // at the view level — the window backing store stays rectangular)
72
+ let cornerRadius: CGFloat = 14
73
+
74
+ let effectView = NSVisualEffectView()
75
+ effectView.blendingMode = .behindWindow
76
+ effectView.material = .popover
77
+ effectView.state = .active
78
+ effectView.wantsLayer = true
79
+ effectView.maskImage = Self.maskImage(cornerRadius: cornerRadius)
80
+
81
+ panel.contentView = effectView
82
+
83
+ effectView.addSubview(hosting)
84
+ NSLayoutConstraint.activate([
85
+ hosting.leadingAnchor.constraint(equalTo: effectView.leadingAnchor),
86
+ hosting.trailingAnchor.constraint(equalTo: effectView.trailingAnchor),
87
+ hosting.topAnchor.constraint(equalTo: effectView.topAnchor),
88
+ hosting.bottomAnchor.constraint(equalTo: effectView.bottomAnchor),
89
+ ])
90
+
91
+ // Center horizontally, slightly above vertical center (Spotlight-style)
92
+ if let screen = NSScreen.main {
93
+ let screenFrame = screen.visibleFrame
94
+ let x = screenFrame.midX - 270
95
+ let y = screenFrame.midY - 220 + (screenFrame.height * 0.1)
96
+ panel.setFrameOrigin(NSPoint(x: x, y: y))
97
+ }
98
+
99
+ panel.makeKeyAndOrderFront(nil)
100
+ NSApp.activate(ignoringOtherApps: true)
101
+
102
+ self.panel = panel
103
+ AppDelegate.updateActivationPolicy()
104
+ }
105
+
106
+ func dismiss() {
107
+ panel?.orderOut(nil)
108
+ panel = nil
109
+ AppDelegate.updateActivationPolicy()
110
+ }
111
+
112
+ /// Stretchable mask image for rounded corners — capInsets preserve the
113
+ /// corner arcs while the center stretches to any window size
114
+ private static func maskImage(cornerRadius: CGFloat) -> NSImage {
115
+ let edgeLength = 2.0 * cornerRadius + 1.0
116
+ let maskImage = NSImage(
117
+ size: NSSize(width: edgeLength, height: edgeLength),
118
+ flipped: false
119
+ ) { rect in
120
+ let path = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius)
121
+ NSColor.black.set()
122
+ path.fill()
123
+ return true
124
+ }
125
+ maskImage.capInsets = NSEdgeInsets(
126
+ top: cornerRadius,
127
+ left: cornerRadius,
128
+ bottom: cornerRadius,
129
+ right: cornerRadius
130
+ )
131
+ maskImage.resizingMode = .stretch
132
+ return maskImage
133
+ }
134
+ }