feat(view_mode_model): add D2 pure lane model (ownership index, lane ops, auto-tag)
Extends the REAPER-free view_mode_model for per-item mode membership via fixed lanes: lane->mode C_LANEPLAYS mapping, managed-vs-manual lane-ownership index, managed-only item-lane ops in the toggle planner, "which lanes may this toggle touch" query, auto-tag decision (manual-lane items exempt), and JSON round-trip of the lane index. D1 behavior and tests unchanged.
This commit is contained in:
+141
-1
@@ -89,6 +89,63 @@ std::set<std::string> MembershipIndex::modesOf(const std::string& guid) const {
|
||||
return m ? m->modeIds : std::set<std::string>{};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LaneOwnershipIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool LaneOwnershipIndex::setManaged(const std::string& trackGuid, const std::string& laneKey,
|
||||
const std::string& modeId) {
|
||||
if (trackGuid.empty() || laneKey.empty() || modeId.empty()) return false;
|
||||
entries_[LaneRef{trackGuid, laneKey}] = LaneOwnership{modeId};
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LaneOwnershipIndex::setManual(const std::string& trackGuid, const std::string& laneKey) {
|
||||
if (trackGuid.empty() || laneKey.empty()) return false;
|
||||
entries_[LaneRef{trackGuid, laneKey}] = LaneOwnership{std::nullopt};
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LaneOwnershipIndex::remove(const std::string& trackGuid, const std::string& laneKey) {
|
||||
return entries_.erase(LaneRef{trackGuid, laneKey}) > 0;
|
||||
}
|
||||
|
||||
const LaneOwnership* LaneOwnershipIndex::query(const std::string& trackGuid,
|
||||
const std::string& laneKey) const {
|
||||
auto it = entries_.find(LaneRef{trackGuid, laneKey});
|
||||
return it == entries_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
int laneModeState(const std::string& managedMode, const std::string& activeMode) {
|
||||
// The active mode's lane plays exclusively; every other managed lane is silenced
|
||||
// and hidden (C_LANEPLAYS = 0). Exclusive membership: only one stance's lane at a
|
||||
// time. Show-both, which keeps a lane audible across modes, is a per-lane opt-out
|
||||
// the shell layers on; the default per-mode decision here is exclusive.
|
||||
return managedMode == activeMode ? kLanePlaysExclusive : kLaneSilent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// auto-tag decision
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackGuids,
|
||||
const std::vector<NewItem>& newItems,
|
||||
const std::string& activeMode) {
|
||||
std::vector<AutoTag> tags;
|
||||
if (activeMode.empty()) return tags; // nothing to tag into
|
||||
|
||||
for (const auto& guid : newTrackGuids) {
|
||||
if (guid.empty()) continue;
|
||||
tags.push_back(AutoTag{guid, activeMode});
|
||||
}
|
||||
for (const auto& item : newItems) {
|
||||
if (item.guid.empty()) continue;
|
||||
if (item.onManualLane) continue; // manual-lane content is off-limits to auto-tag
|
||||
tags.push_back(AutoTag{item.guid, activeMode});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// planner helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -255,11 +312,35 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string&
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
for (const auto& [ref, ownership] : lanes_.all()) {
|
||||
if (!ownership.isManaged()) continue; // manual lanes are off-limits
|
||||
const int lanePlays = laneModeState(*ownership.managedMode, targetMode);
|
||||
plan.lanes.push_back(LanePlayOp{ref.trackGuid, ref.laneKey, lanePlays});
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
std::set<LaneRef> ViewModeModel::lanesTouchedByToggle() const {
|
||||
// Managed-only: exactly the lanes a toggle is permitted to drive. A manual lane —
|
||||
// absent OR recorded manual in the ownership index — is never returned, so the shell
|
||||
// can never write C_LANEPLAYS to a lane the user hand-manages.
|
||||
std::set<LaneRef> touched;
|
||||
for (const auto& [ref, ownership] : lanes_.all()) {
|
||||
if (ownership.isManaged()) touched.insert(ref);
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
bool ViewModeModel::operator==(const ViewModeModel& o) const {
|
||||
return modes_ == o.modes_ && membership_ == o.membership_ &&
|
||||
return modes_ == o.modes_ && membership_ == o.membership_ && lanes_ == o.lanes_ &&
|
||||
activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_;
|
||||
}
|
||||
|
||||
@@ -403,6 +484,25 @@ 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.
|
||||
root.keyBegin("lanes");
|
||||
out += '[';
|
||||
{
|
||||
bool first = true;
|
||||
for (const auto& [ref, ownership] : lanes_.all()) {
|
||||
if (!first) out += ','; first = false;
|
||||
ObjWriter e(out);
|
||||
e.keyStr("trackGuid", ref.trackGuid);
|
||||
e.keyStr("laneKey", ref.laneKey);
|
||||
e.keyRaw("managed", ownership.isManaged() ? "true" : "false");
|
||||
if (ownership.isManaged()) e.keyStr("mode", *ownership.managedMode);
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
} // root closes here (see bank_model note on NRVO + deferred close)
|
||||
return out;
|
||||
}
|
||||
@@ -447,6 +547,7 @@ private:
|
||||
bool parseModes(ModeRegistry& reg);
|
||||
bool parseMembership(MembershipIndex& idx);
|
||||
bool parseSnapshots(std::map<std::string, TrackSnapshot>& snaps);
|
||||
bool parseLanes(LaneOwnershipIndex& idx);
|
||||
bool parseIntArray(std::vector<int>& out);
|
||||
};
|
||||
|
||||
@@ -701,6 +802,41 @@ bool Parser::parseSnapshots(std::map<std::string, TrackSnapshot>& snaps) {
|
||||
return consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseLanes(LaneOwnershipIndex& idx) {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true;
|
||||
do {
|
||||
if (!consume('{')) return false;
|
||||
std::string trackGuid, laneKey, mode;
|
||||
bool haveTrack = false, haveLane = false, managed = false, haveManaged = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!parseKey(k)) return false;
|
||||
if (k == "trackGuid") { if (!parseString(trackGuid)) return false; haveTrack = true; }
|
||||
else if (k == "laneKey") { if (!parseString(laneKey)) return false; haveLane = true; }
|
||||
else if (k == "managed") { if (!parseBool(managed)) return false; haveManaged = true; }
|
||||
else if (k == "mode") { if (!parseString(mode)) return false; }
|
||||
else if (!skipValue()) return false;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
// Both keys mandatory and non-empty (they form the lane's identity). A managed
|
||||
// lane must carry a non-empty mode; a manual lane must not claim one. Enforcing
|
||||
// this on parse keeps a round-tripped index byte-for-byte identical to the
|
||||
// serialized one and rejects a malformed managed-without-mode entry.
|
||||
if (!haveTrack || !haveLane || !haveManaged) return false;
|
||||
if (trackGuid.empty() || laneKey.empty()) return false;
|
||||
if (managed) {
|
||||
if (mode.empty()) return false;
|
||||
if (!idx.setManaged(trackGuid, laneKey, mode)) return false;
|
||||
} else {
|
||||
if (!mode.empty()) return false; // manual lane must not carry a mode
|
||||
if (!idx.setManual(trackGuid, laneKey)) return false;
|
||||
}
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseModel(ViewModeModel& out) {
|
||||
if (!consume('{')) return false;
|
||||
skipWs();
|
||||
@@ -711,6 +847,7 @@ bool Parser::parseModel(ViewModeModel& out) {
|
||||
std::string activeMode;
|
||||
bool haveActive = false;
|
||||
MembershipIndex membership;
|
||||
LaneOwnershipIndex lanes;
|
||||
std::map<std::string, TrackSnapshot> snaps;
|
||||
|
||||
do {
|
||||
@@ -728,6 +865,8 @@ bool Parser::parseModel(ViewModeModel& out) {
|
||||
if (!parseMembership(membership)) return false;
|
||||
} else if (key == "snapshots") {
|
||||
if (!parseSnapshots(snaps)) return false;
|
||||
} else if (key == "lanes") {
|
||||
if (!parseLanes(lanes)) return false;
|
||||
} else {
|
||||
// Unknown keys and the "version" field are skipped here.
|
||||
// "version" is serialized as a forward-compat placeholder — there is no
|
||||
@@ -743,6 +882,7 @@ bool Parser::parseModel(ViewModeModel& out) {
|
||||
|
||||
if (haveModes) out.modes() = reg;
|
||||
out.membership() = membership;
|
||||
out.lanes() = lanes;
|
||||
for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap);
|
||||
if (haveActive) {
|
||||
if (!out.setActiveMode(activeMode)) return false; // active mode must exist
|
||||
|
||||
Reference in New Issue
Block a user