Skip to content

Add drag-handle overlay to edit per-note playback start/duration offsets - #1

Open
tharos-devs wants to merge 17 commits into
feature/note-offsetsfrom
feature/note-offset-drag-handles
Open

Add drag-handle overlay to edit per-note playback start/duration offsets#1
tharos-devs wants to merge 17 commits into
feature/note-offsetsfrom
feature/note-offset-drag-handles

Conversation

@tharos-devs

Copy link
Copy Markdown
Owner

Summary

Builds on musescore#34546 (feature/note-offsets) to add a visual, on-canvas way to edit the playbackStartOffset/playbackDurationOffset properties introduced there, as an alternative to the Properties panel spinboxes.

  • Toggleable "Note offsets" overlay (toolbar action, next to Automation): draws a small rounded rectangle above each note, positioned on the note's own layout position.
  • Dragging the rectangle's left/right edge adjusts the start/duration offset respectively, with a live preview and a single undo/redo step on release.
  • Dragging a note that's part of a multi-note selection applies the same tick delta to every selected note.
  • The notation dims while the mode is active, matching the existing Automation overlay.
image

Architecture

Mirrors the existing Automation overlay pattern (NotationAutomationController + PolylinePlot), simplified since offset persistence goes through the existing generic undoChangeProperty/Pid mechanism rather than a dedicated curve/undo command:

  • INotationNoteOffsets / NotationNoteOffsets: edit-mode toggle state.
  • NotationNoteOffsetController: builds/positions one overlay per staff from actual note layout, converts drag input to ticks via segment interpolation, commits through undoChangeProperty.
  • NoteOffsetOverlay: QQuickPaintedItem drawing rectangles/handles and doing mouse hit-testing, independent of the Grip/NotationInteraction path.
  • segmentcanvasinterpolation.{h,cpp}: canvasX<->tick interpolation shared between automation and note-offset controllers (previously duplicated inside notationautomationcontroller.cpp).

Test plan

  • Built in Release mode, app launches cleanly
  • Toggle shows rectangles above each note, width = notated duration, correct stacking on chords
  • Dragging left/right edges updates playbackStartOffset/ playbackDurationOffset live, matches Properties panel after release
  • Multi-note selection: dragging one edge applies the same delta to all selected notes in a single undo step
  • Undo/redo works after a committed drag
  • Scroll/zoom keep overlays aligned; switching Page View <-> Continuous View refreshes overlay geometry correctly
  • Toggling off removes overlays with no regression to normal selection/Grip editing

Depends on musescore#34546 — please review/merge that one first.

@tharos-devs
tharos-devs force-pushed the feature/note-offset-drag-handles branch from 1b030ad to e77b97b Compare August 13, 2026 12:05
Adds an on-canvas alternative to the Properties panel spinboxes for editing
the playbackStartOffset/playbackDurationOffset introduced in
"Add per-note playback start/duration offset properties": a toggleable
overlay ("Note offsets" toolbar action, alongside Automation) draws a small
rounded rectangle above each note, anchored on the note's own position.
Dragging the rectangle's left/right edge adjusts the start/duration offset
respectively, with a live preview during the drag and a single undo/redo
step on release. Dragging a note that is part of a multi-note selection
applies the same tick delta to every selected note. The notation dims while
the mode is active, matching the existing Automation overlay's behavior.

New files:
- INotationNoteOffsets / NotationNoteOffsets: edit-mode toggle state,
  mirroring INotationAutomation.
- NotationNoteOffsetController: builds/positions one overlay per staff from
  the actual note layout (not a fixed lane), converts drag input to ticks
  via segment interpolation, and commits offset changes through the
  existing undoChangeProperty/Pid machinery (no new UndoableCommand needed).
- NoteOffsetOverlay: QQuickPaintedItem drawing the rectangles/handles and
  doing its own hit-testing, independent of the Grip/NotationInteraction
  edit path - the same pattern PolylinePlot uses for automation.
- segmentcanvasinterpolation.{h,cpp}: canvasX<->tick interpolation shared
  between the automation and note-offset controllers (previously
  duplicated).

Wiring follows the existing "toggle-automation" action end to end
(notationuiactions.cpp, notationactioncontroller.cpp,
notationcommandsregister.cpp, notationcommandsstate.cpp,
notationtoolbarmodel.cpp) and reuses AbstractNotationPaintView's existing
per-view-matrix redraw/dim hooks.
@tharos-devs
tharos-devs force-pushed the feature/note-offset-drag-handles branch from e77b97b to 8990603 Compare August 13, 2026 14:16
Live-dragging a style value (e.g. "Staff space (sp)" in Page Settings)
relayouts the score without going through changesChannel(), so the
overlay's cached note positions went stale and stopped tracking the
rescaled notation in real time.
- Compare SysStaffKey's System pointer by address only, never
  dereferencing it - a stale key still held from a previous rebuild
  had a dangling System* once Page/Continuous view mode switching
  destroyed and recreated every System, crashing on
  MeasureBase::index().
- Skip tied-continuation notes when building overlay handles: their
  own playback offset is ignored by NoteRenderer::shouldRender() in
  most cases, so a handle there could never actually do anything.
- Disconnect from a document's viewModeChanged/styleChanged the moment
  a different document becomes current, instead of leaking one
  subscription per document ever opened in the session.
- Don't delete an overlay that's mid-drag (holding the mouse grab)
  when a rebuild is triggered; defer until the drag finishes.
- Handle mouseUngrabEvent so a stolen mouse grab mid-drag doesn't
  leave the overlay stuck thinking a drag is still in progress.
- Consolidate the three parallel per-staff maps (overlay/notes/band
  rect) into one map to a per-staff struct, and reuse an existing
  overlay item in place instead of destroying and recreating every
  overlay on every rebuild.
- Mutate a single rect in place during a drag instead of copying the
  whole staff's rect vector out and back on every mouse-move.
Continuation-line indentation was off by one space, flagged by the
codestyle CI check.
createOverlayForStaff() anchored the nominal (zero-offset) right edge on
"the next ChordRest segment", but that segment is shared across every
voice/track on the staff. If a different voice had a shorter simultaneous
note (e.g. an eighth note under a quarter note), its segment became the
"next" one for every voice, cutting the longer note's rectangle down to
the shorter note's end tick instead of its own.

Anchor the right edge on this chord's own end tick via canvasXFromTick
instead, which already correctly interpolates/snaps to the real segment
at that tick regardless of which voice created it.
Match the note-velocity overlay's convention: green when both playback
offsets are at their default (0), orange as soon as either the start
or duration offset has been user-modified, blue when the note is
selected (taking priority over the modified color, same as velocity).
Both offsets already round-trip correctly through save/reload, so the
color - computed live from those persisted values on every rebuild -
does too.
Mirrors the existing Automation context-menu entry: only appears when
note-offset edit mode is enabled (NotationContextMenuModel::loadItems),
and resets both playback offsets to 0 for every selected note via the
undo stack, so notes revert to the system-default (unmodified) offset.
The edge-drag handle nubs were always a fixed gray regardless of
whether the pill body was showing default/modified/selected color,
making them visually disconnected from the rectangle they belong to.
Handles now use a darker shade of the same state color (green/orange/
blue) as the pill body they're attached to.
Previously a tied-into note (note->tieBack()) got no overlay entry at
all, so the rectangle stopped at the first note's own duration and
left a visual gap over the rest of the tied chain, even though
playback already correctly sounds through the whole chain.

NoteEntry now tracks headNote/tailNote/anchorNote instead of a single
note. A chain-head's rectangle extends to the tail's own end tick
(walked via Note::lastTiedNote(), playback-accurate to match
NoteRenderer::renderNormalTie()), with the right/duration handle shown
only if the tail resolves within the same System. A new branch
handles a tied-continuation note whose *predecessor* lives in a
different System: it gets its own continuation fragment starting at
that System's own left edge, which is what actually closes the gap
for a tie crossing a system or page break - each System still only
knows its own coordinate space, so a single rectangle can't literally
span two of them, but a fragment per System reads as continuous.

Only the chain's first note owns the left/start handle and only its
last note owns the right/duration handle; everything in between has
neither, matching the fact that dragging duration from the middle of
a tie chain (or start from past its own end) has no sensible meaning.
Property writes always target the chain's head note regardless of
which fragment/handle was actually dragged, since only the head's own
offset is ever honored during playback.

Also added two one-sided drag clamps, both only active for a real tie
(tailNote != headNote): the duration handle can't shrink the total
span to end before the tail note's own start, and the start handle
can't push the start past the head note's own end.
The "Start offset"/"Duration offset" spinboxes read and wrote
Pid::PLAYBACK_START_OFFSET/DURATION_OFFSET on the exact selected note
with no tie-awareness at all: selecting a tied-continuation note let
you type a value that was silently ignored during playback (only the
chain's head note's own offset is ever honored) and, now that the
on-canvas overlay is tie-aware too, disagreed with what it shows for
that same chain.

NotePlaybackModel::headNoteElements() now remaps each selected note to
its firstTiedNote(false) for both directions, via a custom
onPropertyChangedCallBack passed to buildPropertyItem - ties into an
extension point PropertiesPanelAbstractModel already supported, no
base-class changes needed. A note that is neither its chain's head nor
its tail (a middle link in a 3+-note chain) owns no handle at all in
the overlay, so it's dropped from the list entirely rather than
redirected, leaving both spinboxes disabled for it instead of quietly
editing a value it has no visual handle for.

Also fixed a separate, pre-existing refresh bug found while testing
this: committing a property change (e.g. releasing a drag) never
live-updated these spinboxes without leaving and re-entering the
Properties tab. GeneralSettingsModel::onNotationChanged() only ever
reloaded its own four Pids and never forwarded the notification down
into m_playbackProxyModel (unlike onCurrentNotationChanged() right
below it, which does forward to both nested models), and
NotePlaybackModel never overrode onNotationChanged() to react even if
it had been forwarded. Both gaps are now closed for NotePlaybackModel;
PlaybackProxyModel's other sibling models (arpeggio/fermata/breath/
glissando/gradual tempo change) have the same gap and are left as a
known, separate follow-up.
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