Skip to content

Hold a press for the length of a touch, and fix the click sequence counter - #29

Draft
sapkra wants to merge 4 commits into
shueber:mainfrom
sapkra:fix/press-and-hold-and-click-sequence
Draft

Hold a press for the length of a touch, and fix the click sequence counter#29
sapkra wants to merge 4 commits into
shueber:mainfrom
sapkra:fix/press-and-hold-and-click-sequence

Conversation

@sapkra

@sapkra sapkra commented Aug 4, 2026

Copy link
Copy Markdown

⚠️ Base this on fix/tap-slop-click-detection, not main. It builds on that branch's tap-slop work — the safe moment to emit an early press only exists because of it. If that PR merges first, retarget to main.

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 (hypot instead of comparing two signed axis
deltas 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:

Touch Up/SettingsView.swift
Touch Up/TouchUp.swift
TouchUpCore/TUCCursorUtilities.m
TouchUpCore/TUCTouchInputManager.m

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 logCursorEvents
logging inside performClickAt:, which this PR rewrites, and both edit
updateCursorClickCountWithLocation: and the tail of processTouchesForCursorInput.

Not just textual. #23 adds cursorTouchInitialLocation + tapMovementTolerance
origin-based tap slop, the same mechanism as the base branch — but gated behind
usesWindowsTouchMode:

if (!self.usesWindowsTouchMode || exceededTapMovementTolerance) {
    self.cursorTouchQualifiedForTap = NO;
    self.cursorTouchStationarySinceDate = nil;
}

With the mode off (the default) !usesWindowsTouchMode is true and the flag is cleared on any
non-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 tapTolerance instead 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: reads nativePhysicalSize
unguarded, so on a panel reporting no EDID size it returns 0 and the tolerance becomes
effectively infinite. The base branch's effectivePhysicalSize covers 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:

mouseUp and mouseDown both instantaneously fire upon releasing a touch, rather than
mouseDown firing the moment a touch is detected

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 the
enum 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 .none and 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 that
starts 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 press
becomes 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 tap
could 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: 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 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, the
click sequence left alone — and had never been called from anywhere. Wiring it up is the
entire fix.

The title-bar strip in isLocationOutsideFrontmostWindow exists only as a workaround for this
bug (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

Configuration Behavior
Press and Hold off (default) LongPress maps to .none; unchanged in every respect
Press and Hold on, tap shorter than Hold Duration One click on lift, as before
Press and Hold on, tap longer than Hold Duration Button down at recognition, up on lift — one actuation, no extra click
Press and Hold on, rest then drag Same drag as today, just pressed earlier
Press and Hold on, double tap Click states 1 then 2 — works because of the reference fix above
Bring Windows to Front on, tap on background window Window raises, then one single click (was: double click)
Bring Windows to Front on, genuine double tap Second tap needs no raise, so it still doubles correctly
Bring Windows to Front off (default) Untouched — the raise path never runs
Device unplugged mid-touch Button released instead of stuck down (improvement, applies either way)
Touch cancelled / stale Already released via stopCurrentGesture; unchanged

Word-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 preserved
deliberately.

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.

sapkra and others added 4 commits August 4, 2026 02:09
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>
@sapkra sapkra changed the title Fix/press and hold and click sequence Hold a press for the length of a touch, and fix the click sequence counter Aug 4, 2026
@Jac0b-Shi

Copy link
Copy Markdown

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 tapTolerance implementation underneath both the default behavior and Windows Touch Mode is cleaner than maintaining two similar mechanisms.

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.

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.

2 participants