FB2: Zone panel adopts the r11 knob deck + curve popup (R11-F2 parity) — slider rows and inline curve box retired, zone-authoring affordances kept

This commit is contained in:
2026-07-27 23:58:07 -04:00
parent a66acb7473
commit 682c21509a
2 changed files with 283 additions and 365 deletions
+212 -307
View File
@@ -23,7 +23,7 @@
#include "master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1)
#include "theme.h" // Role / InteractionState / KitColor / spectralColor (L3)
#include "note_entry.h" // S12 direct numeric note-entry parse
#include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping
#include "param_slider.h" // the FA4 radial-knob primitive (value<->needle map, drag delta)
#include "peaks.h" // computeEnvelope
#include "reaper_bridge.h"
#include "reasampler_processor.h"
@@ -174,6 +174,9 @@ void ReaSamplerEditor::refreshFromBank() {
// curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible
// modal would swallow clicks.
if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false;
// FB2: on the Zone surface the popup edits the SELECTED zone; if the refresh dropped the
// selection (the zones list shrank), close it rather than let it retarget another zone.
if (view_ == View::kZone && selectedZone_ < 0) curvePopupOpen_ = false;
// Drop a filter that names a bank no longer present.
if (!activeFilterBankId_.empty()) {
bool found = false;
@@ -373,34 +376,6 @@ constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slide
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
} // namespace
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const {
std::vector<ControlDesc> out;
// Always: the two mode toggles.
out.push_back({static_cast<int>(ParamControl::kPlayMode), ControlKind::Toggle});
out.push_back({static_cast<int>(ParamControl::kPitchEngine), ControlKind::Toggle});
// Mode-relevant amplitude sliders.
if (play.playMode == PlayMode::Gate) {
out.push_back({static_cast<int>(ParamControl::kAttack), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kHold), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kDecay), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kSustain), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kRelease), ControlKind::Slider});
} else { // Trigger
out.push_back({static_cast<int>(ParamControl::kTrigLength), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kTrigFadeIn), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kTrigFadeOut), ControlKind::Slider});
}
// The AD pitch envelope: an enable toggle + its three sliders (drawn always; inert until on).
out.push_back({static_cast<int>(ParamControl::kPitchEnvEnable), ControlKind::Toggle});
out.push_back({static_cast<int>(ParamControl::kPitchEnvAttack), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kPitchEnvDecay), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kPitchEnvDepth), ControlKind::Slider});
// S-VIEW-6 key-tracking (0..200%). Lives on PerformanceZone, not ZonePlaySeconds — the shell
// reads/writes it against the zone directly (controlValue/applyControl ignore it). Always shown.
out.push_back({static_cast<int>(ParamControl::kKeyTrack), ControlKind::Slider});
return out;
}
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
// the frames ceiling. Two domains, kept explicit so neither leaks a rate.
@@ -487,12 +462,12 @@ enum DeckGroup {
};
} // namespace
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const {
// The PER-ZONE groups — the deck grammar both surfaces share (FB2: the Zone panel renders
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs).
// Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger
// leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows
// the neighbouring groups (r11). The VOICE + MASTER groups are the FB1 homes for the
// provisional voice-deck controls and the new post-mixer gain — the r11 spec predates
// both; surfaced as a judgment call in the handoff.
// the neighbouring groups (r11).
std::vector<DeckGroupDesc> out;
{
DeckGroupDesc amp;
@@ -532,6 +507,15 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySecond
static_cast<int>(ParamControl::kPitchEnvDepth)};
out.push_back(std::move(penv));
}
return out;
}
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER
// groups. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the
// post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays
// OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs.
std::vector<DeckGroupDesc> out = zoneDeckGroupDescs(play);
{
DeckGroupDesc voice;
voice.id = kGroupVoice;
@@ -888,12 +872,11 @@ tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
namespace {
constexpr int kPad = 8;
// The S-VIEW-10 velocity-curve editor box metrics. Since r11 the Sample face hosts the curve in
// a POPUP (curve_popup) summoned from the mini preview button; the Zone param panel still draws
// the inline box (FB2 owns Zone parity). The INSET keeps node handles + the pick radius inside
// the border so an endpoint at amp 0/1 stays grabbable — the ONE curveBoxFromRect grammar every
// curve surface (Zone inline box AND the r11 popup) derives its mapping box through.
constexpr int kVelCurveBoxW = 168; // the Zone inline curve box width (incl. border)
// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the curve
// in the POPUP (curve_popup), each summoned from its own mini preview button the Sample
// cluster's and the Zone panel's (the inline Zone box is retired). The INSET keeps node handles
// + the pick radius inside the border so an endpoint at amp 0/1 stays grabbable — the ONE
// curveBoxFromRect grammar the popup derives its mapping box through.
constexpr int kVelCurveInset = 14; // border -> mapping-box inset: caption band (~12px) + 2px gap
constexpr int kCurveDragOffMargin = 24; // release beyond box+margin -> drag-off delete
@@ -1010,25 +993,21 @@ Rect zonesControlPanel(const Rect& content) {
content.bottom - 4};
}
// The S-VIEW-10 velocity-curve editor inside the Zone param panel: right-anchored, top-aligned,
// with the control rows keeping the panel's left. Suppressed (empty rect) when the panel is too
// narrow/short for both — the controls then keep the full panel (the pre-S-VIEW-10 layout).
// Both draw + hit-test derive from these two formulas so they never drift.
constexpr int kVelCurveBoxH = 140; // the Zone-panel curve box height (Sample uses the hero's)
constexpr int kVelCurveMinPanelW = 220; // controls keep at least this much width beside the box
Rect zonesCurveBox(const Rect& content) {
// FB2 (R11-F2 parity): the Zone panel's per-zone controls render as the SAME knob deck the
// Sample face uses. The deck lays out from the panel top (top-anchored — the Zone panel reads
// top-down, unlike the Sample face's bottom-anchored band), with a column at the panel's right
// reserved for the mini curve-preview button so a deck row can never collide with it (the pure
// knob_deck wrap keeps whole groups inside availWidth). Both draw + hit-test derive from these
// two formulas so they never drift.
Rect zonesDeckArea(const Rect& content) {
const Rect panel = zonesControlPanel(content);
if (panel.width() < kVelCurveBoxW + kPad + kVelCurveMinPanelW || panel.height() < 80) {
return Rect{};
}
const int boxH = (std::min)(kVelCurveBoxH, panel.height());
return Rect{panel.right - kVelCurveBoxW, panel.top, panel.right, panel.top + boxH};
return Rect{panel.left, panel.top, panel.right - kCurveBtnSize - kPad, panel.bottom};
}
Rect zonesControlsArea(const Rect& content) {
// The Zone panel's mini curve-preview button (opens the SAME popup editor as the Sample
// cluster's button): the cluster's 28px square, right-anchored at the panel top.
Rect zonesCurveButton(const Rect& content) {
const Rect panel = zonesControlPanel(content);
const Rect curve = zonesCurveBox(content);
if (curve.width() <= 0) return panel;
return Rect{panel.left, panel.top, curve.left - kPad, panel.bottom};
return Rect{panel.right - kCurveBtnSize, panel.top, panel.right, panel.top + kCurveBtnSize};
}
// The pure-module mapping Box for a drawn curve rect: inset from the border so node handles and
@@ -1333,36 +1312,9 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
}
}
// The mini curve-preview button (r11): a hairline-bordered bg/cell square with the zone's
// live velocity curve traced in miniature (no node markers at this scale). Hover lifts it;
// it draws ACTIVE (accent-primary border) while its popup is open, and re-renders live as
// the popup edits the curve (same zone, re-read each paint).
{
const bool hov = isHovered(HoverKind::kCurveButton, -1);
fillSurface(bmp, toKitBox(cr.curveBtn), Role::BgCell,
hov ? InteractionState::Hover : InteractionState::Rest);
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
: roleColor(Role::LineHairline);
LICE_DrawRect(bmp, cr.curveBtn.left, cr.curveBtn.top, cr.curveBtn.width() - 1,
cr.curveBtn.height() - 1, toLice(border), 1.0f, 0);
const VelocityCurve& curve = zone.velocityCurve;
const int inset = 3;
const VelocityCurve::Box mini{cr.curveBtn.left + inset, cr.curveBtn.top + inset,
cr.curveBtn.width() - 2 * inset,
cr.curveBtn.height() - 2 * inset};
if (mini.width > 1 && mini.height > 1) {
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
int prevX = 0, prevY = 0;
for (int px = 0; px <= mini.width; ++px) {
const int mx = mini.left + px;
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
prevX = mx;
prevY = my;
}
}
}
// The mini curve-preview button (r11): opens the popup editor. Shared painter with the
// Zone panel's button (FB2 — one grammar on both surfaces).
paintCurveButton(bmp, cr.curveBtn, zone);
// Mono | Stereo output-mode toggle.
{
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
@@ -1377,7 +1329,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
}
// --- The knob deck (r11: the fenced control groups, bottom-anchored) -------------------
paintKnobDeck(bmp, bands.deck, zone);
paintKnobDeck(bmp, bands.deck, zone, deckDescs);
// --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ----
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
@@ -1419,19 +1371,15 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveA
}
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
const PerformanceZone& zone,
bool drawCaption) {
if (r.width() <= 0 || r.height() <= 0) return; // suppressed (window too narrow)
const PerformanceZone& zone) {
if (r.width() <= 0 || r.height() <= 0) return; // defensive (degenerate rect)
// The bordered box: a panel surface + hairline border, drawn by palette role.
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the
// popup is the only host).
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1,
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
// A corner caption (decorative — the axes are velocity -> amp). Suppressed when the caller
// (e.g. the curve popup) renders its own sheet title so the label doesn't double.
if (drawCaption)
kitText(bmp, Rect{r.left + 4, r.top + 1, r.right - 4, r.top + 12}, "Vel curve",
Font::Micro, Role::TextDim);
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
@@ -1479,9 +1427,9 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
}
void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
const PerformanceZone& zone) {
const PerformanceZone& zone,
const std::vector<DeckGroupDesc>& descs) {
if (deckArea.width() <= 0 || deckArea.height() <= 0) return;
const std::vector<DeckGroupDesc> descs = deckGroupDescs(zone.play);
const DeckLayout dl = layoutDeck(descs, deckArea.left, deckArea.top, deckArea.width());
const ZonePlaySeconds& play = zone.play;
const bool isMono = (voiceMode_ == VoiceMode::Mono);
@@ -1594,6 +1542,37 @@ void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
}
}
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r,
const PerformanceZone& zone) {
// The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone
// panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced
// in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE
// (accent-primary border) while its popup is open, and re-renders live as the popup
// edits the curve (same zone, re-read each paint).
const bool hov = isHovered(HoverKind::kCurveButton, -1);
fillSurface(bmp, toKitBox(r), Role::BgCell,
hov ? InteractionState::Hover : InteractionState::Rest);
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
: roleColor(Role::LineHairline);
LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, toLice(border), 1.0f, 0);
const VelocityCurve& curve = zone.velocityCurve;
const int inset = 3;
const VelocityCurve::Box mini{r.left + inset, r.top + inset, r.width() - 2 * inset,
r.height() - 2 * inset};
if (mini.width > 1 && mini.height > 1) {
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
int prevX = 0, prevY = 0;
for (int px = 0; px <= mini.width; ++px) {
const int mx = mini.left + px;
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
prevX = mx;
prevY = my;
}
}
}
void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
// The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the
// Sample face stays legible behind it), then the centered sheet.
@@ -1610,11 +1589,58 @@ void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
: InteractionState::Rest;
drawButton(bmp, box, "x", st, /*warn=*/false);
}
// The full-size editor: the SAME draw path as the Zone inline box (paintVelocityCurve +
// the one curveBoxFromRect mapping formula), so trace/handles/drag-off cues cannot drift
// between the two surfaces. The popup edits the picked capture's one-zone site. Caption
// suppressed (the sheet's own "VELOCITY -> AMP" title above is the label for this context).
paintVelocityCurve(bmp, pl.curveBox, effectiveSampleZone(), /*drawCaption=*/false);
// The full-size editor: ONE draw path + the one curveBoxFromRect mapping formula, so
// trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() —
// the picked capture's one-zone site on the Sample face, the selected zone on the Zone
// surface (FB2).
paintVelocityCurve(bmp, pl.curveBox, popupZone());
}
PerformanceZone ReaSamplerEditor::popupZone() const {
// The zone the popup displays: the Zone surface's SELECTED zone (FB2), else the Sample
// face's one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
if (view_ == View::kZone && selectedZone_ >= 0 &&
selectedZone_ < static_cast<int>(map_.zones.size())) {
return map_.zones[static_cast<std::size_t>(selectedZone_)];
}
return effectiveSampleZone();
}
int ReaSamplerEditor::popupZoneIndex() {
// The map_.zones index a popup edit lands on, or -1 when there is no valid target. The
// Zone surface never materializes (the button only shows for an explicit selection); the
// Sample face finds-or-materializes the picked id's one-zone site.
if (view_ == View::kZone) {
return (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size()))
? selectedZone_
: -1;
}
return ensureSampleZone();
}
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
// The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home
// (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash
// click dismiss (outside only when no drag is in flight, per the spec); in-box clicks
// route to the shared curve machinery against popupZoneIndex(); anything else on the
// sheet is swallowed.
if (!curvePopupOpen_) return false;
const CurvePopupLayout pl = computeCurvePopup(w, h);
if (contains(pl.close, x, y)) {
curvePopupOpen_ = false;
invalidate();
return true;
}
if (contains(pl.curveBox, x, y)) {
const int zi = popupZoneIndex();
if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y);
return true;
}
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
curvePopupOpen_ = false;
invalidate();
}
return true;
}
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) {
@@ -1955,104 +1981,20 @@ void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
Font::Label, Role::TextDim);
}
// The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger +
// pitch engine + AD pitch envelope). Shown for an explicit zone selection OR for the
// single-capture face when the map is empty but a capture is picked (S15-F2 lean: the
// single capture is already a one-zone map — one storage site serves both).
// The per-zone parameter surface for the selected zone. FB2 (R11-F2): the SAME knob deck +
// curve-preview-button/popup grammar as the Sample face — one control language over the one
// storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. Only
// the per-zone groups render here; VOICE/MASTER are per-instance (ComponentState) and live
// on the Sample deck only.
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
paintControls(bmp, zonesControlsArea(content), z);
// S-VIEW-10: the same velocity-curve editor, right-anchored in the per-zone param panel
// (one curve per zone). Suppressed (empty rect) when the panel is too narrow.
paintVelocityCurve(bmp, zonesCurveBox(content), z);
paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play));
paintCurveButton(bmp, zonesCurveButton(content), z);
}
}
// The label + the two toggle-segment captions for a control (member so it can name the private
// ParamControl enum). Segments are only read for a ControlKind::Toggle.
namespace {
struct ControlLabels { const char* label; const char* seg0; const char* seg1; };
} // namespace
void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel,
const PerformanceZone& zone) {
// The control panel edits `zone`: its ZonePlaySeconds (Mode/Pitch/AHDSR-or-Trigger/AD-pitch)
// plus the S-VIEW-6 keyTrack scalar (which lives on PerformanceZone, not the play struct — it
// is read/written from `zone` directly). Shared by the Sample home face (single-capture, the
// effective one-zone site) and the Zone surface (an explicit zone) — one storage site.
const ZonePlaySeconds& play = zone.play;
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const auto labelsFor = [](ParamControl c) -> ControlLabels {
switch (c) {
case ParamControl::kPlayMode: return {"Mode", "Gate", "Trigger"};
case ParamControl::kPitchEngine: return {"Pitch eng", "Varisp", "Preserve"};
case ParamControl::kAttack: return {"Attack", "", ""};
case ParamControl::kHold: return {"Hold", "", ""};
case ParamControl::kDecay: return {"Decay", "", ""};
case ParamControl::kSustain: return {"Sustain", "", ""};
case ParamControl::kRelease: return {"Release", "", ""};
case ParamControl::kTrigLength: return {"Length %", "", ""};
case ParamControl::kTrigFadeIn: return {"Fade in", "", ""};
case ParamControl::kTrigFadeOut: return {"Fade out", "", ""};
case ParamControl::kPitchEnvEnable: return {"Pitch env", "Off", "On"};
case ParamControl::kPitchEnvAttack: return {"P.Attack", "", ""};
case ParamControl::kPitchEnvDecay: return {"P.Decay", "", ""};
case ParamControl::kPitchEnvDepth: return {"P.Depth", "", ""};
case ParamControl::kKeyTrack: return {"Key track", "", ""};
default: return {"", "", ""};
}
};
for (const ControlRow& r : rows) {
if (r.row.top >= panel.bottom) break; // clip at the panel bottom
const ControlLabels lab = labelsFor(static_cast<ParamControl>(r.id));
kitText(bmp, r.label, lab.label, Font::Micro, Role::TextDim);
// kKeyTrack lives on the zone (0..200% over kKeyTrackMax), not in `play` — resolve it
// directly; every other control reads through controlValue against the play struct.
const double v = (r.id == static_cast<int>(ParamControl::kKeyTrack))
? clamp01(zone.keyTrack / kKeyTrackMax)
: controlValue(r.id, play);
const bool hov = isHovered(HoverKind::kControl, r.id);
if (r.kind == ControlKind::Toggle) {
const bool seg1 = (v >= 0.5);
const Rect s0 = toggleSegmentRect(r.control, 0);
const Rect s1 = toggleSegmentRect(r.control, 1);
// The lit segment carries the primary accent (Active); the unlit segment hovers
// toward accent/hot when the whole control is under the pointer.
const InteractionState s0State =
!seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest);
const InteractionState s1State =
seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest);
fillSurface(bmp, toKitBox(s0), Role::BgCell, s0State);
fillSurface(bmp, toKitBox(s1), Role::BgCell, s1State);
kitTextCentered(bmp, s0, lab.seg0, Font::Micro, !seg1 ? Role::BgBase : Role::TextPrimary);
kitTextCentered(bmp, s1, lab.seg1, Font::Micro, seg1 ? Role::BgBase : Role::TextPrimary);
} else {
// Track groove (recessed cell) + accent filled portion up to the handle + a raised
// handle. Dragging THIS control brightens the fill/handle (accent/hot).
const bool dragging = (drag_ == DragKind::kParamSlider && dragParamId_ == r.id);
const Rect track = sliderTrackRect(r.control);
fillSurface(bmp, toKitBox(Rect{track.left, track.top + track.height() / 2 - 1,
track.right, track.top + track.height() / 2 + 1}),
Role::BgCell, InteractionState::Pressed);
const Rect handle = sliderHandleRect(r.control, v);
// Filled portion: track-left to the handle center.
const int fillW = (std::max)(0, (handle.left + handle.width() / 2) - track.left);
if (fillW > 0) {
LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, fillW, 2,
toLice(roleColor(dragging ? Role::AccentHot : Role::AccentPrimary)),
1.0f, 0);
}
const KitButtonBox knob{toKitBox(Rect{handle.left, handle.top + 2, handle.right,
handle.bottom - 2})};
drawButton(bmp, knob, nullptr,
dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover : InteractionState::Rest),
/*warn=*/false);
}
}
// The curve popup (FB2): a centered sheet over the whole Zone surface, drawn LAST —
// the same modal grammar as the Sample face.
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
}
// --- Hover resolution (Phase L, L3) ------------------------------------------
@@ -2087,6 +2029,16 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
if (tab >= 0) h = {HoverKind::kFilterTab, tab};
else if (card >= 0) h = {HoverKind::kCard, card};
}
} else if (curvePopupOpen_) { // the r11 curve popup — modal over Sample AND Zone (FB2)
const CurvePopupLayout pl = computeCurvePopup(w, hgt);
if (contains(pl.close, x, y)) {
h = {HoverKind::kPopupClose, -1};
} else if (contains(pl.curveBox, x, y)) {
// A curve node under the pointer lights accent-hot.
const int idx =
popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
}
} else if (view_ == View::kZone) {
const Rect back{w - kPad - kNavButtonWidth, 2,
w - kPad, (std::max)(2, (std::min)(kTitleHeight, hgt) - 2)};
@@ -2100,33 +2052,20 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
h = {HoverKind::kDeleteZone, -1};
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const Rect cb = zonesCurveBox(content);
if (cb.width() > 0 && contains(cb, x, y)) {
// S-VIEW-10: a curve node under the pointer lights accent-hot.
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
const int idx = z.velocityCurve.pointAtPixel(curveBoxFromRect(cb), x, y);
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
// FB2: the per-zone knob deck + the mini curve-preview button (the Sample deck's
// hover grammar — knobs light + swap label->value).
if (contains(zonesCurveButton(content), x, y)) {
h = {HoverKind::kCurveButton, -1};
} else {
const ZonePlaySeconds& play =
map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const Rect panel = zonesControlsArea(content);
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const int id = controlAtPoint(rows, x, y);
if (id >= 0) h = {HoverKind::kControl, id};
const Rect deckArea = zonesDeckArea(content);
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.left,
deckArea.top, deckArea.width());
const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
}
}
} else if (curvePopupOpen_) { // Sample view with the r11 curve popup open (modal sheet)
const CurvePopupLayout pl = computeCurvePopup(w, hgt);
if (contains(pl.close, x, y)) {
h = {HoverKind::kPopupClose, -1};
} else if (contains(pl.curveBox, x, y)) {
// A curve node under the pointer lights accent-hot (mirror of the inline box).
const PerformanceZone zone = effectiveSampleZone();
const int idx =
zone.velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
}
} else { // Sample view (home, r11 recomposition)
const PerformanceZone zone = effectiveSampleZone();
const std::vector<DeckGroupDesc> descs = deckGroupDescs(zone.play);
@@ -2242,28 +2181,8 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
// ---- Sample home (S-VIEW-2 / r11) ----
if (view_ == View::kSample) {
// r11 curve popup: while open the sheet is modal over the Sample face — it owns every
// left-click. Close click / outside-wash click dismiss (outside only when no drag is
// in flight, per the spec); in-box clicks route to the SAME curve machinery the inline
// Zone editor uses; anything else on the sheet is swallowed.
if (curvePopupOpen_) {
const CurvePopupLayout pl = computeCurvePopup(w, h);
if (contains(pl.close, x, y)) {
curvePopupOpen_ = false;
invalidate();
return;
}
if (contains(pl.curveBox, x, y)) {
const int zi = ensureSampleZone();
if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y);
return;
}
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
curvePopupOpen_ = false;
invalidate();
}
return;
}
// r11 curve popup: while open the sheet is modal — it owns every left-click.
if (handlePopupMouseDown(w, h, x, y)) return;
const PerformanceZone probeZone = effectiveSampleZone();
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(probeZone.play);
@@ -2459,7 +2378,10 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
return;
}
// ---- Zone surface (S-VIEW-8) ----
// ---- Zone surface (S-VIEW-8 / FB2) ----
// The curve popup is modal over the Zone surface too (FB2) — it owns every click while
// open, checked before every Zone affordance (incl. Back).
if (handlePopupMouseDown(w, h, x, y)) return;
const Rect back{w - kPad - kNavButtonWidth, 2,
w - kPad, (std::max)(2, (std::min)(kTitleHeight, h) - 2)};
if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; }
@@ -2566,17 +2488,47 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
}
entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry
// The param panel: a toggle segment flips at once (commit); a slider grab starts a live drag.
// Only when a zone is selected (the Zone surface has no single-capture fallback — that lives
// on the Sample face now). The S-VIEW-10 curve box is checked first — it sits inside the
// panel's right column (zonesControlsArea is the panel minus that column).
// The per-zone param surface (FB2): the knob deck + the mini curve-preview button — the
// SAME grammar and hit-test machinery as the Sample face. Only when a zone is selected
// (the Zone surface has no single-capture fallback — that lives on the Sample face).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const Rect cb = zonesCurveBox(content);
if (cb.width() > 0 && contains(cb, x, y)) {
handleCurveMouseDown(cb, selectedZone_, x, y);
if (contains(zonesCurveButton(content), x, y)) {
curvePopupOpen_ = true;
invalidate();
return;
}
handleControlClick(selectedZone_, zonesControlsArea(content), x, y);
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const Rect deckArea = zonesDeckArea(content);
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.left, deckArea.top,
deckArea.width());
const DeckHit hit = hitTestDeck(dl, x, y);
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
// Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete,
// final edit committed at once (the deck precedent). No per-instance ids reach
// here — VOICE/MASTER are not in the zone group set.
applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment);
commitAndReload();
return;
}
if (hit.kind == DeckHitKind::Knob) {
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the
// Sample deck's guard, mirrored.
const bool pitchEnvKnob =
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
if (pitchEnvKnob && !play.pitchEnv.enabled) return;
// GRAB-ANCHORED vertical drag (FA4): live-drag the map, commit on release.
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragParamZone_ = selectedZone_;
dragStartMap_ = map_;
dragKnobStartValue_ = deckControlNorm(
hit.id, map_.zones[static_cast<std::size_t>(selectedZone_)]);
dragStartX_ = x;
dragStartY_ = y;
invalidate();
}
}
}
@@ -2591,36 +2543,6 @@ void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int
}
}
bool ReaSamplerEditor::handleControlClick(int zoneIndex, const Rect& panel, int x, int y) {
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return false;
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(zoneIndex)].play;
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const int id = controlAtPoint(rows, x, y);
if (id < 0) return false;
for (const ControlRow& r : rows) {
if (r.id != id) continue;
if (r.kind == ControlKind::Toggle) {
const int seg = toggleSegmentHitTest(r.control, x, y);
if (seg >= 0) {
applyZoneControl(zoneIndex, id, 0.0, seg);
commitAndReload(); // a toggle is a discrete, final edit
}
} else {
// Grab the slider: set the value at the grab x immediately, then live-drag.
drag_ = DragKind::kParamSlider;
dragParamId_ = id;
dragParamZone_ = zoneIndex;
dragParamPanel_ = panel;
dragStartMap_ = map_;
applyZoneControl(zoneIndex, id, valueAtPoint(r.control, x), 0);
invalidate(); // live feedback; commit on WM_LBUTTONUP
}
break;
}
return true;
}
void ReaSamplerEditor::onMouseMove(int x, int y) {
if (drag_ == DragKind::kNone) return;
dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn)
@@ -2773,25 +2695,6 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
return;
}
if (drag_ == DragKind::kParamSlider) {
// S12/S15/S16 + keyTrack (Zone surface only since r11 — the Sample face's controls are
// deck knobs): re-lay the panel and map x -> value against the grabbed control's live
// track rect. Uses dragParamZone_.
const int zi = dragParamZone_;
if (zi < 0 || zi >= static_cast<int>(map_.zones.size())) return;
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(zi)].play;
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(dragParamPanel_, descs);
for (const ControlRow& r : rows) {
if (r.id == dragParamId_) {
applyZoneControl(zi, dragParamId_, valueAtPoint(r.control, x), 0);
break;
}
}
invalidate();
return;
}
// Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached
// in the Zone surface where selectedZone_ is set + the strip lives under its content area.
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
@@ -2873,20 +2776,21 @@ void ReaSamplerEditor::onMouseRDown(int x, int y) {
// r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete
// affordance; Alt-click and drag-off remain as landed alternates. Commits immediately
// through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint
// right-click a safe no-op. Right-clicks act ONLY while the popup is open (nothing else
// in the editor consumes them), and never during an in-flight left drag.
if (!processor_ || view_ != View::kSample || !curvePopupOpen_) return;
// right-click a safe no-op. Right-clicks act ONLY while the popup is open — over the
// Sample face OR the Zone surface (FB2; nothing else in the editor consumes them) —
// and never during an in-flight left drag.
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
if (drag_ != DragKind::kNone) return;
RECT rc{};
GetClientRect(childHwnd_, &rc);
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
if (!contains(pl.curveBox, x, y)) return;
// Hit-test first (read-only, via effectiveSampleZone) so a right-click that lands between
// nodes does not materialize an uncommitted zone in map_. Materialize only on an actual hit.
// Hit-test first (read-only, via popupZone) so a right-click that lands between nodes
// does not materialize an uncommitted zone in map_. Materialize only on an actual hit.
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
const int idx = effectiveSampleZone().velocityCurve.pointAtPixel(box, x, y);
const int idx = popupZone().velocityCurve.pointAtPixel(box, x, y);
if (idx < 0) return;
const int zi = ensureSampleZone();
const int zi = popupZoneIndex();
if (zi < 0) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zi)];
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
@@ -2909,8 +2813,9 @@ void ReaSamplerEditor::onMouseWheel(int delta) {
}
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
// r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face,
// where neither the note-entry fields nor the Browse search can hold focus).
// r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face
// or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search
// cannot hold focus under it).
if (curvePopupOpen_ && ch == 27) {
curvePopupOpen_ = false;
invalidate();
+71 -58
View File
@@ -33,8 +33,7 @@
#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
#include "envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit)
#include "envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam)
#include "knob_deck.h" // DeckGroupDesc / DeckLayout (r11 Sample-face knob deck, FB1)
#include "param_slider.h" // ControlRow + the FA4 radial-knob primitive (S12/S15/S16 + r11)
#include "knob_deck.h" // DeckGroupDesc / DeckLayout (r11 knob deck — Sample FB1, Zone FB2)
#include "peaks.h" // Envelope (the cached peak thumbnail)
#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
#include "velocity_curve.h" // VelocityCurve (S-VIEW-10 transfer-curve editor state)
@@ -82,14 +81,15 @@ private:
// draggable envelope breakpoint on the Sample-view hero overlay (S-VIEW-3, which node in
// envNode_); kCurveNode is a draggable velocity-curve control point in the S-VIEW-10
// transfer-curve editor (which point in curvePointIndex_); kDeckKnob is a GRAB-ANCHORED
// vertical radial-knob drag on the r11 Sample-face deck/cluster (which control in
// dragParamId_; the value at grab in dragKnobStartValue_ — no jump on grab, FA4).
// vertical radial-knob drag on an r11 knob deck — the Sample face's deck/cluster (FB1)
// or the Zone panel's per-zone deck (FB2) — (which control in dragParamId_; the value at
// grab in dragKnobStartValue_ — no jump on grab, FA4).
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
kScrollThumb, kParamSlider, kEnvNode, kCurveNode, kDeckKnob };
kScrollThumb, kEnvNode, kCurveNode, kDeckKnob };
// The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces).
// The int value is the ControlDesc id the pure param_slider hit-test returns; the shell
// maps it to the picked zone's play params. Order here is the panel's top-down stack order.
// The int value is the opaque control id the pure knob_deck hit-test returns; the shell
// maps it to the picked zone's play params (or a processor-side per-instance setter).
enum class ParamControl {
kPlayMode = 0, // Gate | Trigger toggle (S15)
kPitchEngine, // Varispeed | Preserve toggle (S16)
@@ -162,14 +162,20 @@ private:
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // S-VIEW-5 modal picker overlay
void paintZone(LICE_IBitmap* bmp, int w, int h); // S-VIEW-8 zone surface
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
void paintControls(LICE_IBitmap* bmp, const Rect& panel, const PerformanceZone& zone); // S12/S15/S16 + keyTrack (Zone surface; the Sample face uses the r11 knob deck)
// --- r11 Sample-face recomposition (FB1) ---------------------------------------
// The knob deck: the fenced task groups (AMP ENVELOPE / PITCH / PITCH ENV / VOICE /
// MASTER) drawn through the L1 kit — group fence + caption + compact caption toggles +
// radial knobs (param_slider's FA4 primitive) with label<->value swap on hover/drag.
void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone);
// The centered curve-popup sheet (wash + title + close + full-size curve editor).
// --- r11 knob-deck rendering (FB1 Sample face; FB2 Zone panel) -------------------
// The knob deck: the fenced task groups drawn through the L1 kit — group fence + caption +
// compact caption toggles + radial knobs (param_slider's FA4 primitive) with label<->value
// swap on hover/drag. `descs` picks the group set: the full Sample deck (deckGroupDescs)
// or the Zone panel's per-zone groups (zoneDeckGroupDescs). Lays out from deckArea's
// top-left; the caller anchors (Sample bottom-anchors, Zone top-anchors).
void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone,
const std::vector<DeckGroupDesc>& descs);
// The mini curve-preview button (shared by the Sample cluster + the Zone panel, FB2): a
// hairline bg/cell square tracing the zone's live curve; Active border while the popup is up.
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
// The centered curve-popup sheet (wash + title + close + full-size curve editor). Edits
// popupZone() — the Sample face's one-zone site or the Zone surface's selected zone (FB2).
void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
// Trace the S-VIEW-3 amp-envelope overlay + its draggable node handles over `waveArea` for
// `zone`'s play params, at the sample's wall-clock duration. Shared by the Sample hero band.
@@ -177,11 +183,10 @@ private:
std::int64_t frames);
// S-VIEW-10: the velocity->amp transfer-curve editor — a bordered box (X = velocity 0-127,
// Y = amp 0-1), the monotone spline traced by eval, one draggable node handle per control
// point. Shared by the Sample face (beside the hero) and the Zone param panel; all mapping /
// hit-test / clamp math lives in the pure velocity_curve module. `r` empty -> draws nothing.
// drawCaption: false suppresses the "Vel curve" corner label (the popup draws its own title).
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone,
bool drawCaption = true);
// point. Since FB2 its ONLY host is the r11 popup sheet (both surfaces summon it via the
// mini preview button); all mapping / hit-test / clamp math lives in the pure
// velocity_curve module. `r` empty -> draws nothing.
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
// Route a mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
// starts a kCurveNode drag; Alt-click on an interior node deletes it (committed at once);
@@ -189,23 +194,25 @@ private:
// `zoneIndex` must be a valid index into map_.zones (callers materialize first).
void handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y);
// Route a left-click while the curve popup is open (the popup is MODAL over the Sample
// face AND the Zone surface, FB2): Close / outside-wash dismiss, in-box clicks into the
// shared curve machinery against popupZoneIndex(), everything else on the sheet swallowed.
// Returns true when the popup consumed the click (i.e. whenever it is open).
bool handlePopupMouseDown(int w, int h, int x, int y);
void onMouseDown(int x, int y);
void onMouseMove(int x, int y);
void onMouseUp(int x, int y);
// r11: right-click — the curve popup's PRIMARY node-delete affordance (issue 3c). Only
// acts while the popup is open; a right-click on a popup curve node deletes it through
// the same commit path as Alt-click (deletePoint's endpoint guard makes endpoint
// right-clicks a safe no-op). Everything else ignores right-clicks.
// acts while the popup is open (over the Sample face OR the Zone surface, FB2); a
// right-click on a popup curve node deletes it through the same commit path as Alt-click
// (deletePoint's endpoint guard makes endpoint right-clicks a safe no-op). Everything
// else ignores right-clicks.
void onMouseRDown(int x, int y);
// Route a click at (x,y) into the param control panel `panel` editing map_.zones[zoneIndex]:
// a toggle segment commits immediately, a slider grab starts a live param-drag (kParamSlider),
// the keyTrack slider likewise (against the zone's keyTrack scalar). Returns true when the
// click landed on a control (handled). `zoneIndex` must be a valid index into map_.zones.
bool handleControlClick(int zoneIndex, const Rect& panel, int x, int y);
// Apply a slider/toggle interaction to map_.zones[zoneIndex] for control `id`: routes ordinary
// Apply a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: routes ordinary
// controls through applyControl against the zone's play struct, and kKeyTrack against the
// zone's keyTrack scalar (0..200% over the slider's 0..1). Used by both the click + drag paths.
// zone's keyTrack scalar (0..200% over the knob's 0..1). Used by both the click + drag paths.
void applyZoneControl(int zoneIndex, int id, double value, int segment);
// Resolve the interactive element under (x, y) into hover_ (Phase L, L3). Called from
@@ -296,24 +303,18 @@ private:
// (0-based) that was updated or appended, so callers can set selectedZone_.
int upsertPickedOverride(const SetupMarkers& m);
// --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------
// --- S12/S15/S16 parameter value domains (both deck surfaces) ------------------
//
// The control panel edits the SELECTED zone's ZonePlaySeconds (S15 play mode + AHDSR; S16
// pitch engine + AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap
// build resolves them to frames at the live rate. Instrument-owned (D-B), never a bank fact.
// The control descriptors the panel shows for `play`'s CURRENT play mode: the two toggles +
// the mode-relevant sliders (AHDSR for Gate, %-length/fades for Trigger) + the pitch-envelope
// controls. The pure param_slider lays these out; this only picks the set. Static (a free
// choice of set from the mode) — kept a member for the ParamControl enum access.
std::vector<ControlDesc> controlDescs(const ZonePlaySeconds& play) const;
// The deck knobs edit a zone's ZonePlaySeconds (S15 play mode + AHDSR; S16 pitch engine +
// AD pitch envelope). Wall-clock times are SECONDS (rate-free); the keymap build resolves
// them to frames at the live rate. Instrument-owned (D-B), never a bank fact.
// The normalized [0,1] display value for control `id` given `play` (the shell's domain
// mapping: seconds->0..1 over a fixed seconds ceiling, sustain 0..1 as-is, %-length/fade
// frames->0..1, semitone depth centered at 0.5).
double controlValue(int id, const ZonePlaySeconds& play) const;
// Apply a committed control interaction to `play`: a slider's normalized `value` (mapped back
// Apply a committed control interaction to `play`: a knob's normalized `value` (mapped back
// into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place.
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
@@ -364,18 +365,31 @@ private:
// needs a concrete zone to write. Returns -1 if selectedId_ is empty.
int ensureSampleZone();
// --- r11 knob-deck plumbing (FB1) ---------------------------------------------
// --- Curve-popup target resolution (r11 FB1 + FB2) -----------------------------
//
// The deck is the r11 replacement for the Sample face's slider control strip: the pure
// knob_deck module lays out the fenced groups, param_slider's FA4 primitive owns the
// value<->needle map, and these members own the control-id <-> value binding (the same
// division of labor paintControls/applyControl use for the Zone surface's sliders).
// The popup edits ONE zone per open: the Zone surface's SELECTED zone (FB2) or the Sample
// face's picked one-zone site. popupZone is the read-only resolve (paint/hover/right-click
// hit-test); popupZoneIndex is the edit target — it materializes the Sample-face zone via
// ensureSampleZone but NEVER materializes on the Zone surface (the button only shows for
// an explicit selection). Returns -1 when there is no valid target (callers guard).
PerformanceZone popupZone() const;
int popupZoneIndex();
// The deck group descriptors for the current mode: AMP ENVELOPE (Gate: A/H/D/S/R;
// Trigger: Fade In / Length % / Fade Out + two RESERVED blanks so a mode flip never
// reflows the neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth) /
// VOICE (Voices knob + Poly|Mono caption toggle + Retrig|Legato row toggle) / MASTER
// (the FB1 post-mixer Gain knob).
// --- r11 knob-deck plumbing (FB1 Sample face; FB2 Zone panel) -------------------
//
// The deck is the r11 replacement for the slider control strips on BOTH surfaces: the pure
// knob_deck module lays out the fenced groups, param_slider's FA4 primitive owns the
// value<->needle map, and these members own the control-id <-> value binding.
// The PER-ZONE deck groups (FB2 — the set both surfaces share): AMP ENVELOPE (Gate:
// A/H/D/S/R; Trigger: Fade In / Length % / Fade Out + two RESERVED blanks so a mode flip
// never reflows the neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth).
// The Zone panel renders exactly these — per-instance state stays off it.
std::vector<DeckGroupDesc> zoneDeckGroupDescs(const ZonePlaySeconds& play) const;
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE (Voices
// knob + Poly|Mono caption toggle + Retrig|Legato row toggle) and MASTER (the FB1
// post-mixer Gain knob) groups.
std::vector<DeckGroupDesc> deckGroupDescs(const ZonePlaySeconds& play) const;
// The normalized [0,1] value a deck knob shows for `zone` — zone params route through
@@ -480,12 +494,10 @@ private:
std::int64_t dragStartFrame_ = 0; // zone startPoint at grab time (0 if absent); for env-node drag
// S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts
// from it). S12/S15/S16 param-slider drag: which control id + the panel it lives in (the
// shell re-lays the panel each move to map x->value against the live control rect).
// from it). kDeckKnob drag: which control id + the zone it edits.
int dragStartScrollOffset_ = 0;
int dragParamId_ = -1; // ControlDesc id under a kParamSlider drag; -2 = preview-vel knob
int dragParamZone_ = -1; // the zone index a kParamSlider drag edits (Sample or Zone face)
Rect dragParamPanel_{};
int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob
int dragParamZone_ = -1; // the zone index a kDeckKnob drag edits; -1 = processor-side
// S-VIEW-3 envelope-node drag: which node is grabbed + the AmpEnvelope snapshotted at grab
// (so the pixel delta is absolute, per envelope_edit's grabEnv contract). The overlay rect +
@@ -506,9 +518,10 @@ private:
// the vertical pixel delta from this anchor, so a grab never jumps the value (FA4).
double dragKnobStartValue_ = 0.0;
// r11 curve popup (FB1): open flag — editor-local, never persisted. The popup edits the
// picked capture's one-zone site (effectiveSampleZone / ensureSampleZone), re-resolved
// each paint so a sync-tick refresh mid-open stays coherent.
// r11 curve popup (FB1 + FB2): open flag — editor-local, never persisted. The popup edits
// popupZone() — the picked capture's one-zone site on the Sample face, the SELECTED zone
// on the Zone surface — re-resolved each paint so a sync-tick refresh mid-open stays
// coherent (a refresh that drops the target closes it; see refreshFromBank).
bool curvePopupOpen_ = false;
// --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------