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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user