diff --git a/src/view.cpp b/src/view.cpp index aac2c10..a6c4321 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -595,11 +595,25 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { const bool changed = applyMintPlan(model, plan, handleByGuid); if (!changed) { - // The plan was non-empty but every write was already satisfied (idempotent - // re-run: lanes exist, items already assigned, ownership already recorded). Close - // the block with no description so REAPER discards the empty undo point rather - // than flooding history with a no-change entry every detection tick. + // The plan was non-empty but every REAPER write was already satisfied. Close the + // block with no description so REAPER discards the empty undo point rather than + // flooding history with a no-change entry every detection tick. Undo_EndBlock2(proj, "", 0); + + // BUT the arrange still needs a redraw. On the detect-tick caller (bankPanelRefresh) + // mintManagedLanes runs only when this tick just tagged new content, and a NON-EMPTY + // plan means that content sits on a managed-split track. The idempotent no-op path is + // reached when a freshly-inserted item ALREADY landed on the active mode's playing + // lane (REAPER places a new item on the playing lane; the active mode's lane IS the + // playing lane, so assignItemToLane sees I_FIXEDLANE unchanged and writes nothing). + // The item is correctly placed and confined, but the arrange was never told to + // repaint it onto the lane — so it stayed invisible until a manual mode toggle forced + // applyMode's refresh. Force the redraw here so the item appears immediately without a + // toggle. UpdateArrange() only repaints (no I_FREEMODE transition happened on this + // path, so UpdateTimeline is not owed); it is NOT a project mutation, so it stays + // outside the undo block and adds no history entry. On the action caller (doMoveItems) + // this is a harmless repaint immediately before its own reapplyActiveMode() refresh. + UpdateArrange(); return false; } diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index 2e98388..a659b0e 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -1459,6 +1459,50 @@ static void testLaneMintingFolderOwnItemsSpanBothModes() { CHECK(hasAssign(plan, "{d}", "{F}", kDesignModeId)); } +// -- Fix (pd2): item inserted onto an ALREADY-split folder still yields a non-empty plan -- +// +// Regression guard for the "inserted item invisible until a manual toggle" bug. When a new +// item lands (via insert/capture) on a folder that is ALREADY lane-split and derived-visible +// in >1 mode, and REAPER placed it on the active mode's currently-playing lane, the shell's +// assignItemToLane sees I_FIXEDLANE unchanged and writes nothing — applyMintPlan reports +// changed==false. The shell must STILL treat this tick as "content landed on a managed track" +// and refresh the arrange (so the item draws immediately, no toggle). The pure signal the +// shell keys on is: planLaneMinting returns a NON-EMPTY plan carrying an assign for the new +// item. This test locks that signal; if planLaneMinting ever went empty here, the shell would +// have nothing to refresh on and the bug would return. +static void testLaneMintingNewItemOnAlreadySplitFolderYieldsPlan() { + ViewModeModel vm; + vm.membership().tag("{LD}", kDesignModeId); // Design leaf ⇒ folder derived-visible Design + // {LA} untagged ⇒ Arrange ⇒ folder ALSO derived-visible in Arrange (dual-visible). + vm.membership().tag("{own}", kDesignModeId); // the pre-existing own Design item + vm.membership().tag("{new}", kDesignModeId); // the JUST-INSERTED item (auto-tagged Design) + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); + + // The folder is already split for Design (its lane exists + is owned). This mirrors the + // live "already auto-split" track the bug reproduces on. + CHECK(vm.lanes().setManaged("{F}", laneNameForMode(kDesignModeId), kDesignModeId)); + + // {F} now carries its original own item PLUS the freshly-inserted one, both Design. + std::vector tracks{ + LaneTrack{"{F}", { + LaneItem{"{own}", kDesignModeId, false}, + LaneItem{"{new}", kDesignModeId, false}, + }}, + }; + const LaneMintPlan plan = planLaneMinting(vm, tree, tracks); + + // The plan is NON-EMPTY (folder is dual-visible ⇒ splits) and carries an assign for the + // new item onto the Design lane. In the shell this is the exact branch that must force a + // redraw even when the assign is an idempotent no-op (item already on the playing lane). + CHECK(!plan.empty()); + CHECK(hasAssign(plan, "{new}", "{F}", kDesignModeId)); + CHECK(hasAssign(plan, "{own}", "{F}", kDesignModeId)); +} + // SHOW-BOTH escape hatch: a show-both track carrying its own items is visible in every // mode ON PURPOSE and must NOT be force-split — its content stays cross-mode-visible. // Even with own items that would otherwise span modes, the decision skips it entirely. @@ -1631,6 +1675,7 @@ int main() { testLaneMintingFolderDerivedVisibleSplitsOwnMedia(); testLaneMintingLazySingleLaneStillConfines(); testLaneMintingFolderOwnItemsSpanBothModes(); + testLaneMintingNewItemOnAlreadySplitFolderYieldsPlan(); testLaneMintingShowBothNotForceSplit(); testLaneMintingSingleModeLeafVisibleOnceNoSplit(); testLaneMintingEmptyFolderNotSplit();