diff --git a/CMakeLists.txt b/CMakeLists.txt index 3c57852..9cf4104 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,17 @@ target_include_directories(bank_grid PUBLIC src) add_library(view_mode_model STATIC src/view_mode_model.cpp) target_include_directories(view_mode_model PUBLIC src) +# --------------------------------------------------------------------------- +# 2d) Pure view_tree library — NO REAPER, NO SWELL. The one testable-outside-DAW +# piece of the D2 view shell: turning REAPER's linear I_FOLDERDEPTH stream into +# the parent<->child FolderTree the model consumes. The REAPER reads stay in +# view.cpp; this fiddly folder-depth walk is unit-tested here (mirrors +# capture_paths splitting the path math out of the capture shell). +# --------------------------------------------------------------------------- +add_library(view_tree STATIC src/view_tree.cpp) +target_include_directories(view_tree PUBLIC src) +target_link_libraries(view_tree PUBLIC view_mode_model) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -78,6 +89,10 @@ add_executable(view_mode_model_tests tests/test_view_mode_model.cpp) target_link_libraries(view_mode_model_tests PRIVATE view_mode_model) add_test(NAME view_mode_model_tests COMMAND view_mode_model_tests) +add_executable(view_tree_tests tests/test_view_tree.cpp) +target_link_libraries(view_tree_tests PRIVATE view_tree) +add_test(NAME view_tree_tests COMMAND view_tree_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -98,6 +113,9 @@ add_library(reaper_reasampler MODULE src/persist.cpp src/bank_panel.cpp ${LICE_SRC} + src/view_mode_model.cpp + src/view_tree.cpp + src/view.cpp ) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/view.cpp b/src/view.cpp new file mode 100644 index 0000000..400a25b --- /dev/null +++ b/src/view.cpp @@ -0,0 +1,210 @@ +// 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 +#include +#include + +#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 readFolderEntries( + ReaProject* proj, + std::vector>& handleByGuid) { + std::vector entries; + int count = CountTracks(proj); + entries.reserve(static_cast(count)); + handleByGuid.reserve(static_cast(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(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH")); + entries.push_back(TrackFolderEntry{guid, depth}); + handleByGuid.emplace_back(guid, tr); + } + return entries; +} + +MediaTrack* resolve(const std::vector>& 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(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP")); + snap.showInMixer = static_cast(GetMediaTrackInfo_Value(tr, "B_SHOWINMIXER")); + snap.mainSend = static_cast(GetMediaTrackInfo_Value(tr, "B_MAINSEND")); + snap.fxEnable = static_cast(GetMediaTrackInfo_Value(tr, "I_FXEN")); + + int fxCount = TrackFX_GetCount(tr); + snap.fxOffline.reserve(static_cast(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& flags) { + for (const TrackFlagOp& op : flags) { + SetMediaTrackInfo_Value(tr, flagParm(op.flag), static_cast(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). +// +// HAZARD (deferred, PLAN "reconcile on delete/restructure"): the remap is by +// slot INDEX, not plugin identity. If the FX chain changed while the track was +// parked, snapshot slot k is restored onto whatever plugin now occupies slot k — +// the bounds-check guards against out-of-range, not against a reshuffled chain. +// Acceptable for D2; full identity-based reconciliation is future hardening. +void restoreFxOffline(MediaTrack* tr, const std::vector& 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> handleByGuid; + std::vector 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); + } + + // PARENT VISIBILITY (derived, never parked): a folder is visible iff at least + // one of its descendant leaves is visible in the target mode. That derivation is + // pure (membership + active mode), so it is recomputed every toggle rather than + // snapshotted — the four leaf-park flags don't apply to parents. Drive only the + // two visibility flags; never touch B_MAINSEND/I_FXEN/FX-offline on a parent. + std::set visible = model.visibleTracks(tree, targetModeId); + for (const FolderNode& node : tree.nodes) { + if (!node.isParent) continue; + MediaTrack* tr = resolve(handleByGuid, node.guid); + if (!tr) continue; // stale GUID — prune + double show = visible.count(node.guid) ? 1.0 : 0.0; + SetMediaTrackInfo_Value(tr, "B_SHOWINTCP", show); + SetMediaTrackInfo_Value(tr, "B_SHOWINMIXER", show); + } + + model.setActiveMode(targetModeId); + + Undo_EndBlock2(proj, "ReaSampler: apply Design View mode", -1); + return true; +} + +} // namespace reasampler diff --git a/src/view.h b/src/view.h new file mode 100644 index 0000000..1b1d248 --- /dev/null +++ b/src/view.h @@ -0,0 +1,53 @@ +#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 + +#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. For each PARENT (folder) node: drives B_SHOWINTCP / B_SHOWINMIXER to 1 if the +// parent is in model.visibleTracks(tree, targetModeId), else 0 — derived from +// membership, never parked/snapshotted. Only the two visibility flags. +// 6. 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 diff --git a/src/view_tree.cpp b/src/view_tree.cpp new file mode 100644 index 0000000..3f5a966 --- /dev/null +++ b/src/view_tree.cpp @@ -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& 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 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 diff --git a/src/view_tree.h b/src/view_tree.h new file mode 100644 index 0000000..f349d84 --- /dev/null +++ b/src/view_tree.h @@ -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 +#include + +#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& entries); + +} // namespace reasampler diff --git a/tests/test_view_tree.cpp b/tests/test_view_tree.cpp new file mode 100644 index 0000000..f52c131 --- /dev/null +++ b/tests/test_view_tree.cpp @@ -0,0 +1,119 @@ +// Standalone tests for reasampler::buildFolderTree — no REAPER, no framework. +// The D2 view shell is DAW-bound and only verifiable in REAPER; this covers the +// one genuinely pure piece: the I_FOLDERDEPTH walk that turns REAPER's linear +// track stream into the parent<->child FolderTree the pure model consumes. + +#include "../src/view_tree.h" + +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// Find a node by guid; returns nullptr if absent. +static const FolderNode* nodeOf(const FolderTree& t, const std::string& guid) { + for (const auto& n : t.nodes) + if (n.guid == guid) return &n; + return nullptr; +} + +// All top-level leaves: three depth-0 tracks, no folders. Every parentGuid empty, +// nothing is a parent, order preserved. +static void testFlatLeaves() { + FolderTree t = buildFolderTree({{"a", 0}, {"b", 0}, {"c", 0}}); + CHECK(t.nodes.size() == 3); + CHECK(t.nodes[0].guid == "a" && t.nodes[1].guid == "b" && t.nodes[2].guid == "c"); + for (const auto& n : t.nodes) { + CHECK(n.parentGuid.empty()); + CHECK(!n.isParent); + } +} + +// One folder: parent P (depth 1) contains child C, which closes the folder (-1). +// P is a parent and is itself top-level; C's parent is P. +static void testSingleFolder() { + FolderTree t = buildFolderTree({{"P", 1}, {"C", -1}}); + const FolderNode* p = nodeOf(t, "P"); + const FolderNode* c = nodeOf(t, "C"); + CHECK(p && c); + CHECK(p->isParent); + CHECK(p->parentGuid.empty()); // the folder parent is itself top-level + CHECK(!c->isParent); + CHECK(c->parentGuid == "P"); // child sits under the folder +} + +// Folder with two children; a following top-level leaf must land back at the root, +// proving the close (-1) popped the folder off the stack. +static void testFolderThenSibling() { + FolderTree t = buildFolderTree({{"P", 1}, {"C1", 0}, {"C2", -1}, {"S", 0}}); + CHECK(nodeOf(t, "C1")->parentGuid == "P"); + CHECK(nodeOf(t, "C2")->parentGuid == "P"); + CHECK(nodeOf(t, "S")->parentGuid.empty()); // popped back to root +} + +// Nested folders: P > Q > leaf. The inner leaf closes BOTH folders at once (-2). +// A sibling after it returns to the root. +static void testNestedDeepClose() { + FolderTree t = buildFolderTree({{"P", 1}, {"Q", 1}, {"L", -2}, {"S", 0}}); + CHECK(nodeOf(t, "P")->isParent && nodeOf(t, "P")->parentGuid.empty()); + CHECK(nodeOf(t, "Q")->isParent && nodeOf(t, "Q")->parentGuid == "P"); + CHECK(nodeOf(t, "L")->parentGuid == "Q"); // deepest leaf under Q + CHECK(!nodeOf(t, "L")->isParent); + CHECK(nodeOf(t, "S")->parentGuid.empty()); // both levels popped +} + +// Nested with a stepwise close: inner folder closes (-1), then a sibling at the +// OUTER level, then that folder closes (-1). Proves partial pops track correctly. +static void testNestedStepwiseClose() { + FolderTree t = buildFolderTree( + {{"P", 1}, {"Q", 1}, {"L", -1}, {"M", -1}}); + CHECK(nodeOf(t, "Q")->parentGuid == "P"); + CHECK(nodeOf(t, "L")->parentGuid == "Q"); // L inside inner folder Q + CHECK(nodeOf(t, "M")->parentGuid == "P"); // after Q closed, M is under P +} + +// Malformed stream: a close deeper than the open stack must clamp to empty, never +// underflow. A following leaf is still top-level and the walk stays total. +static void testMalformedCloseClamps() { + FolderTree t = buildFolderTree({{"P", 1}, {"C", -5}, {"S", 0}}); + CHECK(nodeOf(t, "C")->parentGuid == "P"); + CHECK(nodeOf(t, "S")->parentGuid.empty()); // clamped, not underflowed + CHECK(t.nodes.size() == 3); +} + +// Empty input yields an empty tree (no fault). +static void testEmpty() { + FolderTree t = buildFolderTree({}); + CHECK(t.nodes.empty()); +} + +// An empty folder (parent immediately closes itself, depth accounting P=1 then a +// child that both belongs to and closes it). Here P opens and the sole child +// closes; verifies a folder with exactly one leaf child. +static void testSingleChildFolder() { + FolderTree t = buildFolderTree({{"P", 1}, {"only", -1}}); + CHECK(nodeOf(t, "only")->parentGuid == "P"); + CHECK(nodeOf(t, "P")->isParent); +} + +int main() { + testFlatLeaves(); + testSingleFolder(); + testFolderThenSibling(); + testNestedDeepClose(); + testNestedStepwiseClose(); + testMalformedCloseClamps(); + testEmpty(); + testSingleChildFolder(); + + if (g_fail == 0) { + std::printf("view_tree: all tests passed\n"); + return 0; + } + std::printf("view_tree: %d checks FAILED\n", g_fail); + return 1; +}