@lattices/cli 0.3.0 → 0.4.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 +85 -9
- package/app/Package.swift +8 -1
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +44 -12
- package/app/Sources/AppShellView.swift +81 -8
- package/app/Sources/AudioProvider.swift +386 -0
- package/app/Sources/CheatSheetHUD.swift +261 -19
- package/app/Sources/DaemonProtocol.swift +13 -0
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DesktopModel.swift +164 -5
- package/app/Sources/DesktopModelTypes.swift +2 -0
- package/app/Sources/DiagnosticLog.swift +104 -2
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HUDBottomBar.swift +279 -0
- package/app/Sources/HUDController.swift +1158 -0
- package/app/Sources/HUDLeftBar.swift +849 -0
- package/app/Sources/HUDMinimap.swift +179 -0
- package/app/Sources/HUDRightBar.swift +774 -0
- package/app/Sources/HUDState.swift +367 -0
- package/app/Sources/HUDTopBar.swift +243 -0
- package/app/Sources/HandsOffSession.swift +733 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +45 -9
- package/app/Sources/IntentEngine.swift +925 -0
- package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
- package/app/Sources/Intents/DistributeIntent.swift +56 -0
- package/app/Sources/Intents/FocusIntent.swift +69 -0
- package/app/Sources/Intents/HelpIntent.swift +41 -0
- package/app/Sources/Intents/KillIntent.swift +47 -0
- package/app/Sources/Intents/LatticeIntent.swift +78 -0
- package/app/Sources/Intents/LaunchIntent.swift +67 -0
- package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
- package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
- package/app/Sources/Intents/ScanIntent.swift +52 -0
- package/app/Sources/Intents/SearchIntent.swift +190 -0
- package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
- package/app/Sources/Intents/TileIntent.swift +61 -0
- package/app/Sources/LatticesApi.swift +1235 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/OcrModel.swift +34 -1
- package/app/Sources/OmniSearchState.swift +99 -102
- package/app/Sources/OnboardingView.swift +457 -0
- package/app/Sources/PermissionChecker.swift +2 -12
- package/app/Sources/PiChatDock.swift +454 -0
- package/app/Sources/PiChatSession.swift +815 -0
- package/app/Sources/PiWorkspaceView.swift +364 -0
- package/app/Sources/PlacementSpec.swift +195 -0
- package/app/Sources/Preferences.swift +59 -0
- package/app/Sources/ProjectScanner.swift +1 -1
- package/app/Sources/ScreenMapState.swift +701 -55
- package/app/Sources/ScreenMapView.swift +843 -103
- package/app/Sources/ScreenMapWindowController.swift +22 -0
- package/app/Sources/SessionLayerStore.swift +285 -0
- package/app/Sources/SessionManager.swift +4 -1
- package/app/Sources/SettingsView.swift +186 -3
- package/app/Sources/Theme.swift +9 -8
- package/app/Sources/TmuxModel.swift +7 -0
- package/app/Sources/TmuxQuery.swift +27 -3
- package/app/Sources/VoiceChatView.swift +192 -0
- package/app/Sources/VoiceCommandWindow.swift +1594 -0
- package/app/Sources/VoiceIntentResolver.swift +671 -0
- package/app/Sources/VoxClient.swift +454 -0
- package/app/Sources/WindowTiler.swift +348 -87
- package/app/Sources/WorkspaceManager.swift +127 -18
- package/bin/client.ts +16 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +280 -0
- package/bin/handsoff-worker.ts +731 -0
- package/bin/{lattices-app.js → lattices-app.ts} +67 -32
- package/bin/lattices-dev +160 -0
- package/bin/{lattices.js → lattices.ts} +600 -137
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agents.md +142 -0
- package/docs/api.md +153 -34
- package/docs/app.md +29 -1
- package/docs/config.md +5 -1
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/layers.md +20 -20
- package/docs/ocr.md +14 -5
- package/docs/overview.md +5 -1
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +374 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/tiling-reference.md +167 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice.md +219 -0
- package/package.json +21 -10
- package/bin/client.js +0 -4
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Combine
|
|
3
|
+
import SwiftUI
|
|
4
|
+
|
|
5
|
+
// MARK: - KeyableHUDPanel
|
|
6
|
+
|
|
7
|
+
private class KeyableHUDPanel: NSPanel {
|
|
8
|
+
override var canBecomeKey: Bool { true }
|
|
9
|
+
|
|
10
|
+
/// Suppress NSBeep — our local event monitor handles all keys
|
|
11
|
+
override func keyDown(with event: NSEvent) {
|
|
12
|
+
// Don't call super — that's what triggers the system bonk sound
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Allow performKeyEquivalent to pass through for event monitor
|
|
16
|
+
override func performKeyEquivalent(with event: NSEvent) -> Bool { false }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// MARK: - HUDController (singleton, cockpit-style HUD)
|
|
20
|
+
//
|
|
21
|
+
// Speed strategy:
|
|
22
|
+
// 1. Panels are pre-built at launch with content already rendered
|
|
23
|
+
// 2. Panels stay ordered (never orderOut on dismiss — just alpha=0)
|
|
24
|
+
// 3. Show = synchronous alphaValue=1 + makeKey (zero animation, instant paint)
|
|
25
|
+
// 4. Data refresh happens AFTER first paint
|
|
26
|
+
// 5. Dismiss = short slide-out animation (non-blocking, delightful)
|
|
27
|
+
|
|
28
|
+
final class HUDController {
|
|
29
|
+
static let shared = HUDController()
|
|
30
|
+
|
|
31
|
+
private var topPanel: NSPanel?
|
|
32
|
+
private var bottomPanel: NSPanel?
|
|
33
|
+
private var leftPanel: NSPanel?
|
|
34
|
+
private var rightPanel: NSPanel?
|
|
35
|
+
private var previewPanel: NSPanel?
|
|
36
|
+
private var minimapPanels: [NSPanel] = []
|
|
37
|
+
private var keyMonitor: Any?
|
|
38
|
+
private var clickMonitor: Any?
|
|
39
|
+
private var minimapObserver: AnyCancellable?
|
|
40
|
+
private var sidebarWidthObserver: AnyCancellable?
|
|
41
|
+
private var selectionObserver: AnyCancellable?
|
|
42
|
+
private var previewObserver: AnyCancellable?
|
|
43
|
+
private var previewImageObserver: AnyCancellable?
|
|
44
|
+
private let state = HUDState()
|
|
45
|
+
private let previewModel = WindowPreviewStore.shared
|
|
46
|
+
|
|
47
|
+
private let topHeight: CGFloat = 44
|
|
48
|
+
private let bottomHeight: CGFloat = 48
|
|
49
|
+
private let rightWidth: CGFloat = 400
|
|
50
|
+
private let previewWidth: CGFloat = 380
|
|
51
|
+
private let previewHeight: CGFloat = 240
|
|
52
|
+
private let previewGap: CGFloat = 14
|
|
53
|
+
private let expandedMapWidth: CGFloat = 380
|
|
54
|
+
private let expandedMapHeight: CGFloat = 240
|
|
55
|
+
|
|
56
|
+
private var leftWidth: CGFloat { state.leftSidebarWidth }
|
|
57
|
+
|
|
58
|
+
/// Track which screen panels are positioned on (for multi-monitor repositioning)
|
|
59
|
+
private var positionedScreen: NSScreen?
|
|
60
|
+
private var previewSettledItemID: String?
|
|
61
|
+
private var previewSettledAnchorScreenY: CGFloat?
|
|
62
|
+
|
|
63
|
+
var isVisible: Bool { leftPanel?.alphaValue ?? 0 > 0.5 }
|
|
64
|
+
private(set) var voiceBarVisible: Bool = false
|
|
65
|
+
private var voiceBarObserver: AnyCancellable?
|
|
66
|
+
|
|
67
|
+
func toggle() {
|
|
68
|
+
if isVisible { dismiss() } else { show() }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// MARK: - Voice bar (top panel only, for HandsOff mode)
|
|
72
|
+
|
|
73
|
+
private var voiceBarKeyMonitor: Any?
|
|
74
|
+
|
|
75
|
+
func showVoiceBar() {
|
|
76
|
+
guard !isVisible else { return } // full HUD is showing, no need
|
|
77
|
+
ensurePanels()
|
|
78
|
+
|
|
79
|
+
state.voiceActive = true
|
|
80
|
+
|
|
81
|
+
let screen = mouseScreen()
|
|
82
|
+
if positionedScreen != screen { positionAllPanels(on: screen) }
|
|
83
|
+
|
|
84
|
+
// Show only top + bottom bars
|
|
85
|
+
topPanel?.alphaValue = 1
|
|
86
|
+
topPanel?.orderFront(nil)
|
|
87
|
+
bottomPanel?.alphaValue = 1
|
|
88
|
+
bottomPanel?.orderFront(nil)
|
|
89
|
+
voiceBarVisible = true
|
|
90
|
+
|
|
91
|
+
// Escape key dismisses the voice bar
|
|
92
|
+
if voiceBarKeyMonitor == nil {
|
|
93
|
+
voiceBarKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
94
|
+
if event.keyCode == 53 { // Escape
|
|
95
|
+
DispatchQueue.main.async { self?.hideVoiceBar() }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Auto-hide 3s after HandsOff goes idle (turn complete)
|
|
101
|
+
voiceBarObserver = HandsOffSession.shared.$state.sink { [weak self] hsState in
|
|
102
|
+
if hsState == .idle {
|
|
103
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
|
104
|
+
guard let self, self.voiceBarVisible,
|
|
105
|
+
HandsOffSession.shared.state == .idle else { return }
|
|
106
|
+
self.hideVoiceBar()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func hideVoiceBar() {
|
|
113
|
+
guard voiceBarVisible else { return }
|
|
114
|
+
voiceBarObserver = nil
|
|
115
|
+
if let m = voiceBarKeyMonitor { NSEvent.removeMonitor(m); voiceBarKeyMonitor = nil }
|
|
116
|
+
state.voiceActive = false
|
|
117
|
+
voiceBarVisible = false
|
|
118
|
+
|
|
119
|
+
NSAnimationContext.runAnimationGroup({ ctx in
|
|
120
|
+
ctx.duration = 0.15
|
|
121
|
+
topPanel?.animator().alphaValue = 0
|
|
122
|
+
bottomPanel?.animator().alphaValue = 0
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// MARK: - Warm up (call at launch)
|
|
127
|
+
|
|
128
|
+
func warmUp() {
|
|
129
|
+
ensurePanels()
|
|
130
|
+
|
|
131
|
+
let screen = NSScreen.main ?? NSScreen.screens.first!
|
|
132
|
+
positionAllPanels(on: screen)
|
|
133
|
+
|
|
134
|
+
// Order into window server at alpha 0 — instant show later
|
|
135
|
+
for p in allPanels {
|
|
136
|
+
p.orderFrontRegardless()
|
|
137
|
+
p.alphaValue = 0
|
|
138
|
+
p.ignoresMouseEvents = true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// MARK: - Show (instant first paint)
|
|
143
|
+
|
|
144
|
+
func show() {
|
|
145
|
+
ensurePanels()
|
|
146
|
+
|
|
147
|
+
state.query = ""
|
|
148
|
+
state.selectedIndex = 0
|
|
149
|
+
state.selectedItem = nil
|
|
150
|
+
state.pinnedItem = nil
|
|
151
|
+
state.hoveredPreviewItem = nil
|
|
152
|
+
state.hoverPreviewAnchorScreenY = nil
|
|
153
|
+
state.previewInteractionActive = false
|
|
154
|
+
state.selectedItems = []
|
|
155
|
+
state.focus = .search
|
|
156
|
+
previewSettledItemID = nil
|
|
157
|
+
previewSettledAnchorScreenY = nil
|
|
158
|
+
state.resetSectionDefaults(hasRunningProjects: ProjectScanner.shared.projects.contains(where: \.isRunning))
|
|
159
|
+
|
|
160
|
+
let screen = mouseScreen()
|
|
161
|
+
if positionedScreen != screen { positionAllPanels(on: screen) }
|
|
162
|
+
|
|
163
|
+
// Pre-compute tile grid BEFORE showing panels (captures real z-order)
|
|
164
|
+
DesktopModel.shared.poll()
|
|
165
|
+
precomputeTileGrid(on: screen)
|
|
166
|
+
prewarmLikelyPreviews()
|
|
167
|
+
|
|
168
|
+
// ── INSTANT SHOW ── alphaValue flip, zero animation
|
|
169
|
+
let isExpanded = state.minimapMode == .expanded
|
|
170
|
+
topPanel?.alphaValue = 1
|
|
171
|
+
topPanel?.ignoresMouseEvents = false
|
|
172
|
+
bottomPanel?.alphaValue = 1
|
|
173
|
+
bottomPanel?.ignoresMouseEvents = false
|
|
174
|
+
leftPanel?.alphaValue = 1
|
|
175
|
+
leftPanel?.ignoresMouseEvents = false
|
|
176
|
+
updateRightPanelVisibility(animated: false)
|
|
177
|
+
updatePreviewPanelVisibility(animated: false)
|
|
178
|
+
if isExpanded {
|
|
179
|
+
for panel in minimapPanels {
|
|
180
|
+
panel.alphaValue = 1
|
|
181
|
+
panel.ignoresMouseEvents = false
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
for panel in minimapPanels {
|
|
185
|
+
panel.ignoresMouseEvents = true
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
leftPanel?.makeKey()
|
|
189
|
+
|
|
190
|
+
installMonitors()
|
|
191
|
+
|
|
192
|
+
DispatchQueue.main.async { ProjectScanner.shared.scan() }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// MARK: - Dismiss (animated, delightful)
|
|
196
|
+
|
|
197
|
+
func dismiss() {
|
|
198
|
+
guard isVisible else { return }
|
|
199
|
+
removeMonitors()
|
|
200
|
+
|
|
201
|
+
// Restore untiled windows only if user actually tiled something
|
|
202
|
+
if !state.tiledWindows.isEmpty {
|
|
203
|
+
restoreUntiled()
|
|
204
|
+
} else {
|
|
205
|
+
// Just clean up tile state without moving anything
|
|
206
|
+
state.tileSnapshot = []
|
|
207
|
+
state.tileMode = false
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if state.voiceActive {
|
|
211
|
+
if HandsOffSession.shared.state == .listening { HandsOffSession.shared.toggle() }
|
|
212
|
+
state.voiceActive = false
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let sf = (positionedScreen ?? mouseScreen()).visibleFrame
|
|
216
|
+
|
|
217
|
+
NSAnimationContext.runAnimationGroup({ [weak self] ctx in
|
|
218
|
+
guard let self else { return }
|
|
219
|
+
ctx.duration = 0.12
|
|
220
|
+
ctx.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
|
221
|
+
let sideHeight = max(0, sf.height - topHeight - bottomHeight)
|
|
222
|
+
|
|
223
|
+
topPanel?.animator().setFrame(
|
|
224
|
+
NSRect(x: sf.minX, y: sf.maxY,
|
|
225
|
+
width: sf.width, height: topHeight), display: false)
|
|
226
|
+
bottomPanel?.animator().setFrame(
|
|
227
|
+
NSRect(x: sf.minX, y: sf.minY - bottomHeight,
|
|
228
|
+
width: sf.width, height: bottomHeight), display: false)
|
|
229
|
+
leftPanel?.animator().setFrame(
|
|
230
|
+
NSRect(x: sf.minX - leftWidth * 0.3, y: sf.minY + bottomHeight, width: leftWidth, height: sideHeight), display: false)
|
|
231
|
+
rightPanel?.animator().setFrame(
|
|
232
|
+
NSRect(x: sf.maxX + rightWidth * 0.3 - rightWidth, y: sf.minY + bottomHeight, width: rightWidth, height: sideHeight), display: false)
|
|
233
|
+
for p in allPanels { p.animator().alphaValue = 0 }
|
|
234
|
+
}) { [weak self] in
|
|
235
|
+
guard let self, let screen = self.positionedScreen else { return }
|
|
236
|
+
self.positionAllPanels(on: screen)
|
|
237
|
+
for panel in self.allPanels {
|
|
238
|
+
panel.ignoresMouseEvents = true
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// MARK: - Position panels on screen
|
|
244
|
+
|
|
245
|
+
private var allPanels: [NSPanel] {
|
|
246
|
+
[topPanel, bottomPanel, leftPanel, rightPanel, previewPanel].compactMap { $0 } + minimapPanels
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private func positionAllPanels(on screen: NSScreen) {
|
|
250
|
+
let sf = screen.visibleFrame
|
|
251
|
+
let sideHeight = max(0, sf.height - topHeight - bottomHeight)
|
|
252
|
+
|
|
253
|
+
topPanel?.setFrame(NSRect(x: sf.minX, y: sf.maxY - topHeight,
|
|
254
|
+
width: sf.width, height: topHeight), display: false)
|
|
255
|
+
bottomPanel?.setFrame(NSRect(x: sf.minX, y: sf.minY,
|
|
256
|
+
width: sf.width, height: bottomHeight), display: false)
|
|
257
|
+
leftPanel?.setFrame(NSRect(x: sf.minX, y: sf.minY + bottomHeight,
|
|
258
|
+
width: leftWidth, height: sideHeight), display: false)
|
|
259
|
+
rightPanel?.setFrame(NSRect(x: sf.maxX - rightWidth, y: sf.minY + bottomHeight,
|
|
260
|
+
width: rightWidth, height: sideHeight), display: false)
|
|
261
|
+
if let previewPanel,
|
|
262
|
+
let frame = previewFrame(on: screen, itemID: previewSettledItemID ?? state.transientPreviewItem?.id) {
|
|
263
|
+
previewPanel.setFrame(frame, display: false)
|
|
264
|
+
}
|
|
265
|
+
positionMinimapPanels()
|
|
266
|
+
positionedScreen = screen
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private func buildMinimapPanels(dismiss: @escaping () -> Void) {
|
|
270
|
+
minimapPanels.forEach { $0.orderOut(nil) }
|
|
271
|
+
minimapPanels.removeAll()
|
|
272
|
+
|
|
273
|
+
for i in 0..<NSScreen.screens.count {
|
|
274
|
+
let mp = makePanel()
|
|
275
|
+
let hosting = NSHostingView(rootView:
|
|
276
|
+
HUDMinimap(state: state, onDismiss: dismiss, screenIndex: i).preferredColorScheme(.dark))
|
|
277
|
+
hosting.sizingOptions = []
|
|
278
|
+
mp.contentView = hosting
|
|
279
|
+
mp.alphaValue = 0
|
|
280
|
+
minimapPanels.append(mp)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private func positionMinimapPanels() {
|
|
285
|
+
let screens = NSScreen.screens
|
|
286
|
+
let hudScreen = positionedScreen ?? screens.first!
|
|
287
|
+
|
|
288
|
+
for (i, mp) in minimapPanels.enumerated() {
|
|
289
|
+
guard i < screens.count else { continue }
|
|
290
|
+
let screen = screens[i]
|
|
291
|
+
let sf = screen.visibleFrame
|
|
292
|
+
|
|
293
|
+
if screen == hudScreen {
|
|
294
|
+
// On HUD screen: attach to left bar + bottom bar corner
|
|
295
|
+
mp.setFrame(NSRect(
|
|
296
|
+
x: sf.minX + leftWidth,
|
|
297
|
+
y: sf.minY + bottomHeight,
|
|
298
|
+
width: expandedMapWidth,
|
|
299
|
+
height: expandedMapHeight
|
|
300
|
+
), display: false)
|
|
301
|
+
} else {
|
|
302
|
+
// On other screens: bottom-left corner
|
|
303
|
+
mp.setFrame(NSRect(
|
|
304
|
+
x: sf.minX + 12,
|
|
305
|
+
y: sf.minY + 12,
|
|
306
|
+
width: expandedMapWidth,
|
|
307
|
+
height: expandedMapHeight
|
|
308
|
+
), display: false)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// MARK: - Build panels (once)
|
|
314
|
+
|
|
315
|
+
private func ensurePanels() -> Void {
|
|
316
|
+
guard topPanel == nil else { return }
|
|
317
|
+
let dismiss: () -> Void = { [weak self] in self?.dismiss() }
|
|
318
|
+
|
|
319
|
+
let tp = makePanel()
|
|
320
|
+
let tpHosting = NSHostingView(rootView:
|
|
321
|
+
HUDTopBar(state: state, onDismiss: dismiss).preferredColorScheme(.dark))
|
|
322
|
+
tpHosting.sizingOptions = []
|
|
323
|
+
tp.contentView = tpHosting
|
|
324
|
+
|
|
325
|
+
let bp = makePanel()
|
|
326
|
+
let bpHosting = NSHostingView(rootView:
|
|
327
|
+
HUDBottomBar(state: state, onDismiss: dismiss).preferredColorScheme(.dark))
|
|
328
|
+
bpHosting.sizingOptions = []
|
|
329
|
+
bp.contentView = bpHosting
|
|
330
|
+
|
|
331
|
+
let lp = makePanel(keyable: true)
|
|
332
|
+
let lpHosting = NSHostingView(rootView:
|
|
333
|
+
HUDLeftBar(state: state, onDismiss: dismiss).preferredColorScheme(.dark))
|
|
334
|
+
lpHosting.sizingOptions = []
|
|
335
|
+
lp.contentView = lpHosting
|
|
336
|
+
|
|
337
|
+
let rp = makePanel()
|
|
338
|
+
let rpHosting = NSHostingView(rootView:
|
|
339
|
+
HUDRightBar(state: state, onDismiss: dismiss).preferredColorScheme(.dark))
|
|
340
|
+
rpHosting.sizingOptions = []
|
|
341
|
+
rp.contentView = rpHosting
|
|
342
|
+
|
|
343
|
+
let pp = makePanel()
|
|
344
|
+
pp.hasShadow = true
|
|
345
|
+
pp.contentMinSize = NSSize(width: previewWidth, height: previewHeight)
|
|
346
|
+
pp.contentMaxSize = NSSize(width: previewWidth, height: previewHeight)
|
|
347
|
+
let ppHosting = NSHostingView(rootView:
|
|
348
|
+
HUDHoverPreviewView(state: state)
|
|
349
|
+
.frame(width: previewWidth, height: previewHeight)
|
|
350
|
+
.preferredColorScheme(.dark))
|
|
351
|
+
ppHosting.sizingOptions = []
|
|
352
|
+
pp.contentView = ppHosting
|
|
353
|
+
|
|
354
|
+
self.topPanel = tp
|
|
355
|
+
self.bottomPanel = bp
|
|
356
|
+
self.leftPanel = lp
|
|
357
|
+
self.rightPanel = rp
|
|
358
|
+
self.previewPanel = pp
|
|
359
|
+
|
|
360
|
+
// Create one minimap panel per screen
|
|
361
|
+
buildMinimapPanels(dismiss: dismiss)
|
|
362
|
+
|
|
363
|
+
// Observe minimap mode changes to show/hide expanded panels
|
|
364
|
+
minimapObserver = state.$minimapMode.sink { [weak self] mode in
|
|
365
|
+
guard let self else { return }
|
|
366
|
+
if mode == .expanded {
|
|
367
|
+
self.positionMinimapPanels()
|
|
368
|
+
for mp in self.minimapPanels {
|
|
369
|
+
mp.alphaValue = 1
|
|
370
|
+
mp.orderFront(nil)
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
for mp in self.minimapPanels { mp.alphaValue = 0 }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
sidebarWidthObserver = state.$leftSidebarWidth
|
|
378
|
+
.removeDuplicates()
|
|
379
|
+
.sink { [weak self] _ in
|
|
380
|
+
guard let self, let screen = self.positionedScreen ?? NSScreen.main ?? NSScreen.screens.first else { return }
|
|
381
|
+
self.positionAllPanels(on: screen)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
selectionObserver = state.$pinnedItem
|
|
385
|
+
.removeDuplicates()
|
|
386
|
+
.sink { [weak self] _ in
|
|
387
|
+
self?.updateRightPanelVisibility(animated: true)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
previewObserver = Publishers.CombineLatest4(
|
|
391
|
+
state.$hoveredPreviewItem
|
|
392
|
+
.map { $0?.id }
|
|
393
|
+
.removeDuplicates(),
|
|
394
|
+
state.$pinnedItem
|
|
395
|
+
.map { $0?.id }
|
|
396
|
+
.removeDuplicates(),
|
|
397
|
+
state.$selectedItem
|
|
398
|
+
.map { $0?.id }
|
|
399
|
+
.removeDuplicates(),
|
|
400
|
+
state.$focus
|
|
401
|
+
.removeDuplicates()
|
|
402
|
+
)
|
|
403
|
+
.sink { [weak self] _, _, _, _ in
|
|
404
|
+
DispatchQueue.main.async {
|
|
405
|
+
self?.updatePreviewPanelVisibility(animated: true)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
previewImageObserver = previewModel.objectWillChange
|
|
410
|
+
.sink { [weak self] _ in
|
|
411
|
+
DispatchQueue.main.async {
|
|
412
|
+
self?.updatePreviewPanelVisibility(animated: true)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private func updateRightPanelVisibility(animated: Bool) {
|
|
418
|
+
guard let rightPanel else { return }
|
|
419
|
+
let shouldShow = isVisible && state.pinnedItem != nil
|
|
420
|
+
let targetAlpha: CGFloat = shouldShow ? 1 : 0
|
|
421
|
+
rightPanel.ignoresMouseEvents = !shouldShow
|
|
422
|
+
|
|
423
|
+
guard rightPanel.alphaValue != targetAlpha else { return }
|
|
424
|
+
|
|
425
|
+
if animated {
|
|
426
|
+
NSAnimationContext.runAnimationGroup { context in
|
|
427
|
+
context.duration = 0.12
|
|
428
|
+
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
429
|
+
rightPanel.animator().alphaValue = targetAlpha
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
rightPanel.alphaValue = targetAlpha
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func updatePreviewPanelVisibility(animated: Bool) {
|
|
437
|
+
guard let previewPanel else { return }
|
|
438
|
+
let targetItem = state.transientPreviewItem
|
|
439
|
+
let motionItem = commitPreviewMotionTarget(from: targetItem)
|
|
440
|
+
|
|
441
|
+
if let screen = positionedScreen ?? NSScreen.main ?? NSScreen.screens.first,
|
|
442
|
+
let frame = previewFrame(on: screen, itemID: motionItem?.id) {
|
|
443
|
+
if animated {
|
|
444
|
+
NSAnimationContext.runAnimationGroup { context in
|
|
445
|
+
context.duration = 0.18
|
|
446
|
+
context.timingFunction = CAMediaTimingFunction(controlPoints: 0.22, 1.0, 0.36, 1.0)
|
|
447
|
+
previewPanel.animator().setFrame(frame, display: false)
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
previewPanel.setFrame(frame, display: false)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
let shouldShow = isVisible && motionItem != nil
|
|
454
|
+
let targetAlpha: CGFloat = shouldShow ? 1 : 0
|
|
455
|
+
previewPanel.ignoresMouseEvents = !shouldShow
|
|
456
|
+
|
|
457
|
+
guard previewPanel.alphaValue != targetAlpha else { return }
|
|
458
|
+
|
|
459
|
+
if animated {
|
|
460
|
+
NSAnimationContext.runAnimationGroup { context in
|
|
461
|
+
context.duration = 0.14
|
|
462
|
+
context.timingFunction = CAMediaTimingFunction(controlPoints: 0.22, 1.0, 0.36, 1.0)
|
|
463
|
+
previewPanel.animator().alphaValue = targetAlpha
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
previewPanel.alphaValue = targetAlpha
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private func makePanel(keyable: Bool = false) -> NSPanel {
|
|
471
|
+
let p: NSPanel
|
|
472
|
+
if keyable {
|
|
473
|
+
p = KeyableHUDPanel(contentRect: .zero,
|
|
474
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
475
|
+
backing: .buffered, defer: false)
|
|
476
|
+
} else {
|
|
477
|
+
p = NSPanel(contentRect: .zero,
|
|
478
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
479
|
+
backing: .buffered, defer: false)
|
|
480
|
+
}
|
|
481
|
+
p.isOpaque = false
|
|
482
|
+
p.backgroundColor = .clear
|
|
483
|
+
p.level = .floating
|
|
484
|
+
p.hasShadow = true
|
|
485
|
+
p.hidesOnDeactivate = false
|
|
486
|
+
p.isReleasedWhenClosed = false
|
|
487
|
+
p.isMovableByWindowBackground = false
|
|
488
|
+
p.alphaValue = 0
|
|
489
|
+
// Visible to screen recorders (default .readWrite allows capture)
|
|
490
|
+
p.sharingType = .readOnly
|
|
491
|
+
// Keep composited even when transparent — eliminates ordering cost on show
|
|
492
|
+
p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
493
|
+
return p
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private func previewFrame(on screen: NSScreen, itemID: String?) -> NSRect? {
|
|
497
|
+
guard itemID != nil else { return nil }
|
|
498
|
+
let sf = screen.visibleFrame
|
|
499
|
+
let leftFrame = leftPanel?.frame ?? NSRect(
|
|
500
|
+
x: sf.minX,
|
|
501
|
+
y: sf.minY + bottomHeight,
|
|
502
|
+
width: leftWidth,
|
|
503
|
+
height: max(0, sf.height - topHeight - bottomHeight)
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
let proposedX = leftFrame.maxX - 1
|
|
507
|
+
let maxX = sf.maxX - previewWidth - previewGap
|
|
508
|
+
let previewX = min(proposedX, maxX)
|
|
509
|
+
|
|
510
|
+
let anchorY = previewAnchorY(fallbackFrame: leftFrame)
|
|
511
|
+
let minY = leftFrame.minY + previewGap
|
|
512
|
+
let maxY = leftFrame.maxY - previewHeight - previewGap
|
|
513
|
+
let previewY = min(max(anchorY - previewHeight / 2, minY), maxY)
|
|
514
|
+
|
|
515
|
+
return NSRect(x: previewX, y: previewY, width: previewWidth, height: previewHeight)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private func previewAnchorY(fallbackFrame: NSRect) -> CGFloat {
|
|
519
|
+
previewSettledAnchorScreenY ?? state.hoverPreviewAnchorScreenY ?? fallbackFrame.midY
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private func commitPreviewMotionTarget(from targetItem: HUDItem?) -> HUDItem? {
|
|
523
|
+
guard let targetItem else {
|
|
524
|
+
previewSettledItemID = nil
|
|
525
|
+
previewSettledAnchorScreenY = nil
|
|
526
|
+
return nil
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let targetID = targetItem.id
|
|
530
|
+
|
|
531
|
+
if previewCanSettle(for: targetItem) {
|
|
532
|
+
previewSettledItemID = targetID
|
|
533
|
+
previewSettledAnchorScreenY = state.hoverPreviewAnchorScreenY ?? previewSettledAnchorScreenY
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if previewSettledItemID == nil {
|
|
537
|
+
return previewCanSettle(for: targetItem) ? targetItem : nil
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if previewSettledItemID == targetID {
|
|
541
|
+
return targetItem
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return state.flatItems.first(where: { $0.id == previewSettledItemID }) ?? targetItem
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private func previewCanSettle(for item: HUDItem) -> Bool {
|
|
548
|
+
guard let window = previewWindow(for: item) else { return false }
|
|
549
|
+
return previewModel.hasSettled(window.wid)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private func previewWindow(for item: HUDItem) -> WindowEntry? {
|
|
553
|
+
switch item {
|
|
554
|
+
case .window(let window):
|
|
555
|
+
return window
|
|
556
|
+
case .project(let project):
|
|
557
|
+
guard project.isRunning else { return nil }
|
|
558
|
+
return DesktopModel.shared.windowForSession(project.sessionName)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// MARK: - Keyboard routing
|
|
563
|
+
|
|
564
|
+
private func handleKey(_ event: NSEvent) -> NSEvent? {
|
|
565
|
+
let keyCode = event.keyCode
|
|
566
|
+
|
|
567
|
+
// Escape: tile mode → exit tile, search → list, otherwise dismiss
|
|
568
|
+
if keyCode == 53 {
|
|
569
|
+
if state.tileMode {
|
|
570
|
+
exitTileMode()
|
|
571
|
+
return nil
|
|
572
|
+
}
|
|
573
|
+
if state.focus == .search {
|
|
574
|
+
state.focus = .list
|
|
575
|
+
return nil
|
|
576
|
+
}
|
|
577
|
+
dismiss()
|
|
578
|
+
return nil
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Tab: cycle between search and list
|
|
582
|
+
if keyCode == 48 {
|
|
583
|
+
switch state.focus {
|
|
584
|
+
case .search:
|
|
585
|
+
if !state.flatItems.isEmpty {
|
|
586
|
+
state.focus = .list
|
|
587
|
+
if state.selectedItem == nil {
|
|
588
|
+
state.selectedIndex = 0
|
|
589
|
+
state.selectedItem = state.flatItems[safe: 0]
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
case .list, .inspector:
|
|
593
|
+
state.focus = .search
|
|
594
|
+
}
|
|
595
|
+
return nil
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Down arrow (Shift = extend multi-select)
|
|
599
|
+
if keyCode == 125 {
|
|
600
|
+
let shift = event.modifierFlags.contains(.shift)
|
|
601
|
+
if state.focus == .search {
|
|
602
|
+
state.focus = .list
|
|
603
|
+
if let firstItem = state.flatItems[safe: 0] {
|
|
604
|
+
state.selectSingle(firstItem, index: 0)
|
|
605
|
+
}
|
|
606
|
+
} else if state.focus == .list {
|
|
607
|
+
state.moveSelection(by: 1, extend: shift)
|
|
608
|
+
}
|
|
609
|
+
return nil
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Up arrow (Shift = extend multi-select)
|
|
613
|
+
if keyCode == 126 {
|
|
614
|
+
let shift = event.modifierFlags.contains(.shift)
|
|
615
|
+
if state.focus == .list {
|
|
616
|
+
if state.selectedIndex == 0 && !shift {
|
|
617
|
+
state.focus = .search
|
|
618
|
+
} else if state.selectedIndex > 0 {
|
|
619
|
+
state.moveSelection(by: -1, extend: shift)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return nil
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Enter: activate
|
|
626
|
+
if keyCode == 36 {
|
|
627
|
+
if let item = state.selectedItem, state.focus != .search {
|
|
628
|
+
activateItem(item)
|
|
629
|
+
}
|
|
630
|
+
return nil
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Option+V: toggle voice from ANY context (including search)
|
|
634
|
+
if keyCode == 9 && event.modifierFlags.contains(.option) {
|
|
635
|
+
toggleVoice()
|
|
636
|
+
return nil
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// V key (keyCode 9): toggle voice mode (works from any non-search context)
|
|
640
|
+
if keyCode == 9 && state.focus != .search {
|
|
641
|
+
toggleVoice()
|
|
642
|
+
return nil
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// M key (keyCode 46): cycle minimap mode (hidden → docked → expanded → hidden)
|
|
646
|
+
if keyCode == 46 && state.focus != .search {
|
|
647
|
+
switch state.minimapMode {
|
|
648
|
+
case .hidden: state.minimapMode = .docked
|
|
649
|
+
case .docked: state.minimapMode = .expanded
|
|
650
|
+
case .expanded: state.minimapMode = .hidden
|
|
651
|
+
}
|
|
652
|
+
return nil
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// T key (keyCode 17): tile selected windows or toggle tile mode
|
|
656
|
+
if keyCode == 17 && state.focus != .search {
|
|
657
|
+
DiagnosticLog.shared.info("[TileKey] tileMode=\(state.tileMode) multiSelection=\(state.multiSelectionCount) items=\(state.effectiveSelectionIDs)")
|
|
658
|
+
if state.tileMode {
|
|
659
|
+
exitTileMode()
|
|
660
|
+
} else if !selectedWindowsForActions().isEmpty {
|
|
661
|
+
tileSelectedItems()
|
|
662
|
+
} else {
|
|
663
|
+
enterTileMode()
|
|
664
|
+
}
|
|
665
|
+
return nil
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// D key (keyCode 2): detach selected projects or distribute selected windows
|
|
669
|
+
if keyCode == 2 && state.focus != .search {
|
|
670
|
+
if detachSelectedProjects() {
|
|
671
|
+
return nil
|
|
672
|
+
}
|
|
673
|
+
if distributeSelectedWindows() {
|
|
674
|
+
return nil
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Tile mode keys — H/J/K/L/F for tiling selected window
|
|
679
|
+
if state.tileMode && state.focus != .search {
|
|
680
|
+
let tileMap: [UInt16: TilePosition] = [
|
|
681
|
+
4: .left, // H = left half
|
|
682
|
+
37: .right, // L = right half
|
|
683
|
+
40: .top, // K = top half
|
|
684
|
+
38: .bottom, // J = bottom half
|
|
685
|
+
3: .maximize, // F = maximize/fullscreen
|
|
686
|
+
// Quadrants: Y U B N
|
|
687
|
+
16: .topLeft, // Y = top-left
|
|
688
|
+
32: .topRight, // U = top-right
|
|
689
|
+
11: .bottomLeft, // B = bottom-left
|
|
690
|
+
45: .bottomRight,// N = bottom-right
|
|
691
|
+
]
|
|
692
|
+
if let position = tileMap[keyCode] {
|
|
693
|
+
tileSelectedWindow(to: position)
|
|
694
|
+
return nil
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// / key (keyCode 44): enter search mode
|
|
699
|
+
if keyCode == 44 && state.focus != .search {
|
|
700
|
+
state.focus = .search
|
|
701
|
+
return nil
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// [ key (keyCode 33): cycle layer prev
|
|
705
|
+
if keyCode == 33 && state.focus != .search {
|
|
706
|
+
let ws = WorkspaceManager.shared
|
|
707
|
+
if let layers = ws.config?.layers, !layers.isEmpty {
|
|
708
|
+
let prev = ws.activeLayerIndex <= 0 ? layers.count - 1 : ws.activeLayerIndex - 1
|
|
709
|
+
ws.focusLayer(index: prev)
|
|
710
|
+
}
|
|
711
|
+
return nil
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ] key (keyCode 30): cycle layer next
|
|
715
|
+
if keyCode == 30 && state.focus != .search {
|
|
716
|
+
let ws = WorkspaceManager.shared
|
|
717
|
+
if let layers = ws.config?.layers, !layers.isEmpty {
|
|
718
|
+
let next = (ws.activeLayerIndex + 1) % layers.count
|
|
719
|
+
ws.focusLayer(index: next)
|
|
720
|
+
}
|
|
721
|
+
return nil
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Number keys 1-2: jump to section (when not in search)
|
|
725
|
+
if state.focus != .search {
|
|
726
|
+
let numberMap: [UInt16: Int] = [18: 1, 19: 2]
|
|
727
|
+
if let num = numberMap[keyCode] {
|
|
728
|
+
if !state.isSectionExpanded(num) {
|
|
729
|
+
state.toggleSection(num)
|
|
730
|
+
}
|
|
731
|
+
if let offset = state.sectionOffsets[num] {
|
|
732
|
+
state.focus = .list
|
|
733
|
+
if let item = state.flatItems[safe: offset] {
|
|
734
|
+
state.selectSingle(item, index: offset)
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return nil
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// In search mode, pass through to text field
|
|
742
|
+
if state.focus == .search { return event }
|
|
743
|
+
|
|
744
|
+
return event
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private func toggleVoice() {
|
|
748
|
+
let enabling = !state.voiceActive
|
|
749
|
+
let timed = AppFeedback.shared.beginTimed(
|
|
750
|
+
"HUD voice toggle",
|
|
751
|
+
state: state,
|
|
752
|
+
feedback: enabling ? "Voice on" : "Voice off"
|
|
753
|
+
)
|
|
754
|
+
state.voiceActive.toggle()
|
|
755
|
+
HandsOffSession.shared.setAudibleFeedbackEnabled(state.voiceActive)
|
|
756
|
+
if state.voiceActive {
|
|
757
|
+
HandsOffSession.shared.start()
|
|
758
|
+
HandsOffSession.shared.toggle()
|
|
759
|
+
} else {
|
|
760
|
+
if HandsOffSession.shared.state == .listening {
|
|
761
|
+
HandsOffSession.shared.toggle()
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
DispatchQueue.main.async {
|
|
765
|
+
AppFeedback.shared.finish(timed)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// MARK: - Tile mode
|
|
770
|
+
|
|
771
|
+
/// Pre-compute tile grid on HUD show — top 10 frontmost windows, grid positions ready
|
|
772
|
+
private func precomputeTileGrid(on screen: NSScreen) {
|
|
773
|
+
let sf = screen.visibleFrame
|
|
774
|
+
let primaryH = NSScreen.screens.first?.frame.height ?? 900
|
|
775
|
+
let screenCGX = sf.origin.x
|
|
776
|
+
let screenCGY = primaryH - sf.origin.y - sf.height
|
|
777
|
+
|
|
778
|
+
// Get the focused window's wid via AX (always include it)
|
|
779
|
+
let focusedWid: UInt32? = {
|
|
780
|
+
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
|
|
781
|
+
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
|
782
|
+
var focusedValue: AnyObject?
|
|
783
|
+
guard AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &focusedValue) == .success else { return nil }
|
|
784
|
+
let axWin = focusedValue as! AXUIElement
|
|
785
|
+
var widValue: CGWindowID = 0
|
|
786
|
+
let result = _AXUIElementGetWindow(axWin, &widValue)
|
|
787
|
+
return result == .success ? UInt32(widValue) : nil
|
|
788
|
+
}()
|
|
789
|
+
|
|
790
|
+
// Front 6 by z-order (most recently used first).
|
|
791
|
+
let allOnScreen = DesktopModel.shared.allWindows()
|
|
792
|
+
.filter { $0.isOnScreen && $0.app != "Lattices" && !$0.title.isEmpty }
|
|
793
|
+
.filter { win in
|
|
794
|
+
let cx = win.frame.x + win.frame.w / 2
|
|
795
|
+
let cy = win.frame.y + win.frame.h / 2
|
|
796
|
+
return cx >= Double(screenCGX) && cx < Double(screenCGX + sf.width) &&
|
|
797
|
+
cy >= Double(screenCGY) && cy < Double(screenCGY + sf.height)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
let log = DiagnosticLog.shared
|
|
801
|
+
log.info("[TileGrid.input] screenSize=\(sf.width)x\(sf.height) onScreen=\(allOnScreen.count)")
|
|
802
|
+
for (i, w) in allOnScreen.prefix(10).enumerated() {
|
|
803
|
+
log.info("[TileGrid.eval] #\(i) z=\(w.zIndex) app=\(w.app) title=\(w.title.prefix(30))")
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
var windows = Array(allOnScreen.prefix(6))
|
|
807
|
+
|
|
808
|
+
// Ensure the focused window is always included
|
|
809
|
+
if let fwid = focusedWid,
|
|
810
|
+
!windows.contains(where: { $0.wid == fwid }),
|
|
811
|
+
let focusedWin = allOnScreen.first(where: { $0.wid == fwid }) {
|
|
812
|
+
if windows.count >= 6 {
|
|
813
|
+
windows[windows.count - 1] = focusedWin // swap out last
|
|
814
|
+
} else {
|
|
815
|
+
windows.append(focusedWin)
|
|
816
|
+
}
|
|
817
|
+
log.info("[TileGrid.focused] swapped in focusedWid=\(fwid) (\(focusedWin.app): \(focusedWin.title.prefix(30)))")
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
log.info("[TileGrid.result] picked=\(windows.count)")
|
|
821
|
+
|
|
822
|
+
let count = windows.count
|
|
823
|
+
guard count > 0 else { state.precomputedGrid = []; return }
|
|
824
|
+
|
|
825
|
+
let cols = Int(ceil(sqrt(Double(count))))
|
|
826
|
+
let rows = Int(ceil(Double(count) / Double(cols)))
|
|
827
|
+
let cellW = sf.width / CGFloat(cols)
|
|
828
|
+
let cellH = sf.height / CGFloat(rows)
|
|
829
|
+
let gap: CGFloat = 2
|
|
830
|
+
|
|
831
|
+
state.precomputedGrid = windows.enumerated().map { (i, win) in
|
|
832
|
+
let col = i % cols
|
|
833
|
+
let row = i / cols
|
|
834
|
+
let frame = CGRect(
|
|
835
|
+
x: screenCGX + CGFloat(col) * cellW + gap,
|
|
836
|
+
y: screenCGY + CGFloat(row) * cellH + gap,
|
|
837
|
+
width: cellW - gap * 2,
|
|
838
|
+
height: cellH - gap * 2
|
|
839
|
+
)
|
|
840
|
+
return (win.wid, win.pid, frame)
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
private func prewarmLikelyPreviews() {
|
|
845
|
+
let desktop = DesktopModel.shared
|
|
846
|
+
let windows = desktop.allWindows()
|
|
847
|
+
.filter { $0.app != "Lattices" }
|
|
848
|
+
.filter { !$0.title.isEmpty }
|
|
849
|
+
.filter { $0.title != $0.app }
|
|
850
|
+
.sorted { lhs, rhs in
|
|
851
|
+
let lhsDate = desktop.lastInteractionDate(for: lhs.wid) ?? .distantPast
|
|
852
|
+
let rhsDate = desktop.lastInteractionDate(for: rhs.wid) ?? .distantPast
|
|
853
|
+
if lhsDate != rhsDate {
|
|
854
|
+
return lhsDate > rhsDate
|
|
855
|
+
}
|
|
856
|
+
return lhs.zIndex < rhs.zIndex
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
previewModel.prewarm(windows: windows, limit: 4)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private func enterTileMode() {
|
|
863
|
+
guard !state.precomputedGrid.isEmpty else { return }
|
|
864
|
+
let timed = AppFeedback.shared.beginTimed(
|
|
865
|
+
"HUD enter tile mode",
|
|
866
|
+
state: state,
|
|
867
|
+
feedback: "Tile mode"
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
// Snapshot current positions (for restore on dismiss)
|
|
871
|
+
state.tileSnapshot = state.precomputedGrid.map { move in
|
|
872
|
+
// Look up current frame from DesktopModel
|
|
873
|
+
let win = DesktopModel.shared.windows[move.wid]
|
|
874
|
+
let currentFrame = win.map {
|
|
875
|
+
CGRect(x: $0.frame.x, y: $0.frame.y, width: $0.frame.w, height: $0.frame.h)
|
|
876
|
+
} ?? CGRect.zero
|
|
877
|
+
return HUDState.WindowSnapshot(wid: move.wid, pid: move.pid, frame: currentFrame)
|
|
878
|
+
}
|
|
879
|
+
state.tiledWindows = []
|
|
880
|
+
state.tileMode = true
|
|
881
|
+
|
|
882
|
+
// Apply pre-computed grid — instant
|
|
883
|
+
WindowTiler.batchMoveAndRaiseWindows(state.precomputedGrid)
|
|
884
|
+
|
|
885
|
+
// Auto-expand minimap
|
|
886
|
+
if state.minimapMode != .expanded {
|
|
887
|
+
state.minimapMode = .expanded
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Select first window
|
|
891
|
+
let firstWid = state.precomputedGrid.first?.wid
|
|
892
|
+
if let wid = firstWid,
|
|
893
|
+
let win = DesktopModel.shared.windows[wid],
|
|
894
|
+
let idx = state.flatItems.firstIndex(of: .window(win)) {
|
|
895
|
+
state.focus = .list
|
|
896
|
+
state.selectedIndex = idx
|
|
897
|
+
state.selectedItem = .window(win)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
DispatchQueue.main.async {
|
|
901
|
+
AppFeedback.shared.finish(timed)
|
|
902
|
+
}
|
|
903
|
+
playCue("Tiled.")
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private func exitTileMode() {
|
|
907
|
+
AppFeedback.shared.acknowledge(
|
|
908
|
+
"HUD exit tile mode",
|
|
909
|
+
state: state,
|
|
910
|
+
feedback: "Tile mode off"
|
|
911
|
+
)
|
|
912
|
+
state.tileMode = false
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private func tileSelectedWindow(to position: TilePosition) {
|
|
916
|
+
guard let item = state.selectedItem,
|
|
917
|
+
case .window(let win) = item else { return }
|
|
918
|
+
let timed = AppFeedback.shared.beginTimed(
|
|
919
|
+
"HUD tile window",
|
|
920
|
+
state: state,
|
|
921
|
+
feedback: "Tiling \(win.title)"
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
let screen = positionedScreen ?? mouseScreen()
|
|
925
|
+
let frame = WindowTiler.tileFrame(for: position, on: screen)
|
|
926
|
+
WindowTiler.batchMoveAndRaiseWindows([(win.wid, win.pid, frame)])
|
|
927
|
+
|
|
928
|
+
state.tiledWindows.insert(win.wid)
|
|
929
|
+
DispatchQueue.main.async {
|
|
930
|
+
AppFeedback.shared.finish(timed)
|
|
931
|
+
}
|
|
932
|
+
playCue("Tiled.")
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/// Restore windows that weren't explicitly tiled back to their original positions
|
|
936
|
+
private func restoreUntiled() {
|
|
937
|
+
guard !state.tileSnapshot.isEmpty else { return }
|
|
938
|
+
|
|
939
|
+
var restores: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
940
|
+
for snap in state.tileSnapshot {
|
|
941
|
+
if !state.tiledWindows.contains(snap.wid) {
|
|
942
|
+
restores.append((snap.wid, snap.pid, snap.frame))
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if !restores.isEmpty {
|
|
947
|
+
WindowTiler.batchMoveAndRaiseWindows(restores)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
state.tileSnapshot = []
|
|
951
|
+
state.tiledWindows = []
|
|
952
|
+
state.tileMode = false
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/// Tile only the multi-selected windows from the sidebar
|
|
956
|
+
private func tileSelectedItems() {
|
|
957
|
+
let windows = selectedWindowsForActions()
|
|
958
|
+
guard !windows.isEmpty else { return }
|
|
959
|
+
let timed = AppFeedback.shared.beginTimed(
|
|
960
|
+
"HUD tile selection",
|
|
961
|
+
state: state,
|
|
962
|
+
feedback: "Tiling \(windows.count) window\(windows.count == 1 ? "" : "s")"
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
let screen = positionedScreen ?? mouseScreen()
|
|
966
|
+
let sf = screen.visibleFrame
|
|
967
|
+
let primaryH = NSScreen.screens.first?.frame.height ?? 900
|
|
968
|
+
let screenCGX = sf.origin.x
|
|
969
|
+
let screenCGY = primaryH - sf.origin.y - sf.height
|
|
970
|
+
|
|
971
|
+
// Snapshot for restore
|
|
972
|
+
state.tileSnapshot = windows.map { win in
|
|
973
|
+
HUDState.WindowSnapshot(
|
|
974
|
+
wid: win.wid, pid: win.pid,
|
|
975
|
+
frame: CGRect(x: win.frame.x, y: win.frame.y,
|
|
976
|
+
width: win.frame.w, height: win.frame.h)
|
|
977
|
+
)
|
|
978
|
+
}
|
|
979
|
+
state.tiledWindows = []
|
|
980
|
+
state.tileMode = true
|
|
981
|
+
|
|
982
|
+
// Grid layout
|
|
983
|
+
let count = windows.count
|
|
984
|
+
let cols = Int(ceil(sqrt(Double(count))))
|
|
985
|
+
let rows = Int(ceil(Double(count) / Double(cols)))
|
|
986
|
+
let cellW = sf.width / CGFloat(cols)
|
|
987
|
+
let cellH = sf.height / CGFloat(rows)
|
|
988
|
+
let gap: CGFloat = 2
|
|
989
|
+
|
|
990
|
+
var moves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
991
|
+
for (i, win) in windows.enumerated() {
|
|
992
|
+
let col = i % cols
|
|
993
|
+
let row = i / cols
|
|
994
|
+
let frame = CGRect(
|
|
995
|
+
x: screenCGX + CGFloat(col) * cellW + gap,
|
|
996
|
+
y: screenCGY + CGFloat(row) * cellH + gap,
|
|
997
|
+
width: cellW - gap * 2,
|
|
998
|
+
height: cellH - gap * 2
|
|
999
|
+
)
|
|
1000
|
+
moves.append((win.wid, win.pid, frame))
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
WindowTiler.batchMoveAndRaiseWindows(moves)
|
|
1004
|
+
state.precomputedGrid = moves
|
|
1005
|
+
|
|
1006
|
+
// Expand minimap
|
|
1007
|
+
if state.minimapMode != .expanded { state.minimapMode = .expanded }
|
|
1008
|
+
|
|
1009
|
+
DispatchQueue.main.async {
|
|
1010
|
+
AppFeedback.shared.finish(timed)
|
|
1011
|
+
}
|
|
1012
|
+
playCue("Tiled.")
|
|
1013
|
+
DiagnosticLog.shared.info("[TileGrid.selected] tiled \(windows.count) selected windows")
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private func detachSelectedProjects() -> Bool {
|
|
1017
|
+
let projects = selectedProjectsForActions().filter(\.isRunning)
|
|
1018
|
+
guard !projects.isEmpty else { return false }
|
|
1019
|
+
let timed = AppFeedback.shared.beginTimed(
|
|
1020
|
+
"HUD detach selection",
|
|
1021
|
+
state: state,
|
|
1022
|
+
feedback: "Detaching \(projects.count) project\(projects.count == 1 ? "" : "s")"
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
for project in projects {
|
|
1026
|
+
SessionManager.detach(project: project)
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
DispatchQueue.main.async {
|
|
1030
|
+
AppFeedback.shared.finish(timed)
|
|
1031
|
+
}
|
|
1032
|
+
playCue("Done.")
|
|
1033
|
+
DiagnosticLog.shared.info("[Detach.selected] detached \(projects.count) project(s)")
|
|
1034
|
+
dismiss()
|
|
1035
|
+
return true
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private func distributeSelectedWindows() -> Bool {
|
|
1039
|
+
let windows = selectedWindowsForActions()
|
|
1040
|
+
guard windows.count > 1 else { return false }
|
|
1041
|
+
let timed = AppFeedback.shared.beginTimed(
|
|
1042
|
+
"HUD distribute selection",
|
|
1043
|
+
state: state,
|
|
1044
|
+
feedback: "Distributing \(windows.count) windows"
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
WindowTiler.batchRaiseAndDistribute(windows: windows.map { (wid: $0.wid, pid: $0.pid) })
|
|
1048
|
+
DispatchQueue.main.async {
|
|
1049
|
+
AppFeedback.shared.finish(timed)
|
|
1050
|
+
}
|
|
1051
|
+
playCue("Distributed.")
|
|
1052
|
+
DiagnosticLog.shared.info("[Distribute.selected] distributed \(windows.count) window(s)")
|
|
1053
|
+
return true
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private func selectedProjectsForActions() -> [Project] {
|
|
1057
|
+
let ids = state.effectiveSelectionIDs
|
|
1058
|
+
guard !ids.isEmpty else { return [] }
|
|
1059
|
+
|
|
1060
|
+
return state.flatItems.compactMap { item in
|
|
1061
|
+
guard ids.contains(item.id), case .project(let project) = item else { return nil }
|
|
1062
|
+
return project
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
private func selectedWindowsForActions() -> [WindowEntry] {
|
|
1067
|
+
let ids = state.effectiveSelectionIDs
|
|
1068
|
+
guard !ids.isEmpty else { return [] }
|
|
1069
|
+
|
|
1070
|
+
var seen = Set<UInt32>()
|
|
1071
|
+
var windows: [WindowEntry] = []
|
|
1072
|
+
|
|
1073
|
+
for item in state.flatItems {
|
|
1074
|
+
guard ids.contains(item.id) else { continue }
|
|
1075
|
+
|
|
1076
|
+
switch item {
|
|
1077
|
+
case .window(let window):
|
|
1078
|
+
if seen.insert(window.wid).inserted {
|
|
1079
|
+
windows.append(window)
|
|
1080
|
+
}
|
|
1081
|
+
case .project(let project):
|
|
1082
|
+
guard project.isRunning else { continue }
|
|
1083
|
+
let projectWindows = DesktopModel.shared.allWindows().filter { $0.latticesSession == project.sessionName }
|
|
1084
|
+
for window in projectWindows where seen.insert(window.wid).inserted {
|
|
1085
|
+
windows.append(window)
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return windows
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
private func activateItem(_ item: HUDItem) {
|
|
1094
|
+
let (label, feedback): (String, String) = {
|
|
1095
|
+
switch item {
|
|
1096
|
+
case .project(let p):
|
|
1097
|
+
let verb = p.isRunning ? "Focus" : "Launch"
|
|
1098
|
+
return ("HUD \(verb.lowercased()) project", "\(verb) \(p.name)")
|
|
1099
|
+
case .window(let w):
|
|
1100
|
+
return ("HUD focus window", "Focus \(w.title)")
|
|
1101
|
+
}
|
|
1102
|
+
}()
|
|
1103
|
+
let timed = AppFeedback.shared.beginTimed(label, state: state, feedback: feedback)
|
|
1104
|
+
switch item {
|
|
1105
|
+
case .project(let p):
|
|
1106
|
+
SessionManager.launch(project: p)
|
|
1107
|
+
playCue(p.isRunning ? "Focused." : "Done.")
|
|
1108
|
+
case .window(let w):
|
|
1109
|
+
_ = WindowTiler.focusWindow(wid: w.wid, pid: w.pid)
|
|
1110
|
+
playCue("Focused.")
|
|
1111
|
+
}
|
|
1112
|
+
DispatchQueue.main.async {
|
|
1113
|
+
AppFeedback.shared.finish(timed)
|
|
1114
|
+
}
|
|
1115
|
+
dismiss()
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
private func playCue(_ phrase: String) {
|
|
1119
|
+
HandsOffSession.shared.playCachedCue(phrase)
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// MARK: - Event monitors
|
|
1123
|
+
|
|
1124
|
+
private func installMonitors() {
|
|
1125
|
+
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
1126
|
+
self?.handleKey(event) ?? event
|
|
1127
|
+
}
|
|
1128
|
+
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .keyDown]) { [weak self] event in
|
|
1129
|
+
guard let self else { return }
|
|
1130
|
+
if event.type == .keyDown {
|
|
1131
|
+
if event.keyCode == 53 { self.dismiss() }
|
|
1132
|
+
return
|
|
1133
|
+
}
|
|
1134
|
+
let loc = NSEvent.mouseLocation
|
|
1135
|
+
let inAny = self.allPanels
|
|
1136
|
+
.compactMap { $0 }
|
|
1137
|
+
.contains { $0.frame.contains(loc) }
|
|
1138
|
+
if !inAny { self.dismiss() }
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
private func removeMonitors() {
|
|
1143
|
+
if let m = keyMonitor { NSEvent.removeMonitor(m); keyMonitor = nil }
|
|
1144
|
+
if let m = clickMonitor { NSEvent.removeMonitor(m); clickMonitor = nil }
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
private func mouseScreen() -> NSScreen {
|
|
1148
|
+
let loc = NSEvent.mouseLocation
|
|
1149
|
+
return NSScreen.screens.first(where: { $0.frame.contains(loc) })
|
|
1150
|
+
?? NSScreen.main ?? NSScreen.screens.first!
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
private extension Array {
|
|
1155
|
+
subscript(safe index: Int) -> Element? {
|
|
1156
|
+
indices.contains(index) ? self[index] : nil
|
|
1157
|
+
}
|
|
1158
|
+
}
|