Merge Ψ-W1-T2: disjoint per-mode solo surfaces and a playback-gated mode switch
This commit is contained in:
+23
-13
@@ -157,12 +157,19 @@ load hitch and any un-persisted internal state is lost. This is an accepted cost
|
||||
of the CPU reclaim, not a bug. It must be documented at the toggle affordance so
|
||||
the user isn't surprised.
|
||||
|
||||
**Never touched:** `B_MUTE` and `I_SOLO`. The tool owns visibility, `B_MAINSEND`,
|
||||
`I_FXEN`, and FX-offline — nothing else — across every managed leaf, tagged or
|
||||
untagged. The user's mute/solo survives every toggle, untouched. This is the exact
|
||||
analog of the
|
||||
capture pillar's non-destructive invariant: **the tool never destroys the user's
|
||||
real state to do its job.**
|
||||
**Never touched:** `B_MUTE`. The tool owns visibility, `B_MAINSEND`, `I_FXEN`,
|
||||
FX-offline, and `I_SOLO` — nothing else — across every managed leaf, tagged or
|
||||
untagged.
|
||||
|
||||
**Solo is owned but never lost.** Solo is a per-mode surface: switching modes banks
|
||||
the outgoing mode's solo state, clears it, and replays the incoming mode's on
|
||||
return, verbatim. Two modes therefore never share a solo — you can solo the drum
|
||||
bus in Arrange and the sound-design chain in Design without either leaking into the
|
||||
other — and neither is destroyed. That is the same exact analog of the capture
|
||||
pillar's non-destructive invariant the flags above satisfy: **the tool never
|
||||
destroys the user's real state to do its job.** It is snapshot-and-restore, one
|
||||
level out from a single toggle to the pair of stances. Reapplying the current mode
|
||||
(tagging, project load) is not a switch and does not touch solo at all.
|
||||
|
||||
---
|
||||
|
||||
@@ -316,8 +323,10 @@ Mirrors the capture pillar's split exactly.
|
||||
- Snapshots prior flag values before parking (reads the same flags it will drive).
|
||||
- Resolves track GUIDs via `GetTrackGUID` / `guidToString` / `stringToGuid` for the
|
||||
index; never uses track index (unstable across reorders).
|
||||
- Never touches the master track's visibility flags; never touches `B_MUTE` /
|
||||
`I_SOLO` on anything.
|
||||
- On a real switch only, banks/clears/replays `I_SOLO` per the per-mode solo surface
|
||||
above.
|
||||
- Never touches the master track's visibility flags; never touches `B_MUTE` on
|
||||
anything.
|
||||
|
||||
**`persist` slice:**
|
||||
- Serialize/deserialize the view section (modes + membership + show-both + snapshots
|
||||
@@ -585,11 +594,12 @@ The settled distinction:
|
||||
touch them**: a mode toggle never shows, hides, silences, re-lanes, or re-plays a
|
||||
manual lane. Its `C_LANEPLAYS` state is the user's, left exactly as they set it.
|
||||
|
||||
This is the fixed-lane analog of the two invariants already load-bearing in D1 —
|
||||
*never touch `B_MUTE`/`I_SOLO`* and *never touch the master* — extended to a third
|
||||
surface: **never drive a lane the tool did not mint.** It is the same non-destructive
|
||||
promise (the tool owns only what it created) reaching one level deeper, into the lane
|
||||
dimension.
|
||||
This is the fixed-lane analog of the invariants already load-bearing in D1 —
|
||||
*never touch `B_MUTE`*, *never touch the master*, and *never lose the user's solo*
|
||||
(see "Never touched" above) — extended to a further surface: **never drive a lane
|
||||
the tool did not mint.** It is the same non-destructive promise (the tool owns only
|
||||
what it created, and restores what it parks) reaching one level deeper, into the
|
||||
lane dimension.
|
||||
|
||||
### Lane-ownership index (the new data)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ add_library(reaper_reasampler MODULE
|
||||
${LICE_SRC}
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/view/view.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/view/view_solo.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/item_read.cpp
|
||||
@@ -45,7 +46,7 @@ add_library(reaper_reasampler MODULE
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name)
|
||||
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter. The instrument
|
||||
# renders its own bake in its own process, which is what keeps the extension's link graph
|
||||
# free of the voice engine — a link edge to it here means the design drifted.
|
||||
|
||||
@@ -95,7 +95,7 @@ L7 sub-pass, 2026-07-27):
|
||||
- `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure.
|
||||
- `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers.
|
||||
- `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching).
|
||||
- `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement.
|
||||
- `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement, plus `modeSegmentEnabled` — the mode segment's live/dead predicate under the playback gate AND under whether the shell resolved a routable command id for it (both bools passed IN, so this stays REAPER-free).
|
||||
- `overflow_menu` — pure overflow-menu-button geometry/reserve/hit-test for the top-toolbar More (⋯) button.
|
||||
- `mode_enable` — pure opposite-mode enablement predicate: given the active mode, computes per-button live/disabled state for the four Item/Track × Arrange/Design tag buttons.
|
||||
- `tooltip` — pure tooltip placement + prefix-strip: strips the `ReaSampler:` display prefix from the registered action phrase; width clamped to the client rect.
|
||||
|
||||
@@ -64,4 +64,8 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) {
|
||||
return FooterHit::None;
|
||||
}
|
||||
|
||||
bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable) {
|
||||
return isActiveSegment || (!transportRunning && routable);
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
|
||||
@@ -61,4 +61,15 @@ FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec&
|
||||
// a passive readout, never a control). Half-open bounds match computeFooterBar.
|
||||
FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout);
|
||||
|
||||
// Whether one [Arrange|Design] segment is live. A mode switch is refused while the project
|
||||
// plays or records (shell/view::transportBlocksModeSwitch), so an unreachable segment must READ
|
||||
// dead before the click, not merely refuse on it. The ACTIVE segment stays live regardless: it
|
||||
// fires a reapply, which is never gated, and dimming the mode you are already in would read as
|
||||
// "this mode is unavailable" rather than "you cannot leave it right now". `routable` is whether
|
||||
// the shell resolved a live command id for this segment (the model is N-mode, the UI ships two
|
||||
// seeded ids — a third registered mode has no action to route through, so its segment must read
|
||||
// dead rather than paint live and silently no-op on click). Passed IN so this predicate — and
|
||||
// `core/ui` — stays REAPER-free, exactly as `transportRunning` is.
|
||||
bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
|
||||
+15
-7
@@ -3,12 +3,13 @@
|
||||
## Scope
|
||||
|
||||
Pure, REAPER-free Design View model: mode/track membership, folder-derived
|
||||
visibility, snapshot-based park/restore planning, new-content (GUID) detection,
|
||||
and the managed/manual lane-identity convention that underlies per-item mode
|
||||
separation (fixed lanes). Does **not** include: the actual DAW-side flag
|
||||
application (hide, CPU-park, per-FX offline, restore via `B_SHOWINTCP` /
|
||||
`B_SHOWINMIXER` / `B_MAINSEND` / `I_FXEN`) or the never-touch-master/mute/solo
|
||||
enforcement — those live in `shell/view`.
|
||||
visibility, snapshot-based park/restore planning, the per-mode solo cache and its
|
||||
replay plan, new-content (GUID) detection, and the managed/manual lane-identity
|
||||
convention that underlies per-item mode separation (fixed lanes). Does **not**
|
||||
include: the actual DAW-side flag application (hide, CPU-park, per-FX offline,
|
||||
restore via `B_SHOWINTCP` / `B_SHOWINMIXER` / `B_MAINSEND` / `I_FXEN`, solo
|
||||
cache/clear/replay via `I_SOLO`) or the never-touch-master/mute enforcement —
|
||||
those live in `shell/view`.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -33,6 +34,12 @@ settled 2026-07-23):
|
||||
from the snapshot, never to a hardcoded default. Round-trip (snapshot → park →
|
||||
restore) returns every driven flag to its captured value — this is the
|
||||
phase's trust anchor, the analog of the capture null test.
|
||||
- **Disjoint solo surfaces, cached not destroyed.** Solo is per mode: a real
|
||||
switch banks the outgoing mode's raw `I_SOLO` values, clears them, and replays
|
||||
the incoming mode's verbatim. Same snapshot sense of non-destructive as the
|
||||
bullet above — the tool never *loses* the user's solo, it parks it with the mode
|
||||
it belongs to. `B_MUTE` and the master track stay untouched absolutely. A
|
||||
reapply touches solo not at all.
|
||||
- **GUID-keyed, reorder-safe.** Membership keys on track GUID (`GetTrackGUID`),
|
||||
never track index; tolerates unknown/stale GUIDs (pruned on reconcile via
|
||||
`ViewModeModel::reconcile(liveGuids)`).
|
||||
@@ -84,7 +91,8 @@ settled 2026-07-23):
|
||||
|
||||
## Modules
|
||||
|
||||
- `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, JSON round-trip.
|
||||
- `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, the per-mode `SoloCache` it owns, JSON round-trip.
|
||||
- `solo_cache` — the per-mode solo surface: `SoloCache` (mode id → GUID → raw `I_SOLO`), the soloed-subset filter, and `planSoloRestore`, whose two drop rules (dead GUID, not visible in the incoming mode) and their reasoning live in its header.
|
||||
- `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell.
|
||||
- `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch.
|
||||
- `guid_diff` — the pure, REAPER-free core of the D2 Wave-2 new-content detection: `newGuids(previous, current)` computes the GUIDs present in `current` but absent from `previous` (empty GUIDs ignored); `GuidBaseline` tracks the live GUID set across polls for one project, implementing the first-poll-after-open guard (the first `observe()` after construction/`reset()` records a baseline and reports nothing new, so pre-existing content is never mass-tagged) and re-arms via `reset()` on a detected project switch so detection never diffs across two unrelated projects.
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
reasampler_pure_library(lane_keys SOURCES lane_keys.cpp)
|
||||
reasampler_test(lane_keys LINK lane_keys)
|
||||
|
||||
# lane_keys is PUBLIC: the lane-minting plan names managed lanes through the one durable-key
|
||||
# convention, so every consumer has to resolve that symbol too.
|
||||
reasampler_pure_library(solo_cache SOURCES solo_cache.cpp)
|
||||
reasampler_test(solo_cache LINK solo_cache)
|
||||
|
||||
# lane_keys and solo_cache are PUBLIC: the lane-minting plan names managed lanes through the
|
||||
# one durable-key convention, and ViewModeModel exposes the SoloCache by reference, so every
|
||||
# consumer has to resolve those symbols too.
|
||||
reasampler_pure_library(view_mode_model
|
||||
SOURCES view_mode_model.cpp
|
||||
LINK PRIVATE json PUBLIC lane_keys)
|
||||
LINK PRIVATE json PUBLIC lane_keys solo_cache)
|
||||
reasampler_test(view_mode_model LINK view_mode_model)
|
||||
|
||||
reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// solo_cache — pure implementation. See solo_cache.h.
|
||||
|
||||
#include "core/view/solo_cache.h"
|
||||
|
||||
namespace reasampler::view {
|
||||
|
||||
std::map<std::string, int> soloedTracks(const std::vector<TrackSolo>& live) {
|
||||
std::map<std::string, int> soloed;
|
||||
for (const TrackSolo& t : live) {
|
||||
if (t.guid.empty() || t.solo == 0) continue;
|
||||
soloed.emplace(t.guid, t.solo); // first reading wins if a GUID repeats
|
||||
}
|
||||
return soloed;
|
||||
}
|
||||
|
||||
bool SoloCache::store(const std::string& modeId, const std::map<std::string, int>& soloed) {
|
||||
if (modeId.empty()) return false;
|
||||
if (soloed.empty()) {
|
||||
byMode_.erase(modeId);
|
||||
return true;
|
||||
}
|
||||
byMode_[modeId] = soloed;
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::map<std::string, int>* SoloCache::query(const std::string& modeId) const {
|
||||
auto it = byMode_.find(modeId);
|
||||
return it == byMode_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
bool SoloCache::clear(const std::string& modeId) {
|
||||
return byMode_.erase(modeId) > 0;
|
||||
}
|
||||
|
||||
std::size_t SoloCache::reconcile(const std::set<std::string>& liveGuids) {
|
||||
std::size_t removed = 0;
|
||||
for (auto mode = byMode_.begin(); mode != byMode_.end();) {
|
||||
for (auto entry = mode->second.begin(); entry != mode->second.end();) {
|
||||
if (liveGuids.count(entry->first) == 0) {
|
||||
entry = mode->second.erase(entry);
|
||||
++removed;
|
||||
} else {
|
||||
++entry;
|
||||
}
|
||||
}
|
||||
// A mode emptied by pruning must not survive as an empty record — same
|
||||
// reason store() drops one (see header).
|
||||
if (mode->second.empty()) mode = byMode_.erase(mode);
|
||||
else ++mode;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
std::vector<SoloOp> planSoloRestore(const std::map<std::string, int>& cached,
|
||||
const std::set<std::string>& liveGuids,
|
||||
const std::set<std::string>& visibleGuids) {
|
||||
std::vector<SoloOp> ops;
|
||||
for (const auto& [guid, value] : cached) {
|
||||
if (liveGuids.count(guid) == 0) continue;
|
||||
if (visibleGuids.count(guid) == 0) continue;
|
||||
ops.push_back(SoloOp{guid, value});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
} // namespace reasampler::view
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
// Per-mode solo surface: the cache of raw I_SOLO values a real mode switch banks on
|
||||
// the way out and replays on the way back, plus the two decisions over it. Pure —
|
||||
// the GetMediaTrackInfo_Value/SetMediaTrackInfo_Value pair is shell/view/view_solo.
|
||||
// Values are the RAW I_SOLO int, never collapsed to a bool: the SDK's domain is
|
||||
// 0=off, 1=solo, 2=solo-in-place, 5=safe solo, 6=safe solo-in-place, and all four
|
||||
// non-zero variants must survive the round trip.
|
||||
|
||||
#include <cstddef>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::view {
|
||||
|
||||
// One live (track GUID, I_SOLO) reading from the shell's enumeration.
|
||||
struct TrackSolo {
|
||||
std::string guid;
|
||||
int solo = 0;
|
||||
};
|
||||
|
||||
// One I_SOLO write the shell must apply.
|
||||
struct SoloOp {
|
||||
std::string guid;
|
||||
int value = 0;
|
||||
|
||||
bool operator==(const SoloOp& o) const { return guid == o.guid && value == o.value; }
|
||||
};
|
||||
|
||||
// The soloed subset of a live enumeration. Zero is the resting value — a track at
|
||||
// zero is neither cached (nothing to replay) nor cleared (nothing to undo), so a
|
||||
// project with no solo anywhere produces no cache entry and no project write at
|
||||
// all. Empty GUIDs are dropped: they can never resolve back to a track.
|
||||
std::map<std::string, int> soloedTracks(const std::vector<TrackSolo>& live);
|
||||
|
||||
// mode id -> (track GUID -> raw I_SOLO). Lifecycle mirrors the park/restore
|
||||
// snapshots: stored on the way out of a mode, consumed on the way back in, pruned
|
||||
// when a GUID stops existing.
|
||||
class SoloCache {
|
||||
public:
|
||||
// Replaces `modeId`'s entry. An EMPTY set removes it rather than storing an
|
||||
// empty record — otherwise a serialized cache would parse back to a model that
|
||||
// differs from its source, breaking the model's round-trip contract.
|
||||
bool store(const std::string& modeId, const std::map<std::string, int>& soloed);
|
||||
|
||||
const std::map<std::string, int>* query(const std::string& modeId) const;
|
||||
|
||||
bool clear(const std::string& modeId);
|
||||
|
||||
const std::map<std::string, std::map<std::string, int>>& all() const { return byMode_; }
|
||||
|
||||
bool empty() const { return byMode_.empty(); }
|
||||
|
||||
// Drops every cached GUID absent from `liveGuids`, and any mode left empty.
|
||||
// Returns the number of GUID entries removed.
|
||||
//
|
||||
// Pruned like the snapshots and unlike membership: a cached solo is a captured
|
||||
// prior value awaiting replay onto one specific track, so a stale entry
|
||||
// surviving a delete would replay onto whatever track later reuses that GUID —
|
||||
// soloing a track the user never soloed and silencing the rest of the mix.
|
||||
// The cost is the mirror case: undoing a track delete restores the GUID but not
|
||||
// its cached solo. One lost solo the user can see and re-click beats an
|
||||
// inexplicable mix-wide mute.
|
||||
std::size_t reconcile(const std::set<std::string>& liveGuids);
|
||||
|
||||
bool operator==(const SoloCache& o) const { return byMode_ == o.byMode_; }
|
||||
|
||||
private:
|
||||
std::map<std::string, std::map<std::string, int>> byMode_;
|
||||
};
|
||||
|
||||
// The incoming mode's restore writes, in GUID order. Two kinds of entry are dropped
|
||||
// rather than written:
|
||||
// * a GUID absent from `liveGuids` — the track is gone (same prune rule as above);
|
||||
// * a GUID absent from `visibleGuids` — not visible in the incoming mode, whether
|
||||
// because the leaf is parked or because it is a folder parent that is itself
|
||||
// derived-invisible there. Either way the track is hidden and (for a parked
|
||||
// leaf) carries B_MAINSEND=0, so soloing it would silence the whole mix while
|
||||
// contributing nothing audible, and the user would have no visible control to
|
||||
// undo it. A show-both leaf is a member of every mode's visible set, so it is
|
||||
// never dropped by this rule.
|
||||
// The caller consumes the whole mode entry regardless (clear-on-restore), so a
|
||||
// dropped entry does not linger as zombie state waiting on a track that may never
|
||||
// become visible again.
|
||||
std::vector<SoloOp> planSoloRestore(const std::map<std::string, int>& cached,
|
||||
const std::set<std::string>& liveGuids,
|
||||
const std::set<std::string>& visibleGuids);
|
||||
|
||||
} // namespace reasampler::view
|
||||
@@ -247,6 +247,8 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
|
||||
|
||||
std::size_t ViewModeModel::reconcile(const std::set<std::string>& liveGuids) {
|
||||
// See header: snapshots are pruned, membership is not (undo-delete rationale).
|
||||
soloCache_.reconcile(liveGuids);
|
||||
|
||||
std::size_t removed = 0;
|
||||
for (auto it = snapshots_.begin(); it != snapshots_.end();) {
|
||||
if (liveGuids.count(it->first) == 0) {
|
||||
@@ -347,7 +349,8 @@ std::set<LaneRef> ViewModeModel::lanesTouchedByToggle() const {
|
||||
|
||||
bool ViewModeModel::operator==(const ViewModeModel& o) const {
|
||||
return modes_ == o.modes_ && membership_ == o.membership_ && lanes_ == o.lanes_ &&
|
||||
activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_;
|
||||
activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_ &&
|
||||
soloCache_ == o.soloCache_;
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -443,6 +446,31 @@ std::string ViewModeModel::serialize() const {
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
|
||||
// soloCache: array of { mode, tracks: [ { guid, solo } ] }
|
||||
root.keyBegin("soloCache");
|
||||
out += '[';
|
||||
{
|
||||
bool firstMode = true;
|
||||
for (const auto& [modeId, byGuid] : soloCache_.all()) {
|
||||
if (!firstMode) out += ',';
|
||||
firstMode = false;
|
||||
ObjWriter m(out);
|
||||
m.keyStr("mode", modeId);
|
||||
m.keyBegin("tracks");
|
||||
out += '[';
|
||||
bool firstTrack = true;
|
||||
for (const auto& [guid, solo] : byGuid) {
|
||||
if (!firstTrack) out += ',';
|
||||
firstTrack = false;
|
||||
ObjWriter e(out);
|
||||
e.keyStr("guid", guid);
|
||||
e.keyRaw("solo", intToStr(solo));
|
||||
}
|
||||
out += ']';
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
} // root closes here (NRVO + deferred close, mirrors bank_model)
|
||||
return out;
|
||||
}
|
||||
@@ -569,6 +597,59 @@ bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) {
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
// One mode's cached solo set. Strict like parseLanes: every key the writer emits is
|
||||
// mandatory and non-empty. An empty tracks array is rejected — serialize never emits
|
||||
// one (store drops an empty set), so accepting it would let a hand-edited blob parse
|
||||
// into a model that re-serializes differently.
|
||||
bool parseSoloTracks(json::Reader& r, std::map<std::string, int>& byGuid) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true;
|
||||
do {
|
||||
if (!r.consume('{')) return false;
|
||||
std::string guid;
|
||||
int solo = 0;
|
||||
bool haveGuid = false, haveSolo = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
|
||||
else if (k == "solo") { if (!r.parseInt(solo)) return false; haveSolo = true; }
|
||||
else if (!r.skipValue()) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveGuid || !haveSolo || guid.empty()) return false;
|
||||
byGuid[guid] = solo;
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool parseSoloCache(json::Reader& r, view::SoloCache& cache) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true;
|
||||
do {
|
||||
if (!r.consume('{')) return false;
|
||||
std::string modeId;
|
||||
std::map<std::string, int> byGuid;
|
||||
bool haveMode = false, haveTracks = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "mode") { if (!r.parseString(modeId)) return false; haveMode = true; }
|
||||
else if (k == "tracks") {
|
||||
if (!parseSoloTracks(r, byGuid)) return false;
|
||||
haveTracks = true;
|
||||
}
|
||||
else if (!r.skipValue()) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveMode || modeId.empty() || !haveTracks || byGuid.empty()) return false;
|
||||
if (!cache.store(modeId, byGuid)) return false;
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
@@ -581,6 +662,7 @@ bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
MembershipIndex membership;
|
||||
LaneOwnershipIndex lanes;
|
||||
std::map<std::string, TrackSnapshot> snaps;
|
||||
view::SoloCache soloCache;
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
@@ -599,6 +681,8 @@ bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
if (!parseSnapshots(r, snaps)) return false;
|
||||
} else if (key == "lanes") {
|
||||
if (!parseLanes(r, lanes)) return false;
|
||||
} else if (key == "soloCache") {
|
||||
if (!parseSoloCache(r, soloCache)) return false;
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // unknown keys / "version" placeholder
|
||||
}
|
||||
@@ -611,6 +695,7 @@ bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
if (haveModes) out.modes() = reg;
|
||||
out.membership() = membership;
|
||||
out.lanes() = lanes;
|
||||
out.soloCache() = soloCache;
|
||||
for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap);
|
||||
if (haveActive) {
|
||||
if (!out.setActiveMode(activeMode)) return false; // active mode must exist
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/view/solo_cache.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Stable seed-mode ids. Arrange is the default home for untagged leaves.
|
||||
@@ -291,6 +293,8 @@ public:
|
||||
const MembershipIndex& membership() const { return membership_; }
|
||||
LaneOwnershipIndex& lanes() { return lanes_; }
|
||||
const LaneOwnershipIndex& lanes() const { return lanes_; }
|
||||
view::SoloCache& soloCache() { return soloCache_; }
|
||||
const view::SoloCache& soloCache() const { return soloCache_; }
|
||||
|
||||
const std::string& activeModeId() const { return activeModeId_; }
|
||||
// Returns false (no change) if the id is not registered.
|
||||
@@ -302,8 +306,9 @@ public:
|
||||
const TrackSnapshot* snapshot(const std::string& guid) const;
|
||||
const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }
|
||||
|
||||
// Drops every snapshot whose GUID is NOT in `liveGuids`. Returns the count
|
||||
// removed.
|
||||
// Drops every snapshot whose GUID is NOT in `liveGuids`, and prunes the solo
|
||||
// cache the same way (see SoloCache::reconcile). Returns the count of
|
||||
// SNAPSHOTS removed — the solo cache's own count is available from it directly.
|
||||
//
|
||||
// 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
|
||||
@@ -355,6 +360,7 @@ private:
|
||||
LaneOwnershipIndex lanes_; // (guid, laneKey) -> ownership
|
||||
std::string activeModeId_; // always a registered id
|
||||
std::map<std::string, TrackSnapshot> snapshots_; // guid -> pre-park snapshot
|
||||
view::SoloCache soloCache_; // modeId -> guid -> raw I_SOLO
|
||||
};
|
||||
|
||||
// Fixed-zero park plan for one leaf, offlining `fxCount` slots.
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Help_Set
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
@@ -160,21 +161,31 @@ void persistViewState() {
|
||||
g_session->saveToActiveProject();
|
||||
}
|
||||
|
||||
// The footer segment already reads disabled while the transport runs, but an action can
|
||||
// be fired with the panel closed — so the refusal also goes through Help_Set (SDK
|
||||
// documents only the signature, not its display surface; inferred to be visible
|
||||
// feedback by name, not confirmed). Guarded on the transport because applyMode's
|
||||
// other refusal is an unregistered mode id.
|
||||
void reportModeSwitchRefused() {
|
||||
if (transportBlocksModeSwitch(nullptr) && Help_Set)
|
||||
Help_Set("ReaSampler: stop the transport to switch view mode", true);
|
||||
}
|
||||
|
||||
// Cycle to the next mode in ordinal order. applyMode itself sets the model's active
|
||||
// mode, so we only compute the target and apply.
|
||||
void doToggleMode() {
|
||||
const std::string target =
|
||||
nextModeId(g_session->view().modes(), g_session->view().activeModeId());
|
||||
if (target.empty()) return; // no modes to cycle to (degenerate)
|
||||
applyMode(g_session->view(), target, nullptr);
|
||||
if (!applyMode(g_session->view(), target, nullptr)) { reportModeSwitchRefused(); return; }
|
||||
persistViewState();
|
||||
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
|
||||
}
|
||||
|
||||
// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if
|
||||
// the id is unregistered, so an absent mode fails safe.
|
||||
// the id is unregistered or the transport is running, so both fail safe.
|
||||
void doActivateMode(const std::string& modeId) {
|
||||
applyMode(g_session->view(), modeId, nullptr);
|
||||
if (!applyMode(g_session->view(), modeId, nullptr)) { reportModeSwitchRefused(); return; }
|
||||
persistViewState();
|
||||
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag
|
||||
#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam
|
||||
#include "shell/capture/track_guid.h" // guidString — canonical track GUID key
|
||||
#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation
|
||||
#include "shell/view/view.h" // mintManagedLanes / transportBlocksModeSwitch
|
||||
|
||||
// New-content detection: enumerate live tracks + items and read fixed-lane state to
|
||||
// classify an item's lane as managed vs manual.
|
||||
@@ -44,6 +44,20 @@ TailSetting currentTail() {
|
||||
return g_panel.session ? g_panel.session->tail() : TailSetting{};
|
||||
}
|
||||
|
||||
// The registered activate action behind a footer mode segment, or 0 if the mode has none.
|
||||
// Routing the segment through the SAME action the Actions list fires is what gives a
|
||||
// panel-initiated switch the persist + repaint it used to skip; a mode with no such
|
||||
// action (the model is N-mode, the UI ships two) resolves to 0 here, which
|
||||
// modeSegmentEnabled reads as unroutable so the segment paints dead rather than live-
|
||||
// but-inert. Non-anonymous: panel_render.cpp resolves the same id to compute that bool.
|
||||
int modeActivateCommandId(const std::string& modeId) {
|
||||
ActionBarRow row{};
|
||||
if (modeId == kArrangeModeId) row.suffix = "VIEW_ACTIVATE_ARRANGE";
|
||||
else if (modeId == kDesignModeId) row.suffix = "VIEW_ACTIVATE_DESIGN";
|
||||
else return 0;
|
||||
return resolveBarCommandId(row);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Commits the current tail setting to ext state and marks the active project dirty so the
|
||||
@@ -299,11 +313,17 @@ void handleClick(int x, int y) {
|
||||
{
|
||||
const int seg = footerToggleSegmentHit(x, y, w, h);
|
||||
if (seg >= 0) {
|
||||
const std::vector<Mode>& modes = g_panel.session->view().modes().all();
|
||||
const ViewModeModel& view = g_panel.session->view();
|
||||
const std::vector<Mode>& modes = view.modes().all();
|
||||
if (seg < static_cast<int>(modes.size())) {
|
||||
applyMode(g_panel.session->view(),
|
||||
modes[static_cast<std::size_t>(seg)].id, nullptr);
|
||||
invalidatePanel();
|
||||
const std::string& id = modes[static_cast<std::size_t>(seg)].id;
|
||||
const bool isActive = id == view.activeModeId();
|
||||
const int cmd = modeActivateCommandId(id);
|
||||
// Disabled/dead rationale: core/ui/footer_bar.h. Claimed but inert, the
|
||||
// same shape a disabled toolbar row takes — never falls through to the grid.
|
||||
if (modeSegmentEnabled(isActive, g_panel.modeSwitchBlocked, cmd != 0)) {
|
||||
if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -534,6 +554,14 @@ void bankPanelRefresh() {
|
||||
|
||||
if (!panel::g_panel.open || !panel::g_panel.hwnd) return;
|
||||
|
||||
// Transport transitions are not ours to cause and REAPER offers no change callback,
|
||||
// so the mode segments' disabled state is polled here and repainted only on an edge.
|
||||
const bool blocked = transportBlocksModeSwitch(nullptr);
|
||||
if (blocked != panel::g_panel.modeSwitchBlocked) {
|
||||
panel::g_panel.modeSwitchBlocked = blocked;
|
||||
InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE);
|
||||
}
|
||||
|
||||
// The custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if
|
||||
// a toolbar button has rested under the pointer past the delay, latch + repaint it.
|
||||
panel::maybeShowTooltip();
|
||||
|
||||
@@ -112,13 +112,17 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||
const SegmentRect& s = segs[static_cast<std::size_t>(i)];
|
||||
const Mode& mode = modes[static_cast<std::size_t>(i)];
|
||||
const bool active = mode.id == activeId;
|
||||
// Disabled/dead rationale: core/ui/footer_bar.h.
|
||||
const bool live = modeSegmentEnabled(active, g_panel.modeSwitchBlocked,
|
||||
modeActivateCommandId(mode.id) != 0);
|
||||
const InteractionState state =
|
||||
active ? InteractionState::Active
|
||||
: hoverState(g_panel.hovered, HoverKind::ModeSegment, i);
|
||||
: (live ? hoverState(g_panel.hovered, HoverKind::ModeSegment, i)
|
||||
: InteractionState::Disabled);
|
||||
fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state);
|
||||
LICE_DrawRect(bmp, s.x, s.y, s.width, s.height,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
const Role tr = active ? Role::BgBase : Role::TextPrimary;
|
||||
const Role tr = active ? Role::BgBase : (live ? Role::TextPrimary : Role::TextDim);
|
||||
kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(),
|
||||
Font::Label, tr, Align::Center);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ using ui::hitTestPruneButton;
|
||||
using ui::hitTestSlot;
|
||||
using ui::hitTestTabStrip;
|
||||
using ui::menuButtonReserve;
|
||||
using ui::modeSegmentEnabled;
|
||||
using ui::navigate;
|
||||
using ui::roleColor;
|
||||
using ui::stripActionPrefix;
|
||||
@@ -291,6 +292,12 @@ struct PanelState {
|
||||
unsigned int hoverSinceTick = 0;
|
||||
bool tooltipShown = false;
|
||||
|
||||
// Polled on the OnTimer tick (REAPER exposes no transport-change callback). Feeds
|
||||
// modeSegmentEnabled (core/ui/footer_bar.h). Cached rather than read per paint AND
|
||||
// per click so the pixel the user saw and the click they made cannot disagree
|
||||
// within a tick.
|
||||
bool modeSwitchBlocked = false;
|
||||
|
||||
BankPanelFullHeight fullHeight = BankPanelFullHeight::Split;
|
||||
|
||||
// The named bank the banks region shows — distinct from the active/capture-target
|
||||
@@ -478,6 +485,9 @@ void startAudition(int idx);
|
||||
// panel_input.cpp — click/wheel/key routing, accelerator, new-content detection,
|
||||
// and the session-tail read/mutate helpers.
|
||||
TailSetting currentTail();
|
||||
// The registered activate action behind a footer mode segment, or 0 if unroutable.
|
||||
// Shared with panel_render.cpp so paint and click resolve the same id.
|
||||
int modeActivateCommandId(const std::string& modeId);
|
||||
void handleClick(int x, int y);
|
||||
bool handleWheel(int x, int y, int delta);
|
||||
void registerAccel();
|
||||
|
||||
@@ -11,11 +11,21 @@ decide membership or mode rules.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Never touches master or `B_MUTE`/`I_SOLO`.** The tool owns only visibility,
|
||||
`B_MAINSEND`, `I_FXEN`, and per-FX offline, on every managed leaf, tagged or
|
||||
untagged. User mute/solo survives every toggle untouched; the master track's
|
||||
visibility flags are never driven (the SDK forbids `B_SHOWINTCP`/`B_SHOWINMIXER`
|
||||
on master).
|
||||
- **Never touches master or `B_MUTE`; never LOSES solo.** The tool owns visibility,
|
||||
`B_MAINSEND`, `I_FXEN`, per-FX offline, and — on a real mode switch only —
|
||||
`I_SOLO`, on every managed leaf, tagged or untagged. `B_MUTE` is untouched
|
||||
absolutely. The master track is untouched absolutely: it is outside `GetTrack`'s
|
||||
index space, so it never enters the enumeration any of these writes iterate, and
|
||||
its visibility flags are never driven (the SDK forbids
|
||||
`B_SHOWINTCP`/`B_SHOWINMIXER` on master).
|
||||
- **Solo surfaces are disjoint per mode, cached not destroyed.** A real switch
|
||||
(target != active) reads every live track's raw `I_SOLO`, banks the non-zero
|
||||
values against the OUTGOING mode, clears them, and replays the incoming mode's
|
||||
banked values verbatim — solo-in-place and safe-solo variants included, never
|
||||
collapsed to a boolean. This is the same snapshot sense of non-destructive that
|
||||
park/restore already gives visibility and FX state: the user's solo is never
|
||||
lost, only parked with the mode it belongs to. A REAPPLY (target == active —
|
||||
tag/untag/show-both, project load) touches solo not at all.
|
||||
- **Parking a track** (inactive-mode leaf) drives `B_SHOWINTCP=0`, `B_SHOWINMIXER=0`
|
||||
(hide both panels), `B_MAINSEND=0` (out of mix), `I_FXEN=0` (FX bypassed), and
|
||||
`TrackFX_SetOffline(track, fx, true)` for each FX (reclaim CPU) — full CPU-park,
|
||||
@@ -72,7 +82,8 @@ applies the resulting lane state to live tracks.
|
||||
|
||||
## Modules
|
||||
|
||||
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. **Never touches master or `B_MUTE`/`I_SOLO`.**
|
||||
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.**
|
||||
- `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -83,3 +94,9 @@ applies the resulting lane state to live tracks.
|
||||
(fixed-lane mechanics, mode-aware capture placement, hidden-AND-silenced) are
|
||||
reflected in Invariants above; the membership/lane-ownership model concepts
|
||||
it also covers live in `core/view`'s Invariants.
|
||||
- REAPER's own undo restores live `I_SOLO` but not the model's solo cache or
|
||||
`activeModeId` — neither rolls back with a Ctrl-Z. An undo after a mode switch
|
||||
leaves the two out of step, and the next switch banks the undo-restored solos
|
||||
under whatever mode id is active at that point, not the one the user undid back
|
||||
to. Pre-existing: `snapshots_` already carries this same model-vs-undo split;
|
||||
the solo cache inherits it rather than introducing it. Not fixed here.
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
|
||||
#include "shell/capture/item_read.h"
|
||||
#include "core/view/lane_keys.h"
|
||||
#include "core/view/solo_cache.h"
|
||||
#include "shell/capture/track_guid.h"
|
||||
#include "shell/view/view_solo.h"
|
||||
#include "core/view/view_tree.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetPlayStateEx
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
@@ -53,6 +56,10 @@ namespace {
|
||||
// I_FREEMODE value for fixed lanes. SDK: 0=normal, 1=free item positioning, 2=fixed lanes.
|
||||
constexpr int kFreeModeFixedLanes = 2;
|
||||
|
||||
// GetPlayStateEx bitmask. SDK: &1 playing, &2 paused, &4 recording — paused is
|
||||
// deliberately excluded, nothing is moving there.
|
||||
constexpr int kTransportMoving = 1 | 4;
|
||||
|
||||
// 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).
|
||||
@@ -377,11 +384,26 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
|
||||
|
||||
} // namespace
|
||||
|
||||
bool transportBlocksModeSwitch(ReaProject* proj) {
|
||||
return (GetPlayStateEx(proj) & kTransportMoving) != 0;
|
||||
}
|
||||
|
||||
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
|
||||
if (!model.modes().contains(targetModeId)) {
|
||||
return false; // reject before touching the project — no partial apply
|
||||
}
|
||||
|
||||
// The discriminator behind both halves of the contract in view.h. A gate placed
|
||||
// unconditionally here would break tagging and the project-load reapply during
|
||||
// playback; a solo round on every reapply would flicker the user's solo on every
|
||||
// membership edit.
|
||||
const std::string outgoingModeId = model.activeModeId();
|
||||
const bool realSwitch = targetModeId != outgoingModeId;
|
||||
|
||||
if (realSwitch && transportBlocksModeSwitch(proj)) {
|
||||
return false; // same fail-closed shape as the mode-exists guard above
|
||||
}
|
||||
|
||||
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
||||
FolderTree tree = buildFolderTree(entries);
|
||||
@@ -394,10 +416,22 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
for (const auto& kv : handleByGuid) liveGuids.insert(kv.first);
|
||||
model.reconcile(liveGuids);
|
||||
|
||||
// Read before any write, and while activeModeId() still names the mode being left.
|
||||
const std::map<std::string, int> outgoingSolo =
|
||||
realSwitch ? view::soloedTracks(readTrackSolos(handleByGuid))
|
||||
: std::map<std::string, int>{};
|
||||
|
||||
TogglePlan plan = model.planToggle(tree, targetModeId);
|
||||
|
||||
Undo_BeginBlock2(proj);
|
||||
|
||||
// DISJOIN THE SOLO SURFACES. Both this clear and the replay after setActiveMode
|
||||
// ride the existing undo block — one mode toggle stays one Ctrl-Z.
|
||||
if (realSwitch) {
|
||||
model.soloCache().store(outgoingModeId, outgoingSolo);
|
||||
clearTrackSolos(handleByGuid, outgoingSolo);
|
||||
}
|
||||
|
||||
// PARK: snapshot before mutating, store into the model, then apply.
|
||||
for (const TrackPlan& tp : plan.park) {
|
||||
if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track
|
||||
@@ -454,6 +488,17 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
|
||||
model.setActiveMode(targetModeId);
|
||||
|
||||
// Replay + consume, before the single relayout below picks the change up. Dropped
|
||||
// against `visible` (computed above for parent visibility), not the park plan: a
|
||||
// folder parent can be hidden without being parked, and a hidden track must not
|
||||
// receive a replayed solo it carries no visible control to undo.
|
||||
if (realSwitch) {
|
||||
if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) {
|
||||
restoreTrackSolos(handleByGuid, *cached, liveGuids, visible);
|
||||
model.soloCache().clear(targetModeId);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
+17
-5
@@ -3,7 +3,8 @@
|
||||
// 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).
|
||||
// the enforced invariants (never touch master/mute, solo cached per mode and
|
||||
// never lost, snapshot-based restore).
|
||||
|
||||
#include <string>
|
||||
|
||||
@@ -14,10 +15,21 @@ class ReaProject;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// 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.
|
||||
// True while `proj`'s transport is playing or recording — the condition under which
|
||||
// applyMode refuses a real mode switch. Exposed so the panel can paint the footer's
|
||||
// [Arrange|Design] segments disabled BEFORE the click rather than only refusing on
|
||||
// it. `proj` is passed through to GetPlayStateEx as-is; the SDK documents proj=0
|
||||
// meaning "current project" for GetTrack but is silent on GetPlayStateEx, so
|
||||
// `nullptr` meaning the current project here is inferred by analogy, not confirmed.
|
||||
bool transportBlocksModeSwitch(ReaProject* proj);
|
||||
|
||||
// Snapshots each about-to-park track's flags into `model`, caches/clears the
|
||||
// outgoing mode's solo state and replays the incoming mode's, 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, or if this is a real switch (target != active) while the
|
||||
// transport is running. A reapply (target == active) is never gated and never
|
||||
// touches solo. `proj` == nullptr means the current project.
|
||||
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
|
||||
|
||||
// Splits any track visible in more than one mode while carrying its own media
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// See view_solo.h. Compiled into the reaper_reasampler module; includes
|
||||
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that).
|
||||
|
||||
#include "shell/view/view_solo.h"
|
||||
|
||||
#include <map>
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
// SDK: int, 0=not soloed, 1=soloed, 2=soloed in place, 5=safe soloed,
|
||||
// 6=safe soloed in place. Documented under SetMediaTrackInfo_Value with no
|
||||
// read-only marker (unlike B_RECMON_IN_EFFECT in the same list), so it is settable.
|
||||
constexpr const char* kSoloParm = "I_SOLO";
|
||||
} // namespace
|
||||
|
||||
std::vector<view::TrackSolo> readTrackSolos(const TrackHandles& handles) {
|
||||
std::vector<view::TrackSolo> live;
|
||||
live.reserve(handles.size());
|
||||
for (const auto& [guid, tr] : handles) {
|
||||
if (!tr) continue;
|
||||
live.push_back(view::TrackSolo{
|
||||
guid, static_cast<int>(GetMediaTrackInfo_Value(tr, kSoloParm))});
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
void applySoloOps(const TrackHandles& handles, const std::vector<view::SoloOp>& ops) {
|
||||
if (ops.empty()) return; // the common case: nothing soloed, no project write
|
||||
|
||||
std::map<std::string, int> byGuid;
|
||||
for (const view::SoloOp& op : ops) byGuid[op.guid] = op.value;
|
||||
|
||||
for (const auto& [guid, tr] : handles) {
|
||||
if (!tr) continue;
|
||||
auto it = byGuid.find(guid);
|
||||
if (it == byGuid.end()) continue;
|
||||
SetMediaTrackInfo_Value(tr, kSoloParm, static_cast<double>(it->second));
|
||||
}
|
||||
}
|
||||
|
||||
void clearTrackSolos(const TrackHandles& handles, const std::map<std::string, int>& soloed) {
|
||||
std::vector<view::SoloOp> ops;
|
||||
ops.reserve(soloed.size());
|
||||
for (const auto& entry : soloed) ops.push_back(view::SoloOp{entry.first, 0});
|
||||
applySoloOps(handles, ops);
|
||||
}
|
||||
|
||||
void restoreTrackSolos(const TrackHandles& handles,
|
||||
const std::map<std::string, int>& cached,
|
||||
const std::set<std::string>& liveGuids,
|
||||
const std::set<std::string>& visibleGuids) {
|
||||
applySoloOps(handles, view::planSoloRestore(cached, liveGuids, visibleGuids));
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
// The REAPER read/write half of the per-mode solo surface. Every decision about what
|
||||
// to cache, clear, or replay is core/view/solo_cache; this pair only moves I_SOLO
|
||||
// between the live tracks and that pure plan.
|
||||
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/view/solo_cache.h"
|
||||
|
||||
// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header.
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The (GUID, handle) enumeration applyMode builds from GetTrack. GetTrack's index
|
||||
// space excludes the master track, so no solo read or write reachable through this
|
||||
// type can ever land on master.
|
||||
using TrackHandles = std::vector<std::pair<std::string, MediaTrack*>>;
|
||||
|
||||
std::vector<view::TrackSolo> readTrackSolos(const TrackHandles& handles);
|
||||
|
||||
// Applies each op to its live handle in ONE pass over `handles`; an op naming a GUID
|
||||
// absent from the enumeration is skipped (stale/deleted track).
|
||||
void applySoloOps(const TrackHandles& handles, const std::vector<view::SoloOp>& ops);
|
||||
|
||||
// The outgoing-mode clear: zeroes I_SOLO for every GUID in `soloed` (the just-banked
|
||||
// values applyMode is about to cache).
|
||||
void clearTrackSolos(const TrackHandles& handles, const std::map<std::string, int>& soloed);
|
||||
|
||||
// The incoming-mode replay: applies `cached` filtered through planSoloRestore's drop
|
||||
// rules (dead GUID, not visible in the incoming mode).
|
||||
void restoreTrackSolos(const TrackHandles& handles,
|
||||
const std::map<std::string, int>& cached,
|
||||
const std::set<std::string>& liveGuids,
|
||||
const std::set<std::string>& visibleGuids);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -228,6 +228,37 @@ static void testResizeSweepNoOverlap() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mode-segment enablement (the playback gate's visible half) ---------------
|
||||
|
||||
static void testModeSegmentsAreLiveWithTheTransportStopped() {
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/false, /*routable=*/true));
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false, /*routable=*/true));
|
||||
}
|
||||
|
||||
static void testInactiveSegmentGoesDeadWhileTheTransportRuns() {
|
||||
// The one that would fire a real switch — refused while playing/recording, so it
|
||||
// must read dead rather than invite a click that silently does nothing.
|
||||
CHECK(!modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/true, /*routable=*/true));
|
||||
}
|
||||
|
||||
static void testActiveSegmentStaysLiveWhileTheTransportRuns() {
|
||||
// Clicking the mode you are already in is a reapply, which is never gated;
|
||||
// dimming it would read as "this mode is unavailable".
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/true, /*routable=*/false));
|
||||
}
|
||||
|
||||
static void testInactiveSegmentGoesDeadWhenUnroutable() {
|
||||
// A third registered mode with no seeded command id (the model is N-mode, the UI
|
||||
// ships two ids) must read dead, not live-but-silently-inert on click.
|
||||
CHECK(!modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/false, /*routable=*/false));
|
||||
}
|
||||
|
||||
static void testActiveSegmentStaysLiveEvenWhenUnroutable() {
|
||||
// The active segment always fires a reapply through its own already-resolved id in
|
||||
// practice, but the predicate's active bypass does not consult routable either way.
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false, /*routable=*/false));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testWideFooterAllPlaced();
|
||||
testOffsetFooterAnchorsLeft();
|
||||
@@ -241,6 +272,11 @@ int main() {
|
||||
testHitEdgesAndOutside();
|
||||
testSuppressedClaimsNothing();
|
||||
testResizeSweepNoOverlap();
|
||||
testModeSegmentsAreLiveWithTheTransportStopped();
|
||||
testInactiveSegmentGoesDeadWhileTheTransportRuns();
|
||||
testActiveSegmentStaysLiveWhileTheTransportRuns();
|
||||
testInactiveSegmentGoesDeadWhenUnroutable();
|
||||
testActiveSegmentStaysLiveEvenWhenUnroutable();
|
||||
|
||||
if (g_fail == 0) std::printf("footer_bar: all tests passed\n");
|
||||
else std::printf("footer_bar: %d CHECK(s) FAILED\n", g_fail);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// Standalone tests for reasampler::view::solo_cache — no REAPER, no test framework.
|
||||
//
|
||||
// Covers: the soloed-subset filter (zeros and empty GUIDs dropped, raw values kept),
|
||||
// the cache's store/query/clear lifecycle including the empty-set-removes rule, GUID
|
||||
// pruning on reconcile, and the restore plan's two drop rules (dead GUID, not visible
|
||||
// in the incoming mode — covering both a parked leaf and a derived-invisible parent).
|
||||
|
||||
#include "../src/core/view/solo_cache.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::view;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- soloedTracks: the filter ------------------------------------------------
|
||||
|
||||
static void testUnsoloedProjectYieldsNothingToCache() {
|
||||
const std::map<std::string, int> soloed =
|
||||
soloedTracks({{"{A}", 0}, {"{B}", 0}, {"{C}", 0}});
|
||||
CHECK(soloed.empty());
|
||||
}
|
||||
|
||||
static void testSoloedTracksKeepsRawValueNotABoolean() {
|
||||
const std::map<std::string, int> soloed =
|
||||
soloedTracks({{"{A}", 1}, {"{B}", 0}, {"{C}", 2}, {"{D}", 6}});
|
||||
|
||||
CHECK(soloed.size() == 3);
|
||||
CHECK(soloed.at("{A}") == 1); // plain solo
|
||||
CHECK(soloed.at("{C}") == 2); // solo-in-place survives
|
||||
CHECK(soloed.at("{D}") == 6); // safe solo-in-place survives
|
||||
CHECK(soloed.count("{B}") == 0);
|
||||
}
|
||||
|
||||
static void testSoloedTracksDropsEmptyGuids() {
|
||||
const std::map<std::string, int> soloed = soloedTracks({{"", 1}, {"{A}", 1}});
|
||||
CHECK(soloed.size() == 1);
|
||||
CHECK(soloed.count("{A}") == 1);
|
||||
}
|
||||
|
||||
// --- SoloCache: store / query / clear ----------------------------------------
|
||||
|
||||
static void testStoreThenQueryReturnsTheStoredSet() {
|
||||
SoloCache cache;
|
||||
CHECK(cache.store("arrange", {{"{A}", 1}, {"{B}", 2}}));
|
||||
|
||||
const std::map<std::string, int>* got = cache.query("arrange");
|
||||
CHECK(got != nullptr);
|
||||
CHECK(got->size() == 2);
|
||||
CHECK(got->at("{A}") == 1);
|
||||
CHECK(got->at("{B}") == 2);
|
||||
CHECK(cache.query("design") == nullptr);
|
||||
}
|
||||
|
||||
static void testStoringAnEmptySetRemovesTheModeEntry() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{A}", 1}});
|
||||
CHECK(cache.query("arrange") != nullptr);
|
||||
|
||||
// Unsoloing everything and switching away must leave no record behind — an
|
||||
// empty record would not survive the serialize round trip.
|
||||
CHECK(cache.store("arrange", {}));
|
||||
CHECK(cache.query("arrange") == nullptr);
|
||||
CHECK(cache.empty());
|
||||
}
|
||||
|
||||
static void testStoreReplacesRatherThanMerges() {
|
||||
SoloCache cache;
|
||||
cache.store("design", {{"{A}", 1}, {"{B}", 1}});
|
||||
cache.store("design", {{"{C}", 2}});
|
||||
|
||||
const std::map<std::string, int>* got = cache.query("design");
|
||||
CHECK(got != nullptr);
|
||||
CHECK(got->size() == 1);
|
||||
CHECK(got->count("{C}") == 1);
|
||||
}
|
||||
|
||||
static void testStoreRejectsAnEmptyModeId() {
|
||||
SoloCache cache;
|
||||
CHECK(!cache.store("", {{"{A}", 1}}));
|
||||
CHECK(cache.empty());
|
||||
}
|
||||
|
||||
static void testClearConsumesOnlyTheNamedMode() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{A}", 1}});
|
||||
cache.store("design", {{"{B}", 1}});
|
||||
|
||||
CHECK(cache.clear("arrange"));
|
||||
CHECK(cache.query("arrange") == nullptr);
|
||||
CHECK(cache.query("design") != nullptr);
|
||||
CHECK(!cache.clear("arrange")); // already consumed
|
||||
}
|
||||
|
||||
// --- SoloCache::reconcile ----------------------------------------------------
|
||||
|
||||
static void testReconcileDropsDeadGuidsAcrossEveryMode() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{LIVE}", 1}, {"{DEAD}", 2}});
|
||||
cache.store("design", {{"{DEAD}", 1}});
|
||||
|
||||
CHECK(cache.reconcile({"{LIVE}"}) == 2);
|
||||
|
||||
const std::map<std::string, int>* arrange = cache.query("arrange");
|
||||
CHECK(arrange != nullptr);
|
||||
CHECK(arrange->size() == 1);
|
||||
CHECK(arrange->count("{LIVE}") == 1);
|
||||
// The design entry lost its only track, so the mode record goes with it.
|
||||
CHECK(cache.query("design") == nullptr);
|
||||
}
|
||||
|
||||
static void testReconcileWithEveryGuidLiveRemovesNothing() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{A}", 1}, {"{B}", 5}});
|
||||
CHECK(cache.reconcile({"{A}", "{B}", "{UNRELATED}"}) == 0);
|
||||
CHECK(cache.query("arrange")->size() == 2);
|
||||
}
|
||||
|
||||
// --- planSoloRestore ---------------------------------------------------------
|
||||
|
||||
static void testRestorePlanReplaysEveryLiveVisibleEntryVerbatim() {
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{A}", 1}, {"{B}", 2}}, {"{A}", "{B}"}, {"{A}", "{B}"});
|
||||
|
||||
CHECK(ops.size() == 2);
|
||||
CHECK(ops[0] == (SoloOp{"{A}", 1}));
|
||||
CHECK(ops[1] == (SoloOp{"{B}", 2}));
|
||||
}
|
||||
|
||||
static void testRestorePlanSkipsAGuidThatNoLongerExists() {
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{GONE}", 1}, {"{HERE}", 1}}, {"{HERE}"}, {"{HERE}"});
|
||||
|
||||
CHECK(ops.size() == 1);
|
||||
CHECK(ops[0].guid == "{HERE}");
|
||||
}
|
||||
|
||||
static void testRestorePlanSkipsAParkedLeafNotVisibleInTheIncomingMode() {
|
||||
// Soloing a parked track would silence the mix while contributing nothing
|
||||
// audible, and the track is hidden, so the user could not undo it.
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{PARKED}", 1}, {"{VISIBLE}", 2}}, {"{PARKED}", "{VISIBLE}"},
|
||||
{"{VISIBLE}"});
|
||||
|
||||
CHECK(ops.size() == 1);
|
||||
CHECK(ops[0] == (SoloOp{"{VISIBLE}", 2}));
|
||||
}
|
||||
|
||||
static void testRestorePlanSkipsAFolderParentHiddenInTheIncomingMode() {
|
||||
// The parent is never parked (parents are never parked — see planToggle), but a
|
||||
// derived-invisible parent is hidden all the same: replaying its cached solo
|
||||
// would silence the mix onto a track the user cannot see or unsolo. `visibleGuids`
|
||||
// (not the park plan) is the drop set precisely so this case is caught too.
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{HIDDEN_PARENT}", 1}}, {"{HIDDEN_PARENT}"}, {});
|
||||
|
||||
CHECK(ops.empty());
|
||||
}
|
||||
|
||||
static void testRestorePlanOfAnEmptyCacheWritesNothing() {
|
||||
CHECK(planSoloRestore({}, {"{A}"}, {}).empty());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testUnsoloedProjectYieldsNothingToCache();
|
||||
testSoloedTracksKeepsRawValueNotABoolean();
|
||||
testSoloedTracksDropsEmptyGuids();
|
||||
|
||||
testStoreThenQueryReturnsTheStoredSet();
|
||||
testStoringAnEmptySetRemovesTheModeEntry();
|
||||
testStoreReplacesRatherThanMerges();
|
||||
testStoreRejectsAnEmptyModeId();
|
||||
testClearConsumesOnlyTheNamedMode();
|
||||
|
||||
testReconcileDropsDeadGuidsAcrossEveryMode();
|
||||
testReconcileWithEveryGuidLiveRemovesNothing();
|
||||
|
||||
testRestorePlanReplaysEveryLiveVisibleEntryVerbatim();
|
||||
testRestorePlanSkipsAGuidThatNoLongerExists();
|
||||
testRestorePlanSkipsAParkedLeafNotVisibleInTheIncomingMode();
|
||||
testRestorePlanSkipsAFolderParentHiddenInTheIncomingMode();
|
||||
testRestorePlanOfAnEmptyCacheWritesNothing();
|
||||
|
||||
if (g_fail == 0) std::printf("solo_cache: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ static void testSerializeGoldenLiteral() {
|
||||
"{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
|
||||
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[],"
|
||||
"\"snapshots\":[],\"lanes\":[]}");
|
||||
"\"snapshots\":[],\"lanes\":[],\"soloCache\":[]}");
|
||||
}
|
||||
|
||||
// -- 1. N-mode proven --------------------------------------------------------
|
||||
@@ -1800,6 +1800,88 @@ static void testLaneMalformedJson() {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Per-mode solo cache: persistence + reconcile participation ---------------
|
||||
|
||||
static void testSoloCacheJsonRoundTrip() {
|
||||
ViewModeModel vm;
|
||||
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
vm.storeSnapshot("{T}", TrackSnapshot{1, 1, 1, 1, {0, 1}});
|
||||
CHECK(vm.lanes().setManaged("{T}", "lane:0", kArrangeModeId));
|
||||
|
||||
// Every non-zero I_SOLO variant, across more than one mode, plus a GUID that
|
||||
// exercises the string escaper.
|
||||
CHECK(vm.soloCache().store(kArrangeModeId, {{"{A}", 1}, {"{B\"q\"}", 2}}));
|
||||
CHECK(vm.soloCache().store("mixdown", {{"{C}", 5}, {"{D}", 6}}));
|
||||
CHECK(vm.setActiveMode(kDesignModeId));
|
||||
|
||||
const std::string json = vm.serialize();
|
||||
auto back = ViewModeModel::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == vm); // deserialize(serialize(x)) == x
|
||||
if (back) CHECK(back->serialize() == json); // stable second round-trip
|
||||
|
||||
if (back) {
|
||||
const std::map<std::string, int>* arrange = back->soloCache().query(kArrangeModeId);
|
||||
CHECK(arrange != nullptr);
|
||||
CHECK(arrange && arrange->at("{A}") == 1);
|
||||
CHECK(arrange && arrange->at("{B\"q\"}") == 2);
|
||||
const std::map<std::string, int>* mix = back->soloCache().query("mixdown");
|
||||
CHECK(mix != nullptr);
|
||||
CHECK(mix && mix->at("{C}") == 5);
|
||||
CHECK(mix && mix->at("{D}") == 6);
|
||||
CHECK(back->soloCache().query(kDesignModeId) == nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
static void testBlobWithoutSoloCacheKeyStillParses() {
|
||||
// The compatibility case both ways: a project saved by a build that predates the
|
||||
// key parses to an empty cache, and its own output stays readable here.
|
||||
const char* older =
|
||||
"{\"version\":1,\"activeMode\":\"design\",\"modes\":[{\"id\":\"arrange\","
|
||||
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
|
||||
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[{\"guid\":\"{T}\","
|
||||
"\"modes\":[\"design\"],\"showBoth\":false}],\"snapshots\":[],\"lanes\":[]}";
|
||||
|
||||
auto back = ViewModeModel::deserialize(older);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && back->soloCache().empty());
|
||||
CHECK(back && back->activeModeId() == kDesignModeId);
|
||||
CHECK(back && back->membership().query("{T}") != nullptr);
|
||||
}
|
||||
|
||||
static void testSoloCacheMalformedJson() {
|
||||
const char* bad[] = {
|
||||
"{\"soloCache\":[{\"tracks\":[{\"guid\":\"{A}\",\"solo\":1}]}]}", // missing mode
|
||||
"{\"soloCache\":[{\"mode\":\"\",\"tracks\":[{\"guid\":\"{A}\",\"solo\":1}]}]}", // empty mode
|
||||
"{\"soloCache\":[{\"mode\":\"arrange\"}]}", // missing tracks
|
||||
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[]}]}", // empty tracks
|
||||
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"solo\":1}]}]}", // missing guid
|
||||
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"guid\":\"{A}\"}]}]}", // missing solo
|
||||
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"guid\":\"\",\"solo\":1}]}]}", // empty guid
|
||||
"{\"soloCache\":[", // truncated
|
||||
};
|
||||
for (const char* j : bad) {
|
||||
auto r = ViewModeModel::deserialize(j);
|
||||
CHECK(!r.has_value());
|
||||
}
|
||||
}
|
||||
|
||||
static void testReconcilePrunesTheSoloCacheAlongsideSnapshots() {
|
||||
ViewModeModel vm;
|
||||
vm.storeSnapshot("{LIVE}", TrackSnapshot{1, 1, 1, 1, {}});
|
||||
vm.storeSnapshot("{DEAD}", TrackSnapshot{1, 1, 1, 1, {}});
|
||||
vm.soloCache().store(kDesignModeId, {{"{LIVE}", 1}, {"{DEAD}", 2}});
|
||||
|
||||
// The return stays the SNAPSHOT count; the solo cache is pruned by the same call.
|
||||
CHECK(vm.reconcile({"{LIVE}"}) == 1);
|
||||
|
||||
const std::map<std::string, int>* design = vm.soloCache().query(kDesignModeId);
|
||||
CHECK(design != nullptr);
|
||||
CHECK(design && design->size() == 1);
|
||||
CHECK(design && design->count("{LIVE}") == 1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testSerializeGoldenLiteral();
|
||||
testNModeRegistryAndMembership();
|
||||
@@ -1843,6 +1925,12 @@ int main() {
|
||||
testLaneJsonRoundTrip();
|
||||
testLaneMalformedJson();
|
||||
|
||||
// Per-mode solo cache
|
||||
testSoloCacheJsonRoundTrip();
|
||||
testBlobWithoutSoloCacheKeyStillParses();
|
||||
testSoloCacheMalformedJson();
|
||||
testReconcilePrunesTheSoloCacheAlongsideSnapshots();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user