Skip to content

refactor: chord drags are stock JUCE drag-and-drop, and the ghost crosses windows - #13

Merged
owenpkent merged 1 commit into
mainfrom
refactor/chord-drag-dnd
Aug 2, 2026
Merged

refactor: chord drags are stock JUCE drag-and-drop, and the ghost crosses windows#13
owenpkent merged 1 commit into
mainfrom
refactor/chord-drag-dnd

Conversation

@owenpkent

Copy link
Copy Markdown
Owner

Stacked on #12. The base is feat/two-arp-lines, so the diff here is this refactor alone. Merge #12 first and GitHub retargets this to main.

The premise was false

Every chord drag in Keys — a tray candidate onto a pad, a pad onto the reference box, a card onto an arp slot, tab or macro row, a card off the row to clear it — was hand-rolled on mouseDown/mouseDrag/mouseUp plus juce::Desktop::findComponentAt, with the editor in the middle forwarding screen positions between two windows that could not see each other.

It rested on something the code and the docs stated as settled fact: that no DragAndDropContainer can deliver a drop across two top-level windows.

That is not true. startDragging takes a fourth parameter, allowDraggingToOtherJuceWindows, defaulting to false. Pass true and the drag image is added to the desktop instead of to the container, which makes getParentComponent() null inside JUCE's own findTarget and routes target lookup through findDesktopComponentBelow — every desktop component in z-order, walking up each parent chain for an interested DragAndDropTarget. That is the same hit test the workaround was performing by hand, and it was there the whole time.

Verified against JUCE 8.0.8 (juce_DragAndDropContainer.cpp:520-527 and :300-320). A docs PR is open upstream as juce-framework/JUCE#1692. I also checked that the VST3 wrapper calls addToDesktop, so the editor is in Desktop's list in a DAW and not only in the standalone — the same mechanism the old code already depended on.

What you will see

The ghost follows your cursor out of the window. It used to be an 84×26 chip painted inside whichever component owned the gesture, so it vanished at the window edge — exactly where the drop you were aiming for lived. The card itself now travels, at full size, carrying its own lock dot.

Nothing else about any gesture changed.

The seven behaviours, and where each one lives now

Behaviour How it survives
Drag off the row clears the card, unless a taker vetoes Payload::taken, set by every target. ChordPads::itemDropped sets it for any release landing on the strip at all, refused or not
Tray → reference copies, tray → pad consumes Two flags, not one. ChordRefCard sets taken only; ChordPads sets both for a tray source
Reference offered before the far window Deleted, and not replaced. One target is under the cursor, and z-order is JUCE's answer — strictly better than the old bounds test, which lit the reference box through a window sitting over it. The {-1,-1} sentinel is gone
clearChordPad before setChordPad Unchanged, in the tray branch of ChordPads::itemDropped
Locked pads refuse incoming drops; a locked card off the strip does nothing dropCellFor and the clear both check it. An internal move onto a lock is still allowed — moveChordPad swaps two slots and destroys nothing
Absolute slot indices; -2 is the live card Payload::index is absolute, so a page flip mid-drag cannot move it
Hover highlight clears on every exit path JUCE's own itemDragExit, including the far window closed mid-drag — ~DragImageComponent sends it

Two decisions worth arguing with

Boxing the chord, not passing an index. A tray candidate belongs to no slot and is not in the session, so there is no index the far end could look it up by. Payload is a ReferenceCountedObject in the var; the index it carries is provenance, not a way to fetch the chord.

The veto is read one message-loop turn after mouse-up, not in dragOperationEnded. A source's own mouseUp is too early — JUCE dispatches a component's mouseUp before its mouse listeners, and the drag image is a listener, so itemDropped has not run yet. dragOperationEnded is late enough but fires from ~DragImageComponent after a 120 ms dismissal animation and a timer: a third of a second in which a card dragged off the row still looks like it is there. Posting from mouseUp lands after the same event's listener dispatch and before the next frame.

I considered tracking hover state and reading it synchronously instead. Rejected: its failure mode is "the reference box takes the chord and the pad clears" — data loss, however rare — against a delay you cannot see.

Deleted

ChordPads::externalDropSlotAt and ArpPanel::externalDropSlotAt / externalDropLineAt (the same logic written twice, which ArpPanel.cpp admitted in a comment), setExternalDropTarget, setExternalDropSlot, dropExternalChord, draggedSlot, onDragOutside / onDropOutside / onDragEnd, the three onCandidate* pass-throughs on ChordGenPanel and its three reference-drop methods.

Two bug classes went with them: a highlight lit on the way out can no longer be left glowing at nothing, and the reference box no longer lights through an overlapping window.

One affordance changed

The locked card's ghost no longer fades when it is over nothing. The ghost is the real card now and carries its lock dot, which says the same thing without depending on where the pointer is. Noted in CONTROLS.md rather than left to be discovered.

While I was there I also fixed a CONTROLS.md sentence that was wrong before this branch: it claimed a drop that misses everything leaves the card where it was, when off-the-strip has always cleared it.

Testing

Builds clean (VST3, Keys_Standalone, KeysHost_Standalone), ctest passes, both windows render — the generator opens with a filled tray and an empty reference card.

Not yet hand-tested in a DAW, and that matters here. The drag image is now a plugin-owned temporary top-level window, which is the thing hosts differ on, so a passing standalone is not evidence for Live. Worth walking through there: tray→pad, tray→reference, pad→arp slot, pad→reference, drag off the row to clear, drag out and back onto a pad, and closing the generator window mid-drag.

…sses windows

Every chord drag in Keys - a tray candidate onto a pad, a pad onto the reference
box, a card onto an arp slot, tab or macro row, a card off the row to clear it -
was hand-rolled on mouseDown/mouseDrag/mouseUp plus Desktop::findComponentAt,
with the editor in the middle forwarding screen positions between two windows
that could not see each other.

It rested on a premise the code and the docs asserted as settled fact: that no
DragAndDropContainer can deliver a drop across two top-level windows. That
premise is false. startDragging takes a fourth parameter,
allowDraggingToOtherJuceWindows, defaulting to false; pass true and the drag
image is added to the desktop rather than to the container, which makes
getParentComponent() null inside JUCE's own findTarget and routes target lookup
through findDesktopComponentBelow - every desktop component in z-order, walking
up each parent chain for an interested target. That is the same hit test the
workaround was performing by hand, and it was there the whole time. Verified
against JUCE 8.0.8.

User-visible: the ghost now follows the cursor out of one window and across the
other. It used to be an 84x26 chip painted inside whichever component owned the
gesture, so it vanished at the window edge - exactly where the drop you were
aiming for lived. The card itself now travels, at full size, carrying its own
lock dot.

No gesture behaviour changed. A drop still refuses a locked pad, still calls
clearChordPad before setChordPad so a pad left ringing by Sustain or feeding the
arp gives its notes up properly, and dragging a card off the row still clears it
unless something took it. That last one is the sharp edge - reaching for the
reference box means dragging a card off the strip - so the veto rides on the drag
payload as `taken`, with `consumed` as the separate answer for a tray candidate
(committed to a pad its cell empties, copied to the reference it does not).

Both flags are read one message-loop turn after mouse-up rather than in
dragOperationEnded: a source's own mouseUp runs before its mouse listeners and so
before itemDropped, while dragOperationEnded waits out a 120 ms dismissal
animation and a timer - a third of a second in which a card dragged off the row
still looks like it is there.

Gone: ChordPads::externalDropSlotAt and ArpPanel::externalDropSlotAt /
externalDropLineAt (the same logic written twice), setExternalDropTarget,
setExternalDropSlot, dropExternalChord, draggedSlot, onDragOutside /
onDropOutside / onDragEnd, the three onCandidate* pass-throughs on ChordGenPanel
and its three reference-drop methods.

Two bug classes went with them. A target's highlight is now put out by JUCE's own
itemDragExit on every path a drag can end, including the far window being closed
mid-gesture, which the editor had to remember by hand; and the reference box no
longer lights up through a window sitting on top of it, because z-order is the
framework's answer rather than a bounds test.

One affordance did change: the locked card's ghost no longer fades when it is
over nothing. The ghost is the real card now and carries its lock dot, which says
the same thing without depending on where the pointer is. Noted in CONTROLS.md.
@owenpkent
owenpkent force-pushed the refactor/chord-drag-dnd branch from d65840b to 934b5fc Compare August 2, 2026 20:18
@owenpkent
owenpkent changed the base branch from feat/two-arp-lines to main August 2, 2026 20:18
@owenpkent

Copy link
Copy Markdown
Owner Author

Reviewed and rebased onto main now that #12 has landed. Base retargeted, 934b5fc.

The premise checks out, which is the thing worth confirming: allowDraggingToExternalWindows really does null out getParentComponent() inside JUCE's findTarget and route the lookup through findDesktopComponentBelow, walking every desktop component in z-order. That is exactly the hit test externalDropSlotAt was doing by hand, so this deletes a workaround for a limitation that was never there.

All four pad gestures survive the move, including the three lock rules, which I checked one at a time against the old mouseUp: a tray drop refuses a locked pad, a move onto a locked pad is allowed because moveChordPad only swaps and destroys nothing, and a live-card capture onto a locked pad stays allowed rather than being quietly tightened in a refactor. Setting taken before the branches - so a lock refusing, a candidate over the live card and a card dropped back where it started all count as "did not go off the row" - is the right place for it.

The one thing to keep an eye on is whenDragSettles. It rests on JUCE dispatching a component's own mouseUp before its mouse listeners, and the drag image being a listener. That is true, the comment explains why dragOperationEnded is the wrong length of wait, and there is no better hook. But it is an implementation detail rather than a documented contract, and the failure mode if a JUCE upgrade reorders it is bad and silent: taken reads false before itemDropped sets it, and a card dropped on the reference box or an arp target gets cleared instead of kept - the exact data loss the veto exists to prevent. Worth a line in docs/ naming the JUCE version this was verified against, so the next upgrade has something to check rather than a behaviour to rediscover.

Rebase was clean, no conflicts. Local build and Keys_tests green in the worktree.

@owenpkent
owenpkent merged commit 9eaafe3 into main Aug 2, 2026
5 of 6 checks passed
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