@lattices/cli 0.4.13 → 0.4.14

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.13</string>
29
+ <string>0.4.14</string>
30
30
  <key>CFBundleShortVersionString</key>
31
- <string>0.4.13</string>
31
+ <string>0.4.14</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>42928c7</string>
37
+ <string>5aace7d</string>
38
38
  <key>LatticesBuildTimestamp</key>
39
- <string>2026-05-04T17:39:23Z</string>
39
+ <string>2026-05-05T15:30:06Z</string>
40
40
  <key>LSMinimumSystemVersion</key>
41
41
  <string>13.0</string>
42
42
  <key>LSUIElement</key>
@@ -581,6 +581,9 @@ final class MouseGestureController: ObservableObject {
581
581
  DispatchQueue.main.async { [weak self] in
582
582
  self?.processMouseDragged(snapshot: snapshot)
583
583
  }
584
+ if trackingState.nativeClickPassthrough, direction == nil {
585
+ return Unmanaged.passUnretained(event)
586
+ }
584
587
  return nil
585
588
  }
586
589
 
@@ -1026,8 +1029,13 @@ final class MouseGestureController: ObservableObject {
1026
1029
  "\(Int(point.x)),\(Int(point.y))"
1027
1030
  }
1028
1031
 
1029
- private func shouldDismissOverlayBeforeAction(match _: MouseShortcutMatchResult?) -> Bool {
1030
- true
1032
+ private func shouldDismissOverlayBeforeAction(match: MouseShortcutMatchResult?) -> Bool {
1033
+ switch match?.action.type {
1034
+ case .shortcutSend, .appActivate:
1035
+ return match?.rule.visual == nil
1036
+ case .spacePrevious, .spaceNext, .screenMapToggle, .dictationStart, .none:
1037
+ return true
1038
+ }
1031
1039
  }
1032
1040
 
1033
1041
  private func previewProgress(dominantDistance: CGFloat, threshold: CGFloat) -> CGFloat {
@@ -1417,10 +1425,17 @@ private final class MouseGestureOverlayView: NSView {
1417
1425
  private var committedStartProgress: CGFloat = 0
1418
1426
  private var accessoryAnimationDelay: TimeInterval = 0
1419
1427
  private let committedArrowAnimationDuration: TimeInterval = 0.06
1428
+ private let matrixCompletionAnimationDuration: TimeInterval = 0.30
1420
1429
  private let arrowAnimationDelay: TimeInterval = 0.012
1421
1430
  private let labelRevealThreshold: CGFloat = 0.8
1422
1431
 
1423
1432
  var replayLeadInDuration: TimeInterval {
1433
+ if case .committed(_, _, _, _, _, let visual, _, let shape, let path, _, _) = state,
1434
+ shape != nil,
1435
+ path.count > 1,
1436
+ shouldDrawMatrixCompletion(visual) {
1437
+ return matrixCompletionAnimationDuration + 0.05
1438
+ }
1424
1439
  if committedStartProgress >= labelRevealThreshold {
1425
1440
  return 0
1426
1441
  }
@@ -1487,20 +1502,31 @@ private final class MouseGestureOverlayView: NSView {
1487
1502
  )
1488
1503
  }
1489
1504
  case .committed(let origin, let direction, let label, let success, let style, let visual, let visualPhase, let shape, let path, let accessory, _):
1490
- drawOrigin(at: origin, in: ctx, alpha: 1.0)
1491
1505
  if path.count > 1 {
1492
- drawGesturePath(
1493
- path,
1494
- fallbackOrigin: origin,
1495
- direction: direction,
1496
- label: label,
1497
- success: success,
1498
- committed: true,
1499
- style: style,
1500
- accessory: accessory,
1501
- progressOverride: nil,
1502
- in: ctx
1503
- )
1506
+ if shape != nil, shouldDrawMatrixCompletion(visual) {
1507
+ drawMatrixGestureCompletion(
1508
+ path,
1509
+ label: label,
1510
+ success: success,
1511
+ direction: direction,
1512
+ accessory: accessory,
1513
+ in: ctx
1514
+ )
1515
+ } else {
1516
+ drawOrigin(at: origin, in: ctx, alpha: 1.0)
1517
+ drawGesturePath(
1518
+ path,
1519
+ fallbackOrigin: origin,
1520
+ direction: direction,
1521
+ label: label,
1522
+ success: success,
1523
+ committed: true,
1524
+ style: style,
1525
+ accessory: accessory,
1526
+ progressOverride: nil,
1527
+ in: ctx
1528
+ )
1529
+ }
1504
1530
  drawVisualPOCIfNeeded(
1505
1531
  visual,
1506
1532
  phase: visualPhase,
@@ -1511,6 +1537,7 @@ private final class MouseGestureOverlayView: NSView {
1511
1537
  in: ctx
1512
1538
  )
1513
1539
  } else {
1540
+ drawOrigin(at: origin, in: ctx, alpha: 1.0)
1514
1541
  drawArrow(
1515
1542
  from: origin,
1516
1543
  direction: direction,
@@ -1652,6 +1679,276 @@ private final class MouseGestureOverlayView: NSView {
1652
1679
  ctx.restoreGState()
1653
1680
  }
1654
1681
 
1682
+ private func drawMatrixGestureCompletion(
1683
+ _ points: [CGPoint],
1684
+ label: String,
1685
+ success: Bool,
1686
+ direction: MouseGestureDirection,
1687
+ accessory: MouseGestureAccessory?,
1688
+ in ctx: CGContext
1689
+ ) {
1690
+ guard points.count > 1 else { return }
1691
+ let progress = min(1, max(0, currentArrowProgress(committed: true)))
1692
+ let cleanedPoints = cleanedMatrixGesturePoints(points)
1693
+ let matrixRect = matrixGestureRect(for: cleanedPoints)
1694
+ let transformedPoints = transformGesturePoints(cleanedPoints, into: matrixRect.insetBy(dx: 16, dy: 16))
1695
+ let visiblePoints = visiblePathPoints(transformedPoints, progress: progress)
1696
+ let accent = success ? theme.accent : theme.failure
1697
+ let activeCells = matrixCellsTouched(by: visiblePoints, in: matrixRect)
1698
+ let pulsePoint = visiblePoints.last ?? transformedPoints.last ?? CGPoint(x: matrixRect.midX, y: matrixRect.midY)
1699
+ let completionAlpha = min(1, max(0, (progress - 0.68) / 0.22))
1700
+
1701
+ ctx.saveGState()
1702
+
1703
+ let halo = NSBezierPath(roundedRect: matrixRect.insetBy(dx: -9, dy: -9), xRadius: 16, yRadius: 16)
1704
+ theme.graphiteDark.withAlphaComponent(0.20 + 0.14 * completionAlpha).setFill()
1705
+ halo.fill()
1706
+
1707
+ drawMatrixCells(
1708
+ in: matrixRect,
1709
+ activeCells: activeCells,
1710
+ completionAlpha: completionAlpha,
1711
+ accent: accent,
1712
+ success: success,
1713
+ context: ctx
1714
+ )
1715
+
1716
+ if visiblePoints.count > 1 {
1717
+ let replayPath = smoothedGesturePath(from: visiblePoints)
1718
+ ctx.addPath(replayPath)
1719
+ ctx.setLineCap(.round)
1720
+ ctx.setLineJoin(.round)
1721
+ ctx.setLineWidth(14)
1722
+ ctx.setStrokeColor(accent.withAlphaComponent(0.16 + 0.16 * completionAlpha).cgColor)
1723
+ ctx.strokePath()
1724
+
1725
+ ctx.addPath(replayPath)
1726
+ ctx.setLineWidth(5)
1727
+ ctx.setStrokeColor(accent.withAlphaComponent(0.72).cgColor)
1728
+ ctx.strokePath()
1729
+
1730
+ ctx.addPath(replayPath)
1731
+ ctx.setLineWidth(1.8)
1732
+ ctx.setStrokeColor(theme.highlight.withAlphaComponent(0.86).cgColor)
1733
+ ctx.strokePath()
1734
+ }
1735
+
1736
+ let pulseRadius = 8 + 10 * easeOut(progress)
1737
+ ctx.setFillColor(accent.withAlphaComponent(0.18 * (1 - completionAlpha * 0.45)).cgColor)
1738
+ ctx.fillEllipse(in: CGRect(
1739
+ x: pulsePoint.x - pulseRadius,
1740
+ y: pulsePoint.y - pulseRadius,
1741
+ width: pulseRadius * 2,
1742
+ height: pulseRadius * 2
1743
+ ))
1744
+ ctx.setFillColor(theme.highlight.withAlphaComponent(0.96).cgColor)
1745
+ ctx.fillEllipse(in: CGRect(x: pulsePoint.x - 3, y: pulsePoint.y - 3, width: 6, height: 6))
1746
+
1747
+ if completionAlpha > 0.1 {
1748
+ drawMatrixConfirmationGlyph(in: matrixRect, alpha: completionAlpha, accent: accent, context: ctx)
1749
+ }
1750
+
1751
+ if progress >= 0.76 {
1752
+ let labelAlpha = min(1, max(0, (progress - 0.76) / 0.18))
1753
+ drawMatrixLabel(label, near: matrixRect, alpha: labelAlpha, accent: accent)
1754
+ }
1755
+
1756
+ if let accessory, progress >= labelRevealThreshold {
1757
+ drawAccessory(accessory, from: CGPoint(x: matrixRect.midX, y: matrixRect.midY), to: pulsePoint, direction: direction, color: accent, in: ctx)
1758
+ }
1759
+
1760
+ ctx.restoreGState()
1761
+ }
1762
+
1763
+ private func drawMatrixCells(
1764
+ in rect: CGRect,
1765
+ activeCells: Set<Int>,
1766
+ completionAlpha: CGFloat,
1767
+ accent: NSColor,
1768
+ success: Bool,
1769
+ context ctx: CGContext
1770
+ ) {
1771
+ let cellSize: CGFloat = 13
1772
+ let gap: CGFloat = 7
1773
+ let gridSize = cellSize * 3 + gap * 2
1774
+ let startX = rect.midX - gridSize / 2
1775
+ let startY = rect.midY - gridSize / 2
1776
+ let logoCells: Set<Int> = [0, 3, 6, 7, 8]
1777
+
1778
+ for row in 0..<3 {
1779
+ for col in 0..<3 {
1780
+ let idx = row * 3 + col
1781
+ let x = startX + CGFloat(col) * (cellSize + gap)
1782
+ let y = startY + CGFloat(row) * (cellSize + gap)
1783
+ let cellRect = CGRect(x: x, y: y, width: cellSize, height: cellSize)
1784
+ let isActive = activeCells.contains(idx)
1785
+ let isLogoCell = logoCells.contains(idx)
1786
+ let baseAlpha: CGFloat = isLogoCell ? 0.20 : 0.11
1787
+ let activeAlpha: CGFloat = success ? 0.88 : 0.70
1788
+ let snapAlpha = isLogoCell ? completionAlpha * 0.36 : 0
1789
+ let fillAlpha = max(baseAlpha + snapAlpha, isActive ? activeAlpha : baseAlpha)
1790
+ let fill = isActive ? accent : theme.highlight
1791
+
1792
+ let glow = NSBezierPath(roundedRect: cellRect.insetBy(dx: -4, dy: -4), xRadius: 6, yRadius: 6)
1793
+ accent.withAlphaComponent(isActive ? 0.14 : snapAlpha * 0.16).setFill()
1794
+ glow.fill()
1795
+
1796
+ let cell = NSBezierPath(roundedRect: cellRect, xRadius: 3, yRadius: 3)
1797
+ fill.withAlphaComponent(fillAlpha).setFill()
1798
+ cell.fill()
1799
+ }
1800
+ }
1801
+ }
1802
+
1803
+ private func drawMatrixConfirmationGlyph(in rect: CGRect, alpha: CGFloat, accent: NSColor, context ctx: CGContext) {
1804
+ let path = CGMutablePath()
1805
+ let x0 = rect.midX + 23
1806
+ let y0 = rect.midY + 22
1807
+ path.move(to: CGPoint(x: x0, y: y0))
1808
+ path.addLine(to: CGPoint(x: x0, y: rect.midY - 24))
1809
+ path.addLine(to: CGPoint(x: rect.midX - 23, y: rect.midY - 24))
1810
+
1811
+ ctx.addPath(path)
1812
+ ctx.setLineCap(.round)
1813
+ ctx.setLineJoin(.round)
1814
+ ctx.setLineWidth(3)
1815
+ ctx.setStrokeColor(accent.withAlphaComponent(0.54 * alpha).cgColor)
1816
+ ctx.strokePath()
1817
+
1818
+ let arrow = NSBezierPath()
1819
+ let end = CGPoint(x: rect.midX - 23, y: rect.midY - 24)
1820
+ arrow.move(to: end)
1821
+ arrow.line(to: CGPoint(x: end.x + 8, y: end.y + 6))
1822
+ arrow.move(to: end)
1823
+ arrow.line(to: CGPoint(x: end.x + 8, y: end.y - 6))
1824
+ accent.withAlphaComponent(0.70 * alpha).setStroke()
1825
+ arrow.lineWidth = 2
1826
+ arrow.lineCapStyle = .round
1827
+ arrow.stroke()
1828
+ }
1829
+
1830
+ private func drawMatrixLabel(_ label: String, near rect: CGRect, alpha: CGFloat, accent: NSColor) {
1831
+ let font = NSFont.monospacedSystemFont(ofSize: 9, weight: .bold)
1832
+ let display = labelComponents(for: label).title
1833
+ let attributed = NSAttributedString(
1834
+ string: display,
1835
+ attributes: [
1836
+ .font: font,
1837
+ .foregroundColor: theme.highlight.withAlphaComponent(0.94 * alpha),
1838
+ ]
1839
+ )
1840
+ let size = attributed.size()
1841
+ let bubbleRect = CGRect(
1842
+ x: rect.midX - (size.width + 16) / 2,
1843
+ y: rect.minY - size.height - 13,
1844
+ width: size.width + 16,
1845
+ height: size.height + 7
1846
+ )
1847
+ let bubble = NSBezierPath(roundedRect: bubbleRect, xRadius: 8, yRadius: 8)
1848
+ theme.graphiteDark.withAlphaComponent(0.78 * alpha).setFill()
1849
+ bubble.fill()
1850
+ accent.withAlphaComponent(0.38 * alpha).setStroke()
1851
+ bubble.lineWidth = 1
1852
+ bubble.stroke()
1853
+ attributed.draw(in: CGRect(
1854
+ x: bubbleRect.minX + 8,
1855
+ y: bubbleRect.minY + 3.5,
1856
+ width: size.width,
1857
+ height: size.height
1858
+ ))
1859
+ }
1860
+
1861
+ private func cleanedMatrixGesturePoints(_ points: [CGPoint]) -> [CGPoint] {
1862
+ let simplified = simplifyGesturePoints(points, minimumDistance: 5)
1863
+ guard simplified.count > 2 else { return simplified }
1864
+
1865
+ var cleaned: [CGPoint] = []
1866
+ for index in simplified.indices {
1867
+ let previous = simplified[max(index - 1, simplified.startIndex)]
1868
+ let current = simplified[index]
1869
+ let next = simplified[min(index + 1, simplified.index(before: simplified.endIndex))]
1870
+ cleaned.append(CGPoint(
1871
+ x: (previous.x + current.x * 2 + next.x) / 4,
1872
+ y: (previous.y + current.y * 2 + next.y) / 4
1873
+ ))
1874
+ }
1875
+ return cleaned
1876
+ }
1877
+
1878
+ private func simplifyGesturePoints(_ points: [CGPoint], minimumDistance: CGFloat) -> [CGPoint] {
1879
+ guard var last = points.first else { return [] }
1880
+ var result = [last]
1881
+ for point in points.dropFirst() {
1882
+ let dx = point.x - last.x
1883
+ let dy = point.y - last.y
1884
+ if sqrt(dx * dx + dy * dy) >= minimumDistance {
1885
+ result.append(point)
1886
+ last = point
1887
+ }
1888
+ }
1889
+ if let final = points.last, result.last != final {
1890
+ result.append(final)
1891
+ }
1892
+ return result
1893
+ }
1894
+
1895
+ private func matrixGestureRect(for points: [CGPoint]) -> CGRect {
1896
+ let end = points.last ?? CGPoint(x: bounds.midX, y: bounds.midY)
1897
+ let size = CGSize(width: 88, height: 88)
1898
+ var origin = CGPoint(x: end.x + 24, y: end.y + 18)
1899
+
1900
+ if origin.x + size.width > bounds.width - 12 {
1901
+ origin.x = end.x - size.width - 24
1902
+ }
1903
+ if origin.y + size.height > bounds.height - 12 {
1904
+ origin.y = end.y - size.height - 18
1905
+ }
1906
+ origin.x = min(max(origin.x, 12), max(12, bounds.width - size.width - 12))
1907
+ origin.y = min(max(origin.y, 12), max(12, bounds.height - size.height - 12))
1908
+
1909
+ return CGRect(origin: origin, size: size)
1910
+ }
1911
+
1912
+ private func transformGesturePoints(_ points: [CGPoint], into rect: CGRect) -> [CGPoint] {
1913
+ guard !points.isEmpty else { return [] }
1914
+ let minX = points.map(\.x).min() ?? 0
1915
+ let maxX = points.map(\.x).max() ?? minX
1916
+ let minY = points.map(\.y).min() ?? 0
1917
+ let maxY = points.map(\.y).max() ?? minY
1918
+ let sourceWidth = max(maxX - minX, 1)
1919
+ let sourceHeight = max(maxY - minY, 1)
1920
+ let scale = min(rect.width / sourceWidth, rect.height / sourceHeight)
1921
+ let scaledWidth = sourceWidth * scale
1922
+ let scaledHeight = sourceHeight * scale
1923
+ let offsetX = rect.midX - scaledWidth / 2
1924
+ let offsetY = rect.midY - scaledHeight / 2
1925
+
1926
+ return points.map { point in
1927
+ CGPoint(
1928
+ x: offsetX + (point.x - minX) * scale,
1929
+ y: offsetY + (point.y - minY) * scale
1930
+ )
1931
+ }
1932
+ }
1933
+
1934
+ private func matrixCellsTouched(by points: [CGPoint], in rect: CGRect) -> Set<Int> {
1935
+ guard !points.isEmpty else { return [] }
1936
+ let cellSize: CGFloat = 13
1937
+ let gap: CGFloat = 7
1938
+ let gridSize = cellSize * 3 + gap * 2
1939
+ let startX = rect.midX - gridSize / 2
1940
+ let startY = rect.midY - gridSize / 2
1941
+ let step = cellSize + gap
1942
+
1943
+ var touched = Set<Int>()
1944
+ for point in points {
1945
+ let col = min(2, max(0, Int(round((point.x - startX - cellSize / 2) / step))))
1946
+ let row = min(2, max(0, Int(round((point.y - startY - cellSize / 2) / step))))
1947
+ touched.insert(row * 3 + col)
1948
+ }
1949
+ return touched
1950
+ }
1951
+
1655
1952
  private func drawGestureGuideDots(around points: [CGPoint], accent: NSColor, in ctx: CGContext) {
1656
1953
  guard !points.isEmpty else { return }
1657
1954
  let minX = (points.map(\.x).min() ?? 0) - 34
@@ -2050,7 +2347,8 @@ private final class MouseGestureOverlayView: NSView {
2050
2347
  let shouldRestart = oldDirection != newDirection || !oldCommitted
2051
2348
  if shouldRestart {
2052
2349
  let previousProgress = trackingProgress(from: oldState)
2053
- committedStartProgress = max(0, min(1, previousProgress ?? 0))
2350
+ let isMatrixReplay = isCommittedShapeReplay(state: newState)
2351
+ committedStartProgress = isMatrixReplay ? 0 : max(0, min(1, previousProgress ?? 0))
2054
2352
  if committedStartProgress >= 0.94 {
2055
2353
  arrowAnimationTimer?.invalidate()
2056
2354
  arrowAnimationTimer = nil
@@ -2058,7 +2356,8 @@ private final class MouseGestureOverlayView: NSView {
2058
2356
  arrowAnimationDuration = 0
2059
2357
  committedStartProgress = 1
2060
2358
  } else {
2061
- startArrowAnimation(duration: committedArrowAnimationDuration)
2359
+ let duration = isMatrixReplay ? matrixCompletionAnimationDuration : committedArrowAnimationDuration
2360
+ startArrowAnimation(duration: duration)
2062
2361
  }
2063
2362
  }
2064
2363
  return
@@ -2262,6 +2561,19 @@ private final class MouseGestureOverlayView: NSView {
2262
2561
  return false
2263
2562
  }
2264
2563
 
2564
+ private func isCommittedShapeReplay(state: State) -> Bool {
2565
+ if case .committed(_, _, _, _, _, let visual, _, let shape, let path, _, _) = state {
2566
+ return shape != nil && path.count > 1 && shouldDrawMatrixCompletion(visual)
2567
+ }
2568
+ return false
2569
+ }
2570
+
2571
+ private func shouldDrawMatrixCompletion(_ visual: MouseShortcutVisualDefinition?) -> Bool {
2572
+ guard let visual else { return false }
2573
+ return visual.renderer.localizedCaseInsensitiveCompare("matrix") == .orderedSame
2574
+ || visual.theme?.localizedCaseInsensitiveCompare("matrix") == .orderedSame
2575
+ }
2576
+
2265
2577
  private func trackingProgress(from state: State) -> CGFloat? {
2266
2578
  if case .tracking(_, _, _, _, _, _, _, _, _, let progress) = state {
2267
2579
  return progress
@@ -2,78 +2,147 @@
2
2
 
3
3
  ## Concept
4
4
 
5
- Hold a mouse button drag in a direction release to execute an action.
6
- The MX Master 3S back-side button (button 3) triggers grid layouts.
5
+ Mouse gestures are a user-level shortcut system for the macOS app. Hold a
6
+ configured mouse button, draw a direction or shape, then release to run the
7
+ matched action.
7
8
 
8
- ## Button Mapping
9
+ The app code owns the recognizer, action dispatcher, and JSON schema. Your
10
+ actual gesture mappings live in:
9
11
 
10
- Defaults — remap by editing `~/.lattices/mouse-shortcuts.json`:
12
+ ```bash
13
+ ~/.lattices/mouse-shortcuts.json
14
+ ```
11
15
 
12
- - **Button 3 (Back)** grid layouts (Maximize / 2×2 / 3×3 / 4×4)
16
+ That file is machine-local preference data. It is not project config, and it
17
+ should not be committed to this repository unless you are intentionally adding
18
+ an example fixture or changing the schema.
13
19
 
14
- ## Gesture Detection
20
+ ## Config Ownership
15
21
 
16
- 1. Button held → start tracking mouse movement
17
- 2. After 30px of movement, commit to a direction (↑ ← → ↓)
18
- 3. Show HUD feedback badge: current direction + predicted action
19
- 4. Release → execute the action
22
+ There are two layers:
20
23
 
21
- If released before 30px threshold no action (treated as a normal click).
24
+ - **Code defaults** in `MouseGestureConfig.swift` provide a minimal starter
25
+ config when no user file exists.
26
+ - **User rules** in `~/.lattices/mouse-shortcuts.json` are the source of truth
27
+ after the file has been created.
22
28
 
23
- ## Actions
29
+ Do not add personal shortcuts by changing `MouseGestureConfig.swift`. Add them
30
+ to the user JSON file instead. The Settings UI can open that file from
31
+ **Settings -> Shortcuts -> Mouse Gestures -> Configure...**.
24
32
 
25
- ### Button 3 — Grid / Tile
33
+ ## Button Names
26
34
 
27
- | Direction | Action | Result |
28
- |-----------|--------|--------|
29
- | ↑ Up | Maximize | Frontmost window fills the screen |
30
- | ← Left | 2×2 grid | Distribute frontmost 4 windows |
31
- | → Right | 3×3 grid | Distribute frontmost 9 windows |
32
- | ↓ Down | 4×4 grid | Distribute frontmost 16 windows |
35
+ The config accepts these common button names:
33
36
 
34
- Grid distributes the visible non-Lattices windows to fill the grid cells on
35
- the screen the cursor is on.
37
+ | Config value | Meaning |
38
+ |---|---|
39
+ | `middle` | Middle button / wheel click |
40
+ | `back` | Back side button, often mouse button 4 |
41
+ | `forward` | Forward side button, often mouse button 5 |
42
+ | `right` | Right mouse button |
43
+ | `buttonN` | Explicit numbered button fallback |
36
44
 
37
- ## HUD Feedback
45
+ Normal clicks pass through when a configured button is only being watched for
46
+ drag or shape gestures. Once movement crosses the gesture threshold and matches
47
+ a real gesture, Lattices claims the interaction.
38
48
 
39
- While dragging:
40
- - Small floating badge near the cursor: direction arrow + action name
41
- - Floats above all windows, fades out on release
49
+ ## Trigger Kinds
42
50
 
43
- ## Safety: self-healing event tap
51
+ Rules match one of three trigger kinds:
44
52
 
45
- The controller installs a session-wide CGEvent tap. To avoid blocking the
46
- system input pipeline:
53
+ | Kind | Required fields | Example |
54
+ |---|---|---|
55
+ | `click` | `button` | Middle click sends paste |
56
+ | `drag` | `button`, `direction` | Middle drag left switches Space |
57
+ | `shape` | `button`, `shape` | Back-button L shape activates iTerm |
47
58
 
48
- - The tap callback never blocks — actions dispatch async to the main queue.
49
- - A circuit breaker trips on (a) OS `tapDisabledByTimeout` events, or
50
- (b) any single action that exceeds 500ms.
51
- - Cooldowns escalate inside a 10-minute rolling window: **30s → 2min →
52
- permanent** (until config reload or app restart).
53
- - A center-screen badge surfaces "Mouse gestures paused — resuming in Ns"
54
- on trip and "Mouse gestures resumed" on auto-recover. All trips log to
55
- `~/.lattices/lattices.log`.
59
+ Directions are `up`, `down`, `left`, and `right`.
56
60
 
57
- ## Implementation
61
+ Useful two-movement shapes include:
62
+
63
+ | Shape | Motion |
64
+ |---|---|
65
+ | `l-shape-down-right` | Down, then right |
66
+ | `l-shape-down-left` | Down, then left |
67
+ | `l-shape-up-right` | Up, then right |
68
+ | `l-shape-up-left` | Up, then left |
69
+ | `reverse-l-right-down` | Right, then down |
70
+ | `reverse-l-left-down` | Left, then down |
71
+ | `v-shape` | Down, then up |
72
+ | `reverse-v` | Up, then down |
58
73
 
59
- - `apps/mac/Sources/MouseGestureController.swift` — CGEvent tap, gesture
60
- state machine, breaker.
61
- - Tile actions go through `WindowTiler.tileFrontmostViaAX(...)`.
62
- - Grid actions enumerate visible windows via `DesktopModel.shared.allWindows()`
63
- and batch-move them with `WindowTiler.batchMoveAndRaiseWindows(...)`.
74
+ ## Actions
75
+
76
+ Supported action types include:
77
+
78
+ | Type | Purpose |
79
+ |---|---|
80
+ | `space.previous` | Switch to the previous macOS Space |
81
+ | `space.next` | Switch to the next macOS Space |
82
+ | `screenmap.toggle` | Open the Screen Map overview |
83
+ | `dictation.start` | Start dictation |
84
+ | `shortcut.send` | Send a keyboard shortcut |
85
+ | `app.activate` | Activate an app by name |
86
+
87
+ ## Example: Enter Gesture
88
+
89
+ This is a user-level rule, not a code default. Add it to
90
+ `~/.lattices/mouse-shortcuts.json` if you want the back button plus a quick
91
+ down-then-left shape to press Enter:
92
+
93
+ ```json
94
+ {
95
+ "id": "back-down-left-enter",
96
+ "enabled": true,
97
+ "device": "any",
98
+ "trigger": {
99
+ "button": "back",
100
+ "kind": "shape",
101
+ "shape": "l-shape-down-left"
102
+ },
103
+ "action": {
104
+ "type": "shortcut.send",
105
+ "shortcut": {
106
+ "key": "enter",
107
+ "keyCode": 36,
108
+ "modifiers": []
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ ## Visuals
115
+
116
+ Rules may include an optional `visual` block for feedback. Visuals are
117
+ decorative: they must never decide whether a gesture matches or whether an
118
+ action runs. If a visual asset is missing or slow, the gesture should still
119
+ execute through the native action path.
120
+
121
+ Shape gestures can opt into native matrix completion feedback with
122
+ `"visual": { "renderer": "matrix" }`. When enabled, Lattices smooths the
123
+ captured path, replays it briefly inside a small 3x3 rounded-cell matrix, then
124
+ snaps into a compact confirmation glyph and action label. This is feedback
125
+ only; the matched rule has already dispatched.
64
126
 
65
- ## Settings UI
127
+ ## Implementation
66
128
 
67
- **Settings Shortcuts Mouse Gestures**:
129
+ - `apps/mac/Sources/Core/Input/MouseGestureController.swift` owns the CGEvent
130
+ tap, gesture session state, passthrough behavior, and action dispatch.
131
+ - `apps/mac/Sources/Core/Input/MouseGestureConfig.swift` defines the Codable
132
+ schema and initial fallback defaults.
133
+ - `apps/mac/Sources/Core/Input/MouseShortcutStore.swift` loads the user-level
134
+ JSON file and provides thread-safe snapshots to the event tap.
135
+ - `apps/mac/Sources/Core/Input/ShapeRecognizer.swift` converts raw gesture
136
+ paths into direction and shape labels.
68
137
 
69
- - Button 3 grid card showing the active mapping
70
- - Hint to edit `~/.lattices/mouse-shortcuts.json` for remapping
138
+ ## Safety
71
139
 
72
- ## Edge Cases
140
+ The controller installs a session-wide CGEvent tap. To avoid blocking the
141
+ system input pipeline:
73
142
 
74
- - Multiple monitors: gesture executes on the screen the cursor starts on
75
- - Button held + cursor leaves the screen: still tracks; action applies
76
- to the starting screen
77
- - Short press (< 30px movement): ignored, treated as a normal click
78
- - Slow action / OS tap timeout: breaker trips, gestures pause briefly,
79
- then auto-recover
143
+ - The tap callback does only cheap work and dispatches heavier work async.
144
+ - A circuit breaker handles OS tap timeout events and pauses gestures when
145
+ needed.
146
+ - Short, unmatched clicks are replayed or passed through so native app behavior
147
+ remains intact.
148
+ - The emergency reset chord clears stuck input capture state.
@@ -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.13',
7
+ version: '0.4.14',
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.13",
3
+ "version": "0.4.14",
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": {