@lattices/cli 0.4.14 → 0.5.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 +5 -7
- package/apps/mac/Info.plist +2 -2
- package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
- package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/bin/lattices-app.ts +110 -17
- package/bin/lattices-build +125 -0
- package/bin/lattices-dev +89 -16
- package/bin/lattices.ts +977 -16
- package/docs/agents.md +81 -4
- package/docs/ai-chat-ux-review.md +416 -0
- package/docs/api.md +135 -3
- package/docs/app.md +30 -8
- package/docs/config.md +4 -0
- package/docs/mouse-gestures.md +60 -1
- package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
- package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
- package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
- package/docs/reference/dewey.config.ts +2 -2
- package/docs/release.md +171 -0
- package/docs/repo-structure.md +4 -5
- package/docs/voice.md +11 -27
- package/package.json +9 -10
- package/apps/mac/Package.swift +0 -27
- package/apps/mac/Sources/AppShell/App.swift +0 -26
- package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
- package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
- package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
- package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
- package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
- package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
- package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
- package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
- package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
- package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
- package/apps/mac/Sources/AppShell/MainView.swift +0 -847
- package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
- package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
- package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
- package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
- package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
- package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
- package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
- package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
- package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
- package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
- package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
- package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
- package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
- package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
- package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
- package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
- package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
- package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
- package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
- package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
- package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
- package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
- package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
- package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
- package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
- package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
- package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
- package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
- package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
- package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
- package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
- package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
- package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
- package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
- package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
- package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
- package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
- package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
- package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
- package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
- package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
- package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
- package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
- package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
- package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
- package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
- package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
- package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
- package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
- package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
- package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
- package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
- package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
- package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
- package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
- package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
- package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
- package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
- package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
- package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
- package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
- package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
- package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
- package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
- package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
- package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
- package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
- package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
- package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
- package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
- package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
- package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
- package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
- package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
- package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
- package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
- package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
- package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
- package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
- package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
- package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
- package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
- package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
- package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
- package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
- package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
- package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
- package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
- package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
- package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
- package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
- package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
- package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
- package/apps/mac/Sources/Core/System/Capability.swift +0 -79
- package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
- package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
- package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
- package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
- package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
- package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
- package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
- package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
- package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
- package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
- package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
- package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
- package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
- package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
- package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
- package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
- package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
- package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
- package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
- package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
- package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
- package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
- package/apps/mac/Sources/UI/ActionRow.swift +0 -78
- package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
- package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
- package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
- package/apps/mac/Sources/UI/Theme.swift +0 -164
- package/apps/mac/Tests/StageDragTests.swift +0 -333
- package/apps/mac/Tests/StageJoinTests.swift +0 -313
- package/apps/mac/Tests/StageManagerTests.swift +0 -280
- package/apps/mac/Tests/StageTileTests.swift +0 -353
- package/swift/Package.swift +0 -20
- package/swift/Sources/DeckKit/DeckAction.swift +0 -51
- package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
- package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
- package/swift/Sources/DeckKit/DeckHost.swift +0 -7
- package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
- package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
- package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
- package/swift/Sources/DeckKit/DeckValue.swift +0 -93
- package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
- package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
|
@@ -1,3977 +0,0 @@
|
|
|
1
|
-
import SwiftUI
|
|
2
|
-
import AppKit
|
|
3
|
-
|
|
4
|
-
// MARK: - Screen Map View (Standalone)
|
|
5
|
-
|
|
6
|
-
struct ScreenMapView: View {
|
|
7
|
-
private static let canvasPadding: CGFloat = 8
|
|
8
|
-
private static let canvasFitInsets = CGSize(width: 132, height: 112)
|
|
9
|
-
private static let canvasViewportInsets = CGSize(width: 16, height: 16)
|
|
10
|
-
private static let canvasPanMinVisiblePixels: CGFloat = 1
|
|
11
|
-
private static let canvasFitScaleMultiplier: CGFloat = 0.66
|
|
12
|
-
private static let canvasStageMaxWidth: CGFloat = 980
|
|
13
|
-
private static let canvasStageMaxHeight: CGFloat = 560
|
|
14
|
-
private static let canvasStageMinAspect: CGFloat = 1.25
|
|
15
|
-
private static let canvasStageMaxAspect: CGFloat = 2.45
|
|
16
|
-
private static let sidebarWindowRowHeight: CGFloat = 28
|
|
17
|
-
private static let sidebarWindowRowStride: CGFloat = 30
|
|
18
|
-
|
|
19
|
-
private struct CanvasMetrics: Equatable {
|
|
20
|
-
let worldBounds: CGRect
|
|
21
|
-
let fitScale: CGFloat
|
|
22
|
-
let effectiveScale: CGFloat
|
|
23
|
-
let mapSize: CGSize
|
|
24
|
-
let centerOffset: CGPoint
|
|
25
|
-
let syncedViewportSize: CGSize
|
|
26
|
-
|
|
27
|
-
init(editor: ScreenMapEditorState?, displays: [DisplayGeometry], viewportSize: CGSize) {
|
|
28
|
-
let fallbackBounds: CGRect = {
|
|
29
|
-
guard let first = displays.first else {
|
|
30
|
-
let size = NSScreen.main?.frame.size ?? CGSize(width: 1920, height: 1080)
|
|
31
|
-
return CGRect(origin: .zero, size: size)
|
|
32
|
-
}
|
|
33
|
-
return displays.dropFirst().reduce(first.cgRect) { $0.union($1.cgRect) }
|
|
34
|
-
}()
|
|
35
|
-
|
|
36
|
-
worldBounds = editor?.canvasWorldBounds ?? fallbackBounds
|
|
37
|
-
|
|
38
|
-
let fitArea = CGSize(
|
|
39
|
-
width: max(viewportSize.width - ScreenMapView.canvasFitInsets.width, 1),
|
|
40
|
-
height: max(viewportSize.height - ScreenMapView.canvasFitInsets.height, 1)
|
|
41
|
-
)
|
|
42
|
-
syncedViewportSize = CGSize(
|
|
43
|
-
width: max(viewportSize.width - ScreenMapView.canvasViewportInsets.width, 1),
|
|
44
|
-
height: max(viewportSize.height - ScreenMapView.canvasViewportInsets.height, 1)
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
fitScale = min(
|
|
48
|
-
fitArea.width / max(worldBounds.width, 1),
|
|
49
|
-
fitArea.height / max(worldBounds.height, 1)
|
|
50
|
-
) * ScreenMapView.canvasFitScaleMultiplier
|
|
51
|
-
effectiveScale = fitScale * (editor?.zoomLevel ?? 1)
|
|
52
|
-
mapSize = CGSize(width: worldBounds.width * effectiveScale, height: worldBounds.height * effectiveScale)
|
|
53
|
-
centerOffset = CGPoint(
|
|
54
|
-
x: (viewportSize.width - mapSize.width) / 2,
|
|
55
|
-
y: (viewportSize.height - mapSize.height) / 2
|
|
56
|
-
)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
func mapRect(for worldRect: CGRect, minimumSize: CGFloat = 4) -> CGRect {
|
|
60
|
-
CGRect(
|
|
61
|
-
x: (worldRect.origin.x - worldBounds.origin.x) * effectiveScale,
|
|
62
|
-
y: (worldRect.origin.y - worldBounds.origin.y) * effectiveScale,
|
|
63
|
-
width: max(worldRect.width * effectiveScale, minimumSize),
|
|
64
|
-
height: max(worldRect.height * effectiveScale, minimumSize)
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
private struct CanvasProjection {
|
|
70
|
-
let scale: CGFloat
|
|
71
|
-
let bboxOrigin: CGPoint
|
|
72
|
-
let mapOrigin: CGPoint
|
|
73
|
-
let panOffset: CGPoint
|
|
74
|
-
|
|
75
|
-
init(editor: ScreenMapEditorState) {
|
|
76
|
-
scale = max(editor.fitScale * editor.zoomLevel, editor.effectiveScale)
|
|
77
|
-
bboxOrigin = editor.bboxOrigin
|
|
78
|
-
mapOrigin = editor.mapOrigin
|
|
79
|
-
panOffset = editor.panOffset
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
func mapRect(for worldRect: CGRect, minimumSize: CGFloat = 4) -> CGRect {
|
|
83
|
-
CGRect(
|
|
84
|
-
x: (worldRect.origin.x - bboxOrigin.x) * scale,
|
|
85
|
-
y: (worldRect.origin.y - bboxOrigin.y) * scale,
|
|
86
|
-
width: max(worldRect.width * scale, minimumSize),
|
|
87
|
-
height: max(worldRect.height * scale, minimumSize)
|
|
88
|
-
)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
func mapPoint(forCanvasPoint canvasPoint: CGPoint) -> CGPoint {
|
|
92
|
-
CGPoint(
|
|
93
|
-
x: canvasPoint.x - ScreenMapView.canvasPadding - mapOrigin.x - panOffset.x,
|
|
94
|
-
y: canvasPoint.y - ScreenMapView.canvasPadding - mapOrigin.y - panOffset.y
|
|
95
|
-
)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
private struct CanvasHit {
|
|
100
|
-
let id: UInt32
|
|
101
|
-
let mapRect: CGRect
|
|
102
|
-
let mapPoint: CGPoint
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
private struct CanvasSyncKey: Equatable {
|
|
106
|
-
let viewportSize: CGSize
|
|
107
|
-
let worldBounds: CGRect
|
|
108
|
-
let zoomLevel: CGFloat
|
|
109
|
-
let navigationRevision: Int
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
private struct MiniMapMetrics {
|
|
113
|
-
let worldBounds: CGRect
|
|
114
|
-
let scale: CGFloat
|
|
115
|
-
let drawSize: CGSize
|
|
116
|
-
let offset: CGPoint
|
|
117
|
-
|
|
118
|
-
init(worldBounds: CGRect, canvasSize: CGSize) {
|
|
119
|
-
self.worldBounds = worldBounds
|
|
120
|
-
let scaleW = canvasSize.width / max(worldBounds.width, 1)
|
|
121
|
-
let scaleH = canvasSize.height / max(worldBounds.height, 1)
|
|
122
|
-
scale = min(scaleW, scaleH)
|
|
123
|
-
drawSize = CGSize(width: worldBounds.width * scale, height: worldBounds.height * scale)
|
|
124
|
-
offset = CGPoint(
|
|
125
|
-
x: (canvasSize.width - drawSize.width) / 2,
|
|
126
|
-
y: (canvasSize.height - drawSize.height) / 2
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
func rect(for worldRect: CGRect, minimumSize: CGFloat) -> CGRect {
|
|
131
|
-
CGRect(
|
|
132
|
-
x: (worldRect.origin.x - worldBounds.origin.x) * scale + offset.x,
|
|
133
|
-
y: (worldRect.origin.y - worldBounds.origin.y) * scale + offset.y,
|
|
134
|
-
width: max(worldRect.width * scale, minimumSize),
|
|
135
|
-
height: max(worldRect.height * scale, minimumSize)
|
|
136
|
-
)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
func worldPoint(for localPoint: CGPoint) -> CGPoint {
|
|
140
|
-
let localX = min(max(localPoint.x - offset.x, 0), drawSize.width)
|
|
141
|
-
let localY = min(max(localPoint.y - offset.y, 0), drawSize.height)
|
|
142
|
-
return CGPoint(
|
|
143
|
-
x: worldBounds.origin.x + localX / max(scale, 0.0001),
|
|
144
|
-
y: worldBounds.origin.y + localY / max(scale, 0.0001)
|
|
145
|
-
)
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
@ObservedObject var controller: ScreenMapController
|
|
150
|
-
var onNavigate: ((AppPage) -> Void)? = nil
|
|
151
|
-
@ObservedObject private var daemon = DaemonServer.shared
|
|
152
|
-
@ObservedObject private var handsOff = HandsOffSession.shared
|
|
153
|
-
@ObservedObject private var diagnosticLog = DiagnosticLog.shared
|
|
154
|
-
@StateObject private var piChat = PiChatSession.shared
|
|
155
|
-
@State private var eventMonitor: Any?
|
|
156
|
-
@State private var mouseDownMonitor: Any?
|
|
157
|
-
@State private var mouseDragMonitor: Any?
|
|
158
|
-
@State private var mouseUpMonitor: Any?
|
|
159
|
-
@State private var rightClickMonitor: Any?
|
|
160
|
-
@State private var scrollWheelMonitor: Any?
|
|
161
|
-
@State private var screenMapCanvasOrigin: CGPoint = .zero
|
|
162
|
-
@State private var screenMapCanvasSize: CGSize = .zero
|
|
163
|
-
@State private var screenMapClickWindowId: UInt32? = nil
|
|
164
|
-
@State private var screenMapClickPoint: NSPoint = .zero
|
|
165
|
-
@State private var hoveredWindowId: UInt32?
|
|
166
|
-
@State private var hoveredShelfAction: String?
|
|
167
|
-
@State private var dropTargetLayer: Int?
|
|
168
|
-
@State private var layerRowFrames: [Int: CGRect] = [:]
|
|
169
|
-
@State private var sidebarDragWindowId: UInt32? = nil
|
|
170
|
-
@State private var sidebarDragOffset: CGSize = .zero
|
|
171
|
-
@State private var expandedLayers: Set<Int> = []
|
|
172
|
-
@State private var showUnnamedLayers: Bool = false
|
|
173
|
-
@State private var showSets: Bool = false
|
|
174
|
-
@State private var showExplorer: Bool = false
|
|
175
|
-
@State private var mouseMovedMonitor: Any?
|
|
176
|
-
@State private var sidebarWidth: CGFloat = 180
|
|
177
|
-
@State private var isDraggingSidebar: Bool = false
|
|
178
|
-
@State private var inspectorWidth: CGFloat = 280
|
|
179
|
-
@State private var isDraggingInspector: Bool = false
|
|
180
|
-
@FocusState private var isSearchFieldFocused: Bool
|
|
181
|
-
@State private var searchHoveredDisplayIndex: Int? = nil
|
|
182
|
-
@State private var canvasTransitionOffset: CGFloat = 0
|
|
183
|
-
@State private var canvasTransitionOpacity: Double = 1.0
|
|
184
|
-
@State private var isSpaceHeld: Bool = false
|
|
185
|
-
@State private var canvasPanStart: NSPoint? = nil
|
|
186
|
-
@State private var canvasPanStartOffset: CGPoint = .zero
|
|
187
|
-
@State private var searchOverlayFrame: CGRect = .zero
|
|
188
|
-
|
|
189
|
-
var body: some View {
|
|
190
|
-
VStack(spacing: 0) {
|
|
191
|
-
HStack(spacing: 0) {
|
|
192
|
-
if let editor = controller.editor {
|
|
193
|
-
layerSidebar(editor: editor)
|
|
194
|
-
panelResizeHandle(isActive: $isDraggingSidebar, width: $sidebarWidth,
|
|
195
|
-
range: 140...320, edge: .trailing)
|
|
196
|
-
}
|
|
197
|
-
ZStack {
|
|
198
|
-
VStack(spacing: 0) {
|
|
199
|
-
canvasHeaderBezel
|
|
200
|
-
screenMapCanvas(editor: controller.editor)
|
|
201
|
-
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
202
|
-
}
|
|
203
|
-
.offset(x: canvasTransitionOffset)
|
|
204
|
-
.opacity(canvasTransitionOpacity)
|
|
205
|
-
.onChange(of: controller.displayTransition) { direction in
|
|
206
|
-
guard direction != .none else { return }
|
|
207
|
-
let slideDistance: CGFloat = direction == .right ? -60 : 60
|
|
208
|
-
// Start from opposite side
|
|
209
|
-
canvasTransitionOffset = -slideDistance
|
|
210
|
-
canvasTransitionOpacity = 0.3
|
|
211
|
-
withAnimation(.easeOut(duration: 0.2)) {
|
|
212
|
-
canvasTransitionOffset = 0
|
|
213
|
-
canvasTransitionOpacity = 1.0
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
if controller.isSearchActive, let editor = controller.editor {
|
|
217
|
-
floatingSearchOverlay(editor: editor)
|
|
218
|
-
}
|
|
219
|
-
// Viewport controls removed — accessible via keyboard shortcuts
|
|
220
|
-
}
|
|
221
|
-
if let editor = controller.editor {
|
|
222
|
-
panelResizeHandle(isActive: $isDraggingInspector, width: $inspectorWidth,
|
|
223
|
-
range: 220...480, edge: .leading)
|
|
224
|
-
inspectorPane(editor: editor)
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
if piChat.isVisible {
|
|
228
|
-
PiChatDock(session: piChat)
|
|
229
|
-
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
230
|
-
}
|
|
231
|
-
footerBar
|
|
232
|
-
}
|
|
233
|
-
.background(Palette.bg)
|
|
234
|
-
.overlay(flashOverlay)
|
|
235
|
-
.onAppear {
|
|
236
|
-
installKeyHandler()
|
|
237
|
-
installMouseMonitors()
|
|
238
|
-
}
|
|
239
|
-
.onDisappear {
|
|
240
|
-
removeKeyHandler()
|
|
241
|
-
removeMouseMonitors()
|
|
242
|
-
}
|
|
243
|
-
.onChange(of: controller.editor?.isPreviewing) { isPreviewing in
|
|
244
|
-
handlePreviewChange(isPreviewing: isPreviewing ?? false)
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// MARK: - Display Toolbar (floating in canvas)
|
|
249
|
-
|
|
250
|
-
private func displayToolbar(editor: ScreenMapEditorState) -> some View {
|
|
251
|
-
HStack(spacing: 4) {
|
|
252
|
-
Button {
|
|
253
|
-
controller.stepDisplayFocus(.previous)
|
|
254
|
-
} label: {
|
|
255
|
-
Image(systemName: "chevron.left")
|
|
256
|
-
.font(.system(size: 8, weight: .semibold))
|
|
257
|
-
.foregroundColor(Palette.textDim)
|
|
258
|
-
.frame(width: 18, height: 18)
|
|
259
|
-
.contentShape(Rectangle())
|
|
260
|
-
}
|
|
261
|
-
.buttonStyle(.plain)
|
|
262
|
-
|
|
263
|
-
Button {
|
|
264
|
-
controller.setDisplayFocus(nil)
|
|
265
|
-
} label: {
|
|
266
|
-
displayToolbarPill(name: "All", isActive: editor.focusedDisplayIndex == nil)
|
|
267
|
-
}
|
|
268
|
-
.buttonStyle(.plain)
|
|
269
|
-
|
|
270
|
-
ForEach(Array(editor.spatialDisplayOrder.enumerated()), id: \.element.index) { spatialPos, disp in
|
|
271
|
-
let isActive = editor.focusedDisplayIndex == disp.index
|
|
272
|
-
Button {
|
|
273
|
-
controller.setDisplayFocus(disp.index)
|
|
274
|
-
} label: {
|
|
275
|
-
displayToolbarPill(
|
|
276
|
-
badge: spatialPos + 1,
|
|
277
|
-
name: disp.label,
|
|
278
|
-
isActive: isActive
|
|
279
|
-
)
|
|
280
|
-
}
|
|
281
|
-
.buttonStyle(.plain)
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
Button {
|
|
285
|
-
controller.stepDisplayFocus(.next)
|
|
286
|
-
} label: {
|
|
287
|
-
Image(systemName: "chevron.right")
|
|
288
|
-
.font(.system(size: 8, weight: .semibold))
|
|
289
|
-
.foregroundColor(Palette.textDim)
|
|
290
|
-
.frame(width: 18, height: 18)
|
|
291
|
-
.contentShape(Rectangle())
|
|
292
|
-
}
|
|
293
|
-
.buttonStyle(.plain)
|
|
294
|
-
}
|
|
295
|
-
.padding(.horizontal, 6)
|
|
296
|
-
.padding(.vertical, 4)
|
|
297
|
-
.background(
|
|
298
|
-
RoundedRectangle(cornerRadius: 8)
|
|
299
|
-
.fill(Color.black.opacity(0.65))
|
|
300
|
-
.overlay(
|
|
301
|
-
RoundedRectangle(cornerRadius: 8)
|
|
302
|
-
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
|
|
303
|
-
)
|
|
304
|
-
)
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
private func displayToolbarPill(badge: Int? = nil, name: String, isActive: Bool) -> some View {
|
|
308
|
-
HStack(spacing: 4) {
|
|
309
|
-
if let badge = badge {
|
|
310
|
-
ZStack {
|
|
311
|
-
Circle()
|
|
312
|
-
.fill(isActive ? Palette.running.opacity(0.5) : Color.white.opacity(0.25))
|
|
313
|
-
.frame(width: 14, height: 14)
|
|
314
|
-
Text("\(badge)")
|
|
315
|
-
.font(.system(size: 7, weight: .bold, design: .monospaced))
|
|
316
|
-
.foregroundColor(isActive ? .white : .black)
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
Text(name)
|
|
320
|
-
.font(Typo.monoBold(8))
|
|
321
|
-
.foregroundColor(isActive ? Palette.text : Palette.textDim)
|
|
322
|
-
.lineLimit(1)
|
|
323
|
-
}
|
|
324
|
-
.padding(.horizontal, 6)
|
|
325
|
-
.padding(.vertical, 3)
|
|
326
|
-
.background(
|
|
327
|
-
RoundedRectangle(cornerRadius: 5)
|
|
328
|
-
.fill(isActive ? Palette.running.opacity(0.15) : Color.white.opacity(0.06))
|
|
329
|
-
)
|
|
330
|
-
.overlay(
|
|
331
|
-
RoundedRectangle(cornerRadius: 5)
|
|
332
|
-
.strokeBorder(isActive ? Palette.running.opacity(0.4) : Color.clear, lineWidth: 0.5)
|
|
333
|
-
)
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// MARK: - Canvas Header Bezel
|
|
337
|
-
|
|
338
|
-
private var canvasHeaderBezel: some View {
|
|
339
|
-
HStack(spacing: 6) {
|
|
340
|
-
if let editor = controller.editor {
|
|
341
|
-
if let focused = editor.focusedDisplay {
|
|
342
|
-
Circle().fill(Palette.running.opacity(0.4)).frame(width: 6, height: 6)
|
|
343
|
-
Text(focused.label).font(Typo.monoBold(9)).foregroundColor(Palette.textDim).lineLimit(1)
|
|
344
|
-
Text("\(Int(focused.cgRect.width))×\(Int(focused.cgRect.height))").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
|
|
345
|
-
} else {
|
|
346
|
-
Text("All Displays").font(Typo.monoBold(9)).foregroundColor(Palette.textDim)
|
|
347
|
-
Text("\(editor.displays.count) monitors").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
|
|
348
|
-
}
|
|
349
|
-
Spacer()
|
|
350
|
-
Text("\(editor.focusedVisibleWindows.count) windows").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
|
|
351
|
-
} else { Text("Canvas"); Spacer() }
|
|
352
|
-
}
|
|
353
|
-
.padding(.horizontal, 10).padding(.vertical, 5)
|
|
354
|
-
.background(Color(red: 0.08, green: 0.08, blue: 0.09))
|
|
355
|
-
.overlay(alignment: .bottom) { Rectangle().fill(Palette.border).frame(height: 0.5) }
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// MARK: - Panel Resize Handle
|
|
359
|
-
|
|
360
|
-
enum PanelEdge { case trailing, leading }
|
|
361
|
-
|
|
362
|
-
private func panelResizeHandle(isActive: Binding<Bool>, width: Binding<CGFloat>,
|
|
363
|
-
range: ClosedRange<CGFloat>, edge: PanelEdge) -> some View {
|
|
364
|
-
Rectangle()
|
|
365
|
-
.fill(isActive.wrappedValue ? Palette.running.opacity(0.3) : Palette.border)
|
|
366
|
-
.frame(width: isActive.wrappedValue ? 2 : 0.5)
|
|
367
|
-
.contentShape(Rectangle().inset(by: -3))
|
|
368
|
-
.onHover { h in if h { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } }
|
|
369
|
-
.gesture(
|
|
370
|
-
DragGesture(minimumDistance: 1)
|
|
371
|
-
.onChanged { value in
|
|
372
|
-
isActive.wrappedValue = true
|
|
373
|
-
let delta = edge == .trailing ? value.translation.width : -value.translation.width
|
|
374
|
-
let newWidth = width.wrappedValue + delta
|
|
375
|
-
width.wrappedValue = max(range.lowerBound, min(range.upperBound, newWidth))
|
|
376
|
-
}
|
|
377
|
-
.onEnded { _ in isActive.wrappedValue = false }
|
|
378
|
-
)
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// MARK: - Inspector Pane
|
|
382
|
-
|
|
383
|
-
private func inspectorPane(editor: ScreenMapEditorState) -> some View {
|
|
384
|
-
let selectedWindows = editor.windows.filter { controller.selectedWindowIds.contains($0.id) }
|
|
385
|
-
|
|
386
|
-
return VStack(spacing: 0) {
|
|
387
|
-
ScrollView(.vertical, showsIndicators: false) {
|
|
388
|
-
VStack(alignment: .leading, spacing: 12) {
|
|
389
|
-
Text("INSPECTOR")
|
|
390
|
-
.font(Typo.monoBold(9))
|
|
391
|
-
.foregroundColor(Palette.textMuted)
|
|
392
|
-
|
|
393
|
-
inspectorCanvasContextCard(editor: editor, selectedCount: selectedWindows.count)
|
|
394
|
-
|
|
395
|
-
if selectedWindows.isEmpty {
|
|
396
|
-
VStack(spacing: 8) {
|
|
397
|
-
Text("No Selection")
|
|
398
|
-
.font(Typo.monoBold(10))
|
|
399
|
-
.foregroundColor(Palette.textDim)
|
|
400
|
-
Text("Click a window on the canvas to inspect.")
|
|
401
|
-
.font(Typo.mono(9))
|
|
402
|
-
.foregroundColor(Palette.textMuted)
|
|
403
|
-
.multilineTextAlignment(.center)
|
|
404
|
-
.lineLimit(3)
|
|
405
|
-
}
|
|
406
|
-
.frame(maxWidth: .infinity)
|
|
407
|
-
.padding(.top, 20)
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
ForEach(selectedWindows) { win in
|
|
411
|
-
inspectorWindowCard(win: win, editor: editor)
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
.padding(8)
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Pinned action tray at bottom
|
|
418
|
-
inspectorActionTray(editor: editor)
|
|
419
|
-
}
|
|
420
|
-
.frame(width: inspectorWidth)
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
private func inspectorCanvasContextCard(editor: ScreenMapEditorState, selectedCount: Int) -> some View {
|
|
424
|
-
let viewport = editor.viewportWorldRect
|
|
425
|
-
let world = editor.canvasWorldBounds
|
|
426
|
-
let scope = editor.focusedDisplay.map { "\(editor.spatialNumber(for: $0.index)). \($0.label)" } ?? "All Displays"
|
|
427
|
-
|
|
428
|
-
return VStack(alignment: .leading, spacing: 4) {
|
|
429
|
-
inspectorRow(label: "Scope", value: scope)
|
|
430
|
-
inspectorRow(label: "Mode", value: "Desktop")
|
|
431
|
-
inspectorRow(label: "View", value: "\(Int(viewport.midX)), \(Int(viewport.midY)) · \(Int(viewport.width))×\(Int(viewport.height))")
|
|
432
|
-
inspectorRow(label: "World", value: "\(Int(world.width))×\(Int(world.height))")
|
|
433
|
-
inspectorRow(label: "Set", value: controller.activeWindowSet?.name ?? "None")
|
|
434
|
-
inspectorRow(label: "Select", value: "\(selectedCount) window\(selectedCount == 1 ? "" : "s")")
|
|
435
|
-
}
|
|
436
|
-
.padding(8)
|
|
437
|
-
.background(
|
|
438
|
-
RoundedRectangle(cornerRadius: 6)
|
|
439
|
-
.fill(Color.black.opacity(0.25))
|
|
440
|
-
.overlay(
|
|
441
|
-
RoundedRectangle(cornerRadius: 6)
|
|
442
|
-
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
|
443
|
-
)
|
|
444
|
-
)
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// MARK: - Inspector Window Card
|
|
448
|
-
|
|
449
|
-
private func inspectorWindowCard(win: ScreenMapWindowEntry, editor: ScreenMapEditorState) -> some View {
|
|
450
|
-
let desktopEntry = DesktopModel.shared.windows[UInt32(win.id)]
|
|
451
|
-
let ocrText = OcrModel.shared.results[UInt32(win.id)]?.fullText
|
|
452
|
-
|
|
453
|
-
return VStack(alignment: .leading, spacing: 8) {
|
|
454
|
-
// Header: app + visibility
|
|
455
|
-
HStack(spacing: 5) {
|
|
456
|
-
Circle()
|
|
457
|
-
.fill(Palette.running.opacity(0.8))
|
|
458
|
-
.frame(width: 6, height: 6)
|
|
459
|
-
Text(win.app)
|
|
460
|
-
.font(Typo.monoBold(11))
|
|
461
|
-
.foregroundColor(Palette.text)
|
|
462
|
-
.lineLimit(1)
|
|
463
|
-
Spacer()
|
|
464
|
-
if desktopEntry?.isOnScreen == true {
|
|
465
|
-
Text("visible")
|
|
466
|
-
.font(Typo.monoBold(7))
|
|
467
|
-
.foregroundColor(Palette.running)
|
|
468
|
-
.padding(.horizontal, 4)
|
|
469
|
-
.padding(.vertical, 1)
|
|
470
|
-
.background(
|
|
471
|
-
RoundedRectangle(cornerRadius: 2)
|
|
472
|
-
.fill(Palette.running.opacity(0.1))
|
|
473
|
-
)
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// Title
|
|
478
|
-
if !win.title.isEmpty {
|
|
479
|
-
Text(win.title)
|
|
480
|
-
.font(Typo.mono(10))
|
|
481
|
-
.foregroundColor(Palette.textDim)
|
|
482
|
-
.lineLimit(3)
|
|
483
|
-
.textSelection(.enabled)
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Identity
|
|
487
|
-
HStack(spacing: 10) {
|
|
488
|
-
inspectorLabel(label: "wid", value: "\(win.id)")
|
|
489
|
-
if let entry = desktopEntry {
|
|
490
|
-
inspectorLabel(label: "pid", value: "\(entry.pid)")
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Layout info
|
|
495
|
-
VStack(alignment: .leading, spacing: 3) {
|
|
496
|
-
inspectorRow(label: "Display", value: {
|
|
497
|
-
if let disp = editor.displays.first(where: { $0.index == win.displayIndex }) {
|
|
498
|
-
return "\(editor.spatialNumber(for: disp.index)). \(disp.label)"
|
|
499
|
-
}
|
|
500
|
-
return "Display \(win.displayIndex)"
|
|
501
|
-
}())
|
|
502
|
-
inspectorRow(label: "Size",
|
|
503
|
-
value: "\(Int(win.virtualFrame.width))×\(Int(win.virtualFrame.height))")
|
|
504
|
-
inspectorRow(label: "Position",
|
|
505
|
-
value: "(\(Int(win.virtualFrame.origin.x)), \(Int(win.virtualFrame.origin.y)))")
|
|
506
|
-
inspectorRow(label: "Z-Index", value: "\(win.zIndex)")
|
|
507
|
-
if win.hasEdits {
|
|
508
|
-
inspectorRow(label: "Original",
|
|
509
|
-
value: "\(Int(win.originalFrame.width))×\(Int(win.originalFrame.height))")
|
|
510
|
-
}
|
|
511
|
-
if let entry = desktopEntry, !entry.spaceIds.isEmpty {
|
|
512
|
-
inspectorRow(label: "Spaces", value: entry.spaceIds.map(String.init).joined(separator: ", "))
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Session
|
|
517
|
-
if let session = desktopEntry?.latticesSession {
|
|
518
|
-
HStack(spacing: 4) {
|
|
519
|
-
Text("session")
|
|
520
|
-
.font(Typo.monoBold(8))
|
|
521
|
-
.foregroundColor(Palette.textMuted)
|
|
522
|
-
Text(session)
|
|
523
|
-
.font(Typo.mono(9))
|
|
524
|
-
.foregroundColor(Palette.running)
|
|
525
|
-
.lineLimit(1)
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
if win.hasEdits {
|
|
530
|
-
HStack(spacing: 4) {
|
|
531
|
-
Circle()
|
|
532
|
-
.fill(Color.orange)
|
|
533
|
-
.frame(width: 5, height: 5)
|
|
534
|
-
Text("Modified")
|
|
535
|
-
.font(Typo.monoBold(8))
|
|
536
|
-
.foregroundColor(Color.orange)
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// OCR snippet
|
|
541
|
-
if let ocr = ocrText, !ocr.isEmpty {
|
|
542
|
-
VStack(alignment: .leading, spacing: 2) {
|
|
543
|
-
Text("SCREEN TEXT")
|
|
544
|
-
.font(Typo.monoBold(8))
|
|
545
|
-
.foregroundColor(Palette.textMuted)
|
|
546
|
-
Text(String(ocr.prefix(400)))
|
|
547
|
-
.font(Typo.mono(8))
|
|
548
|
-
.foregroundColor(Palette.textMuted)
|
|
549
|
-
.lineLimit(8)
|
|
550
|
-
.textSelection(.enabled)
|
|
551
|
-
}
|
|
552
|
-
.padding(6)
|
|
553
|
-
.background(
|
|
554
|
-
RoundedRectangle(cornerRadius: 4)
|
|
555
|
-
.fill(Palette.bg.opacity(0.5))
|
|
556
|
-
)
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Window actions — contextual to this card
|
|
560
|
-
if let entry = desktopEntry {
|
|
561
|
-
windowCardActions(wid: UInt32(win.id), entry: entry)
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
.padding(10)
|
|
565
|
-
.background(
|
|
566
|
-
RoundedRectangle(cornerRadius: 6)
|
|
567
|
-
.fill(Palette.surface)
|
|
568
|
-
.overlay(
|
|
569
|
-
RoundedRectangle(cornerRadius: 6)
|
|
570
|
-
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
571
|
-
)
|
|
572
|
-
)
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
private func windowCardActions(wid: UInt32, entry: WindowEntry) -> some View {
|
|
576
|
-
let actions: [(key: String, label: String, action: () -> Void)] = [
|
|
577
|
-
("f", "focus", { [controller] in
|
|
578
|
-
controller.focusWindowOnScreen(wid)
|
|
579
|
-
}),
|
|
580
|
-
("h", "highlight", {
|
|
581
|
-
WindowTiler.highlightWindowById(wid: wid)
|
|
582
|
-
}),
|
|
583
|
-
("←", "tile left", {
|
|
584
|
-
WindowTiler.focusWindow(wid: wid, pid: entry.pid)
|
|
585
|
-
WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: .left)
|
|
586
|
-
}),
|
|
587
|
-
("→", "tile right", {
|
|
588
|
-
WindowTiler.focusWindow(wid: wid, pid: entry.pid)
|
|
589
|
-
WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: .right)
|
|
590
|
-
}),
|
|
591
|
-
("m", "maximize", {
|
|
592
|
-
WindowTiler.focusWindow(wid: wid, pid: entry.pid)
|
|
593
|
-
WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: .maximize)
|
|
594
|
-
}),
|
|
595
|
-
("r", "rescan", {
|
|
596
|
-
OcrModel.shared.scanSingle(wid: wid)
|
|
597
|
-
}),
|
|
598
|
-
("c", "copy info", { [controller] in
|
|
599
|
-
let info = [
|
|
600
|
-
"wid: \(wid)",
|
|
601
|
-
"app: \(entry.app)",
|
|
602
|
-
"title: \(entry.title)",
|
|
603
|
-
"pid: \(entry.pid)",
|
|
604
|
-
"frame: \(Int(entry.frame.x)),\(Int(entry.frame.y)) \(Int(entry.frame.w))×\(Int(entry.frame.h))",
|
|
605
|
-
entry.latticesSession.map { "session: \($0)" },
|
|
606
|
-
].compactMap { $0 }.joined(separator: "\n")
|
|
607
|
-
NSPasteboard.general.clearContents()
|
|
608
|
-
NSPasteboard.general.setString(info, forType: .string)
|
|
609
|
-
controller.flash("Copied")
|
|
610
|
-
}),
|
|
611
|
-
]
|
|
612
|
-
|
|
613
|
-
let columns = [GridItem(.flexible()), GridItem(.flexible())]
|
|
614
|
-
|
|
615
|
-
return VStack(spacing: 0) {
|
|
616
|
-
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
617
|
-
.padding(.horizontal, -10)
|
|
618
|
-
.padding(.top, 4)
|
|
619
|
-
|
|
620
|
-
LazyVGrid(columns: columns, spacing: 3) {
|
|
621
|
-
ForEach(Array(actions.enumerated()), id: \.offset) { _, item in
|
|
622
|
-
let isHov = hoveredShelfAction == "w_\(wid)_\(item.label)"
|
|
623
|
-
Button(action: item.action) {
|
|
624
|
-
HStack(spacing: 4) {
|
|
625
|
-
Text(item.key)
|
|
626
|
-
.font(.system(size: 8))
|
|
627
|
-
.foregroundColor(Self.shelfGreen)
|
|
628
|
-
.frame(width: 14)
|
|
629
|
-
Text(item.label)
|
|
630
|
-
.font(Typo.mono(8))
|
|
631
|
-
.foregroundColor(isHov ? Palette.text : Palette.textDim)
|
|
632
|
-
.lineLimit(1)
|
|
633
|
-
Spacer()
|
|
634
|
-
}
|
|
635
|
-
.padding(.horizontal, 6)
|
|
636
|
-
.padding(.vertical, 4)
|
|
637
|
-
.background(
|
|
638
|
-
RoundedRectangle(cornerRadius: 4)
|
|
639
|
-
.fill(isHov ? Palette.surfaceHov : Palette.surface)
|
|
640
|
-
.overlay(
|
|
641
|
-
RoundedRectangle(cornerRadius: 4)
|
|
642
|
-
.strokeBorder(isHov ? Palette.borderLit : Palette.border, lineWidth: 0.5)
|
|
643
|
-
)
|
|
644
|
-
)
|
|
645
|
-
.contentShape(Rectangle())
|
|
646
|
-
}
|
|
647
|
-
.buttonStyle(.plain)
|
|
648
|
-
.onHover { h in
|
|
649
|
-
let key = "w_\(wid)_\(item.label)"
|
|
650
|
-
hoveredShelfAction = h ? key : (hoveredShelfAction == key ? nil : hoveredShelfAction)
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
.padding(.top, 6)
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
private func inspectorLabel(label: String, value: String) -> some View {
|
|
659
|
-
HStack(spacing: 3) {
|
|
660
|
-
Text(label)
|
|
661
|
-
.font(Typo.monoBold(8))
|
|
662
|
-
.foregroundColor(Palette.textMuted)
|
|
663
|
-
Text(value)
|
|
664
|
-
.font(Typo.mono(9))
|
|
665
|
-
.foregroundColor(Palette.textDim)
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// MARK: - Floating Search Overlay
|
|
670
|
-
|
|
671
|
-
private func floatingSearchOverlay(editor: ScreenMapEditorState) -> some View {
|
|
672
|
-
let results = editor.searchFilteredWindows
|
|
673
|
-
let groups = editor.searchResultsByDisplay
|
|
674
|
-
let highlightIdx = max(0, min(controller.searchHighlightIndex, results.count - 1))
|
|
675
|
-
let terms = editor.searchTerms
|
|
676
|
-
|
|
677
|
-
return VStack(spacing: 0) {
|
|
678
|
-
Spacer().frame(height: 60)
|
|
679
|
-
|
|
680
|
-
VStack(spacing: 0) {
|
|
681
|
-
// Search field
|
|
682
|
-
HStack(spacing: 10) {
|
|
683
|
-
Image(systemName: "magnifyingglass")
|
|
684
|
-
.font(.system(size: 14, weight: .medium))
|
|
685
|
-
.foregroundColor(Self.shelfGreen)
|
|
686
|
-
TextField("Search windows…", text: Binding(
|
|
687
|
-
get: { editor.windowSearchQuery },
|
|
688
|
-
set: { newValue in
|
|
689
|
-
editor.windowSearchQuery = newValue
|
|
690
|
-
controller.searchHighlightIndex = 0
|
|
691
|
-
}
|
|
692
|
-
))
|
|
693
|
-
.textFieldStyle(.plain)
|
|
694
|
-
.font(Typo.mono(14))
|
|
695
|
-
.foregroundColor(Palette.text)
|
|
696
|
-
.focused($isSearchFieldFocused)
|
|
697
|
-
if !editor.windowSearchQuery.isEmpty {
|
|
698
|
-
Text("\(results.count)")
|
|
699
|
-
.font(Typo.monoBold(10))
|
|
700
|
-
.foregroundColor(Palette.textMuted)
|
|
701
|
-
Button {
|
|
702
|
-
editor.windowSearchQuery = ""
|
|
703
|
-
} label: {
|
|
704
|
-
Image(systemName: "xmark.circle.fill")
|
|
705
|
-
.font(.system(size: 12))
|
|
706
|
-
.foregroundColor(Palette.textMuted)
|
|
707
|
-
}
|
|
708
|
-
.buttonStyle(.plain)
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
.padding(.horizontal, 14)
|
|
712
|
-
.padding(.vertical, 10)
|
|
713
|
-
|
|
714
|
-
// Results: side-by-side columns per display
|
|
715
|
-
if !groups.isEmpty {
|
|
716
|
-
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
717
|
-
HStack(alignment: .top, spacing: 0) {
|
|
718
|
-
ForEach(groups.indices, id: \.self) { groupIdx in
|
|
719
|
-
let group = groups[groupIdx]
|
|
720
|
-
if groupIdx > 0 {
|
|
721
|
-
Rectangle().fill(Palette.border).frame(width: 0.5)
|
|
722
|
-
}
|
|
723
|
-
VStack(spacing: 0) {
|
|
724
|
-
// Display header with hover → mini-map highlight
|
|
725
|
-
searchDisplayHeader(
|
|
726
|
-
spatialNumber: group.spatialNumber,
|
|
727
|
-
label: group.label,
|
|
728
|
-
matchCount: group.windows.count,
|
|
729
|
-
isHovered: searchHoveredDisplayIndex == group.displayIndex
|
|
730
|
-
)
|
|
731
|
-
.onHover { hovering in
|
|
732
|
-
searchHoveredDisplayIndex = hovering ? group.displayIndex : nil
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// Window list for this display
|
|
736
|
-
ScrollView(.vertical, showsIndicators: false) {
|
|
737
|
-
VStack(spacing: 2) {
|
|
738
|
-
ForEach(Array(group.windows.enumerated()), id: \.element.id) { _, win in
|
|
739
|
-
let flatIdx = flatIndex(for: win, in: groups)
|
|
740
|
-
let isHighlighted = flatIdx == highlightIdx
|
|
741
|
-
searchResultRow(win: win, editor: editor, terms: terms, isHighlighted: isHighlighted)
|
|
742
|
-
.onTapGesture {
|
|
743
|
-
controller.selectSingle(win.id)
|
|
744
|
-
if editor.searchHasDirectHit {
|
|
745
|
-
controller.closeSearch()
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
.padding(4)
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
.frame(maxWidth: .infinity)
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
.frame(maxHeight: 280)
|
|
757
|
-
} else if !editor.windowSearchQuery.isEmpty {
|
|
758
|
-
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
759
|
-
Text("No matches")
|
|
760
|
-
.font(Typo.mono(11))
|
|
761
|
-
.foregroundColor(Palette.textMuted)
|
|
762
|
-
.padding(.vertical, 12)
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// Keyboard hints
|
|
766
|
-
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
767
|
-
HStack(spacing: 8) {
|
|
768
|
-
searchHint("↑↓", label: "nav")
|
|
769
|
-
searchHint("↩", label: "select")
|
|
770
|
-
searchHint("⌘↩", label: "show")
|
|
771
|
-
searchHint("esc", label: "close")
|
|
772
|
-
if terms.count > 1 {
|
|
773
|
-
Spacer()
|
|
774
|
-
Text("\(terms.count) terms")
|
|
775
|
-
.font(Typo.mono(7))
|
|
776
|
-
.foregroundColor(Palette.textMuted)
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
.padding(.horizontal, 10)
|
|
780
|
-
.padding(.vertical, 5)
|
|
781
|
-
}
|
|
782
|
-
.background(
|
|
783
|
-
RoundedRectangle(cornerRadius: 10)
|
|
784
|
-
.fill(Color(red: 0.1, green: 0.1, blue: 0.11))
|
|
785
|
-
.overlay(
|
|
786
|
-
RoundedRectangle(cornerRadius: 10)
|
|
787
|
-
.strokeBorder(Self.shelfGreen.opacity(0.3), lineWidth: 1)
|
|
788
|
-
)
|
|
789
|
-
.shadow(color: Self.shelfGreen.opacity(0.15), radius: 20)
|
|
790
|
-
.shadow(color: Color.black.opacity(0.5), radius: 30)
|
|
791
|
-
)
|
|
792
|
-
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
793
|
-
.frame(width: groups.count > 1 ? 600 : 500)
|
|
794
|
-
.background(
|
|
795
|
-
GeometryReader { geo in
|
|
796
|
-
Color.clear.preference(key: SearchOverlayFrameKey.self,
|
|
797
|
-
value: geo.frame(in: .global))
|
|
798
|
-
}
|
|
799
|
-
)
|
|
800
|
-
.onPreferenceChange(SearchOverlayFrameKey.self) { frame in
|
|
801
|
-
searchOverlayFrame = frame
|
|
802
|
-
}
|
|
803
|
-
.onAppear {
|
|
804
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
805
|
-
isSearchFieldFocused = true
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
Spacer()
|
|
810
|
-
}
|
|
811
|
-
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
812
|
-
.background(Color.black.opacity(0.3))
|
|
813
|
-
.contentShape(Rectangle())
|
|
814
|
-
.onTapGesture {
|
|
815
|
-
controller.closeSearch()
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/// Compute flat index of a window within the grouped results (for highlight tracking)
|
|
820
|
-
private func flatIndex(
|
|
821
|
-
for win: ScreenMapWindowEntry,
|
|
822
|
-
in groups: [(displayIndex: Int, spatialNumber: Int, label: String, windows: [ScreenMapWindowEntry])]
|
|
823
|
-
) -> Int {
|
|
824
|
-
var idx = 0
|
|
825
|
-
for group in groups {
|
|
826
|
-
for w in group.windows {
|
|
827
|
-
if w.id == win.id { return idx }
|
|
828
|
-
idx += 1
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
return 0
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
/// Display section header within search results
|
|
835
|
-
private func searchDisplayHeader(spatialNumber: Int, label: String, matchCount: Int, isHovered: Bool = false) -> some View {
|
|
836
|
-
HStack(spacing: 6) {
|
|
837
|
-
Text("\(spatialNumber)")
|
|
838
|
-
.font(Typo.monoBold(8))
|
|
839
|
-
.foregroundColor(isHovered ? Palette.bg : Palette.bg)
|
|
840
|
-
.frame(width: 14, height: 14)
|
|
841
|
-
.background(
|
|
842
|
-
RoundedRectangle(cornerRadius: 3)
|
|
843
|
-
.fill(isHovered ? Self.shelfGreen : Palette.textMuted)
|
|
844
|
-
)
|
|
845
|
-
Text(label)
|
|
846
|
-
.font(Typo.mono(9))
|
|
847
|
-
.foregroundColor(isHovered ? Palette.text : Palette.textMuted)
|
|
848
|
-
.lineLimit(1)
|
|
849
|
-
Spacer()
|
|
850
|
-
Text("\(matchCount)")
|
|
851
|
-
.font(Typo.monoBold(8))
|
|
852
|
-
.foregroundColor(isHovered ? Self.shelfGreen : Palette.textMuted)
|
|
853
|
-
}
|
|
854
|
-
.padding(.horizontal, 8)
|
|
855
|
-
.padding(.top, 6)
|
|
856
|
-
.padding(.bottom, 4)
|
|
857
|
-
.background(isHovered ? Self.shelfGreen.opacity(0.06) : Color.clear)
|
|
858
|
-
.contentShape(Rectangle())
|
|
859
|
-
.animation(.easeInOut(duration: 0.15), value: isHovered)
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
private func searchResultRow(win: ScreenMapWindowEntry, editor: ScreenMapEditorState, terms: [String], isHighlighted: Bool) -> some View {
|
|
863
|
-
HStack(spacing: 6) {
|
|
864
|
-
Circle()
|
|
865
|
-
.fill(Self.layerColor(for: win.layer))
|
|
866
|
-
.frame(width: 5, height: 5)
|
|
867
|
-
VStack(alignment: .leading, spacing: 1) {
|
|
868
|
-
highlightedText(win.app, terms: terms, baseFont: Typo.monoBold(9),
|
|
869
|
-
baseColor: isHighlighted ? Palette.text : Palette.textDim)
|
|
870
|
-
.lineLimit(1)
|
|
871
|
-
if !win.title.isEmpty {
|
|
872
|
-
highlightedText(win.title, terms: terms, baseFont: Typo.mono(8),
|
|
873
|
-
baseColor: Palette.textMuted)
|
|
874
|
-
.lineLimit(1)
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
Spacer()
|
|
878
|
-
if isHighlighted {
|
|
879
|
-
Button {
|
|
880
|
-
controller.focusWindowOnScreen(win.id)
|
|
881
|
-
} label: {
|
|
882
|
-
Image(systemName: "macwindow.and.cursorarrow")
|
|
883
|
-
.font(.system(size: 8))
|
|
884
|
-
.foregroundColor(Self.shelfGreen)
|
|
885
|
-
.padding(3)
|
|
886
|
-
.background(
|
|
887
|
-
RoundedRectangle(cornerRadius: 3)
|
|
888
|
-
.fill(Self.shelfGreen.opacity(0.1))
|
|
889
|
-
)
|
|
890
|
-
}
|
|
891
|
-
.buttonStyle(.plain)
|
|
892
|
-
.help("Show on screen (⌘↩)")
|
|
893
|
-
}
|
|
894
|
-
Text(editor.layerDisplayName(for: win.layer))
|
|
895
|
-
.font(Typo.mono(7))
|
|
896
|
-
.foregroundColor(Palette.textMuted)
|
|
897
|
-
.padding(.horizontal, 4)
|
|
898
|
-
.padding(.vertical, 1)
|
|
899
|
-
.background(
|
|
900
|
-
RoundedRectangle(cornerRadius: 3)
|
|
901
|
-
.fill(Self.layerColor(for: win.layer).opacity(0.15))
|
|
902
|
-
)
|
|
903
|
-
}
|
|
904
|
-
.padding(.horizontal, 6)
|
|
905
|
-
.padding(.vertical, 4)
|
|
906
|
-
.background(
|
|
907
|
-
RoundedRectangle(cornerRadius: 4)
|
|
908
|
-
.fill(isHighlighted ? Self.shelfGreen.opacity(0.12) : Color.clear)
|
|
909
|
-
.overlay(
|
|
910
|
-
RoundedRectangle(cornerRadius: 4)
|
|
911
|
-
.strokeBorder(isHighlighted ? Self.shelfGreen.opacity(0.3) : Color.clear, lineWidth: 0.5)
|
|
912
|
-
)
|
|
913
|
-
)
|
|
914
|
-
.contentShape(Rectangle())
|
|
915
|
-
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/// Highlight matching search terms within text
|
|
919
|
-
private func highlightedText(_ text: String, terms: [String], baseFont: Font, baseColor: Color) -> Text {
|
|
920
|
-
guard !terms.isEmpty else {
|
|
921
|
-
return Text(text).font(baseFont).foregroundColor(baseColor)
|
|
922
|
-
}
|
|
923
|
-
let lower = text.lowercased()
|
|
924
|
-
// Build set of character offsets that match any term
|
|
925
|
-
var matchSet = IndexSet()
|
|
926
|
-
for term in terms {
|
|
927
|
-
var searchStart = lower.startIndex
|
|
928
|
-
while searchStart < lower.endIndex,
|
|
929
|
-
let range = lower.range(of: term, range: searchStart..<lower.endIndex) {
|
|
930
|
-
let startOffset = lower.distance(from: lower.startIndex, to: range.lowerBound)
|
|
931
|
-
let length = lower.distance(from: range.lowerBound, to: range.upperBound)
|
|
932
|
-
matchSet.insert(integersIn: startOffset..<(startOffset + length))
|
|
933
|
-
searchStart = range.upperBound
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
// Convert to segments
|
|
937
|
-
var result = Text("")
|
|
938
|
-
var i = 0
|
|
939
|
-
let chars = Array(text)
|
|
940
|
-
while i < chars.count {
|
|
941
|
-
let isMatch = matchSet.contains(i)
|
|
942
|
-
var j = i + 1
|
|
943
|
-
while j < chars.count && matchSet.contains(j) == isMatch { j += 1 }
|
|
944
|
-
let segment = String(chars[i..<j])
|
|
945
|
-
if isMatch {
|
|
946
|
-
result = result + Text(segment).font(baseFont).foregroundColor(Self.shelfGreen)
|
|
947
|
-
} else {
|
|
948
|
-
result = result + Text(segment).font(baseFont).foregroundColor(baseColor)
|
|
949
|
-
}
|
|
950
|
-
i = j
|
|
951
|
-
}
|
|
952
|
-
return result
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
private func footerHint(_ key: String, label: String) -> some View {
|
|
956
|
-
HStack(spacing: 2) {
|
|
957
|
-
Text(key)
|
|
958
|
-
.font(Typo.monoBold(8))
|
|
959
|
-
.foregroundColor(Palette.textDim)
|
|
960
|
-
.padding(.horizontal, 3)
|
|
961
|
-
.padding(.vertical, 1)
|
|
962
|
-
.background(
|
|
963
|
-
RoundedRectangle(cornerRadius: 2)
|
|
964
|
-
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
965
|
-
)
|
|
966
|
-
Text(label)
|
|
967
|
-
.font(Typo.mono(8))
|
|
968
|
-
.foregroundColor(Palette.textMuted)
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
private func searchHint(_ key: String, label: String) -> some View {
|
|
973
|
-
HStack(spacing: 3) {
|
|
974
|
-
Text(key)
|
|
975
|
-
.font(Typo.monoBold(7))
|
|
976
|
-
.foregroundColor(Palette.textDim)
|
|
977
|
-
.padding(.horizontal, 3)
|
|
978
|
-
.padding(.vertical, 1)
|
|
979
|
-
.background(
|
|
980
|
-
RoundedRectangle(cornerRadius: 2)
|
|
981
|
-
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
982
|
-
)
|
|
983
|
-
Text(label)
|
|
984
|
-
.font(Typo.mono(7))
|
|
985
|
-
.foregroundColor(Palette.textMuted)
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// MARK: - Inspector Bottom Rail
|
|
990
|
-
|
|
991
|
-
private func inspectorActionTray(editor: ScreenMapEditorState) -> some View {
|
|
992
|
-
let actions: [(key: String, label: String, action: () -> Void)] = [
|
|
993
|
-
("s", "spread", { [controller] in controller.smartSpreadLayer() }),
|
|
994
|
-
("e", "expose", { [controller] in controller.exposeLayer() }),
|
|
995
|
-
("t", "tile", { [controller] in controller.tileLayer() }),
|
|
996
|
-
("d", "distrib", { [controller] in controller.distributeVisible() }),
|
|
997
|
-
("g", "grow", { [controller] in controller.fitAvailableSpace() }),
|
|
998
|
-
("u", "set", { [controller] in controller.createWindowSetFromSelection() }),
|
|
999
|
-
("m", "project", { [controller] in controller.materializeViewport() }),
|
|
1000
|
-
("v", "preview", { [controller] in controller.previewLayer() }),
|
|
1001
|
-
]
|
|
1002
|
-
|
|
1003
|
-
let columns = [GridItem(.flexible()), GridItem(.flexible())]
|
|
1004
|
-
let editCount = editor.pendingEditCount
|
|
1005
|
-
let isZoomed = editor.zoomLevel != 1.0 || editor.panOffset != .zero
|
|
1006
|
-
|
|
1007
|
-
return VStack(spacing: 0) {
|
|
1008
|
-
// Contextual commands area (fixed slot, always reserved)
|
|
1009
|
-
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
1010
|
-
VStack(spacing: 0) {
|
|
1011
|
-
if editor.isTilingMode {
|
|
1012
|
-
VStack(spacing: 4) {
|
|
1013
|
-
HStack(spacing: 4) {
|
|
1014
|
-
Text("TILE")
|
|
1015
|
-
.font(Typo.monoBold(9))
|
|
1016
|
-
.foregroundColor(.white)
|
|
1017
|
-
.padding(.horizontal, 5)
|
|
1018
|
-
.padding(.vertical, 2)
|
|
1019
|
-
.background(RoundedRectangle(cornerRadius: 3).fill(Self.shelfGreen))
|
|
1020
|
-
Spacer()
|
|
1021
|
-
Text("esc cancel")
|
|
1022
|
-
.font(Typo.mono(7))
|
|
1023
|
-
.foregroundColor(Palette.textMuted)
|
|
1024
|
-
}
|
|
1025
|
-
HStack(spacing: 3) {
|
|
1026
|
-
ForEach(["←", "→", "↑", "↓"], id: \.self) { key in
|
|
1027
|
-
Text(key)
|
|
1028
|
-
.font(Typo.monoBold(8))
|
|
1029
|
-
.foregroundColor(Palette.textDim)
|
|
1030
|
-
.padding(.horizontal, 3)
|
|
1031
|
-
.padding(.vertical, 1)
|
|
1032
|
-
.background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
|
|
1033
|
-
.overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
|
|
1034
|
-
}
|
|
1035
|
-
Text("1-7")
|
|
1036
|
-
.font(Typo.monoBold(8))
|
|
1037
|
-
.foregroundColor(Palette.textDim)
|
|
1038
|
-
.padding(.horizontal, 3)
|
|
1039
|
-
.padding(.vertical, 1)
|
|
1040
|
-
.background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
|
|
1041
|
-
.overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
|
|
1042
|
-
Text("c")
|
|
1043
|
-
.font(Typo.monoBold(8))
|
|
1044
|
-
.foregroundColor(Palette.textDim)
|
|
1045
|
-
.padding(.horizontal, 3)
|
|
1046
|
-
.padding(.vertical, 1)
|
|
1047
|
-
.background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
|
|
1048
|
-
.overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
|
|
1049
|
-
Spacer()
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
.padding(.horizontal, 8)
|
|
1053
|
-
.padding(.vertical, 5)
|
|
1054
|
-
}
|
|
1055
|
-
if editCount > 0 {
|
|
1056
|
-
Button {
|
|
1057
|
-
controller.applyEditsFromButton()
|
|
1058
|
-
} label: {
|
|
1059
|
-
HStack(spacing: 6) {
|
|
1060
|
-
Text("↩")
|
|
1061
|
-
.font(Typo.monoBold(10))
|
|
1062
|
-
.foregroundColor(Self.shelfGreen)
|
|
1063
|
-
Text("Apply \(editCount) \(editCount == 1 ? "edit" : "edits")")
|
|
1064
|
-
.font(Typo.monoBold(9))
|
|
1065
|
-
.foregroundColor(Self.shelfGreen)
|
|
1066
|
-
Spacer()
|
|
1067
|
-
}
|
|
1068
|
-
.padding(.horizontal, 8)
|
|
1069
|
-
.padding(.vertical, 5)
|
|
1070
|
-
.contentShape(Rectangle())
|
|
1071
|
-
}
|
|
1072
|
-
.buttonStyle(.plain)
|
|
1073
|
-
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
1074
|
-
}
|
|
1075
|
-
if isZoomed {
|
|
1076
|
-
Button {
|
|
1077
|
-
controller.focusViewportPreset(.overview)
|
|
1078
|
-
} label: {
|
|
1079
|
-
HStack(spacing: 4) {
|
|
1080
|
-
Text("r")
|
|
1081
|
-
.font(Typo.monoBold(8))
|
|
1082
|
-
.foregroundColor(Self.shelfGreen)
|
|
1083
|
-
.padding(.horizontal, 4)
|
|
1084
|
-
.padding(.vertical, 1)
|
|
1085
|
-
.background(RoundedRectangle(cornerRadius: 2).fill(Self.shelfGreen.opacity(0.15)))
|
|
1086
|
-
Text("fit all")
|
|
1087
|
-
.font(Typo.mono(8))
|
|
1088
|
-
.foregroundColor(Palette.textDim)
|
|
1089
|
-
Spacer()
|
|
1090
|
-
Text("\(Int(editor.zoomLevel * 100))%")
|
|
1091
|
-
.font(Typo.mono(8))
|
|
1092
|
-
.foregroundColor(Palette.textMuted)
|
|
1093
|
-
}
|
|
1094
|
-
.padding(.horizontal, 8)
|
|
1095
|
-
.padding(.vertical, 4)
|
|
1096
|
-
.contentShape(Rectangle())
|
|
1097
|
-
}
|
|
1098
|
-
.buttonStyle(.plain)
|
|
1099
|
-
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
1100
|
-
}
|
|
1101
|
-
if let ref = editor.lastActionRef {
|
|
1102
|
-
Button {
|
|
1103
|
-
if let json = editor.actionLog.lastEntryJSON() {
|
|
1104
|
-
NSPasteboard.general.clearContents()
|
|
1105
|
-
NSPasteboard.general.setString(json, forType: .string)
|
|
1106
|
-
controller.flash("Copied \(ref)")
|
|
1107
|
-
}
|
|
1108
|
-
} label: {
|
|
1109
|
-
HStack(spacing: 4) {
|
|
1110
|
-
Text(ref)
|
|
1111
|
-
.font(Typo.monoBold(8))
|
|
1112
|
-
.foregroundColor(Self.shelfGreen.opacity(0.6))
|
|
1113
|
-
Spacer()
|
|
1114
|
-
Text("copy")
|
|
1115
|
-
.font(Typo.mono(7))
|
|
1116
|
-
.foregroundColor(Palette.textMuted)
|
|
1117
|
-
}
|
|
1118
|
-
.padding(.horizontal, 8)
|
|
1119
|
-
.padding(.vertical, 4)
|
|
1120
|
-
.contentShape(Rectangle())
|
|
1121
|
-
}
|
|
1122
|
-
.buttonStyle(.plain)
|
|
1123
|
-
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
.frame(maxWidth: .infinity)
|
|
1127
|
-
.background(Color(red: 0.05, green: 0.05, blue: 0.06))
|
|
1128
|
-
|
|
1129
|
-
// Actions grid (always pinned at bottom)
|
|
1130
|
-
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
1131
|
-
|
|
1132
|
-
Text("ACTIONS")
|
|
1133
|
-
.font(Typo.monoBold(8))
|
|
1134
|
-
.foregroundColor(Palette.textMuted)
|
|
1135
|
-
.frame(maxWidth: .infinity, alignment: .leading)
|
|
1136
|
-
.padding(.horizontal, 8)
|
|
1137
|
-
.padding(.top, 6)
|
|
1138
|
-
.padding(.bottom, 4)
|
|
1139
|
-
|
|
1140
|
-
LazyVGrid(columns: columns, spacing: 4) {
|
|
1141
|
-
ForEach(Array(actions.enumerated()), id: \.offset) { _, item in
|
|
1142
|
-
let isHovered = hoveredShelfAction == item.key
|
|
1143
|
-
Button(action: item.action) {
|
|
1144
|
-
HStack(spacing: 4) {
|
|
1145
|
-
Text(item.key)
|
|
1146
|
-
.font(Typo.monoBold(8))
|
|
1147
|
-
.foregroundColor(Self.shelfGreen)
|
|
1148
|
-
.frame(width: 14)
|
|
1149
|
-
Text(item.label)
|
|
1150
|
-
.font(Typo.mono(8))
|
|
1151
|
-
.foregroundColor(isHovered ? Palette.text : Palette.textDim)
|
|
1152
|
-
.lineLimit(1)
|
|
1153
|
-
Spacer()
|
|
1154
|
-
}
|
|
1155
|
-
.padding(.horizontal, 6)
|
|
1156
|
-
.padding(.vertical, 4)
|
|
1157
|
-
.background(
|
|
1158
|
-
RoundedRectangle(cornerRadius: 4)
|
|
1159
|
-
.fill(isHovered ? Palette.surfaceHov : Palette.surface)
|
|
1160
|
-
.overlay(
|
|
1161
|
-
RoundedRectangle(cornerRadius: 4)
|
|
1162
|
-
.strokeBorder(isHovered ? Palette.borderLit : Palette.border, lineWidth: 0.5)
|
|
1163
|
-
)
|
|
1164
|
-
)
|
|
1165
|
-
.contentShape(Rectangle())
|
|
1166
|
-
}
|
|
1167
|
-
.buttonStyle(.plain)
|
|
1168
|
-
.onHover { h in
|
|
1169
|
-
hoveredShelfAction = h ? item.key : (hoveredShelfAction == item.key ? nil : hoveredShelfAction)
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
.padding(.horizontal, 6)
|
|
1174
|
-
.padding(.bottom, 4)
|
|
1175
|
-
|
|
1176
|
-
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
1177
|
-
inspectorVoiceTray
|
|
1178
|
-
|
|
1179
|
-
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
1180
|
-
inspectorLogTray
|
|
1181
|
-
}
|
|
1182
|
-
.background(Color(red: 0.06, green: 0.06, blue: 0.07))
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
private var inspectorVoiceStateLabel: String {
|
|
1186
|
-
switch handsOff.state {
|
|
1187
|
-
case .idle: return handsOff.lastTranscript == nil ? "ready" : "idle"
|
|
1188
|
-
case .connecting: return "connecting"
|
|
1189
|
-
case .listening: return "listening"
|
|
1190
|
-
case .thinking: return "thinking"
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
private var inspectorVoiceColor: Color {
|
|
1195
|
-
switch handsOff.state {
|
|
1196
|
-
case .idle: return Palette.textMuted.opacity(0.55)
|
|
1197
|
-
case .connecting: return Palette.detach
|
|
1198
|
-
case .listening: return Palette.running
|
|
1199
|
-
case .thinking: return Palette.detach
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
private var visibleDiagnosticEntries: [DiagnosticLog.Entry] {
|
|
1204
|
-
let entries = diagnosticLog.entries
|
|
1205
|
-
let tail = 8
|
|
1206
|
-
if entries.count <= tail { return entries }
|
|
1207
|
-
return Array(entries.suffix(tail))
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
private var inspectorVoiceTray: some View {
|
|
1211
|
-
VStack(alignment: .leading, spacing: 6) {
|
|
1212
|
-
HStack(spacing: 6) {
|
|
1213
|
-
Text("VOICE")
|
|
1214
|
-
.font(Typo.monoBold(8))
|
|
1215
|
-
.foregroundColor(Palette.textMuted)
|
|
1216
|
-
Spacer()
|
|
1217
|
-
Circle()
|
|
1218
|
-
.fill(inspectorVoiceColor)
|
|
1219
|
-
.frame(width: 6, height: 6)
|
|
1220
|
-
Text(inspectorVoiceStateLabel)
|
|
1221
|
-
.font(Typo.mono(8))
|
|
1222
|
-
.foregroundColor(inspectorVoiceColor)
|
|
1223
|
-
Text("V")
|
|
1224
|
-
.font(Typo.monoBold(7))
|
|
1225
|
-
.foregroundColor(Palette.textDim)
|
|
1226
|
-
.padding(.horizontal, 4)
|
|
1227
|
-
.padding(.vertical, 2)
|
|
1228
|
-
.background(
|
|
1229
|
-
RoundedRectangle(cornerRadius: 3)
|
|
1230
|
-
.fill(Palette.surface)
|
|
1231
|
-
.overlay(
|
|
1232
|
-
RoundedRectangle(cornerRadius: 3)
|
|
1233
|
-
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
1234
|
-
)
|
|
1235
|
-
)
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
if let transcript = handsOff.lastTranscript, !transcript.isEmpty {
|
|
1239
|
-
VStack(alignment: .leading, spacing: 2) {
|
|
1240
|
-
Text("heard")
|
|
1241
|
-
.font(Typo.mono(7))
|
|
1242
|
-
.foregroundColor(Palette.textMuted)
|
|
1243
|
-
Text(transcript)
|
|
1244
|
-
.font(Typo.mono(8))
|
|
1245
|
-
.foregroundColor(Palette.text)
|
|
1246
|
-
.lineLimit(2)
|
|
1247
|
-
}
|
|
1248
|
-
} else {
|
|
1249
|
-
Text("Voice activity will show up here. Press V to talk.")
|
|
1250
|
-
.font(Typo.mono(8))
|
|
1251
|
-
.foregroundColor(Palette.textMuted)
|
|
1252
|
-
.lineLimit(2)
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
if let response = handsOff.lastResponse, !response.isEmpty {
|
|
1256
|
-
VStack(alignment: .leading, spacing: 2) {
|
|
1257
|
-
Text("response")
|
|
1258
|
-
.font(Typo.mono(7))
|
|
1259
|
-
.foregroundColor(Palette.textMuted)
|
|
1260
|
-
Text(response)
|
|
1261
|
-
.font(Typo.mono(8))
|
|
1262
|
-
.foregroundColor(Palette.textDim)
|
|
1263
|
-
.lineLimit(2)
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
.padding(.horizontal, 8)
|
|
1268
|
-
.padding(.vertical, 8)
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
private var inspectorLogTray: some View {
|
|
1272
|
-
VStack(alignment: .leading, spacing: 6) {
|
|
1273
|
-
HStack(spacing: 6) {
|
|
1274
|
-
Text("LOGS")
|
|
1275
|
-
.font(Typo.monoBold(8))
|
|
1276
|
-
.foregroundColor(Palette.textMuted)
|
|
1277
|
-
Spacer()
|
|
1278
|
-
if !visibleDiagnosticEntries.isEmpty {
|
|
1279
|
-
Button("copy") {
|
|
1280
|
-
let text = visibleDiagnosticEntries.map { entry in
|
|
1281
|
-
"\(Self.inspectorLogTimeFormatter.string(from: entry.time)) \(entry.icon) \(entry.message)"
|
|
1282
|
-
}.joined(separator: "\n")
|
|
1283
|
-
NSPasteboard.general.clearContents()
|
|
1284
|
-
NSPasteboard.general.setString(text, forType: .string)
|
|
1285
|
-
controller.flash("Copied logs")
|
|
1286
|
-
}
|
|
1287
|
-
.font(Typo.mono(7))
|
|
1288
|
-
.foregroundColor(Palette.textMuted)
|
|
1289
|
-
.buttonStyle(.plain)
|
|
1290
|
-
}
|
|
1291
|
-
Button("open") {
|
|
1292
|
-
DiagnosticWindow.shared.toggle()
|
|
1293
|
-
}
|
|
1294
|
-
.font(Typo.mono(7))
|
|
1295
|
-
.foregroundColor(Palette.textMuted)
|
|
1296
|
-
.buttonStyle(.plain)
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
if visibleDiagnosticEntries.isEmpty {
|
|
1300
|
-
Text("Waiting for diagnostic activity.")
|
|
1301
|
-
.font(Typo.mono(8))
|
|
1302
|
-
.foregroundColor(Palette.textMuted)
|
|
1303
|
-
} else {
|
|
1304
|
-
VStack(alignment: .leading, spacing: 5) {
|
|
1305
|
-
ForEach(visibleDiagnosticEntries) { entry in
|
|
1306
|
-
HStack(alignment: .top, spacing: 6) {
|
|
1307
|
-
Text(Self.inspectorLogTimeFormatter.string(from: entry.time))
|
|
1308
|
-
.font(Typo.mono(7))
|
|
1309
|
-
.foregroundColor(Palette.textMuted)
|
|
1310
|
-
.frame(width: 52, alignment: .leading)
|
|
1311
|
-
Text(entry.icon)
|
|
1312
|
-
.font(Typo.monoBold(7))
|
|
1313
|
-
.foregroundColor(inspectorLogColor(entry.level))
|
|
1314
|
-
.frame(width: 8, alignment: .leading)
|
|
1315
|
-
Text(entry.message)
|
|
1316
|
-
.font(Typo.mono(8))
|
|
1317
|
-
.foregroundColor(inspectorLogColor(entry.level))
|
|
1318
|
-
.lineLimit(2)
|
|
1319
|
-
.frame(maxWidth: .infinity, alignment: .leading)
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
.frame(maxWidth: .infinity, alignment: .leading)
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
.padding(.horizontal, 8)
|
|
1327
|
-
.padding(.vertical, 8)
|
|
1328
|
-
.frame(maxWidth: .infinity, alignment: .leading)
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
private func inspectorLogColor(_ level: DiagnosticLog.Entry.Level) -> Color {
|
|
1332
|
-
switch level {
|
|
1333
|
-
case .info: return Palette.textDim
|
|
1334
|
-
case .success: return Palette.running
|
|
1335
|
-
case .warning: return Palette.detach
|
|
1336
|
-
case .error: return Palette.kill
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
private func inspectorRow(label: String, value: String) -> some View {
|
|
1341
|
-
HStack(alignment: .top, spacing: 0) {
|
|
1342
|
-
Text(label)
|
|
1343
|
-
.font(Typo.mono(8))
|
|
1344
|
-
.foregroundColor(Palette.textMuted)
|
|
1345
|
-
.frame(width: 52, alignment: .leading)
|
|
1346
|
-
Text(value)
|
|
1347
|
-
.font(Typo.mono(8))
|
|
1348
|
-
.foregroundColor(Palette.textDim)
|
|
1349
|
-
.lineLimit(2)
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
// MARK: - Canvas Context Badge
|
|
1354
|
-
|
|
1355
|
-
private var canvasContextBadge: some View {
|
|
1356
|
-
HStack(spacing: 6) {
|
|
1357
|
-
if let editor = controller.editor {
|
|
1358
|
-
let layerColor = editor.activeLayer != nil
|
|
1359
|
-
? Self.layerColor(for: editor.activeLayer!)
|
|
1360
|
-
: Palette.running
|
|
1361
|
-
|
|
1362
|
-
Circle()
|
|
1363
|
-
.fill(layerColor)
|
|
1364
|
-
.frame(width: 6, height: 6)
|
|
1365
|
-
|
|
1366
|
-
Text(editor.layerLabel)
|
|
1367
|
-
.font(Typo.monoBold(9))
|
|
1368
|
-
.foregroundColor(layerColor)
|
|
1369
|
-
|
|
1370
|
-
Text("·")
|
|
1371
|
-
.foregroundColor(Palette.textMuted)
|
|
1372
|
-
|
|
1373
|
-
Text("\(editor.focusedVisibleWindows.count) windows")
|
|
1374
|
-
.font(Typo.mono(9))
|
|
1375
|
-
.foregroundColor(Palette.textDim)
|
|
1376
|
-
|
|
1377
|
-
Text("·")
|
|
1378
|
-
.foregroundColor(Palette.textMuted)
|
|
1379
|
-
|
|
1380
|
-
Text(editor.viewportPresetSummary.uppercased())
|
|
1381
|
-
.font(Typo.monoBold(8))
|
|
1382
|
-
.foregroundColor(Palette.textMuted)
|
|
1383
|
-
|
|
1384
|
-
if let focused = editor.focusedDisplay {
|
|
1385
|
-
Text("·")
|
|
1386
|
-
.foregroundColor(Palette.textMuted)
|
|
1387
|
-
Text(focused.label)
|
|
1388
|
-
.font(Typo.mono(8))
|
|
1389
|
-
.foregroundColor(Palette.textMuted)
|
|
1390
|
-
.lineLimit(1)
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
let editCount = editor.windows.filter { $0.hasEdits }.count
|
|
1394
|
-
if editCount > 0 {
|
|
1395
|
-
Text("·")
|
|
1396
|
-
.foregroundColor(Palette.textMuted)
|
|
1397
|
-
Text("\(editCount) pending")
|
|
1398
|
-
.font(Typo.mono(8))
|
|
1399
|
-
.foregroundColor(Color.orange.opacity(0.8))
|
|
1400
|
-
.onTapGesture { controller.applyEditsFromButton() }
|
|
1401
|
-
.onHover { hovering in
|
|
1402
|
-
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
if let ref = editor.lastActionRef {
|
|
1407
|
-
Text("·")
|
|
1408
|
-
.foregroundColor(Palette.textMuted)
|
|
1409
|
-
Text(ref)
|
|
1410
|
-
.font(Typo.monoBold(8))
|
|
1411
|
-
.foregroundColor(Self.shelfGreen.opacity(0.7))
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
.padding(.horizontal, 8)
|
|
1416
|
-
.padding(.vertical, 4)
|
|
1417
|
-
.background(
|
|
1418
|
-
RoundedRectangle(cornerRadius: 6)
|
|
1419
|
-
.fill(Color.black.opacity(0.55))
|
|
1420
|
-
.overlay(
|
|
1421
|
-
RoundedRectangle(cornerRadius: 6)
|
|
1422
|
-
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
|
1423
|
-
)
|
|
1424
|
-
)
|
|
1425
|
-
.padding(10)
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
// MARK: - Layer Sidebar
|
|
1429
|
-
|
|
1430
|
-
private func layerSidebar(editor: ScreenMapEditorState) -> some View {
|
|
1431
|
-
return VStack(spacing: 0) {
|
|
1432
|
-
// Header
|
|
1433
|
-
HStack {
|
|
1434
|
-
Text("VIEW")
|
|
1435
|
-
.font(Typo.monoBold(9))
|
|
1436
|
-
.foregroundColor(Palette.textMuted)
|
|
1437
|
-
Spacer()
|
|
1438
|
-
if editor.effectiveLayerCount > 1 && !editor.isShowingAll {
|
|
1439
|
-
Button(action: { controller.consolidateLayers() }) {
|
|
1440
|
-
Image(systemName: "arrow.triangle.merge")
|
|
1441
|
-
.font(.system(size: 8, weight: .semibold))
|
|
1442
|
-
.foregroundColor(Palette.textDim)
|
|
1443
|
-
}
|
|
1444
|
-
.buttonStyle(.plain)
|
|
1445
|
-
.help("Defrag layers (c)")
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
.padding(.bottom, 8)
|
|
1449
|
-
|
|
1450
|
-
// Layer list
|
|
1451
|
-
ScrollView(.vertical, showsIndicators: false) {
|
|
1452
|
-
let visibleWindows = editor.renderedCanvasWindows.sorted { $0.zIndex < $1.zIndex }
|
|
1453
|
-
let rowWidth = max(sidebarWidth - 16, 1)
|
|
1454
|
-
|
|
1455
|
-
ZStack(alignment: .topLeading) {
|
|
1456
|
-
VStack(spacing: 2) {
|
|
1457
|
-
layerTreeHeader(
|
|
1458
|
-
label: "Desktop",
|
|
1459
|
-
count: visibleWindows.count,
|
|
1460
|
-
isActive: editor.isShowingAll,
|
|
1461
|
-
color: Palette.running
|
|
1462
|
-
) {
|
|
1463
|
-
editor.selectLayer(nil)
|
|
1464
|
-
}
|
|
1465
|
-
.frame(width: rowWidth, height: Self.sidebarWindowRowHeight, alignment: .leading)
|
|
1466
|
-
|
|
1467
|
-
ForEach(visibleWindows) { win in
|
|
1468
|
-
visibleWindowRow(win: win)
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
/*
|
|
1472
|
-
Depth controls intentionally stay out of the default flow.
|
|
1473
|
-
Keep the old layer model available internally, but make the
|
|
1474
|
-
normal sidebar answer: "which windows are visible right now?"
|
|
1475
|
-
*/
|
|
1476
|
-
if !editor.isShowingAll {
|
|
1477
|
-
let namedLayers = editor.namedEffectiveLayers
|
|
1478
|
-
let unnamedLayers = editor.unnamedEffectiveLayers
|
|
1479
|
-
ForEach(namedLayers, id: \.self) { layer in
|
|
1480
|
-
layerRow(layer: layer, editor: editor)
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
if !unnamedLayers.isEmpty {
|
|
1484
|
-
HStack(spacing: 4) {
|
|
1485
|
-
let totalWindows = unnamedLayers.reduce(0) { $0 + editor.layerTreeWindows(for: $1).count }
|
|
1486
|
-
Image(systemName: showUnnamedLayers ? "chevron.down" : "chevron.right")
|
|
1487
|
-
.font(.system(size: 6, weight: .bold))
|
|
1488
|
-
.foregroundColor(Palette.textMuted)
|
|
1489
|
-
Text(showUnnamedLayers ? "hide depth" : "show depth")
|
|
1490
|
-
.font(Typo.mono(8))
|
|
1491
|
-
.foregroundColor(Palette.textMuted)
|
|
1492
|
-
Text("· \(totalWindows)w")
|
|
1493
|
-
.font(Typo.mono(7))
|
|
1494
|
-
.foregroundColor(Palette.textDim)
|
|
1495
|
-
Spacer()
|
|
1496
|
-
}
|
|
1497
|
-
.padding(.vertical, 4)
|
|
1498
|
-
.padding(.horizontal, 4)
|
|
1499
|
-
.contentShape(Rectangle())
|
|
1500
|
-
.simultaneousGesture(TapGesture().onEnded { showUnnamedLayers.toggle() })
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
if showUnnamedLayers {
|
|
1504
|
-
ForEach(unnamedLayers, id: \.self) { layer in
|
|
1505
|
-
layerRow(layer: layer, editor: editor)
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
.frame(width: rowWidth, alignment: .topLeading)
|
|
1511
|
-
.allowsHitTesting(!editor.isShowingAll)
|
|
1512
|
-
|
|
1513
|
-
if editor.isShowingAll {
|
|
1514
|
-
SidebarWindowHitCatcher(rowHeight: Self.sidebarWindowRowStride) { row in
|
|
1515
|
-
if row == 0 {
|
|
1516
|
-
editor.selectLayer(nil)
|
|
1517
|
-
} else {
|
|
1518
|
-
let index = row - 1
|
|
1519
|
-
guard visibleWindows.indices.contains(index) else { return }
|
|
1520
|
-
controller.selectSingle(visibleWindows[index].id)
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
.frame(
|
|
1524
|
-
width: rowWidth,
|
|
1525
|
-
height: CGFloat(visibleWindows.count + 1) * Self.sidebarWindowRowStride
|
|
1526
|
-
)
|
|
1527
|
-
}
|
|
1528
|
-
}
|
|
1529
|
-
.frame(
|
|
1530
|
-
width: rowWidth,
|
|
1531
|
-
height: CGFloat(visibleWindows.count + 1) * Self.sidebarWindowRowStride,
|
|
1532
|
-
alignment: .topLeading
|
|
1533
|
-
)
|
|
1534
|
-
}
|
|
1535
|
-
.coordinateSpace(name: "layerSidebar")
|
|
1536
|
-
|
|
1537
|
-
Spacer(minLength: 8)
|
|
1538
|
-
collapsibleSection(title: "SETS", count: controller.windowSets.count, isExpanded: $showSets) {
|
|
1539
|
-
windowSetsSection(editor: editor)
|
|
1540
|
-
}
|
|
1541
|
-
collapsibleSection(title: "EXPLORER", count: editor.canvasExplorerRegions.count, isExpanded: $showExplorer) {
|
|
1542
|
-
canvasExplorer(editor: editor)
|
|
1543
|
-
}
|
|
1544
|
-
Spacer(minLength: 8)
|
|
1545
|
-
sidebarMiniMap(editor: editor)
|
|
1546
|
-
}
|
|
1547
|
-
.padding(.horizontal, 8)
|
|
1548
|
-
.padding(.vertical, 8)
|
|
1549
|
-
.frame(width: sidebarWidth)
|
|
1550
|
-
.onPreferenceChange(LayerRowFrameKey.self) { layerRowFrames = $0 }
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
private func visibleWindowRow(win: ScreenMapWindowEntry) -> some View {
|
|
1554
|
-
let isSelected = controller.selectedWindowIds.contains(win.id)
|
|
1555
|
-
let rowWidth = max(sidebarWidth - 16, 1)
|
|
1556
|
-
return HStack(spacing: 6) {
|
|
1557
|
-
Circle()
|
|
1558
|
-
.fill(isSelected ? Palette.running : Palette.textMuted.opacity(0.55))
|
|
1559
|
-
.frame(width: 4, height: 4)
|
|
1560
|
-
VStack(alignment: .leading, spacing: 1) {
|
|
1561
|
-
Text(win.app)
|
|
1562
|
-
.font(Typo.monoBold(8))
|
|
1563
|
-
.foregroundColor(isSelected ? Palette.running : Palette.textDim)
|
|
1564
|
-
.lineLimit(1)
|
|
1565
|
-
if !win.title.isEmpty {
|
|
1566
|
-
Text(win.title)
|
|
1567
|
-
.font(Typo.mono(7))
|
|
1568
|
-
.foregroundColor(Palette.textMuted)
|
|
1569
|
-
.lineLimit(1)
|
|
1570
|
-
}
|
|
1571
|
-
}
|
|
1572
|
-
Spacer(minLength: 4)
|
|
1573
|
-
if win.hasEdits {
|
|
1574
|
-
Circle()
|
|
1575
|
-
.fill(Color.orange.opacity(0.85))
|
|
1576
|
-
.frame(width: 4, height: 4)
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
|
-
.padding(.vertical, 3)
|
|
1580
|
-
.padding(.leading, 20)
|
|
1581
|
-
.padding(.trailing, 6)
|
|
1582
|
-
.frame(width: rowWidth, height: Self.sidebarWindowRowHeight, alignment: .leading)
|
|
1583
|
-
.clipped()
|
|
1584
|
-
.background(
|
|
1585
|
-
RoundedRectangle(cornerRadius: 4)
|
|
1586
|
-
.fill(isSelected ? Palette.running.opacity(0.08) : Color.clear)
|
|
1587
|
-
)
|
|
1588
|
-
.contentShape(Rectangle())
|
|
1589
|
-
.highPriorityGesture(TapGesture().onEnded {
|
|
1590
|
-
controller.selectSingle(win.id)
|
|
1591
|
-
})
|
|
1592
|
-
.accessibilityElement(children: .combine)
|
|
1593
|
-
.accessibilityLabel(win.title.isEmpty ? win.app : "\(win.app), \(win.title)")
|
|
1594
|
-
.accessibilityAddTraits(.isButton)
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
@ViewBuilder
|
|
1598
|
-
private func layerRow(layer: Int, editor: ScreenMapEditorState) -> some View {
|
|
1599
|
-
let displayName = editor.layerDisplayName(for: layer)
|
|
1600
|
-
let fullName = editor.layerNames[layer]
|
|
1601
|
-
let color = Self.layerColor(for: layer)
|
|
1602
|
-
let isActive = editor.isLayerSelected(layer)
|
|
1603
|
-
let isDropTarget = dropTargetLayer == layer
|
|
1604
|
-
let layerWindows = editor.layerTreeWindows(for: layer)
|
|
1605
|
-
|
|
1606
|
-
VStack(spacing: 0) {
|
|
1607
|
-
layerTreeHeader(label: fullName ?? displayName,
|
|
1608
|
-
count: layerWindows.count,
|
|
1609
|
-
isActive: isActive,
|
|
1610
|
-
color: color,
|
|
1611
|
-
isExpandable: true,
|
|
1612
|
-
isExpanded: expandedLayers.contains(layer),
|
|
1613
|
-
onToggleExpand: { toggleExpandedLayer(layer) }) {
|
|
1614
|
-
selectSidebarLayer(layer, editor: editor)
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
if expandedLayers.contains(layer) {
|
|
1618
|
-
VStack(spacing: 0) {
|
|
1619
|
-
ForEach(layerWindows) { win in
|
|
1620
|
-
let isSelected = controller.selectedWindowIds.contains(win.id)
|
|
1621
|
-
let isDragging = sidebarDragWindowId == win.id
|
|
1622
|
-
HStack(spacing: 4) {
|
|
1623
|
-
Rectangle()
|
|
1624
|
-
.fill(color.opacity(0.4))
|
|
1625
|
-
.frame(width: 1, height: 12)
|
|
1626
|
-
.padding(.leading, 8)
|
|
1627
|
-
Text(win.app)
|
|
1628
|
-
.font(Typo.mono(8))
|
|
1629
|
-
.foregroundColor(isSelected ? Palette.running : Palette.textDim)
|
|
1630
|
-
.lineLimit(1)
|
|
1631
|
-
Spacer()
|
|
1632
|
-
if win.hasEdits {
|
|
1633
|
-
Circle()
|
|
1634
|
-
.fill(Color.orange)
|
|
1635
|
-
.frame(width: 4, height: 4)
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
.padding(.vertical, 2)
|
|
1639
|
-
.padding(.horizontal, 4)
|
|
1640
|
-
.background(
|
|
1641
|
-
RoundedRectangle(cornerRadius: 3)
|
|
1642
|
-
.fill(isSelected ? Palette.running.opacity(0.08) : Color.clear)
|
|
1643
|
-
)
|
|
1644
|
-
.contentShape(Rectangle())
|
|
1645
|
-
.opacity(isDragging ? 0.4 : 1.0)
|
|
1646
|
-
.offset(isDragging ? sidebarDragOffset : .zero)
|
|
1647
|
-
.zIndex(isDragging ? 10 : 0)
|
|
1648
|
-
.gesture(
|
|
1649
|
-
DragGesture(minimumDistance: 4, coordinateSpace: .named("layerSidebar"))
|
|
1650
|
-
.onChanged { value in
|
|
1651
|
-
handleSidebarWindowDragChanged(value, sourceLayer: layer, windowId: win.id)
|
|
1652
|
-
}
|
|
1653
|
-
.onEnded { _ in
|
|
1654
|
-
finishSidebarWindowDrag(win, editor: editor)
|
|
1655
|
-
}
|
|
1656
|
-
)
|
|
1657
|
-
.simultaneousGesture(TapGesture().onEnded {
|
|
1658
|
-
selectSidebarWindow(win.id)
|
|
1659
|
-
})
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
.padding(.leading, 4)
|
|
1663
|
-
.padding(.top, 2)
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
.overlay(
|
|
1667
|
-
RoundedRectangle(cornerRadius: 4)
|
|
1668
|
-
.strokeBorder(isDropTarget ? Palette.running : Color.clear, lineWidth: 1.5)
|
|
1669
|
-
)
|
|
1670
|
-
.background(
|
|
1671
|
-
GeometryReader { geo in
|
|
1672
|
-
Color.clear.preference(key: LayerRowFrameKey.self,
|
|
1673
|
-
value: [layer: geo.frame(in: .named("layerSidebar"))])
|
|
1674
|
-
}
|
|
1675
|
-
)
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
private func toggleExpandedLayer(_ layer: Int) {
|
|
1679
|
-
if expandedLayers.contains(layer) {
|
|
1680
|
-
expandedLayers.remove(layer)
|
|
1681
|
-
} else {
|
|
1682
|
-
expandedLayers.insert(layer)
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
private func selectSidebarLayer(_ layer: Int, editor: ScreenMapEditorState) {
|
|
1687
|
-
if NSEvent.modifierFlags.contains(.command) {
|
|
1688
|
-
editor.toggleLayerSelection(layer)
|
|
1689
|
-
} else {
|
|
1690
|
-
editor.selectLayer(layer)
|
|
1691
|
-
}
|
|
1692
|
-
expandedLayers.insert(layer)
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
private func selectSidebarWindow(_ windowId: UInt32) {
|
|
1696
|
-
if NSEvent.modifierFlags.contains(.command) {
|
|
1697
|
-
controller.toggleSelection(windowId)
|
|
1698
|
-
} else {
|
|
1699
|
-
controller.selectSingle(windowId)
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
private func resolveSidebarDropTarget(at point: CGPoint, excluding layer: Int) -> Int? {
|
|
1704
|
-
for (candidate, frame) in layerRowFrames where candidate != layer {
|
|
1705
|
-
if frame.contains(point) {
|
|
1706
|
-
return candidate
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
return nil
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
private func handleSidebarWindowDragChanged(_ value: DragGesture.Value, sourceLayer: Int, windowId: UInt32) {
|
|
1713
|
-
sidebarDragWindowId = windowId
|
|
1714
|
-
sidebarDragOffset = value.translation
|
|
1715
|
-
controller.selectSingle(windowId)
|
|
1716
|
-
dropTargetLayer = resolveSidebarDropTarget(at: value.location, excluding: sourceLayer)
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
private func finishSidebarWindowDrag(_ win: ScreenMapWindowEntry, editor: ScreenMapEditorState) {
|
|
1720
|
-
if let targetLayer = dropTargetLayer {
|
|
1721
|
-
editor.reassignLayer(windowId: win.id, toLayer: targetLayer, fitToAvailable: true)
|
|
1722
|
-
controller.flash("Moved to L\(targetLayer)")
|
|
1723
|
-
}
|
|
1724
|
-
sidebarDragWindowId = nil
|
|
1725
|
-
sidebarDragOffset = .zero
|
|
1726
|
-
dropTargetLayer = nil
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
private func collapsibleSection<Content: View>(title: String, count: Int, isExpanded: Binding<Bool>,
|
|
1730
|
-
@ViewBuilder content: @escaping () -> Content) -> some View {
|
|
1731
|
-
VStack(spacing: 0) {
|
|
1732
|
-
HStack(spacing: 4) {
|
|
1733
|
-
Image(systemName: isExpanded.wrappedValue ? "chevron.down" : "chevron.right")
|
|
1734
|
-
.font(.system(size: 6, weight: .bold))
|
|
1735
|
-
.foregroundColor(Palette.textMuted)
|
|
1736
|
-
Text(title)
|
|
1737
|
-
.font(Typo.monoBold(8))
|
|
1738
|
-
.foregroundColor(Palette.textMuted)
|
|
1739
|
-
Text("\(count)")
|
|
1740
|
-
.font(Typo.mono(7))
|
|
1741
|
-
.foregroundColor(Palette.textDim)
|
|
1742
|
-
Spacer()
|
|
1743
|
-
}
|
|
1744
|
-
.padding(.vertical, 4)
|
|
1745
|
-
.contentShape(Rectangle())
|
|
1746
|
-
.simultaneousGesture(TapGesture().onEnded { isExpanded.wrappedValue.toggle() })
|
|
1747
|
-
|
|
1748
|
-
if isExpanded.wrappedValue {
|
|
1749
|
-
content()
|
|
1750
|
-
.padding(.top, 4)
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
.padding(.bottom, 4)
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
private func windowSetsSection(editor: ScreenMapEditorState) -> some View {
|
|
1757
|
-
let sets = controller.windowSets
|
|
1758
|
-
let canSave = !controller.selectedWindowIds.isEmpty
|
|
1759
|
-
|
|
1760
|
-
return VStack(alignment: .leading, spacing: 4) {
|
|
1761
|
-
if sets.isEmpty {
|
|
1762
|
-
HStack {
|
|
1763
|
-
Text("No sets yet.")
|
|
1764
|
-
.font(Typo.mono(7))
|
|
1765
|
-
.foregroundColor(Palette.textMuted)
|
|
1766
|
-
Spacer()
|
|
1767
|
-
Button {
|
|
1768
|
-
controller.createWindowSetFromSelection()
|
|
1769
|
-
} label: {
|
|
1770
|
-
Text("u save")
|
|
1771
|
-
.font(Typo.monoBold(7))
|
|
1772
|
-
.foregroundColor(canSave ? Self.shelfGreen : Palette.textMuted)
|
|
1773
|
-
}
|
|
1774
|
-
.buttonStyle(.plain)
|
|
1775
|
-
.disabled(!canSave)
|
|
1776
|
-
}
|
|
1777
|
-
} else {
|
|
1778
|
-
ForEach(sets) { set in
|
|
1779
|
-
windowSetRow(set: set, editor: editor)
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
private func windowSetRow(set: ScreenMapWindowSet, editor: ScreenMapEditorState) -> some View {
|
|
1786
|
-
let liveCount = editor.windows(matching: set.windowIds).count
|
|
1787
|
-
let isActive = controller.activeWindowSetID == set.id
|
|
1788
|
-
|
|
1789
|
-
return HStack(spacing: 6) {
|
|
1790
|
-
Button {
|
|
1791
|
-
controller.focusWindowSet(set)
|
|
1792
|
-
} label: {
|
|
1793
|
-
HStack(spacing: 6) {
|
|
1794
|
-
Circle()
|
|
1795
|
-
.fill(isActive ? Self.shelfGreen : Palette.textMuted.opacity(0.7))
|
|
1796
|
-
.frame(width: 6, height: 6)
|
|
1797
|
-
VStack(alignment: .leading, spacing: 1) {
|
|
1798
|
-
Text(set.name)
|
|
1799
|
-
.font(Typo.monoBold(8))
|
|
1800
|
-
.foregroundColor(isActive ? Palette.text : Palette.textDim)
|
|
1801
|
-
.lineLimit(1)
|
|
1802
|
-
Text("\(liveCount) window\(liveCount == 1 ? "" : "s")")
|
|
1803
|
-
.font(Typo.mono(7))
|
|
1804
|
-
.foregroundColor(Palette.textMuted)
|
|
1805
|
-
.lineLimit(1)
|
|
1806
|
-
}
|
|
1807
|
-
Spacer()
|
|
1808
|
-
}
|
|
1809
|
-
.padding(.horizontal, 6)
|
|
1810
|
-
.padding(.vertical, 4)
|
|
1811
|
-
.background(
|
|
1812
|
-
RoundedRectangle(cornerRadius: 4)
|
|
1813
|
-
.fill(isActive ? Self.shelfGreen.opacity(0.08) : Palette.surface.opacity(0.35))
|
|
1814
|
-
.overlay(
|
|
1815
|
-
RoundedRectangle(cornerRadius: 4)
|
|
1816
|
-
.strokeBorder(isActive ? Self.shelfGreen.opacity(0.2) : Color.white.opacity(0.04), lineWidth: 0.5)
|
|
1817
|
-
)
|
|
1818
|
-
)
|
|
1819
|
-
}
|
|
1820
|
-
.buttonStyle(.plain)
|
|
1821
|
-
|
|
1822
|
-
Button {
|
|
1823
|
-
controller.deleteWindowSet(set)
|
|
1824
|
-
} label: {
|
|
1825
|
-
Image(systemName: "xmark")
|
|
1826
|
-
.font(.system(size: 7, weight: .bold))
|
|
1827
|
-
.foregroundColor(Palette.textMuted)
|
|
1828
|
-
.frame(width: 16, height: 16)
|
|
1829
|
-
.background(
|
|
1830
|
-
RoundedRectangle(cornerRadius: 3)
|
|
1831
|
-
.fill(Palette.surface.opacity(0.8))
|
|
1832
|
-
.overlay(
|
|
1833
|
-
RoundedRectangle(cornerRadius: 3)
|
|
1834
|
-
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
1835
|
-
)
|
|
1836
|
-
)
|
|
1837
|
-
}
|
|
1838
|
-
.buttonStyle(.plain)
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
private func layerTreeHeader(label: String, count: Int, isActive: Bool, color: Color,
|
|
1843
|
-
isExpandable: Bool = false, isExpanded: Bool = false,
|
|
1844
|
-
onToggleExpand: (() -> Void)? = nil,
|
|
1845
|
-
action: @escaping () -> Void) -> some View {
|
|
1846
|
-
HStack(spacing: 0) {
|
|
1847
|
-
if isExpandable {
|
|
1848
|
-
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
|
1849
|
-
.font(.system(size: 7, weight: .bold))
|
|
1850
|
-
.foregroundColor(Palette.textMuted)
|
|
1851
|
-
.frame(width: 16, height: 16)
|
|
1852
|
-
.onTapGesture { onToggleExpand?() }
|
|
1853
|
-
}
|
|
1854
|
-
HStack(spacing: 5) {
|
|
1855
|
-
Circle()
|
|
1856
|
-
.fill(color)
|
|
1857
|
-
.frame(width: 6, height: 6)
|
|
1858
|
-
Text(label)
|
|
1859
|
-
.font(Typo.monoBold(9))
|
|
1860
|
-
.lineLimit(1)
|
|
1861
|
-
Spacer()
|
|
1862
|
-
Text("\(count)")
|
|
1863
|
-
.font(Typo.mono(8))
|
|
1864
|
-
.foregroundColor(isActive ? Palette.text.opacity(0.7) : Palette.textMuted)
|
|
1865
|
-
}
|
|
1866
|
-
.foregroundColor(isActive ? Palette.text : Palette.textDim)
|
|
1867
|
-
}
|
|
1868
|
-
.padding(.leading, isExpandable ? 0 : 16)
|
|
1869
|
-
.padding(.trailing, 8)
|
|
1870
|
-
.padding(.vertical, 5)
|
|
1871
|
-
.frame(maxWidth: .infinity, alignment: .leading)
|
|
1872
|
-
.background(
|
|
1873
|
-
RoundedRectangle(cornerRadius: 6)
|
|
1874
|
-
.fill(isActive ? color.opacity(0.12) : Color.clear)
|
|
1875
|
-
)
|
|
1876
|
-
.contentShape(Rectangle())
|
|
1877
|
-
.simultaneousGesture(TapGesture().onEnded { action() })
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
// MARK: - Canvas
|
|
1881
|
-
|
|
1882
|
-
private func canvasStageAspectRatio(editor: ScreenMapEditorState?, displays: [DisplayGeometry]) -> CGFloat {
|
|
1883
|
-
let scopedDisplays: [DisplayGeometry]
|
|
1884
|
-
if let focusedDisplayIndex = editor?.focusedDisplayIndex {
|
|
1885
|
-
scopedDisplays = displays.filter { $0.index == focusedDisplayIndex }
|
|
1886
|
-
} else {
|
|
1887
|
-
scopedDisplays = displays
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
let displayBounds = scopedDisplays.map(\.cgRect).reduce(nil as CGRect?) { partial, rect in
|
|
1891
|
-
partial.map { $0.union(rect) } ?? rect
|
|
1892
|
-
}
|
|
1893
|
-
let bounds = displayBounds ?? editor?.canvasWorldBounds ?? CGRect(x: 0, y: 0, width: 16, height: 10)
|
|
1894
|
-
let rawAspect = bounds.width / max(bounds.height, 1)
|
|
1895
|
-
return min(max(rawAspect, Self.canvasStageMinAspect), Self.canvasStageMaxAspect)
|
|
1896
|
-
}
|
|
1897
|
-
|
|
1898
|
-
private func screenMapCanvas(editor: ScreenMapEditorState?) -> some View {
|
|
1899
|
-
let isFocused = editor?.focusedDisplayIndex != nil
|
|
1900
|
-
let canvasWindows = editor?.renderedCanvasWindows ?? []
|
|
1901
|
-
let displays = editor?.displays ?? []
|
|
1902
|
-
let zoomLevel = editor?.zoomLevel ?? 1.0
|
|
1903
|
-
let panOffset = editor?.panOffset ?? .zero
|
|
1904
|
-
let canvasShape = RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
1905
|
-
let stageAspectRatio = canvasStageAspectRatio(editor: editor, displays: displays)
|
|
1906
|
-
let usesProjectionWindows = editor?.isShowingAll ?? true
|
|
1907
|
-
|
|
1908
|
-
return GeometryReader { geo in
|
|
1909
|
-
let metrics = CanvasMetrics(editor: editor, displays: displays, viewportSize: geo.size)
|
|
1910
|
-
let syncKey = CanvasSyncKey(
|
|
1911
|
-
viewportSize: geo.size,
|
|
1912
|
-
worldBounds: metrics.worldBounds,
|
|
1913
|
-
zoomLevel: zoomLevel,
|
|
1914
|
-
navigationRevision: editor?.canvasNavigationRevision ?? 0
|
|
1915
|
-
)
|
|
1916
|
-
ZStack(alignment: .topLeading) {
|
|
1917
|
-
// Per-display background rectangles
|
|
1918
|
-
if isFocused, editor?.focusedDisplay != nil {
|
|
1919
|
-
focusedDisplayBackground(mapSize: metrics.mapSize)
|
|
1920
|
-
} else if !displays.isEmpty {
|
|
1921
|
-
multiDisplayBackgrounds(displays: displays, editor: editor, metrics: metrics)
|
|
1922
|
-
} else {
|
|
1923
|
-
singleDisplayBackground(mapSize: metrics.mapSize)
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
// Ghost outlines for edited windows
|
|
1927
|
-
ForEach(canvasWindows.filter(\.hasEdits)) { win in
|
|
1928
|
-
let rect = metrics.mapRect(for: win.originalFrame)
|
|
1929
|
-
|
|
1930
|
-
RoundedRectangle(cornerRadius: 2)
|
|
1931
|
-
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
|
1932
|
-
.foregroundColor(Palette.textMuted.opacity(0.4))
|
|
1933
|
-
.frame(width: rect.width, height: rect.height)
|
|
1934
|
-
.offset(x: rect.minX, y: rect.minY)
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
if !usesProjectionWindows {
|
|
1938
|
-
// Live windows back-to-front
|
|
1939
|
-
ForEach(Array(canvasWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
|
|
1940
|
-
windowTile(win: win, editor: editor, metrics: metrics)
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
}
|
|
1944
|
-
.frame(width: metrics.mapSize.width, height: metrics.mapSize.height)
|
|
1945
|
-
.offset(x: metrics.centerOffset.x + panOffset.x, y: metrics.centerOffset.y + panOffset.y)
|
|
1946
|
-
.onAppear {
|
|
1947
|
-
syncCanvasGeometry(editor: editor, metrics: metrics)
|
|
1948
|
-
}
|
|
1949
|
-
.onChange(of: syncKey) { _ in
|
|
1950
|
-
syncCanvasGeometry(editor: editor, metrics: metrics)
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
.padding(8)
|
|
1954
|
-
.frame(maxWidth: Self.canvasStageMaxWidth, maxHeight: Self.canvasStageMaxHeight)
|
|
1955
|
-
.aspectRatio(stageAspectRatio, contentMode: .fit)
|
|
1956
|
-
.contentShape(canvasShape)
|
|
1957
|
-
.clipShape(canvasShape)
|
|
1958
|
-
.clipped()
|
|
1959
|
-
.background(
|
|
1960
|
-
ZStack {
|
|
1961
|
-
canvasShape
|
|
1962
|
-
.fill(Color.black.opacity(0.25))
|
|
1963
|
-
canvasShape
|
|
1964
|
-
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
|
1965
|
-
Canvas { context, size in
|
|
1966
|
-
let spacing: CGFloat = 20
|
|
1967
|
-
let dotColor = Color.white.opacity(0.04)
|
|
1968
|
-
for x in stride(from: spacing, to: size.width, by: spacing) {
|
|
1969
|
-
for y in stride(from: spacing, to: size.height, by: spacing) {
|
|
1970
|
-
context.fill(
|
|
1971
|
-
Path(ellipseIn: CGRect(x: x - 0.5, y: y - 0.5, width: 1, height: 1)),
|
|
1972
|
-
with: .color(dotColor)
|
|
1973
|
-
)
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
)
|
|
1979
|
-
.overlay(alignment: .topLeading) {
|
|
1980
|
-
Group {
|
|
1981
|
-
if let editor = editor {
|
|
1982
|
-
let projection = CanvasProjection(editor: editor)
|
|
1983
|
-
let projectionLabelIds = projectionLabelWindowIds(
|
|
1984
|
-
windows: canvasWindows,
|
|
1985
|
-
projection: projection,
|
|
1986
|
-
selectedIds: controller.selectedWindowIds
|
|
1987
|
-
)
|
|
1988
|
-
|
|
1989
|
-
ZStack(alignment: .topLeading) {
|
|
1990
|
-
if usesProjectionWindows {
|
|
1991
|
-
ForEach(Array(canvasWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
|
|
1992
|
-
let rect = projection.mapRect(for: win.virtualFrame)
|
|
1993
|
-
let radius = min(max(rect.width, rect.height) * 0.012, 3)
|
|
1994
|
-
let isSelected = controller.selectedWindowIds.contains(win.id)
|
|
1995
|
-
let fill = isSelected
|
|
1996
|
-
? Palette.running.opacity(0.10)
|
|
1997
|
-
: win.hasEdits ? Color.orange.opacity(0.10) : Color.white.opacity(0.018)
|
|
1998
|
-
let stroke = isSelected
|
|
1999
|
-
? Palette.running.opacity(0.55)
|
|
2000
|
-
: win.hasEdits ? Color.orange.opacity(0.5) : Color.white.opacity(0.10)
|
|
2001
|
-
|
|
2002
|
-
RoundedRectangle(cornerRadius: radius)
|
|
2003
|
-
.fill(fill)
|
|
2004
|
-
.overlay(
|
|
2005
|
-
RoundedRectangle(cornerRadius: radius)
|
|
2006
|
-
.strokeBorder(stroke, lineWidth: isSelected ? 0.9 : 0.7)
|
|
2007
|
-
)
|
|
2008
|
-
.overlay {
|
|
2009
|
-
if projectionLabelIds.contains(win.id) {
|
|
2010
|
-
projectionWindowLabel(win: win, rect: rect, isSelected: isSelected)
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
.frame(width: rect.width, height: rect.height)
|
|
2014
|
-
.contentShape(Rectangle())
|
|
2015
|
-
.offset(
|
|
2016
|
-
x: Self.canvasPadding + projection.mapOrigin.x + projection.panOffset.x + rect.minX,
|
|
2017
|
-
y: Self.canvasPadding + projection.mapOrigin.y + projection.panOffset.y + rect.minY
|
|
2018
|
-
)
|
|
2019
|
-
.zIndex(isSelected ? 2 : 0)
|
|
2020
|
-
}
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
ForEach(editor.displays, id: \.index) { display in
|
|
2024
|
-
let rect = projection.mapRect(for: display.cgRect, minimumSize: 12)
|
|
2025
|
-
let isFocused = editor.focusedDisplayIndex == display.index
|
|
2026
|
-
RoundedRectangle(cornerRadius: 5)
|
|
2027
|
-
.strokeBorder(
|
|
2028
|
-
isFocused ? Palette.running.opacity(0.36) : Color.white.opacity(0.22),
|
|
2029
|
-
lineWidth: isFocused ? 1 : 0.8
|
|
2030
|
-
)
|
|
2031
|
-
.overlay(alignment: .topLeading) {
|
|
2032
|
-
Text("\(editor.spatialNumber(for: display.index))")
|
|
2033
|
-
.font(Typo.monoBold(8))
|
|
2034
|
-
.foregroundColor(isFocused ? Palette.running : Palette.textMuted)
|
|
2035
|
-
.padding(.horizontal, 4)
|
|
2036
|
-
.padding(.vertical, 2)
|
|
2037
|
-
.background(
|
|
2038
|
-
RoundedRectangle(cornerRadius: 3)
|
|
2039
|
-
.fill(Color.black.opacity(0.42))
|
|
2040
|
-
)
|
|
2041
|
-
.padding(5)
|
|
2042
|
-
}
|
|
2043
|
-
.frame(width: rect.width, height: rect.height)
|
|
2044
|
-
.offset(
|
|
2045
|
-
x: Self.canvasPadding + projection.mapOrigin.x + projection.panOffset.x + rect.minX,
|
|
2046
|
-
y: Self.canvasPadding + projection.mapOrigin.y + projection.panOffset.y + rect.minY
|
|
2047
|
-
)
|
|
2048
|
-
.allowsHitTesting(false)
|
|
2049
|
-
.zIndex(-1)
|
|
2050
|
-
}
|
|
2051
|
-
}
|
|
2052
|
-
.allowsHitTesting(false)
|
|
2053
|
-
}
|
|
2054
|
-
}
|
|
2055
|
-
}
|
|
2056
|
-
.clipShape(canvasShape)
|
|
2057
|
-
.clipped()
|
|
2058
|
-
.overlay(alignment: .top) {
|
|
2059
|
-
if let editor = controller.editor, editor.displays.count > 1 {
|
|
2060
|
-
displayToolbar(editor: editor)
|
|
2061
|
-
.padding(.top, 8)
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
.overlay(alignment: .bottomLeading) {
|
|
2065
|
-
canvasContextBadge
|
|
2066
|
-
}
|
|
2067
|
-
.overlay(
|
|
2068
|
-
GeometryReader { geo in
|
|
2069
|
-
Color.clear.onAppear {
|
|
2070
|
-
let frame = geo.frame(in: .global)
|
|
2071
|
-
screenMapCanvasOrigin = frame.origin
|
|
2072
|
-
screenMapCanvasSize = frame.size
|
|
2073
|
-
}
|
|
2074
|
-
.onChange(of: geo.frame(in: .global)) { newFrame in
|
|
2075
|
-
screenMapCanvasOrigin = newFrame.origin
|
|
2076
|
-
screenMapCanvasSize = newFrame.size
|
|
2077
|
-
}
|
|
2078
|
-
}
|
|
2079
|
-
)
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
// MARK: - Display Backgrounds
|
|
2083
|
-
|
|
2084
|
-
private func focusedDisplayBackground(mapSize: CGSize) -> some View {
|
|
2085
|
-
ZStack(alignment: .topLeading) {
|
|
2086
|
-
RoundedRectangle(cornerRadius: 6)
|
|
2087
|
-
.fill(Palette.bg.opacity(0.5))
|
|
2088
|
-
.overlay(
|
|
2089
|
-
RoundedRectangle(cornerRadius: 6)
|
|
2090
|
-
.strokeBorder(Palette.running.opacity(0.3), lineWidth: 1)
|
|
2091
|
-
)
|
|
2092
|
-
.contentShape(Rectangle())
|
|
2093
|
-
.onTapGesture { controller.clearSelection() }
|
|
2094
|
-
}
|
|
2095
|
-
.frame(width: mapSize.width, height: mapSize.height)
|
|
2096
|
-
}
|
|
2097
|
-
|
|
2098
|
-
private func multiDisplayBackgrounds(displays: [DisplayGeometry], editor: ScreenMapEditorState?, metrics: CanvasMetrics) -> some View {
|
|
2099
|
-
ForEach(displays, id: \.index) { disp in
|
|
2100
|
-
let frame = metrics.mapRect(for: disp.cgRect, minimumSize: 12)
|
|
2101
|
-
let bezel: CGFloat = 3
|
|
2102
|
-
|
|
2103
|
-
ZStack {
|
|
2104
|
-
RoundedRectangle(cornerRadius: 8)
|
|
2105
|
-
.fill(Color.white.opacity(0.07))
|
|
2106
|
-
.overlay(
|
|
2107
|
-
RoundedRectangle(cornerRadius: 8)
|
|
2108
|
-
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1.5)
|
|
2109
|
-
)
|
|
2110
|
-
RoundedRectangle(cornerRadius: 5)
|
|
2111
|
-
.fill(Palette.bg.opacity(0.55))
|
|
2112
|
-
.overlay(
|
|
2113
|
-
RoundedRectangle(cornerRadius: 5)
|
|
2114
|
-
.strokeBorder(Color.black.opacity(0.4), lineWidth: 0.5)
|
|
2115
|
-
)
|
|
2116
|
-
.padding(bezel)
|
|
2117
|
-
|
|
2118
|
-
// Display number badge (top-left corner)
|
|
2119
|
-
VStack {
|
|
2120
|
-
HStack {
|
|
2121
|
-
ZStack {
|
|
2122
|
-
Circle()
|
|
2123
|
-
.fill(Color.white.opacity(0.3))
|
|
2124
|
-
.frame(width: 16, height: 16)
|
|
2125
|
-
Text("\(editor?.spatialNumber(for: disp.index) ?? (disp.index + 1))")
|
|
2126
|
-
.font(.system(size: 8, weight: .bold, design: .monospaced))
|
|
2127
|
-
.foregroundColor(.black)
|
|
2128
|
-
}
|
|
2129
|
-
.padding(.top, bezel + 4)
|
|
2130
|
-
.padding(.leading, bezel + 4)
|
|
2131
|
-
Spacer()
|
|
2132
|
-
}
|
|
2133
|
-
Spacer()
|
|
2134
|
-
}
|
|
2135
|
-
}
|
|
2136
|
-
.contentShape(Rectangle())
|
|
2137
|
-
.onTapGesture {
|
|
2138
|
-
controller.setDisplayFocus(disp.index)
|
|
2139
|
-
}
|
|
2140
|
-
.frame(width: frame.width, height: frame.height)
|
|
2141
|
-
.offset(x: frame.minX, y: frame.minY)
|
|
2142
|
-
}
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
private func singleDisplayBackground(mapSize: CGSize) -> some View {
|
|
2146
|
-
ZStack(alignment: .topLeading) {
|
|
2147
|
-
RoundedRectangle(cornerRadius: 6)
|
|
2148
|
-
.fill(Palette.bg.opacity(0.5))
|
|
2149
|
-
.overlay(
|
|
2150
|
-
RoundedRectangle(cornerRadius: 6)
|
|
2151
|
-
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
2152
|
-
)
|
|
2153
|
-
.contentShape(Rectangle())
|
|
2154
|
-
.onTapGesture { controller.clearSelection() }
|
|
2155
|
-
|
|
2156
|
-
}
|
|
2157
|
-
.frame(width: mapSize.width, height: mapSize.height)
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
// MARK: - Window Tile
|
|
2161
|
-
|
|
2162
|
-
private func projectionLabelWindowIds(
|
|
2163
|
-
windows: [ScreenMapWindowEntry],
|
|
2164
|
-
projection: CanvasProjection,
|
|
2165
|
-
selectedIds: Set<UInt32>
|
|
2166
|
-
) -> Set<UInt32> {
|
|
2167
|
-
if !selectedIds.isEmpty {
|
|
2168
|
-
return Set(windows.compactMap { win in
|
|
2169
|
-
guard selectedIds.contains(win.id) else { return nil }
|
|
2170
|
-
let rect = projection.mapRect(for: win.virtualFrame)
|
|
2171
|
-
return rect.width > 54 && rect.height > 24 ? win.id : nil
|
|
2172
|
-
})
|
|
2173
|
-
}
|
|
2174
|
-
|
|
2175
|
-
var acceptedIds = Set<UInt32>()
|
|
2176
|
-
var occupiedLabelRects: [CGRect] = []
|
|
2177
|
-
|
|
2178
|
-
for (frontOrder, win) in windows.sorted(by: { $0.zIndex < $1.zIndex }).enumerated() {
|
|
2179
|
-
let rect = projection.mapRect(for: win.virtualFrame)
|
|
2180
|
-
let isSelected = selectedIds.contains(win.id)
|
|
2181
|
-
guard isSelected || frontOrder < 14 else { continue }
|
|
2182
|
-
guard rect.width > 54, rect.height > 24 else { continue }
|
|
2183
|
-
|
|
2184
|
-
let labelRect = projectionLabelCollisionRect(for: rect, includesTitle: rect.width > 130 && rect.height > 38 && !win.title.isEmpty)
|
|
2185
|
-
let collides = occupiedLabelRects.contains { occupied in
|
|
2186
|
-
occupied.insetBy(dx: -8, dy: -5).intersects(labelRect)
|
|
2187
|
-
}
|
|
2188
|
-
|
|
2189
|
-
if isSelected || !collides {
|
|
2190
|
-
acceptedIds.insert(win.id)
|
|
2191
|
-
occupiedLabelRects.append(labelRect)
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
return acceptedIds
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
|
-
private func projectionLabelCollisionRect(for rect: CGRect, includesTitle: Bool) -> CGRect {
|
|
2199
|
-
let width = min(max(48, rect.width - 10), includesTitle ? 170 : 96)
|
|
2200
|
-
let height: CGFloat = includesTitle ? 27 : 15
|
|
2201
|
-
return CGRect(
|
|
2202
|
-
x: rect.midX - width / 2,
|
|
2203
|
-
y: rect.midY - height / 2,
|
|
2204
|
-
width: width,
|
|
2205
|
-
height: height
|
|
2206
|
-
)
|
|
2207
|
-
}
|
|
2208
|
-
|
|
2209
|
-
@ViewBuilder
|
|
2210
|
-
private func projectionWindowLabel(win: ScreenMapWindowEntry, rect: CGRect, isSelected: Bool) -> some View {
|
|
2211
|
-
if rect.width > 54, rect.height > 24 {
|
|
2212
|
-
Group {
|
|
2213
|
-
if isSelected {
|
|
2214
|
-
VStack(alignment: .leading, spacing: 2) {
|
|
2215
|
-
HStack(spacing: 4) {
|
|
2216
|
-
Circle()
|
|
2217
|
-
.fill(Palette.running)
|
|
2218
|
-
.frame(width: 4, height: 4)
|
|
2219
|
-
Text(win.app)
|
|
2220
|
-
.font(Typo.monoBold(9))
|
|
2221
|
-
.foregroundColor(Palette.running)
|
|
2222
|
-
.lineLimit(1)
|
|
2223
|
-
}
|
|
2224
|
-
if rect.width > 120, rect.height > 42, !win.title.isEmpty {
|
|
2225
|
-
Text(win.title)
|
|
2226
|
-
.font(Typo.mono(7))
|
|
2227
|
-
.foregroundColor(Palette.textMuted.opacity(0.86))
|
|
2228
|
-
.lineLimit(1)
|
|
2229
|
-
}
|
|
2230
|
-
}
|
|
2231
|
-
.padding(.horizontal, 6)
|
|
2232
|
-
.padding(.vertical, 4)
|
|
2233
|
-
.background(
|
|
2234
|
-
RoundedRectangle(cornerRadius: 4)
|
|
2235
|
-
.fill(Color.black.opacity(0.48))
|
|
2236
|
-
)
|
|
2237
|
-
.overlay(
|
|
2238
|
-
RoundedRectangle(cornerRadius: 4)
|
|
2239
|
-
.strokeBorder(Palette.running.opacity(0.42), lineWidth: 0.75)
|
|
2240
|
-
)
|
|
2241
|
-
.frame(maxWidth: rect.width, maxHeight: rect.height)
|
|
2242
|
-
} else {
|
|
2243
|
-
VStack(spacing: 1) {
|
|
2244
|
-
Text(win.app)
|
|
2245
|
-
.font(Typo.monoBold(max(7, min(10, rect.height * 0.13))))
|
|
2246
|
-
.foregroundColor(Palette.text.opacity(0.78))
|
|
2247
|
-
.lineLimit(1)
|
|
2248
|
-
if rect.width > 130, rect.height > 38, !win.title.isEmpty {
|
|
2249
|
-
Text(win.title)
|
|
2250
|
-
.font(Typo.mono(max(6, min(8, rect.height * 0.09))))
|
|
2251
|
-
.foregroundColor(Palette.textMuted.opacity(0.75))
|
|
2252
|
-
.lineLimit(1)
|
|
2253
|
-
}
|
|
2254
|
-
}
|
|
2255
|
-
.padding(.horizontal, 5)
|
|
2256
|
-
.padding(.vertical, 3)
|
|
2257
|
-
.frame(maxWidth: rect.width, maxHeight: rect.height)
|
|
2258
|
-
}
|
|
2259
|
-
}
|
|
2260
|
-
.allowsHitTesting(false)
|
|
2261
|
-
}
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
|
-
@ViewBuilder
|
|
2265
|
-
private func windowTile(win: ScreenMapWindowEntry, editor: ScreenMapEditorState?, metrics: CanvasMetrics) -> some View {
|
|
2266
|
-
let rect = metrics.mapRect(for: win.virtualFrame)
|
|
2267
|
-
let w = rect.width
|
|
2268
|
-
let h = rect.height
|
|
2269
|
-
let isSelected = controller.selectedWindowIds.contains(win.id)
|
|
2270
|
-
let isDragging = editor?.draggingWindowId == win.id
|
|
2271
|
-
let isInActiveLayer = editor?.isLayerSelected(win.layer) ?? true
|
|
2272
|
-
let winLayerColor = Self.layerColor(for: win.layer)
|
|
2273
|
-
let isSearchHighlighted = controller.searchHighlightedWindowId == win.id
|
|
2274
|
-
let usesFlatStyle = editor?.isShowingAll ?? true
|
|
2275
|
-
|
|
2276
|
-
let fillColor = isSearchHighlighted
|
|
2277
|
-
? Self.shelfGreen.opacity(0.2)
|
|
2278
|
-
: isSelected
|
|
2279
|
-
? Palette.running.opacity(0.18)
|
|
2280
|
-
: win.hasEdits ? Color.orange.opacity(0.14) : usesFlatStyle ? Palette.surface.opacity(0.68) : winLayerColor.opacity(0.16)
|
|
2281
|
-
let borderColor = isSearchHighlighted
|
|
2282
|
-
? Self.shelfGreen.opacity(0.8)
|
|
2283
|
-
: isSelected
|
|
2284
|
-
? Palette.running.opacity(0.8)
|
|
2285
|
-
: win.hasEdits ? Color.orange.opacity(0.65) : usesFlatStyle ? Palette.border.opacity(0.55) : winLayerColor.opacity(0.42)
|
|
2286
|
-
|
|
2287
|
-
RoundedRectangle(cornerRadius: 2)
|
|
2288
|
-
.fill(fillColor)
|
|
2289
|
-
.overlay(
|
|
2290
|
-
RoundedRectangle(cornerRadius: 2)
|
|
2291
|
-
.strokeBorder(borderColor, lineWidth: isSearchHighlighted ? 2 : isSelected ? 1.5 : 0.5)
|
|
2292
|
-
)
|
|
2293
|
-
.overlay(alignment: .leading) {
|
|
2294
|
-
if !usesFlatStyle {
|
|
2295
|
-
Rectangle()
|
|
2296
|
-
.fill(winLayerColor)
|
|
2297
|
-
.frame(width: 2)
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
.clipShape(RoundedRectangle(cornerRadius: 2))
|
|
2301
|
-
.overlay {
|
|
2302
|
-
ZStack {
|
|
2303
|
-
VStack(spacing: 1) {
|
|
2304
|
-
Text(win.app)
|
|
2305
|
-
.font(Typo.monoBold(max(7, min(10, h * 0.15))))
|
|
2306
|
-
.foregroundColor(isSelected ? Palette.running : Palette.text)
|
|
2307
|
-
.lineLimit(1)
|
|
2308
|
-
if h > 30 {
|
|
2309
|
-
Text(win.title)
|
|
2310
|
-
.font(Typo.mono(max(6, min(8, h * 0.1))))
|
|
2311
|
-
.foregroundColor(Palette.textDim)
|
|
2312
|
-
.lineLimit(1)
|
|
2313
|
-
}
|
|
2314
|
-
if h > 50 {
|
|
2315
|
-
Text("\(Int(win.virtualFrame.width))x\(Int(win.virtualFrame.height))")
|
|
2316
|
-
.font(Typo.mono(6))
|
|
2317
|
-
.foregroundColor(Palette.textMuted)
|
|
2318
|
-
}
|
|
2319
|
-
}
|
|
2320
|
-
.padding(.leading, 4)
|
|
2321
|
-
.padding(2)
|
|
2322
|
-
|
|
2323
|
-
if h > 40, let tileIcon = Self.inferTileIcon(for: win, displays: editor?.displays ?? []) {
|
|
2324
|
-
VStack {
|
|
2325
|
-
HStack {
|
|
2326
|
-
Spacer()
|
|
2327
|
-
Image(systemName: tileIcon)
|
|
2328
|
-
.font(.system(size: 6))
|
|
2329
|
-
.foregroundColor(Color.white.opacity(0.3))
|
|
2330
|
-
.padding(2)
|
|
2331
|
-
}
|
|
2332
|
-
Spacer()
|
|
2333
|
-
}
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
|
-
if h > 50, let session = Self.extractLatticesSession(from: win.title) {
|
|
2337
|
-
VStack {
|
|
2338
|
-
Spacer()
|
|
2339
|
-
HStack {
|
|
2340
|
-
Text("[\(session)]")
|
|
2341
|
-
.font(Typo.mono(6))
|
|
2342
|
-
.foregroundColor(Palette.running.opacity(0.7))
|
|
2343
|
-
.lineLimit(1)
|
|
2344
|
-
.padding(.leading, 4)
|
|
2345
|
-
.padding(.bottom, 2)
|
|
2346
|
-
Spacer()
|
|
2347
|
-
}
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2350
|
-
}
|
|
2351
|
-
}
|
|
2352
|
-
.frame(width: w, height: h)
|
|
2353
|
-
.contentShape(Rectangle())
|
|
2354
|
-
.onTapGesture {
|
|
2355
|
-
if NSEvent.modifierFlags.contains(.command) {
|
|
2356
|
-
controller.toggleSelection(win.id)
|
|
2357
|
-
} else {
|
|
2358
|
-
controller.selectSingle(win.id)
|
|
2359
|
-
}
|
|
2360
|
-
}
|
|
2361
|
-
.overlay {
|
|
2362
|
-
if isSelected && w > 30 && h > 20 {
|
|
2363
|
-
resizeHandles(width: w, height: h)
|
|
2364
|
-
}
|
|
2365
|
-
}
|
|
2366
|
-
.onHover { isHovering in
|
|
2367
|
-
hoveredWindowId = isHovering ? win.id : (hoveredWindowId == win.id ? nil : hoveredWindowId)
|
|
2368
|
-
}
|
|
2369
|
-
.overlay {
|
|
2370
|
-
if isSearchHighlighted {
|
|
2371
|
-
RoundedRectangle(cornerRadius: 2)
|
|
2372
|
-
.strokeBorder(Self.shelfGreen.opacity(0.6), lineWidth: 2)
|
|
2373
|
-
.shadow(color: Self.shelfGreen.opacity(0.5), radius: 6)
|
|
2374
|
-
}
|
|
2375
|
-
}
|
|
2376
|
-
.offset(x: rect.minX, y: rect.minY)
|
|
2377
|
-
.opacity(isInActiveLayer ? 1.0 : 0.3)
|
|
2378
|
-
.shadow(color: isDragging ? Palette.running.opacity(0.4) : .clear,
|
|
2379
|
-
radius: isDragging ? 6 : 0)
|
|
2380
|
-
}
|
|
2381
|
-
|
|
2382
|
-
@ViewBuilder
|
|
2383
|
-
private func resizeHandles(width w: CGFloat, height h: CGFloat) -> some View {
|
|
2384
|
-
let dotSize: CGFloat = 5
|
|
2385
|
-
let barW: CGFloat = 8
|
|
2386
|
-
let barH: CGFloat = 3
|
|
2387
|
-
let handleColor = Palette.running.opacity(0.7)
|
|
2388
|
-
let halfDot = dotSize / 2
|
|
2389
|
-
|
|
2390
|
-
ZStack {
|
|
2391
|
-
// Corner dots
|
|
2392
|
-
Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
|
|
2393
|
-
.position(x: halfDot, y: halfDot)
|
|
2394
|
-
Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
|
|
2395
|
-
.position(x: w - halfDot, y: halfDot)
|
|
2396
|
-
Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
|
|
2397
|
-
.position(x: halfDot, y: h - halfDot)
|
|
2398
|
-
Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
|
|
2399
|
-
.position(x: w - halfDot, y: h - halfDot)
|
|
2400
|
-
|
|
2401
|
-
// Edge midpoint bars
|
|
2402
|
-
if w > 50 {
|
|
2403
|
-
RoundedRectangle(cornerRadius: 1).fill(handleColor)
|
|
2404
|
-
.frame(width: barW, height: barH)
|
|
2405
|
-
.position(x: w / 2, y: 1.5)
|
|
2406
|
-
RoundedRectangle(cornerRadius: 1).fill(handleColor)
|
|
2407
|
-
.frame(width: barW, height: barH)
|
|
2408
|
-
.position(x: w / 2, y: h - 1.5)
|
|
2409
|
-
}
|
|
2410
|
-
if h > 40 {
|
|
2411
|
-
RoundedRectangle(cornerRadius: 1).fill(handleColor)
|
|
2412
|
-
.frame(width: barH, height: barW)
|
|
2413
|
-
.position(x: 1.5, y: h / 2)
|
|
2414
|
-
RoundedRectangle(cornerRadius: 1).fill(handleColor)
|
|
2415
|
-
.frame(width: barH, height: barW)
|
|
2416
|
-
.position(x: w - 1.5, y: h / 2)
|
|
2417
|
-
}
|
|
2418
|
-
}
|
|
2419
|
-
.allowsHitTesting(false)
|
|
2420
|
-
}
|
|
2421
|
-
|
|
2422
|
-
// MARK: - Canvas Viewport Controls
|
|
2423
|
-
|
|
2424
|
-
private func canvasViewportDock(editor: ScreenMapEditorState) -> some View {
|
|
2425
|
-
VStack(alignment: .trailing, spacing: 6) {
|
|
2426
|
-
HStack(spacing: 4) {
|
|
2427
|
-
ForEach(ScreenMapViewportPreset.allCases) { preset in
|
|
2428
|
-
canvasViewportPresetPill(preset, isActive: editor.activeViewportPreset == preset)
|
|
2429
|
-
}
|
|
2430
|
-
}
|
|
2431
|
-
canvasZoomControls(editor: editor)
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
|
-
private func canvasViewportPresetPill(_ preset: ScreenMapViewportPreset, isActive: Bool) -> some View {
|
|
2436
|
-
Button {
|
|
2437
|
-
controller.focusViewportPreset(preset)
|
|
2438
|
-
} label: {
|
|
2439
|
-
HStack(spacing: 4) {
|
|
2440
|
-
Text(preset.keyHint)
|
|
2441
|
-
.font(Typo.monoBold(8))
|
|
2442
|
-
.foregroundColor(isActive ? Color.black : Palette.textDim)
|
|
2443
|
-
Text(preset.shortLabel)
|
|
2444
|
-
.font(Typo.monoBold(8))
|
|
2445
|
-
.foregroundColor(isActive ? Color.black : Palette.text)
|
|
2446
|
-
}
|
|
2447
|
-
.padding(.horizontal, 7)
|
|
2448
|
-
.padding(.vertical, 4)
|
|
2449
|
-
.background(
|
|
2450
|
-
RoundedRectangle(cornerRadius: 5)
|
|
2451
|
-
.fill(isActive ? Self.shelfGreen.opacity(0.95) : Color(red: 0.1, green: 0.1, blue: 0.11).opacity(0.88))
|
|
2452
|
-
.overlay(
|
|
2453
|
-
RoundedRectangle(cornerRadius: 5)
|
|
2454
|
-
.strokeBorder(isActive ? Self.shelfGreen.opacity(0.95) : Palette.border, lineWidth: 0.5)
|
|
2455
|
-
)
|
|
2456
|
-
)
|
|
2457
|
-
}
|
|
2458
|
-
.buttonStyle(.plain)
|
|
2459
|
-
}
|
|
2460
|
-
|
|
2461
|
-
private func canvasZoomControls(editor: ScreenMapEditorState) -> some View {
|
|
2462
|
-
let pct = Int(editor.zoomLevel * 100)
|
|
2463
|
-
return HStack(spacing: 0) {
|
|
2464
|
-
Button {
|
|
2465
|
-
controller.adjustZoom(by: -0.25)
|
|
2466
|
-
} label: {
|
|
2467
|
-
Image(systemName: "minus")
|
|
2468
|
-
.font(.system(size: 9, weight: .medium))
|
|
2469
|
-
.frame(width: 22, height: 20)
|
|
2470
|
-
.contentShape(Rectangle())
|
|
2471
|
-
}
|
|
2472
|
-
.buttonStyle(.plain)
|
|
2473
|
-
|
|
2474
|
-
Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
|
|
2475
|
-
|
|
2476
|
-
Button {
|
|
2477
|
-
controller.clearSelection()
|
|
2478
|
-
controller.focusViewportPreset(.overview)
|
|
2479
|
-
} label: {
|
|
2480
|
-
Text("\(pct)%")
|
|
2481
|
-
.font(Typo.mono(9))
|
|
2482
|
-
.frame(width: 40, height: 20)
|
|
2483
|
-
.contentShape(Rectangle())
|
|
2484
|
-
}
|
|
2485
|
-
.buttonStyle(.plain)
|
|
2486
|
-
|
|
2487
|
-
Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
|
|
2488
|
-
|
|
2489
|
-
Button {
|
|
2490
|
-
controller.adjustZoom(by: 0.25)
|
|
2491
|
-
} label: {
|
|
2492
|
-
Image(systemName: "plus")
|
|
2493
|
-
.font(.system(size: 9, weight: .medium))
|
|
2494
|
-
.frame(width: 22, height: 20)
|
|
2495
|
-
.contentShape(Rectangle())
|
|
2496
|
-
}
|
|
2497
|
-
.buttonStyle(.plain)
|
|
2498
|
-
}
|
|
2499
|
-
.foregroundColor(Palette.textMuted)
|
|
2500
|
-
.background(
|
|
2501
|
-
RoundedRectangle(cornerRadius: 5)
|
|
2502
|
-
.fill(Color(red: 0.1, green: 0.1, blue: 0.11).opacity(0.85))
|
|
2503
|
-
.overlay(
|
|
2504
|
-
RoundedRectangle(cornerRadius: 5)
|
|
2505
|
-
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
2506
|
-
)
|
|
2507
|
-
)
|
|
2508
|
-
}
|
|
2509
|
-
|
|
2510
|
-
private static let shelfGreen = Color(red: 0.18, green: 0.82, blue: 0.48)
|
|
2511
|
-
|
|
2512
|
-
// MARK: - Canvas Status Bar
|
|
2513
|
-
|
|
2514
|
-
private var canvasStatusBar: some View {
|
|
2515
|
-
VStack(spacing: 0) {
|
|
2516
|
-
Rectangle().fill(Color.white.opacity(0.04)).frame(height: 0.5)
|
|
2517
|
-
HStack(spacing: 6) {
|
|
2518
|
-
if let editor = controller.editor {
|
|
2519
|
-
Circle().fill(Palette.running).frame(width: 5, height: 5)
|
|
2520
|
-
Text("DESKTOP")
|
|
2521
|
-
.font(Typo.monoBold(8))
|
|
2522
|
-
.foregroundColor(Palette.running)
|
|
2523
|
-
Text("·").foregroundColor(Palette.textMuted).font(Typo.mono(7))
|
|
2524
|
-
Text("\(editor.renderedCanvasWindows.count) windows")
|
|
2525
|
-
.font(Typo.mono(8))
|
|
2526
|
-
.foregroundColor(Palette.textDim)
|
|
2527
|
-
if let focused = editor.focusedDisplay {
|
|
2528
|
-
Text("·").foregroundColor(Palette.textMuted).font(Typo.mono(7))
|
|
2529
|
-
Text(focused.label)
|
|
2530
|
-
.font(Typo.mono(8))
|
|
2531
|
-
.foregroundColor(Palette.textMuted)
|
|
2532
|
-
.lineLimit(1)
|
|
2533
|
-
}
|
|
2534
|
-
Spacer()
|
|
2535
|
-
let editCount = editor.windows.filter { $0.hasEdits }.count
|
|
2536
|
-
if editCount > 0 {
|
|
2537
|
-
Text("\(editCount) pending")
|
|
2538
|
-
.font(Typo.mono(7))
|
|
2539
|
-
.foregroundColor(Color.orange.opacity(0.7))
|
|
2540
|
-
}
|
|
2541
|
-
if let ref = editor.lastActionRef {
|
|
2542
|
-
Text(ref)
|
|
2543
|
-
.font(Typo.monoBold(8))
|
|
2544
|
-
.foregroundColor(Self.shelfGreen.opacity(0.6))
|
|
2545
|
-
}
|
|
2546
|
-
}
|
|
2547
|
-
}
|
|
2548
|
-
.padding(.horizontal, 10)
|
|
2549
|
-
.padding(.vertical, 4)
|
|
2550
|
-
}
|
|
2551
|
-
.background(Color(red: 0.08, green: 0.08, blue: 0.09))
|
|
2552
|
-
}
|
|
2553
|
-
|
|
2554
|
-
// MARK: - Footer Bar
|
|
2555
|
-
|
|
2556
|
-
// MARK: - Status Bar
|
|
2557
|
-
|
|
2558
|
-
private var footerBar: some View {
|
|
2559
|
-
VStack(spacing: 0) {
|
|
2560
|
-
Rectangle().fill(Palette.borderLit).frame(height: 0.5)
|
|
2561
|
-
HStack(spacing: 0) {
|
|
2562
|
-
// Left: server health + settings
|
|
2563
|
-
HStack(spacing: 6) {
|
|
2564
|
-
Circle()
|
|
2565
|
-
.fill(daemon.isListening ? Palette.running : Palette.kill)
|
|
2566
|
-
.frame(width: 6, height: 6)
|
|
2567
|
-
if daemon.isListening {
|
|
2568
|
-
Text("Serving")
|
|
2569
|
-
.font(Typo.monoBold(9))
|
|
2570
|
-
.foregroundColor(Palette.running.opacity(0.8))
|
|
2571
|
-
Text(":9399")
|
|
2572
|
-
.font(Typo.mono(9))
|
|
2573
|
-
.foregroundColor(Palette.textMuted)
|
|
2574
|
-
if daemon.clientCount > 0 {
|
|
2575
|
-
Text("·")
|
|
2576
|
-
.foregroundColor(Palette.textMuted)
|
|
2577
|
-
Text("\(daemon.clientCount) client\(daemon.clientCount == 1 ? "" : "s")")
|
|
2578
|
-
.font(Typo.mono(9))
|
|
2579
|
-
.foregroundColor(Palette.textDim)
|
|
2580
|
-
}
|
|
2581
|
-
} else {
|
|
2582
|
-
Text("Offline")
|
|
2583
|
-
.font(Typo.monoBold(9))
|
|
2584
|
-
.foregroundColor(Palette.kill.opacity(0.7))
|
|
2585
|
-
}
|
|
2586
|
-
|
|
2587
|
-
Text("·").foregroundColor(Palette.textMuted)
|
|
2588
|
-
|
|
2589
|
-
statusBarButton(icon: "gearshape", label: "Settings") {
|
|
2590
|
-
onNavigate?(.settings)
|
|
2591
|
-
}
|
|
2592
|
-
}
|
|
2593
|
-
|
|
2594
|
-
Spacer()
|
|
2595
|
-
if let editor = controller.editor {
|
|
2596
|
-
if editor.pendingEditCount > 0 {
|
|
2597
|
-
Button {
|
|
2598
|
-
controller.applyEditsFromButton()
|
|
2599
|
-
} label: {
|
|
2600
|
-
HStack(spacing: 4) {
|
|
2601
|
-
Text("↩")
|
|
2602
|
-
.font(Typo.monoBold(9))
|
|
2603
|
-
.foregroundColor(Self.shelfGreen)
|
|
2604
|
-
Text("\(editor.pendingEditCount) pending")
|
|
2605
|
-
.font(Typo.monoBold(9))
|
|
2606
|
-
.foregroundColor(Color.orange.opacity(0.8))
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
.buttonStyle(.plain)
|
|
2610
|
-
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
2611
|
-
}
|
|
2612
|
-
if let ref = editor.lastActionRef {
|
|
2613
|
-
Text(ref)
|
|
2614
|
-
.font(Typo.monoBold(8))
|
|
2615
|
-
.foregroundColor(Self.shelfGreen.opacity(0.6))
|
|
2616
|
-
}
|
|
2617
|
-
}
|
|
2618
|
-
Spacer()
|
|
2619
|
-
|
|
2620
|
-
// Quick keyboard hints
|
|
2621
|
-
HStack(spacing: 6) {
|
|
2622
|
-
if !controller.selectedWindowIds.isEmpty {
|
|
2623
|
-
footerHint("⌘↩", label: "show")
|
|
2624
|
-
}
|
|
2625
|
-
footerHint("/", label: "search")
|
|
2626
|
-
footerHint("q", label: "quit")
|
|
2627
|
-
}
|
|
2628
|
-
.padding(.trailing, 8)
|
|
2629
|
-
|
|
2630
|
-
// Right: docs + logs
|
|
2631
|
-
HStack(spacing: 10) {
|
|
2632
|
-
statusBarButton(icon: "terminal", label: piChat.isVisible ? "Hide Pi" : "Pi") {
|
|
2633
|
-
withAnimation(.easeOut(duration: 0.16)) {
|
|
2634
|
-
piChat.toggleVisibility()
|
|
2635
|
-
}
|
|
2636
|
-
}
|
|
2637
|
-
statusBarButton(icon: "book", label: "Docs") {
|
|
2638
|
-
onNavigate?(.docs)
|
|
2639
|
-
}
|
|
2640
|
-
statusBarButton(icon: "text.alignleft", label: "Logs") {
|
|
2641
|
-
DiagnosticWindow.shared.toggle()
|
|
2642
|
-
}
|
|
2643
|
-
}
|
|
2644
|
-
}
|
|
2645
|
-
.padding(.horizontal, 10)
|
|
2646
|
-
.padding(.vertical, 4)
|
|
2647
|
-
}
|
|
2648
|
-
.background(Color(red: 0.08, green: 0.08, blue: 0.09))
|
|
2649
|
-
}
|
|
2650
|
-
|
|
2651
|
-
private func statusBarButton(icon: String, label: String, action: @escaping () -> Void) -> some View {
|
|
2652
|
-
Button(action: action) {
|
|
2653
|
-
HStack(spacing: 4) {
|
|
2654
|
-
Image(systemName: icon)
|
|
2655
|
-
.font(.system(size: 9))
|
|
2656
|
-
Text(label)
|
|
2657
|
-
.font(Typo.mono(9))
|
|
2658
|
-
}
|
|
2659
|
-
.foregroundColor(Palette.textMuted)
|
|
2660
|
-
.contentShape(Rectangle())
|
|
2661
|
-
}
|
|
2662
|
-
.buttonStyle(.plain)
|
|
2663
|
-
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
2664
|
-
}
|
|
2665
|
-
|
|
2666
|
-
private func chordHint(key: String, label: String) -> some View {
|
|
2667
|
-
HStack(spacing: 4) {
|
|
2668
|
-
Text(key)
|
|
2669
|
-
.font(Typo.mono(9))
|
|
2670
|
-
.foregroundColor(Palette.text)
|
|
2671
|
-
.padding(.horizontal, 4)
|
|
2672
|
-
.padding(.vertical, 2)
|
|
2673
|
-
.background(
|
|
2674
|
-
RoundedRectangle(cornerRadius: 3)
|
|
2675
|
-
.fill(Palette.surface)
|
|
2676
|
-
.overlay(
|
|
2677
|
-
RoundedRectangle(cornerRadius: 3)
|
|
2678
|
-
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
2679
|
-
)
|
|
2680
|
-
)
|
|
2681
|
-
Text(label)
|
|
2682
|
-
.font(Typo.mono(9))
|
|
2683
|
-
.foregroundColor(Palette.textMuted)
|
|
2684
|
-
}
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
// MARK: - Sidebar Mini-Map
|
|
2688
|
-
|
|
2689
|
-
@ViewBuilder
|
|
2690
|
-
private func sidebarMiniMap(editor: ScreenMapEditorState) -> some View {
|
|
2691
|
-
let displays = editor.displays
|
|
2692
|
-
let windows = editor.renderedCanvasWindows
|
|
2693
|
-
let miniW: CGFloat = sidebarWidth - 28
|
|
2694
|
-
let miniH: CGFloat = 118
|
|
2695
|
-
let metrics = MiniMapMetrics(
|
|
2696
|
-
worldBounds: editor.canvasWorldBounds,
|
|
2697
|
-
canvasSize: CGSize(width: miniW, height: miniH)
|
|
2698
|
-
)
|
|
2699
|
-
|
|
2700
|
-
sidebarPanel {
|
|
2701
|
-
VStack(alignment: .leading, spacing: 6) {
|
|
2702
|
-
HStack(spacing: 6) {
|
|
2703
|
-
Text("MAP")
|
|
2704
|
-
.font(Typo.monoBold(8))
|
|
2705
|
-
.foregroundColor(Palette.textMuted)
|
|
2706
|
-
Spacer()
|
|
2707
|
-
Text("drag to pan")
|
|
2708
|
-
.font(Typo.mono(7))
|
|
2709
|
-
.foregroundColor(Palette.textMuted)
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
|
-
ZStack(alignment: .topLeading) {
|
|
2713
|
-
RoundedRectangle(cornerRadius: 6)
|
|
2714
|
-
.fill(Color.black.opacity(0.28))
|
|
2715
|
-
|
|
2716
|
-
ZStack(alignment: .topLeading) {
|
|
2717
|
-
RoundedRectangle(cornerRadius: 5)
|
|
2718
|
-
.fill(Palette.bg.opacity(0.35))
|
|
2719
|
-
.frame(width: metrics.drawSize.width, height: metrics.drawSize.height)
|
|
2720
|
-
.offset(x: metrics.offset.x, y: metrics.offset.y)
|
|
2721
|
-
|
|
2722
|
-
ForEach(displays, id: \.index) { disp in
|
|
2723
|
-
let rect = metrics.rect(for: disp.cgRect, minimumSize: 12)
|
|
2724
|
-
let isFocused = editor.focusedDisplayIndex == nil || editor.focusedDisplayIndex == disp.index
|
|
2725
|
-
|
|
2726
|
-
RoundedRectangle(cornerRadius: 3)
|
|
2727
|
-
.fill(isFocused ? Color.white.opacity(0.05) : Color.white.opacity(0.02))
|
|
2728
|
-
.overlay(
|
|
2729
|
-
RoundedRectangle(cornerRadius: 3)
|
|
2730
|
-
.strokeBorder(
|
|
2731
|
-
editor.focusedDisplayIndex == disp.index ? Palette.running.opacity(0.55) : Color.white.opacity(0.12),
|
|
2732
|
-
lineWidth: editor.focusedDisplayIndex == disp.index ? 1 : 0.5
|
|
2733
|
-
)
|
|
2734
|
-
)
|
|
2735
|
-
.frame(width: rect.width, height: rect.height)
|
|
2736
|
-
.offset(x: rect.minX, y: rect.minY)
|
|
2737
|
-
}
|
|
2738
|
-
|
|
2739
|
-
ForEach(Array(windows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
|
|
2740
|
-
let rect = metrics.rect(for: win.virtualFrame, minimumSize: 2)
|
|
2741
|
-
let isSelected = controller.selectedWindowIds.contains(win.id)
|
|
2742
|
-
|
|
2743
|
-
RoundedRectangle(cornerRadius: 1.5)
|
|
2744
|
-
.fill((isSelected ? Palette.running : Self.layerColor(for: win.layer)).opacity(isSelected ? 0.35 : 0.18))
|
|
2745
|
-
.overlay(
|
|
2746
|
-
RoundedRectangle(cornerRadius: 1.5)
|
|
2747
|
-
.strokeBorder(isSelected ? Palette.running.opacity(0.85) : Color.white.opacity(0.12), lineWidth: isSelected ? 1 : 0.5)
|
|
2748
|
-
)
|
|
2749
|
-
.frame(width: rect.width, height: rect.height)
|
|
2750
|
-
.offset(x: rect.minX, y: rect.minY)
|
|
2751
|
-
}
|
|
2752
|
-
|
|
2753
|
-
let viewportRect = metrics.rect(for: editor.viewportWorldRect, minimumSize: 12)
|
|
2754
|
-
|
|
2755
|
-
RoundedRectangle(cornerRadius: 4)
|
|
2756
|
-
.strokeBorder(Palette.running.opacity(0.9), lineWidth: 1.25)
|
|
2757
|
-
.background(
|
|
2758
|
-
RoundedRectangle(cornerRadius: 4)
|
|
2759
|
-
.fill(Palette.running.opacity(0.08))
|
|
2760
|
-
)
|
|
2761
|
-
.frame(width: viewportRect.width, height: viewportRect.height)
|
|
2762
|
-
.offset(x: viewportRect.minX, y: viewportRect.minY)
|
|
2763
|
-
}
|
|
2764
|
-
}
|
|
2765
|
-
.frame(width: miniW, height: miniH)
|
|
2766
|
-
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
2767
|
-
.contentShape(Rectangle())
|
|
2768
|
-
.gesture(
|
|
2769
|
-
DragGesture(minimumDistance: 0)
|
|
2770
|
-
.onChanged { value in
|
|
2771
|
-
controller.recenterViewport(at: metrics.worldPoint(for: value.location))
|
|
2772
|
-
}
|
|
2773
|
-
)
|
|
2774
|
-
|
|
2775
|
-
HStack(spacing: 6) {
|
|
2776
|
-
mapScopePill("ALL", isActive: editor.focusedDisplayIndex == nil) {
|
|
2777
|
-
controller.clearSelection()
|
|
2778
|
-
controller.focusViewportPreset(.overview)
|
|
2779
|
-
}
|
|
2780
|
-
ForEach(editor.spatialDisplayOrder, id: \.index) { disp in
|
|
2781
|
-
mapScopePill("\(editor.spatialNumber(for: disp.index))", isActive: editor.focusedDisplayIndex == disp.index) {
|
|
2782
|
-
controller.focusCanvas(
|
|
2783
|
-
on: editor.displayRegion(for: disp.index)?.rect ?? disp.cgRect,
|
|
2784
|
-
focusDisplay: disp.index,
|
|
2785
|
-
zoomToFit: true
|
|
2786
|
-
)
|
|
2787
|
-
}
|
|
2788
|
-
}
|
|
2789
|
-
}
|
|
2790
|
-
}
|
|
2791
|
-
}
|
|
2792
|
-
}
|
|
2793
|
-
|
|
2794
|
-
private func canvasExplorer(editor: ScreenMapEditorState) -> some View {
|
|
2795
|
-
let regions = editor.canvasExplorerRegions
|
|
2796
|
-
|
|
2797
|
-
return VStack(alignment: .leading, spacing: 4) {
|
|
2798
|
-
ForEach(regions.prefix(8)) { region in
|
|
2799
|
-
canvasExplorerRow(region: region)
|
|
2800
|
-
}
|
|
2801
|
-
}
|
|
2802
|
-
}
|
|
2803
|
-
|
|
2804
|
-
private func canvasExplorerRow(region: ScreenMapCanvasRegion) -> some View {
|
|
2805
|
-
let tint: Color = {
|
|
2806
|
-
switch region.kind {
|
|
2807
|
-
case .overview: return Palette.running
|
|
2808
|
-
case .display: return Color.blue.opacity(0.8)
|
|
2809
|
-
case .layer: return Self.layerColor(for: region.layer ?? 0)
|
|
2810
|
-
}
|
|
2811
|
-
}()
|
|
2812
|
-
|
|
2813
|
-
return Button {
|
|
2814
|
-
controller.jumpToCanvasRegion(region)
|
|
2815
|
-
controller.flash(region.title)
|
|
2816
|
-
} label: {
|
|
2817
|
-
HStack(spacing: 6) {
|
|
2818
|
-
Circle()
|
|
2819
|
-
.fill(tint)
|
|
2820
|
-
.frame(width: 6, height: 6)
|
|
2821
|
-
VStack(alignment: .leading, spacing: 1) {
|
|
2822
|
-
Text(region.title)
|
|
2823
|
-
.font(Typo.monoBold(8))
|
|
2824
|
-
.foregroundColor(Palette.text)
|
|
2825
|
-
.lineLimit(1)
|
|
2826
|
-
Text(region.subtitle)
|
|
2827
|
-
.font(Typo.mono(7))
|
|
2828
|
-
.foregroundColor(Palette.textMuted)
|
|
2829
|
-
.lineLimit(1)
|
|
2830
|
-
}
|
|
2831
|
-
Spacer()
|
|
2832
|
-
Text("\(region.count)")
|
|
2833
|
-
.font(Typo.mono(7))
|
|
2834
|
-
.foregroundColor(Palette.textDim)
|
|
2835
|
-
}
|
|
2836
|
-
.padding(.horizontal, 6)
|
|
2837
|
-
.padding(.vertical, 4)
|
|
2838
|
-
.background(
|
|
2839
|
-
RoundedRectangle(cornerRadius: 4)
|
|
2840
|
-
.fill(tint.opacity(0.08))
|
|
2841
|
-
.overlay(
|
|
2842
|
-
RoundedRectangle(cornerRadius: 4)
|
|
2843
|
-
.strokeBorder(tint.opacity(0.18), lineWidth: 0.5)
|
|
2844
|
-
)
|
|
2845
|
-
)
|
|
2846
|
-
}
|
|
2847
|
-
.buttonStyle(.plain)
|
|
2848
|
-
}
|
|
2849
|
-
|
|
2850
|
-
private func mapScopePill(_ label: String, isActive: Bool, action: @escaping () -> Void) -> some View {
|
|
2851
|
-
Button(action: action) {
|
|
2852
|
-
Text(label)
|
|
2853
|
-
.font(Typo.monoBold(7))
|
|
2854
|
-
.foregroundColor(isActive ? Palette.running : Palette.textDim)
|
|
2855
|
-
.padding(.horizontal, 6)
|
|
2856
|
-
.padding(.vertical, 3)
|
|
2857
|
-
.background(
|
|
2858
|
-
RoundedRectangle(cornerRadius: 4)
|
|
2859
|
-
.fill(isActive ? Palette.running.opacity(0.12) : Palette.surface.opacity(0.7))
|
|
2860
|
-
.overlay(
|
|
2861
|
-
RoundedRectangle(cornerRadius: 4)
|
|
2862
|
-
.strokeBorder(isActive ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
|
|
2863
|
-
)
|
|
2864
|
-
)
|
|
2865
|
-
}
|
|
2866
|
-
.buttonStyle(.plain)
|
|
2867
|
-
}
|
|
2868
|
-
|
|
2869
|
-
// MARK: - Flash Overlay
|
|
2870
|
-
|
|
2871
|
-
@ViewBuilder
|
|
2872
|
-
private var flashOverlay: some View {
|
|
2873
|
-
if let msg = controller.flashMessage {
|
|
2874
|
-
VStack {
|
|
2875
|
-
Spacer()
|
|
2876
|
-
HStack(spacing: 6) {
|
|
2877
|
-
Image(systemName: "rectangle.3.group")
|
|
2878
|
-
.font(.system(size: 11))
|
|
2879
|
-
Text(msg)
|
|
2880
|
-
.font(Typo.monoBold(11))
|
|
2881
|
-
}
|
|
2882
|
-
.foregroundColor(Palette.text)
|
|
2883
|
-
.padding(.horizontal, 14)
|
|
2884
|
-
.padding(.vertical, 8)
|
|
2885
|
-
.background(
|
|
2886
|
-
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
2887
|
-
.fill(Palette.surface)
|
|
2888
|
-
.overlay(
|
|
2889
|
-
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
2890
|
-
.strokeBorder(Palette.running.opacity(0.3), lineWidth: 0.5)
|
|
2891
|
-
)
|
|
2892
|
-
.shadow(color: .black.opacity(0.2), radius: 8, y: 2)
|
|
2893
|
-
)
|
|
2894
|
-
.padding(.bottom, 60)
|
|
2895
|
-
}
|
|
2896
|
-
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
|
2897
|
-
.animation(.easeOut(duration: 0.2), value: controller.flashMessage)
|
|
2898
|
-
.allowsHitTesting(false)
|
|
2899
|
-
}
|
|
2900
|
-
}
|
|
2901
|
-
|
|
2902
|
-
private var divider: some View {
|
|
2903
|
-
Rectangle()
|
|
2904
|
-
.fill(Palette.border)
|
|
2905
|
-
.frame(height: 0.5)
|
|
2906
|
-
}
|
|
2907
|
-
|
|
2908
|
-
// MARK: - Helpers
|
|
2909
|
-
|
|
2910
|
-
@ViewBuilder
|
|
2911
|
-
private func sidebarPanel<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
2912
|
-
content()
|
|
2913
|
-
.padding(6)
|
|
2914
|
-
.background(
|
|
2915
|
-
RoundedRectangle(cornerRadius: 6)
|
|
2916
|
-
.fill(Color.black.opacity(0.4))
|
|
2917
|
-
.overlay(
|
|
2918
|
-
RoundedRectangle(cornerRadius: 6)
|
|
2919
|
-
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
|
2920
|
-
)
|
|
2921
|
-
)
|
|
2922
|
-
}
|
|
2923
|
-
|
|
2924
|
-
private func syncCanvasGeometry(editor: ScreenMapEditorState?, metrics: CanvasMetrics) {
|
|
2925
|
-
let previous = editor.map {
|
|
2926
|
-
(
|
|
2927
|
-
fitScale: $0.fitScale,
|
|
2928
|
-
scale: $0.scale,
|
|
2929
|
-
mapOrigin: $0.mapOrigin,
|
|
2930
|
-
viewportSize: $0.viewportSize,
|
|
2931
|
-
screenSize: $0.screenSize,
|
|
2932
|
-
bboxOrigin: $0.bboxOrigin
|
|
2933
|
-
)
|
|
2934
|
-
}
|
|
2935
|
-
editor?.fitScale = metrics.fitScale
|
|
2936
|
-
editor?.scale = metrics.effectiveScale
|
|
2937
|
-
editor?.mapOrigin = metrics.centerOffset
|
|
2938
|
-
editor?.viewportSize = metrics.syncedViewportSize
|
|
2939
|
-
editor?.screenSize = metrics.worldBounds.size
|
|
2940
|
-
editor?.bboxOrigin = metrics.worldBounds.origin
|
|
2941
|
-
controller.applyPendingCanvasNavigationIfNeeded()
|
|
2942
|
-
if let editor {
|
|
2943
|
-
let boundedPan = boundedPanOffset(editor.panOffset, editor: editor)
|
|
2944
|
-
if boundedPan != editor.panOffset {
|
|
2945
|
-
editor.panOffset = boundedPan
|
|
2946
|
-
}
|
|
2947
|
-
}
|
|
2948
|
-
if let editor,
|
|
2949
|
-
let previous,
|
|
2950
|
-
previous.fitScale != editor.fitScale ||
|
|
2951
|
-
previous.scale != editor.scale ||
|
|
2952
|
-
previous.mapOrigin != editor.mapOrigin ||
|
|
2953
|
-
previous.viewportSize != editor.viewportSize ||
|
|
2954
|
-
previous.screenSize != editor.screenSize ||
|
|
2955
|
-
previous.bboxOrigin != editor.bboxOrigin {
|
|
2956
|
-
DispatchQueue.main.async {
|
|
2957
|
-
editor.objectWillChange.send()
|
|
2958
|
-
controller.objectWillChange.send()
|
|
2959
|
-
}
|
|
2960
|
-
}
|
|
2961
|
-
}
|
|
2962
|
-
|
|
2963
|
-
private func boundedPanOffset(_ proposed: CGPoint, editor: ScreenMapEditorState) -> CGPoint {
|
|
2964
|
-
guard editor.scale > 0,
|
|
2965
|
-
editor.viewportSize.width > 0,
|
|
2966
|
-
editor.viewportSize.height > 0,
|
|
2967
|
-
editor.screenSize.width > 0,
|
|
2968
|
-
editor.screenSize.height > 0 else {
|
|
2969
|
-
return proposed
|
|
2970
|
-
}
|
|
2971
|
-
|
|
2972
|
-
func clampAxis(_ value: CGFloat, mapOrigin: CGFloat, content: CGFloat, viewport: CGFloat) -> CGFloat {
|
|
2973
|
-
let effectiveScale = max(editor.fitScale * editor.zoomLevel, editor.scale)
|
|
2974
|
-
let mapSize = content * effectiveScale
|
|
2975
|
-
let minVisible = min(Self.canvasPanMinVisiblePixels, max(viewport / 2, 0))
|
|
2976
|
-
let minValue = minVisible - mapOrigin - mapSize
|
|
2977
|
-
let maxValue = viewport - minVisible - mapOrigin
|
|
2978
|
-
return min(max(value, minValue), maxValue)
|
|
2979
|
-
}
|
|
2980
|
-
|
|
2981
|
-
return CGPoint(
|
|
2982
|
-
x: clampAxis(proposed.x,
|
|
2983
|
-
mapOrigin: editor.mapOrigin.x,
|
|
2984
|
-
content: editor.screenSize.width,
|
|
2985
|
-
viewport: editor.viewportSize.width),
|
|
2986
|
-
y: clampAxis(proposed.y,
|
|
2987
|
-
mapOrigin: editor.mapOrigin.y,
|
|
2988
|
-
content: editor.screenSize.height,
|
|
2989
|
-
viewport: editor.viewportSize.height)
|
|
2990
|
-
)
|
|
2991
|
-
}
|
|
2992
|
-
|
|
2993
|
-
// MARK: - Layer Colors
|
|
2994
|
-
|
|
2995
|
-
private static let layerColors: [Color] = [
|
|
2996
|
-
.green, .cyan, .orange, .purple, .pink, .yellow, .mint, .indigo
|
|
2997
|
-
]
|
|
2998
|
-
|
|
2999
|
-
private static let inspectorLogTimeFormatter: DateFormatter = {
|
|
3000
|
-
let formatter = DateFormatter()
|
|
3001
|
-
formatter.dateFormat = "HH:mm:ss"
|
|
3002
|
-
return formatter
|
|
3003
|
-
}()
|
|
3004
|
-
|
|
3005
|
-
private static func layerColor(for layer: Int) -> Color {
|
|
3006
|
-
layerColors[layer % layerColors.count]
|
|
3007
|
-
}
|
|
3008
|
-
|
|
3009
|
-
private static func inferTileIcon(for win: ScreenMapWindowEntry, displays: [DisplayGeometry]) -> String? {
|
|
3010
|
-
guard let disp = displays.first(where: { $0.index == win.displayIndex }) else { return nil }
|
|
3011
|
-
let screenW = disp.cgRect.width
|
|
3012
|
-
let screenH = disp.cgRect.height
|
|
3013
|
-
let relX = win.virtualFrame.origin.x - disp.cgRect.origin.x
|
|
3014
|
-
let relY = win.virtualFrame.origin.y - disp.cgRect.origin.y
|
|
3015
|
-
let winW = win.virtualFrame.width
|
|
3016
|
-
let winH = win.virtualFrame.height
|
|
3017
|
-
let tolerance: CGFloat = 30
|
|
3018
|
-
|
|
3019
|
-
for pos in TilePosition.allCases {
|
|
3020
|
-
let (fx, fy, fw, fh) = pos.rect
|
|
3021
|
-
let expectedX = fx * screenW
|
|
3022
|
-
let expectedY = fy * screenH
|
|
3023
|
-
let expectedW = fw * screenW
|
|
3024
|
-
let expectedH = fh * screenH
|
|
3025
|
-
if abs(relX - expectedX) < tolerance && abs(relY - expectedY) < tolerance
|
|
3026
|
-
&& abs(winW - expectedW) < tolerance && abs(winH - expectedH) < tolerance {
|
|
3027
|
-
return pos.icon
|
|
3028
|
-
}
|
|
3029
|
-
}
|
|
3030
|
-
return nil
|
|
3031
|
-
}
|
|
3032
|
-
|
|
3033
|
-
private static func extractLatticesSession(from title: String) -> String? {
|
|
3034
|
-
guard let range = title.range(of: #"\[lattices:([^\]]+)\]"#, options: .regularExpression) else { return nil }
|
|
3035
|
-
let match = String(title[range])
|
|
3036
|
-
return String(match.dropFirst(9).dropLast(1))
|
|
3037
|
-
}
|
|
3038
|
-
|
|
3039
|
-
// MARK: - Layer Preview
|
|
3040
|
-
|
|
3041
|
-
private func handlePreviewChange(isPreviewing: Bool) {
|
|
3042
|
-
guard isPreviewing, let editor = controller.editor else { return }
|
|
3043
|
-
let screens = NSScreen.screens
|
|
3044
|
-
guard !screens.isEmpty else { return }
|
|
3045
|
-
|
|
3046
|
-
let primaryHeight = screens.first?.frame.height ?? 0
|
|
3047
|
-
|
|
3048
|
-
// Scope preview to the focused display's screen, or union of all
|
|
3049
|
-
let targetFrame: NSRect
|
|
3050
|
-
let cgOrigin: CGPoint
|
|
3051
|
-
if let focusedIdx = editor.focusedDisplayIndex, focusedIdx < screens.count {
|
|
3052
|
-
let screen = screens[focusedIdx]
|
|
3053
|
-
targetFrame = screen.frame
|
|
3054
|
-
cgOrigin = CGPoint(x: screen.frame.origin.x,
|
|
3055
|
-
y: primaryHeight - screen.frame.maxY)
|
|
3056
|
-
} else {
|
|
3057
|
-
var union = screens[0].frame
|
|
3058
|
-
for screen in screens.dropFirst() { union = union.union(screen.frame) }
|
|
3059
|
-
targetFrame = union
|
|
3060
|
-
cgOrigin = CGPoint(x: union.origin.x,
|
|
3061
|
-
y: primaryHeight - (union.origin.y + union.height))
|
|
3062
|
-
}
|
|
3063
|
-
|
|
3064
|
-
let visible = editor.focusedVisibleWindows
|
|
3065
|
-
let label = editor.layerLabel
|
|
3066
|
-
let captures = controller.previewCaptures
|
|
3067
|
-
|
|
3068
|
-
let overlay = ScreenMapPreviewOverlay(
|
|
3069
|
-
windows: visible, layerLabel: label, captures: captures,
|
|
3070
|
-
screenFrame: targetFrame,
|
|
3071
|
-
screenCGOrigin: cgOrigin
|
|
3072
|
-
)
|
|
3073
|
-
let hostingView = NSHostingView(rootView: overlay)
|
|
3074
|
-
controller.showPreviewWindow(contentView: hostingView, frame: targetFrame)
|
|
3075
|
-
}
|
|
3076
|
-
|
|
3077
|
-
// MARK: - Key Handler
|
|
3078
|
-
|
|
3079
|
-
private func installKeyHandler() {
|
|
3080
|
-
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { event in
|
|
3081
|
-
// Only handle keys when our window is the key window
|
|
3082
|
-
guard let win = ScreenMapWindowController.shared.nsWindow,
|
|
3083
|
-
win.isKeyWindow else { return event }
|
|
3084
|
-
if isEditableTextResponder(win.firstResponder) {
|
|
3085
|
-
return event
|
|
3086
|
-
}
|
|
3087
|
-
// Track space key for canvas drag-to-pan
|
|
3088
|
-
if event.keyCode == 49 && !controller.isSearchActive {
|
|
3089
|
-
if event.type == .keyDown {
|
|
3090
|
-
if !isSpaceHeld {
|
|
3091
|
-
isSpaceHeld = true
|
|
3092
|
-
NSCursor.openHand.push()
|
|
3093
|
-
}
|
|
3094
|
-
return nil
|
|
3095
|
-
} else if event.type == .keyUp {
|
|
3096
|
-
guard isSpaceHeld else { return nil }
|
|
3097
|
-
isSpaceHeld = false
|
|
3098
|
-
canvasPanStart = nil
|
|
3099
|
-
NSCursor.pop()
|
|
3100
|
-
return nil
|
|
3101
|
-
}
|
|
3102
|
-
}
|
|
3103
|
-
guard event.type == .keyDown else { return event }
|
|
3104
|
-
let consumed = controller.handleKey(event.keyCode, modifiers: event.modifierFlags)
|
|
3105
|
-
return consumed ? nil : event
|
|
3106
|
-
}
|
|
3107
|
-
}
|
|
3108
|
-
|
|
3109
|
-
private func isEditableTextResponder(_ responder: NSResponder?) -> Bool {
|
|
3110
|
-
if let textView = responder as? NSTextView {
|
|
3111
|
-
return textView.isEditable || textView.isFieldEditor
|
|
3112
|
-
}
|
|
3113
|
-
|
|
3114
|
-
if let textField = responder as? NSTextField {
|
|
3115
|
-
return textField.isEditable
|
|
3116
|
-
}
|
|
3117
|
-
|
|
3118
|
-
guard let responder else { return false }
|
|
3119
|
-
let className = NSStringFromClass(type(of: responder))
|
|
3120
|
-
return className.contains("FieldEditor") || className.contains("TextView")
|
|
3121
|
-
}
|
|
3122
|
-
|
|
3123
|
-
private func removeKeyHandler() {
|
|
3124
|
-
if let monitor = eventMonitor {
|
|
3125
|
-
NSEvent.removeMonitor(monitor)
|
|
3126
|
-
eventMonitor = nil
|
|
3127
|
-
}
|
|
3128
|
-
}
|
|
3129
|
-
|
|
3130
|
-
// MARK: - Mouse Monitors
|
|
3131
|
-
|
|
3132
|
-
private func installMouseMonitors() {
|
|
3133
|
-
let dragThreshold: CGFloat = 4
|
|
3134
|
-
|
|
3135
|
-
mouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { event in
|
|
3136
|
-
guard let eventWindow = event.window,
|
|
3137
|
-
eventWindow === ScreenMapWindowController.shared.nsWindow else { return event }
|
|
3138
|
-
let flippedPt = flippedScreenPoint(event)
|
|
3139
|
-
guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else { return event }
|
|
3140
|
-
|
|
3141
|
-
// Space+click → begin canvas pan
|
|
3142
|
-
if isSpaceHeld,
|
|
3143
|
-
let editor = controller.editor {
|
|
3144
|
-
canvasPanStart = event.locationInWindow
|
|
3145
|
-
canvasPanStartOffset = editor.panOffset
|
|
3146
|
-
NSCursor.closedHand.push()
|
|
3147
|
-
return nil
|
|
3148
|
-
}
|
|
3149
|
-
|
|
3150
|
-
if let editor = controller.editor,
|
|
3151
|
-
let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor) {
|
|
3152
|
-
screenMapClickWindowId = nil
|
|
3153
|
-
if NSEvent.modifierFlags.contains(.command) {
|
|
3154
|
-
controller.toggleSelection(hit.id)
|
|
3155
|
-
} else if !controller.isSelected(hit.id) {
|
|
3156
|
-
controller.selectSingle(hit.id)
|
|
3157
|
-
}
|
|
3158
|
-
canvasPanStart = event.locationInWindow
|
|
3159
|
-
canvasPanStartOffset = editor.panOffset
|
|
3160
|
-
return nil
|
|
3161
|
-
} else {
|
|
3162
|
-
screenMapClickWindowId = nil
|
|
3163
|
-
}
|
|
3164
|
-
|
|
3165
|
-
if let editor = controller.editor {
|
|
3166
|
-
canvasPanStart = event.locationInWindow
|
|
3167
|
-
canvasPanStartOffset = editor.panOffset
|
|
3168
|
-
return nil
|
|
3169
|
-
}
|
|
3170
|
-
|
|
3171
|
-
return event
|
|
3172
|
-
}
|
|
3173
|
-
|
|
3174
|
-
mouseDragMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
|
|
3175
|
-
// Empty-canvas drag, or Space+drag, pans the viewport.
|
|
3176
|
-
if let start = canvasPanStart, let editor = controller.editor {
|
|
3177
|
-
let dx = event.locationInWindow.x - start.x
|
|
3178
|
-
let dy = event.locationInWindow.y - start.y
|
|
3179
|
-
let bounded = boundedPanOffset(
|
|
3180
|
-
CGPoint(x: canvasPanStartOffset.x + dx, y: canvasPanStartOffset.y - dy),
|
|
3181
|
-
editor: editor
|
|
3182
|
-
)
|
|
3183
|
-
if bounded != editor.panOffset {
|
|
3184
|
-
editor.activeViewportPreset = nil
|
|
3185
|
-
editor.panOffset = bounded
|
|
3186
|
-
}
|
|
3187
|
-
return nil
|
|
3188
|
-
}
|
|
3189
|
-
|
|
3190
|
-
guard let hitId = screenMapClickWindowId,
|
|
3191
|
-
let editor = controller.editor else { return event }
|
|
3192
|
-
let dx = event.locationInWindow.x - screenMapClickPoint.x
|
|
3193
|
-
let dy = event.locationInWindow.y - screenMapClickPoint.y
|
|
3194
|
-
guard sqrt(dx * dx + dy * dy) >= dragThreshold else { return event }
|
|
3195
|
-
|
|
3196
|
-
if editor.draggingWindowId != hitId {
|
|
3197
|
-
editor.draggingWindowId = hitId
|
|
3198
|
-
if let idx = editor.windows.firstIndex(where: { $0.id == hitId }) {
|
|
3199
|
-
editor.dragStartFrame = editor.windows[idx].virtualFrame
|
|
3200
|
-
}
|
|
3201
|
-
controller.selectSingle(hitId)
|
|
3202
|
-
}
|
|
3203
|
-
|
|
3204
|
-
let effScale = editor.effectiveScale
|
|
3205
|
-
guard let startFrame = editor.dragStartFrame,
|
|
3206
|
-
effScale > 0,
|
|
3207
|
-
let idx = editor.windows.firstIndex(where: { $0.id == hitId }) else { return event }
|
|
3208
|
-
let screenDx = dx / effScale
|
|
3209
|
-
let screenDy = -dy / effScale // CG coords: Y flipped
|
|
3210
|
-
let mode = editor.canvasDragMode
|
|
3211
|
-
let minW: CGFloat = 100
|
|
3212
|
-
let minH: CGFloat = 50
|
|
3213
|
-
|
|
3214
|
-
var newFrame = startFrame
|
|
3215
|
-
|
|
3216
|
-
switch mode {
|
|
3217
|
-
case .move:
|
|
3218
|
-
newFrame.origin.x = startFrame.origin.x + screenDx
|
|
3219
|
-
newFrame.origin.y = startFrame.origin.y + screenDy
|
|
3220
|
-
|
|
3221
|
-
case .resizeRight:
|
|
3222
|
-
newFrame.size.width = max(minW, startFrame.width + screenDx)
|
|
3223
|
-
case .resizeLeft:
|
|
3224
|
-
let dw = min(screenDx, startFrame.width - minW)
|
|
3225
|
-
newFrame.origin.x = startFrame.origin.x + dw
|
|
3226
|
-
newFrame.size.width = startFrame.width - dw
|
|
3227
|
-
case .resizeBottom:
|
|
3228
|
-
newFrame.size.height = max(minH, startFrame.height + screenDy)
|
|
3229
|
-
case .resizeTop:
|
|
3230
|
-
let dh = min(screenDy, startFrame.height - minH)
|
|
3231
|
-
newFrame.origin.y = startFrame.origin.y + dh
|
|
3232
|
-
newFrame.size.height = startFrame.height - dh
|
|
3233
|
-
|
|
3234
|
-
case .resizeTopLeft:
|
|
3235
|
-
let dw = min(screenDx, startFrame.width - minW)
|
|
3236
|
-
newFrame.origin.x = startFrame.origin.x + dw
|
|
3237
|
-
newFrame.size.width = startFrame.width - dw
|
|
3238
|
-
let dh = min(screenDy, startFrame.height - minH)
|
|
3239
|
-
newFrame.origin.y = startFrame.origin.y + dh
|
|
3240
|
-
newFrame.size.height = startFrame.height - dh
|
|
3241
|
-
case .resizeTopRight:
|
|
3242
|
-
newFrame.size.width = max(minW, startFrame.width + screenDx)
|
|
3243
|
-
let dh = min(screenDy, startFrame.height - minH)
|
|
3244
|
-
newFrame.origin.y = startFrame.origin.y + dh
|
|
3245
|
-
newFrame.size.height = startFrame.height - dh
|
|
3246
|
-
case .resizeBottomLeft:
|
|
3247
|
-
let dw = min(screenDx, startFrame.width - minW)
|
|
3248
|
-
newFrame.origin.x = startFrame.origin.x + dw
|
|
3249
|
-
newFrame.size.width = startFrame.width - dw
|
|
3250
|
-
newFrame.size.height = max(minH, startFrame.height + screenDy)
|
|
3251
|
-
case .resizeBottomRight:
|
|
3252
|
-
newFrame.size.width = max(minW, startFrame.width + screenDx)
|
|
3253
|
-
newFrame.size.height = max(minH, startFrame.height + screenDy)
|
|
3254
|
-
}
|
|
3255
|
-
|
|
3256
|
-
editor.syncLayoutFrame(at: idx, to: newFrame)
|
|
3257
|
-
return nil
|
|
3258
|
-
}
|
|
3259
|
-
|
|
3260
|
-
mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
|
|
3261
|
-
// End canvas pan.
|
|
3262
|
-
if canvasPanStart != nil {
|
|
3263
|
-
canvasPanStart = nil
|
|
3264
|
-
if isSpaceHeld {
|
|
3265
|
-
NSCursor.pop() // pop closedHand, openHand remains
|
|
3266
|
-
}
|
|
3267
|
-
return event
|
|
3268
|
-
}
|
|
3269
|
-
if screenMapClickWindowId != nil {
|
|
3270
|
-
if let editor = controller.editor, editor.draggingWindowId != nil {
|
|
3271
|
-
editor.draggingWindowId = nil
|
|
3272
|
-
editor.dragStartFrame = nil
|
|
3273
|
-
editor.canvasDragMode = .move
|
|
3274
|
-
}
|
|
3275
|
-
screenMapClickWindowId = nil
|
|
3276
|
-
}
|
|
3277
|
-
return event
|
|
3278
|
-
}
|
|
3279
|
-
|
|
3280
|
-
rightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { event in
|
|
3281
|
-
guard let eventWindow = event.window,
|
|
3282
|
-
eventWindow === ScreenMapWindowController.shared.nsWindow,
|
|
3283
|
-
let editor = controller.editor else { return event }
|
|
3284
|
-
|
|
3285
|
-
let flippedPt = flippedScreenPoint(event)
|
|
3286
|
-
guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else { return event }
|
|
3287
|
-
|
|
3288
|
-
if let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor) {
|
|
3289
|
-
if !controller.isSelected(hit.id) {
|
|
3290
|
-
controller.selectSingle(hit.id)
|
|
3291
|
-
}
|
|
3292
|
-
showLayerContextMenu(for: hit.id, at: event.locationInWindow, in: eventWindow, editor: editor)
|
|
3293
|
-
return nil
|
|
3294
|
-
}
|
|
3295
|
-
return event
|
|
3296
|
-
}
|
|
3297
|
-
|
|
3298
|
-
scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
|
|
3299
|
-
guard let eventWindow = event.window,
|
|
3300
|
-
eventWindow === ScreenMapWindowController.shared.nsWindow,
|
|
3301
|
-
let editor = controller.editor else { return event }
|
|
3302
|
-
|
|
3303
|
-
// Let search overlay handle its own scroll
|
|
3304
|
-
if controller.isSearchActive {
|
|
3305
|
-
let screenPt = event.locationInWindow
|
|
3306
|
-
let windowPt = eventWindow.convertPoint(toScreen: screenPt)
|
|
3307
|
-
let flippedY = NSScreen.main.map { $0.frame.height - windowPt.y } ?? windowPt.y
|
|
3308
|
-
let testPt = CGPoint(x: windowPt.x, y: flippedY)
|
|
3309
|
-
if searchOverlayFrame.contains(testPt) {
|
|
3310
|
-
return event // pass to SwiftUI ScrollView
|
|
3311
|
-
}
|
|
3312
|
-
}
|
|
3313
|
-
|
|
3314
|
-
let flippedPt = flippedScreenPoint(event)
|
|
3315
|
-
guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else { return event }
|
|
3316
|
-
|
|
3317
|
-
let isZoom = event.modifierFlags.contains(.command)
|
|
3318
|
-
|
|
3319
|
-
if isZoom {
|
|
3320
|
-
let zoomDelta: CGFloat = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY * 0.01 : event.scrollingDeltaY * 0.05
|
|
3321
|
-
let oldZoom = editor.zoomLevel
|
|
3322
|
-
let newZoom = max(ScreenMapEditorState.minZoom, min(ScreenMapEditorState.maxZoom, oldZoom + zoomDelta))
|
|
3323
|
-
guard newZoom != oldZoom else { return nil }
|
|
3324
|
-
|
|
3325
|
-
let canvasLocal = CGPoint(
|
|
3326
|
-
x: flippedPt.x - screenMapCanvasOrigin.x,
|
|
3327
|
-
y: flippedPt.y - screenMapCanvasOrigin.y
|
|
3328
|
-
)
|
|
3329
|
-
let canvasCenterX = screenMapCanvasSize.width / 2
|
|
3330
|
-
let canvasCenterY = screenMapCanvasSize.height / 2
|
|
3331
|
-
let cursorFromCenter = CGPoint(
|
|
3332
|
-
x: canvasLocal.x - canvasCenterX,
|
|
3333
|
-
y: canvasLocal.y - canvasCenterY
|
|
3334
|
-
)
|
|
3335
|
-
|
|
3336
|
-
let ratio = newZoom / oldZoom
|
|
3337
|
-
let newPanX = cursorFromCenter.x - ratio * (cursorFromCenter.x - editor.panOffset.x)
|
|
3338
|
-
let newPanY = cursorFromCenter.y - ratio * (cursorFromCenter.y - editor.panOffset.y)
|
|
3339
|
-
|
|
3340
|
-
editor.activeViewportPreset = nil
|
|
3341
|
-
editor.zoomLevel = newZoom
|
|
3342
|
-
editor.scale = editor.fitScale * newZoom
|
|
3343
|
-
let bounded = boundedPanOffset(CGPoint(x: newPanX, y: newPanY), editor: editor)
|
|
3344
|
-
if bounded != editor.panOffset {
|
|
3345
|
-
editor.activeViewportPreset = nil
|
|
3346
|
-
editor.panOffset = bounded
|
|
3347
|
-
}
|
|
3348
|
-
} else {
|
|
3349
|
-
guard editor.zoomLevel > 1.0001 else {
|
|
3350
|
-
if editor.panOffset != .zero {
|
|
3351
|
-
editor.panOffset = .zero
|
|
3352
|
-
}
|
|
3353
|
-
return nil
|
|
3354
|
-
}
|
|
3355
|
-
let bounded = boundedPanOffset(
|
|
3356
|
-
CGPoint(
|
|
3357
|
-
x: editor.panOffset.x + event.scrollingDeltaX,
|
|
3358
|
-
y: editor.panOffset.y - event.scrollingDeltaY
|
|
3359
|
-
),
|
|
3360
|
-
editor: editor
|
|
3361
|
-
)
|
|
3362
|
-
if bounded != editor.panOffset {
|
|
3363
|
-
editor.activeViewportPreset = nil
|
|
3364
|
-
editor.panOffset = bounded
|
|
3365
|
-
}
|
|
3366
|
-
}
|
|
3367
|
-
return nil
|
|
3368
|
-
}
|
|
3369
|
-
|
|
3370
|
-
mouseMovedMonitor = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved) { event in
|
|
3371
|
-
guard let eventWindow = event.window,
|
|
3372
|
-
eventWindow === ScreenMapWindowController.shared.nsWindow,
|
|
3373
|
-
let editor = controller.editor else {
|
|
3374
|
-
resetCursorIfNeeded()
|
|
3375
|
-
return event
|
|
3376
|
-
}
|
|
3377
|
-
|
|
3378
|
-
let flippedPt = flippedScreenPoint(event)
|
|
3379
|
-
guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else {
|
|
3380
|
-
resetCursorIfNeeded()
|
|
3381
|
-
return event
|
|
3382
|
-
}
|
|
3383
|
-
|
|
3384
|
-
if let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor) {
|
|
3385
|
-
let mode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
|
|
3386
|
-
if mode != editor.currentCursorMode {
|
|
3387
|
-
if editor.currentCursorMode != .move { NSCursor.pop() }
|
|
3388
|
-
editor.currentCursorMode = mode
|
|
3389
|
-
switch mode {
|
|
3390
|
-
case .resizeLeft, .resizeRight:
|
|
3391
|
-
NSCursor.resizeLeftRight.push()
|
|
3392
|
-
case .resizeTop, .resizeBottom:
|
|
3393
|
-
NSCursor.resizeUpDown.push()
|
|
3394
|
-
case .resizeTopLeft, .resizeTopRight, .resizeBottomLeft, .resizeBottomRight:
|
|
3395
|
-
NSCursor.crosshair.push()
|
|
3396
|
-
case .move:
|
|
3397
|
-
break
|
|
3398
|
-
}
|
|
3399
|
-
}
|
|
3400
|
-
} else {
|
|
3401
|
-
resetCursorIfNeeded()
|
|
3402
|
-
}
|
|
3403
|
-
return event
|
|
3404
|
-
}
|
|
3405
|
-
}
|
|
3406
|
-
|
|
3407
|
-
private func resetCursorIfNeeded() {
|
|
3408
|
-
guard let editor = controller.editor else { return }
|
|
3409
|
-
if editor.currentCursorMode != .move {
|
|
3410
|
-
NSCursor.pop()
|
|
3411
|
-
editor.currentCursorMode = .move
|
|
3412
|
-
}
|
|
3413
|
-
}
|
|
3414
|
-
|
|
3415
|
-
private func removeMouseMonitors() {
|
|
3416
|
-
if let m = mouseDownMonitor { NSEvent.removeMonitor(m); mouseDownMonitor = nil }
|
|
3417
|
-
if let m = mouseDragMonitor { NSEvent.removeMonitor(m); mouseDragMonitor = nil }
|
|
3418
|
-
if let m = mouseUpMonitor { NSEvent.removeMonitor(m); mouseUpMonitor = nil }
|
|
3419
|
-
if let m = rightClickMonitor { NSEvent.removeMonitor(m); rightClickMonitor = nil }
|
|
3420
|
-
if let m = scrollWheelMonitor { NSEvent.removeMonitor(m); scrollWheelMonitor = nil }
|
|
3421
|
-
if let m = mouseMovedMonitor { NSEvent.removeMonitor(m); mouseMovedMonitor = nil }
|
|
3422
|
-
resetCursorIfNeeded()
|
|
3423
|
-
}
|
|
3424
|
-
|
|
3425
|
-
// MARK: - Hit Test / Coordinate Conversion
|
|
3426
|
-
|
|
3427
|
-
private func canvasHit(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> CanvasHit? {
|
|
3428
|
-
guard isCanvasPoint(flippedScreenPt) else { return nil }
|
|
3429
|
-
let projection = CanvasProjection(editor: editor)
|
|
3430
|
-
guard projection.scale > 0 else { return nil }
|
|
3431
|
-
let canvasLocal = CGPoint(
|
|
3432
|
-
x: flippedScreenPt.x - screenMapCanvasOrigin.x,
|
|
3433
|
-
y: flippedScreenPt.y - screenMapCanvasOrigin.y
|
|
3434
|
-
)
|
|
3435
|
-
let mapPoint = projection.mapPoint(forCanvasPoint: canvasLocal)
|
|
3436
|
-
let sorted = editor.renderedCanvasWindows.sorted(by: { $0.zIndex < $1.zIndex })
|
|
3437
|
-
for win in sorted {
|
|
3438
|
-
let mapRect = projection.mapRect(for: win.virtualFrame)
|
|
3439
|
-
if mapRect.contains(mapPoint) {
|
|
3440
|
-
return CanvasHit(id: win.id, mapRect: mapRect, mapPoint: mapPoint)
|
|
3441
|
-
}
|
|
3442
|
-
}
|
|
3443
|
-
return nil
|
|
3444
|
-
}
|
|
3445
|
-
|
|
3446
|
-
private func isCanvasPoint(_ point: CGPoint) -> Bool {
|
|
3447
|
-
CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize).contains(point)
|
|
3448
|
-
}
|
|
3449
|
-
|
|
3450
|
-
private func isCanvasInteractionEvent(_ event: NSEvent, flippedPoint: CGPoint) -> Bool {
|
|
3451
|
-
guard isCanvasPoint(flippedPoint),
|
|
3452
|
-
let contentWidth = event.window?.contentView?.bounds.width else {
|
|
3453
|
-
return false
|
|
3454
|
-
}
|
|
3455
|
-
|
|
3456
|
-
let x = event.locationInWindow.x
|
|
3457
|
-
let leftBoundary = sidebarWidth + 8
|
|
3458
|
-
let rightBoundary = max(leftBoundary, contentWidth - inspectorWidth - 8)
|
|
3459
|
-
return x >= leftBoundary && x <= rightBoundary
|
|
3460
|
-
}
|
|
3461
|
-
|
|
3462
|
-
private func flippedScreenPoint(_ event: NSEvent) -> CGPoint {
|
|
3463
|
-
guard let nsWindow = event.window else { return .zero }
|
|
3464
|
-
let loc = event.locationInWindow
|
|
3465
|
-
let windowHeight = nsWindow.contentView?.frame.height ?? nsWindow.frame.height
|
|
3466
|
-
return CGPoint(x: loc.x, y: windowHeight - loc.y)
|
|
3467
|
-
}
|
|
3468
|
-
|
|
3469
|
-
private func detectDragMode(mapPoint: CGPoint, windowMapRect: CGRect) -> CanvasDragMode {
|
|
3470
|
-
let w = windowMapRect.width
|
|
3471
|
-
let h = windowMapRect.height
|
|
3472
|
-
let threshold = max(4, min(8, min(w, h) * 0.25))
|
|
3473
|
-
|
|
3474
|
-
let nearLeft = mapPoint.x - windowMapRect.minX < threshold
|
|
3475
|
-
let nearRight = windowMapRect.maxX - mapPoint.x < threshold
|
|
3476
|
-
let nearTop = mapPoint.y - windowMapRect.minY < threshold
|
|
3477
|
-
let nearBottom = windowMapRect.maxY - mapPoint.y < threshold
|
|
3478
|
-
|
|
3479
|
-
// Corners take priority
|
|
3480
|
-
if nearTop && nearLeft { return .resizeTopLeft }
|
|
3481
|
-
if nearTop && nearRight { return .resizeTopRight }
|
|
3482
|
-
if nearBottom && nearLeft { return .resizeBottomLeft }
|
|
3483
|
-
if nearBottom && nearRight { return .resizeBottomRight }
|
|
3484
|
-
|
|
3485
|
-
// Edges
|
|
3486
|
-
if nearLeft { return .resizeLeft }
|
|
3487
|
-
if nearRight { return .resizeRight }
|
|
3488
|
-
if nearTop { return .resizeTop }
|
|
3489
|
-
if nearBottom { return .resizeBottom }
|
|
3490
|
-
|
|
3491
|
-
return .move
|
|
3492
|
-
}
|
|
3493
|
-
|
|
3494
|
-
// MARK: - Context Menu
|
|
3495
|
-
|
|
3496
|
-
private func showLayerContextMenu(for windowId: UInt32, at point: NSPoint, in window: NSWindow, editor: ScreenMapEditorState) {
|
|
3497
|
-
guard let winIdx = editor.windows.firstIndex(where: { $0.id == windowId }) else { return }
|
|
3498
|
-
let win = editor.windows[winIdx]
|
|
3499
|
-
let currentLayer = win.layer
|
|
3500
|
-
|
|
3501
|
-
let menu = NSMenu()
|
|
3502
|
-
let header = NSMenuItem(title: "\(win.app) — Layer \(currentLayer)", action: nil, keyEquivalent: "")
|
|
3503
|
-
header.isEnabled = false
|
|
3504
|
-
menu.addItem(header)
|
|
3505
|
-
menu.addItem(.separator())
|
|
3506
|
-
|
|
3507
|
-
// Focus window on screen
|
|
3508
|
-
let focusItem = NSMenuItem(title: "Show on Screen ⌘↩", action: nil, keyEquivalent: "")
|
|
3509
|
-
focusItem.representedObject = ScreenMapFocusMenuAction(windowId: windowId, controller: controller)
|
|
3510
|
-
focusItem.action = #selector(ScreenMapMenuTarget.performFocus(_:))
|
|
3511
|
-
focusItem.target = ScreenMapMenuTarget.shared
|
|
3512
|
-
menu.addItem(focusItem)
|
|
3513
|
-
|
|
3514
|
-
menu.addItem(.separator())
|
|
3515
|
-
|
|
3516
|
-
// Move to Layer → submenu
|
|
3517
|
-
let moveItem = NSMenuItem(title: "Move to Layer", action: nil, keyEquivalent: "")
|
|
3518
|
-
let layerSubmenu = NSMenu()
|
|
3519
|
-
|
|
3520
|
-
for layer in editor.effectiveLayers where layer != currentLayer {
|
|
3521
|
-
let name = editor.layerDisplayName(for: layer)
|
|
3522
|
-
let count = editor.effectiveWindowCount(for: layer)
|
|
3523
|
-
let item = NSMenuItem(title: "\(name) (\(count) windows)", action: nil, keyEquivalent: "")
|
|
3524
|
-
item.representedObject = ScreenMapLayerMenuAction(windowId: windowId, targetLayer: layer, editor: editor, controller: controller)
|
|
3525
|
-
item.action = #selector(ScreenMapMenuTarget.performLayerMove(_:))
|
|
3526
|
-
item.target = ScreenMapMenuTarget.shared
|
|
3527
|
-
layerSubmenu.addItem(item)
|
|
3528
|
-
}
|
|
3529
|
-
|
|
3530
|
-
layerSubmenu.addItem(.separator())
|
|
3531
|
-
let newLayerItem = NSMenuItem(title: "New Layer", action: nil, keyEquivalent: "")
|
|
3532
|
-
newLayerItem.representedObject = ScreenMapLayerMenuAction(windowId: windowId, targetLayer: editor.layerCount, editor: editor, controller: controller)
|
|
3533
|
-
newLayerItem.action = #selector(ScreenMapMenuTarget.performLayerMove(_:))
|
|
3534
|
-
newLayerItem.target = ScreenMapMenuTarget.shared
|
|
3535
|
-
layerSubmenu.addItem(newLayerItem)
|
|
3536
|
-
|
|
3537
|
-
moveItem.submenu = layerSubmenu
|
|
3538
|
-
menu.addItem(moveItem)
|
|
3539
|
-
|
|
3540
|
-
// Convert window coordinates to contentView coordinates for correct menu positioning
|
|
3541
|
-
let menuPoint: NSPoint
|
|
3542
|
-
if let contentView = window.contentView {
|
|
3543
|
-
menuPoint = contentView.convert(point, from: nil)
|
|
3544
|
-
} else {
|
|
3545
|
-
menuPoint = point
|
|
3546
|
-
}
|
|
3547
|
-
menu.popUp(positioning: nil, at: menuPoint, in: window.contentView)
|
|
3548
|
-
}
|
|
3549
|
-
}
|
|
3550
|
-
|
|
3551
|
-
// MARK: - Context Menu Helpers
|
|
3552
|
-
|
|
3553
|
-
struct ScreenMapLayerMenuAction {
|
|
3554
|
-
let windowId: UInt32
|
|
3555
|
-
let targetLayer: Int
|
|
3556
|
-
let editor: ScreenMapEditorState
|
|
3557
|
-
let controller: ScreenMapController
|
|
3558
|
-
}
|
|
3559
|
-
|
|
3560
|
-
struct ScreenMapFocusMenuAction {
|
|
3561
|
-
let windowId: UInt32
|
|
3562
|
-
let controller: ScreenMapController
|
|
3563
|
-
}
|
|
3564
|
-
|
|
3565
|
-
final class ScreenMapMenuTarget: NSObject {
|
|
3566
|
-
static let shared = ScreenMapMenuTarget()
|
|
3567
|
-
|
|
3568
|
-
@objc func performLayerMove(_ sender: NSMenuItem) {
|
|
3569
|
-
guard let action = sender.representedObject as? ScreenMapLayerMenuAction else { return }
|
|
3570
|
-
action.editor.reassignLayer(windowId: action.windowId, toLayer: action.targetLayer, fitToAvailable: true)
|
|
3571
|
-
}
|
|
3572
|
-
|
|
3573
|
-
@objc func performFocus(_ sender: NSMenuItem) {
|
|
3574
|
-
guard let action = sender.representedObject as? ScreenMapFocusMenuAction else { return }
|
|
3575
|
-
action.controller.focusWindowOnScreen(action.windowId)
|
|
3576
|
-
}
|
|
3577
|
-
}
|
|
3578
|
-
|
|
3579
|
-
// MARK: - Preview Overlay
|
|
3580
|
-
|
|
3581
|
-
struct ScreenMapPreviewOverlay: View {
|
|
3582
|
-
let windows: [ScreenMapWindowEntry]
|
|
3583
|
-
let layerLabel: String
|
|
3584
|
-
let captures: [UInt32: NSImage]
|
|
3585
|
-
let screenFrame: CGRect
|
|
3586
|
-
let screenCGOrigin: CGPoint
|
|
3587
|
-
|
|
3588
|
-
private static let layerColors: [Color] = [
|
|
3589
|
-
.green, .cyan, .orange, .purple, .pink, .yellow, .mint, .indigo
|
|
3590
|
-
]
|
|
3591
|
-
|
|
3592
|
-
var body: some View {
|
|
3593
|
-
ZStack(alignment: .topLeading) {
|
|
3594
|
-
Color.black.opacity(0.88)
|
|
3595
|
-
|
|
3596
|
-
ForEach(windows) { win in
|
|
3597
|
-
let f = win.virtualFrame
|
|
3598
|
-
let x = f.origin.x - screenCGOrigin.x
|
|
3599
|
-
let y = f.origin.y - screenCGOrigin.y
|
|
3600
|
-
let w = f.width
|
|
3601
|
-
let h = f.height
|
|
3602
|
-
let color = Self.layerColors[win.layer % Self.layerColors.count]
|
|
3603
|
-
|
|
3604
|
-
ZStack {
|
|
3605
|
-
RoundedRectangle(cornerRadius: 6)
|
|
3606
|
-
.fill(color.opacity(0.12))
|
|
3607
|
-
RoundedRectangle(cornerRadius: 6)
|
|
3608
|
-
.strokeBorder(color.opacity(0.7), lineWidth: 2)
|
|
3609
|
-
|
|
3610
|
-
VStack(spacing: 4) {
|
|
3611
|
-
Text(win.app)
|
|
3612
|
-
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
|
3613
|
-
.foregroundColor(.white)
|
|
3614
|
-
if !win.title.isEmpty && h > 60 {
|
|
3615
|
-
Text(win.title)
|
|
3616
|
-
.font(.system(size: 10, design: .monospaced))
|
|
3617
|
-
.foregroundColor(.white.opacity(0.6))
|
|
3618
|
-
.lineLimit(1)
|
|
3619
|
-
}
|
|
3620
|
-
if h > 40 {
|
|
3621
|
-
Text("\(Int(w)) × \(Int(h))")
|
|
3622
|
-
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
3623
|
-
.foregroundColor(color.opacity(0.7))
|
|
3624
|
-
}
|
|
3625
|
-
if win.hasEdits && h > 80 {
|
|
3626
|
-
Text("L\(win.layer)")
|
|
3627
|
-
.font(.system(size: 9, weight: .medium, design: .monospaced))
|
|
3628
|
-
.foregroundColor(color.opacity(0.5))
|
|
3629
|
-
}
|
|
3630
|
-
}
|
|
3631
|
-
.padding(8)
|
|
3632
|
-
}
|
|
3633
|
-
.shadow(color: color.opacity(0.3), radius: 8)
|
|
3634
|
-
.frame(width: w, height: h)
|
|
3635
|
-
.offset(x: x, y: y)
|
|
3636
|
-
}
|
|
3637
|
-
|
|
3638
|
-
VStack {
|
|
3639
|
-
Spacer()
|
|
3640
|
-
HStack {
|
|
3641
|
-
Spacer()
|
|
3642
|
-
Text("\(layerLabel) • \(windows.count) windows • click or press any key to dismiss")
|
|
3643
|
-
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
|
3644
|
-
.foregroundColor(.white)
|
|
3645
|
-
.padding(.horizontal, 16)
|
|
3646
|
-
.padding(.vertical, 8)
|
|
3647
|
-
.background(Color.black.opacity(0.7))
|
|
3648
|
-
.cornerRadius(8)
|
|
3649
|
-
.padding(20)
|
|
3650
|
-
Spacer()
|
|
3651
|
-
}
|
|
3652
|
-
}
|
|
3653
|
-
}
|
|
3654
|
-
.frame(width: screenFrame.width, height: screenFrame.height)
|
|
3655
|
-
}
|
|
3656
|
-
}
|
|
3657
|
-
|
|
3658
|
-
// MARK: - Layer Row Frame Preference Key
|
|
3659
|
-
|
|
3660
|
-
private struct LayerRowFrameKey: PreferenceKey {
|
|
3661
|
-
static var defaultValue: [Int: CGRect] = [:]
|
|
3662
|
-
static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
|
|
3663
|
-
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
|
3664
|
-
}
|
|
3665
|
-
}
|
|
3666
|
-
|
|
3667
|
-
// MARK: - Show on Screen Bezel
|
|
3668
|
-
|
|
3669
|
-
struct ShowOnScreenBezelView: View {
|
|
3670
|
-
let appName: String
|
|
3671
|
-
let windowTitle: String
|
|
3672
|
-
let displayName: String
|
|
3673
|
-
let displayNumber: Int
|
|
3674
|
-
let layerName: String
|
|
3675
|
-
let windowSize: String
|
|
3676
|
-
let windowsOnDisplay: Int
|
|
3677
|
-
let layersOnDisplay: Int
|
|
3678
|
-
let windowLocalFrame: CGRect // NS coordinates relative to tight window
|
|
3679
|
-
let screenSize: CGSize // tight window size (not full screen)
|
|
3680
|
-
let labelPlacement: LabelPlacement
|
|
3681
|
-
let flush: FlushEdges
|
|
3682
|
-
let windowSnapshot: NSImage? // pre-captured window content for screenshot tools
|
|
3683
|
-
|
|
3684
|
-
enum LabelPlacement { case below, above, right, left }
|
|
3685
|
-
|
|
3686
|
-
/// Which edges of the window are flush with the screen boundary
|
|
3687
|
-
struct FlushEdges {
|
|
3688
|
-
let top: Bool
|
|
3689
|
-
let bottom: Bool
|
|
3690
|
-
let left: Bool
|
|
3691
|
-
let right: Bool
|
|
3692
|
-
static let none = FlushEdges(top: false, bottom: false, left: false, right: false)
|
|
3693
|
-
}
|
|
3694
|
-
|
|
3695
|
-
// Inverted from OS appearance so bezel contrasts with desktop:
|
|
3696
|
-
// Dark mode desktop → light bezel, Light mode desktop → dark bezel
|
|
3697
|
-
@Environment(\.colorScheme) private var colorScheme
|
|
3698
|
-
|
|
3699
|
-
private let accent = Color(red: 0.13, green: 0.62, blue: 0.38)
|
|
3700
|
-
|
|
3701
|
-
private var bg: Color {
|
|
3702
|
-
colorScheme == .dark
|
|
3703
|
-
? Color(red: 0.92, green: 0.92, blue: 0.93)
|
|
3704
|
-
: Color(red: 0.16, green: 0.16, blue: 0.18)
|
|
3705
|
-
}
|
|
3706
|
-
private var textPrimary: Color {
|
|
3707
|
-
colorScheme == .dark
|
|
3708
|
-
? Color(red: 0.10, green: 0.10, blue: 0.12)
|
|
3709
|
-
: Color(red: 0.95, green: 0.95, blue: 0.97)
|
|
3710
|
-
}
|
|
3711
|
-
private var textSecondary: Color {
|
|
3712
|
-
colorScheme == .dark
|
|
3713
|
-
? Color(red: 0.35, green: 0.35, blue: 0.38)
|
|
3714
|
-
: Color(red: 0.68, green: 0.68, blue: 0.72)
|
|
3715
|
-
}
|
|
3716
|
-
private var textTertiary: Color {
|
|
3717
|
-
colorScheme == .dark
|
|
3718
|
-
? Color(red: 0.55, green: 0.55, blue: 0.58)
|
|
3719
|
-
: Color(red: 0.48, green: 0.48, blue: 0.52)
|
|
3720
|
-
}
|
|
3721
|
-
|
|
3722
|
-
// ZStack uses top-left origin; convert from NS bottom-left
|
|
3723
|
-
private var winX: CGFloat { windowLocalFrame.origin.x }
|
|
3724
|
-
private var winY: CGFloat { screenSize.height - windowLocalFrame.origin.y - windowLocalFrame.height }
|
|
3725
|
-
private var winW: CGFloat { windowLocalFrame.width }
|
|
3726
|
-
private var winH: CGFloat { windowLocalFrame.height }
|
|
3727
|
-
|
|
3728
|
-
// Frame dimensions
|
|
3729
|
-
private let edge: CGFloat = 5 // border thickness on non-flush edges
|
|
3730
|
-
private let shelfHeight: CGFloat = 40 // info shelf thickness
|
|
3731
|
-
private let cornerR: CGFloat = 10 // matches macOS window corners
|
|
3732
|
-
|
|
3733
|
-
// Edge insets: 0 on flush edges, `edge` on free edges
|
|
3734
|
-
private var insetTop: CGFloat { flush.top ? 0 : edge }
|
|
3735
|
-
private var insetBottom: CGFloat { flush.bottom ? 0 : edge }
|
|
3736
|
-
private var insetLeft: CGFloat { flush.left ? 0 : edge }
|
|
3737
|
-
private var insetRight: CGFloat { flush.right ? 0 : edge }
|
|
3738
|
-
|
|
3739
|
-
// Corner radii: 0 if either adjacent edge is flush
|
|
3740
|
-
private var rTL: CGFloat { (flush.top || flush.left) ? 0 : cornerR }
|
|
3741
|
-
private var rTR: CGFloat { (flush.top || flush.right) ? 0 : cornerR }
|
|
3742
|
-
private var rBL: CGFloat { (flush.bottom || flush.left) ? 0 : cornerR }
|
|
3743
|
-
private var rBR: CGFloat { (flush.bottom || flush.right) ? 0 : cornerR }
|
|
3744
|
-
|
|
3745
|
-
var body: some View {
|
|
3746
|
-
ZStack(alignment: .topLeading) {
|
|
3747
|
-
Color.clear
|
|
3748
|
-
|
|
3749
|
-
// Frame origin and size, accounting for flush edges and shelf placement
|
|
3750
|
-
let frameX = winX - insetLeft + shelfOffsetX
|
|
3751
|
-
let frameY = winY - insetTop + shelfOffsetY
|
|
3752
|
-
let frameW = winW + insetLeft + insetRight + shelfExtraW
|
|
3753
|
-
let frameH = winH + insetTop + insetBottom + shelfExtraH
|
|
3754
|
-
|
|
3755
|
-
// Adjust corner radii for shelf side
|
|
3756
|
-
let finalTL = adjustedCornerRadius(rTL, forShelf: labelPlacement, corner: .topLeft)
|
|
3757
|
-
let finalTR = adjustedCornerRadius(rTR, forShelf: labelPlacement, corner: .topRight)
|
|
3758
|
-
let finalBL = adjustedCornerRadius(rBL, forShelf: labelPlacement, corner: .bottomLeft)
|
|
3759
|
-
let finalBR = adjustedCornerRadius(rBR, forShelf: labelPlacement, corner: .bottomRight)
|
|
3760
|
-
|
|
3761
|
-
UnevenRoundedRectangle(
|
|
3762
|
-
topLeadingRadius: finalTL,
|
|
3763
|
-
bottomLeadingRadius: finalBL,
|
|
3764
|
-
bottomTrailingRadius: finalBR,
|
|
3765
|
-
topTrailingRadius: finalTR
|
|
3766
|
-
)
|
|
3767
|
-
.fill(bg)
|
|
3768
|
-
.frame(width: frameW, height: frameH)
|
|
3769
|
-
.offset(x: frameX, y: frameY)
|
|
3770
|
-
|
|
3771
|
-
// Window snapshot — baked into the bezel so screenshot tools get the full composite
|
|
3772
|
-
if let snapshot = windowSnapshot {
|
|
3773
|
-
Image(nsImage: snapshot)
|
|
3774
|
-
.resizable()
|
|
3775
|
-
.interpolation(.high)
|
|
3776
|
-
.frame(width: winW, height: winH)
|
|
3777
|
-
.clipped()
|
|
3778
|
-
.offset(x: winX, y: winY)
|
|
3779
|
-
}
|
|
3780
|
-
|
|
3781
|
-
// Shelf content
|
|
3782
|
-
switch labelPlacement {
|
|
3783
|
-
case .below:
|
|
3784
|
-
shelfContent
|
|
3785
|
-
.frame(width: winW + insetLeft + insetRight - 8, height: shelfHeight - 4)
|
|
3786
|
-
.offset(x: winX - insetLeft + 4, y: winY + winH + insetBottom)
|
|
3787
|
-
case .above:
|
|
3788
|
-
shelfContent
|
|
3789
|
-
.frame(width: winW + insetLeft + insetRight - 8, height: shelfHeight - 4)
|
|
3790
|
-
.offset(x: winX - insetLeft + 4, y: winY - insetTop - shelfHeight + 4)
|
|
3791
|
-
case .right:
|
|
3792
|
-
sideShelfContent
|
|
3793
|
-
.frame(width: 190, height: winH + insetTop + insetBottom)
|
|
3794
|
-
.offset(x: winX + winW + insetRight + 4, y: winY - insetTop)
|
|
3795
|
-
case .left:
|
|
3796
|
-
sideShelfContent
|
|
3797
|
-
.frame(width: 190, height: winH + insetTop + insetBottom)
|
|
3798
|
-
.offset(x: winX - insetLeft - 194, y: winY - insetTop)
|
|
3799
|
-
}
|
|
3800
|
-
}
|
|
3801
|
-
.frame(width: screenSize.width, height: screenSize.height)
|
|
3802
|
-
}
|
|
3803
|
-
|
|
3804
|
-
// MARK: - Shelf geometry helpers
|
|
3805
|
-
|
|
3806
|
-
/// How much extra width/height the shelf adds to the frame
|
|
3807
|
-
private var shelfExtraW: CGFloat {
|
|
3808
|
-
switch labelPlacement {
|
|
3809
|
-
case .below, .above: return 0
|
|
3810
|
-
case .right, .left: return 200
|
|
3811
|
-
}
|
|
3812
|
-
}
|
|
3813
|
-
private var shelfExtraH: CGFloat {
|
|
3814
|
-
switch labelPlacement {
|
|
3815
|
-
case .below, .above: return shelfHeight
|
|
3816
|
-
case .right, .left: return 0
|
|
3817
|
-
}
|
|
3818
|
-
}
|
|
3819
|
-
|
|
3820
|
-
/// Offset the frame origin for shelf on top/left
|
|
3821
|
-
private var shelfOffsetX: CGFloat {
|
|
3822
|
-
labelPlacement == .left ? -200 : 0
|
|
3823
|
-
}
|
|
3824
|
-
private var shelfOffsetY: CGFloat {
|
|
3825
|
-
labelPlacement == .above ? -shelfHeight : 0
|
|
3826
|
-
}
|
|
3827
|
-
|
|
3828
|
-
private enum Corner { case topLeft, topRight, bottomLeft, bottomRight }
|
|
3829
|
-
|
|
3830
|
-
/// Ensure the shelf-side corners are rounded even if the window edge is flush there
|
|
3831
|
-
private func adjustedCornerRadius(_ base: CGFloat, forShelf shelf: LabelPlacement, corner: Corner) -> CGFloat {
|
|
3832
|
-
// The shelf extends outward from the window, so its outer corners should be rounded
|
|
3833
|
-
switch (shelf, corner) {
|
|
3834
|
-
case (.below, .bottomLeft), (.below, .bottomRight):
|
|
3835
|
-
return cornerR
|
|
3836
|
-
case (.above, .topLeft), (.above, .topRight):
|
|
3837
|
-
return cornerR
|
|
3838
|
-
case (.right, .topRight), (.right, .bottomRight):
|
|
3839
|
-
return cornerR
|
|
3840
|
-
case (.left, .topLeft), (.left, .bottomLeft):
|
|
3841
|
-
return cornerR
|
|
3842
|
-
default:
|
|
3843
|
-
return base
|
|
3844
|
-
}
|
|
3845
|
-
}
|
|
3846
|
-
|
|
3847
|
-
// MARK: - Horizontal shelf (bottom / top)
|
|
3848
|
-
|
|
3849
|
-
private var shelfContent: some View {
|
|
3850
|
-
HStack(spacing: 8) {
|
|
3851
|
-
// App name — distinctive rounded font
|
|
3852
|
-
Text(appName)
|
|
3853
|
-
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
|
3854
|
-
.foregroundColor(textPrimary)
|
|
3855
|
-
.lineLimit(1)
|
|
3856
|
-
|
|
3857
|
-
if !windowTitle.isEmpty {
|
|
3858
|
-
Text("·")
|
|
3859
|
-
.foregroundColor(textTertiary)
|
|
3860
|
-
Text(windowTitle)
|
|
3861
|
-
.font(.system(size: 10, design: .monospaced))
|
|
3862
|
-
.foregroundColor(textSecondary)
|
|
3863
|
-
.lineLimit(1)
|
|
3864
|
-
.frame(maxWidth: .infinity, alignment: .leading)
|
|
3865
|
-
} else {
|
|
3866
|
-
Spacer()
|
|
3867
|
-
}
|
|
3868
|
-
|
|
3869
|
-
bezelTag(layerName, color: accent)
|
|
3870
|
-
bezelTag(windowSize, color: textSecondary)
|
|
3871
|
-
|
|
3872
|
-
// Display badge
|
|
3873
|
-
HStack(spacing: 3) {
|
|
3874
|
-
Image(systemName: "display")
|
|
3875
|
-
.font(.system(size: 9))
|
|
3876
|
-
.foregroundColor(textTertiary)
|
|
3877
|
-
Text("\(displayNumber)")
|
|
3878
|
-
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
|
3879
|
-
.foregroundColor(textSecondary)
|
|
3880
|
-
}
|
|
3881
|
-
}
|
|
3882
|
-
.padding(.horizontal, 10)
|
|
3883
|
-
}
|
|
3884
|
-
|
|
3885
|
-
// MARK: - Side shelf (right)
|
|
3886
|
-
|
|
3887
|
-
private var sideShelfContent: some View {
|
|
3888
|
-
VStack(alignment: .leading, spacing: 6) {
|
|
3889
|
-
Text(appName)
|
|
3890
|
-
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
|
3891
|
-
.foregroundColor(textPrimary)
|
|
3892
|
-
.lineLimit(1)
|
|
3893
|
-
if !windowTitle.isEmpty {
|
|
3894
|
-
Text(windowTitle)
|
|
3895
|
-
.font(.system(size: 9, design: .monospaced))
|
|
3896
|
-
.foregroundColor(textSecondary)
|
|
3897
|
-
.lineLimit(2)
|
|
3898
|
-
}
|
|
3899
|
-
HStack(spacing: 6) {
|
|
3900
|
-
bezelTag(layerName, color: accent)
|
|
3901
|
-
bezelTag(windowSize, color: textSecondary)
|
|
3902
|
-
}
|
|
3903
|
-
Spacer()
|
|
3904
|
-
HStack(spacing: 4) {
|
|
3905
|
-
Image(systemName: "display")
|
|
3906
|
-
.font(.system(size: 9))
|
|
3907
|
-
.foregroundColor(textTertiary)
|
|
3908
|
-
Text("\(displayNumber)")
|
|
3909
|
-
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
3910
|
-
.foregroundColor(textSecondary)
|
|
3911
|
-
Text(displayName)
|
|
3912
|
-
.font(.system(size: 8, design: .monospaced))
|
|
3913
|
-
.foregroundColor(textTertiary)
|
|
3914
|
-
.lineLimit(1)
|
|
3915
|
-
}
|
|
3916
|
-
}
|
|
3917
|
-
.padding(8)
|
|
3918
|
-
}
|
|
3919
|
-
|
|
3920
|
-
// MARK: - Helpers
|
|
3921
|
-
|
|
3922
|
-
private func bezelTag(_ text: String, color: Color) -> some View {
|
|
3923
|
-
Text(text)
|
|
3924
|
-
.font(.system(size: 9, weight: .medium, design: .monospaced))
|
|
3925
|
-
.foregroundColor(color)
|
|
3926
|
-
.padding(.horizontal, 5)
|
|
3927
|
-
.padding(.vertical, 2)
|
|
3928
|
-
.background(
|
|
3929
|
-
RoundedRectangle(cornerRadius: 3)
|
|
3930
|
-
.fill(color.opacity(0.08))
|
|
3931
|
-
.overlay(
|
|
3932
|
-
RoundedRectangle(cornerRadius: 3)
|
|
3933
|
-
.strokeBorder(color.opacity(0.15), lineWidth: 0.5)
|
|
3934
|
-
)
|
|
3935
|
-
)
|
|
3936
|
-
}
|
|
3937
|
-
}
|
|
3938
|
-
|
|
3939
|
-
private struct SidebarWindowHitCatcher: NSViewRepresentable {
|
|
3940
|
-
let rowHeight: CGFloat
|
|
3941
|
-
let onClick: (Int) -> Void
|
|
3942
|
-
|
|
3943
|
-
func makeNSView(context: Context) -> HitView {
|
|
3944
|
-
let view = HitView()
|
|
3945
|
-
view.rowHeight = rowHeight
|
|
3946
|
-
view.onClick = onClick
|
|
3947
|
-
return view
|
|
3948
|
-
}
|
|
3949
|
-
|
|
3950
|
-
func updateNSView(_ nsView: HitView, context: Context) {
|
|
3951
|
-
nsView.rowHeight = rowHeight
|
|
3952
|
-
nsView.onClick = onClick
|
|
3953
|
-
}
|
|
3954
|
-
|
|
3955
|
-
final class HitView: NSView {
|
|
3956
|
-
var rowHeight: CGFloat = 30
|
|
3957
|
-
var onClick: ((Int) -> Void)?
|
|
3958
|
-
|
|
3959
|
-
override var isFlipped: Bool { true }
|
|
3960
|
-
|
|
3961
|
-
override func mouseDown(with event: NSEvent) {
|
|
3962
|
-
let point = convert(event.locationInWindow, from: nil)
|
|
3963
|
-
guard point.x >= 0, point.x <= bounds.width, point.y >= 0, point.y <= bounds.height else { return }
|
|
3964
|
-
let row = max(0, Int(point.y / max(rowHeight, 1)))
|
|
3965
|
-
onClick?(row)
|
|
3966
|
-
}
|
|
3967
|
-
}
|
|
3968
|
-
}
|
|
3969
|
-
|
|
3970
|
-
// MARK: - Preference Keys
|
|
3971
|
-
|
|
3972
|
-
private struct SearchOverlayFrameKey: PreferenceKey {
|
|
3973
|
-
static var defaultValue: CGRect = .zero
|
|
3974
|
-
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
|
3975
|
-
value = nextValue()
|
|
3976
|
-
}
|
|
3977
|
-
}
|