feat: add M6 insert — place selected bank sample at edit cursor

InsertMedia-driven placement reading the bank panel selection, undo-wrapped.
Native length by default; conform-to-tempo is an explicit second action, never
a silent stretch. Pure mode-bit logic (insert_plan) unit-tested.
This commit is contained in:
2026-07-23 05:23:37 -04:00
parent 60add4afc7
commit 172d5902c8
9 changed files with 557 additions and 5 deletions
+120
View File
@@ -0,0 +1,120 @@
// insert.cpp — REAPER-facing placement shell (M6). See insert.h.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are
// extern (CLAUDE.md §contract).
//
// THIS IS THE INTENDED PLACEMENT PATH. Unlike capture / bank_panel (which never
// touch the arrange), insert deliberately adds items to the arrange — that is its
// whole job (CONTEXT.md §load-bearing principle). It runs ONLY from its own action.
//
// 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.
#include "insert.h"
#include <filesystem>
#include <string>
#include <vector>
#include "bank_model.h"
#include "bank_panel.h"
#include "capture_paths.h"
#include "persist.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetCursorPosition
#define REAPERAPI_WANT_InsertMedia
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
namespace fs = std::filesystem;
// The current project's directory (mirrors bank_panel/capture/persist). The bank
// index stores relative paths; resolving a bank file needs the current .rpp dir.
// FOLLOW-UP (already noted in bank_panel.cpp): a shared "current project dir"
// REAPER helper is a clean small refactor now that a fourth consumer exists — out
// of scope for M6.
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
std::string rpp(buf.data());
if (rpp.empty()) return {}; // unsaved project: no resolvable bank
return normalizeSlashes(fs::path(rpp).parent_path().string());
}
} // 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).
const std::vector<std::string> ids = bankPanelSelectedSampleIds();
if (ids.empty()) { result.status = InsertStatus::NoSelection; return result; }
// 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).
const std::string projectDir = currentProjectDir();
if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; }
const BankIndex& bank = session->bank();
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).
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.
InsertMedia(abs.c_str(), mode);
++result.inserted;
}
// Label reflects the count and the conform choice so the undo history reads
// clearly ("ReaSampler: insert 2 samples" etc.). extraflags 0 = default scope.
const std::string label =
"ReaSampler: insert " + std::to_string(result.inserted) +
(result.inserted == 1 ? " sample" : " samples") +
(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;
return result;
}
} // namespace reasampler