Skip to content

feat(curve-editor): live drag preview + trackpad pan + scroll/zoom controls - #392

Merged
fernandotonon merged 2 commits into
masterfrom
feat/curve-editor-d3-followups
May 5, 2026
Merged

feat(curve-editor): live drag preview + trackpad pan + scroll/zoom controls#392
fernandotonon merged 2 commits into
masterfrom
feat/curve-editor-d3-followups

Conversation

@fernandotonon

@fernandotonon fernandotonon commented May 5, 2026

Copy link
Copy Markdown
Owner

Summary

Addresses the two follow-up issues from D3 (#380, #382) and the user-reported "T-pose blink while dragging keyframes":

  • Live drag preview — adds moveKeyframePreview / setKeyframeValuePreview non-undoable setters used during keyframe drag. Single Ctrl+Z still reverts the whole gesture because the real MoveKeyframeCommand + SetKeyframeValueCommand are pushed once on release.
  • Trackpad pan — two-finger swipe pans (horizontal = time, vertical = value) via WheelHandler with pixelDelta for high-resolution input.
  • Scroll + zoom — horizontal ScrollBar.AlwaysOn for long animations, +/− zoom buttons for both axes, fit-to-view button, Cmd/Ctrl+wheel for horizontal zoom, Shift+wheel for vertical zoom.

Why the preview API

Before: every keyframe drag move pushed a SetKeyframeValueCommand AND MoveKeyframeCommand onto the undo stack — once per pixel of cursor motion. MainWindow's QUndoStack::indexChanged handler calls skel->reset(true) after every push, so the bone visibly snapped to T-pose between events.

After: drag previews retime/rewrite in place and skip the undo stack. On release, the QML handler restores the originals first, then pushes the real commands — redo() captures the correct old-state snapshot, so Ctrl+Z reverts the whole gesture as a single edit.

Trackpad gesture summary

  • Plain two-finger scroll → pan (time + value)
  • Cmd/Ctrl + wheel → horizontal zoom
  • Shift + wheel → vertical zoom
  • +/− toolbar buttons → zoom either axis
  • ⤢ → fit animation length to canvas

Pinch-to-zoom isn't wired up: Qt 6 routes macOS native magnify gestures (NSEventTypeMagnify) through paths that PinchHandler doesn't observe when a covering MouseArea owns the pointer. Cmd+wheel and the +/− buttons cover the use case consistently across platforms.

Test plan

  • Drag a keyframe square in the curve editor → no T-pose blink, square + curve track cursor
  • Two-finger trackpad swipe → pans cleanly
  • Cmd+wheel / Shift+wheel → zoom horizontally / vertically
  • +/− toolbar buttons → zoom around canvas center
  • ⤢ → animation fits to view width
  • Long animation → scrollbar appears, drag thumb scrolls, position stays in sync with wheel pan
  • Single Ctrl+Z reverts the whole drag gesture (value + time)
  • Unit tests: MoveKeyframePreview* and SetKeyframeValuePreview* cover undo-stack behavior, TRS preservation, collision/missing-time rejection, no-op cases

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added horizontal and vertical zoom controls to the animation curve editor with +/-, and fit-to-view buttons.
    • Enabled drag-and-drop keyframe editing with optional axis-locking.
    • Added tangent handle manipulation with live curve updates.
    • Enhanced mouse wheel controls for panning and zooming.
    • Introduced live preview mode for keyframe edits without affecting undo history.
  • Tests

    • Added comprehensive tests for keyframe preview operations.

…rols

The curve editor's keyframe drag pushed an undo command per move event,
firing MainWindow's QUndoStack::indexChanged handler — which calls
Skeleton::reset(true) — so the bone visibly snapped to T-pose between
events. Add non-undoable preview setters (moveKeyframePreview,
setKeyframeValuePreview) that retime/rewrite in place and skip the
undo stack; the QML drag handler commits one MoveKeyframeCommand +
SetKeyframeValueCommand on release so a single Ctrl+Z reverts the
whole gesture.

Also wires up:
- Two-finger trackpad pan (horizontal swipe = time, vertical = value)
  via WheelHandler with pixelDelta tracking, scoped inside the
  MouseArea so pointer handlers actually receive trackpad events.
- Cmd/Ctrl+wheel = horizontal zoom; Shift+wheel = vertical zoom.
- Toolbar +/− zoom buttons + fit-to-view, anchored to a cursor pixel.
- Horizontal ScrollBar.AlwaysOn that tracks viewStart in both
  directions with a `syncing` guard against the binding loop.
- Inline rows + channelValues refresh during preview so the keyframe
  square + curve track the cursor in real time.

Tests cover preview methods: no undo push, TRS preserved on retime,
collision rejection, missing-time rejection, no-op on identical time,
no-op without selection, unknown-channel rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@fernandotonon has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 31 minutes and 17 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fd2807da-53b7-4827-8f64-c8883f68de4e

📥 Commits

Reviewing files that changed from the base of the PR and between 84917f6 and bf8d9b8.

📒 Files selected for processing (1)
  • qml/AnimationCurveEditor.qml
📝 Walkthrough

Walkthrough

This PR transforms the AnimationCurveEditor into an interactive tool by adding horizontal/vertical zoom controls with fit-to-view, keyframe and tangent-handle dragging via live preview APIs, axis-locking via Shift, and viewport synchronization with a horizontal scrollbar. The C++ controller gains moveKeyframePreview and setKeyframeValuePreview methods to support drag-preview commits without undo.

Changes

Interactive Curve Editor with Live Preview

Layer / File(s) Summary
C++ API Contract
src/AnimationControlController.h
New Q_INVOKABLE methods moveKeyframePreview(boneName, oldTime, newTime) and setKeyframeValuePreview(boneName, channel, time, value) for drag-preview operations without undo.
C++ Implementation
src/AnimationControlController.cpp
moveKeyframePreview retimes keyframes by remove/re-create with TRS preservation; setKeyframeValuePreview updates single channel values via new writeChannel(...) helper; both call notifyOgreUpdate() to refresh Ogre state.
Zoom & Pan Helpers
qml/AnimationCurveEditor.qml
Added zoomHorizontal, zoomVertical, fitToView, clampViewStart functions to adjust viewport scale/position with anchor-point preservation.
UI Controls
qml/AnimationCurveEditor.qml
New header buttons for H/V zoom in/out/fit-to-view; canvas layout updated with bottom margin; tangent hit-test function pickTangentHandleAt added.
Drag Interaction
qml/AnimationCurveEditor.qml
panArea expanded to accept LeftButton; left-drag triggers tangent/keyframe hit-tests; keyframe dragging calls preview APIs during motion, commits real moves on release; tangent dragging calls CurveEditModel.setTangents; Shift-lock restricts to single axis.
Wheel & Scroll
qml/AnimationCurveEditor.qml
Multiple WheelHandlers: default scrolls (panning), Ctrl/Cmd zooms horizontally, Shift zooms vertically; persistent horizontal scrollbar syncs positionroot.viewStart with guard to prevent bounce.
Tests & Validation
src/AnimationControlController_test.cpp
Comprehensive test suite for moveKeyframePreview (undo isolation, TRS preservation, collision/missing-time rejection) and setKeyframeValuePreview (channel write, unknown-channel rejection).

Sequence Diagram

sequenceDiagram
    participant User as User Interaction
    participant QML as AnimationCurveEditor.qml
    participant Controller as AnimationControlController
    participant Ogre as Ogre Rendering
    participant Visual as Visual Feedback

    User->>QML: Left-drag keyframe/tangent
    QML->>QML: Hit-test & track drag state
    QML->>Controller: moveKeyframePreview/setKeyframeValuePreview
    Controller->>Controller: Validate & update keyframe
    Controller->>Ogre: notifyOgreUpdate()
    Ogre->>Ogre: Refresh transform state
    Ogre->>Visual: Render updated curves
    Visual->>User: Live preview on canvas
    
    User->>User: Release drag
    QML->>Controller: moveKeyframe/setKeyframeValue (commit)
    Controller->>Controller: Push undo command & apply
    Controller->>Ogre: notifyOgreUpdate()
    Visual->>User: Final rendered state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

Possibly related PRs

  • fernandotonon/QtMeshEditor#375: Both extend AnimationControlController keyframe APIs (this PR adds preview operations for drag-preview; PR #375 adds bulk dope-sheet APIs).
  • fernandotonon/QtMeshEditor#384: Both enhance AnimationControlController keyframe behavior (this PR adds preview APIs with writeChannel; PR #384 adds auto-key and live-pose addKeyframe logic).
  • fernandotonon/QtMeshEditor#379: Builds on the same AnimationCurveEditor.qml, extending it from read-only view to fully interactive editor with drag, zoom, and tangent control.

Poem

🐰 Curves now dance beneath the paw,
Zoom and drag without a flaw!
Tangents bend on preview's grace,
Live feedback lights up the space—
Motion curves, at last, embrace!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.05% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description includes a summary of the solution and detailed technical explanations of why the preview API was needed and how the features work, but lacks explicit separation using the template's required section headings (Features, Bugfixes). Reorganize the description to explicitly use the template's Summary, Technical Details, Features, and Bugfixes sections with proper markdown structure.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: live drag preview, trackpad pan, and scroll/zoom controls, matching the core features in the PR objectives.
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.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/curve-editor-d3-followups

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 84917f6d47

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread qml/AnimationCurveEditor.qml Outdated
Comment on lines +513 to +518
panArea.dragLastValue = newValue
if (!lockX) {
AnimationControlController.setKeyframeValuePreview(
panArea.dragBone, panArea.dragChannel,
panArea.dragKeyTime, newValue)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Initialize and gate dragLastValue before committing release

dragLastValue is updated unconditionally on every mouse move, even when Y is axis-locked (lockY) and no preview value write occurs, and it is never initialized on press. On release, valueChanged compares this stale value to dragOriginalValue and can push setKeyframeValue(...) with an unintended value (e.g., Shift-horizontal drags with slight vertical jitter, or click-and-release without movement using a previous drag's value/default 0). This causes silent keyframe value edits during operations that should only retime or select.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 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 `@qml/AnimationCurveEditor.qml`:
- Around line 467-475: When starting a keyframe drag, seed the preview state by
initializing panArea.dragLastValue to the keyframe's current value so onReleased
doesn't treat an untouched click as an edit; update the first drag-start block
(where panArea.dragMode, dragBone, dragChannel, dragKeyTime,
dragOriginalKeyTime, dragOriginalValue, dragPressX/Y are set) to also set
panArea.dragLastValue = khit.value, and make the identical change in the other
drag-start block referenced around the later section (the block that sets the
same panArea.drag* properties at lines ~551-573) so both drag paths initialize
dragLastValue consistently.
- Around line 84-90: clampViewStart currently allows viewStart up to maxT -
visibleSecs * 0.5 which lets the canvas drift past the scrollbar's end; change
the clamp to use the same range as the scrollbar by computing visibleSecs =
curveCanvas.width / root.pxPerSec and clamping root.viewStart to Math.max(0,
AnimationControlController.animationLength - visibleSecs). Update the clamp
logic in the clampViewStart function (and the other similar clamp blocks noted
around the file) to use this exact maxStart formula so panning and middle-button
movement cannot move the canvas into blank space beyond the scrollbar limit.
- Around line 503-523: The axis-lock logic is inverted when applying previews:
swap the two condition checks so value updates are skipped when lockY is true
and time moves are skipped when lockX is true. Concretely, in the drag handler
use if (!lockY) around AnimationControlController.setKeyframeValuePreview(...)
(and update panArea.dragLastValue as now), and use if (!lockX) around
AnimationControlController.moveKeyframePreview(...) (and update
panArea.dragKeyTime when ok); keep the existing lockX/lockY calculations and the
referenced symbols (lockX, lockY, panArea.dragLastValue, panArea.dragBone,
panArea.dragChannel, panArea.dragKeyTime,
AnimationControlController.setKeyframeValuePreview,
AnimationControlController.moveKeyframePreview).
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6c3453c3-4d78-4d83-8fd3-92b981f14e3f

📥 Commits

Reviewing files that changed from the base of the PR and between d59eb7f and 84917f6.

📒 Files selected for processing (4)
  • qml/AnimationCurveEditor.qml
  • src/AnimationControlController.cpp
  • src/AnimationControlController.h
  • src/AnimationControlController_test.cpp

Comment thread qml/AnimationCurveEditor.qml
Comment thread qml/AnimationCurveEditor.qml
Comment thread qml/AnimationCurveEditor.qml
…range

Addresses CodeRabbit + ChatGPT-Codex review on PR #392:

- Initialize dragLastValue at press time so a click without drag
  doesn't commit a stale 0 back to the curve.
- clampViewStart() now clamps to (animationLength - visibleSecs),
  matching the scrollbar's scrollable range; middle-button pan
  routes through it instead of clamping only to >= 0, so the canvas
  can't drift past the thumb's end position.
- Replace the inverted lockX/lockY flags with writeValue/writeTime
  gating: a Shift-constrained drag now writes only the dominant
  axis instead of skipping the wrong one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented May 5, 2026

Copy link
Copy Markdown

@fernandotonon

Copy link
Copy Markdown
Owner Author

Thanks @coderabbitai @chatgpt-codex-connector — the three issues you flagged on commit bf8d9b8 are addressed by that same commit:

  • Clamp range (line 92): clampViewStart now uses maxT - visibleSecs matching the scrollbar's scrollable range; middle-button pan routes through clampViewStart().
  • dragLastValue init (line 482): seeded to khit.value at press time so a click-without-drag can't commit a stale 0.
  • Shift-lock semantics (line 533): replaced inverted lockX/lockY with writeValue/writeTime gates so a Shift-constrained drag writes only the dominant axis.

CI is green, SonarCloud quality gate passes, unit tests pass with new MoveKeyframePreview* and SetKeyframeValuePreview* coverage.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@fernandotonon: Thanks for the thorough follow-up! All three fixes look well-reasoned:

  • Clamp range: Using maxT - visibleSecs correctly aligns clampViewStart with the scrollbar's scrollable range — no more over-panning past the end of the animation.
  • dragLastValue init: Seeding to khit.value at press time is the right defensive move; prevents any accidental commit of a zero/stale value on a bare click.
  • Shift-lock semantics: The writeValue/writeTime gate pattern is much cleaner than the inverted boolean approach — the intent is immediately clear at the call site.

Great to hear CI, SonarCloud, and the new preview unit test coverage are all green. 🎉

(\_/)
(•ᴗ•)ノ🎉
/ >🐇

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@fernandotonon
fernandotonon merged commit d722fde into master May 5, 2026
19 checks passed
@fernandotonon
fernandotonon deleted the feat/curve-editor-d3-followups branch May 5, 2026 13:18
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.

1 participant