diff --git a/CMakeLists.txt b/CMakeLists.txt index 2b5fec5..864ea08 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/src/actions.cpp b/src/actions.cpp index efb961f..7742ca1 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -24,9 +24,11 @@ #include #include +#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 selectedRetagItems() { + std::vector items; + const int n = CountSelectedMediaItems(nullptr); // nullptr = active project + items.reserve(static_cast(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(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 selected = selectedRetagItems(); + const std::vector 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); diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 6e68684..d6a07a2 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -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(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(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 diff --git a/src/item_read.cpp b/src/item_read.cpp new file mode 100644 index 0000000..40edc65 --- /dev/null +++ b/src/item_read.cpp @@ -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 + +#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(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 diff --git a/src/item_read.h b/src/item_read.h new file mode 100644 index 0000000..7bb74e9 --- /dev/null +++ b/src/item_read.h @@ -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 + +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 diff --git a/src/view.cpp b/src/view.cpp index 501e2d9..aac2c10 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -18,6 +18,7 @@ #include #include +#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>& 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(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 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 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 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(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(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); // Which managed keys are already present on this track (durable-name reconcile). std::map 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(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"))) { - SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", - static_cast(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(laneCount)); char parm[32]; std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); std::vector 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 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); } } diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 56c361d..7528e1a 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -158,6 +158,18 @@ std::vector autoTagNewContent(const std::vector& newTrackG return tags; } +std::vector planItemRetag(const std::vector& selected, + const std::string& targetMode) { + std::vector 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 // --------------------------------------------------------------------------- diff --git a/src/view_mode_model.h b/src/view_mode_model.h index b1daaaf..42445f5 100644 --- a/src/view_mode_model.h +++ b/src/view_mode_model.h @@ -519,6 +519,50 @@ std::vector autoTagNewContent(const std::vector& newTrackG const std::vector& 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 planItemRetag(const std::vector& 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 diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index b5e39c3..2e98388 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -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& 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 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 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 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 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();