feat(view): D4 Design View action family + reapply-on-open wiring

Register toggle/activate/tag/untag/show-both actions driving the D2
shell and D3 model; reapply saved active mode on project load via a
persist load-signal seam. Shared guidString helper; pure nextModeId
unit-tested.
This commit is contained in:
2026-07-22 21:46:58 -04:00
parent 322581227c
commit ae41cad4f5
12 changed files with 418 additions and 16 deletions
+2
View File
@@ -116,6 +116,8 @@ add_library(reaper_reasampler MODULE
src/view_mode_model.cpp
src/view_tree.cpp
src/view.cpp
src/track_guid.cpp
src/actions.cpp
)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
+221
View File
@@ -0,0 +1,221 @@
// actions.cpp — the Design View action family (Phase D4). See actions.h.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers
// (CLAUDE.md §contract). The action ids are minted from FOREVER-STABLE strings (the
// same CEREBELLUM_REASAMPLER_ family prefix main.cpp uses); user keybindings key off
// them, so they must never change after ship.
//
// Each action:
// 1. mutates the session's ViewModeModel (membership tag/untag/show-both, or the
// active mode via toggle/activate) — the pure D1 state,
// 2. reapplies the active mode through the D2 view shell (applyMode) so the change
// takes visible effect immediately (tagging a track into Design while in Arrange
// parks it right away; a mode change re-partitions and re-parks in one step).
//
// Selection-driven mutations iterate the CURRENT REAPER track selection
// (CountSelectedTracks/GetSelectedTrack — both ignore the master, which is correct:
// the master is never tagged) and resolve each track to its canonical GUID key via
// the shared guidString helper, so the keys match exactly what the D2 shell / view
// tree key on (the cross-module key contract).
#include "actions.h"
#include <string>
#include <vector>
#include "persist.h" // ReaSamplerSession (owns view() model)
#include "track_guid.h" // shared MediaTrack* -> canonical GUID key
#include "view.h" // applyMode (D2 shell)
#include "view_mode_model.h"
#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs)
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks
#define REAPERAPI_WANT_GetSelectedTrack
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
// FOREVER-STABLE action-id strings. Same family prefix as main.cpp's capture/panel
// actions; each full string is minted into a persistent command id and user
// keybindings key off it — NEVER change these after ship.
constexpr const char* kIdToggleMode = "CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE";
constexpr const char* kIdActivateArrange = "CEREBELLUM_REASAMPLER_VIEW_ACTIVATE_ARRANGE";
constexpr const char* kIdActivateDesign = "CEREBELLUM_REASAMPLER_VIEW_ACTIVATE_DESIGN";
constexpr const char* kIdTagDesign = "CEREBELLUM_REASAMPLER_VIEW_TAG_DESIGN";
constexpr const char* kIdTagArrange = "CEREBELLUM_REASAMPLER_VIEW_TAG_ARRANGE";
constexpr const char* kIdUntag = "CEREBELLUM_REASAMPLER_VIEW_UNTAG";
constexpr const char* kIdShowBoth = "CEREBELLUM_REASAMPLER_VIEW_SHOW_BOTH";
// The live session the actions mutate. Set once by designViewRegisterActions and
// read by the hookcommand handler. Not owned here (main.cpp owns g_session).
ReaSamplerSession* g_session = nullptr;
// Minted command ids (0 until registration succeeds). Compared in the handler.
int g_cmdToggleMode = 0;
int g_cmdActivateArrange = 0;
int g_cmdActivateDesign = 0;
int g_cmdTagDesign = 0;
int g_cmdTagArrange = 0;
int g_cmdUntag = 0;
int g_cmdShowBoth = 0;
// gaccel storage must outlive registration — REAPER holds each pointer until we
// mirror-unregister it. One per action.
gaccel_register_t g_accelToggleMode{};
gaccel_register_t g_accelActivateArrange{};
gaccel_register_t g_accelActivateDesign{};
gaccel_register_t g_accelTagDesign{};
gaccel_register_t g_accelTagArrange{};
gaccel_register_t g_accelUntag{};
gaccel_register_t g_accelShowBoth{};
// Mints a command id from a stable string and registers its gaccel (Actions-list
// entry with `desc`). Returns the command id (0 on failure). The gaccel storage is
// caller-owned and must outlive the module (the file-scope g_accel* above).
int registerAction(reaper_plugin_info_t* rec, const char* stableId,
gaccel_register_t& accel, const char* desc) {
const int cmd = rec->Register("command_id", (void*)stableId);
if (cmd) {
accel.accel.cmd = cmd;
accel.desc = desc;
rec->Register("gaccel", (void*)&accel);
}
return cmd;
}
// Collects the canonical GUID keys of the current track selection. Empty if nothing
// is selected. CountSelectedTracks/GetSelectedTrack ignore the master (SDK), which is
// exactly right — the master is never a tagged leaf.
std::vector<std::string> selectedTrackGuids() {
std::vector<std::string> guids;
const int n = CountSelectedTracks(nullptr); // nullptr = active project
guids.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
for (int i = 0; i < n; ++i) {
MediaTrack* tr = GetSelectedTrack(nullptr, i);
if (!tr) continue;
std::string g = guidString(tr);
if (!g.empty()) guids.push_back(std::move(g));
}
return guids;
}
// Reapplies the model's CURRENT active mode to the active project so a membership
// mutation takes visible effect immediately (park/unpark/re-derive parents). Called
// after every tag/untag/show-both. `proj = nullptr` -> REAPER's active project.
void reapplyActiveMode() {
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
}
// -- Action bodies ---------------------------------------------------------
// Toggle: cycle to the next mode in ordinal order (Arrange <-> Design with two
// seeds; scales to cycle-through-all for >2 modes with no change here). 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);
}
// 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.
void doActivateMode(const std::string& modeId) {
applyMode(g_session->view(), modeId, nullptr);
}
// Tag the selection's leaves into `modeId`, then reapply so the change is immediate.
// tag() replaces any prior single-mode membership (a leaf lives in one mode; the
// cross-mode case is show-both), matching the D1 contract.
void doTag(const std::string& modeId) {
for (const std::string& g : selectedTrackGuids())
g_session->view().membership().tag(g, modeId);
reapplyActiveMode();
}
// Untag the selection entirely (return each to the Arrange default). This is the
// shared body behind both "Untag selected" and "Tag -> Arrange" (Arrange = the
// absence of a tag), so the two actions are the same act by definition.
void doUntag() {
for (const std::string& g : selectedTrackGuids())
g_session->view().membership().untag(g);
reapplyActiveMode();
}
// Toggle the per-track show-both pin for the selection. Read the CURRENT pin of each
// track and flip it independently (a mixed selection converges toward "all on" then
// "all off" only if uniform; per-track flip is the honest semantics of a toggle on a
// multi-selection). show-both leaves are never parked (D1), so reapply reflects the
// change immediately.
void doShowBoth() {
MembershipIndex& m = g_session->view().membership();
for (const std::string& g : selectedTrackGuids())
m.setShowBoth(g, !m.isShowBoth(g));
reapplyActiveMode();
}
} // namespace
void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
g_session = session;
// command_id -> gaccel for each. The single hookcommand that routes these lives
// in main.cpp (one hook per extension); designViewHandleCommand services them.
g_cmdToggleMode = registerAction(rec, kIdToggleMode, g_accelToggleMode,
"ReaSampler: toggle Design View mode");
g_cmdActivateArrange = registerAction(rec, kIdActivateArrange, g_accelActivateArrange,
"ReaSampler: activate mode Arrange");
g_cmdActivateDesign = registerAction(rec, kIdActivateDesign, g_accelActivateDesign,
"ReaSampler: activate mode Design");
g_cmdTagDesign = registerAction(rec, kIdTagDesign, g_accelTagDesign,
"ReaSampler: tag selected tracks -> Design");
g_cmdTagArrange = registerAction(rec, kIdTagArrange, g_accelTagArrange,
"ReaSampler: tag selected tracks -> Arrange");
g_cmdUntag = registerAction(rec, kIdUntag, g_accelUntag,
"ReaSampler: untag selected tracks");
g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth,
"ReaSampler: show both for selected tracks");
}
bool designViewHandleCommand(int command) {
if (command == 0 || !g_session) return false;
if (command == g_cmdToggleMode) { doToggleMode(); return true; }
if (command == g_cmdActivateArrange) { doActivateMode(kArrangeModeId); return true; }
if (command == g_cmdActivateDesign) { doActivateMode(kDesignModeId); return true; }
if (command == g_cmdTagDesign) { doTag(kDesignModeId); return true; }
// Tag -> Arrange and Untag are the same act (Arrange = the absence of a tag).
if (command == g_cmdTagArrange) { doUntag(); return true; }
if (command == g_cmdUntag) { doUntag(); return true; }
if (command == g_cmdShowBoth) { doShowBoth(); return true; }
return false; // not ours — caller's hookcommand keeps looking
}
void designViewUnregisterActions(reaper_plugin_info_t* rec) {
// Mirror-unregister with '-'-prefixed strings, per the contract's unload rule.
// gaccel first, then the command_id string (reverse of registration order).
rec->Register("-gaccel", (void*)&g_accelShowBoth);
rec->Register("-command_id", (void*)kIdShowBoth);
rec->Register("-gaccel", (void*)&g_accelUntag);
rec->Register("-command_id", (void*)kIdUntag);
rec->Register("-gaccel", (void*)&g_accelTagArrange);
rec->Register("-command_id", (void*)kIdTagArrange);
rec->Register("-gaccel", (void*)&g_accelTagDesign);
rec->Register("-command_id", (void*)kIdTagDesign);
rec->Register("-gaccel", (void*)&g_accelActivateDesign);
rec->Register("-command_id", (void*)kIdActivateDesign);
rec->Register("-gaccel", (void*)&g_accelActivateArrange);
rec->Register("-command_id", (void*)kIdActivateArrange);
rec->Register("-gaccel", (void*)&g_accelToggleMode);
rec->Register("-command_id", (void*)kIdToggleMode);
g_session = nullptr;
}
} // namespace reasampler
+42
View File
@@ -0,0 +1,42 @@
#pragma once
// actions — the Design View action family (Phase D4). Registers the bindable
// actions that drive the mode workflow and wires them end-to-end: toggle/activate
// a mode, tag/untag/show-both the current track selection. Each action mutates the
// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies
// the active mode through the view shell (D2) so the change takes effect immediately.
//
// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This
// header is SDK-free; main.cpp calls register/handle/unregister and nothing else.
//
// Split out of main.cpp (rather than inlined there) to match CONTEXT.md's planned
// `actions` module and keep main.cpp's entrypoint focused on API-pointer ownership
// and lifecycle. The reapply-on-open glue stays in main.cpp (it owns the timer that
// drives persist.poll()); this module only registers and services the actions.
// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct
// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real
// definition. Declared before the namespace so it is the global type, not a
// namespace-local shadow.
struct reaper_plugin_info_t;
namespace reasampler {
class ReaSamplerSession;
// Registers the Design View action family against `rec` (command_id + gaccel +
// hookcommand-routing is owned by the caller's single hookcommand). `session` is the
// live session the actions mutate; it must outlive the registration. Idempotent is
// NOT promised — call exactly once at load, mirror-unregister once at unload.
void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session);
// Services one fired command. Returns true iff `command` is one of this module's
// action ids (and it was handled); false otherwise so the caller's hookcommand keeps
// looking (per the contract: claim only our own ids). Safe to call for any command.
bool designViewHandleCommand(int command);
// Mirror-unregisters everything designViewRegisterActions registered, with the
// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr.
void designViewUnregisterActions(reaper_plugin_info_t* rec);
} // namespace reasampler
+28 -2
View File
@@ -20,10 +20,12 @@
#include <string>
#include "actions.h"
#include "bank_model.h"
#include "bank_panel.h"
#include "capture.h"
#include "persist.h"
#include "view.h"
// Persistent action-id prefix for the ReaSampler action family.
// Every bindable action (capture / insert / slot / verify) mints its command id
@@ -63,6 +65,18 @@ static reasampler::ReaSamplerSession g_session;
static void OnTimer()
{
g_session.poll();
// D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view
// model but deliberately does NOT apply visibility — that would couple persist
// to the view shell). Instead poll() raises a one-shot load signal; here — the
// integration layer that already drives both persist and the view shell — we
// drain it and reapply the SAVED active mode's visibility/processing so opening a
// project saved in Design mode parks the Arrange tracks automatically, no manual
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
// skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
if (g_session.consumeLoadSignal())
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
// Reflect a live bank change (capture / project load) in the docked grid.
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
// an actual change. No-op when the panel is closed.
@@ -118,6 +132,9 @@ static bool OnHookCommand(int command, int /*flag*/)
if (command == 0) return false;
if (command == g_cmdCaptureMasterSpike) { RunCaptureMasterSpike(); return true; }
if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; }
// Design View action family (D4). Claims only its own ids; returns false for the
// rest so this hook keeps looking (per the contract).
if (reasampler::designViewHandleCommand(command)) return true;
return false;
}
@@ -146,6 +163,9 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
// Tear down the Design View action family (D4) — mirror-unregisters each
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
reasampler::designViewUnregisterActions(g_rec);
g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel);
g_rec->Register("-command_id",
(void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL"));
@@ -201,8 +221,14 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
rec->Register("toggleaction", (void*)&OnToggleAction);
}
// One hookcommand routes every ReaSampler action (spike + toggle). Registered
// once, after both command ids are minted.
// Register the Design View action family (D4): toggle/activate mode, tag/untag/
// show-both selected tracks. Each mints its own command_id + gaccel; the single
// hookcommand below routes them via designViewHandleCommand. Registered before
// the hook so every id is minted first.
reasampler::designViewRegisterActions(rec, &g_session);
// One hookcommand routes every ReaSampler action (spike + toggle + Design View).
// Registered once, after all command ids are minted.
rec->Register("hookcommand", (void*)&OnHookCommand);
// Drive project-load / Save-As detection (M4 persist). The timer polls the
+14
View File
@@ -207,6 +207,14 @@ ViewModeModel loadViewModel(ReaProject* proj) {
} // namespace
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
// Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the
// single choke point for every load path (prime, project switch/open, forked-
// sibling load), so setting it here — and NOT on the Save-As branch, which keeps
// the in-memory model as-is — makes the signal fire exactly when a fresh view
// model has been installed and its active mode's visibility needs reapplying.
// main.cpp drains it via consumeLoadSignal() on the same tick.
loadPending_ = true;
// The view model is restored on EVERY load path (peer-symmetry with the bank
// reset below): switching to a project with no view state must clear stale
// in-memory state, not inherit the previous project's. D3 restores MODEL STATE
@@ -264,6 +272,12 @@ std::string ensureProjectGuid(void* proj, const std::string& rppPath,
} // namespace
bool ReaSamplerSession::consumeLoadSignal() {
const bool pending = loadPending_;
loadPending_ = false;
return pending;
}
void ReaSamplerSession::poll() {
std::string rppPath;
void* proj = readActiveProject(rppPath);
+13
View File
@@ -89,6 +89,18 @@ public:
// Intended to be driven by REAPER's "timer" register. Idempotent per tick.
void poll();
// Load signal for the D4 reapply-on-open glue. poll() raises this whenever it
// (re)loads the view model from a project — prime, a project switch/open, or a
// forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears
// it, so the integration layer (main.cpp) can react by reapplying the saved
// active mode's visibility exactly once, then goes quiet on idle ticks.
//
// Signal-based seam by design: persist stays MODEL-ONLY (it never calls the view
// shell), so there is no persist -> view dependency. main.cpp owns the glue —
// it drives both persist.poll() and view::applyMode, so the reapply wiring lives
// where those two already meet. D3 deliberately deferred exactly this to D4.
bool consumeLoadSignal();
private:
BankIndex bank_;
@@ -108,6 +120,7 @@ private:
std::string lastGuid_; // "" until the first saved project is seen
std::string lastRppPath_; // .rpp path last seen for lastProject_
bool primed_ = false; // false until the first poll() observes state
bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal
// Load the index from the given project's ext state and resolve bank paths
// against projectDir. Replaces the in-memory bank. projectDir empty -> clears
+24
View File
@@ -0,0 +1,24 @@
// track_guid.cpp — the single MediaTrack* -> canonical GUID key formatter. See
// track_guid.h. Compiled into the reaper_reasampler MODULE; includes
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU
// that defines the API pointers — CLAUDE.md §contract).
#include "track_guid.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_GetTrackGUID
#define REAPERAPI_WANT_guidToString
#include "reaper_plugin_functions.h"
namespace reasampler {
std::string guidString(MediaTrack* tr) {
if (!tr) return {};
GUID* g = GetTrackGUID(tr);
if (!g) return {};
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
guidToString(g, buf);
return std::string(buf);
}
} // namespace reasampler
+24
View File
@@ -0,0 +1,24 @@
#pragma once
// track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID
// string used as a membership-index key. Both the Design View shell (view.cpp) and
// the actions layer (actions.cpp) key membership on this exact string, so the key
// contract lives in a single helper rather than being re-derived (and drifting) at
// two call sites (the cross-module key contract flagged in D2 review).
//
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern —
// CLAUDE.md §contract). MediaTrack is forward-declared so this header stays SDK-lite.
#include <string>
class MediaTrack;
namespace reasampler {
// REAPER's canonical "{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}" form of a track's
// GUID (GetTrackGUID -> guidToString). Empty string if `tr` has no GUID. This IS
// the membership-index key format — it must match guidToString's braces exactly so
// the view tree keys and the model/actions keys align.
std::string guidString(MediaTrack* tr);
} // namespace reasampler
+2 -14
View File
@@ -14,13 +14,12 @@
#include <string>
#include <vector>
#include "track_guid.h"
#include "view_tree.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetTrackGUID
#define REAPERAPI_WANT_guidToString
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_TrackFX_GetCount
@@ -34,17 +33,6 @@ namespace reasampler {
namespace {
// REAPER's GUID -> string form. guidToString needs a 64-byte destination (SDK
// contract); the canonical "{XXXXXXXX-...}" string is the membership-index key the
// actions layer tags with, so the tree keys and the model keys align exactly.
std::string trackGuidString(MediaTrack* tr) {
GUID* g = GetTrackGUID(tr);
if (!g) return {};
char buf[64] = {0};
guidToString(g, buf);
return std::string(buf);
}
// The parmname for each planner Flag. All four are documented bool*/int* track
// info params driven through the double-valued Get/SetMediaTrackInfo_Value API.
const char* flagParm(Flag f) {
@@ -72,7 +60,7 @@ std::vector<TrackFolderEntry> readFolderEntries(
for (int i = 0; i < count; ++i) {
MediaTrack* tr = GetTrack(proj, i);
if (!tr) continue;
std::string guid = trackGuidString(tr);
std::string guid = guidString(tr);
if (guid.empty()) continue;
int depth = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"));
entries.push_back(TrackFolderEntry{guid, depth});
+12
View File
@@ -123,6 +123,18 @@ TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
return p;
}
std::string nextModeId(const ModeRegistry& modes, const std::string& currentModeId) {
const std::vector<Mode>& all = modes.all();
if (all.empty()) return {}; // nothing to cycle to
for (std::size_t i = 0; i < all.size(); ++i) {
if (all[i].id == currentModeId)
return all[(i + 1) % all.size()].id; // wrap past the last
}
// Active mode not in the registry (stale/unknown) — jump to the first mode as a
// sane home rather than returning "".
return all.front().id;
}
// ---------------------------------------------------------------------------
// ViewModeModel
// ---------------------------------------------------------------------------
+11
View File
@@ -313,4 +313,15 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount);
// restore-contract invariant directly.
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap);
// The next mode id in the registry's ordinal order, cycling past `currentModeId`
// and wrapping to the first mode after the last (Arrange -> Design -> Arrange with
// the two seed modes; the same cycle scales to N modes with no call-site change).
// This is the pure decision behind the "toggle active mode" action: the shell reads
// the model's active mode, asks for the next one, and applies it.
// * empty registry -> "" (nothing to cycle to)
// * currentModeId not present -> the first mode's id (a sane home to jump to)
// Exposed as a free function (not a model member) so it is unit-testable against a
// bare ModeRegistry without a full ViewModeModel.
std::string nextModeId(const ModeRegistry& modes, const std::string& currentModeId);
} // namespace reasampler
+25
View File
@@ -383,6 +383,30 @@ static void testPlanToggleParkHasEmptyFxOffline() {
CHECK(plan.park[0].flags.size() == 4);
}
// -- 8. nextModeId cycle (D4 toggle helper) ----------------------------------
static void testNextModeIdCycles() {
ModeRegistry seeded; // Arrange(0) + Design(1)
// Two-mode cycle: Arrange -> Design -> Arrange (wraps past the last).
CHECK(nextModeId(seeded, kArrangeModeId) == kDesignModeId);
CHECK(nextModeId(seeded, kDesignModeId) == kArrangeModeId);
// Extends to cycle-through-all with >2 modes, in ordinal order.
ModeRegistry three;
CHECK(three.add(Mode{"mixdown", "Mixdown", 2}));
CHECK(nextModeId(three, kArrangeModeId) == kDesignModeId);
CHECK(nextModeId(three, kDesignModeId) == "mixdown");
CHECK(nextModeId(three, "mixdown") == kArrangeModeId); // wraps
// Unknown/stale current id -> first mode (a sane home, not "").
CHECK(nextModeId(seeded, "does-not-exist") == kArrangeModeId);
// Empty registry -> "" (nothing to cycle to).
ModeRegistry empty = ModeRegistry::makeEmpty();
CHECK(nextModeId(empty, kArrangeModeId).empty());
}
int main() {
testNModeRegistryAndMembership();
testParentDerivationMultiMode();
@@ -393,6 +417,7 @@ int main() {
testEmptyModelRoundTrip();
testMalformedJson();
testPlanToggleParkHasEmptyFxOffline();
testNextModeIdCycles();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;