Skip to content

feat: Tweeq-style drag scrubbing for ScrubableNumberInput - #12418

Open
LittleSound wants to merge 3 commits into
mainfrom
rizumu/scrubable-tweeq
Open

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

Conversation

@LittleSound

@LittleSound LittleSound commented May 22, 2026

Copy link
Copy Markdown
Contributor

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

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 090317de-1f93-4ff7-8eaa-f234fb447d84

📥 Commits

Reviewing files that changed from the base of the PR and between 96890a0 and b452309.

📒 Files selected for processing (1)
  • src/components/common/scrubableNumberInput/useDragGesture.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/common/scrubableNumberInput/useDragGesture.test.ts

📝 Walkthrough

Walkthrough

Refactors ScrubableNumberInput to a gesture-driven scrubbing system: pure gesture interpreter, scrub state composable, pointer-event drag composable, BallRuler SVG visualization, plus integration and tests for gesture math and pointer handling.

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 converts dx/dy into direction EMA, crossfade weight, valueDelta, and clamped speedMultNext. Tests verify horizontal/vertical/diagonal behavior, normalization, and clamping.
Scrub Value State Management
src/components/common/scrubableNumberInput/useScrubValue.ts
useScrubValue holds raw accumulator, dir EMA, speed multiplier, exposes readonly validated state.value, and methods apply(dx,dy), reset(), setValue(), emitting onChange only for validated changes.
Drag Gesture Event Handling
src/components/common/scrubableNumberInput/useDragGesture.ts, src/components/common/scrubableNumberInput/useDragGesture.test.ts
useDragGesture wraps pointer events with gating, optional long-press delay, pointer capture/optional pointer-lock, zoom-compensated deltas, and click-vs-drag semantics. Tests cover thresholds, timers, pointer-lock, movement sources, and cancel behavior.
Ball Ruler Visualization Component
src/components/common/scrubableNumberInput/BallRuler.vue
SVG ruler component that computes dashOffset and layered dash properties from scrub state, width, and min/max; renders multiple dashed lines reflecting speed/precision.
ScrubableNumberInput Component Integration
src/components/common/ScrubableNumberInput.vue
Refactors component wiring: decrement/increment/keyboard use scrub.setValue(clamp(...)); swipe overlay uses useDragGesture and useScrubValue (reset/apply/validate on drag); preserves text edit mode and click-out commit behavior; measures width to show BallRuler when appropriate.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

core/1.44

Suggested reviewers

  • marawan206
  • pythongosssss
  • DrJKL

Poem

🐰 I hop and nudge the number line,
With gentle drags the digits shine,
A ruler hums, the value glides,
Snaps and checks on every stride,
Small rabbit taps — precise designs.

🚥 Pre-merge checks | ✅ 6 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% 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 accurately summarizes the main change: implementing Tweeq-style drag scrubbing for the ScrubableNumberInput component.
Description check ✅ Passed The description is comprehensive and well-structured. It covers the summary, detailed changes, breaking changes confirmation, dependencies, and design considerations. All required sections from the template are present and filled out appropriately.
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:" prefix indicating a feature addition, not a bug fix. Check only requires e2e regression tests for bug-fix PRs, not feature PRs.
Adr Compliance For Entity/Litegraph Changes ✅ Passed PR contains no changes to src/lib/litegraph/, src/ecs/, or graph entity files; all changes are UI components in src/components/common/. ADR compliance check does not apply.

✏️ 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

github-actions Bot commented May 22, 2026

Copy link
Copy Markdown

🎭 Playwright: ❌ 1637 passed, 1 failed · 1 flaky

❌ Failed Tests

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

@github-actions

github-actions Bot commented May 22, 2026

Copy link
Copy Markdown

🎨 Storybook: ✅ Built — View Storybook

Details

⏰ Completed at: 05/27/2026, 06:18:14 AM UTC

Links

@coderabbitai coderabbitai Bot 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.

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 thread src/components/common/scrubableNumberInput/useDragGesture.ts
@codecov

codecov Bot commented May 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.85083% with 60 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
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 ⚠️
...ents/common/scrubableNumberInput/useDragGesture.ts 95.83% 3 Missing ⚠️
@@             Coverage Diff             @@
##             main   #12418       +/-   ##
===========================================
- Coverage   75.07%   60.31%   -14.76%     
===========================================
  Files        1526     1420      -106     
  Lines       96710    72782    -23928     
  Branches    28279    20273     -8006     
===========================================
- Hits        72602    43901    -28701     
- Misses      23214    28405     +5191     
+ Partials      894      476      -418     
Flag Coverage Δ
e2e ?
unit 60.31% <66.85%> (+0.38%) ⬆️

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%> (ø)
...ents/common/scrubableNumberInput/useDragGesture.ts 95.83% <95.83%> (ø)
...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%) ⬇️

... and 1049 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.

Add behavioral tests for the full pointer lifecycle (click vs drag,
long-press, pointer lock, pointercancel/leave cleanup). Derive scrub
deltas from clientX/Y when not pointer-locked so touch — where
movementX/Y is often 0/undefined — works; keep movementX/Y under lock
where clientX/Y is pinned.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/components/common/scrubableNumberInput/useDragGesture.test.ts (1)

34-54: ⚡ Quick win

Tighten mount options typing to prevent silent test misconfiguration.

Line 34 and Line 53 currently allow any option key via Record<string, unknown> and then force-cast. Typing this as Partial<Parameters<typeof useDragGesture>[1]> keeps test helpers type-safe and catches option typos at compile time.

♻️ Proposed change
-function mount(options: Record<string, unknown> = {}) {
+function mount(options: Partial<Parameters<typeof useDragGesture>[1]> = {}) {
@@
-  const merged = { ...cb, ...options } as Parameters<typeof useDragGesture>[1]
+  const merged: Parameters<typeof useDragGesture>[1] = { ...cb, ...options }
   scope.run(() => useDragGesture(ref(el), merged))
 }

As per coding guidelines, "Use TypeScript for type safety throughout the codebase."

🤖 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.test.ts` around
lines 34 - 54, The mount helper currently types options as Record<string,
unknown> then force-casts into the useDragGesture options which hides
misconfigurations; change the mount signature to accept options:
Partial<Parameters<typeof useDragGesture>[1]> (and remove the forced cast when
creating merged) so TypeScript will catch invalid option keys — update the mount
function, the options parameter, and the merged variable creation (which
currently spreads cb and options) to use that Partial type to ensure type-safe
test setup for useDragGesture.
🤖 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.test.ts`:
- Around line 199-207: Add a new sibling test case after the existing
pointercancel test that validates cleanup behavior for the pointerleave event.
The test should follow the same pattern as the current test: mount the
component, dispatch pointerdown and pointermove events to initiate a drag, then
dispatch a pointerleave event instead of pointercancel. Assert that both
cb.onDragEnd is called once and el.releasePointerCapture is invoked with the
pointer ID (1) to ensure pointerleave properly ends the drag and releases
pointer capture just as pointercancel does.

---

Nitpick comments:
In `@src/components/common/scrubableNumberInput/useDragGesture.test.ts`:
- Around line 34-54: The mount helper currently types options as Record<string,
unknown> then force-casts into the useDragGesture options which hides
misconfigurations; change the mount signature to accept options:
Partial<Parameters<typeof useDragGesture>[1]> (and remove the forced cast when
creating merged) so TypeScript will catch invalid option keys — update the mount
function, the options parameter, and the merged variable creation (which
currently spreads cb and options) to use that Partial type to ensure type-safe
test setup for useDragGesture.
🪄 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: 35b39815-d3b6-4aed-82e0-50976f2d9302

📥 Commits

Reviewing files that changed from the base of the PR and between bb0293a and 96890a0.

📒 Files selected for processing (2)
  • src/components/common/scrubableNumberInput/useDragGesture.test.ts
  • src/components/common/scrubableNumberInput/useDragGesture.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/common/scrubableNumberInput/useDragGesture.ts

Comment thread src/components/common/scrubableNumberInput/useDragGesture.test.ts
Add a pointerleave sibling to the pointercancel test, and type the mount
helper's options as Partial<DragOptions> with explicitly-typed callback
mocks so option-key typos are caught at compile time instead of cast away.
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