Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ffb3d01
refactor: centralize generated config defaults
KulmanD Jul 26, 2026
57a16e1
test: enforce config default consistency
KulmanD Jul 26, 2026
8ac3154
docs: clarify config default ownership
KulmanD Jul 26, 2026
600e40e
docs: record config defaults performance result
KulmanD Jul 26, 2026
7189fa5
release: add signed multi-architecture packaging
KulmanD Jul 26, 2026
a13c93b
docs: record distribution readiness evidence
KulmanD Jul 26, 2026
7cd0233
docs: align distribution report revision
KulmanD Jul 26, 2026
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
2b5d27b
docs: record distribution CI result
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
b95b7dd
merge: assemble final feature candidate
KulmanD Jul 26, 2026
bca953e
docs: record complete candidate readiness
KulmanD Jul 26, 2026
727060f
docs: record final candidate CI result
KulmanD Jul 26, 2026
ed65a24
docs: record final candidate clean idle result
KulmanD Jul 27, 2026
19892e7
docs: record final candidate physical validation
KulmanD Jul 27, 2026
0130c71
docs: prepare public launch documentation
KulmanD Jul 27, 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
30 changes: 30 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ jobs:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Verify generated config defaults
run: swift scripts/generate-config-defaults.swift --check
- name: Run unit tests
run: swift test
- name: Build release executable
Expand All @@ -29,3 +31,31 @@ jobs:
echo "sharing archive contains development tests or performance probes" >&2
exit 1
fi
unzip -Z1 "$archive" | grep -q '/config/defaults.json$'
if unzip -Z1 "$archive" | grep -qi 'default-config'; then
echo "sharing archive contains a retired config name" >&2
exit 1
fi
- name: Verify app config resource
run: |
bash scripts/build-app.sh
test -f dist/mx3-lite.app/Contents/Resources/defaults.json
test ! -e dist/mx3-lite.app/Contents/Resources/default-config.json

release-archives:
runs-on: macos-14
strategy:
matrix:
arch: [arm64, x86_64]
steps:
- uses: actions/checkout@v4
- name: Check release scripts
run: bash -n scripts/build-release-app.sh scripts/package-release.sh scripts/verify-release.sh
- name: Build and verify ${{ matrix.arch }} validation archive
run: bash scripts/package-release.sh --arch "${{ matrix.arch }}"
- name: Upload validation archive
uses: actions/upload-artifact@v4
with:
name: mx3-lite-${{ matrix.arch }}-validation
path: dist/releases/v*/
if-no-files-found: error
398 changes: 273 additions & 125 deletions README.md

Large diffs are not rendered by default.

34 changes: 19 additions & 15 deletions Sources/MX3AppSupport/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,24 +93,28 @@ public struct AppSettings: Codable, Equatable, Sendable {
public var scrollTransformMode: AppScrollTransformMode

public static let `default` = AppSettings(
threshold: 220,
buttons: AppButtonSettings(gesture: 5, back: 3, forward: 4),
threshold: ConfigDefaults.threshold,
buttons: AppButtonSettings(
gesture: ConfigDefaults.gestureButton,
back: ConfigDefaults.backButton,
forward: ConfigDefaults.forwardButton
),
downAction: .ctrlDown,
invertY: false,
invertDesktopGestureDirection: true,
invertY: ConfigDefaults.invertY,
invertDesktopGestureDirection: ConfigDefaults.invertDesktopGestureDirection,
actionBackend: .systemEvents,
invertVerticalScroll: true,
invertHorizontalScroll: false,
scrollMultiplier: 1,
smoothScroll: true,
invertVerticalScroll: ConfigDefaults.invertVerticalScroll,
invertHorizontalScroll: ConfigDefaults.invertHorizontalScroll,
scrollMultiplier: ConfigDefaults.scrollMultiplier,
smoothScroll: ConfigDefaults.smoothScroll,
smoothScrollMode: .ema,
smoothScrollFactor: 0.35,
scrollDeadzone: 0,
maxScrollStep: 8,
scrollAcceleration: 1,
freeSpinDamping: 0.75,
ratchetStepMultiplier: 1,
allowGlobalScrollTransform: false,
smoothScrollFactor: ConfigDefaults.smoothScrollFactor,
scrollDeadzone: ConfigDefaults.scrollDeadzone,
maxScrollStep: ConfigDefaults.maxScrollStep,
scrollAcceleration: ConfigDefaults.scrollAcceleration,
freeSpinDamping: ConfigDefaults.freeSpinDamping,
ratchetStepMultiplier: ConfigDefaults.ratchetStepMultiplier,
allowGlobalScrollTransform: ConfigDefaults.allowGlobalScrollTransform,
scrollTransformMode: .mxMasterOnly
)

Expand Down
23 changes: 23 additions & 0 deletions Sources/MX3AppSupport/ConfigDefaults.generated.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
enum ConfigDefaults {
static let threshold = 220.0
static let gestureButton = 5
static let backButton = 3
static let forwardButton = 4
static let downAction = AppDownAction.ctrlDown
static let invertY = false
static let invertDesktopGestureDirection = true
static let actionBackend = AppActionBackend.systemEvents
static let invertVerticalScroll = true
static let invertHorizontalScroll = false
static let scrollMultiplier = 1.0
static let smoothScroll = true
static let smoothScrollMode = AppSmoothScrollMode.ema
static let smoothScrollFactor = 0.35
static let scrollDeadzone = 0.0
static let maxScrollStep = 8.0
static let scrollAcceleration = 1.0
static let freeSpinDamping = 0.75
static let ratchetStepMultiplier = 1.0
static let allowGlobalScrollTransform = false
static let scrollTransformMode = AppScrollTransformMode.mxMasterOnly
}
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
34 changes: 19 additions & 15 deletions Sources/MX3Lite/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -390,24 +390,28 @@ struct AppConfig: Codable {
var scrollTransformMode: ScrollTransformMode

static let `default` = AppConfig(
threshold: 220,
buttons: ButtonConfig(gesture: 5, back: 3, forward: 4),
threshold: ConfigDefaults.threshold,
buttons: ButtonConfig(
gesture: ConfigDefaults.gestureButton,
back: ConfigDefaults.backButton,
forward: ConfigDefaults.forwardButton
),
downAction: .ctrlDown,
invertY: false,
invertDesktopGestureDirection: true,
invertY: ConfigDefaults.invertY,
invertDesktopGestureDirection: ConfigDefaults.invertDesktopGestureDirection,
actionBackend: .systemEvents,
invertVerticalScroll: true,
invertHorizontalScroll: false,
scrollMultiplier: 1,
smoothScroll: true,
invertVerticalScroll: ConfigDefaults.invertVerticalScroll,
invertHorizontalScroll: ConfigDefaults.invertHorizontalScroll,
scrollMultiplier: ConfigDefaults.scrollMultiplier,
smoothScroll: ConfigDefaults.smoothScroll,
smoothScrollMode: .ema,
smoothScrollFactor: 0.35,
scrollDeadzone: 0,
maxScrollStep: 8,
scrollAcceleration: 1,
freeSpinDamping: 0.75,
ratchetStepMultiplier: 1,
allowGlobalScrollTransform: false,
smoothScrollFactor: ConfigDefaults.smoothScrollFactor,
scrollDeadzone: ConfigDefaults.scrollDeadzone,
maxScrollStep: ConfigDefaults.maxScrollStep,
scrollAcceleration: ConfigDefaults.scrollAcceleration,
freeSpinDamping: ConfigDefaults.freeSpinDamping,
ratchetStepMultiplier: ConfigDefaults.ratchetStepMultiplier,
allowGlobalScrollTransform: ConfigDefaults.allowGlobalScrollTransform,
scrollTransformMode: .mxMasterOnly
)

Expand Down
23 changes: 23 additions & 0 deletions Sources/MX3Lite/ConfigDefaults.generated.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
enum ConfigDefaults {
static let threshold = 220.0
static let gestureButton = 5
static let backButton = 3
static let forwardButton = 4
static let downAction = DownAction.ctrlDown
static let invertY = false
static let invertDesktopGestureDirection = true
static let actionBackend = ActionBackend.systemEvents
static let invertVerticalScroll = true
static let invertHorizontalScroll = false
static let scrollMultiplier = 1.0
static let smoothScroll = true
static let smoothScrollMode = SmoothScrollMode.ema
static let smoothScrollFactor = 0.35
static let scrollDeadzone = 0.0
static let maxScrollStep = 8.0
static let scrollAcceleration = 1.0
static let freeSpinDamping = 0.75
static let ratchetStepMultiplier = 1.0
static let allowGlobalScrollTransform = false
static let scrollTransformMode = ScrollTransformMode.mxMasterOnly
}
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
Loading
Loading