@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,1405 @@
1
+ import SwiftUI
2
+ import AppKit
3
+
4
+ // MARK: - Row Frame PreferenceKey
5
+
6
+ struct WindowRowFrameKey: PreferenceKey {
7
+ static var defaultValue: [UInt32: CGRect] = [:]
8
+ static func reduce(value: inout [UInt32: CGRect], nextValue: () -> [UInt32: CGRect]) {
9
+ value.merge(nextValue(), uniquingKeysWith: { _, new in new })
10
+ }
11
+ }
12
+
13
+ // MARK: - Focus Ring Suppressor
14
+
15
+ private struct FocusRingSuppressor: ViewModifier {
16
+ func body(content: Content) -> some View {
17
+ if #available(macOS 14, *) {
18
+ content.focusEffectDisabled()
19
+ } else {
20
+ content
21
+ }
22
+ }
23
+ }
24
+
25
+ struct CommandModeView: View {
26
+ @ObservedObject var state: CommandModeState
27
+ @State private var eventMonitor: Any?
28
+ @State private var mouseDownMonitor: Any?
29
+ @State private var mouseDragMonitor: Any?
30
+ @State private var mouseUpMonitor: Any?
31
+ @State private var panelOriginY: CGFloat = 0
32
+ @State private var hoveredWindowId: UInt32?
33
+ @FocusState private var isSearchFieldFocused: Bool
34
+
35
+ private var isDesktopInventory: Bool {
36
+ state.phase == .desktopInventory
37
+ }
38
+
39
+ // Column widths for inventory table
40
+ private static let sizeColW: CGFloat = 80
41
+ private static let tileColW: CGFloat = 60
42
+
43
+ private var displayColumnWidth: CGFloat {
44
+ let count = CGFloat(max(1, state.filteredSnapshot?.displays.count ?? 1))
45
+ let available = panelWidth - 32 - (count - 1) * 0.5
46
+ return max(360, (available / count).rounded(.down))
47
+ }
48
+
49
+ private var panelWidth: CGFloat {
50
+ if isDesktopInventory {
51
+ let displayCount = max(1, state.filteredSnapshot?.displays.count ?? 1)
52
+ let ideal = CGFloat(displayCount) * 480 + CGFloat(displayCount - 1) + 32
53
+ let screenWidth = NSScreen.main?.visibleFrame.width ?? 1920
54
+ return min(ideal, screenWidth * 0.92)
55
+ }
56
+ return 580
57
+ }
58
+
59
+ var body: some View {
60
+ VStack(spacing: 0) {
61
+ header
62
+ divider
63
+ if isDesktopInventory && state.desktopMode == .gridPreview {
64
+ gridPreviewContent
65
+ } else if isDesktopInventory {
66
+ desktopInventoryContent
67
+ } else {
68
+ inventoryGrid
69
+ }
70
+ divider
71
+ chordFooter
72
+ }
73
+ .frame(width: panelWidth)
74
+ .background(Palette.bg)
75
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
76
+ .overlay(
77
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
78
+ .strokeBorder(Palette.borderLit, lineWidth: 0.5)
79
+ )
80
+ .overlay(executingOverlay)
81
+ .overlay(flashOverlay)
82
+ .onAppear { installKeyHandler(); installMouseMonitors() }
83
+ .onDisappear { removeKeyHandler(); removeMouseMonitors() }
84
+ .onChange(of: state.desktopMode) { mode in
85
+ CommandModeWindow.shared.panelWindow?.isMovableByWindowBackground = true
86
+ }
87
+ .animation(.easeInOut(duration: 0.2), value: isDesktopInventory)
88
+ .modifier(FocusRingSuppressor())
89
+ }
90
+
91
+ // MARK: - Header
92
+
93
+ private var header: some View {
94
+ HStack {
95
+ Text(isDesktopInventory ? "DESKTOP INVENTORY" : "COMMAND MODE")
96
+ .font(Typo.monoBold(11))
97
+ .foregroundColor(Palette.text)
98
+
99
+ if isDesktopInventory {
100
+ Button(action: { state.copyInventoryToClipboard() }) {
101
+ HStack(spacing: 3) {
102
+ Image(systemName: "doc.on.doc")
103
+ .font(.system(size: 9))
104
+ Text("Copy")
105
+ .font(Typo.mono(9))
106
+ }
107
+ .foregroundColor(Palette.textDim)
108
+ .padding(.horizontal, 6)
109
+ .padding(.vertical, 3)
110
+ .background(
111
+ RoundedRectangle(cornerRadius: 3)
112
+ .fill(Palette.surface)
113
+ .overlay(
114
+ RoundedRectangle(cornerRadius: 3)
115
+ .strokeBorder(Palette.border, lineWidth: 0.5)
116
+ )
117
+ )
118
+ }
119
+ .buttonStyle(.plain)
120
+ }
121
+
122
+ Spacer()
123
+
124
+ if let layer = state.inventory.activeLayer {
125
+ HStack(spacing: 4) {
126
+ Text("Layer: \(layer)")
127
+ .font(Typo.mono(10))
128
+ .foregroundColor(Palette.running)
129
+
130
+ Text("[\(state.inventory.layerCount > 0 ? "\(WorkspaceManager.shared.activeLayerIndex + 1)/\(state.inventory.layerCount)" : "—")]")
131
+ .font(Typo.mono(10))
132
+ .foregroundColor(Palette.textMuted)
133
+ }
134
+ .padding(.horizontal, 6)
135
+ .padding(.vertical, 2)
136
+ .background(
137
+ RoundedRectangle(cornerRadius: 3)
138
+ .fill(Palette.running.opacity(0.10))
139
+ )
140
+ }
141
+ }
142
+ .padding(.horizontal, 16)
143
+ .padding(.vertical, 10)
144
+ .contentShape(Rectangle())
145
+ .gesture(
146
+ DragGesture()
147
+ .onChanged { _ in
148
+ CommandModeWindow.shared.panelWindow?.performDrag(with: NSApp.currentEvent!)
149
+ }
150
+ )
151
+ }
152
+
153
+ // MARK: - Inventory Grid
154
+
155
+ private var inventoryGrid: some View {
156
+ ScrollView {
157
+ LazyVStack(alignment: .leading, spacing: 0) {
158
+ let grouped = groupedItems
159
+ if grouped.isEmpty {
160
+ emptyState
161
+ } else {
162
+ ForEach(grouped, id: \.0) { section, items in
163
+ sectionHeader(section)
164
+ ForEach(Array(items.enumerated()), id: \.offset) { _, item in
165
+ inventoryRow(item)
166
+ }
167
+ }
168
+ }
169
+ }
170
+ .padding(.vertical, 6)
171
+ }
172
+ .frame(minHeight: 160, maxHeight: 240)
173
+ }
174
+
175
+ private var emptyState: some View {
176
+ HStack {
177
+ Spacer()
178
+ Text("No sessions found")
179
+ .font(Typo.mono(11))
180
+ .foregroundColor(Palette.textMuted)
181
+ Spacer()
182
+ }
183
+ .padding(.vertical, 24)
184
+ }
185
+
186
+ // MARK: - Desktop Inventory Content
187
+
188
+ private var desktopInventoryContent: some View {
189
+ VStack(spacing: 0) {
190
+ if state.isSearching {
191
+ searchBar
192
+ } else {
193
+ filterPillBar
194
+ }
195
+ divider
196
+
197
+ ZStack {
198
+ Group {
199
+ if let snapshot = state.filteredSnapshot, !snapshot.displays.isEmpty {
200
+ ScrollView(.horizontal, showsIndicators: false) {
201
+ HStack(alignment: .top, spacing: 0) {
202
+ let total = snapshot.displays.count
203
+ ForEach(Array(snapshot.displays.enumerated()), id: \.element.id) { idx, display in
204
+ if idx > 0 {
205
+ Rectangle()
206
+ .fill(Palette.border)
207
+ .frame(width: 0.5)
208
+ }
209
+ displayColumn(display, index: idx, total: total)
210
+ .frame(width: displayColumnWidth)
211
+ }
212
+ }
213
+ }
214
+ } else {
215
+ desktopEmptyState
216
+ }
217
+ }
218
+
219
+ marqueeOverlay
220
+ }
221
+ .coordinateSpace(name: "inventoryPanel")
222
+ .background(
223
+ GeometryReader { geo in
224
+ Color.clear.onAppear {
225
+ panelOriginY = geo.frame(in: .global).origin.y
226
+ }
227
+ .onChange(of: geo.frame(in: .global).origin.y) { newY in
228
+ panelOriginY = newY
229
+ }
230
+ }
231
+ )
232
+ .onPreferenceChange(WindowRowFrameKey.self) { frames in
233
+ state.rowFrames = frames
234
+ }
235
+ .frame(maxHeight: .infinity)
236
+ }
237
+ }
238
+
239
+ private var filterPillBar: some View {
240
+ HStack(spacing: 6) {
241
+ ForEach(FilterPreset.allCases, id: \.rawValue) { preset in
242
+ let isActive = state.activePreset == preset
243
+ Button {
244
+ if isActive {
245
+ state.activePreset = nil
246
+ } else {
247
+ state.activePreset = preset
248
+ state.clearSelection()
249
+ }
250
+ } label: {
251
+ HStack(spacing: 3) {
252
+ Text(preset.rawValue)
253
+ .font(Typo.mono(9))
254
+ if let idx = preset.keyIndex {
255
+ Text("\(idx)")
256
+ .font(Typo.mono(8))
257
+ .foregroundColor(isActive ? Palette.text.opacity(0.7) : Palette.textMuted)
258
+ }
259
+ }
260
+ .foregroundColor(isActive ? Palette.text : Palette.textDim)
261
+ .padding(.horizontal, 8)
262
+ .padding(.vertical, 4)
263
+ .background(
264
+ RoundedRectangle(cornerRadius: 10)
265
+ .fill(isActive ? Palette.running.opacity(0.2) : Palette.surface)
266
+ )
267
+ .overlay(
268
+ RoundedRectangle(cornerRadius: 10)
269
+ .strokeBorder(isActive ? Palette.running.opacity(0.4) : Palette.border, lineWidth: 0.5)
270
+ )
271
+ }
272
+ .buttonStyle(.plain)
273
+ }
274
+ Spacer()
275
+ }
276
+ .padding(.horizontal, 14)
277
+ .padding(.vertical, 6)
278
+ }
279
+
280
+ private var searchBar: some View {
281
+ HStack(spacing: 10) {
282
+ Image(systemName: "magnifyingglass")
283
+ .font(.system(size: 11))
284
+ .foregroundColor(Palette.textDim)
285
+ TextField("Search windows & content…", text: $state.searchQuery)
286
+ .textFieldStyle(.plain)
287
+ .font(Typo.mono(12))
288
+ .foregroundColor(Palette.text)
289
+ .focused($isSearchFieldFocused)
290
+ if !state.searchQuery.isEmpty {
291
+ let total = state.flatWindowList.count
292
+ let ocrCount = state.ocrMatchSnippets.count
293
+ Text(ocrCount > 0 ? "\(total) matches (\(ocrCount) by content)" : "\(total) matches")
294
+ .font(Typo.mono(9))
295
+ .foregroundColor(Palette.textMuted)
296
+ }
297
+ Button(action: { state.deactivateSearch() }) {
298
+ Image(systemName: "xmark.circle.fill")
299
+ .font(.system(size: 11))
300
+ .foregroundColor(Palette.textDim)
301
+ }
302
+ .buttonStyle(.plain)
303
+ }
304
+ .padding(.horizontal, 14)
305
+ .padding(.vertical, 8)
306
+ .onAppear {
307
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
308
+ isSearchFieldFocused = true
309
+ }
310
+ }
311
+ }
312
+
313
+ private func displayColumn(_ display: DesktopInventorySnapshot.DisplayInfo, index: Int, total: Int) -> some View {
314
+ VStack(alignment: .leading, spacing: 0) {
315
+ displayHeader(display, index: index, total: total)
316
+ divider
317
+
318
+ ScrollViewReader { proxy in
319
+ ScrollView {
320
+ LazyVStack(alignment: .leading, spacing: 0) {
321
+ ForEach(display.spaces) { space in
322
+ spaceHeader(space, display: display)
323
+ columnHeaders
324
+ ForEach(space.apps) { appGroup in
325
+ appGroupRows(appGroup, dimmed: !space.isCurrent)
326
+ }
327
+ }
328
+ }
329
+ .padding(.vertical, 4)
330
+ }
331
+ .onChange(of: state.selectedWindowIds) { newIds in
332
+ // Only scroll if the selected window is in this display
333
+ guard let id = newIds.first else { return }
334
+ let displayWindows = display.spaces.flatMap { $0.apps.flatMap { $0.windows } }
335
+ if displayWindows.contains(where: { $0.id == id }) {
336
+ withAnimation(.easeInOut(duration: 0.15)) {
337
+ proxy.scrollTo(id, anchor: .center)
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ private var desktopEmptyState: some View {
346
+ HStack {
347
+ Spacer()
348
+ if state.isSearching && !state.searchQuery.isEmpty {
349
+ Text("No matches for \"\(state.searchQuery)\"")
350
+ .font(Typo.mono(11))
351
+ .foregroundColor(Palette.textMuted)
352
+ } else {
353
+ Text("No windows found")
354
+ .font(Typo.mono(11))
355
+ .foregroundColor(Palette.textMuted)
356
+ }
357
+ Spacer()
358
+ }
359
+ .padding(.vertical, 24)
360
+ }
361
+
362
+ private func positionLabel(index: Int, total: Int) -> String {
363
+ if total == 2 { return index == 0 ? "Left" : "Right" }
364
+ if total == 3 { return ["Left", "Center", "Right"][index] }
365
+ return "\(index + 1) of \(total)"
366
+ }
367
+
368
+ private func displayHeader(_ display: DesktopInventorySnapshot.DisplayInfo, index: Int, total: Int) -> some View {
369
+ HStack(spacing: 6) {
370
+ Text(display.name)
371
+ .font(Typo.monoBold(11))
372
+ .foregroundColor(Palette.text)
373
+ if display.isMain {
374
+ Text("main")
375
+ .font(Typo.mono(8))
376
+ .foregroundColor(Palette.running.opacity(0.7))
377
+ .padding(.horizontal, 4)
378
+ .padding(.vertical, 1)
379
+ .background(
380
+ RoundedRectangle(cornerRadius: 2)
381
+ .fill(Palette.running.opacity(0.10))
382
+ )
383
+ }
384
+ if total > 1 {
385
+ Text(positionLabel(index: index, total: total))
386
+ .font(Typo.mono(9))
387
+ .foregroundColor(Palette.textDim)
388
+ }
389
+ Text("\(display.visibleFrame.w)×\(display.visibleFrame.h)")
390
+ .font(Typo.mono(9))
391
+ .foregroundColor(Palette.textDim)
392
+ Spacer()
393
+ Text("\(display.spaceCount) space\(display.spaceCount == 1 ? "" : "s")")
394
+ .font(Typo.mono(9))
395
+ .foregroundColor(Palette.textMuted)
396
+ }
397
+ .padding(.horizontal, 14)
398
+ .padding(.vertical, 8)
399
+ }
400
+
401
+ private func spaceHeader(_ space: DesktopInventorySnapshot.SpaceGroup, display: DesktopInventorySnapshot.DisplayInfo) -> some View {
402
+ HStack(spacing: 5) {
403
+ Text("Space \(space.index)")
404
+ .font(Typo.monoBold(10))
405
+ .foregroundColor(space.isCurrent ? Palette.running : Palette.textDim)
406
+ if space.isCurrent {
407
+ Text("active")
408
+ .font(Typo.mono(8))
409
+ .foregroundColor(Palette.running.opacity(0.7))
410
+ .padding(.horizontal, 4)
411
+ .padding(.vertical, 1)
412
+ .background(
413
+ RoundedRectangle(cornerRadius: 2)
414
+ .fill(Palette.running.opacity(0.10))
415
+ )
416
+ }
417
+ Spacer()
418
+ let windowCount = space.apps.reduce(0) { $0 + $1.windows.count }
419
+ Text("\(windowCount)")
420
+ .font(Typo.mono(9))
421
+ .foregroundColor(Palette.textMuted)
422
+ }
423
+ .padding(.horizontal, 14)
424
+ .padding(.top, 6)
425
+ .padding(.bottom, 2)
426
+ }
427
+
428
+ private var columnHeaders: some View {
429
+ HStack(spacing: 0) {
430
+ Text("APP / WINDOW")
431
+ .frame(maxWidth: .infinity, alignment: .leading)
432
+ Text("SIZE")
433
+ .frame(width: Self.sizeColW, alignment: .leading)
434
+ Text("TILE")
435
+ .frame(width: Self.tileColW, alignment: .trailing)
436
+ }
437
+ .font(Typo.mono(9))
438
+ .foregroundColor(Palette.textMuted)
439
+ .padding(.horizontal, 14)
440
+ .padding(.vertical, 3)
441
+ }
442
+
443
+ private func appGroupRows(_ appGroup: DesktopInventorySnapshot.AppGroup, dimmed: Bool = false) -> some View {
444
+ VStack(alignment: .leading, spacing: 0) {
445
+ if appGroup.windows.count == 1, let win = appGroup.windows.first {
446
+ inventoryRow(window: win, appLabel: appGroup.appName)
447
+ ocrSnippetRow(for: win.id)
448
+ if state.isSelected(win.id), let path = win.inventoryPath {
449
+ inventoryPathLabel(path)
450
+ }
451
+ } else {
452
+ Text(appGroup.appName)
453
+ .font(Typo.monoBold(10))
454
+ .foregroundColor(dimmed ? Palette.textDim : Palette.text)
455
+ .padding(.horizontal, 14)
456
+ .padding(.top, 4)
457
+ .padding(.bottom, 1)
458
+ ForEach(appGroup.windows) { win in
459
+ inventoryRow(window: win, indented: true)
460
+ ocrSnippetRow(for: win.id)
461
+ if state.isSelected(win.id), let path = win.inventoryPath {
462
+ inventoryPathLabel(path)
463
+ }
464
+ }
465
+ }
466
+ }
467
+ .opacity(dimmed ? 0.6 : 1.0)
468
+ }
469
+
470
+ private func inventoryPathLabel(_ path: InventoryPath) -> some View {
471
+ Text(path.description)
472
+ .font(Typo.mono(8))
473
+ .foregroundColor(Palette.textMuted)
474
+ .padding(.horizontal, 28)
475
+ .padding(.vertical, 2)
476
+ }
477
+
478
+ @ViewBuilder
479
+ private func ocrSnippetRow(for windowId: UInt32) -> some View {
480
+ if let snippet = state.ocrMatchSnippets[windowId] {
481
+ HStack(spacing: 4) {
482
+ Image(systemName: "text.magnifyingglass")
483
+ .font(.system(size: 7))
484
+ .foregroundColor(Palette.textMuted)
485
+ Text(snippet)
486
+ .font(Typo.mono(9).italic())
487
+ .foregroundColor(Palette.textMuted)
488
+ .lineLimit(1)
489
+ .truncationMode(.tail)
490
+ }
491
+ .padding(.horizontal, 28)
492
+ .padding(.vertical, 1)
493
+ }
494
+ }
495
+
496
+ /// Unified inventory row — handles both single-app rows (with appLabel) and
497
+ /// sub-rows under a multi-window app header (with indented).
498
+ private func inventoryRow(
499
+ window: DesktopInventorySnapshot.InventoryWindowInfo,
500
+ appLabel: String? = nil,
501
+ indented: Bool = false
502
+ ) -> some View {
503
+ let isSelected = state.isSelected(window.id)
504
+ let isHovered = hoveredWindowId == window.id
505
+ let isLattices = window.isLattices
506
+
507
+ return HStack(spacing: 0) {
508
+ HStack(spacing: 4) {
509
+ if indented {
510
+ Spacer().frame(width: 8)
511
+ }
512
+ Text(isLattices ? "●" : "•")
513
+ .font(.system(size: 7))
514
+ .foregroundColor(isLattices ? Palette.running : (isSelected ? Palette.text : Palette.textDim))
515
+ if let app = appLabel {
516
+ Text(app)
517
+ .font(Typo.monoBold(10))
518
+ .foregroundColor(isLattices ? Palette.running : Palette.text)
519
+ }
520
+ Text(windowTitle(window))
521
+ .font(Typo.mono(10))
522
+ .foregroundColor(
523
+ isLattices
524
+ ? Palette.running.opacity(appLabel != nil && !isSelected ? 0.7 : 1.0)
525
+ : (isSelected ? Palette.text : Palette.textDim)
526
+ )
527
+ .lineLimit(1)
528
+ if isLattices, let session = window.latticesSession, appLabel == nil {
529
+ Text("[\(session)]")
530
+ .font(Typo.mono(9))
531
+ .foregroundColor(Palette.running.opacity(isSelected ? 1.0 : 0.6))
532
+ }
533
+ }
534
+ .frame(maxWidth: .infinity, alignment: .leading)
535
+
536
+ Text(sizeText(window.frame))
537
+ .font(Typo.mono(10))
538
+ .foregroundColor(isSelected ? Palette.text : Palette.textDim)
539
+ .frame(width: Self.sizeColW, alignment: .leading)
540
+
541
+ Text(window.tilePosition?.label ?? "\u{2014}")
542
+ .font(Typo.mono(10))
543
+ .foregroundColor(window.tilePosition != nil ? (isSelected ? Palette.text : Palette.textDim) : Palette.textMuted)
544
+ .frame(width: Self.tileColW, alignment: .trailing)
545
+ }
546
+ .padding(.horizontal, 14)
547
+ .padding(.vertical, 3)
548
+ .background(
549
+ RoundedRectangle(cornerRadius: 4)
550
+ .fill(isSelected ? Palette.surface : (isHovered ? Palette.surface.opacity(0.5) : Color.clear))
551
+ .padding(.horizontal, 6)
552
+ )
553
+ .overlay(
554
+ isSelected ?
555
+ RoundedRectangle(cornerRadius: 4)
556
+ .strokeBorder(Palette.borderLit, lineWidth: 0.5)
557
+ .padding(.horizontal, 6)
558
+ : nil
559
+ )
560
+ .background(
561
+ GeometryReader { geo in
562
+ Color.clear.preference(
563
+ key: WindowRowFrameKey.self,
564
+ value: [window.id: geo.frame(in: .named("inventoryPanel"))]
565
+ )
566
+ }
567
+ )
568
+ .contentShape(Rectangle())
569
+ .onTapGesture(count: 2) {
570
+ WindowTiler.navigateToWindowById(wid: window.id, pid: window.pid)
571
+ }
572
+ .onTapGesture(count: 1) {
573
+ let mods = NSEvent.modifierFlags
574
+ if mods.contains(.shift) {
575
+ state.selectRange(to: window.id)
576
+ } else if mods.contains(.command) {
577
+ state.toggleSelection(window.id)
578
+ } else {
579
+ state.selectSingle(window.id)
580
+ }
581
+ }
582
+ .contextMenu { windowContextMenu(for: window) }
583
+ .onHover { hovering in hoveredWindowId = hovering ? window.id : nil }
584
+ .id(window.id)
585
+ }
586
+
587
+ // MARK: - Context Menu
588
+
589
+ @ViewBuilder
590
+ private func windowContextMenu(for window: DesktopInventorySnapshot.InventoryWindowInfo) -> some View {
591
+ let multiSelected = state.selectedWindowIds.count > 1 && state.isSelected(window.id)
592
+ let selCount = state.selectedWindowIds.count
593
+
594
+ if multiSelected {
595
+ // Multi-select context menu
596
+ Button {
597
+ state.showAndDistributeSelected()
598
+ } label: {
599
+ Label("Show & Distribute (\(selCount))", systemImage: "rectangle.3.group")
600
+ }
601
+
602
+ Button {
603
+ state.showAllSelected()
604
+ } label: {
605
+ Label("Show All (\(selCount))", systemImage: "macwindow.on.rectangle")
606
+ }
607
+
608
+ Button {
609
+ state.distributeSelected()
610
+ } label: {
611
+ Label("Distribute (\(selCount))", systemImage: "rectangle.split.3x1")
612
+ }
613
+
614
+ Divider()
615
+
616
+ Button {
617
+ state.focusAllSelected()
618
+ } label: {
619
+ Label("Focus All (\(selCount))", systemImage: "eye")
620
+ }
621
+
622
+ Button {
623
+ state.highlightAllSelected()
624
+ } label: {
625
+ Label("Highlight All (\(selCount))", systemImage: "sparkle")
626
+ }
627
+
628
+ Divider()
629
+
630
+ Menu("Tile All (\(selCount))") {
631
+ ForEach(TilePosition.allCases) { tile in
632
+ Button {
633
+ let windows = state.flatWindowList.filter { state.selectedWindowIds.contains($0.id) }
634
+ for (i, win) in windows.enumerated() {
635
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.1) {
636
+ WindowTiler.tileWindowById(wid: win.id, pid: win.pid, to: tile)
637
+ }
638
+ }
639
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3 + Double(windows.count) * 0.1) {
640
+ state.desktopSnapshot = nil
641
+ }
642
+ } label: {
643
+ Label(tile.label, systemImage: tile.icon)
644
+ }
645
+ }
646
+ }
647
+
648
+ Divider()
649
+
650
+ Button {
651
+ state.clearSelection()
652
+ } label: {
653
+ Label("Deselect All", systemImage: "xmark.circle")
654
+ }
655
+ } else {
656
+ // Single window context menu
657
+ Button {
658
+ WindowTiler.navigateToWindowById(wid: window.id, pid: window.pid)
659
+ } label: {
660
+ Label("Bring to Front", systemImage: "macwindow")
661
+ }
662
+
663
+ Button {
664
+ WindowTiler.highlightWindowById(wid: window.id)
665
+ } label: {
666
+ Label("Highlight", systemImage: "sparkle")
667
+ }
668
+
669
+ Divider()
670
+
671
+ Menu("Tile Window") {
672
+ ForEach(TilePosition.allCases) { tile in
673
+ Button {
674
+ WindowTiler.tileWindowById(wid: window.id, pid: window.pid, to: tile)
675
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
676
+ state.desktopSnapshot = nil
677
+ }
678
+ } label: {
679
+ Label(tile.label, systemImage: tile.icon)
680
+ }
681
+ }
682
+ }
683
+
684
+ Divider()
685
+
686
+ Button {
687
+ let info: String
688
+ if let path = window.inventoryPath {
689
+ info = path.description
690
+ } else {
691
+ let app = window.appName ?? "Unknown"
692
+ let title = window.title.isEmpty ? "(untitled)" : window.title
693
+ info = "[\(app)] \(title) wid=\(window.id)"
694
+ }
695
+ NSPasteboard.general.clearContents()
696
+ NSPasteboard.general.setString(info, forType: .string)
697
+ } label: {
698
+ Label("Copy Info", systemImage: "doc.on.doc")
699
+ }
700
+ }
701
+ }
702
+
703
+ private func windowTitle(_ window: DesktopInventorySnapshot.InventoryWindowInfo) -> String {
704
+ let title = window.title
705
+ if title.isEmpty { return "(untitled)" }
706
+ if title.count > 30 {
707
+ return String(title.prefix(27)) + "..."
708
+ }
709
+ return title
710
+ }
711
+
712
+ private func sizeText(_ frame: WindowFrame) -> String {
713
+ "\(Int(frame.w))×\(Int(frame.h))"
714
+ }
715
+
716
+ /// Group items by their group label
717
+ private var groupedItems: [(String, [CommandModeInventory.Item])] {
718
+ var result: [(String, [CommandModeInventory.Item])] = []
719
+ var seen = Set<String>()
720
+ for item in state.inventory.items {
721
+ if !seen.contains(item.group) {
722
+ seen.insert(item.group)
723
+ result.append((item.group, state.inventory.items.filter { $0.group == item.group }))
724
+ }
725
+ }
726
+ return result
727
+ }
728
+
729
+ private func sectionHeader(_ title: String) -> some View {
730
+ Text(title.uppercased())
731
+ .font(Typo.mono(9))
732
+ .foregroundColor(Palette.textMuted)
733
+ .padding(.horizontal, 16)
734
+ .padding(.top, 10)
735
+ .padding(.bottom, 4)
736
+ }
737
+
738
+ private func inventoryRow(_ item: CommandModeInventory.Item) -> some View {
739
+ HStack(spacing: 0) {
740
+ // Name
741
+ Text(item.name)
742
+ .font(Typo.mono(11))
743
+ .foregroundColor(statusColor(item.status))
744
+ .lineLimit(1)
745
+ .frame(width: 160, alignment: .leading)
746
+
747
+ // Pane count
748
+ Text(item.paneCount > 0 ? "\(item.paneCount) pane\(item.paneCount == 1 ? "" : "s")" : "—")
749
+ .font(Typo.mono(10))
750
+ .foregroundColor(Palette.textDim)
751
+ .frame(width: 70, alignment: .leading)
752
+
753
+ // Status dot + label
754
+ HStack(spacing: 4) {
755
+ Circle()
756
+ .fill(statusColor(item.status))
757
+ .frame(width: 5, height: 5)
758
+ Text(statusLabel(item.status))
759
+ .font(Typo.mono(10))
760
+ .foregroundColor(statusColor(item.status))
761
+ }
762
+ .frame(width: 80, alignment: .leading)
763
+
764
+ // Tile hint
765
+ Text(item.tileHint ?? "\u{2014}")
766
+ .font(Typo.mono(10))
767
+ .foregroundColor(Palette.textMuted)
768
+ .frame(width: 60, alignment: .leading)
769
+
770
+ Spacer()
771
+ }
772
+ .padding(.horizontal, 16)
773
+ .padding(.vertical, 3)
774
+ }
775
+
776
+ private func statusColor(_ status: CommandModeInventory.Status) -> Color {
777
+ switch status {
778
+ case .running: return Palette.running
779
+ case .attached: return Palette.running
780
+ case .stopped: return Palette.textMuted
781
+ }
782
+ }
783
+
784
+ private func statusLabel(_ status: CommandModeInventory.Status) -> String {
785
+ switch status {
786
+ case .running: return "running"
787
+ case .attached: return "attached"
788
+ case .stopped: return "stopped"
789
+ }
790
+ }
791
+
792
+ // MARK: - Chord Footer
793
+
794
+ private var chordFooter: some View {
795
+ VStack(spacing: 4) {
796
+ // Restore banner — shown when positions are saved
797
+ if isDesktopInventory && state.savedPositions != nil {
798
+ HStack(spacing: 10) {
799
+ Text("Layout changed")
800
+ .font(Typo.mono(10))
801
+ .foregroundColor(Palette.text)
802
+ Spacer()
803
+ Button {
804
+ state.restorePositions()
805
+ } label: {
806
+ HStack(spacing: 3) {
807
+ Image(systemName: "arrow.uturn.backward")
808
+ .font(.system(size: 9))
809
+ Text("Restore")
810
+ .font(Typo.mono(9))
811
+ }
812
+ .foregroundColor(Palette.text)
813
+ .padding(.horizontal, 8)
814
+ .padding(.vertical, 4)
815
+ .background(
816
+ RoundedRectangle(cornerRadius: 4)
817
+ .fill(Palette.surface)
818
+ .overlay(
819
+ RoundedRectangle(cornerRadius: 4)
820
+ .strokeBorder(Palette.border, lineWidth: 0.5)
821
+ )
822
+ )
823
+ }
824
+ .buttonStyle(.plain)
825
+
826
+ Button {
827
+ state.discardSavedPositions()
828
+ } label: {
829
+ HStack(spacing: 3) {
830
+ Image(systemName: "checkmark")
831
+ .font(.system(size: 9))
832
+ Text("Keep")
833
+ .font(Typo.mono(9))
834
+ }
835
+ .foregroundColor(Palette.running)
836
+ .padding(.horizontal, 8)
837
+ .padding(.vertical, 4)
838
+ .background(
839
+ RoundedRectangle(cornerRadius: 4)
840
+ .fill(Palette.running.opacity(0.1))
841
+ .overlay(
842
+ RoundedRectangle(cornerRadius: 4)
843
+ .strokeBorder(Palette.running.opacity(0.3), lineWidth: 0.5)
844
+ )
845
+ )
846
+ }
847
+ .buttonStyle(.plain)
848
+ }
849
+ .padding(.horizontal, 16)
850
+ .padding(.vertical, 6)
851
+ .background(Palette.running.opacity(0.05))
852
+ divider
853
+ }
854
+
855
+ if isDesktopInventory && state.desktopMode == .gridPreview {
856
+ // Grid preview hints
857
+ HStack(spacing: 12) {
858
+ chordHint(key: "↩", label: "apply layout")
859
+ chordHint(key: "s", label: "apply layout")
860
+ chordHint(key: "esc", label: "cancel")
861
+ Spacer()
862
+ let shape = state.gridPreviewShape
863
+ Text(shape.map(String.init).joined(separator: " + "))
864
+ .font(Typo.monoBold(9))
865
+ .foregroundColor(Palette.running)
866
+ }
867
+ } else if isDesktopInventory && state.isSearching {
868
+ // Search mode hints
869
+ HStack(spacing: 12) {
870
+ chordHint(key: "↩", label: "select & front")
871
+ chordHint(key: "⌘A", label: "select all")
872
+ chordHint(key: "⇧↑↓", label: "multi-select")
873
+ if !state.selectedWindowIds.isEmpty {
874
+ chordHint(key: "t", label: "tile")
875
+ }
876
+ chordHint(key: "esc", label: "exit search")
877
+ Spacer()
878
+ if state.selectedWindowIds.count > 1 {
879
+ Text("\(state.selectedWindowIds.count) selected")
880
+ .font(Typo.mono(9))
881
+ .foregroundColor(Palette.running)
882
+ }
883
+ }
884
+ } else if isDesktopInventory && state.desktopMode == .tiling {
885
+ // Tiling sub-mode hints
886
+ HStack(spacing: 12) {
887
+ if state.selectedWindowIds.count == 2 {
888
+ chordHint(key: "←→", label: "split L/R")
889
+ } else {
890
+ chordHint(key: "←", label: "left")
891
+ chordHint(key: "→", label: "right")
892
+ }
893
+ chordHint(key: "↑", label: "top")
894
+ chordHint(key: "↓", label: "bottom")
895
+ chordHint(key: "⇧↑", label: "max")
896
+ chordHint(key: "1-4", label: "quad")
897
+ chordHint(key: "5-7", label: "thirds")
898
+ chordHint(key: "c", label: "center")
899
+ if state.selectedWindowIds.count >= 2 {
900
+ chordHint(key: "d", label: "distribute")
901
+ }
902
+ chordHint(key: "esc", label: "back")
903
+ Spacer()
904
+ if state.selectedWindowIds.count > 1 {
905
+ Text("\(state.selectedWindowIds.count) windows")
906
+ .font(Typo.mono(9))
907
+ .foregroundColor(Palette.running)
908
+ }
909
+ }
910
+ } else if isDesktopInventory && state.selectedWindowIds.count > 1 {
911
+ // Multi-selection active
912
+ HStack(spacing: 12) {
913
+ chordHint(key: "s", label: "show")
914
+ chordHint(key: "↩", label: "front")
915
+ chordHint(key: "t", label: "tile")
916
+ chordHint(key: "f", label: "focus")
917
+ chordHint(key: "h", label: "highlight")
918
+ chordHint(key: "esc", label: "clear")
919
+ Spacer()
920
+ Text("\(state.selectedWindowIds.count) selected")
921
+ .font(Typo.mono(9))
922
+ .foregroundColor(Palette.running)
923
+ }
924
+ } else if isDesktopInventory && !state.selectedWindowIds.isEmpty {
925
+ // Single selection active — browsing hints with direct shortcuts
926
+ HStack(spacing: 12) {
927
+ chordHint(key: "s", label: "show")
928
+ chordHint(key: "↩", label: "front")
929
+ chordHint(key: "f", label: "focus+close")
930
+ chordHint(key: "t", label: "tile")
931
+ chordHint(key: "h", label: "highlight")
932
+ chordHint(key: "esc", label: "deselect")
933
+ Spacer()
934
+ }
935
+ } else if isDesktopInventory {
936
+ // No selection — browsing hints
937
+ HStack(spacing: 12) {
938
+ chordHint(key: "↑↓", label: "navigate")
939
+ chordHint(key: "←→", label: "display")
940
+ chordHint(key: "m", label: "map")
941
+ chordHint(key: "/", label: "search")
942
+ chordHint(key: "`", label: "chords")
943
+ chordHint(key: "esc", label: "back")
944
+ Spacer()
945
+ }
946
+ } else {
947
+ // First row: action chords
948
+ HStack(spacing: 12) {
949
+ chordHint(key: "`", label: "desktop")
950
+ ForEach(state.chords.prefix(3), id: \.key) { chord in
951
+ chordHint(key: chord.key, label: chord.label)
952
+ }
953
+ Spacer()
954
+ }
955
+
956
+ // Second row: layer chords + utility
957
+ HStack(spacing: 12) {
958
+ ForEach(state.chords.dropFirst(3), id: \.key) { chord in
959
+ chordHint(key: chord.key, label: chord.label)
960
+ }
961
+ chordHint(key: "esc", label: "dismiss")
962
+ Spacer()
963
+ }
964
+ }
965
+ }
966
+ .padding(.horizontal, 16)
967
+ .padding(.vertical, 8)
968
+ .background(Palette.surface.opacity(0.4))
969
+ }
970
+
971
+ private func chordHint(key: String, label: String) -> some View {
972
+ HStack(spacing: 4) {
973
+ Text(key)
974
+ .font(Typo.mono(9))
975
+ .foregroundColor(Palette.text)
976
+ .padding(.horizontal, 4)
977
+ .padding(.vertical, 2)
978
+ .background(
979
+ RoundedRectangle(cornerRadius: 3)
980
+ .fill(Palette.surface)
981
+ .overlay(
982
+ RoundedRectangle(cornerRadius: 3)
983
+ .strokeBorder(Palette.border, lineWidth: 0.5)
984
+ )
985
+ )
986
+ Text(label)
987
+ .font(Typo.mono(9))
988
+ .foregroundColor(Palette.textMuted)
989
+ }
990
+ }
991
+
992
+ private func actionButton(key: String, label: String, action: @escaping () -> Void) -> some View {
993
+ Button(action: action) {
994
+ HStack(spacing: 4) {
995
+ Text(key)
996
+ .font(Typo.mono(9))
997
+ .foregroundColor(Palette.text)
998
+ .padding(.horizontal, 4)
999
+ .padding(.vertical, 2)
1000
+ .background(
1001
+ RoundedRectangle(cornerRadius: 3)
1002
+ .fill(Palette.surface)
1003
+ .overlay(
1004
+ RoundedRectangle(cornerRadius: 3)
1005
+ .strokeBorder(Palette.border, lineWidth: 0.5)
1006
+ )
1007
+ )
1008
+ Text(label)
1009
+ .font(Typo.mono(9))
1010
+ .foregroundColor(Palette.textMuted)
1011
+ }
1012
+ .padding(.horizontal, 4)
1013
+ .padding(.vertical, 2)
1014
+ .background(
1015
+ RoundedRectangle(cornerRadius: 4)
1016
+ .fill(Color.white.opacity(0.001))
1017
+ )
1018
+ .contentShape(Rectangle())
1019
+ }
1020
+ .buttonStyle(.plain)
1021
+ .onHover { hovering in
1022
+ if hovering {
1023
+ NSCursor.pointingHand.push()
1024
+ } else {
1025
+ NSCursor.pop()
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ // MARK: - Executing Overlay
1031
+
1032
+ @ViewBuilder
1033
+ private var executingOverlay: some View {
1034
+ if case .executing(let label) = state.phase {
1035
+ ZStack {
1036
+ Palette.bg.opacity(0.85)
1037
+ HStack(spacing: 8) {
1038
+ Image(systemName: "checkmark.circle.fill")
1039
+ .font(.system(size: 16))
1040
+ .foregroundColor(Palette.running)
1041
+ Text(label)
1042
+ .font(Typo.monoBold(13))
1043
+ .foregroundColor(Palette.running)
1044
+ }
1045
+ }
1046
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
1047
+ .transition(.opacity)
1048
+ }
1049
+ }
1050
+
1051
+ // MARK: - Flash Overlay
1052
+
1053
+ @ViewBuilder
1054
+ private var flashOverlay: some View {
1055
+ if let msg = state.flashMessage {
1056
+ VStack {
1057
+ Spacer()
1058
+ HStack(spacing: 6) {
1059
+ Image(systemName: "rectangle.3.group")
1060
+ .font(.system(size: 11))
1061
+ Text(msg)
1062
+ .font(Typo.monoBold(11))
1063
+ }
1064
+ .foregroundColor(Palette.text)
1065
+ .padding(.horizontal, 14)
1066
+ .padding(.vertical, 8)
1067
+ .background(
1068
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
1069
+ .fill(Palette.surface)
1070
+ .overlay(
1071
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
1072
+ .strokeBorder(Palette.running.opacity(0.3), lineWidth: 0.5)
1073
+ )
1074
+ .shadow(color: .black.opacity(0.2), radius: 8, y: 2)
1075
+ )
1076
+ .padding(.bottom, 60)
1077
+ }
1078
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
1079
+ .animation(.easeOut(duration: 0.2), value: state.flashMessage)
1080
+ .allowsHitTesting(false)
1081
+ }
1082
+ }
1083
+
1084
+ // MARK: - Divider
1085
+
1086
+ private var divider: some View {
1087
+ Rectangle()
1088
+ .fill(Palette.border)
1089
+ .frame(height: 0.5)
1090
+ }
1091
+
1092
+ // MARK: - Grid Preview
1093
+
1094
+ private var gridPreviewContent: some View {
1095
+ let windows = state.gridPreviewWindows
1096
+ let shape = state.gridPreviewShape
1097
+ let gridDesc = shape.map(String.init).joined(separator: " + ")
1098
+
1099
+ return VStack(spacing: 0) {
1100
+ // Title bar
1101
+ HStack {
1102
+ Text("LAYOUT PREVIEW")
1103
+ .font(Typo.monoBold(10))
1104
+ .foregroundColor(Palette.textDim)
1105
+ Text(gridDesc)
1106
+ .font(Typo.monoBold(10))
1107
+ .foregroundColor(Palette.running)
1108
+ Spacer()
1109
+ Text("\(windows.count) window\(windows.count == 1 ? "" : "s")")
1110
+ .font(Typo.mono(9))
1111
+ .foregroundColor(Palette.textMuted)
1112
+ }
1113
+ .padding(.horizontal, 16)
1114
+ .padding(.vertical, 8)
1115
+
1116
+ divider
1117
+
1118
+ // Screen map: current positions (dimmed) + target grid (bright)
1119
+ screenMap(windows: windows, shape: shape)
1120
+ .frame(height: 160)
1121
+ .padding(.horizontal, 12)
1122
+ .padding(.vertical, 8)
1123
+
1124
+ divider
1125
+
1126
+ // Grid cells with window details
1127
+ VStack(spacing: 2) {
1128
+ ForEach(Array(shape.enumerated()), id: \.offset) { rowIdx, colCount in
1129
+ HStack(spacing: 2) {
1130
+ ForEach(0..<colCount, id: \.self) { colIdx in
1131
+ let idx = shape[0..<rowIdx].reduce(0, +) + colIdx
1132
+ if idx < windows.count {
1133
+ gridCell(windows[idx], index: idx + 1)
1134
+ }
1135
+ }
1136
+ }
1137
+ }
1138
+ }
1139
+ .padding(8)
1140
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
1141
+ }
1142
+ }
1143
+
1144
+
1145
+ // MARK: - Grid Preview Screen Map
1146
+
1147
+ /// Miniature proportional map of the screen showing current window positions and target grid slots
1148
+ private func screenMap(windows: [DesktopInventorySnapshot.InventoryWindowInfo], shape: [Int]) -> some View {
1149
+ GeometryReader { geo in
1150
+ let availW = geo.size.width
1151
+ let availH = geo.size.height
1152
+
1153
+ // Get screen dimensions from snapshot
1154
+ let display = state.filteredSnapshot?.displays.first
1155
+ let screenW = CGFloat(display?.visibleFrame.w ?? 3440)
1156
+ let screenH = CGFloat(display?.visibleFrame.h ?? 1440)
1157
+
1158
+ // Scale to fit
1159
+ let scaleX = availW / screenW
1160
+ let scaleY = availH / screenH
1161
+ let scale = min(scaleX, scaleY)
1162
+ let mapW = screenW * scale
1163
+ let mapH = screenH * scale
1164
+ let offsetX = (availW - mapW) / 2
1165
+ let offsetY = (availH - mapH) / 2
1166
+
1167
+ ZStack(alignment: .topLeading) {
1168
+ // Screen background
1169
+ RoundedRectangle(cornerRadius: 4)
1170
+ .fill(Palette.bg.opacity(0.5))
1171
+ .overlay(
1172
+ RoundedRectangle(cornerRadius: 4)
1173
+ .strokeBorder(Palette.border, lineWidth: 0.5)
1174
+ )
1175
+ .frame(width: mapW, height: mapH)
1176
+
1177
+ // Current positions (dimmed)
1178
+ ForEach(Array(windows.enumerated()), id: \.element.id) { idx, win in
1179
+ let f = win.frame
1180
+ let x = CGFloat(f.x) * scale
1181
+ let y = CGFloat(f.y) * scale
1182
+ let w = max(CGFloat(f.w) * scale, 2)
1183
+ let h = max(CGFloat(f.h) * scale, 2)
1184
+
1185
+ RoundedRectangle(cornerRadius: 2)
1186
+ .fill(Palette.textMuted.opacity(0.15))
1187
+ .overlay(
1188
+ RoundedRectangle(cornerRadius: 2)
1189
+ .strokeBorder(Palette.textMuted.opacity(0.3), lineWidth: 0.5)
1190
+ )
1191
+ .frame(width: w, height: h)
1192
+ .offset(x: x, y: y)
1193
+ }
1194
+
1195
+ // Target grid slots (bright)
1196
+ let slots = computeMapSlots(count: windows.count, shape: shape, mapW: mapW, mapH: mapH)
1197
+ ForEach(Array(slots.enumerated()), id: \.offset) { idx, slot in
1198
+ let win = idx < windows.count ? windows[idx] : nil
1199
+ RoundedRectangle(cornerRadius: 2)
1200
+ .fill(Palette.running.opacity(0.12))
1201
+ .overlay(
1202
+ RoundedRectangle(cornerRadius: 2)
1203
+ .strokeBorder(Palette.running.opacity(0.5), lineWidth: 1)
1204
+ )
1205
+ .overlay {
1206
+ VStack(spacing: 1) {
1207
+ Text("\(idx + 1)")
1208
+ .font(Typo.monoBold(9))
1209
+ .foregroundColor(Palette.running)
1210
+ if let win = win {
1211
+ Text(win.appName ?? "")
1212
+ .font(Typo.mono(7))
1213
+ .foregroundColor(Palette.running.opacity(0.7))
1214
+ .lineLimit(1)
1215
+ }
1216
+ }
1217
+ }
1218
+ .frame(width: slot.width - 2, height: slot.height - 2)
1219
+ .offset(x: slot.origin.x + 1, y: slot.origin.y + 1)
1220
+ }
1221
+ }
1222
+ .offset(x: offsetX, y: offsetY)
1223
+ }
1224
+ }
1225
+
1226
+ /// Compute grid slots scaled to the mini map dimensions
1227
+ private func computeMapSlots(count: Int, shape: [Int], mapW: CGFloat, mapH: CGFloat) -> [CGRect] {
1228
+ let rowCount = shape.count
1229
+ let rowH = mapH / CGFloat(rowCount)
1230
+ var slots: [CGRect] = []
1231
+ for (row, cols) in shape.enumerated() {
1232
+ let colW = mapW / CGFloat(cols)
1233
+ let y = CGFloat(row) * rowH
1234
+ for col in 0..<cols {
1235
+ slots.append(CGRect(x: CGFloat(col) * colW, y: y, width: colW, height: rowH))
1236
+ }
1237
+ }
1238
+ return slots
1239
+ }
1240
+
1241
+ private func gridCell(_ window: DesktopInventorySnapshot.InventoryWindowInfo, index: Int) -> some View {
1242
+ VStack(spacing: 3) {
1243
+ // App name
1244
+ Text(window.appName ?? "Unknown")
1245
+ .font(Typo.monoBold(10))
1246
+ .foregroundColor(window.isLattices ? Palette.running : Palette.text)
1247
+ .lineLimit(1)
1248
+
1249
+ // Window title
1250
+ Text(windowTitle(window))
1251
+ .font(Typo.mono(9))
1252
+ .foregroundColor(Palette.textDim)
1253
+ .lineLimit(2)
1254
+ .multilineTextAlignment(.center)
1255
+
1256
+ // Size
1257
+ Text(sizeText(window.frame))
1258
+ .font(Typo.mono(8))
1259
+ .foregroundColor(Palette.textMuted)
1260
+ }
1261
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
1262
+ .padding(.vertical, 8)
1263
+ .padding(.horizontal, 6)
1264
+ .background(
1265
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
1266
+ .fill(Palette.surface)
1267
+ )
1268
+ .overlay(
1269
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
1270
+ .strokeBorder(window.isLattices ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
1271
+ )
1272
+ .overlay(alignment: .topLeading) {
1273
+ Text("\(index)")
1274
+ .font(Typo.mono(8))
1275
+ .foregroundColor(Palette.textMuted)
1276
+ .padding(4)
1277
+ }
1278
+ }
1279
+
1280
+ // MARK: - Marquee Overlay
1281
+
1282
+ @ViewBuilder
1283
+ private var marqueeOverlay: some View {
1284
+ if state.isDragging {
1285
+ let rect = state.marqueeRect
1286
+ Rectangle()
1287
+ .fill(Palette.running.opacity(0.08))
1288
+ .overlay(
1289
+ Rectangle()
1290
+ .strokeBorder(Palette.running.opacity(0.4), lineWidth: 1)
1291
+ )
1292
+ .frame(width: rect.width, height: rect.height)
1293
+ .position(x: rect.midX, y: rect.midY)
1294
+ .allowsHitTesting(false)
1295
+ }
1296
+ }
1297
+
1298
+ // MARK: - Key Handler
1299
+
1300
+ private func installKeyHandler() {
1301
+ eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
1302
+ guard state.phase == .inventory || state.phase == .desktopInventory else { return event }
1303
+ // Only handle keys when our panel is the key window
1304
+ guard let panel = CommandModeWindow.shared.panelWindow,
1305
+ panel.isKeyWindow else { return event }
1306
+ let consumed = state.handleKey(event.keyCode, modifiers: event.modifierFlags)
1307
+ return consumed ? nil : event
1308
+ }
1309
+ }
1310
+
1311
+ // MARK: - Mouse Monitors (marquee drag + screen map drag)
1312
+
1313
+ private func installMouseMonitors() {
1314
+ let dragThreshold: CGFloat = 4
1315
+
1316
+ mouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { event in
1317
+ guard let eventWindow = event.window,
1318
+ eventWindow === CommandModeWindow.shared.panelWindow else { return event }
1319
+ guard state.phase == .desktopInventory else { return event }
1320
+
1321
+ state.dragStartPoint = event.locationInWindow
1322
+ return event
1323
+ }
1324
+
1325
+ mouseDragMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
1326
+ guard state.phase == .desktopInventory else { return event }
1327
+
1328
+ guard let startPt = state.dragStartPoint else { return event }
1329
+
1330
+ let currentPt = event.locationInWindow
1331
+
1332
+ if !state.isDragging {
1333
+ // Check threshold before starting drag
1334
+ let dx = currentPt.x - startPt.x
1335
+ let dy = currentPt.y - startPt.y
1336
+ let dist = sqrt(dx * dx + dy * dy)
1337
+ guard dist >= dragThreshold else { return event }
1338
+
1339
+ // Convert NSEvent bottom-left → SwiftUI top-left in inventoryPanel space
1340
+ let additive = event.modifierFlags.contains(.command)
1341
+ let swiftUIStart = convertToPanel(startPt, event: event)
1342
+ state.beginDrag(at: swiftUIStart, additive: additive)
1343
+ }
1344
+
1345
+ let swiftUICurrent = convertToPanel(currentPt, event: event)
1346
+ state.updateDrag(to: swiftUICurrent)
1347
+
1348
+ return nil // consume to prevent ScrollView scrolling during drag
1349
+ }
1350
+
1351
+ mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
1352
+ if state.isDragging {
1353
+ state.endDrag()
1354
+ }
1355
+ state.dragStartPoint = nil
1356
+ return event
1357
+ }
1358
+
1359
+ }
1360
+
1361
+
1362
+
1363
+ /// Convert NSEvent window coordinates (bottom-left origin) to SwiftUI inventoryPanel coordinates (top-left origin)
1364
+ private func convertToPanel(_ windowPoint: NSPoint, event: NSEvent) -> CGPoint {
1365
+ guard let nsWindow = event.window else { return .zero }
1366
+ // Convert to screen coordinates
1367
+ let screenPoint = nsWindow.convertPoint(toScreen: windowPoint)
1368
+ // Convert to SwiftUI top-left: screen Y is bottom-up, SwiftUI Y is top-down
1369
+ let screenHeight = NSScreen.main?.frame.height ?? 0
1370
+ let flippedY = screenHeight - screenPoint.y
1371
+ // Subtract the panel's global origin to get panel-local coordinates
1372
+ let panelY = flippedY - panelOriginY
1373
+ // X is relative to window — we need global X minus panel X
1374
+ // For simplicity, use the window point X directly since the panel fills the window width
1375
+ return CGPoint(x: windowPoint.x, y: panelY)
1376
+ }
1377
+
1378
+ /// Convert NSEvent to flipped window-local coordinates (Y=0 at top of window content)
1379
+ /// This matches SwiftUI GeometryReader's `.global` coordinate space inside NSHostingView
1380
+ private func flippedScreenPoint(_ event: NSEvent) -> CGPoint {
1381
+ guard let nsWindow = event.window else { return .zero }
1382
+ let loc = event.locationInWindow // bottom-left origin
1383
+ let windowHeight = nsWindow.contentView?.frame.height ?? nsWindow.frame.height
1384
+ return CGPoint(x: loc.x, y: windowHeight - loc.y)
1385
+ }
1386
+
1387
+ private func removeMouseMonitors() {
1388
+ if let m = mouseDownMonitor { NSEvent.removeMonitor(m); mouseDownMonitor = nil }
1389
+ if let m = mouseDragMonitor { NSEvent.removeMonitor(m); mouseDragMonitor = nil }
1390
+ if let m = mouseUpMonitor { NSEvent.removeMonitor(m); mouseUpMonitor = nil }
1391
+ }
1392
+
1393
+ // Clear hover when leaving desktop inventory
1394
+ private func clearDesktopState() {
1395
+ hoveredWindowId = nil
1396
+ }
1397
+
1398
+ private func removeKeyHandler() {
1399
+ if let monitor = eventMonitor {
1400
+ NSEvent.removeMonitor(monitor)
1401
+ eventMonitor = nil
1402
+ }
1403
+ }
1404
+ }
1405
+