S12: editor scale + S15/S16 control surfaces
Pure browser_scroll/note_entry/param_slider modules (+ CTest) for scroll, type-to-filter search, numeric note entry, and the AHDSR/Trigger/pitch-engine/ pitch-env control panel. Editor shell draws + routes through them; params edit the selected zone's ZonePlayParams via commitAndReload.
This commit is contained in:
+467
-20
@@ -10,11 +10,14 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry
|
||||
#include "capture_browser.h"
|
||||
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "editor_geometry.h" // Rect, contains
|
||||
#include "ext_keys.h"
|
||||
#include "keyboard_strip.h"
|
||||
#include "note_entry.h" // S12 direct numeric note-entry parse
|
||||
#include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping
|
||||
#include "peaks.h" // computeEnvelope
|
||||
#include "reaper_bridge.h"
|
||||
#include "reasampler_processor.h"
|
||||
@@ -175,11 +178,18 @@ void ReaSamplerEditor::refreshFromBank() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::rebuildVisible() {
|
||||
// S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search
|
||||
// narrows the survivors by name substring (nameMatchesQuery — empty query is the identity).
|
||||
visible_.clear();
|
||||
for (const SampleChoice& s : samples_) {
|
||||
if (activeFilterBankId_.empty() || s.bankId == activeFilterBankId_)
|
||||
visible_.push_back(s);
|
||||
const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_;
|
||||
if (!inBank) continue;
|
||||
const std::string& name = s.displayName.empty() ? s.id : s.displayName;
|
||||
if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s);
|
||||
}
|
||||
// NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel
|
||||
// height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it
|
||||
// must not reset the user's scroll here.
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -287,6 +297,102 @@ void ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is
|
||||
// engine-free and maps only 0..1). Time sliders span [0, max] frames at a nominal rate so a
|
||||
// full-throw reaches a musically generous ceiling; the exact wall-clock is DAW-verified. These
|
||||
// are build-time residuals (one place to retune), not persisted.
|
||||
constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling
|
||||
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
|
||||
|
||||
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||
} // namespace
|
||||
|
||||
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlayParams& 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});
|
||||
return out;
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const {
|
||||
const auto framesToNorm = [](std::int64_t f) {
|
||||
return clamp01(static_cast<double>(f) / kEnvTimeMaxFrames);
|
||||
};
|
||||
switch (static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
|
||||
case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
|
||||
case ParamControl::kAttack: return framesToNorm(play.adsr.attackFrames);
|
||||
case ParamControl::kHold: return framesToNorm(play.adsr.holdFrames);
|
||||
case ParamControl::kDecay: return framesToNorm(play.adsr.decayFrames);
|
||||
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
|
||||
case ParamControl::kRelease: return framesToNorm(play.adsr.releaseFrames);
|
||||
case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction);
|
||||
case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames);
|
||||
case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames);
|
||||
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
|
||||
case ParamControl::kPitchEnvAttack:return framesToNorm(play.pitchEnv.attackFrames);
|
||||
case ParamControl::kPitchEnvDecay: return framesToNorm(play.pitchEnv.decayFrames);
|
||||
case ParamControl::kPitchEnvDepth:
|
||||
// Signed depth centered at 0.5 (0.5 == 0 semitones).
|
||||
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
||||
default: return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::applyControl(int id, ZonePlayParams& play, double value,
|
||||
int segment) const {
|
||||
const auto normToFrames = [](double v) {
|
||||
return static_cast<std::int64_t>(clamp01(v) * kEnvTimeMaxFrames + 0.5);
|
||||
};
|
||||
switch (static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kPlayMode:
|
||||
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
break;
|
||||
case ParamControl::kPitchEngine:
|
||||
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
break;
|
||||
case ParamControl::kAttack: play.adsr.attackFrames = normToFrames(value); break;
|
||||
case ParamControl::kHold: play.adsr.holdFrames = normToFrames(value); break;
|
||||
case ParamControl::kDecay: play.adsr.decayFrames = normToFrames(value); break;
|
||||
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
|
||||
case ParamControl::kRelease: play.adsr.releaseFrames = normToFrames(value); break;
|
||||
case ParamControl::kTrigLength:
|
||||
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
|
||||
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
||||
break;
|
||||
case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break;
|
||||
case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break;
|
||||
case ParamControl::kPitchEnvEnable:
|
||||
play.pitchEnv.enabled = (segment == 1);
|
||||
break;
|
||||
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackFrames = normToFrames(value); break;
|
||||
case ParamControl::kPitchEnvDecay: play.pitchEnv.decayFrames = normToFrames(value); break;
|
||||
case ParamControl::kPitchEnvDepth:
|
||||
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
|
||||
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert,
|
||||
// mirror of the root-marker path): a full-keyboard zone carrying the override. This plays
|
||||
@@ -492,6 +598,38 @@ Rect zonesStripArea(const EditorBands& bands) {
|
||||
stripTop + kStripBandHeight};
|
||||
}
|
||||
|
||||
// The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the
|
||||
// sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw +
|
||||
// hit-test use this single formula so they never drift.
|
||||
Rect noteEntryFieldsArea(const EditorBands& bands) {
|
||||
const Rect strip = Rect{bands.content.left + 8,
|
||||
bands.content.top + 4 + 20 + 12 + kStripBandHeight,
|
||||
bands.content.right - 8, 0};
|
||||
const int top = strip.top + 8; // legendTop (== zonesStripArea.bottom + 8)
|
||||
return Rect{bands.content.left + 8 + 128, top, bands.content.right - 8, top + 18};
|
||||
}
|
||||
|
||||
// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal
|
||||
// segments left-to-right. An out-of-range index yields an empty rect.
|
||||
Rect noteEntryFieldRect(const Rect& fields, int f) {
|
||||
if (f < 0 || f > 2 || fields.width() <= 0) return Rect{};
|
||||
const int segW = fields.width() / 3;
|
||||
const int left = fields.left + f * segW + (f > 0 ? 4 : 0); // small inter-field gap
|
||||
const int right = (f == 2) ? fields.right : fields.left + (f + 1) * segW;
|
||||
return Rect{left, fields.top, right, fields.bottom};
|
||||
}
|
||||
|
||||
// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip +
|
||||
// the one-line selected-zone legend, running to the content bottom. `bands.content` is the
|
||||
// Zones mode-content area. Both draw + hit-test use this single formula so they never drift.
|
||||
Rect zonesControlPanel(const EditorBands& bands) {
|
||||
constexpr int pad = 8;
|
||||
const Rect strip = zonesStripArea(bands);
|
||||
const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap
|
||||
return Rect{bands.content.left + pad, panelTop, bands.content.right - pad,
|
||||
bands.content.bottom - 4};
|
||||
}
|
||||
|
||||
// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's
|
||||
// header row (same y as the sample-name header, so it reads as "this capture's output mode").
|
||||
// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide,
|
||||
@@ -572,13 +710,31 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
|
||||
const bool havePick = !selectedId_.empty();
|
||||
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
|
||||
: bands.content.bottom;
|
||||
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
|
||||
const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
|
||||
|
||||
// S12: reserve a type-to-filter search box at the top of the browser area; the tabs + grid
|
||||
// sit below it. The search box spans the browser width.
|
||||
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
|
||||
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
|
||||
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
|
||||
LICE_FillRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width(), searchAbs.height(),
|
||||
searchFocused_ ? kColTabActiveBg : kColTabBg, 1.0f, 0);
|
||||
{
|
||||
std::string sb = searchQuery_.empty()
|
||||
? std::string("Search captures...")
|
||||
: ("Search: " + searchQuery_ + (searchFocused_ ? "_" : ""));
|
||||
Rect sbText{searchAbs.left + 6, searchAbs.top, searchAbs.right - 6, searchAbs.bottom};
|
||||
drawText(bmp, sbText, sb.c_str(), searchQuery_.empty() ? kRgbDim : kRgbText);
|
||||
}
|
||||
|
||||
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
|
||||
|
||||
// The browser tabs + card grid, laid out by the pure module over the browser sub-area.
|
||||
// capture_browser lays out from (0,0); offset the draw by browserArea's origin.
|
||||
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
|
||||
const int ox = browserArea.left;
|
||||
const int oy = browserArea.top;
|
||||
scrollOffset_ = clampScrollOffset(bl, static_cast<int>(visible_.size()), scrollOffset_);
|
||||
|
||||
// Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights.
|
||||
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||
@@ -593,16 +749,24 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
|
||||
drawTextCentered(bmp, t, label.c_str(), kRgbText);
|
||||
}
|
||||
|
||||
// Cards: one per visible sample. Clip at the browser area bottom (scroll is S12).
|
||||
// Cards: only the S12 visible window at the current scroll offset (a bank longer than the
|
||||
// panel is reachable by wheel/thumb drag). scrolledCardCellRect shifts each cell up by the
|
||||
// offset; we clip to the grid region so a partially-scrolled row is trimmed at the edges.
|
||||
const int bins = thumbBins(bl);
|
||||
for (int i = 0; i < static_cast<int>(visible_.size()); ++i) {
|
||||
const int cardCount = static_cast<int>(visible_.size());
|
||||
const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_);
|
||||
for (int i = vr.first; i < vr.last; ++i) {
|
||||
// The scrolled CELL, then the same gutter/thumbnail/label insets the pure module derives,
|
||||
// shifted by the scroll offset (they share the cell's top, so subtract the offset).
|
||||
Rect content = cardContentRect(bl, i);
|
||||
if (content.top + oy >= browserArea.bottom) break; // past the visible grid
|
||||
Rect thumb = cardThumbnailRect(bl, i);
|
||||
Rect labelR = cardLabelRect(bl, i);
|
||||
content = Rect{content.left + ox, content.top + oy, content.right + ox, content.bottom + oy};
|
||||
thumb = Rect{thumb.left + ox, thumb.top + oy, thumb.right + ox, thumb.bottom + oy};
|
||||
labelR = Rect{labelR.left + ox, labelR.top + oy, labelR.right + ox, labelR.bottom + oy};
|
||||
content = Rect{content.left + ox, content.top + oy - scrollOffset_,
|
||||
content.right + ox, content.bottom + oy - scrollOffset_};
|
||||
thumb = Rect{thumb.left + ox, thumb.top + oy - scrollOffset_,
|
||||
thumb.right + ox, thumb.bottom + oy - scrollOffset_};
|
||||
labelR = Rect{labelR.left + ox, labelR.top + oy - scrollOffset_,
|
||||
labelR.right + ox, labelR.bottom + oy - scrollOffset_};
|
||||
|
||||
const SampleChoice& s = visible_[static_cast<std::size_t>(i)];
|
||||
const bool sel = (s.id == selectedId_);
|
||||
@@ -624,6 +788,17 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
|
||||
drawText(bmp, badgeR, badge.c_str(), kRgbDim);
|
||||
}
|
||||
|
||||
// S12 scrollbar: a thumb in the grid's right-edge gutter, sized/positioned by the pure
|
||||
// module (empty when the content fits — the shell simply draws nothing then). Offset by the
|
||||
// browser origin like every other card rect.
|
||||
{
|
||||
const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_);
|
||||
if (thumb.height() > 0) {
|
||||
LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(),
|
||||
kColRootMarker, 0.8f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (havePick) {
|
||||
paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom});
|
||||
} else if (visible_.empty()) {
|
||||
@@ -769,18 +944,100 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
|
||||
sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0);
|
||||
}
|
||||
|
||||
// A one-line legend of the selected zone below the strip.
|
||||
Rect infoR{stripArea.left, stripArea.bottom + 8, stripArea.right, stripArea.bottom + 26};
|
||||
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
|
||||
// entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it
|
||||
// (entryField_) and typed text commits via parseNoteEntry on Enter.
|
||||
const int legendTop = stripArea.bottom + 8;
|
||||
Rect infoR{stripArea.left, legendTop, stripArea.right, legendTop + 18};
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) +
|
||||
" - " + noteLabel(z.highNote) + " root " +
|
||||
(z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)"));
|
||||
drawText(bmp, infoR, info.c_str(), kRgbText);
|
||||
drawText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom},
|
||||
sampleLabel(samples_, z.sampleId).c_str(), kRgbText);
|
||||
// Three fields laid out left-to-right after the sample label.
|
||||
const Rect fields = noteEntryFieldsArea(bands);
|
||||
const char* names[3] = {"Low", "High", "Root"};
|
||||
const std::string vals[3] = {
|
||||
noteLabel(z.lowNote), noteLabel(z.highNote),
|
||||
z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")};
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
const Rect fr = noteEntryFieldRect(fields, f);
|
||||
const bool editing = (entryField_ == f);
|
||||
LICE_FillRect(bmp, fr.left, fr.top, fr.width(), fr.height(),
|
||||
editing ? kColTabActiveBg : kColCardBg, 1.0f, 0);
|
||||
LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1,
|
||||
kColCardBorder, 1.0f, 0);
|
||||
std::string cap = std::string(names[f]) + ": " +
|
||||
(editing ? (entryText_ + "_") : vals[f]);
|
||||
drawText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(), kRgbText);
|
||||
}
|
||||
} else if (map_.zones.empty()) {
|
||||
drawText(bmp, infoR,
|
||||
"No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim);
|
||||
}
|
||||
|
||||
// The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger +
|
||||
// pitch engine + AD pitch envelope). Only when a zone is selected.
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
paintControls(bmp, zonesControlPanel(computeBands(w, h)));
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
const ZonePlayParams play = map_.zones[static_cast<std::size_t>(selectedZone_)].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", "", ""};
|
||||
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));
|
||||
drawText(bmp, r.label, lab.label, kRgbDim);
|
||||
const double v = controlValue(r.id, play);
|
||||
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);
|
||||
LICE_FillRect(bmp, s0.left, s0.top, s0.width(), s0.height(),
|
||||
seg1 ? kColTabBg : kColTabActiveBg, 1.0f, 0);
|
||||
LICE_FillRect(bmp, s1.left, s1.top, s1.width(), s1.height(),
|
||||
seg1 ? kColTabActiveBg : kColTabBg, 1.0f, 0);
|
||||
drawTextCentered(bmp, s0, lab.seg0, kRgbText);
|
||||
drawTextCentered(bmp, s1, lab.seg1, kRgbText);
|
||||
} else {
|
||||
const Rect track = sliderTrackRect(r.control);
|
||||
LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, track.width(), 2,
|
||||
kColStripKey, 1.0f, 0);
|
||||
const Rect handle = sliderHandleRect(r.control, v);
|
||||
LICE_FillRect(bmp, handle.left, handle.top + 2, handle.width(), handle.height() - 4,
|
||||
kColRootMarker, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Input: the drag-state machine -------------------------------------------
|
||||
@@ -801,7 +1058,20 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
const bool havePick = !selectedId_.empty();
|
||||
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
|
||||
: bands.content.bottom;
|
||||
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
|
||||
const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
|
||||
|
||||
// S12 search box (mirror of paintBrowser): a click focuses it; the browser sits below.
|
||||
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
|
||||
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
|
||||
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
|
||||
if (contains(searchAbs, x, y)) {
|
||||
searchFocused_ = true;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
searchFocused_ = false; // any other browser click defocuses the search box
|
||||
|
||||
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
|
||||
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
|
||||
const int bx = x - browserArea.left;
|
||||
const int by = y - browserArea.top;
|
||||
@@ -816,8 +1086,20 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Cards: pick a capture -> load it (this is the whole time-to-first-note gesture).
|
||||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by);
|
||||
// S12 scrollbar thumb: grab to drag-scroll (checked before cards — the thumb overlays the
|
||||
// grid's right gutter). scrollThumbRect is empty when the content fits, so this is inert then.
|
||||
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
|
||||
if (thumb.height() > 0 &&
|
||||
contains(Rect{thumb.left + browserArea.left, thumb.top + browserArea.top,
|
||||
thumb.right + browserArea.left, thumb.bottom + browserArea.top}, x, y)) {
|
||||
drag_ = DragKind::kScrollThumb;
|
||||
dragStartY_ = y;
|
||||
dragStartScrollOffset_ = scrollOffset_;
|
||||
return;
|
||||
}
|
||||
// Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). The
|
||||
// hit-test adds the scroll offset back so a scrolled card maps to the right index.
|
||||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (card >= 0) {
|
||||
selectedId_ = visible_[static_cast<std::size_t>(card)].id;
|
||||
commitAndReload(); // publishes the pick + reloads; process() plays it repitched
|
||||
@@ -958,6 +1240,54 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
|
||||
commitAndReload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
|
||||
// zone is selected. entryText_ starts empty (the user types the full value).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const Rect fields = noteEntryFieldsArea(bands);
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
if (contains(noteEntryFieldRect(fields, f), x, y)) {
|
||||
entryField_ = f;
|
||||
entryText_.clear();
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
entryField_ = -1; // a click elsewhere in the Zones view cancels an in-progress entry
|
||||
|
||||
// The S12/S15/S16 parameter panel: a toggle segment flips at once (commit); a slider grab
|
||||
// starts a live drag (commit on release). Only when a zone is selected.
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
const Rect panel = zonesControlPanel(bands);
|
||||
const std::vector<ControlDesc> descs = controlDescs(z.play);
|
||||
const std::vector<ControlRow> rows = layoutControls(panel, descs);
|
||||
const int id = controlAtPoint(rows, x, y);
|
||||
if (id >= 0) {
|
||||
// Find the row to know its kind + control rect.
|
||||
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) {
|
||||
applyControl(id, z.play, 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;
|
||||
dragParamPanel_ = panel;
|
||||
dragStartMap_ = map_;
|
||||
applyControl(id, z.play, valueAtPoint(r.control, x), 0);
|
||||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1044,6 +1374,40 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kScrollThumb) {
|
||||
// S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card
|
||||
// window recomputes at paint from scrollOffset_. The browser sub-area matches paintBrowser
|
||||
// when a capture is picked (the setup band takes the bottom).
|
||||
const int dyThumb = y - dragStartY_;
|
||||
const bool havePick = !selectedId_.empty();
|
||||
const int setupTop = havePick
|
||||
? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
|
||||
: bands.content.bottom;
|
||||
const BrowserLayout bl = layoutBrowser(bands.content.width(), setupTop - bands.content.top);
|
||||
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
|
||||
dragStartScrollOffset_, dyThumb);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kParamSlider) {
|
||||
// S12/S15/S16: re-lay the panel and map x -> value against the grabbed control's live
|
||||
// track rect (the panel geometry is stable during the drag; re-laying keeps the value
|
||||
// mapping exact even if a mode toggle changed the row set — it did not, mid-drag).
|
||||
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
const std::vector<ControlDesc> descs = controlDescs(z.play);
|
||||
const std::vector<ControlRow> rows = layoutControls(dragParamPanel_, descs);
|
||||
for (const ControlRow& r : rows) {
|
||||
if (r.id == dragParamId_) {
|
||||
applyControl(dragParamId_, z.play, valueAtPoint(r.control, x), 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Zone edits: recompute the grabbed field(s) against the pure resolver, live.
|
||||
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
const Rect stripArea = zonesStripArea(bands);
|
||||
@@ -1068,11 +1432,79 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
|
||||
void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
const DragKind kind = drag_;
|
||||
drag_ = DragKind::kNone;
|
||||
// One coherent edit lands on release: publish the in-flight map + reload off-thread.
|
||||
// A scrollbar drag is transient UI (no map change) — repaint but do NOT reload. Every other
|
||||
// drag is a coherent map edit: publish the in-flight map + reload off-thread on release.
|
||||
if (kind == DragKind::kScrollThumb) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
commitAndReload();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||
// S12 browser scroll (only in the browser view). One wheel notch (WHEEL_DELTA==120) scrolls
|
||||
// roughly one card row; the offset is clamped at paint (the layout/panel height is known
|
||||
// there). A positive delta (wheel up) scrolls toward the top (smaller offset).
|
||||
if (view_ != View::kBrowser) return;
|
||||
const int rows = delta / 120;
|
||||
if (rows == 0) return;
|
||||
scrollOffset_ -= rows * kBrowserCardHeight;
|
||||
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
// S12 numeric note-entry (Zones view): a focused low/high/root field accumulates keystrokes
|
||||
// and commits via parseNoteEntry on Enter. Handled before the search box (a field, when
|
||||
// focused, owns the keystrokes).
|
||||
if (view_ == View::kZones && entryField_ >= 0) {
|
||||
if (ch == 13) { // Enter: parse + commit
|
||||
if (auto note = parseNoteEntry(entryText_)) {
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote);
|
||||
else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote);
|
||||
else z.rootOverride = *note;
|
||||
commitAndReload();
|
||||
}
|
||||
}
|
||||
entryField_ = -1;
|
||||
entryText_.clear();
|
||||
invalidate();
|
||||
} else if (ch == 27) { // Escape cancels
|
||||
entryField_ = -1;
|
||||
entryText_.clear();
|
||||
invalidate();
|
||||
} else if (ch == 8) { // backspace
|
||||
if (!entryText_.empty()) entryText_.pop_back();
|
||||
invalidate();
|
||||
} else if (ch >= 32 && ch < 127) {
|
||||
entryText_.push_back(static_cast<char>(ch));
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
|
||||
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search).
|
||||
if (view_ != View::kBrowser || !searchFocused_) return;
|
||||
if (ch == 8) { // backspace
|
||||
if (!searchQuery_.empty()) searchQuery_.pop_back();
|
||||
} else if (ch == 27) { // escape clears + defocuses
|
||||
searchQuery_.clear();
|
||||
searchFocused_ = false;
|
||||
} else if (ch >= 32 && ch < 127) {
|
||||
searchQuery_.push_back(static_cast<char>(ch));
|
||||
} else {
|
||||
return; // ignore other control chars
|
||||
}
|
||||
scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list
|
||||
rebuildVisible();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
LPARAM lParam) {
|
||||
auto* self =
|
||||
@@ -1088,12 +1520,24 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
case WM_LBUTTONDOWN:
|
||||
if (self) {
|
||||
SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child
|
||||
SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12)
|
||||
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||
}
|
||||
return 0;
|
||||
case WM_MOUSEMOVE:
|
||||
if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||
return 0;
|
||||
case WM_MOUSEWHEEL:
|
||||
// S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
|
||||
if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam));
|
||||
return 0;
|
||||
case WM_CHAR:
|
||||
// S12 type-to-filter search keystrokes (only acted on when the search box is focused).
|
||||
if (self) self->onSearchChar(static_cast<unsigned int>(wParam));
|
||||
return 0;
|
||||
case WM_GETDLGCODE:
|
||||
// Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search).
|
||||
return DLGC_WANTCHARS | DLGC_WANTARROWS;
|
||||
case WM_LBUTTONUP:
|
||||
if (self) {
|
||||
self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||||
@@ -1106,7 +1550,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
// the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
|
||||
// Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler.
|
||||
if (self && self->drag_ != DragKind::kNone) {
|
||||
self->map_ = self->dragStartMap_;
|
||||
// A scrollbar drag is transient (no map mutation + dragStartMap_ was not
|
||||
// snapshotted for it) — reset the drag state only, never touch map_. Every
|
||||
// map-editing drag rolls its live mutation back to the pre-grab snapshot.
|
||||
if (self->drag_ != DragKind::kScrollThumb) self->map_ = self->dragStartMap_;
|
||||
self->drag_ = DragKind::kNone;
|
||||
self->invalidate();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user