Merge dev (Phase D1) into p1-w9-bank-panel
# Conflicts: # CMakeLists.txt
This commit is contained in:
@@ -0,0 +1,724 @@
|
||||
#include "view_mode_model.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <climits>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
// view_mode_model implementation.
|
||||
//
|
||||
// JSON is hand-rolled and self-contained, mirroring bank_model's approach (brief:
|
||||
// keep the pure core dependency-free — no third-party JSON lib). A compact writer
|
||||
// plus a recursive-descent parser covers the field set: the mode registry, the
|
||||
// GUID-keyed membership map, per-track snapshots (with a variable-length per-FX
|
||||
// offline vector), and the active mode. Ints are emitted plainly; strings are
|
||||
// escaped identically to bank_model so control chars and unicode survive.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// equality
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool Mode::operator==(const Mode& o) const {
|
||||
return id == o.id && displayName == o.displayName && ordinal == o.ordinal;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModeRegistry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ModeRegistry::ModeRegistry() {
|
||||
modes_.push_back(Mode{kArrangeModeId, "Arrange", 0});
|
||||
modes_.push_back(Mode{kDesignModeId, "Design", 1});
|
||||
}
|
||||
|
||||
bool ModeRegistry::add(const Mode& mode) {
|
||||
if (mode.id.empty()) return false;
|
||||
if (query(mode.id) != nullptr) return false; // ids are unique
|
||||
modes_.push_back(mode);
|
||||
// Keep ordinal order stable; std::stable_sort so equal ordinals keep insertion
|
||||
// order (the tie-break documented in the header).
|
||||
std::stable_sort(modes_.begin(), modes_.end(),
|
||||
[](const Mode& a, const Mode& b) { return a.ordinal < b.ordinal; });
|
||||
return true;
|
||||
}
|
||||
|
||||
const Mode* ModeRegistry::query(const std::string& id) const {
|
||||
for (const auto& m : modes_)
|
||||
if (m.id == id) return &m;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MembershipIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool MembershipIndex::tag(const std::string& guid, const std::string& modeId) {
|
||||
if (guid.empty() || modeId.empty()) return false;
|
||||
Membership& m = entries_[guid];
|
||||
m.modeIds.clear(); // a leaf lives in exactly one mode (show-both aside)
|
||||
m.modeIds.insert(modeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MembershipIndex::untag(const std::string& guid) {
|
||||
return entries_.erase(guid) > 0;
|
||||
}
|
||||
|
||||
bool MembershipIndex::setShowBoth(const std::string& guid, bool showBoth) {
|
||||
if (guid.empty()) return false;
|
||||
entries_[guid].showBoth = showBoth; // creates an Arrange-default entry if new
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MembershipIndex::restore(const std::string& guid, const Membership& membership) {
|
||||
if (guid.empty()) return false;
|
||||
entries_[guid] = membership;
|
||||
return true;
|
||||
}
|
||||
|
||||
const Membership* MembershipIndex::query(const std::string& guid) const {
|
||||
auto it = entries_.find(guid);
|
||||
return it == entries_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
std::set<std::string> MembershipIndex::modesOf(const std::string& guid) const {
|
||||
const Membership* m = query(guid);
|
||||
return m ? m->modeIds : std::set<std::string>{};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// planner helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
|
||||
// Parking contract: hide both panels, out of the mix, FX bypassed, every FX
|
||||
// offline. All fixed zeros — park never consults a snapshot.
|
||||
TrackPlan p;
|
||||
p.flags = {
|
||||
{guid, Flag::ShowInTcp, 0},
|
||||
{guid, Flag::ShowInMixer, 0},
|
||||
{guid, Flag::MainSend, 0},
|
||||
{guid, Flag::FxEnable, 0},
|
||||
};
|
||||
for (int i = 0; i < fxCount; ++i)
|
||||
p.fxOffline.push_back({guid, i, true});
|
||||
return p;
|
||||
}
|
||||
|
||||
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
|
||||
// Restore contract: every driven flag returns to its SNAPSHOTTED value — never
|
||||
// a hardcoded "on"/default. A flag captured at 0 restores to 0.
|
||||
TrackPlan p;
|
||||
p.flags = {
|
||||
{guid, Flag::ShowInTcp, snap.showInTcp},
|
||||
{guid, Flag::ShowInMixer, snap.showInMixer},
|
||||
{guid, Flag::MainSend, snap.mainSend},
|
||||
{guid, Flag::FxEnable, snap.fxEnable},
|
||||
};
|
||||
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i)
|
||||
p.fxOffline.push_back({guid, static_cast<int>(i), snap.fxOffline[i] != 0});
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ViewModeModel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ViewModeModel::ViewModeModel() : activeModeId_(kArrangeModeId) {}
|
||||
|
||||
bool ViewModeModel::setActiveMode(const std::string& modeId) {
|
||||
if (!modes_.contains(modeId)) return false;
|
||||
activeModeId_ = modeId;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ViewModeModel::storeSnapshot(const std::string& guid, const TrackSnapshot& snap) {
|
||||
snapshots_[guid] = snap;
|
||||
}
|
||||
|
||||
void ViewModeModel::clearSnapshot(const std::string& guid) {
|
||||
snapshots_.erase(guid);
|
||||
}
|
||||
|
||||
const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
|
||||
auto it = snapshots_.find(guid);
|
||||
return it == snapshots_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
bool ViewModeModel::leafBelongsToMode(const std::string& guid, const std::string& modeId) const {
|
||||
const Membership* m = membership_.query(guid);
|
||||
if (!m) return modeId == kArrangeModeId; // untagged ⇒ Arrange default
|
||||
if (m->showBoth) return true; // show-both ⇒ every mode
|
||||
if (m->modeIds.empty()) return modeId == kArrangeModeId; // show-both-cleared, no mode
|
||||
return m->modeIds.count(modeId) > 0;
|
||||
}
|
||||
|
||||
std::set<std::string> ViewModeModel::visibleTracks(const FolderTree& tree,
|
||||
const std::string& modeId) const {
|
||||
std::set<std::string> visible;
|
||||
|
||||
// Pass 1: every leaf that belongs to the mode is visible.
|
||||
for (const auto& node : tree.nodes) {
|
||||
if (node.isParent) continue; // parents derived in pass 2
|
||||
if (leafBelongsToMode(node.guid, modeId))
|
||||
visible.insert(node.guid);
|
||||
}
|
||||
|
||||
// Pass 2: a parent is visible if any descendant leaf is visible. Walk each
|
||||
// visible leaf up its parent chain and mark ancestors. Parent chains are read
|
||||
// from the supplied tree only (no REAPER access). A cycle-guard bounds the walk
|
||||
// in case a malformed tree links a node to itself.
|
||||
std::map<std::string, std::string> parentOf;
|
||||
for (const auto& node : tree.nodes) parentOf[node.guid] = node.parentGuid;
|
||||
|
||||
// Snapshot the leaf-visible set so we don't re-walk parents we add mid-loop.
|
||||
const std::vector<std::string> seeds(visible.begin(), visible.end());
|
||||
for (const auto& leaf : seeds) {
|
||||
auto it = parentOf.find(leaf);
|
||||
std::size_t guard = 0;
|
||||
while (it != parentOf.end() && !it->second.empty() && guard++ < parentOf.size()) {
|
||||
const std::string& parent = it->second;
|
||||
if (!visible.insert(parent).second) break; // already marked ⇒ chain done
|
||||
it = parentOf.find(parent);
|
||||
}
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& targetMode) const {
|
||||
TogglePlan plan;
|
||||
|
||||
// Index the tree so we can classify each tagged GUID (leaf vs parent vs stale).
|
||||
std::map<std::string, const FolderNode*> byGuid;
|
||||
for (const auto& node : tree.nodes) byGuid[node.guid] = &node;
|
||||
|
||||
for (const auto& [guid, m] : membership_.all()) {
|
||||
auto it = byGuid.find(guid);
|
||||
if (it == byGuid.end()) continue; // stale GUID: prune-safe, ignore
|
||||
if (it->second->isParent) continue; // parents are derived, never parked
|
||||
if (m.showBoth) continue; // show-both leaves are never parked
|
||||
|
||||
const bool active = leafBelongsToMode(guid, targetMode);
|
||||
if (active) {
|
||||
// Returning to visibility: restore from snapshot if we have one. No
|
||||
// snapshot ⇒ the track was never parked, nothing to restore.
|
||||
if (const TrackSnapshot* snap = snapshot(guid))
|
||||
plan.restore.push_back(makeRestorePlan(guid, *snap));
|
||||
} else {
|
||||
// Inactive tagged leaf ⇒ park. fxOffline is intentionally empty here:
|
||||
// the D2 shell expands per-FX offline writes using TrackFX_GetCount.
|
||||
// The pure model has no access to REAPER FX counts at plan time;
|
||||
// makeParkPlan(guid, 0) emits only the scalar flags as a result.
|
||||
plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0));
|
||||
}
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
bool ViewModeModel::operator==(const ViewModeModel& o) const {
|
||||
return modes_ == o.modes_ && membership_ == o.membership_ &&
|
||||
activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// JSON — writer
|
||||
// ===========================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
void writeEscaped(std::string& out, const std::string& s) {
|
||||
out += '"';
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(c));
|
||||
out += buf;
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
out += '"';
|
||||
}
|
||||
|
||||
std::string intToStr(int v) {
|
||||
char buf[16];
|
||||
std::snprintf(buf, sizeof(buf), "%d", v);
|
||||
return buf;
|
||||
}
|
||||
|
||||
void writeIntArray(std::string& out, const std::vector<int>& v) {
|
||||
out += '[';
|
||||
for (std::size_t i = 0; i < v.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
out += intToStr(v[i]);
|
||||
}
|
||||
out += ']';
|
||||
}
|
||||
|
||||
class ObjWriter {
|
||||
public:
|
||||
explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; }
|
||||
~ObjWriter() { out_ += '}'; }
|
||||
|
||||
void keyRaw(const char* key, const std::string& rawValue) {
|
||||
sep();
|
||||
writeEscaped(out_, key);
|
||||
out_ += ':';
|
||||
out_ += rawValue;
|
||||
}
|
||||
void keyStr(const char* key, const std::string& value) {
|
||||
sep();
|
||||
writeEscaped(out_, key);
|
||||
out_ += ':';
|
||||
writeEscaped(out_, value);
|
||||
}
|
||||
void keyBegin(const char* key) {
|
||||
sep();
|
||||
writeEscaped(out_, key);
|
||||
out_ += ':';
|
||||
}
|
||||
|
||||
private:
|
||||
void sep() { if (first_) first_ = false; else out_ += ','; }
|
||||
std::string& out_;
|
||||
bool first_ = true;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string ViewModeModel::serialize() const {
|
||||
std::string out;
|
||||
{
|
||||
ObjWriter root(out);
|
||||
root.keyRaw("version", intToStr(1));
|
||||
root.keyStr("activeMode", activeModeId_);
|
||||
|
||||
// modes
|
||||
root.keyBegin("modes");
|
||||
out += '[';
|
||||
{
|
||||
const auto& all = modes_.all();
|
||||
for (std::size_t i = 0; i < all.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
ObjWriter m(out);
|
||||
m.keyStr("id", all[i].id);
|
||||
m.keyStr("displayName", all[i].displayName);
|
||||
m.keyRaw("ordinal", intToStr(all[i].ordinal));
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
|
||||
// membership: array of { guid, modes[], showBoth }
|
||||
root.keyBegin("membership");
|
||||
out += '[';
|
||||
{
|
||||
bool first = true;
|
||||
for (const auto& [guid, mem] : membership_.all()) {
|
||||
if (!first) out += ','; first = false;
|
||||
ObjWriter e(out);
|
||||
e.keyStr("guid", guid);
|
||||
e.keyBegin("modes");
|
||||
out += '[';
|
||||
{
|
||||
bool mf = true;
|
||||
for (const auto& id : mem.modeIds) {
|
||||
if (!mf) out += ','; mf = false;
|
||||
writeEscaped(out, id);
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
e.keyRaw("showBoth", mem.showBoth ? "true" : "false");
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
|
||||
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable, fxOffline[] }
|
||||
root.keyBegin("snapshots");
|
||||
out += '[';
|
||||
{
|
||||
bool first = true;
|
||||
for (const auto& [guid, snap] : snapshots_) {
|
||||
if (!first) out += ','; first = false;
|
||||
ObjWriter e(out);
|
||||
e.keyStr("guid", guid);
|
||||
e.keyRaw("showInTcp", intToStr(snap.showInTcp));
|
||||
e.keyRaw("showInMixer", intToStr(snap.showInMixer));
|
||||
e.keyRaw("mainSend", intToStr(snap.mainSend));
|
||||
e.keyRaw("fxEnable", intToStr(snap.fxEnable));
|
||||
e.keyBegin("fxOffline");
|
||||
writeIntArray(out, snap.fxOffline);
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
} // root closes here (see bank_model note on NRVO + deferred close)
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// JSON — parser (recursive descent; false on any malformed input, never UB)
|
||||
// ===========================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const std::string& s) : s_(s) {}
|
||||
bool parseModel(ViewModeModel& out);
|
||||
|
||||
private:
|
||||
const std::string& s_;
|
||||
std::size_t pos_ = 0;
|
||||
|
||||
bool eof() const { return pos_ >= s_.size(); }
|
||||
|
||||
void skipWs() {
|
||||
while (!eof()) {
|
||||
char c = s_[pos_];
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
bool consume(char c) {
|
||||
skipWs();
|
||||
if (eof() || s_[pos_] != c) return false;
|
||||
++pos_;
|
||||
return true;
|
||||
}
|
||||
bool parseString(std::string& out);
|
||||
bool parseRawScalar(std::string& out);
|
||||
bool parseInt(int& out);
|
||||
bool parseBool(bool& out);
|
||||
bool parseKey(std::string& key);
|
||||
bool skipValue();
|
||||
|
||||
bool parseModes(ModeRegistry& reg);
|
||||
bool parseMembership(MembershipIndex& idx);
|
||||
bool parseSnapshots(std::map<std::string, TrackSnapshot>& snaps);
|
||||
bool parseIntArray(std::vector<int>& out);
|
||||
};
|
||||
|
||||
bool Parser::parseString(std::string& out) {
|
||||
skipWs();
|
||||
if (eof() || s_[pos_] != '"') return false;
|
||||
++pos_;
|
||||
out.clear();
|
||||
while (!eof()) {
|
||||
char c = s_[pos_++];
|
||||
if (c == '"') return true;
|
||||
if (c == '\\') {
|
||||
if (eof()) return false;
|
||||
char e = s_[pos_++];
|
||||
switch (e) {
|
||||
case '"': out += '"'; break;
|
||||
case '\\': out += '\\'; break;
|
||||
case '/': out += '/'; break;
|
||||
case 'b': out += '\b'; break;
|
||||
case 'f': out += '\f'; break;
|
||||
case 'n': out += '\n'; break;
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
auto readHex4 = [&](unsigned int& cp) -> bool {
|
||||
if (pos_ + 4 > s_.size()) return false;
|
||||
cp = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
char h = s_[pos_++];
|
||||
cp <<= 4;
|
||||
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
|
||||
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
|
||||
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
|
||||
else return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
unsigned int hi = 0;
|
||||
if (!readHex4(hi)) return false;
|
||||
unsigned int codePoint = hi;
|
||||
if (hi >= 0xD800 && hi <= 0xDBFF) {
|
||||
if (pos_ + 6 > s_.size()) return false;
|
||||
if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
|
||||
pos_ += 2;
|
||||
unsigned int lo = 0;
|
||||
if (!readHex4(lo)) return false;
|
||||
if (lo < 0xDC00 || lo > 0xDFFF) return false;
|
||||
codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
|
||||
} else if (hi >= 0xDC00 && hi <= 0xDFFF) {
|
||||
return false;
|
||||
}
|
||||
if (codePoint <= 0x7F) {
|
||||
out += static_cast<char>(codePoint);
|
||||
} else if (codePoint <= 0x7FF) {
|
||||
out += static_cast<char>(0xC0 | (codePoint >> 6));
|
||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||
} else if (codePoint <= 0xFFFF) {
|
||||
out += static_cast<char>(0xE0 | (codePoint >> 12));
|
||||
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||
} else {
|
||||
out += static_cast<char>(0xF0 | (codePoint >> 18));
|
||||
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
|
||||
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
return false; // unterminated
|
||||
}
|
||||
|
||||
bool Parser::parseRawScalar(std::string& out) {
|
||||
skipWs();
|
||||
std::size_t start = pos_;
|
||||
while (!eof()) {
|
||||
char c = s_[pos_];
|
||||
if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' ||
|
||||
c == '\n' || c == '\r')
|
||||
break;
|
||||
++pos_;
|
||||
}
|
||||
if (pos_ == start) return false;
|
||||
out.assign(s_, start, pos_ - start);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Parser::parseInt(int& out) {
|
||||
std::string tok;
|
||||
if (!parseRawScalar(tok)) return false;
|
||||
const char* b = tok.c_str();
|
||||
char* end = nullptr;
|
||||
errno = 0;
|
||||
long long v = std::strtoll(b, &end, 10);
|
||||
if (end != b + tok.size()) return false;
|
||||
if (errno == ERANGE) return false;
|
||||
if (v < INT_MIN || v > INT_MAX) return false;
|
||||
out = static_cast<int>(v);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Parser::parseBool(bool& out) {
|
||||
std::string tok;
|
||||
if (!parseRawScalar(tok)) return false;
|
||||
if (tok == "true") { out = true; return true; }
|
||||
if (tok == "false") { out = false; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Parser::parseKey(std::string& key) {
|
||||
if (!parseString(key)) return false;
|
||||
return consume(':');
|
||||
}
|
||||
|
||||
bool Parser::skipValue() {
|
||||
skipWs();
|
||||
if (eof()) return false;
|
||||
char c = s_[pos_];
|
||||
if (c == '"') { std::string tmp; return parseString(tmp); }
|
||||
if (c == '{' || c == '[') {
|
||||
char open = c, close = (c == '{') ? '}' : ']';
|
||||
++pos_;
|
||||
int depth = 1;
|
||||
while (!eof() && depth > 0) {
|
||||
char d = s_[pos_];
|
||||
if (d == '"') { std::string tmp; if (!parseString(tmp)) return false; continue; }
|
||||
if (d == open) ++depth;
|
||||
else if (d == close) --depth;
|
||||
++pos_;
|
||||
}
|
||||
return depth == 0;
|
||||
}
|
||||
std::string tmp;
|
||||
return parseRawScalar(tmp);
|
||||
}
|
||||
|
||||
bool Parser::parseIntArray(std::vector<int>& out) {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true;
|
||||
do {
|
||||
int v = 0;
|
||||
if (!parseInt(v)) return false;
|
||||
out.push_back(v);
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
}
|
||||
|
||||
// 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 Parser::parseModes(ModeRegistry& reg) {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true; // empty array (unusual, but valid)
|
||||
do {
|
||||
if (!consume('{')) return false;
|
||||
Mode m;
|
||||
bool haveId = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!parseKey(k)) return false;
|
||||
if (k == "id") { if (!parseString(m.id)) return false; haveId = true; }
|
||||
else if (k == "displayName") { if (!parseString(m.displayName)) return false; }
|
||||
else if (k == "ordinal") { if (!parseInt(m.ordinal)) return false; }
|
||||
else if (!skipValue()) return false;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
if (!haveId || !reg.add(m)) return false; // malformed / duplicate id
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseMembership(MembershipIndex& idx) {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true;
|
||||
do {
|
||||
if (!consume('{')) return false;
|
||||
std::string guid;
|
||||
Membership mem;
|
||||
bool haveGuid = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!parseKey(k)) return false;
|
||||
if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; }
|
||||
else if (k == "modes") {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (!consume(']')) {
|
||||
do {
|
||||
std::string id;
|
||||
if (!parseString(id)) return false;
|
||||
mem.modeIds.insert(id);
|
||||
} while (consume(','));
|
||||
if (!consume(']')) return false;
|
||||
}
|
||||
}
|
||||
else if (k == "showBoth") { if (!parseBool(mem.showBoth)) return false; }
|
||||
else if (!skipValue()) return false;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
if (!haveGuid || guid.empty()) return false;
|
||||
// Install the entry verbatim (tag() would clear a multi-mode set and drop
|
||||
// show-both). A serialized entry is trusted to already satisfy the model's
|
||||
// invariants.
|
||||
//
|
||||
// Deliberate tolerance: we do NOT validate that membership modeIds reference
|
||||
// registered modes, and we do not validate snapshot GUIDs against the index.
|
||||
// Stale-GUID and stale-mode tolerance is a stated invariant of this model —
|
||||
// a deserialized entry is treated as trusted data, not as live cross-checked
|
||||
// state. Rejecting stale entries here would violate that invariant. The one
|
||||
// exception is activeMode (validated below in parseModel): a persisted active
|
||||
// mode that no longer exists has an immediate behavioral consequence, so it
|
||||
// is caught and the parse is rejected.
|
||||
if (!idx.restore(guid, mem)) return false;
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseSnapshots(std::map<std::string, TrackSnapshot>& snaps) {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true;
|
||||
do {
|
||||
if (!consume('{')) return false;
|
||||
std::string guid;
|
||||
TrackSnapshot snap;
|
||||
bool haveGuid = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!parseKey(k)) return false;
|
||||
if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; }
|
||||
else if (k == "showInTcp") { if (!parseInt(snap.showInTcp)) return false; }
|
||||
else if (k == "showInMixer") { if (!parseInt(snap.showInMixer)) return false; }
|
||||
else if (k == "mainSend") { if (!parseInt(snap.mainSend)) return false; }
|
||||
else if (k == "fxEnable") { if (!parseInt(snap.fxEnable)) return false; }
|
||||
else if (k == "fxOffline") { if (!parseIntArray(snap.fxOffline)) return false; }
|
||||
else if (!skipValue()) return false;
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
if (!haveGuid || guid.empty()) return false;
|
||||
snaps[guid] = snap;
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseModel(ViewModeModel& out) {
|
||||
if (!consume('{')) return false;
|
||||
skipWs();
|
||||
if (consume('}')) return true; // lenient empty root ⇒ default-seeded model
|
||||
|
||||
ModeRegistry reg; // seeded default; REPLACED if a modes array is present
|
||||
bool haveModes = false;
|
||||
std::string activeMode;
|
||||
bool haveActive = false;
|
||||
MembershipIndex membership;
|
||||
std::map<std::string, TrackSnapshot> snaps;
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
if (!parseKey(key)) return false;
|
||||
if (key == "activeMode") {
|
||||
if (!parseString(activeMode)) return false;
|
||||
haveActive = true;
|
||||
} else if (key == "modes") {
|
||||
ModeRegistry fresh = ModeRegistry::makeEmpty(); // parse into empty, then own
|
||||
if (!parseModes(fresh)) return false;
|
||||
reg = fresh;
|
||||
haveModes = true;
|
||||
} else if (key == "membership") {
|
||||
if (!parseMembership(membership)) return false;
|
||||
} else if (key == "snapshots") {
|
||||
if (!parseSnapshots(snaps)) 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 (!skipValue()) return false;
|
||||
}
|
||||
} while (consume(','));
|
||||
|
||||
if (!consume('}')) return false;
|
||||
skipWs();
|
||||
if (!eof()) return false; // trailing garbage
|
||||
|
||||
if (haveModes) out.modes() = reg;
|
||||
out.membership() = membership;
|
||||
for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap);
|
||||
if (haveActive) {
|
||||
if (!out.setActiveMode(activeMode)) return false; // active mode must exist
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string& json) {
|
||||
ViewModeModel vm;
|
||||
Parser p(json);
|
||||
if (!p.parseModel(vm)) return std::nullopt;
|
||||
return vm;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,316 @@
|
||||
#pragma once
|
||||
// view_mode_model — the pure core of the Design View feature, deliberately free of any
|
||||
// REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the mirror of
|
||||
// bank_model: it owns the mode registry, the GUID-keyed membership index, the
|
||||
// folder-tree-aware visibility derivation, the parking/restore planner, and the
|
||||
// JSON round-trip of all of it.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The folder structure is an INPUT
|
||||
// supplied by the D2 shell (which reads REAPER's I_FOLDERDEPTH); this model never
|
||||
// fetches or stores REAPER's live tree — folder structure is REAPER's truth and
|
||||
// changes underneath us, so it is passed in per query, not held.
|
||||
//
|
||||
// -- Representation decisions (design latitude exercised; invariants below) -----
|
||||
//
|
||||
// * A mode is (stable string id, display name, ordinal). Arrange (id "arrange",
|
||||
// ordinal 0) and Design (id "design", ordinal 1) are seeded. Arrange is the
|
||||
// fallback home for every untagged leaf; structurally it is just another mode.
|
||||
//
|
||||
// * Membership is GUID -> { mode ids } (a set, not a bool) plus a per-track
|
||||
// show-both flag. Normally a leaf is in exactly one mode; multiple only via the
|
||||
// parent-derivation rule (computed, not stored) or the show-both escape hatch.
|
||||
// An untagged GUID is NOT in the index and belongs to Arrange by default.
|
||||
//
|
||||
// * The planner drives exactly four scalar flags (showInTcp, showInMixer,
|
||||
// mainSend, fxEnable) plus a per-FX offline list. Park values are fixed zeros
|
||||
// (defined by the parking contract), so PARK ops need no snapshot. RESTORE ops
|
||||
// come entirely FROM a TrackSnapshot captured before parking — never a hardcoded
|
||||
// default. This is where the restore-contract invariant lives and is tested.
|
||||
//
|
||||
// * The snapshot stores the full prior per-FX offline vector so a save-while-parked
|
||||
// project round-trips and restores each FX to its exact prior offline state. The
|
||||
// pure model does NOT need REAPER FX counts to plan a park (park offlines all N,
|
||||
// which the shell expands from TrackFX_GetCount); it only needs them to restore,
|
||||
// and it gets them from the snapshot it captured.
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Stable seed-mode ids. Arrange is the default home for untagged leaves.
|
||||
inline constexpr const char* kArrangeModeId = "arrange";
|
||||
inline constexpr const char* kDesignModeId = "design";
|
||||
|
||||
// A display "stance" the user adopts. Modes are ordered by `ordinal` for tab order.
|
||||
struct Mode {
|
||||
std::string id; // stable, persisted; never reused for a different mode
|
||||
std::string displayName;
|
||||
int ordinal = 0; // tab order
|
||||
|
||||
bool operator==(const Mode& o) const;
|
||||
};
|
||||
|
||||
// Ordered registry of modes. Arrange + Design are seeded on construction. Add more
|
||||
// to prove the model is N-mode, not boolean. Ids are unique; adding a duplicate id
|
||||
// is rejected.
|
||||
class ModeRegistry {
|
||||
public:
|
||||
ModeRegistry(); // seeds Arrange (ordinal 0) + Design (ordinal 1)
|
||||
|
||||
// Adds a mode. Rejects (returns false, no mutation) an empty or duplicate id.
|
||||
bool add(const Mode& mode);
|
||||
|
||||
// Returns the mode with `id`, or nullptr. Invalidated by any mutating call.
|
||||
const Mode* query(const std::string& id) const;
|
||||
|
||||
bool contains(const std::string& id) const { return query(id) != nullptr; }
|
||||
|
||||
// All modes in ordinal order (ties broken by insertion order).
|
||||
const std::vector<Mode>& all() const { return modes_; }
|
||||
|
||||
std::size_t size() const { return modes_.size(); }
|
||||
|
||||
bool operator==(const ModeRegistry& o) const { return modes_ == o.modes_; }
|
||||
|
||||
// An empty registry (no seed modes). Deserialization parses the persisted mode
|
||||
// set into this and then owns it; the default ctor's seed would otherwise make
|
||||
// the serialized Arrange/Design collide on add() and fail to round-trip.
|
||||
static ModeRegistry makeEmpty() { return ModeRegistry(EmptyTag{}); }
|
||||
|
||||
private:
|
||||
struct EmptyTag {};
|
||||
explicit ModeRegistry(EmptyTag) {} // no seed
|
||||
|
||||
std::vector<Mode> modes_; // kept sorted by ordinal, then insertion
|
||||
};
|
||||
|
||||
// The membership record for one tagged leaf track, keyed externally by GUID.
|
||||
struct Membership {
|
||||
std::set<std::string> modeIds; // the mode(s) this leaf opted into
|
||||
bool showBoth = false; // pinned visible + running in every mode
|
||||
|
||||
bool operator==(const Membership& o) const {
|
||||
return modeIds == o.modeIds && showBoth == o.showBoth;
|
||||
}
|
||||
};
|
||||
|
||||
// GUID-keyed membership index. Untagged GUIDs are absent and belong to Arrange.
|
||||
// Keyed by track GUID string, never index (reorder-safe).
|
||||
class MembershipIndex {
|
||||
public:
|
||||
// Tags `guid` into `modeId`, replacing any prior mode set (a leaf lives in one
|
||||
// mode; use showBoth for the cross-mode case). No-op-safe on repeated calls.
|
||||
// Returns false if guid or modeId is empty.
|
||||
bool tag(const std::string& guid, const std::string& modeId);
|
||||
|
||||
// Removes `guid` from the index entirely (returns it to the Arrange default).
|
||||
// Returns true if it was present.
|
||||
bool untag(const std::string& guid);
|
||||
|
||||
// Sets the show-both flag for `guid`. Tags the guid into no new mode; if the
|
||||
// guid is untagged it is created with an empty mode set (Arrange default) so
|
||||
// show-both alone is representable. Returns false if guid is empty.
|
||||
bool setShowBoth(const std::string& guid, bool showBoth);
|
||||
|
||||
// Installs a complete membership record verbatim (multi-mode set + show-both),
|
||||
// replacing any existing entry for `guid`. Used by deserialization to rebuild a
|
||||
// trusted, already-valid entry without tag()'s single-mode clobbering. Returns
|
||||
// false if guid is empty.
|
||||
bool restore(const std::string& guid, const Membership& membership);
|
||||
|
||||
// Returns the membership for `guid`, or nullptr if untagged. Invalidated by any
|
||||
// mutating call.
|
||||
const Membership* query(const std::string& guid) const;
|
||||
|
||||
bool isShowBoth(const std::string& guid) const {
|
||||
const Membership* m = query(guid);
|
||||
return m && m->showBoth;
|
||||
}
|
||||
|
||||
// The mode ids `guid` belongs to. Empty for an untagged guid (⇒ Arrange).
|
||||
std::set<std::string> modesOf(const std::string& guid) const;
|
||||
|
||||
const std::map<std::string, Membership>& all() const { return entries_; }
|
||||
|
||||
std::size_t size() const { return entries_.size(); }
|
||||
bool empty() const { return entries_.empty(); }
|
||||
|
||||
bool operator==(const MembershipIndex& o) const { return entries_ == o.entries_; }
|
||||
|
||||
private:
|
||||
std::map<std::string, Membership> entries_; // guid -> membership
|
||||
};
|
||||
|
||||
// -- Folder tree (INPUT, not stored) ----------------------------------------
|
||||
//
|
||||
// The shell builds this from I_FOLDERDEPTH each time and passes it to a visibility
|
||||
// query. A node is a leaf or a parent; a parent is visible in every mode any of
|
||||
// its descendant leaves belongs to, and is never parked. The master track is
|
||||
// modeled implicitly (always visible, never touched) and is NOT a node here.
|
||||
struct FolderNode {
|
||||
std::string guid;
|
||||
std::string parentGuid; // empty ⇒ top-level (child of master / project root)
|
||||
bool isParent = false; // true if this node has descendant tracks (a folder)
|
||||
};
|
||||
|
||||
// A flat parent↔child description of the current track tree. Order is arrange-view
|
||||
// order; parentGuid links each node to its immediate parent folder.
|
||||
struct FolderTree {
|
||||
std::vector<FolderNode> nodes;
|
||||
};
|
||||
|
||||
// -- Snapshot + planner ------------------------------------------------------
|
||||
|
||||
// The prior value of every tool-driven flag on one track, captured BEFORE parking.
|
||||
// Restore uses these values verbatim — the restore contract's source of truth.
|
||||
// Flags mirror REAPER's numeric representation (0/1 for the bools) so the shell
|
||||
// applies them without translation; ints, not bools, so a snapshot faithfully
|
||||
// round-trips whatever REAPER reported (defensive against non-0/1 values).
|
||||
struct TrackSnapshot {
|
||||
int showInTcp = 0; // B_SHOWINTCP prior value
|
||||
int showInMixer = 0; // B_SHOWINMIXER prior value
|
||||
int mainSend = 0; // B_MAINSEND prior value
|
||||
int fxEnable = 0; // I_FXEN prior value
|
||||
|
||||
// Prior per-FX offline state, index = fx slot. Lets restore return each FX to
|
||||
// exactly its captured offline value rather than a blanket "online".
|
||||
std::vector<int> fxOffline;
|
||||
|
||||
bool operator==(const TrackSnapshot& o) const {
|
||||
return showInTcp == o.showInTcp && showInMixer == o.showInMixer &&
|
||||
mainSend == o.mainSend && fxEnable == o.fxEnable &&
|
||||
fxOffline == o.fxOffline;
|
||||
}
|
||||
};
|
||||
|
||||
// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it is
|
||||
// per-slot, variable length), see TrackParkPlan::fxOffline.
|
||||
enum class Flag {
|
||||
ShowInTcp, // B_SHOWINTCP
|
||||
ShowInMixer, // B_SHOWINMIXER
|
||||
MainSend, // B_MAINSEND
|
||||
FxEnable, // I_FXEN
|
||||
};
|
||||
|
||||
// One scalar-flag write the shell must apply: SetMediaTrackInfo_Value(guid, flag, value).
|
||||
struct TrackFlagOp {
|
||||
std::string guid;
|
||||
Flag flag = Flag::ShowInTcp;
|
||||
int value = 0;
|
||||
|
||||
bool operator==(const TrackFlagOp& o) const {
|
||||
return guid == o.guid && flag == o.flag && value == o.value;
|
||||
}
|
||||
};
|
||||
|
||||
// One per-FX offline write: TrackFX_SetOffline(guid, fxIndex, offline).
|
||||
struct FxOfflineOp {
|
||||
std::string guid;
|
||||
int fxIndex = 0;
|
||||
bool offline = false;
|
||||
|
||||
bool operator==(const FxOfflineOp& o) const {
|
||||
return guid == o.guid && fxIndex == o.fxIndex && offline == o.offline;
|
||||
}
|
||||
};
|
||||
|
||||
// The complete set of operations to park one inactive leaf, or restore one leaf.
|
||||
// Park uses fixed zeros (parking contract); restore uses a snapshot's values.
|
||||
// fxOffline is emitted per known FX slot: on park, from the snapshot's slot count
|
||||
// (all -> offline); on restore, each slot back to its captured value.
|
||||
struct TrackPlan {
|
||||
std::vector<TrackFlagOp> flags;
|
||||
std::vector<FxOfflineOp> fxOffline;
|
||||
};
|
||||
|
||||
// The plan for a whole toggle to a target mode: which tracks to park, and which to
|
||||
// restore from their snapshots. Parents and show-both leaves never appear here —
|
||||
// they are derived-visible and never parked (visibility is answered separately by
|
||||
// visibleTracks). Untagged tracks never appear either (the tool owns only what it
|
||||
// tagged).
|
||||
struct TogglePlan {
|
||||
std::vector<TrackPlan> park; // inactive leaves -> parked (fixed zeros)
|
||||
std::vector<TrackPlan> restore; // active leaves returning -> snapshot values
|
||||
};
|
||||
|
||||
// -- The view mode model -----------------------------------------------------
|
||||
//
|
||||
// Owns the mode registry, the membership index, the active mode, and the durable
|
||||
// per-track snapshots (kept for tracks currently parked so a save-while-parked
|
||||
// project restores correctly). Visibility and the toggle plan are computed against
|
||||
// a supplied FolderTree — the tree is never stored.
|
||||
class ViewModeModel {
|
||||
public:
|
||||
ViewModeModel(); // Arrange + Design seeded; active mode = Arrange
|
||||
|
||||
ModeRegistry& modes() { return modes_; }
|
||||
const ModeRegistry& modes() const { return modes_; }
|
||||
MembershipIndex& membership() { return membership_; }
|
||||
const MembershipIndex& membership() const { return membership_; }
|
||||
|
||||
const std::string& activeModeId() const { return activeModeId_; }
|
||||
// Sets the active mode. Returns false (no change) if the id is not registered.
|
||||
bool setActiveMode(const std::string& modeId);
|
||||
|
||||
// Records / clears the pre-park snapshot for a track. The shell calls store
|
||||
// before it parks a track; the model persists it so restore survives a save.
|
||||
void storeSnapshot(const std::string& guid, const TrackSnapshot& snap);
|
||||
void clearSnapshot(const std::string& guid);
|
||||
const TrackSnapshot* snapshot(const std::string& guid) const;
|
||||
const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }
|
||||
|
||||
// Does `guid` belong to `modeId`? A leaf belongs if it is tagged into modeId,
|
||||
// is show-both (belongs everywhere), or is untagged and modeId is Arrange (the
|
||||
// default). Parent derivation is NOT applied here — this is the LEAF rule; use
|
||||
// visibleTracks for the tree-aware answer.
|
||||
bool leafBelongsToMode(const std::string& guid, const std::string& modeId) const;
|
||||
|
||||
// The set of track GUIDs visible in `modeId`, tree-aware: active leaves,
|
||||
// show-both leaves, and every parent with at least one descendant leaf in the
|
||||
// mode. Untagged leaves count as Arrange. Stale GUIDs in the tree are tolerated.
|
||||
// The master is not represented (always visible; the shell never touches it).
|
||||
std::set<std::string> visibleTracks(const FolderTree& tree,
|
||||
const std::string& modeId) const;
|
||||
|
||||
// Plans a toggle to `targetMode` against the current tree. Inactive tagged
|
||||
// leaves (not show-both, not derived-visible-only) are parked with fixed zeros;
|
||||
// leaves that become active AND have a stored snapshot are restored from it.
|
||||
// Parents, show-both leaves, the master, and untagged tracks are never parked.
|
||||
// Unknown/stale membership GUIDs absent from the tree are ignored (prune-safe).
|
||||
//
|
||||
// Note: park plans emitted here have an empty fxOffline vector. The D2 shell
|
||||
// expands per-FX offline writes using TrackFX_GetCount — the pure model has no
|
||||
// access to REAPER FX counts at plan time.
|
||||
TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const;
|
||||
|
||||
bool operator==(const ViewModeModel& o) const;
|
||||
|
||||
std::string serialize() const;
|
||||
|
||||
// Parses a JSON string produced by serialize(). std::nullopt on malformed
|
||||
// input. On success deserialize(serialize(x)) == x.
|
||||
static std::optional<ViewModeModel> deserialize(const std::string& json);
|
||||
|
||||
private:
|
||||
ModeRegistry modes_;
|
||||
MembershipIndex membership_;
|
||||
std::string activeModeId_; // always a registered id
|
||||
std::map<std::string, TrackSnapshot> snapshots_; // guid -> pre-park snapshot
|
||||
};
|
||||
|
||||
// Builds the fixed-zero park plan for one leaf. Offlines `fxCount` slots. Exposed
|
||||
// for the shell and for direct testing of the parking contract.
|
||||
TrackPlan makeParkPlan(const std::string& guid, int fxCount);
|
||||
|
||||
// Builds the restore plan for one leaf from its snapshot — every flag set to its
|
||||
// captured value, never a default. Exposed for the shell and for testing the
|
||||
// restore-contract invariant directly.
|
||||
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap);
|
||||
|
||||
} // namespace reasampler
|
||||
Reference in New Issue
Block a user