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();