From ae41cad4f5ea4ba1fc65622a6b447bf2148bf764 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 22 Jul 2026 21:46:58 -0400 Subject: [PATCH] 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. --- CMakeLists.txt | 2 + src/actions.cpp | 221 +++++++++++++++++++++++++++++++++ src/actions.h | 42 +++++++ src/main.cpp | 30 ++++- src/persist.cpp | 14 +++ src/persist.h | 13 ++ src/track_guid.cpp | 24 ++++ src/track_guid.h | 24 ++++ src/view.cpp | 16 +-- src/view_mode_model.cpp | 12 ++ src/view_mode_model.h | 11 ++ tests/test_view_mode_model.cpp | 25 ++++ 12 files changed, 418 insertions(+), 16 deletions(-) create mode 100644 src/actions.cpp create mode 100644 src/actions.h create mode 100644 src/track_guid.cpp create mode 100644 src/track_guid.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9cf4104..214eaec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) diff --git a/src/actions.cpp b/src/actions.cpp new file mode 100644 index 0000000..bee0e87 --- /dev/null +++ b/src/actions.cpp @@ -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 +#include + +#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 selectedTrackGuids() { + std::vector guids; + const int n = CountSelectedTracks(nullptr); // nullptr = active project + guids.reserve(static_cast(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 diff --git a/src/actions.h b/src/actions.h new file mode 100644 index 0000000..4b8d382 --- /dev/null +++ b/src/actions.h @@ -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 diff --git a/src/main.cpp b/src/main.cpp index dbf2dcf..0f0648f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,10 +20,12 @@ #include +#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 diff --git a/src/persist.cpp b/src/persist.cpp index aef6d27..091c50f 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -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); diff --git a/src/persist.h b/src/persist.h index 86a273c..b27a0ec 100644 --- a/src/persist.h +++ b/src/persist.h @@ -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 diff --git a/src/track_guid.cpp b/src/track_guid.cpp new file mode 100644 index 0000000..8a0de5b --- /dev/null +++ b/src/track_guid.cpp @@ -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 diff --git a/src/track_guid.h b/src/track_guid.h new file mode 100644 index 0000000..f13bac3 --- /dev/null +++ b/src/track_guid.h @@ -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 + +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 diff --git a/src/view.cpp b/src/view.cpp index 400a25b..91d113e 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -14,13 +14,12 @@ #include #include +#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 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(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH")); entries.push_back(TrackFolderEntry{guid, depth}); diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 71dc284..77d5bad 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -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& 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 // --------------------------------------------------------------------------- diff --git a/src/view_mode_model.h b/src/view_mode_model.h index 1414948..353eea3 100644 --- a/src/view_mode_model.h +++ b/src/view_mode_model.h @@ -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 diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index e712955..d8cf5d8 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -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;