Ψ-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
+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 (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.
+12 -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,29 @@ 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 lands as a status line. 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
}
+32 -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.
@@ -76,6 +76,18 @@ bool handleToolbarClick(int x, int y, const ActionBarRect& bar,
return true;
}
// The registered activate action behind a footer mode segment. 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) is unreachable from the footer rather than routed around it.
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);
}
// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). Value verified in view.cpp;
// reproduced locally so this file stays self-contained.
constexpr int kFreeModeFixedLanes = 2;
@@ -299,11 +311,18 @@ 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();
// Disabled while the transport runs: claimed but inert, the same shape a
// disabled toolbar row takes — a dead segment reads inert, never absent,
// and never falls through to the grid.
if (modeSegmentEnabled(isActive, g_panel.modeSwitchBlocked)) {
const int cmd = modeActivateCommandId(id);
if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
}
}
return;
}
@@ -534,6 +553,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;
// A switch is refused while the transport runs, so an unreachable segment
// draws dead rather than inviting a click that would silently do nothing.
const bool live = modeSegmentEnabled(active, g_panel.modeSwitchBlocked);
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);
}
+7
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): a mode
// switch is refused while the transport runs, so the footer's [Arrange|Design]
// segments paint disabled. 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
+17 -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. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`.
## Gotchas
+57
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,19 @@ 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;
// GUIDs the plan parks in the mode being entered — the set a solo restore skips.
std::set<std::string> parkedGuidsOf(const TogglePlan& plan) {
std::set<std::string> parked;
for (const TrackPlan& tp : plan.park) {
if (!tp.flags.empty()) parked.insert(tp.flags.front().guid);
}
return parked;
}
// 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 +393,27 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
} // namespace
bool transportBlocksModeSwitch(ReaProject* proj) {
if (!GetPlayStateEx) return false; // fail-open: never gate on a missing API pointer
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 +426,26 @@ 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);
std::vector<view::SoloOp> clearOps;
clearOps.reserve(outgoingSolo.size());
for (const auto& entry : outgoingSolo)
clearOps.push_back(view::SoloOp{entry.first, 0});
applySoloOps(handleByGuid, clearOps);
}
// 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 +502,15 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
model.setActiveMode(targetModeId);
// Replay + consume, before the single relayout below picks the change up.
if (realSwitch) {
if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) {
applySoloOps(handleByGuid,
view::planSoloRestore(*cached, liveGuids, parkedGuidsOf(plan)));
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.
+13 -4
View File
@@ -14,10 +14,19 @@ 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` == nullptr means the current project.
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
+47
View File
@@ -0,0 +1,47 @@
// 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));
}
}
} // namespace reasampler
+28
View File
@@ -0,0 +1,28 @@
#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 <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);
} // namespace reasampler