One undo-block opener for the whole tree: fourteen sites route through it, and the reclaim span can no longer swallow a user's act

The drain refuses a tick inside a foreign block; applyMode wakes the reclaimer
before it opens the span.
This commit is contained in:
2026-08-07 16:38:11 -04:00
parent b2f6b3f1d0
commit b7b93a7ac3
25 changed files with 561 additions and 313 deletions
+53
View File
@@ -0,0 +1,53 @@
# src/shell/undo — the one REAPER undo-block opener
## Scope
One type, `UndoBlock`, and one query, `undoBlockOpen()`. Every extension-owned undo
block in the tree is opened through this type; nothing here decides what a point is
called or whether one is worth minting — each call site still owns its own label,
mask, and discard-vs-mint condition. The FX reclaim span is NOT this directory's:
`shell/view/fx_reclaim` owns it, and this directory only guarantees no block ever
nests over it.
## Invariants
- **`Undo_BeginBlock2` is not callable outside this directory, and that is enforced
by the compiler, not by review.** Only `undo_block.cpp` and `shell/view`'s
`fx_reclaim.cpp` (which owns the span's own begin/end) define
`REAPERAPI_WANT_Undo_BeginBlock2` / `..._Undo_EndBlock2`. Every other TU compiles
under `REAPERAPI_MINIMAL` with no header in its include graph pulling the SDK in
unguarded, so a raw `Undo_BeginBlock2` there is an undeclared identifier. Adding
the WANT define back to a third TU is the one way to defeat this — do not.
- **Ending the reclaim span is part of opening, and ONLY the outermost block does
it.** At depth 0 the span is the only block that can be open, so ending it mints
the pending mode switch's point whole before this block exists. At depth > 0 an
enclosing `UndoBlock` already ended it, and a close from the inner one would land
at depth 2->1 — minting nothing and hijacking the enclosing block's label. That
is the same criterion `applyMode`'s reapply is gated on; it lives in the
constructor so no call site has to make the judgment. Full reasoning for the rule
itself: `src/shell/view/CLAUDE.md`.
- **Closing is a destructor.** No throw between the open and the label decision can
leave a block open in the user's project, which is why several call sites can
allocate freely between the two.
- **No label recorded means no point.** `UndoBlock` closes on `("", 0)` unless
`recordPoint` was called — the discard form, real from a hand-fired action
(DAW-measured; `src/shell/view/CLAUDE.md`). A verb that mutated nothing therefore
costs no undo entry simply by saying nothing.
## Modules
- `undo_block` — `UndoBlock` (RAII open/close plus `recordPoint`) and
`undoBlockOpen()`. The latter is read by `fxReclaimTick`, which refuses a tick
outright while any block is open: the drain's lazy `openSpan` would otherwise
begin at depth 2, and the enclosing block's close would then mint under the span's
label and destroy the user's own point. The refusal consumes nothing — no wakeup,
no queue, no project pin — so the first tick after the block closes drains as if
this one had never been due.
## Gotchas
- The depth counter deliberately does NOT count the reclaim span. The span is the
thing this stands off, not a peer.
- The counter drops AFTER `Undo_EndBlock2`, not before: the close that reaches depth
0 mints a point, and anything re-entered through that must still read a block as
open rather than let the drain open a span underneath it.