diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a689a8..37c628c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ All notable changes to Keys are documented here. Format follows ## [Unreleased] +### Changed: the chord drag is stock JUCE, and the ghost now follows your cursor between windows + +Every chord drag in Keys - a tray candidate onto a pad, a pad onto the reference box, a card onto +an arp slot, a tab or a 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 is +now `juce::DragAndDropContainer` / `DragAndDropTarget`, which is what it should have been all +along. + +- **The premise the workaround rested on was false.** The code and the docs asserted, as settled + fact, that no `DragAndDropContainer` can deliver a drop across two top-level windows. + `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 performed + by hand. Verified against JUCE 8.0.8; a docs PR is open upstream as juce-framework/JUCE#1692. +- **User-visible: the ghost crosses the window boundary.** It used to be an 84x26 chip painted at + the cursor 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, as + a window of its own. The dimmed hole it leaves behind stays, because that is what says which + card is in the air. +- **Nothing else about any gesture 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, still keeps the candidate when it misses, 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, and JUCE has no opinion about it, so the + veto rides on the drag payload as `taken`. `consumed` is the separate answer for a tray + candidate - committed to a pad its cell empties, copied to the reference it does not. +- **Two hit tests written twice are now written none.** `ChordPads::externalDropSlotAt` and + `ArpPanel::externalDropSlotAt` / `externalDropLineAt` are gone, along with `onDragOutside`, + `onDropOutside`, `onDragEnd`, `setExternalDropSlot`, `dropExternalChord`, + `setExternalDropTarget` and the three `onCandidate*` pass-throughs on `ChordGenPanel`. +- **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 used to have to remember by hand; and the reference box no longer lights up + through a window sitting on top of it, because z-order is now the framework's answer rather + than a bounds test. + ### Changed: two arpeggiator lines, both on screen, and cards that sound on release Owen: "I only wanna view two arpeggiators in this window, and I wanna be able to drag a chord diff --git a/CLAUDE.md b/CLAUDE.md index 1ab4c86..1cfcc84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,10 +180,11 @@ Read `docs/ARCHITECTURE.md` first. Load-bearing ideas: line a chord-card click feeds. **Dragging a chord card onto an arp slot binds it there**, or onto a tab - or onto a line's **row in the macro view**, which is the same target the size of a row rather than the size of a tab - to hand it over now. The left-click twin *Send to arp - slot* never had. `externalDropLineAt` walks *up* from whatever `Desktop::findComponentAt` - returns, which is what makes the whole macro row a target including the knobs on it. A drop - sets the current line and never changes the view (`setEditLine(line, false)`): it is routing a - chord, not navigating. + slot* never had. The slot cards, the tabs and the macro rows are each a + `juce::DragAndDropTarget` (2026-08-02, see the chord-drag bullet below); JUCE walks *up* from + whatever is under the point, which is what makes the whole macro row a target including the + knobs on it. A drop sets the current line and never changes the view + (`setEditLine(line, false)`): it is routing a chord, not navigating. **A fourth tab, All, is the macro view** (2026-08-01, Owen: "the goal is to be able to create complex polyrhythms from one view"). It replaces the band and the step editor with three rows, one per line, over a shared row holding the BPM knob and Launch Quantize. A row carries the @@ -357,12 +358,20 @@ Read `docs/ARCHITECTURE.md` first. Load-bearing ideas: audition (through `ChordGenMenu::auditionChord`, so the note path and the 800 ms timer stay on the brain and neither `ChordGenPanel` nor `ChordTray` ever calls `noteOn`), **drag onto a pad** to commit. The drag is the only gesture that can name a slot, which is why it and not a second - click is the commit. **It crosses two top-level windows and JUCE gives you nothing for that**: - no `DragAndDropContainer` spans them and mouse capture keeps the whole gesture on the tray, so - the editor - the one object holding both - passes a *screen* position to - `ChordPads::externalDropSlotAt`, which hit-tests with `Desktop::findComponentAt` so that the - generator window sitting over the strip means "not over a pad" and a folded Pads section means - nothing is found. A drop refuses a **locked** pad and calls `clearChordPad` before + click is the commit. **It crosses two top-level windows and JUCE does that for free** + (2026-08-02). This entry said the opposite for a day and a half - "no `DragAndDropContainer` + spans them" - and the whole cross-window protocol was hand-rolled on screen positions passed + through the editor because of it. `DragAndDropContainer::startDragging` takes a fourth + parameter, `allowDraggingToOtherJuceWindows`, defaulting to false; pass true and the drag image + goes on the desktop, which makes `getParentComponent()` null inside JUCE's `findTarget` and + routes it through `findDesktopComponentBelow` - every desktop component in z-order, walking up + for an interested target. Same hit test, already written. See `src/ui/ChordDrag.h`, which is + also where the two things JUCE has *no* opinion about live: `taken` (the veto that stops + reaching for the reference box from deleting the pad you reached with) and `consumed` (a tray + candidate committed to a pad empties its cell; the same one copied to the reference does not). + Both are read a message-loop turn after mouse-up, because a source's own `mouseUp` runs before + its listeners and so before `itemDropped`, while `dragOperationEnded` waits out a 120 ms + animation. A drop refuses a **locked** pad and calls `clearChordPad` before `setChordPad`, so a target left ringing by Sustain or feeding the arp gives its old notes up instead of stranding them; a drop that misses keeps the candidate and does nothing, because this gesture is the same shape as the strip's own drag-off-to-clear and must never lose work. @@ -382,8 +391,8 @@ Read `docs/ARCHITECTURE.md` first. Load-bearing ideas: Owen: "another box for the reference chord ... so when you regenerate everything, it doesn't erase your reference chord"). One chord that no tray action touches, filled by dragging a tray card *or* a pad from the main window onto it, with **Similar** and **Could follow** beside it. - A pad dropped there is **copied**: dragging a card off the strip normally clears it, so - `ChordPads::onDropOutside` returning true is what suppresses that clear, and a gesture that + A pad dropped there is **copied**: dragging a card off the strip normally clears it, so the + card setting `taken` on the drag payload is what suppresses that clear, and a gesture that reached for the reference and deleted a chord instead would be the worst bug in the window. **An audition takes the room.** `previewChord` calls `stopAllChordPads()` before it sounds anything. This is not optional politeness: Keys emits one note-on per pitch on the 0→1 refcount diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8eee9b9..476a465 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -621,10 +621,11 @@ coming back: "I think we should have another box for the reference chord where we can drag in something from the main window or one of the other chords. So when you regenerate everything, it doesn't erase your reference chord"). It fills from a **tray card** dropped on it, or from a - **pad in the main window** dropped on it - the latter is why `ChordPads` grew `onDragOutside` - / `onDropOutside`: a drop on the reference card **copies**, and `onDropOutside` returning - true suppresses the ordinary "drag off the strip clears the pad" behaviour, so reaching for - the reference box can never delete the chord you were trying to keep. Left-click auditions it + **pad in the main window** dropped on it. `ChordRefCard` is a `juce::DragAndDropTarget` and + takes both directly. A drop on it **copies**: it sets `taken` on the payload and never + `consumed`, which suppresses the ordinary "drag off the strip clears the pad" behaviour for a + pad and leaves a tray candidate in its cell, so reaching for the reference box can never + delete the chord you were trying to keep. Left-click auditions it the same as a tray card. Beside it, **Similar** and **Could follow** call `ChordGenMenu::similarTo` / `couldFollow` with the reference chord as seed and write a fresh trayful (`similarTo` keeps the root and varies the colour; `couldFollow` reuses @@ -673,19 +674,50 @@ coming back: Send to first empty pad is the drag with the aim taken out (`ChordPads::firstEmptyPadOnPage` / `sendChordToFirstEmptyPad`), and it is the one *placing* item, greyed by `onPageHasEmptyPad` when the current page has no room. -- **The drag crosses two top-level windows**, which JUCE gives nothing for: a - `DragAndDropContainer` only ever sees a drop inside its own window, and the tray lives in - the generator's `DetachedWindow` while the pads live in the main editor or a `DetachedWindow` - of their own. `ChordGenPanel::onCandidateDragOver` / `onCandidateDropped` / - `onCandidateDragEnd` hand the editor a **screen** position - the one space the two windows - share - and the editor forwards it to `ChordPads::externalDropSlotAt(screenPos)` / - `setExternalDropSlot(slot)` / `dropExternalChord(screenPos, pad)`. The hit test is - `juce::Desktop::findComponentAt`, so a generator window sitting over the strip reads as "not - over a pad," and a folded Pads section finds nothing at all - occlusion is the target's - problem, same as every other drag in Keys. A drop **refuses a locked pad** (the lock that - protects a chord from generation protects it from a stray drag too) and calls - `clearChordPad` before `setChordPad`, so a sounding or arp-held pad releases its old notes - properly instead of having the chord swapped out from under them. +- **The drag crosses two top-level windows, and JUCE gives that for free** (2026-08-02). It was + hand-rolled on `mouseDown` / `mouseDrag` / `mouseUp` plus `juce::Desktop::findComponentAt` + until then, on the stated belief that a `DragAndDropContainer` only ever sees a drop inside + its own window. **That belief was false and this document asserted it as settled fact.** + `DragAndDropContainer::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 the 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 doing by hand, and it was + there the whole time (verified against JUCE 8.0.8; a docs PR is open upstream as + juce-framework/JUCE#1692). See `src/ui/ChordDrag.h`. + So: **the tray is an ordinary drag source and every taker is an ordinary + `DragAndDropTarget`.** The containers are `ChordGenPanel` (for the tray) and + `KeysEditor::Holder` (for the pad strip - the holder rather than the editor, because it is the + one ancestor a section keeps when it is popped out into a window of its own). The ghost now + follows the cursor out of one window and across the other, which the hand-rolled version + explicitly could not do. Occlusion, a folded Pads section and a detached one are all answered + by JUCE's search, better than before: the reference box used to light up through a window + sitting over it. A drop **refuses a locked pad** (the lock that protects a chord from + generation protects it from a stray drag too) and calls `clearChordPad` before `setChordPad`, + so a sounding or arp-held pad releases its old notes properly instead of having the chord + swapped out from under them. +- **Two things JUCE has no opinion about ride on the payload** (`chorddrag::Payload`, a + `ReferenceCountedObject` boxed in the `var` that `startDragging` takes). Boxing rather than + passing an index is deliberate: 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. + - **`taken`** is the veto. Dragging a card off the pad strip clears it, and reaching for the + reference box *means* dragging a card off the strip, so without an answer the one gesture + that keeps a chord would be the one that deletes it. Every target sets it; + `ChordPads::itemDropped` sets it for any release that lands on the strip at all, refused or + not, because "landed here" and "did something" are different questions and only the first + decides whether the drag left the row. + - **`consumed`** is the *other* ownership answer, and it is why one flag is not enough. A tray + candidate dropped on a pad is committed and its cell goes empty; the same candidate dropped + on the reference box is copied and the cell stays, because a reference is a copy of a chord + you like. Same gesture, opposite outcome. + Both are read one message-loop turn after the button comes up + (`chorddrag::whenDragSettles`), not in `DragAndDropContainer::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 - while + `dragOperationEnded` fires from `~DragImageComponent` after a 120 ms dismissal animation and a + timer, which is a third of a second of a card that still looks like it is there. Posting from + `mouseUp` lands after the same event's listener dispatch and before the next frame. - **The window is not a `Section`.** It never docks, so it has no bar, no fold, no caption and no Detach button, and every one of those is something `KeysEditor::sections` walks. What it does share is `DetachedWindow` (the skinned 38 px title bar with mouse-only-sized buttons, diff --git a/docs/ARP_DESIGN.md b/docs/ARP_DESIGN.md index 063bd7b..d01476c 100644 --- a/docs/ARP_DESIGN.md +++ b/docs/ARP_DESIGN.md @@ -195,12 +195,16 @@ Two things this deliberately does not change: Same state as the tabs; it is on that bar because it is a fact about the cards, and because it has to be reachable with the arp folded shut. - **Drag a chord card onto a slot** to bind it there, **onto a tab**, or **onto a line's row in - the macro view**, to hand it over now. Screen-position hit-testing through - `Desktop::findComponentAt`, mediated by the editor, for the reason the audition tray needs the - same: mouse capture keeps the gesture on the strip and the two surfaces can be in different - windows. Walking *up* from whatever is under the point is what makes the whole macro row a - target including the knobs sitting on it - the knob is found first, and its parent is the - line. A drop sets the current line but never changes the view: it is routing a chord, not + the macro view**, to hand it over now. Stock `juce::DragAndDropTarget` on each of the three + (2026-08-02): the slot cards, the line tabs and the macro rows take the drop themselves, and + the pair of screen-position hit tests the editor used to mediate - `externalDropSlotAt` and + `externalDropLineAt`, the second a near-copy of `ChordPads`' own - are gone with the belief + that made them necessary (see `src/ui/ChordDrag.h` and the chord generator section of + `ARCHITECTURE.md`: JUCE delivers across two top-level windows, it just needs telling to). + Walking *up* from whatever is under the point is what makes the whole macro row a target + including the knobs sitting on it - the knob is found first, and its parent is the line - and + that is precisely what JUCE's own `findTarget` does, so it survived the deletion rather than + being reimplemented. A drop sets the current line but never changes the view: it is routing a chord, not navigating, and in the macro view the line it landed on is already in front of you. ### The macro view (the fourth tab) diff --git a/docs/CONTROLS.md b/docs/CONTROLS.md index 21f4cf7..44c5fc8 100644 --- a/docs/CONTROLS.md +++ b/docs/CONTROLS.md @@ -231,8 +231,11 @@ keyboard on each, and it went on 2026-07-31 once the note list fit under the nam the rows to empty it, or drag a pad onto the live card to bring its notes back onto the keyboard (held) for editing — capture in reverse. **A locked pad can still be moved and still cannot be emptied**: dropping one off the rows does nothing, the same answer - the greyed-out **Clear pad** on its menu gives. The ghost fades as you leave the rows to - say so before you let go. + the greyed-out **Clear pad** on its menu gives. What you drag is the card itself, at full + size, so its lock dot travels with it and says which of the two this drag is doing. (Until + 2026-08-02 the ghost was a small chip that faded once you were over nothing; the chip is gone + because the real card can now follow the cursor out of the window entirely, which is what + makes the generator's reference box something you can aim at.) 6. **Edit a pad on the keyboard.** Right-click a pad and pick **Edit on keyboard**: its chord latches onto the keys, and every key you add or remove is written straight back to the pad, with the name re-detected as you go. That pad wears a **✓** at its @@ -796,6 +799,9 @@ differently, which is the whole reason there are four. - **Send to arp slot**, in a pad's right-click menu, parks a copy of that chord in one of the twelve slots to launch later. -Neither drag can lose a chord. Dragging a card off the strip normally clears it; a drop that -lands on a tab or a slot **copies** instead, and a drop that misses everything leaves the card -exactly where it was. +Neither drag can lose a chord *by landing*. Dragging a card off the strip clears it, so both of +these are drags off the strip — what saves them is that a drop on a tab, a slot, a macro row or +the generator's reference box **copies** the chord and leaves the card exactly where it was. +A drop that lands on none of those is an ordinary drag off the strip and still clears the card, +which is the gesture working, not failing. Lock the pad if you want it to survive a miss: a +locked card dropped anywhere off the strip does nothing at all. diff --git a/src/PluginEditor.cpp b/src/PluginEditor.cpp index 93f843b..9634b2f 100644 --- a/src/PluginEditor.cpp +++ b/src/PluginEditor.cpp @@ -617,61 +617,18 @@ KeysEditor::KeysEditor(KeysProcessor& p) // were offered only while the Chords view was up once, and that is the exact bug this // arrangement exists to prevent. Everything about the page or the settings is in the // generator's window instead (chordGenButton, above). - // A card leaving the strip is offered to the generator's reference box, when that window is - // open. Guarded on the panel existing rather than wired and unwired as it opens: the strip - // outlives every window, and a hook that had to be taken back down on close is a hook that - // gets left dangling one day. - // ...and to the arp panel, which is the left-click twin "Send to arp slot" never had: a - // drag is a target picker, which is the whole reason that menu item was allowed to be - // right-click-only. Drop on a slot to bind the chord there; drop on a line tab to hand it - // to that line now. Both live here rather than in either surface because the editor is the - // one object that holds both, and either can be in a window of its own. - chordPads.onDragOutside = [this](juce::Point p) - { - if (chordGenPanel) - chordGenPanel->showReferenceDropTarget(p); - if (arpPanel) - arpPanel->setExternalDropTarget(arpPanel->externalDropSlotAt(p), - arpPanel->externalDropLineAt(p)); - }; - chordPads.onDropOutside = [this](juce::Point p, const KeysProcessor::ChordPad& pad) - { - if (arpPanel != nullptr) - { - arpPanel->setExternalDropTarget(-1, -1); - const int line = arpPanel->externalDropLineAt(p); - if (line >= 0 && ! pad.notes.empty()) - { - // Straight into that line, and it becomes the current one: you aimed at it, so - // the next card click should follow the same aim. The view does not move with - // it - in the macro view you dropped onto the line itself, and being thrown - // into that line's deep controls is not what the gesture asked for. - processor.holdArpChordFromPad(chordPads.draggedSlot(), line); - arpPanel->setEditLine(line, /*leaveMacroView*/ false); - refreshArpTargetButton(); - return true; // suppresses the strip's drag-off-to-clear - } - if (const int slot = arpPanel->externalDropSlotAt(p); slot >= 0 && ! pad.notes.empty()) - { - processor.setArpSlotChord(slot, pad.notes, pad.name, arpPanel->editLine()); - arpPanel->repaint(); - return true; - } - } - return chordGenPanel != nullptr && chordGenPanel->offerReferenceDrop(p, pad); - }; - // And the other end of it, for **both** takers. onDragOutside lights the reference box and a - // line tab or slot; only a drop that lands off the row reaches onDropOutside to put them out - // again. Drag out over either, change your mind and drop back onto a pad or the live card, - // and the highlight stayed on with no drag in progress. The tray's own drag has had this half - // since it was built (onCandidateDragEnd, below); this is the same half for the strip's. - chordPads.onDragEnd = [this] - { - if (chordGenPanel) - chordGenPanel->clearReferenceDropTarget(); - if (arpPanel) - arpPanel->setExternalDropTarget(-1, -1); - }; + // A card leaving the strip is offered to the generator's reference box and to the arp + // panel's slots, tabs and macro rows, and **none of that is wired here any more** + // (2026-08-02). Each of those is a `juce::DragAndDropTarget` and JUCE delivers to it + // directly, across a window boundary or not; the editor used to be the one object holding + // both ends, forwarding screen positions between them in three `std::function`s, because the + // code believed the framework could not do this. It can - see ChordDrag.h. + // + // Two bug classes went with the plumbing. Every target now gets `itemDragExit` on every path + // a drag can end, so a highlight lit on the way out cannot be left glowing at nothing (drag + // out over the reference box, change your mind, drop back on a pad); and a target whose + // window is closed mid-drag simply stops being found, which is what the explicit cleanup + // below setChordGenWindowOpen used to have to do by hand. chordPads.onExtraMenuItems = [this](int slot, juce::PopupMenu& m) { chordGen.addPadMenuItems(slot, m); }; chordPads.onExtraMenuChoice = [this](int slot, int id) { chordGen.handlePadMenuChoice(slot, id); }; @@ -1265,17 +1222,11 @@ void KeysEditor::setChordGenWindowOpen(bool open) }; chordGenPanel->onClose = close; - // The audition tray's drag reaches the pad strip through here, and only through here. - // The tray is in this window's *sibling*, so JUCE keeps the whole gesture on the tray - // and neither component can see the other; the editor is the one object that holds - // both, which is what makes it the place the two ends meet. Screen coordinates all the - // way across - see ChordTray. ChordPads owns the hit test, the refusals and the paint. - chordGenPanel->onCandidateDragOver = [this](juce::Point p) - { chordPads.setExternalDropSlot(chordPads.externalDropSlotAt(p)); }; - chordGenPanel->onCandidateDropped = [this](juce::Point p, const KeysProcessor::ChordPad& pad) - { return chordPads.dropExternalChord(p, pad); }; - chordGenPanel->onCandidateDragEnd = [this] { chordPads.setExternalDropSlot(-1); }; - // And the same crossing for the card menu's aimless commit. + // The audition tray's drag needs nothing from this class. It leaves the tray as a stock + // JUCE drag with the drag image on the desktop and lands on whichever target is under + // the cursor - a pad in this window, or the reference box beside it - so the editor is + // no longer the place the two ends have to meet. What still crosses here is the *menu* + // item, because a menu item has no target to hit and no drop to deliver. chordGenPanel->onCandidateToFirstEmptyPad = [this](const KeysProcessor::ChordPad& pad) { return chordPads.sendChordToFirstEmptyPad(pad); }; chordGenPanel->onPageHasEmptyPad = [this] { return chordPads.firstEmptyPadOnPage() >= 0; }; @@ -1290,9 +1241,10 @@ void KeysEditor::setChordGenWindowOpen(bool open) rememberChordGenBounds(); chordGenWindow.reset(); // clears its content first, so the panel is unparented chordGenPanel.reset(); - // Close it mid-drag and the tray never gets to run its own cleanup, so the pad it was - // hovering would stay lit with nothing left to drop on it. - chordPads.setExternalDropSlot(-1); + // Closing this window mid-drag used to need a line here putting the pad strip's hover + // highlight back out, because the tray never got to run its own cleanup. JUCE does it: + // the drag image is a mouse listener on the source, the source dies with the window, and + // `~DragImageComponent` sends `itemDragExit` to whatever it was last over. } chordGenButton.setToggleState(open, juce::dontSendNotification); diff --git a/src/PluginEditor.h b/src/PluginEditor.h index f959173..8743872 100644 --- a/src/PluginEditor.h +++ b/src/PluginEditor.h @@ -118,7 +118,15 @@ class KeysEditor : public juce::AudioProcessorEditor, // A section's content lives in a holder rather than directly in the editor, so popping it // out is one re-parent instead of a shuffle of every control in the section. The holder's // parent is either this editor or a DetachedWindow's content slot; nothing else changes. - struct Holder : juce::Component + // + // It is a `DragAndDropContainer` for one thing, and the thing is the Pads section: JUCE wants + // the container to be an ancestor of whatever starts a drag, and the holder is the one + // ancestor a section keeps in both places it can live. Put it on the editor instead and every + // drag would stop working the moment the section was popped out. The other three holders + // inherit it and never use it, which costs a vtable and is the price of the table staying one + // kind of thing (see `sections`). + struct Holder : juce::Component, + juce::DragAndDropContainer { std::function layout; std::function painter; // default: the plain section background diff --git a/src/ui/ArpPanel.cpp b/src/ui/ArpPanel.cpp index 45a5b73..2cb2096 100644 --- a/src/ui/ArpPanel.cpp +++ b/src/ui/ArpPanel.cpp @@ -299,55 +299,23 @@ int ArpPanel::editLine() const return juce::jlimit(0, KeysProcessor::uiArpLines - 1, editedLine); } -// A chord card dragged out of the pad strip, in screen coordinates. Mouse capture keeps the -// whole gesture on the strip - JUCE never tells these cards a drag is over them - and the two -// surfaces can be in different top-level windows, so the editor that owns both hit-tests here -// by screen position. Desktop::findComponentAt is what makes another window sitting over the -// row read as "not over a slot", which is the answer you want. -// Walking up from whatever the desktop says is under the point, rather than testing bounds, -// is what makes another window over the row read as "not over a slot" - and it answers the -// folded and detached cases for free, since a panel that is not on screen is never hit. Same -// shape as ChordPads::externalDropSlotAt, deliberately. -int ArpPanel::externalDropSlotAt(juce::Point screenPos) const -{ - auto* hit = juce::Desktop::getInstance().findComponentAt(screenPos); - for (auto* c = hit; c != nullptr; c = c->getParentComponent()) - for (int i = 0; i < (int) slotCards.size(); ++i) - if (c == slotCards[(size_t) i].get()) - return i; - return -1; -} - -int ArpPanel::externalDropLineAt(juce::Point screenPos) const -{ - auto* hit = juce::Desktop::getInstance().findComponentAt(screenPos); - // Walking up from whatever is under the point is what makes the *whole* macro row a target - // including the knobs sitting on it: the knob is found first, and its parent is the line. - for (auto* c = hit; c != nullptr; c = c->getParentComponent()) - { - for (int n = 0; n < (int) lineTabs.size(); ++n) - if (c == lineTabs[(size_t) n].get()) - return n; - if (macroView) - for (int n = 0; n < (int) macroRows.size(); ++n) - if (c == macroRows[(size_t) n].get()) - return n; - } - return -1; +// A chord card dropped on one of the twelve slots binds that chord there. The slot keeps its +// pattern; what it gains is the chord a launch will hold into the line. +void ArpPanel::takeChordOnSlot(int slot, const chorddrag::Payload& p) +{ + processor.setArpSlotChord(slot, p.chord.notes, p.chord.name, editLine()); + repaint(); } -void ArpPanel::setExternalDropTarget(int slot, int lineTab) +// A chord card dropped on a line - its tab, or its whole row in the macro view - goes straight +// into that line, and the line becomes the current one: you aimed at it, so the next card click +// should follow the same aim. The view does not move with it (`leaveMacroView` false); in the +// macro view you dropped onto the line itself, and being thrown into that line's deep controls +// is not what the gesture asked for. +void ArpPanel::takeChordOnLine(int line, const chorddrag::Payload& p) { - for (int i = 0; i < (int) slotCards.size(); ++i) - if (slotCards[(size_t) i] != nullptr) - slotCards[(size_t) i]->setDropTarget(i == slot); - for (int n = 0; n < (int) lineTabs.size(); ++n) - if (lineTabs[(size_t) n] != nullptr) - lineTabs[(size_t) n]->setDropTarget(n == lineTab); - // The macro rows answer the same question, so they light on the same answer. - for (int n = 0; n < (int) macroRows.size(); ++n) - if (macroRows[(size_t) n] != nullptr) - macroRows[(size_t) n]->setDropTarget(macroView && n == lineTab); + processor.holdArpChordFromPad(p.index, line); + setEditLine(line, /*leaveMacroView*/ false); } juce::String ArpPanel::paramId(KeysProcessor::ArpParam which) const @@ -861,8 +829,8 @@ void ArpPanel::refreshPatternButtons() // --------------------------------------------------------------------------- // SlotCard -ArpPanel::SlotCard::SlotCard(KeysProcessor& p, const ArpPanel& o, int i) - : juce::Button("Arp slot " + juce::String(i + 1)), processor(p), owner(o), index(i) +ArpPanel::SlotCard::SlotCard(ArpPanel& o, KeysProcessor& p, int i) + : juce::Button("Arp slot " + juce::String(i + 1)), owner(o), processor(p), index(i) { okstudio::ui::makeMouseOnly(*this); setTitle("Arp slot " + juce::String(i + 1)); // accessible name for the capture script @@ -876,6 +844,28 @@ void ArpPanel::SlotCard::setDropTarget(bool b) repaint(); } +// Chords from the pad strip only. A tray candidate is not offered a slot today - the tray's drag +// went to the pads and the reference box and nowhere else - and widening that is a feature, not +// something to let in sideways because the framework now makes it free. +bool ArpPanel::SlotCard::isInterestedInDragSource(const SourceDetails& details) +{ + auto* p = chorddrag::chordBeingDragged(details); + return p != nullptr && p->from == chorddrag::Payload::From::padSlot; +} + +void ArpPanel::SlotCard::itemDragEnter(const SourceDetails&) { setDropTarget(true); } +void ArpPanel::SlotCard::itemDragExit(const SourceDetails&) { setDropTarget(false); } + +void ArpPanel::SlotCard::itemDropped(const SourceDetails& details) +{ + setDropTarget(false); + if (auto* p = isInterestedInDragSource(details) ? chorddrag::of(details) : nullptr) + { + owner.takeChordOnSlot(index, *p); + p->taken = true; // and the strip leaves the card it came from alone + } +} + void ArpPanel::SlotCard::mouseDown(const juce::MouseEvent& e) { if (e.mods.isPopupMenu()) @@ -922,7 +912,7 @@ namespace "every macro knob needs a heading and a parameter"); } // namespace -ArpPanel::MacroRow::MacroRow(KeysProcessor& p, int n) : processor(p), line(n) +ArpPanel::MacroRow::MacroRow(ArpPanel& o, KeysProcessor& p, int n) : owner(o), processor(p), line(n) { okstudio::ui::makeMouseOnly(*this); const auto letter = juce::String::charToString((juce::juce_wchar) ('A' + n)); @@ -1213,6 +1203,28 @@ void ArpPanel::MacroRow::setDropTarget(bool b) repaint(); } +// Same source rule as a slot card, and one thing more that used to need saying by hand: a row +// that is not on screen is never hit, so the `macroView` test the old screen hit-test carried +// is now the row's own visibility. +bool ArpPanel::MacroRow::isInterestedInDragSource(const SourceDetails& details) +{ + auto* p = chorddrag::chordBeingDragged(details); + return p != nullptr && p->from == chorddrag::Payload::From::padSlot; +} + +void ArpPanel::MacroRow::itemDragEnter(const SourceDetails&) { setDropTarget(true); } +void ArpPanel::MacroRow::itemDragExit(const SourceDetails&) { setDropTarget(false); } + +void ArpPanel::MacroRow::itemDropped(const SourceDetails& details) +{ + setDropTarget(false); + if (auto* p = isInterestedInDragSource(details) ? chorddrag::of(details) : nullptr) + { + owner.takeChordOnLine(line, *p); + p->taken = true; + } +} + void ArpPanel::MacroRow::paint(juce::Graphics& g) { // A chord card is over this row: the whole row lights, because the row *is* the line here @@ -1332,10 +1344,10 @@ void ArpPanel::MacroRow::resized() // --------------------------------------------------------------------------- // LineTab -ArpPanel::LineTab::LineTab(KeysProcessor& p, const ArpPanel& o, int n) +ArpPanel::LineTab::LineTab(ArpPanel& o, KeysProcessor& p, int n) : juce::Button(n < 0 ? juce::String("Arp macro view") : "Arp line " + juce::String::charToString((juce::juce_wchar) ('A' + n))), - processor(p), owner(o), line(n) + owner(o), processor(p), line(n) { okstudio::ui::makeMouseOnly(*this); // Named for the capture script, and distinct from the A/B/C chips on the section bar: @@ -1352,6 +1364,28 @@ void ArpPanel::LineTab::setDropTarget(bool b) repaint(); } +// The macro tab refuses everything: it selects a view, and there is no line behind it to hand a +// chord to. That was true of the old hit test too, which walked `lineTabs` alone and never saw +// this one, so nothing changed except where it is written down. +bool ArpPanel::LineTab::isInterestedInDragSource(const SourceDetails& details) +{ + auto* p = chorddrag::chordBeingDragged(details); + return line >= 0 && p != nullptr && p->from == chorddrag::Payload::From::padSlot; +} + +void ArpPanel::LineTab::itemDragEnter(const SourceDetails&) { setDropTarget(true); } +void ArpPanel::LineTab::itemDragExit(const SourceDetails&) { setDropTarget(false); } + +void ArpPanel::LineTab::itemDropped(const SourceDetails& details) +{ + setDropTarget(false); + if (auto* p = isInterestedInDragSource(details) ? chorddrag::of(details) : nullptr) + { + owner.takeChordOnLine(line, *p); + p->taken = true; + } +} + void ArpPanel::LineTab::paintButton(juce::Graphics& g, bool over, bool down) { const auto b = getLocalBounds().toFloat().reduced(1.0f); @@ -1733,7 +1767,7 @@ void ArpPanel::buildControls() // beside the row. for (int i = 0; i < (int) slotCards.size(); ++i) { - auto card = std::make_unique(processor, *this, i); + auto card = std::make_unique(*this, processor, i); card->onClick = [this, i] { recallOrCopy(i); }; card->onRightClick = [this, i] { showSlotMenu(i); }; card->setTooltip("Launch slot " + juce::String(i + 1) + ": its pattern, its shape and " @@ -1811,7 +1845,7 @@ void ArpPanel::buildControls() // which is why every loop over them already null-checks. Nothing else has to know. for (int n = 0; n < KeysProcessor::uiArpLines; ++n) { - auto row = std::make_unique(processor, n); + auto row = std::make_unique(*this, processor, n); addChildComponent(*row); macroRows[(size_t) n] = std::move(row); } @@ -1858,7 +1892,7 @@ void ArpPanel::buildControls() for (int n = 0; n < KeysProcessor::uiArpLines; ++n) { - auto tab = std::make_unique(processor, *this, n); + auto tab = std::make_unique(*this, processor, n); tab->onClick = [this, n] { setEditLine(n); }; const auto letter = juce::String::charToString((juce::juce_wchar) ('A' + n)); tab->setTooltip("Arpeggiator line " + letter + ". Click to edit it here, and to send it " @@ -1873,7 +1907,7 @@ void ArpPanel::buildControls() // is why it is a tab in the row that already selects lines rather than a window or a fifth // section. It kept its place at the right of the tabs when line C went, so the two that // remain are still where they were. - macroTab = std::make_unique(processor, *this, -1); + macroTab = std::make_unique(*this, processor, -1); macroTab->onClick = [this] { setMacroView(true); }; macroTab->setTooltip("Both lines at once: rate, shape, gate, chance and swing for each, with " "the tempo and Launch Quantize they share. Drag a chord card up from the " diff --git a/src/ui/ArpPanel.h b/src/ui/ArpPanel.h index 4228411..efddaa2 100644 --- a/src/ui/ArpPanel.h +++ b/src/ui/ArpPanel.h @@ -2,6 +2,7 @@ #include "../ArpEngine.h" #include "../PluginProcessor.h" +#include "ChordDrag.h" #include #include #include @@ -74,17 +75,19 @@ class ArpPanel : public juce::Component, // Told when a tab is clicked, so the editor can move the Pads bar's letter chip with it. std::function onEditLineChanged; - // A drop target for a chord card dragged out of the pad strip, in screen coordinates: - // the slot card under `screenPos`, or -1. Mirrors ChordPads::externalDropSlotAt, and for - // the same reason - the two surfaces can be in different top-level windows, so the editor - // that holds both passes screen positions between them. - int externalDropSlotAt(juce::Point screenPos) const; - // Same, for the line tabs: the line under `screenPos`, or -1. A card dropped on a tab is - // handed to that line there and then, without going through a slot. - int externalDropLineAt(juce::Point screenPos) const; - // Paint the slot or tab a drag is currently over (-1 = none). Set by the editor while a - // card is being dragged, so the target lights up before the mouse is released. - void setExternalDropTarget(int slot, int lineTab); + // What a chord card dropped on this panel does, once JUCE has said where it landed. The + // slot cards, line tabs and macro rows are each a `DragAndDropTarget` of their own and call + // one of these; the panel owns the actions because both of them touch more than one card. + // + // There used to be a pair of screen-coordinate hit tests up here - `externalDropSlotAt` and + // `externalDropLineAt`, the second of them a near-copy of ChordPads' own, all three walking + // `Desktop::findComponentAt` by hand. They existed because the strip and this panel can be in + // different top-level windows and the code believed JUCE could not deliver a drop across two + // of them. It can: see ChordDrag.h. Walking *up* from the component under the point, which is + // what made the whole macro row a target including the knobs sitting on it, is exactly what + // JUCE's own `findTarget` does, so the behaviour survived the deletion. + void takeChordOnSlot(int slot, const chorddrag::Payload&); + void takeChordOnLine(int line, const chorddrag::Payload&); using ComboAtt = juce::AudioProcessorValueTreeState::ComboBoxAttachment; using SliderAtt = juce::AudioProcessorValueTreeState::SliderAttachment; @@ -99,19 +102,25 @@ class ArpPanel : public juce::Component, // Each row's attachments are bound to its own line for the row's whole life, unlike the // band above, which rebinds every time the tabs move. That is the point of the row: three // lines on screen at once cannot each be "the current line". - class MacroRow : public juce::Component + class MacroRow : public juce::Component, + public juce::DragAndDropTarget { public: - MacroRow(KeysProcessor&, int line); + MacroRow(ArpPanel&, KeysProcessor&, int line); void paint(juce::Graphics&) override; void resized() override; // Readouts that no attachment drives: the rate text (it spans two parameters and two // units), the shape, and the chord this line is holding. Called by the panel's timer. void refresh(); - // Lit while a chord card dragged out of the pad strip is over this row. The row is the - // line here, laid out large, so it is a far easier target than the tab that names it. - void setDropTarget(bool); + + // A chord card dragged out of the pad strip. The row is the line here, laid out large, + // so it is a far easier target than the tab that names it - and because JUCE walks up + // from whatever is under the point, the knobs sitting on the row are part of it. + bool isInterestedInDragSource(const SourceDetails&) override; + void itemDragEnter(const SourceDetails&) override; + void itemDragExit(const SourceDetails&) override; + void itemDropped(const SourceDetails&) override; // The eight knobs a row carries, left to right. One table so the labels, the // parameters and the layout cannot drift apart; the headings are drawn once, on the @@ -158,6 +167,9 @@ class ArpPanel : public juce::Component, std::unique_ptr onAtt, latchAtt, keysAtt, rateModeAtt; std::unique_ptr dotAtt, tripAtt, anchorAtt; std::array, numKnobs> knobAtts; + void setDropTarget(bool); + + ArpPanel& owner; // Exactly one of these is ever non-null; refreshRateMode owns that invariant. std::unique_ptr rateSyncAtt, rateHzAtt; int lastRateFree = -1; // -1 = no attachment installed yet @@ -237,23 +249,32 @@ class ArpPanel : public juce::Component, // twelve identical letters. Left-click launches it. Right-click opens its menu, an // accelerator only: everything in there has a left-click path on the buttons below the // row (the same arrangement the chord pads use, per the CLAUDE.md exception). - class SlotCard : public juce::Button + class SlotCard : public juce::Button, + public juce::DragAndDropTarget { public: - SlotCard(KeysProcessor&, const ArpPanel& owner, int index); + SlotCard(ArpPanel&, KeysProcessor&, int index); void paintButton(juce::Graphics&, bool over, bool down) override; void mouseDown(const juce::MouseEvent&) override; std::function onRightClick; - // Lit while a chord card dragged out of the pad strip is over this slot. The drop - // itself is the editor's to deliver: the drag never leaves the strip's mouse capture, - // so this card is never told about it by JUCE. - void setDropTarget(bool); + + // A chord card dropped here binds that chord to this slot: the left-click twin *Send to + // arp slot* never had, and the reason that menu item was allowed to be right-click-only + // is that naming one slot of twelve needs a target picker. JUCE tells this card about the + // drop directly, mouse capture and window boundaries notwithstanding - the comment that + // used to sit here saying otherwise was wrong (ChordDrag.h). + bool isInterestedInDragSource(const SourceDetails&) override; + void itemDragEnter(const SourceDetails&) override; + void itemDragExit(const SourceDetails&) override; + void itemDropped(const SourceDetails&) override; private: + void setDropTarget(bool); + + ArpPanel& owner; KeysProcessor& processor; - const ArpPanel& owner; int index; bool dropTarget = false; @@ -264,20 +285,30 @@ class ArpPanel : public juce::Component, // panel edits, and says what that line is doing: lit when the line is on, and carrying the // name of the chord it is holding, so three tabs read as three arpeggiators at a glance. // A chord card can also be dropped straight onto one. - class LineTab : public juce::Button + class LineTab : public juce::Button, + public juce::DragAndDropTarget { public: // `line` < 0 is the macro tab: the fourth one, which selects the all-three view // rather than a line. One class for both because they are one row of targets and have // to look like one. - LineTab(KeysProcessor&, const ArpPanel& owner, int line); + LineTab(ArpPanel&, KeysProcessor&, int line); void paintButton(juce::Graphics&, bool over, bool down) override; - void setDropTarget(bool); + + // A chord card dropped on a tab is handed to that line there and then, without going + // through a slot. The macro tab (`line` < 0) selects a view rather than a line, so it + // takes no chord and never lights. + bool isInterestedInDragSource(const SourceDetails&) override; + void itemDragEnter(const SourceDetails&) override; + void itemDragExit(const SourceDetails&) override; + void itemDropped(const SourceDetails&) override; private: + void setDropTarget(bool); + + ArpPanel& owner; KeysProcessor& processor; - const ArpPanel& owner; int line; bool dropTarget = false; diff --git a/src/ui/ChordDrag.h b/src/ui/ChordDrag.h new file mode 100644 index 0000000..cb37d03 --- /dev/null +++ b/src/ui/ChordDrag.h @@ -0,0 +1,108 @@ +#pragma once + +#include "../PluginProcessor.h" +#include +#include + +namespace keys::chorddrag +{ +// What a chord drag carries, and the two answers that come back on it. +// +// Every chord drag in Keys is stock `juce::DragAndDropContainer` / `DragAndDropTarget` +// (2026-08-02). It was hand-rolled on mouseDown/mouseDrag/mouseUp plus +// `Desktop::findComponentAt` until then, on the stated belief that JUCE could not deliver a drop +// across two top-level windows. **That belief was wrong.** +// `DragAndDropContainer::startDragging` takes a fourth parameter, +// `allowDraggingToExternalWindows`, defaulting to false; pass true and the drag image goes on the +// desktop rather than inside the container, which makes `getParentComponent()` null inside JUCE's +// own `findTarget` and routes the lookup through `findDesktopComponentBelow` - every desktop +// component in z-order, walking up each parent chain for an interested target. That is exactly +// the hit test the workaround was performing by hand, and it was there the whole time. Anything +// still claiming otherwise is out of date. +// +// The payload is a `ReferenceCountedObject` boxed in a `var` rather than an index the target +// re-reads. Boxing is what keeps the old semantics: 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, and both ends of a +// pad drag already spoke in whole chords. It also makes the drag immune to the page flipping +// under it. The index below is provenance, not a way to fetch the chord. +struct Payload : public juce::ReferenceCountedObject +{ + using Ptr = juce::ReferenceCountedObjectPtr; + + enum class From + { + trayCell, // a candidate in the generator's audition tray, in its own window + padSlot, // a card on the pad strip, by *absolute* slot so a page flip cannot move it + liveCard // the live "current chord" card at the left of the strip + }; + + Payload(From f, int i, KeysProcessor::ChordPad c) + : from(f), index(i), chord(std::move(c)) + { + } + + const From from; + const int index; // tray cell, absolute pad slot, or -1 for the live card + const KeysProcessor::ChordPad chord; + + // Set by whichever target accepted the drop. **This is the veto**, and it is the one piece + // of app semantics JUCE has no opinion about: dragging a card off the pad strip clears it, + // and reaching for the generator's reference box means dragging a card off the strip, so + // without an answer here the one gesture that keeps a chord would be the gesture that + // deletes it. `DragAndDropContainer::dragOperationEnded` does not say whether anyone + // accepted, so the targets say it here instead. + bool taken = false; + + // Set only by a target that means "this candidate now lives somewhere else", which is a + // *different* question from `taken`. A tray card dropped on a pad is committed and its cell + // goes empty; the same card dropped on the reference box is copied and the candidate stays, + // because a reference is a copy of a chord you like and taking the candidate away as payment + // for keeping it would be backwards. Same gesture, opposite ownership, so two flags. + bool consumed = false; +}; + +// Pull the payload back out, or nullptr when the drag is not one of ours. +inline Payload* of(const juce::var& description) +{ + return dynamic_cast(description.getObject()); +} + +inline Payload* of(const juce::DragAndDropTarget::SourceDetails& details) +{ + return of(details.description); +} + +// What every chord target's `isInterestedInDragSource` starts from. An empty chord is refused +// everywhere: there is nothing to take, and lighting a target for it would promise otherwise. +inline Payload* chordBeingDragged(const juce::DragAndDropTarget::SourceDetails& details) +{ + auto* p = of(details); + return (p != nullptr && ! p->chord.notes.empty()) ? p : nullptr; +} + +// Read the two flags above, one message-loop turn after the button comes up. +// +// **Why a callAsync and not `DragAndDropContainer::dragOperationEnded`.** Both ends of a drag +// need to know how it finished, and the source's own `mouseUp` is too early: JUCE dispatches a +// component's own `mouseUp` before its mouse *listeners* (juce_Component.cpp, `internalMouseUp`), +// and the drag image is a listener - so `itemDropped` has not run yet when the source is asked. +// `dragOperationEnded` is late enough, but it fires from `~DragImageComponent`, which waits out +// a 120 ms dismissal animation and then 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 - so after `itemDropped` - and before the next frame, which is the only +// window that is both correct and invisible. +// +// The payload is captured by `Ptr`, so it outlives the drag image whatever order things die in, +// and the source is held by SafePointer so a window closed on the way through is a no-op. +template +void whenDragSettles(ComponentType& source, Payload::Ptr payload, Fn&& fn) +{ + juce::Component::SafePointer safe(&source); + juce::MessageManager::callAsync( + [safe, payload, fn = std::forward(fn)] + { + if (auto* c = safe.getComponent()) + fn(*c, *payload); + }); +} +} // namespace keys::chorddrag diff --git a/src/ui/ChordGenPanel.cpp b/src/ui/ChordGenPanel.cpp index fa50f7f..fff77f8 100644 --- a/src/ui/ChordGenPanel.cpp +++ b/src/ui/ChordGenPanel.cpp @@ -533,29 +533,13 @@ void ChordGenPanel::buildControls() for (auto* b : { &similarButton, &followButton, &clearRefButton }) addAndMakeVisible(*b); - // Straight through to whoever holds the pad strip. Unwired, the tray still auditions. - // A tray drag is offered to the reference card first, because that target is inside this - // window and the editor's is not: asking the far end about a point that never left this - // window would light a pad under a drag that was always going to land here. - tray.onDragOver = [this](juce::Point p) - { - const bool overRef = refCard.getScreenBounds().contains(p); - refCard.setDropHighlight(overRef); - if (onCandidateDragOver) - onCandidateDragOver(overRef ? juce::Point { -1, -1 } : p); - }; - tray.onDrop = [this](juce::Point p, const KeysProcessor::ChordPad& pad) - { - if (refCard.getScreenBounds().contains(p)) - { - refCard.setChord(pad); - // False, so the tray keeps the card: a reference is a *copy* of a chord you like, - // and taking the candidate away as payment for keeping it would be backwards. - return false; - } - return onCandidateDropped ? onCandidateDropped(p, pad) : false; - }; - tray.onDragEnd = [this] { if (onCandidateDragEnd) onCandidateDragEnd(); }; + // The tray's drag needs no wiring at all any more (2026-08-02). It goes out through this + // class as a `DragAndDropContainer` and lands on whichever target is under the cursor - the + // reference card beside it, or a pad in the other window - so the ordering this used to have + // to arrange by hand ("offer the reference first, because that target is inside this window") + // is now just which component the point is over, and it is right about a window sitting on + // top of another where a bounds test was not. Only the *menu* item below still needs a + // pass-through, because a menu item has no target to hit. tray.onSendToFirstEmpty = [this](const KeysProcessor::ChordPad& pad) { return onCandidateToFirstEmptyPad ? onCandidateToFirstEmptyPad(pad) : false; }; tray.onPageHasEmptyPad = [this] { return onPageHasEmptyPad ? onPageHasEmptyPad() : false; }; @@ -712,29 +696,11 @@ void ChordGenPanel::refreshMoodItems() gen.setMoodChoice(moodBox.getSelectedId() <= 1 ? juce::String() : moodBox.getText()); } -// The reverse crossing: a pad being dragged out of the main window, offered to the reference. -// Bounds-checked rather than hit-tested through Desktop::findComponentAt, unlike the drop going -// the other way. That test exists because the *generator window* can cover the pad strip; this -// direction has the opposite problem and no equivalent, since a drag that has reached this -// window is over it by definition. -void ChordGenPanel::showReferenceDropTarget(juce::Point screenPos) -{ - refCard.setDropHighlight(refCard.getScreenBounds().contains(screenPos)); -} - -bool ChordGenPanel::offerReferenceDrop(juce::Point screenPos, const KeysProcessor::ChordPad& pad) -{ - refCard.setDropHighlight(false); - if (! refCard.getScreenBounds().contains(screenPos) || pad.notes.empty()) - return false; - refCard.setChord(pad); - return true; // and ChordPads reads this as "do not clear the card I just dragged" -} - -void ChordGenPanel::clearReferenceDropTarget() -{ - refCard.setDropHighlight(false); -} +// The reverse crossing - a pad dragged out of the main window and offered to the reference - has +// no entry point here at all now. ChordRefCard is a `DragAndDropTarget` and JUCE delivers to it +// directly, highlight and drop and the exit that puts the highlight back out, so the three +// screen-coordinate methods that used to live here went with the editor's plumbing that called +// them (2026-08-02). void ChordGenPanel::timerCallback() { diff --git a/src/ui/ChordGenPanel.h b/src/ui/ChordGenPanel.h index fdf3771..65e608d 100644 --- a/src/ui/ChordGenPanel.h +++ b/src/ui/ChordGenPanel.h @@ -56,7 +56,12 @@ namespace keys // // This class still never calls noteOn. The tray auditions through ChordGenMenu, the same path // the suggestion preview takes and for the same reason: the brain outlives every window. +// It is a `DragAndDropContainer` for one reason: the tray inside it drags candidates onto the pad +// strip in the *other* window, and JUCE requires the container to be an ancestor of the source. +// Nothing else about this class is drag machinery - the ends of the gesture belong to the tray +// and to whatever takes the drop, which is how it can now cross a window at all. class ChordGenPanel : public juce::Component, + public juce::DragAndDropContainer, private juce::Timer { public: @@ -70,26 +75,12 @@ class ChordGenPanel : public juce::Component, // wires both to one call, so there is exactly one way for this object to die. std::function onClose; - // The audition tray's cross-window drag, passed straight through to the editor - this class - // is the only thing that holds the tray, and the editor is the only thing that holds both it - // and the pad strip. Screen coordinates; see ChordTray for why there is no other option. - std::function screenPos)> onCandidateDragOver; - std::function screenPos, const KeysProcessor::ChordPad&)> onCandidateDropped; - std::function onCandidateDragEnd; - - // "Send to first empty pad" on a tray card's menu: the drag with the aim taken out. Same - // pass-through, and the same reason for it - this window cannot see the pad strip. + // "Send to first empty pad" on a tray card's menu: the commit drag with the aim taken out. + // Passed through to the editor, because a *menu item* has no target and no drop - this + // window still cannot name a pad on its own. The drag itself needs nothing here any more. std::function onCandidateToFirstEmptyPad; std::function onPageHasEmptyPad; - // A chord dragged *out* of the main window's pad strip and offered to the reference card: - // the mirror of the commit drag, and the only route by which anything outside this window - // puts something into it. Screen coordinates again. `offerReferenceDrop` returns true when - // the reference took it, which is what tells ChordPads not to treat the drag as a clear. - void showReferenceDropTarget(juce::Point screenPos); - bool offerReferenceDrop(juce::Point screenPos, const KeysProcessor::ChordPad&); - void clearReferenceDropTarget(); - // What the layout below actually needs, so the window's minimum is derived rather than // guessed. Widest row is the algorithmic settings row; tallest is all four rows plus the // gaps between them. See resized() for the arithmetic each of these adds up. diff --git a/src/ui/ChordPads.cpp b/src/ui/ChordPads.cpp index 5f70eee..8ff33e9 100644 --- a/src/ui/ChordPads.cpp +++ b/src/ui/ChordPads.cpp @@ -104,57 +104,121 @@ int ChordPads::cellAt(juce::Point pos) const return -1; } -// The cross-window drop, all three parts of it. See the header for why the generator's tray -// cannot reach this component the ordinary way. -// -// Desktop::findComponentAt rather than a bounds check on `screenPos`, because the generator's -// window is free to sit on top of the pad strip and a point over that window is not over a pad -// even when it is inside these bounds. Walking up from whatever is hit also answers the folded -// and detached cases for free: fold the Pads section and this component is not visible, so -// nothing here is ever found. -int ChordPads::externalDropSlotAt(juce::Point screenPos) const +// --------------------------------------------------------------------------------------- +// Taking a drop. Four gestures arrive here and JUCE tells this component about all of them, +// including the one that starts in another window - see ChordDrag.h for why that is not the +// impossibility the code here used to assert it was. +// --------------------------------------------------------------------------------------- + +bool ChordPads::isInterestedInDragSource(const SourceDetails& details) { - auto* hit = juce::Desktop::getInstance().findComponentAt(screenPos); - for (auto* c = hit; c != nullptr; c = c->getParentComponent()) - if (c == this) - { - const int slot = cellAt(getLocalPoint(nullptr, screenPos).toFloat()); - // Pads only. The live card is what is under your hand on the keyboard, not a place - // to put things, and a locked pad refuses: the lock is the thing that stops a chord - // being destroyed (Owen, 2026-07-30), and a drop replaces one outright. - if (slot >= 0 && ! processor.chordPad(slot).locked) - return slot; - return -1; - } - return -1; + return chorddrag::chordBeingDragged(details) != nullptr; +} + +// Which cell a drop would land on, refusals applied. They are not the same for every source, +// which is why this is one function with the provenance in hand rather than a rule per caller. +int ChordPads::dropCellFor(const chorddrag::Payload& p, juce::Point local) const +{ + using From = chorddrag::Payload::From; + const int cell = cellAt(local.toFloat()); + + if (cell == -2) + { + // The live card takes a pad being dragged back for editing (onRecall) and nothing else. + // It is what is under your hand on the keyboard, not a place to put things, so a + // candidate from the tray has nothing to mean here. + return p.from == From::padSlot ? -2 : -1; + } + if (cell < 0) + return -1; + if (p.from == From::padSlot && cell == p.index) + return -1; // dropped back where it was picked up: the gesture cancelled + + // A locked pad refuses a chord arriving from *outside* this strip, because that drop replaces + // what the pad holds outright and the lock is the thing that stops a chord being destroyed + // (Owen, 2026-07-30). A move inside the strip is allowed onto a lock, deliberately: + // moveChordPad only swaps two slots and destroys nothing, so rearranging a page is not what + // a lock is protecting against. Capturing the live card onto a locked pad has always been + // allowed too, and stays that way here rather than being quietly tightened in a refactor. + if (p.from == From::trayCell && processor.chordPad(cell).locked) + return -1; + return cell; } -void ChordPads::setExternalDropSlot(int slot) +void ChordPads::itemDragEnter(const SourceDetails& details) { itemDragMove(details); } + +void ChordPads::itemDragMove(const SourceDetails& details) { - if (slot == externalDropSlot) + auto* p = chorddrag::chordBeingDragged(details); + const int cell = p != nullptr ? dropCellFor(*p, details.localPosition) : -1; + if (cell == dropCell) return; - externalDropSlot = slot; + dropCell = cell; repaint(); } -bool ChordPads::dropExternalChord(juce::Point screenPos, const KeysProcessor::ChordPad& pad) +void ChordPads::itemDragExit(const SourceDetails&) { - const int slot = externalDropSlotAt(screenPos); - if (slot < 0) - return false; + if (dropCell == -1) + return; + dropCell = -1; + repaint(); +} - // clearChordPad first, and it is not tidiness. A dropped candidate is a *different* chord - // from whatever the slot held, unlike the octave and voicing edits that go through - // rewritePadChord and want the sounding notes to follow the card. setChordPad on its own - // does not stop anything, so a pad left ringing by Sustain - or one feeding the arp - would - // have had its old notes stranded on with nothing left owning them. clearChordPad is the - // one public call that stops the pad *and* releases the arp hold if this card is the one - // holding it, so the old chord is properly given up before the new one lands. - processor.clearChordPad(slot); - processor.setChordPad(slot, pad); - externalDropSlot = -1; +void ChordPads::itemDropped(const SourceDetails& details) +{ + using From = chorddrag::Payload::From; + dropCell = -1; + auto* p = chorddrag::chordBeingDragged(details); + if (p == nullptr) + return; + + // The release landed on this strip, so this drag did not go off the row - and that is true + // whether or not the strip goes on to do anything with it. A lock refusing, a candidate over + // the live card, a card dropped back where it started: all of those are gestures that end + // here, and none of them may read as the drag-off-to-clear. Set before the branches below + // for exactly that reason. + if (cellAt(details.localPosition.toFloat()) != -1) + p->taken = true; + + const int cell = dropCellFor(*p, details.localPosition); + if (cell == -1) + { + repaint(); + return; + } + + if (cell == -2) + { + // A filled pad dropped onto the live card: recall its chord for editing. Not a move and + // not a clear - the pad stays exactly where it was. + if (onRecall) + onRecall(p->chord.notes); + } + else if (p->from == From::padSlot) + { + processor.moveChordPad(p->index, cell); // rearrange, locked or not + } + else if (p->from == From::liveCard) + { + processor.setChordPad(cell, p->chord.notes, p->chord.name); // capture the live chord + } + else + { + // clearChordPad first, and it is not tidiness. A dropped candidate is a *different* + // chord from whatever the slot held, unlike the octave and voicing edits that go through + // rewritePadChord and want the sounding notes to follow the card. setChordPad on its own + // does not stop anything, so a pad left ringing by Sustain - or one feeding the arp - + // would have had its old notes stranded on with nothing left owning them. clearChordPad + // is the one public call that stops the pad *and* releases the arp hold if this card is + // the one holding it, so the old chord is properly given up before the new one lands. + processor.clearChordPad(cell); + processor.setChordPad(cell, p->chord); + // Committed, so the tray's cell goes empty. `taken` alone would not say this: the + // reference box sets that and keeps the candidate. + p->consumed = true; + } repaint(); - return true; } int ChordPads::firstEmptyPadOnPage() const @@ -202,7 +266,6 @@ void ChordPads::paint(juce::Graphics& g) const juce::Colour inkOnAccent { 0xff07272c }; // dark ink for text on lit surfaces const int offset = processor.padPageOffset(); - const int hovered = dragging ? cellAt(dragPos) : -1; // Live chord card: an inset well that lights up while a chord is sounding. // While a filled pad is being dragged over it, it highlights to offer the @@ -210,7 +273,7 @@ void ChordPads::paint(juce::Graphics& g) { const auto b = cardBounds(); const bool has = isChord(currentNotes); - const bool recallHover = dragging && dragSource >= 0 && hovered == -2; + const bool recallHover = dropCell == -2; g.setColour(juce::Colours::black.withAlpha(0.4f)); g.drawRoundedRectangle(b.expanded(0.5f), kRadius + 0.5f, 1.0f); @@ -260,12 +323,16 @@ void ChordPads::paint(juce::Graphics& g) const auto& pad = processor.chordPad(i); const bool filled = ! pad.notes.empty(); const bool active = processor.chordPadActive(i); - // Either drag can be offering this pad: one inside the strip, or a candidate being - // dragged in from the generator's tray in another window. They light the same, because - // to the pad they mean the same thing - let go here and this card takes that chord. - const bool dropHere = (dragging && hovered == i - && ((dragSource == -2 && isChord(currentNotes)) || (dragSource >= 0 && dragSource != i))) - || externalDropSlot == i; + // Any drag can be offering this pad: one inside the strip, or a candidate dragged in + // from the generator's tray in another window. They light the same, because to the pad + // they mean the same thing - let go here and this card takes that chord. One field for + // all of them now, and it already has the refusals applied, so a locked pad no longer + // lights for a drop it is going to turn away. + const bool dropHere = dropCell == i; + + // The card in the air fades where it sits. The ghost is a desktop window of its own and + // follows the cursor, so this is the hole it left rather than a stand-in for it. + const bool airborne = dragging && dragSource == i; // The pad being edited gives up its right end to the tick that ends the edit, so // the chord name moves over rather than running underneath it. @@ -273,6 +340,11 @@ void ChordPads::paint(juce::Graphics& g) if (i == editingSlot) nameArea.removeFromRight(kSaveW); + // A transparency layer rather than g.setOpacity: setColour resets the fill alpha, so + // dimming that way reaches the card's fill and leaves its lettering at full strength. + if (airborne) + g.beginTransparencyLayer(0.4f); + if (! filled) { g.setColour(skin::well.withAlpha(0.55f)); @@ -383,40 +455,16 @@ void ChordPads::paint(juce::Graphics& g) g.strokePath(tick, juce::PathStrokeType(2.2f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); } - } - // Drag ghost following the cursor. - // - // A locked card is dragged like any other - moveChordPad swaps two slots and destroys - // nothing, so rearranging a page is not what a lock is protecting against - but dropping - // one *off* the strip no longer clears it (see mouseUp). The ghost says so on the way: - // it carries the same corner dot the card does, and it fades once the pointer is over - // nothing, which is the spot where an unlocked card would be wiped and this one will not. - if (dragging && sourceIsDraggable()) - { - const bool locked = dragSource >= 0 && processor.chordPad(dragSource).locked; - const float dim = (locked && hovered == -1) ? 0.45f : 1.0f; - const juce::String label = dragSource == -2 ? currentName : processor.chordPad(dragSource).name; - auto ghost = juce::Rectangle(0.0f, 0.0f, 84.0f, 26.0f).withCentre(dragPos); - g.setColour(juce::Colours::black.withAlpha(0.35f * dim)); - g.fillRoundedRectangle(ghost.translated(0.0f, 2.0f), kRadius); - g.setGradientFill({ skin::accentOf(*this).hot.withMultipliedAlpha(dim), 0.0f, ghost.getY(), - skin::accentOf(*this).base.withMultipliedAlpha(dim), 0.0f, ghost.getBottom(), false }); - g.fillRoundedRectangle(ghost, kRadius); - g.setColour(inkOnAccent.withMultipliedAlpha(dim)); - g.setFont(skin::uiSemi(12.0f)); - g.drawText(label, ghost, juce::Justification::centred); - - if (locked) - { - const auto dot = juce::Rectangle(5.0f, 5.0f) - .withCentre({ ghost.getRight() - 8.0f, ghost.getY() + 8.0f }); - g.setColour(inkOnAccent.withAlpha(0.4f * dim)); - g.fillEllipse(dot.expanded(2.0f)); - g.setColour(inkOnAccent.withMultipliedAlpha(dim)); - g.fillEllipse(dot); - } + if (airborne) + g.endTransparencyLayer(); } + + // There is no ghost drawn here any more (2026-08-02). It used to be an 84x26 chip painted at + // the cursor because the drag could not leave this component; the real card now travels as a + // desktop window of its own and follows the mouse across the whole screen, which is what + // makes dropping onto the generator's reference box in another window a thing you can aim. + // The dimmed hole where the card sits is the half of that feedback this component still owns. } void ChordPads::setEditingSlot(int slot) @@ -682,7 +730,6 @@ void ChordPads::mouseDown(const juce::MouseEvent& e) // and that is not knowable yet (2026-08-02; see the note on the class). // downPos = e.position; - dragPos = e.position; dragging = false; dragSource = cellAt(e.position); repaint(); @@ -713,66 +760,91 @@ void ChordPads::timerCallback() void ChordPads::mouseDrag(const juce::MouseEvent& e) { - dragPos = e.position; - if (! dragging && e.position.getDistanceFrom(downPos) > 6.0f) + if (dragging || e.position.getDistanceFrom(downPos) <= 6.0f) + return; + + // Nothing to silence here: the press never sounded. What a drag has to decide is only + // whether there was something under it worth carrying, and a *filled* pad is the test - + // dragging an empty cell has never meant anything. The arp branch used to clear dragSource + // before this ran, which is what made a card undraggable with a line on. + if (! sourceIsDraggable()) { - // Nothing to silence here any more: the press never sounded. What a drag has to decide - // is only whether there was something under it worth carrying, and a *filled* pad is the - // test - dragging an empty cell has never meant anything. The arp branch used to clear - // dragSource before this ran, which is what made a card undraggable with a line on. - if (dragSource >= 0 && ! processor.chordPad(dragSource).notes.empty()) - dragging = true; - else if (dragSource == -2 && isChord(currentNotes)) - dragging = true; // dragging the live card to capture it - else - dragSource = -1; // nothing grabbable under the press + dragSource = -1; // nothing grabbable under the press + return; } - if (dragging) + + beginChordDrag(e); +} + +// Everything after this call belongs to JUCE: the ghost that follows the cursor out of this +// window, the target lighting up, and the drop itself. What stays here is which card was picked +// up and what happens if nobody wants it. +void ChordPads::beginChordDrag(const juce::MouseEvent& e) +{ + using From = chorddrag::Payload::From; + auto* container = juce::DragAndDropContainer::findParentDragContainerFor(this); + if (container == nullptr) { - // Tell whatever is outside this window where the drag is, so the generator's reference - // box can light up before you let go. Only a *pad* is offered: the live card is what you - // are holding on the keyboard, not something to keep a copy of. - if (onDragOutside && dragSource >= 0) - onDragOutside(e.getScreenPosition()); - repaint(); + jassertfalse; // the section holder is meant to be one, docked or in a window of its own + dragSource = -1; + return; } + + const bool live = dragSource == -2; + auto chord = live ? KeysProcessor::ChordPad {} : processor.chordPad(dragSource); + if (live) + { + chord.notes = currentNotes; + chord.name = currentName; + } + + // Snapped before `dragging` goes true, so the ghost is a picture of the card as it reads at + // rest rather than of the dimmed hole it is about to leave behind. + const auto b = (live ? cardBounds() : padBounds(dragSource - processor.padPageOffset())) + .toNearestInt(); + const auto ghost = createComponentSnapshot(b, true, 2.0f).convertedToFormat(juce::Image::ARGB); + const auto grab = b.getTopLeft() - downPos.roundToInt(); // JUCE negates this into the image + + inFlight = new chorddrag::Payload(live ? From::liveCard : From::padSlot, + live ? -1 : dragSource, std::move(chord)); + dragging = true; + repaint(); + + container->startDragging(juce::var(inFlight.get()), this, juce::ScaledImage(ghost, 2.0), + /*allowDraggingToExternalWindows*/ true, &grab, &e.source); } -void ChordPads::mouseUp(const juce::MouseEvent& e) +void ChordPads::mouseUp(const juce::MouseEvent&) { if (dragging) { - const int target = cellAt(e.position); - if (dragSource == -2 && target >= 0 && isChord(currentNotes)) - processor.setChordPad(target, currentNotes, currentName); // capture the live chord - else if (dragSource >= 0) - { - if (target == -2) + // Where this drag landed is not known yet. Every drop - onto a pad of this strip, onto + // the live card, onto the generator's reference box, onto an arp slot or line - is + // delivered by JUCE *after* this method, later in the same event, so the one question + // left here has to be asked a message-loop turn from now. See chorddrag::whenDragSettles + // for why that is the right length of wait and dragOperationEnded is not. + chorddrag::whenDragSettles( + *this, inFlight, + [](ChordPads& pads, const chorddrag::Payload& p) { - // Dropped a filled pad onto the live card: recall its chord for editing. - // Not a move and not a clear - the pad stays exactly where it was. - if (onRecall) - onRecall(processor.chordPad(dragSource).notes); - } - else if (target >= 0 && target != dragSource) - processor.moveChordPad(dragSource, target); // rearrange, locked or not - else if (target == -1 - && ! (onDropOutside - && onDropOutside(e.getScreenPosition(), processor.chordPad(dragSource))) - && ! processor.chordPad(dragSource).locked) - processor.clearChordPad(dragSource); // dragged off the row = clear - // The outside offer has to come *before* the clear and short-circuit it. Off the row - // is where the generator's reference box is, so without that ordering the one way to - // keep a chord you like would be the way to delete it. Whoever took it took a copy; - // this card does not move. - // A locked card dropped off the strip does nothing at all. The lock is the thing - // that stops a chord being destroyed (Owen, 2026-07-30), and "Clear pad" on the - // card menu has always greyed for a locked pad - this path was the hole in that, - // and a wider gesture than the menu item it was quietly overriding. The drag - // itself stays allowed, because moveChordPad only swaps two slots and a locked - // card still has to be arrangeable; the ghost fades over the refusal so the card - // says which of the two it is doing. - } + // Nobody took it, so the card was let go off the row, and off the row means + // clear. The veto is what keeps this from being the way to *lose* a chord: + // reaching for the reference box means dragging a card off the strip, and that + // box - like every arp target - says so by setting `taken`. + // + // A locked card dropped off the strip does nothing at all. The lock is the thing + // that stops a chord being destroyed (Owen, 2026-07-30), and "Clear pad" on the + // card menu has always greyed for a locked pad - this path was the hole in that, + // and a wider gesture than the menu item it was quietly overriding. The drag + // itself stays allowed, because moveChordPad only swaps two slots and a locked + // card still has to be arrangeable. + if (! p.taken && p.from == chorddrag::Payload::From::padSlot + && ! pads.processor.chordPad(p.index).locked) + pads.processor.clearChordPad(p.index); + + pads.inFlight = nullptr; + pads.repaint(); + }); } else { @@ -833,12 +905,11 @@ void ChordPads::mouseUp(const juce::MouseEvent& e) startTimer(auditionMs); } } - // Whatever the gesture turned out to be, the drag is over: tell whoever lit up for it. This - // is unconditional and outside the branches above on purpose - only one of those three ends - // off the row, and the other two are exactly the paths that used to leave the generator's - // reference box glowing at nothing. - if (dragging && onDragEnd) - onDragEnd(); + // Putting the outside taker's highlight back out is nobody's job here any more. Every target + // gets `itemDragExit` from JUCE on every path a drag can end - dropped elsewhere, dragged + // away, or the window it lives in closed mid-gesture - which is the whole of a bug class the + // editor used to carry by hand and get wrong: dragging out over the reference box and then + // back onto a pad left the box lit with nothing in the air. dragging = false; dragSource = -1; repaint(); diff --git a/src/ui/ChordPads.h b/src/ui/ChordPads.h index 18e41c2..ac3b7ad 100644 --- a/src/ui/ChordPads.h +++ b/src/ui/ChordPads.h @@ -1,6 +1,7 @@ #pragma once #include "../PluginProcessor.h" +#include "ChordDrag.h" #include #include #include @@ -46,7 +47,13 @@ namespace keys // reading, and it cost too much: starting a drag blurted the chord every time, and with an arp // line switched on the press *consumed* the click and cleared `dragSource`, so a card could not // be dragged at all in the one mode where dragging it onto a line is the whole point. +// +// **Every drag on this strip is stock JUCE drag-and-drop** (2026-08-02), including the ones that +// leave the window. See ChordDrag.h: the container is the section holder this component is +// parented into, which is what makes the machinery survive the Pads section being popped out +// into a window of its own. class ChordPads : public juce::Component, + public juce::DragAndDropTarget, private juce::Timer { public: @@ -89,23 +96,21 @@ class ChordPads : public juce::Component, std::function onExtraMenuItems; std::function onExtraMenuChoice; - // A chord dragged in from *another window* - today the generator's audition tray, which - // lives in a DetachedWindow of its own (ChordTray, 2026-08-01). JUCE's mouse capture keeps - // the drag events on the tray for the whole gesture and no drag-and-drop container spans two - // desktop windows, so the source cannot find this component and this component never sees - // the mouse. The editor holds both and forwards a **screen** position, the one space the two - // share; the geometry, the paint and the refusals stay on this side, where the pads are. + // Every drop this strip takes, wherever the chord came from: a pad moved to another pad, a + // pad dropped on the live card to recall it, the live card captured onto a pad, and a + // candidate dragged in from the generator's audition tray in a window of its own. // - // Occlusion is answered here rather than by the caller: the generator window can sit over - // the strip, and a point inside these bounds but underneath that window is not a drop. That - // is what makes this a Desktop hit test and not a bounds check. - int externalDropSlotAt(juce::Point screenPos) const; // absolute slot, or -1 - // Which pad the drag in progress started from, or -1. Read by onDropOutside handlers that - // need to name the *card*, not just copy its notes - handing a chord to an arp line marks - // the card it came from, so the strip can ring it. - int draggedSlot() const { return dragSource; } - void setExternalDropSlot(int slot); // hover feedback; -1 clears it - bool dropExternalChord(juce::Point screenPos, const KeysProcessor::ChordPad& pad); + // One entry point for all four, because to a pad they are the same event. The old + // arrangement had two - the strip's own mouseUp for the internal cases and a screen-position + // call from the editor for the tray - on the belief that JUCE could not deliver a drop across + // two top-level windows. It can (ChordDrag.h). Occlusion, the folded section and the detached + // section are all answered by JUCE's own target search, which is a better hit test than the + // one that was here: it cannot light a pad through a window sitting over it. + bool isInterestedInDragSource(const SourceDetails&) override; + void itemDragEnter(const SourceDetails&) override; + void itemDragMove(const SourceDetails&) override; + void itemDragExit(const SourceDetails&) override; + void itemDropped(const SourceDetails&) override; // The aimless twin of that drop, for "Send to first empty pad" on a tray card's menu. It // takes the first blank slot on the *current page*, left to right, and refuses when there is @@ -114,23 +119,19 @@ class ChordPads : public juce::Component, int firstEmptyPadOnPage() const; bool sendChordToFirstEmptyPad(const KeysProcessor::ChordPad& pad); - // The drag going the *other* way: a card leaving this strip, offered to whatever is outside - // this window before the strip decides what the gesture meant. Today the only taker is the - // generator's reference box. - // - // `onDropOutside` returning true is load-bearing rather than informational. Dragging a card - // off the row clears it, and reaching for the reference box means dragging a card off the - // row: without this the one gesture Owen asked for would delete the chord it was trying to - // keep. True means "somebody took a copy", and the card stays exactly where it was. + // The drag going the *other* way needs no hooks at all now. A card leaving this strip is + // offered to the generator's reference box and to the arp panel's slots, tabs and macro rows + // by JUCE, because each of those is a `DragAndDropTarget`; the three `std::function`s the + // editor used to forward screen positions through are gone, and so is the bug class they + // came with, where a highlight lit on the way out had to be put back out by hand on every + // path a drag could end (including the far window being closed mid-gesture). // - // `onDragEnd` fires once at the end of every drag off this strip, whatever the gesture - // turned out to mean, and it is what puts the outside taker's highlight back out. - // `onDropOutside` cannot do that job: it only runs when the card was let go *off* the row, - // so dragging out over the reference box and then back onto a pad - or onto the live card - - // left the box lit with nothing being dragged at all. - std::function screenPos)> onDragOutside; - std::function screenPos, const KeysProcessor::ChordPad&)> onDropOutside; - std::function onDragEnd; + // What did *not* go is the veto. Dragging a card off the row clears it, and reaching for the + // reference box means dragging a card off the row, so a taker has to be able to say "I have + // it, leave the card alone" or the one gesture that keeps a chord would be the one that + // deletes it. JUCE has no opinion about that, so it rides on the payload: see + // `chorddrag::Payload::taken`, which every target here sets and this strip reads once the + // drop has been delivered. private: juce::Rectangle cardBounds() const; @@ -146,6 +147,15 @@ class ChordPads : public juce::Component, bool sourceIsDraggable() const; void showPadMenu(int slot); + // Hand the card under the press to JUCE, ghost and all. + void beginChordDrag(const juce::MouseEvent&); + // Which cell a drop of this chord would actually land on: the same `cellAt` answer with the + // refusals applied, which differ by where the chord came from. -2 is the live card, >= 0 an + // absolute pad slot, -1 nothing. Kept apart from `cellAt` because "over a card of this strip" + // and "this drop would do something" are different questions and only the first one decides + // whether the drag counts as having left the row. + int dropCellFor(const chorddrag::Payload&, juce::Point local) const; + // The two chord-shaping actions on that menu, both acting on the *stored* chord of one // pad. Menu-only by Owen's call: they are edits, not performance, and the cards have no // room for three more targets. @@ -176,12 +186,19 @@ class ChordPads : public juce::Component, void timerCallback() override; void endAudition(); - int externalDropSlot = -1; // pad a cross-window drag is currently over, or -1 + // The cell a drag - anyone's, from either window - is currently offering a chord to, or -1. + // One field for both, because to a pad they mean the same thing: let go here and this card + // takes that chord. + int dropCell = -1; int dragSource = -1; // -2 card, 0..N-1 pad, -1 none int playing = -1; // pad sounding out its audition, and lit while it does bool playingLive = false; // the live card is sounding its chord bool dragging = false; - juce::Point downPos, dragPos; + juce::Point downPos; + + // The chord this strip currently has in the air, so the answer the targets write on it can + // be read once the drop has been delivered. Null except during one of this strip's own drags. + chorddrag::Payload::Ptr inFlight; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ChordPads) }; diff --git a/src/ui/ChordTray.cpp b/src/ui/ChordTray.cpp index bfeb94f..2c4dcae 100644 --- a/src/ui/ChordTray.cpp +++ b/src/ui/ChordTray.cpp @@ -185,10 +185,10 @@ void ChordTray::paint(juce::Graphics& g) const auto b = cellBounds(i); const auto& c = cells[(size_t) i]; const bool filled = ! c.notes.empty(); - // The card being dragged dims where it sits: the ghost cannot follow the cursor out of - // this window, so the source saying "this one is in the air" plus the pad strip lighting - // its target is the whole of the drag's feedback. - const bool inFlight = dragging && i == pressed; + // The card being dragged dims where it sits. The ghost does follow the cursor out of this + // window now (2026-08-02), so this is no longer standing in for it - it is the hole the + // card left, which is the thing that says a commit will empty this cell. + const bool airborne = dragging && i == pressed; if (! filled) { @@ -201,7 +201,7 @@ void ChordTray::paint(juce::Graphics& g) { juce::Graphics::ScopedSaveState ss(g); - if (inFlight) + if (airborne) g.setOpacity(0.4f); skin::raisedFill(g, b, kRadius, juce::Colour(0xff272b32), juce::Colour(0xff1e2126)); @@ -381,23 +381,46 @@ void ChordTray::mouseDown(const juce::MouseEvent& e) void ChordTray::mouseDrag(const juce::MouseEvent& e) { - if (pressed < 0) + if (pressed < 0 || dragging) return; - if (! dragging && e.position.getDistanceFrom(downPos) > 6.0f) + if (e.position.getDistanceFrom(downPos) > 6.0f) { // A press that becomes a drag is a commit, not an audition. Stop the note first, the // same way the pad strip turns a press into a rearrange. gen.stopAudition(); - dragging = true; - repaint(); + beginDrag(e); + } +} + +// Hand the candidate to JUCE and let go of it. Everything after this - the ghost, the target +// lighting up, the drop landing in another window - is the framework's, which is the whole point +// of the 2026-08-02 migration: this used to be a screen-coordinate callback per phase. +void ChordTray::beginDrag(const juce::MouseEvent& e) +{ + auto* container = juce::DragAndDropContainer::findParentDragContainerFor(this); + if (container == nullptr) + { + jassertfalse; // ChordGenPanel is meant to be one; a tray with no container cannot commit + return; } - if (dragging && onDragOver) - onDragOver(e.getScreenPosition()); + // The snapshot is taken *before* `dragging` goes true, so the ghost is a picture of the card + // as it reads at rest rather than of the dimmed hole it is about to become. + const auto cell = cellBounds(pressed).toNearestInt(); + const auto ghost = createComponentSnapshot(cell, true, 2.0f).convertedToFormat(juce::Image::ARGB); + const auto grab = cell.getTopLeft() - downPos.roundToInt(); // JUCE negates this into the image + + inFlight = new chorddrag::Payload(chorddrag::Payload::From::trayCell, pressed, + cells[(size_t) pressed]); + dragging = true; + repaint(); + + container->startDragging(juce::var(inFlight.get()), this, juce::ScaledImage(ghost, 2.0), + /*allowDraggingToExternalWindows*/ true, &grab, &e.source); } -void ChordTray::mouseUp(const juce::MouseEvent& e) +void ChordTray::mouseUp(const juce::MouseEvent&) { if (pressed < 0) return; @@ -412,30 +435,38 @@ void ChordTray::mouseUp(const juce::MouseEvent& e) return; } - if (onDrop && onDrop(e.getScreenPosition(), cells[(size_t) pressed])) - { - // It landed, and the cell it came from goes empty rather than refilling itself. The hole - // is the only state this tray keeps and it earns its place twice: it is how you see which - // of the sixteen you have already taken, and it is what gives Fill something to do. A - // cell that refilled instantly left Fill permanently greyed and Regen indistinguishable - // from "reroll everything", which is most of why the window's three buttons were still - // pointed at the pads. - cells[(size_t) pressed] = {}; - } - // A drop anywhere else - the tray itself, the desktop, a folded pad section - does nothing - // at all and keeps the candidate. There is no "drag off to discard" here: a tray card costs - // nothing to leave alone, and the gesture that clears a *pad* is the same shape, so making - // this one destructive would be the one drag in Keys that loses work by missing. - - endDrag(); + // Whether a pad took the candidate is not known yet - `itemDropped` runs after this, later in + // the same event. Ask a message-loop turn from now, which is after it and before the next + // frame. See chorddrag::whenDragSettles. + chorddrag::whenDragSettles(*this, inFlight, + [](ChordTray& t, const chorddrag::Payload& p) + { + if (p.consumed && p.index >= 0 && p.index < numCells) + { + // It landed, and the cell it came from goes empty rather + // than refilling itself. The hole is the only state this + // tray keeps and it earns its place twice: it is how you + // see which of the sixteen you have already taken, and it + // is what gives Fill something to do. A cell that refilled + // instantly left Fill permanently greyed and Regen + // indistinguishable from "reroll everything". + t.cells[(size_t) p.index] = {}; + } + // Anywhere else - the tray itself, the reference box, the + // desktop, a folded pad section - keeps the candidate. There is + // no "drag off to discard" here: a tray card costs nothing to + // leave alone, and the gesture that clears a *pad* is the same + // shape, so making this one destructive would be the one drag + // in Keys that loses work by missing. + t.endDrag(); + }); } void ChordTray::endDrag() { dragging = false; pressed = -1; - if (onDragEnd) - onDragEnd(); + inFlight = nullptr; repaint(); } @@ -474,6 +505,36 @@ void ChordRefCard::setDropHighlight(bool on) repaint(); } +// The reference takes chords from either window. It refuses the live card because that one is +// what you are holding on the keyboard rather than a chord you have decided to keep, which is +// the same refusal the strip's own drag made when it offered only filled pads to the outside. +bool ChordRefCard::isInterestedInDragSource(const SourceDetails& details) +{ + auto* p = chorddrag::chordBeingDragged(details); + return p != nullptr && p->from != chorddrag::Payload::From::liveCard; +} + +void ChordRefCard::itemDragEnter(const SourceDetails&) { setDropHighlight(true); } + +// The highlight now goes out on every path there is, including the one that used to leak: JUCE +// calls this when the drag leaves, when it is dropped elsewhere, and when the drag image dies +// with the window that started it. The editor used to have to remember to clear it by hand. +void ChordRefCard::itemDragExit(const SourceDetails&) { setDropHighlight(false); } + +void ChordRefCard::itemDropped(const SourceDetails& details) +{ + setDropHighlight(false); + auto* p = chorddrag::chordBeingDragged(details); + if (p == nullptr || p->from == chorddrag::Payload::From::liveCard) + return; + + setChord(p->chord); + // Taken, so a pad that came from the strip is not cleared behind it - but never *consumed*, + // so a tray candidate stays in its cell. A reference is a copy of a chord you like, and + // charging the tray a card for keeping one would be backwards. + p->taken = true; +} + void ChordRefCard::mouseEnter(const juce::MouseEvent&) { hovered = true; repaint(); } void ChordRefCard::mouseExit(const juce::MouseEvent&) { hovered = false; repaint(); } diff --git a/src/ui/ChordTray.h b/src/ui/ChordTray.h index 53bebab..3efd0eb 100644 --- a/src/ui/ChordTray.h +++ b/src/ui/ChordTray.h @@ -1,6 +1,7 @@ #pragma once #include "../PluginProcessor.h" +#include "ChordDrag.h" #include "ChordGenMenu.h" #include #include @@ -31,12 +32,14 @@ namespace keys // * **drag onto a pad** - commit it there. A drag is the only gesture that can name a slot, // which is why it and not a second click is the commit. // -// The drag crosses windows, and nothing in JUCE does that for free. The pad strip is in the -// main editor window and this is in a DetachedWindow of its own, so a `DragAndDropContainer` -// would never see the drop and `mouseUp` arrives here with coordinates local to a component the -// pads know nothing about. The two hooks below hand the editor a *screen* position, which is -// the one space the two windows share; the editor holds both ends and closes the gap. Occlusion -// is the target's problem, not this one's (see ChordPads::externalDropSlotAt). +// The drag crosses windows, and JUCE does that for free - which is the opposite of what this +// comment said until 2026-08-02. `DragAndDropContainer::startDragging` takes a fourth parameter, +// `allowDraggingToExternalWindows`; pass true and the drop is delivered to a target in any other +// JUCE window, the pad strip included. See ChordDrag.h for the mechanism. The container is +// ChordGenPanel, this window's content, and it is a container for no other reason. +// +// The ghost follows the cursor out of this window and over the strip, because the drag image is +// a desktop window of its own rather than a child of anything here. // // A committed card **leaves its cell empty**, which is the one piece of state the tray keeps: // the hole is how you see which candidates you have already taken, and it is what gives Fill @@ -110,13 +113,6 @@ class ChordTray : public juce::Component // would mark the tray stale every time you committed a card. bool settingsMovedSinceFill() const; - // The drag's two ends plus its cleanup, all in *screen* coordinates - see the class comment - // for why there is no other space these two windows share. Unset, the tray still auditions, - // which is what makes the click the half of the gesture that cannot break. - std::function screenPos)> onDragOver; - std::function screenPos, const KeysProcessor::ChordPad&)> onDrop; - std::function onDragEnd; - // "Send to first empty pad" on the card menu. It is the menu's one *placing* item and it // exists because a drag needs a hand steady enough to land on one card of sixteen in another // window; this is the same commit with the aim taken out. Returns false when the page is @@ -131,6 +127,7 @@ class ChordTray : public juce::Component void writeInto(const std::vector& cellIndices); // the one call that asks for candidates void showCardMenu(int index); void reshapeCell(int index, const std::vector& notes); // an edit to one candidate + void beginDrag(const juce::MouseEvent&); void endDrag(); KeysProcessor& processor; @@ -149,6 +146,11 @@ class ChordTray : public juce::Component bool dragging = false; juce::Point downPos; + // The chord currently in the air, kept so the answer that comes back on it can be read when + // the button goes up. Held by the drag itself as well, so this pointer going stale is not a + // way the payload can die. + chorddrag::Payload::Ptr inFlight; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ChordTray) }; @@ -165,15 +167,22 @@ class ChordTray : public juce::Component // It fills from either direction, and both are drags: // // * a **tray card** dropped on it, which never leaves this window; -// * a **pad from the main window** dropped on it, which is the mirror of the commit drag and -// the reason ChordPads grew `onDropOutside`. Dropping a pad here **copies** it: dragging a -// card off the strip normally clears it, and a gesture that reached for the reference box -// and deleted a chord instead would be the worst bug in the window. +// * a **pad from the main window** dropped on it, the mirror of the commit drag. Dropping a +// pad here **copies** it: dragging a card off the strip normally clears it, so this card +// sets `taken` on the payload and the strip reads that as "somebody has it, leave the card +// alone". A gesture that reached for the reference box and deleted a chord instead would be +// the worst bug in the window, which is the whole reason that flag exists. +// +// It takes the drop itself, as an ordinary `DragAndDropTarget`, rather than being offered a +// screen position by the editor: a drag that has reached this card is over this card, and JUCE's +// own target search answers "which window is on top here" better than a bounds test could - the +// reference box used to light up through a window sitting over it. // // Left-click auditions it, the same as a tray card. It is not a drag source: it is where chords // come to be kept, and the pads are one click away through Similar / Could follow rather than a // second commit path nobody asked for. -class ChordRefCard : public juce::Component +class ChordRefCard : public juce::Component, + public juce::DragAndDropTarget { public: ChordRefCard(KeysProcessor&, ChordGenMenu&); @@ -184,6 +193,13 @@ class ChordRefCard : public juce::Component void mouseEnter(const juce::MouseEvent&) override; void mouseExit(const juce::MouseEvent&) override; + // A chord card from the tray beside it or from the pad strip in another window. The live + // card is refused: it is what is under your hand on the keyboard, not a chord you have kept. + bool isInterestedInDragSource(const SourceDetails&) override; + void itemDragEnter(const SourceDetails&) override; + void itemDragExit(const SourceDetails&) override; + void itemDropped(const SourceDetails&) override; + void setChord(const KeysProcessor::ChordPad&); void clearChord(); bool hasChord() const { return ! held.notes.empty(); }