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
+47 -1
View File
@@ -716,6 +716,31 @@ add_library(bank_sync STATIC src/vst/bank_sync.cpp)
target_include_directories(bank_sync PUBLIC src/vst src)
target_link_libraries(bank_sync PUBLIC assignment_request)
# browser_scroll (Phase S12) — PURE scroll-window + scrollbar-thumb + type-to-filter-search
# geometry LAYERED over the S10 capture_browser: the visible-card window, thumb rect +
# thumb-drag<->offset mapping, and the name-substring filter that composes with the bank
# filter. The mirror of capture_browser; links capture_browser (for BrowserLayout + the card
# metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK.
add_library(browser_scroll STATIC src/vst/browser_scroll.cpp)
target_include_directories(browser_scroll PUBLIC src/vst)
target_link_libraries(browser_scroll PUBLIC capture_browser)
# note_entry (Phase S12) — PURE text->clamped-MIDI-note parse for the direct numeric entry of
# a zone's low/high/root (decimal integer OR note name under the C4==60 convention, clamped to
# [0,127]). No dependency beyond the standard library. NEITHER SDK.
add_library(note_entry STATIC src/vst/note_entry.cpp)
target_include_directories(note_entry PUBLIC src/vst)
# param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface
# layout + hit-test + normalized value<->pixel mapping for the editor parameter panel (the
# Gate|Trigger + Varispeed|Preserve toggles and the AHDSR / Trigger / pitch-env sliders). The
# mirror of keyboard_strip; links editor_geometry for the shared Rect. Deliberately engine-free
# (no sampler_core types) — the shell owns the control-id -> param binding + the value DOMAIN
# mapping. NEITHER SDK.
add_library(param_slider STATIC src/vst/param_slider.cpp)
target_include_directories(param_slider PUBLIC src/vst)
target_link_libraries(param_slider PUBLIC editor_geometry)
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
@@ -755,6 +780,22 @@ add_executable(bank_sync_tests tests/test_bank_sync.cpp)
target_link_libraries(bank_sync_tests PRIVATE bank_sync)
add_test(NAME bank_sync_tests COMMAND bank_sync_tests)
# browser_scroll (S12): the pure scroll-window/thumb + search geometry over capture_browser.
add_executable(browser_scroll_tests tests/test_browser_scroll.cpp)
target_link_libraries(browser_scroll_tests PRIVATE browser_scroll)
add_test(NAME browser_scroll_tests COMMAND browser_scroll_tests)
# note_entry (S12): the pure text->clamped-MIDI-note parse for direct numeric entry.
add_executable(note_entry_tests tests/test_note_entry.cpp)
target_link_libraries(note_entry_tests PRIVATE note_entry)
add_test(NAME note_entry_tests COMMAND note_entry_tests)
# param_slider (S12 + S15/S16 control surfaces): the pure control-panel layout + slider/toggle
# value<->pixel mapping the editor parameter surface draws + routes against.
add_executable(param_slider_tests tests/test_param_slider.cpp)
target_link_libraries(param_slider_tests PRIVATE param_slider)
add_test(NAME param_slider_tests COMMAND param_slider_tests)
# ---------------------------------------------------------------------------
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# ---------------------------------------------------------------------------
@@ -941,9 +982,14 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# bank_sync (S9/S8 reader): the pure generation-compare + assignment-consume decision the
# processor's off-thread poll runs; links assignment_request transitively (the decoded
# request it consumes) — the same key the extension writes, shared via the pure module.
# browser_scroll + note_entry + param_slider (S12 + S15/S16 control surfaces): the pure
# scroll/search geometry over the capture browser, the numeric-note-entry parse, and the
# control-panel layout + slider/toggle value<->pixel mapping the editor's parameter surface
# draws + routes against. browser_scroll pulls capture_browser transitively; param_slider +
# note_entry link editor_geometry / the stdlib only. All engine-free, DAW-verified in the shell.
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
waveform_view bank_sync)
waveform_view bank_sync browser_scroll note_entry param_slider)
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
+24 -9
View File
@@ -581,19 +581,34 @@ into the voice engine; today they are fixed defaults), S5 (the map the numeric f
> control, Trigger %-length/fade controls, Varispeed/Preserve engine toggle, and the AD pitch
> envelope depth/shape controls) — deferred here from S15 and S16 per spec.
- [ ] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a
- [x] Scrollable, searchable capture browser: a scroll offset (wheel + scrollbar drag) so a
bank longer than the panel is fully reachable; a **type-to-filter search** that narrows
the drawn cards to matching display names, **composing with S10's bank filter** (bank
filter selects the bank; search narrows within it). Scroll/search layout + hit-test is
pure geometry (visible-card window, scrollbar thumb rect, search-box rect), layered over
S10's `capture_browser` module; filter/scroll state is transient UI state.
- [ ] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip
(LICE text-entry idiom or a SWELL edit control on the child HWND) so a precise note can be
typed, not only dragged. Commits via `commitMapAndReload` like every other edit.
- [ ] ADSR editor: four draggable controls (attack/decay/sustain/release) over the S3
`AdsrParams`; per-instance component state (additive to the map/selection blob, version-
bumped, back-compat defaults to the current fixed envelope). Slider layout/hit-test pure;
the audible envelope change goes through the same off-thread reload.
S10's `capture_browser` module; filter/scroll state is transient UI state. **Landed:**
pure `browser_scroll` module (`browser_scroll_tests`) — scrollContentHeight / max / clamp,
visibleCardRange window, scrolledCardCellRect, scrollThumbRect + thumbDragToOffset inverse,
searchBoxRect, nameMatchesQuery + filterNameIndices. Editor shell wires wheel (`WM_MOUSEWHEEL`),
thumb-drag (`DragKind::kScrollThumb`), and the search box (`WM_CHAR` -> `onSearchChar`,
composed into `rebuildVisible`). Scroll/search are transient (never persisted).
- [x] Direct numeric entry for zone low/high/root: a click-to-edit field over the strip
(LICE text-entry idiom) so a precise note can be typed, not only dragged. Commits via
`commitAndReload` like every other edit. **Landed:** pure `note_entry` module
(`note_entry_tests`) — `parseNoteEntry` accepts a decimal integer OR a note name (C4==60),
clamps to [0,127], rejects garbage. Editor shell hosts three focusable fields (low/high/root)
on the Zones legend, committing on Enter through `commitAndReload`.
- [x] ADSR/AHDSR editor + S15/S16 control surfaces: draggable sliders over the S3 `AdsrParams`
(attack/**hold**/decay/sustain/release — hold is the S15 addition) plus the deferred S15/S16
controls — per-zone Gate|Trigger mode toggle, Trigger %-length/fade-in/fade-out, Varispeed|
Preserve engine toggle, and the AD pitch-envelope enable/attack/decay/±semitone depth. All
edit the SELECTED zone's `ZonePlayParams` (instrument-owned, D-B; never the bank), round-trip
through the existing v3 component-state blob (no new persistence — the S15/S16 payload already
landed in the core pass), and commit off-thread via `commitAndReload`. **Landed:** pure
`param_slider` module (`param_slider_tests`) — control-panel stack layout, toggle-segment
split + hit-test, slider value<->pixel round-trip + clamping, point->control routing. The
shell owns the control-id -> engine-param binding + the value DOMAIN mapping (frames/fraction/
semitones); the module stays engine-free.
## S13 — drop-to-load: the S8 ingest story, folded into the editor UX (UX overhaul, part 4)
**Goal:** Make "load a sample into the sampler" **one gesture from the editor**: dropping
+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.
+179
View File
@@ -0,0 +1,179 @@
// Standalone tests for reasampler::vst::browser_scroll — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure editor tests: assert the S12 scroll-window +
// scrollbar-thumb + type-to-filter-search geometry LAYERED over the S10 capture_browser.
//
// Covers: scrollContentHeight (ceil rows * card height, 0 for no cards); scrollMaxOffset (0
// when content fits, else content-visible); clampScrollOffset pinning to [0,max]; visibleCardRange
// windowing (top rows only, scrolled window, empty when scrolled past the end); scrolledCardCellRect
// shifting a cell up by the offset; scrollThumbRect (empty when it fits, proportional height +
// position, minimum height, at-max pins to the track bottom); thumbDragToOffset as the position
// inverse (a full-track drag reaches max, round-trips); searchBoxRect; nameMatchesQuery
// (case-insensitive substring, empty-query identity, no-match); filterNameIndices preserving order
// and returning every index for an empty query.
#include "../src/vst/browser_scroll.h"
#include <cstdio>
#include <string>
#include <vector>
using namespace reasampler::vst;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// A layout wide enough for a few columns and tall enough to show a few rows.
static BrowserLayout wideLayout() { return layoutBrowser(560, 300); }
// --- content / max / clamp ----------------------------------------------------
static void testContentHeight() {
const BrowserLayout L = wideLayout();
CHECK(scrollContentHeight(L, 0) == 0);
// One card -> one row -> one card height.
CHECK(scrollContentHeight(L, 1) == kBrowserCardHeight);
// columns+1 cards -> two rows.
CHECK(scrollContentHeight(L, L.columns + 1) == 2 * kBrowserCardHeight);
// Exactly `columns` cards -> one row.
CHECK(scrollContentHeight(L, L.columns) == kBrowserCardHeight);
}
static void testMaxOffsetFitsAndOverflows() {
const BrowserLayout L = wideLayout();
// A single row fits within the 300px area -> no scroll.
CHECK(scrollMaxOffset(L, L.columns) == 0);
// Many rows overflow -> max = content - gridHeight.
const int many = L.columns * 20;
const int expect = scrollContentHeight(L, many) - L.grid.height();
CHECK(scrollMaxOffset(L, many) == expect);
CHECK(expect > 0);
}
static void testClamp() {
const BrowserLayout L = wideLayout();
const int many = L.columns * 20;
const int maxOff = scrollMaxOffset(L, many);
CHECK(clampScrollOffset(L, many, -50) == 0);
CHECK(clampScrollOffset(L, many, maxOff + 500) == maxOff);
CHECK(clampScrollOffset(L, many, maxOff / 2) == maxOff / 2);
}
// --- visible window -----------------------------------------------------------
static void testVisibleRangeTop() {
const BrowserLayout L = wideLayout();
const int many = L.columns * 20;
const VisibleRange vr = visibleCardRange(L, many, 0);
CHECK(vr.first == 0);
// At offset 0, the last visible row is the one containing (gridH-1).
const int expectedLastRow = (L.grid.height() - 1) / kBrowserCardHeight + 1;
CHECK(vr.last == expectedLastRow * L.columns);
}
static void testVisibleRangeScrolled() {
const BrowserLayout L = wideLayout();
const int many = L.columns * 20;
// Scroll one full card row down.
const VisibleRange vr = visibleCardRange(L, many, kBrowserCardHeight);
CHECK(vr.first == L.columns); // the first row scrolled off the top
}
static void testVisibleRangeEmptyWhenNoCards() {
const BrowserLayout L = wideLayout();
const VisibleRange vr = visibleCardRange(L, 0, 0);
CHECK(vr.first == 0 && vr.last == 0);
}
static void testScrolledCellShiftsUp() {
const BrowserLayout L = wideLayout();
const Rect base = cardCellRect(L, 3);
const Rect shifted = scrolledCardCellRect(L, 3, 40);
CHECK(shifted.top == base.top - 40);
CHECK(shifted.bottom == base.bottom - 40);
CHECK(shifted.left == base.left);
}
// --- scrollbar thumb ----------------------------------------------------------
static void testThumbEmptyWhenFits() {
const BrowserLayout L = wideLayout();
CHECK(scrollThumbRect(L, L.columns, 0).height() == 0); // one row fits -> no thumb
}
static void testThumbProportionalAndClamped() {
const BrowserLayout L = wideLayout();
const int many = L.columns * 20;
const Rect atTop = scrollThumbRect(L, many, 0);
CHECK(atTop.height() > 0);
CHECK(atTop.top == L.grid.top); // at offset 0 the thumb starts at the track top
CHECK(atTop.width() == kScrollbarWidth);
CHECK(atTop.right == L.grid.right);
// At max offset, the thumb bottom reaches the grid bottom (pinned to the end).
const int maxOff = scrollMaxOffset(L, many);
const Rect atMax = scrollThumbRect(L, many, maxOff);
CHECK(atMax.bottom == L.grid.top + L.grid.height());
}
static void testThumbDragIsInverse() {
const BrowserLayout L = wideLayout();
const int many = L.columns * 20;
const int maxOff = scrollMaxOffset(L, many);
// A zero drag holds the start offset.
CHECK(thumbDragToOffset(L, many, 0, 0) == 0);
// A large positive drag pins to max; a large negative drag pins to 0.
CHECK(thumbDragToOffset(L, many, 0, 100000) == maxOff);
CHECK(thumbDragToOffset(L, many, maxOff, -100000) == 0);
// Dragging the thumb by the whole track span from top reaches (near) max.
const Rect thumb = scrollThumbRect(L, many, 0);
const int trackSpan = L.grid.height() - thumb.height();
const int off = thumbDragToOffset(L, many, 0, trackSpan);
CHECK(off >= maxOff - 2 && off <= maxOff);
}
// --- search -------------------------------------------------------------------
static void testSearchBoxRect() {
const Rect r = searchBoxRect(200);
CHECK(r.left == 0 && r.top == 0 && r.right == 200 && r.height() == kSearchBoxHeight);
CHECK(searchBoxRect(0).width() == 0);
}
static void testNameMatch() {
CHECK(nameMatchesQuery("Kick Drum 01", "")); // empty query matches all
CHECK(nameMatchesQuery("Kick Drum 01", "drum")); // case-insensitive substring
CHECK(nameMatchesQuery("Kick Drum 01", "KICK"));
CHECK(!nameMatchesQuery("Kick Drum 01", "snare"));
CHECK(!nameMatchesQuery("ab", "abc")); // query longer than name
}
static void testFilterIndices() {
std::vector<std::string> names{"Kick", "Snare", "Kick Sub", "Hat"};
// Empty query -> every index, in order.
const std::vector<int> all = filterNameIndices(names, "");
CHECK(all.size() == 4 && all[0] == 0 && all[3] == 3);
// "kick" -> indices 0 and 2, order preserved.
const std::vector<int> kicks = filterNameIndices(names, "kick");
CHECK(kicks.size() == 2 && kicks[0] == 0 && kicks[1] == 2);
// No match -> empty.
CHECK(filterNameIndices(names, "zzz").empty());
}
int main() {
testContentHeight();
testMaxOffsetFitsAndOverflows();
testClamp();
testVisibleRangeTop();
testVisibleRangeScrolled();
testVisibleRangeEmptyWhenNoCards();
testScrolledCellShiftsUp();
testThumbEmptyWhenFits();
testThumbProportionalAndClamped();
testThumbDragIsInverse();
testSearchBoxRect();
testNameMatch();
testFilterIndices();
if (g_fail == 0) std::printf("browser_scroll: all tests passed\n");
return g_fail != 0;
}
+73
View File
@@ -0,0 +1,73 @@
// Standalone tests for reasampler::vst::note_entry — no VST3, no REAPER, no framework.
// Assert the S12 direct-numeric-entry parse for a zone's low/high/root MIDI note.
//
// Covers: plain decimal integers (with +/- sign + surrounding whitespace); note names under the
// C4==60 convention (C-1==0, sharps + flats, negative octaves); out-of-range values CLAMPING to
// [0,127] rather than rejecting; empty / whitespace-only / unparseable input returning nullopt;
// the integer path taking precedence over the note-name path for a leading digit.
#include "../src/vst/note_entry.h"
#include <cstdio>
using namespace reasampler::vst;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static void testPlainIntegers() {
CHECK(parseNoteEntry("60") == 60);
CHECK(parseNoteEntry("0") == 0);
CHECK(parseNoteEntry("127") == 127);
CHECK(parseNoteEntry(" 64 ") == 64); // surrounding whitespace ignored
CHECK(parseNoteEntry("+5") == 5);
}
static void testIntegerClamps() {
CHECK(parseNoteEntry("200") == 127); // over-range clamps to the ceiling
CHECK(parseNoteEntry("-10") == 0); // under-range clamps to the floor
CHECK(parseNoteEntry("99999") == 127);
}
static void testNoteNames() {
// C4 == 60 (MIDI 0 == C-1).
CHECK(parseNoteEntry("C4") == 60);
CHECK(parseNoteEntry("c4") == 60); // case-insensitive
CHECK(parseNoteEntry("A4") == 69); // A4 = 69 (concert A)
CHECK(parseNoteEntry("C-1") == 0); // lowest MIDI note
CHECK(parseNoteEntry("G9") == 127); // G9 = 127
}
static void testAccidentals() {
CHECK(parseNoteEntry("C#4") == 61);
CHECK(parseNoteEntry("Db4") == 61); // enharmonic of C#4
CHECK(parseNoteEntry("F#3") == 54);
CHECK(parseNoteEntry("Bb3") == 58); // Bb3 = 58
}
static void testNoteNameClamps() {
CHECK(parseNoteEntry("C10") == 127); // above the range clamps
CHECK(parseNoteEntry("C-5") == 0); // below the range clamps
}
static void testRejects() {
CHECK(parseNoteEntry("") == std::nullopt);
CHECK(parseNoteEntry(" ") == std::nullopt);
CHECK(parseNoteEntry("hello") == std::nullopt);
CHECK(parseNoteEntry("C") == std::nullopt); // a bare letter with no octave is ambiguous
CHECK(parseNoteEntry("H4") == std::nullopt); // H is not a note letter
CHECK(parseNoteEntry("+") == std::nullopt);
}
int main() {
testPlainIntegers();
testIntegerClamps();
testNoteNames();
testAccidentals();
testNoteNameClamps();
testRejects();
if (g_fail == 0) std::printf("note_entry: all tests passed\n");
return g_fail != 0;
}
+204
View File
@@ -0,0 +1,204 @@
// Standalone tests for reasampler::vst::param_slider — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure editor tests (capture_browser / keyboard_strip):
// assert the S12/S15/S16 control-surface layout, toggle-segment split + hit-test, slider
// value<->pixel mapping (round-trip + clamping + endpoints), and point->control routing.
//
// Covers: layoutControls stacking rows top-down with the label column + control column and the
// inter-row gap; an empty list / degenerate panel yielding nothing; toggleSegmentRect splitting
// a toggle into two tiling segments (last absorbs the remainder) + toggleSegmentHitTest;
// sliderTrackRect insetting a half-handle at each end; sliderHandleRect at value 0/0.5/1 and
// out-of-range clamping; valueAtPoint mapping x back to 0..1 (endpoints saturate) as the inverse
// of the handle position; controlAtPoint routing a point to the right control id (toggle whole
// area vs slider track) and MISSING in the label column, a row gap, and off-panel.
#include "../src/vst/param_slider.h"
#include <cstdio>
#include <vector>
using namespace reasampler::vst;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static bool approx(double a, double b) { return (a - b) < 1e-9 && (b - a) < 1e-9; }
// --- layoutControls -----------------------------------------------------------
static void testLayoutStacksRows() {
const Rect panel{0, 100, 300, 400};
std::vector<ControlDesc> ctl{
{1, ControlKind::Toggle},
{2, ControlKind::Slider},
{3, ControlKind::Slider},
};
const std::vector<ControlRow> rows = layoutControls(panel, ctl);
CHECK(rows.size() == 3);
// Row 0 sits at the panel top; each subsequent row is one row-height + gap below.
CHECK(rows[0].row.top == 100);
CHECK(rows[0].row.bottom == 100 + kControlRowHeight);
CHECK(rows[1].row.top == rows[0].row.bottom + kControlRowGap);
CHECK(rows[2].row.top == rows[1].row.bottom + kControlRowGap);
// Ids + kinds carried through in order.
CHECK(rows[0].id == 1 && rows[0].kind == ControlKind::Toggle);
CHECK(rows[1].id == 2 && rows[1].kind == ControlKind::Slider);
// Label column then control column, contiguous, spanning the panel width.
CHECK(rows[0].label.left == panel.left);
CHECK(rows[0].control.left == rows[0].label.right);
CHECK(rows[0].control.right == panel.right);
CHECK(rows[0].label.width() == kControlLabelWidth);
}
static void testLayoutEmptyAndDegenerate() {
CHECK(layoutControls(Rect{0, 0, 300, 300}, {}).empty());
std::vector<ControlDesc> ctl{{1, ControlKind::Slider}};
CHECK(layoutControls(Rect{0, 0, 0, 0}, ctl).empty());
CHECK(layoutControls(Rect{0, 0, 300, 0}, ctl).empty());
}
static void testLayoutNarrowPanelClampsLabel() {
// A panel narrower than 2*labelWidth clamps the label column to half so a control column
// survives.
const Rect panel{0, 0, 100, 200};
const std::vector<ControlRow> rows = layoutControls(panel, {{1, ControlKind::Slider}});
CHECK(rows.size() == 1);
CHECK(rows[0].label.width() <= panel.width() / 2 + 1);
CHECK(rows[0].control.width() > 0);
}
// --- toggle -------------------------------------------------------------------
static void testToggleSegmentsTile() {
const Rect control{100, 0, 300, 22}; // width 200
const Rect s0 = toggleSegmentRect(control, 0);
const Rect s1 = toggleSegmentRect(control, 1);
CHECK(s0.left == 100 && s0.right == 200);
CHECK(s1.left == 200 && s1.right == 300); // last absorbs remainder -> reaches control.right
// Out of range.
CHECK(toggleSegmentRect(control, 2).width() == 0);
CHECK(toggleSegmentRect(control, -1).width() == 0);
}
static void testToggleSegmentRemainderInLast() {
const Rect control{0, 0, 201, 22}; // odd width -> seg0 = 100, seg1 = 101 (absorbs remainder)
CHECK(toggleSegmentRect(control, 0).width() == 100);
CHECK(toggleSegmentRect(control, 1).right == 201);
}
static void testToggleHitTest() {
const Rect control{100, 0, 300, 22};
CHECK(toggleSegmentHitTest(control, 150, 10) == 0);
CHECK(toggleSegmentHitTest(control, 250, 10) == 1);
CHECK(toggleSegmentHitTest(control, 50, 10) == -1); // left of control
CHECK(toggleSegmentHitTest(control, 150, 40) == -1); // below control
}
// --- slider -------------------------------------------------------------------
static void testSliderTrackInsetsHalfHandle() {
const Rect control{100, 0, 300, 22};
const Rect track = sliderTrackRect(control);
CHECK(track.left == control.left + kSliderHandleWidth / 2);
CHECK(track.right == control.right - kSliderHandleWidth / 2);
// A control too narrow for a handle yields an empty track.
CHECK(sliderTrackRect(Rect{0, 0, kSliderHandleWidth - 1, 22}).width() == 0);
}
static void testSliderHandleAtEndpointsAndMid() {
const Rect control{100, 0, 300, 22};
const Rect track = sliderTrackRect(control);
const int half = kSliderHandleWidth / 2;
// Value 0 -> handle centered at track.left.
const Rect h0 = sliderHandleRect(control, 0.0);
CHECK(h0.left + half == track.left);
// Value 1 -> handle centered at track.right.
const Rect h1 = sliderHandleRect(control, 1.0);
CHECK(h1.left + half == track.right);
// Value 0.5 -> centered at the track middle.
const Rect hm = sliderHandleRect(control, 0.5);
CHECK(hm.left + half == track.left + track.width() / 2);
}
static void testSliderHandleClampsOutOfRange() {
const Rect control{0, 0, 200, 22};
CHECK(sliderHandleRect(control, -0.5).left == sliderHandleRect(control, 0.0).left);
CHECK(sliderHandleRect(control, 5.0).left == sliderHandleRect(control, 1.0).left);
}
static void testValueAtPointEndpointsSaturate() {
const Rect control{100, 0, 300, 22};
const Rect track = sliderTrackRect(control);
CHECK(approx(valueAtPoint(control, track.left - 20), 0.0));
CHECK(approx(valueAtPoint(control, track.left), 0.0));
CHECK(approx(valueAtPoint(control, track.right + 20), 1.0));
CHECK(approx(valueAtPoint(control, track.right), 1.0));
}
static void testValueAtPointIsHandleInverse() {
// Round-trip: a value -> handle center -> valueAtPoint recovers (within one pixel quantum).
const Rect control{50, 0, 450, 22}; // wide track for pixel resolution
const Rect track = sliderTrackRect(control);
for (double v : {0.1, 0.25, 0.5, 0.75, 0.9}) {
const Rect h = sliderHandleRect(control, v);
const int centerX = h.left + kSliderHandleWidth / 2;
const double back = valueAtPoint(control, centerX);
CHECK(back >= v - 0.01 && back <= v + 0.01);
CHECK(centerX >= track.left && centerX <= track.right);
}
}
static void testValueAtPointDegenerateTrack() {
CHECK(approx(valueAtPoint(Rect{0, 0, kSliderHandleWidth - 1, 22}, 5), 0.0));
}
// --- controlAtPoint routing ---------------------------------------------------
static void testControlAtPointRoutes() {
const Rect panel{0, 0, 300, 400};
std::vector<ControlDesc> ctl{
{10, ControlKind::Toggle},
{20, ControlKind::Slider},
};
const std::vector<ControlRow> rows = layoutControls(panel, ctl);
// A point in the toggle's control area routes to the toggle id.
const Rect tctl = rows[0].control;
CHECK(controlAtPoint(rows, (tctl.left + tctl.right) / 2, (tctl.top + tctl.bottom) / 2) == 10);
// A point on the slider's track routes to the slider id.
const Rect strack = sliderTrackRect(rows[1].control);
CHECK(controlAtPoint(rows, (strack.left + strack.right) / 2,
(strack.top + strack.bottom) / 2) == 20);
}
static void testControlAtPointMisses() {
const Rect panel{0, 0, 300, 400};
const std::vector<ControlRow> rows =
layoutControls(panel, {{10, ControlKind::Toggle}, {20, ControlKind::Slider}});
// The label column is not interactive.
CHECK(controlAtPoint(rows, rows[0].label.left + 2, rows[0].label.top + 4) == -1);
// The gap between rows is a miss.
const int gapY = rows[0].row.bottom + kControlRowGap / 2;
CHECK(controlAtPoint(rows, 200, gapY) == -1);
// Off-panel below.
CHECK(controlAtPoint(rows, 200, 5000) == -1);
}
int main() {
testLayoutStacksRows();
testLayoutEmptyAndDegenerate();
testLayoutNarrowPanelClampsLabel();
testToggleSegmentsTile();
testToggleSegmentRemainderInLast();
testToggleHitTest();
testSliderTrackInsetsHalfHandle();
testSliderHandleAtEndpointsAndMid();
testSliderHandleClampsOutOfRange();
testValueAtPointEndpointsSaturate();
testValueAtPointIsHandleInverse();
testValueAtPointDegenerateTrack();
testControlAtPointRoutes();
testControlAtPointMisses();
if (g_fail == 0) std::printf("param_slider: all tests passed\n");
return g_fail != 0;
}