Merge: D2 W3-B — item-level Design/Arrange move + untag actions; W3-A polish

This commit is contained in:
2026-07-25 13:53:48 -04:00
9 changed files with 372 additions and 54 deletions
+1
View File
@@ -251,6 +251,7 @@ add_library(reaper_reasampler MODULE
src/track_guid.cpp
src/guid_diff.cpp
src/lane_keys.cpp
src/item_read.cpp
src/actions.cpp
)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings tail_control realtime_record wav_trim)
+116 -2
View File
@@ -24,9 +24,11 @@
#include <string>
#include <vector>
#include "item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B)
#include "lane_keys.h" // isOnManualLane — the single managed/manual predicate
#include "persist.h" // ReaSamplerSession (owns view() model)
#include "track_guid.h" // shared MediaTrack* -> canonical GUID key
#include "view.h" // applyMode (D2 shell)
#include "view.h" // applyMode + mintManagedLanes (D2 shell)
#include "view_mode_model.h"
#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs)
@@ -34,9 +36,15 @@
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks
#define REAPERAPI_WANT_GetSelectedTrack
#define REAPERAPI_WANT_CountSelectedMediaItems
#define REAPERAPI_WANT_GetSelectedMediaItem
#define REAPERAPI_WANT_GetMediaItemTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_Main_SaveProject
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -53,6 +61,12 @@ 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";
// D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same
// FOREVER-STABLE contract: minted into a persistent command id, user keybindings key off
// each — NEVER change these strings after ship.
constexpr const char* kIdMoveItemsDesign = "CEREBELLUM_REASAMPLER_VIEW_MOVE_ITEMS_DESIGN";
constexpr const char* kIdMoveItemsArrange = "CEREBELLUM_REASAMPLER_VIEW_MOVE_ITEMS_ARRANGE";
constexpr const char* kIdUntagItems = "CEREBELLUM_REASAMPLER_VIEW_UNTAG_ITEMS";
// The live session the actions mutate. Set once by designViewRegisterActions and
// read by the hookcommand handler. Not owned here (main.cpp owns g_session).
@@ -66,6 +80,9 @@ int g_cmdTagDesign = 0;
int g_cmdTagArrange = 0;
int g_cmdUntag = 0;
int g_cmdShowBoth = 0;
int g_cmdMoveItemsDesign = 0;
int g_cmdMoveItemsArrange = 0;
int g_cmdUntagItems = 0;
// gaccel storage must outlive registration — REAPER holds each pointer until we
// mirror-unregister it. One per action.
@@ -76,6 +93,9 @@ gaccel_register_t g_accelTagDesign{};
gaccel_register_t g_accelTagArrange{};
gaccel_register_t g_accelUntag{};
gaccel_register_t g_accelShowBoth{};
gaccel_register_t g_accelMoveItemsDesign{};
gaccel_register_t g_accelMoveItemsArrange{};
gaccel_register_t g_accelUntagItems{};
// 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
@@ -114,6 +134,37 @@ void reapplyActiveMode() {
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
}
// Track fixed-lane mode value (I_FREEMODE=2). Mirrors the shell's constant; used only to
// decide whether an item's lane name is meaningful for the manual-lane read.
constexpr int kFreeModeFixedLanes = 2;
// Collects the current media-item selection as the pure decision's input: each selected
// item's GUID plus whether it sits on a MANUAL lane (⇒ EXEMPT — never retagged/re-laned).
// The manual-lane read follows the shared pure predicate exactly as the shell's readers
// do: only on a fixed-lane track (I_FREEMODE==2) is the item's lane name read; on a normal
// track isOnManualLane returns false for the empty name, so the P_LANENAME read is skipped.
// Items whose GUID cannot be read are dropped (an empty GUID must never be retagged).
std::vector<RetagItem> selectedRetagItems() {
std::vector<RetagItem> items;
const int n = CountSelectedMediaItems(nullptr); // nullptr = active project
items.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
for (int i = 0; i < n; ++i) {
MediaItem* it = GetSelectedMediaItem(nullptr, i);
if (!it) continue;
std::string g = itemGuid(it);
if (g.empty()) continue;
MediaTrack* tr = GetMediaItemTrack(it);
const bool fixedLane =
tr && static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
// Only read the lane name on a fixed-lane track; the pure predicate handles the
// normal-track case (returns false) so we pass an empty name and skip the read.
const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{};
items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)});
}
return items;
}
// Persists both the bank and the Design-View model to the active project's ext
// state. Called after every state-changing Design View action so the view model
// is not lost across save/close/reopen. Marking the project dirty is correct —
@@ -212,6 +263,48 @@ void doShowBoth() {
persistViewState();
}
// -- Item-level mode moves (D2 Wave 3-B) -----------------------------------
//
// Retag the current ITEM selection to `targetMode` (empty ⇒ untag → Arrange default),
// then re-drive the minting + apply path so each moved item lands on its target mode's
// managed lane and the active-mode lane visibility is reasserted. The pure planItemRetag
// decides which selected items to retag (manual-lane items are EXEMPT — never retagged,
// never re-laned), upholding the managed-lanes-only invariant even under this explicit
// user action. The whole structural act is wrapped in ONE Undo block with a descriptive
// label (the inner blocks mintManagedLanes / applyMode open nest harmlessly under it).
//
// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog (Main_SaveProject) which
// must NOT sit inside the Undo block, so we close the block first, then persist — the same
// separation the track actions rely on (they persist outside applyMode's own block).
void doMoveItems(const std::string& targetMode) {
const std::vector<RetagItem> selected = selectedRetagItems();
const std::vector<ItemRetagOp> ops = planItemRetag(selected, targetMode);
if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty
MembershipIndex& membership = g_session->view().membership();
Undo_BeginBlock2(nullptr);
// Apply the pure decision's membership writes: tag into targetMode, or untag.
for (const ItemRetagOp& op : ops) {
if (op.untag) membership.untag(op.guid);
else membership.tag(op.guid, op.modeId);
}
// Re-drive the SAME minting/apply path auto-tag uses: mint/split lanes for any track
// whose items now span modes and assign each moved item to its mode's managed lane,
// then reassert the active mode's lane visibility. Manual lanes stay untouched
// (mintManagedLanes reports their items exempt and never mints over them).
mintManagedLanes(g_session->view(), nullptr);
reapplyActiveMode();
const std::string label =
targetMode.empty()
? std::string("ReaSampler: untag selected items")
: std::string("ReaSampler: move selected items -> ") + targetMode;
Undo_EndBlock2(nullptr, label.c_str(), -1);
persistViewState();
}
} // namespace
void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
@@ -233,6 +326,14 @@ void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* ses
"ReaSampler: untag selected tracks");
g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth,
"ReaSampler: show both for selected tracks");
// Item-level mode moves (D2 W3-B): the item analog of the track tag family.
g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign,
"ReaSampler: move selected items -> Design");
g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange,
"ReaSampler: move selected items -> Arrange");
g_cmdUntagItems = registerAction(rec, kIdUntagItems, g_accelUntagItems,
"ReaSampler: untag selected items");
}
bool designViewHandleCommand(int command) {
@@ -247,12 +348,25 @@ bool designViewHandleCommand(int command) {
if (command == g_cmdUntag) { doUntag(); return true; }
if (command == g_cmdShowBoth) { doShowBoth(); return true; }
// Item-level moves. Move -> Arrange and Untag items collapse to the same act (an
// empty target ⇒ untag ⇒ Arrange default), mirroring the track-level pairing above.
if (command == g_cmdMoveItemsDesign) { doMoveItems(kDesignModeId); return true; }
if (command == g_cmdMoveItemsArrange) { doMoveItems(std::string{}); return true; }
if (command == g_cmdUntagItems) { doMoveItems(std::string{}); 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).
// gaccel first, then the command_id string (reverse of registration order — the item
// moves registered last, so they tear down first).
rec->Register("-gaccel", (void*)&g_accelUntagItems);
rec->Register("-command_id", (void*)kIdUntagItems);
rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange);
rec->Register("-command_id", (void*)kIdMoveItemsArrange);
rec->Register("-gaccel", (void*)&g_accelMoveItemsDesign);
rec->Register("-command_id", (void*)kIdMoveItemsDesign);
rec->Register("-gaccel", (void*)&g_accelShowBoth);
rec->Register("-command_id", (void*)kIdShowBoth);
rec->Register("-gaccel", (void*)&g_accelUntag);
+3 -25
View File
@@ -42,6 +42,7 @@
#include "bank_model.h"
#include "capture_paths.h"
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
#include "mode_switch.h"
#include "peaks.h"
@@ -86,11 +87,8 @@
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_CountTrackMediaItems
#define REAPERAPI_WANT_GetTrackMediaItem
#define REAPERAPI_WANT_GetMediaItemInfo_Value
#define REAPERAPI_WANT_GetSetMediaItemInfo_String
// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h):
// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the
// STOCK symbols (not SWS-only) — see the audition section below.
@@ -684,28 +682,8 @@ bool isFixedLaneTrack(MediaTrack* tr) {
return static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
}
// Reads the durable P_LANENAME for the lane that item `it` sits on. Returns empty if
// the lane is unnamed or P_LANENAME is unavailable. Callers must already know the track
// is a fixed-lane track (I_FREEMODE==2) before calling this — the manual/non-manual
// distinction only applies there. On a non-fixed-lane track `I_FIXEDLANE` is
// meaningless; the isOnManualLane predicate handles that case via its isFixedLaneTrack
// argument, so callers should not call this at all for non-fixed-lane tracks.
std::string itemLaneName(MediaTrack* tr, MediaItem* it) {
const int laneIdx = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
char parm[32];
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
char buf[512] = {0};
if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
return std::string(buf);
}
// An item's canonical GUID string via GetSetMediaItemInfo_String("GUID"). Empty on
// failure (a read failure must never be tagged — guid_diff/autoTag both skip empties).
std::string itemGuid(MediaItem* it) {
char buf[64] = {0};
if (!GetSetMediaItemInfo_String(it, "GUID", buf, false)) return {};
return std::string(buf);
}
// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h):
// itemGuid(it) and itemLaneName(tr, it). bank_panel.cpp no longer carries its own copies.
// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set,
// baseline input) and, for each item, records whether it sits on a manual lane so a
+33
View File
@@ -0,0 +1,33 @@
// item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See
// item_read.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 "item_read.h"
#include <cstdio>
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_GetSetMediaItemInfo_String
#define REAPERAPI_WANT_GetMediaItemInfo_Value
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#include "reaper_plugin_functions.h"
namespace reasampler {
std::string itemGuid(MediaItem* it) {
char buf[64] = {0};
if (!GetSetMediaItemInfo_String(it, "GUID", buf, false)) return {};
return std::string(buf);
}
std::string itemLaneName(MediaTrack* tr, MediaItem* it) {
const int laneIdx = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
char parm[32];
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
char buf[512] = {0};
if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
return std::string(buf);
}
} // namespace reasampler
+34
View File
@@ -0,0 +1,34 @@
#pragma once
// item_read — the ONE place a MediaItem* is read for its canonical GUID string and for
// the durable P_LANENAME of the fixed lane it sits on. Before this seam, view.cpp and
// bank_panel.cpp each carried a near-identical private itemGuid / itemLaneName pair
// (both files' comments acknowledged the deliberate copy); the D2 Wave-3-B item actions
// need the same two reads, so the duplication is extracted here — the item-read analog
// of track_guid's single MediaTrack* -> GUID-key formatter.
//
// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern —
// CLAUDE.md §contract). MediaItem / MediaTrack are forward-declared so this header
// stays SDK-lite. These are shell reads (REAPER string/value getters); the managed/
// manual DECISION that consumes the lane name stays pure in lane_keys (isOnManualLane).
#include <string>
class MediaItem;
class MediaTrack;
namespace reasampler {
// An item's canonical GUID string via GetSetMediaItemInfo_String("GUID"). Empty on a
// read failure (an empty GUID must never be tagged — every caller skips empties).
std::string itemGuid(MediaItem* it);
// The durable P_LANENAME of the fixed lane item `it` currently sits on (read via the
// item's I_FIXEDLANE ordinal, then P_LANENAME:n on `tr`). Empty if the lane is unnamed
// or the param is unavailable. Callers must already know `tr` is a fixed-lane track
// (I_FREEMODE==2) before calling — I_FIXEDLANE is meaningless otherwise; the pure
// isOnManualLane predicate handles the non-fixed-lane case via its own argument, so
// callers should not call this at all for a normal track.
std::string itemLaneName(MediaTrack* tr, MediaItem* it);
} // namespace reasampler
+24 -27
View File
@@ -18,6 +18,7 @@
#include <utility>
#include <vector>
#include "item_read.h"
#include "lane_keys.h"
#include "track_guid.h"
#include "view_tree.h"
@@ -42,7 +43,6 @@
#define REAPERAPI_WANT_GetTrackMediaItem
#define REAPERAPI_WANT_GetMediaItemInfo_Value
#define REAPERAPI_WANT_SetMediaItemInfo_Value
#define REAPERAPI_WANT_GetSetMediaItemInfo_String
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -289,21 +289,8 @@ bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handle
// planLaneMinting; this shell only reads live per-item mode+lane state, calls the
// decision, and applies the resulting REAPER + ownership-index writes.
// An item's canonical GUID string via GetSetMediaItemInfo_String("GUID"). Empty on
// failure. Mirrors bank_panel.cpp's itemGuid — the same read seam for item identity.
std::string itemGuidString(MediaItem* it) {
char buf[64] = {0};
if (!GetSetMediaItemInfo_String(it, "GUID", buf, false)) return {};
return std::string(buf);
}
// The durable P_LANENAME of the lane item `it` currently sits on, for a track known to
// be a fixed-lane track. Empty if unnamed/unavailable. Same derivation as bank_panel's
// itemLaneName; kept local so view.cpp stays self-contained.
std::string itemLaneNameOf(MediaTrack* tr, MediaItem* it) {
const int laneIdx = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
return laneName(tr, laneIdx);
}
// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h):
// itemGuid(it) and itemLaneName(tr, it). view.cpp no longer carries its own copies.
// Maps every item GUID on `tr` to its MediaItem* handle, in one pass. The assign pass
// resolves plan item GUIDs back to handles through this map rather than re-scanning the
@@ -314,7 +301,7 @@ std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
for (int i = 0; i < itemCount; ++i) {
MediaItem* it = GetTrackMediaItem(tr, i);
if (!it) continue;
std::string ig = itemGuidString(it);
std::string ig = itemGuid(it);
if (!ig.empty()) byGuid.emplace(std::move(ig), it);
}
return byGuid;
@@ -354,7 +341,7 @@ std::vector<LaneTrack> readLaneTracks(
for (int i = 0; i < itemCount; ++i) {
MediaItem* it = GetTrackMediaItem(tr, i);
if (!it) continue;
const std::string ig = itemGuidString(it);
const std::string ig = itemGuid(it);
if (ig.empty()) continue;
LaneItem li;
li.guid = ig;
@@ -362,7 +349,7 @@ std::vector<LaneTrack> readLaneTracks(
// Manual-lane exemption: only meaningful on a fixed-lane track. The shared
// pure predicate decides; on a normal track it returns false regardless of
// name, so we pass an empty name and skip the P_LANENAME read.
const std::string ln = fixedLane ? itemLaneNameOf(tr, it) : std::string{};
const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
li.onManualLane = isOnManualLane(fixedLane, ln);
lt.items.push_back(std::move(li));
}
@@ -425,24 +412,23 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
// Ensure enough lanes for the managed set WITHOUT shrinking: a track may already
// carry the user's manual lanes, so only GROW the count, never reduce it (which
// would delete a user lane). The managed lanes we mint occupy the tail ordinals.
const int haveLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
// laneCount tracks the live I_NUMFIXEDLANES as we grow it: read ONCE here, then
// each mint appends at laneCount and bumps it. No per-mint I_NUMFIXEDLANES re-read
// is needed — nextOrdinal and laneCount are the same running value.
int laneCount = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
// Which managed keys are already present on this track (durable-name reconcile).
std::map<std::string, int> present = managedLaneOrdinals(tr);
// Mint each managed lane that is not already present, appending at the tail so an
// existing manual lane is never overwritten. Record ownership in the model.
int nextOrdinal = haveLanes;
for (const LaneMint* m : mintsByTrack[split.trackGuid]) {
model.lanes().setManaged(m->trackGuid, m->laneKey, m->modeId); // ownership
if (present.count(m->laneKey)) continue; // already minted — idempotent
// Grow the lane count to include the new tail ordinal, then stamp its name.
const int laneIdx = nextOrdinal++;
if (laneIdx >= static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"))) {
SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES",
static_cast<double>(laneIdx + 1));
}
// Append at the current tail ordinal, grow the tracked count, stamp its name.
const int laneIdx = laneCount++;
SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast<double>(laneCount));
char parm[32];
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
std::vector<char> name(m->laneKey.begin(), m->laneKey.end());
@@ -658,6 +644,17 @@ void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) {
if (!key) continue; // manual/unnamed lane — leave off the index
std::optional<std::string> mode = modeIdFromLaneName(name);
if (!mode) continue; // prefix-only/illegal name — skip defensively
// UNREGISTERED-MODE GUARD: the durable name encodes a mode id, but that mode
// may no longer be a registered Mode (e.g. a mode removed from the registry
// after the project was saved with lanes minted for it). Recording it MANAGED
// would make the toggle planner drive a lane keyed to a mode that can never be
// the active mode — the lane would stay silenced+hidden forever, orphaning its
// items with no way for the user to reach them. So we do NOT record it: the
// lane is left off the ownership index and thus treated as manual-by-default
// (never driven). Its durable name is preserved on the track, so if the mode is
// ever re-registered a later reconcile recovers the ownership cleanly.
if (!model.modes().contains(*mode)) continue;
model.lanes().setManaged(guid, *key, *mode);
}
}
+12
View File
@@ -158,6 +158,18 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
return tags;
}
std::vector<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& selected,
const std::string& targetMode) {
std::vector<ItemRetagOp> ops;
const bool untag = targetMode.empty(); // empty target ⇒ untag (→ Arrange default)
for (const RetagItem& item : selected) {
if (item.guid.empty()) continue; // defensive; a real item always has a GUID
if (item.onManualLane) continue; // manual-lane item is EXEMPT — never retagged
ops.push_back(ItemRetagOp{item.guid, untag, untag ? std::string{} : targetMode});
}
return ops;
}
// ---------------------------------------------------------------------------
// lane minting decision
// ---------------------------------------------------------------------------
+44
View File
@@ -519,6 +519,50 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
const std::vector<NewItem>& newItems,
const std::string& activeMode);
// -- Item-level mode-move decision (Phase D2 / Wave 3-B) ---------------------
//
// The bindable item actions (Move selected items -> Design / -> Arrange / Untag)
// retag the CURRENT item selection's membership, then re-drive the minting/apply
// path so each moved item lands on its target mode's managed lane. The DECISION —
// which selected items to retag, and to what — is pure and unit-tested here; the
// shell only reads the item selection (GUID + manual-lane disposition) and applies
// the resulting membership writes + re-lane pass.
//
// MANAGED-LANES-ONLY INVARIANT (upheld at the source, exactly as auto-tag does): an
// item the shell reports as already on a MANUAL lane is EXEMPT — it is never retagged,
// never untagged, never re-laned. The tool drives only what it minted, even under an
// explicit user action. The shell reports `onManualLane` per item and this decision
// emits NO op for such items; the shell then skips them entirely.
// One selected item the shell reports for the retag decision: its GUID and whether it
// currently sits on a MANUAL lane (⇒ EXEMPT: no membership change, no re-lane).
struct RetagItem {
std::string guid;
bool onManualLane = false; // true ⇒ EXEMPT from the item mode-move actions
};
// One membership op the item mode-move decision produced for one selected item. `untag`
// true ⇒ remove the item from the index (return it to the Arrange default); otherwise
// tag it into `modeId`. The shell applies each verbatim to the MembershipIndex.
struct ItemRetagOp {
std::string guid;
bool untag = false; // true ⇒ untag; false ⇒ tag into modeId
std::string modeId; // the target mode when !untag (empty when untag)
bool operator==(const ItemRetagOp& o) const {
return guid == o.guid && untag == o.untag && modeId == o.modeId;
}
};
// The pure item mode-move decision: given the selected items and a target mode, produce
// the membership ops. An EMPTY `targetMode` means UNTAG (the "Untag selected items" and
// "Move -> Arrange" actions collapse to the same act — Arrange is the absence of a tag,
// mirroring the track-level doUntag). A non-empty `targetMode` tags each eligible item
// into it. Manual-lane items are skipped (no op emitted); items with an empty GUID are
// skipped (defensive). The function mutates nothing — it returns a plan the shell applies.
std::vector<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& selected,
const std::string& targetMode);
// -- Lane minting decision (Phase D2 / Wave 3) -------------------------------
//
// D1 parks a whole track when it holds content of only ONE mode. The moment a track
+105
View File
@@ -1074,6 +1074,109 @@ static void testAutoTagDecision() {
}
}
// -- D2 W3-B item-level mode-move decision -----------------------------------
//
// planItemRetag: the pure decision behind the three item actions. A non-empty target
// tags each eligible selected item into it; an EMPTY target untags (→ Arrange default).
// Manual-lane items are EXEMPT (no op) and empty-GUID items are skipped.
// Find the single op for `guid`, or nullptr.
static const ItemRetagOp* retagOpFor(const std::vector<ItemRetagOp>& ops,
const std::string& guid) {
for (const auto& o : ops)
if (o.guid == guid) return &o;
return nullptr;
}
static void testPlanItemRetag() {
// Move -> Design: a managed-lane / normal item is tagged into Design (untag=false).
{
std::vector<RetagItem> sel{
RetagItem{"{A}", /*onManualLane=*/false},
RetagItem{"{B}", false},
};
auto ops = planItemRetag(sel, kDesignModeId);
CHECK(ops.size() == 2);
const ItemRetagOp* a = retagOpFor(ops, "{A}");
CHECK(a != nullptr);
CHECK(a && !a->untag); // a tag, not an untag
CHECK(a && a->modeId == kDesignModeId); // into Design specifically
const ItemRetagOp* b = retagOpFor(ops, "{B}");
CHECK(b && !b->untag && b->modeId == kDesignModeId);
}
// Empty target ⇒ UNTAG each item (Move -> Arrange / Untag items collapse to this).
// untag must be true and modeId empty — NOT a tag into "arrange".
{
std::vector<RetagItem> sel{ RetagItem{"{A}", false} };
auto ops = planItemRetag(sel, std::string{});
CHECK(ops.size() == 1);
const ItemRetagOp* a = retagOpFor(ops, "{A}");
CHECK(a != nullptr);
CHECK(a && a->untag); // an untag
CHECK(a && a->modeId.empty()); // no target mode carried on an untag
}
// Manual-lane exemption: a manual-lane item yields NO op — not for Move nor for Untag.
{
std::vector<RetagItem> sel{
RetagItem{"{NORMAL}", false},
RetagItem{"{MANUAL}", true}, // on a hand-managed lane ⇒ EXEMPT
};
auto design = planItemRetag(sel, kDesignModeId);
CHECK(design.size() == 1);
CHECK(retagOpFor(design, "{NORMAL}") != nullptr);
CHECK(retagOpFor(design, "{MANUAL}") == nullptr); // exempt — never retagged
auto untag = planItemRetag(sel, std::string{});
CHECK(untag.size() == 1);
CHECK(retagOpFor(untag, "{NORMAL}") != nullptr);
CHECK(retagOpFor(untag, "{MANUAL}") == nullptr); // exempt — never untagged
}
// Empty-GUID items are skipped defensively; empty selection ⇒ no ops.
{
std::vector<RetagItem> sel{ RetagItem{"", false}, RetagItem{"{A}", false} };
auto ops = planItemRetag(sel, kDesignModeId);
CHECK(ops.size() == 1);
CHECK(retagOpFor(ops, "{A}") != nullptr);
CHECK(planItemRetag({}, kDesignModeId).empty());
CHECK(planItemRetag({}, std::string{}).empty());
}
}
// -- D2 W3-B reconcile unregistered-mode guard (pure decision) ---------------
//
// reconcileManagedLanes (shell) recovers a lane's managed ownership from its durable
// name, but must NOT record ownership for a mode the registry no longer knows — a lane
// keyed to an unregistered mode can never become the active mode's lane and would stay
// silenced+hidden forever, orphaning its items. The guard's pure decision is exactly
// modeIdFromLaneName(name) ∈ modes(): this locks that composition so the shell's guard
// (which calls model.modes().contains(*mode)) cannot silently drift.
static void testReconcileUnregisteredModeGuardDecision() {
ViewModeModel vm; // seeds Arrange + Design only
// A managed lane naming a REGISTERED mode: mode decodes and IS contained ⇒ record.
{
const std::string name = laneNameForMode(kDesignModeId);
auto mode = modeIdFromLaneName(name);
CHECK(mode.has_value());
CHECK(vm.modes().contains(*mode)); // guard passes ⇒ shell records ownership
}
// A managed lane naming an UNREGISTERED mode: mode decodes but is NOT contained ⇒
// the guard rejects it and the shell leaves the lane off the index (manual-by-default).
{
const std::string name = laneNameForMode("removed_mode");
auto mode = modeIdFromLaneName(name);
CHECK(mode.has_value());
CHECK(*mode == "removed_mode");
CHECK(!vm.modes().contains(*mode)); // guard fails ⇒ shell must skip
}
}
// -- D2.7 Lane minting decision (Wave 3) -------------------------------------
//
// planLaneMinting: a track with content of only ONE mode is NOT split (D1 unchanged);
@@ -1519,6 +1622,8 @@ int main() {
testLaneOwnershipLastWriterWins();
testManagedOnlyPlannerAndQuery();
testAutoTagDecision();
testPlanItemRetag();
testReconcileUnregisteredModeGuardDecision();
testLaneMintingSingleModeNoSplit();
testLaneMintingMultiModeMintsAndAssignsAll();
testLaneMintingManualLaneExempt();