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
+53 -20
View File
@@ -55,16 +55,20 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
}
const SetupMarkers m = pickedMarkers(frames);
const WaveMarks grabbable = grabbableMarks(m);
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
// Three affordances can claim the same pixel: a node (the staged envelope's or the drawn
// contour's — a small fixed pick box either way), the crossfade tab (a small clipped
// top-strip tab), and a marker's full-height grab column (waveform_view.h's tab-vs-column
// split already keeps the tab apart from ITS OWN column; this is the cross-affordance case
// on top of that). resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures
// each claimant's own NOMINAL target area and lets the smallest hit win, since a fixed check
// order shadows whichever one loses the tie — this seam regressed twice from exactly that
// fix. Never add here (kAdd is only tried once nothing else has claimed the click, below).
// contour's — a small fixed pick box either way), a mark's CAP (a small clipped top-strip
// tab), and a mark's full-height grab column (waveform_view.h's cap-vs-column split already
// keeps a cap apart from ITS OWN column; this is the cross-affordance case on top of that).
// resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures each claimant's
// own NOMINAL target area and lets the smallest hit win, since a fixed check order shadows
// whichever one loses the tie — this seam regressed twice from exactly that fix. Giving
// every mark a cap changed WHICH mark the cap slot resolves to, not the slot's nominal area
// (every cap is one markerHandleRect) and not the ordering cap < node < column, so the
// arbitration itself is unchanged. Never add here (kAdd is only tried once nothing else has
// claimed the click, below).
WaveformClaim node;
if (envNodeHit.hit) {
constexpr std::int64_t side = 2 * kNodeGrabRadius + 1;
@@ -81,18 +85,22 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
}
}
const Rect tabRect =
m.hasLoop ? markerHandleRect(overlay, frames, m.loopStart - m.crossfade) : Rect{};
const WaveformClaim tab = (m.hasLoop && contains(tabRect, x, y))
? WaveformClaim{true, static_cast<std::int64_t>(tabRect.width) *
tabRect.height}
: WaveformClaim{};
const int capHit = capAtPoint(overlay, frames, grabbable, x, y);
const WaveformClaim tab =
(capHit >= 0)
? WaveformClaim{true, static_cast<std::int64_t>(2 * kMarkerHandleHalfWidth + 1) *
kMarkerHandleHeight}
: WaveformClaim{};
// Nominal, not actual: markerAtPoint clips the column at the overlay edges (a marker at
// frame 0 has 6 usable columns, not 11) and the node's fixed side clips too at a pick-box
// corner. Both overestimate in the direction that already produces the intended winner, so
// the arbitration runs on NOMINAL area, not the measured hit-testable pixel count.
const int markerHit = markerAtPoint(overlay, frames, markerFrames, 3, x, y);
const int markerHit =
(grabbable.present[static_cast<int>(WaveMark::kLoopStart)]
? markerAtPoint(overlay, frames, markerFrames, 3, x, y)
// In Trigger only START answers a column, and it is index 0 of the same array.
: markerAtPoint(overlay, frames, markerFrames, 1, x, y));
const WaveformClaim marker =
(markerHit >= 0)
? WaveformClaim{true, static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) *
@@ -114,7 +122,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
}
return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
case WaveformClaimant::kTab:
beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x);
beginMarkerDrag(static_cast<WaveMarker>(capHit), m, frames, x);
return true;
case WaveformClaimant::kMarker:
beginMarkerDrag(static_cast<WaveMarker>(markerHit), m, frames, x);
@@ -179,6 +187,28 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
return true;
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x,
int y) {
// Caps only: the cap is the grip, so it is the one thing on the overlay a resting pointer
// can be "on". A hovered mark promotes its own label past the suppression rule.
//
// Rejected on the band's own rect FIRST, before anything expensive: this runs on every
// WM_MOUSEMOVE, and with no loop override set pickedMarkers costs a bridge read plus a bank
// parse. Every cap lives in the top kMarkerHandleHeight of the band, so that strip is the
// only place the answer can be anything but a miss.
const Rect& band = fl.bands.waveform;
if (band.empty() || x < band.x || x >= band.right() || y < band.y ||
y >= band.y + kMarkerHandleHeight) {
return {};
}
const auto frames = static_cast<std::int64_t>(monoPcmFor(selectedId_).size());
if (frames <= 0) return {};
const OverlayArea overlay = waveformOverlayArea(band);
const int cap = capAtPoint(overlay, frames, grabbableMarks(pickedMarkers(frames)), x, y);
if (cap < 0) return {};
return {HoverKind::kWaveMark, cap};
}
void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m,
std::int64_t frames, int x) {
drag_ = DragKind::kWaveMarker;
@@ -243,7 +273,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const int idx = static_cast<int>(waveMarker_);
const std::int64_t startVals[4] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd,
dragStartMarkers_.loopStart -
dragStartMarkers_.loopEnd -
dragStartMarkers_.crossfade};
std::int64_t newFrame = resolveDragFrame(overlay, frames, startVals[idx], dx);
@@ -251,13 +281,16 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
// frames — no host types, no file I/O. The crossfade handle is exempt: it sets a fade
// LENGTH, and the whole point of the fade is that its edges need no zero crossing.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
if (!pcm.empty() && waveMarker_ != WaveMarker::kLoopXfade) {
if (!pcm.empty() && waveMarker_ != WaveMarker::kCrossfade) {
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
newFrame);
}
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a LOOP marker turns the
// enable on — a grab implies intent to loop, and it is what teaches the chrome toggle by
// demonstration. Dragging START does not: it is live in both modes and says nothing about
// the loop.
SetupMarkers m = dragStartMarkers_;
if (waveMarker_ == WaveMarker::kStart) {
m.start = newFrame;
@@ -267,8 +300,8 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
} else if (waveMarker_ == WaveMarker::kLoopEnd) {
m.loopEnd = (std::max)(newFrame, m.loopStart);
m.hasLoop = true;
} else { // kLoopXfade — the handle sits at loopStart - crossfade, so left lengthens it
m.crossfade = (std::max)(std::int64_t{0}, m.loopStart - newFrame);
} else { // kCrossfade — the handle sits at loopEnd - crossfade, so left still lengthens it
m.crossfade = (std::max)(std::int64_t{0}, m.loopEnd - newFrame);
}
if (m.start < 0) m.start = 0;
if (m.start > frames - 1) m.start = frames - 1;