Merge Ψ-W1-T2: disjoint per-mode solo surfaces and a playback-gated mode switch

This commit is contained in:
2026-08-01 20:37:24 -04:00
23 changed files with 876 additions and 50 deletions
+23 -13
View File
@@ -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)
+2 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+4
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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.
+7 -3
View File
@@ -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)
+66
View File
@@ -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
+90
View File
@@ -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
+86 -1
View File
@@ -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
+8 -2
View File
@@ -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.
+14 -3
View File
@@ -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
}
+33 -5
View File
@@ -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();
+6 -2
View File
@@ -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);
}
+10
View File
@@ -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();
+23 -6
View File
@@ -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.
+45
View File
@@ -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
View File
@@ -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
+61
View File
@@ -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
+41
View File
@@ -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
+36
View File
@@ -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);
+188
View File
@@ -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;
}
+89 -1
View File
@@ -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;
}