@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.
- package/apps/mac/Lattices.app/Contents/Info.plist +4 -4
- package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/apps/mac/Sources/Core/Input/MouseGestureController.swift +329 -17
- package/docs/mouse-gestures.md +122 -53
- package/docs/reference/dewey.config.ts +1 -1
- package/package.json +1 -1
|
@@ -26,17 +26,17 @@
|
|
|
26
26
|
</dict>
|
|
27
27
|
</array>
|
|
28
28
|
<key>CFBundleVersion</key>
|
|
29
|
-
<string>0.4.
|
|
29
|
+
<string>0.4.14</string>
|
|
30
30
|
<key>CFBundleShortVersionString</key>
|
|
31
|
-
<string>0.4.
|
|
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>
|
|
37
|
+
<string>5aace7d</string>
|
|
38
38
|
<key>LatticesBuildTimestamp</key>
|
|
39
|
-
<string>2026-05-
|
|
39
|
+
<string>2026-05-05T15:30:06Z</string>
|
|
40
40
|
<key>LSMinimumSystemVersion</key>
|
|
41
41
|
<string>13.0</string>
|
|
42
42
|
<key>LSUIElement</key>
|
|
Binary file
|
|
@@ -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
|
|
1030
|
-
|
|
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
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/docs/mouse-gestures.md
CHANGED
|
@@ -2,78 +2,147 @@
|
|
|
2
2
|
|
|
3
3
|
## Concept
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
9
|
+
The app code owns the recognizer, action dispatcher, and JSON schema. Your
|
|
10
|
+
actual gesture mappings live in:
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
```bash
|
|
13
|
+
~/.lattices/mouse-shortcuts.json
|
|
14
|
+
```
|
|
11
15
|
|
|
12
|
-
|
|
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
|
-
##
|
|
20
|
+
## Config Ownership
|
|
15
21
|
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
## Button Names
|
|
26
34
|
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
Rules match one of three trigger kinds:
|
|
44
52
|
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
##
|
|
127
|
+
## Implementation
|
|
66
128
|
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
- Hint to edit `~/.lattices/mouse-shortcuts.json` for remapping
|
|
138
|
+
## Safety
|
|
71
139
|
|
|
72
|
-
|
|
140
|
+
The controller installs a session-wide CGEvent tap. To avoid blocking the
|
|
141
|
+
system input pipeline:
|
|
73
142
|
|
|
74
|
-
-
|
|
75
|
-
-
|
|
76
|
-
|
|
77
|
-
- Short
|
|
78
|
-
|
|
79
|
-
|
|
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.
|