Hold a press for the length of a touch, and fix the click sequence counter - #29
Hold a press for the length of a touch, and fix the click sequence counter#29sapkra wants to merge 4 commits into
Conversation
A touch only produced a click on lift-off if `cursorTouchQualifiedForTap` was still set, and that flag was cleared the moment two consecutive HID reports differed by more than 0.1 mm. That is below the resolution of many digitizers and well below the shift of the reported contact centroid as a finger flattens onto the glass, so on affected panels every tap was read as the start of a drag: the cursor moved to the touch point and no click was ever generated. Hold-and-drag could never arm for the same reason, since it also required the tap flag. Measure both decisions against an anchor instead of the previous report: - A touch stays a tap until the finger leaves a slop radius around where it landed, configurable as "Tap Zone" (default 2.5 mm). - The hold clock runs while the finger stays within 1 mm of its anchor and restarts when it wanders off, so a slow deliberate drag still never turns into a hold. It is evaluated on every report rather than only on stationary ones, which a flickering phase used to swallow. - Scroll and drag no longer start until the tap slop has been exceeded, so digitizer noise can no longer leak a few pixels of scroll into a tap. The per-report threshold is kept, but only to classify the touch phase, where a small value keeps fine slow scrolling responsive. Also in the same paths: - Distances were computed from relative coordinates scaled by the panel width on both axes, which made every vertical threshold wrong on non-square panels. `-[TUCScreen millimetreDistanceBetweenRelativePoint:and:]` now scales each axis by its own physical extent. - Panels reporting a zero EDID physical size turned every mm threshold into 0 or infinity. `-[TUCScreen effectivePhysicalSize]` falls back to an assumed density. - Dropped a dead branch that re-posted `identifiedMultitouchGesture` after `stopCurrentGesture` had already cleared it. - Fixed the "$errorResistance" key typo that stopped Error Resistance from ever persisting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The distance guard in the click-sequence counter compared the two signed axis deltas against the tolerance and required *both* to exceed it. Two quick taps therefore only started a new sequence when the second landed down and to the right of the first; in every other direction the guard could not fire, and the tap inherited click state 2. On a large touchscreen, where consecutive taps are naturally far apart, that turned any pair of quick taps anywhere on the glass into a double click. Compare the distance as a radius instead, which is what the "Double Click Zone" setting already describes. The counter is shared with the start of a drag, which deliberately inherits an elevated click state so that a drag right after a tap selects by word. That still works — the drag begins inside the tap slop, well within the zone — while a drag starting far from the last tap now correctly begins a plain drag instead of a word selection. This is the first release in which the tolerance actually binds, so the value range is adjusted to match: - The slider no longer offers 0 mm, which would mean two taps can never be close enough to double click, and a stored 0 from when the setting was inert is lifted to 1 on load. - Its ceiling goes from 8 to 16 mm. Until now the guard was effectively unbounded, so nothing tested how far apart a deliberate double tap lands on a wall-sized panel; the headroom keeps those devices working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two follow-ups to the tap-slop work, both in the click bookkeeping. ## Press and hold A tap emits its mouse-down and mouse-up together at lift-off, so no app ever observes a button being held. That is inherent to the design rather than an oversight: while the finger is on the glass the gesture is undecided — tap, scroll, pinch and secondary click all begin identically — and a delivered press cannot be withdrawn, so pressing at touch-down would start a text selection every time the user meant to scroll. The press can be emitted early once the gesture stops being ambiguous, and recognising a hold is exactly that moment: a scroll or a pinch would have moved by then. `TUCCursorGestureLongPress` — until now defined but never posted — is emitted there, once per touch. Mapping it to a drag presses the button and holds it until lift-off, which is what apps reacting to a held button need. The lift-off click is then suppressed, keyed off the button already being down, so a touch still actuates exactly once. Exposed as "Press and Hold" in Settings → Gestures, off by default: it turns a long rest into a held button rather than the click it produces today, and that should be a deliberate choice. With it off the gesture maps to no action and nothing about the current behaviour changes. Releasing on digitizer disconnect goes with it. Stale touches are only reaped as further reports arrive, so a device unplugged mid-touch left the button down forever — reachable before this change during a drag, and more reachable now that any resting finger can hold the button. ## Click sequence bookkeeping Both defects made the sequence advance when it should have restarted: - The count wrapped from 4 back to 1, so the fifth press of a rapid series looked like the second press of a new double click and repeated tapping produced double clicks nobody asked for. A real mouse keeps counting — a quadruple click selects a paragraph in some text views — so the cap is gone. - Only `performClickAt:` moved the reference time and location forward, so the press that starts a drag consumed a count without moving them. The window for the next tap was still measured from the click *before* the drag, letting that tap inherit a count it had not earned: tap, drag, tap in one spot arrived as a triple click. The update now lives in `updateCursorClickCountWithLocation:`, alongside the decision it belongs to, so every emitted press becomes the reference for the next. This is also what makes press-and-hold usable: with no `performClickAt:` in that path, the reference would never advance and a double tap could never form. `performClickAt:` also built its pair by re-typing and re-posting one event, which gave the press and the release a single shared creation timestamp and therefore a press duration of exactly zero. It now creates both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With "Bring Windows to Front" enabled, a tap on a window that is not frontmost produced two full clicks: `TUCCursorActionMoveClickIfNeeded` injected one to raise the window, and the same tap's lift-off produced the real one. Both went through `performClickAt:`, so the injected click advanced the click sequence and the real one arrived at the same spot, within the double-click interval, as click state 2 — every tap on a background window was a double click. In Finder that opens the item the user only meant to select. The reverse leaked too: because the raise reused the user's current click count, a tap shortly after a double tap raised the window with click state 2 or 3. `bringWindowToFrontAt:` already exists for exactly this — click state pinned to 1, click sequence untouched — and was never wired up. Call it instead, and document why the distinction matters so the general-purpose click does not get substituted back in. The title-bar strip in `isLocationOutsideFrontmostWindow` exists only as a workaround for this bug, and in principle becomes redundant here. It is kept for now, with a comment recording that: the case it guards against is a window zooming to fullscreen, which is destructive enough that confirming it on real hardware should come before deleting the guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for the very thorough write-up. I agree that this is more than a textual conflict, and that the shared tap-slop and click bookkeeping should probably be established first rather than duplicated across the two implementations. The overlap around the Euclidean double-click check is straightforward, and I am happy to drop or rework that part of #23 depending on the eventual merge order. The same applies to the origin-based tap tolerance: having one unconditional My current preference would be to land #28 first, then #29 on top of it, and finally rebase #23 onto the resulting code. I can then keep the parts that are still distinct in #23—per-digitizer calibration, HID/event logging, device identity, and the Windows-style gesture mappings—while adapting them to the shared tap-slop and click-sequence implementation. The long-press behavior will need an explicit reconciliation rather than a mechanical conflict resolution, since #23 currently maps it to secondary click in Windows Touch Mode while this PR introduces an optional held left-button action. I think those behaviors can coexist with a clear precedence or mutually exclusive setting. The fixes here for click-sequence anchoring and the window-raising click also make sense to me. I can help test the press-and-hold path, background-window clicks, title-bar behavior, and the interaction with Windows Touch Mode on real touchscreen hardware once the branches are in a testable order. Thanks again for tracing the overlap in detail and calling out the relevant parts of #23. |
Three follow-ups to the tap-slop PR, all in the click bookkeeping. Stacked on top of it —
please review that one first.
Relationship to #23 (please read before merging either)
#23 independently found and fixed one of these bugs, and the two changes collide. Worth
settling merge order first.
Overlap. The Euclidean double-click fix (
hypotinstead of comparing two signed axisdeltas with
&&) is in both. @Jac0b-Shi got there independently — same diagnosis, same fix.It sits in this PR's base branch rather than here, so one of the two should drop it,
whichever lands second.
Conflicts. A test merge against #23 conflicts in four files:
The same four conflict from the base branch alone, so this is one overlap across the whole
stack rather than something introduced here. This PR deepens it: #23 adds
logCursorEventslogging inside
performClickAt:, which this PR rewrites, and both editupdateCursorClickCountWithLocation:and the tail ofprocessTouchesForCursorInput.Not just textual. #23 adds
cursorTouchInitialLocation+tapMovementTolerance—origin-based tap slop, the same mechanism as the base branch — but gated behind
usesWindowsTouchMode:With the mode off (the default)
!usesWindowsTouchModeis true and the flag is cleared on anynon-stationary report, exactly as before — so #23 leaves #13 unfixed for anyone who doesn't
opt into a mode that also changes first-tap-moves/second-tap-clicks, long-press → right-click,
and one-finger-drag → drag. Once tap slop exists unconditionally, that machinery is largely
redundant and Windows Touch Mode can be built on
tapToleranceinstead of carrying its own.Whoever merges second should reconcile rather than resolve markers.
One more thing worth folding in from #23 either way: its
distanceInMillimetersBetweenRelativePoint:and:locationID:readsnativePhysicalSizeunguarded, so on a panel reporting no EDID size it returns 0 and the tolerance becomes
effectively infinite. The base branch's
effectivePhysicalSizecovers that.Press and hold
A tap emits its mouse-down and mouse-up together at lift-off, so no app ever observes the
button being held. This came up in #13, where it was floated as a possible cause:
That's an accurate description, and it isn't the cause of #13 — but it is a real limitation,
and the reason behind it is worth stating because it constrains any fix. While the finger is
on the glass the gesture is genuinely undecided: tap, scroll, pinch and secondary click all
begin identically. A delivered press cannot be withdrawn, so pressing at touch-down would
start a text selection every single time the user meant to scroll. There is no eager press
that is safe in general.
There is a safe moment, and the tap-slop work created it: by the time a hold is recognised,
a scroll or a pinch would already have moved. So
TUCCursorGestureLongPress— defined in theenum since the beginning and never once posted — is now emitted there, exactly once per
touch. Mapping it to a drag presses the button at that point and holds it until lift-off,
which is what apps reacting to a held button need. The lift-off click is then suppressed,
keyed off the button already being down, so a touch still actuates exactly once.
Exposed as "Press and Hold" in Settings → Gestures, off by default. It converts a
long rest into a held button rather than the click it produces today, and that should be a
deliberate choice rather than something users discover by accident. With it off the gesture
maps to
.noneand nothing about current behaviour changes.Releasing the button on digitizer disconnect goes with it. Stale touches are only reaped as
further reports arrive, so a device unplugged mid-touch left the button down forever. That
was already reachable during a drag; it becomes more reachable once any resting finger can
hold the button, so
didDisconnectTouchscreenWithLocationID:now stops the gesture.Click sequence counter
Two defects, both making the sequence advance where it should have restarted.
The count wrapped from 4 back to 1. So the fifth press of a rapid series looked like the
second press of a new double click, and repeated tapping produced double clicks nobody asked
for. A real mouse keeps counting past three — a quadruple click selects a paragraph in some
text views — so the cap is simply gone rather than moved.
Only
performClickAt:moved the reference time and location forward. The press thatstarts a drag advanced the count without moving them, so the window for the next tap was
still measured from the click before the drag, letting that tap inherit a count it had not
earned — tap, drag, tap in one spot arrived as a triple click. The update now lives in
updateCursorClickCountWithLocation:, next to the decision it serves, so every emitted pressbecomes the reference for the next one.
That second fix turned out to be a prerequisite rather than a nicety: press-and-hold never
calls
performClickAt:, so without it the reference would never advance and a double tapcould never form at all.
The raise-click was a click too
With Bring Windows to Front enabled, a tap on a window that isn't frontmost produced
two full clicks:
TUCCursorActionMoveClickIfNeededinjected one to raise the window, andthe same tap's lift-off produced the real one. Both went through
performClickAt:, so theinjected click advanced the sequence and the real one landed at the same spot, inside the
double-click interval, as click state 2. Every tap on a background window was a double
click — in Finder, that opens the item the user meant to select.
It leaked the other way as well: because the raise reused the user's live click count, a tap
shortly after a double tap raised the window with click state 2 or 3.
bringWindowToFrontAt:already existed for precisely this job — click state pinned to 1, theclick sequence left alone — and had never been called from anywhere. Wiring it up is the
entire fix.
The title-bar strip in
isLocationOutsideFrontmostWindowexists only as a workaround for thisbug (its comment says so), and in principle becomes redundant. I've left it in with a comment
recording that, rather than deleting it: the case it guards is a window zooming to fullscreen,
which is destructive enough that it deserves a real-hardware check before the guard goes.
Removing it would restore raise-on-tap for background title bars.
Also
performClickAt:built its event pair by re-typing and re-posting a single event object,which gave the press and the release one shared creation timestamp and therefore a press
duration of exactly zero. It now creates both events. I can't attribute a specific app
failure to this, so treat it as making the injected click well-formed rather than as a fix
for anything observed.
Compatibility
LongPressmaps to.none; unchanged in every respectstopCurrentGesture; unchangedWord-selection drag (a drag inheriting click state 2 right after a tap) still works — that
behavior is the reason
dragCursorTo:advances the counter at all, and it's preserveddeliberately.
Testing
Builds clean, no new warnings. No touchscreen here, so all of the above is traced through
the code rather than measured. Two things want real-device confirmation: Press and Hold,
which is why it ships off by default, and whether the title-bar strip can now be deleted.