@lattices/cli 0.4.6 → 0.4.8
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 +8 -6
- package/app/Info.plist +13 -2
- package/app/Lattices.app/Contents/Info.plist +13 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Sources/AppShell/App.swift +7 -1
- package/app/Sources/AppShell/AppDelegate.swift +65 -1
- package/app/Sources/AppShell/AppShellView.swift +10 -0
- package/app/Sources/AppShell/CliActionLauncher.swift +2 -2
- package/app/Sources/AppShell/KeyRecorderView.swift +1 -1
- package/app/Sources/AppShell/MainView.swift +1 -1
- package/app/Sources/AppShell/Preferences.swift +29 -3
- package/app/Sources/AppShell/SettingsView.swift +525 -60
- package/app/Sources/AppShell/SettingsWindow.swift +4 -0
- package/app/Sources/Core/Actions/HotkeyStore.swift +13 -1
- package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +23 -7
- package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +35 -0
- package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +1 -1
- package/app/Sources/Core/Desktop/WindowTiler.swift +0 -2
- package/app/Sources/Core/Input/KeyboardRemapConfig.swift +69 -0
- package/app/Sources/Core/Input/KeyboardRemapController.swift +184 -0
- package/app/Sources/Core/Input/KeyboardRemapStore.swift +84 -0
- package/app/Sources/Core/Overlays/CommandMode/CommandModeState.swift +101 -0
- package/app/Sources/Core/Overlays/CommandMode/CommandModeView.swift +113 -4
- package/app/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +9 -5
- package/app/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +1 -0
- package/app/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -1
- package/app/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +20 -7
- package/app/Sources/Core/Workspace/SessionManager.swift +1 -1
- package/app/Sources/Core/Workspace/WorkspaceManager.swift +62 -7
- package/bin/lattices-app.ts +11 -0
- package/bin/lattices-dev +11 -0
- package/bin/lattices.ts +57 -17
- package/docs/app.md +30 -2
- package/docs/companion-deck.md +29 -0
- package/docs/concepts.md +5 -5
- package/docs/config.md +34 -9
- package/docs/layers.md +1 -1
- package/docs/overview.md +1 -1
- package/docs/quickstart.md +4 -4
- package/package.json +1 -1
|
@@ -45,6 +45,11 @@ enum DesktopInventoryMode: Equatable {
|
|
|
45
45
|
case screenMap // m → interactive screen map editor
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
enum CommandModeLaunchMode: Equatable {
|
|
49
|
+
case normal
|
|
50
|
+
case organize(appName: String?)
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
// DisplayGeometry, ScreenMapWindowEntry, ScreenMapEditorState, ScreenMapActionLog
|
|
49
54
|
// are defined in ScreenMapState.swift
|
|
50
55
|
// MARK: - Filter Presets
|
|
@@ -132,10 +137,43 @@ final class CommandModeState: ObservableObject {
|
|
|
132
137
|
|
|
133
138
|
var onDismiss: (() -> Void)?
|
|
134
139
|
var onPanelResize: ((_ width: CGFloat, _ height: CGFloat) -> Void)?
|
|
140
|
+
private let launchMode: CommandModeLaunchMode
|
|
135
141
|
|
|
136
142
|
/// Tracks the last item navigated to, for consistent Shift+arrow multi-select
|
|
137
143
|
private var cursorWindowId: UInt32?
|
|
138
144
|
|
|
145
|
+
init(launchMode: CommandModeLaunchMode = .normal) {
|
|
146
|
+
self.launchMode = launchMode
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
var isOrganizeFlow: Bool {
|
|
150
|
+
if case .organize = launchMode { return true }
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
var organizeSeedAppName: String? {
|
|
155
|
+
if case .organize(let appName) = launchMode { return appName }
|
|
156
|
+
return nil
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
var organizeSelectionSummary: String {
|
|
160
|
+
let count = selectedWindowIds.count
|
|
161
|
+
if let appName = organizeSeedAppName, !appName.isEmpty {
|
|
162
|
+
return "\(count) \(appName) window\(count == 1 ? "" : "s") selected"
|
|
163
|
+
}
|
|
164
|
+
return "\(count) window\(count == 1 ? "" : "s") selected"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
var organizeGuidance: String {
|
|
168
|
+
if selectedWindowIds.count > 1 {
|
|
169
|
+
return "Press D to organize. Cmd-click adds or removes windows. Shift-click extends the selection."
|
|
170
|
+
}
|
|
171
|
+
if selectedWindowIds.count == 1 {
|
|
172
|
+
return "Cmd-click another window to add it, then press D to organize the set."
|
|
173
|
+
}
|
|
174
|
+
return "Click windows to select them. Cmd-click adds or removes windows, and D organizes the selection."
|
|
175
|
+
}
|
|
176
|
+
|
|
139
177
|
// MARK: - Selection Helpers
|
|
140
178
|
|
|
141
179
|
/// Backwards-compat: returns single selected ID (first element)
|
|
@@ -369,6 +407,7 @@ final class CommandModeState: ObservableObject {
|
|
|
369
407
|
desktopMode = .browsing
|
|
370
408
|
gridPreviewPlacement = nil
|
|
371
409
|
phase = .desktopInventory
|
|
410
|
+
configureLaunchMode()
|
|
372
411
|
// Don't call onPanelResize here — caller handles initial sizing
|
|
373
412
|
}
|
|
374
413
|
|
|
@@ -576,6 +615,20 @@ final class CommandModeState: ObservableObject {
|
|
|
576
615
|
}
|
|
577
616
|
return true
|
|
578
617
|
|
|
618
|
+
case 2: // d → distribute selected
|
|
619
|
+
if isSearching && selectedWindowIds.isEmpty { return false }
|
|
620
|
+
if isSearching { deactivateSearch() }
|
|
621
|
+
guard !selectedWindowIds.isEmpty else {
|
|
622
|
+
flash("Select 2+ windows to organize")
|
|
623
|
+
return true
|
|
624
|
+
}
|
|
625
|
+
guard selectedWindowIds.count > 1 else {
|
|
626
|
+
flash("Add another window, then press D to organize")
|
|
627
|
+
return true
|
|
628
|
+
}
|
|
629
|
+
distributeSelected()
|
|
630
|
+
return true
|
|
631
|
+
|
|
579
632
|
case 46: // m → screen map editor (standalone window)
|
|
580
633
|
if isSearching { deactivateSearch() }
|
|
581
634
|
ScreenMapWindowController.shared.show()
|
|
@@ -1050,6 +1103,54 @@ final class CommandModeState: ObservableObject {
|
|
|
1050
1103
|
}
|
|
1051
1104
|
}
|
|
1052
1105
|
|
|
1106
|
+
private func configureLaunchMode() {
|
|
1107
|
+
switch launchMode {
|
|
1108
|
+
case .normal:
|
|
1109
|
+
return
|
|
1110
|
+
case .organize(let appName):
|
|
1111
|
+
activePreset = .currentSpace
|
|
1112
|
+
seedSelectionForOrganization(appName: appName)
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
private func seedSelectionForOrganization(appName: String?) {
|
|
1117
|
+
let visibleWindows = flatWindowList.filter(\.isOnScreen)
|
|
1118
|
+
let targetApp = appName ?? visibleWindows.first?.appName
|
|
1119
|
+
let initialSelection = visibleWindows.filter { window in
|
|
1120
|
+
guard let name = window.appName, let targetApp else { return false }
|
|
1121
|
+
return name.localizedCaseInsensitiveCompare(targetApp) == .orderedSame
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if initialSelection.isEmpty {
|
|
1125
|
+
flash("Select windows to organize. Cmd-click adds or removes windows; D distributes.")
|
|
1126
|
+
return
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
selectedWindowIds = Set(initialSelection.map(\.id))
|
|
1130
|
+
cursorWindowId = initialSelection.first?.id
|
|
1131
|
+
|
|
1132
|
+
if let targetApp {
|
|
1133
|
+
if initialSelection.count > 1 {
|
|
1134
|
+
flash("Selected \(initialSelection.count) \(targetApp) windows. Press D to organize.")
|
|
1135
|
+
} else {
|
|
1136
|
+
flash("Selected the \(targetApp) window. Cmd-click more windows, then press D.")
|
|
1137
|
+
}
|
|
1138
|
+
} else if initialSelection.count > 1 {
|
|
1139
|
+
flash("Selected \(initialSelection.count) windows. Press D to organize.")
|
|
1140
|
+
} else {
|
|
1141
|
+
flash("Selected 1 window. Cmd-click more windows, then press D.")
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
DispatchQueue.main.async { [weak self] in
|
|
1145
|
+
guard let self = self else { return }
|
|
1146
|
+
if self.selectedWindowIds.count > 1 {
|
|
1147
|
+
self.highlightAllSelected()
|
|
1148
|
+
} else {
|
|
1149
|
+
self.highlightSelectedWindow()
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1053
1154
|
// MARK: - Inventory Builder
|
|
1054
1155
|
|
|
1055
1156
|
private func buildInventory() -> CommandModeInventory {
|
|
@@ -200,10 +200,17 @@ struct CommandModeView: View {
|
|
|
200
200
|
|
|
201
201
|
private var header: some View {
|
|
202
202
|
HStack {
|
|
203
|
-
Text(isDesktopInventory ? "DESKTOP INVENTORY" : "COMMAND MODE")
|
|
203
|
+
Text(isDesktopInventory ? (state.isOrganizeFlow ? "ORGANIZE WINDOWS" : "DESKTOP INVENTORY") : "COMMAND MODE")
|
|
204
204
|
.font(Typo.monoBold(11))
|
|
205
205
|
.foregroundColor(Palette.text)
|
|
206
206
|
|
|
207
|
+
if isDesktopInventory && state.isOrganizeFlow {
|
|
208
|
+
bannerBadge("Current Space", tone: .neutral)
|
|
209
|
+
if let appName = state.organizeSeedAppName, !appName.isEmpty {
|
|
210
|
+
bannerBadge(appName, tone: .accent)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
207
214
|
if isDesktopInventory {
|
|
208
215
|
Button(action: { state.copyInventoryToClipboard() }) {
|
|
209
216
|
HStack(spacing: 3) {
|
|
@@ -275,6 +282,11 @@ struct CommandModeView: View {
|
|
|
275
282
|
|
|
276
283
|
private func desktopInventoryContent(contentWidth: CGFloat) -> some View {
|
|
277
284
|
VStack(spacing: 0) {
|
|
285
|
+
if state.isOrganizeFlow {
|
|
286
|
+
organizeBanner
|
|
287
|
+
divider
|
|
288
|
+
}
|
|
289
|
+
|
|
278
290
|
if state.isSearching {
|
|
279
291
|
searchBar
|
|
280
292
|
} else {
|
|
@@ -358,6 +370,33 @@ struct CommandModeView: View {
|
|
|
358
370
|
}
|
|
359
371
|
}
|
|
360
372
|
|
|
373
|
+
private var organizeBanner: some View {
|
|
374
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
375
|
+
HStack(alignment: .center, spacing: 8) {
|
|
376
|
+
Image(systemName: "rectangle.3.group")
|
|
377
|
+
.font(.system(size: 11, weight: .medium))
|
|
378
|
+
.foregroundColor(Palette.running)
|
|
379
|
+
Text(state.organizeSelectionSummary)
|
|
380
|
+
.font(Typo.monoBold(10))
|
|
381
|
+
.foregroundColor(Palette.text)
|
|
382
|
+
Spacer()
|
|
383
|
+
if state.selectedWindowIds.count > 1 {
|
|
384
|
+
bannerBadge("Ready", tone: .accent)
|
|
385
|
+
} else {
|
|
386
|
+
bannerBadge("Add More", tone: .neutral)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
Text(state.organizeGuidance)
|
|
391
|
+
.font(Typo.mono(10))
|
|
392
|
+
.foregroundColor(Palette.textDim)
|
|
393
|
+
.lineLimit(2)
|
|
394
|
+
}
|
|
395
|
+
.padding(.horizontal, 14)
|
|
396
|
+
.padding(.vertical, 8)
|
|
397
|
+
.background(Palette.running.opacity(0.06))
|
|
398
|
+
}
|
|
399
|
+
|
|
361
400
|
private var filterPillBar: some View {
|
|
362
401
|
HStack(spacing: 6) {
|
|
363
402
|
ForEach(FilterPreset.allCases, id: \.rawValue) { preset in
|
|
@@ -835,9 +874,17 @@ struct CommandModeView: View {
|
|
|
835
874
|
if indented {
|
|
836
875
|
Spacer().frame(width: 8)
|
|
837
876
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
877
|
+
Group {
|
|
878
|
+
if isSelected {
|
|
879
|
+
Image(systemName: "checkmark.circle.fill")
|
|
880
|
+
.font(.system(size: 10, weight: .semibold))
|
|
881
|
+
.foregroundColor(Palette.running)
|
|
882
|
+
} else {
|
|
883
|
+
Text(isLattices ? "●" : "•")
|
|
884
|
+
.font(.system(size: 7))
|
|
885
|
+
.foregroundColor(isLattices ? Palette.running : Palette.textDim)
|
|
886
|
+
}
|
|
887
|
+
}
|
|
841
888
|
if let app = appLabel {
|
|
842
889
|
Text(app)
|
|
843
890
|
.font(Typo.monoBold(10))
|
|
@@ -1242,6 +1289,9 @@ struct CommandModeView: View {
|
|
|
1242
1289
|
chordHint(key: "↩", label: "select & front")
|
|
1243
1290
|
chordHint(key: "⌘A", label: "select all")
|
|
1244
1291
|
chordHint(key: "⇧↑↓", label: "multi-select")
|
|
1292
|
+
if state.isOrganizeFlow && state.selectedWindowIds.count > 1 {
|
|
1293
|
+
chordHint(key: "d", label: "organize")
|
|
1294
|
+
}
|
|
1245
1295
|
if !state.selectedWindowIds.isEmpty {
|
|
1246
1296
|
chordHint(key: "t", label: "tile")
|
|
1247
1297
|
}
|
|
@@ -1279,9 +1329,42 @@ struct CommandModeView: View {
|
|
|
1279
1329
|
.foregroundColor(Palette.running)
|
|
1280
1330
|
}
|
|
1281
1331
|
}
|
|
1332
|
+
} else if isDesktopInventory && state.isOrganizeFlow && state.selectedWindowIds.count > 1 {
|
|
1333
|
+
HStack(spacing: 12) {
|
|
1334
|
+
chordHint(key: "d", label: "organize")
|
|
1335
|
+
chordHint(key: "⌘-click", label: "add/remove")
|
|
1336
|
+
chordHint(key: "⇧-click", label: "range")
|
|
1337
|
+
chordHint(key: "↩", label: "front")
|
|
1338
|
+
chordHint(key: "esc", label: "cancel")
|
|
1339
|
+
Spacer()
|
|
1340
|
+
Text("\(state.selectedWindowIds.count) selected")
|
|
1341
|
+
.font(Typo.mono(9))
|
|
1342
|
+
.foregroundColor(Palette.running)
|
|
1343
|
+
}
|
|
1344
|
+
} else if isDesktopInventory && state.isOrganizeFlow && !state.selectedWindowIds.isEmpty {
|
|
1345
|
+
HStack(spacing: 12) {
|
|
1346
|
+
chordHint(key: "⌘-click", label: "add more")
|
|
1347
|
+
chordHint(key: "d", label: "need 2+")
|
|
1348
|
+
chordHint(key: "↩", label: "front")
|
|
1349
|
+
chordHint(key: "esc", label: "cancel")
|
|
1350
|
+
Spacer()
|
|
1351
|
+
Text(state.organizeSelectionSummary)
|
|
1352
|
+
.font(Typo.mono(9))
|
|
1353
|
+
.foregroundColor(Palette.textDim)
|
|
1354
|
+
}
|
|
1355
|
+
} else if isDesktopInventory && state.isOrganizeFlow {
|
|
1356
|
+
HStack(spacing: 12) {
|
|
1357
|
+
chordHint(key: "click", label: "select")
|
|
1358
|
+
chordHint(key: "⌘-click", label: "add/remove")
|
|
1359
|
+
chordHint(key: "/", label: "search")
|
|
1360
|
+
chordHint(key: "esc", label: "cancel")
|
|
1361
|
+
Spacer()
|
|
1362
|
+
}
|
|
1282
1363
|
} else if isDesktopInventory && state.selectedWindowIds.count > 1 {
|
|
1283
1364
|
// Multi-selection active
|
|
1284
1365
|
HStack(spacing: 12) {
|
|
1366
|
+
chordHint(key: "s", label: "grid preview")
|
|
1367
|
+
chordHint(key: "d", label: "distribute")
|
|
1285
1368
|
chordHint(key: "s", label: "grid preview")
|
|
1286
1369
|
chordHint(key: "↩", label: "front")
|
|
1287
1370
|
chordHint(key: "t", label: "grid region")
|
|
@@ -1302,6 +1385,7 @@ struct CommandModeView: View {
|
|
|
1302
1385
|
} else if isDesktopInventory && !state.selectedWindowIds.isEmpty {
|
|
1303
1386
|
// Single selection active — browsing hints with direct shortcuts
|
|
1304
1387
|
HStack(spacing: 12) {
|
|
1388
|
+
chordHint(key: "d", label: "organize")
|
|
1305
1389
|
chordHint(key: "s", label: "show")
|
|
1306
1390
|
chordHint(key: "↩", label: "front")
|
|
1307
1391
|
chordHint(key: "f", label: "focus+close")
|
|
@@ -1367,6 +1451,31 @@ struct CommandModeView: View {
|
|
|
1367
1451
|
}
|
|
1368
1452
|
}
|
|
1369
1453
|
|
|
1454
|
+
private enum BannerTone {
|
|
1455
|
+
case neutral
|
|
1456
|
+
case accent
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
private func bannerBadge(_ text: String, tone: BannerTone) -> some View {
|
|
1460
|
+
let foreground = tone == .accent ? Palette.running : Palette.textDim
|
|
1461
|
+
let fill = tone == .accent ? Palette.running.opacity(0.10) : Palette.surface
|
|
1462
|
+
let stroke = tone == .accent ? Palette.running.opacity(0.30) : Palette.border
|
|
1463
|
+
|
|
1464
|
+
return Text(text)
|
|
1465
|
+
.font(Typo.mono(8))
|
|
1466
|
+
.foregroundColor(foreground)
|
|
1467
|
+
.padding(.horizontal, 6)
|
|
1468
|
+
.padding(.vertical, 3)
|
|
1469
|
+
.background(
|
|
1470
|
+
RoundedRectangle(cornerRadius: 8)
|
|
1471
|
+
.fill(fill)
|
|
1472
|
+
.overlay(
|
|
1473
|
+
RoundedRectangle(cornerRadius: 8)
|
|
1474
|
+
.strokeBorder(stroke, lineWidth: 0.5)
|
|
1475
|
+
)
|
|
1476
|
+
)
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1370
1479
|
private func actionButton(key: String, label: String, action: @escaping () -> Void) -> some View {
|
|
1371
1480
|
Button(action: action) {
|
|
1372
1481
|
HStack(spacing: 4) {
|
|
@@ -43,15 +43,19 @@ final class CommandModeWindow {
|
|
|
43
43
|
|
|
44
44
|
var isVisible: Bool { isOpen }
|
|
45
45
|
|
|
46
|
-
func toggle() {
|
|
46
|
+
func toggle(launchMode: CommandModeLaunchMode = .normal) {
|
|
47
47
|
if isOpen {
|
|
48
|
-
|
|
48
|
+
if launchMode == .normal {
|
|
49
|
+
dismiss()
|
|
50
|
+
} else {
|
|
51
|
+
show(launchMode: launchMode)
|
|
52
|
+
}
|
|
49
53
|
} else {
|
|
50
|
-
show()
|
|
54
|
+
show(launchMode: launchMode)
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
func show() {
|
|
58
|
+
func show(launchMode: CommandModeLaunchMode = .normal) {
|
|
55
59
|
// Always rebuild for fresh state
|
|
56
60
|
dismiss()
|
|
57
61
|
isOpen = true
|
|
@@ -61,7 +65,7 @@ final class CommandModeWindow {
|
|
|
61
65
|
CommandPaletteWindow.shared.dismiss()
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
let state = CommandModeState()
|
|
68
|
+
let state = CommandModeState(launchMode: launchMode)
|
|
65
69
|
state.onDismiss = { [weak self] in
|
|
66
70
|
self?.dismiss()
|
|
67
71
|
}
|
|
@@ -255,6 +255,7 @@ struct CheatSheetView: View {
|
|
|
255
255
|
shortcutRow(action: .tileCenter)
|
|
256
256
|
shortcutRow(action: .tileDistribute)
|
|
257
257
|
shortcutRow(action: .tileTypeGrid)
|
|
258
|
+
shortcutRow(action: .tileOrganize)
|
|
258
259
|
|
|
259
260
|
// Hovered shortcut detail
|
|
260
261
|
if let hovered = hoveredAction, let binding = hotkeyStore.bindings[hovered] {
|
|
@@ -2035,7 +2035,6 @@ final class ScreenMapController: ObservableObject {
|
|
|
2035
2035
|
|
|
2036
2036
|
func handleKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
|
|
2037
2037
|
let diag = DiagnosticLog.shared
|
|
2038
|
-
diag.info("[ScreenMap] key: \(keyCode)")
|
|
2039
2038
|
|
|
2040
2039
|
// Tiling mode intercepts keys before anything else
|
|
2041
2040
|
if editor?.isTilingMode == true {
|
|
@@ -1805,6 +1805,7 @@ struct ScreenMapView: View {
|
|
|
1805
1805
|
let displays = editor?.displays ?? []
|
|
1806
1806
|
let zoomLevel = editor?.zoomLevel ?? 1.0
|
|
1807
1807
|
let panOffset = editor?.panOffset ?? .zero
|
|
1808
|
+
let canvasShape = RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
1808
1809
|
|
|
1809
1810
|
return GeometryReader { geo in
|
|
1810
1811
|
let metrics = CanvasMetrics(editor: editor, displays: displays, viewportSize: geo.size)
|
|
@@ -1852,12 +1853,14 @@ struct ScreenMapView: View {
|
|
|
1852
1853
|
}
|
|
1853
1854
|
.padding(8)
|
|
1854
1855
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
1856
|
+
.contentShape(canvasShape)
|
|
1857
|
+
.clipShape(canvasShape)
|
|
1855
1858
|
.clipped()
|
|
1856
1859
|
.background(
|
|
1857
1860
|
ZStack {
|
|
1858
|
-
|
|
1861
|
+
canvasShape
|
|
1859
1862
|
.fill(Color.black.opacity(0.25))
|
|
1860
|
-
|
|
1863
|
+
canvasShape
|
|
1861
1864
|
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
|
1862
1865
|
Canvas { context, size in
|
|
1863
1866
|
let spacing: CGFloat = 20
|
|
@@ -2742,11 +2745,14 @@ struct ScreenMapView: View {
|
|
|
2742
2745
|
}
|
|
2743
2746
|
// Track space key for canvas drag-to-pan
|
|
2744
2747
|
if event.keyCode == 49 && !controller.isSearchActive {
|
|
2745
|
-
if event.type == .keyDown
|
|
2746
|
-
isSpaceHeld
|
|
2747
|
-
|
|
2748
|
+
if event.type == .keyDown {
|
|
2749
|
+
if !isSpaceHeld {
|
|
2750
|
+
isSpaceHeld = true
|
|
2751
|
+
NSCursor.openHand.push()
|
|
2752
|
+
}
|
|
2748
2753
|
return nil
|
|
2749
2754
|
} else if event.type == .keyUp {
|
|
2755
|
+
guard isSpaceHeld else { return nil }
|
|
2750
2756
|
isSpaceHeld = false
|
|
2751
2757
|
spaceDragStart = nil
|
|
2752
2758
|
NSCursor.pop()
|
|
@@ -2788,16 +2794,18 @@ struct ScreenMapView: View {
|
|
|
2788
2794
|
mouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { event in
|
|
2789
2795
|
guard let eventWindow = event.window,
|
|
2790
2796
|
eventWindow === ScreenMapWindowController.shared.nsWindow else { return event }
|
|
2797
|
+
let flippedPt = flippedScreenPoint(event)
|
|
2791
2798
|
|
|
2792
2799
|
// Space+click → begin canvas pan
|
|
2793
|
-
if isSpaceHeld,
|
|
2800
|
+
if isSpaceHeld,
|
|
2801
|
+
isCanvasPoint(flippedPt),
|
|
2802
|
+
let editor = controller.editor {
|
|
2794
2803
|
spaceDragStart = event.locationInWindow
|
|
2795
2804
|
spaceDragPanStart = editor.panOffset
|
|
2796
2805
|
NSCursor.closedHand.push()
|
|
2797
2806
|
return nil
|
|
2798
2807
|
}
|
|
2799
2808
|
|
|
2800
|
-
let flippedPt = flippedScreenPoint(event)
|
|
2801
2809
|
if let editor = controller.editor,
|
|
2802
2810
|
let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor),
|
|
2803
2811
|
hoveredWindowId == hit.id {
|
|
@@ -3042,6 +3050,7 @@ struct ScreenMapView: View {
|
|
|
3042
3050
|
// MARK: - Hit Test / Coordinate Conversion
|
|
3043
3051
|
|
|
3044
3052
|
private func canvasHit(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> CanvasHit? {
|
|
3053
|
+
guard isCanvasPoint(flippedScreenPt) else { return nil }
|
|
3045
3054
|
let projection = CanvasProjection(editor: editor)
|
|
3046
3055
|
guard projection.scale > 0 else { return nil }
|
|
3047
3056
|
let canvasLocal = CGPoint(
|
|
@@ -3059,6 +3068,10 @@ struct ScreenMapView: View {
|
|
|
3059
3068
|
return nil
|
|
3060
3069
|
}
|
|
3061
3070
|
|
|
3071
|
+
private func isCanvasPoint(_ point: CGPoint) -> Bool {
|
|
3072
|
+
CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize).contains(point)
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3062
3075
|
private func detectDragMode(mapPoint: CGPoint, windowMapRect: CGRect) -> CanvasDragMode {
|
|
3063
3076
|
let w = windowMapRect.width
|
|
3064
3077
|
let h = windowMapRect.height
|
|
@@ -66,12 +66,40 @@ struct GridFile: Codable {
|
|
|
66
66
|
let snapZones: SnapZonesConfig?
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
enum SnapModifierKey: String, Codable, Equatable {
|
|
69
|
+
enum SnapModifierKey: String, Codable, Equatable, CaseIterable, Identifiable {
|
|
70
70
|
case command
|
|
71
71
|
case option
|
|
72
72
|
case control
|
|
73
73
|
case shift
|
|
74
74
|
|
|
75
|
+
var id: String { rawValue }
|
|
76
|
+
|
|
77
|
+
var label: String {
|
|
78
|
+
switch self {
|
|
79
|
+
case .command:
|
|
80
|
+
return "Command"
|
|
81
|
+
case .option:
|
|
82
|
+
return "Option"
|
|
83
|
+
case .control:
|
|
84
|
+
return "Control"
|
|
85
|
+
case .shift:
|
|
86
|
+
return "Shift"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var shortLabel: String {
|
|
91
|
+
switch self {
|
|
92
|
+
case .command:
|
|
93
|
+
return "Cmd"
|
|
94
|
+
case .option:
|
|
95
|
+
return "Opt"
|
|
96
|
+
case .control:
|
|
97
|
+
return "Ctrl"
|
|
98
|
+
case .shift:
|
|
99
|
+
return "Shift"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
75
103
|
var eventFlags: NSEvent.ModifierFlags {
|
|
76
104
|
switch self {
|
|
77
105
|
case .command:
|
|
@@ -98,9 +126,6 @@ enum SnapModifierKey: String, Codable, Equatable {
|
|
|
98
126
|
}
|
|
99
127
|
}
|
|
100
128
|
|
|
101
|
-
var label: String {
|
|
102
|
-
rawValue.capitalized
|
|
103
|
-
}
|
|
104
129
|
}
|
|
105
130
|
|
|
106
131
|
enum SnapZoneTriggerSpec: Codable, Equatable {
|
|
@@ -459,6 +484,36 @@ class WorkspaceManager: ObservableObject {
|
|
|
459
484
|
self.snapZonesConfig = snapZones
|
|
460
485
|
}
|
|
461
486
|
|
|
487
|
+
func updateSnapModifier(_ modifier: SnapModifierKey) {
|
|
488
|
+
let updated = SnapZonesConfig(
|
|
489
|
+
enabled: snapZonesConfig.enabled,
|
|
490
|
+
modifier: modifier,
|
|
491
|
+
zoneOpacity: snapZonesConfig.zoneOpacity,
|
|
492
|
+
highlightOpacity: snapZonesConfig.highlightOpacity,
|
|
493
|
+
previewOpacity: snapZonesConfig.previewOpacity,
|
|
494
|
+
cornerRadius: snapZonesConfig.cornerRadius,
|
|
495
|
+
rules: snapZonesConfig.rules
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
do {
|
|
499
|
+
let url = URL(fileURLWithPath: snapZonesConfigPath)
|
|
500
|
+
try FileManager.default.createDirectory(
|
|
501
|
+
at: url.deletingLastPathComponent(),
|
|
502
|
+
withIntermediateDirectories: true
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
let encoder = JSONEncoder()
|
|
506
|
+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
507
|
+
let data = try encoder.encode(updated)
|
|
508
|
+
try data.write(to: url, options: .atomic)
|
|
509
|
+
|
|
510
|
+
loadGridConfig()
|
|
511
|
+
DiagnosticLog.shared.info("WorkspaceManager: updated snap modifier to \(modifier.rawValue)")
|
|
512
|
+
} catch {
|
|
513
|
+
DiagnosticLog.shared.error("WorkspaceManager: failed to write snap-zones.json — \(error.localizedDescription)")
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
462
517
|
/// Resolve a tile string to fractions: check user presets first, then built-in TilePosition
|
|
463
518
|
func resolveTileFractions(_ tile: String) -> (CGFloat, CGFloat, CGFloat, CGFloat)? {
|
|
464
519
|
resolvePlacement(tile)?.fractions
|
|
@@ -500,12 +555,12 @@ class WorkspaceManager: ObservableObject {
|
|
|
500
555
|
let label = tab.label ?? (tab.path as NSString).lastPathComponent
|
|
501
556
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.4) {
|
|
502
557
|
if i == 0 {
|
|
503
|
-
terminal.launch(command: "/opt/homebrew/bin/lattices", in: tab.path)
|
|
558
|
+
terminal.launch(command: "/opt/homebrew/bin/lattices start", in: tab.path)
|
|
504
559
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
505
560
|
terminal.nameTab(label)
|
|
506
561
|
}
|
|
507
562
|
} else {
|
|
508
|
-
terminal.launchTab(command: "/opt/homebrew/bin/lattices", in: tab.path, tabName: label)
|
|
563
|
+
terminal.launchTab(command: "/opt/homebrew/bin/lattices start", in: tab.path, tabName: label)
|
|
509
564
|
}
|
|
510
565
|
}
|
|
511
566
|
}
|
|
@@ -796,7 +851,7 @@ class WorkspaceManager: ObservableObject {
|
|
|
796
851
|
diag.finish(t)
|
|
797
852
|
} else {
|
|
798
853
|
diag.info(" launch (direct): \(sessionName)")
|
|
799
|
-
terminal.launch(command: "/opt/homebrew/bin/lattices", in: path)
|
|
854
|
+
terminal.launch(command: "/opt/homebrew/bin/lattices start", in: path)
|
|
800
855
|
}
|
|
801
856
|
launchQueue.append((sessionName, position, lpScreen, {}))
|
|
802
857
|
} else {
|
package/bin/lattices-app.ts
CHANGED
|
@@ -152,6 +152,17 @@ function writeInfoPlist(): void {
|
|
|
152
152
|
<string>AppIcon</string>
|
|
153
153
|
<key>CFBundlePackageType</key>
|
|
154
154
|
<string>APPL</string>
|
|
155
|
+
<key>CFBundleURLTypes</key>
|
|
156
|
+
<array>
|
|
157
|
+
<dict>
|
|
158
|
+
<key>CFBundleURLName</key>
|
|
159
|
+
<string>com.arach.lattices</string>
|
|
160
|
+
<key>CFBundleURLSchemes</key>
|
|
161
|
+
<array>
|
|
162
|
+
<string>lattices</string>
|
|
163
|
+
</array>
|
|
164
|
+
</dict>
|
|
165
|
+
</array>
|
|
155
166
|
<key>CFBundleVersion</key>
|
|
156
167
|
<string>${version}</string>
|
|
157
168
|
<key>CFBundleShortVersionString</key>
|
package/bin/lattices-dev
CHANGED
|
@@ -78,6 +78,17 @@ write_info_plist() {
|
|
|
78
78
|
<string>AppIcon</string>
|
|
79
79
|
<key>CFBundlePackageType</key>
|
|
80
80
|
<string>APPL</string>
|
|
81
|
+
<key>CFBundleURLTypes</key>
|
|
82
|
+
<array>
|
|
83
|
+
<dict>
|
|
84
|
+
<key>CFBundleURLName</key>
|
|
85
|
+
<string>com.arach.lattices</string>
|
|
86
|
+
<key>CFBundleURLSchemes</key>
|
|
87
|
+
<array>
|
|
88
|
+
<string>lattices</string>
|
|
89
|
+
</array>
|
|
90
|
+
</dict>
|
|
91
|
+
</array>
|
|
81
92
|
<key>CFBundleVersion</key>
|
|
82
93
|
<string>$VERSION</string>
|
|
83
94
|
<key>CFBundleShortVersionString</key>
|