Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bdf2019
fix: cancel gestures after event tap interruption
KulmanD Jul 26, 2026
c8f4ad4
fix: cancel stale gesture holds on input
KulmanD Jul 26, 2026
b15d21c
test: cover repeated gesture recovery
KulmanD Jul 26, 2026
d917e3c
test: cover repeated stale hold cancellation
KulmanD Jul 26, 2026
16dd375
perf: reduce combined recovery binary size
KulmanD Jul 26, 2026
5d73c46
perf: reset smoothing between scroll sequences
KulmanD Jul 26, 2026
68df425
perf: skip stateless scroll sequence tracking
KulmanD Jul 26, 2026
1a38079
fix: preserve scroll delta representations
KulmanD Jul 26, 2026
eaf8cce
test: cover scroll field consistency
KulmanD Jul 26, 2026
aab60f8
perf: skip inactive scroll representations
KulmanD Jul 26, 2026
a1786bf
test: cover combined scroll processing
KulmanD Jul 26, 2026
e2a773c
test: add repeatable candidate measurements
KulmanD Jul 26, 2026
7567aba
test: cover cumulative runtime recovery
KulmanD Jul 26, 2026
96001c8
perf: keep runtime below text boundary
KulmanD Jul 26, 2026
fb1c364
docs: record cumulative runtime readiness
KulmanD Jul 26, 2026
7174506
test: respect pixel scroll units
KulmanD Jul 26, 2026
ca57d4e
docs: record cumulative runtime CI result
KulmanD Jul 26, 2026
e3c6167
docs: reject raw gesture movement deltas
KulmanD Jul 26, 2026
ef70ec3
docs: record interruption recovery performance
KulmanD Jul 26, 2026
43f620b
docs: record stale hold performance
KulmanD Jul 26, 2026
741a01c
docs: record gesture recovery interaction results
KulmanD Jul 26, 2026
933316d
docs: record scroll reset performance
KulmanD Jul 26, 2026
db83fe1
docs: record scroll field performance
KulmanD Jul 26, 2026
dbe199c
docs: record scroll integration readiness
KulmanD Jul 26, 2026
1b714eb
docs: record scroll integration CI result
KulmanD Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions Sources/MX3Lite/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ struct CLI {
mxMasterRegistry.start()

let debugger = ScrollDebugger(config: loadedConfig.config, logger: logger, mxMasterRegistry: mxMasterRegistry)
let eventTap = try MouseEventTap(mode: .debugScroll(debugger), logger: logger)
let eventTap = try MouseEventTap(mode: .debugScroll(debugger))

let signalController = SignalController()
signalController.start {
Expand Down Expand Up @@ -352,10 +352,9 @@ struct CLI {
let mapper = GestureMapper(config: config, logger: logger, liveEventDebug: debug, mxMasterRegistry: mxMasterRegistry)
let eventTap = try MouseEventTap(
mode: .map(mapper),
eventTypes: debug ? nil : mapper.primaryEventTypes,
logger: logger
eventTypes: debug ? nil : mapper.primaryEventTypes
)
let movementEventTap = try makeMovementEventTap(for: mapper, shouldCreate: !debug, logger: logger)
let movementEventTap = try makeMovementEventTap(for: mapper, shouldCreate: !debug)

let signalController = SignalController()
signalController.start {
Expand Down Expand Up @@ -399,8 +398,8 @@ struct CLI {
}

let mapper = GestureMapper(config: config, logger: logger, liveEventDebug: false, mxMasterRegistry: mxMasterRegistry)
let eventTap = try MouseEventTap(mode: .map(mapper), eventTypes: mapper.primaryEventTypes, logger: logger)
let movementEventTap = try makeMovementEventTap(for: mapper, shouldCreate: true, logger: logger)
let eventTap = try MouseEventTap(mode: .map(mapper), eventTypes: mapper.primaryEventTypes)
let movementEventTap = try makeMovementEventTap(for: mapper, shouldCreate: true)

let signalController = SignalController()
signalController.start {
Expand All @@ -419,17 +418,15 @@ struct CLI {

private func makeMovementEventTap(
for mapper: GestureMapper,
shouldCreate: Bool,
logger: DebugLogger
shouldCreate: Bool
) throws -> MouseEventTap? {
guard shouldCreate, !mapper.movementEventTypes.isEmpty else {
return nil
}

let movementEventTap = try MouseEventTap(
mode: .map(mapper),
eventTypes: mapper.movementEventTypes,
logger: logger
eventTypes: mapper.movementEventTypes
)
mapper.onGestureActivityChanged = { [weak movementEventTap] isActive in
movementEventTap?.setEnabled(isActive)
Expand Down
67 changes: 55 additions & 12 deletions Sources/MX3Lite/GestureMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,30 @@ final class GestureMapper {
return suppressed
}

func cancelGestureForEventTapInterruption() {
guard gestureState.isActive else {
return
}

gestureState.end()
onGestureActivityChanged?(false)
}

#if DEBUG
var gestureStateForTesting: GestureState {
gestureState
}
#endif

private func handleButtonDown(event: CGEvent) -> Bool {
let button = Int(event.getIntegerValueField(.mouseEventButtonNumber))
if gestureState.isActive {
_ = cancelGestureIfStale(event: event)
}

switch GestureState.buttonDownDecision(button: button, buttons: config.buttons, gestureActive: gestureState.isActive) {
case .beginGesture:
gestureState.begin(at: event.location)
gestureState.begin(at: event.location, timestamp: event.timestamp)
onGestureActivityChanged?(true)
logger.log("gesture start button=\(button) startLocation=\(event.location.compactDescription)")
return true
Expand All @@ -99,11 +117,14 @@ final class GestureMapper {

private func handleButtonUp(event: CGEvent) -> Bool {
let button = Int(event.getIntegerValueField(.mouseEventButtonNumber))
let staleGestureCancelled = gestureState.isActive && cancelGestureIfStale(event: event)

if matches(button, config.buttons.gesture) {
logger.log("gesture end button=\(button) final accumulatedDx=\(format(gestureState.accumulatedDx)) accumulatedDy=\(format(gestureState.accumulatedDy)) accumulatedScrollX=\(format(gestureState.accumulatedScrollX)) accumulatedScrollY=\(format(gestureState.accumulatedScrollY))")
gestureState.end()
onGestureActivityChanged?(false)
if !staleGestureCancelled {
logger.log("gesture end button=\(button) final accumulatedDx=\(format(gestureState.accumulatedDx)) accumulatedDy=\(format(gestureState.accumulatedDy)) accumulatedScrollX=\(format(gestureState.accumulatedScrollX)) accumulatedScrollY=\(format(gestureState.accumulatedScrollY))")
gestureState.end()
onGestureActivityChanged?(false)
}
return true
}

Expand All @@ -118,6 +139,9 @@ final class GestureMapper {
guard gestureState.isActive else {
return false
}
guard !cancelGestureIfStale(event: event) else {
return false
}

let currentLocation = event.location
let previousLocation = gestureState.lastLocation ?? currentLocation
Expand All @@ -135,14 +159,11 @@ final class GestureMapper {

private func handleScroll(event: CGEvent) -> Bool {
guard gestureState.isActive else {
if let result = scrollTransformer.apply(
to: event,
config: config,
mxMasterRegistry: mxMasterRegistry,
captureDebug: liveEventDebug
) {
logger.log(result.debugDescription(prefix: "scroll transform"))
}
transformInactiveScroll(event: event)
return false
}
guard !cancelGestureIfStale(event: event) else {
transformInactiveScroll(event: event)
return false
}

Expand All @@ -158,6 +179,28 @@ final class GestureMapper {
return false
}

private func cancelGestureIfStale(event: CGEvent) -> Bool {
guard gestureState.isStale(at: event.timestamp) else {
return false
}

logger.log("stale gesture cancelled")
gestureState.end()
onGestureActivityChanged?(false)
return true
}

private func transformInactiveScroll(event: CGEvent) {
if let result = scrollTransformer.apply(
to: event,
config: config,
mxMasterRegistry: mxMasterRegistry,
captureDebug: liveEventDebug
) {
logger.log(result.debugDescription(prefix: "scroll transform"))
}
}

private func evaluateGesture(source: String) {
guard let action = gestureState.consumeAction(
threshold: config.threshold,
Expand Down
14 changes: 13 additions & 1 deletion Sources/MX3Lite/GestureState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ enum ButtonDownDecision: Equatable {
}

struct GestureState {
static let staleHoldTimeout: CGEventTimestamp = 30_000_000_000

private(set) var isActive = false
private(set) var isConsumed = false
private(set) var lastLocation: CGPoint?
private(set) var startedAt: CGEventTimestamp?
private(set) var accumulatedDx = 0.0
private(set) var accumulatedDy = 0.0
private(set) var accumulatedScrollX = 0.0
Expand All @@ -34,10 +37,11 @@ struct GestureState {
return .passThrough
}

mutating func begin(at location: CGPoint) {
mutating func begin(at location: CGPoint, timestamp: CGEventTimestamp = 0) {
isActive = true
isConsumed = false
lastLocation = location
startedAt = timestamp
accumulatedDx = 0
accumulatedDy = 0
accumulatedScrollX = 0
Expand All @@ -48,12 +52,20 @@ struct GestureState {
isActive = false
isConsumed = false
lastLocation = nil
startedAt = nil
accumulatedDx = 0
accumulatedDy = 0
accumulatedScrollX = 0
accumulatedScrollY = 0
}

func isStale(at timestamp: CGEventTimestamp, timeout: CGEventTimestamp = staleHoldTimeout) -> Bool {
guard isActive, let startedAt, timestamp >= startedAt else {
return false
}
return timestamp - startedAt >= timeout
}

mutating func addMovement(to location: CGPoint, invertY: Bool) {
guard isActive else {
return
Expand Down
19 changes: 8 additions & 11 deletions Sources/MX3Lite/MouseEventTap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,15 @@ enum MouseEventTapMode {
final class MouseEventTap {
private let mode: MouseEventTapMode
private let configuredEventTypes: [CGEventType]
private let logger: DebugLogger?
private var machPort: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var isStarted = false
private var shouldBeEnabled = false
private var recoveryMessagePrinted = false

init(mode: MouseEventTapMode, eventTypes: [CGEventType]? = nil, logger: DebugLogger? = nil) throws {
init(mode: MouseEventTapMode, eventTypes: [CGEventType]? = nil) throws {
self.mode = mode
self.configuredEventTypes = eventTypes ?? mode.eventTypes
self.logger = logger
try createTap()
}

Expand Down Expand Up @@ -112,10 +110,14 @@ final class MouseEventTap {

fileprivate func handle(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
if case .map(let mapper) = mode {
mapper.cancelGestureForEventTapInterruption()
}

if shouldBeEnabled, let machPort {
CGEvent.tapEnable(tap: machPort, enable: true)
logTapRecovery(type: type)
}
logTapRecovery()
return Unmanaged.passUnretained(event)
}

Expand All @@ -134,17 +136,12 @@ final class MouseEventTap {
}
}

private func logTapRecovery(type: CGEventType) {
if let logger, logger.isEnabled {
logger.log("event tap disabled type=\(type.label); re-enabled")
return
}

private func logTapRecovery() {
guard !recoveryMessagePrinted else {
return
}
recoveryMessagePrinted = true
FileHandle.standardError.writeLine("mx3-lite: event tap was disabled; re-enabled")
FileHandle.standardError.writeLine("mx3-lite: event tap re-enabled; gesture reset")
}
}

Expand Down
Loading
Loading