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:
2026-07-27 01:09:19 -04:00
parent ddc2ec1e75
commit 4f30124ef7
13 changed files with 1667 additions and 31 deletions
+158
View File
@@ -0,0 +1,158 @@
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10
// capture_browser. No host types; only the shared Rect + BrowserLayout.
#include "browser_scroll.h"
#include <algorithm>
#include <cctype>
namespace reasampler::vst {
namespace {
// The minimum thumb height so a very long bank still yields a grabbable thumb.
constexpr int kMinThumbHeight = 20;
char asciiLower(char c) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
} // namespace
int scrollContentHeight(const BrowserLayout& layout, int cardCount) {
if (cardCount <= 0) return 0;
const int columns = (std::max)(1, layout.columns);
const int rows = (cardCount + columns - 1) / columns; // ceil
return rows * kBrowserCardHeight;
}
int scrollMaxOffset(const BrowserLayout& layout, int cardCount) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height());
return (std::max)(0, content - gridH);
}
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset) {
const int maxOff = scrollMaxOffset(layout, cardCount);
if (proposedOffset < 0) return 0;
if (proposedOffset > maxOff) return maxOff;
return proposedOffset;
}
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset) {
VisibleRange vr;
if (cardCount <= 0) return vr;
const int columns = (std::max)(1, layout.columns);
const int gridH = (std::max)(0, layout.grid.height());
if (gridH <= 0 || kBrowserCardHeight <= 0) {
vr.first = 0;
vr.last = 0;
return vr;
}
if (offset < 0) offset = 0;
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row
// partially scrolled off the top still draws (its lower part is visible).
const int firstRow = offset / kBrowserCardHeight;
// Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for
// the exclusive end. A row straddling the bottom edge still draws.
const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
int first = firstRow * columns;
int last = lastRow * columns;
if (first > cardCount) first = cardCount;
if (last > cardCount) last = cardCount;
if (last < first) last = first;
vr.first = first;
vr.last = last;
return vr;
}
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) {
Rect r = cardCellRect(layout, index);
if (r.right <= r.left && r.bottom <= r.top) return r; // empty (negative index) stays empty
return Rect{r.left, r.top - offset, r.right, r.bottom - offset};
}
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height());
if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar
const int maxOff = content - gridH;
if (offset < 0) offset = 0;
if (offset > maxOff) offset = maxOff;
const int trackRight = layout.grid.right;
const int trackLeft = trackRight - kScrollbarWidth;
const int trackTop = layout.grid.top;
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but
// never taller than the track.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH);
// Thumb top proportional to the offset over the movable track span.
const int trackSpan = gridH - thumbH; // >= 0
int thumbTop = trackTop;
if (maxOff > 0 && trackSpan > 0) {
thumbTop = trackTop + static_cast<int>(
static_cast<long long>(offset) * trackSpan / maxOff);
}
return Rect{trackLeft, thumbTop, trackRight, thumbTop + thumbH};
}
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset,
int dyPixels) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height());
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
// Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH);
const int trackSpan = gridH - thumbH;
if (trackSpan <= 0) return clampScrollOffset(layout, cardCount, startOffset);
const int maxOff = content - gridH;
// A 1px thumb move covers maxOff/trackSpan content px. Round to nearest for symmetry.
const long long deltaOffset =
(static_cast<long long>(dyPixels) * maxOff + (dyPixels >= 0 ? trackSpan / 2 : -trackSpan / 2)) /
trackSpan;
const long long proposed = static_cast<long long>(startOffset) + deltaOffset;
if (proposed < 0) return 0;
if (proposed > maxOff) return maxOff;
return static_cast<int>(proposed);
}
Rect searchBoxRect(int w) {
if (w <= 0) return Rect{};
return Rect{0, 0, w, kSearchBoxHeight};
}
bool nameMatchesQuery(const std::string& name, const std::string& query) {
if (query.empty()) return true;
if (query.size() > name.size()) return false;
// Case-insensitive substring scan (ASCII fold). Small strings; a naive scan is fine.
for (std::size_t i = 0; i + query.size() <= name.size(); ++i) {
bool match = true;
for (std::size_t j = 0; j < query.size(); ++j) {
if (asciiLower(name[i + j]) != asciiLower(query[j])) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query) {
std::vector<int> out;
out.reserve(names.size());
for (int i = 0; i < static_cast<int>(names.size()); ++i) {
if (nameMatchesQuery(names[static_cast<std::size_t>(i)], query))
out.push_back(i);
}
return out;
}
} // namespace reasampler::vst
+107
View File
@@ -0,0 +1,107 @@
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the
// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes
// into these functions.
//
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry:
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a
// wheel tick or a thumb drag reaches every card; and
// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards,
// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first,
// then this search narrows within it) + the search-box rect the shell draws the field in.
//
// It holds NO card data and draws nothing — it knows only the browser layout (from
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
#pragma once
#include <string>
#include <vector>
#include "capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect
namespace reasampler::vst {
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so
// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is
// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter.
inline constexpr int kScrollbarWidth = 10;
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a
// thin band spanning the browser width). Exposed so the shell reserves the band and tests
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
inline constexpr int kSearchBoxHeight = 22;
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height.
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
int scrollContentHeight(const BrowserLayout& layout, int cardCount);
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0.
// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp
// ceiling for every offset the shell tracks.
int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
// or above the first. Pure.
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset);
// The half-open range of card INDICES [first, last) at least partially visible in the grid at
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every
// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row
// yields an empty range (first==last==cardCount). Pure.
struct VisibleRange {
int first = 0; // first card index drawn (inclusive)
int last = 0; // one past the last card index drawn (exclusive)
};
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset);
// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to
// the visible fraction (grid height / content height) and positioned proportional to the
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell
// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very
// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against.
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset);
// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to
// content pixels (a 1px thumb move covers content/track px of content). A degenerate track /
// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map.
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels);
// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser
// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width;
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
Rect searchBoxRect(int w);
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query
// matches everything (the no-filter identity). Matching is ASCII case-folded (the display
// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only
// text). Pure — the single match predicate the shell's search narrow is built from.
bool nameMatchesQuery(const std::string& name, const std::string& query);
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving
// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list
// and applies the bank filter FIRST, then feeds the surviving display names here (search
// narrows within the bank). Pure.
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query);
} // namespace reasampler::vst
+113
View File
@@ -0,0 +1,113 @@
// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry.
#include "note_entry.h"
#include <algorithm>
#include <cctype>
namespace reasampler::vst {
namespace {
char asciiUpper(char c) {
return static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
std::string trim(const std::string& s) {
std::size_t a = 0;
std::size_t b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
int clampNote(long long n) {
if (n < 0) return 0;
if (n > 127) return 127;
return static_cast<int>(n);
}
// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter.
int letterSemitone(char up) {
switch (up) {
case 'C': return 0;
case 'D': return 2;
case 'E': return 4;
case 'F': return 5;
case 'G': return 7;
case 'A': return 9;
case 'B': return 11;
default: return -1;
}
}
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4
// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name.
std::optional<int> parseNoteName(const std::string& s) {
if (s.empty()) return std::nullopt;
std::size_t i = 0;
const int base = letterSemitone(asciiUpper(s[i]));
if (base < 0) return std::nullopt; // not a letter -> not a note name
++i;
int semitone = base;
// Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs).
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
// A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is
// an accidental. '#' raises, 'b'/'B' lowers.
if (s[i] == '#') ++semitone;
else --semitone;
++i;
}
// The octave: an optional sign then digits, running to the end.
if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous)
bool neg = false;
if (s[i] == '+' || s[i] == '-') {
neg = (s[i] == '-');
++i;
}
if (i >= s.size()) return std::nullopt;
int octave = 0;
bool anyDigit = false;
for (; i < s.size(); ++i) {
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
octave = octave * 10 + (s[i] - '0');
anyDigit = true;
}
if (!anyDigit) return std::nullopt;
if (neg) octave = -octave;
// MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60).
const long long note = static_cast<long long>(octave + 1) * 12 + semitone;
return clampNote(note);
}
std::optional<int> parseInteger(const std::string& s) {
if (s.empty()) return std::nullopt;
std::size_t i = 0;
bool neg = false;
if (s[i] == '+' || s[i] == '-') {
neg = (s[i] == '-');
++i;
}
if (i >= s.size()) return std::nullopt;
long long v = 0;
for (; i < s.size(); ++i) {
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
v = v * 10 + (s[i] - '0');
if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway
}
if (neg) v = -v;
return clampNote(v);
}
} // namespace
std::optional<int> parseNoteEntry(const std::string& text) {
const std::string s = trim(text);
if (s.empty()) return std::nullopt;
// Try a plain integer first (the common MIDI-number case); fall back to a note name.
if (std::isdigit(static_cast<unsigned char>(s[0])) || s[0] == '+' ||
(s[0] == '-' && s.size() > 1 && std::isdigit(static_cast<unsigned char>(s[1])))) {
if (auto n = parseInteger(s)) return n;
}
return parseNoteName(s);
}
} // namespace reasampler::vst
+33
View File
@@ -0,0 +1,33 @@
// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's
// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The
// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit-
// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control
// or a LICE text-entry idiom) and feeds the committed string here on Enter.
//
// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't
// hit a precise note reliably. This adds a typed field: the user clicks the field, types a
// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI
// note [0,127] and commits via the same off-thread reload as every other edit.
//
// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served):
// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and
// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60
// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel).
// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than
// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns
// nullopt (the shell keeps the old value + may flash the field).
#pragma once
#include <optional>
#include <string>
namespace reasampler::vst {
// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal
// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is
// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps
// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types.
std::optional<int> parseNoteEntry(const std::string& text);
} // namespace reasampler::vst
+92
View File
@@ -0,0 +1,92 @@
// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16
// editor parameter panel. No host types; only the shared Rect + contains().
#include "param_slider.h"
#include <algorithm>
namespace reasampler::vst {
std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls) {
std::vector<ControlRow> out;
if (controls.empty() || panel.width() <= 0 || panel.height() <= 0) return out;
out.reserve(controls.size());
// The label column is clamped so a narrow panel still leaves a control column.
const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width() / 2));
int rowTop = panel.top;
for (const ControlDesc& d : controls) {
ControlRow r;
r.id = d.id;
r.kind = d.kind;
const int rowBottom = rowTop + kControlRowHeight;
r.row = Rect{panel.left, rowTop, panel.right, rowBottom};
r.label = Rect{panel.left, rowTop, panel.left + labelW, rowBottom};
r.control = Rect{panel.left + labelW, rowTop, panel.right, rowBottom};
out.push_back(r);
rowTop = rowBottom + kControlRowGap;
}
return out;
}
Rect toggleSegmentRect(const Rect& control, int seg) {
if (seg < 0 || seg >= kToggleSegments) return Rect{};
const int w = control.width();
if (w <= 0 || control.height() <= 0) return Rect{};
const int segW = w / kToggleSegments;
const int left = control.left + seg * segW;
// The last segment absorbs the width remainder so the segments tile the whole control.
const int right = (seg == kToggleSegments - 1) ? control.right : left + segW;
return Rect{left, control.top, right, control.bottom};
}
int toggleSegmentHitTest(const Rect& control, int x, int y) {
if (!contains(control, x, y)) return -1;
for (int seg = 0; seg < kToggleSegments; ++seg) {
if (contains(toggleSegmentRect(control, seg), x, y)) return seg;
}
return -1;
}
Rect sliderTrackRect(const Rect& control) {
// Inset a half-handle at each end so the handle stays fully inside the control at value
// 0 and 1. The handle CENTER ranges across [track.left, track.right].
const int half = kSliderHandleWidth / 2;
if (control.width() <= kSliderHandleWidth || control.height() <= 0) return Rect{};
return Rect{control.left + half, control.top, control.right - half, control.bottom};
}
Rect sliderHandleRect(const Rect& control, double value) {
const Rect track = sliderTrackRect(control);
if (track.width() <= 0) return Rect{};
if (value < 0.0) value = 0.0;
if (value > 1.0) value = 1.0;
const int span = track.width(); // handle-center movable span
const int centerX = track.left + static_cast<int>(value * span + 0.5);
const int half = kSliderHandleWidth / 2;
return Rect{centerX - half, control.top, centerX - half + kSliderHandleWidth,
control.bottom};
}
double valueAtPoint(const Rect& control, int x) {
const Rect track = sliderTrackRect(control);
const int span = track.width();
if (span <= 0) return 0.0;
if (x <= track.left) return 0.0;
if (x >= track.right) return 1.0;
return static_cast<double>(x - track.left) / static_cast<double>(span);
}
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
for (const ControlRow& r : rows) {
if (r.kind == ControlKind::Toggle) {
if (contains(r.control, x, y)) return r.id;
} else { // Slider — the interactive area is the track
if (contains(sliderTrackRect(r.control), x, y)) return r.id;
}
}
return -1;
}
} // namespace reasampler::vst
+104
View File
@@ -0,0 +1,104 @@
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the
// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell
// draws each row (label + track/segments + handle) and routes clicks/drags into these
// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping.
//
// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope
// enable/attack/decay/depth. They are two shapes only — a two-segment TOGGLE and a
// horizontal SLIDER — laid out as a vertical stack of fixed-height rows. This module lays out
// that stack and maps a slider's NORMALIZED value (0..1) to/from its handle pixel; the shell
// converts each control's engine value (frames, seconds, a fraction, a signed semitone
// depth) to/from that 0..1 with its own domain knowledge (this module stays engine-free so it
// tests without the audio core).
//
// It reuses editor_geometry's Rect + contains() (one shared geometry idiom).
#pragma once
#include <vector>
#include "editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::vst {
// Fixed control-panel metrics, exposed so the shell and tests agree.
inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap)
inline constexpr int kControlRowGap = 4; // vertical gap below each row
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px)
inline constexpr int kToggleSegments = 2; // a toggle is always two segments
// A control is one of two shapes. Toggle = a two-segment selector (the active segment
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value.
enum class ControlKind { Toggle, Slider };
// One control the shell places in the panel, in stack order. `id` is the shell's own control
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the
// shell routes the interaction to the right engine param — this module never interprets it.
struct ControlDesc {
int id = 0;
ControlKind kind = ControlKind::Slider;
};
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle
// into segments via toggleSegmentRect). `index` is the control's position in the stack.
struct ControlRow {
int id = 0;
ControlKind kind = ControlKind::Slider;
Rect row; // the full row (label column + control column)
Rect label; // the label column at the left
Rect control; // the control column to the right of the label (track / toggle area)
};
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A
// row whose top falls past the panel bottom is still returned (the shell clips at paint /
// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty
// control list or a degenerate panel yields an empty vector. Pure.
std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls);
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect,
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width
// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate
// control rect yields an empty rect. Pure.
Rect toggleSegmentRect(const Rect& control, int seg);
// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a
// miss (outside the control area). Pure.
int toggleSegmentHitTest(const Rect& control, int x, int y);
// The slider track sub-rect inside a slider control's `control` rect: the control inset so the
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle
// margin at each end). The handle CENTER ranges across [track.left, track.right] as the value
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
// empty rect. Pure.
Rect sliderTrackRect(const Rect& control);
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a
// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A
// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint.
Rect sliderHandleRect(const Rect& control, double value);
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range).
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate
// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the
// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones).
double valueAtPoint(const Rect& control, int x);
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc
// id) whose interactive area (a Slider's track, a Toggle's whole control area) contains the
// point, or -1 for a miss (a gap, the label column, or outside every row). The FIRST matching
// row wins (rows never overlap, so at most one matches). Pure — the shell's routing entry
// point: on a hit it reads the value (valueAtPoint / toggleSegmentHitTest) and commits.
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
} // namespace reasampler::vst
+467 -20
View File
@@ -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();
}
+66 -1
View File
@@ -31,6 +31,7 @@
#include "public.sdk/source/common/pluginview.h"
#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules)
#include "param_slider.h" // ControlRow (the S12/S15/S16 control-surface geometry)
#include "peaks.h" // Envelope (the cached peak thumbnail)
#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
@@ -70,7 +71,29 @@ private:
// flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the
// single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop
// marker on the S11 waveform surface (which marker is in waveMarker_).
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker };
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
kScrollThumb, kParamSlider };
// 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.
enum class ParamControl {
kPlayMode = 0, // Gate | Trigger toggle (S15)
kPitchEngine, // Varispeed | Preserve toggle (S16)
kAttack, // AHDSR attack (Gate) / —
kHold, // AHDSR hold (Gate, S15)
kDecay, // AHDSR decay (Gate)
kSustain, // AHDSR sustain (Gate)
kRelease, // AHDSR release (Gate)
kTrigLength, // Trigger %-length (Trigger, S15)
kTrigFadeIn, // Trigger fade-in (Trigger, S15)
kTrigFadeOut, // Trigger fade-out (Trigger, S15)
kPitchEnvEnable, // AD pitch envelope on|off (S16)
kPitchEnvAttack, // AD pitch attack (S16)
kPitchEnvDecay, // AD pitch decay (S16)
kPitchEnvDepth, // AD pitch depth in +/- semitones (S16)
kCount
};
// The waveform markers on the single-capture setup surface (S11). Order is the draw + hit
// order (start first). Named generically per the spec so S15 can repurpose the surface with
@@ -83,10 +106,13 @@ private:
void paintSetup(LICE_IBitmap* bmp, const Rect& area);
void paintZones(LICE_IBitmap* bmp, int w, int h);
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
void paintControls(LICE_IBitmap* bmp, const Rect& panel); // S12/S15/S16 param surface
void onMouseDown(int x, int y);
void onMouseMove(int x, int y);
void onMouseUp(int x, int y);
void onMouseWheel(int delta); // S12 browser scroll (wheel)
void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
@@ -149,6 +175,25 @@ private:
// final commit. selectedId_ must be non-empty before calling.
void upsertPickedOverride(const SetupMarkers& m);
// --- S12/S15/S16 parameter surface (Zones panel, keyed to selectedZone_) ------
//
// The control panel edits the SELECTED zone's ZonePlayParams (S15 play mode + AHDSR; S16
// pitch engine + AD pitch envelope). 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 ZonePlayParams& play) const;
// The normalized [0,1] display value for control `id` given `play` (the shell's domain
// mapping: frames->0..1 over a fixed max, sustain 0..1 as-is, semitone depth centered at 0.5).
double controlValue(int id, const ZonePlayParams& play) const;
// Apply a committed control interaction to `play`: a slider's normalized `value` (mapped back
// into the control's engine domain) or a toggle's `segment` (0/1). Mutates `play` in place.
void applyControl(int id, ZonePlayParams& play, double value, int segment) const;
ReaSamplerProcessor* processor_ = nullptr;
// --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) ---
@@ -164,9 +209,22 @@ private:
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none
// --- S12 browser scroll + search (transient UI state, never persisted) --------
int scrollOffset_ = 0; // vertical px offset into the card grid (clamped)
std::string searchQuery_; // type-to-filter narrow; "" = no search
bool searchFocused_ = false; // whether the search box has keyboard focus
// --- S12 numeric note entry (LICE text-entry idiom, transient) ----------------
// When >= 0, a low/high/root field is being typed; entryText_ accumulates the keystrokes
// and commits (parseNoteEntry) on Enter. -1 = no field editing. The field id is a
// ParamControl-independent small enum encoded inline (see the .cpp: 0=low,1=high,2=root).
int entryField_ = -1;
std::string entryText_;
// --- Drag-state machine ------------------------------------------------------
DragKind drag_ = DragKind::kNone;
int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver
int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag
int dragStartLow_ = 0; // the grabbed field's note at grab time
int dragStartHigh_ = 0;
int dragStartRoot_ = 60;
@@ -179,6 +237,13 @@ private:
SetupMarkers dragStartMarkers_;
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the 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).
int dragStartScrollOffset_ = 0;
int dragParamId_ = -1;
Rect dragParamPanel_{};
// --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------
// Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so
// a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.