fix: insert onto selected track(s) at edit cursor, not a new track

Wire both insert actions to CurrentTrack (InsertMedia base 0). Per selected
track: SetOnlyTrackSelected, reset cursor to snapshot, InsertMedia; then restore
original selection + cursor. No-op when no track selected. Corrected stale
extraflags / new-track / preservePitch comments.
This commit is contained in:
2026-07-23 05:56:25 -04:00
parent 59e3c5907f
commit 2536b4bedb
5 changed files with 142 additions and 66 deletions
+96 -37
View File
@@ -10,21 +10,25 @@
//
// FLAGGED RUNTIME ASSUMPTIONS (semantics the header does not fully specify — must
// be DAW-verified by Daniel post-merge; see the handoff):
// A. InsertMedia base modes 0/1 insert AT THE EDIT CURSOR. The header names the
// base targets ("add to current track" / "add new track") but does not spell
// out an explicit "at edit cursor" bit — placement at the edit cursor is
// REAPER's documented convention for these modes, relied on here.
// B. InsertMedia ADVANCES the edit cursor to the end of the inserted media. This
// is the behavior that makes sequential multi-insert lay items end-to-end. It
// is REAPER's long-standing behavior but is not stated in the header — flagged.
// We do NOT re-read/patch the cursor between inserts (we trust B); if B proved
// false in the DAW, the fix is to advance the cursor ourselves by the inserted
// item length. Not done now (no evidence it is needed, and item length is not
// returned by InsertMedia).
// C. New-track insert (mode base 1) creates the track and leaves it selected;
// current-track insert (base 0) targets the current/last-selected track. We do
// not force a track selection — the user's current selection is the target for
// base 0, matching REAPER's drag-to-track semantics.
// A. InsertMedia base mode 0 ("add to current track") targets the track that is
// currently the ONLY selected track. The header names the base target but does
// not spell out how "current track" resolves at runtime. We force exactly one
// selected track via SetOnlyTrackSelected before each InsertMedia call, which
// is the most defensible interpretation; if REAPER uses a different notion of
// "current" (e.g. last-focused, not last-selected), DAW-verify and adjust.
// B. InsertMedia mode 0 inserts AT THE EDIT CURSOR. Placement at the edit cursor
// is REAPER's documented convention for base modes 0/1 (the header does not
// spell out an explicit "at edit cursor" bit). Flagged for DAW-verification.
// C. InsertMedia ADVANCES the edit cursor to the end of the inserted media. We
// reset the cursor to the snapshot position before EACH track's insert, so
// assumption C's truth or falsity is irrelevant: we own the cursor reset.
// D. SetEditCurPos(time, false, false) moves the cursor without scrolling the view
// and without seeking the transport. The header lists the args as
// (time, moveview, seekplay) — moveview=false and seekplay=false are the
// non-disruptive choice; flagged in case the DAW shows otherwise.
// E. SetOnlyTrackSelected deselects all tracks and selects exactly one. The header
// doc-comment says "Set exactly one track selected, deselect all others" —
// this is the strongest confirmation we have; flagged for DAW-verification.
#include "insert.h"
@@ -38,9 +42,15 @@
#include "persist.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetCursorPosition
#define REAPERAPI_WANT_GetSelectedTrack
#define REAPERAPI_WANT_InsertMedia
#define REAPERAPI_WANT_SetEditCurPos
#define REAPERAPI_WANT_SetOnlyTrackSelected
#define REAPERAPI_WANT_SetTrackSelected
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
@@ -64,15 +74,51 @@ std::string currentProjectDir() {
return normalizeSlashes(fs::path(rpp).parent_path().string());
}
// Snapshot the user's currently-selected track set (ignores master, matches
// CountSelectedTracks / GetSelectedTrack which both skip master). Returns the
// tracks in selection order so we can restore the original state afterward.
std::vector<MediaTrack*> snapshotSelectedTracks() {
const int n = CountSelectedTracks(nullptr); // nullptr = active project
std::vector<MediaTrack*> tracks;
tracks.reserve(static_cast<size_t>(n));
for (int i = 0; i < n; ++i)
tracks.push_back(GetSelectedTrack(nullptr, i));
return tracks;
}
// Restore a previously-snapshotted track selection: deselect all (by setting the
// first track alone) then re-select the full set. If the snapshot is empty we
// leave all tracks deselected; no-op guard handles a completely empty project.
void restoreSelectedTracks(const std::vector<MediaTrack*>& tracks) {
if (tracks.empty()) return;
// Deselect all via the first track, then re-add the rest.
SetOnlyTrackSelected(tracks[0]);
for (size_t i = 1; i < tracks.size(); ++i)
SetTrackSelected(tracks[i], true);
}
} // namespace
InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) {
InsertResult result;
if (!session) { result.status = InsertStatus::NoSelection; return result; }
// WHAT to place: the panel's current selection (ids, in bank order).
// WHO to target: the user's currently-selected track set. No-op (with a clear
// console message) when nothing is selected — inserting without a target track
// would create an unintended new track or behave unpredictably.
const std::vector<MediaTrack*> selectedTracks = snapshotSelectedTracks();
if (selectedTracks.empty()) {
ShowConsoleMsg("ReaSampler insert: select a track first.\n");
result.status = InsertStatus::NoSelection;
return result;
}
// WHAT to place: the single focused sample from the panel. Multi-select is
// deprioritized; take the first (or only) selected id. An empty panel selection
// is a no-op — nothing to place.
const std::vector<std::string> ids = bankPanelSelectedSampleIds();
if (ids.empty()) { result.status = InsertStatus::NoSelection; return result; }
const std::string& id = ids.front(); // focused / first selected — single sample
// WHERE the bank lives on disk. An unsaved project has no resolvable bank dir;
// insert is a no-op rather than resolving against CWD (CLAUDE.md invariant).
@@ -80,40 +126,53 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; }
const BankIndex& bank = session->bank();
const Sample* sample = bank.query(id);
if (!sample) { result.status = InsertStatus::NothingResolved; return result; }
const std::string abs = resolveBankFile(projectDir, sample->relativePath);
if (abs.empty() || !fs::exists(fs::path(abs))) {
result.status = InsertStatus::NothingResolved;
return result;
}
const int mode = computeInsertMode(request.options);
// Wrap the whole placement in ONE undo block so a single undo removes every
// item inserted by this action (proj=nullptr -> active project). Opened before
// the first InsertMedia and closed after the last, unconditionally, so the block
// is always balanced even if nothing resolves (an empty block is harmless).
// Snapshot the edit cursor position up front so we can restore it to the same
// position for each track insert (and after the whole operation).
const double cursorPos = GetCursorPosition();
// Wrap the whole placement (all tracks + selection/cursor save-restore) in ONE
// undo block so a single undo removes every item and restores the state before
// the action. Opened before the first InsertMedia, closed after the restore,
// unconditionally — the block is always balanced.
Undo_BeginBlock2(nullptr);
for (const std::string& id : ids) {
const Sample* sample = bank.query(id);
if (!sample) { ++result.skipped; continue; } // id no longer in the bank
const std::string abs = resolveBankFile(projectDir, sample->relativePath);
if (abs.empty()) { ++result.skipped; continue; } // unresolvable relative path
if (!fs::exists(fs::path(abs))) { ++result.skipped; continue; } // file missing
// Insert AT THE EDIT CURSOR (assumption A). InsertMedia advances the cursor
// to the end of the inserted media (assumption B), so the next iteration
// lands contiguously — no manual cursor math needed. Non-destructive to the
// bank: this references abs, it does not modify the file or the index.
// Insert onto EACH selected track at the SAME edit-cursor position (assumption B).
// For each track: isolate it as the only selection so InsertMedia mode 0 targets
// it unambiguously (assumption A + E), reset the cursor to the snapshot position
// (assumption C cursor advance is irrelevant — we own the reset), then insert.
for (MediaTrack* track : selectedTracks) {
SetOnlyTrackSelected(track); // assumption A + E
SetEditCurPos(cursorPos, false, false); // assumption D
InsertMedia(abs.c_str(), mode);
++result.inserted;
}
// Restore the user's original track selection and cursor position so the action
// is non-destructive to their DAW state (non-negotiable per the brief).
restoreSelectedTracks(selectedTracks);
SetEditCurPos(cursorPos, false, false);
// Label reflects the count and the conform choice so the undo history reads
// clearly ("ReaSampler: insert 2 samples" etc.). extraflags 0 = default scope.
// clearly ("ReaSampler: insert on 2 tracks" etc.). extraflags -1 = UNDO_STATE_ALL
// (superset: tracks, items, envelope points, project state).
const std::string label =
"ReaSampler: insert " + std::to_string(result.inserted) +
(result.inserted == 1 ? " sample" : " samples") +
"ReaSampler: insert on " + std::to_string(result.inserted) +
(result.inserted == 1 ? " track" : " tracks") +
(request.options.conform == TempoConform::None ? "" : " (conform)");
Undo_EndBlock2(nullptr, label.c_str(), -1);
if (result.inserted == 0) result.status = InsertStatus::NothingResolved;
else result.status = InsertStatus::Ok;
result.status = InsertStatus::Ok;
return result;
}