Ψ-W1-T2: disjoint per-mode solo surfaces and a playback-gated mode switch
Solo is cached, cleared and replayed per mode on a real switch only; the switch is refused visibly while the transport runs. The footer segment now routes through the activate actions, so a panel switch finally persists.
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Help_Set
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
@@ -160,21 +161,29 @@ void persistViewState() {
|
||||
g_session->saveToActiveProject();
|
||||
}
|
||||
|
||||
// The footer segment already reads disabled while the transport runs, but an action can
|
||||
// be fired with the panel closed — so the refusal also lands as a status line. Guarded on
|
||||
// the transport because applyMode's other refusal is an unregistered mode id.
|
||||
void reportModeSwitchRefused() {
|
||||
if (transportBlocksModeSwitch(nullptr) && Help_Set)
|
||||
Help_Set("ReaSampler: stop the transport to switch view mode", true);
|
||||
}
|
||||
|
||||
// Cycle to the next mode in ordinal order. applyMode itself sets the model's active
|
||||
// mode, so we only compute the target and apply.
|
||||
void doToggleMode() {
|
||||
const std::string target =
|
||||
nextModeId(g_session->view().modes(), g_session->view().activeModeId());
|
||||
if (target.empty()) return; // no modes to cycle to (degenerate)
|
||||
applyMode(g_session->view(), target, nullptr);
|
||||
if (!applyMode(g_session->view(), target, nullptr)) { reportModeSwitchRefused(); return; }
|
||||
persistViewState();
|
||||
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
|
||||
}
|
||||
|
||||
// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if
|
||||
// the id is unregistered, so an absent mode fails safe.
|
||||
// the id is unregistered or the transport is running, so both fail safe.
|
||||
void doActivateMode(const std::string& modeId) {
|
||||
applyMode(g_session->view(), modeId, nullptr);
|
||||
if (!applyMode(g_session->view(), modeId, nullptr)) { reportModeSwitchRefused(); return; }
|
||||
persistViewState();
|
||||
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag
|
||||
#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam
|
||||
#include "shell/capture/track_guid.h" // guidString — canonical track GUID key
|
||||
#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation
|
||||
#include "shell/view/view.h" // mintManagedLanes / transportBlocksModeSwitch
|
||||
|
||||
// New-content detection: enumerate live tracks + items and read fixed-lane state to
|
||||
// classify an item's lane as managed vs manual.
|
||||
@@ -76,6 +76,18 @@ bool handleToolbarClick(int x, int y, const ActionBarRect& bar,
|
||||
return true;
|
||||
}
|
||||
|
||||
// The registered activate action behind a footer mode segment. Routing the segment
|
||||
// through the SAME action the Actions list fires is what gives a panel-initiated switch
|
||||
// the persist + repaint it used to skip; a mode with no such action (the model is
|
||||
// N-mode, the UI ships two) is unreachable from the footer rather than routed around it.
|
||||
int modeActivateCommandId(const std::string& modeId) {
|
||||
ActionBarRow row{};
|
||||
if (modeId == kArrangeModeId) row.suffix = "VIEW_ACTIVATE_ARRANGE";
|
||||
else if (modeId == kDesignModeId) row.suffix = "VIEW_ACTIVATE_DESIGN";
|
||||
else return 0;
|
||||
return resolveBarCommandId(row);
|
||||
}
|
||||
|
||||
// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). Value verified in view.cpp;
|
||||
// reproduced locally so this file stays self-contained.
|
||||
constexpr int kFreeModeFixedLanes = 2;
|
||||
@@ -299,11 +311,18 @@ void handleClick(int x, int y) {
|
||||
{
|
||||
const int seg = footerToggleSegmentHit(x, y, w, h);
|
||||
if (seg >= 0) {
|
||||
const std::vector<Mode>& modes = g_panel.session->view().modes().all();
|
||||
const ViewModeModel& view = g_panel.session->view();
|
||||
const std::vector<Mode>& modes = view.modes().all();
|
||||
if (seg < static_cast<int>(modes.size())) {
|
||||
applyMode(g_panel.session->view(),
|
||||
modes[static_cast<std::size_t>(seg)].id, nullptr);
|
||||
invalidatePanel();
|
||||
const std::string& id = modes[static_cast<std::size_t>(seg)].id;
|
||||
const bool isActive = id == view.activeModeId();
|
||||
// Disabled while the transport runs: claimed but inert, the same shape a
|
||||
// disabled toolbar row takes — a dead segment reads inert, never absent,
|
||||
// and never falls through to the grid.
|
||||
if (modeSegmentEnabled(isActive, g_panel.modeSwitchBlocked)) {
|
||||
const int cmd = modeActivateCommandId(id);
|
||||
if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -534,6 +553,14 @@ void bankPanelRefresh() {
|
||||
|
||||
if (!panel::g_panel.open || !panel::g_panel.hwnd) return;
|
||||
|
||||
// Transport transitions are not ours to cause and REAPER offers no change callback,
|
||||
// so the mode segments' disabled state is polled here and repainted only on an edge.
|
||||
const bool blocked = transportBlocksModeSwitch(nullptr);
|
||||
if (blocked != panel::g_panel.modeSwitchBlocked) {
|
||||
panel::g_panel.modeSwitchBlocked = blocked;
|
||||
InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE);
|
||||
}
|
||||
|
||||
// The custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if
|
||||
// a toolbar button has rested under the pointer past the delay, latch + repaint it.
|
||||
panel::maybeShowTooltip();
|
||||
|
||||
@@ -112,13 +112,17 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||
const SegmentRect& s = segs[static_cast<std::size_t>(i)];
|
||||
const Mode& mode = modes[static_cast<std::size_t>(i)];
|
||||
const bool active = mode.id == activeId;
|
||||
// A switch is refused while the transport runs, so an unreachable segment
|
||||
// draws dead rather than inviting a click that would silently do nothing.
|
||||
const bool live = modeSegmentEnabled(active, g_panel.modeSwitchBlocked);
|
||||
const InteractionState state =
|
||||
active ? InteractionState::Active
|
||||
: hoverState(g_panel.hovered, HoverKind::ModeSegment, i);
|
||||
: (live ? hoverState(g_panel.hovered, HoverKind::ModeSegment, i)
|
||||
: InteractionState::Disabled);
|
||||
fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state);
|
||||
LICE_DrawRect(bmp, s.x, s.y, s.width, s.height,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
const Role tr = active ? Role::BgBase : Role::TextPrimary;
|
||||
const Role tr = active ? Role::BgBase : (live ? Role::TextPrimary : Role::TextDim);
|
||||
kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(),
|
||||
Font::Label, tr, Align::Center);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ using ui::hitTestPruneButton;
|
||||
using ui::hitTestSlot;
|
||||
using ui::hitTestTabStrip;
|
||||
using ui::menuButtonReserve;
|
||||
using ui::modeSegmentEnabled;
|
||||
using ui::navigate;
|
||||
using ui::roleColor;
|
||||
using ui::stripActionPrefix;
|
||||
@@ -291,6 +292,12 @@ struct PanelState {
|
||||
unsigned int hoverSinceTick = 0;
|
||||
bool tooltipShown = false;
|
||||
|
||||
// Polled on the OnTimer tick (REAPER exposes no transport-change callback): a mode
|
||||
// switch is refused while the transport runs, so the footer's [Arrange|Design]
|
||||
// segments paint disabled. Cached rather than read per paint AND per click so the
|
||||
// pixel the user saw and the click they made cannot disagree within a tick.
|
||||
bool modeSwitchBlocked = false;
|
||||
|
||||
BankPanelFullHeight fullHeight = BankPanelFullHeight::Split;
|
||||
|
||||
// The named bank the banks region shows — distinct from the active/capture-target
|
||||
|
||||
@@ -11,11 +11,21 @@ decide membership or mode rules.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Never touches master or `B_MUTE`/`I_SOLO`.** The tool owns only visibility,
|
||||
`B_MAINSEND`, `I_FXEN`, and per-FX offline, on every managed leaf, tagged or
|
||||
untagged. User mute/solo survives every toggle untouched; the master track's
|
||||
visibility flags are never driven (the SDK forbids `B_SHOWINTCP`/`B_SHOWINMIXER`
|
||||
on master).
|
||||
- **Never touches master or `B_MUTE`; never LOSES solo.** The tool owns visibility,
|
||||
`B_MAINSEND`, `I_FXEN`, per-FX offline, and — on a real mode switch only —
|
||||
`I_SOLO`, on every managed leaf, tagged or untagged. `B_MUTE` is untouched
|
||||
absolutely. The master track is untouched absolutely: it is outside `GetTrack`'s
|
||||
index space, so it never enters the enumeration any of these writes iterate, and
|
||||
its visibility flags are never driven (the SDK forbids
|
||||
`B_SHOWINTCP`/`B_SHOWINMIXER` on master).
|
||||
- **Solo surfaces are disjoint per mode, cached not destroyed.** A real switch
|
||||
(target != active) reads every live track's raw `I_SOLO`, banks the non-zero
|
||||
values against the OUTGOING mode, clears them, and replays the incoming mode's
|
||||
banked values verbatim — solo-in-place and safe-solo variants included, never
|
||||
collapsed to a boolean. This is the same snapshot sense of non-destructive that
|
||||
park/restore already gives visibility and FX state: the user's solo is never
|
||||
lost, only parked with the mode it belongs to. A REAPPLY (target == active —
|
||||
tag/untag/show-both, project load) touches solo not at all.
|
||||
- **Parking a track** (inactive-mode leaf) drives `B_SHOWINTCP=0`, `B_SHOWINMIXER=0`
|
||||
(hide both panels), `B_MAINSEND=0` (out of mix), `I_FXEN=0` (FX bypassed), and
|
||||
`TrackFX_SetOffline(track, fx, true)` for each FX (reclaim CPU) — full CPU-park,
|
||||
@@ -72,7 +82,8 @@ applies the resulting lane state to live tracks.
|
||||
|
||||
## Modules
|
||||
|
||||
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. **Never touches master or `B_MUTE`/`I_SOLO`.**
|
||||
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.**
|
||||
- `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
|
||||
#include "shell/capture/item_read.h"
|
||||
#include "core/view/lane_keys.h"
|
||||
#include "core/view/solo_cache.h"
|
||||
#include "shell/capture/track_guid.h"
|
||||
#include "shell/view/view_solo.h"
|
||||
#include "core/view/view_tree.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetPlayStateEx
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
@@ -53,6 +56,19 @@ namespace {
|
||||
// I_FREEMODE value for fixed lanes. SDK: 0=normal, 1=free item positioning, 2=fixed lanes.
|
||||
constexpr int kFreeModeFixedLanes = 2;
|
||||
|
||||
// GetPlayStateEx bitmask. SDK: &1 playing, &2 paused, &4 recording — paused is
|
||||
// deliberately excluded, nothing is moving there.
|
||||
constexpr int kTransportMoving = 1 | 4;
|
||||
|
||||
// GUIDs the plan parks in the mode being entered — the set a solo restore skips.
|
||||
std::set<std::string> parkedGuidsOf(const TogglePlan& plan) {
|
||||
std::set<std::string> parked;
|
||||
for (const TrackPlan& tp : plan.park) {
|
||||
if (!tp.flags.empty()) parked.insert(tp.flags.front().guid);
|
||||
}
|
||||
return parked;
|
||||
}
|
||||
|
||||
// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane
|
||||
// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist
|
||||
// but displays as non-fixed-lane).
|
||||
@@ -377,11 +393,27 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
|
||||
|
||||
} // namespace
|
||||
|
||||
bool transportBlocksModeSwitch(ReaProject* proj) {
|
||||
if (!GetPlayStateEx) return false; // fail-open: never gate on a missing API pointer
|
||||
return (GetPlayStateEx(proj) & kTransportMoving) != 0;
|
||||
}
|
||||
|
||||
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
|
||||
if (!model.modes().contains(targetModeId)) {
|
||||
return false; // reject before touching the project — no partial apply
|
||||
}
|
||||
|
||||
// The discriminator behind both halves of the contract in view.h. A gate placed
|
||||
// unconditionally here would break tagging and the project-load reapply during
|
||||
// playback; a solo round on every reapply would flicker the user's solo on every
|
||||
// membership edit.
|
||||
const std::string outgoingModeId = model.activeModeId();
|
||||
const bool realSwitch = targetModeId != outgoingModeId;
|
||||
|
||||
if (realSwitch && transportBlocksModeSwitch(proj)) {
|
||||
return false; // same fail-closed shape as the mode-exists guard above
|
||||
}
|
||||
|
||||
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
||||
FolderTree tree = buildFolderTree(entries);
|
||||
@@ -394,10 +426,26 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
for (const auto& kv : handleByGuid) liveGuids.insert(kv.first);
|
||||
model.reconcile(liveGuids);
|
||||
|
||||
// Read before any write, and while activeModeId() still names the mode being left.
|
||||
const std::map<std::string, int> outgoingSolo =
|
||||
realSwitch ? view::soloedTracks(readTrackSolos(handleByGuid))
|
||||
: std::map<std::string, int>{};
|
||||
|
||||
TogglePlan plan = model.planToggle(tree, targetModeId);
|
||||
|
||||
Undo_BeginBlock2(proj);
|
||||
|
||||
// DISJOIN THE SOLO SURFACES. Both this clear and the replay after setActiveMode
|
||||
// ride the existing undo block — one mode toggle stays one Ctrl-Z.
|
||||
if (realSwitch) {
|
||||
model.soloCache().store(outgoingModeId, outgoingSolo);
|
||||
std::vector<view::SoloOp> clearOps;
|
||||
clearOps.reserve(outgoingSolo.size());
|
||||
for (const auto& entry : outgoingSolo)
|
||||
clearOps.push_back(view::SoloOp{entry.first, 0});
|
||||
applySoloOps(handleByGuid, clearOps);
|
||||
}
|
||||
|
||||
// PARK: snapshot before mutating, store into the model, then apply.
|
||||
for (const TrackPlan& tp : plan.park) {
|
||||
if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track
|
||||
@@ -454,6 +502,15 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
|
||||
model.setActiveMode(targetModeId);
|
||||
|
||||
// Replay + consume, before the single relayout below picks the change up.
|
||||
if (realSwitch) {
|
||||
if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) {
|
||||
applySoloOps(handleByGuid,
|
||||
view::planSoloRestore(*cached, liveGuids, parkedGuidsOf(plan)));
|
||||
model.soloCache().clear(targetModeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Force REAPER to rebuild the TCP/MCP now rather than on the next user
|
||||
// interaction: TrackList_AdjustWindows(false) does the full relayout owed
|
||||
// when tracks appear/disappear; UpdateArrange() repaints.
|
||||
|
||||
+13
-4
@@ -14,10 +14,19 @@ class ReaProject;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Snapshots each about-to-park track's flags into `model`, runs planToggle,
|
||||
// applies park/restore writes plus parent visibility flags, then sets the
|
||||
// active mode. Wrapped in one Undo block. Returns false (no mutation) if
|
||||
// `targetModeId` isn't registered. `proj` == nullptr means the current project.
|
||||
// True while `proj`'s transport is playing or recording — the condition under which
|
||||
// applyMode refuses a real mode switch. Exposed so the panel can paint the footer's
|
||||
// [Arrange|Design] segments disabled BEFORE the click rather than only refusing on
|
||||
// it. `proj` == nullptr means the current project.
|
||||
bool transportBlocksModeSwitch(ReaProject* proj);
|
||||
|
||||
// Snapshots each about-to-park track's flags into `model`, caches/clears the
|
||||
// outgoing mode's solo state and replays the incoming mode's, runs planToggle,
|
||||
// applies park/restore writes plus parent visibility flags, then sets the active
|
||||
// mode. Wrapped in one Undo block. Returns false (no mutation) if `targetModeId`
|
||||
// isn't registered, or if this is a real switch (target != active) while the
|
||||
// transport is running. A reapply (target == active) is never gated and never
|
||||
// touches solo. `proj` == nullptr means the current project.
|
||||
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
|
||||
|
||||
// Splits any track visible in more than one mode while carrying its own media
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// See view_solo.h. Compiled into the reaper_reasampler module; includes
|
||||
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that).
|
||||
|
||||
#include "shell/view/view_solo.h"
|
||||
|
||||
#include <map>
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
// SDK: int, 0=not soloed, 1=soloed, 2=soloed in place, 5=safe soloed,
|
||||
// 6=safe soloed in place. Documented under SetMediaTrackInfo_Value with no
|
||||
// read-only marker (unlike B_RECMON_IN_EFFECT in the same list), so it is settable.
|
||||
constexpr const char* kSoloParm = "I_SOLO";
|
||||
} // namespace
|
||||
|
||||
std::vector<view::TrackSolo> readTrackSolos(const TrackHandles& handles) {
|
||||
std::vector<view::TrackSolo> live;
|
||||
live.reserve(handles.size());
|
||||
for (const auto& [guid, tr] : handles) {
|
||||
if (!tr) continue;
|
||||
live.push_back(view::TrackSolo{
|
||||
guid, static_cast<int>(GetMediaTrackInfo_Value(tr, kSoloParm))});
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
void applySoloOps(const TrackHandles& handles, const std::vector<view::SoloOp>& ops) {
|
||||
if (ops.empty()) return; // the common case: nothing soloed, no project write
|
||||
|
||||
std::map<std::string, int> byGuid;
|
||||
for (const view::SoloOp& op : ops) byGuid[op.guid] = op.value;
|
||||
|
||||
for (const auto& [guid, tr] : handles) {
|
||||
if (!tr) continue;
|
||||
auto it = byGuid.find(guid);
|
||||
if (it == byGuid.end()) continue;
|
||||
SetMediaTrackInfo_Value(tr, kSoloParm, static_cast<double>(it->second));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
// The REAPER read/write half of the per-mode solo surface. Every decision about what
|
||||
// to cache, clear, or replay is core/view/solo_cache; this pair only moves I_SOLO
|
||||
// between the live tracks and that pure plan.
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/view/solo_cache.h"
|
||||
|
||||
// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header.
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The (GUID, handle) enumeration applyMode builds from GetTrack. GetTrack's index
|
||||
// space excludes the master track, so no solo read or write reachable through this
|
||||
// type can ever land on master.
|
||||
using TrackHandles = std::vector<std::pair<std::string, MediaTrack*>>;
|
||||
|
||||
std::vector<view::TrackSolo> readTrackSolos(const TrackHandles& handles);
|
||||
|
||||
// Applies each op to its live handle in ONE pass over `handles`; an op naming a GUID
|
||||
// absent from the enumeration is skipped (stale/deleted track).
|
||||
void applySoloOps(const TrackHandles& handles, const std::vector<view::SoloOp>& ops);
|
||||
|
||||
} // namespace reasampler
|
||||
Reference in New Issue
Block a user