Ψ-W1-T2 review remediation: solo restore drops on visible-in-target, not parked; N-mode segments read dead when unroutable

Fixes a hidden-parent solo replay that could silence the mix. Also closes the N-mode segment silent no-op, amends the invariant comment, trims view.cpp under 600 lines, hedges two SDK inferences, drops a dead null-check.
This commit is contained in:
2026-08-01 20:13:10 -04:00
parent 9c234c2e6b
commit f2cdf676f3
17 changed files with 137 additions and 73 deletions
+4 -2
View File
@@ -162,8 +162,10 @@ void persistViewState() {
}
// 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.
// 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);
+18 -17
View File
@@ -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
@@ -76,18 +90,6 @@ 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;
@@ -316,11 +318,10 @@ void handleClick(int x, int y) {
if (seg < static_cast<int>(modes.size())) {
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);
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);
}
}
+3 -3
View File
@@ -112,9 +112,9 @@ 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);
// 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
: (live ? hoverState(g_panel.hovered, HoverKind::ModeSegment, i)
+7 -4
View File
@@ -292,10 +292,10 @@ 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.
// 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;
@@ -485,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();
+7 -1
View File
@@ -83,7 +83,7 @@ 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. 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`.
- `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
@@ -94,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.
+6 -18
View File
@@ -60,15 +60,6 @@ constexpr int kFreeModeFixedLanes = 2;
// 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).
@@ -394,7 +385,6 @@ 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;
}
@@ -439,11 +429,7 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
// 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);
clearTrackSolos(handleByGuid, outgoingSolo);
}
// PARK: snapshot before mutating, store into the model, then apply.
@@ -502,11 +488,13 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
model.setActiveMode(targetModeId);
// Replay + consume, before the single relayout below picks the change up.
// 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)) {
applySoloOps(handleByGuid,
view::planSoloRestore(*cached, liveGuids, parkedGuidsOf(plan)));
restoreTrackSolos(handleByGuid, *cached, liveGuids, visible);
model.soloCache().clear(targetModeId);
}
}
+5 -2
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>
@@ -17,7 +18,9 @@ namespace reasampler {
// 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.
// 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
+14
View File
@@ -44,4 +44,18 @@ void applySoloOps(const TrackHandles& handles, const std::vector<view::SoloOp>&
}
}
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
+13
View File
@@ -3,6 +3,8 @@
// 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>
@@ -25,4 +27,15 @@ std::vector<view::TrackSolo> readTrackSolos(const TrackHandles& handles);
// 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