@lattices/cli 0.4.12 → 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/KeyboardRemapController.swift +17 -6
- package/apps/mac/Sources/Core/Input/MouseGestureController.swift +329 -17
- package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +9 -56
- 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
|
|
@@ -21,11 +21,12 @@ final class KeyboardRemapController: ObservableObject {
|
|
|
21
21
|
private var capsLayerLastEventAt: CFAbsoluteTime?
|
|
22
22
|
private var bypassUntil: CFAbsoluteTime = 0
|
|
23
23
|
private var lastCapsLayerStaleLogAt: CFAbsoluteTime = 0
|
|
24
|
-
private var pressedKeyCodes =
|
|
24
|
+
private var pressedKeyCodes: [Int64: CFAbsoluteTime] = [:]
|
|
25
25
|
private let breaker = EventTapBreaker(label: "KeyboardRemap")
|
|
26
26
|
private let budgetMeter = TapBudgetMeter(label: "KeyboardRemap")
|
|
27
27
|
private let maxCapsLayerIdleDuration: TimeInterval = 2.0
|
|
28
28
|
private let maxCapsLayerHeldDuration: TimeInterval = 20.0
|
|
29
|
+
private let maxTrackedKeyDownDuration: TimeInterval = 120.0
|
|
29
30
|
private let emergencyBypassDuration: TimeInterval = 3.0
|
|
30
31
|
|
|
31
32
|
private init() {
|
|
@@ -182,7 +183,8 @@ final class KeyboardRemapController: ObservableObject {
|
|
|
182
183
|
return Unmanaged.passUnretained(event)
|
|
183
184
|
}
|
|
184
185
|
|
|
185
|
-
|
|
186
|
+
expireStalePressedKeys(now: started)
|
|
187
|
+
updatePressedKeys(type: type, keyCode: event.getIntegerValueField(.keyboardEventKeycode), now: started)
|
|
186
188
|
if shouldTriggerEmergencyReset(type: type, event: event) {
|
|
187
189
|
emergencyClear(now: started)
|
|
188
190
|
InputCaptureResetCenter.reset(reason: "keyboard emergency chord")
|
|
@@ -295,23 +297,32 @@ final class KeyboardRemapController: ObservableObject {
|
|
|
295
297
|
DiagnosticLog.shared.warn("KeyboardRemap: emergency bypass via Escape")
|
|
296
298
|
}
|
|
297
299
|
|
|
298
|
-
private func updatePressedKeys(type: CGEventType, keyCode: Int64) {
|
|
300
|
+
private func updatePressedKeys(type: CGEventType, keyCode: Int64, now: CFAbsoluteTime) {
|
|
299
301
|
switch type {
|
|
300
302
|
case .keyDown:
|
|
301
|
-
pressedKeyCodes
|
|
303
|
+
pressedKeyCodes[keyCode] = now
|
|
302
304
|
case .keyUp:
|
|
303
|
-
pressedKeyCodes.
|
|
305
|
+
pressedKeyCodes.removeValue(forKey: keyCode)
|
|
304
306
|
default:
|
|
305
307
|
break
|
|
306
308
|
}
|
|
307
309
|
}
|
|
308
310
|
|
|
311
|
+
private func expireStalePressedKeys(now: CFAbsoluteTime) {
|
|
312
|
+
let staleKeys = pressedKeyCodes.filter { now - $0.value > maxTrackedKeyDownDuration }.map(\.key)
|
|
313
|
+
guard !staleKeys.isEmpty else { return }
|
|
314
|
+
for keyCode in staleKeys {
|
|
315
|
+
pressedKeyCodes.removeValue(forKey: keyCode)
|
|
316
|
+
}
|
|
317
|
+
DiagnosticLog.shared.warn("KeyboardRemap: cleared stale key-down state for \(staleKeys.count) key(s)")
|
|
318
|
+
}
|
|
319
|
+
|
|
309
320
|
private func shouldTriggerEmergencyReset(type: CGEventType, event: CGEvent) -> Bool {
|
|
310
321
|
guard type == .keyDown else { return false }
|
|
311
322
|
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
|
|
312
323
|
let flags = event.flags
|
|
313
324
|
return keyCode == 40
|
|
314
|
-
&& pressedKeyCodes
|
|
325
|
+
&& pressedKeyCodes[53] != nil
|
|
315
326
|
&& flags.contains(.maskShift)
|
|
316
327
|
}
|
|
317
328
|
|
|
@@ -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
|
|
@@ -371,30 +371,19 @@ final class ScreenOverlayCanvasController {
|
|
|
371
371
|
let hasAgentLayer = layersByID.values.contains { $0.owner == .agentApi }
|
|
372
372
|
if hasAgentLayer, globalDismissMonitor == nil {
|
|
373
373
|
let mask: NSEvent.EventTypeMask = [
|
|
374
|
-
.mouseMoved,
|
|
375
374
|
.leftMouseDown,
|
|
376
|
-
.leftMouseUp,
|
|
377
375
|
.rightMouseDown,
|
|
378
376
|
.otherMouseDown,
|
|
379
|
-
.leftMouseDragged,
|
|
380
|
-
.rightMouseDragged,
|
|
381
|
-
.otherMouseDragged,
|
|
382
377
|
]
|
|
383
378
|
globalDismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in
|
|
384
379
|
DispatchQueue.main.async {
|
|
385
380
|
_ = self?.handlePointerEvent(event)
|
|
386
381
|
}
|
|
387
382
|
}
|
|
388
|
-
localDismissMonitor = NSEvent.addLocalMonitorForEvents(matching:
|
|
389
|
-
if event.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return nil
|
|
393
|
-
}
|
|
394
|
-
} else {
|
|
395
|
-
if self?.handlePointerEvent(event) == true {
|
|
396
|
-
return nil
|
|
397
|
-
}
|
|
383
|
+
localDismissMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
384
|
+
if event.keyCode == 53 {
|
|
385
|
+
self?.dismissAgentOverlays()
|
|
386
|
+
return nil
|
|
398
387
|
}
|
|
399
388
|
return event
|
|
400
389
|
}
|
|
@@ -416,35 +405,7 @@ final class ScreenOverlayCanvasController {
|
|
|
416
405
|
@discardableResult
|
|
417
406
|
private func handlePointerEvent(_ event: NSEvent) -> Bool {
|
|
418
407
|
switch event.type {
|
|
419
|
-
case .
|
|
420
|
-
updatePointerCapture(at: NSEvent.mouseLocation)
|
|
421
|
-
return false
|
|
422
|
-
case .leftMouseDown:
|
|
423
|
-
updatePointerCapture(at: NSEvent.mouseLocation)
|
|
424
|
-
if beginActorDrag(at: NSEvent.mouseLocation) {
|
|
425
|
-
return true
|
|
426
|
-
}
|
|
427
|
-
dismissAgentOverlays()
|
|
428
|
-
return false
|
|
429
|
-
case .leftMouseDragged:
|
|
430
|
-
if dragActor(to: NSEvent.mouseLocation) {
|
|
431
|
-
return true
|
|
432
|
-
}
|
|
433
|
-
dismissAgentOverlays()
|
|
434
|
-
return false
|
|
435
|
-
case .leftMouseUp:
|
|
436
|
-
let wasDragging = dragState != nil
|
|
437
|
-
endActorDrag()
|
|
438
|
-
updatePointerCapture(at: NSEvent.mouseLocation)
|
|
439
|
-
return wasDragging
|
|
440
|
-
case .rightMouseDown:
|
|
441
|
-
updatePointerCapture(at: NSEvent.mouseLocation)
|
|
442
|
-
if closeActor(at: NSEvent.mouseLocation) {
|
|
443
|
-
return true
|
|
444
|
-
}
|
|
445
|
-
dismissAgentOverlays()
|
|
446
|
-
return false
|
|
447
|
-
case .otherMouseDown, .rightMouseDragged, .otherMouseDragged:
|
|
408
|
+
case .leftMouseDown, .rightMouseDown, .otherMouseDown:
|
|
448
409
|
dismissAgentOverlays()
|
|
449
410
|
return false
|
|
450
411
|
default:
|
|
@@ -453,16 +414,7 @@ final class ScreenOverlayCanvasController {
|
|
|
453
414
|
}
|
|
454
415
|
|
|
455
416
|
private func updatePointerCapture(at globalPoint: CGPoint) {
|
|
456
|
-
|
|
457
|
-
let captureWindow: ScreenOverlayWindow?
|
|
458
|
-
if let dragState {
|
|
459
|
-
captureWindow = windowsByScreenID[dragState.screenID]
|
|
460
|
-
} else {
|
|
461
|
-
captureWindow = hitActor(at: globalPoint)?.window
|
|
462
|
-
}
|
|
463
|
-
for window in windowsByScreenID.values {
|
|
464
|
-
window.ignoresMouseEvents = window !== captureWindow
|
|
465
|
-
}
|
|
417
|
+
resetPointerCapture()
|
|
466
418
|
}
|
|
467
419
|
|
|
468
420
|
private func beginActorDrag(at globalPoint: CGPoint) -> Bool {
|
|
@@ -512,6 +464,7 @@ final class ScreenOverlayCanvasController {
|
|
|
512
464
|
let layer = layersByID[dragState.id],
|
|
513
465
|
case .pet(let payload) = layer.payload else {
|
|
514
466
|
self.dragState = nil
|
|
467
|
+
cancelActorDragTimeout()
|
|
515
468
|
resetPointerCapture()
|
|
516
469
|
return
|
|
517
470
|
}
|
|
@@ -520,7 +473,7 @@ final class ScreenOverlayCanvasController {
|
|
|
520
473
|
cancelActorDragTimeout()
|
|
521
474
|
render()
|
|
522
475
|
updateLifecycleMonitors()
|
|
523
|
-
|
|
476
|
+
resetPointerCapture()
|
|
524
477
|
}
|
|
525
478
|
|
|
526
479
|
private func clearStaleActorDragIfNeeded() {
|
|
@@ -562,7 +515,7 @@ final class ScreenOverlayCanvasController {
|
|
|
562
515
|
}
|
|
563
516
|
render()
|
|
564
517
|
updateLifecycleMonitors()
|
|
565
|
-
|
|
518
|
+
resetPointerCapture()
|
|
566
519
|
return true
|
|
567
520
|
}
|
|
568
521
|
|
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.
|