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