Merge Γ-W2-T2: an explicit loop enable, four named marks with grabbable caps, and the crossfade painted where it is heard

This commit is contained in:
2026-08-02 06:32:29 -04:00
28 changed files with 1289 additions and 192 deletions
+46 -21
View File
@@ -19,7 +19,9 @@
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
#include "core/instrument/ui/loop_marks.h" // LoopMarks (the loop enable's state machine)
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
#include "core/instrument/ui/waveform_view.h" // WaveMark / WaveMarks (the overlay's marks)
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
#include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior)
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
@@ -104,12 +106,13 @@ private:
// [0, DeckParam::kCount) is what keeps liveCommitFor answering "not a live control".
static constexpr int kBakeHoldKnobId = -3;
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
// in draw + hit order, then the crossfade handle. The crossfade is NOT part of the
// full-height column hit-test — it answers only in its top-strip handle (waveform_view's
// The waveform band's four marks, in draw + hit order. The crossfade is NOT part of the
// full-height column hit-test — it answers only in its cap (waveform_view's
// markerHandleRect), because at a zero fade it sits exactly on the loop start.
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kLoopXfade = 3,
kCount = 4 };
using WaveMarker = instrument::ui::WaveMark;
// What those four marks are showing — see pickedMarkers.
using SetupMarkers = instrument::ui::LoopMarks;
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index`
// disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 when
@@ -125,6 +128,9 @@ private:
kBrowseCancel, // the Browse modal "Cancel" button
kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment
kLoopOff, // the loop enable's Off segment
kLoopOn, // the loop enable's On segment
kWaveMark, // a waveform overlay mark (index = WaveMark ordinal); promotes its label
kPreview, // the preview-trigger button
kBake, // the resample-bake trigger
kControl, // a knob-deck element (index = control id)
@@ -162,12 +168,14 @@ private:
// --- Band painters (one TU each, mirroring the input side) ---
// Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob,
// channel toggle).
void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty);
void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty,
const SetupMarkers& marks);
// The hovered piano key's note-name chip. Drawn after every band — it overhangs the
// chrome into whatever is below it.
void paintChromeTooltip(LICE_IBitmap* bmp, const FaceLayout& fl, int w, int h);
// Waveform: the channel lane(s), the loop/start markers, and the envelope overlay.
void paintWaveform(LICE_IBitmap* bmp, const Rect& band);
// Waveform: the channel lane(s), the four marks, and the envelope overlay. `marks` is
// resolved once per paint by paintSample — see there.
void paintWaveform(LICE_IBitmap* bmp, const Rect& band, const SetupMarkers& marks);
// Decks: the group fence + caption + compact caption toggles + radial knobs with
// label<->value swap on hover/drag.
void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
@@ -243,6 +251,9 @@ private:
// mouse-down branches but are read-only. Windows-only.
void resolveHover(int x, int y);
HoverTarget hoverChrome(const FaceLayout& fl, int x, int y) const;
// Marks only — the cap strip. Everything else in the band already reports its own state
// through the drag, so nothing else on the overlay needs a hover.
HoverTarget hoverWaveform(const FaceLayout& fl, int x, int y);
HoverTarget hoverDeck(const FaceLayout& fl, int x, int y) const;
HoverTarget hoverBrowse(int w, int h, int x, int y) const;
HoverTarget hoverCurvePopup(int w, int h, int x, int y) const;
@@ -326,20 +337,28 @@ private:
// instance's own SampleRefs as the self-contained fallback. "" when unresolvable.
std::string samplePathFor(const std::string& sampleId) const;
// The effective loop + start markers for the loaded capture: the parameter set's
// override when one is set, else the bank's loop intrinsic / frame 0. With no loop set,
// the loop handles park on loop_span's defaultLoopBounds so both stay grabbable — the
// frame-0 default they replace put loopStart under the start marker, where nothing could
// reach it.
struct SetupMarkers {
std::int64_t start = 0;
std::int64_t loopStart = 0;
std::int64_t loopEnd = 0;
std::int64_t crossfade = 0; // pre-seam fade, SOURCE frames; handle at loopStart - this
bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance)
};
// The effective loop + start markers for the loaded capture; the state machine behind them
// is the pure loop_marks module's. With no override set this costs a bridge read plus a bank
// parse — hoverWaveform calls it on every WM_MOUSEMOVE, so the answer is memoized (see
// HoldNeedKey below, which this shares) rather than paid per move.
SetupMarkers pickedMarkers(std::int64_t frames) const;
// Whether the loop controls answer at all: the sustain loop is Gate-only, so in Trigger the
// marks and the chrome enable draw Disabled and inert. Reads the mode AFTER the drawn-EG
// fold, so a drawn envelope disables them through the same predicate.
bool loopControlsLive() const;
// Which marks the band DRAWS, and which of those accept a grab. They differ in exactly one
// place — Trigger, where the loop marks stay drawn (hiding a set loop on a mode flip would
// destroy information the user put there) but refuse every gesture, because that refusal
// comes from the engine and no drag can talk it out of it.
instrument::ui::WaveMarks waveMarksFor(const SetupMarkers& m) const;
instrument::ui::WaveMarks grabbableMarks(const SetupMarkers& m) const;
// Flips the enable. `on` false retains the span and the crossfade — that retention is the
// whole difference between a toggle and a delete button.
void setLoopEnabled(bool on);
// Whether the loaded sound's bake window needs the user's Hold — the pure predicate
// (bake_plan.h) answered against the markers this face is showing. Decodes and reads the
// bank, so it is called on the sync tick, not per paint, and memoized against the inputs
@@ -347,16 +366,22 @@ private:
bool resolveBakeHoldNeeded();
// What that answer was last computed against. Invalidated wholesale by refreshFromBank,
// which is where the bank half of the input changes.
// which is where the bank half of the input changes. pickedMarkers' own memo (below) shares
// this key shape rather than declaring a second one; resolveBakeHoldNeeded leaves startPoint
// at its default (nullopt) since its own answer doesn't depend on it.
struct HoldNeedKey {
std::string sampleId;
std::optional<SampleLoop> loopOverride;
std::int64_t crossfade = 0;
std::optional<std::int64_t> startPoint;
bool operator==(const HoldNeedKey& other) const;
};
HoldNeedKey holdNeedKey_;
bool holdNeedValid_ = false;
bool holdNeedAnswer_ = false;
mutable HoldNeedKey marksKey_;
mutable SetupMarkers marksCache_;
mutable bool marksValid_ = false;
// Writes `m` into params_ as the loop/start override. Does NOT call commitAndReload —
// callers decide live-drag vs final commit.