@lattices/cli 0.4.2 → 0.4.6
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 +3 -0
- package/app/Info.plist +2 -2
- package/app/Lattices.app/Contents/Info.plist +2 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Package.swift +6 -0
- package/app/Sources/AppShell/App.swift +20 -0
- package/app/Sources/{AppDelegate.swift → AppShell/AppDelegate.swift} +94 -34
- package/app/Sources/{AppShellView.swift → AppShell/AppShellView.swift} +12 -1
- package/app/Sources/AppShell/AppUpdater.swift +92 -0
- package/app/Sources/AppShell/CliActionLauncher.swift +50 -0
- package/app/Sources/{HomeDashboardView.swift → AppShell/HomeDashboardView.swift} +18 -10
- package/app/Sources/AppShell/LatticesRuntime.swift +61 -0
- package/app/Sources/{MainView.swift → AppShell/MainView.swift} +351 -191
- package/app/Sources/{OnboardingView.swift → AppShell/OnboardingView.swift} +30 -16
- package/app/Sources/{Preferences.swift → AppShell/Preferences.swift} +78 -0
- package/app/Sources/{SettingsView.swift → AppShell/SettingsView.swift} +869 -152
- package/app/Sources/{HotkeyStore.swift → Core/Actions/HotkeyStore.swift} +9 -5
- package/app/Sources/{IntentEngine.swift → Core/Actions/IntentEngine.swift} +51 -27
- package/app/Sources/Core/Actions/IntentSchema.swift +94 -0
- package/app/Sources/{Intents → Core/Actions/Intents}/LatticeIntent.swift +0 -25
- package/app/Sources/{PaletteCommand.swift → Core/Actions/PaletteCommand.swift} +26 -6
- package/app/Sources/{VoiceIntentResolver.swift → Core/Actions/VoiceIntentResolver.swift} +46 -4
- package/app/Sources/Core/Companion/CompanionActivityLog.swift +70 -0
- package/app/Sources/Core/Companion/CompanionKeyboardController.swift +141 -0
- package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +438 -0
- package/app/Sources/Core/Companion/LatticesCompanionCockpit.swift +555 -0
- package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +594 -0
- package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +204 -0
- package/app/Sources/Core/Companion/LatticesDeckHost.swift +1463 -0
- package/app/Sources/{LatticesApi.swift → Core/Daemon/LatticesApi.swift} +125 -4
- package/app/Sources/{AppTypeClassifier.swift → Core/Desktop/AppTypeClassifier.swift} +36 -0
- package/app/Sources/{DesktopModel.swift → Core/Desktop/DesktopModel.swift} +6 -8
- package/app/Sources/Core/Desktop/MouseFinder.swift +527 -0
- package/app/Sources/Core/Desktop/SessionWindowLocator.swift +139 -0
- package/app/Sources/Core/Desktop/WindowDragSnapController.swift +628 -0
- package/app/Sources/Core/Desktop/WindowPreviewCard.swift +100 -0
- package/app/Sources/Core/Desktop/WindowPreviewStore.swift +113 -0
- package/app/Sources/Core/Desktop/WindowSelectionStore.swift +76 -0
- package/app/Sources/{WindowTiler.swift → Core/Desktop/WindowTiler.swift} +351 -172
- package/app/Sources/Core/Input/MouseGestureConfig.swift +364 -0
- package/app/Sources/Core/Input/MouseGestureController.swift +1203 -0
- package/app/Sources/Core/Input/MouseInputDeviceStore.swift +98 -0
- package/app/Sources/Core/Input/MouseInputEventViewer.swift +272 -0
- package/app/Sources/Core/Input/MouseShortcutStore.swift +107 -0
- package/app/Sources/{CommandModeState.swift → Core/Overlays/CommandMode/CommandModeState.swift} +127 -24
- package/app/Sources/{CommandModeView.swift → Core/Overlays/CommandMode/CommandModeView.swift} +492 -79
- package/app/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +67 -0
- package/app/Sources/{CheatSheetHUD.swift → Core/Overlays/HUD/CheatSheetHUD.swift} +1 -0
- package/app/Sources/{HUDRightBar.swift → Core/Overlays/HUD/HUDRightBar.swift} +23 -201
- package/app/Sources/{LauncherHUD.swift → Core/Overlays/HUD/LauncherHUD.swift} +12 -26
- package/app/Sources/{OmniSearchView.swift → Core/Overlays/OmniSearch/OmniSearchView.swift} +136 -2
- package/app/Sources/{OmniSearchWindow.swift → Core/Overlays/OmniSearch/OmniSearchWindow.swift} +21 -32
- package/app/Sources/Core/Overlays/OverlayPanelShell.swift +241 -0
- package/app/Sources/{ScreenMapState.swift → Core/Overlays/ScreenMap/ScreenMapState.swift} +116 -32
- package/app/Sources/{ScreenMapView.swift → Core/Overlays/ScreenMap/ScreenMapView.swift} +510 -524
- package/app/Sources/{ScreenMapWindowController.swift → Core/Overlays/ScreenMap/ScreenMapWindowController.swift} +12 -4
- package/app/Sources/{VoiceCommandWindow.swift → Core/Overlays/Voice/VoiceCommandWindow.swift} +46 -53
- package/app/Sources/Core/Pi/PiAuthNextStepCard.swift +148 -0
- package/app/Sources/Core/Pi/PiAuthPromptCard.swift +90 -0
- package/app/Sources/{PiChatDock.swift → Core/Pi/PiChatDock.swift} +137 -74
- package/app/Sources/{PiChatSession.swift → Core/Pi/PiChatSession.swift} +608 -108
- package/app/Sources/Core/Pi/PiInstallCallout.swift +86 -0
- package/app/Sources/Core/Pi/PiProviderSetupCallout.swift +99 -0
- package/app/Sources/{PiWorkspaceView.swift → Core/Pi/PiWorkspaceView.swift} +174 -77
- package/app/Sources/{PermissionChecker.swift → Core/System/PermissionChecker.swift} +76 -2
- package/app/Sources/Core/System/SystemTelemetryMonitor.swift +273 -0
- package/app/Sources/{HandsOffSession.swift → Core/Voice/HandsOffSession.swift} +15 -4
- package/app/Sources/{WorkspaceManager.swift → Core/Workspace/WorkspaceManager.swift} +288 -0
- package/bin/assistant-intelligence.ts +874 -0
- package/bin/handsoff-infer.ts +16 -209
- package/bin/handsoff-worker.ts +45 -258
- package/bin/lattices-app.ts +62 -0
- package/bin/lattices-dev +4 -0
- package/bin/lattices.ts +125 -14
- package/docs/agents.md +14 -0
- package/docs/api.md +55 -0
- package/docs/app.md +3 -0
- package/docs/companion-deck.md +180 -0
- package/docs/component-extraction-roadmap.md +392 -0
- package/docs/config.md +25 -0
- package/docs/tiling-reference.md +55 -0
- package/docs/voice-error-model.md +73 -0
- package/package.json +4 -1
- package/app/Sources/App.swift +0 -10
- package/app/Sources/CommandPaletteWindow.swift +0 -134
- package/app/Sources/MouseFinder.swift +0 -222
- /package/app/Sources/{KeyRecorderView.swift → AppShell/KeyRecorderView.swift} +0 -0
- /package/app/Sources/{MainWindow.swift → AppShell/MainWindow.swift} +0 -0
- /package/app/Sources/{SettingsWindow.swift → AppShell/SettingsWindow.swift} +0 -0
- /package/app/Sources/{HotkeyManager.swift → Core/Actions/HotkeyManager.swift} +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/CreateLayerIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/DistributeIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/FocusIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/HelpIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/KillIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/LaunchIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/ListSessionsIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/ListWindowsIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/ScanIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/SearchIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/SwitchLayerIntent.swift +0 -0
- /package/app/Sources/{Intents → Core/Actions/Intents}/TileIntent.swift +0 -0
- /package/app/Sources/{DaemonProtocol.swift → Core/Daemon/DaemonProtocol.swift} +0 -0
- /package/app/Sources/{DaemonServer.swift → Core/Daemon/DaemonServer.swift} +0 -0
- /package/app/Sources/{AccessibilityTextExtractor.swift → Core/Desktop/AccessibilityTextExtractor.swift} +0 -0
- /package/app/Sources/{DesktopModelTypes.swift → Core/Desktop/DesktopModelTypes.swift} +0 -0
- /package/app/Sources/{InventoryManager.swift → Core/Desktop/InventoryManager.swift} +0 -0
- /package/app/Sources/{InventoryPath.swift → Core/Desktop/InventoryPath.swift} +0 -0
- /package/app/Sources/{OcrModel.swift → Core/Desktop/OcrModel.swift} +0 -0
- /package/app/Sources/{OcrStore.swift → Core/Desktop/OcrStore.swift} +0 -0
- /package/app/Sources/{PlacementSpec.swift → Core/Desktop/PlacementSpec.swift} +0 -0
- /package/app/Sources/{TilePickerView.swift → Core/Desktop/TilePickerView.swift} +0 -0
- /package/app/Sources/{AppWindowShell.swift → Core/Overlays/AppWindowShell.swift} +0 -0
- /package/app/Sources/{CommandModeWindow.swift → Core/Overlays/CommandMode/CommandModeWindow.swift} +0 -0
- /package/app/Sources/{CommandPaletteView.swift → Core/Overlays/CommandPalette/CommandPaletteView.swift} +0 -0
- /package/app/Sources/{HUDBottomBar.swift → Core/Overlays/HUD/HUDBottomBar.swift} +0 -0
- /package/app/Sources/{HUDController.swift → Core/Overlays/HUD/HUDController.swift} +0 -0
- /package/app/Sources/{HUDLeftBar.swift → Core/Overlays/HUD/HUDLeftBar.swift} +0 -0
- /package/app/Sources/{HUDMinimap.swift → Core/Overlays/HUD/HUDMinimap.swift} +0 -0
- /package/app/Sources/{HUDState.swift → Core/Overlays/HUD/HUDState.swift} +0 -0
- /package/app/Sources/{HUDTopBar.swift → Core/Overlays/HUD/HUDTopBar.swift} +0 -0
- /package/app/Sources/{LayerBezel.swift → Core/Overlays/HUD/LayerBezel.swift} +0 -0
- /package/app/Sources/{OmniSearchState.swift → Core/Overlays/OmniSearch/OmniSearchState.swift} +0 -0
- /package/app/Sources/{DiagnosticLog.swift → Core/System/DiagnosticLog.swift} +0 -0
- /package/app/Sources/{EventBus.swift → Core/System/EventBus.swift} +0 -0
- /package/app/Sources/{ProcessModel.swift → Core/System/ProcessModel.swift} +0 -0
- /package/app/Sources/{ProcessQuery.swift → Core/System/ProcessQuery.swift} +0 -0
- /package/app/Sources/{AdvisorLearningStore.swift → Core/Voice/AdvisorLearningStore.swift} +0 -0
- /package/app/Sources/{AgentSession.swift → Core/Voice/AgentSession.swift} +0 -0
- /package/app/Sources/{AudioProvider.swift → Core/Voice/AudioProvider.swift} +0 -0
- /package/app/Sources/{VoiceChatView.swift → Core/Voice/VoiceChatView.swift} +0 -0
- /package/app/Sources/{VoxClient.swift → Core/Voice/VoxClient.swift} +0 -0
- /package/app/Sources/{Project.swift → Core/Workspace/Project.swift} +0 -0
- /package/app/Sources/{ProjectScanner.swift → Core/Workspace/ProjectScanner.swift} +0 -0
- /package/app/Sources/{SessionLayerStore.swift → Core/Workspace/SessionLayerStore.swift} +0 -0
- /package/app/Sources/{SessionManager.swift → Core/Workspace/SessionManager.swift} +0 -0
- /package/app/Sources/{Terminal.swift → Core/Workspace/Terminal/Terminal.swift} +0 -0
- /package/app/Sources/{TerminalQuery.swift → Core/Workspace/Terminal/TerminalQuery.swift} +0 -0
- /package/app/Sources/{TerminalSynthesizer.swift → Core/Workspace/Terminal/TerminalSynthesizer.swift} +0 -0
- /package/app/Sources/{TmuxModel.swift → Core/Workspace/Tmux/TmuxModel.swift} +0 -0
- /package/app/Sources/{TmuxQuery.swift → Core/Workspace/Tmux/TmuxQuery.swift} +0 -0
- /package/app/Sources/{ActionRow.swift → UI/ActionRow.swift} +0 -0
- /package/app/Sources/{OrphanRow.swift → UI/OrphanRow.swift} +0 -0
- /package/app/Sources/{ProjectRow.swift → UI/ProjectRow.swift} +0 -0
- /package/app/Sources/{TabGroupRow.swift → UI/TabGroupRow.swift} +0 -0
- /package/app/Sources/{Theme.swift → UI/Theme.swift} +0 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import DeckKit
|
|
2
|
+
import Foundation
|
|
3
|
+
import IOKit
|
|
4
|
+
import IOKit.ps
|
|
5
|
+
|
|
6
|
+
final class SystemTelemetryMonitor {
|
|
7
|
+
static let shared = SystemTelemetryMonitor()
|
|
8
|
+
|
|
9
|
+
private struct CPUTicks {
|
|
10
|
+
var user: UInt64
|
|
11
|
+
var system: UInt64
|
|
12
|
+
var idle: UInt64
|
|
13
|
+
var nice: UInt64
|
|
14
|
+
|
|
15
|
+
var total: UInt64 {
|
|
16
|
+
user + system + idle + nice
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private struct BatterySample {
|
|
21
|
+
var percent: Double?
|
|
22
|
+
var isCharging: Bool?
|
|
23
|
+
var powerSource: String?
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private struct CoreSample {
|
|
27
|
+
var sampledAt: Date
|
|
28
|
+
var cpuLoadPercent: Double?
|
|
29
|
+
var memoryUsedPercent: Double?
|
|
30
|
+
var gpuLoadPercent: Double?
|
|
31
|
+
var thermalPressurePercent: Double?
|
|
32
|
+
var thermalState: DeckThermalState?
|
|
33
|
+
var temperatureCelsius: Double?
|
|
34
|
+
var batteryPercent: Double?
|
|
35
|
+
var isCharging: Bool?
|
|
36
|
+
var powerSource: String?
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private let lock = NSLock()
|
|
40
|
+
private var previousCPUTicks: [CPUTicks]?
|
|
41
|
+
private var cachedSample: CoreSample?
|
|
42
|
+
private var cachedAt: Date = .distantPast
|
|
43
|
+
private let minSampleInterval: TimeInterval = 0.8
|
|
44
|
+
|
|
45
|
+
private init() {}
|
|
46
|
+
|
|
47
|
+
func snapshot(windowCount: Int, sessionCount: Int) -> DeckSystemTelemetry {
|
|
48
|
+
let core = currentCoreSample()
|
|
49
|
+
return DeckSystemTelemetry(
|
|
50
|
+
sampledAt: core.sampledAt,
|
|
51
|
+
cpuLoadPercent: core.cpuLoadPercent,
|
|
52
|
+
memoryUsedPercent: core.memoryUsedPercent,
|
|
53
|
+
gpuLoadPercent: core.gpuLoadPercent,
|
|
54
|
+
thermalPressurePercent: core.thermalPressurePercent,
|
|
55
|
+
thermalState: core.thermalState,
|
|
56
|
+
temperatureCelsius: core.temperatureCelsius,
|
|
57
|
+
batteryPercent: core.batteryPercent,
|
|
58
|
+
isCharging: core.isCharging,
|
|
59
|
+
powerSource: core.powerSource,
|
|
60
|
+
windowCount: windowCount,
|
|
61
|
+
sessionCount: sessionCount
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private extension SystemTelemetryMonitor {
|
|
67
|
+
private func currentCoreSample() -> CoreSample {
|
|
68
|
+
lock.lock()
|
|
69
|
+
defer { lock.unlock() }
|
|
70
|
+
|
|
71
|
+
let now = Date()
|
|
72
|
+
if let cachedSample, now.timeIntervalSince(cachedAt) < minSampleInterval {
|
|
73
|
+
return cachedSample
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let thermal = readThermalState()
|
|
77
|
+
let battery = readBattery()
|
|
78
|
+
let sample = CoreSample(
|
|
79
|
+
sampledAt: now,
|
|
80
|
+
cpuLoadPercent: readCPULoadPercent(),
|
|
81
|
+
memoryUsedPercent: readMemoryUsedPercent(),
|
|
82
|
+
gpuLoadPercent: readGPULoadPercent(),
|
|
83
|
+
thermalPressurePercent: thermal.pressure,
|
|
84
|
+
thermalState: thermal.state,
|
|
85
|
+
temperatureCelsius: nil,
|
|
86
|
+
batteryPercent: battery.percent,
|
|
87
|
+
isCharging: battery.isCharging,
|
|
88
|
+
powerSource: battery.powerSource
|
|
89
|
+
)
|
|
90
|
+
cachedSample = sample
|
|
91
|
+
cachedAt = now
|
|
92
|
+
return sample
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func readCPULoadPercent() -> Double? {
|
|
96
|
+
var cpuInfo: processor_info_array_t?
|
|
97
|
+
var processorCount: natural_t = 0
|
|
98
|
+
var infoCount: mach_msg_type_number_t = 0
|
|
99
|
+
|
|
100
|
+
let result = host_processor_info(
|
|
101
|
+
mach_host_self(),
|
|
102
|
+
PROCESSOR_CPU_LOAD_INFO,
|
|
103
|
+
&processorCount,
|
|
104
|
+
&cpuInfo,
|
|
105
|
+
&infoCount
|
|
106
|
+
)
|
|
107
|
+
guard result == KERN_SUCCESS, let cpuInfo else {
|
|
108
|
+
return nil
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
defer {
|
|
112
|
+
let size = vm_size_t(Int(infoCount) * MemoryLayout<integer_t>.stride)
|
|
113
|
+
vm_deallocate(mach_task_self_, vm_address_t(UInt(bitPattern: cpuInfo)), size)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let statesPerCPU = Int(CPU_STATE_MAX)
|
|
117
|
+
let info = UnsafeBufferPointer(start: cpuInfo, count: Int(infoCount))
|
|
118
|
+
let ticks: [CPUTicks] = (0..<Int(processorCount)).map { index in
|
|
119
|
+
let offset = index * statesPerCPU
|
|
120
|
+
return CPUTicks(
|
|
121
|
+
user: cpuTick(info[offset + Int(CPU_STATE_USER)]),
|
|
122
|
+
system: cpuTick(info[offset + Int(CPU_STATE_SYSTEM)]),
|
|
123
|
+
idle: cpuTick(info[offset + Int(CPU_STATE_IDLE)]),
|
|
124
|
+
nice: cpuTick(info[offset + Int(CPU_STATE_NICE)])
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
guard !ticks.isEmpty else { return nil }
|
|
129
|
+
|
|
130
|
+
defer { previousCPUTicks = ticks }
|
|
131
|
+
|
|
132
|
+
guard let previousCPUTicks, previousCPUTicks.count == ticks.count else {
|
|
133
|
+
let total = ticks.reduce(UInt64(0)) { $0 + $1.total }
|
|
134
|
+
let idle = ticks.reduce(UInt64(0)) { $0 + $1.idle }
|
|
135
|
+
guard total > 0 else { return nil }
|
|
136
|
+
return clampPercent(100.0 * Double(total - idle) / Double(total))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
var busyDelta: UInt64 = 0
|
|
140
|
+
var totalDelta: UInt64 = 0
|
|
141
|
+
for (current, previous) in zip(ticks, previousCPUTicks) {
|
|
142
|
+
let total = current.total.saturatingSubtract(previous.total)
|
|
143
|
+
let idle = current.idle.saturatingSubtract(previous.idle)
|
|
144
|
+
totalDelta += total
|
|
145
|
+
busyDelta += total.saturatingSubtract(idle)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
guard totalDelta > 0 else { return nil }
|
|
149
|
+
return clampPercent(100.0 * Double(busyDelta) / Double(totalDelta))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func readMemoryUsedPercent() -> Double? {
|
|
153
|
+
var stats = vm_statistics64()
|
|
154
|
+
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.stride / MemoryLayout<integer_t>.stride)
|
|
155
|
+
|
|
156
|
+
let result = withUnsafeMutablePointer(to: &stats) {
|
|
157
|
+
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
|
158
|
+
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
guard result == KERN_SUCCESS else { return nil }
|
|
162
|
+
|
|
163
|
+
var pageSize = vm_size_t()
|
|
164
|
+
host_page_size(mach_host_self(), &pageSize)
|
|
165
|
+
|
|
166
|
+
let freePages = UInt64(stats.free_count) + UInt64(stats.speculative_count)
|
|
167
|
+
let freeBytes = freePages * UInt64(pageSize)
|
|
168
|
+
let totalBytes = ProcessInfo.processInfo.physicalMemory
|
|
169
|
+
guard totalBytes > 0 else { return nil }
|
|
170
|
+
|
|
171
|
+
let usedBytes = totalBytes > freeBytes ? totalBytes - freeBytes : 0
|
|
172
|
+
return clampPercent(100.0 * Double(usedBytes) / Double(totalBytes))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func readGPULoadPercent() -> Double? {
|
|
176
|
+
guard let matching = IOServiceMatching("IOAccelerator") else {
|
|
177
|
+
return nil
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
var iterator: io_iterator_t = 0
|
|
181
|
+
guard IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS else {
|
|
182
|
+
return nil
|
|
183
|
+
}
|
|
184
|
+
defer { IOObjectRelease(iterator) }
|
|
185
|
+
|
|
186
|
+
var values: [Double] = []
|
|
187
|
+
while true {
|
|
188
|
+
let service = IOIteratorNext(iterator)
|
|
189
|
+
if service == 0 { break }
|
|
190
|
+
defer { IOObjectRelease(service) }
|
|
191
|
+
|
|
192
|
+
guard let unmanaged = IORegistryEntryCreateCFProperty(
|
|
193
|
+
service,
|
|
194
|
+
"PerformanceStatistics" as CFString,
|
|
195
|
+
kCFAllocatorDefault,
|
|
196
|
+
0
|
|
197
|
+
) else {
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
guard let stats = unmanaged.takeRetainedValue() as? [String: Any] else {
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for key in ["Device Utilization %", "Renderer Utilization %"] {
|
|
206
|
+
if let number = stats[key] as? NSNumber {
|
|
207
|
+
values.append(number.doubleValue)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
guard !values.isEmpty else { return nil }
|
|
213
|
+
return clampPercent(values.reduce(0, +) / Double(values.count))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func readThermalState() -> (state: DeckThermalState, pressure: Double) {
|
|
217
|
+
switch ProcessInfo.processInfo.thermalState {
|
|
218
|
+
case .nominal:
|
|
219
|
+
return (.nominal, 10)
|
|
220
|
+
case .fair:
|
|
221
|
+
return (.fair, 35)
|
|
222
|
+
case .serious:
|
|
223
|
+
return (.serious, 70)
|
|
224
|
+
case .critical:
|
|
225
|
+
return (.critical, 100)
|
|
226
|
+
@unknown default:
|
|
227
|
+
return (.nominal, 10)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private func readBattery() -> BatterySample {
|
|
232
|
+
let powerInfo = IOPSCopyPowerSourcesInfo().takeRetainedValue()
|
|
233
|
+
let sourceList = IOPSCopyPowerSourcesList(powerInfo).takeRetainedValue() as [CFTypeRef]
|
|
234
|
+
|
|
235
|
+
for source in sourceList {
|
|
236
|
+
guard let description = IOPSGetPowerSourceDescription(powerInfo, source)?
|
|
237
|
+
.takeUnretainedValue() as? [String: Any] else {
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let current = description[kIOPSCurrentCapacityKey] as? Int
|
|
242
|
+
let max = description[kIOPSMaxCapacityKey] as? Int
|
|
243
|
+
let percent = current.flatMap { current in
|
|
244
|
+
max.flatMap { maxValue -> Double? in
|
|
245
|
+
guard maxValue > 0 else { return nil }
|
|
246
|
+
return clampPercent(100.0 * Double(current) / Double(maxValue))
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return BatterySample(
|
|
251
|
+
percent: percent,
|
|
252
|
+
isCharging: description[kIOPSIsChargingKey] as? Bool,
|
|
253
|
+
powerSource: description[kIOPSPowerSourceStateKey] as? String
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return BatterySample(percent: nil, isCharging: nil, powerSource: nil)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
func clampPercent(_ value: Double) -> Double {
|
|
261
|
+
max(0, min(100, value))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
func cpuTick(_ value: integer_t) -> UInt64 {
|
|
265
|
+
UInt64(UInt32(bitPattern: value))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private extension UInt64 {
|
|
270
|
+
func saturatingSubtract(_ other: UInt64) -> UInt64 {
|
|
271
|
+
self > other ? self - other : 0
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -43,7 +43,14 @@ final class HandsOffSession: ObservableObject {
|
|
|
43
43
|
case thinking
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
@Published var state: State = .idle
|
|
46
|
+
@Published var state: State = .idle {
|
|
47
|
+
didSet {
|
|
48
|
+
if state != oldValue {
|
|
49
|
+
stateChangedAt = Date()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
@Published private(set) var stateChangedAt: Date = Date()
|
|
47
54
|
@Published var lastTranscript: String?
|
|
48
55
|
@Published var lastResponse: String?
|
|
49
56
|
@Published var audibleFeedbackEnabled: Bool = false
|
|
@@ -58,6 +65,7 @@ final class HandsOffSession: ObservableObject {
|
|
|
58
65
|
let frame: WindowFrame
|
|
59
66
|
}
|
|
60
67
|
private(set) var frameHistory: [FrameSnapshot] = []
|
|
68
|
+
private(set) var frameHistoryUpdatedAt: Date?
|
|
61
69
|
|
|
62
70
|
/// Snapshot current frames for all windows that are about to be moved.
|
|
63
71
|
/// Stores frames in CG/AX coordinates (top-left origin) for direct use with batchRestoreWindows.
|
|
@@ -77,10 +85,12 @@ final class HandsOffSession: ObservableObject {
|
|
|
77
85
|
break
|
|
78
86
|
}
|
|
79
87
|
}
|
|
88
|
+
frameHistoryUpdatedAt = frameHistory.isEmpty ? nil : Date()
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
func clearFrameHistory() {
|
|
83
92
|
frameHistory.removeAll()
|
|
93
|
+
frameHistoryUpdatedAt = nil
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
/// Running chat log — visible in the voice chat panel. Persists across turns.
|
|
@@ -327,7 +337,9 @@ final class HandsOffSession: ObservableObject {
|
|
|
327
337
|
}
|
|
328
338
|
}
|
|
329
339
|
|
|
330
|
-
// Single dispatch — all @Published mutations in one block
|
|
340
|
+
// Single dispatch — all @Published mutations in one block.
|
|
341
|
+
// The pending callback also mutates @Published state, so it must
|
|
342
|
+
// run on main with the rest of the turn completion.
|
|
331
343
|
DispatchQueue.main.async { [weak self] in
|
|
332
344
|
guard let self else { return }
|
|
333
345
|
if let spoken { self.lastResponse = spoken }
|
|
@@ -342,9 +354,8 @@ final class HandsOffSession: ObservableObject {
|
|
|
342
354
|
self.executeActions(actions)
|
|
343
355
|
}
|
|
344
356
|
self.state = .idle
|
|
357
|
+
cb?(json)
|
|
345
358
|
}
|
|
346
|
-
|
|
347
|
-
cb?(json)
|
|
348
359
|
}
|
|
349
360
|
}
|
|
350
361
|
|
|
@@ -63,6 +63,273 @@ struct LayoutConfig: Codable {
|
|
|
63
63
|
struct GridFile: Codable {
|
|
64
64
|
let presets: [String: GridPreset]?
|
|
65
65
|
let layouts: [String: LayoutConfig]?
|
|
66
|
+
let snapZones: SnapZonesConfig?
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
enum SnapModifierKey: String, Codable, Equatable {
|
|
70
|
+
case command
|
|
71
|
+
case option
|
|
72
|
+
case control
|
|
73
|
+
case shift
|
|
74
|
+
|
|
75
|
+
var eventFlags: NSEvent.ModifierFlags {
|
|
76
|
+
switch self {
|
|
77
|
+
case .command:
|
|
78
|
+
return .command
|
|
79
|
+
case .option:
|
|
80
|
+
return .option
|
|
81
|
+
case .control:
|
|
82
|
+
return .control
|
|
83
|
+
case .shift:
|
|
84
|
+
return .shift
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
var cgEventFlags: CGEventFlags {
|
|
89
|
+
switch self {
|
|
90
|
+
case .command:
|
|
91
|
+
return .maskCommand
|
|
92
|
+
case .option:
|
|
93
|
+
return .maskAlternate
|
|
94
|
+
case .control:
|
|
95
|
+
return .maskControl
|
|
96
|
+
case .shift:
|
|
97
|
+
return .maskShift
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
var label: String {
|
|
102
|
+
rawValue.capitalized
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
enum SnapZoneTriggerSpec: Codable, Equatable {
|
|
107
|
+
case named(String)
|
|
108
|
+
case fractions(FractionalPlacement)
|
|
109
|
+
|
|
110
|
+
init(from decoder: Decoder) throws {
|
|
111
|
+
let container = try decoder.singleValueContainer()
|
|
112
|
+
if let named = try? container.decode(String.self) {
|
|
113
|
+
self = .named(named)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let preset = try container.decode(GridPreset.self)
|
|
118
|
+
guard let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) else {
|
|
119
|
+
throw DecodingError.dataCorruptedError(
|
|
120
|
+
in: container,
|
|
121
|
+
debugDescription: "snap zone trigger fractions must stay within 0...1"
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
self = .fractions(placement)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func encode(to encoder: Encoder) throws {
|
|
128
|
+
var container = encoder.singleValueContainer()
|
|
129
|
+
switch self {
|
|
130
|
+
case .named(let name):
|
|
131
|
+
try container.encode(name)
|
|
132
|
+
case .fractions(let placement):
|
|
133
|
+
try container.encode(GridPreset(x: placement.x, y: placement.y, w: placement.w, h: placement.h))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
enum SnapZonePlacementSpec: Codable, Equatable {
|
|
139
|
+
case named(String)
|
|
140
|
+
case fractions(FractionalPlacement)
|
|
141
|
+
|
|
142
|
+
init(from decoder: Decoder) throws {
|
|
143
|
+
let container = try decoder.singleValueContainer()
|
|
144
|
+
if let named = try? container.decode(String.self) {
|
|
145
|
+
self = .named(named)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let preset = try container.decode(GridPreset.self)
|
|
150
|
+
guard let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) else {
|
|
151
|
+
throw DecodingError.dataCorruptedError(
|
|
152
|
+
in: container,
|
|
153
|
+
debugDescription: "snap zone placement fractions must stay within 0...1"
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
self = .fractions(placement)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func encode(to encoder: Encoder) throws {
|
|
160
|
+
var container = encoder.singleValueContainer()
|
|
161
|
+
switch self {
|
|
162
|
+
case .named(let name):
|
|
163
|
+
try container.encode(name)
|
|
164
|
+
case .fractions(let placement):
|
|
165
|
+
try container.encode(GridPreset(x: placement.x, y: placement.y, w: placement.w, h: placement.h))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
struct SnapZoneDefinition: Codable, Equatable, Identifiable {
|
|
171
|
+
let rawID: String?
|
|
172
|
+
let label: String?
|
|
173
|
+
let placement: SnapZonePlacementSpec
|
|
174
|
+
let trigger: SnapZoneTriggerSpec
|
|
175
|
+
let priority: Int?
|
|
176
|
+
|
|
177
|
+
enum CodingKeys: String, CodingKey {
|
|
178
|
+
case rawID = "id"
|
|
179
|
+
case label
|
|
180
|
+
case placement
|
|
181
|
+
case trigger
|
|
182
|
+
case priority
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
var id: String {
|
|
186
|
+
let trimmed = rawID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
187
|
+
return trimmed.isEmpty ? fallbackID : trimmed
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private var fallbackID: String {
|
|
191
|
+
switch placement {
|
|
192
|
+
case .named(let name):
|
|
193
|
+
return name
|
|
194
|
+
case .fractions(let fractions):
|
|
195
|
+
return "fractions-\(fractions.x)-\(fractions.y)-\(fractions.w)-\(fractions.h)"
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
struct SnapZonesConfig: Codable, Equatable {
|
|
201
|
+
let enabled: Bool?
|
|
202
|
+
let modifier: SnapModifierKey?
|
|
203
|
+
let zoneOpacity: Double?
|
|
204
|
+
let highlightOpacity: Double?
|
|
205
|
+
let previewOpacity: Double?
|
|
206
|
+
let cornerRadius: CGFloat?
|
|
207
|
+
let rules: [SnapZoneDefinition]?
|
|
208
|
+
|
|
209
|
+
enum CodingKeys: String, CodingKey {
|
|
210
|
+
case enabled
|
|
211
|
+
case modifier
|
|
212
|
+
case zoneOpacity
|
|
213
|
+
case highlightOpacity
|
|
214
|
+
case previewOpacity
|
|
215
|
+
case cornerRadius
|
|
216
|
+
case rules
|
|
217
|
+
case zones
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
init(
|
|
221
|
+
enabled: Bool?,
|
|
222
|
+
modifier: SnapModifierKey?,
|
|
223
|
+
zoneOpacity: Double?,
|
|
224
|
+
highlightOpacity: Double?,
|
|
225
|
+
previewOpacity: Double?,
|
|
226
|
+
cornerRadius: CGFloat?,
|
|
227
|
+
rules: [SnapZoneDefinition]?
|
|
228
|
+
) {
|
|
229
|
+
self.enabled = enabled
|
|
230
|
+
self.modifier = modifier
|
|
231
|
+
self.zoneOpacity = zoneOpacity
|
|
232
|
+
self.highlightOpacity = highlightOpacity
|
|
233
|
+
self.previewOpacity = previewOpacity
|
|
234
|
+
self.cornerRadius = cornerRadius
|
|
235
|
+
self.rules = rules
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
init(from decoder: Decoder) throws {
|
|
239
|
+
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
240
|
+
enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled)
|
|
241
|
+
modifier = try container.decodeIfPresent(SnapModifierKey.self, forKey: .modifier)
|
|
242
|
+
zoneOpacity = try container.decodeIfPresent(Double.self, forKey: .zoneOpacity)
|
|
243
|
+
highlightOpacity = try container.decodeIfPresent(Double.self, forKey: .highlightOpacity)
|
|
244
|
+
previewOpacity = try container.decodeIfPresent(Double.self, forKey: .previewOpacity)
|
|
245
|
+
cornerRadius = try container.decodeIfPresent(CGFloat.self, forKey: .cornerRadius)
|
|
246
|
+
let decodedRules = try container.decodeIfPresent([SnapZoneDefinition].self, forKey: .rules)
|
|
247
|
+
let decodedZones = try container.decodeIfPresent([SnapZoneDefinition].self, forKey: .zones)
|
|
248
|
+
rules = decodedRules ?? decodedZones
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func encode(to encoder: Encoder) throws {
|
|
252
|
+
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
253
|
+
try container.encodeIfPresent(enabled, forKey: .enabled)
|
|
254
|
+
try container.encodeIfPresent(modifier, forKey: .modifier)
|
|
255
|
+
try container.encodeIfPresent(zoneOpacity, forKey: .zoneOpacity)
|
|
256
|
+
try container.encodeIfPresent(highlightOpacity, forKey: .highlightOpacity)
|
|
257
|
+
try container.encodeIfPresent(previewOpacity, forKey: .previewOpacity)
|
|
258
|
+
try container.encodeIfPresent(cornerRadius, forKey: .cornerRadius)
|
|
259
|
+
try container.encodeIfPresent(rules, forKey: .rules)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
static let defaults = SnapZonesConfig(
|
|
263
|
+
enabled: true,
|
|
264
|
+
modifier: .command,
|
|
265
|
+
zoneOpacity: 0.10,
|
|
266
|
+
highlightOpacity: 0.22,
|
|
267
|
+
previewOpacity: 0.18,
|
|
268
|
+
cornerRadius: 18,
|
|
269
|
+
rules: [
|
|
270
|
+
SnapZoneDefinition(
|
|
271
|
+
rawID: "top-left",
|
|
272
|
+
label: "Top Left",
|
|
273
|
+
placement: .named("top-left"),
|
|
274
|
+
trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.00, w: 0.24, h: 0.18)!),
|
|
275
|
+
priority: 40
|
|
276
|
+
),
|
|
277
|
+
SnapZoneDefinition(
|
|
278
|
+
rawID: "maximize",
|
|
279
|
+
label: "Maximize",
|
|
280
|
+
placement: .named("maximize"),
|
|
281
|
+
trigger: .fractions(FractionalPlacement(x: 0.24, y: 0.00, w: 0.52, h: 0.12)!),
|
|
282
|
+
priority: 20
|
|
283
|
+
),
|
|
284
|
+
SnapZoneDefinition(
|
|
285
|
+
rawID: "top-right",
|
|
286
|
+
label: "Top Right",
|
|
287
|
+
placement: .named("top-right"),
|
|
288
|
+
trigger: .fractions(FractionalPlacement(x: 0.76, y: 0.00, w: 0.24, h: 0.18)!),
|
|
289
|
+
priority: 40
|
|
290
|
+
),
|
|
291
|
+
SnapZoneDefinition(
|
|
292
|
+
rawID: "left",
|
|
293
|
+
label: "Left",
|
|
294
|
+
placement: .named("left"),
|
|
295
|
+
trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.18, w: 0.12, h: 0.64)!),
|
|
296
|
+
priority: 10
|
|
297
|
+
),
|
|
298
|
+
SnapZoneDefinition(
|
|
299
|
+
rawID: "right",
|
|
300
|
+
label: "Right",
|
|
301
|
+
placement: .named("right"),
|
|
302
|
+
trigger: .fractions(FractionalPlacement(x: 0.88, y: 0.18, w: 0.12, h: 0.64)!),
|
|
303
|
+
priority: 10
|
|
304
|
+
),
|
|
305
|
+
SnapZoneDefinition(
|
|
306
|
+
rawID: "bottom-left",
|
|
307
|
+
label: "Bottom Left",
|
|
308
|
+
placement: .named("bottom-left"),
|
|
309
|
+
trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.82, w: 0.24, h: 0.18)!),
|
|
310
|
+
priority: 40
|
|
311
|
+
),
|
|
312
|
+
SnapZoneDefinition(
|
|
313
|
+
rawID: "bottom-right",
|
|
314
|
+
label: "Bottom Right",
|
|
315
|
+
placement: .named("bottom-right"),
|
|
316
|
+
trigger: .fractions(FractionalPlacement(x: 0.76, y: 0.82, w: 0.24, h: 0.18)!),
|
|
317
|
+
priority: 40
|
|
318
|
+
),
|
|
319
|
+
]
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
func merged(over defaults: SnapZonesConfig = .defaults) -> SnapZonesConfig {
|
|
323
|
+
SnapZonesConfig(
|
|
324
|
+
enabled: enabled ?? defaults.enabled,
|
|
325
|
+
modifier: modifier ?? defaults.modifier,
|
|
326
|
+
zoneOpacity: zoneOpacity ?? defaults.zoneOpacity,
|
|
327
|
+
highlightOpacity: highlightOpacity ?? defaults.highlightOpacity,
|
|
328
|
+
previewOpacity: previewOpacity ?? defaults.previewOpacity,
|
|
329
|
+
cornerRadius: cornerRadius ?? defaults.cornerRadius,
|
|
330
|
+
rules: rules ?? defaults.rules
|
|
331
|
+
)
|
|
332
|
+
}
|
|
66
333
|
}
|
|
67
334
|
|
|
68
335
|
// MARK: - Manager
|
|
@@ -75,9 +342,11 @@ class WorkspaceManager: ObservableObject {
|
|
|
75
342
|
@Published var isSwitching: Bool = false
|
|
76
343
|
@Published var gridPresets: [String: GridPreset] = [:]
|
|
77
344
|
@Published var gridLayouts: [String: LayoutConfig] = [:]
|
|
345
|
+
@Published var snapZonesConfig: SnapZonesConfig = .defaults
|
|
78
346
|
|
|
79
347
|
private let configPath: String
|
|
80
348
|
private let gridConfigPath: String
|
|
349
|
+
private let snapZonesConfigPath: String
|
|
81
350
|
private var tmuxPath: String { TmuxQuery.resolvedPath ?? "/opt/homebrew/bin/tmux" }
|
|
82
351
|
private let activeLayerKey = "lattices.activeLayerIndex"
|
|
83
352
|
|
|
@@ -85,6 +354,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
85
354
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
86
355
|
self.configPath = (home as NSString).appendingPathComponent(".lattices/workspace.json")
|
|
87
356
|
self.gridConfigPath = (home as NSString).appendingPathComponent(".lattices/grid.json")
|
|
357
|
+
self.snapZonesConfigPath = (home as NSString).appendingPathComponent(".lattices/snap-zones.json")
|
|
88
358
|
self.activeLayerIndex = UserDefaults.standard.integer(forKey: activeLayerKey)
|
|
89
359
|
loadConfig()
|
|
90
360
|
loadGridConfig()
|
|
@@ -137,6 +407,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
137
407
|
func loadGridConfig() {
|
|
138
408
|
var presets: [String: GridPreset] = [:]
|
|
139
409
|
var layouts: [String: LayoutConfig] = [:]
|
|
410
|
+
var snapZones = SnapZonesConfig.defaults
|
|
140
411
|
|
|
141
412
|
// Load global ~/.lattices/grid.json
|
|
142
413
|
if FileManager.default.fileExists(atPath: gridConfigPath),
|
|
@@ -145,11 +416,24 @@ class WorkspaceManager: ObservableObject {
|
|
|
145
416
|
let gridFile = try JSONDecoder().decode(GridFile.self, from: data)
|
|
146
417
|
if let p = gridFile.presets { presets.merge(p) { _, new in new } }
|
|
147
418
|
if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
|
|
419
|
+
if let snap = gridFile.snapZones {
|
|
420
|
+
snapZones = snap.merged(over: snapZones)
|
|
421
|
+
}
|
|
148
422
|
} catch {
|
|
149
423
|
DiagnosticLog.shared.error("WorkspaceManager: failed to decode grid.json — \(error.localizedDescription)")
|
|
150
424
|
}
|
|
151
425
|
}
|
|
152
426
|
|
|
427
|
+
if FileManager.default.fileExists(atPath: snapZonesConfigPath),
|
|
428
|
+
let data = FileManager.default.contents(atPath: snapZonesConfigPath) {
|
|
429
|
+
do {
|
|
430
|
+
let config = try JSONDecoder().decode(SnapZonesConfig.self, from: data)
|
|
431
|
+
snapZones = config.merged(over: snapZones)
|
|
432
|
+
} catch {
|
|
433
|
+
DiagnosticLog.shared.error("WorkspaceManager: failed to decode snap-zones.json — \(error.localizedDescription)")
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
153
437
|
// Merge per-project .lattices.json "grid" section on top
|
|
154
438
|
let projectGridPath = ".lattices.json"
|
|
155
439
|
if FileManager.default.fileExists(atPath: projectGridPath),
|
|
@@ -161,6 +445,9 @@ class WorkspaceManager: ObservableObject {
|
|
|
161
445
|
let gridFile = try JSONDecoder().decode(GridFile.self, from: gridData)
|
|
162
446
|
if let p = gridFile.presets { presets.merge(p) { _, new in new } }
|
|
163
447
|
if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
|
|
448
|
+
if let snap = gridFile.snapZones {
|
|
449
|
+
snapZones = snap.merged(over: snapZones)
|
|
450
|
+
}
|
|
164
451
|
}
|
|
165
452
|
} catch {
|
|
166
453
|
DiagnosticLog.shared.error("WorkspaceManager: failed to decode .lattices.json grid — \(error.localizedDescription)")
|
|
@@ -169,6 +456,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
169
456
|
|
|
170
457
|
self.gridPresets = presets
|
|
171
458
|
self.gridLayouts = layouts
|
|
459
|
+
self.snapZonesConfig = snapZones
|
|
172
460
|
}
|
|
173
461
|
|
|
174
462
|
/// Resolve a tile string to fractions: check user presets first, then built-in TilePosition
|