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