@lattices/cli 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -0
- package/app/Lattices.app/Contents/Info.plist +24 -0
- package/app/Package.swift +13 -0
- package/app/Sources/AccessibilityTextExtractor.swift +111 -0
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +10 -0
- package/app/Sources/AppDelegate.swift +242 -0
- package/app/Sources/AppShellView.swift +62 -0
- package/app/Sources/AppTypeClassifier.swift +70 -0
- package/app/Sources/AppWindowShell.swift +63 -0
- package/app/Sources/CheatSheetHUD.swift +332 -0
- package/app/Sources/CommandModeState.swift +1362 -0
- package/app/Sources/CommandModeView.swift +1405 -0
- package/app/Sources/CommandModeWindow.swift +192 -0
- package/app/Sources/CommandPaletteView.swift +307 -0
- package/app/Sources/CommandPaletteWindow.swift +134 -0
- package/app/Sources/DaemonProtocol.swift +101 -0
- package/app/Sources/DaemonServer.swift +414 -0
- package/app/Sources/DesktopModel.swift +149 -0
- package/app/Sources/DesktopModelTypes.swift +71 -0
- package/app/Sources/DiagnosticLog.swift +271 -0
- package/app/Sources/EventBus.swift +30 -0
- package/app/Sources/HotkeyManager.swift +254 -0
- package/app/Sources/HotkeyStore.swift +338 -0
- package/app/Sources/InventoryManager.swift +35 -0
- package/app/Sources/InventoryPath.swift +43 -0
- package/app/Sources/KeyRecorderView.swift +210 -0
- package/app/Sources/LatticesApi.swift +1234 -0
- package/app/Sources/LayerBezel.swift +203 -0
- package/app/Sources/MainView.swift +479 -0
- package/app/Sources/MainWindow.swift +83 -0
- package/app/Sources/OcrModel.swift +430 -0
- package/app/Sources/OcrStore.swift +329 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/OrphanRow.swift +129 -0
- package/app/Sources/PaletteCommand.swift +419 -0
- package/app/Sources/PermissionChecker.swift +125 -0
- package/app/Sources/Preferences.swift +99 -0
- package/app/Sources/ProcessModel.swift +199 -0
- package/app/Sources/ProcessQuery.swift +151 -0
- package/app/Sources/Project.swift +28 -0
- package/app/Sources/ProjectRow.swift +368 -0
- package/app/Sources/ProjectScanner.swift +128 -0
- package/app/Sources/ScreenMapState.swift +2387 -0
- package/app/Sources/ScreenMapView.swift +2820 -0
- package/app/Sources/ScreenMapWindowController.swift +89 -0
- package/app/Sources/SessionManager.swift +72 -0
- package/app/Sources/SettingsView.swift +1064 -0
- package/app/Sources/SettingsWindow.swift +20 -0
- package/app/Sources/TabGroupRow.swift +178 -0
- package/app/Sources/Terminal.swift +259 -0
- package/app/Sources/TerminalQuery.swift +156 -0
- package/app/Sources/TerminalSynthesizer.swift +200 -0
- package/app/Sources/Theme.swift +163 -0
- package/app/Sources/TilePickerView.swift +209 -0
- package/app/Sources/TmuxModel.swift +53 -0
- package/app/Sources/TmuxQuery.swift +81 -0
- package/app/Sources/WindowTiler.swift +1778 -0
- package/app/Sources/WorkspaceManager.swift +575 -0
- package/bin/client.js +4 -0
- package/bin/daemon-client.js +187 -0
- package/bin/lattices-app.js +221 -0
- package/bin/lattices.js +1551 -0
- package/docs/api.md +924 -0
- package/docs/app.md +297 -0
- package/docs/concepts.md +135 -0
- package/docs/config.md +245 -0
- package/docs/layers.md +410 -0
- package/docs/ocr.md +185 -0
- package/docs/overview.md +94 -0
- package/docs/quickstart.md +75 -0
- package/package.json +42 -0
|
@@ -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
|
+
|