Skip to content

feat: Tweeq-style drag scrubbing for ScrubableNumberInput#12418

Open
LittleSound wants to merge 1 commit into
mainfrom
rizumu/scrubable-tweeq
Open

feat: Tweeq-style drag scrubbing for ScrubableNumberInput#12418
LittleSound wants to merge 1 commit into
mainfrom
rizumu/scrubable-tweeq

Conversation

@LittleSound
Copy link
Copy Markdown
Collaborator

@LittleSound LittleSound commented May 22, 2026

Summary

Reworks ScrubableNumberInput into a Tweeq-style two-axis drag scrub: X moves value, Y rescales sensitivity through orders of magnitude. Pointer-lock hides the cursor during scrub; an SVG dot-ruler overlay visualises the active precision.

Changes

  • What: New scrubableNumberInput/ folder with a layered split — interpretGesture.ts (pure algorithm), useScrubValue.ts (state), useDragGesture.ts (DOM pointer wrangling + pointer-lock), BallRuler.vue (pure SVG view). ScrubableNumberInput.vue becomes a thin orchestrator. Drag now uses long-press (0.5s) or 1-px threshold to commit; click still focuses the input. While scrubbing, the ± buttons fade out and four chevron hints appear on the edges.
  • Breaking: None. Public props/slots/v-model preserved; all existing consumers (WidgetInputNumberInput, WidgetBoundingBox, RangeEditor, LinearControls) work unmodified.

Review Focus

A few design tastes worth knowing before reviewing:

  • Sensitivity envelope is calibrated, not magic. Two top-of-file constants — DRAG_PX_FOR_FULL_RANGE and DRAG_PX_PER_STEP_AT_FLOOR — describe the Y-axis bounds in plain English ("drag N px to cover the range at max sensitivity"). Translate to minSpeed/maxSpeed inline; no hidden coefficients.
  • Raw accumulator, quantised on read. Sub-step deltas would otherwise be rounded away and the value would stall under fine sensitivity. useScrubValue accumulates a raw value and runs the validator only on the way out — a tiny per-frame delta still accrues until it crosses a step boundary.
  • Sensitivity persists per-instance across drag sessions. reset() clears the EMA / weight but not speedMult, so a slip-up release doesn't force re-calibration. Each input has its own closure → independent per slider for free.
  • Zero canvas-zoom logic in the gesture pipeline. The ruler SVG uses useElementSize (ResizeObserver contentRect → logical px, transform-independent) and the bar fill is width: % (also logical). Sensitivity uses step, no width involved.
  • BallRuler dashoffset anchoring is mathematically exact in bar mode. dashOffset = (value − min)/(max − min) × width guarantees one ball lies on the handle for any dash gap (j = ⌊D/N⌋ trick). In free mode the +width/2 term phase-aligns a ball to centre at value = 0, paired with pointer-lock so the locked cursor is the visual anchor.

Screenshots

CleanShot.2026-05-21.at.22.10.22-converted.mp4
CleanShot.2026-05-22.at.19.10.04.mp4

X-axis drags value, Y-axis adjusts sensitivity with an SVG dot-ruler
overlay showing the active precision. Pointer-lock hides the cursor
during scrub and returns it to the press position on release.
@LittleSound LittleSound requested a review from a team May 22, 2026 11:07
@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label May 22, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 22, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR refactors ScrubableNumberInput from direct modelValue mutation to a composable-based drag-scrubbing system. The new architecture adds interpretGesture (pure gesture math), useScrubValue (state management), useDragGesture (pointer events), and BallRuler (visualization), enabling quantized value changes, sensitivity-aware deltas, and pointer-locked dragging with optional drag delay.

Changes

Drag-based Number Input Scrubbing System

Layer / File(s) Summary
Gesture Interpretation Core
src/components/common/scrubableNumberInput/interpretGesture.ts, src/components/common/scrubableNumberInput/interpretGesture.test.ts
Pure interpretGesture function converts pointer deltas into normalized direction averaging, smooth crossfade weighting, and speed-scaled value deltas. Tests verify horizontal/vertical/diagonal motion, speed clamping, and normalization.
Scrub Value State Management
src/components/common/scrubableNumberInput/useScrubValue.ts
useScrubValue composable manages reactive scrub accumulators and delegates gesture math to interpretGesture. Exposes apply(dx, dy) for delta integration, reset() to clear transient state, setValue() for external sync, and emits onChange only when validated value changes.
Drag Gesture Event Handling
src/components/common/scrubableNumberInput/useDragGesture.ts
useDragGesture composable wraps pointer event lifecycle: validates by button/type/disabled, enforces optional drag delay, uses pointer capture, discriminates click vs drag, compensates for browser zoom, and invokes onDrag/onClick/onDragEnd callbacks.
Ball Ruler Visualization Component
src/components/common/scrubableNumberInput/BallRuler.vue
BallRuler.vue renders layered SVG dashed lines. Computes dashOffset based on scrub value with bar-pinned or center-pinned behavior and derives layers from speedMult to control precision, dash gap, and opacity.
ScrubableNumberInput Component Integration
src/components/common/ScrubableNumberInput.vue
Refactors component to wire gesture-driven scrubbing: decrement/increment/keyboard handlers call scrub.setValue(clamp(...)) instead of mutating modelValue; useDragGesture manages swipe overlay; useScrubValue quantizes values with dynamic speed bounds; BallRuler visualizes scrub state; edit mode switches between scrub and text input via gesture onClick and onClickOutside.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

core/1.44

Suggested reviewers

  • marawan206
  • pythongosssss
  • DrJKL

Poem

🐰 A rabbit scrolls with grace and ease,
Dragging values through the breeze,
With gestures pure and state precise,
The ruler glides—a polish quite nice!
No clumsy clicks, just swipes divine,
The input shines in its design. ✨

🚥 Pre-merge checks | ✅ 6 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (6 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: implementing Tweeq-style drag scrubbing for the ScrubableNumberInput component.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
End-To-End Regression Coverage For Fixes ✅ Passed PR title uses "feat:" indicating a feature implementation, not a bug fix. End-to-end regression test requirement applies only to PRs with explicit bug-fix language per check criteria.
Adr Compliance For Entity/Litegraph Changes ✅ Passed PR modifies only src/components/common/ Vue component files. Does not touch src/lib/litegraph/, src/ecs/, or graph entity code. ADR compliance check not applicable.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering all required template sections with detailed context and design rationale.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rizumu/scrubable-tweeq

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

🎭 Playwright: ❌ 1628 passed, 3 failed · 3 flaky

❌ Failed Tests

📊 Browser Reports
  • chromium: View Report (✅ 1609 / ❌ 2 / ⚠️ 3 / ⏭️ 5)
  • chromium-2x: View Report (✅ 2 / ❌ 0 / ⚠️ 0 / ⏭️ 0)
  • chromium-0.5x: View Report (✅ 1 / ❌ 0 / ⚠️ 0 / ⏭️ 0)
  • mobile-chrome: View Report (✅ 16 / ❌ 1 / ⚠️ 0 / ⏭️ 0)

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

🎨 Storybook: ✅ Built — View Storybook

Details

⏰ Completed at: 05/22/2026, 11:09:32 AM UTC

Links

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/common/scrubableNumberInput/useDragGesture.ts`:
- Around line 35-141: Add behavioral unit tests for the useDragGesture
composable covering its lifecycle: write tests that mount a dummy element with
useDragGesture and assert that onClick is called for short taps,
onDrag/onDragEnd are called when movement exceeds the threshold in
onPointerMove, the dragDelay path triggers fireStart after the timeout, pointer
lock is requested when lockPointer is true and unlock is called on pointer up,
and teardown behavior runs on pointercancel/pointerleave (pointer capture
released and timers cleared). Target the functions useDragGesture,
onPointerDown, onPointerMove, onPointerUp, fireStart and teardown by dispatching
synthetic PointerEvents (varying pointerType, button, isPrimary, movement
distances) and use fake timers to simulate dragDelay; assert calls to
onDrag/onClick/onDragStart/onDragEnd and that lock/unlock are invoked and
pointer capture/release are performed.
- Around line 87-113: In onPointerMove (inside useDragGesture) stop relying
solely on event.movementX/movementY for dx/dy; detect when movementX/movementY
are 0/undefined (common for touch) and compute deltas from successive
event.clientX/clientY instead. Add a small state (e.g., lastClientX/lastClientY
or pointerLastPos) that you initialize from pointerDownAt when drag starts and
update on every pointer move; compute dx/dy = (movementX/movementY) /
browserZoom when available else (event.clientX - lastClientX) / browserZoom and
similarly for Y, then call options.onDrag(dx, dy, event) and update the
lastClient values. Ensure this logic runs after fireStart() creates the drag
state so the fallback has an initial lastClient value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d8f134d2-94a5-44e5-a861-f56fe612d939

📥 Commits

Reviewing files that changed from the base of the PR and between 7b4fef5 and bb0293a.

📒 Files selected for processing (6)
  • src/components/common/ScrubableNumberInput.vue
  • src/components/common/scrubableNumberInput/BallRuler.vue
  • src/components/common/scrubableNumberInput/interpretGesture.test.ts
  • src/components/common/scrubableNumberInput/interpretGesture.ts
  • src/components/common/scrubableNumberInput/useDragGesture.ts
  • src/components/common/scrubableNumberInput/useScrubValue.ts

Comment on lines +35 to +141
export function useDragGesture(
target: MaybeRef<HTMLElement | null | undefined>,
options: DragGestureOptions = {}
): { dragging: Readonly<ReturnType<typeof ref<boolean>>> } {
const dragging = ref(false)
const allowedTypes = options.pointerType ?? ['mouse', 'pen', 'touch']
const dragDelay = options.dragDelaySeconds ?? 0

const { lock, unlock } = usePointerLock(target)

let pointerId: number | null = null
let pointerDownAt: [number, number] | null = null
let dragDelayTimer: ReturnType<typeof setTimeout> | undefined
let pointerLocked = false

function teardown() {
if (dragDelayTimer !== undefined) {
clearTimeout(dragDelayTimer)
dragDelayTimer = undefined
}
pointerDownAt = null
pointerId = null
}

function fireStart(event: PointerEvent) {
dragging.value = true
if (unref(options.lockPointer) && !pointerLocked) {
pointerLocked = true
void lock(event).catch(() => {
pointerLocked = false
})
}
options.onDragStart?.(event)
}

function onPointerDown(event: PointerEvent) {
if (unref(options.disabled)) return
if (event.button !== 0 || !event.isPrimary) return
if (!allowedTypes.includes(event.pointerType as DragPointerType)) return

pointerId = event.pointerId
pointerDownAt = [event.clientX, event.clientY]
const el = unref(target)
el?.setPointerCapture(pointerId)

// Drag commitment is decided later — either by the movement-distance
// threshold in onPointerMove, or by this long-press timer expiring while
// the pointer is still down. Until then it's just a potential click.
if (dragDelay === 0) return
dragDelayTimer = setTimeout(() => fireStart(event), dragDelay * 1000)
}

function onPointerMove(event: PointerEvent) {
if (pointerId !== event.pointerId || pointerDownAt === null) return

if (!dragging.value) {
// Lock engages inside fireStart, not yet — so clientX/Y is still valid
// for the drag-vs-click distance threshold.
const minDist = event.pointerType === 'mouse' ? 1 : 5
const moved = Math.hypot(
event.clientX - pointerDownAt[0],
event.clientY - pointerDownAt[1]
)
if (moved < minDist) return
if (dragDelayTimer !== undefined) {
clearTimeout(dragDelayTimer)
dragDelayTimer = undefined
}
fireStart(event)
}

// Compensate for browser zoom (Cmd +/-). event.movementX/Y report in
// device-pixel-like units that don't honor the browser zoom level; the
// ratio outerWidth/innerWidth backs that out.
const browserZoom = window.outerWidth / window.innerWidth || 1
const dx = (event.movementX || 0) / browserZoom
const dy = (event.movementY || 0) / browserZoom
options.onDrag?.(dx, dy, event)
}

function onPointerUp(event: PointerEvent) {
if (pointerId !== event.pointerId) return
const el = unref(target)
el?.releasePointerCapture(event.pointerId)

const wasDragging = dragging.value
if (pointerLocked) {
void unlock()
pointerLocked = false
}
if (wasDragging) {
options.onDragEnd?.(event)
} else {
options.onClick?.(event)
}
dragging.value = false
teardown()
}

useEventListener(target, 'pointerdown', onPointerDown)
useEventListener(target, 'pointermove', onPointerMove)
useEventListener(target, 'pointerup', onPointerUp)
useEventListener(target, 'pointercancel', onPointerUp)
useEventListener(target, 'pointerleave', onPointerUp)

return { dragging: readonly(dragging) }
}
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.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Add behavioral tests for drag lifecycle paths in this composable.

This file introduces core interaction logic (delay threshold, click-vs-drag split, pointer lock path, pointer cancel/leave cleanup), but there are no direct tests for it in this PR. Please add src/**/*.test.ts coverage for these paths.

As per coding guidelines "src/**/*.test.ts: ... Aim for behavioral coverage of critical and new features in unit tests" and "**/*.{test,spec}.ts: Write tests for all changes, especially bug fixes to catch future regressions".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/common/scrubableNumberInput/useDragGesture.ts` around lines 35
- 141, Add behavioral unit tests for the useDragGesture composable covering its
lifecycle: write tests that mount a dummy element with useDragGesture and assert
that onClick is called for short taps, onDrag/onDragEnd are called when movement
exceeds the threshold in onPointerMove, the dragDelay path triggers fireStart
after the timeout, pointer lock is requested when lockPointer is true and unlock
is called on pointer up, and teardown behavior runs on
pointercancel/pointerleave (pointer capture released and timers cleared). Target
the functions useDragGesture, onPointerDown, onPointerMove, onPointerUp,
fireStart and teardown by dispatching synthetic PointerEvents (varying
pointerType, button, isPrimary, movement distances) and use fake timers to
simulate dragDelay; assert calls to onDrag/onClick/onDragStart/onDragEnd and
that lock/unlock are invoked and pointer capture/release are performed.

Comment on lines +87 to +113
function onPointerMove(event: PointerEvent) {
if (pointerId !== event.pointerId || pointerDownAt === null) return

if (!dragging.value) {
// Lock engages inside fireStart, not yet — so clientX/Y is still valid
// for the drag-vs-click distance threshold.
const minDist = event.pointerType === 'mouse' ? 1 : 5
const moved = Math.hypot(
event.clientX - pointerDownAt[0],
event.clientY - pointerDownAt[1]
)
if (moved < minDist) return
if (dragDelayTimer !== undefined) {
clearTimeout(dragDelayTimer)
dragDelayTimer = undefined
}
fireStart(event)
}

// Compensate for browser zoom (Cmd +/-). event.movementX/Y report in
// device-pixel-like units that don't honor the browser zoom level; the
// ratio outerWidth/innerWidth backs that out.
const browserZoom = window.outerWidth / window.innerWidth || 1
const dx = (event.movementX || 0) / browserZoom
const dy = (event.movementY || 0) / browserZoom
options.onDrag?.(dx, dy, event)
}
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Do PointerEvent movementX/movementY reliably report non-zero deltas for touch pointers across major browsers?

💡 Result:

No. On major browsers, PointerEvent.movementX/movementY are not reliably non-zero for touch pointers. What the platform says 1) movementX/movementY are defined as deltas from the previous event, and are only specified to be non-zero for pointermove/pointerrawupdate; they are “always zero” for other event types (and for pointer events other than pointermove/pointerrawupdate) [1]. What happens in real engines (touch pointers) 2) There are cross-browser reports that movementX/movementY don’t behave consistently for touch/pointer input; for example, one w3c/pointerevents issue reports movementX is always 0 on pointerup (Chrome and Firefox) [2]. While that’s about pointerup (and the spec says non-deltas are expected there), it demonstrates lack of reliability across event types and engines. 3) Additionally, Mozilla has tracked platform/compat work specifically around whether movementX/movementY for pointerrawupdate should exist, noting Chrome support and uncertainty elsewhere (e.g., “not sure about Safari”)—again implying incomplete/variable support across major browsers [3]. 4) Even when movementX/movementY aren’t usable, the spec/MDN guidance is to compute deltas yourself from coordinates instead (MDN warns that movementX units vary and suggests avoiding the movement properties in favor of computing deltas from client coordinates) [1]. Practical conclusion - You should not depend on PointerEvent.movementX/movementY to reliably provide non-zero deltas for touch pointers across Chrome/Firefox/Safari. - For touch/pointer drag deltas, compute your own delta using event.clientX/event.clientY (or similar) against the previous pointermove for the same pointerId, per MDN’s recommendation [1] and Pointer Events’ requirement that pointermove fires for coordinate changes [4].

Citations:


Fix touch scrubbing: don’t rely on PointerEvent.movementX/movementY for deltas

movementX/movementY aren’t consistently non-zero for touch pointers across major browsers, so this can feed onDrag(0, 0, ...) during real finger movement. Compute deltas from successive clientX/clientY (optionally gated to the cases where movementX/Y are unusable).

💡 Suggested fix (fallback to client-position delta when movement is unavailable)
@@
   let pointerId: number | null = null
   let pointerDownAt: [number, number] | null = null
+  let lastClientPos: [number, number] | null = null
   let dragDelayTimer: ReturnType<typeof setTimeout> | undefined
   let pointerLocked = false
@@
     pointerDownAt = null
+    lastClientPos = null
     pointerId = null
   }
@@
     pointerId = event.pointerId
     pointerDownAt = [event.clientX, event.clientY]
+    lastClientPos = [event.clientX, event.clientY]
@@
     const browserZoom = window.outerWidth / window.innerWidth || 1
-    const dx = (event.movementX || 0) / browserZoom
-    const dy = (event.movementY || 0) / browserZoom
+    const movementDx = event.movementX || 0
+    const movementDy = event.movementY || 0
+    const fallbackDx = lastClientPos ? event.clientX - lastClientPos[0] : 0
+    const fallbackDy = lastClientPos ? event.clientY - lastClientPos[1] : 0
+    const useFallback =
+      event.pointerType !== 'mouse' &&
+      movementDx === 0 &&
+      movementDy === 0
+    const dx = (useFallback ? fallbackDx : movementDx) / browserZoom
+    const dy = (useFallback ? fallbackDy : movementDy) / browserZoom
+    lastClientPos = [event.clientX, event.clientY]
     options.onDrag?.(dx, dy, event)
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/common/scrubableNumberInput/useDragGesture.ts` around lines 87
- 113, In onPointerMove (inside useDragGesture) stop relying solely on
event.movementX/movementY for dx/dy; detect when movementX/movementY are
0/undefined (common for touch) and compute deltas from successive
event.clientX/clientY instead. Add a small state (e.g., lastClientX/lastClientY
or pointerLastPos) that you initialize from pointerDownAt when drag starts and
update on every pointer move; compute dx/dy = (movementX/movementY) /
browserZoom when available else (event.clientX - lastClientX) / browserZoom and
similarly for Y, then call options.onDrag(dx, dy, event) and update the
lastClient values. Ensure this logic runs after fireStart() creates the drag
state so the fallback has an initial lastClient value.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 22, 2026

Codecov Report

❌ Patch coverage is 38.28571% with 108 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ents/common/scrubableNumberInput/useDragGesture.ts 22.72% 51 Missing ⚠️
src/components/common/ScrubableNumberInput.vue 48.93% 22 Missing and 2 partials ⚠️
...nents/common/scrubableNumberInput/useScrubValue.ts 32.00% 17 Missing ⚠️
...mponents/common/scrubableNumberInput/BallRuler.vue 5.88% 16 Missing ⚠️
@@             Coverage Diff             @@
##             main   #12418       +/-   ##
===========================================
- Coverage   75.07%   59.99%   -15.08%     
===========================================
  Files        1526     1421      -105     
  Lines       96710    72679    -24031     
  Branches    28279    20193     -8086     
===========================================
- Hits        72602    43602    -29000     
- Misses      23214    28602     +5388     
+ Partials      894      475      -419     
Flag Coverage Δ
e2e ?
unit 59.99% <38.28%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ts/common/scrubableNumberInput/interpretGesture.ts 100.00% <100.00%> (ø)
...mponents/common/scrubableNumberInput/BallRuler.vue 5.88% <5.88%> (ø)
...nents/common/scrubableNumberInput/useScrubValue.ts 32.00% <32.00%> (ø)
src/components/common/ScrubableNumberInput.vue 52.00% <48.93%> (-26.19%) ⬇️
...ents/common/scrubableNumberInput/useDragGesture.ts 22.72% <22.72%> (ø)

... and 1042 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant