Files
reasampler/src/shell/view/CLAUDE.md
T
daniel 86aa8dabba Collapse a Design View mode switch and its background FX parking to ONE undo point
applyMode now returns with its undo block open; the reclaimer closes it when the drain
settles. A transport deferral or a tab switch ends it early -- two points, never a leak.
2026-08-07 12:53:42 -04:00

21 KiB

src/shell/view — Design View mode application shell

Scope

The REAPER-facing half of Design View: applying a mode's visibility/processing state to live tracks (park/restore), snapshotting flag values before parking, and restoring from snapshot on toggle-back. The mode registry, membership derivation, and the pure park/restore planner are owned by core/view (view_mode_model) — this directory is the shell that reads/writes REAPER track flags, it does not decide membership or mode rules.

Invariants

  • 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), and I_FXEN=0 (FX bypassed) — flags only. Per-FX TrackFX_SetOffline is the reclaimer's job (fx_reclaim), off this path entirely.
  • EXPOSE, not refuse. When no record exists for a track (model.snapshot(guid) == nullptr) and the live chain already reads park-shaped (chainReadsParked, flags only — the FX half is excluded, see its own comment), parking now would adopt a park with no account behind it as the user's intent — an undo/redo or reload that dropped the snapshot, a hand/script edit, or simply a track the user keeps hidden. Instead the tool drives the track visible/in-mix/FX-enabled, stores no snapshot, and reports it once per changed set per project (reportExposedTracks/describeExposedTracks, which NAME the tracks — they were hidden from both the TCP and the mixer, so a bare count points at nothing the user can go and find). The next apply sees an honest chain and parks/captures it normally — self-healing, no user action required. Daniel's ruling: expose a broken track rather than hide it forever. Decision at view_park's parkTrack.
  • Non-destructive restore. For every flag the tool drives, snapshot the prior value BEFORE parking; on toggle-back restore FROM the snapshot, never to a hardcoded "on." Round-trip (snapshot → park → restore) returns every driven flag to its captured value — the phase's trust anchor, the analog of the capture null test. Per-FX offline is snapshotted WITH each FX's identity (TrackFX_GetFXGUID) and restored through core/view/fx_offline, so a chain reordered while the track was parked cannot land one plugin's state on another; an FX gone at restore time is dropped and reported to the console, never restored onto its old slot.
  • GUID-keyed, reorder-safe. Membership/snapshot keys on track GUID (GetTrackGUID), never track index; tolerates unknown/stale GUIDs (pruned on reconcile).
  • Relative/portable state only in the persisted view section (GUID strings, mode ids — no absolute paths, no index positions).
  • Documented caveat: offlined FX re-instantiate when a track returns to the active mode — stateful plugins (convolution, loaded samplers, tail-holding effects) re-initialize on return (load hitch, un-persisted internal state lost). Accepted cost of the CPU reclaim; surfaced at the toggle affordance (tooltip). The hitch no longer sits on the switch's synchronous path: per-FX offline/online runs on the background reclaimer (fx_reclaim), in ticks bounded by kOnlineBudgetMs/kOfflineBudgetMs, off applyMode's block entirely (measured: TrackFX_SetOffline was 1897ms of a 1942ms switch).
  • ONE mode switch is ONE undo point — the background FX parking included. The driven flags, the lane state, AND the per-FX offline/online the reclaimer writes minutes-of-plugin-loads later all roll back in a single Ctrl-Z, because they share ONE undo block: applyMode opens it (fx_reclaim's UndoSpan) and a REAL switch RETURNS WITH IT OPEN, for the reclaimer to close once the drain has converged. A reapply closes its own before returning. This does not move an FX write back onto the switch — the writes still run on the timer, off the 1897ms path. DAW-MEASURED, and the reason the design is shaped this way: block WIDTH is the entire effect. For ten TrackFX_SetOffline calls from a TIMER — unblocked, or under a block per write, by every dispatch route — REAPER minted TEN points; under ONE block spanning all ten it minted ONE. The ("", 0) discard form does NOT discard outside an action, it coalesces; it discards only from a hand-fired action. A block opened on one timer callback and closed on a LATER one holds. Whether one held from an ACTION into a later timer callback holds is the same property but is [verify — DAW]; if it does not, the switch and its parking land as two points, which is the intended degradation.
  • No path may return past a terminating condition with the span open. A leaked block swallows every edit the user makes afterwards, indefinitely, so every terminating path routes through ONE idempotent close (fxReclaimEndUndoSpan): the drain settling, aborting on a generation bump, or deferring on the transport; the project retargeting or closing; the next apply superseding it; a project save; extension unload. The span may stay open only across a wait it can BOUND — the 3s debounce, which is the dead time between the switch and its first FX write, and the only reason the span exists. A TRANSPORT deferral is not bounded (it lasts as long as the playback pass), so it ends the span and the parking that follows mints its own point (spanEndsAfter).
  • A reapply that wrote nothing leaves no undo point — and a reapply never holds the span open, so a project open, a membership edit and render_in_place's inner reapply all keep the block shape they had. The load-tick reapply re-plans a park for every inactive leaf, and over a project saved fully parked it finds every flag and every FX already where the plan wants them. The block then closes on the ("", 0) discard form (view.h's applyMintsUndoPoint; mintManagedLanes' no-op path uses the same idiom) — opening a project must not cost the user a Ctrl-Z. A REAL switch always mints, written state or not: it is an explicitly fired action and has to stay undoable even when the plan found nothing to write.
  • A rapid A→B→A flip moves zero plugins. The reclaimer only starts a park's OFFLINE work once a track has read continuously parked for kParkHoldMs (3000ms). A flip inside that window flips the flags twice but the debounce never matures, so the reclaimer finds every chain already where the record wants it and writes nothing.
  • Show-both semantics: a per-track "pin visible across modes" flag re-enables processing whenever shown. A show-both leaf appears in every mode's visible set and is never parked — its driven flags stay at snapshot/restored values, FX online, in the mix. ("Show but keep parked" is not offered.) Stored on the membership record; persists; togglable per selection.
  • No literal second canvas. A literal second arrange surface, a second window, or a duplicated project stays rejected — reject any such path in review.

Two-canvas sub-phase (Phase D2/E) — settled and landed parts, DAW-application half:

  • Fixed-lane item-level separation mechanics. Map mode → lane; toggle drives per-lane play/show so only the active mode's lane is present. Items keep their real position and real track — nothing is moved in time or deleted. SDK surface (verified present in vendor/reaper-sdk): track-side I_FREEMODE = 2, I_NUMFIXEDLANES, C_LANEPLAYS:N; item-side I_FIXEDLANE, C_LANEPLAYS, B_FIXEDLANE_HIDDEN. I_FREEMODE changes require UpdateTimeline() to take visible effect.
  • Inactive-mode content is hidden AND silenced. The off-mode lane is set C_LANEPLAYS = 0 — neither shown nor played — consistent with exclusive membership and with D1's "flipping modes is a real change, not cosmetic." Show-both is the deliberate opt-out for a lane that must stay audible across modes.
  • Capture placement is mode-aware. An explicit placement while in Design mode — including capture-and-place — lands the item in the Design lane; the same rule governs manual insertion. The capture load-bearing principle is untouched: capture still writes a file + index entry and never auto-inserts; this governs only where an explicit placement lands.
  • REAPER floor: v7 for this sub-phase (fixed lanes shipped in v7); no version-gate branch — below v7 the sub-phase is simply unavailable.

Item→lane membership rules (the adoption rule, exclusive-per-item membership, the managed/manual lane distinction, and the lane-ownership index) are model concepts owned by core/view — see that directory's Invariants; this directory only applies the resulting lane state to live tracks.

Modules

  • view — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and OPENS the ONE undo span all of that rides (applyMintsUndoPoint decides whether it leaves a point; on a real switch fx_reclaim is what closes it). 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. FX are NOT written here — see fx_reclaim.
  • view_park — the park surface, FLAG DOMAIN ONLY. parkTrack captures the pre-park snapshot (flags AND the FX chain's identity-keyed offline state) when the model holds none, drives the four flags, and EXPOSEs a track that reads park-shaped with no record behind it rather than adopting it; restoreTrack writes the flags back from the snapshot and spends its flag half (releaseSnapshotFlags). Also trackFlagParm (the one Flag → REAPER-parameter mapping), writeIfChanged (the read-before-write every driven parameter goes through), fxGuidString (the one FX-identity read snapshot/reclaim both address FX through), kViewUndoMask, and reportExposedTracks/describeExposedTracks (named tracks, once per changed set per project).
  • view_lanes — managed fixed lanes: mintManagedLanes splits multi-mode content onto per-mode lanes, applyLaneOps drives C_LANEPLAYS for the active mode, reconcileManagedLanes recovers ownership from durable P_LANENAME on project open. Manual lanes are never minted, driven, renamed or reassigned.
  • view_reconcile — the repair pass for a project state REAPER reloaded under us: noteAppliedState records what an apply left driven; reconcileAppliedFlags diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for UNDO_STATE_TRACKCFG not carrying B_SHOWINTCP/B_SHOWINMIXER (DAW-confirmed).
  • view_tracks — the scaffolding every write pass sets up: TrackHandles/TrackByGuid, readFolderEntries/indexHandles, and UiRefreshHold (the PreventUIRefresh RAII pair every write phase runs under).
  • 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.
  • fx_reclaim — the FX convergence loop, off the switch's synchronous path (TrackFX_SetOffline was 1897ms of a measured 1942ms switch). drainTick moves one track's FX per step against core/view/fx_state's derived work list (Online before Offline; an Offline item waits out kParkHoldMs so a fast A→B→A flip moves nothing), budgeted per tick (kOnlineBudgetMs/kOfflineBudgetMs) and gated off the transport for the Offline direction only. FxReclaimQueue owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). UndoSpan owns the ONE undo block the whole drain writes under — opened by applyMode on a real switch, else lazily by the first writeOffline, and closed through the single idempotent fxReclaimEndUndoSpan that every terminating path routes through (spanEndsAfter decides which ticks terminate). ReleaseFlush/fxReclaimFlushRelease make a completed record's spend durable on the project's next save, never on the drain's own initiative.

Gotchas

  • The pure mode model (ViewModeModel, membership, reconcile(liveGuids), the snapshot-based park/restore planner) lives in core/view — reference it, do not duplicate its spec here.
  • The Two-canvas sub-phase (Phase D2/E)'s settled DAW-application rules (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.
  • Which routes reach the no-record-plus-parked-chain pair EXPOSE handles. A view_state that PARSED but carries no snapshot for the track — a snapshot reconcile pruned while its track was out of the live enumeration, then undo/redo-restored with the track but not the snapshot; a hand or script edit that leaves a chain park-shaped, including the likeliest real case, a track with untouched flags and just ONE hand-offlined plugin (chainReadsParked never reads the FX half at all, so this route is flags-only); or a track the USER keeps hidden from both panels, out of the mix and FX-bypassed, which reads identically and is EXPOSEd too — no lost state there at all, which is why the report never asserts a cause. A REDO of a mode switch is a further, hypothetical route — see the view_state gotcha below.
  • An UNDO of a mode switch rolls back the FX the span covered, and only those. The span carries UNDO_STATE_FX only once the drain has actually written FX under it (UndoSpan::widen), so a switch whose parking never matured — a fast A→B→A flip — still closes on kViewUndoMask alone and costs no FX serialization. What a Ctrl-Z cannot restore is anything the reclaimer converges AFTER the span closed: a transport deferral, and a drain no switch opened a span for, each mint their own point. Whether that reads as correct in the DAW is [verify — DAW], docs/VERIFICATION.md §"Mode switching".
  • The view_state write is NOT inside applyMode's block — but on a REAL switch it is now inside the SPAN. persistViewState (design_view_actions.cpp) runs after applyMode has returned, and a real switch returns with the span open, so the SetProjExtState — and the modal Save-As persistViewState can pop on an unsaved project — land inside the block after all. That is a consequence of the span, not a decision made here; it does not resolve the redo hypothesis below, which turns on capture-time semantics the SDK does not document. [verify — DAW] that a Save-As dialog opening under an open undo block is harmless. The rest of this gotcha describes the reapply path, where nothing changed. The UNDO direction still comes out right — the point closed over the ext state as it stood BEFORE the switch, which is what a Ctrl-Z should restore — but by position, not by design. The REDO direction is what this leaves open, and the cost is worse than the model-vs-undo split the solo-cache gotcha describes: a redo can plausibly produce a NEW EXPOSE route. As a HYPOTHESIS — the redo restores the parked flags AND the ext state as the point closed over it, i.e. the PRE-switch view_state, carrying no snapshot for those tracks (UNDO_STATE_MISCCFG covers extension state, reaper_plugin.h:1544). The projectconfig reload then rebuilds the model from that stale value, and the reapply it triggers plans NEITHER a park (the track is a member of the restored active mode) nor a restore (no snapshot to restore from) — verified in planToggle, which pushes nothing at all for an active leaf without a snapshot. The tracks stay parked with no snapshot, so the next real switch parks them and parkTrack EXPOSEs them instead. TWO links are unverified and the route dies if either fails: (1) that the point's stored state is captured at Undo_EndBlock2 and therefore excludes the persistViewState write that follows it — the SDK documents no capture-time semantics for the state mask; (2) that a redo fires BeginLoadProjectState with isUndo == true so the session really does reload the stale ext state (asserted in main.cpp, never observed). If (2) is false the in-memory model keeps its snapshots and the redo is consistent. [verify — DAW]. Do not "fix" it by moving persistViewState inside the block — it can open a modal Save-As; whether to split the persist is a separate decision.
  • A DIFFERENT strand entirely, which the EXPOSE report does NOT cover: an absent or malformed view_state (loadViewModel, ext_state_io.cpp) is not a parked-chain reading at all — it falls back to a DEFAULT model (active mode Arrange, empty membership), so nothing is planned to park the track, parkTrack never runs, and a track saved parked stays stuck with no console line whatsoever.
  • applyMode fails closed when re-entered — a model-consistency guard now, not a pump-during-FX-writes one. Nothing applyMode calls is known to pump the message loop any more (the FX writes are gone from it), but a re-entrant apply would still plan against the same model the outer call is iterating and consume records out from under it — modeApplyInProgress (view.cpp's ApplyLatch) drops the re-entrant call rather than risk that. A mode switch or membership reapply fired while another apply is on the stack is DROPPED, not queued; the action callers just no-op, silently (reportModeSwitchRefused speaks only for the transport gate) and the user re-fires. The one caller with nothing to fall back on is main.cpp's load glue, which tests modeApplyInProgress AND fxReclaimDrainInProgress before spending its one-shot signal and retries on the next tick — the reclaimer's own drain (fx_reclaim's DrainLatch) is latched for the same reason over the same window: TrackFX_SetOffline is what pumps now, and it holds the view model the drain reads, not applyMode. render_in_place's own applyMode call is the exception to the drop's recovery path, not to the drop itself: its refusal leaves the membership tags it already wrote in place, so the next reapply — not a user re-fire of render-in-place — is what brings the tracks into sync.
  • render_in_place still calls applyMode inside its own Undo_BeginBlock2 pair for the reapply — one point rather than two for the flags and lane state — but with no FX writes left inside applyMode's block there is nothing left to coalesce beyond that; the SDK's nested-block coalescing itself is still [verify — DAW].
  • fx_offline's identity keying (TrackFX_GetFXGUID) assumes the GUID stays attached to its plugin across a chain mutation while parked. That is [verify — DAW] (see fxGuidString in view_park.cpp) and SWS issue #802 is a known reason it might not hold: SNM_MoveOrRemoveTrackFX reportedly leaves the FXID lines behind on reorder rather than moving them with the plugin. If confirmed, an SWS-driven reorder of a parked track's chain — not a native drag-reorder — can produce wrong-plugin restores or mass drops through resolveFxRestore. Do not design around this pre-emptively; if native reorder is clean (the likely case), only the SWS path degrades.