Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green

This commit is contained in:
2026-07-28 20:48:56 -04:00
parent 67a41728f3
commit 847936f813
222 changed files with 2247 additions and 2079 deletions
+44
View File
@@ -0,0 +1,44 @@
// guid_diff implementation — pure set arithmetic for new-content detection. See
// guid_diff.h. No REAPER, no SWELL — std only.
#include "core/view/guid_diff.h"
#include <algorithm>
namespace reasampler::view {
std::vector<std::string> newGuids(const std::set<std::string>& previous,
const std::set<std::string>& current) {
std::vector<std::string> added;
// current \ previous. std::set iterates ascending, so set_difference yields a
// deterministic order without a separate sort.
for (const std::string& g : current) {
if (g.empty()) continue; // never tag a GUID-read failure
if (previous.count(g) == 0) added.push_back(g);
}
return added;
}
std::vector<std::string> GuidBaseline::observe(const std::set<std::string>& current) {
if (!primed_) {
// First poll after open/reset: establish the baseline, report nothing new so
// pre-existing content is NOT auto-tagged (it defaults to Arrange).
baseline_ = current;
primed_ = true;
return {};
}
std::vector<std::string> added = newGuids(baseline_, current);
// Advance the baseline to the full current set. Using `current` (not baseline_
// added) means a DELETED GUID drops out of the baseline too, so if REAPER later
// reuses that GUID for genuinely new content it is detected again — the baseline
// tracks the live set exactly, not a monotonic union.
baseline_ = current;
return added;
}
void GuidBaseline::reset() {
baseline_.clear();
primed_ = false; // next observe() re-baselines (first-poll guard re-armed)
}
} // namespace reasampler::view
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// guid_diff — the pure, REAPER-free core of the D2 Wave-2 new-content detection.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Unit-tested outside the DAW.
//
// The shell (bank_panel timer) reads REAPER's live track/item GUID set each tick;
// this module owns the DECISION of "which GUIDs are new since the last tick" and the
// first-poll-after-open guard so pre-existing content is never mass-tagged. Keeping
// this here — rather than in the shell — means the fiddly baseline/diff logic is
// unit-tested, mirroring how view_tree splits the folder-depth walk out of view.cpp.
//
// The shell then hands the "new since last tick" GUIDs to the pure autoTagNewContent
// (view_mode_model) to produce the membership writes.
#include <set>
#include <string>
#include <vector>
namespace reasampler::view {
// The GUIDs present in `current` but absent from `previous` — i.e. new since the
// previous poll. Order is the set's ascending order (deterministic; the caller does
// not depend on discovery order). Empty GUIDs are ignored (a GUID read failure at the
// shell boundary must never be tagged).
std::vector<std::string> newGuids(const std::set<std::string>& previous,
const std::set<std::string>& current);
// Tracks the live GUID set across polls for ONE project, implementing the
// first-poll-after-open guard: the first observation after a (re)start establishes a
// BASELINE and reports NOTHING new, so pre-existing content stays at its default
// (Arrange) rather than being mass-tagged. Every subsequent observe() returns only the
// GUIDs created since the prior observe().
//
// Project switches are handled by reset(): the shell detects a project change (the
// active ReaProject* / project GUID changed) and calls reset() so the next observe()
// re-baselines against the newly-opened project instead of diffing across two
// unrelated projects (which would spuriously "detect" the entire new project as new
// content, or miss content because a same-GUID collision looked pre-existing).
class GuidBaseline {
public:
// Observes the current live GUID set. On the FIRST call after construction or
// reset() this records the baseline and returns {} (nothing is "new" at open).
// On every later call it returns the GUIDs added since the previous call and
// advances the baseline to `current`. Empty GUIDs are ignored.
std::vector<std::string> observe(const std::set<std::string>& current);
// Re-arms the first-poll guard: the next observe() re-baselines and reports
// nothing new. Called on a project switch so detection never diffs across
// projects.
void reset();
// True until the first observe() after construction/reset — exposed for the shell
// to reason about (and for tests) about whether a baseline is established yet.
bool primed() const { return primed_; }
private:
std::set<std::string> baseline_;
bool primed_ = false; // false ⇒ next observe() sets the baseline
};
} // namespace reasampler::view
+51
View File
@@ -0,0 +1,51 @@
// lane_keys implementation — pure string convention, no REAPER. See lane_keys.h.
#include "core/view/lane_keys.h"
#include <cstring>
namespace reasampler::view {
namespace {
// Does `s` start with the managed-lane prefix?
bool hasManagedPrefix(const std::string& s) {
const std::size_t n = std::strlen(kManagedLanePrefix);
return s.size() >= n && s.compare(0, n, kManagedLanePrefix) == 0;
}
} // namespace
bool isManagedLaneName(const std::string& laneName) {
return hasManagedPrefix(laneName);
}
std::optional<std::string> managedLaneKey(const std::string& laneName) {
if (!hasManagedPrefix(laneName)) return std::nullopt; // manual/unnamed ⇒ no key
// The durable name IS the key (stable across ordinal renumber). Keeping the full
// prefixed name — rather than stripping to the mode id — means the key is globally
// unambiguous and the ownership index's mode field remains the single source of
// truth for which mode owns the lane.
return laneName;
}
std::string laneNameForMode(const std::string& modeId) {
return std::string(kManagedLanePrefix) + modeId;
}
std::optional<std::string> modeIdFromLaneName(const std::string& laneName) {
if (!hasManagedPrefix(laneName)) return std::nullopt; // manual/unnamed ⇒ no mode
const std::size_t n = std::strlen(kManagedLanePrefix);
if (laneName.size() == n) return std::nullopt; // prefix only, no mode suffix (illegal)
return laneName.substr(n);
}
bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName) {
// On a normal (non-fixed-lane) track there is no concept of a manual lane; the
// item follows the normal auto-tag rule.
if (!isFixedLaneTrack) return false;
// On a fixed-lane track: a managed lane (prefixed) is NOT manual; everything else
// — including the empty/unnamed lane that REAPER creates by default — IS manual
// (user-minted, off-limits to auto-tag and to the lane-drive path).
return !hasManagedPrefix(laneName);
}
} // namespace reasampler::view
+85
View File
@@ -0,0 +1,85 @@
#pragma once
// lane_keys — the pure, REAPER-free convention that maps a REAPER fixed lane's
// durable NAME (P_LANENAME:n) to the opaque lane-key the pure view_mode_model uses,
// and the managed/manual heuristic that rides on it.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL. std only.
// Unit-tested outside the DAW. The shell (view.cpp) reads each lane's P_LANENAME:n
// string from REAPER and asks this module whether the lane is tool-managed and what
// its stable lane-key is; the shell never re-derives the prefix rule itself.
//
// -- Design point #2 (lane-identity robustness) resolution --------------------
//
// REAPER exposes no durable per-lane GUID. The only lane identity is the ordinal
// I_FIXEDLANE, which REAPER RENUMBERS when lanes are reordered or deleted — so keying
// the ownership index by raw ordinal would silently corrupt managed/manual ownership
// on any reorder. REAPER DOES expose a writable, durable lane NAME (P_LANENAME:n) that
// travels with the lane across renumber. So the tool names each lane it mints with a
// stable, prefixed identity ("reasampler:<mode>") and keys the ownership index by that
// NAME, not the ordinal. On each apply the shell walks the track's lanes by current
// ordinal, reads each name, and reconciles ordinal<->laneKey — so a C_LANEPLAYS:N
// write always targets the lane's CURRENT ordinal for a given durable key even after a
// reorder. A lane WITHOUT the prefix was not minted by the tool: it is manual and
// off-limits (the fixed-lane analog of "never touch mute/solo").
//
// -- Design point #1 (manual-lane exemption) resolution -----------------------
//
// The SAME prefix rule is the manual/managed heuristic for auto-tag: an item on a lane
// whose name lacks the "reasampler:" prefix is on a manual lane and is EXEMPT from
// auto-tag. isManagedLaneName is the single predicate both the toggle-apply path and
// the new-content detection path consult, so the boundary is defined in one place and
// unit-tested.
#include <optional>
#include <string>
namespace reasampler::view {
// The prefix the tool stamps on every lane NAME it mints. A lane name carrying this
// prefix is a managed lane the tool created; any other name (or an empty/unnamed lane)
// is a user-minted manual lane. Stable-forever: changing it would strand the ownership
// of every lane in every already-saved project, so treat it like an action id string.
inline constexpr const char* kManagedLanePrefix = "reasampler:";
// True iff `laneName` is a tool-minted managed-lane name (carries kManagedLanePrefix).
// This is the load-bearing managed/manual predicate for BOTH design points #1 and #2.
bool isManagedLaneName(const std::string& laneName);
// The opaque lane-key the pure model keys by, for a lane with REAPER name `laneName`.
// For a managed lane the key IS the durable name (stable across ordinal renumber). For
// a manual/unnamed lane there is no managed key: returns std::nullopt so the caller
// treats the lane as manual (never driven, items on it exempt from auto-tag).
std::optional<std::string> managedLaneKey(const std::string& laneName);
// The lane NAME the tool mints for the lane owned by `modeId` (kManagedLanePrefix +
// modeId). The inverse of managedLaneKey for a managed lane: managedLaneKey(
// laneNameForMode(m)) == kManagedLanePrefix + m. Exposed for the Wave-3 lane-minting
// path and for tests; the apply path in this wave only READS names, but the round-trip
// contract is asserted here so minting and reading cannot drift.
std::string laneNameForMode(const std::string& modeId);
// The owning mode id encoded in a managed lane NAME — the suffix after the managed
// prefix. std::nullopt for a manual/unnamed lane (no managed prefix) or a name that is
// EXACTLY the prefix with no mode suffix (illegal — a managed lane always names a mode).
// The exact inverse of laneNameForMode: modeIdFromLaneName(laneNameForMode(m)) == m.
// Used by the load-time reconcile to recover managed ownership from REAPER's durable
// lane name (the source of truth for identity across sessions — design point #2).
std::optional<std::string> modeIdFromLaneName(const std::string& laneName);
// True iff an item on a fixed-lane track with the given lane name is on a MANUAL lane
// (i.e. exempt from auto-tag). The two inputs are:
// isFixedLaneTrack — whether the item's track has I_FREEMODE==2. On a normal
// (non-fixed-lane) track the concept of a "manual lane" does not
// apply; the item follows the normal auto-tag rule (return false).
// laneName — the durable P_LANENAME of the lane the item sits on. A lane
// that carries kManagedLanePrefix is a tool-minted managed lane
// (not manual); any other name — including empty (unnamed) — is
// a user-minted manual lane (exempt from auto-tag).
//
// This is the SINGLE predicate that governs BOTH the apply path (which lanes may be
// driven) and the auto-tag exemption path (which items are exempt). It is unit-tested
// here so both paths share exactly one definition; the shell supplies the two REAPER
// inputs (I_FREEMODE result, P_LANENAME string) and never re-derives this logic.
bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName);
} // namespace reasampler::view
+64
View File
@@ -0,0 +1,64 @@
// mode_switch — pure implementation. See mode_switch.h. NO REAPER / SWELL / vendor.
#include "core/view/mode_switch.h"
#include <cstddef>
namespace reasampler::view {
namespace {
// The left edge of segment i in a header of the given x-origin and width divided
// into `count` segments. Boundary i is x + (i * width) / count, so segment i spans
// [edge(i), edge(i+1)). Because every boundary is derived from the same formula,
// consecutive segments share an exact edge (no gap, no overlap) and edge(count)
// == x + width precisely. count assumed >= 1 by callers.
int segmentEdge(int x, int width, int i, int count) {
return x + (i * width) / count;
}
} // namespace
std::vector<SegmentRect> computeSegmentRects(const HeaderRect& header,
int segmentCount) {
std::vector<SegmentRect> rects;
if (segmentCount <= 0 || header.width <= 0) return rects;
rects.reserve(static_cast<std::size_t>(segmentCount));
for (int i = 0; i < segmentCount; ++i) {
const int left = segmentEdge(header.x, header.width, i, segmentCount);
const int right = segmentEdge(header.x, header.width, i + 1, segmentCount);
SegmentRect r;
r.x = left;
r.y = header.y;
r.width = right - left; // absorbs rounding; adjacent segments abut exactly
r.height = header.height;
rects.push_back(r);
}
return rects;
}
int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount) {
if (segmentCount <= 0 || header.width <= 0 || header.height <= 0) return -1;
// Reject anything outside the header band first (half-open bounds match the
// segment rects). Below the header is where the grid lives — the panel falls
// through to grid handling on a -1.
if (px < header.x || px >= header.x + header.width ||
py < header.y || py >= header.y + header.height)
return -1;
// Inside the band: find the segment whose [edge(i), edge(i+1)) contains px.
// Linear over N (N is tiny — one per mode); mirrors the boundary formula so the
// hit matches the drawn segment exactly.
for (int i = 0; i < segmentCount; ++i) {
const int left = segmentEdge(header.x, header.width, i, segmentCount);
const int right = segmentEdge(header.x, header.width, i + 1, segmentCount);
if (px >= left && px < right) return i;
}
// Guard: px == header.x + header.width would fail the < above but was already
// excluded by the band check. Any residual falls to -1 (defensive, unreachable).
return -1;
}
} // namespace reasampler::view
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include "core/ui/rect.h"
// mode_switch — the REAPER-free layout math behind the bank_panel's Design-View
// mode switch (Phase D, Wave 4 — D5). A segmented control `[ Arrange | Design ]`
// (N-mode general, one segment per registered mode) drawn in a fixed-height header
// strip at the top of the docked panel. The panel shell (bank_panel.cpp) owns the
// SWELL window, LICE drawing, and the live ViewModeModel read + mode activation —
// all REAPER-bound, DAW-verified. What is NOT DAW-bound — how N segments tile a
// header rectangle, and which segment a click lands in — lives here so it is
// unit-tested outside the DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER.
#include <vector>
namespace reasampler::view {
// The header strip the switch is drawn into, top-left origin (SWELL/LICE
// convention). (x, y) is the top-left corner; width/height are the strip extents.
// The panel reserves this at the top of its client area and offsets the grid below.
using HeaderRect = ui::Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// One segment's pixel rectangle within the header, top-left origin. These are the
// draw bounds for one mode's button; the panel draws the mode's display name inside
// it and lights it when it is the active mode.
using SegmentRect = ui::Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// Divides `header` into `segmentCount` equal segments left-to-right, in the caller's
// order (the panel passes modes in ordinal order). Returns exactly segmentCount
// rects. The division tiles the header EXACTLY: each segment's left edge is
// header.x + (i * width) / segmentCount, so integer rounding is absorbed at the
// boundaries — segments abut with no gap and no overlap, and the last segment
// reaches header.x + header.width precisely (individual widths may differ by one
// pixel when width does not divide evenly). Each segment inherits the header's full
// y/height. segmentCount <= 0 or a non-positive header width returns empty.
std::vector<SegmentRect> computeSegmentRects(const HeaderRect& header,
int segmentCount);
// Hit-tests a point (SWELL/LICE top-left client coords) against the segmented
// control laid out in `header` with `segmentCount` segments. Returns the index of
// the segment containing the point, or -1 for a miss: a point outside the header
// bounds entirely (including below it, where the grid lives), or when segmentCount
// <= 0. Half-open bounds [x, x+width) x [y, y+height) match computeSegmentRects, so
// adjacent segments never both claim a pixel and the point maps to the same segment
// the panel drew there.
int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount);
} // namespace reasampler::view
+803
View File
@@ -0,0 +1,803 @@
#include "core/view/view_mode_model.h"
#include <algorithm>
#include <cassert>
#include <set>
#include <utility>
#include "core/json/json.h"
#include "core/view/lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention
// view_mode_model implementation.
//
// JSON rides on the shared core/json lexical layer (Q-W1), mirroring bank_model.
// A compact writer
// plus a recursive-descent parser covers the field set: the mode registry, the
// GUID-keyed membership map, per-track snapshots (with a variable-length per-FX
// offline vector), and the active mode. Ints are emitted plainly; strings are
// escaped identically to bank_model so control chars and unicode survive.
namespace reasampler {
// Q-W1 interim: laneNameForMode lives in reasampler::view now; this god module
// re-namespaces in its own split wave.
using view::laneNameForMode;
// ---------------------------------------------------------------------------
// equality
// ---------------------------------------------------------------------------
bool Mode::operator==(const Mode& o) const {
return id == o.id && displayName == o.displayName && ordinal == o.ordinal;
}
// ---------------------------------------------------------------------------
// ModeRegistry
// ---------------------------------------------------------------------------
ModeRegistry::ModeRegistry() {
modes_.push_back(Mode{kArrangeModeId, "Arrange", 0});
modes_.push_back(Mode{kDesignModeId, "Design", 1});
}
bool ModeRegistry::add(const Mode& mode) {
if (mode.id.empty()) return false;
if (query(mode.id) != nullptr) return false; // ids are unique
modes_.push_back(mode);
// Keep ordinal order stable; std::stable_sort so equal ordinals keep insertion
// order (the tie-break documented in the header).
std::stable_sort(modes_.begin(), modes_.end(),
[](const Mode& a, const Mode& b) { return a.ordinal < b.ordinal; });
return true;
}
const Mode* ModeRegistry::query(const std::string& id) const {
for (const auto& m : modes_)
if (m.id == id) return &m;
return nullptr;
}
// ---------------------------------------------------------------------------
// MembershipIndex
// ---------------------------------------------------------------------------
bool MembershipIndex::tag(const std::string& guid, const std::string& modeId) {
if (guid.empty() || modeId.empty()) return false;
Membership& m = entries_[guid];
m.modeIds.clear(); // a leaf lives in exactly one mode (show-both aside)
m.modeIds.insert(modeId);
return true;
}
bool MembershipIndex::untag(const std::string& guid) {
return entries_.erase(guid) > 0;
}
bool MembershipIndex::setShowBoth(const std::string& guid, bool showBoth) {
if (guid.empty()) return false;
entries_[guid].showBoth = showBoth; // creates an Arrange-default entry if new
return true;
}
bool MembershipIndex::restore(const std::string& guid, const Membership& membership) {
if (guid.empty()) return false;
entries_[guid] = membership;
return true;
}
const Membership* MembershipIndex::query(const std::string& guid) const {
auto it = entries_.find(guid);
return it == entries_.end() ? nullptr : &it->second;
}
std::set<std::string> MembershipIndex::modesOf(const std::string& guid) const {
const Membership* m = query(guid);
return m ? m->modeIds : std::set<std::string>{};
}
// ---------------------------------------------------------------------------
// LaneOwnershipIndex
// ---------------------------------------------------------------------------
bool LaneOwnershipIndex::setManaged(const std::string& trackGuid, const std::string& laneKey,
const std::string& modeId) {
if (trackGuid.empty() || laneKey.empty() || modeId.empty()) return false;
entries_[LaneRef{trackGuid, laneKey}] = LaneOwnership{modeId};
return true;
}
bool LaneOwnershipIndex::setManual(const std::string& trackGuid, const std::string& laneKey) {
if (trackGuid.empty() || laneKey.empty()) return false;
entries_[LaneRef{trackGuid, laneKey}] = LaneOwnership{std::nullopt};
return true;
}
bool LaneOwnershipIndex::remove(const std::string& trackGuid, const std::string& laneKey) {
return entries_.erase(LaneRef{trackGuid, laneKey}) > 0;
}
const LaneOwnership* LaneOwnershipIndex::query(const std::string& trackGuid,
const std::string& laneKey) const {
auto it = entries_.find(LaneRef{trackGuid, laneKey});
return it == entries_.end() ? nullptr : &it->second;
}
int laneModeState(const std::string& managedMode, const std::string& activeMode) {
// The active mode's lane plays exclusively; every other managed lane is silenced
// and hidden (C_LANEPLAYS = 0). Exclusive membership: only one stance's lane at a
// time. Show-both, which keeps a lane audible across modes, is a per-lane opt-out
// the shell layers on; the default per-mode decision here is exclusive.
//
// EXCLUSIVITY ASSUMPTION (one managed lane per mode per track): the model assumes a
// given (track, mode) owns AT MOST ONE managed lane. C_LANEPLAYS=1 means "this lane
// plays EXCLUSIVELY" — two lanes on the same track both claiming mode M would both
// be told to play exclusively on M's toggle, which REAPER cannot honor coherently
// (the last write wins in the DAW). The Wave-3 lane-minting path is responsible for
// upholding one-lane-per-(track,mode); planToggle asserts it in debug builds.
return managedMode == activeMode ? kLanePlaysExclusive : kLaneSilent;
}
// ---------------------------------------------------------------------------
// auto-tag decision
// ---------------------------------------------------------------------------
std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackGuids,
const std::vector<NewItem>& newItems,
const std::string& activeMode) {
std::vector<AutoTag> tags;
if (activeMode.empty()) return tags; // nothing to tag into
for (const auto& guid : newTrackGuids) {
if (guid.empty()) continue;
tags.push_back(AutoTag{guid, activeMode});
}
for (const auto& item : newItems) {
if (item.guid.empty()) continue;
if (item.onManualLane) continue; // manual-lane content is off-limits to auto-tag
// ADOPTION (strand guard): a new item on a track whose PRE-EXISTING content
// resolves to exactly one mode adopts THAT mode, so a drop onto a track already
// showing content never pushes it multi-mode and never triggers a lane split that
// would silence the pre-existing, previously-visible items. A track with no prior
// content (empty trackModes) or one already carrying a deliberate multi-mode split
// (>1) falls back to the active-mode rule.
const std::string& target =
item.trackModes.size() == 1 ? *item.trackModes.begin() : activeMode;
tags.push_back(AutoTag{item.guid, target});
}
return tags;
}
std::vector<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& selected,
const std::string& targetMode) {
std::vector<ItemRetagOp> ops;
const bool untag = targetMode.empty(); // empty target ⇒ untag (→ Arrange default)
for (const RetagItem& item : selected) {
if (item.guid.empty()) continue; // defensive; a real item always has a GUID
if (item.onManualLane) continue; // manual-lane item is EXEMPT — never retagged
ops.push_back(ItemRetagOp{item.guid, untag, untag ? std::string{} : targetMode});
}
return ops;
}
// ---------------------------------------------------------------------------
// lane minting decision
// ---------------------------------------------------------------------------
LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree,
const std::vector<LaneTrack>& tracks) {
LaneMintPlan plan;
// Precompute, per track GUID, the count of modes it is VISIBLE in and the set of
// those mode ids — tree-aware, so a content-bearing folder's DERIVED visibility
// (visibleTracks marks a parent visible in every mode a descendant is visible in)
// is captured, not only the track's own item mode-span. This is the visibility
// trigger source (b): a folder derived-visible in >= 2 modes must lane-separate its
// own media even when that media is single-mode. Computed once for all tracks.
std::map<std::string, std::set<std::string>> visibleModesOf;
for (const Mode& mode : model.modes().all()) {
const std::set<std::string> vis = model.visibleTracks(tree, mode.id);
for (const std::string& guid : vis)
visibleModesOf[guid].insert(mode.id);
}
for (const LaneTrack& track : tracks) {
if (track.trackGuid.empty()) continue;
// SHOW-BOTH escape hatch: never force-split. A show-both track is visible in
// every mode ON PURPOSE and its content is meant to play across all of them, so
// neither the visibility trigger nor the own-item-span trigger confines it. Skip
// it entirely (no split/mint/assign) so its items stay cross-mode-visible.
if (model.membership().isShowBoth(track.trackGuid)) continue;
// Collect the DISTINCT modes the track's managed-eligible OWN items belong to, in
// deterministic (sorted) order so the mint list and lane count are stable across
// runs (a set orders by mode id). Items on a manual lane are EXEMPT — never
// counted toward the multi-mode test and never reassigned (the managed-only
// invariant, upheld at the source of the decision).
std::set<std::string> ownItemModes;
for (const LaneItem& item : track.items) {
if (item.guid.empty() || item.modeId.empty()) continue;
if (item.onManualLane) continue; // exempt — user's hand-managed lane
ownItemModes.insert(item.modeId);
}
// A track with NO managed-eligible own media never splits: there is nothing to
// confine (lane separation projects OWN items across modes). A folder derived-
// visible in many modes but carrying no own content stays whole-track visibility-
// only (D1 parent handling) — this guards the "carries its own media" clause.
if (ownItemModes.empty()) continue;
// The two visibility sources, OR'd:
// (a) own items span >= 2 modes (W3-A trigger), and
// (b) the track is derived-visible in >= 2 modes (the folder-media case).
// A track qualifies for a split if EITHER makes it multi-mode.
const auto visIt = visibleModesOf.find(track.trackGuid);
const std::size_t visibleModeCount =
visIt == visibleModesOf.end() ? 0 : visIt->second.size();
const bool multiMode = ownItemModes.size() >= 2 || visibleModeCount >= 2;
// Single-mode (visible in exactly one mode, own items single-mode): whole-track
// parking (D1) still separates the stances. NO split, NO mint, NO assignment —
// this is the load-bearing "don't lane-split single-mode tracks" rule.
if (!multiMode) continue;
// Lazy-mint: lanes to mint = ONLY the modes the track's OWN items actually occupy —
// never an empty reserved lane for a mode the track is merely derived-visible in.
// A folder whose own item is Design-only but which is derived-visible in Arrange too
// mints a Design lane ONLY (holding the item); it mints NO Arrange lane. Confinement
// still holds: with only a Design lane present, toggling to Arrange drives that lane's
// C_LANEPLAYS to 0 (it hides+silences) and no lane plays, so the track reads as an
// empty normal track — the Design item does not leak. The Arrange lane is minted on
// demand the moment an Arrange item first lands (a later mint tick sees ownItemModes
// gain Arrange). The visibility trigger above still decides WHETHER to split; it no
// longer inflates WHICH lanes are minted.
const std::set<std::string>& laneModes = ownItemModes;
// Transition to lane-split: one managed lane per own-content mode (durable key =
// laneNameForMode(mode)), owned by that mode.
plan.splits.push_back(LaneMintPlan::TrackSplit{
track.trackGuid, static_cast<int>(laneModes.size())});
for (const std::string& mode : laneModes) {
plan.mints.push_back(
LaneMint{track.trackGuid, laneNameForMode(mode), mode});
}
// Assign EVERY managed-eligible OWN item onto its tagged mode's lane — including
// the pre-existing single-mode items, so a folder carrying one own Design item
// while derived-visible in Arrange still lanes that item to the Design lane (it
// then hides+silences whenever Arrange is active — the exact failing-case fix).
for (const LaneItem& item : track.items) {
if (item.guid.empty() || item.modeId.empty()) continue;
if (item.onManualLane) continue; // exempt — never reassigned
plan.assigns.push_back(LaneAssign{
item.guid, track.trackGuid, laneNameForMode(item.modeId)});
}
}
return plan;
}
// ---------------------------------------------------------------------------
// planner helpers
// ---------------------------------------------------------------------------
TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
// Parking contract: hide both panels, out of the mix, FX bypassed, every FX
// offline. All fixed zeros — park never consults a snapshot.
TrackPlan p;
p.flags = {
{guid, Flag::ShowInTcp, 0},
{guid, Flag::ShowInMixer, 0},
{guid, Flag::MainSend, 0},
{guid, Flag::FxEnable, 0},
};
for (int i = 0; i < fxCount; ++i)
p.fxOffline.push_back({guid, i, true});
return p;
}
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
// Restore contract: every driven flag returns to its SNAPSHOTTED value — never
// a hardcoded "on"/default. A flag captured at 0 restores to 0.
TrackPlan p;
p.flags = {
{guid, Flag::ShowInTcp, snap.showInTcp},
{guid, Flag::ShowInMixer, snap.showInMixer},
{guid, Flag::MainSend, snap.mainSend},
{guid, Flag::FxEnable, snap.fxEnable},
};
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i)
p.fxOffline.push_back({guid, static_cast<int>(i), snap.fxOffline[i] != 0});
return p;
}
std::string nextModeId(const ModeRegistry& modes, const std::string& currentModeId) {
const std::vector<Mode>& all = modes.all();
if (all.empty()) return {}; // nothing to cycle to
for (std::size_t i = 0; i < all.size(); ++i) {
if (all[i].id == currentModeId)
return all[(i + 1) % all.size()].id; // wrap past the last
}
// Active mode not in the registry (stale/unknown) — jump to the first mode as a
// sane home rather than returning "".
return all.front().id;
}
// ---------------------------------------------------------------------------
// ViewModeModel
// ---------------------------------------------------------------------------
ViewModeModel::ViewModeModel() : activeModeId_(kArrangeModeId) {}
bool ViewModeModel::setActiveMode(const std::string& modeId) {
if (!modes_.contains(modeId)) return false;
activeModeId_ = modeId;
return true;
}
void ViewModeModel::storeSnapshot(const std::string& guid, const TrackSnapshot& snap) {
snapshots_[guid] = snap;
}
void ViewModeModel::clearSnapshot(const std::string& guid) {
snapshots_.erase(guid);
}
const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
auto it = snapshots_.find(guid);
return it == snapshots_.end() ? nullptr : &it->second;
}
std::size_t ViewModeModel::reconcile(const std::set<std::string>& liveGuids) {
// Prune snapshots for GUIDs the project no longer contains (see header for the
// deliberate snapshot-yes / membership-no asymmetry and the undo-delete rationale).
std::size_t removed = 0;
for (auto it = snapshots_.begin(); it != snapshots_.end();) {
if (liveGuids.count(it->first) == 0) {
it = snapshots_.erase(it);
++removed;
} else {
++it;
}
}
return removed;
}
bool ViewModeModel::leafBelongsToMode(const std::string& guid, const std::string& modeId) const {
const Membership* m = membership_.query(guid);
if (!m) return modeId == kArrangeModeId; // untagged ⇒ Arrange default
if (m->showBoth) return true; // show-both ⇒ every mode
if (m->modeIds.empty()) return modeId == kArrangeModeId; // show-both-cleared, no mode
return m->modeIds.count(modeId) > 0;
}
std::set<std::string> ViewModeModel::visibleTracks(const FolderTree& tree,
const std::string& modeId) const {
std::set<std::string> visible;
// Pass 1: every node — leaf OR parent — that belongs to the mode by its OWN
// membership is visible. For a leaf this is the tagged/show-both/untagged-Arrange
// rule; for a parent it means an untagged folder (which carries its own FX/media
// and defaults to Arrange) shows in Arrange even when none of its children do.
// Parents ALSO become visible in pass 2 by derivation from a visible descendant;
// the two rules are OR'd, so an untagged folder of all-Design leaves shows in both
// Arrange (own default) and Design (derived).
for (const auto& node : tree.nodes) {
if (leafBelongsToMode(node.guid, modeId))
visible.insert(node.guid);
}
// Pass 2: a parent is also visible if any descendant is visible. Walk each
// currently-visible node up its parent chain and mark ancestors. Seeding from the
// full pass-1 set means a parent made visible by its own membership propagates its
// visibility up the remaining ancestors too. Parent chains are read from the
// supplied tree only (no REAPER access). A cycle-guard bounds the walk in case a
// malformed tree links a node to itself.
std::map<std::string, std::string> parentOf;
for (const auto& node : tree.nodes) parentOf[node.guid] = node.parentGuid;
// Snapshot the pass-1 visible set so we don't re-walk parents we add mid-loop.
const std::vector<std::string> seeds(visible.begin(), visible.end());
for (const auto& node : seeds) {
auto it = parentOf.find(node);
std::size_t guard = 0;
while (it != parentOf.end() && !it->second.empty() && guard++ < parentOf.size()) {
const std::string& parent = it->second;
if (!visible.insert(parent).second) break; // already marked ⇒ chain done
it = parentOf.find(parent);
}
}
return visible;
}
TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& targetMode) const {
TogglePlan plan;
// The mode system manages EVERY leaf, not just tagged ones. An untagged leaf is
// an Arrange member (leafBelongsToMode resolves that), so it must park when the
// target mode is not Arrange and restore when it is — the same full park/restore
// a tagged leaf gets. Enumerating the FolderTree (not membership_.all()) is what
// brings untagged leaves — which are absent from the membership index — under
// management. Parents are visibility-only (handled by visibleTracks + the shell's
// parent-visibility pass) and show-both leaves are the always-visible escape;
// neither is ever parked.
for (const auto& node : tree.nodes) {
if (node.isParent) continue; // parents are derived, never parked
const std::string& guid = node.guid;
if (membership_.isShowBoth(guid)) continue; // show-both leaves are never parked
const bool active = leafBelongsToMode(guid, targetMode);
if (active) {
// Returning to visibility: restore from snapshot if we have one. No
// snapshot ⇒ the track was never parked, nothing to restore.
if (const TrackSnapshot* snap = snapshot(guid))
plan.restore.push_back(makeRestorePlan(guid, *snap));
} else {
// Inactive leaf (tagged into another mode, or untagged in a non-Arrange
// mode) ⇒ park. fxOffline is intentionally empty here: the D2 shell
// expands per-FX offline writes using TrackFX_GetCount. The pure model
// has no access to REAPER FX counts at plan time; makeParkPlan(guid, 0)
// emits only the scalar flags as a result.
plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0));
}
}
// D2 item-level projection: emit a C_LANEPLAYS op for every MANAGED lane. The
// active mode's lane plays exclusively; every other managed lane is silenced+hidden
// (laneModeState). MANUAL lanes are skipped entirely — the load-bearing invariant:
// a toggle never drives a lane the tool did not mint (the fixed-lane analog of
// "never touch mute/solo"). Lane ownership is not a tree property, so this walks the
// ownership index directly, not the FolderTree; a project with no fixed lanes leaves
// plan.lanes empty and the plan is byte-identical to a D1 plan.
#ifndef NDEBUG
// Debug-time guard for the one-managed-lane-per-mode-per-track exclusivity
// assumption (see laneModeState). Two managed lanes on the same track claiming the
// same mode would both be told to play exclusively on that mode's toggle, which
// REAPER cannot honor. Cheap set membership over the (usually tiny) managed-lane
// set; compiled out of release builds.
std::set<std::pair<std::string, std::string>> seenTrackMode; // (trackGuid, mode)
#endif
for (const auto& [ref, ownership] : lanes_.all()) {
if (!ownership.isManaged()) continue; // manual lanes are off-limits
#ifndef NDEBUG
assert(seenTrackMode.insert({ref.trackGuid, *ownership.managedMode}).second &&
"two managed lanes on one track claim the same mode (exclusivity broken)");
#endif
const int lanePlays = laneModeState(*ownership.managedMode, targetMode);
plan.lanes.push_back(LanePlayOp{ref.trackGuid, ref.laneKey, lanePlays});
}
return plan;
}
std::set<LaneRef> ViewModeModel::lanesTouchedByToggle() const {
// Managed-only: exactly the lanes a toggle is permitted to drive. A manual lane —
// absent OR recorded manual in the ownership index — is never returned, so the shell
// can never write C_LANEPLAYS to a lane the user hand-manages.
std::set<LaneRef> touched;
for (const auto& [ref, ownership] : lanes_.all()) {
if (ownership.isManaged()) touched.insert(ref);
}
return touched;
}
bool ViewModeModel::operator==(const ViewModeModel& o) const {
return modes_ == o.modes_ && membership_ == o.membership_ && lanes_ == o.lanes_ &&
activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_;
}
// ===========================================================================
// JSON — writer
// ===========================================================================
namespace {
// Shared core/json emit helpers (Q-W1): same escape set + %d rendering as the
// prior file-local writer, so the emitted blob is byte-identical.
using json::writeEscaped;
using json::writeIntArray;
std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer;
} // namespace
std::string ViewModeModel::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", intToStr(1));
root.keyStr("activeMode", activeModeId_);
// modes
root.keyBegin("modes");
out += '[';
{
const auto& all = modes_.all();
for (std::size_t i = 0; i < all.size(); ++i) {
if (i) out += ',';
ObjWriter m(out);
m.keyStr("id", all[i].id);
m.keyStr("displayName", all[i].displayName);
m.keyRaw("ordinal", intToStr(all[i].ordinal));
}
}
out += ']';
// membership: array of { guid, modes[], showBoth }
root.keyBegin("membership");
out += '[';
{
bool first = true;
for (const auto& [guid, mem] : membership_.all()) {
if (!first) out += ',';
first = false;
ObjWriter e(out);
e.keyStr("guid", guid);
e.keyBegin("modes");
out += '[';
{
bool mf = true;
for (const auto& id : mem.modeIds) {
if (!mf) out += ',';
mf = false;
writeEscaped(out, id);
}
}
out += ']';
e.keyRaw("showBoth", mem.showBoth ? "true" : "false");
}
}
out += ']';
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable, fxOffline[] }
root.keyBegin("snapshots");
out += '[';
{
bool first = true;
for (const auto& [guid, snap] : snapshots_) {
if (!first) out += ',';
first = false;
ObjWriter e(out);
e.keyStr("guid", guid);
e.keyRaw("showInTcp", intToStr(snap.showInTcp));
e.keyRaw("showInMixer", intToStr(snap.showInMixer));
e.keyRaw("mainSend", intToStr(snap.mainSend));
e.keyRaw("fxEnable", intToStr(snap.fxEnable));
e.keyBegin("fxOffline");
writeIntArray(out, snap.fxOffline);
}
}
out += ']';
// lanes: array of { trackGuid, laneKey, managed(bool), mode(str, managed only) }.
// A manual lane omits "mode"; managed carries the owning mode id. Emitting an
// explicit "managed" bool keeps a manual lane distinguishable from a managed lane
// whose mode string is (illegally) empty — the parser rejects the latter.
root.keyBegin("lanes");
out += '[';
{
bool first = true;
for (const auto& [ref, ownership] : lanes_.all()) {
if (!first) out += ',';
first = false;
ObjWriter e(out);
e.keyStr("trackGuid", ref.trackGuid);
e.keyStr("laneKey", ref.laneKey);
e.keyRaw("managed", ownership.isManaged() ? "true" : "false");
if (ownership.isManaged()) e.keyStr("mode", *ownership.managedMode);
}
}
out += ']';
} // root closes here (see bank_model note on NRVO + deferred close)
return out;
}
// ===========================================================================
// JSON — parser (recursive descent; false on any malformed input, never UB)
// ===========================================================================
namespace {
// The model DOMAIN grammar over the shared core/json lexical layer (Q-W1).
// The registry starts seeded (Arrange + Design). Deserialization must reproduce the
// serialized set exactly, so we replace the seeded contents with the parsed ones —
// add() dedups by id, so a serialized Arrange/Design would otherwise be rejected as
// duplicates and the ordinals/names would not round-trip. We therefore parse into a
// fresh vector and swap. `reg` is passed empty (see parseModel).
bool parseModes(json::Reader& r, ModeRegistry& reg) {
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true; // empty array (unusual, but valid)
do {
if (!r.consume('{')) return false;
Mode m;
bool haveId = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "id") { if (!r.parseString(m.id)) return false; haveId = true; }
else if (k == "displayName") { if (!r.parseString(m.displayName)) return false; }
else if (k == "ordinal") { if (!r.parseInt(m.ordinal)) return false; }
else if (!r.skipValue()) return false;
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || !reg.add(m)) return false; // malformed / duplicate id
} while (r.consume(','));
return r.consume(']');
}
bool parseMembership(json::Reader& r, MembershipIndex& idx) {
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true;
do {
if (!r.consume('{')) return false;
std::string guid;
Membership mem;
bool haveGuid = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
else if (k == "modes") {
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
std::string id;
if (!r.parseString(id)) return false;
mem.modeIds.insert(id);
} while (r.consume(','));
if (!r.consume(']')) return false;
}
}
else if (k == "showBoth") { if (!r.parseBool(mem.showBoth)) return false; }
else if (!r.skipValue()) return false;
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveGuid || guid.empty()) return false;
// Install the entry verbatim (tag() would clear a multi-mode set and drop
// show-both). A serialized entry is trusted to already satisfy the model's
// invariants.
//
// Deliberate tolerance: we do NOT validate that membership modeIds reference
// registered modes, and we do not validate snapshot GUIDs against the index.
// Stale-GUID and stale-mode tolerance is a stated invariant of this model —
// a deserialized entry is treated as trusted data, not as live cross-checked
// state. Rejecting stale entries here would violate that invariant. The one
// exception is activeMode (validated below in parseModel): a persisted active
// mode that no longer exists has an immediate behavioral consequence, so it
// is caught and the parse is rejected.
if (!idx.restore(guid, mem)) return false;
} while (r.consume(','));
return r.consume(']');
}
bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps) {
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true;
do {
if (!r.consume('{')) return false;
std::string guid;
TrackSnapshot snap;
bool haveGuid = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
else if (k == "showInTcp") { if (!r.parseInt(snap.showInTcp)) return false; }
else if (k == "showInMixer") { if (!r.parseInt(snap.showInMixer)) return false; }
else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; }
else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; }
else if (k == "fxOffline") { if (!r.parseIntArray(snap.fxOffline)) return false; }
else if (!r.skipValue()) return false;
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveGuid || guid.empty()) return false;
snaps[guid] = snap;
} while (r.consume(','));
return r.consume(']');
}
bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) {
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true;
do {
if (!r.consume('{')) return false;
std::string trackGuid, laneKey, mode;
bool haveTrack = false, haveLane = false, managed = false, haveManaged = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "trackGuid") { if (!r.parseString(trackGuid)) return false; haveTrack = true; }
else if (k == "laneKey") { if (!r.parseString(laneKey)) return false; haveLane = true; }
else if (k == "managed") { if (!r.parseBool(managed)) return false; haveManaged = true; }
else if (k == "mode") { if (!r.parseString(mode)) return false; }
else if (!r.skipValue()) return false;
} while (r.consume(','));
if (!r.consume('}')) return false;
// Both keys mandatory and non-empty (they form the lane's identity). A managed
// lane must carry a non-empty mode; a manual lane must not claim one. Enforcing
// this on parse keeps a round-tripped index byte-for-byte identical to the
// serialized one and rejects a malformed managed-without-mode entry.
if (!haveTrack || !haveLane || !haveManaged) return false;
if (trackGuid.empty() || laneKey.empty()) return false;
if (managed) {
if (mode.empty()) return false;
if (!idx.setManaged(trackGuid, laneKey, mode)) return false;
} else {
if (!mode.empty()) return false; // manual lane must not carry a mode
if (!idx.setManual(trackGuid, laneKey)) return false;
}
} while (r.consume(','));
return r.consume(']');
}
bool parseModel(json::Reader& r, ViewModeModel& out) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // lenient empty root ⇒ default-seeded model
ModeRegistry reg; // seeded default; REPLACED if a modes array is present
bool haveModes = false;
std::string activeMode;
bool haveActive = false;
MembershipIndex membership;
LaneOwnershipIndex lanes;
std::map<std::string, TrackSnapshot> snaps;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "activeMode") {
if (!r.parseString(activeMode)) return false;
haveActive = true;
} else if (key == "modes") {
ModeRegistry fresh = ModeRegistry::makeEmpty(); // parse into empty, then own
if (!parseModes(r, fresh)) return false;
reg = fresh;
haveModes = true;
} else if (key == "membership") {
if (!parseMembership(r, membership)) return false;
} else if (key == "snapshots") {
if (!parseSnapshots(r, snaps)) return false;
} else if (key == "lanes") {
if (!parseLanes(r, lanes)) return false;
} else {
// Unknown keys and the "version" field are skipped here.
// "version" is serialized as a forward-compat placeholder — there is no
// active version gate yet; all persisted data is parsed the same way
// regardless of the value. A future gate would add a version branch here.
if (!r.skipValue()) return false;
}
} while (r.consume(','));
if (!r.consume('}')) return false;
r.skipWs();
if (!r.eof()) return false; // trailing garbage
if (haveModes) out.modes() = reg;
out.membership() = membership;
out.lanes() = lanes;
for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap);
if (haveActive) {
if (!out.setActiveMode(activeMode)) return false; // active mode must exist
}
return true;
}
} // namespace
std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string& blob) {
ViewModeModel vm;
json::Reader r(blob);
if (!parseModel(r, vm)) return std::nullopt;
return vm;
}
} // namespace reasampler
+748
View File
@@ -0,0 +1,748 @@
#pragma once
// view_mode_model — the pure core of the Design View feature, deliberately free of any
// REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the mirror of
// bank_model: it owns the mode registry, the GUID-keyed membership index, the
// folder-tree-aware visibility derivation, the parking/restore planner, and the
// JSON round-trip of all of it.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The folder structure is an INPUT
// supplied by the D2 shell (which reads REAPER's I_FOLDERDEPTH); this model never
// fetches or stores REAPER's live tree — folder structure is REAPER's truth and
// changes underneath us, so it is passed in per query, not held.
//
// -- Representation decisions (design latitude exercised; invariants below) -----
//
// * A mode is (stable string id, display name, ordinal). Arrange (id "arrange",
// ordinal 0) and Design (id "design", ordinal 1) are seeded. Arrange is the
// fallback home for every untagged leaf; structurally it is just another mode.
//
// * Membership is GUID -> { mode ids } (a set, not a bool) plus a per-track
// show-both flag. Normally a leaf is in exactly one mode; multiple only via the
// parent-derivation rule (computed, not stored) or the show-both escape hatch.
// An untagged GUID is NOT in the index and belongs to Arrange by default.
//
// * The planner drives exactly four scalar flags (showInTcp, showInMixer,
// mainSend, fxEnable) plus a per-FX offline list. Park values are fixed zeros
// (defined by the parking contract), so PARK ops need no snapshot. RESTORE ops
// come entirely FROM a TrackSnapshot captured before parking — never a hardcoded
// default. This is where the restore-contract invariant lives and is tested.
//
// * The snapshot stores the full prior per-FX offline vector so a save-while-parked
// project round-trips and restores each FX to its exact prior offline state. The
// pure model does NOT need REAPER FX counts to plan a park (park offlines all N,
// which the shell expands from TrackFX_GetCount); it only needs them to restore,
// and it gets them from the snapshot it captured.
#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <vector>
namespace reasampler {
// Stable seed-mode ids. Arrange is the default home for untagged leaves.
inline constexpr const char* kArrangeModeId = "arrange";
inline constexpr const char* kDesignModeId = "design";
// A display "stance" the user adopts. Modes are ordered by `ordinal` for tab order.
struct Mode {
std::string id; // stable, persisted; never reused for a different mode
std::string displayName;
int ordinal = 0; // tab order
bool operator==(const Mode& o) const;
};
// Ordered registry of modes. Arrange + Design are seeded on construction. Add more
// to prove the model is N-mode, not boolean. Ids are unique; adding a duplicate id
// is rejected.
class ModeRegistry {
public:
ModeRegistry(); // seeds Arrange (ordinal 0) + Design (ordinal 1)
// Adds a mode. Rejects (returns false, no mutation) an empty or duplicate id.
bool add(const Mode& mode);
// Returns the mode with `id`, or nullptr. Invalidated by any mutating call.
const Mode* query(const std::string& id) const;
bool contains(const std::string& id) const { return query(id) != nullptr; }
// All modes in ordinal order (ties broken by insertion order).
const std::vector<Mode>& all() const { return modes_; }
std::size_t size() const { return modes_.size(); }
bool operator==(const ModeRegistry& o) const { return modes_ == o.modes_; }
// An empty registry (no seed modes). Deserialization parses the persisted mode
// set into this and then owns it; the default ctor's seed would otherwise make
// the serialized Arrange/Design collide on add() and fail to round-trip.
static ModeRegistry makeEmpty() { return ModeRegistry(EmptyTag{}); }
private:
struct EmptyTag {};
explicit ModeRegistry(EmptyTag) {} // no seed
std::vector<Mode> modes_; // kept sorted by ordinal, then insertion
};
// The membership record for one tagged leaf track, keyed externally by GUID.
struct Membership {
std::set<std::string> modeIds; // the mode(s) this leaf opted into
bool showBoth = false; // pinned visible + running in every mode
bool operator==(const Membership& o) const {
return modeIds == o.modeIds && showBoth == o.showBoth;
}
};
// -- Lane ownership (Phase D2 / two-canvas item-level projection) -------------
//
// D2 extends the track-level projection to the ITEM level via REAPER fixed lanes
// (I_FREEMODE=2). On a track shared by two stances, each mode owns a fixed lane; a
// toggle shows/plays only the active mode's lane. This is the item-visibility analog
// of D1's track parking, and it carries the same load-bearing guarantee:
//
// THE TOOL DRIVES ONLY WHAT IT MINTED. A fixed-lane track is also REAPER's native
// comping surface — a user may keep their OWN manual lanes (comp takes, alternate
// reads). Mode operations touch ONLY managed lanes; manual lanes are never shown,
// hidden, silenced, or re-laned, and their C_LANEPLAYS stays exactly as set. This
// is the fixed-lane analog of "never touch B_MUTE/I_SOLO" and "never touch master".
//
// LANE IDENTITY IS AN OPAQUE, STABLE KEY SUPPLIED BY THE SHELL (boundary). The pure
// index keys a lane by (track GUID + a lane key string). The lane key is an OPAQUE
// identifier the shell provides; this model does NOT assume lane ordinals are stable
// and bakes in NO I_FIXEDLANE renumber/reorder assumptions. Whether the shell derives
// the key from a raw I_FIXEDLANE ordinal or a more durable identity — and how it keeps
// the index from going stale across lane reorder/renumber/deletion — is a Wave-2 SHELL
// design point (CONTEXT.md §Lane-identity fragility). The pure model's only contract:
// the same lane key denotes the same lane across calls.
// One lane's ownership: managed by a specific mode, or manual (user-minted, outside
// the mode system). `managedMode` present ⇒ managed by that mode id; absent ⇒ manual.
struct LaneOwnership {
std::optional<std::string> managedMode; // set ⇒ managed by this mode; unset ⇒ manual
bool isManaged() const { return managedMode.has_value(); }
bool isManual() const { return !managedMode.has_value(); }
bool operator==(const LaneOwnership& o) const { return managedMode == o.managedMode; }
};
// A lane's composite key: (track GUID, opaque lane key). Ordered so it can key a map.
struct LaneRef {
std::string trackGuid;
std::string laneKey; // opaque, shell-supplied; NOT assumed to be a stable ordinal
bool operator<(const LaneRef& o) const {
if (trackGuid != o.trackGuid) return trackGuid < o.trackGuid;
return laneKey < o.laneKey;
}
bool operator==(const LaneRef& o) const {
return trackGuid == o.trackGuid && laneKey == o.laneKey;
}
};
// (track GUID, lane key) -> ownership. Managed lanes name their owning mode; manual
// lanes are user-minted and off-limits to every mode operation. GUID-keyed and
// portable, it rides in the "reasampler" view_state alongside the membership index.
// A lane ABSENT from the index has no recorded ownership — the model treats an absent
// lane as manual by default (the tool never minted it), so the managed-only guarantee
// holds even before the index is populated.
class LaneOwnershipIndex {
public:
// Records lane (trackGuid, laneKey) as MANAGED by `modeId`, replacing any prior
// ownership. Returns false if any argument is empty.
bool setManaged(const std::string& trackGuid, const std::string& laneKey,
const std::string& modeId);
// Records lane (trackGuid, laneKey) as MANUAL (user-minted), replacing any prior
// ownership. Returns false if trackGuid or laneKey is empty.
bool setManual(const std::string& trackGuid, const std::string& laneKey);
// Removes the lane from the index entirely (⇒ treated as manual-by-default again).
// Returns true if it was present.
bool remove(const std::string& trackGuid, const std::string& laneKey);
// The ownership for a lane, or nullptr if the lane has no recorded entry (⇒ manual
// by default). Invalidated by any mutating call.
const LaneOwnership* query(const std::string& trackGuid, const std::string& laneKey) const;
// True if the lane is recorded MANAGED (by any mode). A lane absent from the index
// is NOT managed (manual by default) — this is the load-bearing predicate the
// toggle planner and the "which lanes may this toggle touch" query gate on.
bool isManaged(const std::string& trackGuid, const std::string& laneKey) const {
const LaneOwnership* o = query(trackGuid, laneKey);
return o && o->isManaged();
}
const std::map<LaneRef, LaneOwnership>& all() const { return entries_; }
std::size_t size() const { return entries_.size(); }
bool empty() const { return entries_.empty(); }
bool operator==(const LaneOwnershipIndex& o) const { return entries_ == o.entries_; }
private:
std::map<LaneRef, LaneOwnership> entries_; // (guid, laneKey) -> ownership
};
// The play/show state a managed lane takes for a given active mode, matching REAPER's
// item/track-side C_LANEPLAYS values (SDK: 0=lane silent+hidden, 1=lane plays
// exclusively). A managed lane owned by the ACTIVE mode plays (1); every other managed
// lane is silenced+hidden (0) — consistent with exclusive membership and D1's "a mode
// flip is a real change, not cosmetic." Exposed as a free function for direct testing.
// managedMode == activeMode ⇒ 1 (plays exclusively)
// otherwise ⇒ 0 (does not play; hidden + silent)
// The caller must only pass MANAGED lanes here; manual lanes never reach this decision.
inline constexpr int kLanePlaysExclusive = 1; // C_LANEPLAYS: plays exclusively
inline constexpr int kLaneSilent = 0; // C_LANEPLAYS: does not play (hidden+silent)
int laneModeState(const std::string& managedMode, const std::string& activeMode);
// GUID-keyed membership index. Untagged GUIDs are absent and belong to Arrange.
// Keyed by track GUID string, never index (reorder-safe).
class MembershipIndex {
public:
// Tags `guid` into `modeId`, replacing any prior mode set (a leaf lives in one
// mode; use showBoth for the cross-mode case). No-op-safe on repeated calls.
// Returns false if guid or modeId is empty.
bool tag(const std::string& guid, const std::string& modeId);
// Removes `guid` from the index entirely (returns it to the Arrange default).
// Returns true if it was present.
bool untag(const std::string& guid);
// Sets the show-both flag for `guid`. Tags the guid into no new mode; if the
// guid is untagged it is created with an empty mode set (Arrange default) so
// show-both alone is representable. Returns false if guid is empty.
bool setShowBoth(const std::string& guid, bool showBoth);
// Installs a complete membership record verbatim (multi-mode set + show-both),
// replacing any existing entry for `guid`. Used by deserialization to rebuild a
// trusted, already-valid entry without tag()'s single-mode clobbering. Returns
// false if guid is empty.
bool restore(const std::string& guid, const Membership& membership);
// Returns the membership for `guid`, or nullptr if untagged. Invalidated by any
// mutating call.
const Membership* query(const std::string& guid) const;
bool isShowBoth(const std::string& guid) const {
const Membership* m = query(guid);
return m && m->showBoth;
}
// The mode ids `guid` belongs to. Empty for an untagged guid (⇒ Arrange).
std::set<std::string> modesOf(const std::string& guid) const;
const std::map<std::string, Membership>& all() const { return entries_; }
std::size_t size() const { return entries_.size(); }
bool empty() const { return entries_.empty(); }
bool operator==(const MembershipIndex& o) const { return entries_ == o.entries_; }
private:
std::map<std::string, Membership> entries_; // guid -> membership
};
// -- Folder tree (INPUT, not stored) ----------------------------------------
//
// The shell builds this from I_FOLDERDEPTH each time and passes it to a visibility
// query. A node is a leaf or a parent; a parent is visible in a mode if it belongs
// to that mode by its own membership OR any of its descendant leaves does, and is
// never parked. The master track is
// modeled implicitly (always visible, never touched) and is NOT a node here.
struct FolderNode {
std::string guid;
std::string parentGuid; // empty ⇒ top-level (child of master / project root)
bool isParent = false; // true if this node has descendant tracks (a folder)
};
// A flat parent↔child description of the current track tree. Order is arrange-view
// order; parentGuid links each node to its immediate parent folder.
struct FolderTree {
std::vector<FolderNode> nodes;
};
// -- Snapshot + planner ------------------------------------------------------
// The prior value of every tool-driven flag on one track, captured BEFORE parking.
// Restore uses these values verbatim — the restore contract's source of truth.
// Flags mirror REAPER's numeric representation (0/1 for the bools) so the shell
// applies them without translation; ints, not bools, so a snapshot faithfully
// round-trips whatever REAPER reported (defensive against non-0/1 values).
struct TrackSnapshot {
int showInTcp = 0; // B_SHOWINTCP prior value
int showInMixer = 0; // B_SHOWINMIXER prior value
int mainSend = 0; // B_MAINSEND prior value
int fxEnable = 0; // I_FXEN prior value
// Prior per-FX offline state, index = fx slot. Lets restore return each FX to
// exactly its captured offline value rather than a blanket "online".
std::vector<int> fxOffline;
bool operator==(const TrackSnapshot& o) const {
return showInTcp == o.showInTcp && showInMixer == o.showInMixer &&
mainSend == o.mainSend && fxEnable == o.fxEnable &&
fxOffline == o.fxOffline;
}
};
// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it is
// per-slot, variable length), see TrackParkPlan::fxOffline.
enum class Flag {
ShowInTcp, // B_SHOWINTCP
ShowInMixer, // B_SHOWINMIXER
MainSend, // B_MAINSEND
FxEnable, // I_FXEN
};
// One scalar-flag write the shell must apply: SetMediaTrackInfo_Value(guid, flag, value).
struct TrackFlagOp {
std::string guid;
Flag flag = Flag::ShowInTcp;
int value = 0;
bool operator==(const TrackFlagOp& o) const {
return guid == o.guid && flag == o.flag && value == o.value;
}
};
// One per-FX offline write: TrackFX_SetOffline(guid, fxIndex, offline).
struct FxOfflineOp {
std::string guid;
int fxIndex = 0;
bool offline = false;
bool operator==(const FxOfflineOp& o) const {
return guid == o.guid && fxIndex == o.fxIndex && offline == o.offline;
}
};
// One managed-lane play/show write the shell must apply. The shell translates this
// into the REAPER lane setters (track-side C_LANEPLAYS:N and, per item, I_FIXEDLANE /
// C_LANEPLAYS; B_FIXEDLANE_HIDDEN follows from the play state). `lanePlays` is a
// C_LANEPLAYS value: kLanePlaysExclusive when the active mode owns the lane,
// kLaneSilent otherwise. The pure model emits these for MANAGED lanes ONLY — never a
// manual lane (the fixed-lane analog of "never touch mute/solo"), enforced in
// planToggle and mirrored by lanesTouchedByToggle.
struct LanePlayOp {
std::string trackGuid;
std::string laneKey; // opaque, shell-supplied
int lanePlays = kLaneSilent;
bool operator==(const LanePlayOp& o) const {
return trackGuid == o.trackGuid && laneKey == o.laneKey && lanePlays == o.lanePlays;
}
};
// The complete set of operations to park one inactive leaf, or restore one leaf.
// Park uses fixed zeros (parking contract); restore uses a snapshot's values.
// fxOffline is emitted per known FX slot: on park, from the snapshot's slot count
// (all -> offline); on restore, each slot back to its captured value.
struct TrackPlan {
std::vector<TrackFlagOp> flags;
std::vector<FxOfflineOp> fxOffline;
};
// The plan for a whole toggle to a target mode: which tracks to park, and which to
// restore from their snapshots. Parents and show-both leaves never appear here —
// they are derived-visible and never parked (visibility is answered separately by
// visibleTracks). Untagged LEAVES DO appear: an untagged leaf is an Arrange member,
// so it parks in every non-Arrange mode and restores in Arrange — the mode system
// manages all leaves, not only tagged ones.
struct TogglePlan {
std::vector<TrackPlan> park; // inactive leaves -> parked (fixed zeros)
std::vector<TrackPlan> restore; // active leaves returning -> snapshot values
// D2 item-level projection: per managed lane, the C_LANEPLAYS state for the target
// mode (active mode's lane plays; every other managed lane silenced+hidden). MANAGED
// lanes ONLY — a manual lane never appears here. Empty when no managed lanes exist,
// so a D1-only project (no fixed lanes) produces an identical plan to before.
std::vector<LanePlayOp> lanes;
};
// -- The view mode model -----------------------------------------------------
//
// Owns the mode registry, the membership index, the active mode, and the durable
// per-track snapshots (kept for tracks currently parked so a save-while-parked
// project restores correctly). Visibility and the toggle plan are computed against
// a supplied FolderTree — the tree is never stored.
class ViewModeModel {
public:
ViewModeModel(); // Arrange + Design seeded; active mode = Arrange
ModeRegistry& modes() { return modes_; }
const ModeRegistry& modes() const { return modes_; }
MembershipIndex& membership() { return membership_; }
const MembershipIndex& membership() const { return membership_; }
LaneOwnershipIndex& lanes() { return lanes_; }
const LaneOwnershipIndex& lanes() const { return lanes_; }
const std::string& activeModeId() const { return activeModeId_; }
// Sets the active mode. Returns false (no change) if the id is not registered.
bool setActiveMode(const std::string& modeId);
// Records / clears the pre-park snapshot for a track. The shell calls store
// before it parks a track; the model persists it so restore survives a save.
void storeSnapshot(const std::string& guid, const TrackSnapshot& snap);
void clearSnapshot(const std::string& guid);
const TrackSnapshot* snapshot(const std::string& guid) const;
const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }
// Prunes orphaned per-track state: drops every snapshot whose GUID is NOT in
// `liveGuids` (the set of GUIDs the shell currently enumerates from the project).
// Returns the number of snapshots removed. The shell calls this before planning a
// toggle; because reapply-on-load also routes through the shell's applyMode, this
// reconciles on project open too.
//
// Why snapshots and NOT membership: a parked track's snapshot is dead weight once
// the track is deleted — it can never be restored, and if REAPER reuses that GUID
// for a different track a stale snapshot would drive an INCORRECT restore. So it
// must be pruned. Membership is deliberately KEPT: REAPER's undo of a track delete
// restores the SAME GUID, so dropping the Design tag on delete would silently lose
// it on undo-delete. Keeping membership means an undone delete brings the track
// back correctly tagged and it re-snapshots + re-parks cleanly on the next toggle.
// A genuinely-deleted-and-never-restored track leaves only a tiny dormant
// membership entry — acceptable, and far better than losing tags on undo. Folder
// RESTRUCTURE (moving tracks without deleting) is already self-healing: the tree is
// rebuilt from I_FOLDERDEPTH every toggle, so a restructure leaves every GUID live
// and reconcile is a no-op over it. This handles DELETION specifically.
std::size_t reconcile(const std::set<std::string>& liveGuids);
// Does `guid` belong to `modeId`? A leaf belongs if it is tagged into modeId,
// is show-both (belongs everywhere), or is untagged and modeId is Arrange (the
// default). Parent derivation is NOT applied here — this is the LEAF rule; use
// visibleTracks for the tree-aware answer.
bool leafBelongsToMode(const std::string& guid, const std::string& modeId) const;
// The set of track GUIDs visible in `modeId`, tree-aware: active leaves,
// show-both leaves, and every parent that EITHER belongs to the mode by its own
// membership OR has at least one descendant visible in the mode. Untagged nodes
// (leaf or folder) count as Arrange, so an untagged folder carrying its own
// FX/media shows in Arrange even when none of its children do, and additionally
// shows in a child's mode by derivation. Stale GUIDs in the tree are tolerated.
// The master is not represented (always visible; the shell never touches it).
std::set<std::string> visibleTracks(const FolderTree& tree,
const std::string& modeId) const;
// Plans a toggle to `targetMode` by enumerating EVERY leaf in the supplied tree.
// A leaf inactive in the target mode — tagged into another mode, or untagged and
// the target isn't Arrange — is parked with fixed zeros; a leaf that becomes
// active AND has a stored snapshot is restored from it. Parents (visibility-only)
// and show-both leaves (always visible) are never parked; the master is not in
// the tree. Untagged leaves ARE managed: they are Arrange members, so they park
// in non-Arrange modes and restore in Arrange. Tree membership is the enumeration
// source, so stale membership GUIDs absent from the tree are naturally ignored.
//
// Note: park plans emitted here have an empty fxOffline vector. The D2 shell
// expands per-FX offline writes using TrackFX_GetCount — the pure model has no
// access to REAPER FX counts at plan time.
TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const;
// The managed-only "which lanes may this toggle touch" query: the set of lane refs
// a toggle is permitted to drive — MANAGED lanes ONLY, from the ownership index.
// Manual lanes are NEVER in the result, regardless of target mode. This is the pure,
// testable decision behind the load-bearing invariant; the shell reads live lane
// state and applies C_LANEPLAYS only to lanes this query returns. Independent of the
// folder tree (lane ownership is not a tree property) — the target mode does not
// filter the SET (every managed lane is touchable), only the play VALUE each takes
// (see planToggle / laneModeState).
std::set<LaneRef> lanesTouchedByToggle() const;
bool operator==(const ViewModeModel& o) const;
std::string serialize() const;
// Parses a JSON string produced by serialize(). std::nullopt on malformed
// input. On success deserialize(serialize(x)) == x.
static std::optional<ViewModeModel> deserialize(const std::string& json);
private:
ModeRegistry modes_;
MembershipIndex membership_;
LaneOwnershipIndex lanes_; // (guid, laneKey) -> ownership
std::string activeModeId_; // always a registered id
std::map<std::string, TrackSnapshot> snapshots_; // guid -> pre-park snapshot
};
// Builds the fixed-zero park plan for one leaf. Offlines `fxCount` slots. Exposed
// for the shell and for direct testing of the parking contract.
TrackPlan makeParkPlan(const std::string& guid, int fxCount);
// Builds the restore plan for one leaf from its snapshot — every flag set to its
// captured value, never a default. Exposed for the shell and for testing the
// restore-contract invariant directly.
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap);
// -- Auto-tag decision (Phase D2) --------------------------------------------
//
// New content — both new tracks and new items — is tagged to whatever mode is active
// when it is created; pre-existing content defaults to Arrange. The DECISION is pure:
// the Wave-2 shell detects new GUIDs by diffing project state on the panel timer and
// asks this function what to tag. Pre-existing content (a GUID the shell does not
// report as new) never reaches here and stays at its index state (Arrange by default).
//
// Manual-lane exemption: an item that landed in a MANUAL lane is off-limits to auto-tag
// — auto-tag governs normal timeline content, not hand-managed lanes. The shell marks
// such an item `onManualLane = true` (it knows the item's lane and consults the
// ownership index); the decision then emits NO tag for it. New tracks and new items on
// managed/no lane follow the active-mode rule.
//
// -- Pre-existing-content adoption (strand fix) -------------------------------
//
// A new item dropped onto a track that ALREADY carries currently-visible content must
// not silently push that track into a different mode. If the pre-existing content
// resolves to ONE mode and the new item were blindly tagged to the (different) ACTIVE
// mode, the track would become multi-mode, planLaneMinting would split it, and the
// toggle would silence whichever lane the active mode does not own — stranding the
// pre-existing, previously-visible items on a C_LANEPLAYS=0 lane with no user intent.
//
// The rule: a new item ADOPTS the single mode of the pre-existing content already on its
// track. Only when the track carries no pre-existing managed-eligible content (an empty
// or brand-new track), or when that content already spans multiple modes (an existing
// deliberate split, which the new item joins under the active mode), does the new item
// fall back to the active-mode rule. Deliberate two-take splits are unaffected: those go
// through the explicit item mode-move actions (planItemRetag), never auto-tag.
// The shell reports each new item's track pre-existing-content modes in `trackModes`.
// One new item the shell detected this poll. Its lane disposition decides exemption; its
// track's pre-existing content modes decide adoption (see above).
struct NewItem {
std::string guid;
bool onManualLane = false; // true ⇒ EXEMPT from auto-tag (hand-managed lane)
// The distinct modes the PRE-EXISTING (not-new-this-tick) managed-eligible content on
// this item's track resolves to. Empty ⇒ the item's track carried no prior content, so
// the item takes the active mode. Exactly one ⇒ ADOPT that mode (the strand guard).
// More than one ⇒ the track is already a deliberate split; the item takes the active
// mode. The shell fills this by resolving each pre-existing item's mode from membership.
std::set<std::string> trackModes;
};
// One membership write the auto-tag decision produced: tag `guid` into `modeId`. The
// shell applies it to the MembershipIndex (a new track/item joins the active mode).
struct AutoTag {
std::string guid;
std::string modeId;
bool operator==(const AutoTag& o) const { return guid == o.guid && modeId == o.modeId; }
};
// The pure auto-tag decision: given the new track GUIDs and new items detected this
// poll plus the active mode, produce the membership writes. Every new track is tagged
// to `activeMode`. Every new item is tagged UNLESS it landed on a manual lane (exempt);
// its target mode is the single mode of its track's pre-existing content (adoption — the
// strand guard) when that content resolves to exactly one mode, otherwise `activeMode`.
// An empty `activeMode` yields no tags (nothing to tag into). Empty GUIDs are skipped.
// The result is a plan the shell applies; this function mutates nothing.
std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackGuids,
const std::vector<NewItem>& newItems,
const std::string& activeMode);
// -- Item-level mode-move decision (Phase D2 / Wave 3-B) ---------------------
//
// The bindable item actions (Move selected items -> Design / -> Arrange / Untag)
// retag the CURRENT item selection's membership, then re-drive the minting/apply
// path so each moved item lands on its target mode's managed lane. The DECISION —
// which selected items to retag, and to what — is pure and unit-tested here; the
// shell only reads the item selection (GUID + manual-lane disposition) and applies
// the resulting membership writes + re-lane pass.
//
// MANAGED-LANES-ONLY INVARIANT (upheld at the source, exactly as auto-tag does): an
// item the shell reports as already on a MANUAL lane is EXEMPT — it is never retagged,
// never untagged, never re-laned. The tool drives only what it minted, even under an
// explicit user action. The shell reports `onManualLane` per item and this decision
// emits NO op for such items; the shell then skips them entirely.
// One selected item the shell reports for the retag decision: its GUID and whether it
// currently sits on a MANUAL lane (⇒ EXEMPT: no membership change, no re-lane).
struct RetagItem {
std::string guid;
bool onManualLane = false; // true ⇒ EXEMPT from the item mode-move actions
};
// One membership op the item mode-move decision produced for one selected item. `untag`
// true ⇒ remove the item from the index (return it to the Arrange default); otherwise
// tag it into `modeId`. The shell applies each verbatim to the MembershipIndex.
struct ItemRetagOp {
std::string guid;
bool untag = false; // true ⇒ untag; false ⇒ tag into modeId
std::string modeId; // the target mode when !untag (empty when untag)
bool operator==(const ItemRetagOp& o) const {
return guid == o.guid && untag == o.untag && modeId == o.modeId;
}
};
// The pure item mode-move decision: given the selected items and a target mode, produce
// the membership ops. An EMPTY `targetMode` means UNTAG (the "Untag selected items" and
// "Move -> Arrange" actions collapse to the same act — Arrange is the absence of a tag,
// mirroring the track-level doUntag). A non-empty `targetMode` tags each eligible item
// into it. Manual-lane items are skipped (no op emitted); items with an empty GUID are
// skipped (defensive). The function mutates nothing — it returns a plan the shell applies.
std::vector<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& selected,
const std::string& targetMode);
// -- Lane minting decision (Phase D2 / Wave 3) -------------------------------
//
// D1 parks a whole track when it holds content of only ONE mode. The moment a track
// is VISIBLE IN MORE THAN ONE MODE while carrying its OWN media, whole-track parking
// can no longer keep the stances separate (the track shows in every mode it is visible
// in, so its items leak across all of them), so the projection drops to the ITEM level:
// the track becomes a fixed-lane track, each involved mode gets its own MANAGED lane,
// and each item is assigned to its mode's lane. A toggle then shows+plays only the
// active mode's lane.
//
// "Visible in more than one mode" has TWO sources, and both trigger a split:
// (1) the track's OWN managed-eligible items span >= 2 modes (a leaf carrying both
// an Arrange take and a Design take), OR
// (2) the track is a content-bearing FOLDER whose descendant leaves span modes, so
// it is DERIVED-VISIBLE in >= 2 modes (ViewModeModel::visibleTracks) even though
// its own single item is single-mode. This second source is why the decision is
// folder-tree / visibility aware — mirroring visibleTracks — rather than looking
// only at the track's own item mode-span. Without it, one MIDI item or capture
// dropped straight onto such a folder sits on the default lane and leaks into
// every mode the folder derives visibility in.
//
// SHOW-BOTH is the deliberate escape hatch: a show-both track is visible in every mode
// ON PURPOSE and its content is meant to play in all of them. It is NEVER force-split —
// neither the visibility trigger nor the own-item-span trigger confines its items to
// per-mode lanes. (Confining show-both content would contradict "stay audible across
// modes.") The decision skips show-both tracks entirely.
//
// This is the pure DECISION behind that transition — REAPER-free and unit-tested.
// The shell reads each track's items and their live mode+lane disposition, builds the
// FolderTree (via the existing view_tree helper, exactly as the D1 shell does), calls
// this with the model + tree, and applies the resulting REAPER writes (I_FREEMODE /
// I_NUMFIXEDLANES / P_LANENAME / I_FIXEDLANE) plus the ownership-index writes. The
// DECISION never lives in the shell.
//
// THE MANAGED-LANES-ONLY INVARIANT is upheld here at the source: an item the shell
// reports as already on a MANUAL lane is EXEMPT — it is never counted toward the
// multi-mode test, never reassigned, and its lane is never minted-over. The plan only
// ever names lanes with the managed prefix (laneNameForMode) and only ever moves
// managed-eligible items. A track the user already lane-splits for their own comping
// is handled by minting ADDITIONAL managed lanes alongside the user's manual lanes;
// the manual lanes and the items on them are untouched (they are reported exempt).
// One item the shell reports for the minting decision: its GUID, the mode its
// membership resolves to (untagged ⇒ Arrange, resolved by the shell via
// leafBelongsToMode / the active-mode default), and whether it currently sits on a
// MANUAL lane (⇒ exempt: never counted, never reassigned).
struct LaneItem {
std::string guid;
std::string modeId; // the mode this item's content belongs to
bool onManualLane = false; // true ⇒ EXEMPT (user's hand-managed lane)
};
// One track the shell reports: its GUID plus the items on it. The shell builds this by
// enumerating the track's media items and resolving each item's mode from membership.
struct LaneTrack {
std::string trackGuid;
std::vector<LaneItem> items;
};
// One item→lane assignment the shell must apply (I_FIXEDLANE = the lane the durable
// key `laneKey` currently occupies; the shell resolves key→ordinal exactly as the
// C_LANEPLAYS apply path does). Only managed-eligible items appear here.
struct LaneAssign {
std::string itemGuid;
std::string trackGuid;
std::string laneKey; // durable managed-lane key (laneNameForMode(modeId))
bool operator==(const LaneAssign& o) const {
return itemGuid == o.itemGuid && trackGuid == o.trackGuid && laneKey == o.laneKey;
}
};
// One managed lane the shell must mint on a track: its durable key (== the name to
// stamp via P_LANENAME) and the mode that owns it (recorded in the ownership index).
struct LaneMint {
std::string trackGuid;
std::string laneKey; // == laneNameForMode(modeId); the P_LANENAME to stamp
std::string modeId; // the owning mode (ownership-index managed-for-mode write)
bool operator==(const LaneMint& o) const {
return trackGuid == o.trackGuid && laneKey == o.laneKey && modeId == o.modeId;
}
};
// The complete lane-minting plan for the tracks the shell reported. Empty (all three
// vectors) when NO track needs splitting — a single-mode-only project produces an empty
// plan and the shell does nothing (D1 behavior unchanged). The shell wraps the whole
// application in ONE Undo block because it is a visible structural mutation.
struct LaneMintPlan {
// Tracks to switch into fixed-lane mode, each with the number of managed lanes to
// ensure (I_FREEMODE=2, I_NUMFIXEDLANES >= laneCount). Only tracks that need a
// split appear; a track already carrying the tool's managed lanes for exactly the
// involved modes still appears (idempotent — the shell's ensure is a no-op then).
struct TrackSplit {
std::string trackGuid;
int laneCount = 0; // number of managed lanes this track needs
};
std::vector<TrackSplit> splits;
std::vector<LaneMint> mints; // managed lanes to mint (name + ownership write)
std::vector<LaneAssign> assigns; // item→managed-lane assignments
bool empty() const {
return splits.empty() && mints.empty() && assigns.empty();
}
};
// The pure lane-minting decision, folder-tree / visibility aware. `model` supplies the
// membership + show-both state; `tree` supplies the folder structure so a content-bearing
// folder's DERIVED visibility is accounted for (mirrors ViewModeModel::visibleTracks).
// For each reported track:
// * SHOW-BOTH tracks are skipped outright — never force-split (the escape hatch: their
// content is meant to stay audible in every mode). No split, mint, or assignment.
// * Ignore items on manual lanes entirely (exempt — the managed-only invariant).
// * A track splits iff it CARRIES OWN managed-eligible media AND is VISIBLE IN >= 2
// MODES. Visibility spans two sources, either of which qualifies:
// (a) the track's own managed-eligible items span >= 2 modes (leaf carrying an
// Arrange take and a Design take), OR
// (b) the track is derived-visible in >= 2 modes per visibleTracks (a content-
// bearing folder whose descendant leaves span modes) — the missed case.
// * A track visible in exactly ONE mode (single-mode leaf, single-mode folder) stays
// whole-track-parked (D1) — NO split. This is the single-mode-track rule.
// * On a split: one TrackSplit (laneCount == number of lanes to mint), one LaneMint per
// mode the track's OWN items occupy, and one LaneAssign per managed-eligible OWN item
// onto ITS tagged mode's lane — INCLUDING pre-existing items, so a folder carrying one
// own Design item while derived-visible in Arrange too still lanes that item to the
// Design lane (it then hides+silences whenever Arrange is active).
// * LAZY-MINT: lanes are minted ONLY for modes the track's own items actually occupy —
// never an empty reserved lane for a mode the track is merely derived-visible in. So a
// folder whose own item is Design-only but which is derived-visible in Arrange mints a
// Design lane ONLY (holding the item), NOT an empty Arrange lane. Confinement still
// holds: with only a Design lane present, toggling to Arrange drives that lane's
// C_LANEPLAYS to 0 (hide+silence) and no lane plays, so the track reads as an empty
// normal track and the Design item does not leak. The Arrange lane is minted on demand
// when an Arrange item first lands. The derived-visibility trigger still decides WHETHER
// to split; it no longer inflates WHICH lanes are minted.
//
// Items with an empty GUID or empty modeId are skipped (defensive; a real item always
// resolves to a mode). The function mutates nothing — it returns a plan the shell
// applies. Idempotency: re-reporting an already-split track yields the same mints and
// assignments; the shell's ensure/assign writes are no-ops when the state already
// matches, so re-running the detection path does not thrash the project or the undo
// history (the shell only opens an Undo block when the plan is non-empty AND some
// write actually changes state — see the shell).
LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree,
const std::vector<LaneTrack>& tracks);
// The next mode id in the registry's ordinal order, cycling past `currentModeId`
// and wrapping to the first mode after the last (Arrange -> Design -> Arrange with
// the two seed modes; the same cycle scales to N modes with no call-site change).
// This is the pure decision behind the "toggle active mode" action: the shell reads
// the model's active mode, asks for the next one, and applies it.
// * empty registry -> "" (nothing to cycle to)
// * currentModeId not present -> the first mode's id (a sane home to jump to)
// Exposed as a free function (not a model member) so it is unit-testable against a
// bare ModeRegistry without a full ViewModeModel.
std::string nextModeId(const ModeRegistry& modes, const std::string& currentModeId);
} // namespace reasampler
+41
View File
@@ -0,0 +1,41 @@
// view_tree — pure folder-depth walk. See view_tree.h.
#include "core/view/view_tree.h"
namespace reasampler::view {
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::view
+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 "core/view/view_mode_model.h"
namespace reasampler::view {
// 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::view