feat(view): D2 view shell — apply mode toggle plan to live project

Reads I_FOLDERDEPTH into a FolderTree (pure view_tree helper, unit-tested),
snapshots to-be-parked tracks before parking, runs the D1 planner, and applies
park/restore flag + per-FX-offline writes under an undo block. Master and
mute/solo untouched; acts only on tagged tracks.
This commit is contained in:
2026-07-22 20:55:50 -04:00
parent 2d040d6896
commit c068279aac
6 changed files with 448 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
// view.cpp — REAPER-facing Design View shell (Phase D2). See view.h.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
// pointers; here they are extern (CLAUDE.md §contract).
//
// The tree arithmetic (I_FOLDERDEPTH -> FolderTree) lives in the pure view_tree
// module so it is unit-tested outside the DAW; this file owns only the REAPER
// reads/writes and the snapshot-before-park ordering.
#include "view.h"
#include <string>
#include <vector>
#include "view_tree.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetTrackGUID
#define REAPERAPI_WANT_guidToString
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
// REAPER's GUID -> string form. guidToString needs a 64-byte destination (SDK
// contract); the canonical "{XXXXXXXX-...}" string is the membership-index key the
// actions layer tags with, so the tree keys and the model keys align exactly.
std::string trackGuidString(MediaTrack* tr) {
GUID* g = GetTrackGUID(tr);
if (!g) return {};
char buf[64] = {0};
guidToString(g, buf);
return std::string(buf);
}
// The parmname for each planner Flag. All four are documented bool*/int* track
// info params driven through the double-valued Get/SetMediaTrackInfo_Value API.
const char* flagParm(Flag f) {
switch (f) {
case Flag::ShowInTcp: return "B_SHOWINTCP";
case Flag::ShowInMixer: return "B_SHOWINMIXER";
case Flag::MainSend: return "B_MAINSEND";
case Flag::FxEnable: return "I_FXEN";
}
return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet
}
// Reads the arrange-ordered track list and their I_FOLDERDEPTH, keyed by GUID.
// The master track is NOT enumerated by GetTrack (index space is the non-master
// tracks), so it can never enter the tree — the master-untouched invariant holds
// by construction. Also caches the MediaTrack* per GUID so later apply steps
// resolve a GUID back to its handle without a second linear scan.
std::vector<TrackFolderEntry> readFolderEntries(
ReaProject* proj,
std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
std::vector<TrackFolderEntry> entries;
int count = CountTracks(proj);
entries.reserve(static_cast<std::size_t>(count));
handleByGuid.reserve(static_cast<std::size_t>(count));
for (int i = 0; i < count; ++i) {
MediaTrack* tr = GetTrack(proj, i);
if (!tr) continue;
std::string guid = trackGuidString(tr);
if (guid.empty()) continue;
int depth = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"));
entries.push_back(TrackFolderEntry{guid, depth});
handleByGuid.emplace_back(guid, tr);
}
return entries;
}
MediaTrack* resolve(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
const std::string& guid) {
for (const auto& kv : handleByGuid) {
if (kv.first == guid) return kv.second;
}
return nullptr; // stale/deleted GUID — pruned by being skipped
}
// Captures a track's prior driven-flag state BEFORE it is parked. Reads only the
// four owned flags + per-FX offline; never B_MUTE/I_SOLO, never the master (not
// reachable here). ints preserve whatever REAPER reported (defensive per D1's
// TrackSnapshot contract).
TrackSnapshot snapshotTrack(MediaTrack* tr) {
TrackSnapshot snap;
snap.showInTcp = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP"));
snap.showInMixer = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINMIXER"));
snap.mainSend = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_MAINSEND"));
snap.fxEnable = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FXEN"));
int fxCount = TrackFX_GetCount(tr);
snap.fxOffline.reserve(static_cast<std::size_t>(fxCount));
for (int fx = 0; fx < fxCount; ++fx) {
snap.fxOffline.push_back(TrackFX_GetOffline(tr, fx) ? 1 : 0);
}
return snap;
}
// Applies the planner's scalar-flag writes. B_* are bool* params, I_FXEN is int*,
// all driven through the double API — marshal the plan's int value to double.
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags) {
for (const TrackFlagOp& op : flags) {
SetMediaTrackInfo_Value(tr, flagParm(op.flag), static_cast<double>(op.value));
}
}
// Parks a track's FX offline: the pure park plan leaves fxOffline empty by design;
// the shell expands it from the live FX count and offlines every slot.
void parkFxOffline(MediaTrack* tr) {
int fxCount = TrackFX_GetCount(tr);
for (int fx = 0; fx < fxCount; ++fx) {
TrackFX_SetOffline(tr, fx, true);
}
}
// Restores per-FX offline from the snapshot verbatim — each slot back to its
// captured value, never a blanket "online". Bounds-checked against the live FX
// count in case the plugin chain changed while parked (prune-safe).
void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
int fxCount = TrackFX_GetCount(tr);
for (const FxOfflineOp& op : fxOffline) {
if (op.fxIndex < 0 || op.fxIndex >= fxCount) continue;
TrackFX_SetOffline(tr, op.fxIndex, op.offline);
}
}
} // namespace
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
// Reject an unregistered target before touching the project (no partial apply).
if (!model.modes().contains(targetModeId)) {
return false;
}
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
FolderTree tree = buildFolderTree(entries);
TogglePlan plan = model.planToggle(tree, targetModeId);
Undo_BeginBlock2(proj);
// PARK: snapshot BEFORE mutating, store into the model (so restore survives a
// save-while-parked), then apply the park writes + expand the FX-offline loop.
for (const TrackPlan& tp : plan.park) {
// Every op in a TrackPlan targets the same track; take the guid from the
// first flag op (the pure park plan always emits the four flag ops).
if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(handleByGuid, guid);
if (!tr) continue; // stale GUID — prune
model.storeSnapshot(guid, snapshotTrack(tr));
applyFlags(tr, tp.flags);
parkFxOffline(tr);
}
// RESTORE: apply the snapshot-sourced flag + per-FX offline writes verbatim,
// then drop the now-consumed snapshot so a re-park recaptures fresh state.
for (const TrackPlan& tp : plan.restore) {
if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(handleByGuid, guid);
if (!tr) continue; // stale GUID — prune
applyFlags(tr, tp.flags);
restoreFxOffline(tr, tp.fxOffline);
model.clearSnapshot(guid);
}
model.setActiveMode(targetModeId);
Undo_EndBlock2(proj, "ReaSampler: apply Design View mode", -1);
return true;
}
} // namespace reasampler
+50
View File
@@ -0,0 +1,50 @@
#pragma once
// view — the REAPER-facing shell of the Design View feature (Phase D2). It is the
// mirror of the capture shell: the ViewModeModel (pure, D1) holds the mode/
// membership/snapshot state and emits the toggle plan; this shell reads the live
// project's folder tree, snapshots the tracks it is about to park, runs the model's
// planner, and applies the resulting flag + per-FX-offline writes to REAPER.
//
// It includes view_mode_model (pure) but NO REAPER headers — the .cpp is the one
// REAPER-facing translation unit (CLAUDE.md §contract: only main.cpp defines the
// API pointers; every other .cpp gets them extern). Callers (persist, actions)
// depend on this seam without dragging the SDK into their include sites.
//
// Hard invariants this shell enforces (CONTEXT.md §Design View, precision
// invariants) — verified in self-review, never crossed:
// * Never touches the master track's visibility (SDK forbids B_SHOWINTCP/
// B_SHOWINMIXER on master); the master is never a node in the tree.
// * Never reads or writes B_MUTE / I_SOLO on any track.
// * Acts only on tracks the model owns (tagged leaves the planner names) plus
// derived parents' visibility — never an untagged track's owned flags.
// * Snapshots every to-be-parked track's prior flags BEFORE parking, storing
// them into the model so restore is faithful and survives a save-while-parked.
#include <string>
#include "view_mode_model.h"
// REAPER's opaque project handle. Forward-declared to keep this header SDK-free;
// the .cpp includes reaper_plugin_functions.h and sees the real class.
class ReaProject;
namespace reasampler {
// Applies `targetModeId` to the live project `proj`:
// 1. Reads the arrange-ordered track list, builds the FolderTree from
// I_FOLDERDEPTH (via the pure buildFolderTree helper).
// 2. Runs model.planToggle(tree, targetModeId).
// 3. For each track about to be PARKED: snapshots its current B_SHOWINTCP /
// B_SHOWINMIXER / B_MAINSEND / I_FXEN and per-FX offline state, stores the
// snapshot into the model, THEN applies the park writes (expanding the
// per-FX offline loop from TrackFX_GetCount, which the pure plan leaves empty).
// 4. For each track to RESTORE: applies the plan's snapshot-sourced flag + per-FX
// offline writes verbatim.
// 5. Sets the model's active mode to `targetModeId`.
// All track mutations are wrapped in Undo_BeginBlock2 / Undo_EndBlock2.
//
// Returns false (no mutation, active mode unchanged) if `targetModeId` is not a
// registered mode. `proj` may be nullptr to mean REAPER's current project.
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
} // namespace reasampler
+41
View File
@@ -0,0 +1,41 @@
// view_tree — pure folder-depth walk. See view_tree.h.
#include "view_tree.h"
namespace reasampler {
FolderTree buildFolderTree(const std::vector<TrackFolderEntry>& entries) {
FolderTree tree;
tree.nodes.reserve(entries.size());
// Stack of currently-open folder-parent GUIDs. The top is the immediate parent
// of the next track. A folder-parent track opens its folder AFTER contributing
// its own node (its own parent is the enclosing folder), so the push trails the
// assignment. A closing track belongs to the folder it closes, so the pop also
// trails the assignment.
std::vector<std::string> open;
for (const TrackFolderEntry& e : entries) {
FolderNode node;
node.guid = e.guid;
node.parentGuid = open.empty() ? std::string{} : open.back();
node.isParent = e.folderDepth == 1;
tree.nodes.push_back(node);
if (e.folderDepth == 1) {
open.push_back(e.guid); // this track's folder opens for what follows
} else if (e.folderDepth < 0) {
// Closes |folderDepth| levels after this (already-assigned) track.
// Clamp to the stack size so a malformed/stale depth stream can't
// underflow — the walk stays total.
int levels = -e.folderDepth;
while (levels-- > 0 && !open.empty()) {
open.pop_back();
}
}
}
return tree;
}
} // namespace reasampler
+33
View File
@@ -0,0 +1,33 @@
#pragma once
// view_tree — the ONE genuinely pure piece of the D2 view shell: turning REAPER's
// linear I_FOLDERDEPTH stream into the parent<->child FolderTree the pure model
// consumes. The REAPER reads (GetTrack / GetTrackGUID / I_FOLDERDEPTH) stay in
// view.cpp; this tree arithmetic is REAPER-free so the fiddly folder-depth walk is
// unit-tested outside the DAW (mirrors capture_paths splitting the path math out).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library + view_mode_model.h (for FolderTree) only.
#include <string>
#include <vector>
#include "view_mode_model.h"
namespace reasampler {
// One track's contribution to the folder walk, read from REAPER in arrange order.
// folderDepth is I_FOLDERDEPTH verbatim: 0 = normal, 1 = folder parent (opens a
// folder after this track), <0 = closes |folderDepth| folder levels after this
// track (-1 last in innermost, -2 last in innermost + next-innermost, ...).
struct TrackFolderEntry {
std::string guid;
int folderDepth = 0;
};
// Walks the ordered entries, tracking the open-folder stack, and assigns each
// node its immediate parentGuid (empty = top level) and isParent (opens a folder).
// Pure and total: tolerates malformed depth streams (a close deeper than the stack
// is clamped to empty) so a corrupt/stale project can never fault the shell.
FolderTree buildFolderTree(const std::vector<TrackFolderEntry>& entries);
} // namespace reasampler