Skip to content

Touch classifier - #137

Open
danielwallner wants to merge 8 commits into
NullPointerDepressiveDisorder:mainfrom
danielwallner:touch-classifier
Open

Touch classifier#137
danielwallner wants to merge 8 commits into
NullPointerDepressiveDisorder:mainfrom
danielwallner:touch-classifier

Conversation

@danielwallner

Copy link
Copy Markdown

Description

These commits mainly address the shortcoming that MiddleDrag did not support resting your thumb on the touchpad.
This made thumb + two finger scroll trigger a middle drag.
I find that using the left thumb for clicks makes the right hand a lot more relaxed and separating force (click) from movement also increases the precision.
Without this fix MiddleDrag is unusable for anyone that is using the thumb to click.
An additional benefit is that this also makes it possible to support middle drag + left click that e.g. FreeCAD uses to orbit.
With these changes it is possible to pan with three fingers and then click with the thumb to add a left click to rotate/orbit.

There is also a touch debug window and calibration in these commits.

Do note that I am not a Swift programmer and this was mostly vibe coded.

I have tested this on both an M4 Air and a 2013 MacBook Pro.
With these fixes FreeCAD is fully useable with the touchpad. (With Allow Left Click During Drag enabled.)

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to change)
  • 📚 Documentation update
  • 🔧 Refactor (code change that neither fixes a bug nor adds a feature)
  • 🧪 Test improvement

Related Issues

Testing Performed

  • Tested with built-in trackpad
  • Tested with Magic Trackpad (if available)
  • Verified system gestures (Mission Control, etc.) still work
  • Tested in target apps (list any specific apps tested):

Checklist

  • My code follows the project's code style guidelines
  • I have tested my changes thoroughly
  • I have added comments for non-obvious logic
  • My changes don't break existing functionality
  • I have updated documentation if needed

Code Coverage

  • I have added tests for new functionality (where applicable)
  • My changes meet the 80% patch coverage requirement

Note: Codecov will automatically check patch coverage. PRs that don't meet the 80% threshold will fail the coverage check. Files in MiddleDragTests/, AppDelegate.swift, MiddleDragApp.swift, and UpdateManager.swift are excluded from coverage requirements.

Screenshots / Recordings

Additional Notes

Route both gesture recognition and force-click conversion through a single
stateful TouchClassifier so a resting/moving thumb or a fragmenting palm no
longer inflates the finger count. Classification combines size/shape evidence
with differential-motion evidence (a contact that stays still or moves against
the digit cluster while the others drag), a palm-satellite rule keyed off a
heel-sized contact, and sticky per-touch roles. The recognizer forgives an
extra ambiguous/stationary contact during an active drag (thumb landing to
click) and confirms four-finger cancellation over a few frames so a single
classification flicker cannot drop a drag.

Force-click conversion and the drag centroid now use the classified digit
count, not the raw contact count. Thresholds live in a per-user
TouchClassifierCalibration (defaults measured from real trackpad readings) that
can be derived from labelled recording sessions and persisted to disk. Includes
the session/recording model used for calibration and debug capture, plus unit
tests and replay fixtures recorded on real hardware (palm rest, resting thumb,
three-finger drag with thumb click) asserting MiddleDrag never falsely starts
or cancels.
Advanced menu item opening a window that draws each active contact as an
ellipse (sized by major/minor axis, coloured by classified role) with motion
trails, the digit-cluster centroid, live raw/digit/incidental/uncertain counts,
and recognizer/event state. Supports labelled session recording with JSON and
CSV export, used to capture real trackpad data for tuning and calibration.
Guided wizard (Advanced > Palm Rejection) that walks through natural hand
postures, records a few seconds per step, validates each sample, and derives
per-user classifier thresholds that persist to disk and load at launch. Adds
"Ignore Incidental Contacts" to toggle the classifier-based filtering and
"Reset Touch Calibration" to revert to defaults.
Two opt-in Advanced toggles for compatibility with certain apps:
"Preserve Modifier Keys During Drag" forwards the live keyboard modifier state
on the synthetic middle-button events instead of always reporting none, and
"Allow Left Click During Drag" passes a real left click (e.g. a thumb press)
through to the app while a middle-drag is active instead of suppressing it.
Both default off to preserve existing behavior.
…ssifier

The shared touch classifier now handles thumb/palm rejection by contact
shape/size and motion, making the two crude pre-filters redundant: the
fixed bottom-of-trackpad exclusion zone and the zTotal large-contact filter.
Removes their config fields, preferences, menu items and actions, and the
classifier's passesLegacyFilters pre-pass, along with the tests that covered
them. Require Modifier Key is kept — it gates gesture activation rather than
rejecting palms.
Lets repeated three-finger swipes accumulate into one continuous
middle-drag - e.g. rotating a CAD view further than a single swipe's
travel allows - instead of each lift ending the drag outright. Only
active when allowLeftClickDuringDrag is on and the real left mouse
button stays physically held across the lift.

The synthetic middle button is kept down in MouseEventGenerator, but
isActivelyDragging still drops like a normal end so the rest of the
system (cursor movement, other clicks) stays fully responsive during
the pause between swipes; only the mouse generator's internal button
state is preserved. A 4-finger Mission Control gesture still cancels
unconditionally. The stuck-drag watchdog keeps running throughout with
an extended timeout while paused, so a legitimate multi-second gap
isn't force-released, but a truly stuck hold still self-heals.
Revoking (or fully removing) MiddleDrag's Accessibility entry while its
event tap is alive left the tap registered but broken: a session tap
created with .defaultTap (active filtering) that loses trust keeps
swallowing every mouse/keyboard event system-wide until the process is
killed, rather than failing cleanly (see Apple Developer Forums thread
735204, and deskflow#9562 for the same symptom in another tool). The
previous code also blindly re-enabled the tap on any disable reason,
including tapDisabledByUserInput - the signal macOS sends specifically
when the user revokes trust - actively fighting that action.

Toggling the permission off reliably delivers tapDisabledByUserInput,
so that path now tears the tap down for real (CFMachPortInvalidate +
run loop source removal) instead of just skipping the re-enable.
Fully removing the entry (vs. unchecking it) turned out not to deliver
that callback at all, and left CGEvent.tapIsEnabled on the existing tap
reporting true even though it had gone dead - so a second, independent
1-second timer now probes by actually creating (and discarding) a
fresh tap, which macOS reliably refuses when trust is gone, regardless
of what the existing tap or AXIsProcessTrusted() report.

Also fixes a related gap where MultitouchManager.stop() never told the
menu bar to refresh, leaving the "enabled" icon stale after a silent
revocation until some unrelated trigger (device connect, polling
timeout, manual toggle) happened to run.
Comment thread MiddleDrag/Managers/MultitouchManager.swift
isRealLeftButtonDown and isSustainingDragForLeftClick were plain vars
read and written from both processEvent (event tap callback, main
thread) and the GestureRecognizerDelegate callbacks (gestureQueue) with
no synchronization - a real cross-thread race, unlike currentFingerCount
and lastForceClickTime which already use this lock pattern in the same
file.

processEvent's left-mouse-up handler and
gestureRecognizerDidBeginDragging both do a check-then-act on
isSustainingDragForLeftClick, so without synchronization both could
observe true concurrently and double-handle the same sustained hold -
one ending the drag while the other simultaneously tries to resume it.
Added claimSustainingDragForLeftClick() to make that check-and-clear
atomic under one lock, not just each property access individually.
Comment on lines 1307 to +1317
self?.isActivelyDragging = true
}

if claimSustainingDragForLeftClick() {
// The middle button was never released after the last three-finger
// lift (see gestureRecognizerDidEndDragging) - resume in place rather
// than pressing it down again, which would look like a fresh drag to
// the target app and cancel the in-progress CAD rotation.
mouseGenerator.resumeDragKeepingButtonHeld()
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A race condition when resuming a drag causes left-clicks to be incorrectly suppressed because isActivelyDragging is set asynchronously, creating a window where the flag is not yet true.
Severity: HIGH

Suggested Fix

To eliminate the race condition, update the isActivelyDragging state synchronously using DispatchQueue.main.sync instead of async. This ensures the flag is set before any other code can check its value, preventing left-clicks from being incorrectly suppressed when a drag is resumed.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: MiddleDrag/Managers/MultitouchManager.swift#L1307-L1317

Potential issue: In the `gestureRecognizerDidBeginDragging` method, the
`isActivelyDragging` flag is set to `true` within a `DispatchQueue.main.async` block.
However, subsequent logic, such as `resumeDragKeepingButtonHeld`, executes synchronously
on a separate `gestureQueue` before the async block completes. This creates a race
condition. If a left-click event is processed on the main thread during this brief
window, it will read `isActivelyDragging` as `false`. As a result, the pass-through
check for the click will fail, causing it to be incorrectly suppressed instead of being
passed to the target application, which breaks the intended workflow for the 'allow left
click during drag' feature.

@danielwallner

Copy link
Copy Markdown
Author

I have pushed a fix for the data race and also created a new branch (touch-classifier-rebased) with the commit that introduced this fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant