Cut core/instrument/ui comment bloat ~49% (comments only, zero code change)

This commit is contained in:
2026-07-29 20:48:39 -04:00
parent 1f24c4b095
commit ccd9968be1
21 changed files with 539 additions and 1043 deletions
+7 -13
View File
@@ -1,9 +1,8 @@
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10 // browser_scroll.cpp — see browser_scroll.h. Pure scroll + search geometry; no host types.
// capture_browser. No host types; only the shared Rect + BrowserLayout.
#include "core/instrument/ui/browser_scroll.h" #include "core/instrument/ui/browser_scroll.h"
#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth (Q-W2v hoist) #include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -50,11 +49,10 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of
return vr; return vr;
} }
if (offset < 0) offset = 0; if (offset < 0) offset = 0;
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row // First row: floored so a row partially scrolled off the top still draws. Last row:
// partially scrolled off the top still draws (its lower part is visible). // the row containing pixel (offset + gridH - 1), +1 for the exclusive end, so a row
// straddling the bottom edge still draws.
const int firstRow = offset / kBrowserCardHeight; 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; const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
int first = firstRow * columns; int first = firstRow * columns;
int last = lastRow * columns; int last = lastRow * columns;
@@ -84,13 +82,11 @@ Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
const int trackLeft = trackRight - kScrollbarWidth; const int trackLeft = trackRight - kScrollbarWidth;
const int trackTop = layout.grid.y; const int trackTop = layout.grid.y;
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but // Thumb height proportional to the visible fraction, floored/capped to the track.
// never taller than the track.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content); int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH); thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH); thumbH = (std::min)(thumbH, gridH);
// Thumb top proportional to the offset over the movable track span.
const int trackSpan = gridH - thumbH; // >= 0 const int trackSpan = gridH - thumbH; // >= 0
int thumbTop = trackTop; int thumbTop = trackTop;
if (maxOff > 0 && trackSpan > 0) { if (maxOff > 0 && trackSpan > 0) {
@@ -106,7 +102,7 @@ int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffse
const int gridH = (std::max)(0, layout.grid.height); const int gridH = (std::max)(0, layout.grid.height);
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset); if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
// Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels. // Same thumb-height formula as scrollThumbRect.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content); int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH); thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH); thumbH = (std::min)(thumbH, gridH);
@@ -157,8 +153,6 @@ std::vector<int> filterNameIndices(const std::vector<std::string>& names,
return out; return out;
} }
// The Browse-modal regions (hoisted from the editor shell, Q-W2v/T2-06 — body verbatim;
// the band metrics come from editor_geometry, the search height from searchBoxRect).
BrowseModal computeBrowseModal(int w, int h) { BrowseModal computeBrowseModal(int w, int h) {
constexpr int kBrowseFooterH = 30; constexpr int kBrowseFooterH = 30;
BrowseModal m; BrowseModal m;
+47 -76
View File
@@ -1,23 +1,16 @@
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10 // browser_scroll.h — scroll + type-to-filter geometry layered over capture_browser. Mirror
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of // of capture_browser/editor_geometry; the shell draws the clipped card window, scrollbar,
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box // and search field, and routes wheel/drag/keystrokes into these functions.
// 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 // capture_browser lays out every card top-down and the shell clips at the browser bottom —
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach // a bank longer than the panel has no way to reach the rest. This module adds scroll (a
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry: // vertical pixel offset with max-offset clamp, visible-row window, scrollbar thumb, and
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the // thumb-drag<->offset mapping) and search (a case-insensitive name-substring filter that
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a // composes with capture_browser's bank filter — the shell applies the bank filter first,
// wheel tick or a thumb drag reaches every card; and // then this search narrows within it).
// * 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 // Holds no card data and draws nothing — knows only the browser layout, counts, and the
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It // scroll offset the shell owns as transient UI state.
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
#pragma once #pragma once
@@ -28,96 +21,74 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell // Width of the vertical scrollbar gutter at the grid's right edge. When content fits (no
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so // scroll needed), scrollThumbRect returns empty and the shell may reclaim the gutter.
// 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; inline constexpr int kScrollbarWidth = 10;
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a // Height of the type-to-filter search box the shell draws above the tab strip.
// thin band spanning the browser width). Exposed so the shell reserves the band and tests // capture_browser's tab strip + grid sit below this band.
// 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; inline constexpr int kSearchBoxHeight = 22;
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s // Total pixel height the card grid needs for `cardCount` cards at `layout`'s column
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height. // count: 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); int scrollContentHeight(const BrowserLayout& layout, int cardCount);
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0. // Maximum scroll offset: content height minus 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); int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel // Clamps a proposed scroll offset into [0, scrollMaxOffset].
// 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); 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 // Half-open range of card indices [first, last) at least partially visible at scroll
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every // `offset` (assumed pre-clamped). The shell draws only these cards.
// 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 { struct VisibleRange {
int first = 0; // first card index drawn (inclusive) int first = 0;
int last = 0; // one past the last card index drawn (exclusive) int last = 0;
}; };
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset); 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 // Cell rect of card `index` shifted up by the scroll offset (the shell still adds the
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with // browser sub-area origin).
// 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); Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to // Vertical scrollbar thumb rect within the grid's right-edge gutter, sized proportional
// the visible fraction (grid height / content height) and positioned proportional to the // to the visible fraction and positioned proportional to the scroll offset. Empty when
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell // the content fits. A minimum thumb height keeps a tiny thumb grabbable on a long bank.
// 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); 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 // Maps a thumb-drag to a new (clamped) scroll offset: `startOffset` shifted by the pixel
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new // delta scaled from thumb-track pixels to content pixels. A degenerate track or fitting
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to // content pins to startOffset.
// 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); 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 // Search-box rect: 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; // area. `w` is the browser sub-area width; the shell adds its origin.
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
Rect searchBoxRect(int w); Rect searchBoxRect(int w);
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query // 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 // matches everything.
// 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); bool nameMatchesQuery(const std::string& name, const std::string& query);
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving // Narrows 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 // order. An empty query returns every index. Kept name-only (indices, not card structs)
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this // so this module stays free of the sample_map/bank_book chain — the shell applies the
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list // bank filter first, then feeds the surviving display names here.
// 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, std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query); const std::string& query);
// --- The Browse-modal (S-VIEW-5) top-level regions (Q-W2v hoist, T2-06) ------- // --- Browse-modal top-level regions --------------------------------------------
// //
// A title band with a Back button, the search box, the browser sub-area (tabs + card // A title band with a Back button, the search box, the browser sub-area (tabs + card
// grid — layoutBrowser's origin), and a footer with Cancel / Load-confirm. The picker // grid — layoutBrowser's origin), and a footer with Cancel / Load-confirm. The picker
// covers the full window (F3: full-window overlay). Draw + hit-test both derive from // covers the full window. Homed here (not editor_geometry) because the search-box
// this single layout so they never drift. Homed here (not editor_geometry) because the // height feeds it.
// search-box height feeds it — browser_scroll already owns the search/scroll geometry.
struct BrowseModal { struct BrowseModal {
Rect title; Rect title;
Rect back; // the "Back" title-band button Rect back;
Rect search; // the type-to-filter box (absolute) Rect search;
Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin Rect content; // browser sub-area (tabs + grid) — layoutBrowser's origin
Rect cancel; // footer Cancel Rect cancel;
Rect confirm; // footer Load (confirm) Rect confirm;
}; };
BrowseModal computeBrowseModal(int w, int h); BrowseModal computeBrowseModal(int w, int h);
+3 -3
View File
@@ -8,9 +8,9 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// The left edge of tab i in a strip of the given x-origin and width divided into `count` // Left edge of tab i in a strip divided into `count` equal segments (mirror of
// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same // mode_switch::segmentEdge). Same formula for every boundary so consecutive tabs share
// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly. // an exact edge.
int tabEdge(int x, int width, int i, int count) { int tabEdge(int x, int width, int i, int count) {
return x + (i * width) / count; return x + (i * width) / count;
} }
+36 -61
View File
@@ -1,92 +1,67 @@
// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default // capture_browser.h — layout + hit-test for the capture-first editor's default face: a
// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3, // scannable grid of capture cards with a bank-filter tab strip above it. Mirror of
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / // editor_geometry/embed_strip/mode_switch; the shell draws thumbnails/names/badges and
// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is // routes clicks into these functions.
// unit-tested outside the DAW, while the editor shell draws each card's peak thumbnail +
// name + root/key badge and routes clicks into these functions.
// //
// The browser replaces the old text item-list (the named anti-pattern). It lays out N // This module knows only counts and rects — it draws nothing and holds no sample data;
// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab // the shell owns the SampleChoice list, peak envelopes, and filter state.
// strip of bank filters (one tab per bank_book bank + an "All" tab) above the grid. This
// module knows only COUNTS and RECTS — it draws nothing and holds no sample data; the
// shell owns the SampleChoice list, the peak envelopes, and the filter state, and asks this
// module only "where does card i draw" / "what did the user click".
// //
// Scroll is NOT here (S12 layers it over this module). The browser lays out every card // Scroll is layered on top by browser_scroll — this module lays out every card top-down
// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping // and the shell clips at the bottom until a scroll offset is applied.
// scroll out keeps this module the stable card/tab geometry S12 builds on.
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom).
#pragma once #pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom #include "core/instrument/ui/editor_geometry.h" // Rect, contains
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a // Fixed browser metrics, exposed so the shell and tests agree.
// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list. inline constexpr int kBrowserTabHeight = 26;
inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height inline constexpr int kBrowserCardWidth = 132;
inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter) inline constexpr int kBrowserCardHeight = 84;
inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter) inline constexpr int kBrowserCardGutter = 8;
inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card inline constexpr int kBrowserThumbHeight = 44;
inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card
// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to // Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
// the area so a degenerate (tiny/zero) size never yields an inverted rect.
struct BrowserLayout { struct BrowserLayout {
Rect tabStrip; // top: the bank-filter tabs Rect tabStrip;
Rect grid; // below the tabs: where the capture cards tile Rect grid;
int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width int columns = 1; // cards per row in `grid` (>= 1)
}; };
// Divide a (w x h) browser area into its regions and compute the column count. Pure: same // Divide a (w x h) browser area into its regions and compute the column count. columns =
// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never // max(1, grid.width/cardWidth) so a browser narrower than one card still lays out a
// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a // single column.
// browser narrower than one card still lays out a single column. A zero/negative size
// yields empty rects + columns==1.
BrowserLayout layoutBrowser(int w, int h); BrowserLayout layoutBrowser(int w, int h);
// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then // Cell rect of capture card `index` (0-based), left-to-right then top-to-bottom across
// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect // `columns`. This is the full cell (card + gutter); cardContentRect insets it.
// insets it to the drawable card. Rows past the visible grid are still computed (the shell
// clips at paint time). A negative index yields an empty rect. Pure.
Rect cardCellRect(const BrowserLayout& layout, int index); Rect cardCellRect(const BrowserLayout& layout, int index);
// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides. // Drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides.
// The shell fills this (background + border) and draws the thumbnail/name/badge inside it. Pure.
Rect cardContentRect(const BrowserLayout& layout, int index); Rect cardContentRect(const BrowserLayout& layout, int index);
// The peak-thumbnail sub-rect at the top of a card's content: full card width, the top // Peak-thumbnail sub-rect at the top of a card's content: full card width, the top
// kBrowserThumbHeight (clamped to the card height). The shell draws the envelope here; the // kBrowserThumbHeight (clamped to the card height).
// name + badge go in the remaining strip below. Pure.
Rect cardThumbnailRect(const BrowserLayout& layout, int index); Rect cardThumbnailRect(const BrowserLayout& layout, int index);
// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band. // Name/badge sub-rect below the thumbnail.
// The shell draws the display name + root/key badge here. Pure.
Rect cardLabelRect(const BrowserLayout& layout, int index); Rect cardLabelRect(const BrowserLayout& layout, int index);
// The card a click at (x, y) lands on, given `cardCount` cards, or -1 for a click outside // Card a click at (x, y) lands on, given `cardCount` cards, or -1 for a miss. Only the
// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT // card content rect counts as a hit — a click in the inter-card gutter is a miss.
// rect counts as a hit — a click in the inter-card gutter is a miss. Pure.
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y); int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y);
// --- Bank-filter tabs -------------------------------------------------------- // --- Bank-filter tabs ---------------------------------------------------------
// //
// The tab strip divides tabStrip into `tabCount` equal segments (mirror of mode_switch): // Divides tabStrip into `tabCount` equal segments: one tab per bank plus a leading "All"
// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount == // tab the shell prepends. This module only divides the strip + hit-tests; the shell
// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell // supplies labels and tracks the active tab.
// supplies the labels and tracks which tab is active. A tab click narrows the card list to
// that bank (the shell filters its SampleChoice list before laying out cards).
// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal // Rect of tab `index` when the strip is divided into `tabCount` equal segments. The last
// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with // tab absorbs any width remainder so the tabs tile the whole strip with no gap.
// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an
// empty rect. Pure.
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index); Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index);
// The tab a click at (x, y) lands on, given `tabCount` tabs, or -1 for a click outside the
// tab strip. Pure.
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y); int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+18 -24
View File
@@ -1,17 +1,12 @@
// curve_popup.h — PURE sheet geometry + dismissal test for the r11 velocity-curve popup // curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor.
// editor (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror // Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via
// of overflow_menu: the size-clamp / centering / title-row arithmetic lives here, unit-tested // these rects.
// at the clamps outside the DAW, while the editor shell draws the wash + sheet through the
// L1 kit and routes clicks (close / curve box / outside-sheet dismiss) via these rects.
// //
// THE POPUP (CONTEXT.md §S-VIEW r11). Summoned by the mini curve-preview button, a CENTERED // A centered sheet over the Sample face (a lighter wash than Browse's, since this is a
// SHEET over the Sample face (a 0.50-alpha bg/base wash behind it — lighter than Browse's // focused sub-editor, not a view change): width/height each clamp to a fraction of the
// 0.82; a focused sub-editor, not a view change): width clamp(60% of window, 360..520), // window within min/max bounds. A title row sits over the curve box. The curve box rect
// height clamp(55% of window, 260..380). Inside: a ~22px title row ("VELOCITY -> AMP" // here is the border rect — the shell derives the mapping box via its curveBoxFromRect
// micro-caps left, an 18x18 Close button right) over the full-size curve box filling the // formula, so the popup editor and the Zone-panel inline editor share coordinates.
// remainder. The curve box rect here is the BORDER rect — the shell derives the mapping box
// through its ONE curveBoxFromRect formula (the landed inset grammar), so the popup editor
// and the Zone-panel inline editor share coordinates by construction.
#pragma once #pragma once
@@ -19,7 +14,7 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// Fixed popup metrics (spec r11), exposed so the shell and tests agree. // Fixed popup metrics, exposed so the shell and tests agree.
inline constexpr int kCurvePopupMinW = 360; inline constexpr int kCurvePopupMinW = 360;
inline constexpr int kCurvePopupMaxW = 520; inline constexpr int kCurvePopupMaxW = 520;
inline constexpr int kCurvePopupMinH = 260; inline constexpr int kCurvePopupMinH = 260;
@@ -29,20 +24,19 @@ inline constexpr int kCurvePopupCloseSize = 18;
inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins) inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins)
struct CurvePopupLayout { struct CurvePopupLayout {
Rect sheet; // the bg/panel sheet, centered in the window Rect sheet;
Rect title; // the caption text rect (left part of the title row) Rect title;
Rect close; // the 18x18 Close (x) button, right-anchored in the title row Rect close; // Close (x) button, right-anchored in the title row
Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect) Rect curveBox; // full-size curve editor border rect (shell insets via curveBoxFromRect)
}; };
// The popup geometry for a (w x h) window: sheet width clamp(60% w, 360..520) and height // Popup geometry for a (w x h) window: sheet width clamp(60% w, min..max) and height
// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate // clamp(55% h, min..max), each additionally capped at the window dimension so a
// window never yields an overhanging sheet — centered; title row + close button at the top; // degenerate window never yields an overhanging sheet — centered.
// the curve box filling the remainder inside kCurvePopupPad margins. Pure.
CurvePopupLayout computeCurvePopup(int w, int h); CurvePopupLayout computeCurvePopup(int w, int h);
// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test. // True when (x, y) lands outside the sheet (on the wash) — the click-outside dismissal
// The shell additionally gates on "no drag in flight" (spec). Pure. // test. The shell additionally gates on "no drag in flight".
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y); bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+16 -51
View File
@@ -8,8 +8,6 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// Spike editor layout constants. These are the editor's fixed metrics; the real
// editor (S4/S5) will parameterize as its content demands.
constexpr int kTitleBarHeight = 28; constexpr int kTitleBarHeight = 28;
constexpr int kButtonMargin = 10; constexpr int kButtonMargin = 10;
constexpr int kButtonWidth = 120; constexpr int kButtonWidth = 120;
@@ -17,26 +15,18 @@ constexpr int kButtonHeight = 24;
} // namespace } // namespace
// contains() now lives with the shared ui::Rect (core/ui/rect.h) — same half-open
// semantics, re-exported through the header's using-declaration.
EditorLayout layoutEditor(int w, int h) { EditorLayout layoutEditor(int w, int h) {
// Clamp the surface to non-negative extents so a degenerate view can't produce // Clamp to non-negative extents so a degenerate view can't produce inverted rects.
// inverted rects.
const int cw = std::max(0, w); const int cw = std::max(0, w);
const int ch = std::max(0, h); const int ch = std::max(0, h);
EditorLayout out; EditorLayout out;
// Title bar spans the top, clamped so it never exceeds the client height.
const int titleH = std::min(kTitleBarHeight, ch); const int titleH = std::min(kTitleBarHeight, ch);
out.titleBar = Rect::ltrb(0, 0, cw, titleH); out.titleBar = Rect::ltrb(0, 0, cw, titleH);
// Canvas is everything below the title bar.
out.canvas = Rect::ltrb(0, titleH, cw, ch); out.canvas = Rect::ltrb(0, titleH, cw, ch);
// Button sits at the top-left of the canvas, inset by a margin, and is clamped to // Button inset from the canvas top-left, clamped so it never overhangs a small view.
// fit inside the canvas so it never overhangs on a small view.
const int bx = out.canvas.x + kButtonMargin; const int bx = out.canvas.x + kButtonMargin;
const int by = out.canvas.y + kButtonMargin; const int by = out.canvas.y + kButtonMargin;
const int bRight = std::min(bx + kButtonWidth, out.canvas.right()); const int bRight = std::min(bx + kButtonWidth, out.canvas.right());
@@ -59,15 +49,11 @@ Rect sampleRowRect(const EditorLayout& layout, int index) {
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) { int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
if (rowCount <= 0) return -1; if (rowCount <= 0) return -1;
// Must be within the canvas horizontally and at/below its top.
if (x < layout.canvas.x || x >= layout.canvas.right()) return -1; if (x < layout.canvas.x || x >= layout.canvas.right()) return -1;
if (y < layout.canvas.y) return -1; if (y < layout.canvas.y) return -1;
// Clip at the canvas bottom: clicks in the canvas's dead-zone below the last
// visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom().
if (y >= layout.canvas.bottom()) return -1; if (y >= layout.canvas.bottom()) return -1;
const int index = (y - layout.canvas.y) / kSampleRowHeight; const int index = (y - layout.canvas.y) / kSampleRowHeight;
if (index < 0 || index >= rowCount) return -1; if (index < 0 || index >= rowCount) return -1;
// Guard the bottom edge: a click below the last row's bottom is outside.
const Rect r = sampleRowRect(layout, index); const Rect r = sampleRowRect(layout, index);
if (y >= r.bottom()) return -1; if (y >= r.bottom()) return -1;
return index; return index;
@@ -80,9 +66,6 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) {
out.base = layoutEditor(w, h); out.base = layoutEditor(w, h);
const Rect& canvas = out.base.canvas; const Rect& canvas = out.base.canvas;
// Split the canvas vertically: the left column is the bank-sample list, the right
// column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so
// the split point never crosses the canvas edges.
const int canvasW = std::max(0, canvas.width); const int canvasW = std::max(0, canvas.width);
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
const int splitX = std::max(canvas.x, canvas.right() - splitW); const int splitX = std::max(canvas.x, canvas.right() - splitW);
@@ -90,13 +73,11 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) {
out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom()); out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom());
out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom()); out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom());
// "Add Zone" button spans the top of the zone panel, clamped to its height.
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height)); const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height));
out.addZoneButton = out.addZoneButton =
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(), Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
out.zonePanel.y + addH); out.zonePanel.y + addH);
// Zone rows stack below the button.
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(), out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
out.zonePanel.right(), out.zonePanel.bottom()); out.zonePanel.right(), out.zonePanel.bottom());
return out; return out;
@@ -138,10 +119,8 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int
const Rect row = zoneRowRect(layout, index); const Rect row = zoneRowRect(layout, index);
if (y >= row.bottom()) return ZoneHit{}; if (y >= row.bottom()) return ZoneHit{};
// Seven mini-buttons pinned to the right edge, right-to-left: // Seven mini-buttons pinned to the right edge, each kZoneCtrlWidth wide, in slot
// delete, root+, root-, high+, high-, low+, low- // order 0..6; a click left of the leftmost is the label ("select").
// Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select").
// The fields laid out LEFT-TO-RIGHT in slot order 0..6.
const ZoneField fields[7] = { const ZoneField fields[7] = {
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown, ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp, ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
@@ -159,35 +138,25 @@ bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
return contains(layout.addZoneButton, x, y); return contains(layout.addZoneButton, x, y);
} }
// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ----------------------
// Bodies moved verbatim from the reasampler_editor shell (behavior-identical); the
// only signature change is clusterRects' `knobSize` parameter (formerly knob_deck's
// kDeckKnobSize read directly — passed in so this module stays knob_deck-free).
namespace { namespace {
// Fixed band metrics (formerly the editor shell's anon-ns constants). constexpr int kHeroMinHeight = 150; // elastic hero's floor
constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11)
constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle
constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip) constexpr int kStripBandHeight = 40; // keyboard-strip band height (root strip + zone strip)
// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial // Cluster's fixed right-anchored run: Preview button, vel knob cell, curve button, Mono|Stereo.
// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo).
constexpr int kPreviewBtnW = 64; constexpr int kPreviewBtnW = 64;
constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) constexpr int kVelCellW = 48;
constexpr int kCurveBtnSize = 28; // the square curve-preview button constexpr int kCurveBtnSize = 28;
// The S7 mono/stereo toggle segments.
constexpr int kChanSegW = 52; constexpr int kChanSegW = 52;
constexpr int kChanSegH = 18; constexpr int kChanSegH = 18;
} // namespace } // namespace
// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed // Band order: title (fixed) -> hero (elastic, absorbs remaining height, floor
// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- // kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-anchored). A
// anchored). When the window is too short for the floor (below the checkSizeConstraint // window too short for the floor keeps the hero at its floor and clips lower bands.
// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the
// window bottom gracefully.
SampleBands computeSampleBands(int w, int h, int deckH) { SampleBands computeSampleBands(int w, int h, int deckH) {
SampleBands b; SampleBands b;
const int titleH = (std::min)(kTitleHeight, h); const int titleH = (std::min)(kTitleHeight, h);
@@ -214,7 +183,6 @@ SampleBands computeSampleBands(int w, int h, int deckH) {
return b; return b;
} }
// Draw + hit-test both derive from this ONE formula.
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) { ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) {
ClusterRects r; ClusterRects r;
const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2; const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2;
@@ -261,16 +229,14 @@ Rect zoneDeleteRect(const Rect& addR) {
return Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); return Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom());
} }
// Zone content sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px // Sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px gap.
// gap, padded kPad horizontally. All call sites use this formula.
Rect zonesStripArea(const Rect& content) { Rect zonesStripArea(const Rect& content) {
const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12 const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12
return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad, return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad,
stripTop + kStripBandHeight); stripTop + kStripBandHeight);
} }
// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom // Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom.
// without re-inlining the strip arithmetic here.
Rect noteEntryFieldsArea(const Rect& content) { Rect noteEntryFieldsArea(const Rect& content) {
const int stripBottom = zonesStripArea(content).bottom(); const int stripBottom = zonesStripArea(content).bottom();
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8) const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8)
@@ -292,9 +258,8 @@ Rect zonesControlPanel(const Rect& content) {
content.bottom() - 4); content.bottom() - 4);
} }
// FB2 (R11-F2 parity): the deck lays out from the panel top (top-anchored), with a // Top-anchored; reserves a column at the panel's right for the curve-preview button so
// column at the panel's right reserved for the mini curve-preview button so no deck row // no deck row starts inside it.
// starts inside it.
Rect zonesDeckArea(const Rect& content) { Rect zonesDeckArea(const Rect& content) {
const Rect panel = zonesControlPanel(content); const Rect panel = zonesControlPanel(content);
return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom()); return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom());
+78 -118
View File
@@ -1,14 +1,6 @@
// editor_geometry.h — PURE view geometry + hit-test for the VST3 IPlugView LICE // editor_geometry.h — view geometry + hit-test for the VST3 IPlugView LICE editor. The
// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. // IPlugView shell owns window/bitmap/SWELL plumbing; the rectangle math and hit-testing
// // live here so they can be unit-tested outside the DAW.
// The IPlugView shell (reasampler_editor.cpp) owns the window/bitmap/SWELL plumbing
// and is DAW-verified; this module holds the fiddly rectangle math and hit-testing so
// it can be unit-tested outside the DAW — the mirror of how bank_grid / mode_switch /
// tab_strip split their layout math out of the panel shell.
//
// The spike's editor is deliberately trivial (a title band + one clickable button),
// enough to PROVE the host->draw/hit-test event routing works. As the real editor
// (S4/S5) grows, its layout math accretes here, not in the shell.
#pragma once #pragma once
@@ -16,101 +8,80 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The shared pixel rectangle + containment test (Q-W1, T2-05 ≡ T4-21): the former
// LTRB Rect defined here is folded into the ONE concrete ui::Rect (XYWH storage,
// right()/bottom() accessors, Rect::ltrb() for edge-wise construction, same
// half-open convention). Aliased here so every instrument-ui call site keeps its
// established `Rect` / `contains` spelling.
using Rect = ::reasampler::ui::Rect; using Rect = ::reasampler::ui::Rect;
using ::reasampler::ui::contains; using ::reasampler::ui::contains;
// The regions the spike editor draws, derived from the current view size. All are // Title band + one button + remaining canvas, clamped so a degenerate (too-small) view
// clamped to the client area so a degenerate (too-small) view never yields a region // never yields a region spilling outside the surface.
// that spills outside the surface.
struct EditorLayout { struct EditorLayout {
Rect titleBar; // top band: the plugin name + a live-state readout Rect titleBar;
Rect button; // a single clickable button (proves hit-test routing) Rect button;
Rect canvas; // the remaining surface below the title bar Rect canvas;
}; };
// Divide a (w x h) client area into the spike editor's regions. Pure: the same // Divide a (w x h) client area into the editor's top-level regions. Pure.
// inputs always yield the same layout. Guards tiny sizes — every returned rect stays
// within [0,w] x [0,h], and the button never overhangs the canvas.
EditorLayout layoutEditor(int w, int h); EditorLayout layoutEditor(int w, int h);
// The editor's hit-test targets. kNone means the point landed on inert surface.
enum class HitTarget { enum class HitTarget {
kNone, kNone,
kButton, kButton,
}; };
// Classify a click at (x, y) against a layout. The button wins only when the point is // Classify a click at (x, y) against a layout.
// inside the button rect; everything else (including the title bar and empty canvas)
// is kNone in the spike.
HitTarget hitTest(const EditorLayout& layout, int x, int y); HitTarget hitTest(const EditorLayout& layout, int x, int y);
// --- Sample-selection list (S4 Tier-0 UI) ----------------------------------- // --- Sample-selection list ---------------------------------------------------
// //
// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows // A vertical stack of fixed-height rows below the title bar; clicking a row selects that
// below the title bar; clicking a row selects that sample. This is the pure geometry: // sample. Pure geometry only — the shell draws names and routes the click.
// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the
// shell draws the names and routes the click into the processor's reloadInstrument.
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
inline constexpr int kSampleRowHeight = 22; inline constexpr int kSampleRowHeight = 22;
// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside // Rect for row `index` (0-based), laid out top-down inside the layout's canvas. Rows
// the layout's canvas. Rows beyond what the canvas can show are still computed (the // beyond what the canvas can show are still computed (the shell clips at paint time); a
// shell clips at paint time); a negative index yields an empty rect. Pure. // negative index yields an empty rect.
Rect sampleRowRect(const EditorLayout& layout, int index); Rect sampleRowRect(const EditorLayout& layout, int index);
// The row index a click at (x, y) lands on, given `rowCount` rows, or -1 for a click // Row index a click at (x, y) lands on given `rowCount` rows, or -1 for a click outside
// outside the list (above the first row, past the last, or on the title bar). Pure. // the list.
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y); int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
// --- Keymap editor (S5 Tier-1 UI) ------------------------------------------- // --- Keymap editor ------------------------------------------------------------
// //
// The Tier-1 editor splits the canvas into a LEFT bank-sample list (the same rows as // Splits the canvas into a LEFT bank-sample list (the sample-selection rows above, reused
// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing // as the "sample to add / fallback pick") and a RIGHT zone panel listing the performance
// the performance map's zones. An "Add Zone" button sits at the top of the zone panel; // map's zones. An "Add Zone" button sits at the top of the zone panel; each zone row
// each zone row carries small nudge/delete controls so the user can set the range and // carries nudge/delete mini-buttons (LICE has no native numeric entry field).
// root note without a text field (LICE has no native numeric entry). All rectangle math
// is here so the shell only draws + routes — the mirror of the sample-list split above.
// Fixed metrics for the zone panel, exposed so the shell and tests agree.
inline constexpr int kZoneRowHeight = 24; inline constexpr int kZoneRowHeight = 24;
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas
inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button
inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band height inline constexpr int kAddZoneHeight = 22; // "Add Zone" button band height
// The keymap editor's regions, derived from the (w x h) client area. All clamp to the // Clamps every rect to the canvas so a degenerate view still yields in-bounds rects.
// canvas so a degenerate view yields in-bounds rects.
struct KeymapEditorLayout { struct KeymapEditorLayout {
EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half) EditorLayout base;
Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here) Rect sampleList; // LEFT column
Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows Rect zonePanel; // RIGHT column
Rect addZoneButton; // top of the zone panel Rect addZoneButton; // top of the zone panel
Rect zoneRowArea; // below addZoneButton: where zone rows stack Rect zoneRowArea; // below addZoneButton
}; };
KeymapEditorLayout layoutKeymapEditor(int w, int h); KeymapEditorLayout layoutKeymapEditor(int w, int h);
// The rectangle for bank-sample row `index` inside the LEFT sample list column of a // Rect for bank-sample row `index` inside the LEFT column. Negative index -> empty.
// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside
// sampleList. Negative index -> empty. Pure.
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index); Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index);
// The bank-sample row a click lands on inside the left list, or -1 outside it. Pure. // Bank-sample row a click lands on inside the left list, or -1 outside it.
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y); int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative // Rect for zone row `index` inside zoneRowArea. Negative index -> empty.
// index -> empty. Pure.
Rect zoneRowRect(const KeymapEditorLayout& layout, int index); Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
// A zone row's interactive fields. The row is a horizontal strip: a label on the left, // A zone row's interactive fields: a label on the left, then seven fixed-width
// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+, // mini-buttons on the right (low-, low+, high-, high+, root-, root+, delete). kZoneNone
// root-, root+, delete). kZoneNone means the click missed a control // means the click missed a control (e.g. the label) — the shell may still treat that as
// (e.g. on the label) — the shell may still treat that as "select this zone". // "select this zone".
enum class ZoneField { enum class ZoneField {
kZoneNone, kZoneNone,
kLowDown, kLowDown,
@@ -122,101 +93,90 @@ enum class ZoneField {
kDelete, kDelete,
}; };
// The result of hit-testing a click against the zone rows: which zone row (or -1) and // Which zone row (or -1) and which field within it a click landed on. A click on
// which field within it. A click on the "Add Zone" button is reported separately by // "Add Zone" is reported separately by addZoneHitTest.
// addZoneHitTest — this covers only the zone rows.
struct ZoneHit { struct ZoneHit {
int zoneIndex = -1; int zoneIndex = -1;
ZoneField field = ZoneField::kZoneNone; ZoneField field = ZoneField::kZoneNone;
}; };
// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a // Classify a click at (x, y) against `zoneCount` zone rows. {-1, kZoneNone} for a miss.
// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width // Within a row, the seven mini-buttons occupy fixed-width slots on the right edge; a
// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete); // click left of those slots is {index, kZoneNone} (the label area — "select").
// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure.
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y); ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y);
// True if (x, y) lands on the "Add Zone" button. Pure.
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y); bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- // --- Sample / Zone face layout ------------------------------------------------
// //
// The capture-first editor's band/cluster/zone-surface layout math, hoisted out of the // The capture-first editor's band/cluster/zone-surface layout math. Draw and hit-test
// reasampler_editor shell where it had accreted untestable (the §2 scope gap). Draw and // both derive every rect from these formulas so they can never drift; the shell only
// hit-test both derive every rect from these ONE formulas so they can never drift; the // draws + routes. The Browse-modal layout lives in browser_scroll (its search box
// shell only draws + routes. The Browse-modal layout lives in browser_scroll (its search // height feeds it).
// box height feeds it — dependency-clean placement beside its scroll/search siblings).
// Shared band metrics (the shell's remaining direct uses: horizontal padding + the
// title-band height; everything else is internal to the layout functions below).
inline constexpr int kPad = 8; inline constexpr int kPad = 8;
inline constexpr int kTitleHeight = 26; inline constexpr int kTitleHeight = 26;
inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons
// The r11 Sample-face bands (top->bottom): a TITLE band (name + Browse/Zone nav), the // Sample-face bands (top->bottom): TITLE (name + Browse/Zone nav), a full-width elastic
// FULL-WIDTH ELASTIC HERO (absorbs all height left after the fixed bands, floor // HERO (absorbs all height left after the fixed bands, floored), the root+preview
// kHeroMinHeight), the ROOT + PREVIEW CLUSTER, and the bottom-anchored KNOB DECK // CLUSTER, and the bottom-anchored knob DECK (height `deckH` from knob_deck's wrap). A
// (height `deckH` from the pure knob_deck wrap). When the window is too short for the // window shorter than the hero floor clips the lower bands past the window bottom.
// hero floor (below the checkSizeConstraint minimum — defensive), the hero keeps its
// floor and the lower bands clip past the window bottom gracefully.
struct SampleBands { struct SampleBands {
Rect title; // top: name + Browse/Zone nav buttons Rect title;
Rect navBrowse; // the "Browse" title-band button Rect navBrowse;
Rect navZone; // the "Zone" title-band button Rect navZone;
Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay Rect hero; // waveform + envelope overlay
Rect cluster; // root strip + preview + vel knob + curve button + channel toggle Rect cluster; // root strip + preview + vel knob + curve button + channel toggle
Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap) Rect deck;
}; };
SampleBands computeSampleBands(int w, int h, int deckH); SampleBands computeSampleBands(int w, int h, int deckH);
// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the // Cluster sub-rects: the root strip keeps the left side at remainder width; the right
// right side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve // side is the fixed-width right-anchored run (Preview · vel knob cell · curve button ·
// preview button 28 · Mono|Stereo). `knobSize` is the deck knob square (knob_deck's // Mono|Stereo). `knobSize` is the deck knob square, passed in so this module does not
// kDeckKnobSize — passed in so this module does not depend on knob_deck). // depend on knob_deck.
struct ClusterRects { struct ClusterRects {
Rect rootStrip; // remainder-width fenced root strip Rect rootStrip;
Rect preview; // the preview-trigger button Rect preview;
Rect velCell; // the radial preview-velocity knob cell (knob + label band) Rect velCell; // preview-velocity knob cell (knob + label band)
Rect velKnob; // the knob square at the cell's top Rect velKnob;
Rect velLabel; // the label band beneath it Rect velLabel;
Rect curveBtn; // the mini curve-preview button (opens the popup) Rect curveBtn; // opens the curve-preview popup
}; };
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize); ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize);
// The S7 mono/stereo toggle: a two-segment control right-anchored in `area`, vertically // Mono/stereo toggle: a two-segment control right-anchored in `area`, vertically centered.
// centered. Returns {mono-segment, stereo-segment}, side by side.
struct ChannelToggleRects { struct ChannelToggleRects {
Rect mono; Rect mono;
Rect stereo; Rect stereo;
}; };
ChannelToggleRects channelToggleRects(const Rect& area); ChannelToggleRects channelToggleRects(const Rect& area);
// The Zone-view (S-VIEW-8) content area: the whole window below the title band. // Zone-view content area: the whole window below the title band.
Rect zoneContentArea(int w, int h); Rect zoneContentArea(int w, int h);
// The Zone/Browse "Back" title-band button (right-anchored — the same slot the Sample // Zone/Browse "Back" button — the same slot the Sample face's Zone nav button occupies.
// face's Zone nav button occupies).
Rect zoneBackRect(int w, int h); Rect zoneBackRect(int w, int h);
// The "+ Add Zone" affordance at the top of the Zone content, and the "Delete" button // "+ Add Zone" affordance and the "Delete" button beside it (Delete only draws/hits
// beside it (Delete only draws/hits when a zone is selected). // when a zone is selected).
Rect zoneAddRect(const Rect& content); Rect zoneAddRect(const Rect& content);
Rect zoneDeleteRect(const Rect& addR); Rect zoneDeleteRect(const Rect& addR);
// The Zone-view keyboard strip rect: below the "+ Add Zone" affordance with a 12px gap, // Zone-view keyboard strip rect: below "+ Add Zone" with a 12px gap, padded kPad
// padded kPad horizontally. // horizontally.
Rect zonesStripArea(const Rect& content); Rect zonesStripArea(const Rect& content);
// The S12 numeric-entry field ROW area inside the Zones legend (a band to the right of // Numeric-entry field row area inside the Zones legend, and the rect of field `f`
// the sample label), and the rect of field `f` (0=low, 1=high, 2=root) within it — // (0=low, 1=high, 2=root) within it — three equal segments left-to-right. Out-of-range
// three equal segments left-to-right. An out-of-range index yields an empty rect. // index yields an empty rect.
Rect noteEntryFieldsArea(const Rect& content); Rect noteEntryFieldsArea(const Rect& content);
Rect noteEntryFieldRect(const Rect& fields, int f); Rect noteEntryFieldRect(const Rect& fields, int f);
// The per-zone parameter panel below the strip + the one-line legend, running to the // Per-zone parameter panel below the strip + legend, running to the content bottom; the
// content bottom; the FB2 knob-deck area within it (a column at the right reserved for // knob-deck area within it (a right column reserved for the curve-preview button); and
// the mini curve-preview button); and that button's rect (the cluster's 28px square, // that button's rect (right-anchored at the panel top).
// right-anchored at the panel top).
Rect zonesControlPanel(const Rect& content); Rect zonesControlPanel(const Rect& content);
Rect zonesDeckArea(const Rect& content); Rect zonesDeckArea(const Rect& content);
Rect zonesCurveButton(const Rect& content); Rect zonesCurveButton(const Rect& content);
+5 -8
View File
@@ -8,17 +8,15 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// Clamp a MIDI note to [0, kEmbedKeyCount-1].
int clampNote(int n) { int clampNote(int n) {
if (n < 0) return 0; if (n < 0) return 0;
if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1; if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1;
return n; return n;
} }
// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given // Maps a key boundary (0..128) to an x pixel; keyEdge==128 maps to the band's right. A
// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right. // zone's left uses floor(low) and its right uses floor(high+1), tiling adjacent zones
// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1), // without a seam.
// which tiles adjacent zones without a seam.
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft; if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth; if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth;
@@ -33,9 +31,8 @@ EmbedLayout layoutEmbed(int w, int h) {
EmbedLayout out; EmbedLayout out;
// The level band takes a fixed height at the bottom, but never so much that the keymap // Fixed height at the bottom, but never so much that the keymap falls below its
// above it falls below its minimum (or that the band exceeds the area). On a very short // minimum; on a very short area the band yields to the keymap entirely.
// area the band yields to the keymap entirely.
int bandH = std::min(kEmbedLevelBandHeight, ch); int bandH = std::min(kEmbedLevelBandHeight, ch);
if (ch - bandH < kEmbedKeymapMinHeight) { if (ch - bandH < kEmbedKeymapMinHeight) {
bandH = std::max(0, ch - kEmbedKeymapMinHeight); bandH = std::max(0, ch - kEmbedKeymapMinHeight);
+28 -47
View File
@@ -1,76 +1,57 @@
// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3, // embed_strip.h — layout + hit-test for the embedded TCP/MCP strip. Mirror of
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / // editor_geometry/mode_switch; the embed shell marshals REAPER's embed messages (paint
// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives // bitmap + mouse coords) into these functions.
// here so it is unit-tested outside the DAW, while the embed shell (reasampler_embed.cpp)
// marshals REAPER's embed messages (paint bitmap + mouse coords) into these functions.
// //
// The strip is a single compact band REAPER draws inline in the track/mixer control panel // A single compact band REAPER draws inline in the track/mixer control panel via the
// (context TCP or MCP) via the Cockos embedded-UI surface. It shows: // Cockos embedded-UI surface: each performance zone as a horizontal segment across the
// * the zone layout — each performance zone as a horizontal segment across the keyboard // keyboard span (MIDI 0..127 mapped to the strip width), plus a thin activity level band
// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and // at the bottom. Interaction is zone selection only — no editing.
// * a thin level band at the bottom — a 0..1 activity indicator the shell fills.
// Interaction is zone SELECTION at most (S6 constraint: no new editing semantics) — a
// click maps to the zone whose key range covers that point, or -1.
//
// It reuses the same Rect + contains() as editor_geometry (the strip and the editor share
// one geometry idiom), so this header depends on editor_geometry.h rather than redefining
// a second rectangle type.
#pragma once #pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom #include "core/instrument/ui/editor_geometry.h" // Rect, contains
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's
// horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle.
inline constexpr int kEmbedKeyCount = 128; inline constexpr int kEmbedKeyCount = 128;
// Fixed metrics for the strip, exposed so the shell and tests agree. // Fixed metrics for the strip, exposed so the shell and tests agree.
inline constexpr int kEmbedLevelBandHeight = 4; // the bottom activity band (px) inline constexpr int kEmbedLevelBandHeight = 4;
inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller inline constexpr int kEmbedKeymapMinHeight = 6;
// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal // One zone rendered on the strip: its inclusive MIDI key range the minimal projection
// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM // of a PerformanceZone the strip needs (no sample ids or PCM). Expected in [0,127] with
// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are // low <= high; layout clamps defensively regardless.
// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed
// zone never yields an out-of-strip rect.
struct EmbedZone { struct EmbedZone {
int lowNote = 0; int lowNote = 0;
int highNote = 127; int highNote = 127;
}; };
// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to // Clamped to the area so a degenerate (tiny) size never yields a region spilling outside
// the area so a degenerate (tiny) size never yields a region spilling outside the surface. // the surface.
struct EmbedLayout { struct EmbedLayout {
Rect keymap; // top: the zone-segment band (the compact keymap) Rect keymap; // top: zone-segment band
Rect levelBand; // bottom: the thin level/activity indicator Rect levelBand; // bottom: level/activity indicator
}; };
// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout. // Divide a (w x h) embed area into the strip's regions. The level band takes a fixed
// The level band takes a fixed height at the bottom (clamped so it never exceeds the area // height at the bottom (clamped so it never starves the keymap below
// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or // kEmbedKeymapMinHeight); the keymap takes the rest.
// negative size yields empty rects (no inversion).
EmbedLayout layoutEmbed(int w, int h); EmbedLayout layoutEmbed(int w, int h);
// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote] // Horizontal sub-rect of the keymap band for a zone spanning [lowNote, highNote]
// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect // (inclusive). Spans the half-open pixel range so adjacent zones tile without a gap or
// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g. // overlap. Notes clamp to [0,127] and low clamps to <= high.
// 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low
// is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect,
// never an inverted one. Pure.
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote); Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote);
// The zone a click at (x, y) lands on, given the zones in draw order, or -1 for a click // Zone a click at (x, y) lands on, given zones in draw order, or -1 for a miss. When
// outside the keymap band or on a key not covered by any zone. When zones overlap on a // zones overlap on a key, the first covering zone in order wins — mirroring the sampler
// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match // core's first-match Keymap::resolve, so selection agrees with playback.
// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure.
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
int y); int y);
// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the // Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand
// returned rect is the left sub-rectangle of levelBand whose width is level * band width // whose width is level * band width.
// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure.
Rect levelFillRect(const EmbedLayout& layout, double level); Rect levelFillRect(const EmbedLayout& layout, double level);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+19 -43
View File
@@ -9,34 +9,28 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion).
// Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the
// area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX.
double secondsPerPixel(const Rect& area, double totalSeconds) { double secondsPerPixel(const Rect& area, double totalSeconds) {
const int w = std::max(0, area.width); const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return 0.0; if (w <= 0 || totalSeconds <= 0.0) return 0.0;
return totalSeconds / static_cast<double>(w); return totalSeconds / static_cast<double>(w);
} }
// Seconds per pixel for a GATE time-node drag (FA2): the reciprocal of the overlay's // Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a
// param-domain gatePxPerSecond(area) scale — sample-length-free, matching // dragged handle tracks the cursor 1:1.
// envelope_overlay::gatePolyline exactly so the dragged handle tracks the cursor 1:1 (each
// node's x is affine in its own segment duration with slope gatePxPerSecond). Zero when the
// area is degenerate.
double gateSecondsPerPixel(const Rect& area) { double gateSecondsPerPixel(const Rect& area) {
const double pps = gatePxPerSecond(area); const double pps = gatePxPerSecond(area);
return pps > 0.0 ? 1.0 / pps : 0.0; return pps > 0.0 ? 1.0 / pps : 0.0;
} }
// Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one // Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]).
// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY.
double levelPerPixel(const Rect& area) { double levelPerPixel(const Rect& area) {
const int h = std::max(0, area.height); const int h = std::max(0, area.height);
if (h <= 1) return 0.0; if (h <= 1) return 0.0;
return 1.0 / static_cast<double>(h - 1); return 1.0 / static_cast<double>(h - 1);
} }
// True for the nodes the user can grab-and-drag (Origin + ReleaseStart are draw-only anchors). // Origin + ReleaseStart are draw-only anchors, not grabbable.
bool isDraggable(EnvNode n) { bool isDraggable(EnvNode n) {
switch (n) { switch (n) {
case EnvNode::Origin: case EnvNode::Origin:
@@ -47,10 +41,8 @@ bool isDraggable(EnvNode n) {
} }
} }
// True when the node belongs to the envelope's active mode. Guards the degenerate cross-mode // Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in
// write: the degenerate baseline polyline carries a ReleaseEnd vertex regardless of mode, so a // Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver.
// zero-height Trigger-mode grab of it must not write releaseSeconds (and vice versa for Gate
// nodes vs Trigger fields). Applied by BOTH the hit-test and the drag resolver so they agree.
bool nodeInMode(EnvNode n, EnvMode m) { bool nodeInMode(EnvNode n, EnvMode m) {
switch (n) { switch (n) {
case EnvNode::AttackEnd: case EnvNode::AttackEnd:
@@ -64,7 +56,7 @@ bool nodeInMode(EnvNode n, EnvMode m) {
return m == EnvMode::Trigger; return m == EnvMode::Trigger;
case EnvNode::Origin: case EnvNode::Origin:
case EnvNode::ReleaseStart: case EnvNode::ReleaseStart:
return false; // never draggable in any mode (isDraggable filters these anyway) return false;
} }
return false; return false;
} }
@@ -73,18 +65,15 @@ bool nodeInMode(EnvNode n, EnvMode m) {
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) { NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds); const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
// NEAREST draggable, mode-matching node within the pick radius wins (Chebyshev distance // Nearest draggable, mode-matching node within the pick radius wins (Chebyshev distance);
// the square grab box); ties break to the earlier draw-order node (FA2). Gate nodes never // ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
// coincide (the forward map enforces kGateNodeSepPx separation), so the tie-break only // coincidence (FadeOutStart overlaps LengthEnd and wins).
// matters for Trigger's zero-fade-out coincidence: FadeOutStart overlays LengthEnd, WINS the
// tie, and can be dragged inward from the right edge. The mode filter keeps the degenerate
// baseline's ReleaseEnd vertex from registering as a grabbable node in Trigger mode.
NodeHit best; NodeHit best;
int bestDist = kNodeGrabRadius + 1; int bestDist = kNodeGrabRadius + 1;
for (const EnvVertex& v : poly) { for (const EnvVertex& v : poly) {
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue; if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue;
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y)); const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
if (dist < bestDist) { // strictly closer only: earlier draw order keeps ties if (dist < bestDist) { // strict-less-than keeps ties at the earlier draw order
bestDist = dist; bestDist = dist;
best = NodeHit{true, v.node}; best = NodeHit{true, v.node};
} }
@@ -101,16 +90,12 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
const double secPerPx = secondsPerPixel(area, totalSeconds); const double secPerPx = secondsPerPixel(area, totalSeconds);
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
const double dSec = static_cast<double>(dxPixels) * secPerPx; const double dSec = static_cast<double>(dxPixels) * secPerPx;
// Gate time nodes use the schematic's PARAM-DOMAIN px->seconds scale (FA2) — the reciprocal
// of the overlay's gatePxPerSecond, sample-length-free — so the dragged handle tracks the
// cursor 1:1. gateTimedWidth >= 1 whenever the area is non-empty, so gateDSec is
// well-defined past the degenerate guard above.
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(area); const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(area);
switch (node) { switch (node) {
// --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative // Gate: each cumulative-time node edits its own segment duration. Non-negative durations
// durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour // ARE the monotonic-in-time guarantee (a segment can never go negative, so a node can
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint. // never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd: case EnvNode::AttackEnd:
out.attackSeconds = out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
@@ -119,8 +104,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds); out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break; break;
case EnvNode::DecayEnd: { case EnvNode::DecayEnd: {
// Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower // X sets decay time, Y sets sustain level (drag down = higher y = lower level).
// level, so subtract the level delta).
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds); out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double lvlPerPx = levelPerPixel(area); const double lvlPerPx = levelPerPixel(area);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx; const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
@@ -132,17 +116,9 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds); std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
break; break;
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED // Trigger: fades + length are fractions. X pixels convert to a fraction of the played
// span (fades) or the whole sample (length). Monotonic: fadeIn + fadeOut <= 1 so the // span (fades) or the whole sample (length). fadeIn + fadeOut <= 1 keeps the two fade
// two fade nodes never cross (each clamps against the other), and length in [0, max]. // nodes from crossing (each clamps against the other).
//
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
// fadeInFraction/fadeOutFraction in AmpEnvelope are fractions of the played span.
// TriggerParams (sampler_core.h) stores the corresponding values as SOURCE FRAMES
// (fadeInFrames/fadeOutFrames, int64_t). The shell owes a converter on BOTH directions:
// pack (draw): fadeInFrames/fadeOutFrames -> fraction (needs frameCount + rate)
// unpack (commit): fraction -> fadeInFrames/fadeOutFrames (same inputs)
// See the TRIGGER SEAM note on AmpEnvelope in envelope_overlay.h for the formula.
case EnvNode::FadeInEnd: { case EnvNode::FadeInEnd: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division if (dxPixels == 0) break; // zero-motion grab: no param change, no division
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds; const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
+35 -72
View File
@@ -1,41 +1,18 @@
// envelope_edit.h — PURE node hit-test + pixel-deltaclamped-param inverse map for the S-VIEW-3 // envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror // envelope nodes. Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested
// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here, // outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
// unit-tested at the boundaries outside the DAW, while the editor shell (reasampler_editor.cpp)
// draws the handles, captures the grab on WM_LBUTTONDOWN, feeds each move's pixel delta back
// through here, and commits the resulting params to the zone through the same off-audio-thread
// path a slider edit uses.
// //
// TWO SURFACES, ONE MODEL. envelope_overlay owns the paramspolyline FORWARD map (draw); this // envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME // (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the zone
// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node // every paint), so a node drag and a slider edit are two views on one source of truth.
// drag and a slider edit are two views on one source of truth and can never diverge.
// //
// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't: // A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end // between time predecessor/successor) and range-clamped to the same per-param [min,max] the
// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0. // slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side).
// * RANGE-CLAMPED — times clamp to the SAME per-param [min,max] the slider enforces; levels
// clamp to [0,1]. Because the concrete second/fraction maxima live SHELL-SIDE (param_slider
// is deliberately engine-free — the shell owns the 0..1↔domain mapping), the clamp bounds are
// CALLER-SUPPLIED here (EnvClampBounds): the shell passes the same maxima it feeds the slider,
// so the two surfaces share one clamp by construction.
// //
// WHICH AXES. Time-only nodes (AttackEnd, HoldEnd, ReleaseEnd; FadeInEnd, FadeOutStart, // Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time,
// LengthEnd) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the // Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the // editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa).
// drawing-only ReleaseStart vertex are NOT draggable.
//
// GATE DRAG SCALE (FA2). Gate time nodes convert px->seconds via the reciprocal of the
// schematic's PARAM-DOMAIN scale (envelope_overlay's gatePxPerSecond — sample-length-free), so
// a dragged handle tracks the cursor exactly 1:1 for stages within the schematic domain (each
// node's x is affine in its own segment duration). Trigger nodes keep the full-canvas
// PCM-aligned scale. Both match the forward map in envelope_overlay. A node is only editable in
// its OWN mode: Gate nodes ignore drags while the envelope is in Trigger mode and vice versa
// (guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds).
//
// Reuses editor_geometry's Rect + the EnvNode / AmpEnvelope / EnvMode types from
// envelope_overlay (one shared node vocabulary across draw + edit), and the shared timeToX /
// levelToY maps so the handle the overlay drew and the grab region here agree pixel-for-pixel.
#pragma once #pragma once
@@ -47,60 +24,46 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and // Pick radius (px) around a node's drawn point, in both x and y. Mirrors waveform_view's
// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a // kMarkerGrabWidth.
// small handle comfortably, narrow enough that adjacent nodes stay distinguishable.
inline constexpr int kNodeGrabRadius = 6; inline constexpr int kNodeGrabRadius = 6;
// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All // Per-param clamp bounds the shell supplies the same maxima its sliders map [0,1] onto.
// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the // Lower bound is always 0; the monotonic-in-time constraint tightens further at edit time.
// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative // Defaults are placeholders; the shell overrides with its live slider domain.
// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly.
struct EnvClampBounds { struct EnvClampBounds {
double maxAttackSeconds = 4.0; // upper bound of the attack slider double maxAttackSeconds = 4.0;
double maxHoldSeconds = 4.0; double maxHoldSeconds = 4.0;
double maxDecaySeconds = 4.0; double maxDecaySeconds = 4.0;
double maxReleaseSeconds = 4.0; double maxReleaseSeconds = 4.0;
// Trigger fades + length are fractions; their natural upper bound is 1.0. Exposed so a shell
// that caps a fade below the full span (e.g. 0.5) shares that cap with its slider.
double maxFadeInFraction = 1.0; double maxFadeInFraction = 1.0;
double maxFadeOutFraction = 1.0; double maxFadeOutFraction = 1.0;
double maxLengthFraction = 1.0; double maxLengthFraction = 1.0;
// sustainLevel is always [0,1] — no shell knob needed, kept implicit. // sustainLevel is always [0,1] — no shell knob needed.
}; };
// Which node a grab at (x, y) lands on, given the CURRENT envelope + overlay rect + sample // Which node a grab at (x, y) lands on, given the current envelope/rect/duration (the same
// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles). // inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node;
// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a // Origin/ReleaseStart and nodes from the other mode never hit. Nearest node within the radius
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable), // wins (Chebyshev distance); an exact tie goes to the earlier draw-order node — this only matters
// and a node from the OTHER mode is never returned (the degenerate baseline's ReleaseEnd vertex // for Trigger's zero-fade-out coincidence (FadeOutStart overlaps LengthEnd and wins, so the fade
// is not grabbable in Trigger mode). The NEAREST node within the radius wins (Chebyshev // can be dragged open from zero). Gate nodes never coincide (forward map enforces
// distance); an exact tie goes to the earlier draw-order node (FA2 — deterministic). Gate nodes // kGateNodeSepPx), so every Gate handle is independently grabbable.
// never coincide (the forward map enforces kGateNodeSepPx separation, so every Gate handle is
// individually grabbable in every state); the tie-break matters only for Trigger's zero-fade-out
// coincidence, where FadeOutStart overlays LengthEnd, wins the tie, and can be dragged inward
// from the right edge. Pure.
struct NodeHit { struct NodeHit {
bool hit = false; bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true EnvNode node = EnvNode::Origin; // meaningful only when hit == true
}; };
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y); NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y);
// Resolve a drag of `node` to a new AmpEnvelope. Given the envelope AS OF GRAB TIME (`grabEnv` — // Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the
// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay // shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/
// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta // `dyPixels` is the pixel delta since grab.
// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe: // * X delta -> the node's time param, shifted via the same linear map as timeToX, clamped to
// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX), // [0, per-param max] and to its monotonic-in-time neighbours.
// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time, // * Y delta -> the level param, only for DecayEnd; clamped to [0,1]. Ignored for time-only nodes.
// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment // * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns
// duration (e.g. dragging HoldEnd changes holdSeconds, not attack). // `grabEnv` unchanged.
// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1]. // Only the dragged node's param(s) change. Pure.
// dyPixels is IGNORED for every time-only node.
// * Non-draggable node (Origin / ReleaseStart), a node from the OTHER mode (a Gate node while
// grabEnv.mode is Trigger, or vice versa), a zero-width/zero-height area, or
// totalSeconds <= 0 -> `grabEnv` returned unchanged (no motion).
// Only the dragged node's param(s) change; every other field carries through from `grabEnv`. Pure
// — rounding is to the param's continuous value (no snapping, matching the sliders' resolution).
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area, AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
double totalSeconds, const EnvClampBounds& bounds, double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels); int dxPixels, int dyPixels);
+18 -29
View File
@@ -8,15 +8,14 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) using util::clamp01;
int timeToX(const Rect& area, double totalSeconds, double t) { int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width); const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return area.x; if (w <= 0 || totalSeconds <= 0.0) return area.x;
if (t < 0.0) t = 0.0; if (t < 0.0) t = 0.0;
// Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the // Clamp in double space before the int cast — a huge t would overflow a 32-bit long
// last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge // (Windows) and wrap to the wrong edge.
// t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round.
double px = (t / totalSeconds) * static_cast<double>(w); double px = (t / totalSeconds) * static_cast<double>(w);
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1); if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
return area.x + static_cast<int>(px + 0.5); return area.x + static_cast<int>(px + 0.5);
@@ -33,9 +32,7 @@ int gateTimedWidth(const Rect& area) {
double gatePxPerSecond(const Rect& area) { double gatePxPerSecond(const Rect& area) {
const int timedW = gateTimedWidth(area); const int timedW = gateTimedWidth(area);
if (timedW <= 0) return 0.0; if (timedW <= 0) return 0.0;
// Usable width = timed region minus the four per-segment separation bases and the last // Minus the four per-segment separation bases and the last in-bounds column, floored at 1.
// in-bounds column, floored at 1 px so the scale never degenerates; the domain is the four
// stages end-to-end at their schematic maxima (param-domain scale — sample-length-free).
const double usable = const double usable =
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx)); std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
return usable / (4.0 * kGateStageMaxSeconds); return usable / (4.0 * kGateStageMaxSeconds);
@@ -46,8 +43,8 @@ int levelToY(const Rect& area, double level) {
if (h <= 0) return area.y; if (h <= 0) return area.y;
if (level < 0.0) level = 0.0; if (level < 0.0) level = 0.0;
if (level > 1.0) level = 1.0; if (level > 1.0) level = 1.0;
// Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The // Level 1 -> top row, level 0 -> bottom row; spans (h-1) px so both endpoints land on a
// range spans (h-1) pixels so both endpoints land ON a drawable row. // drawable row.
const int span = h - 1; const int span = h - 1;
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5); const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
return area.y + static_cast<int>(dy); return area.y + static_cast<int>(dy);
@@ -64,10 +61,8 @@ EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, dou
return v; return v;
} }
// One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space — // Gate works in px space (timed px + the fixed sustain-plateau reserve) rather than the plain
// timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x in // timeToX map; clamps in double space before the int cast for the same overflow reason as above.
// DOUBLE space to the last in-bounds column BEFORE the integer cast (FA2 bounds invariant; a
// huge px would overflow a 32-bit long on Windows and wrap to the WRONG edge).
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) { EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
const int w = std::max(1, area.width); const int w = std::max(1, area.width);
if (px < 0.0) px = 0.0; if (px < 0.0) px = 0.0;
@@ -81,18 +76,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
} }
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) { std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
// Non-negative segment durations (a stored negative would be an upstream bug; clamp defensively). // Clamp defensively — a stored negative duration would be an upstream bug.
const double a = std::max(0.0, env.attackSeconds); const double a = std::max(0.0, env.attackSeconds);
const double h = std::max(0.0, env.holdSeconds); const double h = std::max(0.0, env.holdSeconds);
const double d = std::max(0.0, env.decaySeconds); const double d = std::max(0.0, env.decaySeconds);
const double r = std::max(0.0, env.releaseSeconds); const double r = std::max(0.0, env.releaseSeconds);
const double sus = clamp01(env.sustainLevel); const double sus = clamp01(env.sustainLevel);
// BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved // A/H/D/R map onto the timed region at the param-domain scale, each segment getting a
// sustain-plateau width) at the PARAM-DOMAIN scale — sample-length-free — and every segment // kGateNodeSepPx base so nodes never coincide even at the tier-0 zero-hold/zero-decay
// gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually // defaults. The sustain plateau is the fixed reserve between DecayEnd and ReleaseStart.
// grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain
// plateau is the fixed reserve between DecayEnd and ReleaseStart.
const int W = std::max(1, area.width); const int W = std::max(1, area.width);
const double sustainPx = static_cast<double>(W - gateTimedWidth(area)); const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
const double sep = static_cast<double>(kGateNodeSepPx); const double sep = static_cast<double>(kGateNodeSepPx);
@@ -104,10 +97,9 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off) double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
// Right-edge overrun (a stored stage beyond the schematic domain): compress from the RIGHT // Overrun beyond the schematic domain compresses from the right, preserving minimum gaps so
// preserving the minimum gaps, so trailing nodes stay individually separated instead of // trailing nodes stay separated instead of piling on the last column. This re-floor only
// piling on the last column. The re-floor pass only bites when the canvas is too narrow to // bites when the canvas is too narrow to hold the gaps at all — gateVtx's clamp wins then.
// hold the minimum gaps at all — then gateVtx's [0, W-1] clamp wins (in-bounds > separation).
const double xMax = static_cast<double>(W - 1); const double xMax = static_cast<double>(W - 1);
if (xRelease > xMax) { if (xRelease > xMax) {
xRelease = xMax; xRelease = xMax;
@@ -135,12 +127,11 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area, std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) { double totalSeconds) {
// The played span is lengthFraction of the whole sample; fades are fractions OF that span. // Played span is lengthFraction of the whole sample; fades are fractions of that span.
const double len = clamp01(env.lengthFraction); const double len = clamp01(env.lengthFraction);
double fadeIn = clamp01(env.fadeInFraction); double fadeIn = clamp01(env.fadeInFraction);
double fadeOut = clamp01(env.fadeOutFraction); double fadeOut = clamp01(env.fadeOutFraction);
// Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the // Fades cannot overlap; trim fade-out first, matching the engine's TriggerParams clamp.
// engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine.
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn); if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
const double playSeconds = len * totalSeconds; const double playSeconds = len * totalSeconds;
@@ -161,12 +152,10 @@ std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) { double totalSeconds) {
if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) { if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) {
// Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line. // Degenerate surface: flat two-point baseline so the shell always has a line.
return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0), return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)}; vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)};
} }
// Gate is a param-domain schematic — totalSeconds only gates the degenerate branch above
// (no loaded duration -> baseline); Trigger is PCM-aligned and consumes it.
return env.mode == EnvMode::Gate ? gatePolyline(env, area) return env.mode == EnvMode::Gate ? gatePolyline(env, area)
: triggerPolyline(env, area, totalSeconds); : triggerPolyline(env, area, totalSeconds);
} }
+54 -176
View File
@@ -1,59 +1,7 @@
// envelope_overlay.h — PURE amp-envelope polyline geometry for the S-VIEW-3 Sample-view // envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of // Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside // param_slider. The shell packs the zone's AdsrSeconds/TriggerParams into AmpEnvelope and draws
// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue // the polyline plus a handle at each node (envelope_edit does the hit-test).
// and draws the node handles (via envelope_edit's hit-test).
//
// WHAT IT DRAWS. The amp envelope over the Sample view's hero waveform (Simpler / Phase-Plant
// grammar):
// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain,
// sustain plateau, release sustain->0. Since there is no held note-off to draw
// against, Gate is a BOUNDED SCHEMATIC (FA2): a fixed fraction of the canvas
// width (kGateSustainDisplayFraction) is RESERVED for the sustain plateau, and
// the remaining "timed" width carries A/H/D AND the release at the PARAM-DOMAIN
// scale — the timed width represents 4 x kGateStageMaxSeconds (the four stage
// sliders end-to-end at their maxima), NOT the sample's duration, so the layout
// is identical for a 0.3s and a 10s capture. Each segment additionally gets a
// kGateNodeSepPx pixel base, so consecutive nodes NEVER coincide: every Gate
// node is individually grabbable at ANY param values, including the tier-0
// defaults (hold 0 / decay 0). A -> (H) -> D -> S-plateau -> R all render INSIDE
// the canvas and the release is a visible, draggable segment.
// * Trigger -> the fade/%-length shape: fade-in 0->1, unity plateau, fade-out 1->0 anchored
// to playEnd (= lengthFraction of the post-start span). Trigger keeps the
// waveform's exact time base so the shape lines up with the PCM under it.
// The horizontal axis is TIME (Gate: schematic, see above; Trigger: wall-clock across the rect);
// the vertical axis is LEVEL (0 at rect bottom, 1 at rect top).
//
// BOUNDS INVARIANT (FA2). EVERY vertex of EVERY polyline is clamped inside the canvas:
// x in [area.x, area.right()-1], y in [area.y, area.bottom()-1] (half-open rect convention).
// No node and no drawn segment ever exceeds the canvas — paint-time clipping of handles is no
// longer needed (and never fires) in the shell.
//
// FA2 CONTRACT CHANGE — WAVE B SHELL AUTHOR, READ THIS:
// * The EnvNode enum is UNCHANGED (same node set, same draggable set — Origin + ReleaseStart
// remain the only non-draggable anchors).
// * ALL vertices are now in-bounds (see above). The shell's previous "skip handle when
// v.x >= waveArea.right()" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd
// (Trigger, at full length / zero fade-out) now land at area.right()-1 and MUST get handles.
// * Gate's x-axis is SCHEMATIC, not PCM-aligned: the timed region is scaled to the param
// domain (4 x kGateStageMaxSeconds), the sustain reserve is a fixed width, and every
// segment carries a kGateNodeSepPx pixel base. The Gate curve does NOT line up with the
// waveform under it — do not label it as if it did. Trigger's x-axis IS still PCM-aligned.
// * Gate nodes never coincide (min-separation, above), so every Gate handle is individually
// grabbable in every state. nodeAtPoint (envelope_edit) resolves to the NEAREST node within
// the grab radius with a draw-order tie-break; the tie-break only matters for the one
// remaining coincidence, Trigger's zero-fade-out (FadeOutStart overlays LengthEnd at the
// right edge and wins the tie, so the fade can be dragged open from zero).
//
// DELIBERATELY ENGINE-FREE (house pattern — param_slider does the same). It does NOT depend on
// sample_map / sampler_core (which would drag bank_book / wav_codec in). The shell reads the
// zone's AdsrSeconds / TriggerParams and packs them into the small AmpEnvelope view struct here.
// AHDSR times are wall-clock SECONDS (rate-free, matching the stored domain — Daniel's no-
// hardcoded-rate ruling); Trigger fades are FRACTIONS of the play span. The one rate-bound input
// is the total sample duration in seconds, which the shell resolves once from the live rate and
// the frame count and passes in — this module never sees a sample rate.
//
// It reuses editor_geometry's Rect + contains(), the one shared geometry idiom.
#pragma once #pragma once
@@ -64,166 +12,96 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the // Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free.
// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases.
enum class EnvMode { Gate, Trigger }; enum class EnvMode { Gate, Trigger };
// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of // Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the // Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd).
// forward map (draw) and inverse map (edit) name the same nodes. // Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map).
//
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(=sustain corner) -> ReleaseStart
// -> ReleaseEnd. The sustain node is DecayEnd (its Y is the sustain level);
// ReleaseStart is a drawing-only plateau-end vertex (the schematic note-off);
// release is edited by dragging ReleaseEnd.
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd, level 0). The fade-out
// ramp is the FadeOutStart->LengthEnd segment; LengthEnd is the playEnd terminal.
enum class EnvNode { enum class EnvNode {
Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor) Origin, // t=0, level 0 — not draggable
AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds AttackEnd, // Gate: attack ramp top — sets attackSeconds
HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds HoldEnd, // Gate: hold plateau end — sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds, DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y)
// Y sets sustainLevel) ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable
ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) — ReleaseEnd, // Gate: release tail end — sets releaseSeconds
// a DRAWING vertex only, not a draggable handle (release is edited at FadeInEnd, // Trigger: fade-in top — sets fadeInFraction
// ReleaseEnd; this vertex sits a fixed sustain-plateau width right of FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction
// DecayEnd — the schematic note-off — Y = sustain level) LengthEnd, // Trigger: playEnd terminal — sets lengthFraction
ReleaseEnd, // Gate: end of the release tail (level 0) — X sets releaseSeconds
FadeInEnd, // Trigger: top of the fade-in (level 1) — X sets fadeInFraction
FadeOutStart, // Trigger: end of the unity plateau / start of the fade-out (level 1) —
// X sets fadeOutFraction
LengthEnd, // Trigger: the playEnd terminal / %-length (level 0) — X sets lengthFraction
}; };
// The amp-envelope params the overlay draws — the small view struct the shell packs from the // Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from
// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include). // TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in
// // core/instrument/CLAUDE.md.
// Gate fields (SECONDS, wall-clock): attack / hold / decay / release; sustain is a LEVEL 0..1.
// These map 1-to-1 with the stored AdsrSeconds fields — no conversion required.
//
// Trigger fields (FRACTIONS of play): fadeIn / fadeOut as a fraction of the played span;
// lengthFraction is the played span as a fraction of the
// post-start sample length (matching TriggerParams).
//
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
// TriggerParams (sampler_core.h) stores Trigger fades as SOURCE FRAMES:
// fadeInFrames (int64_t) — 0->1 ramp length in source frames
// fadeOutFrames (int64_t) — 1->0 ramp length in source frames
// AmpEnvelope stores them as FRACTIONS of the played span:
// fadeInFraction = fadeInFrames / playLengthFrames
// fadeOutFraction = fadeOutFrames / playLengthFrames
// where playLengthFrames = round(lengthFraction * (frameCount - startFrame)).
// This is a NON-TRIVIAL derived view — NOT a direct field copy. The shell owes a
// converter on BOTH directions:
// PACK (draw): frames -> fraction (TriggerParams -> AmpEnvelope, needs frameCount + rate)
// UNPACK (commit): fraction -> frames (AmpEnvelope -> TriggerParams, same inputs)
// lengthFraction maps 1-to-1 with TriggerParams::lengthFraction and needs no conversion.
//
// Unused fields for the active mode are ignored.
struct AmpEnvelope { struct AmpEnvelope {
EnvMode mode = EnvMode::Gate; EnvMode mode = EnvMode::Gate;
// Gate (AHDSR), seconds + a dimensionless sustain level. // Gate (AHDSR): seconds, plus a dimensionless sustain level.
double attackSeconds = 0.003; double attackSeconds = 0.003;
double holdSeconds = 0.0; double holdSeconds = 0.0;
double decaySeconds = 0.0; double decaySeconds = 0.0;
double sustainLevel = 1.0; double sustainLevel = 1.0;
double releaseSeconds = 0.060; double releaseSeconds = 0.060;
// Trigger, fractions of the play span (fadeIn/fadeOut) and of the post-start length. // Trigger: fractions of the played span.
// NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/ double lengthFraction = 1.0;
// fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the double fadeInFraction = 0.0;
// pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy. double fadeOutFraction = 0.0;
double lengthFraction = 1.0; // (0,1] of the post-start span that plays (1-to-1 with TriggerParams)
double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span (DERIVED — see above)
double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span (DERIVED — see above)
}; };
// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the // One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for
// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin. // inspection.
// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y.
struct EnvVertex { struct EnvVertex {
EnvNode node = EnvNode::Origin; EnvNode node = EnvNode::Origin;
int x = 0; // pixel x inside the overlay rect int x = 0;
int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0) int y = 0;
double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection) double level = 0.0;
bool operator==(const EnvVertex& o) const { bool operator==(const EnvVertex& o) const {
return node == o.node && x == o.x && y == o.y && level == o.level; return node == o.node && x == o.x && y == o.y && level == o.level;
} }
}; };
// The fraction of the canvas width RESERVED for the Gate sustain-plateau display (FA2). The // Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width
// plateau is a fixed-width schematic region between DecayEnd and ReleaseStart; the remaining // carries A/H/D/R at the param-domain scale. Shared with envelope_edit.
// width is the "timed" region A/H/D/R map onto at the schematic param-domain scale. One
// constant shared by the forward map (here) and the inverse map (envelope_edit).
inline constexpr double kGateSustainDisplayFraction = 0.15; inline constexpr double kGateSustainDisplayFraction = 0.15;
// The minimum pixel separation between consecutive Gate polyline nodes: every Gate segment gets // Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0
// this many px as a base, PLUS its time-proportional extent, so zero-duration stages (tier-0 // defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab
// defaults: hold 0, decay 0) still render as distinct, individually grabbable handles. Chosen // radius (6) so a click can never tie between neighbours.
// larger than envelope_edit's kNodeGrabRadius (6) so a click dead-on a node can never tie with
// its neighbour. Shared by the forward map and the drag inverse.
inline constexpr int kGateNodeSepPx = 8; inline constexpr int kGateNodeSepPx = 8;
// The Gate schematic's per-stage time domain (seconds): the timed region represents the four // Gate schematic's per-stage time domain (seconds) the timed region represents four stages
// stages end-to-end at this maximum each (4 x this total). MIRRORS the shell's stage-slider // end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider
// ceiling (kEnvTimeMaxSeconds in reasampler_editor.cpp) — keep the two equal so a stage at its // lands exactly at the canvas edge.
// slider max lands exactly at the canvas edge. Drag safety does NOT depend on this constant
// (param clamps are caller-supplied in envelope_edit); only layout does.
inline constexpr double kGateStageMaxSeconds = 2.0; inline constexpr double kGateStageMaxSeconds = 2.0;
// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve, // Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for
// floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0 // a non-empty area; 0 for a zero/negative-width area.
// for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale.
int gateTimedWidth(const Rect& area); int gateTimedWidth(const Rect& area);
// Pixels per second of the Gate timed region under the PARAM-DOMAIN scale: the timed width, // Pixels per second of the Gate timed region, independent of the sample's actual duration.
// minus the four per-segment kGateNodeSepPx bases and the last in-bounds column, spread over // Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
// 4 x kGateStageMaxSeconds. Independent of the sample's duration. Returns 0 for a // the cursor 1:1.
// zero/negative-width area; otherwise > 0 (the usable width floors at 1 px). The ONE px<->sec
// scale shared by the forward map (gatePolyline) and the drag inverse (envelope_edit), so a
// dragged handle tracks the cursor 1:1.
double gatePxPerSecond(const Rect& area); double gatePxPerSecond(const Rect& area);
// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds` // Maps an amp envelope to polyline vertices inside `area` over a sample of `totalSeconds`
// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive); // duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); vertices
// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads // are in draw order, Origin first.
// left-to-right in draw order, Origin first.
// //
// TIME BASE (FA2). // Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
// * Gate: a bounded schematic, INDEPENDENT of totalSeconds. The canvas splits into a TIMED // waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
// region of gateTimedWidth(area) px — where attack/hold/decay run from t=0 and the release // the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
// ramp runs after the plateau, at the gatePxPerSecond(area) PARAM-DOMAIN scale, each segment // or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0].
// carrying a kGateNodeSepPx base so consecutive nodes never coincide — plus a FIXED sustain
// plateau of (width - timedWidth) px between DecayEnd and ReleaseStart (the schematic
// note-off). Stages beyond the schematic domain (a stored stage > kGateStageMaxSeconds)
// compress from the RIGHT preserving the minimum gaps, so trailing nodes stay individually
// separated instead of piling on the last column; only a canvas too narrow to hold the
// minimum gaps at all sacrifices separation (in-bounds wins).
// * Trigger: the waveform's exact time base (PCM-aligned). The played span is
// lengthFraction * totalSeconds; fade-in/out are fractions OF that played span. Nodes past
// the played span never appear (FadeOutStart/LengthEnd sit at the played span's right edge).
//
// BOUNDS: every vertex is inside the canvas — x in [area.x, area.right()-1], y in
// [area.y, area.bottom()-1]. Nothing maps past area.right() (the pre-FA2 release tail is gone). A
// degenerate area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline
// [Origin, end at level 0] so the shell always has a drawable line. Pure — same inputs, same
// polyline.
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds); double totalSeconds);
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds -> // Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins // with envelope_edit's node hit-test so the drawn handle and its grab region agree.
// to area.right()-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields
// area.x. Pure — the shared time->x map the Trigger polyline and the node hit-test
// (envelope_edit) use, so the drawn handle and its grab region agree.
int timeToX(const Rect& area, double totalSeconds, double t); int timeToX(const Rect& area, double totalSeconds, double t);
// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.y, level 0 -> area.bottom()-1 // Maps a level [0,1] to a pixel y inside `area` (level 1 at the top, 0 at the bottom row),
// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is // clamped. Shared with envelope_edit's node hit-test.
// clamped to [0,1]. A zero-height area yields area.y. Pure — the shared level->y map the polyline
// and the node hit-test share.
int levelToY(const Rect& area, double level); int levelToY(const Rect& area, double level);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+8 -22
View File
@@ -14,10 +14,8 @@ int clampNote(int n) {
return n; return n;
} }
// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given // Maps a key boundary (0..128) to an x pixel. Key N's left is keyEdgeToX(N), right is
// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer // keyEdgeToX(N+1) — tiles adjacent keys/zones without a seam. Mirrors embed_strip::keyEdgeToX.
// math, floored — key N's left is keyEdgeToX(N) and its right is keyEdgeToX(N+1), tiling
// adjacent keys/zones without a seam (mirror of embed_strip::keyEdgeToX).
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft; if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth; if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
@@ -37,8 +35,7 @@ StripLayout layoutStrip(int w, int h) {
int keyLeftX(const StripLayout& layout, int note) { int keyLeftX(const StripLayout& layout, int note) {
const Rect& band = layout.keys; const Rect& band = layout.keys;
const int bandWidth = std::max(0, band.width); const int bandWidth = std::max(0, band.width);
// note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to // note is a key (0..127); callers pass note+1 to get its right edge, 128 -> band right.
// get a key's right edge, and 128 maps to the band right.
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note); const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
return keyEdgeToX(band.x, bandWidth, edge); return keyEdgeToX(band.x, bandWidth, edge);
} }
@@ -59,8 +56,7 @@ int keyAtPoint(const StripLayout& layout, int x, int y) {
if (!contains(band, x, y)) return -1; if (!contains(band, x, y)) return -1;
const int bandWidth = std::max(0, band.width); const int bandWidth = std::max(0, band.width);
if (bandWidth <= 0) return -1; if (bandWidth <= 0) return -1;
// Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide // Inverts keyEdgeToX: the key whose half-open [leftX, rightX) contains x.
// the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127).
const int offset = x - band.x; const int offset = x - band.x;
int note = (offset * kStripKeyCount) / bandWidth; int note = (offset * kStripKeyCount) / bandWidth;
return clampNote(note); return clampNote(note);
@@ -69,7 +65,7 @@ int keyAtPoint(const StripLayout& layout, int x, int y) {
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) { Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
int lo = clampNote(lowNote); int lo = clampNote(lowNote);
int hi = clampNote(highNote); int hi = clampNote(highNote);
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts if (lo > hi) lo = hi; // malformed zone collapses rather than inverts
const int leftX = keyLeftX(layout, lo); const int leftX = keyLeftX(layout, lo);
const int rightX = keyLeftX(layout, hi + 1); const int rightX = keyLeftX(layout, hi + 1);
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom()); return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
@@ -80,8 +76,7 @@ ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x,
if (!contains(bar, x, y)) return ZoneGrab::kNone; if (!contains(bar, x, y)) return ZoneGrab::kNone;
const int barW = bar.width; const int barW = bar.width;
// A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so // A narrow bar has no body: split at the midpoint, low edge wins the tie.
// a click exactly on the midpoint resizes low (deterministic).
if (barW < 2 * kStripEdgeGrabWidth) { if (barW < 2 * kStripEdgeGrabWidth) {
const int mid = bar.x + barW / 2; const int mid = bar.x + barW / 2;
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge; return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
@@ -103,11 +98,7 @@ ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int*
} }
bool isNaturalKey(int note) { bool isNaturalKey(int note) {
// Clamp to the valid MIDI range before indexing.
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note); const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note);
// The 12-semitone pattern of natural (white) keys within an octave, starting at C:
// positions 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B) are natural;
// positions 1(C#) 3(D#) 6(F#) 8(G#) 10(A#) are accidental.
static constexpr bool kNatural[12] = { static constexpr bool kNatural[12] = {
true, // 0 C true, // 0 C
false, // 1 C# false, // 1 C#
@@ -129,13 +120,8 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
if (dxPixels == 0) return clampNote(startNote); if (dxPixels == 0) return clampNote(startNote);
const int bandWidth = std::max(0, layout.keys.width); const int bandWidth = std::max(0, layout.keys.width);
if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion
// Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag // Same linear mapping as keyAtPoint/keyEdgeToX (exact rational), not a truncated-integer
// agree across the full strip, even on non-divisible-by-128 widths. The proportional // bandWidth/128 key width — that drifted at the far end of the strip.
// key width is (bandWidth / kStripKeyCount) in exact rational arithmetic; rounding to
// the nearest key (half-key drag flips at the key centre) is achieved by adding
// bandWidth/2 to the absolute pixel delta before dividing — identical to the old
// formula except keyWidth is now derived from the same linear map (exact rational)
// rather than the truncated-integer bandWidth/128 that caused drift at the far end.
const int half = bandWidth / 2; const int half = bandWidth / 2;
int shift; int shift;
if (dxPixels > 0) { if (dxPixels > 0) {
+41 -88
View File
@@ -1,106 +1,70 @@
// keyboard_strip.h — PURE layout + hit-test + drag math for the S10 capture-first // keyboard_strip.h — layout + hit-test + drag math for the capture-first editor's
// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. // keyboard strip. Mirror of editor_geometry/embed_strip/mode_switch; the shell draws
// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle + // and marshals mouse events into these functions.
// note-mapping arithmetic lives here so it is unit-tested outside the DAW, while the
// editor shell (reasampler_editor.cpp) draws the strip and marshals mouse events into
// these functions.
// //
// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span // The strip maps the full 128-key MIDI span across a horizontal band (the same idiom
// idiom embed_strip uses). It serves TWO faces of the S10 editor: // embed_strip uses) and serves two faces: the single-capture fast path (a root marker,
// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on // click-a-key or drag it to set root) and the opt-in zones panel (each zone drawn as a
// the strip, click-a-key (or drag the marker) sets the capture's root note; and // bar with edge-grab resize handles + a body move-handle).
// * the opt-in ZONES panel (S10-Z, demoted): each performance zone drawn as a bar over
// the keys it covers, with edge-grab resize handles + a body move-handle so a drag
// sets low/high (edges) or moves the span (body), and a key-click sets the zone root.
//
// All interaction resolves through the pure DRAG-DELTA resolver here: the shell captures
// a grab on WM_LBUTTONDOWN, feeds each WM_MOUSEMOVE's pixel delta back through
// resolveDragNote, and commits the resolved note(s) on WM_LBUTTONUP. Live feedback is the
// shell re-drawing the in-flight note; one coherent edit lands on release.
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom),
// so this header depends on editor_geometry.h rather than redefining a rectangle type.
#pragma once #pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom #include "core/instrument/ui/editor_geometry.h" // Rect, contains
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named // Named distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips
// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay // stay independent.
// independent — the editor strip may grow octave labels/metrics the embed strip never does.
inline constexpr int kStripKeyCount = 128; inline constexpr int kStripKeyCount = 128;
// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started // Pixel width of a zone bar's edge-grab region. A zone narrower than 2x this has no
// within this many pixels of the bar's left/right edge resizes that edge; a drag started // body move-handle (both edges win their halves).
// anywhere else on the bar moves the whole span. A zone narrower than 2*this has no body
// move-handle (both edges win their halves) — deliberate: a 1-key zone is all edges.
inline constexpr int kStripEdgeGrabWidth = 6; inline constexpr int kStripEdgeGrabWidth = 6;
// The strip's regions, derived from the (w x h) band the shell allots it. The keys band // The keys band takes the whole strip area today; clamped so a degenerate size never
// takes the whole area today (a future octave-label lane can carve a sub-band here without // yields an inverted rect.
// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
struct StripLayout { struct StripLayout {
Rect keys; // the key band: the 128-key span maps linearly across keys.width Rect keys;
}; };
// Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or // Divide a (w x h) strip area into its regions. Pure.
// negative size yields empty rects (no inversion).
StripLayout layoutStrip(int w, int h); StripLayout layoutStrip(int w, int h);
// The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key // x pixel of the LEFT edge of key `note` (0..127) under the linear 128-key map; key N
// span maps linearly across keys.width; key N occupies the half-open pixel range // occupies [keyLeftX(N), keyLeftX(N+1)). note==128 maps to the band's right edge.
// [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's
// right edge (so a key's right edge is keyLeftX(note+1)). Pure.
int keyLeftX(const StripLayout& layout, int note); int keyLeftX(const StripLayout& layout, int note);
// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note), // Half-open rect of a single key `note`, clamped to [0,127].
// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range)
// note clamps to [0,127]. Pure.
Rect keyRect(const StripLayout& layout, int note); Rect keyRect(const StripLayout& layout, int note);
// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`, // Root-marker rect for the single-capture fast path; equivalent to
// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point // keyRect(layout, rootNote) but named so the intent reads at the call site.
// so the shell's intent (this is the root marker, not just any key) reads at the call site,
// and so a future marker shape (a triangle over the key) has one place to change. Pure.
Rect rootMarkerRect(const StripLayout& layout, int rootNote); Rect rootMarkerRect(const StripLayout& layout, int rootNote);
// The MIDI note a point (x, y) lands on, or -1 for a point outside the keys band. Backs // MIDI note a point (x, y) lands on, or -1 outside the keys band.
// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure.
int keyAtPoint(const StripLayout& layout, int x, int y); int keyAtPoint(const StripLayout& layout, int x, int y);
// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote] // Horizontal sub-rect for a zone spanning [lowNote, highNote] inclusive. Notes clamp to
// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes // [0,127] and low clamps to <= high, so a malformed zone never yields an inverted rect.
// clamp to [0,127] and low clamps to <= high, so a malformed zone yields an in-band
// (possibly zero-width) rect, never an inverted one. Mirrors embed_strip::zoneSegmentRect.
// Pure.
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote); Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote);
// Which part of a zone bar a grab landed on. The shell uses this to decide what a drag // Which part of a zone bar a grab landed on: an edge resizes that boundary, the body
// edits: an edge resizes that boundary; the body moves the whole span; none means the grab // moves the whole span, kNone means the grab missed the bar.
// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a
// deselect).
enum class ZoneGrab { enum class ZoneGrab {
kNone, // the point is not on this zone's bar kNone,
kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low kLowEdge,
kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high kHighEdge,
kBody, // on the bar but not an edge -> move the whole span kBody,
}; };
// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the // Classify a grab at (x, y) against one zone's bar. A narrow bar (< 2*kStripEdgeGrabWidth)
// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within // resolves the near half to each edge (no body); the low edge wins a tie at the exact
// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth) // midpoint.
// resolves the near half to each edge (no body). The LOW edge wins a tie at the exact
// midpoint of a narrow bar (deterministic). Pure.
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y); ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y);
// The zone (index into `lows`/`highs`, draw order) whose bar a grab at (x, y) lands on, // Zone (index into the parallel `lows`/`highs` arrays, draw order) whose bar a grab
// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in // lands on, plus which part, or {-1, kNone} for a miss. First covering zone in draw
// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The // order wins.
// arrays are parallel (lows[i]/highs[i] is zone i's inclusive range); `count` is their
// length. Pure — no host containers at the boundary (a raw pointer pair, like
// embed_strip::zoneAtPoint).
struct ZoneBarHit { struct ZoneBarHit {
int zoneIndex = -1; int zoneIndex = -1;
ZoneGrab grab = ZoneGrab::kNone; ZoneGrab grab = ZoneGrab::kNone;
@@ -108,24 +72,13 @@ struct ZoneBarHit {
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs, ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
int count, int x, int y); int count, int x, int y);
// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time // Resolves a drag to a new MIDI note: `startNote` shifted by round(dxPixels / keyWidth),
// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note // clamped to [0,127]. The one arithmetic behind edge-resize, body-move (apply to both
// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to // edges with the same delta to preserve span), and root-marker drag.
// [0,127]. keyWidth is derived from the layout (band width / 128); a zero-width band pins
// the result to startNote (no motion). This is the single arithmetic behind edge-resize,
// body-move (apply to both edges with the SAME delta so the span is preserved), and
// root-marker drag. Pure — rounding is to the nearest key so a half-key drag flips at the
// key centre. Returns startNote unchanged for dxPixels==0.
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels);
// Returns true when `note` (0..127) is a NATURAL (white) key in standard 12-tone equal // True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal
// temperament; false when it is an ACCIDENTAL (black) key. Notes out of the [0,127] // temperament; false for an accidental (black) key.
// range are clamped to [0,127] before classification (i.e. this never throws/UBs on a
// bad input). The 12 semitone positions within an octave:
// Natural (white): 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B)
// Accidental (black): 1(C#) 3(D#) 6(F#) 8(G#) 10(A#)
// Used by the shell to overlay the two-tone bright/dark piano-key pattern over the
// pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types.
bool isNaturalKey(int note); bool isNaturalKey(int note);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+1 -1
View File
@@ -37,7 +37,7 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int innerLeft = box.x + kDeckGroupPadX; const int innerLeft = box.x + kDeckGroupPadX;
const int innerRight = box.right() - kDeckGroupPadX; const int innerRight = box.right() - kDeckGroupPadX;
// Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home). // Caption row: text left, compact toggle right-anchored.
out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH); out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
if (g.captionToggle.id >= 0) { if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth; const int segW = g.captionToggle.segWidth;
+27 -39
View File
@@ -1,27 +1,18 @@
// knob_deck.h — PURE knob-deck layout + hit-test for the r11 Sample-face recomposition // knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free
// (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary, and — like // like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of
// param_slider — NO engine types: cells and toggles carry opaque shell-owned control ids. // action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is
// The mirror of action_bar / param_slider: the fiddly group-box / caption-row / cell-grid // param_slider's — a knob cell here is just a rect the shell composes it into.
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws each
// group (fence, caption, compact toggles, knobs) through the L1 kit and routes clicks/drags
// via the hit-test. The KNOB PRIMITIVE itself (value<->needle-angle, vertical drag) is
// param_slider's (FA4); a knob cell here is just a rect — the shell composes the two.
// //
// THE DECK (CONTEXT.md §S-VIEW r11). A horizontal run of FENCED GROUPS, left -> right, each // The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
// a hairline-bordered bg/panel box with a CAPTION ROW (micro-caps caption left; the group's // caption row (caption left, the group's compact mode toggle right-anchored) over a knob
// compact mode toggle right-anchored IN the caption row — this is where the not-full-width // row of fixed cells (knob centered, label band beneath). A group may also place one
// toggles live) over a KNOB ROW of fixed 48x58 cells (28px knob centered, 12px label band // two-segment toggle in the knob row after its cells. Groups that must keep stable
// beneath). A group may additionally place one 18px-tall two-segment toggle IN the knob row // geometry across a mode flip reserve blank cells (id -1) so a mode flip never reflows
// after its cells (the VOICE group's Retrig|Legato — same Mono/Stereo segment grammar, // neighbouring groups.
// vertically centered). Groups that must keep stable geometry across a mode flip reserve
// blank cells (id -1): the AMP ENVELOPE group always spans 5 cells so Gate<->Trigger never
// reflows its neighbours.
// //
// WRAP (deterministic): groups place left-to-right with kDeckGroupGap between; a group that // Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
// does not fit the remaining width starts a new deck row (whole groups only, never split). // that does not fit the remaining width starts a new row (whole groups only, never
// The first group of a row always places even if wider than the row (degenerate width). // split); the first group of a row always places even if wider than the row.
// deckHeight() exposes the resulting height so the shell can bottom-anchor the deck band and
// give the ELASTIC HERO the rest (r11 band order).
#pragma once #pragma once
@@ -31,7 +22,7 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// Fixed deck metrics (spec r11), exposed so the shell and tests agree. // Fixed deck metrics, exposed so the shell and tests agree.
inline constexpr int kDeckCellW = 48; // one knob cell inline constexpr int kDeckCellW = 48; // one knob cell
inline constexpr int kDeckCellH = 58; inline constexpr int kDeckCellH = 58;
inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell
@@ -55,9 +46,8 @@ struct DeckToggleDesc {
}; };
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1 // One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// is a RESERVED BLANK cell (geometry held, never hit — the AMP ENVELOPE Trigger face). // is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell
// `captionWidth` is the px the shell reserves for the caption text (this module does not // reserves for the caption text (this module does not measure text).
// measure text — the house constant-metrics pattern).
struct DeckGroupDesc { struct DeckGroupDesc {
int id = 0; // shell group id (opaque here) int id = 0; // shell group id (opaque here)
int captionWidth = 60; int captionWidth = 60;
@@ -96,21 +86,20 @@ struct DeckLayout {
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups
}; };
// The width of one group box: the wider of its caption row (caption + gap + toggle) and its // Width of one group box: the wider of its caption row (caption + gap + toggle) and its
// knob row (cells + gap + row toggle), plus the horizontal padding. Pure. // knob row (cells + gap + row toggle), plus horizontal padding.
int deckGroupWidth(const DeckGroupDesc& g); int deckGroupWidth(const DeckGroupDesc& g);
// The number of deck rows the groups occupy at `availWidth` under the greedy whole-group // Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap.
// wrap (a group that does not fit the remaining row width starts a new row; the first group // 0 for an empty list.
// of a row always places). 0 for an empty group list. Pure — the wrap is deterministic.
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth); int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth);
// The total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). 0 for an // Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell
// empty list. The shell bottom-anchors a band of exactly this height. Pure. // bottom-anchors a band of exactly this height.
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth); int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth);
// Lay the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's rule. // Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's
// Every rect is absolute. Pure — same inputs, same layout. // rule. Every rect is absolute.
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top, DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth); int availWidth);
@@ -124,10 +113,9 @@ struct DeckHit {
int segment = -1; // 0/1 for a toggle hit; -1 otherwise int segment = -1; // 0/1 for a toggle hit; -1 otherwise
}; };
// The deck element a point lands on: a knob CELL (the whole 48x58 cell — friendlier than the // The deck element a point lands on: a knob cell (the whole cell, not just the knob
// bare knob circle; the shell anchors the vertical drag wherever the grab lands), a caption- // circle the shell anchors the vertical drag wherever the grab lands), a caption-toggle
// toggle segment, or a row-toggle segment. Blank cells (id -1) and everything else miss. // segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
// Pure — the shell's routing entry point.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+3 -4
View File
@@ -1,5 +1,4 @@
// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16 // param_slider.cpp — see param_slider.h. Pure control-surface geometry; no host types.
// editor parameter panel. No host types; only the shared Rect + contains().
#include "core/instrument/ui/param_slider.h" #include "core/instrument/ui/param_slider.h"
@@ -10,7 +9,7 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24) using util::clamp01;
std::vector<ControlRow> layoutControls(const Rect& panel, std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls) { const std::vector<ControlDesc>& controls) {
@@ -83,7 +82,7 @@ double valueAtPoint(const Rect& control, int x) {
return static_cast<double>(x - track.x) / static_cast<double>(span); return static_cast<double>(x - track.x) / static_cast<double>(span);
} }
// --- Radial knob (Wave A FA4) --------------------------------------------------------- // --- Radial knob -----------------------------------------------------------------------
namespace { namespace {
+68 -107
View File
@@ -1,180 +1,141 @@
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the // param_slider.h — 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 // editor parameter panel. Engine-free by design (no sampler_core/sample_map). Mirror of
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The // keyboard_strip/waveform_view/mode_switch; the shell draws each row and routes
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and // clicks/drags into these functions, owning the control-id -> engine-param binding and
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell // the value domain mapping.
// 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 // Controls are one of three shapes — a two-segment Toggle, a horizontal Slider, or a
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR // radial Knob with a needle and vertical-drag mapping — laid out as a vertical stack of
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade // fixed-height rows. This module maps a control's normalized value (0..1) to/from its
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope // handle pixel / needle angle; the shell converts each control's engine value (frames,
// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal // seconds, a fraction, a signed semitone depth) to/from that 0..1.
// SLIDER, and (Wave A FA4) a radial KNOB with a needle indicator and vertical-drag value
// mapping — laid out as a vertical stack of fixed-height rows. This module lays out that
// stack and maps a control's NORMALIZED value (0..1) to/from its handle pixel / needle
// angle; 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 #pragma once
#include <vector> #include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom #include "core/instrument/ui/editor_geometry.h" // Rect, contains
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// Fixed control-panel metrics, exposed so the shell and tests agree. // 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 kControlRowHeight = 22;
inline constexpr int kControlRowGap = 4; // vertical gap below each row inline constexpr int kControlRowGap = 4;
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left inline constexpr int kControlLabelWidth = 92;
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px) inline constexpr int kSliderHandleWidth = 8;
inline constexpr int kToggleSegments = 2; // a toggle is always two segments inline constexpr int kToggleSegments = 2;
// A control is one of three shapes. Toggle = a two-segment selector (the active segment // Toggle = two-segment selector (active segment highlights); Slider = horizontal track
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value; // with a draggable handle over a 0..1 value; Knob = radial dial with a needle, dragged
// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY // vertically (up = increase).
// (up = increase).
enum class ControlKind { Toggle, Slider, Knob }; enum class ControlKind { Toggle, Slider, Knob };
// One control the shell places in the panel, in stack order. `id` is the shell's own control // One control the shell places in the panel, in stack order. `id` is the shell's own
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the // control identifier, returned by the hit-test so the shell routes to the right engine
// shell routes the interaction to the right engine param — this module never interprets it. // param — this module never interprets it.
struct ControlDesc { struct ControlDesc {
int id = 0; int id = 0;
ControlKind kind = ControlKind::Slider; ControlKind kind = ControlKind::Slider;
}; };
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect // Laid-out geometry of one control row: full row rect plus the interactive sub-rect (the
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle // 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. // into segments via toggleSegmentRect).
struct ControlRow { struct ControlRow {
int id = 0; int id = 0;
ControlKind kind = ControlKind::Slider; ControlKind kind = ControlKind::Slider;
Rect row; // the full row (label column + control column) Rect row;
Rect label; // the label column at the left Rect label;
Rect control; // the control column to the right of the label (track / toggle area) Rect control;
}; };
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each // Lays out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down.
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left // Label column takes the left kControlLabelWidth (clamped to the panel), control column
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A // the rest. A row past the panel bottom is still returned (the shell clips/suppresses it)
// row whose top falls past the panel bottom is still returned (the shell clips at paint / // so stack geometry is deterministic regardless of panel height.
// 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, std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls); const std::vector<ControlDesc>& controls);
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect, // Rect of segment `seg` within a toggle's `control` rect, splitting it into
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width // kToggleSegments equal segments left-to-right (last absorbs any width remainder).
// 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); 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); 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 // Slider track sub-rect inside `control`: inset so the handle stays fully within the
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle // control at value 0 and 1. The handle center ranges across [track.x, track.right()] as
// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value // the value ranges [0,1].
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
// empty rect. Pure.
Rect sliderTrackRect(const Rect& control); Rect sliderTrackRect(const Rect& control);
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a // Handle rect for a slider at normalized `value` (clamped to [0,1]).
// 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); Rect sliderHandleRect(const Rect& control, double value);
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range). // Maps a point x to a normalized slider value [0,1]: at/left of track start -> 0, at/right
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate // of end -> 1, linear between. Inverse of sliderHandleRect's position map.
// 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); double valueAtPoint(const Rect& control, int x);
// --- Radial knob (Wave A FA4) -------------------------------------------------------------- // --- Radial knob ---------------------------------------------------------------------
// //
// Angle convention: DEGREES CLOCKWISE FROM 12 O'CLOCK, matching a clock face in screen // Angle convention: degrees clockwise from 12 o'clock (screen coords, y grows downward).
// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6 // The value arc sweeps clockwise from startDeg (value 0) to endDeg (value 1); an endDeg
// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg // at-or-behind startDeg wraps +360.
// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal
// angles mean a full 360° sweep.
// //
// The DEFAULT arc is the conventional 7→5 o'clock layout: min at 7 o'clock (210°) sweeping // Default arc: 7 o'clock (210°) sweeping clockwise 300° to 5 o'clock (150°), leaving a
// clockwise 300° around to max at 5 o'clock (150°), leaving a symmetric 60° dead arc at the // symmetric 60° dead arc at the bottom; the 50% value lands at 12 o'clock. Angles are
// bottom. The 50% (midpoint) value lands at 12 o'clock (0°/360°) — straight up. The angles // parameters, not hardcoded.
// are PARAMETERS, not hardcoded — the shell sets the final sweep when the parallel layout inline constexpr double kKnobArcStartDeg = 210.0;
// spec lands. inline constexpr double kKnobArcEndDeg = 150.0;
inline constexpr double kKnobArcStartDeg = 210.0; // value 0 — 7 o'clock
inline constexpr double kKnobArcEndDeg = 150.0; // value 1 — 5 o'clock (clockwise wrap)
// Default vertical-drag sensitivity: pixels of upward drag for one full 0->1 sweep. // Pixels of upward drag for one full 0->1 sweep.
inline constexpr int kKnobDragRangePixels = 128; inline constexpr int kKnobDragRangePixels = 128;
// The configurable value arc of a knob. Defaults to the 7->5 o'clock reading above.
struct KnobArc { struct KnobArc {
double startDeg = kKnobArcStartDeg; double startDeg = kKnobArcStartDeg;
double endDeg = kKnobArcEndDeg; double endDeg = kKnobArcEndDeg;
}; };
// A knob's circle within its control cell: center + radius in pixel space (doubles so the // A knob's circle within its control cell: center + radius (doubles so the shell rounds
// shell rounds once, at draw time). radius == 0 marks a degenerate cell. // once, at draw time). radius == 0 marks a degenerate cell.
struct KnobGeometry { struct KnobGeometry {
double centerX = 0.0; double centerX = 0.0;
double centerY = 0.0; double centerY = 0.0;
double radius = 0.0; double radius = 0.0;
}; };
// A pixel-space point (the needle endpoint the shell draws to).
struct KnobPoint { struct KnobPoint {
double x = 0.0; double x = 0.0;
double y = 0.0; double y = 0.0;
}; };
// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A // Knob circle inscribed in `cell`, centered, radius = half the smaller dimension. The
// degenerate cell yields radius 0. CONTRACT: the shell MUST pass `row.control` (the full // shell must pass `row.control` both when drawing and hit-testing — controlAtPoint always
// control column) both when drawing and when hit-testing — `controlAtPoint` always uses // uses `r.control` as the cell, so draw cell and hit cell must agree.
// `r.control` as the cell, so the draw cell and hit cell must be the same. If the shell
// wants to draw a smaller circle it must center it within `row.control` and accept that the
// hit area is the larger column-inscribed circle. Pure.
KnobGeometry computeKnob(const Rect& cell); KnobGeometry computeKnob(const Rect& cell);
// True if (x, y) falls strictly inside the knob circle (boundary exclusive, matching the // True if (x, y) falls strictly inside the knob circle (boundary exclusive).
// module's half-open Rect convention). A degenerate knob (radius <= 0) hits nothing. Pure.
bool knobHitTest(const KnobGeometry& knob, int x, int y); bool knobHitTest(const KnobGeometry& knob, int x, int y);
// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping // Clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping +360
// +360 when the end is at-or-behind the start (default arc -> 300). Pure. // when the end is at-or-behind the start (default arc -> 300).
double knobSweepDeg(const KnobArc& arc); double knobSweepDeg(const KnobArc& arc);
// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1, // Needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
// linear between, returned normalized to [0, 360). Pure. // linear between, normalized to [0, 360).
double knobValueAngleDeg(const KnobArc& arc, double value); double knobValueAngleDeg(const KnobArc& arc, double value);
// The needle endpoint for normalized `value`: the point on the knob circle at the value's // Needle endpoint for normalized `value`: the point on the knob circle at the value's
// angle, from the center. The shell draws the needle from (centerX, centerY) to this point // angle, from the center.
// (or lerps toward the center for a shorter needle). Pure.
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value); KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value);
// Map a vertical drag onto a knob value: `startValue` is the value at drag start (clamped), // Maps a vertical drag onto a knob value: `startValue` is the value at drag start,
// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging // `dyPixels` the pointer's y displacement (down = positive). Up increases, down
// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1 // decreases; `dragRangePixels` pixels of travel covers the full 0..1 range.
// range. Result clamps to [0,1]; a non-positive drag range yields the clamped start value.
// Pure — the inverse map for the knob's drag interaction.
double knobDragValue(double startValue, int dyPixels, double knobDragValue(double startValue, int dyPixels,
int dragRangePixels = kKnobDragRangePixels); int dragRangePixels = kKnobDragRangePixels);
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc // Control a point lands on, given laid-out `rows`. Returns the control id whose
// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's // interactive area contains the point, or -1 for a miss. First matching row wins (rows
// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every // never overlap).
// 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 / knobDragValue over the ensuing drag) and commits.
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y); int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+2 -4
View File
@@ -21,8 +21,7 @@ int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) {
const int w = std::max(0, area.width); const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return area.x; if (frameCount <= 0 || w <= 0) return area.x;
const std::int64_t f = clampFrame(frame, frameCount); const std::int64_t f = clampFrame(frame, frameCount);
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line // x = left + round(f * w / frameCount); multiply before divide to keep this exact.
// visually centered on its frame; the divide is exact rational (multiply first).
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2; const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
return area.x + static_cast<int>(num / frameCount); return area.x + static_cast<int>(num / frameCount);
} }
@@ -33,8 +32,7 @@ std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
if (x <= area.x) return 0; if (x <= area.x) return 0;
if (x >= area.right()) return frameCount; if (x >= area.right()) return frameCount;
const std::int64_t dx = static_cast<std::int64_t>(x - area.x); const std::int64_t dx = static_cast<std::int64_t>(x - area.x);
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw // Inverse of frameToX: frame = round(dx * frameCount / w).
// agree at bin granularity.
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2; const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
return clampFrame(num / static_cast<std::int64_t>(w), frameCount); return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
} }
+25 -57
View File
@@ -1,84 +1,52 @@
// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11 // waveform_view.h — waveform/marker geometry + zero-crossing snap. Mirror of keyboard_strip/
// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror // editor_geometry: frame<->pixel + marker hit-test + snap arithmetic lives here, unit-tested
// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap // outside the DAW; the shell draws and marshals mouse events into it.
// arithmetic lives here, unit-tested outside the DAW, while the editor shell
// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it.
// //
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal // The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal
// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start, // waveform rect. Markers are a generic N-named-marker set (not hardcoded specials), so a
// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately // different mode (e.g. start + %-length end + fades) can repurpose the same machinery.
// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a
// different marker set (start + %-length end + fades) without reworking the machinery.
//
// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab
// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's
// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps),
// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame.
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so
// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio
// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_codec do
// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded.
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom #include "core/instrument/ui/editor_geometry.h" // Rect, contains
#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans #include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
using audio::AudioSample; using audio::AudioSample;
// The width (px) of a marker's grab region either side of its x line: a grab within this many // Pixel width of a marker's grab region either side of its x line. Mirrors keyboard_strip's
// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab // edge-grab idiom.
// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay
// distinguishable.
inline constexpr int kMarkerGrabWidth = 5; inline constexpr int kMarkerGrabWidth = 5;
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x, // x pixel of `frame` under the linear map: frame 0 -> area.x, frame frameCount -> area.right().
// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an // Frame is clamped to [0, frameCount] before mapping. frameCount <= 0 or a zero-width area pins
// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a // every frame to area.x.
// zero-width area pins every frame to area.x (a degenerate, non-inverting result). Pure.
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame); int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame);
// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to // Inverse of frameToX: the frame a point x maps to, clamped to [0, frameCount]. A point left of
// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount. // area.x yields 0; right of area.right() yields frameCount.
// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips
// to the same frame at bin granularity).
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x); std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x);
// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at // Which marker (index into the caller's parallel `frames` array, in draw order) a grab at
// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is // (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of
// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker // its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie.
// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring
// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or
// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like
// keyboard_strip::zoneBarAtPoint.
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames, int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y); int count, int x, int y);
// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time // Resolves a drag to a new frame: `startFrame` shifted by round(dxPixels * frameCount /
// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the // areaWidth), clamped to [0, frameCount]. The shell applies between-marker clamps (e.g.
// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth), // start <= loopEnd) after this per-marker resolve.
// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to
// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag;
// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this
// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame
// for dxPixels == 0.
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame, std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
int dxPixels); int dxPixels);
// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the // Nearest zero-crossing frame to `target` in the mono PCM, for loop/start snap. A crossing is a
// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames) // frame i (1 <= i < frames) where pcm[i-1] and pcm[i] differ in sign (pcm[i] == 0 snaps to i).
// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing // Search fans out symmetrically from the clamped target; an equidistant tie resolves to the
// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and // lower frame. No sign change anywhere (or fewer than 2 frames) returns the clamped target
// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to // unchanged.
// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or
// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller
// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the
// decoded PCM the shell already holds; no host types, no file I/O.
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
std::int64_t target); std::int64_t target);