Loop: an explicit enable, four named marks with grabbable caps, and the crossfade painted where it is actually heard

hasLoop becomes user-owned with the gestures as shortcuts onto it; no format change. START uses overlay/trace, not accent/primary, which is the waveform's own fill.
This commit is contained in:
2026-08-02 05:18:53 -04:00
parent ef59265e7a
commit a7c3c7a828
26 changed files with 1190 additions and 191 deletions
+41 -2
View File
@@ -107,7 +107,7 @@ static void testOverlayBoxIsTheWholeArea() {
// --- Smallest-target-first: resolveWaveformClaim, the shell's own comparison chain -----
//
// editor_input_waveform.cpp's mouseDownWaveform resolves a click among a contour node (a fixed
// pick box), the crossfade tab, and a marker's full-height column by calling
// pick box), a mark's CAP, and a mark's full-height column by calling
// resolveWaveformClaim with each candidate's own target area; the smallest hit wins. These
// tests build the real geometry over the pure primitives the shell composes, then feed it into
// resolveWaveformClaim itself, so a reverted node-first/marker-first/tab-first ordering fails
@@ -215,7 +215,45 @@ static void testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere() {
WaveformClaimant::kMarker);
}
// The only live tie: the crossfade tab (<=110) can equal the node (169) only off-geometry, but
// Case (d): every mark now carries a cap, which RESOLVES the long-open "staged-envelope-node
// shadow at zero-attack" wart. A zero-attack AttackEnd node sits at the canvas's top-left — the
// same pixel a START marker at frame 0 draws at — and used to win the click outright, because
// the marker's only target there was its full-height column. START's cap is the same 11x10 tab
// the crossfade always had, so the node no longer shadows it. The cap slot's nominal area is
// unchanged by the change (every mark's cap is one markerHandleRect), which is why the
// arbitration itself needed no re-tuning: cap < node < column still holds.
static void testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip() {
const Rect a = Rect{20, 10, 1000, kWaveformMinHeight};
const OverlayArea overlay = overlayOf(a);
const std::int64_t frames = 100000;
// START at frame 0: its cap clips against the band's left edge, and a zero-attack node is
// drawn on that same corner.
const Rect cap = markerHandleRect(overlay, frames, 0);
CHECK(!cap.empty());
CHECK(contains(cap, a.x, a.y));
// NOMINAL, matching what the shell feeds the arbitration — the clipped tab at frame 0 is
// the worst case for the cap, and it still wins on the nominal number the shell uses.
const std::int64_t capArea =
static_cast<std::int64_t>(2 * kMarkerHandleHalfWidth + 1) * kMarkerHandleHeight;
const std::int64_t columnArea =
static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) * a.height;
CHECK(capArea < kNodeArea);
CHECK(kNodeArea < columnArea);
const WaveformClaim node{true, kNodeArea};
const WaveformClaim capClaim{true, capArea};
const WaveformClaim column{true, columnArea};
CHECK(resolveWaveformClaim(node, capClaim, column, SplineGesture::kLeft) ==
WaveformClaimant::kTab);
// And the node keeps everything below the cap strip, which is where it is actually drawn
// for any non-degenerate envelope.
CHECK(!contains(cap, a.x, a.y + kMarkerHandleHeight));
CHECK(resolveWaveformClaim(node, WaveformClaim{}, column, SplineGesture::kLeft) ==
WaveformClaimant::kNode);
}
// The only live tie: a mark's cap (<=110) can equal the node (169) only off-geometry, but
// tab-vs-marker ties at overlay height 10 (kMarkerHandleHeight), where the tab's 11x10 strip
// (110) equals a marker column's 11 * 10 (110) — the tab wins, matching check order.
static void testTabWinsAGenuineTabVersusMarkerTie() {
@@ -269,6 +307,7 @@ int main() {
testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero();
testCrossfadeTabBeatsAContourNodeNearItsTopStrip();
testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere();
testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip();
testTabWinsAGenuineTabVersusMarkerTie();
testNoHitAnywhereFallsThroughToNone();
testAMissedCandidateNeverWinsOnADegenerateZeroArea();