diff --git a/docs/product/design-view.md b/docs/product/design-view.md index 72ec0b0..dffb4ca 100644 --- a/docs/product/design-view.md +++ b/docs/product/design-view.md @@ -157,12 +157,19 @@ load hitch and any un-persisted internal state is lost. This is an accepted cost of the CPU reclaim, not a bug. It must be documented at the toggle affordance so the user isn't surprised. -**Never touched:** `B_MUTE` and `I_SOLO`. The tool owns visibility, `B_MAINSEND`, -`I_FXEN`, and FX-offline — nothing else — across every managed leaf, tagged or -untagged. The user's mute/solo survives every toggle, untouched. This is the exact -analog of the -capture pillar's non-destructive invariant: **the tool never destroys the user's -real state to do its job.** +**Never touched:** `B_MUTE`. The tool owns visibility, `B_MAINSEND`, `I_FXEN`, +FX-offline, and `I_SOLO` — nothing else — across every managed leaf, tagged or +untagged. + +**Solo is owned but never lost.** Solo is a per-mode surface: switching modes banks +the outgoing mode's solo state, clears it, and replays the incoming mode's on +return, verbatim. Two modes therefore never share a solo — you can solo the drum +bus in Arrange and the sound-design chain in Design without either leaking into the +other — and neither is destroyed. That is the same exact analog of the capture +pillar's non-destructive invariant the flags above satisfy: **the tool never +destroys the user's real state to do its job.** It is snapshot-and-restore, one +level out from a single toggle to the pair of stances. Reapplying the current mode +(tagging, project load) is not a switch and does not touch solo at all. --- @@ -316,8 +323,10 @@ Mirrors the capture pillar's split exactly. - Snapshots prior flag values before parking (reads the same flags it will drive). - Resolves track GUIDs via `GetTrackGUID` / `guidToString` / `stringToGuid` for the index; never uses track index (unstable across reorders). -- Never touches the master track's visibility flags; never touches `B_MUTE` / - `I_SOLO` on anything. +- On a real switch only, banks/clears/replays `I_SOLO` per the per-mode solo surface + above. +- Never touches the master track's visibility flags; never touches `B_MUTE` on + anything. **`persist` slice:** - Serialize/deserialize the view section (modes + membership + show-both + snapshots @@ -585,11 +594,12 @@ The settled distinction: touch them**: a mode toggle never shows, hides, silences, re-lanes, or re-plays a manual lane. Its `C_LANEPLAYS` state is the user's, left exactly as they set it. -This is the fixed-lane analog of the two invariants already load-bearing in D1 — -*never touch `B_MUTE`/`I_SOLO`* and *never touch the master* — extended to a third -surface: **never drive a lane the tool did not mint.** It is the same non-destructive -promise (the tool owns only what it created) reaching one level deeper, into the lane -dimension. +This is the fixed-lane analog of the invariants already load-bearing in D1 — +*never touch `B_MUTE`*, *never touch the master*, and *never lose the user's solo* +(see "Never touched" above) — extended to a further surface: **never drive a lane +the tool did not mint.** It is the same non-destructive promise (the tool owns only +what it created, and restores what it parks) reaching one level deeper, into the +lane dimension. ### Lane-ownership index (the new data) diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index aa776a8..7c82568 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -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. diff --git a/src/core/ui/CLAUDE.md b/src/core/ui/CLAUDE.md index e28b7f9..f11d14a 100644 --- a/src/core/ui/CLAUDE.md +++ b/src/core/ui/CLAUDE.md @@ -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 AND under whether the shell resolved a routable command id for it (both bools 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. diff --git a/src/core/ui/footer_bar.cpp b/src/core/ui/footer_bar.cpp index 7094938..e945ef7 100644 --- a/src/core/ui/footer_bar.cpp +++ b/src/core/ui/footer_bar.cpp @@ -64,4 +64,8 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) { return FooterHit::None; } +bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable) { + return isActiveSegment || (!transportRunning && routable); +} + } // namespace reasampler::ui diff --git a/src/core/ui/footer_bar.h b/src/core/ui/footer_bar.h index 1064b5f..9b2c562 100644 --- a/src/core/ui/footer_bar.h +++ b/src/core/ui/footer_bar.h @@ -61,4 +61,15 @@ 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". `routable` is whether +// the shell resolved a live command id for this segment (the model is N-mode, the UI ships two +// seeded ids — a third registered mode has no action to route through, so its segment must read +// dead rather than paint live and silently no-op on click). Passed IN so this predicate — and +// `core/ui` — stays REAPER-free, exactly as `transportRunning` is. +bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable); + } // namespace reasampler::ui diff --git a/src/core/view/CLAUDE.md b/src/core/view/CLAUDE.md index 1fcc3dc..b8ecfbd 100644 --- a/src/core/view/CLAUDE.md +++ b/src/core/view/CLAUDE.md @@ -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, not visible 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. diff --git a/src/core/view/CMakeLists.txt b/src/core/view/CMakeLists.txt index 5062b57..ee1669e 100644 --- a/src/core/view/CMakeLists.txt +++ b/src/core/view/CMakeLists.txt @@ -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) diff --git a/src/core/view/solo_cache.cpp b/src/core/view/solo_cache.cpp new file mode 100644 index 0000000..753b7a0 --- /dev/null +++ b/src/core/view/solo_cache.cpp @@ -0,0 +1,66 @@ +// solo_cache — pure implementation. See solo_cache.h. + +#include "core/view/solo_cache.h" + +namespace reasampler::view { + +std::map soloedTracks(const std::vector& live) { + std::map 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& soloed) { + if (modeId.empty()) return false; + if (soloed.empty()) { + byMode_.erase(modeId); + return true; + } + byMode_[modeId] = soloed; + return true; +} + +const std::map* 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& 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 planSoloRestore(const std::map& cached, + const std::set& liveGuids, + const std::set& visibleGuids) { + std::vector ops; + for (const auto& [guid, value] : cached) { + if (liveGuids.count(guid) == 0) continue; + if (visibleGuids.count(guid) == 0) continue; + ops.push_back(SoloOp{guid, value}); + } + return ops; +} + +} // namespace reasampler::view diff --git a/src/core/view/solo_cache.h b/src/core/view/solo_cache.h new file mode 100644 index 0000000..4baa715 --- /dev/null +++ b/src/core/view/solo_cache.h @@ -0,0 +1,90 @@ +#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 +#include +#include +#include +#include + +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 soloedTracks(const std::vector& 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& soloed); + + const std::map* query(const std::string& modeId) const; + + bool clear(const std::string& modeId); + + const std::map>& 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& liveGuids); + + bool operator==(const SoloCache& o) const { return byMode_ == o.byMode_; } + +private: + std::map> 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 absent from `visibleGuids` — not visible in the incoming mode, whether +// because the leaf is parked or because it is a folder parent that is itself +// derived-invisible there. Either way the track is hidden and (for a parked +// leaf) 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. A show-both leaf is a member of every mode's visible set, so it is +// never dropped by this rule. +// 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 +// become visible again. +std::vector planSoloRestore(const std::map& cached, + const std::set& liveGuids, + const std::set& visibleGuids); + +} // namespace reasampler::view diff --git a/src/core/view/view_mode_model.cpp b/src/core/view/view_mode_model.cpp index 0a6da20..81b53be 100644 --- a/src/core/view/view_mode_model.cpp +++ b/src/core/view/view_mode_model.cpp @@ -247,6 +247,8 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const { std::size_t ViewModeModel::reconcile(const std::set& 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 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& 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 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 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 diff --git a/src/core/view/view_mode_model.h b/src/core/view/view_mode_model.h index db4408f..31d617d 100644 --- a/src/core/view/view_mode_model.h +++ b/src/core/view/view_mode_model.h @@ -12,6 +12,8 @@ #include #include +#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& 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 snapshots_; // guid -> pre-park snapshot + view::SoloCache soloCache_; // modeId -> guid -> raw I_SOLO }; // Fixed-zero park plan for one leaf, offlining `fxCount` slots. diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index 5e0d4ca..a6f45ca 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -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,31 @@ 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 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); +} + // 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 } diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 2fbdf91..8fbd912 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -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. @@ -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 @@ -299,11 +313,17 @@ void handleClick(int x, int y) { { const int seg = footerToggleSegmentHit(x, y, w, h); if (seg >= 0) { - const std::vector& modes = g_panel.session->view().modes().all(); + const ViewModeModel& view = g_panel.session->view(); + const std::vector& modes = view.modes().all(); if (seg < static_cast(modes.size())) { - applyMode(g_panel.session->view(), - modes[static_cast(seg)].id, nullptr); - invalidatePanel(); + const std::string& id = modes[static_cast(seg)].id; + const bool isActive = id == view.activeModeId(); + 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); + } } return; } @@ -534,6 +554,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(); diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp index c683a8b..cd6be8f 100644 --- a/src/shell/panel/panel_render.cpp +++ b/src/shell/panel/panel_render.cpp @@ -112,13 +112,17 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) { const SegmentRect& s = segs[static_cast(i)]; const Mode& mode = modes[static_cast(i)]; const bool active = mode.id == activeId; + // 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 - : 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); } diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index 2187c2f..a16eb6c 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -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). 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; // The named bank the banks region shows — distinct from the active/capture-target @@ -478,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(); diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 6144b6f..ac49705 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -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, 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 @@ -83,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. diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 9bb8ed1..a81058f 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -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,10 @@ 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; + // 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 +384,26 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, } // namespace +bool transportBlocksModeSwitch(ReaProject* proj) { + 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> handleByGuid; std::vector entries = readFolderEntries(proj, handleByGuid); FolderTree tree = buildFolderTree(entries); @@ -394,10 +416,22 @@ 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 outgoingSolo = + realSwitch ? view::soloedTracks(readTrackSolos(handleByGuid)) + : std::map{}; + 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); + clearTrackSolos(handleByGuid, outgoingSolo); + } + // 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 +488,17 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject model.setActiveMode(targetModeId); + // 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* cached = model.soloCache().query(targetModeId)) { + restoreTrackSolos(handleByGuid, *cached, liveGuids, visible); + 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. diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 856a0cd..94448ed 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -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 @@ -14,10 +15,21 @@ 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` 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 +// 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 diff --git a/src/shell/view/view_solo.cpp b/src/shell/view/view_solo.cpp new file mode 100644 index 0000000..20a925c --- /dev/null +++ b/src/shell/view/view_solo.cpp @@ -0,0 +1,61 @@ +// 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 + +#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 readTrackSolos(const TrackHandles& handles) { + std::vector live; + live.reserve(handles.size()); + for (const auto& [guid, tr] : handles) { + if (!tr) continue; + live.push_back(view::TrackSolo{ + guid, static_cast(GetMediaTrackInfo_Value(tr, kSoloParm))}); + } + return live; +} + +void applySoloOps(const TrackHandles& handles, const std::vector& ops) { + if (ops.empty()) return; // the common case: nothing soloed, no project write + + std::map 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(it->second)); + } +} + +void clearTrackSolos(const TrackHandles& handles, const std::map& soloed) { + std::vector 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& cached, + const std::set& liveGuids, + const std::set& visibleGuids) { + applySoloOps(handles, view::planSoloRestore(cached, liveGuids, visibleGuids)); +} + +} // namespace reasampler diff --git a/src/shell/view/view_solo.h b/src/shell/view/view_solo.h new file mode 100644 index 0000000..7e4d5bb --- /dev/null +++ b/src/shell/view/view_solo.h @@ -0,0 +1,41 @@ +#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 +#include +#include +#include +#include + +#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::vector 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& 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& 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& cached, + const std::set& liveGuids, + const std::set& visibleGuids); + +} // namespace reasampler diff --git a/tests/test_footer_bar.cpp b/tests/test_footer_bar.cpp index 73a2462..88717dd 100644 --- a/tests/test_footer_bar.cpp +++ b/tests/test_footer_bar.cpp @@ -228,6 +228,37 @@ static void testResizeSweepNoOverlap() { } } +// --- Mode-segment enablement (the playback gate's visible half) --------------- + +static void testModeSegmentsAreLiveWithTheTransportStopped() { + CHECK(modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/false, /*routable=*/true)); + CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false, /*routable=*/true)); +} + +static void testInactiveSegmentGoesDeadWhileTheTransportRuns() { + // The one that would fire a real switch — refused while playing/recording, so it + // must read dead rather than invite a click that silently does nothing. + CHECK(!modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/true, /*routable=*/true)); +} + +static void testActiveSegmentStaysLiveWhileTheTransportRuns() { + // Clicking the mode you are already in is a reapply, which is never gated; + // dimming it would read as "this mode is unavailable". + CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/true, /*routable=*/false)); +} + +static void testInactiveSegmentGoesDeadWhenUnroutable() { + // A third registered mode with no seeded command id (the model is N-mode, the UI + // ships two ids) must read dead, not live-but-silently-inert on click. + CHECK(!modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/false, /*routable=*/false)); +} + +static void testActiveSegmentStaysLiveEvenWhenUnroutable() { + // The active segment always fires a reapply through its own already-resolved id in + // practice, but the predicate's active bypass does not consult routable either way. + CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false, /*routable=*/false)); +} + int main() { testWideFooterAllPlaced(); testOffsetFooterAnchorsLeft(); @@ -241,6 +272,11 @@ int main() { testHitEdgesAndOutside(); testSuppressedClaimsNothing(); testResizeSweepNoOverlap(); + testModeSegmentsAreLiveWithTheTransportStopped(); + testInactiveSegmentGoesDeadWhileTheTransportRuns(); + testActiveSegmentStaysLiveWhileTheTransportRuns(); + testInactiveSegmentGoesDeadWhenUnroutable(); + testActiveSegmentStaysLiveEvenWhenUnroutable(); if (g_fail == 0) std::printf("footer_bar: all tests passed\n"); else std::printf("footer_bar: %d CHECK(s) FAILED\n", g_fail); diff --git a/tests/test_solo_cache.cpp b/tests/test_solo_cache.cpp new file mode 100644 index 0000000..6567c63 --- /dev/null +++ b/tests/test_solo_cache.cpp @@ -0,0 +1,188 @@ +// Standalone tests for reasampler::view::solo_cache — no REAPER, no test framework. +// +// Covers: the soloed-subset filter (zeros and empty GUIDs dropped, raw values kept), +// the cache's store/query/clear lifecycle including the empty-set-removes rule, GUID +// pruning on reconcile, and the restore plan's two drop rules (dead GUID, not visible +// in the incoming mode — covering both a parked leaf and a derived-invisible parent). + +#include "../src/core/view/solo_cache.h" + +#include + +using namespace reasampler::view; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- soloedTracks: the filter ------------------------------------------------ + +static void testUnsoloedProjectYieldsNothingToCache() { + const std::map soloed = + soloedTracks({{"{A}", 0}, {"{B}", 0}, {"{C}", 0}}); + CHECK(soloed.empty()); +} + +static void testSoloedTracksKeepsRawValueNotABoolean() { + const std::map soloed = + soloedTracks({{"{A}", 1}, {"{B}", 0}, {"{C}", 2}, {"{D}", 6}}); + + CHECK(soloed.size() == 3); + CHECK(soloed.at("{A}") == 1); // plain solo + CHECK(soloed.at("{C}") == 2); // solo-in-place survives + CHECK(soloed.at("{D}") == 6); // safe solo-in-place survives + CHECK(soloed.count("{B}") == 0); +} + +static void testSoloedTracksDropsEmptyGuids() { + const std::map soloed = soloedTracks({{"", 1}, {"{A}", 1}}); + CHECK(soloed.size() == 1); + CHECK(soloed.count("{A}") == 1); +} + +// --- SoloCache: store / query / clear ---------------------------------------- + +static void testStoreThenQueryReturnsTheStoredSet() { + SoloCache cache; + CHECK(cache.store("arrange", {{"{A}", 1}, {"{B}", 2}})); + + const std::map* got = cache.query("arrange"); + CHECK(got != nullptr); + CHECK(got->size() == 2); + CHECK(got->at("{A}") == 1); + CHECK(got->at("{B}") == 2); + CHECK(cache.query("design") == nullptr); +} + +static void testStoringAnEmptySetRemovesTheModeEntry() { + SoloCache cache; + cache.store("arrange", {{"{A}", 1}}); + CHECK(cache.query("arrange") != nullptr); + + // Unsoloing everything and switching away must leave no record behind — an + // empty record would not survive the serialize round trip. + CHECK(cache.store("arrange", {})); + CHECK(cache.query("arrange") == nullptr); + CHECK(cache.empty()); +} + +static void testStoreReplacesRatherThanMerges() { + SoloCache cache; + cache.store("design", {{"{A}", 1}, {"{B}", 1}}); + cache.store("design", {{"{C}", 2}}); + + const std::map* got = cache.query("design"); + CHECK(got != nullptr); + CHECK(got->size() == 1); + CHECK(got->count("{C}") == 1); +} + +static void testStoreRejectsAnEmptyModeId() { + SoloCache cache; + CHECK(!cache.store("", {{"{A}", 1}})); + CHECK(cache.empty()); +} + +static void testClearConsumesOnlyTheNamedMode() { + SoloCache cache; + cache.store("arrange", {{"{A}", 1}}); + cache.store("design", {{"{B}", 1}}); + + CHECK(cache.clear("arrange")); + CHECK(cache.query("arrange") == nullptr); + CHECK(cache.query("design") != nullptr); + CHECK(!cache.clear("arrange")); // already consumed +} + +// --- SoloCache::reconcile ---------------------------------------------------- + +static void testReconcileDropsDeadGuidsAcrossEveryMode() { + SoloCache cache; + cache.store("arrange", {{"{LIVE}", 1}, {"{DEAD}", 2}}); + cache.store("design", {{"{DEAD}", 1}}); + + CHECK(cache.reconcile({"{LIVE}"}) == 2); + + const std::map* arrange = cache.query("arrange"); + CHECK(arrange != nullptr); + CHECK(arrange->size() == 1); + CHECK(arrange->count("{LIVE}") == 1); + // The design entry lost its only track, so the mode record goes with it. + CHECK(cache.query("design") == nullptr); +} + +static void testReconcileWithEveryGuidLiveRemovesNothing() { + SoloCache cache; + cache.store("arrange", {{"{A}", 1}, {"{B}", 5}}); + CHECK(cache.reconcile({"{A}", "{B}", "{UNRELATED}"}) == 0); + CHECK(cache.query("arrange")->size() == 2); +} + +// --- planSoloRestore --------------------------------------------------------- + +static void testRestorePlanReplaysEveryLiveVisibleEntryVerbatim() { + const std::vector ops = + planSoloRestore({{"{A}", 1}, {"{B}", 2}}, {"{A}", "{B}"}, {"{A}", "{B}"}); + + CHECK(ops.size() == 2); + CHECK(ops[0] == (SoloOp{"{A}", 1})); + CHECK(ops[1] == (SoloOp{"{B}", 2})); +} + +static void testRestorePlanSkipsAGuidThatNoLongerExists() { + const std::vector ops = + planSoloRestore({{"{GONE}", 1}, {"{HERE}", 1}}, {"{HERE}"}, {"{HERE}"}); + + CHECK(ops.size() == 1); + CHECK(ops[0].guid == "{HERE}"); +} + +static void testRestorePlanSkipsAParkedLeafNotVisibleInTheIncomingMode() { + // Soloing a parked track would silence the mix while contributing nothing + // audible, and the track is hidden, so the user could not undo it. + const std::vector ops = + planSoloRestore({{"{PARKED}", 1}, {"{VISIBLE}", 2}}, {"{PARKED}", "{VISIBLE}"}, + {"{VISIBLE}"}); + + CHECK(ops.size() == 1); + CHECK(ops[0] == (SoloOp{"{VISIBLE}", 2})); +} + +static void testRestorePlanSkipsAFolderParentHiddenInTheIncomingMode() { + // The parent is never parked (parents are never parked — see planToggle), but a + // derived-invisible parent is hidden all the same: replaying its cached solo + // would silence the mix onto a track the user cannot see or unsolo. `visibleGuids` + // (not the park plan) is the drop set precisely so this case is caught too. + const std::vector ops = + planSoloRestore({{"{HIDDEN_PARENT}", 1}}, {"{HIDDEN_PARENT}"}, {}); + + CHECK(ops.empty()); +} + +static void testRestorePlanOfAnEmptyCacheWritesNothing() { + CHECK(planSoloRestore({}, {"{A}"}, {}).empty()); +} + +int main() { + testUnsoloedProjectYieldsNothingToCache(); + testSoloedTracksKeepsRawValueNotABoolean(); + testSoloedTracksDropsEmptyGuids(); + + testStoreThenQueryReturnsTheStoredSet(); + testStoringAnEmptySetRemovesTheModeEntry(); + testStoreReplacesRatherThanMerges(); + testStoreRejectsAnEmptyModeId(); + testClearConsumesOnlyTheNamedMode(); + + testReconcileDropsDeadGuidsAcrossEveryMode(); + testReconcileWithEveryGuidLiveRemovesNothing(); + + testRestorePlanReplaysEveryLiveVisibleEntryVerbatim(); + testRestorePlanSkipsAGuidThatNoLongerExists(); + testRestorePlanSkipsAParkedLeafNotVisibleInTheIncomingMode(); + testRestorePlanSkipsAFolderParentHiddenInTheIncomingMode(); + testRestorePlanOfAnEmptyCacheWritesNothing(); + + if (g_fail == 0) std::printf("solo_cache: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index bb57611..f718198 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -67,7 +67,7 @@ static void testSerializeGoldenLiteral() { "{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\"," "\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\"," "\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[]," - "\"snapshots\":[],\"lanes\":[]}"); + "\"snapshots\":[],\"lanes\":[],\"soloCache\":[]}"); } // -- 1. N-mode proven -------------------------------------------------------- @@ -1800,6 +1800,88 @@ static void testLaneMalformedJson() { } } +// -- Per-mode solo cache: persistence + reconcile participation --------------- + +static void testSoloCacheJsonRoundTrip() { + ViewModeModel vm; + CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2})); + vm.membership().tag("{T}", kDesignModeId); + vm.storeSnapshot("{T}", TrackSnapshot{1, 1, 1, 1, {0, 1}}); + CHECK(vm.lanes().setManaged("{T}", "lane:0", kArrangeModeId)); + + // Every non-zero I_SOLO variant, across more than one mode, plus a GUID that + // exercises the string escaper. + CHECK(vm.soloCache().store(kArrangeModeId, {{"{A}", 1}, {"{B\"q\"}", 2}})); + CHECK(vm.soloCache().store("mixdown", {{"{C}", 5}, {"{D}", 6}})); + CHECK(vm.setActiveMode(kDesignModeId)); + + const std::string json = vm.serialize(); + auto back = ViewModeModel::deserialize(json); + CHECK(back.has_value()); + CHECK(back && *back == vm); // deserialize(serialize(x)) == x + if (back) CHECK(back->serialize() == json); // stable second round-trip + + if (back) { + const std::map* arrange = back->soloCache().query(kArrangeModeId); + CHECK(arrange != nullptr); + CHECK(arrange && arrange->at("{A}") == 1); + CHECK(arrange && arrange->at("{B\"q\"}") == 2); + const std::map* mix = back->soloCache().query("mixdown"); + CHECK(mix != nullptr); + CHECK(mix && mix->at("{C}") == 5); + CHECK(mix && mix->at("{D}") == 6); + CHECK(back->soloCache().query(kDesignModeId) == nullptr); + } +} + +static void testBlobWithoutSoloCacheKeyStillParses() { + // The compatibility case both ways: a project saved by a build that predates the + // key parses to an empty cache, and its own output stays readable here. + const char* older = + "{\"version\":1,\"activeMode\":\"design\",\"modes\":[{\"id\":\"arrange\"," + "\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\"," + "\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[{\"guid\":\"{T}\"," + "\"modes\":[\"design\"],\"showBoth\":false}],\"snapshots\":[],\"lanes\":[]}"; + + auto back = ViewModeModel::deserialize(older); + CHECK(back.has_value()); + CHECK(back && back->soloCache().empty()); + CHECK(back && back->activeModeId() == kDesignModeId); + CHECK(back && back->membership().query("{T}") != nullptr); +} + +static void testSoloCacheMalformedJson() { + const char* bad[] = { + "{\"soloCache\":[{\"tracks\":[{\"guid\":\"{A}\",\"solo\":1}]}]}", // missing mode + "{\"soloCache\":[{\"mode\":\"\",\"tracks\":[{\"guid\":\"{A}\",\"solo\":1}]}]}", // empty mode + "{\"soloCache\":[{\"mode\":\"arrange\"}]}", // missing tracks + "{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[]}]}", // empty tracks + "{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"solo\":1}]}]}", // missing guid + "{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"guid\":\"{A}\"}]}]}", // missing solo + "{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"guid\":\"\",\"solo\":1}]}]}", // empty guid + "{\"soloCache\":[", // truncated + }; + for (const char* j : bad) { + auto r = ViewModeModel::deserialize(j); + CHECK(!r.has_value()); + } +} + +static void testReconcilePrunesTheSoloCacheAlongsideSnapshots() { + ViewModeModel vm; + vm.storeSnapshot("{LIVE}", TrackSnapshot{1, 1, 1, 1, {}}); + vm.storeSnapshot("{DEAD}", TrackSnapshot{1, 1, 1, 1, {}}); + vm.soloCache().store(kDesignModeId, {{"{LIVE}", 1}, {"{DEAD}", 2}}); + + // The return stays the SNAPSHOT count; the solo cache is pruned by the same call. + CHECK(vm.reconcile({"{LIVE}"}) == 1); + + const std::map* design = vm.soloCache().query(kDesignModeId); + CHECK(design != nullptr); + CHECK(design && design->size() == 1); + CHECK(design && design->count("{LIVE}") == 1); +} + int main() { testSerializeGoldenLiteral(); testNModeRegistryAndMembership(); @@ -1843,6 +1925,12 @@ int main() { testLaneJsonRoundTrip(); testLaneMalformedJson(); + // Per-mode solo cache + testSoloCacheJsonRoundTrip(); + testBlobWithoutSoloCacheKeyStillParses(); + testSoloCacheMalformedJson(); + testReconcilePrunesTheSoloCacheAlongsideSnapshots(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }