diff --git a/src/core/view/guid_diff.cpp b/src/core/view/guid_diff.cpp index 303f94e..ef03f9e 100644 --- a/src/core/view/guid_diff.cpp +++ b/src/core/view/guid_diff.cpp @@ -1,5 +1,4 @@ -// guid_diff implementation — pure set arithmetic for new-content detection. See -// guid_diff.h. No REAPER, no SWELL — std only. +// See guid_diff.h. #include "core/view/guid_diff.h" @@ -10,8 +9,6 @@ namespace reasampler::view { std::vector newGuids(const std::set& previous, const std::set& current) { std::vector 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); @@ -21,24 +18,21 @@ std::vector newGuids(const std::set& previous, std::vector GuidBaseline::observe(const std::set& 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 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. + // Assign `current`, not baseline_ ∪ added: a deleted GUID drops out of the + // baseline, so a later reused GUID is detected again rather than looking + // pre-existing. baseline_ = current; return added; } void GuidBaseline::reset() { baseline_.clear(); - primed_ = false; // next observe() re-baselines (first-poll guard re-armed) + primed_ = false; } } // namespace reasampler::view diff --git a/src/core/view/guid_diff.h b/src/core/view/guid_diff.h index 5026ec2..f7bfbd2 100644 --- a/src/core/view/guid_diff.h +++ b/src/core/view/guid_diff.h @@ -1,17 +1,6 @@ #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. +// Pure, REAPER-free new-content detection: which GUIDs appeared since the last +// poll. See src/core/view/CLAUDE.md for the module contract. #include #include @@ -19,44 +8,25 @@ 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). +// GUIDs in `current` but not `previous`, ascending order; empty GUIDs ignored. std::vector newGuids(const std::set& previous, const std::set& 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). +// Tracks the live GUID set across polls for one project. 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. + // First call after construction/reset() establishes the baseline and + // returns {}; later calls return GUIDs added since the prior call. std::vector observe(const std::set& 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. + // Re-arms the first-poll guard on a detected project switch. 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 baseline_; - bool primed_ = false; // false ⇒ next observe() sets the baseline + bool primed_ = false; }; } // namespace reasampler::view diff --git a/src/core/view/lane_keys.cpp b/src/core/view/lane_keys.cpp index b003925..5b660f4 100644 --- a/src/core/view/lane_keys.cpp +++ b/src/core/view/lane_keys.cpp @@ -1,4 +1,4 @@ -// lane_keys implementation — pure string convention, no REAPER. See lane_keys.h. +// See lane_keys.h. #include "core/view/lane_keys.h" @@ -7,7 +7,6 @@ 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; @@ -19,11 +18,10 @@ bool isManagedLaneName(const std::string& laneName) { } std::optional 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. + if (!hasManagedPrefix(laneName)) return std::nullopt; + // Keep the full prefixed name as the key (not just the mode id) so it stays + // globally unambiguous; the ownership index's mode field is the sole + // source of truth for which mode owns the lane. return laneName; } @@ -32,19 +30,14 @@ std::string laneNameForMode(const std::string& modeId) { } std::optional modeIdFromLaneName(const std::string& laneName) { - if (!hasManagedPrefix(laneName)) return std::nullopt; // manual/unnamed ⇒ no mode + if (!hasManagedPrefix(laneName)) return std::nullopt; const std::size_t n = std::strlen(kManagedLanePrefix); - if (laneName.size() == n) return std::nullopt; // prefix only, no mode suffix (illegal) + if (laneName.size() == n) return std::nullopt; // prefix only, no mode suffix 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); } diff --git a/src/core/view/lane_keys.h b/src/core/view/lane_keys.h index 36cb133..c798899 100644 --- a/src/core/view/lane_keys.h +++ b/src/core/view/lane_keys.h @@ -1,85 +1,32 @@ #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:") 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. +// Managed/manual fixed-lane convention: maps a lane's durable P_LANENAME to the +// opaque lane-key view_mode_model keys by. See src/core/view/CLAUDE.md (Gotchas): +// lane identity must ride the durable name, never the raw I_FIXEDLANE ordinal, +// or a reorder silently corrupts managed/manual ownership. #include #include 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. +// Prefix stamped on every lane name the tool mints. Stable-forever like an +// action-id string — changing it strands ownership of every already-minted lane. 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). +// A managed lane's key is its full durable name; nullopt for manual/unnamed. std::optional 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. +// Inverse pair: managedLaneKey(laneNameForMode(m)) == kManagedLanePrefix + m; +// modeIdFromLaneName(laneNameForMode(m)) == m. 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 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. +// Single predicate governing both which lanes the apply path may drive and +// which items are exempt from auto-tag. No manual-lane concept on a non-fixed- +// lane track (returns false); on a fixed-lane track, any unprefixed name — +// including REAPER's default empty lane — is manual. bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName); } // namespace reasampler::view diff --git a/src/core/view/mode_switch.cpp b/src/core/view/mode_switch.cpp index 6bc8c57..eb23895 100644 --- a/src/core/view/mode_switch.cpp +++ b/src/core/view/mode_switch.cpp @@ -1,4 +1,4 @@ -// mode_switch — pure implementation. See mode_switch.h. NO REAPER / SWELL / vendor. +// See mode_switch.h. #include "core/view/mode_switch.h" @@ -8,11 +8,8 @@ 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. +// Left edge of segment i; segment i spans [edge(i), edge(i+1)). count assumed +// >= 1 by callers. int segmentEdge(int x, int width, int i, int count) { return x + (i * width) / count; } @@ -31,7 +28,7 @@ std::vector computeSegmentRects(const HeaderRect& header, SegmentRect r; r.x = left; r.y = header.y; - r.width = right - left; // absorbs rounding; adjacent segments abut exactly + r.width = right - left; r.height = header.height; rects.push_back(r); } @@ -41,23 +38,15 @@ std::vector computeSegmentRects(const HeaderRect& header, 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; } diff --git a/src/core/view/mode_switch.h b/src/core/view/mode_switch.h index 0b85942..c388d6c 100644 --- a/src/core/view/mode_switch.h +++ b/src/core/view/mode_switch.h @@ -1,49 +1,23 @@ #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 (shell/panel/) 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. +// Pure segment layout + hit-test for the bank_panel's Design-View mode switch. +// Mirror of bank_grid. See src/core/view/CLAUDE.md. #include 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 +using HeaderRect = ui::Rect; +using SegmentRect = ui::Rect; -// 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. +// Divides `header` into `segmentCount` equal segments left-to-right. Boundaries +// use header.x + (i * width) / segmentCount so segments abut exactly despite +// integer rounding. segmentCount <= 0 or non-positive width returns empty. std::vector 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. +// Segment index containing (px, py), or -1 for a miss (outside header bounds, +// or segmentCount <= 0). Half-open bounds match computeSegmentRects. int hitTestSegment(int px, int py, const HeaderRect& header, int segmentCount); } // namespace reasampler::view diff --git a/src/core/view/view_mode_model.cpp b/src/core/view/view_mode_model.cpp index f2cfbfc..fd6ab92 100644 --- a/src/core/view/view_mode_model.cpp +++ b/src/core/view/view_mode_model.cpp @@ -6,35 +6,16 @@ #include #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. +#include "core/view/lane_keys.h" // laneNameForMode 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}); @@ -44,8 +25,6 @@ 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; @@ -57,10 +36,6 @@ const Mode* ModeRegistry::query(const std::string& id) const { 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]; @@ -95,10 +70,6 @@ std::set MembershipIndex::modesOf(const std::string& guid) const { return m ? m->modeIds : std::set{}; } -// --------------------------------------------------------------------------- -// 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; @@ -123,24 +94,12 @@ const LaneOwnership* LaneOwnershipIndex::query(const std::string& trackGuid, } 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. + // Assumes at most one managed lane per (track, mode) — planToggle asserts + // this in debug builds; two lanes claiming the same mode would both be + // told to play exclusively, which REAPER can't honor coherently. return managedMode == activeMode ? kLanePlaysExclusive : kLaneSilent; } -// --------------------------------------------------------------------------- -// auto-tag decision -// --------------------------------------------------------------------------- - std::vector autoTagNewContent(const std::vector& newTrackGuids, const std::vector& newItems, const std::string& activeMode) { @@ -153,14 +112,9 @@ std::vector autoTagNewContent(const std::vector& newTrackG } for (const auto& item : newItems) { if (item.guid.empty()) continue; - if (item.onManualLane) continue; // manual-lane content is off-limits to auto-tag + if (item.onManualLane) continue; - // 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. + // Adopt the track's single pre-existing mode (strand guard — see header). const std::string& target = item.trackModes.size() == 1 ? *item.trackModes.begin() : activeMode; tags.push_back(AutoTag{item.guid, target}); @@ -173,27 +127,19 @@ std::vector planItemRetag(const std::vector& selected, std::vector 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 + if (item.guid.empty()) continue; + if (item.onManualLane) continue; 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& 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. + // Per track GUID, the modes it's visible in (tree-aware) — captures the + // folder-derived-visibility split trigger, not just own-item mode span. std::map> visibleModesOf; for (const Mode& mode : model.modes().all()) { const std::set vis = model.visibleTracks(tree, mode.id); @@ -204,58 +150,26 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, 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; + if (model.membership().isShowBoth(track.trackGuid)) continue; // never force-split - // 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 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 + if (item.onManualLane) continue; // exempt 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; + if (ownItemModes.empty()) continue; // no own media, nothing to confine - // 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; + if (!multiMode) continue; // single-mode: D1 whole-track parking still separates - // 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& laneModes = ownItemModes; + const std::set& laneModes = ownItemModes; // lazy-mint: own modes only - // 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(laneModes.size())}); for (const std::string& mode : laneModes) { @@ -263,13 +177,9 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, 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 + if (item.onManualLane) continue; plan.assigns.push_back(LaneAssign{ item.guid, track.trackGuid, laneNameForMode(item.modeId)}); } @@ -278,13 +188,7 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, 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}, @@ -298,8 +202,6 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount) { } 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}, @@ -319,15 +221,9 @@ std::string nextModeId(const ModeRegistry& modes, const std::string& currentMode 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; + return all.front().id; // stale/unknown current id -> jump to the first mode } -// --------------------------------------------------------------------------- -// ViewModeModel -// --------------------------------------------------------------------------- - ViewModeModel::ViewModeModel() : activeModeId_(kArrangeModeId) {} bool ViewModeModel::setActiveMode(const std::string& modeId) { @@ -350,8 +246,7 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const { } std::size_t ViewModeModel::reconcile(const std::set& 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). + // See header: snapshots are pruned, membership is not (undo-delete rationale). std::size_t removed = 0; for (auto it = snapshots_.begin(); it != snapshots_.end();) { if (liveGuids.count(it->first) == 0) { @@ -368,7 +263,7 @@ bool ViewModeModel::leafBelongsToMode(const std::string& guid, const std::string 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 + if (m->modeIds.empty()) return modeId == kArrangeModeId; return m->modeIds.count(modeId) > 0; } @@ -376,28 +271,18 @@ std::set ViewModeModel::visibleTracks(const FolderTree& tree, const std::string& modeId) const { std::set 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). + // Pass 1: nodes visible by their own membership (leaf rule, or an + // untagged/Arrange-default folder). 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. + // Pass 2: propagate up parent chains so a parent with any visible + // descendant is visible too (OR'd with pass 1). Cycle-guarded. std::map 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 seeds(visible.begin(), visible.end()); for (const auto& node : seeds) { auto it = parentOf.find(node); @@ -415,52 +300,29 @@ std::set ViewModeModel::visibleTracks(const FolderTree& tree, 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. + // Enumerate the tree (not membership_.all()) so untagged leaves — absent + // from the membership index but still Arrange members — park/restore too. for (const auto& node : tree.nodes) { - if (node.isParent) continue; // parents are derived, never parked + if (node.isParent) continue; const std::string& guid = node.guid; - if (membership_.isShowBoth(guid)) continue; // show-both leaves are never parked + if (membership_.isShowBoth(guid)) continue; 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. + // fxOffline is empty here; the D2 shell expands it via TrackFX_GetCount. 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. + // One C_LANEPLAYS op per MANAGED lane; manual lanes are skipped entirely. #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> seenTrackMode; // (trackGuid, mode) #endif for (const auto& [ref, ownership] : lanes_.all()) { - if (!ownership.isManaged()) continue; // manual lanes are off-limits + if (!ownership.isManaged()) continue; #ifndef NDEBUG assert(seenTrackMode.insert({ref.trackGuid, *ownership.managedMode}).second && "two managed lanes on one track claim the same mode (exclusivity broken)"); @@ -473,9 +335,6 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& } std::set 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 touched; for (const auto& [ref, ownership] : lanes_.all()) { if (ownership.isManaged()) touched.insert(ref); @@ -488,14 +347,9 @@ bool ViewModeModel::operator==(const ViewModeModel& o) const { 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. +// Shared core/json emit helpers (Q-W1) — byte-identical escape/int rendering. using json::writeEscaped; using json::writeIntArray; std::string intToStr(int v) { return json::numToStr(v); } @@ -510,7 +364,6 @@ std::string ViewModeModel::serialize() const { root.keyRaw("version", intToStr(1)); root.keyStr("activeMode", activeModeId_); - // modes root.keyBegin("modes"); out += '['; { @@ -571,10 +424,7 @@ std::string ViewModeModel::serialize() const { } 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. + // lanes: array of { trackGuid, laneKey, managed(bool), mode(str, managed only) } root.keyBegin("lanes"); out += '['; { @@ -590,23 +440,12 @@ std::string ViewModeModel::serialize() const { } } out += ']'; - } // root closes here (see bank_model note on NRVO + deferred close) + } // root closes here (NRVO + deferred close, mirrors bank_model) 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(); @@ -659,18 +498,9 @@ bool parseMembership(json::Reader& r, MembershipIndex& idx) { } 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. + // Install verbatim (tag() would clobber a multi-mode set / show-both). + // Stale mode ids / stale GUIDs are tolerated by design — only + // activeMode is validated (below). if (!idx.restore(guid, mem)) return false; } while (r.consume(',')); return r.consume(']'); @@ -721,10 +551,8 @@ bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) { 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. + // Both keys mandatory/non-empty; managed must carry a mode, manual must not + // — keeps a round-tripped index byte-for-byte identical to the source. if (!haveTrack || !haveLane || !haveManaged) return false; if (trackGuid.empty() || laneKey.empty()) return false; if (managed) { @@ -769,11 +597,7 @@ bool parseModel(json::Reader& r, ViewModeModel& out) { } 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; + if (!r.skipValue()) return false; // unknown keys / "version" placeholder } } while (r.consume(',')); diff --git a/src/core/view/view_mode_model.h b/src/core/view/view_mode_model.h index bcd0765..db4408f 100644 --- a/src/core/view/view_mode_model.h +++ b/src/core/view/view_mode_model.h @@ -1,38 +1,9 @@ #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. +// Pure core of the Design View feature — mirror of bank_model: mode registry, +// GUID-keyed membership, folder-tree-aware visibility, park/restore planner, +// and JSON round-trip. Folder structure is an INPUT (the D2 shell reads +// REAPER's I_FOLDERDEPTH); this model never fetches or stores REAPER's live +// tree. See src/core/view/CLAUDE.md for the settled invariants. #include #include @@ -56,31 +27,26 @@ struct Mode { 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. +// Ordered registry of modes. Arrange + Design are seeded on construction; 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& 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. + // Empty registry (no seed modes) for deserialization, so the parsed + // Arrange/Design don't collide with the default ctor's seeded ones. static ModeRegistry makeEmpty() { return ModeRegistry(EmptyTag{}); } private: @@ -100,30 +66,12 @@ struct Membership { } }; -// -- 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. +// Item-level (fixed-lane) lane ownership. Mode operations touch only managed +// lanes; manual lanes are the user's own comping lanes and stay untouched — +// the fixed-lane analog of never-touch-mute/solo. Lane identity is an opaque +// key the shell supplies; this model bakes in no I_FIXEDLANE ordinal assumption. -// 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. +// One lane's ownership: managed by a specific mode, or manual (user-minted). struct LaneOwnership { std::optional managedMode; // set ⇒ managed by this mode; unset ⇒ manual @@ -133,10 +81,10 @@ struct LaneOwnership { 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. +// A lane's composite key: (track GUID, opaque lane key). struct LaneRef { std::string trackGuid; - std::string laneKey; // opaque, shell-supplied; NOT assumed to be a stable ordinal + 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; @@ -147,34 +95,22 @@ struct LaneRef { } }; -// (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. +// (track GUID, lane key) -> ownership, GUID-keyed and portable. A lane ABSENT +// from the index is treated as manual by default (never minted by the tool), +// 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. + // Removes the lane entirely (⇒ manual-by-default again). Returns true if 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. + // Load-bearing predicate the toggle planner gates on: absent ⇒ not managed. bool isManaged(const std::string& trackGuid, const std::string& laneKey) const { const LaneOwnership* o = query(trackGuid, laneKey); return o && o->isManaged(); @@ -191,44 +127,32 @@ private: std::map 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. +// C_LANEPLAYS value for a managed lane under the given active mode: the lane +// plays exclusively iff its owning mode is active, else silent+hidden. Callers +// must only pass MANAGED lanes; 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. + // Tags `guid` into `modeId`, replacing any prior mode set. 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. + // Removes `guid` entirely (returns it to the Arrange default). 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. + // Sets the show-both flag; creates an untagged (Arrange-default) entry if + // `guid` had none, so show-both alone is representable. 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. + // Installs a complete membership record verbatim, replacing any existing + // entry. Used by deserialization to rebuild a trusted entry without tag()'s + // single-mode clobbering. 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 { @@ -250,40 +174,32 @@ private: std::map 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. +// Folder tree: an INPUT the shell rebuilds from I_FOLDERDEPTH each call, never +// stored here. A parent is visible in a mode if it belongs by its own +// membership or any descendant leaf does, and is never parked. The master +// track is implicit (always visible, untouched) 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. +// Arrange-view order; parentGuid links each node to its immediate parent folder. struct FolderTree { std::vector 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). +// The prior value of every tool-driven flag on one track, captured BEFORE +// parking — restore's source of truth. 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". + // Prior per-FX offline state, index = fx slot. std::vector fxOffline; bool operator==(const TrackSnapshot& o) const { @@ -293,8 +209,8 @@ struct TrackSnapshot { } }; -// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it is -// per-slot, variable length), see TrackParkPlan::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 @@ -324,13 +240,10 @@ struct FxOfflineOp { } }; -// 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. +// One managed-lane play/show write the shell must apply (translated into +// C_LANEPLAYS / I_FIXEDLANE / B_FIXEDLANE_HIDDEN). Emitted for MANAGED lanes +// only — never a manual lane; enforced in planToggle and mirrored by +// lanesTouchedByToggle. struct LanePlayOp { std::string trackGuid; std::string laneKey; // opaque, shell-supplied @@ -341,38 +254,33 @@ struct LanePlayOp { } }; -// 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. +// The complete set of operations to park one inactive leaf, or restore one +// leaf. Park uses fixed zeros; restore uses a snapshot's values. fxOffline is +// per known FX slot: on park all slots go offline (from the snapshot's slot +// count); on restore each slot returns to its captured value. struct TrackPlan { std::vector flags; std::vector 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. +// The plan for a toggle to a target mode. Parents and show-both leaves never +// appear (derived-visible, never parked — see visibleTracks). Untagged +// leaves DO appear: an untagged leaf is an Arrange member, so it parks in +// every non-Arrange mode and restores in Arrange. struct TogglePlan { std::vector park; // inactive leaves -> parked (fixed zeros) std::vector 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. + // Per managed lane, the C_LANEPLAYS state for the target mode. Managed + // lanes only. Empty when no fixed lanes exist, so a lane-free project + // produces an identical plan to before fixed-lane support. std::vector 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. +// Owns the mode registry, membership index, active mode, and durable +// per-track snapshots (kept while 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 @@ -385,82 +293,60 @@ public: 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. + // 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. + // The shell calls store before it parks a track, 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& 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. + // Drops every snapshot whose GUID is NOT in `liveGuids`. Returns the count + // removed. // - // 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. + // Snapshots are pruned, membership is not: a parked track's snapshot is + // dead weight once the track is deleted (can never restore; a reused GUID + // would drive an incorrect restore). Membership survives because REAPER's + // undo of a track delete restores the SAME GUID — dropping the tag on + // delete would lose it on undo. A never-restored track leaves only a + // dormant membership entry, which is a fine trade against losing tags on + // undo. Folder restructure is self-healing (tree rebuilt every toggle) and + // is not what this handles. std::size_t reconcile(const std::set& 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. + // A leaf belongs if tagged into modeId, show-both, or untagged with modeId + // == Arrange. No parent derivation here — see visibleTracks for that. 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). + // Tree-aware visible set: active leaves, show-both leaves, and every + // parent that belongs to the mode itself or has a visible descendant. + // Untagged nodes count as Arrange. Stale tree GUIDs are tolerated; the + // master is not represented (always visible, untouched). std::set 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. + // Enumerates every leaf in `tree`; a leaf inactive in `targetMode` is + // parked (fixed zeros), one becoming active with a stored snapshot is + // restored from it. Parents and show-both leaves are never parked. + // Untagged leaves are Arrange members and park/restore accordingly. Tree + // membership is the enumeration source, so stale membership GUIDs absent + // from the tree are 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. + // Park plans here carry an empty fxOffline vector — the D2 shell expands + // per-FX offline writes via TrackFX_GetCount (not available to the pure model). 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). + // Managed lanes only, from the ownership index — the set a toggle may + // drive. Independent of the folder tree (lane ownership isn't a tree + // property); the target mode decides each lane's play VALUE, not the set. std::set 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. + // std::nullopt on malformed input. deserialize(serialize(x)) == x on success. static std::optional deserialize(const std::string& json); private: @@ -471,61 +357,38 @@ private: std::map 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. +// Fixed-zero park plan for one leaf, offlining `fxCount` slots. 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. +// Restore plan for one leaf from its snapshot — every flag to its captured +// value, never a default. TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap); -// -- Auto-tag decision (Phase D2) -------------------------------------------- +// Auto-tag decision: new content takes the active mode at creation; the +// Wave-2 shell diffs GUIDs on the panel timer and asks this what to tag. +// Pre-existing content never reaches here. // -// 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 landing on a MANUAL lane is off-limits. // -// 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`. +// Adoption (strand fix): a new item on a track that already carries +// pre-existing content adopts that content's single mode rather than blindly +// taking the active mode — otherwise the track would go multi-mode, get +// lane-split, and strand the pre-existing (previously visible) items on a +// silenced lane with no user intent. Falls back to the active mode only when +// the track has no pre-existing managed-eligible content, or that content +// already spans multiple modes (an existing deliberate split). -// One new item the shell detected this poll. Its lane disposition decides exemption; its -// track's pre-existing content modes decide adoption (see above). +// One new item the shell detected this poll. 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. + bool onManualLane = false; // true ⇒ EXEMPT from auto-tag + // Distinct modes the pre-existing (not-new-this-tick) content on this + // item's track resolves to. Empty ⇒ take active mode. Exactly one ⇒ + // adopt it. More than one ⇒ already a deliberate split, take active mode. std::set 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). +// One membership write: tag `guid` into `modeId`. struct AutoTag { std::string guid; std::string modeId; @@ -533,42 +396,27 @@ struct AutoTag { 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. +// Every new track is tagged to `activeMode`. Every new item is tagged unless +// exempt (manual lane); its target is the adopted single mode of its track's +// pre-existing content, else `activeMode`. Empty `activeMode` yields no tags. +// Empty GUIDs are skipped. Mutates nothing. std::vector autoTagNewContent(const std::vector& newTrackGuids, const std::vector& 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. +// Item-level mode-move decision (bindable "Move selected items -> mode" +// actions): which selected items to retag, and to what. Manual-lane items +// (shell-reported `onManualLane`) are exempt — never retagged, never re-laned, +// upholding the managed-lanes-only invariant under an explicit user action too. -// 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). +// One selected item the shell reports for the retag decision. struct RetagItem { std::string guid; - bool onManualLane = false; // true ⇒ EXEMPT from the item mode-move actions + bool onManualLane = false; // true ⇒ EXEMPT }; -// 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. +// One membership op: `untag` removes the item (Arrange default); otherwise +// tags it into `modeId`. struct ItemRetagOp { std::string guid; bool untag = false; // true ⇒ untag; false ⇒ tag into modeId @@ -579,77 +427,48 @@ struct ItemRetagOp { } }; -// 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. +// Empty `targetMode` means untag (Move -> Arrange and Untag collapse to the +// same act, mirroring the track-level doUntag). Manual-lane and empty-GUID +// items are skipped. Mutates nothing. std::vector planItemRetag(const std::vector& selected, const std::string& targetMode); -// -- Lane minting decision (Phase D2 / Wave 3) ------------------------------- +// Lane-minting decision (D2 Wave 3): once a track is visible in more than one +// mode while carrying its own media, whole-track parking can no longer keep +// stances separate, so it drops to fixed lanes — one managed lane per +// involved mode, each item assigned to its mode's lane. // -// 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 independent triggers, either +// splits the track: (a) the track's own items span >= 2 modes, or (b) the +// track is a content-bearing folder derived-visible in >= 2 modes +// (visibleTracks) even though its own item is single-mode — the folder case +// a naive own-item-span check would miss. // -// "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 tracks are skipped outright (never force-split — the point of +// show-both is staying audible everywhere). Manual-lane items are exempt. +// Lanes are minted LAZILY — only for modes the track's own items actually +// occupy, never an empty reserved lane for a merely-derived-visible mode; +// confinement still holds because an absent lane never plays. // -// 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). +// Idempotent: re-reporting an already-split track yields the same mints and +// assignments, so re-running detection does not thrash the project or undo +// history. -// 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). +// One item the shell reports for the minting decision. 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. +// One track the shell reports: its GUID plus the items on it. struct LaneTrack { std::string trackGuid; std::vector 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. +// One item→lane assignment the shell must apply (I_FIXEDLANE = the lane the +// durable key `laneKey` currently occupies). Only managed-eligible items appear. struct LaneAssign { std::string itemGuid; std::string trackGuid; @@ -660,8 +479,8 @@ struct LaneAssign { } }; -// 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). +// One managed lane the shell must mint: its durable key (== the P_LANENAME to +// stamp) and the mode that owns it (an ownership-index write). struct LaneMint { std::string trackGuid; std::string laneKey; // == laneNameForMode(modeId); the P_LANENAME to stamp @@ -672,15 +491,13 @@ struct LaneMint { } }; -// 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. +// The complete plan; empty when no track needs splitting (D1 behavior +// unchanged). The shell wraps application in one Undo block (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). + // Tracks to switch into fixed-lane mode (I_FREEMODE=2, I_NUMFIXEDLANES >= + // laneCount). Idempotent — an already-split track still appears, but the + // shell's ensure is then a no-op. struct TrackSplit { std::string trackGuid; int laneCount = 0; // number of managed lanes this track needs @@ -694,55 +511,16 @@ struct LaneMintPlan { } }; -// 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). +// `model` supplies membership + show-both state; `tree` supplies folder +// structure for the derived-visibility trigger. Items with an empty GUID or +// modeId are skipped (defensive). Mutates nothing. LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, const std::vector& 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. +// Next mode id in ordinal order, cycling past `currentModeId` and wrapping +// after the last. Empty registry -> "". currentModeId not present -> the +// first mode's id. Free function (not a model member) so it is testable +// against a bare ModeRegistry. std::string nextModeId(const ModeRegistry& modes, const std::string& currentModeId); } // namespace reasampler diff --git a/src/core/view/view_tree.cpp b/src/core/view/view_tree.cpp index 6356ec0..59af866 100644 --- a/src/core/view/view_tree.cpp +++ b/src/core/view/view_tree.cpp @@ -1,4 +1,4 @@ -// view_tree — pure folder-depth walk. See view_tree.h. +// See view_tree.h. #include "core/view/view_tree.h" @@ -8,11 +8,8 @@ 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. + // Stack of open folder-parent GUIDs; top is the next track's parent. A + // folder-parent's push and a closer's pop both trail their own assignment. std::vector open; for (const TrackFolderEntry& e : entries) { @@ -23,11 +20,8 @@ FolderTree buildFolderTree(const std::vector& entries) { tree.nodes.push_back(node); if (e.folderDepth == 1) { - open.push_back(e.guid); // this track's folder opens for what follows + open.push_back(e.guid); } 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(); diff --git a/src/core/view/view_tree.h b/src/core/view/view_tree.h index 7d2d968..8122644 100644 --- a/src/core/view/view_tree.h +++ b/src/core/view/view_tree.h @@ -1,12 +1,6 @@ #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. +// Pure I_FOLDERDEPTH -> FolderTree walk; REAPER reads stay in shell/view. +// See src/core/view/CLAUDE.md. #include #include @@ -15,19 +9,15 @@ 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, ...). +// One track's contribution, read from REAPER in arrange order. folderDepth is +// I_FOLDERDEPTH verbatim: 0 normal, 1 opens a folder, <0 closes |depth| levels. 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. +// Assigns each node its parentGuid (empty = top level) and isParent. Total: a +// malformed depth stream (close deeper than the stack) clamps rather than faults. FolderTree buildFolderTree(const std::vector& entries); } // namespace reasampler::view diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index a427f05..b4af6d2 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -1,12 +1,7 @@ -// 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. +// See view.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). +// Tree arithmetic lives in view_tree (pure); this file owns REAPER reads/writes +// and the snapshot-before-park ordering. #include "shell/view/view.h" @@ -37,8 +32,8 @@ #define REAPERAPI_WANT_TrackList_AdjustWindows #define REAPERAPI_WANT_UpdateArrange #define REAPERAPI_WANT_UpdateTimeline -// Lane minting (D2 Wave 3): enumerate a track's items and read/write item-side lane -// state to assign each item to its mode's managed lane. +// Lane minting (D2 Wave 3): item-side lane reads/writes to assign each item to +// its mode's managed lane. #define REAPERAPI_WANT_CountTrackMediaItems #define REAPERAPI_WANT_GetTrackMediaItem #define REAPERAPI_WANT_GetMediaItemInfo_Value @@ -47,7 +42,6 @@ namespace reasampler { -// Real-namespace-home using-declarations (Q-W6: the namespaces.h shim is retired). using view::buildFolderTree; using view::isOnManualLane; using view::managedLaneKey; @@ -56,35 +50,24 @@ using view::TrackFolderEntry; namespace { -// Track fixed-lane mode value (I_FREEMODE=2). See SDK: 0=normal, 1=free item -// positioning, 2=fixed lanes. +// I_FREEMODE value for fixed lanes. SDK: 0=normal, 1=free item positioning, 2=fixed lanes. constexpr int kFreeModeFixedLanes = 2; -// C_LANESCOLLAPSED display value (char*). SDK: 1=lanes collapsed, -// 2=track displays as non-fixed-lanes but hidden lanes exist. Value 2 is the lever that -// makes a tool-split track read like a NORMAL single-lane track showing only the playing -// lane — the inactive/silenced managed lanes are present but not drawn as separate rows. +// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane +// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist +// but displays as non-fixed-lane). constexpr int kLanesDisplayAsNormal = 2; -// C_LANESETTINGS bit (char* bitmask). SDK: &32=hide lane buttons. We OR this in (never -// clobber the whole mask) to strip the per-lane button chrome from a tool-split track, so -// it reads as an ordinary track. We deliberately do NOT set &1 (auto-remove empty lanes at -// bottom): a managed lane whose item is later deleted would be silently removed out from -// under the ownership index. The lazy-mint decision already avoids ever minting an empty -// lane, so &1 buys nothing and risks a reconcile hazard. +// C_LANESETTINGS &32 = hide per-lane buttons; OR'd in, never clobbering the +// mask. Deliberately NOT setting &1 (auto-remove empty lanes): the lazy-mint +// decision never mints an empty lane, so &1 buys nothing and risks REAPER +// silently removing a managed lane out from under the ownership index. constexpr int kLaneSettingsHideButtons = 32; -// Drives a TOOL-SPLIT track's display transparent: C_LANESCOLLAPSED=2 (render like a normal -// single-lane track showing only the playing lane) + OR C_LANESETTINGS &32 (hide lane -// buttons). Both are char* params driven through the double API, same convention as -// C_LANEPLAYS:N. C_LANESETTINGS is read-modify-write so any pre-existing bit is preserved. -// -// MANAGED-VS-MANUAL BOUNDARY (load-bearing): these are TRACK-LEVEL settings that affect the -// whole track including a user's own manual comp lanes. Every caller gates this on the -// tool-driven transition INTO fixed lanes (freeMode != 2 before the flip), so a track the -// user already had in fixed-lane mode never reaches it and the user's comp-lane display -// prefs are never stomped. Idempotent: a re-run finds the track already at I_FREEMODE==2, -// the transition branch is skipped, and these writes do not fire again. +// Makes a tool-split track's display read as an ordinary track. Gated by every +// caller on the tool-driven transition INTO fixed lanes (freeMode != 2 before +// the flip) — a track already in fixed-lane mode (the user's own) never +// reaches this, so a user's comp-lane display prefs are never stomped. void applyTransparentLaneDisplay(MediaTrack* tr) { SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED", static_cast(kLanesDisplayAsNormal)); @@ -93,8 +76,6 @@ void applyTransparentLaneDisplay(MediaTrack* tr) { static_cast(settings | kLaneSettingsHideButtons)); } -// 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"; @@ -105,11 +86,9 @@ const char* flagParm(Flag f) { 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. +// The master track is not enumerated by GetTrack (index space excludes it), +// so it can never enter the tree — the master-untouched invariant holds by +// construction. Also caches each MediaTrack* by GUID for later resolve(). std::vector readFolderEntries( ReaProject* proj, std::vector>& handleByGuid) { @@ -137,10 +116,8 @@ MediaTrack* resolve(const std::vector>& hand 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). +// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO; +// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract). TrackSnapshot snapshotTrack(MediaTrack* tr) { TrackSnapshot snap; snap.showInTcp = static_cast(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP")); @@ -156,16 +133,14 @@ TrackSnapshot snapshotTrack(MediaTrack* tr) { 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. +// The pure park plan leaves fxOffline empty by design; expand it here from the +// live FX count. void parkFxOffline(MediaTrack* tr) { int fxCount = TrackFX_GetCount(tr); for (int fx = 0; fx < fxCount; ++fx) { @@ -173,15 +148,13 @@ void parkFxOffline(MediaTrack* tr) { } } -// 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). +// Restores per-FX offline from the snapshot, bounds-checked against the live +// FX count (prune-safe if the chain changed while parked). // -// 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. +// HAZARD (open, tracked in docs/TODO.md): this remaps by slot INDEX, not +// plugin identity. If the FX chain reshuffled while parked, snapshot slot k +// restores onto whatever plugin now occupies slot k. Accepted for now; +// identity-based reconciliation is future hardening. void restoreFxOffline(MediaTrack* tr, const std::vector& fxOffline) { int fxCount = TrackFX_GetCount(tr); for (const FxOfflineOp& op : fxOffline) { @@ -190,18 +163,14 @@ void restoreFxOffline(MediaTrack* tr, const std::vector& fxOffline) } } -// -- Managed-lane application (D2 Wave 2) ------------------------------------ -// -// The pure planner emits LanePlayOps keyed by (trackGuid, laneKey) where laneKey is -// the lane's DURABLE name (lane_keys convention: "reasampler:"). REAPER's -// C_LANEPLAYS:N is keyed by the lane's CURRENT ORDINAL, which renumbers on reorder. -// So before applying, we build the ordinal<->key reconcile for a track by reading each -// lane's P_LANENAME:n; the write then targets the correct current ordinal for a given -// durable key even after a reorder (design point #2). A lane whose name lacks the -// managed prefix is manual and never appears in this map, so it can never be driven. +// Managed-lane application: the pure planner keys LanePlayOps by the lane's +// DURABLE name; REAPER's C_LANEPLAYS:N is keyed by current ordinal, which +// renumbers on reorder. So every write here re-resolves durable key -> current +// ordinal first. A lane whose name lacks the managed prefix never enters this +// map and so can never be driven. -// Reads lane index `laneIdx`'s durable name off track `tr` (P_LANENAME:n). Empty if -// the lane is unnamed or the param is unavailable (non-fixed-lane track). +// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed / +// unavailable (non-fixed-lane track). std::string laneName(MediaTrack* tr, int laneIdx) { char parm[32]; std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); @@ -210,9 +179,8 @@ std::string laneName(MediaTrack* tr, int laneIdx) { return std::string(buf); } -// Maps each MANAGED lane's durable key -> its current ordinal on `tr`, by walking the -// track's I_NUMFIXEDLANES lanes and reading each name. Manual (unprefixed/unnamed) -// lanes are omitted, so a key absent from the map is a lane the tool must not drive. +// Managed lane durable key -> current ordinal on `tr`. Manual lanes are +// omitted, so a key absent from the map must not be driven. std::map managedLaneOrdinals(MediaTrack* tr) { std::map byKey; const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); @@ -223,37 +191,25 @@ std::map managedLaneOrdinals(MediaTrack* tr) { return byKey; } -// Drives one managed lane on `tr` to `lanePlays` (C_LANEPLAYS value) via the -// TRACK-SIDE C_LANEPLAYS:N write. Track-side C_LANEPLAYS:N alone produces the -// hide+silence effect for all items on lane N — no per-item write is needed or -// possible (item-side C_LANEPLAYS is marked read-only in the SDK). -// B_FIXEDLANE_HIDDEN is READ-ONLY (SDK) — hide/show follows from C_LANEPLAYS=0/1, -// never written directly. Non-destructive: only reversible play/show flags; no item -// is moved or deleted. -// -// DAW-VERIFY: confirm that track-side C_LANEPLAYS:N alone hides+silences all items -// on lane N without a per-item write. (SDK marks item-side C_LANEPLAYS as read-only; -// the track-side write is the documented mechanism.) +// Track-side C_LANEPLAYS:N alone hides+silences every item on lane N (SDK: +// item-side C_LANEPLAYS is read-only, so no per-item write exists or is +// needed). B_FIXEDLANE_HIDDEN is also read-only — hide/show follows from +// C_LANEPLAYS=0/1, never written directly. void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) { char parm[32]; std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx); SetMediaTrackInfo_Value(tr, parm, static_cast(lanePlays)); } -// Applies the plan's managed-lane ops. Groups ops by track, resolves each op's durable -// laneKey to the track's current ordinal (skipping any key not present on the live -// track — a stale/renamed/deleted managed lane is pruned, never mis-driven), enables -// fixed-lane mode on any track that carries a managed lane, and drives C_LANEPLAYS. -// UpdateTimeline() is called ONCE at the end (SDK: required after I_FREEMODE changes). -// Returns true if any track's I_FREEMODE was (re)set to fixed lanes (⇒ needs timeline -// refresh). MANAGED lanes only — plan.lanes never contains a manual lane (pure planner -// gates on the ownership index), and a manual lane's name never resolves to a key here, -// so the invariant is enforced twice. +// Groups ops by track, reconciles each op's durable laneKey to the track's +// current ordinal (a stale/renamed/deleted key is pruned, never mis-driven), +// enables fixed-lane mode on any track carrying a managed lane, and drives +// C_LANEPLAYS. UpdateTimeline() is the caller's job when this returns true +// (SDK: required after an I_FREEMODE change). bool applyLaneOps(const std::vector>& handleByGuid, const std::vector& lanes) { if (lanes.empty()) return false; - // Group op indices by track guid so we read each track's lane map once. std::map> byTrack; for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op); @@ -262,22 +218,18 @@ bool applyLaneOps(const std::vector>& handle MediaTrack* tr = resolve(handleByGuid, guid); if (!tr) continue; // stale GUID — prune - // Ensure fixed-lane mode is on before driving lane play state. A track carrying - // a managed lane must be in I_FREEMODE=2; set it only if not already, and flag - // that a timeline refresh is owed. Every track reaching this loop is already in the - // managed-lane ownership index (planToggle only emits ops for managed lanes), so a - // track here is one the TOOL split — a re-assert of fixed-lane mode is a tool-driven - // (re)split and must carry the same transparent display, mirroring applyMintPlan's - // transition branch. It is never a user's untouched manual-fixed-lane track. + // Every track reaching here already owns a managed lane (planToggle + // only emits ops for managed lanes), so re-asserting fixed-lane mode + // is always a tool-driven (re)split — never a user's untouched + // manual-fixed-lane track — and gets the same transparent display. const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); if (freeMode != kFreeModeFixedLanes) { SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(kFreeModeFixedLanes)); - applyTransparentLaneDisplay(tr); // tool-managed track ⇒ read like a normal track + applyTransparentLaneDisplay(tr); touchedFreeMode = true; } - // Reconcile durable keys -> current ordinals on THIS track, then drive each op. const std::map ordinals = managedLaneOrdinals(tr); for (const LanePlayOp* op : ops) { auto it = ordinals.find(op->laneKey); @@ -288,20 +240,12 @@ bool applyLaneOps(const std::vector>& handle return touchedFreeMode; } -// -- Managed-lane minting (D2 Wave 3) ---------------------------------------- -// -// Mints one managed fixed lane per mode on any track that now holds content of MORE -// THAN ONE mode, and assigns each item to its mode's managed lane. The DECISION — -// which tracks split, which lanes to mint, which item goes where — is the pure -// planLaneMinting; this shell only reads live per-item mode+lane state, calls the -// decision, and applies the resulting REAPER + ownership-index writes. +// Managed-lane minting: the DECISION (which tracks split, which lanes, which +// item goes where) is planLaneMinting; this shell only reads live per-item +// mode+lane state, calls it, and applies the resulting writes. -// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h): -// itemGuid(it) and itemLaneName(tr, it). view.cpp no longer carries its own copies. - -// Maps every item GUID on `tr` to its MediaItem* handle, in one pass. The assign pass -// resolves plan item GUIDs back to handles through this map rather than re-scanning the -// track per item (avoids the quadratic that a per-item find would incur). +// Maps every item GUID on `tr` to its handle in one pass (avoids a per-item +// re-scan in the assign loop). std::map itemHandlesByGuid(MediaTrack* tr) { std::map byGuid; const int itemCount = CountTrackMediaItems(tr); @@ -314,23 +258,18 @@ std::map itemHandlesByGuid(MediaTrack* tr) { return byGuid; } -// Resolves the mode one item's content belongs to, from the model's membership index. -// An item tagged into exactly one mode returns that mode; an untagged item is an -// Arrange member by default (mirrors leafBelongsToMode's untagged rule). A show-both or -// multi-mode item resolves to its first mode id — such items are unusual for lane -// content, and the pure decision only needs A mode per item; the managed-lane it lands -// on is that mode's lane. Never returns empty for a real item. +// An untagged item is Arrange by default (mirrors leafBelongsToMode). A +// show-both/multi-mode item resolves to its first mode id — unusual for lane +// content, and any one mode is sufficient for the decision. std::string itemModeFromMembership(const ViewModeModel& model, const std::string& itemGuid) { const std::set modes = model.membership().modesOf(itemGuid); - if (modes.empty()) return kArrangeModeId; // untagged ⇒ Arrange default + if (modes.empty()) return kArrangeModeId; return *modes.begin(); } -// Builds the per-track LaneItem picture the pure decision consumes. For each track and -// each item: resolve the item's mode from membership, and — only on a track already in -// fixed-lane mode — read whether it sits on a MANUAL lane (exempt). On a non-fixed-lane -// track no item is on a manual lane (isOnManualLane returns false for the empty name), -// so the manual read is skipped entirely there. +// Builds the per-track LaneItem picture the pure decision consumes. Manual- +// lane reads are skipped on a non-fixed-lane track (isOnManualLane is false +// there regardless of name). std::vector readLaneTracks( const ViewModeModel& model, const std::vector>& handleByGuid) { @@ -353,9 +292,6 @@ std::vector readLaneTracks( LaneItem li; li.guid = ig; li.modeId = itemModeFromMembership(model, ig); - // Manual-lane exemption: only meaningful on a fixed-lane track. The shared - // pure predicate decides; on a normal track it returns false regardless of - // name, so we pass an empty name and skip the P_LANENAME read. const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; li.onManualLane = isOnManualLane(fixedLane, ln); lt.items.push_back(std::move(li)); @@ -365,12 +301,9 @@ std::vector readLaneTracks( return tracks; } -// Assigns item `it` to the managed lane whose durable key resolves to a current ordinal -// on `tr` (via managedLaneOrdinals). Idempotent: writes I_FIXEDLANE only when it differs -// from the item's current lane, so a re-run does not thrash the item or the undo state. -// Returns true iff a write actually changed the item's lane. Non-destructive: only the -// reversible I_FIXEDLANE flag is written — the item is never moved in time or across -// tracks. (I_FIXEDLANE is settable per SDK: "fine to call with setNewValue".) +// Idempotent: writes I_FIXEDLANE only when it differs from the item's current +// lane. Non-destructive — only this reversible flag is written, never a move +// in time or across tracks. bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) { const int current = static_cast(GetMediaItemInfo_Value(it, "I_FIXEDLANE")); if (current == laneOrdinal) return false; // already there — no-op @@ -378,22 +311,16 @@ bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) { return true; } -// Applies the pure LaneMintPlan to the live project. For each track that must split: -// enables fixed lanes, ensures the lane count, stamps each managed lane's durable name, -// records ownership in the model, then assigns each item to its mode's lane by resolving -// the durable key to the lane's current ordinal. Returns true if ANY project write -// changed state (⇒ the caller keeps the Undo block and refreshes the timeline). +// Applies the pure LaneMintPlan. Returns true if any project write actually +// changed state (⇒ caller keeps the Undo block and refreshes the timeline). // -// MANAGED-LANES-ONLY: the plan only ever names lanes with the managed prefix and only -// ever assigns managed-eligible items (manual-lane items were reported exempt and are -// absent from the plan). We only ever GROW I_NUMFIXEDLANES to fit the managed lanes and -// stamp names on the lanes we mint — a user's existing manual lanes keep their ordinals -// below/around ours and are never renamed or reassigned. +// The plan only ever names managed-prefixed lanes and only ever assigns +// managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk, +// so a user's existing manual lanes are never renamed or reassigned. bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, const std::vector>& handleByGuid) { bool changed = false; - // Group mints + assigns by track so each track is set up once. std::map> mintsByTrack; for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m); std::map> assignsByTrack; @@ -403,37 +330,26 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, MediaTrack* tr = resolve(handleByGuid, split.trackGuid); if (!tr) continue; // stale GUID — prune - // Enable fixed-lane mode if not already (SDK: UpdateTimeline() owed after). The - // pre-write freeMode read is ALSO the managed-vs-manual boundary signal: a track that - // was NOT in fixed-lane mode here is one the TOOL is splitting now, so the tool owns - // its lane display and drives it transparent. A track already at I_FREEMODE==2 (user - // had fixed lanes, or a prior tool run) skips this branch — its C_LANESCOLLAPSED / - // C_LANESETTINGS are left exactly as the user set them. + // A track not already in fixed-lane mode is one the tool is splitting + // now, so it owns the display; a track already at I_FREEMODE==2 (the + // user's own, or a prior tool run) skips this and keeps its display prefs. const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); if (freeMode != kFreeModeFixedLanes) { SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(kFreeModeFixedLanes)); - applyTransparentLaneDisplay(tr); // tool-split track ⇒ read like a normal track + applyTransparentLaneDisplay(tr); changed = true; } - // Ensure enough lanes for the managed set WITHOUT shrinking: a track may already - // carry the user's manual lanes, so only GROW the count, never reduce it (which - // would delete a user lane). The managed lanes we mint occupy the tail ordinals. - // laneCount tracks the live I_NUMFIXEDLANES as we grow it: read ONCE here, then - // each mint appends at laneCount and bumps it. No per-mint I_NUMFIXEDLANES re-read - // is needed — nextOrdinal and laneCount are the same running value. + // Grow-only: a track may already carry the user's manual lanes, so the + // lane count only ever increases; managed lanes occupy the tail ordinals. int laneCount = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); - // Which managed keys are already present on this track (durable-name reconcile). std::map present = managedLaneOrdinals(tr); - // Mint each managed lane that is not already present, appending at the tail so an - // existing manual lane is never overwritten. Record ownership in the model. for (const LaneMint* m : mintsByTrack[split.trackGuid]) { model.lanes().setManaged(m->trackGuid, m->laneKey, m->modeId); // ownership if (present.count(m->laneKey)) continue; // already minted — idempotent - // Append at the current tail ordinal, grow the tracked count, stamp its name. const int laneIdx = laneCount++; SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast(laneCount)); char parm[32]; @@ -445,10 +361,6 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, changed = true; } - // Assign each item to its mode's managed lane, resolving the durable key to the - // lane's current ordinal on THIS track. A key not present (shouldn't happen — we - // just minted them all) is skipped rather than mis-assigned. Item handles are - // resolved through a one-pass GUID map (avoids re-scanning the track per item). const std::map ordinals = managedLaneOrdinals(tr); const std::map itemsByGuid = itemHandlesByGuid(tr); for (const LaneAssign* a : assignsByTrack[split.trackGuid]) { @@ -465,21 +377,18 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, } // 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; + return false; // reject before touching the project — no partial apply } std::vector> handleByGuid; std::vector entries = readFolderEntries(proj, handleByGuid); FolderTree tree = buildFolderTree(entries); - // Reconcile orphaned model state BEFORE planning: prune snapshots whose track was - // deleted from the project (its GUID no longer appears in the live enumeration). - // handleByGuid holds every currently-enumerated track GUID, so its keys are the - // authoritative live set. Membership is intentionally NOT pruned (undo-delete - // restores the same GUID — see ViewModeModel::reconcile). Because reapply-on-load - // routes through applyMode, this also reconciles on project open. + // Prune snapshots for tracks no longer in the live enumeration before + // planning (membership is intentionally left alone — see model.reconcile). + // Because reapply-on-load routes through applyMode, this also reconciles + // on project open. std::set liveGuids; for (const auto& kv : handleByGuid) liveGuids.insert(kv.first); model.reconcile(liveGuids); @@ -488,31 +397,25 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject 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. + // PARK: snapshot before mutating, store into the model, then apply. 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; + if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track const std::string& guid = tp.flags.front().guid; MediaTrack* tr = resolve(handleByGuid, guid); if (!tr) continue; // stale GUID — prune - // Snapshot ONCE, at the first park. If a snapshot already exists the track is - // still parked from a prior apply, and its live flags are the PARKED (hidden) - // values — recapturing here would overwrite the true pre-park state with zeros, - // so a later restore would restore the track to hidden and it would vanish for - // good. Re-applying the park flags to an already-parked track is idempotent and - // fine; only the snapshot must not be recaptured. Restore clears the snapshot, - // so the next genuine park recaptures fresh state. + // Snapshot ONCE, at first park: a snapshot already present means the + // track is still parked from a prior apply, so its live flags are the + // parked values — recapturing would overwrite the true pre-park state + // with zeros and a later restore would hide it for good. Restore + // clears the snapshot, so the next genuine park recaptures fresh state. if (model.snapshot(guid) == nullptr) 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. + // RESTORE: apply verbatim, then drop the consumed snapshot. for (const TrackPlan& tp : plan.restore) { if (tp.flags.empty()) continue; const std::string& guid = tp.flags.front().guid; @@ -524,21 +427,13 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject model.clearSnapshot(guid); } - // MANAGED LANES (D2 item-level projection): drive C_LANEPLAYS so the active mode's - // managed lane plays+shows and every inactive-mode managed lane is silenced+hidden. - // plan.lanes carries MANAGED lanes only (the pure planner gates on the ownership - // index); applyLaneOps additionally resolves each op's durable key against the live - // track's lane names, so a manual lane — which never carries the managed prefix — - // can never be driven. Empty for a D1-only project (no fixed lanes), leaving D1 - // behavior byte-identical. UpdateTimeline() is owed only if a track's I_FREEMODE - // was (re)set to fixed lanes (SDK requirement); deferred to the refresh block below. + // MANAGED LANES: drive C_LANEPLAYS so the active mode's lane plays+shows + // and every other managed lane is silenced+hidden. Empty for a D1-only + // project, leaving that behavior byte-identical. const bool laneModeChanged = applyLaneOps(handleByGuid, plan.lanes); - // PARENT VISIBILITY (never parked): visibleTracks() marks a parent visible when - // a descendant leaf is visible in the target mode OR the parent belongs to the - // mode by its own membership (untagged folder → Arrange default). Recomputed - // every toggle rather than snapshotted. Drive only the two visibility flags; - // never touch B_MAINSEND/I_FXEN/FX-offline on a parent. + // PARENT VISIBILITY (never parked): recomputed every toggle, never + // snapshotted. Only the two visibility flags — never mainSend/FX on a parent. std::set visible = model.visibleTracks(tree, targetModeId); for (const FolderNode& node : tree.nodes) { if (!node.isParent) continue; @@ -549,10 +444,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject SetMediaTrackInfo_Value(tr, "B_SHOWINMIXER", show); } - // Build the undo label from the ACTUAL target mode's display name, so activating - // Arrange doesn't leave an "activate Design view" undo point (and vice versa). - // The target is guaranteed registered (checked at entry), so query() is non-null; - // fall back to the id defensively if that ever changes. + // Target is guaranteed registered (checked at entry); fall back to the id + // defensively if that ever changes. const Mode* targetMode = model.modes().query(targetModeId); const std::string undoLabel = "ReaSampler: activate " + @@ -560,18 +453,14 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject model.setActiveMode(targetModeId); - // Force REAPER to rebuild the TCP + MCP so visibility/park changes appear now, - // not on the user's next TCP interaction. TrackList_AdjustWindows(false) does the - // major (full) relayout required when tracks appear/disappear from the panels; - // UpdateArrange() repaints the arrange view. Both are documented for exactly this - // "you changed track-info flags, now refresh the panels" case. + // Force REAPER to rebuild the TCP/MCP now rather than on the next user + // interaction: TrackList_AdjustWindows(false) does the full relayout owed + // when tracks appear/disappear; UpdateArrange() repaints. TrackList_AdjustWindows(false); UpdateArrange(); - // A fixed-lane mode change (I_FREEMODE -> 2) requires UpdateTimeline() to take - // visible effect (SDK). Call it only when we actually toggled a track into fixed - // lanes this apply; the C_LANEPLAYS writes themselves are picked up by the arrange - // refresh above. + // UpdateTimeline() is owed only when a track was actually toggled into + // fixed lanes this apply (SDK requirement for I_FREEMODE changes). if (laneModeChanged) UpdateTimeline(); Undo_EndBlock2(proj, undoLabel.c_str(), -1); @@ -581,63 +470,41 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { std::vector> handleByGuid; std::vector entries = readFolderEntries(proj, handleByGuid); - // The minting decision is now folder-tree / visibility aware: it needs the tree to - // detect a content-bearing folder derived-visible in >1 mode (which must lane-separate - // its own media even when that media is single-mode). Build it exactly as applyMode does. + // The tree is needed to detect a content-bearing folder derived-visible in + // >1 mode, exactly as applyMode builds it. const FolderTree tree = buildFolderTree(entries); - // Build the live per-track item picture and run the PURE decision. A track visible in - // exactly one mode produces no split; a track visible in >1 mode while carrying its own - // media (own items span modes, OR a folder derived-visible across modes) produces mints - // + assignments. Manual-lane items are reported exempt inside readLaneTracks; show-both - // tracks are skipped inside the decision. const std::vector tracks = readLaneTracks(model, handleByGuid); const LaneMintPlan plan = planLaneMinting(model, tree, tracks); if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick - // Wrap the structural mutation in ONE Undo block (unlike the invisible membership - // tag). Only opened when the plan is non-empty; applyMintPlan reports whether any - // write actually changed state so we can label the undo meaningfully. Undo_BeginBlock2(proj); const bool changed = applyMintPlan(model, plan, handleByGuid); if (!changed) { - // The plan was non-empty but every REAPER write was already satisfied. Close the - // block with no description so REAPER discards the empty undo point rather than - // flooding history with a no-change entry every detection tick. + // Plan was non-empty but every write was already satisfied — discard + // the empty undo point rather than flooding history every detect tick. Undo_EndBlock2(proj, "", 0); - // BUT the arrange still needs a redraw. On the detect-tick caller (bankPanelRefresh) - // mintManagedLanes runs only when this tick just tagged new content, and a NON-EMPTY - // plan means that content sits on a managed-split track. The idempotent no-op path is - // reached when a freshly-inserted item ALREADY landed on the active mode's playing - // lane (REAPER places a new item on the playing lane; the active mode's lane IS the - // playing lane, so assignItemToLane sees I_FIXEDLANE unchanged and writes nothing). - // The item is correctly placed and confined, but the arrange was never told to - // repaint it onto the lane — so it stayed invisible until a manual mode toggle forced - // applyMode's refresh. Force the redraw here so the item appears immediately without a - // toggle. UpdateArrange() only repaints (no I_FREEMODE transition happened on this - // path, so UpdateTimeline is not owed); it is NOT a project mutation, so it stays - // outside the undo block and adds no history entry. On the action caller (doMoveItems) - // this is a harmless repaint immediately before its own reapplyActiveMode() refresh. + // The arrange still needs a redraw: this no-op path is reached when a + // freshly-inserted item already landed on the active mode's playing + // lane (REAPER places new items on the playing lane), so + // assignItemToLane wrote nothing even though the item needs to appear + // there now. UpdateArrange() alone (no I_FREEMODE change happened, so + // UpdateTimeline isn't owed) is a repaint, not a mutation — stays + // outside the undo block. UpdateArrange(); return false; } - // Reapply the active mode's lane visibility so the freshly-minted lanes take their - // correct play/show state immediately: the active mode's lane plays+shows, every - // other managed lane hides+silences. Reusing planToggle's lane ops keeps the drive - // logic in one place; applyLaneOps also (re)asserts I_FREEMODE and drives C_LANEPLAYS. - // NOTE: applyMode is NOT reused here — it would re-park/restore whole tracks and - // recompute parent visibility, which the minting tick must not do (it only just - // changed item lanes). Driving lane play state directly is the minimal correct step. + // Reapply the active mode's lane visibility so freshly-minted lanes take + // their play/show state immediately. applyMode is deliberately NOT reused + // here — it would re-park/restore whole tracks and recompute parent + // visibility, which a lane-only mint must not touch. const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId()); applyLaneOps(handleByGuid, togglePlan.lanes); - // I_FREEMODE was (re)set to fixed lanes on at least one track (the plan minted a - // split), so a timeline refresh is owed (SDK). Repaint the arrange too so the new - // lane layout appears immediately. - UpdateTimeline(); + UpdateTimeline(); // a split happened this call — refresh is owed UpdateArrange(); Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", -1); @@ -648,12 +515,9 @@ void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { std::vector> handleByGuid; readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here) - // Walk every track's lanes; for each lane whose durable name carries the managed - // prefix, record it MANAGED-for-its-mode in the ownership index. This is a pure READ - // of REAPER state (no lane is created, no I_FREEMODE/I_NUMFIXEDLANES/I_FIXEDLANE is - // written) plus an index write — self-healing classification from the source of - // truth (the durable name) without re-minting or mass-tagging. A lane lacking the - // prefix is left alone (manual by default), so a user's own lanes stay off the index. + // Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/ + // I_FIXEDLANE written) plus an ownership-index write, recovering managed + // classification from the durable name. An unprefixed lane is left alone. for (const auto& [guid, tr] : handleByGuid) { const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed @@ -666,15 +530,12 @@ void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { std::optional mode = modeIdFromLaneName(name); if (!mode) continue; // prefix-only/illegal name — skip defensively - // UNREGISTERED-MODE GUARD: the durable name encodes a mode id, but that mode - // may no longer be a registered Mode (e.g. a mode removed from the registry - // after the project was saved with lanes minted for it). Recording it MANAGED - // would make the toggle planner drive a lane keyed to a mode that can never be - // the active mode — the lane would stay silenced+hidden forever, orphaning its - // items with no way for the user to reach them. So we do NOT record it: the - // lane is left off the ownership index and thus treated as manual-by-default - // (never driven). Its durable name is preserved on the track, so if the mode is - // ever re-registered a later reconcile recovers the ownership cleanly. + // A mode id encoded in the name may no longer be registered (e.g. + // removed since the project was saved). Recording it managed would + // make the toggle planner drive a lane keyed to a mode that can + // never be active — permanently silenced, orphaning its items. So + // skip: the lane stays off the index (manual-by-default) but keeps + // its name, so a later re-registration of the mode heals cleanly. if (!model.modes().contains(*mode)) continue; model.lanes().setManaged(guid, *key, *mode); } diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 9260cd7..856a0cd 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -1,95 +1,37 @@ #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. -// * Manages ALL leaves via the mode system: an untagged leaf is an Arrange member, -// so it is fully parked in non-Arrange modes and restored in Arrange, identically -// to a tagged leaf. show-both is the always-visible escape; parents are -// visibility-only (derived); the master is never touched. -// * 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. +// REAPER-facing shell of Design View (D2): reads the live folder tree, runs +// ViewModeModel's pure planner, and applies the resulting flag / per-FX / +// lane writes. The .cpp is the sole REAPER-facing TU here (CLAUDE.md contract: +// only main.cpp defines the API pointers). See src/shell/view/CLAUDE.md for +// the enforced invariants (never touch master/mute/solo, snapshot-based restore). #include #include "core/view/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. +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. 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. +// Snapshots each about-to-park track's flags into `model`, runs planToggle, +// applies park/restore writes plus parent visibility flags, then sets the +// active mode. Wrapped in one Undo block. Returns false (no mutation) if +// `targetModeId` isn't registered. `proj` == nullptr means the current project. bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj); -// Mints managed fixed lanes for any track in `proj` that is VISIBLE IN MORE THAN ONE -// MODE while carrying its own media, and assigns each item to its mode's managed lane -// (Phase D2 Wave 3; visibility trigger added by the folder-media fix). -// 1. Enumerates every track + its items; resolves each item's mode from the model's -// membership (untagged ⇒ Arrange) and reads whether it currently sits on a MANUAL -// lane (exempt). Builds the FolderTree (I_FOLDERDEPTH) so derived visibility counts. -// 2. Runs the pure planLaneMinting decision (model + tree aware). A track visible in -// exactly one mode is left whole-track-parked (D1) — NOT lane-split. A track visible -// in >1 mode while carrying own media splits: its own items span modes, OR it is a -// content-bearing folder derived-visible across modes. show-both tracks never split. -// 3. For each track that must split: enables fixed-lane mode (I_FREEMODE=2), ensures -// enough fixed lanes (I_NUMFIXEDLANES), stamps each managed lane's durable name -// (P_LANENAME:n), records the lane MANAGED-for-its-mode in the model's ownership -// index, and assigns each managed-eligible item to its mode's lane (I_FIXEDLANE). -// Manual lanes and the items on them are NEVER minted-over or reassigned. -// 4. Reapplies the active mode's lane visibility so the just-minted lanes take their -// correct play/show state immediately (the active mode's lane plays; others hide). -// The whole structural mutation is wrapped in ONE Undo_BeginBlock2/EndBlock2 — but only -// when the plan is non-empty (no undo point for a tick that mints nothing). -// -// Returns true if any lane was minted this call (⇒ the caller may want a repaint). -// `proj` may be nullptr to mean REAPER's current project. READ of the membership index -// only; the sole model mutation is recording new managed-lane ownership. +// Splits any track visible in more than one mode while carrying its own media +// into fixed lanes (one managed lane per involved mode), assigns items, and +// records ownership in `model`. Never touches manual lanes. Runs planLaneMinting +// (model + tree aware); wraps the mutation in one Undo block when non-empty. +// Returns true if any lane was minted (repaint hint). `proj` == nullptr means +// the current project. bool mintManagedLanes(ViewModeModel& model, ReaProject* proj); -// Reconciles the model's lane-ownership index against the live project's lanes on -// project open (Phase D2 Wave 3). REAPER's durable P_LANENAME is the source of truth for -// lane identity across sessions (design point #2): a lane whose name carries the managed -// prefix is tool-managed and owned by the mode encoded in that name. This walks every -// track's lanes and records each managed-named lane MANAGED-for-its-mode in the index — -// self-healing a saved project's classification WITHOUT re-minting (it never creates a -// lane, changes I_FREEMODE/I_NUMFIXEDLANES, or reassigns an item) and WITHOUT mass- -// tagging (it never touches membership). A lane without the managed prefix is left -// untouched (manual by default). Reload's active-mode lane visibility is then reapplied -// by the caller's applyMode, mirroring D1's reapply-on-open. -// -// `proj` may be nullptr to mean REAPER's current project. The only model mutation is -// recording managed ownership recovered from durable lane names. +// Recovers managed-lane ownership from durable P_LANENAME on project open — +// self-healing, without minting/reassigning anything and without touching +// membership. A lane without the managed prefix is left untouched (manual). +// `proj` == nullptr means the current project. void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj); } // namespace reasampler