@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,2820 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import AppKit
|
|
3
|
+
|
|
4
|
+
// MARK: - Screen Map View (Standalone)
|
|
5
|
+
|
|
6
|
+
struct ScreenMapView: View {
|
|
7
|
+
@ObservedObject var controller: ScreenMapController
|
|
8
|
+
var onNavigate: ((AppPage) -> Void)? = nil
|
|
9
|
+
@ObservedObject private var daemon = DaemonServer.shared
|
|
10
|
+
@State private var eventMonitor: Any?
|
|
11
|
+
@State private var mouseDownMonitor: Any?
|
|
12
|
+
@State private var mouseDragMonitor: Any?
|
|
13
|
+
@State private var mouseUpMonitor: Any?
|
|
14
|
+
@State private var rightClickMonitor: Any?
|
|
15
|
+
@State private var scrollWheelMonitor: Any?
|
|
16
|
+
@State private var screenMapCanvasOrigin: CGPoint = .zero
|
|
17
|
+
@State private var screenMapCanvasSize: CGSize = .zero
|
|
18
|
+
@State private var screenMapTitleBarHeight: CGFloat = 0 // reserved for coordinate math
|
|
19
|
+
@State private var screenMapClickWindowId: UInt32? = nil
|
|
20
|
+
@State private var screenMapClickPoint: NSPoint = .zero
|
|
21
|
+
@State private var hoveredWindowId: UInt32?
|
|
22
|
+
@State private var hoveredShelfAction: String?
|
|
23
|
+
@State private var dropTargetLayer: Int?
|
|
24
|
+
@State private var layerRowFrames: [Int: CGRect] = [:]
|
|
25
|
+
@State private var sidebarDragWindowId: UInt32? = nil
|
|
26
|
+
@State private var sidebarDragOffset: CGSize = .zero
|
|
27
|
+
@State private var expandedLayers: Set<Int> = []
|
|
28
|
+
@State private var mouseMovedMonitor: Any?
|
|
29
|
+
@State private var sidebarWidth: CGFloat = 180
|
|
30
|
+
@State private var isDraggingSidebar: Bool = false
|
|
31
|
+
@State private var inspectorWidth: CGFloat = 200
|
|
32
|
+
@State private var isDraggingInspector: Bool = false
|
|
33
|
+
@FocusState private var isSearchFieldFocused: Bool
|
|
34
|
+
@State private var searchHoveredDisplayIndex: Int? = nil
|
|
35
|
+
@State private var canvasTransitionOffset: CGFloat = 0
|
|
36
|
+
@State private var canvasTransitionOpacity: Double = 1.0
|
|
37
|
+
@State private var isSpaceHeld: Bool = false
|
|
38
|
+
@State private var spaceDragStart: NSPoint? = nil
|
|
39
|
+
@State private var spaceDragPanStart: CGPoint = .zero
|
|
40
|
+
@State private var flagsMonitor: Any?
|
|
41
|
+
@State private var searchOverlayFrame: CGRect = .zero
|
|
42
|
+
|
|
43
|
+
var body: some View {
|
|
44
|
+
VStack(spacing: 0) {
|
|
45
|
+
HStack(spacing: 0) {
|
|
46
|
+
if let editor = controller.editor {
|
|
47
|
+
layerSidebar(editor: editor)
|
|
48
|
+
panelResizeHandle(isActive: $isDraggingSidebar, width: $sidebarWidth,
|
|
49
|
+
range: 140...320, edge: .trailing)
|
|
50
|
+
}
|
|
51
|
+
ZStack {
|
|
52
|
+
VStack(spacing: 0) {
|
|
53
|
+
canvasHeaderBezel
|
|
54
|
+
screenMapCanvas(editor: controller.editor)
|
|
55
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
56
|
+
}
|
|
57
|
+
.offset(x: canvasTransitionOffset)
|
|
58
|
+
.opacity(canvasTransitionOpacity)
|
|
59
|
+
.onChange(of: controller.displayTransition) { direction in
|
|
60
|
+
guard direction != .none else { return }
|
|
61
|
+
let slideDistance: CGFloat = direction == .right ? -60 : 60
|
|
62
|
+
// Start from opposite side
|
|
63
|
+
canvasTransitionOffset = -slideDistance
|
|
64
|
+
canvasTransitionOpacity = 0.3
|
|
65
|
+
withAnimation(.easeOut(duration: 0.2)) {
|
|
66
|
+
canvasTransitionOffset = 0
|
|
67
|
+
canvasTransitionOpacity = 1.0
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if controller.isSearchActive, let editor = controller.editor {
|
|
71
|
+
floatingSearchOverlay(editor: editor)
|
|
72
|
+
}
|
|
73
|
+
// Zoom controls — bottom-right corner of canvas
|
|
74
|
+
if let editor = controller.editor {
|
|
75
|
+
VStack {
|
|
76
|
+
Spacer()
|
|
77
|
+
HStack {
|
|
78
|
+
Spacer()
|
|
79
|
+
canvasZoomControls(editor: editor)
|
|
80
|
+
.padding(10)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if let editor = controller.editor {
|
|
86
|
+
panelResizeHandle(isActive: $isDraggingInspector, width: $inspectorWidth,
|
|
87
|
+
range: 160...360, edge: .leading)
|
|
88
|
+
inspectorPane(editor: editor)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
footerBar
|
|
92
|
+
}
|
|
93
|
+
.background(Palette.bg)
|
|
94
|
+
.overlay(flashOverlay)
|
|
95
|
+
.onAppear {
|
|
96
|
+
installKeyHandler()
|
|
97
|
+
installMouseMonitors()
|
|
98
|
+
}
|
|
99
|
+
.onDisappear {
|
|
100
|
+
removeKeyHandler()
|
|
101
|
+
removeMouseMonitors()
|
|
102
|
+
}
|
|
103
|
+
.onChange(of: controller.editor?.isPreviewing) { isPreviewing in
|
|
104
|
+
handlePreviewChange(isPreviewing: isPreviewing ?? false)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// MARK: - Display Toolbar (floating in canvas)
|
|
109
|
+
|
|
110
|
+
private func displayToolbar(editor: ScreenMapEditorState) -> some View {
|
|
111
|
+
HStack(spacing: 4) {
|
|
112
|
+
Button {
|
|
113
|
+
editor.cyclePreviousDisplay()
|
|
114
|
+
controller.flash(editor.focusedDisplay?.label ?? "All displays")
|
|
115
|
+
controller.objectWillChange.send()
|
|
116
|
+
} label: {
|
|
117
|
+
Image(systemName: "chevron.left")
|
|
118
|
+
.font(.system(size: 8, weight: .semibold))
|
|
119
|
+
.foregroundColor(Palette.textDim)
|
|
120
|
+
.frame(width: 18, height: 18)
|
|
121
|
+
.contentShape(Rectangle())
|
|
122
|
+
}
|
|
123
|
+
.buttonStyle(.plain)
|
|
124
|
+
|
|
125
|
+
Button {
|
|
126
|
+
editor.focusDisplay(nil)
|
|
127
|
+
controller.objectWillChange.send()
|
|
128
|
+
} label: {
|
|
129
|
+
displayToolbarPill(name: "All", isActive: editor.focusedDisplayIndex == nil)
|
|
130
|
+
}
|
|
131
|
+
.buttonStyle(.plain)
|
|
132
|
+
|
|
133
|
+
ForEach(Array(editor.spatialDisplayOrder.enumerated()), id: \.element.index) { spatialPos, disp in
|
|
134
|
+
let isActive = editor.focusedDisplayIndex == disp.index
|
|
135
|
+
Button {
|
|
136
|
+
editor.focusDisplay(disp.index)
|
|
137
|
+
controller.objectWillChange.send()
|
|
138
|
+
} label: {
|
|
139
|
+
displayToolbarPill(
|
|
140
|
+
badge: spatialPos + 1,
|
|
141
|
+
name: disp.label,
|
|
142
|
+
isActive: isActive
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
.buttonStyle(.plain)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
Button {
|
|
149
|
+
editor.cycleNextDisplay()
|
|
150
|
+
controller.flash(editor.focusedDisplay?.label ?? "All displays")
|
|
151
|
+
controller.objectWillChange.send()
|
|
152
|
+
} label: {
|
|
153
|
+
Image(systemName: "chevron.right")
|
|
154
|
+
.font(.system(size: 8, weight: .semibold))
|
|
155
|
+
.foregroundColor(Palette.textDim)
|
|
156
|
+
.frame(width: 18, height: 18)
|
|
157
|
+
.contentShape(Rectangle())
|
|
158
|
+
}
|
|
159
|
+
.buttonStyle(.plain)
|
|
160
|
+
}
|
|
161
|
+
.padding(.horizontal, 6)
|
|
162
|
+
.padding(.vertical, 4)
|
|
163
|
+
.background(
|
|
164
|
+
RoundedRectangle(cornerRadius: 8)
|
|
165
|
+
.fill(Color.black.opacity(0.65))
|
|
166
|
+
.overlay(
|
|
167
|
+
RoundedRectangle(cornerRadius: 8)
|
|
168
|
+
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private func displayToolbarPill(badge: Int? = nil, name: String, isActive: Bool) -> some View {
|
|
174
|
+
HStack(spacing: 4) {
|
|
175
|
+
if let badge = badge {
|
|
176
|
+
ZStack {
|
|
177
|
+
Circle()
|
|
178
|
+
.fill(isActive ? Palette.running.opacity(0.5) : Color.white.opacity(0.25))
|
|
179
|
+
.frame(width: 14, height: 14)
|
|
180
|
+
Text("\(badge)")
|
|
181
|
+
.font(.system(size: 7, weight: .bold, design: .monospaced))
|
|
182
|
+
.foregroundColor(isActive ? .white : .black)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
Text(name)
|
|
186
|
+
.font(Typo.monoBold(8))
|
|
187
|
+
.foregroundColor(isActive ? Palette.text : Palette.textDim)
|
|
188
|
+
.lineLimit(1)
|
|
189
|
+
}
|
|
190
|
+
.padding(.horizontal, 6)
|
|
191
|
+
.padding(.vertical, 3)
|
|
192
|
+
.background(
|
|
193
|
+
RoundedRectangle(cornerRadius: 5)
|
|
194
|
+
.fill(isActive ? Palette.running.opacity(0.15) : Color.white.opacity(0.06))
|
|
195
|
+
)
|
|
196
|
+
.overlay(
|
|
197
|
+
RoundedRectangle(cornerRadius: 5)
|
|
198
|
+
.strokeBorder(isActive ? Palette.running.opacity(0.4) : Color.clear, lineWidth: 0.5)
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// MARK: - Canvas Header Bezel
|
|
203
|
+
|
|
204
|
+
private var canvasHeaderBezel: some View {
|
|
205
|
+
HStack(spacing: 6) {
|
|
206
|
+
if let editor = controller.editor {
|
|
207
|
+
if let focused = editor.focusedDisplay {
|
|
208
|
+
Circle().fill(Palette.running.opacity(0.4)).frame(width: 6, height: 6)
|
|
209
|
+
Text(focused.label).font(Typo.monoBold(9)).foregroundColor(Palette.textDim).lineLimit(1)
|
|
210
|
+
Text("\(Int(focused.cgRect.width))×\(Int(focused.cgRect.height))").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
|
|
211
|
+
} else {
|
|
212
|
+
Text("All Displays").font(Typo.monoBold(9)).foregroundColor(Palette.textDim)
|
|
213
|
+
Text("\(editor.displays.count) monitors").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
|
|
214
|
+
}
|
|
215
|
+
Spacer()
|
|
216
|
+
Text("\(editor.focusedVisibleWindows.count) windows").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
|
|
217
|
+
} else { Text("Canvas"); Spacer() }
|
|
218
|
+
}
|
|
219
|
+
.padding(.horizontal, 10).padding(.vertical, 5)
|
|
220
|
+
.background(Color(red: 0.08, green: 0.08, blue: 0.09))
|
|
221
|
+
.overlay(alignment: .bottom) { Rectangle().fill(Palette.border).frame(height: 0.5) }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// MARK: - Panel Resize Handle
|
|
225
|
+
|
|
226
|
+
enum PanelEdge { case trailing, leading }
|
|
227
|
+
|
|
228
|
+
private func panelResizeHandle(isActive: Binding<Bool>, width: Binding<CGFloat>,
|
|
229
|
+
range: ClosedRange<CGFloat>, edge: PanelEdge) -> some View {
|
|
230
|
+
Rectangle()
|
|
231
|
+
.fill(isActive.wrappedValue ? Palette.running.opacity(0.3) : Palette.border)
|
|
232
|
+
.frame(width: isActive.wrappedValue ? 2 : 0.5)
|
|
233
|
+
.contentShape(Rectangle().inset(by: -3))
|
|
234
|
+
.onHover { h in if h { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } }
|
|
235
|
+
.gesture(
|
|
236
|
+
DragGesture(minimumDistance: 1)
|
|
237
|
+
.onChanged { value in
|
|
238
|
+
isActive.wrappedValue = true
|
|
239
|
+
let delta = edge == .trailing ? value.translation.width : -value.translation.width
|
|
240
|
+
let newWidth = width.wrappedValue + delta
|
|
241
|
+
width.wrappedValue = max(range.lowerBound, min(range.upperBound, newWidth))
|
|
242
|
+
}
|
|
243
|
+
.onEnded { _ in isActive.wrappedValue = false }
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// MARK: - Inspector Pane
|
|
248
|
+
|
|
249
|
+
private func inspectorPane(editor: ScreenMapEditorState) -> some View {
|
|
250
|
+
let selectedWindows = editor.windows.filter { controller.selectedWindowIds.contains($0.id) }
|
|
251
|
+
|
|
252
|
+
return VStack(spacing: 0) {
|
|
253
|
+
ScrollView(.vertical, showsIndicators: false) {
|
|
254
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
255
|
+
Text("INSPECTOR")
|
|
256
|
+
.font(Typo.monoBold(9))
|
|
257
|
+
.foregroundColor(Palette.textMuted)
|
|
258
|
+
|
|
259
|
+
if selectedWindows.isEmpty {
|
|
260
|
+
VStack(spacing: 8) {
|
|
261
|
+
Text("No Selection")
|
|
262
|
+
.font(Typo.monoBold(10))
|
|
263
|
+
.foregroundColor(Palette.textDim)
|
|
264
|
+
Text("Click a window on the canvas to inspect.")
|
|
265
|
+
.font(Typo.mono(9))
|
|
266
|
+
.foregroundColor(Palette.textMuted)
|
|
267
|
+
.multilineTextAlignment(.center)
|
|
268
|
+
.lineLimit(3)
|
|
269
|
+
}
|
|
270
|
+
.frame(maxWidth: .infinity)
|
|
271
|
+
.padding(.top, 40)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
ForEach(selectedWindows) { win in
|
|
275
|
+
inspectorWindowCard(win: win, editor: editor)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
.padding(8)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Pinned action tray at bottom
|
|
282
|
+
inspectorActionTray(editor: editor)
|
|
283
|
+
}
|
|
284
|
+
.frame(width: inspectorWidth)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// MARK: - Inspector Window Card
|
|
288
|
+
|
|
289
|
+
private func inspectorWindowCard(win: ScreenMapWindowEntry, editor: ScreenMapEditorState) -> some View {
|
|
290
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
291
|
+
HStack(spacing: 5) {
|
|
292
|
+
Circle()
|
|
293
|
+
.fill(Self.layerColor(for: win.layer))
|
|
294
|
+
.frame(width: 6, height: 6)
|
|
295
|
+
Text(win.app)
|
|
296
|
+
.font(Typo.monoBold(10))
|
|
297
|
+
.foregroundColor(Palette.text)
|
|
298
|
+
.lineLimit(1)
|
|
299
|
+
}
|
|
300
|
+
if !win.title.isEmpty {
|
|
301
|
+
Text(win.title)
|
|
302
|
+
.font(Typo.mono(9))
|
|
303
|
+
.foregroundColor(Palette.textDim)
|
|
304
|
+
.lineLimit(3)
|
|
305
|
+
}
|
|
306
|
+
VStack(alignment: .leading, spacing: 3) {
|
|
307
|
+
inspectorRow(label: "Layer", value: editor.layerDisplayName(for: win.layer))
|
|
308
|
+
inspectorRow(label: "Display", value: {
|
|
309
|
+
if let disp = editor.displays.first(where: { $0.index == win.displayIndex }) {
|
|
310
|
+
return "\(editor.spatialNumber(for: disp.index)). \(disp.label)"
|
|
311
|
+
}
|
|
312
|
+
return "Display \(win.displayIndex)"
|
|
313
|
+
}())
|
|
314
|
+
inspectorRow(label: "Size",
|
|
315
|
+
value: "\(Int(win.editedFrame.width))×\(Int(win.editedFrame.height))")
|
|
316
|
+
inspectorRow(label: "Position",
|
|
317
|
+
value: "(\(Int(win.editedFrame.origin.x)), \(Int(win.editedFrame.origin.y)))")
|
|
318
|
+
inspectorRow(label: "Z-Index", value: "\(win.zIndex)")
|
|
319
|
+
if win.hasEdits {
|
|
320
|
+
inspectorRow(label: "Original",
|
|
321
|
+
value: "\(Int(win.originalFrame.width))×\(Int(win.originalFrame.height))")
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if win.hasEdits {
|
|
325
|
+
HStack(spacing: 4) {
|
|
326
|
+
Circle()
|
|
327
|
+
.fill(Color.orange)
|
|
328
|
+
.frame(width: 5, height: 5)
|
|
329
|
+
Text("Modified")
|
|
330
|
+
.font(Typo.monoBold(8))
|
|
331
|
+
.foregroundColor(Color.orange)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
.padding(8)
|
|
336
|
+
.background(
|
|
337
|
+
RoundedRectangle(cornerRadius: 6)
|
|
338
|
+
.fill(Palette.surface)
|
|
339
|
+
.overlay(
|
|
340
|
+
RoundedRectangle(cornerRadius: 6)
|
|
341
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// MARK: - Floating Search Overlay
|
|
347
|
+
|
|
348
|
+
private func floatingSearchOverlay(editor: ScreenMapEditorState) -> some View {
|
|
349
|
+
let results = editor.searchFilteredWindows
|
|
350
|
+
let groups = editor.searchResultsByDisplay
|
|
351
|
+
let highlightIdx = max(0, min(controller.searchHighlightIndex, results.count - 1))
|
|
352
|
+
let terms = editor.searchTerms
|
|
353
|
+
|
|
354
|
+
return VStack(spacing: 0) {
|
|
355
|
+
Spacer().frame(height: 60)
|
|
356
|
+
|
|
357
|
+
VStack(spacing: 0) {
|
|
358
|
+
// Search field
|
|
359
|
+
HStack(spacing: 10) {
|
|
360
|
+
Image(systemName: "magnifyingglass")
|
|
361
|
+
.font(.system(size: 14, weight: .medium))
|
|
362
|
+
.foregroundColor(Self.shelfGreen)
|
|
363
|
+
TextField("Search windows…", text: Binding(
|
|
364
|
+
get: { editor.windowSearchQuery },
|
|
365
|
+
set: { newValue in
|
|
366
|
+
editor.windowSearchQuery = newValue
|
|
367
|
+
controller.searchHighlightIndex = 0
|
|
368
|
+
}
|
|
369
|
+
))
|
|
370
|
+
.textFieldStyle(.plain)
|
|
371
|
+
.font(Typo.mono(14))
|
|
372
|
+
.foregroundColor(Palette.text)
|
|
373
|
+
.focused($isSearchFieldFocused)
|
|
374
|
+
if !editor.windowSearchQuery.isEmpty {
|
|
375
|
+
Text("\(results.count)")
|
|
376
|
+
.font(Typo.monoBold(10))
|
|
377
|
+
.foregroundColor(Palette.textMuted)
|
|
378
|
+
Button {
|
|
379
|
+
editor.windowSearchQuery = ""
|
|
380
|
+
} label: {
|
|
381
|
+
Image(systemName: "xmark.circle.fill")
|
|
382
|
+
.font(.system(size: 12))
|
|
383
|
+
.foregroundColor(Palette.textMuted)
|
|
384
|
+
}
|
|
385
|
+
.buttonStyle(.plain)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
.padding(.horizontal, 14)
|
|
389
|
+
.padding(.vertical, 10)
|
|
390
|
+
|
|
391
|
+
// Results: side-by-side columns per display
|
|
392
|
+
if !groups.isEmpty {
|
|
393
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
394
|
+
HStack(alignment: .top, spacing: 0) {
|
|
395
|
+
ForEach(groups.indices, id: \.self) { groupIdx in
|
|
396
|
+
let group = groups[groupIdx]
|
|
397
|
+
if groupIdx > 0 {
|
|
398
|
+
Rectangle().fill(Palette.border).frame(width: 0.5)
|
|
399
|
+
}
|
|
400
|
+
VStack(spacing: 0) {
|
|
401
|
+
// Display header with hover → mini-map highlight
|
|
402
|
+
searchDisplayHeader(
|
|
403
|
+
spatialNumber: group.spatialNumber,
|
|
404
|
+
label: group.label,
|
|
405
|
+
matchCount: group.windows.count,
|
|
406
|
+
isHovered: searchHoveredDisplayIndex == group.displayIndex
|
|
407
|
+
)
|
|
408
|
+
.onHover { hovering in
|
|
409
|
+
searchHoveredDisplayIndex = hovering ? group.displayIndex : nil
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Window list for this display
|
|
413
|
+
ScrollView(.vertical, showsIndicators: false) {
|
|
414
|
+
VStack(spacing: 2) {
|
|
415
|
+
ForEach(Array(group.windows.enumerated()), id: \.element.id) { _, win in
|
|
416
|
+
let flatIdx = flatIndex(for: win, in: groups)
|
|
417
|
+
let isHighlighted = flatIdx == highlightIdx
|
|
418
|
+
searchResultRow(win: win, editor: editor, terms: terms, isHighlighted: isHighlighted)
|
|
419
|
+
.onTapGesture {
|
|
420
|
+
controller.selectSingle(win.id)
|
|
421
|
+
if editor.searchHasDirectHit {
|
|
422
|
+
controller.closeSearch()
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
.padding(4)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
.frame(maxWidth: .infinity)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
.frame(maxHeight: 280)
|
|
434
|
+
} else if !editor.windowSearchQuery.isEmpty {
|
|
435
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
436
|
+
Text("No matches")
|
|
437
|
+
.font(Typo.mono(11))
|
|
438
|
+
.foregroundColor(Palette.textMuted)
|
|
439
|
+
.padding(.vertical, 12)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Keyboard hints
|
|
443
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
444
|
+
HStack(spacing: 8) {
|
|
445
|
+
searchHint("↑↓", label: "nav")
|
|
446
|
+
searchHint("↩", label: "select")
|
|
447
|
+
searchHint("⌘↩", label: "show")
|
|
448
|
+
searchHint("esc", label: "close")
|
|
449
|
+
if terms.count > 1 {
|
|
450
|
+
Spacer()
|
|
451
|
+
Text("\(terms.count) terms")
|
|
452
|
+
.font(Typo.mono(7))
|
|
453
|
+
.foregroundColor(Palette.textMuted)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
.padding(.horizontal, 10)
|
|
457
|
+
.padding(.vertical, 5)
|
|
458
|
+
}
|
|
459
|
+
.background(
|
|
460
|
+
RoundedRectangle(cornerRadius: 10)
|
|
461
|
+
.fill(Color(red: 0.1, green: 0.1, blue: 0.11))
|
|
462
|
+
.overlay(
|
|
463
|
+
RoundedRectangle(cornerRadius: 10)
|
|
464
|
+
.strokeBorder(Self.shelfGreen.opacity(0.3), lineWidth: 1)
|
|
465
|
+
)
|
|
466
|
+
.shadow(color: Self.shelfGreen.opacity(0.15), radius: 20)
|
|
467
|
+
.shadow(color: Color.black.opacity(0.5), radius: 30)
|
|
468
|
+
)
|
|
469
|
+
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
470
|
+
.frame(width: groups.count > 1 ? 600 : 500)
|
|
471
|
+
.background(
|
|
472
|
+
GeometryReader { geo in
|
|
473
|
+
Color.clear.preference(key: SearchOverlayFrameKey.self,
|
|
474
|
+
value: geo.frame(in: .global))
|
|
475
|
+
}
|
|
476
|
+
)
|
|
477
|
+
.onPreferenceChange(SearchOverlayFrameKey.self) { frame in
|
|
478
|
+
searchOverlayFrame = frame
|
|
479
|
+
}
|
|
480
|
+
.onAppear {
|
|
481
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
482
|
+
isSearchFieldFocused = true
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
Spacer()
|
|
487
|
+
}
|
|
488
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
489
|
+
.background(Color.black.opacity(0.3))
|
|
490
|
+
.contentShape(Rectangle())
|
|
491
|
+
.onTapGesture {
|
|
492
|
+
controller.closeSearch()
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/// Compute flat index of a window within the grouped results (for highlight tracking)
|
|
497
|
+
private func flatIndex(
|
|
498
|
+
for win: ScreenMapWindowEntry,
|
|
499
|
+
in groups: [(displayIndex: Int, spatialNumber: Int, label: String, windows: [ScreenMapWindowEntry])]
|
|
500
|
+
) -> Int {
|
|
501
|
+
var idx = 0
|
|
502
|
+
for group in groups {
|
|
503
|
+
for w in group.windows {
|
|
504
|
+
if w.id == win.id { return idx }
|
|
505
|
+
idx += 1
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return 0
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/// Display section header within search results
|
|
512
|
+
private func searchDisplayHeader(spatialNumber: Int, label: String, matchCount: Int, isHovered: Bool = false) -> some View {
|
|
513
|
+
HStack(spacing: 6) {
|
|
514
|
+
Text("\(spatialNumber)")
|
|
515
|
+
.font(Typo.monoBold(8))
|
|
516
|
+
.foregroundColor(isHovered ? Palette.bg : Palette.bg)
|
|
517
|
+
.frame(width: 14, height: 14)
|
|
518
|
+
.background(
|
|
519
|
+
RoundedRectangle(cornerRadius: 3)
|
|
520
|
+
.fill(isHovered ? Self.shelfGreen : Palette.textMuted)
|
|
521
|
+
)
|
|
522
|
+
Text(label)
|
|
523
|
+
.font(Typo.mono(9))
|
|
524
|
+
.foregroundColor(isHovered ? Palette.text : Palette.textMuted)
|
|
525
|
+
.lineLimit(1)
|
|
526
|
+
Spacer()
|
|
527
|
+
Text("\(matchCount)")
|
|
528
|
+
.font(Typo.monoBold(8))
|
|
529
|
+
.foregroundColor(isHovered ? Self.shelfGreen : Palette.textMuted)
|
|
530
|
+
}
|
|
531
|
+
.padding(.horizontal, 8)
|
|
532
|
+
.padding(.top, 6)
|
|
533
|
+
.padding(.bottom, 4)
|
|
534
|
+
.background(isHovered ? Self.shelfGreen.opacity(0.06) : Color.clear)
|
|
535
|
+
.contentShape(Rectangle())
|
|
536
|
+
.animation(.easeInOut(duration: 0.15), value: isHovered)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private func searchResultRow(win: ScreenMapWindowEntry, editor: ScreenMapEditorState, terms: [String], isHighlighted: Bool) -> some View {
|
|
540
|
+
HStack(spacing: 6) {
|
|
541
|
+
Circle()
|
|
542
|
+
.fill(Self.layerColor(for: win.layer))
|
|
543
|
+
.frame(width: 5, height: 5)
|
|
544
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
545
|
+
highlightedText(win.app, terms: terms, baseFont: Typo.monoBold(9),
|
|
546
|
+
baseColor: isHighlighted ? Palette.text : Palette.textDim)
|
|
547
|
+
.lineLimit(1)
|
|
548
|
+
if !win.title.isEmpty {
|
|
549
|
+
highlightedText(win.title, terms: terms, baseFont: Typo.mono(8),
|
|
550
|
+
baseColor: Palette.textMuted)
|
|
551
|
+
.lineLimit(1)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
Spacer()
|
|
555
|
+
if isHighlighted {
|
|
556
|
+
Button {
|
|
557
|
+
controller.focusWindowOnScreen(win.id)
|
|
558
|
+
} label: {
|
|
559
|
+
Image(systemName: "macwindow.and.cursorarrow")
|
|
560
|
+
.font(.system(size: 8))
|
|
561
|
+
.foregroundColor(Self.shelfGreen)
|
|
562
|
+
.padding(3)
|
|
563
|
+
.background(
|
|
564
|
+
RoundedRectangle(cornerRadius: 3)
|
|
565
|
+
.fill(Self.shelfGreen.opacity(0.1))
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
.buttonStyle(.plain)
|
|
569
|
+
.help("Show on screen (⌘↩)")
|
|
570
|
+
}
|
|
571
|
+
Text(editor.layerDisplayName(for: win.layer))
|
|
572
|
+
.font(Typo.mono(7))
|
|
573
|
+
.foregroundColor(Palette.textMuted)
|
|
574
|
+
.padding(.horizontal, 4)
|
|
575
|
+
.padding(.vertical, 1)
|
|
576
|
+
.background(
|
|
577
|
+
RoundedRectangle(cornerRadius: 3)
|
|
578
|
+
.fill(Self.layerColor(for: win.layer).opacity(0.15))
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
.padding(.horizontal, 6)
|
|
582
|
+
.padding(.vertical, 4)
|
|
583
|
+
.background(
|
|
584
|
+
RoundedRectangle(cornerRadius: 4)
|
|
585
|
+
.fill(isHighlighted ? Self.shelfGreen.opacity(0.12) : Color.clear)
|
|
586
|
+
.overlay(
|
|
587
|
+
RoundedRectangle(cornerRadius: 4)
|
|
588
|
+
.strokeBorder(isHighlighted ? Self.shelfGreen.opacity(0.3) : Color.clear, lineWidth: 0.5)
|
|
589
|
+
)
|
|
590
|
+
)
|
|
591
|
+
.contentShape(Rectangle())
|
|
592
|
+
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/// Highlight matching search terms within text
|
|
596
|
+
private func highlightedText(_ text: String, terms: [String], baseFont: Font, baseColor: Color) -> Text {
|
|
597
|
+
guard !terms.isEmpty else {
|
|
598
|
+
return Text(text).font(baseFont).foregroundColor(baseColor)
|
|
599
|
+
}
|
|
600
|
+
let lower = text.lowercased()
|
|
601
|
+
// Build set of character offsets that match any term
|
|
602
|
+
var matchSet = IndexSet()
|
|
603
|
+
for term in terms {
|
|
604
|
+
var searchStart = lower.startIndex
|
|
605
|
+
while searchStart < lower.endIndex,
|
|
606
|
+
let range = lower.range(of: term, range: searchStart..<lower.endIndex) {
|
|
607
|
+
let startOffset = lower.distance(from: lower.startIndex, to: range.lowerBound)
|
|
608
|
+
let length = lower.distance(from: range.lowerBound, to: range.upperBound)
|
|
609
|
+
matchSet.insert(integersIn: startOffset..<(startOffset + length))
|
|
610
|
+
searchStart = range.upperBound
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Convert to segments
|
|
614
|
+
var result = Text("")
|
|
615
|
+
var i = 0
|
|
616
|
+
let chars = Array(text)
|
|
617
|
+
while i < chars.count {
|
|
618
|
+
let isMatch = matchSet.contains(i)
|
|
619
|
+
var j = i + 1
|
|
620
|
+
while j < chars.count && matchSet.contains(j) == isMatch { j += 1 }
|
|
621
|
+
let segment = String(chars[i..<j])
|
|
622
|
+
if isMatch {
|
|
623
|
+
result = result + Text(segment).font(baseFont).foregroundColor(Self.shelfGreen)
|
|
624
|
+
} else {
|
|
625
|
+
result = result + Text(segment).font(baseFont).foregroundColor(baseColor)
|
|
626
|
+
}
|
|
627
|
+
i = j
|
|
628
|
+
}
|
|
629
|
+
return result
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private func footerHint(_ key: String, label: String) -> some View {
|
|
633
|
+
HStack(spacing: 2) {
|
|
634
|
+
Text(key)
|
|
635
|
+
.font(Typo.monoBold(8))
|
|
636
|
+
.foregroundColor(Palette.textDim)
|
|
637
|
+
.padding(.horizontal, 3)
|
|
638
|
+
.padding(.vertical, 1)
|
|
639
|
+
.background(
|
|
640
|
+
RoundedRectangle(cornerRadius: 2)
|
|
641
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
642
|
+
)
|
|
643
|
+
Text(label)
|
|
644
|
+
.font(Typo.mono(8))
|
|
645
|
+
.foregroundColor(Palette.textMuted)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private func searchHint(_ key: String, label: String) -> some View {
|
|
650
|
+
HStack(spacing: 3) {
|
|
651
|
+
Text(key)
|
|
652
|
+
.font(Typo.monoBold(7))
|
|
653
|
+
.foregroundColor(Palette.textDim)
|
|
654
|
+
.padding(.horizontal, 3)
|
|
655
|
+
.padding(.vertical, 1)
|
|
656
|
+
.background(
|
|
657
|
+
RoundedRectangle(cornerRadius: 2)
|
|
658
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
659
|
+
)
|
|
660
|
+
Text(label)
|
|
661
|
+
.font(Typo.mono(7))
|
|
662
|
+
.foregroundColor(Palette.textMuted)
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// MARK: - Inspector Action Tray
|
|
667
|
+
|
|
668
|
+
private func inspectorActionTray(editor: ScreenMapEditorState) -> some View {
|
|
669
|
+
let actions: [(key: String, label: String, action: () -> Void)] = [
|
|
670
|
+
("s", "spread", { [controller] in controller.smartSpreadLayer() }),
|
|
671
|
+
("e", "expose", { [controller] in controller.exposeLayer() }),
|
|
672
|
+
("t", "tile", { [controller] in controller.tileLayer() }),
|
|
673
|
+
("d", "distrib", { [controller] in controller.distributeVisible() }),
|
|
674
|
+
("g", "grow", { [controller] in controller.fitAvailableSpace() }),
|
|
675
|
+
("c", "merge", { [controller] in controller.consolidateLayers() }),
|
|
676
|
+
("f", "flatten", { [controller] in controller.flattenLayers() }),
|
|
677
|
+
("v", "preview", { [controller] in controller.previewLayer() }),
|
|
678
|
+
]
|
|
679
|
+
|
|
680
|
+
let columns = [GridItem(.flexible()), GridItem(.flexible())]
|
|
681
|
+
let editCount = editor.pendingEditCount
|
|
682
|
+
let isZoomed = editor.zoomLevel != 1.0 || editor.panOffset != .zero
|
|
683
|
+
|
|
684
|
+
return VStack(spacing: 0) {
|
|
685
|
+
// Contextual commands area (fixed slot, always reserved)
|
|
686
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
687
|
+
VStack(spacing: 0) {
|
|
688
|
+
if editor.isTilingMode {
|
|
689
|
+
VStack(spacing: 4) {
|
|
690
|
+
HStack(spacing: 4) {
|
|
691
|
+
Text("TILE")
|
|
692
|
+
.font(Typo.monoBold(9))
|
|
693
|
+
.foregroundColor(.white)
|
|
694
|
+
.padding(.horizontal, 5)
|
|
695
|
+
.padding(.vertical, 2)
|
|
696
|
+
.background(RoundedRectangle(cornerRadius: 3).fill(Self.shelfGreen))
|
|
697
|
+
Spacer()
|
|
698
|
+
Text("esc cancel")
|
|
699
|
+
.font(Typo.mono(7))
|
|
700
|
+
.foregroundColor(Palette.textMuted)
|
|
701
|
+
}
|
|
702
|
+
HStack(spacing: 3) {
|
|
703
|
+
ForEach(["←", "→", "↑", "↓"], id: \.self) { key in
|
|
704
|
+
Text(key)
|
|
705
|
+
.font(Typo.monoBold(8))
|
|
706
|
+
.foregroundColor(Palette.textDim)
|
|
707
|
+
.padding(.horizontal, 3)
|
|
708
|
+
.padding(.vertical, 1)
|
|
709
|
+
.background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
|
|
710
|
+
.overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
|
|
711
|
+
}
|
|
712
|
+
Text("1-7")
|
|
713
|
+
.font(Typo.monoBold(8))
|
|
714
|
+
.foregroundColor(Palette.textDim)
|
|
715
|
+
.padding(.horizontal, 3)
|
|
716
|
+
.padding(.vertical, 1)
|
|
717
|
+
.background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
|
|
718
|
+
.overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
|
|
719
|
+
Text("c")
|
|
720
|
+
.font(Typo.monoBold(8))
|
|
721
|
+
.foregroundColor(Palette.textDim)
|
|
722
|
+
.padding(.horizontal, 3)
|
|
723
|
+
.padding(.vertical, 1)
|
|
724
|
+
.background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
|
|
725
|
+
.overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
|
|
726
|
+
Spacer()
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
.padding(.horizontal, 8)
|
|
730
|
+
.padding(.vertical, 5)
|
|
731
|
+
}
|
|
732
|
+
if editCount > 0 {
|
|
733
|
+
Button {
|
|
734
|
+
controller.applyEditsFromButton()
|
|
735
|
+
} label: {
|
|
736
|
+
HStack(spacing: 6) {
|
|
737
|
+
Text("↩")
|
|
738
|
+
.font(Typo.monoBold(10))
|
|
739
|
+
.foregroundColor(Self.shelfGreen)
|
|
740
|
+
Text("Apply \(editCount) \(editCount == 1 ? "edit" : "edits")")
|
|
741
|
+
.font(Typo.monoBold(9))
|
|
742
|
+
.foregroundColor(Self.shelfGreen)
|
|
743
|
+
Spacer()
|
|
744
|
+
}
|
|
745
|
+
.padding(.horizontal, 8)
|
|
746
|
+
.padding(.vertical, 5)
|
|
747
|
+
.contentShape(Rectangle())
|
|
748
|
+
}
|
|
749
|
+
.buttonStyle(.plain)
|
|
750
|
+
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
751
|
+
}
|
|
752
|
+
if isZoomed {
|
|
753
|
+
Button {
|
|
754
|
+
editor.resetZoomPan()
|
|
755
|
+
controller.flash("Fit all")
|
|
756
|
+
} label: {
|
|
757
|
+
HStack(spacing: 4) {
|
|
758
|
+
Text("r")
|
|
759
|
+
.font(Typo.monoBold(8))
|
|
760
|
+
.foregroundColor(Self.shelfGreen)
|
|
761
|
+
.padding(.horizontal, 4)
|
|
762
|
+
.padding(.vertical, 1)
|
|
763
|
+
.background(RoundedRectangle(cornerRadius: 2).fill(Self.shelfGreen.opacity(0.15)))
|
|
764
|
+
Text("fit all")
|
|
765
|
+
.font(Typo.mono(8))
|
|
766
|
+
.foregroundColor(Palette.textDim)
|
|
767
|
+
Spacer()
|
|
768
|
+
Text("\(Int(editor.zoomLevel * 100))%")
|
|
769
|
+
.font(Typo.mono(8))
|
|
770
|
+
.foregroundColor(Palette.textMuted)
|
|
771
|
+
}
|
|
772
|
+
.padding(.horizontal, 8)
|
|
773
|
+
.padding(.vertical, 4)
|
|
774
|
+
.contentShape(Rectangle())
|
|
775
|
+
}
|
|
776
|
+
.buttonStyle(.plain)
|
|
777
|
+
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
778
|
+
}
|
|
779
|
+
if let ref = editor.lastActionRef {
|
|
780
|
+
Button {
|
|
781
|
+
if let json = editor.actionLog.lastEntryJSON() {
|
|
782
|
+
NSPasteboard.general.clearContents()
|
|
783
|
+
NSPasteboard.general.setString(json, forType: .string)
|
|
784
|
+
controller.flash("Copied \(ref)")
|
|
785
|
+
}
|
|
786
|
+
} label: {
|
|
787
|
+
HStack(spacing: 4) {
|
|
788
|
+
Text(ref)
|
|
789
|
+
.font(Typo.monoBold(8))
|
|
790
|
+
.foregroundColor(Self.shelfGreen.opacity(0.6))
|
|
791
|
+
Spacer()
|
|
792
|
+
Text("copy")
|
|
793
|
+
.font(Typo.mono(7))
|
|
794
|
+
.foregroundColor(Palette.textMuted)
|
|
795
|
+
}
|
|
796
|
+
.padding(.horizontal, 8)
|
|
797
|
+
.padding(.vertical, 4)
|
|
798
|
+
.contentShape(Rectangle())
|
|
799
|
+
}
|
|
800
|
+
.buttonStyle(.plain)
|
|
801
|
+
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
.frame(maxWidth: .infinity)
|
|
805
|
+
.background(Color(red: 0.05, green: 0.05, blue: 0.06))
|
|
806
|
+
|
|
807
|
+
// Actions grid (always pinned at bottom)
|
|
808
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
809
|
+
|
|
810
|
+
Text("ACTIONS")
|
|
811
|
+
.font(Typo.monoBold(8))
|
|
812
|
+
.foregroundColor(Palette.textMuted)
|
|
813
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
814
|
+
.padding(.horizontal, 8)
|
|
815
|
+
.padding(.top, 6)
|
|
816
|
+
.padding(.bottom, 4)
|
|
817
|
+
|
|
818
|
+
LazyVGrid(columns: columns, spacing: 4) {
|
|
819
|
+
ForEach(Array(actions.enumerated()), id: \.offset) { _, item in
|
|
820
|
+
let isHovered = hoveredShelfAction == item.key
|
|
821
|
+
Button(action: item.action) {
|
|
822
|
+
HStack(spacing: 4) {
|
|
823
|
+
Text(item.key)
|
|
824
|
+
.font(Typo.monoBold(8))
|
|
825
|
+
.foregroundColor(Self.shelfGreen)
|
|
826
|
+
.frame(width: 14)
|
|
827
|
+
Text(item.label)
|
|
828
|
+
.font(Typo.mono(8))
|
|
829
|
+
.foregroundColor(isHovered ? Palette.text : Palette.textDim)
|
|
830
|
+
.lineLimit(1)
|
|
831
|
+
Spacer()
|
|
832
|
+
}
|
|
833
|
+
.padding(.horizontal, 6)
|
|
834
|
+
.padding(.vertical, 4)
|
|
835
|
+
.background(
|
|
836
|
+
RoundedRectangle(cornerRadius: 4)
|
|
837
|
+
.fill(isHovered ? Palette.surfaceHov : Palette.surface)
|
|
838
|
+
.overlay(
|
|
839
|
+
RoundedRectangle(cornerRadius: 4)
|
|
840
|
+
.strokeBorder(isHovered ? Palette.borderLit : Palette.border, lineWidth: 0.5)
|
|
841
|
+
)
|
|
842
|
+
)
|
|
843
|
+
.contentShape(Rectangle())
|
|
844
|
+
}
|
|
845
|
+
.buttonStyle(.plain)
|
|
846
|
+
.onHover { h in
|
|
847
|
+
hoveredShelfAction = h ? item.key : (hoveredShelfAction == item.key ? nil : hoveredShelfAction)
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
.padding(.horizontal, 6)
|
|
852
|
+
.padding(.bottom, 4)
|
|
853
|
+
}
|
|
854
|
+
.background(Color(red: 0.06, green: 0.06, blue: 0.07))
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
private func inspectorRow(label: String, value: String) -> some View {
|
|
858
|
+
HStack(alignment: .top, spacing: 0) {
|
|
859
|
+
Text(label)
|
|
860
|
+
.font(Typo.mono(8))
|
|
861
|
+
.foregroundColor(Palette.textMuted)
|
|
862
|
+
.frame(width: 52, alignment: .leading)
|
|
863
|
+
Text(value)
|
|
864
|
+
.font(Typo.mono(8))
|
|
865
|
+
.foregroundColor(Palette.textDim)
|
|
866
|
+
.lineLimit(2)
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// MARK: - Canvas Context Badge
|
|
871
|
+
|
|
872
|
+
private var canvasContextBadge: some View {
|
|
873
|
+
HStack(spacing: 6) {
|
|
874
|
+
if let editor = controller.editor {
|
|
875
|
+
let layerColor = editor.activeLayer != nil
|
|
876
|
+
? Self.layerColor(for: editor.activeLayer!)
|
|
877
|
+
: Palette.running
|
|
878
|
+
|
|
879
|
+
Circle()
|
|
880
|
+
.fill(layerColor)
|
|
881
|
+
.frame(width: 6, height: 6)
|
|
882
|
+
|
|
883
|
+
Text(editor.layerLabel)
|
|
884
|
+
.font(Typo.monoBold(9))
|
|
885
|
+
.foregroundColor(layerColor)
|
|
886
|
+
|
|
887
|
+
Text("·")
|
|
888
|
+
.foregroundColor(Palette.textMuted)
|
|
889
|
+
|
|
890
|
+
Text("\(editor.focusedVisibleWindows.count) windows")
|
|
891
|
+
.font(Typo.mono(9))
|
|
892
|
+
.foregroundColor(Palette.textDim)
|
|
893
|
+
|
|
894
|
+
if let focused = editor.focusedDisplay {
|
|
895
|
+
Text("·")
|
|
896
|
+
.foregroundColor(Palette.textMuted)
|
|
897
|
+
Text(focused.label)
|
|
898
|
+
.font(Typo.mono(8))
|
|
899
|
+
.foregroundColor(Palette.textMuted)
|
|
900
|
+
.lineLimit(1)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
let editCount = editor.windows.filter { $0.hasEdits }.count
|
|
904
|
+
if editCount > 0 {
|
|
905
|
+
Text("·")
|
|
906
|
+
.foregroundColor(Palette.textMuted)
|
|
907
|
+
Text("\(editCount) pending")
|
|
908
|
+
.font(Typo.mono(8))
|
|
909
|
+
.foregroundColor(Color.orange.opacity(0.8))
|
|
910
|
+
.onTapGesture { controller.applyEditsFromButton() }
|
|
911
|
+
.onHover { hovering in
|
|
912
|
+
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if let ref = editor.lastActionRef {
|
|
917
|
+
Text("·")
|
|
918
|
+
.foregroundColor(Palette.textMuted)
|
|
919
|
+
Text(ref)
|
|
920
|
+
.font(Typo.monoBold(8))
|
|
921
|
+
.foregroundColor(Self.shelfGreen.opacity(0.7))
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
.padding(.horizontal, 8)
|
|
926
|
+
.padding(.vertical, 4)
|
|
927
|
+
.background(
|
|
928
|
+
RoundedRectangle(cornerRadius: 6)
|
|
929
|
+
.fill(Color.black.opacity(0.55))
|
|
930
|
+
.overlay(
|
|
931
|
+
RoundedRectangle(cornerRadius: 6)
|
|
932
|
+
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
.padding(10)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// MARK: - Layer Sidebar
|
|
939
|
+
|
|
940
|
+
private func layerSidebar(editor: ScreenMapEditorState) -> some View {
|
|
941
|
+
let layers = editor.effectiveLayers
|
|
942
|
+
|
|
943
|
+
return VStack(spacing: 0) {
|
|
944
|
+
// Header
|
|
945
|
+
HStack {
|
|
946
|
+
Text("LAYERS")
|
|
947
|
+
.font(Typo.monoBold(9))
|
|
948
|
+
.foregroundColor(Palette.textMuted)
|
|
949
|
+
Spacer()
|
|
950
|
+
if editor.effectiveLayerCount > 1 {
|
|
951
|
+
Button(action: { controller.consolidateLayers() }) {
|
|
952
|
+
Image(systemName: "arrow.triangle.merge")
|
|
953
|
+
.font(.system(size: 8, weight: .semibold))
|
|
954
|
+
.foregroundColor(Palette.textDim)
|
|
955
|
+
}
|
|
956
|
+
.buttonStyle(.plain)
|
|
957
|
+
.help("Defrag layers (c)")
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
.padding(.bottom, 8)
|
|
961
|
+
|
|
962
|
+
// "All" row
|
|
963
|
+
ScrollView(.vertical, showsIndicators: false) {
|
|
964
|
+
VStack(spacing: 2) {
|
|
965
|
+
layerTreeHeader(
|
|
966
|
+
label: "All",
|
|
967
|
+
count: editor.focusedDisplayIndex != nil
|
|
968
|
+
? editor.windows.filter { $0.displayIndex == editor.focusedDisplayIndex! }.count
|
|
969
|
+
: editor.windows.count,
|
|
970
|
+
isActive: editor.isShowingAll,
|
|
971
|
+
color: Palette.running
|
|
972
|
+
) {
|
|
973
|
+
editor.selectLayer(nil)
|
|
974
|
+
controller.objectWillChange.send()
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Per-layer tree nodes
|
|
978
|
+
ForEach(layers, id: \.self) { layer in
|
|
979
|
+
let displayName = editor.layerDisplayName(for: layer)
|
|
980
|
+
let fullName = editor.layerNames[layer]
|
|
981
|
+
let color = Self.layerColor(for: layer)
|
|
982
|
+
let isActive = editor.isLayerSelected(layer)
|
|
983
|
+
let isDropTarget = dropTargetLayer == layer
|
|
984
|
+
let layerWindows = layerWindowsForTree(editor: editor, layer: layer)
|
|
985
|
+
|
|
986
|
+
VStack(spacing: 0) {
|
|
987
|
+
layerTreeHeader(label: fullName ?? displayName,
|
|
988
|
+
count: layerWindows.count,
|
|
989
|
+
isActive: isActive,
|
|
990
|
+
color: color,
|
|
991
|
+
isExpandable: true,
|
|
992
|
+
isExpanded: expandedLayers.contains(layer),
|
|
993
|
+
onToggleExpand: {
|
|
994
|
+
if expandedLayers.contains(layer) {
|
|
995
|
+
expandedLayers.remove(layer)
|
|
996
|
+
} else {
|
|
997
|
+
expandedLayers.insert(layer)
|
|
998
|
+
}
|
|
999
|
+
}) {
|
|
1000
|
+
if NSEvent.modifierFlags.contains(.command) {
|
|
1001
|
+
editor.toggleLayerSelection(layer)
|
|
1002
|
+
} else {
|
|
1003
|
+
editor.selectLayer(layer)
|
|
1004
|
+
}
|
|
1005
|
+
// Auto-expand on selection
|
|
1006
|
+
expandedLayers.insert(layer)
|
|
1007
|
+
controller.objectWillChange.send()
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Window children (shown when layer is expanded)
|
|
1011
|
+
if expandedLayers.contains(layer) {
|
|
1012
|
+
VStack(spacing: 0) {
|
|
1013
|
+
ForEach(layerWindows) { win in
|
|
1014
|
+
let isSelected = controller.selectedWindowIds.contains(win.id)
|
|
1015
|
+
let isDragging = sidebarDragWindowId == win.id
|
|
1016
|
+
HStack(spacing: 4) {
|
|
1017
|
+
Rectangle()
|
|
1018
|
+
.fill(color.opacity(0.4))
|
|
1019
|
+
.frame(width: 1, height: 12)
|
|
1020
|
+
.padding(.leading, 8)
|
|
1021
|
+
Text(win.app)
|
|
1022
|
+
.font(Typo.mono(8))
|
|
1023
|
+
.foregroundColor(isSelected ? Palette.running : Palette.textDim)
|
|
1024
|
+
.lineLimit(1)
|
|
1025
|
+
Spacer()
|
|
1026
|
+
if win.hasEdits {
|
|
1027
|
+
Circle()
|
|
1028
|
+
.fill(Color.orange)
|
|
1029
|
+
.frame(width: 4, height: 4)
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
.padding(.vertical, 2)
|
|
1033
|
+
.padding(.horizontal, 4)
|
|
1034
|
+
.background(
|
|
1035
|
+
RoundedRectangle(cornerRadius: 3)
|
|
1036
|
+
.fill(isSelected ? Palette.running.opacity(0.08) : Color.clear)
|
|
1037
|
+
)
|
|
1038
|
+
.contentShape(Rectangle())
|
|
1039
|
+
.opacity(isDragging ? 0.4 : 1.0)
|
|
1040
|
+
.offset(isDragging ? sidebarDragOffset : .zero)
|
|
1041
|
+
.zIndex(isDragging ? 10 : 0)
|
|
1042
|
+
.gesture(
|
|
1043
|
+
DragGesture(minimumDistance: 4, coordinateSpace: .named("layerSidebar"))
|
|
1044
|
+
.onChanged { value in
|
|
1045
|
+
sidebarDragWindowId = win.id
|
|
1046
|
+
sidebarDragOffset = value.translation
|
|
1047
|
+
controller.selectSingle(win.id)
|
|
1048
|
+
// Hit-test layer rows
|
|
1049
|
+
let pt = value.location
|
|
1050
|
+
var hit: Int? = nil
|
|
1051
|
+
for (l, frame) in layerRowFrames {
|
|
1052
|
+
if l != layer && frame.contains(pt) {
|
|
1053
|
+
hit = l
|
|
1054
|
+
break
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
dropTargetLayer = hit
|
|
1058
|
+
}
|
|
1059
|
+
.onEnded { _ in
|
|
1060
|
+
if let targetLayer = dropTargetLayer {
|
|
1061
|
+
editor.reassignLayer(windowId: win.id, toLayer: targetLayer, fitToAvailable: true)
|
|
1062
|
+
controller.flash("Moved to L\(targetLayer)")
|
|
1063
|
+
controller.objectWillChange.send()
|
|
1064
|
+
}
|
|
1065
|
+
sidebarDragWindowId = nil
|
|
1066
|
+
sidebarDragOffset = .zero
|
|
1067
|
+
dropTargetLayer = nil
|
|
1068
|
+
}
|
|
1069
|
+
)
|
|
1070
|
+
.onTapGesture {
|
|
1071
|
+
if NSEvent.modifierFlags.contains(.command) {
|
|
1072
|
+
controller.toggleSelection(win.id)
|
|
1073
|
+
} else {
|
|
1074
|
+
controller.selectSingle(win.id)
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
.padding(.leading, 4)
|
|
1080
|
+
.padding(.top, 2)
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
.overlay(
|
|
1084
|
+
RoundedRectangle(cornerRadius: 4)
|
|
1085
|
+
.strokeBorder(isDropTarget ? Palette.running : Color.clear, lineWidth: 1.5)
|
|
1086
|
+
)
|
|
1087
|
+
.background(
|
|
1088
|
+
GeometryReader { geo in
|
|
1089
|
+
Color.clear.preference(key: LayerRowFrameKey.self,
|
|
1090
|
+
value: [layer: geo.frame(in: .named("layerSidebar"))])
|
|
1091
|
+
}
|
|
1092
|
+
)
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
.coordinateSpace(name: "layerSidebar")
|
|
1097
|
+
|
|
1098
|
+
Spacer(minLength: 8)
|
|
1099
|
+
sidebarMiniMap(editor: editor)
|
|
1100
|
+
}
|
|
1101
|
+
.padding(.horizontal, 8)
|
|
1102
|
+
.padding(.vertical, 8)
|
|
1103
|
+
.frame(width: sidebarWidth)
|
|
1104
|
+
.onPreferenceChange(LayerRowFrameKey.self) { layerRowFrames = $0 }
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
private func layerWindowsForTree(editor: ScreenMapEditorState, layer: Int) -> [ScreenMapWindowEntry] {
|
|
1108
|
+
var wins = editor.windows.filter { $0.layer == layer }
|
|
1109
|
+
if let dIdx = editor.focusedDisplayIndex {
|
|
1110
|
+
wins = wins.filter { $0.displayIndex == dIdx }
|
|
1111
|
+
}
|
|
1112
|
+
return wins.sorted { $0.zIndex < $1.zIndex }
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private func layerTreeHeader(label: String, count: Int, isActive: Bool, color: Color,
|
|
1116
|
+
isExpandable: Bool = false, isExpanded: Bool = false,
|
|
1117
|
+
onToggleExpand: (() -> Void)? = nil,
|
|
1118
|
+
action: @escaping () -> Void) -> some View {
|
|
1119
|
+
HStack(spacing: 0) {
|
|
1120
|
+
if isExpandable {
|
|
1121
|
+
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
|
1122
|
+
.font(.system(size: 7, weight: .bold))
|
|
1123
|
+
.foregroundColor(Palette.textMuted)
|
|
1124
|
+
.frame(width: 16, height: 16)
|
|
1125
|
+
.onTapGesture { onToggleExpand?() }
|
|
1126
|
+
}
|
|
1127
|
+
HStack(spacing: 5) {
|
|
1128
|
+
Circle()
|
|
1129
|
+
.fill(color)
|
|
1130
|
+
.frame(width: 6, height: 6)
|
|
1131
|
+
Text(label)
|
|
1132
|
+
.font(Typo.monoBold(9))
|
|
1133
|
+
.lineLimit(1)
|
|
1134
|
+
Spacer()
|
|
1135
|
+
Text("\(count)")
|
|
1136
|
+
.font(Typo.mono(8))
|
|
1137
|
+
.foregroundColor(isActive ? Palette.text.opacity(0.7) : Palette.textMuted)
|
|
1138
|
+
}
|
|
1139
|
+
.foregroundColor(isActive ? Palette.text : Palette.textDim)
|
|
1140
|
+
}
|
|
1141
|
+
.padding(.leading, isExpandable ? 0 : 16)
|
|
1142
|
+
.padding(.trailing, 8)
|
|
1143
|
+
.padding(.vertical, 5)
|
|
1144
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
1145
|
+
.background(
|
|
1146
|
+
RoundedRectangle(cornerRadius: 6)
|
|
1147
|
+
.fill(isActive ? color.opacity(0.12) : Color.clear)
|
|
1148
|
+
)
|
|
1149
|
+
.contentShape(Rectangle())
|
|
1150
|
+
.onTapGesture { action() }
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// MARK: - Canvas
|
|
1154
|
+
|
|
1155
|
+
private func screenMapCanvas(editor: ScreenMapEditorState?) -> some View {
|
|
1156
|
+
let isFocused = editor?.focusedDisplayIndex != nil
|
|
1157
|
+
let allWindows = isFocused ? (editor?.focusedVisibleWindows ?? []) : (editor?.visibleWindows ?? [])
|
|
1158
|
+
let displays = editor?.displays ?? []
|
|
1159
|
+
let zoomLevel = editor?.zoomLevel ?? 1.0
|
|
1160
|
+
let panOffset = editor?.panOffset ?? .zero
|
|
1161
|
+
|
|
1162
|
+
return GeometryReader { geo in
|
|
1163
|
+
let availW = geo.size.width - 24
|
|
1164
|
+
let availH = geo.size.height - 16
|
|
1165
|
+
|
|
1166
|
+
let bboxPad: CGFloat = (!isFocused && displays.count > 1) ? 40 : 0
|
|
1167
|
+
let bbox: CGRect = {
|
|
1168
|
+
if let focused = editor?.focusedDisplay {
|
|
1169
|
+
return focused.cgRect
|
|
1170
|
+
}
|
|
1171
|
+
guard !displays.isEmpty else {
|
|
1172
|
+
let s = NSScreen.main?.frame ?? CGRect(x: 0, y: 0, width: 1920, height: 1080)
|
|
1173
|
+
return CGRect(origin: .zero, size: s.size)
|
|
1174
|
+
}
|
|
1175
|
+
var union = displays[0].cgRect
|
|
1176
|
+
for d in displays.dropFirst() { union = union.union(d.cgRect) }
|
|
1177
|
+
return union.insetBy(dx: -bboxPad, dy: -bboxPad)
|
|
1178
|
+
}()
|
|
1179
|
+
let bboxOriginPt = bbox.origin
|
|
1180
|
+
let screenW = bbox.width
|
|
1181
|
+
let screenH = bbox.height
|
|
1182
|
+
|
|
1183
|
+
let fitScale = min(availW / screenW, availH / screenH)
|
|
1184
|
+
let effScale = fitScale * zoomLevel
|
|
1185
|
+
let mapW = screenW * effScale
|
|
1186
|
+
let mapH = screenH * effScale
|
|
1187
|
+
let centerX = (geo.size.width - mapW) / 2
|
|
1188
|
+
let centerY = (geo.size.height - mapH) / 2
|
|
1189
|
+
|
|
1190
|
+
ZStack(alignment: .topLeading) {
|
|
1191
|
+
// Per-display background rectangles
|
|
1192
|
+
if isFocused, let focused = editor?.focusedDisplay, let editor = editor {
|
|
1193
|
+
focusedDisplayBackground(focused: focused, editor: editor, mapW: mapW, mapH: mapH)
|
|
1194
|
+
} else if displays.count > 1 {
|
|
1195
|
+
multiDisplayBackgrounds(displays: displays, editor: editor, effScale: effScale, bboxOrigin: bboxOriginPt)
|
|
1196
|
+
} else {
|
|
1197
|
+
singleDisplayBackground(displays: displays, mapW: mapW, mapH: mapH)
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Ghost outlines for edited windows
|
|
1201
|
+
ForEach(allWindows.filter(\.hasEdits)) { win in
|
|
1202
|
+
let f = win.originalFrame
|
|
1203
|
+
let x = (f.origin.x - bboxOriginPt.x) * effScale
|
|
1204
|
+
let y = (f.origin.y - bboxOriginPt.y) * effScale
|
|
1205
|
+
let w = max(f.width * effScale, 4)
|
|
1206
|
+
let h = max(f.height * effScale, 4)
|
|
1207
|
+
|
|
1208
|
+
RoundedRectangle(cornerRadius: 2)
|
|
1209
|
+
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
|
1210
|
+
.foregroundColor(Palette.textMuted.opacity(0.4))
|
|
1211
|
+
.frame(width: w, height: h)
|
|
1212
|
+
.offset(x: x, y: y)
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Live windows back-to-front
|
|
1216
|
+
ForEach(Array(allWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
|
|
1217
|
+
windowTile(win: win, editor: editor, scale: effScale, bboxOrigin: bboxOriginPt)
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
.frame(width: mapW, height: mapH)
|
|
1221
|
+
.offset(x: centerX + panOffset.x, y: centerY + panOffset.y)
|
|
1222
|
+
.onAppear {
|
|
1223
|
+
cacheGeometry(editor: editor, fitScale: fitScale, scale: effScale,
|
|
1224
|
+
offsetX: centerX, offsetY: centerY,
|
|
1225
|
+
screenSize: CGSize(width: screenW, height: screenH),
|
|
1226
|
+
bboxOrigin: bboxOriginPt)
|
|
1227
|
+
}
|
|
1228
|
+
.onChange(of: geo.size) { _ in
|
|
1229
|
+
let newFitScale = min((geo.size.width - 24) / screenW, (geo.size.height - 16) / screenH)
|
|
1230
|
+
let newEffScale = newFitScale * zoomLevel
|
|
1231
|
+
let newMapW = screenW * newEffScale
|
|
1232
|
+
let newMapH = screenH * newEffScale
|
|
1233
|
+
let newCX = (geo.size.width - newMapW) / 2
|
|
1234
|
+
let newCY = (geo.size.height - newMapH) / 2
|
|
1235
|
+
cacheGeometry(editor: editor, fitScale: newFitScale, scale: newEffScale,
|
|
1236
|
+
offsetX: newCX, offsetY: newCY,
|
|
1237
|
+
screenSize: CGSize(width: screenW, height: screenH),
|
|
1238
|
+
bboxOrigin: bboxOriginPt)
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
.padding(8)
|
|
1242
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
1243
|
+
.clipped()
|
|
1244
|
+
.background(
|
|
1245
|
+
ZStack {
|
|
1246
|
+
RoundedRectangle(cornerRadius: 6)
|
|
1247
|
+
.fill(Color.black.opacity(0.25))
|
|
1248
|
+
RoundedRectangle(cornerRadius: 6)
|
|
1249
|
+
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
|
1250
|
+
Canvas { context, size in
|
|
1251
|
+
let spacing: CGFloat = 20
|
|
1252
|
+
let dotColor = Color.white.opacity(0.04)
|
|
1253
|
+
for x in stride(from: spacing, to: size.width, by: spacing) {
|
|
1254
|
+
for y in stride(from: spacing, to: size.height, by: spacing) {
|
|
1255
|
+
context.fill(
|
|
1256
|
+
Path(ellipseIn: CGRect(x: x - 0.5, y: y - 0.5, width: 1, height: 1)),
|
|
1257
|
+
with: .color(dotColor)
|
|
1258
|
+
)
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
)
|
|
1264
|
+
.overlay(alignment: .top) {
|
|
1265
|
+
if let editor = controller.editor, editor.displays.count > 1 {
|
|
1266
|
+
displayToolbar(editor: editor)
|
|
1267
|
+
.padding(.top, 8)
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
.overlay(alignment: .bottomLeading) {
|
|
1271
|
+
canvasContextBadge
|
|
1272
|
+
}
|
|
1273
|
+
.overlay(
|
|
1274
|
+
GeometryReader { geo in
|
|
1275
|
+
Color.clear.onAppear {
|
|
1276
|
+
let frame = geo.frame(in: .global)
|
|
1277
|
+
screenMapCanvasOrigin = frame.origin
|
|
1278
|
+
screenMapCanvasSize = frame.size
|
|
1279
|
+
}
|
|
1280
|
+
.onChange(of: geo.frame(in: .global)) { newFrame in
|
|
1281
|
+
screenMapCanvasOrigin = newFrame.origin
|
|
1282
|
+
screenMapCanvasSize = newFrame.size
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
)
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// MARK: - Display Backgrounds
|
|
1289
|
+
|
|
1290
|
+
private func focusedDisplayBackground(focused: DisplayGeometry, editor: ScreenMapEditorState, mapW: CGFloat, mapH: CGFloat) -> some View {
|
|
1291
|
+
ZStack(alignment: .topLeading) {
|
|
1292
|
+
RoundedRectangle(cornerRadius: 6)
|
|
1293
|
+
.fill(Palette.bg.opacity(0.5))
|
|
1294
|
+
.overlay(
|
|
1295
|
+
RoundedRectangle(cornerRadius: 6)
|
|
1296
|
+
.strokeBorder(Palette.running.opacity(0.3), lineWidth: 1)
|
|
1297
|
+
)
|
|
1298
|
+
.contentShape(Rectangle())
|
|
1299
|
+
.onTapGesture { controller.clearSelection() }
|
|
1300
|
+
}
|
|
1301
|
+
.frame(width: mapW, height: mapH)
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
private func multiDisplayBackgrounds(displays: [DisplayGeometry], editor: ScreenMapEditorState?, effScale: CGFloat, bboxOrigin: CGPoint) -> some View {
|
|
1305
|
+
ForEach(displays, id: \.index) { disp in
|
|
1306
|
+
let dx = (disp.cgRect.origin.x - bboxOrigin.x) * effScale
|
|
1307
|
+
let dy = (disp.cgRect.origin.y - bboxOrigin.y) * effScale
|
|
1308
|
+
let dw = disp.cgRect.width * effScale
|
|
1309
|
+
let dh = disp.cgRect.height * effScale
|
|
1310
|
+
let bezel: CGFloat = 3
|
|
1311
|
+
|
|
1312
|
+
ZStack {
|
|
1313
|
+
RoundedRectangle(cornerRadius: 8)
|
|
1314
|
+
.fill(Color.white.opacity(0.07))
|
|
1315
|
+
.overlay(
|
|
1316
|
+
RoundedRectangle(cornerRadius: 8)
|
|
1317
|
+
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1.5)
|
|
1318
|
+
)
|
|
1319
|
+
RoundedRectangle(cornerRadius: 5)
|
|
1320
|
+
.fill(Palette.bg.opacity(0.55))
|
|
1321
|
+
.overlay(
|
|
1322
|
+
RoundedRectangle(cornerRadius: 5)
|
|
1323
|
+
.strokeBorder(Color.black.opacity(0.4), lineWidth: 0.5)
|
|
1324
|
+
)
|
|
1325
|
+
.padding(bezel)
|
|
1326
|
+
|
|
1327
|
+
// Display number badge (top-left corner)
|
|
1328
|
+
VStack {
|
|
1329
|
+
HStack {
|
|
1330
|
+
ZStack {
|
|
1331
|
+
Circle()
|
|
1332
|
+
.fill(Color.white.opacity(0.3))
|
|
1333
|
+
.frame(width: 16, height: 16)
|
|
1334
|
+
Text("\(editor?.spatialNumber(for: disp.index) ?? (disp.index + 1))")
|
|
1335
|
+
.font(.system(size: 8, weight: .bold, design: .monospaced))
|
|
1336
|
+
.foregroundColor(.black)
|
|
1337
|
+
}
|
|
1338
|
+
.padding(.top, bezel + 4)
|
|
1339
|
+
.padding(.leading, bezel + 4)
|
|
1340
|
+
Spacer()
|
|
1341
|
+
}
|
|
1342
|
+
Spacer()
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
.contentShape(Rectangle())
|
|
1346
|
+
.onTapGesture {
|
|
1347
|
+
editor?.focusDisplay(disp.index)
|
|
1348
|
+
controller.objectWillChange.send()
|
|
1349
|
+
}
|
|
1350
|
+
.frame(width: dw, height: dh)
|
|
1351
|
+
.offset(x: dx, y: dy)
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
private func singleDisplayBackground(displays: [DisplayGeometry], mapW: CGFloat, mapH: CGFloat) -> some View {
|
|
1356
|
+
ZStack(alignment: .topLeading) {
|
|
1357
|
+
RoundedRectangle(cornerRadius: 6)
|
|
1358
|
+
.fill(Palette.bg.opacity(0.5))
|
|
1359
|
+
.overlay(
|
|
1360
|
+
RoundedRectangle(cornerRadius: 6)
|
|
1361
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
1362
|
+
)
|
|
1363
|
+
.contentShape(Rectangle())
|
|
1364
|
+
.onTapGesture { controller.clearSelection() }
|
|
1365
|
+
|
|
1366
|
+
}
|
|
1367
|
+
.frame(width: mapW, height: mapH)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// MARK: - Window Tile
|
|
1371
|
+
|
|
1372
|
+
@ViewBuilder
|
|
1373
|
+
private func windowTile(win: ScreenMapWindowEntry, editor: ScreenMapEditorState?, scale: CGFloat, bboxOrigin: CGPoint = .zero) -> some View {
|
|
1374
|
+
let f = win.editedFrame
|
|
1375
|
+
let x = (f.origin.x - bboxOrigin.x) * scale
|
|
1376
|
+
let y = (f.origin.y - bboxOrigin.y) * scale
|
|
1377
|
+
let w = max(f.width * scale, 4)
|
|
1378
|
+
let h = max(f.height * scale, 4)
|
|
1379
|
+
let isSelected = controller.selectedWindowIds.contains(win.id)
|
|
1380
|
+
let isDragging = editor?.draggingWindowId == win.id
|
|
1381
|
+
let isInActiveLayer = editor?.isLayerSelected(win.layer) ?? true
|
|
1382
|
+
let winLayerColor = Self.layerColor(for: win.layer)
|
|
1383
|
+
let isSearchHighlighted = controller.searchHighlightedWindowId == win.id
|
|
1384
|
+
|
|
1385
|
+
let fillColor = isSearchHighlighted
|
|
1386
|
+
? Self.shelfGreen.opacity(0.2)
|
|
1387
|
+
: isSelected
|
|
1388
|
+
? Palette.running.opacity(0.18)
|
|
1389
|
+
: win.hasEdits ? Color.orange.opacity(0.12) : Palette.surface.opacity(0.7)
|
|
1390
|
+
let borderColor = isSearchHighlighted
|
|
1391
|
+
? Self.shelfGreen.opacity(0.8)
|
|
1392
|
+
: isSelected
|
|
1393
|
+
? Palette.running.opacity(0.8)
|
|
1394
|
+
: win.hasEdits ? Color.orange.opacity(0.6) : Palette.border.opacity(0.6)
|
|
1395
|
+
|
|
1396
|
+
Button {
|
|
1397
|
+
if NSEvent.modifierFlags.contains(.command) {
|
|
1398
|
+
controller.toggleSelection(win.id)
|
|
1399
|
+
} else {
|
|
1400
|
+
controller.selectSingle(win.id)
|
|
1401
|
+
}
|
|
1402
|
+
} label: {
|
|
1403
|
+
RoundedRectangle(cornerRadius: 2)
|
|
1404
|
+
.fill(fillColor)
|
|
1405
|
+
.overlay(
|
|
1406
|
+
RoundedRectangle(cornerRadius: 2)
|
|
1407
|
+
.strokeBorder(borderColor, lineWidth: isSearchHighlighted ? 2 : isSelected ? 1.5 : 0.5)
|
|
1408
|
+
)
|
|
1409
|
+
.overlay(alignment: .leading) {
|
|
1410
|
+
Rectangle()
|
|
1411
|
+
.fill(winLayerColor)
|
|
1412
|
+
.frame(width: 2)
|
|
1413
|
+
}
|
|
1414
|
+
.clipShape(RoundedRectangle(cornerRadius: 2))
|
|
1415
|
+
.overlay {
|
|
1416
|
+
ZStack {
|
|
1417
|
+
VStack(spacing: 1) {
|
|
1418
|
+
Text(win.app)
|
|
1419
|
+
.font(Typo.monoBold(max(7, min(10, h * 0.15))))
|
|
1420
|
+
.foregroundColor(isSelected ? Palette.running : Palette.text)
|
|
1421
|
+
.lineLimit(1)
|
|
1422
|
+
if h > 30 {
|
|
1423
|
+
Text(win.title)
|
|
1424
|
+
.font(Typo.mono(max(6, min(8, h * 0.1))))
|
|
1425
|
+
.foregroundColor(Palette.textDim)
|
|
1426
|
+
.lineLimit(1)
|
|
1427
|
+
}
|
|
1428
|
+
if h > 50 {
|
|
1429
|
+
Text("\(Int(win.originalFrame.width))x\(Int(win.originalFrame.height))")
|
|
1430
|
+
.font(Typo.mono(6))
|
|
1431
|
+
.foregroundColor(Palette.textMuted)
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
.padding(.leading, 4)
|
|
1435
|
+
.padding(2)
|
|
1436
|
+
|
|
1437
|
+
if h > 40, let tileIcon = Self.inferTileIcon(for: win, displays: editor?.displays ?? []) {
|
|
1438
|
+
VStack {
|
|
1439
|
+
HStack {
|
|
1440
|
+
Spacer()
|
|
1441
|
+
Image(systemName: tileIcon)
|
|
1442
|
+
.font(.system(size: 6))
|
|
1443
|
+
.foregroundColor(Color.white.opacity(0.3))
|
|
1444
|
+
.padding(2)
|
|
1445
|
+
}
|
|
1446
|
+
Spacer()
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
if h > 50, let session = Self.extractLatticesSession(from: win.title) {
|
|
1451
|
+
VStack {
|
|
1452
|
+
Spacer()
|
|
1453
|
+
HStack {
|
|
1454
|
+
Text("[\(session)]")
|
|
1455
|
+
.font(Typo.mono(6))
|
|
1456
|
+
.foregroundColor(Palette.running.opacity(0.7))
|
|
1457
|
+
.lineLimit(1)
|
|
1458
|
+
.padding(.leading, 4)
|
|
1459
|
+
.padding(.bottom, 2)
|
|
1460
|
+
Spacer()
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
.buttonStyle(.plain)
|
|
1468
|
+
.frame(width: w, height: h)
|
|
1469
|
+
.overlay {
|
|
1470
|
+
if isSelected && w > 30 && h > 20 {
|
|
1471
|
+
resizeHandles(width: w, height: h)
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
.onHover { isHovering in
|
|
1475
|
+
hoveredWindowId = isHovering ? win.id : (hoveredWindowId == win.id ? nil : hoveredWindowId)
|
|
1476
|
+
}
|
|
1477
|
+
.overlay {
|
|
1478
|
+
if isSearchHighlighted {
|
|
1479
|
+
RoundedRectangle(cornerRadius: 2)
|
|
1480
|
+
.strokeBorder(Self.shelfGreen.opacity(0.6), lineWidth: 2)
|
|
1481
|
+
.shadow(color: Self.shelfGreen.opacity(0.5), radius: 6)
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
.offset(x: x, y: y)
|
|
1485
|
+
.opacity(isInActiveLayer ? 1.0 : 0.3)
|
|
1486
|
+
.shadow(color: isDragging ? Palette.running.opacity(0.4) : .clear,
|
|
1487
|
+
radius: isDragging ? 6 : 0)
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
@ViewBuilder
|
|
1491
|
+
private func resizeHandles(width w: CGFloat, height h: CGFloat) -> some View {
|
|
1492
|
+
let dotSize: CGFloat = 5
|
|
1493
|
+
let barW: CGFloat = 8
|
|
1494
|
+
let barH: CGFloat = 3
|
|
1495
|
+
let handleColor = Palette.running.opacity(0.7)
|
|
1496
|
+
let halfDot = dotSize / 2
|
|
1497
|
+
|
|
1498
|
+
ZStack {
|
|
1499
|
+
// Corner dots
|
|
1500
|
+
Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
|
|
1501
|
+
.position(x: halfDot, y: halfDot)
|
|
1502
|
+
Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
|
|
1503
|
+
.position(x: w - halfDot, y: halfDot)
|
|
1504
|
+
Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
|
|
1505
|
+
.position(x: halfDot, y: h - halfDot)
|
|
1506
|
+
Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
|
|
1507
|
+
.position(x: w - halfDot, y: h - halfDot)
|
|
1508
|
+
|
|
1509
|
+
// Edge midpoint bars
|
|
1510
|
+
if w > 50 {
|
|
1511
|
+
RoundedRectangle(cornerRadius: 1).fill(handleColor)
|
|
1512
|
+
.frame(width: barW, height: barH)
|
|
1513
|
+
.position(x: w / 2, y: 1.5)
|
|
1514
|
+
RoundedRectangle(cornerRadius: 1).fill(handleColor)
|
|
1515
|
+
.frame(width: barW, height: barH)
|
|
1516
|
+
.position(x: w / 2, y: h - 1.5)
|
|
1517
|
+
}
|
|
1518
|
+
if h > 40 {
|
|
1519
|
+
RoundedRectangle(cornerRadius: 1).fill(handleColor)
|
|
1520
|
+
.frame(width: barH, height: barW)
|
|
1521
|
+
.position(x: 1.5, y: h / 2)
|
|
1522
|
+
RoundedRectangle(cornerRadius: 1).fill(handleColor)
|
|
1523
|
+
.frame(width: barH, height: barW)
|
|
1524
|
+
.position(x: w - 1.5, y: h / 2)
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
.allowsHitTesting(false)
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// MARK: - Canvas Zoom Controls
|
|
1531
|
+
|
|
1532
|
+
private func canvasZoomControls(editor: ScreenMapEditorState) -> some View {
|
|
1533
|
+
let pct = Int(editor.zoomLevel * 100)
|
|
1534
|
+
return HStack(spacing: 0) {
|
|
1535
|
+
Button {
|
|
1536
|
+
let newZoom = max(ScreenMapEditorState.minZoom, editor.zoomLevel - 0.25)
|
|
1537
|
+
editor.zoomLevel = newZoom
|
|
1538
|
+
editor.objectWillChange.send()
|
|
1539
|
+
controller.objectWillChange.send()
|
|
1540
|
+
} label: {
|
|
1541
|
+
Image(systemName: "minus")
|
|
1542
|
+
.font(.system(size: 9, weight: .medium))
|
|
1543
|
+
.frame(width: 22, height: 20)
|
|
1544
|
+
.contentShape(Rectangle())
|
|
1545
|
+
}
|
|
1546
|
+
.buttonStyle(.plain)
|
|
1547
|
+
|
|
1548
|
+
Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
|
|
1549
|
+
|
|
1550
|
+
Button {
|
|
1551
|
+
editor.resetZoomPan()
|
|
1552
|
+
controller.flash("Fit all")
|
|
1553
|
+
controller.objectWillChange.send()
|
|
1554
|
+
} label: {
|
|
1555
|
+
Text("\(pct)%")
|
|
1556
|
+
.font(Typo.mono(9))
|
|
1557
|
+
.frame(width: 40, height: 20)
|
|
1558
|
+
.contentShape(Rectangle())
|
|
1559
|
+
}
|
|
1560
|
+
.buttonStyle(.plain)
|
|
1561
|
+
|
|
1562
|
+
Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
|
|
1563
|
+
|
|
1564
|
+
Button {
|
|
1565
|
+
let newZoom = min(ScreenMapEditorState.maxZoom, editor.zoomLevel + 0.25)
|
|
1566
|
+
editor.zoomLevel = newZoom
|
|
1567
|
+
editor.objectWillChange.send()
|
|
1568
|
+
controller.objectWillChange.send()
|
|
1569
|
+
} label: {
|
|
1570
|
+
Image(systemName: "plus")
|
|
1571
|
+
.font(.system(size: 9, weight: .medium))
|
|
1572
|
+
.frame(width: 22, height: 20)
|
|
1573
|
+
.contentShape(Rectangle())
|
|
1574
|
+
}
|
|
1575
|
+
.buttonStyle(.plain)
|
|
1576
|
+
}
|
|
1577
|
+
.foregroundColor(Palette.textMuted)
|
|
1578
|
+
.background(
|
|
1579
|
+
RoundedRectangle(cornerRadius: 5)
|
|
1580
|
+
.fill(Color(red: 0.1, green: 0.1, blue: 0.11).opacity(0.85))
|
|
1581
|
+
.overlay(
|
|
1582
|
+
RoundedRectangle(cornerRadius: 5)
|
|
1583
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
1584
|
+
)
|
|
1585
|
+
)
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
private static let shelfGreen = Color(red: 0.18, green: 0.82, blue: 0.48)
|
|
1589
|
+
|
|
1590
|
+
// MARK: - Canvas Status Bar
|
|
1591
|
+
|
|
1592
|
+
private var canvasStatusBar: some View {
|
|
1593
|
+
VStack(spacing: 0) {
|
|
1594
|
+
Rectangle().fill(Color.white.opacity(0.04)).frame(height: 0.5)
|
|
1595
|
+
HStack(spacing: 6) {
|
|
1596
|
+
if let editor = controller.editor {
|
|
1597
|
+
let layerColor = editor.activeLayer != nil
|
|
1598
|
+
? Self.layerColor(for: editor.activeLayer!)
|
|
1599
|
+
: Palette.running
|
|
1600
|
+
Circle().fill(layerColor).frame(width: 5, height: 5)
|
|
1601
|
+
Text(editor.layerLabel)
|
|
1602
|
+
.font(Typo.monoBold(8))
|
|
1603
|
+
.foregroundColor(layerColor)
|
|
1604
|
+
Text("·").foregroundColor(Palette.textMuted).font(Typo.mono(7))
|
|
1605
|
+
Text("\(editor.focusedVisibleWindows.count) windows")
|
|
1606
|
+
.font(Typo.mono(8))
|
|
1607
|
+
.foregroundColor(Palette.textDim)
|
|
1608
|
+
if let focused = editor.focusedDisplay {
|
|
1609
|
+
Text("·").foregroundColor(Palette.textMuted).font(Typo.mono(7))
|
|
1610
|
+
Text(focused.label)
|
|
1611
|
+
.font(Typo.mono(8))
|
|
1612
|
+
.foregroundColor(Palette.textMuted)
|
|
1613
|
+
.lineLimit(1)
|
|
1614
|
+
}
|
|
1615
|
+
Spacer()
|
|
1616
|
+
let editCount = editor.windows.filter { $0.hasEdits }.count
|
|
1617
|
+
if editCount > 0 {
|
|
1618
|
+
Text("\(editCount) pending")
|
|
1619
|
+
.font(Typo.mono(7))
|
|
1620
|
+
.foregroundColor(Color.orange.opacity(0.7))
|
|
1621
|
+
}
|
|
1622
|
+
if let ref = editor.lastActionRef {
|
|
1623
|
+
Text(ref)
|
|
1624
|
+
.font(Typo.monoBold(8))
|
|
1625
|
+
.foregroundColor(Self.shelfGreen.opacity(0.6))
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
.padding(.horizontal, 10)
|
|
1630
|
+
.padding(.vertical, 4)
|
|
1631
|
+
}
|
|
1632
|
+
.background(Color(red: 0.08, green: 0.08, blue: 0.09))
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// MARK: - Footer Bar
|
|
1636
|
+
|
|
1637
|
+
// MARK: - Status Bar
|
|
1638
|
+
|
|
1639
|
+
private var footerBar: some View {
|
|
1640
|
+
VStack(spacing: 0) {
|
|
1641
|
+
Rectangle().fill(Palette.borderLit).frame(height: 0.5)
|
|
1642
|
+
HStack(spacing: 0) {
|
|
1643
|
+
// Left: server health + settings
|
|
1644
|
+
HStack(spacing: 6) {
|
|
1645
|
+
Circle()
|
|
1646
|
+
.fill(daemon.isListening ? Palette.running : Palette.kill)
|
|
1647
|
+
.frame(width: 6, height: 6)
|
|
1648
|
+
if daemon.isListening {
|
|
1649
|
+
Text("Serving")
|
|
1650
|
+
.font(Typo.monoBold(9))
|
|
1651
|
+
.foregroundColor(Palette.running.opacity(0.8))
|
|
1652
|
+
Text(":9399")
|
|
1653
|
+
.font(Typo.mono(9))
|
|
1654
|
+
.foregroundColor(Palette.textMuted)
|
|
1655
|
+
if daemon.clientCount > 0 {
|
|
1656
|
+
Text("·")
|
|
1657
|
+
.foregroundColor(Palette.textMuted)
|
|
1658
|
+
Text("\(daemon.clientCount) client\(daemon.clientCount == 1 ? "" : "s")")
|
|
1659
|
+
.font(Typo.mono(9))
|
|
1660
|
+
.foregroundColor(Palette.textDim)
|
|
1661
|
+
}
|
|
1662
|
+
} else {
|
|
1663
|
+
Text("Offline")
|
|
1664
|
+
.font(Typo.monoBold(9))
|
|
1665
|
+
.foregroundColor(Palette.kill.opacity(0.7))
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
Text("·").foregroundColor(Palette.textMuted)
|
|
1669
|
+
|
|
1670
|
+
statusBarButton(icon: "gearshape", label: "Settings") {
|
|
1671
|
+
onNavigate?(.settings)
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
Spacer()
|
|
1676
|
+
if let editor = controller.editor {
|
|
1677
|
+
if editor.pendingEditCount > 0 {
|
|
1678
|
+
Button {
|
|
1679
|
+
controller.applyEditsFromButton()
|
|
1680
|
+
} label: {
|
|
1681
|
+
HStack(spacing: 4) {
|
|
1682
|
+
Text("↩")
|
|
1683
|
+
.font(Typo.monoBold(9))
|
|
1684
|
+
.foregroundColor(Self.shelfGreen)
|
|
1685
|
+
Text("\(editor.pendingEditCount) pending")
|
|
1686
|
+
.font(Typo.monoBold(9))
|
|
1687
|
+
.foregroundColor(Color.orange.opacity(0.8))
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
.buttonStyle(.plain)
|
|
1691
|
+
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
1692
|
+
}
|
|
1693
|
+
if let ref = editor.lastActionRef {
|
|
1694
|
+
Text(ref)
|
|
1695
|
+
.font(Typo.monoBold(8))
|
|
1696
|
+
.foregroundColor(Self.shelfGreen.opacity(0.6))
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
Spacer()
|
|
1700
|
+
|
|
1701
|
+
// Quick keyboard hints
|
|
1702
|
+
HStack(spacing: 6) {
|
|
1703
|
+
if !controller.selectedWindowIds.isEmpty {
|
|
1704
|
+
footerHint("⌘↩", label: "show")
|
|
1705
|
+
}
|
|
1706
|
+
footerHint("/", label: "search")
|
|
1707
|
+
footerHint("q", label: "quit")
|
|
1708
|
+
}
|
|
1709
|
+
.padding(.trailing, 8)
|
|
1710
|
+
|
|
1711
|
+
// Right: docs + logs
|
|
1712
|
+
HStack(spacing: 10) {
|
|
1713
|
+
statusBarButton(icon: "book", label: "Docs") {
|
|
1714
|
+
onNavigate?(.docs)
|
|
1715
|
+
}
|
|
1716
|
+
statusBarButton(icon: "text.alignleft", label: "Logs") {
|
|
1717
|
+
DiagnosticWindow.shared.toggle()
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
.padding(.horizontal, 10)
|
|
1722
|
+
.padding(.vertical, 4)
|
|
1723
|
+
}
|
|
1724
|
+
.background(Color(red: 0.08, green: 0.08, blue: 0.09))
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
private func statusBarButton(icon: String, label: String, action: @escaping () -> Void) -> some View {
|
|
1728
|
+
Button(action: action) {
|
|
1729
|
+
HStack(spacing: 4) {
|
|
1730
|
+
Image(systemName: icon)
|
|
1731
|
+
.font(.system(size: 9))
|
|
1732
|
+
Text(label)
|
|
1733
|
+
.font(Typo.mono(9))
|
|
1734
|
+
}
|
|
1735
|
+
.foregroundColor(Palette.textMuted)
|
|
1736
|
+
.contentShape(Rectangle())
|
|
1737
|
+
}
|
|
1738
|
+
.buttonStyle(.plain)
|
|
1739
|
+
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
private func chordHint(key: String, label: String) -> some View {
|
|
1743
|
+
HStack(spacing: 4) {
|
|
1744
|
+
Text(key)
|
|
1745
|
+
.font(Typo.mono(9))
|
|
1746
|
+
.foregroundColor(Palette.text)
|
|
1747
|
+
.padding(.horizontal, 4)
|
|
1748
|
+
.padding(.vertical, 2)
|
|
1749
|
+
.background(
|
|
1750
|
+
RoundedRectangle(cornerRadius: 3)
|
|
1751
|
+
.fill(Palette.surface)
|
|
1752
|
+
.overlay(
|
|
1753
|
+
RoundedRectangle(cornerRadius: 3)
|
|
1754
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
1755
|
+
)
|
|
1756
|
+
)
|
|
1757
|
+
Text(label)
|
|
1758
|
+
.font(Typo.mono(9))
|
|
1759
|
+
.foregroundColor(Palette.textMuted)
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// MARK: - Sidebar Mini-Map
|
|
1764
|
+
|
|
1765
|
+
@ViewBuilder
|
|
1766
|
+
private func sidebarMiniMap(editor: ScreenMapEditorState) -> some View {
|
|
1767
|
+
let displays = editor.displays
|
|
1768
|
+
|
|
1769
|
+
if displays.count > 1 {
|
|
1770
|
+
let union: CGRect = {
|
|
1771
|
+
var u = displays[0].cgRect
|
|
1772
|
+
for d in displays.dropFirst() { u = u.union(d.cgRect) }
|
|
1773
|
+
return u
|
|
1774
|
+
}()
|
|
1775
|
+
let miniW: CGFloat = sidebarWidth - 28
|
|
1776
|
+
let scaleW = miniW / max(union.width, 1)
|
|
1777
|
+
let scaleH: CGFloat = 80 / max(union.height, 1)
|
|
1778
|
+
let scale = min(scaleW, scaleH)
|
|
1779
|
+
let contentW = union.width * scale
|
|
1780
|
+
let contentH = union.height * scale
|
|
1781
|
+
|
|
1782
|
+
VStack(spacing: 4) {
|
|
1783
|
+
ZStack {
|
|
1784
|
+
ZStack(alignment: .topLeading) {
|
|
1785
|
+
ForEach(displays, id: \.index) { disp in
|
|
1786
|
+
let isFocused = editor.focusedDisplayIndex == disp.index
|
|
1787
|
+
let dx = (disp.cgRect.origin.x - union.origin.x) * scale
|
|
1788
|
+
let dy = (disp.cgRect.origin.y - union.origin.y) * scale
|
|
1789
|
+
let dw = disp.cgRect.width * scale
|
|
1790
|
+
let dh = disp.cgRect.height * scale
|
|
1791
|
+
let inset: CGFloat = 1.5
|
|
1792
|
+
let fontSize: CGFloat = min(dw, dh) > 28 ? 11 : (min(dw, dh) > 16 ? 9 : 7)
|
|
1793
|
+
|
|
1794
|
+
Button {
|
|
1795
|
+
editor.focusDisplay(disp.index)
|
|
1796
|
+
controller.objectWillChange.send()
|
|
1797
|
+
} label: {
|
|
1798
|
+
ZStack {
|
|
1799
|
+
RoundedRectangle(cornerRadius: 3)
|
|
1800
|
+
.fill(isFocused ? Palette.running.opacity(0.15) : Color.white.opacity(0.06))
|
|
1801
|
+
RoundedRectangle(cornerRadius: 3)
|
|
1802
|
+
.strokeBorder(isFocused ? Palette.running.opacity(0.7) : Color.white.opacity(0.15), lineWidth: isFocused ? 1.5 : 0.5)
|
|
1803
|
+
Text("\(editor.spatialNumber(for: disp.index))")
|
|
1804
|
+
.font(.system(size: fontSize, weight: .bold, design: .monospaced))
|
|
1805
|
+
.foregroundColor(isFocused ? Palette.running : Color.white.opacity(0.35))
|
|
1806
|
+
}
|
|
1807
|
+
.frame(width: max(dw - inset * 2, 12), height: max(dh - inset * 2, 12))
|
|
1808
|
+
}
|
|
1809
|
+
.buttonStyle(.plain)
|
|
1810
|
+
.offset(x: dx + inset, y: dy + inset)
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
.frame(width: contentW, height: contentH)
|
|
1814
|
+
}
|
|
1815
|
+
.frame(width: miniW, height: max(contentH, 48), alignment: .topLeading)
|
|
1816
|
+
.clipped()
|
|
1817
|
+
|
|
1818
|
+
Button {
|
|
1819
|
+
editor.focusDisplay(nil)
|
|
1820
|
+
controller.objectWillChange.send()
|
|
1821
|
+
} label: {
|
|
1822
|
+
Text("ALL")
|
|
1823
|
+
.font(Typo.monoBold(7))
|
|
1824
|
+
.foregroundColor(editor.focusedDisplayIndex == nil ? Palette.running : Palette.textDim)
|
|
1825
|
+
.frame(maxWidth: .infinity)
|
|
1826
|
+
.padding(.vertical, 2)
|
|
1827
|
+
}
|
|
1828
|
+
.buttonStyle(.plain)
|
|
1829
|
+
}
|
|
1830
|
+
.padding(6)
|
|
1831
|
+
.background(
|
|
1832
|
+
RoundedRectangle(cornerRadius: 6)
|
|
1833
|
+
.fill(Color.black.opacity(0.4))
|
|
1834
|
+
.overlay(
|
|
1835
|
+
RoundedRectangle(cornerRadius: 6)
|
|
1836
|
+
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
|
1837
|
+
)
|
|
1838
|
+
)
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// MARK: - Flash Overlay
|
|
1843
|
+
|
|
1844
|
+
@ViewBuilder
|
|
1845
|
+
private var flashOverlay: some View {
|
|
1846
|
+
if let msg = controller.flashMessage {
|
|
1847
|
+
VStack {
|
|
1848
|
+
Spacer()
|
|
1849
|
+
HStack(spacing: 6) {
|
|
1850
|
+
Image(systemName: "rectangle.3.group")
|
|
1851
|
+
.font(.system(size: 11))
|
|
1852
|
+
Text(msg)
|
|
1853
|
+
.font(Typo.monoBold(11))
|
|
1854
|
+
}
|
|
1855
|
+
.foregroundColor(Palette.text)
|
|
1856
|
+
.padding(.horizontal, 14)
|
|
1857
|
+
.padding(.vertical, 8)
|
|
1858
|
+
.background(
|
|
1859
|
+
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
1860
|
+
.fill(Palette.surface)
|
|
1861
|
+
.overlay(
|
|
1862
|
+
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
1863
|
+
.strokeBorder(Palette.running.opacity(0.3), lineWidth: 0.5)
|
|
1864
|
+
)
|
|
1865
|
+
.shadow(color: .black.opacity(0.2), radius: 8, y: 2)
|
|
1866
|
+
)
|
|
1867
|
+
.padding(.bottom, 60)
|
|
1868
|
+
}
|
|
1869
|
+
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
|
1870
|
+
.animation(.easeOut(duration: 0.2), value: controller.flashMessage)
|
|
1871
|
+
.allowsHitTesting(false)
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
private var divider: some View {
|
|
1876
|
+
Rectangle()
|
|
1877
|
+
.fill(Palette.border)
|
|
1878
|
+
.frame(height: 0.5)
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// MARK: - Helpers
|
|
1882
|
+
|
|
1883
|
+
private func cacheGeometry(editor: ScreenMapEditorState?, fitScale: CGFloat? = nil, scale: CGFloat,
|
|
1884
|
+
offsetX: CGFloat, offsetY: CGFloat,
|
|
1885
|
+
screenSize: CGSize, bboxOrigin: CGPoint = .zero) {
|
|
1886
|
+
if let fs = fitScale { editor?.fitScale = fs }
|
|
1887
|
+
editor?.scale = scale
|
|
1888
|
+
editor?.mapOrigin = CGPoint(x: offsetX, y: offsetY)
|
|
1889
|
+
editor?.screenSize = screenSize
|
|
1890
|
+
editor?.bboxOrigin = bboxOrigin
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// MARK: - Layer Colors
|
|
1894
|
+
|
|
1895
|
+
private static let layerColors: [Color] = [
|
|
1896
|
+
.green, .cyan, .orange, .purple, .pink, .yellow, .mint, .indigo
|
|
1897
|
+
]
|
|
1898
|
+
|
|
1899
|
+
private static func layerColor(for layer: Int) -> Color {
|
|
1900
|
+
layerColors[layer % layerColors.count]
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
private static func inferTileIcon(for win: ScreenMapWindowEntry, displays: [DisplayGeometry]) -> String? {
|
|
1904
|
+
guard let disp = displays.first(where: { $0.index == win.displayIndex }) else { return nil }
|
|
1905
|
+
let screenW = disp.cgRect.width
|
|
1906
|
+
let screenH = disp.cgRect.height
|
|
1907
|
+
let relX = win.originalFrame.origin.x - disp.cgRect.origin.x
|
|
1908
|
+
let relY = win.originalFrame.origin.y - disp.cgRect.origin.y
|
|
1909
|
+
let winW = win.originalFrame.width
|
|
1910
|
+
let winH = win.originalFrame.height
|
|
1911
|
+
let tolerance: CGFloat = 30
|
|
1912
|
+
|
|
1913
|
+
for pos in TilePosition.allCases {
|
|
1914
|
+
let (fx, fy, fw, fh) = pos.rect
|
|
1915
|
+
let expectedX = fx * screenW
|
|
1916
|
+
let expectedY = fy * screenH
|
|
1917
|
+
let expectedW = fw * screenW
|
|
1918
|
+
let expectedH = fh * screenH
|
|
1919
|
+
if abs(relX - expectedX) < tolerance && abs(relY - expectedY) < tolerance
|
|
1920
|
+
&& abs(winW - expectedW) < tolerance && abs(winH - expectedH) < tolerance {
|
|
1921
|
+
return pos.icon
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return nil
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
private static func extractLatticesSession(from title: String) -> String? {
|
|
1928
|
+
guard let range = title.range(of: #"\[lattices:([^\]]+)\]"#, options: .regularExpression) else { return nil }
|
|
1929
|
+
let match = String(title[range])
|
|
1930
|
+
return String(match.dropFirst(9).dropLast(1))
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// MARK: - Layer Preview
|
|
1934
|
+
|
|
1935
|
+
private func handlePreviewChange(isPreviewing: Bool) {
|
|
1936
|
+
guard isPreviewing, let editor = controller.editor else { return }
|
|
1937
|
+
let screens = NSScreen.screens
|
|
1938
|
+
guard !screens.isEmpty else { return }
|
|
1939
|
+
|
|
1940
|
+
let primaryHeight = screens.first?.frame.height ?? 0
|
|
1941
|
+
|
|
1942
|
+
// Scope preview to the focused display's screen, or union of all
|
|
1943
|
+
let targetFrame: NSRect
|
|
1944
|
+
let cgOrigin: CGPoint
|
|
1945
|
+
if let focusedIdx = editor.focusedDisplayIndex, focusedIdx < screens.count {
|
|
1946
|
+
let screen = screens[focusedIdx]
|
|
1947
|
+
targetFrame = screen.frame
|
|
1948
|
+
cgOrigin = CGPoint(x: screen.frame.origin.x,
|
|
1949
|
+
y: primaryHeight - screen.frame.maxY)
|
|
1950
|
+
} else {
|
|
1951
|
+
var union = screens[0].frame
|
|
1952
|
+
for screen in screens.dropFirst() { union = union.union(screen.frame) }
|
|
1953
|
+
targetFrame = union
|
|
1954
|
+
cgOrigin = CGPoint(x: union.origin.x,
|
|
1955
|
+
y: primaryHeight - (union.origin.y + union.height))
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
let visible = editor.focusedVisibleWindows
|
|
1959
|
+
let label = editor.layerLabel
|
|
1960
|
+
let captures = controller.previewCaptures
|
|
1961
|
+
|
|
1962
|
+
let overlay = ScreenMapPreviewOverlay(
|
|
1963
|
+
windows: visible, layerLabel: label, captures: captures,
|
|
1964
|
+
screenFrame: targetFrame,
|
|
1965
|
+
screenCGOrigin: cgOrigin
|
|
1966
|
+
)
|
|
1967
|
+
let hostingView = NSHostingView(rootView: overlay)
|
|
1968
|
+
controller.showPreviewWindow(contentView: hostingView, frame: targetFrame)
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// MARK: - Key Handler
|
|
1972
|
+
|
|
1973
|
+
private func installKeyHandler() {
|
|
1974
|
+
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { event in
|
|
1975
|
+
// Only handle keys when our window is the key window
|
|
1976
|
+
guard let win = ScreenMapWindowController.shared.nsWindow,
|
|
1977
|
+
win.isKeyWindow else { return event }
|
|
1978
|
+
// Track space key for canvas drag-to-pan
|
|
1979
|
+
if event.keyCode == 49 && !controller.isSearchActive {
|
|
1980
|
+
if event.type == .keyDown && !event.isARepeat {
|
|
1981
|
+
isSpaceHeld = true
|
|
1982
|
+
NSCursor.openHand.push()
|
|
1983
|
+
return nil
|
|
1984
|
+
} else if event.type == .keyUp {
|
|
1985
|
+
isSpaceHeld = false
|
|
1986
|
+
spaceDragStart = nil
|
|
1987
|
+
NSCursor.pop()
|
|
1988
|
+
return nil
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
guard event.type == .keyDown else { return event }
|
|
1992
|
+
let consumed = controller.handleKey(event.keyCode, modifiers: event.modifierFlags)
|
|
1993
|
+
return consumed ? nil : event
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
private func removeKeyHandler() {
|
|
1998
|
+
if let monitor = eventMonitor {
|
|
1999
|
+
NSEvent.removeMonitor(monitor)
|
|
2000
|
+
eventMonitor = nil
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// MARK: - Mouse Monitors
|
|
2005
|
+
|
|
2006
|
+
private func installMouseMonitors() {
|
|
2007
|
+
let dragThreshold: CGFloat = 4
|
|
2008
|
+
|
|
2009
|
+
mouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { event in
|
|
2010
|
+
guard let eventWindow = event.window,
|
|
2011
|
+
eventWindow === ScreenMapWindowController.shared.nsWindow else { return event }
|
|
2012
|
+
|
|
2013
|
+
// Space+click → begin canvas pan
|
|
2014
|
+
if isSpaceHeld, let editor = controller.editor {
|
|
2015
|
+
spaceDragStart = event.locationInWindow
|
|
2016
|
+
spaceDragPanStart = editor.panOffset
|
|
2017
|
+
NSCursor.closedHand.push()
|
|
2018
|
+
return nil
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
if let hitId = hoveredWindowId, let editor = controller.editor {
|
|
2022
|
+
screenMapClickWindowId = hitId
|
|
2023
|
+
screenMapClickPoint = event.locationInWindow
|
|
2024
|
+
let flippedPt = flippedScreenPoint(event)
|
|
2025
|
+
if let hit = screenMapHitTestWithRect(flippedScreenPt: flippedPt, editor: editor) {
|
|
2026
|
+
editor.canvasDragMode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
|
|
2027
|
+
} else {
|
|
2028
|
+
editor.canvasDragMode = .move
|
|
2029
|
+
}
|
|
2030
|
+
} else {
|
|
2031
|
+
screenMapClickWindowId = nil
|
|
2032
|
+
}
|
|
2033
|
+
return event
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
mouseDragMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
|
|
2037
|
+
// Space+drag → pan canvas
|
|
2038
|
+
if isSpaceHeld, let start = spaceDragStart, let editor = controller.editor {
|
|
2039
|
+
let dx = event.locationInWindow.x - start.x
|
|
2040
|
+
let dy = event.locationInWindow.y - start.y
|
|
2041
|
+
editor.panOffset = CGPoint(x: spaceDragPanStart.x + dx, y: spaceDragPanStart.y - dy)
|
|
2042
|
+
editor.objectWillChange.send()
|
|
2043
|
+
controller.objectWillChange.send()
|
|
2044
|
+
return nil
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
guard let hitId = screenMapClickWindowId,
|
|
2048
|
+
let editor = controller.editor else { return event }
|
|
2049
|
+
let dx = event.locationInWindow.x - screenMapClickPoint.x
|
|
2050
|
+
let dy = event.locationInWindow.y - screenMapClickPoint.y
|
|
2051
|
+
guard sqrt(dx * dx + dy * dy) >= dragThreshold else { return event }
|
|
2052
|
+
|
|
2053
|
+
if editor.draggingWindowId != hitId {
|
|
2054
|
+
editor.draggingWindowId = hitId
|
|
2055
|
+
if let idx = editor.windows.firstIndex(where: { $0.id == hitId }) {
|
|
2056
|
+
editor.dragStartFrame = editor.windows[idx].editedFrame
|
|
2057
|
+
}
|
|
2058
|
+
controller.selectSingle(hitId)
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
let effScale = editor.effectiveScale
|
|
2062
|
+
guard let startFrame = editor.dragStartFrame,
|
|
2063
|
+
effScale > 0,
|
|
2064
|
+
let idx = editor.windows.firstIndex(where: { $0.id == hitId }) else { return event }
|
|
2065
|
+
let screenDx = dx / effScale
|
|
2066
|
+
let screenDy = -dy / effScale // CG coords: Y flipped
|
|
2067
|
+
let mode = editor.canvasDragMode
|
|
2068
|
+
let minW: CGFloat = 100
|
|
2069
|
+
let minH: CGFloat = 50
|
|
2070
|
+
|
|
2071
|
+
var newFrame = startFrame
|
|
2072
|
+
|
|
2073
|
+
switch mode {
|
|
2074
|
+
case .move:
|
|
2075
|
+
newFrame.origin.x = startFrame.origin.x + screenDx
|
|
2076
|
+
newFrame.origin.y = startFrame.origin.y + screenDy
|
|
2077
|
+
|
|
2078
|
+
case .resizeRight:
|
|
2079
|
+
newFrame.size.width = max(minW, startFrame.width + screenDx)
|
|
2080
|
+
case .resizeLeft:
|
|
2081
|
+
let dw = min(screenDx, startFrame.width - minW)
|
|
2082
|
+
newFrame.origin.x = startFrame.origin.x + dw
|
|
2083
|
+
newFrame.size.width = startFrame.width - dw
|
|
2084
|
+
case .resizeBottom:
|
|
2085
|
+
newFrame.size.height = max(minH, startFrame.height + screenDy)
|
|
2086
|
+
case .resizeTop:
|
|
2087
|
+
let dh = min(screenDy, startFrame.height - minH)
|
|
2088
|
+
newFrame.origin.y = startFrame.origin.y + dh
|
|
2089
|
+
newFrame.size.height = startFrame.height - dh
|
|
2090
|
+
|
|
2091
|
+
case .resizeTopLeft:
|
|
2092
|
+
let dw = min(screenDx, startFrame.width - minW)
|
|
2093
|
+
newFrame.origin.x = startFrame.origin.x + dw
|
|
2094
|
+
newFrame.size.width = startFrame.width - dw
|
|
2095
|
+
let dh = min(screenDy, startFrame.height - minH)
|
|
2096
|
+
newFrame.origin.y = startFrame.origin.y + dh
|
|
2097
|
+
newFrame.size.height = startFrame.height - dh
|
|
2098
|
+
case .resizeTopRight:
|
|
2099
|
+
newFrame.size.width = max(minW, startFrame.width + screenDx)
|
|
2100
|
+
let dh = min(screenDy, startFrame.height - minH)
|
|
2101
|
+
newFrame.origin.y = startFrame.origin.y + dh
|
|
2102
|
+
newFrame.size.height = startFrame.height - dh
|
|
2103
|
+
case .resizeBottomLeft:
|
|
2104
|
+
let dw = min(screenDx, startFrame.width - minW)
|
|
2105
|
+
newFrame.origin.x = startFrame.origin.x + dw
|
|
2106
|
+
newFrame.size.width = startFrame.width - dw
|
|
2107
|
+
newFrame.size.height = max(minH, startFrame.height + screenDy)
|
|
2108
|
+
case .resizeBottomRight:
|
|
2109
|
+
newFrame.size.width = max(minW, startFrame.width + screenDx)
|
|
2110
|
+
newFrame.size.height = max(minH, startFrame.height + screenDy)
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
editor.windows[idx].editedFrame = newFrame
|
|
2114
|
+
editor.objectWillChange.send()
|
|
2115
|
+
controller.objectWillChange.send()
|
|
2116
|
+
return nil
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
|
|
2120
|
+
// End space+drag pan
|
|
2121
|
+
if spaceDragStart != nil {
|
|
2122
|
+
spaceDragStart = nil
|
|
2123
|
+
NSCursor.pop() // pop closedHand, openHand remains
|
|
2124
|
+
return event
|
|
2125
|
+
}
|
|
2126
|
+
if screenMapClickWindowId != nil {
|
|
2127
|
+
if let editor = controller.editor, editor.draggingWindowId != nil {
|
|
2128
|
+
editor.draggingWindowId = nil
|
|
2129
|
+
editor.dragStartFrame = nil
|
|
2130
|
+
editor.canvasDragMode = .move
|
|
2131
|
+
editor.objectWillChange.send()
|
|
2132
|
+
}
|
|
2133
|
+
screenMapClickWindowId = nil
|
|
2134
|
+
}
|
|
2135
|
+
return event
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
rightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { event in
|
|
2139
|
+
guard let eventWindow = event.window,
|
|
2140
|
+
eventWindow === ScreenMapWindowController.shared.nsWindow,
|
|
2141
|
+
let editor = controller.editor else { return event }
|
|
2142
|
+
|
|
2143
|
+
let flippedPt = flippedScreenPoint(event)
|
|
2144
|
+
let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
|
|
2145
|
+
guard canvasRect.contains(flippedPt) else { return event }
|
|
2146
|
+
|
|
2147
|
+
if let hitId = screenMapHitTest(flippedScreenPt: flippedPt, editor: editor) {
|
|
2148
|
+
if !controller.isSelected(hitId) {
|
|
2149
|
+
controller.selectSingle(hitId)
|
|
2150
|
+
}
|
|
2151
|
+
showLayerContextMenu(for: hitId, at: event.locationInWindow, in: eventWindow, editor: editor)
|
|
2152
|
+
return nil
|
|
2153
|
+
}
|
|
2154
|
+
return event
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
|
|
2158
|
+
guard let eventWindow = event.window,
|
|
2159
|
+
eventWindow === ScreenMapWindowController.shared.nsWindow,
|
|
2160
|
+
let editor = controller.editor else { return event }
|
|
2161
|
+
|
|
2162
|
+
// Let search overlay handle its own scroll
|
|
2163
|
+
if controller.isSearchActive {
|
|
2164
|
+
let screenPt = event.locationInWindow
|
|
2165
|
+
let windowPt = eventWindow.convertPoint(toScreen: screenPt)
|
|
2166
|
+
let flippedY = NSScreen.main.map { $0.frame.height - windowPt.y } ?? windowPt.y
|
|
2167
|
+
let testPt = CGPoint(x: windowPt.x, y: flippedY)
|
|
2168
|
+
if searchOverlayFrame.contains(testPt) {
|
|
2169
|
+
return event // pass to SwiftUI ScrollView
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
let flippedPt = flippedScreenPoint(event)
|
|
2174
|
+
let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
|
|
2175
|
+
guard canvasRect.contains(flippedPt) else { return event }
|
|
2176
|
+
|
|
2177
|
+
let isZoom = event.modifierFlags.contains(.command) || !event.hasPreciseScrollingDeltas
|
|
2178
|
+
|
|
2179
|
+
if isZoom {
|
|
2180
|
+
let zoomDelta: CGFloat = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY * 0.01 : event.scrollingDeltaY * 0.05
|
|
2181
|
+
let oldZoom = editor.zoomLevel
|
|
2182
|
+
let newZoom = max(ScreenMapEditorState.minZoom, min(ScreenMapEditorState.maxZoom, oldZoom + zoomDelta))
|
|
2183
|
+
guard newZoom != oldZoom else { return nil }
|
|
2184
|
+
|
|
2185
|
+
let canvasLocal = CGPoint(
|
|
2186
|
+
x: flippedPt.x - screenMapCanvasOrigin.x,
|
|
2187
|
+
y: flippedPt.y - screenMapCanvasOrigin.y
|
|
2188
|
+
)
|
|
2189
|
+
let canvasCenterX = screenMapCanvasSize.width / 2
|
|
2190
|
+
let canvasCenterY = screenMapCanvasSize.height / 2
|
|
2191
|
+
let cursorFromCenter = CGPoint(
|
|
2192
|
+
x: canvasLocal.x - canvasCenterX,
|
|
2193
|
+
y: canvasLocal.y - canvasCenterY
|
|
2194
|
+
)
|
|
2195
|
+
|
|
2196
|
+
let ratio = newZoom / oldZoom
|
|
2197
|
+
let newPanX = cursorFromCenter.x - ratio * (cursorFromCenter.x - editor.panOffset.x)
|
|
2198
|
+
let newPanY = cursorFromCenter.y - ratio * (cursorFromCenter.y - editor.panOffset.y)
|
|
2199
|
+
|
|
2200
|
+
editor.zoomLevel = newZoom
|
|
2201
|
+
editor.panOffset = CGPoint(x: newPanX, y: newPanY)
|
|
2202
|
+
editor.objectWillChange.send()
|
|
2203
|
+
controller.objectWillChange.send()
|
|
2204
|
+
} else {
|
|
2205
|
+
editor.panOffset = CGPoint(
|
|
2206
|
+
x: editor.panOffset.x + event.scrollingDeltaX,
|
|
2207
|
+
y: editor.panOffset.y - event.scrollingDeltaY
|
|
2208
|
+
)
|
|
2209
|
+
editor.objectWillChange.send()
|
|
2210
|
+
controller.objectWillChange.send()
|
|
2211
|
+
}
|
|
2212
|
+
return nil
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
mouseMovedMonitor = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved) { event in
|
|
2216
|
+
guard let eventWindow = event.window,
|
|
2217
|
+
eventWindow === ScreenMapWindowController.shared.nsWindow,
|
|
2218
|
+
let editor = controller.editor else {
|
|
2219
|
+
resetCursorIfNeeded()
|
|
2220
|
+
return event
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
let flippedPt = flippedScreenPoint(event)
|
|
2224
|
+
let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
|
|
2225
|
+
guard canvasRect.contains(flippedPt) else {
|
|
2226
|
+
resetCursorIfNeeded()
|
|
2227
|
+
return event
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
if let hit = screenMapHitTestWithRect(flippedScreenPt: flippedPt, editor: editor) {
|
|
2231
|
+
let mode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
|
|
2232
|
+
if mode != editor.currentCursorMode {
|
|
2233
|
+
if editor.currentCursorMode != .move { NSCursor.pop() }
|
|
2234
|
+
editor.currentCursorMode = mode
|
|
2235
|
+
switch mode {
|
|
2236
|
+
case .resizeLeft, .resizeRight:
|
|
2237
|
+
NSCursor.resizeLeftRight.push()
|
|
2238
|
+
case .resizeTop, .resizeBottom:
|
|
2239
|
+
NSCursor.resizeUpDown.push()
|
|
2240
|
+
case .resizeTopLeft, .resizeTopRight, .resizeBottomLeft, .resizeBottomRight:
|
|
2241
|
+
NSCursor.crosshair.push()
|
|
2242
|
+
case .move:
|
|
2243
|
+
break
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
} else {
|
|
2247
|
+
resetCursorIfNeeded()
|
|
2248
|
+
}
|
|
2249
|
+
return event
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
private func resetCursorIfNeeded() {
|
|
2254
|
+
guard let editor = controller.editor else { return }
|
|
2255
|
+
if editor.currentCursorMode != .move {
|
|
2256
|
+
NSCursor.pop()
|
|
2257
|
+
editor.currentCursorMode = .move
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
private func removeMouseMonitors() {
|
|
2262
|
+
if let m = mouseDownMonitor { NSEvent.removeMonitor(m); mouseDownMonitor = nil }
|
|
2263
|
+
if let m = mouseDragMonitor { NSEvent.removeMonitor(m); mouseDragMonitor = nil }
|
|
2264
|
+
if let m = mouseUpMonitor { NSEvent.removeMonitor(m); mouseUpMonitor = nil }
|
|
2265
|
+
if let m = rightClickMonitor { NSEvent.removeMonitor(m); rightClickMonitor = nil }
|
|
2266
|
+
if let m = scrollWheelMonitor { NSEvent.removeMonitor(m); scrollWheelMonitor = nil }
|
|
2267
|
+
if let m = mouseMovedMonitor { NSEvent.removeMonitor(m); mouseMovedMonitor = nil }
|
|
2268
|
+
resetCursorIfNeeded()
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// MARK: - Hit Test / Coordinate Conversion
|
|
2272
|
+
|
|
2273
|
+
private func screenMapHitTest(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> UInt32? {
|
|
2274
|
+
let effScale = editor.effectiveScale
|
|
2275
|
+
let origin = editor.mapOrigin
|
|
2276
|
+
let panOffset = editor.panOffset
|
|
2277
|
+
guard effScale > 0 else { return nil }
|
|
2278
|
+
|
|
2279
|
+
let canvasLocal = CGPoint(
|
|
2280
|
+
x: flippedScreenPt.x - screenMapCanvasOrigin.x,
|
|
2281
|
+
y: flippedScreenPt.y - screenMapCanvasOrigin.y
|
|
2282
|
+
)
|
|
2283
|
+
let mapPoint = CGPoint(
|
|
2284
|
+
x: canvasLocal.x - 8 - origin.x - panOffset.x,
|
|
2285
|
+
y: canvasLocal.y - 8 - origin.y - panOffset.y
|
|
2286
|
+
)
|
|
2287
|
+
|
|
2288
|
+
let bboxOrig = editor.bboxOrigin
|
|
2289
|
+
let windowPool = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.windows
|
|
2290
|
+
let sorted = windowPool.sorted(by: { $0.zIndex < $1.zIndex })
|
|
2291
|
+
for win in sorted {
|
|
2292
|
+
let f = win.editedFrame
|
|
2293
|
+
let mapRect = CGRect(
|
|
2294
|
+
x: (f.origin.x - bboxOrig.x) * effScale,
|
|
2295
|
+
y: (f.origin.y - bboxOrig.y) * effScale,
|
|
2296
|
+
width: max(f.width * effScale, 4),
|
|
2297
|
+
height: max(f.height * effScale, 4)
|
|
2298
|
+
)
|
|
2299
|
+
if mapRect.contains(mapPoint) { return win.id }
|
|
2300
|
+
}
|
|
2301
|
+
return nil
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
private func screenMapHitTestWithRect(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> (id: UInt32, mapRect: CGRect, mapPoint: CGPoint)? {
|
|
2305
|
+
let effScale = editor.effectiveScale
|
|
2306
|
+
let origin = editor.mapOrigin
|
|
2307
|
+
let panOff = editor.panOffset
|
|
2308
|
+
guard effScale > 0 else { return nil }
|
|
2309
|
+
|
|
2310
|
+
let canvasLocal = CGPoint(
|
|
2311
|
+
x: flippedScreenPt.x - screenMapCanvasOrigin.x,
|
|
2312
|
+
y: flippedScreenPt.y - screenMapCanvasOrigin.y
|
|
2313
|
+
)
|
|
2314
|
+
let mapPoint = CGPoint(
|
|
2315
|
+
x: canvasLocal.x - 8 - origin.x - panOff.x,
|
|
2316
|
+
y: canvasLocal.y - 8 - origin.y - panOff.y
|
|
2317
|
+
)
|
|
2318
|
+
|
|
2319
|
+
let bboxOrig = editor.bboxOrigin
|
|
2320
|
+
let windowPool = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.windows
|
|
2321
|
+
let sorted = windowPool.sorted(by: { $0.zIndex < $1.zIndex })
|
|
2322
|
+
for win in sorted {
|
|
2323
|
+
let f = win.editedFrame
|
|
2324
|
+
let mapRect = CGRect(
|
|
2325
|
+
x: (f.origin.x - bboxOrig.x) * effScale,
|
|
2326
|
+
y: (f.origin.y - bboxOrig.y) * effScale,
|
|
2327
|
+
width: max(f.width * effScale, 4),
|
|
2328
|
+
height: max(f.height * effScale, 4)
|
|
2329
|
+
)
|
|
2330
|
+
if mapRect.contains(mapPoint) { return (win.id, mapRect, mapPoint) }
|
|
2331
|
+
}
|
|
2332
|
+
return nil
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
private func detectDragMode(mapPoint: CGPoint, windowMapRect: CGRect) -> CanvasDragMode {
|
|
2336
|
+
let w = windowMapRect.width
|
|
2337
|
+
let h = windowMapRect.height
|
|
2338
|
+
let threshold = max(4, min(8, min(w, h) * 0.25))
|
|
2339
|
+
|
|
2340
|
+
let nearLeft = mapPoint.x - windowMapRect.minX < threshold
|
|
2341
|
+
let nearRight = windowMapRect.maxX - mapPoint.x < threshold
|
|
2342
|
+
let nearTop = mapPoint.y - windowMapRect.minY < threshold
|
|
2343
|
+
let nearBottom = windowMapRect.maxY - mapPoint.y < threshold
|
|
2344
|
+
|
|
2345
|
+
// Corners take priority
|
|
2346
|
+
if nearTop && nearLeft { return .resizeTopLeft }
|
|
2347
|
+
if nearTop && nearRight { return .resizeTopRight }
|
|
2348
|
+
if nearBottom && nearLeft { return .resizeBottomLeft }
|
|
2349
|
+
if nearBottom && nearRight { return .resizeBottomRight }
|
|
2350
|
+
|
|
2351
|
+
// Edges
|
|
2352
|
+
if nearLeft { return .resizeLeft }
|
|
2353
|
+
if nearRight { return .resizeRight }
|
|
2354
|
+
if nearTop { return .resizeTop }
|
|
2355
|
+
if nearBottom { return .resizeBottom }
|
|
2356
|
+
|
|
2357
|
+
return .move
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
private func flippedScreenPoint(_ event: NSEvent) -> CGPoint {
|
|
2361
|
+
guard let nsWindow = event.window else { return .zero }
|
|
2362
|
+
let loc = event.locationInWindow
|
|
2363
|
+
let windowHeight = nsWindow.contentView?.frame.height ?? nsWindow.frame.height
|
|
2364
|
+
return CGPoint(x: loc.x, y: windowHeight - loc.y)
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
// MARK: - Context Menu
|
|
2368
|
+
|
|
2369
|
+
private func showLayerContextMenu(for windowId: UInt32, at point: NSPoint, in window: NSWindow, editor: ScreenMapEditorState) {
|
|
2370
|
+
guard let winIdx = editor.windows.firstIndex(where: { $0.id == windowId }) else { return }
|
|
2371
|
+
let win = editor.windows[winIdx]
|
|
2372
|
+
let currentLayer = win.layer
|
|
2373
|
+
|
|
2374
|
+
let menu = NSMenu()
|
|
2375
|
+
let header = NSMenuItem(title: "\(win.app) — Layer \(currentLayer)", action: nil, keyEquivalent: "")
|
|
2376
|
+
header.isEnabled = false
|
|
2377
|
+
menu.addItem(header)
|
|
2378
|
+
menu.addItem(.separator())
|
|
2379
|
+
|
|
2380
|
+
// Focus window on screen
|
|
2381
|
+
let focusItem = NSMenuItem(title: "Show on Screen ⌘↩", action: nil, keyEquivalent: "")
|
|
2382
|
+
focusItem.representedObject = ScreenMapFocusMenuAction(windowId: windowId, controller: controller)
|
|
2383
|
+
focusItem.action = #selector(ScreenMapMenuTarget.performFocus(_:))
|
|
2384
|
+
focusItem.target = ScreenMapMenuTarget.shared
|
|
2385
|
+
menu.addItem(focusItem)
|
|
2386
|
+
|
|
2387
|
+
menu.addItem(.separator())
|
|
2388
|
+
|
|
2389
|
+
// Move to Layer → submenu
|
|
2390
|
+
let moveItem = NSMenuItem(title: "Move to Layer", action: nil, keyEquivalent: "")
|
|
2391
|
+
let layerSubmenu = NSMenu()
|
|
2392
|
+
|
|
2393
|
+
for layer in editor.effectiveLayers where layer != currentLayer {
|
|
2394
|
+
let name = editor.layerDisplayName(for: layer)
|
|
2395
|
+
let count = editor.effectiveWindowCount(for: layer)
|
|
2396
|
+
let item = NSMenuItem(title: "\(name) (\(count) windows)", action: nil, keyEquivalent: "")
|
|
2397
|
+
item.representedObject = ScreenMapLayerMenuAction(windowId: windowId, targetLayer: layer, editor: editor, controller: controller)
|
|
2398
|
+
item.action = #selector(ScreenMapMenuTarget.performLayerMove(_:))
|
|
2399
|
+
item.target = ScreenMapMenuTarget.shared
|
|
2400
|
+
layerSubmenu.addItem(item)
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
layerSubmenu.addItem(.separator())
|
|
2404
|
+
let newLayerItem = NSMenuItem(title: "New Layer", action: nil, keyEquivalent: "")
|
|
2405
|
+
newLayerItem.representedObject = ScreenMapLayerMenuAction(windowId: windowId, targetLayer: editor.layerCount, editor: editor, controller: controller)
|
|
2406
|
+
newLayerItem.action = #selector(ScreenMapMenuTarget.performLayerMove(_:))
|
|
2407
|
+
newLayerItem.target = ScreenMapMenuTarget.shared
|
|
2408
|
+
layerSubmenu.addItem(newLayerItem)
|
|
2409
|
+
|
|
2410
|
+
moveItem.submenu = layerSubmenu
|
|
2411
|
+
menu.addItem(moveItem)
|
|
2412
|
+
|
|
2413
|
+
// Convert window coordinates to contentView coordinates for correct menu positioning
|
|
2414
|
+
let menuPoint: NSPoint
|
|
2415
|
+
if let contentView = window.contentView {
|
|
2416
|
+
menuPoint = contentView.convert(point, from: nil)
|
|
2417
|
+
} else {
|
|
2418
|
+
menuPoint = point
|
|
2419
|
+
}
|
|
2420
|
+
menu.popUp(positioning: nil, at: menuPoint, in: window.contentView)
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
// MARK: - Context Menu Helpers
|
|
2425
|
+
|
|
2426
|
+
struct ScreenMapLayerMenuAction {
|
|
2427
|
+
let windowId: UInt32
|
|
2428
|
+
let targetLayer: Int
|
|
2429
|
+
let editor: ScreenMapEditorState
|
|
2430
|
+
let controller: ScreenMapController
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
struct ScreenMapFocusMenuAction {
|
|
2434
|
+
let windowId: UInt32
|
|
2435
|
+
let controller: ScreenMapController
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
final class ScreenMapMenuTarget: NSObject {
|
|
2439
|
+
static let shared = ScreenMapMenuTarget()
|
|
2440
|
+
|
|
2441
|
+
@objc func performLayerMove(_ sender: NSMenuItem) {
|
|
2442
|
+
guard let action = sender.representedObject as? ScreenMapLayerMenuAction else { return }
|
|
2443
|
+
action.editor.reassignLayer(windowId: action.windowId, toLayer: action.targetLayer, fitToAvailable: true)
|
|
2444
|
+
action.controller.objectWillChange.send()
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
@objc func performFocus(_ sender: NSMenuItem) {
|
|
2448
|
+
guard let action = sender.representedObject as? ScreenMapFocusMenuAction else { return }
|
|
2449
|
+
action.controller.focusWindowOnScreen(action.windowId)
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
// MARK: - Preview Overlay
|
|
2454
|
+
|
|
2455
|
+
struct ScreenMapPreviewOverlay: View {
|
|
2456
|
+
let windows: [ScreenMapWindowEntry]
|
|
2457
|
+
let layerLabel: String
|
|
2458
|
+
let captures: [UInt32: NSImage]
|
|
2459
|
+
let screenFrame: CGRect
|
|
2460
|
+
let screenCGOrigin: CGPoint
|
|
2461
|
+
|
|
2462
|
+
private static let layerColors: [Color] = [
|
|
2463
|
+
.green, .cyan, .orange, .purple, .pink, .yellow, .mint, .indigo
|
|
2464
|
+
]
|
|
2465
|
+
|
|
2466
|
+
var body: some View {
|
|
2467
|
+
ZStack(alignment: .topLeading) {
|
|
2468
|
+
Color.black.opacity(0.88)
|
|
2469
|
+
|
|
2470
|
+
ForEach(windows) { win in
|
|
2471
|
+
let f = win.editedFrame
|
|
2472
|
+
let x = f.origin.x - screenCGOrigin.x
|
|
2473
|
+
let y = f.origin.y - screenCGOrigin.y
|
|
2474
|
+
let w = f.width
|
|
2475
|
+
let h = f.height
|
|
2476
|
+
let color = Self.layerColors[win.layer % Self.layerColors.count]
|
|
2477
|
+
|
|
2478
|
+
ZStack {
|
|
2479
|
+
RoundedRectangle(cornerRadius: 6)
|
|
2480
|
+
.fill(color.opacity(0.12))
|
|
2481
|
+
RoundedRectangle(cornerRadius: 6)
|
|
2482
|
+
.strokeBorder(color.opacity(0.7), lineWidth: 2)
|
|
2483
|
+
|
|
2484
|
+
VStack(spacing: 4) {
|
|
2485
|
+
Text(win.app)
|
|
2486
|
+
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
|
2487
|
+
.foregroundColor(.white)
|
|
2488
|
+
if !win.title.isEmpty && h > 60 {
|
|
2489
|
+
Text(win.title)
|
|
2490
|
+
.font(.system(size: 10, design: .monospaced))
|
|
2491
|
+
.foregroundColor(.white.opacity(0.6))
|
|
2492
|
+
.lineLimit(1)
|
|
2493
|
+
}
|
|
2494
|
+
if h > 40 {
|
|
2495
|
+
Text("\(Int(w)) × \(Int(h))")
|
|
2496
|
+
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
2497
|
+
.foregroundColor(color.opacity(0.7))
|
|
2498
|
+
}
|
|
2499
|
+
if win.hasEdits && h > 80 {
|
|
2500
|
+
Text("L\(win.layer)")
|
|
2501
|
+
.font(.system(size: 9, weight: .medium, design: .monospaced))
|
|
2502
|
+
.foregroundColor(color.opacity(0.5))
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
.padding(8)
|
|
2506
|
+
}
|
|
2507
|
+
.shadow(color: color.opacity(0.3), radius: 8)
|
|
2508
|
+
.frame(width: w, height: h)
|
|
2509
|
+
.offset(x: x, y: y)
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
VStack {
|
|
2513
|
+
Spacer()
|
|
2514
|
+
HStack {
|
|
2515
|
+
Spacer()
|
|
2516
|
+
Text("\(layerLabel) • \(windows.count) windows • click or press any key to dismiss")
|
|
2517
|
+
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
|
2518
|
+
.foregroundColor(.white)
|
|
2519
|
+
.padding(.horizontal, 16)
|
|
2520
|
+
.padding(.vertical, 8)
|
|
2521
|
+
.background(Color.black.opacity(0.7))
|
|
2522
|
+
.cornerRadius(8)
|
|
2523
|
+
.padding(20)
|
|
2524
|
+
Spacer()
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
.frame(width: screenFrame.width, height: screenFrame.height)
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// MARK: - Layer Row Frame Preference Key
|
|
2533
|
+
|
|
2534
|
+
private struct LayerRowFrameKey: PreferenceKey {
|
|
2535
|
+
static var defaultValue: [Int: CGRect] = [:]
|
|
2536
|
+
static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
|
|
2537
|
+
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
// MARK: - Show on Screen Bezel
|
|
2542
|
+
|
|
2543
|
+
struct ShowOnScreenBezelView: View {
|
|
2544
|
+
let appName: String
|
|
2545
|
+
let windowTitle: String
|
|
2546
|
+
let displayName: String
|
|
2547
|
+
let displayNumber: Int
|
|
2548
|
+
let layerName: String
|
|
2549
|
+
let windowSize: String
|
|
2550
|
+
let windowsOnDisplay: Int
|
|
2551
|
+
let layersOnDisplay: Int
|
|
2552
|
+
let windowLocalFrame: CGRect // NS coordinates relative to tight window
|
|
2553
|
+
let screenSize: CGSize // tight window size (not full screen)
|
|
2554
|
+
let labelPlacement: LabelPlacement
|
|
2555
|
+
let flush: FlushEdges
|
|
2556
|
+
let windowSnapshot: NSImage? // pre-captured window content for screenshot tools
|
|
2557
|
+
|
|
2558
|
+
enum LabelPlacement { case below, above, right, left }
|
|
2559
|
+
|
|
2560
|
+
/// Which edges of the window are flush with the screen boundary
|
|
2561
|
+
struct FlushEdges {
|
|
2562
|
+
let top: Bool
|
|
2563
|
+
let bottom: Bool
|
|
2564
|
+
let left: Bool
|
|
2565
|
+
let right: Bool
|
|
2566
|
+
static let none = FlushEdges(top: false, bottom: false, left: false, right: false)
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// Inverted from OS appearance so bezel contrasts with desktop:
|
|
2570
|
+
// Dark mode desktop → light bezel, Light mode desktop → dark bezel
|
|
2571
|
+
@Environment(\.colorScheme) private var colorScheme
|
|
2572
|
+
|
|
2573
|
+
private let accent = Color(red: 0.13, green: 0.62, blue: 0.38)
|
|
2574
|
+
|
|
2575
|
+
private var bg: Color {
|
|
2576
|
+
colorScheme == .dark
|
|
2577
|
+
? Color(red: 0.92, green: 0.92, blue: 0.93)
|
|
2578
|
+
: Color(red: 0.16, green: 0.16, blue: 0.18)
|
|
2579
|
+
}
|
|
2580
|
+
private var textPrimary: Color {
|
|
2581
|
+
colorScheme == .dark
|
|
2582
|
+
? Color(red: 0.10, green: 0.10, blue: 0.12)
|
|
2583
|
+
: Color(red: 0.95, green: 0.95, blue: 0.97)
|
|
2584
|
+
}
|
|
2585
|
+
private var textSecondary: Color {
|
|
2586
|
+
colorScheme == .dark
|
|
2587
|
+
? Color(red: 0.35, green: 0.35, blue: 0.38)
|
|
2588
|
+
: Color(red: 0.68, green: 0.68, blue: 0.72)
|
|
2589
|
+
}
|
|
2590
|
+
private var textTertiary: Color {
|
|
2591
|
+
colorScheme == .dark
|
|
2592
|
+
? Color(red: 0.55, green: 0.55, blue: 0.58)
|
|
2593
|
+
: Color(red: 0.48, green: 0.48, blue: 0.52)
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
// ZStack uses top-left origin; convert from NS bottom-left
|
|
2597
|
+
private var winX: CGFloat { windowLocalFrame.origin.x }
|
|
2598
|
+
private var winY: CGFloat { screenSize.height - windowLocalFrame.origin.y - windowLocalFrame.height }
|
|
2599
|
+
private var winW: CGFloat { windowLocalFrame.width }
|
|
2600
|
+
private var winH: CGFloat { windowLocalFrame.height }
|
|
2601
|
+
|
|
2602
|
+
// Frame dimensions
|
|
2603
|
+
private let edge: CGFloat = 5 // border thickness on non-flush edges
|
|
2604
|
+
private let shelfHeight: CGFloat = 40 // info shelf thickness
|
|
2605
|
+
private let cornerR: CGFloat = 10 // matches macOS window corners
|
|
2606
|
+
|
|
2607
|
+
// Edge insets: 0 on flush edges, `edge` on free edges
|
|
2608
|
+
private var insetTop: CGFloat { flush.top ? 0 : edge }
|
|
2609
|
+
private var insetBottom: CGFloat { flush.bottom ? 0 : edge }
|
|
2610
|
+
private var insetLeft: CGFloat { flush.left ? 0 : edge }
|
|
2611
|
+
private var insetRight: CGFloat { flush.right ? 0 : edge }
|
|
2612
|
+
|
|
2613
|
+
// Corner radii: 0 if either adjacent edge is flush
|
|
2614
|
+
private var rTL: CGFloat { (flush.top || flush.left) ? 0 : cornerR }
|
|
2615
|
+
private var rTR: CGFloat { (flush.top || flush.right) ? 0 : cornerR }
|
|
2616
|
+
private var rBL: CGFloat { (flush.bottom || flush.left) ? 0 : cornerR }
|
|
2617
|
+
private var rBR: CGFloat { (flush.bottom || flush.right) ? 0 : cornerR }
|
|
2618
|
+
|
|
2619
|
+
var body: some View {
|
|
2620
|
+
ZStack(alignment: .topLeading) {
|
|
2621
|
+
Color.clear
|
|
2622
|
+
|
|
2623
|
+
// Frame origin and size, accounting for flush edges and shelf placement
|
|
2624
|
+
let frameX = winX - insetLeft + shelfOffsetX
|
|
2625
|
+
let frameY = winY - insetTop + shelfOffsetY
|
|
2626
|
+
let frameW = winW + insetLeft + insetRight + shelfExtraW
|
|
2627
|
+
let frameH = winH + insetTop + insetBottom + shelfExtraH
|
|
2628
|
+
|
|
2629
|
+
// Adjust corner radii for shelf side
|
|
2630
|
+
let finalTL = adjustedCornerRadius(rTL, forShelf: labelPlacement, corner: .topLeft)
|
|
2631
|
+
let finalTR = adjustedCornerRadius(rTR, forShelf: labelPlacement, corner: .topRight)
|
|
2632
|
+
let finalBL = adjustedCornerRadius(rBL, forShelf: labelPlacement, corner: .bottomLeft)
|
|
2633
|
+
let finalBR = adjustedCornerRadius(rBR, forShelf: labelPlacement, corner: .bottomRight)
|
|
2634
|
+
|
|
2635
|
+
UnevenRoundedRectangle(
|
|
2636
|
+
topLeadingRadius: finalTL,
|
|
2637
|
+
bottomLeadingRadius: finalBL,
|
|
2638
|
+
bottomTrailingRadius: finalBR,
|
|
2639
|
+
topTrailingRadius: finalTR
|
|
2640
|
+
)
|
|
2641
|
+
.fill(bg)
|
|
2642
|
+
.frame(width: frameW, height: frameH)
|
|
2643
|
+
.offset(x: frameX, y: frameY)
|
|
2644
|
+
|
|
2645
|
+
// Window snapshot — baked into the bezel so screenshot tools get the full composite
|
|
2646
|
+
if let snapshot = windowSnapshot {
|
|
2647
|
+
Image(nsImage: snapshot)
|
|
2648
|
+
.resizable()
|
|
2649
|
+
.interpolation(.high)
|
|
2650
|
+
.frame(width: winW, height: winH)
|
|
2651
|
+
.clipped()
|
|
2652
|
+
.offset(x: winX, y: winY)
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
// Shelf content
|
|
2656
|
+
switch labelPlacement {
|
|
2657
|
+
case .below:
|
|
2658
|
+
shelfContent
|
|
2659
|
+
.frame(width: winW + insetLeft + insetRight - 8, height: shelfHeight - 4)
|
|
2660
|
+
.offset(x: winX - insetLeft + 4, y: winY + winH + insetBottom)
|
|
2661
|
+
case .above:
|
|
2662
|
+
shelfContent
|
|
2663
|
+
.frame(width: winW + insetLeft + insetRight - 8, height: shelfHeight - 4)
|
|
2664
|
+
.offset(x: winX - insetLeft + 4, y: winY - insetTop - shelfHeight + 4)
|
|
2665
|
+
case .right:
|
|
2666
|
+
sideShelfContent
|
|
2667
|
+
.frame(width: 190, height: winH + insetTop + insetBottom)
|
|
2668
|
+
.offset(x: winX + winW + insetRight + 4, y: winY - insetTop)
|
|
2669
|
+
case .left:
|
|
2670
|
+
sideShelfContent
|
|
2671
|
+
.frame(width: 190, height: winH + insetTop + insetBottom)
|
|
2672
|
+
.offset(x: winX - insetLeft - 194, y: winY - insetTop)
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
.frame(width: screenSize.width, height: screenSize.height)
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// MARK: - Shelf geometry helpers
|
|
2679
|
+
|
|
2680
|
+
/// How much extra width/height the shelf adds to the frame
|
|
2681
|
+
private var shelfExtraW: CGFloat {
|
|
2682
|
+
switch labelPlacement {
|
|
2683
|
+
case .below, .above: return 0
|
|
2684
|
+
case .right, .left: return 200
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
private var shelfExtraH: CGFloat {
|
|
2688
|
+
switch labelPlacement {
|
|
2689
|
+
case .below, .above: return shelfHeight
|
|
2690
|
+
case .right, .left: return 0
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
/// Offset the frame origin for shelf on top/left
|
|
2695
|
+
private var shelfOffsetX: CGFloat {
|
|
2696
|
+
labelPlacement == .left ? -200 : 0
|
|
2697
|
+
}
|
|
2698
|
+
private var shelfOffsetY: CGFloat {
|
|
2699
|
+
labelPlacement == .above ? -shelfHeight : 0
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
private enum Corner { case topLeft, topRight, bottomLeft, bottomRight }
|
|
2703
|
+
|
|
2704
|
+
/// Ensure the shelf-side corners are rounded even if the window edge is flush there
|
|
2705
|
+
private func adjustedCornerRadius(_ base: CGFloat, forShelf shelf: LabelPlacement, corner: Corner) -> CGFloat {
|
|
2706
|
+
// The shelf extends outward from the window, so its outer corners should be rounded
|
|
2707
|
+
switch (shelf, corner) {
|
|
2708
|
+
case (.below, .bottomLeft), (.below, .bottomRight):
|
|
2709
|
+
return cornerR
|
|
2710
|
+
case (.above, .topLeft), (.above, .topRight):
|
|
2711
|
+
return cornerR
|
|
2712
|
+
case (.right, .topRight), (.right, .bottomRight):
|
|
2713
|
+
return cornerR
|
|
2714
|
+
case (.left, .topLeft), (.left, .bottomLeft):
|
|
2715
|
+
return cornerR
|
|
2716
|
+
default:
|
|
2717
|
+
return base
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// MARK: - Horizontal shelf (bottom / top)
|
|
2722
|
+
|
|
2723
|
+
private var shelfContent: some View {
|
|
2724
|
+
HStack(spacing: 8) {
|
|
2725
|
+
// App name — distinctive rounded font
|
|
2726
|
+
Text(appName)
|
|
2727
|
+
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
|
2728
|
+
.foregroundColor(textPrimary)
|
|
2729
|
+
.lineLimit(1)
|
|
2730
|
+
|
|
2731
|
+
if !windowTitle.isEmpty {
|
|
2732
|
+
Text("·")
|
|
2733
|
+
.foregroundColor(textTertiary)
|
|
2734
|
+
Text(windowTitle)
|
|
2735
|
+
.font(.system(size: 10, design: .monospaced))
|
|
2736
|
+
.foregroundColor(textSecondary)
|
|
2737
|
+
.lineLimit(1)
|
|
2738
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
2739
|
+
} else {
|
|
2740
|
+
Spacer()
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
bezelTag(layerName, color: accent)
|
|
2744
|
+
bezelTag(windowSize, color: textSecondary)
|
|
2745
|
+
|
|
2746
|
+
// Display badge
|
|
2747
|
+
HStack(spacing: 3) {
|
|
2748
|
+
Image(systemName: "display")
|
|
2749
|
+
.font(.system(size: 9))
|
|
2750
|
+
.foregroundColor(textTertiary)
|
|
2751
|
+
Text("\(displayNumber)")
|
|
2752
|
+
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
|
2753
|
+
.foregroundColor(textSecondary)
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
.padding(.horizontal, 10)
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// MARK: - Side shelf (right)
|
|
2760
|
+
|
|
2761
|
+
private var sideShelfContent: some View {
|
|
2762
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
2763
|
+
Text(appName)
|
|
2764
|
+
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
|
2765
|
+
.foregroundColor(textPrimary)
|
|
2766
|
+
.lineLimit(1)
|
|
2767
|
+
if !windowTitle.isEmpty {
|
|
2768
|
+
Text(windowTitle)
|
|
2769
|
+
.font(.system(size: 9, design: .monospaced))
|
|
2770
|
+
.foregroundColor(textSecondary)
|
|
2771
|
+
.lineLimit(2)
|
|
2772
|
+
}
|
|
2773
|
+
HStack(spacing: 6) {
|
|
2774
|
+
bezelTag(layerName, color: accent)
|
|
2775
|
+
bezelTag(windowSize, color: textSecondary)
|
|
2776
|
+
}
|
|
2777
|
+
Spacer()
|
|
2778
|
+
HStack(spacing: 4) {
|
|
2779
|
+
Image(systemName: "display")
|
|
2780
|
+
.font(.system(size: 9))
|
|
2781
|
+
.foregroundColor(textTertiary)
|
|
2782
|
+
Text("\(displayNumber)")
|
|
2783
|
+
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
2784
|
+
.foregroundColor(textSecondary)
|
|
2785
|
+
Text(displayName)
|
|
2786
|
+
.font(.system(size: 8, design: .monospaced))
|
|
2787
|
+
.foregroundColor(textTertiary)
|
|
2788
|
+
.lineLimit(1)
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
.padding(8)
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// MARK: - Helpers
|
|
2795
|
+
|
|
2796
|
+
private func bezelTag(_ text: String, color: Color) -> some View {
|
|
2797
|
+
Text(text)
|
|
2798
|
+
.font(.system(size: 9, weight: .medium, design: .monospaced))
|
|
2799
|
+
.foregroundColor(color)
|
|
2800
|
+
.padding(.horizontal, 5)
|
|
2801
|
+
.padding(.vertical, 2)
|
|
2802
|
+
.background(
|
|
2803
|
+
RoundedRectangle(cornerRadius: 3)
|
|
2804
|
+
.fill(color.opacity(0.08))
|
|
2805
|
+
.overlay(
|
|
2806
|
+
RoundedRectangle(cornerRadius: 3)
|
|
2807
|
+
.strokeBorder(color.opacity(0.15), lineWidth: 0.5)
|
|
2808
|
+
)
|
|
2809
|
+
)
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
// MARK: - Preference Keys
|
|
2814
|
+
|
|
2815
|
+
private struct SearchOverlayFrameKey: PreferenceKey {
|
|
2816
|
+
static var defaultValue: CGRect = .zero
|
|
2817
|
+
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
|
2818
|
+
value = nextValue()
|
|
2819
|
+
}
|
|
2820
|
+
}
|