Skip to content

Support mid-drag modifier changes - #733

Merged
brentyi merged 9 commits into
mainfrom
brent/drag-modifier-switch
Jul 18, 2026
Merged

brentyi merged 9 commits into
mainfrom
brent/drag-modifier-switch

Conversation

@brentyi

@brentyi brentyi commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator

We previously ignored modifier changes that happened mid-drag.

New behavior for modifier changes:

  • End event is triggered for the old modifier.
  • Start event is triggered for the new modifier.
  • Future update event + end events are bound to the new modifier.
  • The new modifier's start event is confirmed briefly (~100ms, or sooner on pointer motion) before firing. Releasing the mouse button inside that window discards the segment, so ending a drag "modifier up, then button up" — the natural order — doesn't fire a spurious start/end pair on the combo left behind (e.g. a bare on_drag registered alongside a modifier binding).

Bindings are also read live at each switch, so callbacks registered or removed mid-drag take effect at the next modifier change.

cc mujocolab/mjviser#21, @kevinzakka if this sounds reasonable?

brentyi added 3 commits June 26, 2026 06:04
Previously a scene-node drag froze its (button, modifier) combo at
drag-start: changing the held modifier mid-gesture had no effect. This
partitions a physical drag into one logical segment per combo. When the
held modifier changes, the client ends the current segment and -- if the
new combo is bound -- starts a fresh one under it, preserving the grab
geometry (plane, grab point, instance) so the drag continues without a
visual jump; only the addressed callback set changes.

Modifier changes are picked up from both pointermove and (for stationary
switches) window keydown/keyup during the drag. A switch to an unbound
combo drops the gesture into a dormant gap (no messages) so callbacks see
properly paired start/end per bound combo; re-entering a bound combo
starts a new segment.

The wire message already carries `modifier`, so this is client-side
segmentation plus docs -- no message-schema change. `SceneNodeDragEvent`
keeps a single `modifier` field that now identifies the active segment.

Tests: planModifierTransition unit cases (vitest), a server-side
multi-segment dispatch/bookkeeping test, and an e2e test that adds Shift
mid-drag and asserts the cmd/ctrl segment ends before release.
Covers the gap surfaced in review: switching to an unbound modifier combo
mid-drag must suspend the gesture (dormant), not end it. Asserts the
active segment ends on the switch (before release), and that re-entering
a bound combo resumes with a fresh segment within the same button press --
which only holds if dormant is a suspend rather than a teardown. Both
transitions are driven by key events with the pointer stationary, so this
also exercises the keydown/keyup path.
Existing tests each exercised a single modifier switch. These add
sequences within one drag:
- server: one drag cycling cmd/ctrl -> cmd/ctrl+shift -> cmd/ctrl ->
  cmd/ctrl+shift (re-entering both combos), asserting each callback sees
  clean ordered start...end pairs and the active-drag map fully releases.
- e2e: one continuous press cycling cmd/ctrl -> cmd/ctrl+shift ->
  dormant -> cmd/ctrl+shift -> cmd/ctrl, asserting exactly two segments
  per combo across repeated switching, re-entry, and resume-after-dormant.
  Uses async callbacks so the counters settle deterministically.

@kevinzakka kevinzakka left a comment

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.

Thanks Brent!

brentyi added 6 commits July 18, 2026 11:01
…ches

Two review findings on the mid-drag modifier-switch mechanism:

1. The opening segment is now attributed to the modifier held on the
   promoting pointermove, not the one sampled at pointerdown. The drag
   layer's key listeners only install at promotion, so a change inside
   the pointerdown->threshold window was invisible: a promote-and-release
   gesture opened (and closed) a segment under a stale modifier. When
   the promotion-time combo is unbound the drag now begins dormant and
   resumes on the next bound combo (new planDragStart, unit-pinned).

2. transitionDragModifier reads bindings live from the scene-tree store
   instead of a drag-start snapshot, so callbacks registered or removed
   mid-drag take effect at the next switch. The snapshot field is gone
   from ActiveDragState.

E2E pins for both: a pre-promotion Ctrl release must not fire a stale
segment (and must resume on re-press), and a binding registered
mid-drag must own the post-switch segment.
The pointerdown->promotion window fix attributed the opening segment to
the promotion-time modifier but still planned it against the bindings
array captured at pointerdown. A binding added or removed inside that
window was invisible to the opening plan (e.g. a combo bound mid-window
began dormant instead of owning the segment). beginDrag now reads
dragBindings live from the scene-tree store for the opening plan; the
pointerdown snapshot remains only as the candidacy gate.
Ending a modifier-drag the natural way -- modifier up a beat before the
button -- previously fired a degenerate start/end pair on whatever combo
remained bound (e.g. a bare on_drag registered alongside a modifier
binding), since humans can't release both within the same frame. This
reintroduced exactly the degenerate pair the promotion motion-threshold
eliminated, one boundary later.

A mid-drag switch now ends the old segment immediately but holds the new
segment's start PENDING until confirmed: a 100ms timer
(SWITCH_START_DELAY_MS), or sooner by pointer motion past the promotion
threshold from the switch point. Release/cancel/blur inside the window
discards the pending start outright -- no message was sent, none is owed
-- and a further modifier change abandons it silently, which also
swallows key-rollover pass-through combos when releasing multi-key
combos one key at a time.

Stationary switches still fire (timer path, imperceptibly later); the
opening segment is never deferred (promotion's motion threshold is its
confirmation). E2E pins the release order: bare + cmd/ctrl both bound,
ctrl-drag ended modifier-first -> bare sees nothing, ctrl sees one clean
pair, and a follow-up bare drag proves the binding was live.
The discard pin alone was satisfiable with the keyup path broken
entirely (the pointerup would end the ctrl segment and bare would still
read zero), and its keyup->mouseup gap raced the 100ms deferral timer on
slow CI. Split into two gestures: A holds the button through the window
so the keyup's pending bare start CONFIRMS on the timer (proving the
keyup creates a pending segment, and doubling as the bare-binding
positive control); B dispatches keyup + pointerup in one JS task -- both
listeners are window-level -- making the inside-the-window release
deterministic before asserting bare never budged.

Also correct the on_drag docs: start fires when the press is confirmed
as a drag (motion threshold), not at press.
@brentyi
brentyi merged commit b2dbf9f into main Jul 18, 2026
23 checks passed
@brentyi
brentyi deleted the brent/drag-modifier-switch branch July 18, 2026 08:17
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