@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.
Files changed (40) hide show
  1. package/README.md +8 -6
  2. package/app/Info.plist +13 -2
  3. package/app/Lattices.app/Contents/Info.plist +13 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Sources/AppShell/App.swift +7 -1
  6. package/app/Sources/AppShell/AppDelegate.swift +65 -1
  7. package/app/Sources/AppShell/AppShellView.swift +10 -0
  8. package/app/Sources/AppShell/CliActionLauncher.swift +2 -2
  9. package/app/Sources/AppShell/KeyRecorderView.swift +1 -1
  10. package/app/Sources/AppShell/MainView.swift +1 -1
  11. package/app/Sources/AppShell/Preferences.swift +29 -3
  12. package/app/Sources/AppShell/SettingsView.swift +525 -60
  13. package/app/Sources/AppShell/SettingsWindow.swift +4 -0
  14. package/app/Sources/Core/Actions/HotkeyStore.swift +13 -1
  15. package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +23 -7
  16. package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +35 -0
  17. package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +1 -1
  18. package/app/Sources/Core/Desktop/WindowTiler.swift +0 -2
  19. package/app/Sources/Core/Input/KeyboardRemapConfig.swift +69 -0
  20. package/app/Sources/Core/Input/KeyboardRemapController.swift +184 -0
  21. package/app/Sources/Core/Input/KeyboardRemapStore.swift +84 -0
  22. package/app/Sources/Core/Overlays/CommandMode/CommandModeState.swift +101 -0
  23. package/app/Sources/Core/Overlays/CommandMode/CommandModeView.swift +113 -4
  24. package/app/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +9 -5
  25. package/app/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +1 -0
  26. package/app/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -1
  27. package/app/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +20 -7
  28. package/app/Sources/Core/Workspace/SessionManager.swift +1 -1
  29. package/app/Sources/Core/Workspace/WorkspaceManager.swift +62 -7
  30. package/bin/lattices-app.ts +11 -0
  31. package/bin/lattices-dev +11 -0
  32. package/bin/lattices.ts +57 -17
  33. package/docs/app.md +30 -2
  34. package/docs/companion-deck.md +29 -0
  35. package/docs/concepts.md +5 -5
  36. package/docs/config.md +34 -9
  37. package/docs/layers.md +1 -1
  38. package/docs/overview.md +1 -1
  39. package/docs/quickstart.md +4 -4
  40. 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
- Text(isLattices ? "●" : "•")
839
- .font(.system(size: 7))
840
- .foregroundColor(isLattices ? Palette.running : (isSelected ? Palette.text : Palette.textDim))
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
- dismiss()
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
- RoundedRectangle(cornerRadius: 6)
1861
+ canvasShape
1859
1862
  .fill(Color.black.opacity(0.25))
1860
- RoundedRectangle(cornerRadius: 6)
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 && !event.isARepeat {
2746
- isSpaceHeld = true
2747
- NSCursor.openHand.push()
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, let editor = controller.editor {
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
@@ -13,7 +13,7 @@ enum SessionManager {
13
13
  }
14
14
  terminal.focusOrAttach(session: project.sessionName)
15
15
  } else {
16
- terminal.launch(command: latticesPath, in: project.path)
16
+ terminal.launch(command: "\(latticesPath) start", in: project.path)
17
17
  }
18
18
  }
19
19
 
@@ -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 {
@@ -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>