@lattices/cli 0.4.11 → 0.4.12

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.
@@ -26,17 +26,17 @@
26
26
  </dict>
27
27
  </array>
28
28
  <key>CFBundleVersion</key>
29
- <string>0.4.11</string>
29
+ <string>0.4.12</string>
30
30
  <key>CFBundleShortVersionString</key>
31
- <string>0.4.11</string>
31
+ <string>0.4.12</string>
32
32
  <key>LatticesBuildChannel</key>
33
33
  <string>dev</string>
34
34
  <key>LatticesBuildTrack</key>
35
35
  <string>latest</string>
36
36
  <key>LatticesBuildRevision</key>
37
- <string>9ce9e1a</string>
37
+ <string>b84f24f</string>
38
38
  <key>LatticesBuildTimestamp</key>
39
- <string>2026-05-04T15:09:03Z</string>
39
+ <string>2026-05-04T16:17:39Z</string>
40
40
  <key>LSMinimumSystemVersion</key>
41
41
  <string>13.0</string>
42
42
  <key>LSUIElement</key>
@@ -93,6 +93,7 @@ final class MouseGestureController: ObservableObject {
93
93
 
94
94
  private var eventTap: CFMachPort?
95
95
  private var runLoopSource: CFRunLoopSource?
96
+ private var installedEventMask: CGEventMask = 0
96
97
  private var session: GestureSession?
97
98
  private var retainedOverlays: [ObjectIdentifier: MouseGestureOverlay] = [:]
98
99
  private var staleSessionTimer: Timer?
@@ -106,6 +107,9 @@ final class MouseGestureController: ObservableObject {
106
107
  let buttonNumber: Int64
107
108
  let startPoint: CGPoint
108
109
  let nativeClickPassthrough: Bool
110
+ var nativeClickBalanced: Bool
111
+ let dragThreshold: CGFloat
112
+ let axisBias: CGFloat
109
113
  let startedAt: CFAbsoluteTime
110
114
  }
111
115
 
@@ -139,14 +143,11 @@ final class MouseGestureController: ObservableObject {
139
143
  return tapTrackingState
140
144
  }
141
145
 
142
- private func currentTrackingButton() -> Int64? {
143
- currentTrackingState()?.buttonNumber
144
- }
145
-
146
146
  private func setTrackingButton(
147
147
  _ value: Int64?,
148
148
  startPoint: CGPoint = .zero,
149
- nativeClickPassthrough: Bool = false
149
+ nativeClickPassthrough: Bool = false,
150
+ tuning: MouseShortcutTuning = .defaults
150
151
  ) {
151
152
  trackingLock.lock()
152
153
  if let value {
@@ -154,6 +155,9 @@ final class MouseGestureController: ObservableObject {
154
155
  buttonNumber: value,
155
156
  startPoint: startPoint,
156
157
  nativeClickPassthrough: nativeClickPassthrough,
158
+ nativeClickBalanced: !nativeClickPassthrough,
159
+ dragThreshold: tuning.dragThreshold,
160
+ axisBias: tuning.axisBias,
157
161
  startedAt: CFAbsoluteTimeGetCurrent()
158
162
  )
159
163
  } else {
@@ -162,6 +166,21 @@ final class MouseGestureController: ObservableObject {
162
166
  trackingLock.unlock()
163
167
  }
164
168
 
169
+ private func markNativeClickBalanced(buttonNumber: Int64) -> Bool {
170
+ trackingLock.lock()
171
+ defer { trackingLock.unlock() }
172
+ guard var state = tapTrackingState,
173
+ state.buttonNumber == buttonNumber,
174
+ state.nativeClickPassthrough,
175
+ !state.nativeClickBalanced else {
176
+ return false
177
+ }
178
+
179
+ state.nativeClickBalanced = true
180
+ tapTrackingState = state
181
+ return true
182
+ }
183
+
165
184
  private init() {
166
185
  breaker.onStateChanged = { [weak self] newState in
167
186
  self?.breakerState = newState
@@ -238,10 +257,16 @@ final class MouseGestureController: ObservableObject {
238
257
  return
239
258
  }
240
259
 
260
+ let desiredMask = desiredEventMask()
241
261
  if eventTap == nil {
242
- installEventTap()
262
+ installEventTap(mask: desiredMask)
243
263
  } else if let eventTap {
244
- CGEvent.tapEnable(tap: eventTap, enable: true)
264
+ if installedEventMask != desiredMask {
265
+ removeEventTap()
266
+ installEventTap(mask: desiredMask)
267
+ } else {
268
+ CGEvent.tapEnable(tap: eventTap, enable: true)
269
+ }
245
270
  }
246
271
  }
247
272
 
@@ -256,20 +281,18 @@ final class MouseGestureController: ObservableObject {
256
281
  }
257
282
  }
258
283
 
259
- private func installEventTap() {
260
- // Fresh install is a clean slate — drop any stale trip history so
261
- // the new tap's first failure is judged on its own merits.
262
- breaker.reset()
263
-
284
+ private func desiredEventMask() -> CGEventMask {
264
285
  var mask = CGEventMask(0)
265
- mask |= CGEventMask(1) << CGEventType.leftMouseDown.rawValue
266
- mask |= CGEventMask(1) << CGEventType.leftMouseUp.rawValue
267
- mask |= CGEventMask(1) << CGEventType.rightMouseDown.rawValue
268
- mask |= CGEventMask(1) << CGEventType.rightMouseDragged.rawValue
269
- mask |= CGEventMask(1) << CGEventType.rightMouseUp.rawValue
270
286
  mask |= CGEventMask(1) << CGEventType.otherMouseDown.rawValue
271
287
  mask |= CGEventMask(1) << CGEventType.otherMouseDragged.rawValue
272
288
  mask |= CGEventMask(1) << CGEventType.otherMouseUp.rawValue
289
+ return mask
290
+ }
291
+
292
+ private func installEventTap(mask: CGEventMask) {
293
+ // Fresh install is a clean slate — drop any stale trip history so
294
+ // the new tap's first failure is judged on its own merits.
295
+ breaker.reset()
273
296
 
274
297
  let tap = CGEvent.tapCreate(
275
298
  tap: .cgSessionEventTap,
@@ -288,6 +311,7 @@ final class MouseGestureController: ObservableObject {
288
311
  let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
289
312
  eventTap = tap
290
313
  runLoopSource = source
314
+ installedEventMask = mask
291
315
 
292
316
  if let source {
293
317
  EventTapThread.shared.add(source: source)
@@ -309,6 +333,7 @@ final class MouseGestureController: ObservableObject {
309
333
  CFMachPortInvalidate(tap)
310
334
  }
311
335
  eventTap = nil
336
+ installedEventMask = 0
312
337
  }
313
338
 
314
339
  private static let eventTapCallback: CGEventTapCallBack = { _, type, event, userInfo in
@@ -352,19 +377,6 @@ final class MouseGestureController: ObservableObject {
352
377
  return Unmanaged.passUnretained(event)
353
378
  }
354
379
 
355
- switch type {
356
- case .leftMouseDown, .leftMouseUp:
357
- return handlePassiveMouseButtonEvent(type: type, event: event)
358
- case .rightMouseDown:
359
- return handleMouseDown(event, buttonNumber: Int64(CGMouseButton.right.rawValue))
360
- case .rightMouseDragged:
361
- return handleMouseDragged(event, buttonNumber: Int64(CGMouseButton.right.rawValue))
362
- case .rightMouseUp:
363
- return handleMouseUp(event, buttonNumber: Int64(CGMouseButton.right.rawValue))
364
- default:
365
- break
366
- }
367
-
368
380
  let buttonNumber = event.getIntegerValueField(.mouseEventButtonNumber)
369
381
  if buttonNumber < 2 {
370
382
  return Unmanaged.passUnretained(event)
@@ -389,54 +401,15 @@ final class MouseGestureController: ObservableObject {
389
401
  // MouseEventSnapshot, and hand the heavy work to main async — so a slow
390
402
  // main thread never adds latency to mouse events at the head-insert tap.
391
403
 
392
- private func handlePassiveMouseButtonEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
393
- let snapshot = MouseEventSnapshot(
394
- location: event.location,
395
- flags: event.flags,
396
- buttonNumber: event.getIntegerValueField(.mouseEventButtonNumber)
397
- )
398
- DispatchQueue.main.async { [weak self] in
399
- self?.processPassiveMouseButton(type: type, snapshot: snapshot)
400
- }
401
- return Unmanaged.passUnretained(event)
402
- }
403
-
404
404
  private func isEmergencyMouseReset(type: CGEventType, event: CGEvent) -> Bool {
405
405
  switch type {
406
- case .leftMouseDown, .rightMouseDown, .otherMouseDown:
406
+ case .otherMouseDown:
407
407
  return event.flags.intersection(.latticesHyper) == .latticesHyper
408
408
  default:
409
409
  return false
410
410
  }
411
411
  }
412
412
 
413
- private func processPassiveMouseButton(type: CGEventType, snapshot: MouseEventSnapshot) {
414
- dispatchPrecondition(condition: .onQueue(.main))
415
- guard MouseInputEventViewer.shared.isCaptureActive else { return }
416
-
417
- let phase: String
418
- switch type {
419
- case .leftMouseDown, .rightMouseDown:
420
- phase = "down"
421
- case .leftMouseUp, .rightMouseUp:
422
- phase = "up"
423
- default:
424
- return
425
- }
426
-
427
- recordObservedEvent(
428
- phase: phase,
429
- button: MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber)),
430
- location: snapshot.location,
431
- delta: .zero,
432
- modifiers: snapshot.flags,
433
- candidate: nil,
434
- match: nil,
435
- note: "pass-through primary button",
436
- appInfo: currentAppInfo()
437
- )
438
- }
439
-
440
413
  private func handleMouseDown(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
441
414
  let snapshot = MouseEventSnapshot(
442
415
  location: event.location,
@@ -448,6 +421,7 @@ final class MouseGestureController: ObservableObject {
448
421
  let button = MouseShortcutButton(rawButtonNumber: Int(buttonNumber))
449
422
  let needsNativeClickCapture = MouseShortcutStore.shared.hasEnabledRule(button: button, kind: .click)
450
423
  || MouseShortcutStore.shared.hasEnabledRule(button: button, kind: .shape)
424
+ let tuning = MouseShortcutStore.shared.tuning
451
425
  let nativeClickPassthrough = buttonNumber >= 2 && !needsNativeClickCapture
452
426
  let canRecognize = Preferences.shared.mouseGesturesEnabled
453
427
  && MouseShortcutStore.shared.watchedButtonNumbers.contains(buttonNumber)
@@ -472,7 +446,8 @@ final class MouseGestureController: ObservableObject {
472
446
  setTrackingButton(
473
447
  buttonNumber,
474
448
  startPoint: snapshot.location,
475
- nativeClickPassthrough: nativeClickPassthrough
449
+ nativeClickPassthrough: nativeClickPassthrough,
450
+ tuning: tuning
476
451
  )
477
452
  DispatchQueue.main.async { [weak self] in
478
453
  self?.processMouseDownConsume(
@@ -572,7 +547,8 @@ final class MouseGestureController: ObservableObject {
572
547
  }
573
548
 
574
549
  private func handleMouseDragged(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
575
- guard currentTrackingButton() == buttonNumber else {
550
+ guard let trackingState = currentTrackingState(),
551
+ trackingState.buttonNumber == buttonNumber else {
576
552
  return Unmanaged.passUnretained(event)
577
553
  }
578
554
  let snapshot = MouseEventSnapshot(
@@ -580,6 +556,28 @@ final class MouseGestureController: ObservableObject {
580
556
  flags: event.flags,
581
557
  buttonNumber: buttonNumber
582
558
  )
559
+
560
+ let delta = CGPoint(
561
+ x: snapshot.location.x - trackingState.startPoint.x,
562
+ y: snapshot.location.y - trackingState.startPoint.y
563
+ )
564
+ let direction = Self.resolveDirection(
565
+ delta: delta,
566
+ threshold: trackingState.dragThreshold,
567
+ axisBias: trackingState.axisBias
568
+ )
569
+ if direction != nil,
570
+ markNativeClickBalanced(buttonNumber: buttonNumber) {
571
+ postSyntheticMouseUp(
572
+ buttonNumber: buttonNumber,
573
+ at: snapshot.location,
574
+ flags: snapshot.flags
575
+ )
576
+ DispatchQueue.main.async {
577
+ DiagnosticLog.shared.info("MouseGesture: balanced native mouseUp for claimed gesture button=\(buttonNumber)")
578
+ }
579
+ }
580
+
583
581
  DispatchQueue.main.async { [weak self] in
584
582
  self?.processMouseDragged(snapshot: snapshot)
585
583
  }
@@ -677,8 +675,11 @@ final class MouseGestureController: ObservableObject {
677
675
  x: snapshot.location.x - trackingState.startPoint.x,
678
676
  y: snapshot.location.y - trackingState.startPoint.y
679
677
  )
680
- let tuning = MouseShortcutStore.shared.tuning
681
- let direction = Self.resolveDirection(delta: delta, threshold: tuning.dragThreshold, axisBias: tuning.axisBias)
678
+ let direction = Self.resolveDirection(
679
+ delta: delta,
680
+ threshold: trackingState.dragThreshold,
681
+ axisBias: trackingState.axisBias
682
+ )
682
683
  if direction == nil {
683
684
  setTrackingButton(nil)
684
685
  DispatchQueue.main.async { [weak self] in
@@ -982,13 +983,7 @@ final class MouseGestureController: ObservableObject {
982
983
  }
983
984
 
984
985
  private func replayMouseClick(buttonNumber: Int64, at point: CGPoint, flags: CGEventFlags) {
985
- let events: [CGEventType]
986
- if buttonNumber == Int64(CGMouseButton.right.rawValue) {
987
- events = [.rightMouseDown, .rightMouseUp]
988
- } else {
989
- events = [.otherMouseDown, .otherMouseUp]
990
- }
991
- for type in events {
986
+ for type in [CGEventType.otherMouseDown, .otherMouseUp] {
992
987
  guard let mouseButton = CGMouseButton(rawValue: UInt32(buttonNumber)) else { continue }
993
988
  guard let event = CGEvent(
994
989
  mouseEventSource: nil,
@@ -1004,6 +999,23 @@ final class MouseGestureController: ObservableObject {
1004
999
  }
1005
1000
  }
1006
1001
 
1002
+ private func postSyntheticMouseUp(buttonNumber: Int64, at point: CGPoint, flags: CGEventFlags) {
1003
+ guard let mouseButton = CGMouseButton(rawValue: UInt32(buttonNumber)),
1004
+ let event = CGEvent(
1005
+ mouseEventSource: nil,
1006
+ mouseType: .otherMouseUp,
1007
+ mouseCursorPosition: point,
1008
+ mouseButton: mouseButton
1009
+ ) else {
1010
+ return
1011
+ }
1012
+
1013
+ event.setIntegerValueField(CGEventField.mouseEventButtonNumber, value: buttonNumber)
1014
+ event.setIntegerValueField(CGEventField.eventSourceUserData, value: Self.syntheticMarker)
1015
+ event.flags = flags
1016
+ event.post(tap: CGEventTapLocation.cghidEventTap)
1017
+ }
1018
+
1007
1019
  private func screen(containing cgPoint: CGPoint) -> NSScreen? {
1008
1020
  let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
1009
1021
  let nsPoint = NSPoint(x: cgPoint.x, y: primaryHeight - cgPoint.y)
@@ -125,6 +125,7 @@ final class ScreenOverlayCanvasController {
125
125
  private var globalDismissMonitor: Any?
126
126
  private var localDismissMonitor: Any?
127
127
  private var dragState: OverlayActorDragState?
128
+ private var actorDragTimeoutTimer: Timer?
128
129
  private var agentActorsHidden = false
129
130
  private let maxActorDragDuration: TimeInterval = 8.0
130
131
 
@@ -176,6 +177,7 @@ final class ScreenOverlayCanvasController {
176
177
  motionsByLayerID.removeValue(forKey: id)
177
178
  if dragState?.id == id {
178
179
  dragState = nil
180
+ cancelActorDragTimeout()
179
181
  resetPointerCapture()
180
182
  }
181
183
  render()
@@ -188,6 +190,7 @@ final class ScreenOverlayCanvasController {
188
190
  motionsByLayerID = motionsByLayerID.filter { id, _ in layersByID[id] != nil }
189
191
  if let dragState, removedIDs.contains(dragState.id) {
190
192
  self.dragState = nil
193
+ cancelActorDragTimeout()
191
194
  resetPointerCapture()
192
195
  }
193
196
  render()
@@ -198,6 +201,7 @@ final class ScreenOverlayCanvasController {
198
201
  agentActorsHidden.toggle()
199
202
  if agentActorsHidden {
200
203
  dragState = nil
204
+ cancelActorDragTimeout()
201
205
  resetPointerCapture()
202
206
  }
203
207
  render()
@@ -206,6 +210,7 @@ final class ScreenOverlayCanvasController {
206
210
 
207
211
  func resetInputCapture(reason: String) {
208
212
  dragState = nil
213
+ cancelActorDragTimeout()
209
214
  resetPointerCapture()
210
215
  DiagnosticLog.shared.warn("ScreenOverlay: input capture reset for \(reason)")
211
216
  }
@@ -403,6 +408,7 @@ final class ScreenOverlayCanvasController {
403
408
  self.localDismissMonitor = nil
404
409
  }
405
410
  dragState = nil
411
+ cancelActorDragTimeout()
406
412
  resetPointerCapture()
407
413
  }
408
414
  }
@@ -472,6 +478,7 @@ final class ScreenOverlayCanvasController {
472
478
  lastPoint: currentPoint,
473
479
  startedAt: Date()
474
480
  )
481
+ scheduleActorDragTimeout()
475
482
  layersByID[hit.id] = layer.replacingPayload(.pet(payload.moved(to: currentPoint, state: "idle", isDragging: true)))
476
483
  render()
477
484
  updateLifecycleMonitors()
@@ -510,6 +517,7 @@ final class ScreenOverlayCanvasController {
510
517
  }
511
518
  layersByID[dragState.id] = layer.replacingPayload(.pet(payload.moved(to: dragState.lastPoint, state: "idle", isDragging: false)))
512
519
  self.dragState = nil
520
+ cancelActorDragTimeout()
513
521
  render()
514
522
  updateLifecycleMonitors()
515
523
  updatePointerCapture(at: NSEvent.mouseLocation)
@@ -522,6 +530,20 @@ final class ScreenOverlayCanvasController {
522
530
  endActorDrag()
523
531
  }
524
532
 
533
+ private func scheduleActorDragTimeout() {
534
+ cancelActorDragTimeout()
535
+ actorDragTimeoutTimer = Timer.scheduledTimer(withTimeInterval: maxActorDragDuration, repeats: false) { [weak self] _ in
536
+ guard let self, self.dragState != nil else { return }
537
+ DiagnosticLog.shared.warn("ScreenOverlay: actor drag timed out; releasing pointer capture")
538
+ self.endActorDrag()
539
+ }
540
+ }
541
+
542
+ private func cancelActorDragTimeout() {
543
+ actorDragTimeoutTimer?.invalidate()
544
+ actorDragTimeoutTimer = nil
545
+ }
546
+
525
547
  private func resetPointerCapture() {
526
548
  for window in windowsByScreenID.values {
527
549
  window.ignoresMouseEvents = true
@@ -536,6 +558,7 @@ final class ScreenOverlayCanvasController {
536
558
  motionsByLayerID.removeValue(forKey: hit.id)
537
559
  if dragState?.id == hit.id {
538
560
  dragState = nil
561
+ cancelActorDragTimeout()
539
562
  }
540
563
  render()
541
564
  updateLifecycleMonitors()
@@ -569,6 +592,7 @@ final class ScreenOverlayCanvasController {
569
592
  motionsByLayerID = motionsByLayerID.filter { id, _ in layersByID[id] != nil }
570
593
  if let dragState, layersByID[dragState.id] == nil {
571
594
  self.dragState = nil
595
+ cancelActorDragTimeout()
572
596
  resetPointerCapture()
573
597
  }
574
598
  guard layersByID.count != before else { return }
@@ -4,7 +4,7 @@ export default {
4
4
  name: 'lattices',
5
5
  tagline: 'macOS developer workspace manager — tmux sessions with a native menu bar app for tiling, navigation, and project management',
6
6
  type: 'cli-tool',
7
- version: '0.4.11',
7
+ version: '0.4.12',
8
8
  },
9
9
 
10
10
  agent: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lattices/cli",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "description": "The agentic workspace manager for macOS — turn your desktop into a coherent API",
5
5
  "packageManager": "bun@1.3.11",
6
6
  "engines": {