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

Solo is cached, cleared and replayed per mode on a real switch only; the switch is refused visibly while the transport runs. The footer segment now routes through the activate actions, so a panel switch finally persists.
This commit is contained in:
2026-08-01 19:43:18 -04:00
parent 8bf6841f7b
commit 9c234c2e6b
23 changed files with 811 additions and 49 deletions
+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 (the transport bool is 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) {
return isActiveSegment || !transportRunning;
}
} // namespace reasampler::ui
+7
View File
@@ -61,4 +61,11 @@ 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".
bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning);
} // 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, track parked 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>& parkedGuids) {
std::vector<SoloOp> ops;
for (const auto& [guid, value] : cached) {
if (liveGuids.count(guid) == 0) continue;
if (parkedGuids.count(guid) != 0) continue;
ops.push_back(SoloOp{guid, value});
}
return ops;
}
} // namespace reasampler::view
+86
View File
@@ -0,0 +1,86 @@
#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 parked in the incoming mode — a parked track is hidden and 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.
// 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
// come back unparked.
std::vector<SoloOp> planSoloRestore(const std::map<std::string, int>& cached,
const std::set<std::string>& liveGuids,
const std::set<std::string>& parkedGuids);
} // 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.