From 6bcb952830f6623417bf7caa762c0a74413543e9 Mon Sep 17 00:00:00 2001 From: brentyi Date: Fri, 26 Jun 2026 04:03:56 +0000 Subject: [PATCH 1/3] Support live modifier switching mid-drag 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. --- src/viser/_scene_handles.py | 52 +++++--- src/viser/client/src/DragLayer.tsx | 115 ++++++++++++++--- src/viser/client/src/dragUtils.test.ts | 53 ++++++++ src/viser/client/src/dragUtils.ts | 55 ++++++++ tests/e2e/test_scene_node_drag.py | 70 ++++++++++ tests/test_drag_modifier_segments.py | 172 +++++++++++++++++++++++++ 6 files changed, 485 insertions(+), 32 deletions(-) create mode 100644 tests/test_drag_modifier_segments.py diff --git a/src/viser/_scene_handles.py b/src/viser/_scene_handles.py index 792a1b845..e7c561cd5 100644 --- a/src/viser/_scene_handles.py +++ b/src/viser/_scene_handles.py @@ -475,9 +475,16 @@ class SceneNodeDragEvent(Generic[TSceneNodeHandle]): """Scene node that is being dragged.""" phase: DragPhase """Drag lifecycle phase: ``"start"`` at press, ``"update"`` on - every throttled pointermove (~20Hz), ``"end"`` at release. A - single drag fires exactly one ``"start"``, zero or more - ``"update"``s, and exactly one ``"end"``.""" + every throttled pointermove (~20Hz), ``"end"`` at release. + + A gesture is partitioned into one *segment* per held modifier-combo. + Each segment fires exactly one ``"start"``, zero or more + ``"update"``s, and exactly one ``"end"``. If the user changes the + held modifier mid-drag, the current segment ends and a new one + starts under the new modifier (see :attr:`modifier`) -- so a single + physical drag can produce more than one ``start``/``end`` pair. When + the modifier doesn't change, this collapses to the common case of a + single ``start`` ... ``end`` per gesture.""" instance_index: int | None """Instance index within a batched scene node (e.g. batched meshes, batched GLBs, batched axes); ``None`` for non-batched nodes. Frozen @@ -498,9 +505,12 @@ class SceneNodeDragEvent(Generic[TSceneNodeHandle]): button: Literal["left", "middle", "right"] """Mouse button that initiated the drag.""" modifier: _messages.KeyModifier | None - """Modifier-combo held at drag-start (frozen for the drag's - lifetime). ``None`` if no modifiers were held; otherwise a - canonical :data:`KeyModifier` string.""" + """Modifier-combo that owns the current drag segment. Constant within + a segment and matches the binding this callback was registered for; + if the user changes the held modifier mid-drag the segment ends and a + new one begins under the new combo (see :attr:`phase`). ``None`` if no + modifiers are held; otherwise a canonical :data:`KeyModifier` + string.""" _VALID_DRAG_BUTTONS: Tuple[_messages.DragButton, ...] = get_args(_messages.DragButton) @@ -611,12 +621,21 @@ def on_drag( ) -> Any: """Attach a callback for the full drag lifecycle. - Fires three times per gesture: once with - ``event.phase == "start"`` at press, zero or more times with - ``"update"`` (throttled pointermove), once with ``"end"`` at - release. ``end`` fires even on cancellation paths (window - blur, pointer cancel, node removed mid-drag) so per-drag - state can be released. + Fires once with ``event.phase == "start"`` at press, zero or + more times with ``"update"`` (throttled pointermove), and once + with ``"end"`` at release. ``end`` fires even on cancellation + paths (window blur, pointer cancel, node removed mid-drag) so + per-drag state can be released. + + Modifiers are live: if the user changes the held modifier + mid-drag, the current segment ends and a new one begins under + the new combo, routing to whichever callback that combo is bound + to. A callback therefore sees a clean ``start`` ... ``end`` pair + for *its* modifier each time that modifier is engaged, and a + single physical drag may fire more than one such pair. To switch + behavior mid-drag (e.g. changing the drag plane), register a + separate ``on_drag`` for each modifier combo. ``event.modifier`` + identifies the active segment. Usable as a bare decorator (``@handle.on_drag``, defaults to ``button="left"`` and no modifiers) or with arguments @@ -629,9 +648,12 @@ def on_drag( ordered ``"+"``-separated string like ``"cmd/ctrl"``, ``"shift"``, or ``"cmd/ctrl+shift"``. ``None`` matches "no modifiers held". Matching is exact: listed modifiers - must be held and others must not be. Left-drag on this - node intercepts the gesture -- the camera only orbits on - empty-space drags. + must be held and others must not be. The match is + re-evaluated whenever the held modifier changes mid-drag, + so this callback is entered and exited as its combo is + engaged and released. Left-drag on this node intercepts + the gesture -- the camera only orbits on empty-space + drags. Note on ordering: synchronous (``def``) callbacks are submitted to a thread pool fire-and-forget and can run out of order -- an diff --git a/src/viser/client/src/DragLayer.tsx b/src/viser/client/src/DragLayer.tsx index 747749948..c778b00c5 100644 --- a/src/viser/client/src/DragLayer.tsx +++ b/src/viser/client/src/DragLayer.tsx @@ -31,9 +31,12 @@ import { useThrottledMessageSender } from "./WebsocketUtils"; import { ActiveDragState, DragScratches, + KeyModifier, anyBindingMatches, computeInstanceWorldMatrix, isInstancedMesh2VendoredMessage, + keyModifierFromEvent, + planModifierTransition, } from "./dragUtils"; import { DragLayerApi, DragLayerContext } from "./dragLayerContext"; import { useDragArrow } from "./useDragArrow"; @@ -231,21 +234,58 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { // Build + send a drag message in one call, no-op if ``buildDragMessage`` // returns null (live grab point unavailable). Wraps the duplicated - // null-check pattern at the three send sites. */ + // null-check pattern at the send sites. Returns whether a message was + // actually sent -- callers use this to keep ``segmentActive`` honest + // (a ``start`` that couldn't build must not be paired with a later + // ``end``). */ const sendDragMessage = React.useCallback( ( activeDrag: ActiveDragState, phase: "start" | "update" | "end", throttle: boolean, - ) => { + ): boolean => { const message = buildDragMessage(activeDrag, phase); - if (message === null) return; + if (message === null) return false; if (throttle) sendDragsThrottled(message); else viewerMutable.sendMessage(message); + return true; }, [buildDragMessage, sendDragsThrottled, viewerMutable], ); + // Apply a mid-drag modifier change. Ends the current segment (if any), + // switches ownership to ``nextModifier``, and starts a fresh segment + // when the new combo is bound. Geometry is untouched, so the drag + // continues without a visual jump -- only the addressed callback set + // changes. Dormant (unbound) modifiers send nothing; see + // ``planModifierTransition``. */ + const transitionDragModifier = React.useCallback( + (activeDrag: ActiveDragState, nextModifier: KeyModifier | null) => { + const plan = planModifierTransition( + activeDrag.input.modifier, + nextModifier, + activeDrag.bindings, + activeDrag.input.button, + activeDrag.segmentActive, + ); + if (plan === null) return; + if (plan.emitEnd) { + // Flush queued throttled updates so the old segment's pending + // update lands *before* its synthetic end -- preserves wire + // ordering across the segment boundary. + flushDragsThrottled(); + sendDragMessage(activeDrag, "end", false); + } + // Switch ownership before emitting the new ``start`` so the start + // message carries the new modifier. ``button`` is unchanged. + activeDrag.input = { ...activeDrag.input, modifier: nextModifier }; + activeDrag.segmentActive = plan.emitStart + ? sendDragMessage(activeDrag, "start", false) + : false; + }, + [flushDragsThrottled, sendDragMessage], + ); + type EndInfo = { clientX: number; clientY: number; @@ -264,15 +304,14 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { } flushDragsThrottled(); - if (sendEndMessage) { - // Modifier state is frozen at drag_start: a drag is "owned" by - // whichever (button, modifiers) combo was held when the user - // pressed the mouse, and stays owned by that combo until release - // regardless of modifier changes mid-drag. This guarantees the - // drag_start / drag_end callbacks see the same dispatch and - // avoids a class of footguns where a key-up arrives a beat - // before mouse-up (downgrading the gesture at the last moment) - // or a modifier is accidentally pressed mid-drag. + if (sendEndMessage && activeDrag.segmentActive) { + // End the currently-active segment. A drag is partitioned into + // one segment per (button, modifier) combo; the modifier can + // switch mid-drag (see ``transitionDragModifier``), and each + // switch already emitted the prior segment's ``end``. So here we + // only emit when a segment is still active -- a release while + // dormant (current modifier matches no binding) has nothing left + // to end. sendDragMessage(activeDrag, "end", false); } @@ -362,16 +401,42 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { if (event.pointerId !== activeDrag.pointerId) return; if (!updateActiveDragEnd(event.clientX, event.clientY)) return; - // Modifier/button state is frozen at drag_start and reused on - // every update/end -- see the note in `stopActiveDrag` above. + // A modifier change carried on this move ends the current + // segment and starts a new one under the new combo. Run it + // *after* refreshing the end position so the synthetic end + // reports the latest pointer location, and *before* the update + // so the update is attributed to the new segment. + const liveModifier = keyModifierFromEvent(event); + if (liveModifier !== activeDrag.input.modifier) { + transitionDragModifier(activeDrag, liveModifier); + } + // ``start_position`` is recomputed live inside buildDragMessage, // so the wire payload always reflects the click point's - // current world position (tracking the moving object). - sendDragMessage(activeDrag, "update", true); + // current world position (tracking the moving object). Skip + // while dormant -- the current modifier matches no binding. + if (activeDrag.segmentActive) { + sendDragMessage(activeDrag, "update", true); + } // The per-frame useFrame updates the arrow tail from target // transforms; no manual update needed here. }; + // Modifier changes can also arrive with the pointer stationary + // (the user taps a modifier key without moving the mouse). Listen + // for key transitions during the drag and re-evaluate ownership + // using the last-known pointer position. ``keyModifierFromEvent`` + // reads ``ctrl/meta/shift/alt`` off the KeyboardEvent, so both + // keydown and keyup resolve the current combo. + const handleWindowKeyChange = (event: KeyboardEvent) => { + const activeDrag = activeDragRef.current; + if (activeDrag === null) return; + const liveModifier = keyModifierFromEvent(event); + if (liveModifier !== activeDrag.input.modifier) { + transitionDragModifier(activeDrag, liveModifier); + } + }; + const handleWindowPointerUp = (event: PointerEvent) => { // Ignore mismatched pointers -- we only end the drag when the // *same* pointer that started it lifts up (or cancels). @@ -393,6 +458,8 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { window.removeEventListener("pointerup", handleWindowPointerUp); window.removeEventListener("pointercancel", handleWindowPointerUp); window.removeEventListener("blur", handleWindowBlur); + window.removeEventListener("keydown", handleWindowKeyChange); + window.removeEventListener("keyup", handleWindowKeyChange); }; // Plane parallel to the camera image plane, through the start @@ -424,6 +491,13 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { endPointWorld: startWorld.clone(), endPointerXy: [pointerXy[0], pointerXy[1]], input, + bindings, + // Set from the initial ``start`` send below. The pointerdown + // path only calls ``beginDrag`` when ``input`` matches a + // binding, so the opening segment is bound -- but the send can + // still fail if the live grab point is unavailable, so we trust + // its return value rather than assuming ``true``. + segmentActive: false, releaseCameraLock: null, cleanup, }; @@ -434,7 +508,13 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { window.addEventListener("pointerup", handleWindowPointerUp); window.addEventListener("pointercancel", handleWindowPointerUp); window.addEventListener("blur", handleWindowBlur); - sendDragMessage(activeDragRef.current, "start", false); + window.addEventListener("keydown", handleWindowKeyChange); + window.addEventListener("keyup", handleWindowKeyChange); + activeDragRef.current.segmentActive = sendDragMessage( + activeDragRef.current, + "start", + false, + ); return true; }, stopIfNodeIs: (nodeName) => { @@ -449,6 +529,7 @@ function DragLayerActive({ children }: { children?: React.ReactNode }) { frameScratches, sendDragMessage, stopActiveDrag, + transitionDragModifier, updateActiveDragEnd, viewer, viewerMutable, diff --git a/src/viser/client/src/dragUtils.test.ts b/src/viser/client/src/dragUtils.test.ts index 7826715e1..60a2e1fc7 100644 --- a/src/viser/client/src/dragUtils.test.ts +++ b/src/viser/client/src/dragUtils.test.ts @@ -7,6 +7,7 @@ import { anyBindingMatches, hasCmdCtrl, motionExceedsThreshold, + planModifierTransition, MOTION_THRESHOLD_PX, } from "./dragUtils"; @@ -91,6 +92,58 @@ describe("hasCmdCtrl", () => { }); }); +describe("planModifierTransition", () => { + // mjviser-style setup: two bound combos on the left button, used to + // switch the drag plane mid-gesture. + const bindings = [ + { button: "left" as const, modifier: "cmd/ctrl" as const }, + { button: "left" as const, modifier: "cmd/ctrl+shift" as const }, + ]; + + it("is a no-op when the modifier is unchanged", () => { + expect( + planModifierTransition("cmd/ctrl", "cmd/ctrl", bindings, "left", true), + ).toBeNull(); + // Even when nothing is held and nothing changes. + expect(planModifierTransition(null, null, bindings, "left", false)).toBeNull(); + }); + + it("ends the old segment and starts a new one between two bound combos", () => { + expect( + planModifierTransition("cmd/ctrl", "cmd/ctrl+shift", bindings, "left", true), + ).toEqual({ emitEnd: true, emitStart: true }); + }); + + it("ends the segment and goes dormant when the new combo is unbound", () => { + // ctrl is bound, shift-only is not -- releasing ctrl mid-drag drops + // into a dormant gap rather than starting a spurious segment. + expect( + planModifierTransition("cmd/ctrl", "shift", bindings, "left", true), + ).toEqual({ emitEnd: true, emitStart: false }); + }); + + it("starts a fresh segment when re-entering a bound combo from dormant", () => { + // Already dormant (segmentActive=false): no end to emit, just the + // new start. + expect( + planModifierTransition("shift", "cmd/ctrl", bindings, "left", false), + ).toEqual({ emitEnd: false, emitStart: true }); + }); + + it("stays dormant when moving between two unbound combos", () => { + expect( + planModifierTransition("shift", "alt", bindings, "left", false), + ).toEqual({ emitEnd: false, emitStart: false }); + }); + + it("respects the button when matching the new combo", () => { + // The same modifier on a button with no binding is unbound. + expect( + planModifierTransition(null, "cmd/ctrl", bindings, "right", false), + ).toEqual({ emitEnd: false, emitStart: false }); + }); +}); + describe("motionExceedsThreshold", () => { it("is false for movement at or under the threshold (L-infinity)", () => { expect(motionExceedsThreshold([0, 0], [0, 0])).toBe(false); diff --git a/src/viser/client/src/dragUtils.ts b/src/viser/client/src/dragUtils.ts index e00a92a1c..7b708d9b0 100644 --- a/src/viser/client/src/dragUtils.ts +++ b/src/viser/client/src/dragUtils.ts @@ -97,6 +97,48 @@ export function anyBindingMatches( return bindings.some((b) => matchesDragBinding(b, input)); } +/** Plan emitted by :func:`planModifierTransition`: which lifecycle + * messages a mid-drag modifier change should produce. */ +export type DragModifierTransition = { + /** Send a ``phase="end"`` for the *current* (pre-switch) modifier + * before switching ownership. True iff a segment is currently active. */ + emitEnd: boolean; + /** Send a ``phase="start"`` under the *new* modifier after switching. + * True iff the new combo matches a registered binding. */ + emitStart: boolean; +}; + +/** Decide how an in-progress drag reacts to a mid-gesture modifier change. + * + * A single physical drag is partitioned into one logical segment per + * (button, modifier) combo. When the held modifier changes, the current + * segment is ended and -- if the new combo matches a registered binding -- + * a fresh segment is started under it. The grab geometry (plane, grab + * point, instance) is preserved across the boundary by the caller, so the + * drag continues without a visual jump; only which callback set is + * addressed changes. + * + * If the new combo matches no binding the drag goes *dormant*: the + * physical gesture stays alive (camera locked, geometry retained) but no + * messages are sent, so the user's callbacks see properly paired + * start/end per bound combo. Re-entering a bound combo before release + * starts a fresh segment. + * + * Returns ``null`` when the modifier is unchanged (a no-op). */ +export function planModifierTransition( + current: KeyModifier | null, + next: KeyModifier | null, + bindings: DragBinding[], + button: PointerButton, + segmentActive: boolean, +): DragModifierTransition | null { + if (next === current) return null; + return { + emitEnd: segmentActive, + emitStart: anyBindingMatches(bindings, { button, modifier: next }), + }; +} + /** True when the held modifier includes cmd/ctrl. Used to gate browser * context-menu suppression: macOS raises a ``contextmenu`` event on * ctrl+click, and we only want to suppress it when the gesture is a @@ -167,7 +209,20 @@ export type ActiveDragState = { * compute ``end_screen_pos`` and re-cast the pointer ray each * pointermove. */ endPointerXy: [number, number]; + /** Current (button, modifier) the drag is owned by. ``button`` is + * frozen at drag-start; ``modifier`` is *live* -- it switches when the + * held modifier changes mid-drag, partitioning the gesture into one + * segment per combo (see :func:`planModifierTransition`). */ input: DragInput; + /** Registered drag bindings for this node, captured at drag-start. + * Used to decide whether a new modifier combo owns a segment or is + * dormant when the held modifier changes mid-drag. */ + bindings: DragBinding[]; + /** Whether an in-flight segment is currently active (a ``start`` was + * sent and its ``end`` hasn't). ``false`` while dormant -- the gesture + * is physically held but the current modifier matches no binding, so + * no messages are sent. */ + segmentActive: boolean; /** Release for the camera-control lock held for the lifetime of * this drag. Called in `stopActiveDrag` (and on every cancel * path). Routing through `cameraLock` keeps a concurrent diff --git a/tests/e2e/test_scene_node_drag.py b/tests/e2e/test_scene_node_drag.py index 2a1b68925..b00ad254a 100644 --- a/tests/e2e/test_scene_node_drag.py +++ b/tests/e2e/test_scene_node_drag.py @@ -205,6 +205,76 @@ def _(event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: assert event.modifier == "cmd/ctrl", event.modifier +def test_scene_node_drag_modifier_switch_mid_drag( + page: Page, + viser_server: viser.ViserServer, +) -> None: + """Changing the held modifier mid-drag ends the current segment and + starts a new one under the new combo, routing each segment to the + callback bound to it. Holding Ctrl, dragging, then adding Shift while + still dragging should: end the ``cmd/ctrl`` segment, then run a full + ``cmd/ctrl+shift`` segment -- all within one physical button press.""" + viser_server.initial_camera.position = (0.0, 0.0, 4.0) + viser_server.initial_camera.look_at = (0.0, 0.0, 0.0) + + ctrl_started = threading.Event() + ctrl_ended = threading.Event() + ctrl_shift_started = threading.Event() + ctrl_shift_ended = threading.Event() + + box = viser_server.scene.add_box( + "/switch_box", + dimensions=(4.0, 4.0, 0.2), + color=(200, 100, 255), + ) + + # The server routes each segment to the callback bound to its combo, + # so a fired event is itself proof the segment carried that modifier. + _on_drag_phase(box, "start", "left", modifier="cmd/ctrl")( + lambda _: ctrl_started.set() + ) + _on_drag_phase(box, "end", "left", modifier="cmd/ctrl")(lambda _: ctrl_ended.set()) + _on_drag_phase(box, "start", "left", modifier="cmd/ctrl+shift")( + lambda _: ctrl_shift_started.set() + ) + _on_drag_phase(box, "end", "left", modifier="cmd/ctrl+shift")( + lambda _: ctrl_shift_ended.set() + ) + + wait_for_connection(page, viser_server.get_port()) + wait_for_scene_node(page, "/switch_box") + + (start_x, start_y), (end_x, end_y) = _get_canvas_drag_points(page) + mid_x = (start_x + end_x) / 2 + mid_y = (start_y + end_y) / 2 + + # cmd/ctrl segment: press and drag halfway. + page.keyboard.down("Control") + page.mouse.move(start_x, start_y) + page.mouse.down() + page.mouse.move(mid_x, mid_y, steps=8) + assert ctrl_started.wait(timeout=5.0), "cmd/ctrl segment didn't start" + + # Add Shift mid-drag -- the keydown alone (no further motion) ends the + # cmd/ctrl segment and starts the cmd/ctrl+shift one. Asserting + # ctrl_ended *before* mouse-up is the crux: it proves the segment + # ended from the modifier switch, not from the release. + page.keyboard.down("Shift") + assert ctrl_ended.wait(timeout=5.0), "cmd/ctrl segment didn't end on modifier add" + assert ctrl_shift_started.wait(timeout=5.0), ( + "cmd/ctrl+shift segment didn't start when Shift was added mid-drag" + ) + + # Keep dragging under the new combo, then release. + page.mouse.move(end_x, end_y, steps=8) + page.mouse.up() + page.keyboard.up("Shift") + page.keyboard.up("Control") + assert ctrl_shift_ended.wait(timeout=5.0), ( + "cmd/ctrl+shift segment didn't end on release" + ) + + def test_scene_node_drag_filter_rejects_wrong_modifier( page: Page, viser_server: viser.ViserServer, diff --git a/tests/test_drag_modifier_segments.py b/tests/test_drag_modifier_segments.py new file mode 100644 index 000000000..97da7fc36 --- /dev/null +++ b/tests/test_drag_modifier_segments.py @@ -0,0 +1,172 @@ +"""Unit tests for mid-drag modifier switching (drag "segments"). + +A single physical drag is partitioned into one *segment* per held +(button, modifier) combo. When the held modifier changes mid-drag the +client ends the current segment and starts a new one under the new +combo; the server routes each segment's start/update/end to whichever +callback that combo is bound to (``_handle_node_drag`` -> +``_dispatch_drag_callbacks``). + +These tests drive ``_handle_node_drag`` directly with a crafted message +sequence -- no browser -- to lock down that the server bookkeeping +(``_active_drag_handles``) and per-modifier dispatch stay consistent +across the synthetic end/start boundary the client emits. The real +client-side segmentation (key listeners, geometry preservation) is +covered by the e2e suite and the ``planModifierTransition`` unit tests. +""" + +from __future__ import annotations + +import asyncio +from typing import Generator, cast +from unittest.mock import Mock + +import pytest + +import viser +from viser import _messages +from viser.infra import ClientId + + +@pytest.fixture +def server() -> Generator[viser.ViserServer, None, None]: + s = viser.ViserServer() + try: + yield s + finally: + s.stop() + + +def _drag_msg( + name: str, + phase: _messages._DragPhase, + modifier: _messages.KeyModifier | None, +) -> _messages.SceneNodeDragMessage: + return _messages.SceneNodeDragMessage( + phase=phase, + name=name, + instance_index=None, + start_position=(0.0, 0.0, 0.0), + start_screen_pos=(0.0, 0.0), + end_position=(1.0, 1.0, 1.0), + end_screen_pos=(1.0, 1.0), + button="left", + modifier=modifier, + ) + + +def _dispatch( + server: viser.ViserServer, + client_id: ClientId, + message: _messages.SceneNodeDragMessage, +) -> None: + """Run ``_handle_node_drag`` on the server's event loop and block.""" + asyncio.run_coroutine_threadsafe( + server.scene._handle_node_drag(client_id, message), + server._event_loop, + ).result() + + +def test_modifier_switch_routes_segments_to_each_binding( + server: viser.ViserServer, +) -> None: + """Switching the held modifier mid-drag routes each segment's + start/update/end to the callback bound to that combo, and leaves the + other combo's callback untouched.""" + box = server.scene.add_box("/seg_box", dimensions=(1.0, 1.0, 1.0)) + + ctrl_phases: list[str] = [] + ctrl_shift_phases: list[str] = [] + + # Async callbacks are awaited in dispatch order on the event loop, so + # the phase lists are complete and deterministic once ``_dispatch`` + # returns. (Sync callbacks are fire-and-forget on a thread pool and + # would race the assertions.) + @box.on_drag("left", modifier="cmd/ctrl") + async def _(event: viser.SceneNodeDragEvent) -> None: + ctrl_phases.append(event.phase) + # The active segment's modifier always matches this binding. + assert event.modifier == "cmd/ctrl" + + @box.on_drag("left", modifier="cmd/ctrl+shift") + async def _(event: viser.SceneNodeDragEvent) -> None: + ctrl_shift_phases.append(event.phase) + assert event.modifier == "cmd/ctrl+shift" + + client = cast(ClientId, 42) + server._connected_clients[client] = Mock() + + # The client emits this sequence when the user holds Ctrl, drags, + # then adds Shift mid-drag and keeps dragging before releasing: the + # Ctrl segment is ended and a Ctrl+Shift segment is started, with the + # grab geometry preserved across the boundary. + _dispatch(server, client, _drag_msg("/seg_box", "start", "cmd/ctrl")) + _dispatch(server, client, _drag_msg("/seg_box", "update", "cmd/ctrl")) + _dispatch(server, client, _drag_msg("/seg_box", "end", "cmd/ctrl")) + _dispatch(server, client, _drag_msg("/seg_box", "start", "cmd/ctrl+shift")) + _dispatch(server, client, _drag_msg("/seg_box", "update", "cmd/ctrl+shift")) + _dispatch(server, client, _drag_msg("/seg_box", "end", "cmd/ctrl+shift")) + + # Each callback sees exactly one clean start...end for its own combo, + # and nothing from the other segment. + assert ctrl_phases == ["start", "update", "end"] + assert ctrl_shift_phases == ["start", "update", "end"] + + # Bookkeeping released after the final end. + assert not server.scene._is_drag_active_for("/seg_box") + + +def test_modifier_switch_keeps_drag_active_across_boundary( + server: viser.ViserServer, +) -> None: + """The node stays "actively dragged" across the segment boundary -- + the end of one segment is immediately followed by the start of the + next, so a concurrent ``remove()`` would still preserve callbacks + until the gesture truly finishes.""" + box = server.scene.add_box("/active_box", dimensions=(1.0, 1.0, 1.0)) + box.on_drag("left", modifier="cmd/ctrl")(lambda _: None) + box.on_drag("left", modifier="cmd/ctrl+shift")(lambda _: None) + + client = cast(ClientId, 7) + server._connected_clients[client] = Mock() + + _dispatch(server, client, _drag_msg("/active_box", "start", "cmd/ctrl")) + assert server.scene._is_drag_active_for("/active_box") + + # Old segment ends... + _dispatch(server, client, _drag_msg("/active_box", "end", "cmd/ctrl")) + # ...new segment starts. The node is active again immediately. + _dispatch(server, client, _drag_msg("/active_box", "start", "cmd/ctrl+shift")) + assert server.scene._is_drag_active_for("/active_box") + + _dispatch(server, client, _drag_msg("/active_box", "end", "cmd/ctrl+shift")) + assert not server.scene._is_drag_active_for("/active_box") + + +def test_unbound_segment_does_not_dispatch( + server: viser.ViserServer, +) -> None: + """A segment whose modifier matches no binding dispatches to nobody + (server-side filter), but still cycles the active-drag bookkeeping + cleanly. In practice the client suppresses these entirely (dormant + state); this pins the server's filter as a backstop.""" + box = server.scene.add_box("/unbound_box", dimensions=(1.0, 1.0, 1.0)) + + ctrl_phases: list[str] = [] + + @box.on_drag("left", modifier="cmd/ctrl") + async def _(event: viser.SceneNodeDragEvent) -> None: + ctrl_phases.append(event.phase) + + client = cast(ClientId, 99) + server._connected_clients[client] = Mock() + + _dispatch(server, client, _drag_msg("/unbound_box", "start", "cmd/ctrl")) + _dispatch(server, client, _drag_msg("/unbound_box", "end", "cmd/ctrl")) + # Shift-only is unbound: no callback fires for this segment. + _dispatch(server, client, _drag_msg("/unbound_box", "start", "shift")) + _dispatch(server, client, _drag_msg("/unbound_box", "update", "shift")) + _dispatch(server, client, _drag_msg("/unbound_box", "end", "shift")) + + assert ctrl_phases == ["start", "end"] + assert not server.scene._is_drag_active_for("/unbound_box") From a279c1868b726e9c9e82989161fa471df0e860fa Mon Sep 17 00:00:00 2001 From: brentyi Date: Fri, 26 Jun 2026 05:10:48 +0000 Subject: [PATCH 2/3] Add e2e coverage for dormant-then-resume drag 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. --- tests/e2e/test_scene_node_drag.py | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/e2e/test_scene_node_drag.py b/tests/e2e/test_scene_node_drag.py index b00ad254a..c92f08a30 100644 --- a/tests/e2e/test_scene_node_drag.py +++ b/tests/e2e/test_scene_node_drag.py @@ -275,6 +275,96 @@ def test_scene_node_drag_modifier_switch_mid_drag( ) +def test_scene_node_drag_dormant_then_resume( + page: Page, + viser_server: viser.ViserServer, +) -> None: + """Switching to an UNBOUND modifier combo mid-drag suspends the gesture + (dormant) rather than ending it: the active segment's ``end`` fires, but + the physical drag stays alive and a *new* segment starts when a bound + combo is re-entered -- all within one button press. + + Only ``cmd/ctrl`` is bound, so adding Shift lands in a dormant gap. The + crux is the resume: if pressing Shift had torn the drag down, releasing + it could NOT produce a second ``cmd/ctrl`` start without a fresh + mouse-down. Both transitions here are driven by key events with the + pointer stationary, so this also exercises the keydown/keyup path.""" + viser_server.initial_camera.position = (0.0, 0.0, 4.0) + viser_server.initial_camera.look_at = (0.0, 0.0, 0.0) + + starts = [0] + ends = [0] + first_start = threading.Event() + first_end = threading.Event() + resumed_start = threading.Event() + second_end = threading.Event() + lock = threading.Lock() + + box = viser_server.scene.add_box( + "/dormant_box", + dimensions=(4.0, 4.0, 0.2), + color=(120, 220, 160), + ) + + def _on_start(_event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + with lock: + starts[0] += 1 + n = starts[0] + (first_start if n == 1 else resumed_start).set() + + def _on_end(_event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + with lock: + ends[0] += 1 + n = ends[0] + (first_end if n == 1 else second_end).set() + + _on_drag_phase(box, "start", "left", modifier="cmd/ctrl")(_on_start) + _on_drag_phase(box, "end", "left", modifier="cmd/ctrl")(_on_end) + + wait_for_connection(page, viser_server.get_port()) + wait_for_scene_node(page, "/dormant_box") + + (start_x, start_y), (end_x, end_y) = _get_canvas_drag_points(page) + mid_x = (start_x + end_x) / 2 + mid_y = (start_y + end_y) / 2 + + # cmd/ctrl segment: press and drag partway. + page.keyboard.down("Control") + page.mouse.move(start_x, start_y) + page.mouse.down() + page.mouse.move(mid_x, mid_y, steps=8) + assert first_start.wait(timeout=5.0), "cmd/ctrl segment didn't start" + + # Add Shift (cmd/ctrl+shift is unbound) -> dormant. The active segment + # ends now, BEFORE any release -- proving the switch, not the mouse-up, + # ended it. + page.keyboard.down("Shift") + assert first_end.wait(timeout=5.0), "segment didn't end when going dormant" + with lock: + assert ends[0] == 1 and starts[0] == 1, (starts[0], ends[0]) + + # Drag while dormant -- no segment is active, so nothing should fire. + page.mouse.move(end_x, end_y, steps=8) + + # Release Shift -> back to the bound cmd/ctrl combo -> RESUME with a + # fresh segment, even though the pointer is stationary and the button + # was never released. + page.keyboard.up("Shift") + assert resumed_start.wait(timeout=5.0), ( + "drag did not resume on re-entering cmd/ctrl -- dormant was treated " + "as a full teardown instead of a suspend" + ) + + page.mouse.up() + page.keyboard.up("Control") + assert second_end.wait(timeout=5.0), "resumed segment didn't end on release" + + # Exactly two clean segments, no churn from the dormant gap. + with lock: + assert starts[0] == 2, starts[0] + assert ends[0] == 2, ends[0] + + def test_scene_node_drag_filter_rejects_wrong_modifier( page: Page, viser_server: viser.ViserServer, From 78aac95e9c4f777a01600d9d7ddff8eb8d7c4e85 Mon Sep 17 00:00:00 2001 From: brentyi Date: Fri, 26 Jun 2026 06:15:31 +0000 Subject: [PATCH 3/3] Add multi-stage drag coverage (many switches + dormant cycles) 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. --- tests/e2e/test_scene_node_drag.py | 112 +++++++++++++++++++++++++++ tests/test_drag_modifier_segments.py | 55 +++++++++++++ 2 files changed, 167 insertions(+) diff --git a/tests/e2e/test_scene_node_drag.py b/tests/e2e/test_scene_node_drag.py index c92f08a30..99c01aff9 100644 --- a/tests/e2e/test_scene_node_drag.py +++ b/tests/e2e/test_scene_node_drag.py @@ -365,6 +365,118 @@ def _on_end(_event: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: assert ends[0] == 2, ends[0] +def test_scene_node_drag_many_stages_one_drag( + page: Page, + viser_server: viser.ViserServer, +) -> None: + """A single drag that cycles through several segments AND a dormant gap: + cmd/ctrl -> cmd/ctrl+shift -> (dormant) -> cmd/ctrl+shift -> cmd/ctrl, + all within one button press. Both combos are entered twice, with an + unbound (dormant) interval in the middle, exercising repeated switching, + re-entry, and resume-after-dormant in one gesture. Each callback must + see exactly two clean start...end pairs.""" + viser_server.initial_camera.position = (0.0, 0.0, 4.0) + viser_server.initial_camera.look_at = (0.0, 0.0, 0.0) + + ctrl_starts = [0] + ctrl_ends = [0] + cs_starts = [0] + cs_ends = [0] + lock = threading.Lock() + # One event per milestone we gate the gesture on. + ev = { + k: threading.Event() for k in ("cs_s1", "cs_e1", "cs_s2", "ctrl_s2", "ctrl_e2") + } + + box = viser_server.scene.add_box( + "/many_stage_box", + dimensions=(4.0, 4.0, 0.2), + color=(160, 140, 240), + ) + + # Async callbacks are awaited in dispatch order on the event loop, so + # the counters settle deterministically (sync callbacks fire-and-forget + # on a thread pool and would race the final count assertions -- step 4 + # below fires two callbacks at once). + async def _ctrl_start(_e: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + with lock: + ctrl_starts[0] += 1 + n = ctrl_starts[0] + if n == 2: + ev["ctrl_s2"].set() + + async def _ctrl_end(_e: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + with lock: + ctrl_ends[0] += 1 + n = ctrl_ends[0] + if n == 2: + ev["ctrl_e2"].set() + + async def _cs_start(_e: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + with lock: + cs_starts[0] += 1 + n = cs_starts[0] + ev["cs_s1" if n == 1 else "cs_s2"].set() + + async def _cs_end(_e: viser.SceneNodeDragEvent[viser.BoxHandle]) -> None: + with lock: + cs_ends[0] += 1 + n = cs_ends[0] + if n == 1: + ev["cs_e1"].set() + + _on_drag_phase(box, "start", "left", modifier="cmd/ctrl")(_ctrl_start) + _on_drag_phase(box, "end", "left", modifier="cmd/ctrl")(_ctrl_end) + _on_drag_phase(box, "start", "left", modifier="cmd/ctrl+shift")(_cs_start) + _on_drag_phase(box, "end", "left", modifier="cmd/ctrl+shift")(_cs_end) + + wait_for_connection(page, viser_server.get_port()) + wait_for_scene_node(page, "/many_stage_box") + + (start_x, start_y), (end_x, end_y) = _get_canvas_drag_points(page) + q_x = start_x + (end_x - start_x) / 3 + q_y = start_y + (end_y - start_y) / 3 + + # Segment 1: cmd/ctrl. + page.keyboard.down("Control") + page.mouse.move(start_x, start_y) + page.mouse.down() + page.mouse.move(q_x, q_y, steps=6) + + # Segment 2: add Shift -> cmd/ctrl+shift (bound). + page.keyboard.down("Shift") + assert ev["cs_s1"].wait(timeout=5.0), "cmd/ctrl+shift didn't start on Shift add" + + # Dormant: release Ctrl -> shift-only (unbound). cmd/ctrl+shift ends. + page.keyboard.up("Control") + assert ev["cs_e1"].wait(timeout=5.0), "cmd/ctrl+shift didn't end going dormant" + + # Drag while dormant (nothing should fire). + page.mouse.move(end_x, end_y, steps=6) + + # Resume: press Ctrl -> cmd/ctrl+shift again (bound). + page.keyboard.down("Control") + assert ev["cs_s2"].wait(timeout=5.0), "cmd/ctrl+shift didn't resume after dormant" + + # Segment 4: release Shift -> back to cmd/ctrl (bound). + page.keyboard.up("Shift") + assert ev["ctrl_s2"].wait(timeout=5.0), "cmd/ctrl didn't restart on Shift release" + + # Release. + page.mouse.up() + page.keyboard.up("Control") + assert ev["ctrl_e2"].wait(timeout=5.0), ( + "final cmd/ctrl segment didn't end on release" + ) + + # Exactly two clean segments per combo across the whole gesture. + with lock: + assert ctrl_starts[0] == 2, ctrl_starts[0] + assert ctrl_ends[0] == 2, ctrl_ends[0] + assert cs_starts[0] == 2, cs_starts[0] + assert cs_ends[0] == 2, cs_ends[0] + + def test_scene_node_drag_filter_rejects_wrong_modifier( page: Page, viser_server: viser.ViserServer, diff --git a/tests/test_drag_modifier_segments.py b/tests/test_drag_modifier_segments.py index 97da7fc36..d801515d5 100644 --- a/tests/test_drag_modifier_segments.py +++ b/tests/test_drag_modifier_segments.py @@ -170,3 +170,58 @@ async def _(event: viser.SceneNodeDragEvent) -> None: assert ctrl_phases == ["start", "end"] assert not server.scene._is_drag_active_for("/unbound_box") + + +def test_many_segments_one_drag_routes_and_cycles( + server: viser.ViserServer, +) -> None: + """A single drag that switches modifiers SEVERAL times -- including + re-entering a previously-used combo -- routes each segment to its + callback as a clean, ordered start...end pair, and the active-drag + bookkeeping registers/pops correctly across every boundary. + + Dormant (unbound) gaps produce no wire messages (the client suppresses + them), so a realistic multi-stage stream is just the bound segments + back to back. We interleave cmd/ctrl and cmd/ctrl+shift four times to + exercise repeated switching and re-entry of both combos in one drag.""" + box = server.scene.add_box("/many_box", dimensions=(1.0, 1.0, 1.0)) + + ctrl_phases: list[str] = [] + ctrl_shift_phases: list[str] = [] + + @box.on_drag("left", modifier="cmd/ctrl") + async def _(event: viser.SceneNodeDragEvent) -> None: + ctrl_phases.append(event.phase) + assert event.modifier == "cmd/ctrl" + + @box.on_drag("left", modifier="cmd/ctrl+shift") + async def _(event: viser.SceneNodeDragEvent) -> None: + ctrl_shift_phases.append(event.phase) + assert event.modifier == "cmd/ctrl+shift" + + client = cast(ClientId, 13) + server._connected_clients[client] = Mock() + + # ctrl -> ctrl+shift -> ctrl -> ctrl+shift, all in one physical drag. + sequence: list[tuple[_messages._DragPhase, _messages.KeyModifier]] = [ + ("start", "cmd/ctrl"), + ("update", "cmd/ctrl"), + ("end", "cmd/ctrl"), + ("start", "cmd/ctrl+shift"), + ("update", "cmd/ctrl+shift"), + ("end", "cmd/ctrl+shift"), + ("start", "cmd/ctrl"), # re-enter ctrl + ("end", "cmd/ctrl"), + ("start", "cmd/ctrl+shift"), # re-enter ctrl+shift + ("end", "cmd/ctrl+shift"), + ] + for phase, modifier in sequence: + _dispatch(server, client, _drag_msg("/many_box", phase, modifier)) + + # Each callback sees its two segments as clean, ordered, paired phases + # -- no cross-talk, no dropped or duplicated start/end across the four + # switches. + assert ctrl_phases == ["start", "update", "end", "start", "end"] + assert ctrl_shift_phases == ["start", "update", "end", "start", "end"] + # Bookkeeping fully released after the final end. + assert not server.scene._is_drag_active_for("/many_box")