From 7151e19432b04ef5691939cf472bf99868e72fa0 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 20:36:36 -0400 Subject: [PATCH 1/2] =?UTF-8?q?S10:=20capture-first=20ReaSampler=209000=20?= =?UTF-8?q?editor=20=E2=80=94=20browser,=20single-capture=20setup,=20zones?= =?UTF-8?q?=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse S4 auto-select (empty=silence+empty state), add peak-thumbnail capture browser + bank filter + keyboard-strip drag machine, v3 component state (selection + zones), and the S-NAME-1 filename/display rename (UID locked). MSVC min/max macro collisions fixed post-implementation; 26/26 tests green. --- CMakeLists.txt | 46 +- src/vst/capture_browser.cpp | 96 ++++ src/vst/capture_browser.h | 92 ++++ src/vst/keyboard_strip.cpp | 118 +++++ src/vst/keyboard_strip.h | 121 +++++ src/vst/reasampler_editor.cpp | 760 +++++++++++++++++++++++-------- src/vst/reasampler_editor.h | 129 ++++-- src/vst/reasampler_embed.cpp | 7 +- src/vst/reasampler_processor.cpp | 49 +- src/vst/reasampler_processor.h | 13 +- src/vst/reasampler_vst.h | 12 +- src/vst/sample_map.cpp | 130 ++++-- src/vst/sample_map.h | 89 +++- tests/test_capture_browser.cpp | 203 +++++++++ tests/test_keyboard_strip.cpp | 201 ++++++++ tests/test_sample_map.cpp | 165 ++++++- 16 files changed, 1906 insertions(+), 325 deletions(-) create mode 100644 src/vst/capture_browser.cpp create mode 100644 src/vst/capture_browser.h create mode 100644 src/vst/keyboard_strip.cpp create mode 100644 src/vst/keyboard_strip.h create mode 100644 tests/test_capture_browser.cpp create mode 100644 tests/test_keyboard_strip.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0cd069d..d372965 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -642,6 +642,23 @@ add_library(sample_map STATIC src/vst/sample_map.cpp) target_include_directories(sample_map PUBLIC src/vst src) target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core) +# capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the +# capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly +# grid/tab arithmetic lives here, unit-tested outside the DAW; the editor shell draws each +# card's peak thumbnail + name + badge and routes clicks into it. Links editor_geometry for +# the shared Rect + contains(). NEITHER SDK. +add_library(capture_browser STATIC src/vst/capture_browser.cpp) +target_include_directories(capture_browser PUBLIC src/vst) +target_link_libraries(capture_browser PUBLIC editor_geometry) + +# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, zone-bar rects + +# edge-grab hit regions, and the drag-delta note resolver for the capture-first editor's +# keyboard strip (single-capture root-set) and the opt-in Zones panel (S10-Z). The mirror of +# embed_strip; links editor_geometry for the shared Rect. NEITHER SDK. +add_library(keyboard_strip STATIC src/vst/keyboard_strip.cpp) +target_include_directories(keyboard_strip PUBLIC src/vst) +target_link_libraries(keyboard_strip PUBLIC editor_geometry) + add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) @@ -661,6 +678,14 @@ add_executable(sample_map_tests tests/test_sample_map.cpp) target_link_libraries(sample_map_tests PRIVATE sample_map) add_test(NAME sample_map_tests COMMAND sample_map_tests) +add_executable(capture_browser_tests tests/test_capture_browser.cpp) +target_link_libraries(capture_browser_tests PRIVATE capture_browser) +add_test(NAME capture_browser_tests COMMAND capture_browser_tests) + +add_executable(keyboard_strip_tests tests/test_keyboard_strip.cpp) +target_link_libraries(keyboard_strip_tests PRIVATE keyboard_strip) +add_test(NAME keyboard_strip_tests COMMAND keyboard_strip_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -763,8 +788,13 @@ endif() # (VSTGUI/examples/tests are NOT). After `git submodule update --init vendor/vst3sdk`: # cd vendor/vst3sdk && git submodule update --init pluginterfaces base public.sdk # =========================================================================== -if(WIN32) - set(VST3_SDK ${CMAKE_CURRENT_SOURCE_DIR}/vendor/vst3sdk) +set(VST3_SDK ${CMAKE_CURRENT_SOURCE_DIR}/vendor/vst3sdk) +# The VST3 module needs the nested vst3sdk slice (pluginterfaces / base / public.sdk) +# checked out — the one-time step documented above. When it is absent (a fresh clone +# that ran only the top-level `git submodule update --init`), skip the module rather than +# fail configure on missing sources: the pure geometry/mapping libraries + their CTest +# targets still build and test without the SDK. Probe one representative source file. +if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # --- 5a) The bounded slice of the Steinberg VST3 SDK this spike needs. -------- # Enumerated (not add_subdirectory of the whole SDK) to keep the build hermetic and @@ -832,12 +862,18 @@ if(WIN32) # app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so # the instrument reads the SAME namespace the extension writes; its PUBLIC include dir # (build/generated) carries version_generated.h for the channel bit. + # capture_browser + keyboard_strip (S10): the pure card-grid/tab + keyboard-strip + # geometry the capture-first editor draws + hit-tests against; both link editor_geometry + # transitively (shared Rect). target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal - sample_map capture_paths embed_strip app_version) + sample_map capture_paths embed_strip app_version capture_browser keyboard_strip) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) - # A .vst3 is a DLL with a .vst3 extension and no lib-prefix. + # A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk + # product name (S-NAME-1, SETTLED 2026-07-26): reasampler_9000.vst3, matching the + # "ReaSampler 9000" display strings. The VST3 class UID (reasampler_vst.h) is unchanged + # — the compat anchor a saved instance rebinds by (save-rename-reopen is a DAW-verify). set_target_properties(reasampler_vst PROPERTIES PREFIX "" SUFFIX ".vst3" - OUTPUT_NAME "reasampler_vst") + OUTPUT_NAME "reasampler_9000") endif() diff --git a/src/vst/capture_browser.cpp b/src/vst/capture_browser.cpp new file mode 100644 index 0000000..a36f63e --- /dev/null +++ b/src/vst/capture_browser.cpp @@ -0,0 +1,96 @@ +// capture_browser.cpp — see capture_browser.h. Pure math; no host types. + +#include "capture_browser.h" + +#include + +namespace reasampler::vst { + +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. +int tabEdge(int x, int width, int i, int count) { + return x + (i * width) / count; +} + +} // namespace + +BrowserLayout layoutBrowser(int w, int h) { + const int cw = std::max(0, w); + const int ch = std::max(0, h); + + BrowserLayout out; + const int tabH = std::min(kBrowserTabHeight, ch); + out.tabStrip = Rect{0, 0, cw, tabH}; + out.grid = Rect{0, tabH, cw, ch}; + + const int gridW = std::max(0, out.grid.width()); + out.columns = std::max(1, gridW / kBrowserCardWidth); + return out; +} + +Rect cardCellRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const int cols = std::max(1, layout.columns); + const int col = index % cols; + const int row = index / cols; + const int left = layout.grid.left + col * kBrowserCardWidth; + const int top = layout.grid.top + row * kBrowserCardHeight; + return Rect{left, top, left + kBrowserCardWidth, top + kBrowserCardHeight}; +} + +Rect cardContentRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const Rect cell = cardCellRect(layout, index); + return Rect{cell.left + kBrowserCardGutter, cell.top + kBrowserCardGutter, + cell.right - kBrowserCardGutter, cell.bottom - kBrowserCardGutter}; +} + +Rect cardThumbnailRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const Rect content = cardContentRect(layout, index); + const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height())); + return Rect{content.left, content.top, content.right, content.top + thumbH}; +} + +Rect cardLabelRect(const BrowserLayout& layout, int index) { + if (index < 0) return Rect{}; + const Rect content = cardContentRect(layout, index); + const Rect thumb = cardThumbnailRect(layout, index); + return Rect{content.left, thumb.bottom, content.right, content.bottom}; +} + +int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) { + if (cardCount <= 0) return -1; + if (!contains(layout.grid, x, y)) return -1; + const int cols = std::max(1, layout.columns); + const int col = (x - layout.grid.left) / kBrowserCardWidth; + const int row = (y - layout.grid.top) / kBrowserCardHeight; + if (col < 0 || col >= cols) return -1; // past the last column (right dead-zone) + const int index = row * cols + col; + if (index < 0 || index >= cardCount) return -1; + // Only a hit inside the card CONTENT counts — a click in the inter-card gutter misses. + if (!contains(cardContentRect(layout, index), x, y)) return -1; + return index; +} + +Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index) { + if (tabCount <= 0 || index < 0 || index >= tabCount) return Rect{}; + const Rect& strip = layout.tabStrip; + const int left = tabEdge(strip.left, std::max(0, strip.width()), index, tabCount); + const int right = tabEdge(strip.left, std::max(0, strip.width()), index + 1, tabCount); + return Rect{left, strip.top, right, strip.bottom}; +} + +int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) { + if (tabCount <= 0) return -1; + if (!contains(layout.tabStrip, x, y)) return -1; + for (int i = 0; i < tabCount; ++i) { + if (contains(filterTabRect(layout, tabCount, i), x, y)) return i; + } + return -1; +} + +} // namespace reasampler::vst diff --git a/src/vst/capture_browser.h b/src/vst/capture_browser.h new file mode 100644 index 0000000..864f378 --- /dev/null +++ b/src/vst/capture_browser.h @@ -0,0 +1,92 @@ +// 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. +// +// 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". +// +// 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). + +#pragma once + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// 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 + +// 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. +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() +}; + +// 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. +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. +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. +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. +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. +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. +int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y); + +// --- 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). + +// 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 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::vst diff --git a/src/vst/keyboard_strip.cpp b/src/vst/keyboard_strip.cpp new file mode 100644 index 0000000..77b848f --- /dev/null +++ b/src/vst/keyboard_strip.cpp @@ -0,0 +1,118 @@ +// keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types. + +#include "keyboard_strip.h" + +#include + +namespace reasampler::vst { + +namespace { + +int clampNote(int n) { + if (n < 0) return 0; + if (n > kStripKeyCount - 1) return kStripKeyCount - 1; + 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). +int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { + if (keyEdge <= 0) return bandLeft; + if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth; + return bandLeft + (keyEdge * bandWidth) / kStripKeyCount; +} + +} // namespace + +StripLayout layoutStrip(int w, int h) { + const int cw = std::max(0, w); + const int ch = std::max(0, h); + StripLayout out; + out.keys = Rect{0, 0, cw, ch}; + return out; +} + +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. + const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note); + return keyEdgeToX(band.left, bandWidth, edge); +} + +Rect keyRect(const StripLayout& layout, int note) { + const int n = clampNote(note); + const int leftX = keyLeftX(layout, n); + const int rightX = keyLeftX(layout, n + 1); + return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom}; +} + +Rect rootMarkerRect(const StripLayout& layout, int rootNote) { + return keyRect(layout, rootNote); +} + +int keyAtPoint(const StripLayout& layout, int x, int y) { + const Rect& band = layout.keys; + 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). + const int offset = x - band.left; + int note = (offset * kStripKeyCount) / bandWidth; + return clampNote(note); +} + +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 + const int leftX = keyLeftX(layout, lo); + const int rightX = keyLeftX(layout, hi + 1); + return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom}; +} + +ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) { + const Rect bar = zoneBarRect(layout, lowNote, highNote); + 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). + if (barW < 2 * kStripEdgeGrabWidth) { + const int mid = bar.left + barW / 2; + return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge; + } + if (x < bar.left + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge; + if (x >= bar.right - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge; + return ZoneGrab::kBody; +} + +ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs, + int count, int x, int y) { + if (count <= 0 || lows == nullptr || highs == nullptr) return ZoneBarHit{}; + if (!contains(layout.keys, x, y)) return ZoneBarHit{}; + for (int i = 0; i < count; ++i) { + const ZoneGrab g = zoneGrabAt(layout, lows[i], highs[i], x, y); + if (g != ZoneGrab::kNone) return ZoneBarHit{i, g}; + } + return ZoneBarHit{}; // on the band but on no bar +} + +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 + // Key width in pixels (>= 1 via the max). Round the delta to the nearest key so a + // half-key drag flips at the key centre: add/subtract half a key before the divide. + const int keyW = std::max(1, bandWidth / kStripKeyCount); + const int half = keyW / 2; + const int shift = dxPixels >= 0 ? (dxPixels + half) / keyW + : -((-dxPixels + half) / keyW); + return clampNote(startNote + shift); +} + +} // namespace reasampler::vst diff --git a/src/vst/keyboard_strip.h b/src/vst/keyboard_strip.h new file mode 100644 index 0000000..9b6a846 --- /dev/null +++ b/src/vst/keyboard_strip.h @@ -0,0 +1,121 @@ +// 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. +// +// 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. + +#pragma once + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// 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. +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. +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. +struct StripLayout { + Rect keys; // the key band: the 128-key span maps linearly across keys.width() +}; + +// 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). +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. +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. +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. +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. +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. +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). +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 +}; + +// 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. +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). +struct ZoneBarHit { + int zoneIndex = -1; + ZoneGrab grab = ZoneGrab::kNone; +}; +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. +int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index b21b4fb..7ad1759 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -1,22 +1,30 @@ -// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge. -// Windows-only (D5); the whole file is guarded so a non-Windows build (not a target, -// but keeps the TU honest) degrades to the CPluginView defaults. +// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the +// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is +// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults. #include "reasampler_editor.h" +#include +#include +#include #include +#include -#include "editor_geometry.h" +#include "capture_browser.h" +#include "capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "editor_geometry.h" // Rect, contains #include "ext_keys.h" +#include "keyboard_strip.h" +#include "peaks.h" // computeEnvelope #include "reaper_bridge.h" #include "reasampler_processor.h" +#include "reasampler_vst.h" // kPluginName (the editor title band) #include "sample_map.h" +#include "wav_trim.h" // parseWavLayout, extractFloatFrames #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM -// LICE — the same drawing stack bank_panel uses. On Windows LICE routes through native -// GDI; no SWELL needed for the child window itself. #include "wdltypes.h" #include "lice/lice.h" #endif @@ -27,91 +35,194 @@ namespace reasampler::vst { namespace { #ifdef _WIN32 -constexpr const wchar_t* kChildClassName = L"ReaSamplerVstEditor"; +constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; -// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads -// as the same tool. +// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry +// is in capture_browser / keyboard_strip). The title band names the plugin + a live +// readout; the toggle band carries the Browser/Zones switch; the setup band (single- +// capture face) hosts the keyboard strip + level readout under the browser. +constexpr int kTitleHeight = 24; +constexpr int kToggleHeight = 22; +constexpr int kSetupHeight = 96; // the single-capture setup surface (strip + labels) +constexpr int kStripBandHeight = 40; + +// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads as +// the same tool. (Phase L's L1 kit replaces these flat fills later; not gated on it.) const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255); const LICE_pixel kColTitleBg = LICE_RGBA(20, 20, 22, 255); -const LICE_pixel kColBtnBg = LICE_RGBA(44, 44, 48, 255); -const LICE_pixel kColBtnHitBg = LICE_RGBA(58, 96, 84, 255); -const LICE_pixel kColBtnBorder = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColCardBg = LICE_RGBA(44, 44, 48, 255); +const LICE_pixel kColCardSelBg = LICE_RGBA(48, 72, 64, 255); +const LICE_pixel kColCardBorder = LICE_RGBA(70, 70, 76, 255); +const LICE_pixel kColCardSelBorder = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColTabBg = LICE_RGBA(36, 36, 40, 255); +const LICE_pixel kColTabActiveBg = LICE_RGBA(58, 96, 84, 255); +const LICE_pixel kColThumb = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColStripBg = LICE_RGBA(36, 36, 40, 255); +const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255); +const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColZoneBar = LICE_RGBA(58, 96, 84, 255); +const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255); const COLORREF kRgbText = RGB(210, 230, 220); +const COLORREF kRgbDim = RGB(140, 150, 146); -const LICE_pixel kColZoneSelBg = LICE_RGBA(48, 72, 64, 255); -const LICE_pixel kColCtrlBg = LICE_RGBA(60, 60, 66, 255); - -void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { - // LICE has no built-in font handle here; use GDI text into the bitmap DC, matching - // bank_panel's drawCenteredText approach (SetTextColor + DrawText on getDC()). +void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col, + UINT fmt = DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX) { HDC dc = bmp->getDC(); SetBkMode(dc, TRANSPARENT); SetTextColor(dc, col); RECT gr{r.left, r.top, r.right, r.bottom}; - DrawTextA(dc, s, -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + DrawTextA(dc, s, -1, &gr, fmt | DT_END_ELLIPSIS); } void drawTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) { - HDC dc = bmp->getDC(); - SetBkMode(dc, TRANSPARENT); - SetTextColor(dc, col); - RECT gr{r.left, r.top, r.right, r.bottom}; - DrawTextA(dc, s, -1, &gr, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + drawText(bmp, r, s, col, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); } -// Clamp a MIDI note to [0, 127]. -int clampNote(int n) { return n < 0 ? 0 : (n > 127 ? 127 : n); } +// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the +// common DAW convention REAPER uses). +std::string noteLabel(int note) { + static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"}; + if (note < 0) note = 0; + if (note > 127) note = 127; + const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4 + return std::string(kNames[note % 12]) + std::to_string(octave); +} -// A short display name for a bank sample id, from the snapshotted list (id if unnamed, -// "?" if the id no longer resolves — e.g. a stale zone). +// Draw a mono peak envelope centered vertically in `r` (mirror of bank_panel::drawThumbnail, +// single channel). Each bin is a vertical line from its min to its max about the midline. +void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) { + if (r.width() <= 0 || r.height() <= 0 || env.empty() || env[0].empty()) return; + const ChannelEnvelope& ch = env[0]; + const int mid = r.top + r.height() / 2; + const int halfH = r.height() / 2; + const int bins = static_cast(ch.size()); + for (int x = 0; x < r.width() && x < bins; ++x) { + const MinMax& mm = ch[static_cast(x)]; + const int yTop = mid - static_cast(mm.max * halfH); + const int yBot = mid - static_cast(mm.min * halfH); + LICE_Line(bmp, r.left + x, yTop, r.left + x, yBot, kColThumb, 1.0f, 0, false); + } +} + +// A display name for a bank sample id from the snapshotted list ("?" if the id no longer +// resolves — e.g. a zone naming a deleted sample). std::string sampleLabel(const std::vector& samples, const std::string& id) { for (const SampleChoice& c : samples) { if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; } - return "?"; // stale: id not in the live bank + return "?"; +} + +// The bin count a card's thumbnail is computed at: the card thumbnail width, so one bin +// per horizontal pixel. +int thumbBins(const BrowserLayout& layout) { + return (std::max)(1, cardThumbnailRect(layout, 0).width()); } #endif } // namespace ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) : CPluginView(nullptr), processor_(processor) { - // Default view size; the host may resize (canResize() == true). - ViewRect r(0, 0, 420, 260); + // Default view size — sized to show a couple of card rows + the setup strip. + ViewRect r(0, 0, 560, 400); setRect(r); } -void ReaSamplerEditor::refreshSampleList() { +void ReaSamplerEditor::refreshFromBank() { // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). + thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks if (!processor_) { samples_.clear(); + banks_.clear(); + visible_.clear(); selectedId_.clear(); map_.zones.clear(); selectedZone_ = -1; return; } - auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - samples_ = banks ? listSamples(*banks) : std::vector{}; + auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banksJson ? listSamples(*banksJson) : std::vector{}; + banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; selectedId_ = processor_->selectedSampleId(); map_ = processor_->performanceMap(); if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; + // Drop a filter that names a bank no longer present. + if (!activeFilterBankId_.empty()) { + bool found = false; + for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true; + if (!found) activeFilterBankId_.clear(); + } + rebuildVisible(); } -void ReaSamplerEditor::commitMapAndReload() { - // UI thread only. Publish the edited map to the processor, then rebuild the instrument - // off the audio thread (reloadFromBank bakes the zones into the live Keymap). +void ReaSamplerEditor::rebuildVisible() { + visible_.clear(); + for (const SampleChoice& s : samples_) { + if (activeFilterBankId_.empty() || s.bankId == activeFilterBankId_) + visible_.push_back(s); + } +} + +void ReaSamplerEditor::commitAndReload() { + // UI thread only. Publish the edited selection + zones to the processor, then rebuild + // the instrument off the audio thread (reloadFromBank bakes them into the live Keymap). if (!processor_) return; + processor_->setSelectedSampleId(selectedId_); processor_->setPerformanceMap(map_); processor_->reloadFromBank(); #ifdef _WIN32 - if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); + invalidate(); #endif } +const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { + const std::string key = sampleId + "|" + std::to_string(binCount); + auto it = thumbCache_.find(key); + if (it != thumbCache_.end()) return it->second; + + // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so + // resolve the path from the live bank blob (selectSample) and decode via the shared WAV + // parse — the mirror of the processor's decodeRelative. Every failure path caches an + // empty envelope so a broken/missing file is not re-decoded on every paint. + std::string relativePath; + Envelope env; + if (processor_) { + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) { + if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; + } + if (!relativePath.empty()) { + const std::string projectDir = processor_->bridge().activeProjectDir(); + const std::string abs = resolveBankFile(projectDir, relativePath); + std::vector bytes; + std::ifstream f(abs, std::ios::binary | std::ios::ate); + if (f) { + const std::streamoff size = f.tellg(); + if (size > 0) { + f.seekg(0, std::ios::beg); + bytes.resize(static_cast(size)); + if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); + } + } + const WavLayout layout = parseWavLayout(bytes); + if (layout.valid) { + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + std::vector mono = + downmixToMono(interleaved, layout.channelCount); + env = computeEnvelope(mono, 1, mono.size(), + static_cast((std::max)(1, binCount))); + } + } + } + auto ins = thumbCache_.emplace(key, std::move(env)); + return ins.first->second; +} + ReaSamplerEditor::~ReaSamplerEditor() { #ifdef _WIN32 - // Defensive teardown: the host normally calls removed() (which destroys the child) - // before releasing us, but if we're destroyed while still attached, don't leak the - // window — mirror the create in attachedToParent(). if (childHwnd_) { DestroyWindow(childHwnd_); childHwnd_ = nullptr; @@ -132,17 +243,18 @@ tresult PLUGIN_API ReaSamplerEditor::canResize() { #ifdef _WIN32 +void ReaSamplerEditor::invalidate() { + if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); +} + void ReaSamplerEditor::attachedToParent() { - // systemWindow is the parent HWND the host created (CPluginView::attached set it - // from the void* parent when the type is HWND). HWND parent = static_cast(systemWindow); if (!parent) return; - HINSTANCE hInst = reinterpret_cast( - GetWindowLongPtr(parent, GWLP_HINSTANCE)); + HINSTANCE hInst = + reinterpret_cast(GetWindowLongPtr(parent, GWLP_HINSTANCE)); if (!hInst) hInst = GetModuleHandle(nullptr); - // Register the child window class once per module. static bool classRegistered = false; if (!classRegistered) { WNDCLASSW wc{}; @@ -155,17 +267,13 @@ void ReaSamplerEditor::attachedToParent() { classRegistered = true; } - // Snapshot the live bank so the first paint shows the sample list. - refreshSampleList(); + refreshFromBank(); const ViewRect& r = getRect(); - childHwnd_ = CreateWindowExW( - 0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(), - r.getHeight(), parent, nullptr, hInst, nullptr); + childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, + r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); if (childHwnd_) { - // Stash `this` so wndProc can route messages back to the instance. - SetWindowLongPtr(childHwnd_, GWLP_USERDATA, - reinterpret_cast(this)); + SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); } } @@ -177,15 +285,37 @@ void ReaSamplerEditor::removedFromParent() { } tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { - // Let CPluginView latch the new rect, then resize the child window to match so the - // LICE surface fills the host-provided seat. tresult res = CPluginView::onSize(newSize); if (childHwnd_ && newSize) { MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE); + thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them } return res; } +// The client bands: title (top), toggle (below title), then the mode content. In the +// browser view the content is the browser grid on top of the single-capture setup band +// (when a capture is picked); in the zones view the content is the zones strip + list. +namespace { +struct EditorBands { + Rect title; + Rect toggleBrowser; // left half of the toggle band + Rect toggleZones; // right half + Rect content; // below the toggle band: the mode's own area +}; +EditorBands computeBands(int w, int h) { + EditorBands b; + const int titleH = (std::min)(kTitleHeight, h); + b.title = Rect{0, 0, w, titleH}; + const int toggleTop = titleH; + const int toggleBot = (std::min)(h, toggleTop + kToggleHeight); + b.toggleBrowser = Rect{0, toggleTop, w / 2, toggleBot}; + b.toggleZones = Rect{w / 2, toggleTop, w, toggleBot}; + b.content = Rect{0, toggleBot, w, h}; + return b; +} +} // namespace + void ReaSamplerEditor::paint(HDC hdc) { RECT cr{}; GetClientRect(childHwnd_, &cr); @@ -193,173 +323,425 @@ void ReaSamplerEditor::paint(HDC hdc) { const int h = cr.bottom - cr.top; if (w <= 0 || h <= 0) return; - // Same double-buffered LICE pattern as bank_panel::paintPanel: draw into a - // LICE_SysBitmap, then BitBlt to the window DC. LICE_SysBitmap bmp(w, h); LICE_Clear(&bmp, kColBackground); - const KeymapEditorLayout layout = layoutKeymapEditor(w, h); - const EditorLayout& base = layout.base; + const EditorBands bands = computeBands(w, h); - // Title band: the plugin name + a live-state / mode readout. - LICE_FillRect(&bmp, base.titleBar.left, base.titleBar.top, base.titleBar.width(), - base.titleBar.height(), kColTitleBg, 1.0f, 0); - std::string title = "ReaSampler Instrument"; + // Title band: product name + live readout. + LICE_FillRect(&bmp, bands.title.left, bands.title.top, bands.title.width(), + bands.title.height(), kColTitleBg, 1.0f, 0); + std::string title = kPluginName; if (processor_ && processor_->bridge().isConnected()) { - if (samples_.empty()) { - title += " [bank: empty]"; - } else if (map_.zones.empty()) { - title += " [Tier 0: pick a sample - Add Zone for a keymap]"; - } else { - title += " [keymap: " + std::to_string(map_.zones.size()) + " zone(s)]"; - } + if (samples_.empty()) title += " [bank empty]"; + else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]"; + else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; + else title += " [" + sampleLabel(samples_, selectedId_) + "]"; } else { title += " [host: no bridge]"; } - Rect titleText{base.titleBar.left + 8, base.titleBar.top, base.titleBar.right - 8, - base.titleBar.bottom}; + Rect titleText{bands.title.left + 8, bands.title.top, bands.title.right - 8, + bands.title.bottom}; drawText(&bmp, titleText, title.c_str(), kRgbText); - // LEFT column: the bank-sample list. The selected id (fallback + "sample to add" for - // a new zone) is highlighted. Clip rows past the visible list. - for (int i = 0; i < static_cast(samples_.size()); ++i) { - const Rect row = keymapSampleRowRect(layout, i); - if (row.top >= layout.sampleList.bottom) break; - const bool sel = !selectedId_.empty() && samples_[i].id == selectedId_; - LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(), - sel ? kColBtnHitBg : kColBtnBg, 1.0f, 0); - if (sel) { - LICE_DrawRect(&bmp, row.left, row.top, row.width() - 1, row.height() - 1, - kColBtnBorder, 1.0f, 0); - } - Rect textR{row.left + 8, row.top, row.right - 8, row.bottom}; - const std::string& name = samples_[i].displayName; - drawText(&bmp, textR, name.empty() ? samples_[i].id.c_str() : name.c_str(), - kRgbText); - } + // Toggle band: Browser | Zones. + const bool inZones = (view_ == View::kZones); + LICE_FillRect(&bmp, bands.toggleBrowser.left, bands.toggleBrowser.top, + bands.toggleBrowser.width(), bands.toggleBrowser.height(), + inZones ? kColTabBg : kColTabActiveBg, 1.0f, 0); + LICE_FillRect(&bmp, bands.toggleZones.left, bands.toggleZones.top, + bands.toggleZones.width(), bands.toggleZones.height(), + inZones ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(&bmp, bands.toggleBrowser, "Browser", kRgbText); + drawTextCentered(&bmp, bands.toggleZones, "Zones", kRgbText); - // RIGHT column: the zone panel. "Add Zone" button on top, then one row per zone. - LICE_FillRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top, - layout.addZoneButton.width(), layout.addZoneButton.height(), kColBtnBg, - 1.0f, 0); - LICE_DrawRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top, - layout.addZoneButton.width() - 1, layout.addZoneButton.height() - 1, - kColBtnBorder, 1.0f, 0); - drawTextCentered(&bmp, layout.addZoneButton, "+ Add Zone", kRgbText); - - // The seven per-row controls, laid out left-to-right pinned to the right edge. - const char* kCtrlGlyphs[7] = {"-", "+", "-", "+", "-", "+", "x"}; - for (int i = 0; i < static_cast(map_.zones.size()); ++i) { - const Rect row = zoneRowRect(layout, i); - if (row.top >= layout.zoneRowArea.bottom) break; // past the visible panel - const bool sel = (i == selectedZone_); - LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(), - sel ? kColZoneSelBg : kColBtnBg, 1.0f, 0); - - const PerformanceZone& z = map_.zones[i]; - std::string label = sampleLabel(samples_, z.sampleId); - label += " " + std::to_string(z.lowNote) + "-" + std::to_string(z.highNote); - label += " r" + (z.rootOverride ? std::to_string(*z.rootOverride) + "*" - : std::string("(bank)")); - // Label area stops where the control block begins (7 * ctrl width from the right). - const int ctrlBlockLeft = row.right - 7 * kZoneCtrlWidth; - Rect textR{row.left + 6, row.top, ctrlBlockLeft - 4, row.bottom}; - drawText(&bmp, textR, label.c_str(), kRgbText); - - // Draw the 7 mini-buttons. - for (int s = 0; s < 7; ++s) { - const int bx = ctrlBlockLeft + s * kZoneCtrlWidth; - Rect cell{bx, row.top + 2, bx + kZoneCtrlWidth - 1, row.bottom - 2}; - LICE_FillRect(&bmp, cell.left, cell.top, cell.width(), cell.height(), - kColCtrlBg, 1.0f, 0); - drawTextCentered(&bmp, cell, kCtrlGlyphs[s], kRgbText); - } + if (view_ == View::kZones) { + paintZones(&bmp, w, h); + } else { + paintBrowser(&bmp, w, h); } BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } -void ReaSamplerEditor::onClick(int x, int y) { +void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { + // Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from + // a bank filter that hides everything. Either way it is the "pick a capture" empty state. + const char* msg = samples_.empty() + ? "No captures in this project yet — capture audio into the bank to play it here." + : "No captures in this bank filter. Choose another bank tab above."; + drawTextCentered(bmp, area, msg, kRgbDim); +} + +void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { + const EditorBands bands = computeBands(w, h); + // When a capture is picked, the setup band takes the bottom; the browser gets the rest. + const bool havePick = !selectedId_.empty(); + const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) + : bands.content.bottom; + const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + + // The browser tabs + card grid, laid out by the pure module over the browser sub-area. + // capture_browser lays out from (0,0); offset the draw by browserArea's origin. + const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); + const int ox = browserArea.left; + const int oy = browserArea.top; + + // Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights. + const int tabCount = static_cast(banks_.size()) + 1; + for (int i = 0; i < tabCount; ++i) { + Rect t = filterTabRect(bl, tabCount, i); + t = Rect{t.left + ox, t.top + oy, t.right + ox, t.bottom + oy}; + const std::string label = (i == 0) ? "All" : banks_[static_cast(i - 1)].displayName; + const bool active = (i == 0) ? activeFilterBankId_.empty() + : (banks_[static_cast(i - 1)].id == activeFilterBankId_); + LICE_FillRect(bmp, t.left, t.top, t.width(), t.height(), + active ? kColTabActiveBg : kColTabBg, 1.0f, 0); + drawTextCentered(bmp, t, label.c_str(), kRgbText); + } + + // Cards: one per visible sample. Clip at the browser area bottom (scroll is S12). + const int bins = thumbBins(bl); + for (int i = 0; i < static_cast(visible_.size()); ++i) { + Rect content = cardContentRect(bl, i); + if (content.top + oy >= browserArea.bottom) break; // past the visible grid + Rect thumb = cardThumbnailRect(bl, i); + Rect labelR = cardLabelRect(bl, i); + content = Rect{content.left + ox, content.top + oy, content.right + ox, content.bottom + oy}; + thumb = Rect{thumb.left + ox, thumb.top + oy, thumb.right + ox, thumb.bottom + oy}; + labelR = Rect{labelR.left + ox, labelR.top + oy, labelR.right + ox, labelR.bottom + oy}; + + const SampleChoice& s = visible_[static_cast(i)]; + const bool sel = (s.id == selectedId_); + LICE_FillRect(bmp, content.left, content.top, content.width(), content.height(), + sel ? kColCardSelBg : kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1, + sel ? kColCardSelBorder : kColCardBorder, 1.0f, 0); + drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); + + // Name + root/key badge under the thumbnail. + std::string caption = s.displayName.empty() ? s.id : s.displayName; + Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2}; + Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom}; + drawText(bmp, nameR, caption.c_str(), kRgbText); + std::string badge; + if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); + else if (s.key) badge = *s.key; + else badge = "root —"; + drawText(bmp, badgeR, badge.c_str(), kRgbDim); + } + + if (havePick) { + paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom}); + } else if (visible_.empty()) { + paintEmptyState(bmp, browserArea); + } +} + +void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { + // The guided single-capture setup: the picked capture's name + root/level, and a + // keyboard strip with its root marker (drag to set root). + LICE_FillRect(bmp, area.left, area.top, area.width(), area.height(), + kColTitleBg, 1.0f, 0); + + // Effective root: the picked sample's rootNote intrinsic (or middle C when unset). + int root = 60; + for (const SampleChoice& s : visible_) { + if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; + } + // If a matching one-zone override exists (opt-in from Zones), prefer it as the shown root. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride; + } + + const int pad = 8; + Rect headerR{area.left + pad, area.top + 4, area.right - pad, area.top + 22}; + std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root); + drawText(bmp, headerR, header.c_str(), kRgbText); + + Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16}; + drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim); + + // Keyboard strip with the root marker. + const int stripTop = area.bottom - kStripBandHeight; + Rect stripArea{area.left + pad, stripTop, area.right - pad, area.bottom - 4}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int sx = stripArea.left; + const int sy = stripArea.top; + LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(), + kColStripBg, 1.0f, 0); + // Faint per-octave key ticks for orientation. + for (int n = 0; n <= 127; n += 12) { + Rect k = keyRect(sl, n); + LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey, + 1.0f, 0, false); + } + Rect marker = rootMarkerRect(sl, root); + LICE_FillRect(bmp, marker.left + sx, sy, (std::max)(2, marker.width()), stripArea.height(), + kColRootMarker, 1.0f, 0); +} + +void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { + const EditorBands bands = computeBands(w, h); + const int pad = 8; + + // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip + // with one bar per zone. Delete is a small × on the selected zone (keystroke also). + Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, + bands.content.top + 4 + 20}; + LICE_FillRect(bmp, addR.left, addR.top, addR.width(), addR.height(), kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, addR.left, addR.top, addR.width() - 1, addR.height() - 1, + kColCardSelBorder, 1.0f, 0); + drawTextCentered(bmp, addR, "+ Add Zone", kRgbText); + + Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; + if (selectedZone_ >= 0) { + LICE_FillRect(bmp, delR.left, delR.top, delR.width(), delR.height(), kColCardBg, 1.0f, 0); + LICE_DrawRect(bmp, delR.left, delR.top, delR.width() - 1, delR.height() - 1, + kColCardSelBorder, 1.0f, 0); + drawTextCentered(bmp, delR, "Delete", kRgbText); + } + + // The zones strip. + const int stripTop = addR.bottom + 12; + Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, + stripTop + kStripBandHeight}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int sx = stripArea.left; + const int sy = stripArea.top; + LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(), + kColStripBg, 1.0f, 0); + for (int n = 0; n <= 127; n += 12) { + Rect k = keyRect(sl, n); + LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey, + 1.0f, 0, false); + } + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[static_cast(i)]; + Rect bar = zoneBarRect(sl, z.lowNote, z.highNote); + const bool sel = (i == selectedZone_); + LICE_FillRect(bmp, bar.left + sx, sy, (std::max)(2, bar.width()), stripArea.height(), + sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0); + } + + // A one-line legend of the selected zone below the strip. + Rect infoR{stripArea.left, stripArea.bottom + 8, stripArea.right, stripArea.bottom + 26}; + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) + + " – " + noteLabel(z.highNote) + " root " + + (z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)")); + drawText(bmp, infoR, info.c_str(), kRgbText); + } else if (map_.zones.empty()) { + drawText(bmp, infoR, + "No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim); + } +} + +// --- Input: the drag-state machine ------------------------------------------- + +void ReaSamplerEditor::onMouseDown(int x, int y) { if (!processor_) return; RECT cr{}; GetClientRect(childHwnd_, &cr); - const KeymapEditorLayout layout = - layoutKeymapEditor(cr.right - cr.left, cr.bottom - cr.top); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + const EditorBands bands = computeBands(w, h); - // 1. Left list: pick the Tier-0 fallback / the sample a new zone will use. - const int row = - keymapSampleRowHitTest(layout, static_cast(samples_.size()), x, y); - if (row >= 0) { - processor_->setSelectedSampleId(samples_[row].id); - selectedId_ = samples_[row].id; - // A fallback change only affects playback when the map is empty; reload so the - // Tier-0 case updates immediately. - processor_->reloadFromBank(); - InvalidateRect(childHwnd_, nullptr, FALSE); + // Toggle band: switch views. + if (contains(bands.toggleBrowser, x, y)) { view_ = View::kBrowser; invalidate(); return; } + if (contains(bands.toggleZones, x, y)) { view_ = View::kZones; invalidate(); return; } + + if (view_ == View::kBrowser) { + const bool havePick = !selectedId_.empty(); + const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) + : bands.content.bottom; + const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; + const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); + const int bx = x - browserArea.left; + const int by = y - browserArea.top; + + // Filter tabs. + const int tabCount = static_cast(banks_.size()) + 1; + const int tab = filterTabHitTest(bl, tabCount, bx, by); + if (tab >= 0) { + activeFilterBankId_ = (tab == 0) ? std::string() + : banks_[static_cast(tab - 1)].id; + rebuildVisible(); + invalidate(); + return; + } + // Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). + const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by); + if (card >= 0) { + selectedId_ = visible_[static_cast(card)].id; + commitAndReload(); // publishes the pick + reloads; process() plays it repitched + return; + } + // The setup strip: grab the root marker (drag to set the picked capture's root). + if (havePick) { + const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + const int stripTop = area.bottom - kStripBandHeight; + const Rect stripArea{area.left + 8, stripTop, area.right - 8, area.bottom - 4}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); + if (note >= 0) { + drag_ = DragKind::kRootMarker; + dragStartX_ = x; + dragStartRoot_ = note; + // A click sets the root immediately (drag then refines); the override lives on + // a one-zone map entry for the picked capture (D-B, never written to the bank). + onMouseMove(x, y); // apply the click position as the first delta==0 set + return; + } + } return; } - // 2. "Add Zone": append a full-keyboard zone for the currently-selected sample (root - // from the bank intrinsic — no override until the user nudges it). - if (addZoneHitTest(layout, x, y)) { - if (selectedId_.empty()) return; // nothing selected to add + // Zones view. + const int pad = 8; + Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, + bands.content.top + 4 + 20}; + if (contains(addR, x, y)) { + // Append a full-keyboard zone for the picked capture (or the first visible sample as a + // sensible seed). No pick -> nothing to add. + std::string seed = !selectedId_.empty() ? selectedId_ + : (!visible_.empty() ? visible_.front().id : std::string()); + if (seed.empty()) return; PerformanceZone z; - z.sampleId = selectedId_; + z.sampleId = seed; z.lowNote = 0; z.highNote = 127; map_.zones.push_back(z); selectedZone_ = static_cast(map_.zones.size()) - 1; - commitMapAndReload(); + commitAndReload(); return; } - - // 3. Zone rows: select a zone, nudge its range/root, or delete it. - const ZoneHit hit = - zoneHitTest(layout, static_cast(map_.zones.size()), x, y); - if (hit.zoneIndex < 0) return; - selectedZone_ = hit.zoneIndex; - - if (hit.field == ZoneField::kZoneNone) { - InvalidateRect(childHwnd_, nullptr, FALSE); // select only, no reload - return; - } - if (hit.field == ZoneField::kDelete) { - map_.zones.erase(map_.zones.begin() + hit.zoneIndex); + Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; + if (selectedZone_ >= 0 && contains(delR, x, y)) { + map_.zones.erase(map_.zones.begin() + selectedZone_); selectedZone_ = -1; - commitMapAndReload(); + commitAndReload(); return; } - PerformanceZone& z = map_.zones[hit.zoneIndex]; - switch (hit.field) { - case ZoneField::kLowDown: z.lowNote = clampNote(z.lowNote - 1); break; - case ZoneField::kLowUp: z.lowNote = clampNote(z.lowNote + 1); break; - case ZoneField::kHighDown: z.highNote = clampNote(z.highNote - 1); break; - case ZoneField::kHighUp: z.highNote = clampNote(z.highNote + 1); break; - case ZoneField::kRootDown: { - const int base = z.rootOverride ? *z.rootOverride : 60; - z.rootOverride = clampNote(base - 1); // first nudge establishes the override - break; + // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the + // selected zone's root. + const int stripTop = addR.bottom + 12; + const Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, + stripTop + kStripBandHeight}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int lx = x - stripArea.left; + const int ly = y - stripArea.top; + + std::vector lows, highs; + lows.reserve(map_.zones.size()); + highs.reserve(map_.zones.size()); + for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); } + const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(), + highs.empty() ? nullptr : highs.data(), + static_cast(map_.zones.size()), lx, ly); + if (hit.zoneIndex >= 0) { + selectedZone_ = hit.zoneIndex; + const PerformanceZone& z = map_.zones[static_cast(hit.zoneIndex)]; + dragStartX_ = x; + dragStartLow_ = z.lowNote; + dragStartHigh_ = z.highNote; + switch (hit.grab) { + case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break; + case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break; + case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break; + default: drag_ = DragKind::kNone; break; } - case ZoneField::kRootUp: { - const int base = z.rootOverride ? *z.rootOverride : 60; - z.rootOverride = clampNote(base + 1); - break; + invalidate(); + return; + } + // A bare key-click inside the strip sets the selected zone's root override. + if (contains(stripArea, x, y) && selectedZone_ >= 0 && + selectedZone_ < static_cast(map_.zones.size())) { + const int note = keyAtPoint(sl, lx, ly); + if (note >= 0) { + map_.zones[static_cast(selectedZone_)].rootOverride = note; + commitAndReload(); } - default: break; // kZoneNone/kDelete handled above } - // Keep low <= high after a range nudge (clamp the moved edge against its partner). - if (z.lowNote > z.highNote) { - if (hit.field == ZoneField::kLowUp) z.lowNote = z.highNote; - else if (hit.field == ZoneField::kHighDown) z.highNote = z.lowNote; +} + +void ReaSamplerEditor::onMouseMove(int x, int y) { + if (drag_ == DragKind::kNone) return; + RECT cr{}; + GetClientRect(childHwnd_, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + const EditorBands bands = computeBands(w, h); + const int dx = x - dragStartX_; + + if (drag_ == DragKind::kRootMarker) { + // The single-capture root strip lives in the setup band. + const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight); + const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; + const Rect stripArea{area.left + 8, area.bottom - kStripBandHeight, area.right - 8, + area.bottom - 4}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + const int note = resolveDragNote(sl, dragStartRoot_, dx); + // The performance map is the ONLY D-B override vehicle (rootOverride lives on a zone), + // so setting the single capture's root materializes a full-keyboard zone carrying the + // override. This plays identically to the un-zoned single-capture path (one chromatic + // zone over the whole keyboard) and round-trips through the v3 component state; the + // zone becomes visible if the user opens the Zones panel. Upsert by the picked id so a + // repeated drag edits the same zone rather than stacking duplicates. + bool found = false; + for (PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_) { z.rootOverride = note; found = true; break; } + } + if (!found) { + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + z.rootOverride = note; + map_.zones.push_back(z); + } + invalidate(); // live feedback; the commit lands on WM_LBUTTONUP + return; } - commitMapAndReload(); + + // Zone edits: recompute the grabbed field(s) against the pure resolver, live. + if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; + const int pad = 8; + const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12 + const Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, + stripTop + kStripBandHeight}; + const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); + PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; + if (drag_ == DragKind::kZoneLow) { + z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote); + } else if (drag_ == DragKind::kZoneHigh) { + z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote); + } else if (drag_ == DragKind::kZoneBody) { + // Move the whole span: apply the SAME delta to both edges so the span is preserved, + // clamping so neither edge escapes [0,127] (the span shifts, never shrinks). + const int newLow = resolveDragNote(sl, dragStartLow_, dx); + const int newHigh = resolveDragNote(sl, dragStartHigh_, dx); + const int span = dragStartHigh_ - dragStartLow_; + if (newLow < 0) { z.lowNote = 0; z.highNote = span; } + else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; } + else { z.lowNote = newLow; z.highNote = newHigh; } + } + invalidate(); +} + +void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) { + if (drag_ == DragKind::kNone) return; + drag_ = DragKind::kNone; + // One coherent edit lands on release: publish the in-flight map + reload off-thread. + commitAndReload(); } LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { - auto* self = reinterpret_cast( - GetWindowLongPtr(hwnd, GWLP_USERDATA)); + auto* self = + reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); switch (msg) { case WM_PAINT: { PAINTSTRUCT ps{}; @@ -369,10 +751,22 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, return 0; } case WM_LBUTTONDOWN: - if (self) self->onClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + if (self) { + SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child + self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + } + return 0; + case WM_MOUSEMOVE: + if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_LBUTTONUP: + if (self) { + self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + ReleaseCapture(); + } return 0; case WM_ERASEBKGND: - return 1; // we fully repaint in WM_PAINT; skip the flicker-inducing erase + return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase default: return DefWindowProcW(hwnd, msg, wParam, lParam); } diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 3012a75..e492869 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -1,90 +1,135 @@ -// reasampler_editor.h — the VST3 IPlugView LICE editor (Phase S1 bridge spike). THIN -// shell: hosts a LICE-drawn child window inside the host's IPlugView seat and routes -// host paint/click into the pure editor_geometry hit-test. Windows-only (D5). +// reasampler_editor.h — the VST3 IPlugView LICE editor for the ReaSampler 9000 +// capture-first UI (Phase S10). THIN shell: hosts a LICE-drawn child window inside the +// host's IPlugView seat and routes host paint/mouse into the pure geometry modules +// (capture_browser, keyboard_strip) + the pure mapping (sample_map). Windows-only (D5). // -// This is the phase's ONE genuine unknown (per CONTEXT.md §Phase S / S1): wiring LICE -// into an IPlugView. It reuses the bank_panel LICE/SWELL competence — a LICE_SysBitmap -// blitted in WM_PAINT, GET_X/Y_LPARAM hit-testing in WM_LBUTTONDOWN — but hangs it off a -// child HWND created in IPlugView::attached() rather than a docked SWELL dialog. +// The default face is the CAPTURE BROWSER: a bank-filter tab strip over a grid of +// scannable capture cards (peak thumbnail + name + root/key badge). A fresh instance with +// no pick shows a "pick a capture" EMPTY STATE and plays silence (the S10 policy reversal +// of the S4 first-sample auto-play). Picking a card loads that one capture and reveals a +// guided SINGLE-CAPTURE SETUP surface (a keyboard strip with the capture's root marker + +// a level readout). Multi-zone keymap editing is a demoted, opt-in ZONES panel (S10-Z), +// reached by a toggle and driven by the same keyboard_strip drag machine. // -// Subclasses CPluginView (public.sdk/source/common/pluginview.h) for the IPlugView -// refcount + attached/removed/getSize/onSize boilerplate; we override the attach/remove -// hooks to create/destroy the child window and onSize to resize it. +// All layout/hit-test/drag math lives in the pure modules; this shell only draws + routes +// (a LICE_SysBitmap blitted in WM_PAINT, a WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP +// drag-state machine hit-testing via the pure resolvers). Peak thumbnails are computed +// shell-side from the decoded WAV (bank_model's Sample carries no envelope) and cached — +// the mirror of bank_panel::thumbnailFor. Every edit commits OFF the audio thread via the +// processor's reloadFromBank (RT path untouched). +// +// Subclasses CPluginView for the IPlugView boilerplate; overrides the attach/remove hooks +// to create/destroy the child window and onSize to resize it. #pragma once +#include #include +#include #include #include "public.sdk/source/common/pluginview.h" -#include "sample_map.h" // SampleChoice (the list the editor draws) +#include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) +#include "peaks.h" // Envelope (the cached peak thumbnail) +#include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) #ifdef _WIN32 #include #endif +class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only in the .cpp + namespace reasampler::vst { class ReaSamplerProcessor; class ReaSamplerEditor : public Steinberg::CPluginView { public: - // `processor` owns this editor's lifetime domain and outlives it; the editor reads - // the live bank through it (the sample list) and drives selection + reload when the - // user clicks a row. May be null (defensive — a real host always supplies one). + // `processor` owns this editor's lifetime domain and outlives it; the editor reads the + // live bank through it and drives selection/zone edits + reload on user input. May be + // null (defensive — a real host always supplies one). explicit ReaSamplerEditor(ReaSamplerProcessor* processor); ~ReaSamplerEditor() override; - // Accept only the Windows HWND platform type (D5: Windows-only). Steinberg::tresult PLUGIN_API isPlatformTypeSupported( Steinberg::FIDString type) override; - - // The view is user-resizable in the spike so we exercise the onSize path. Steinberg::tresult PLUGIN_API canResize() override; protected: - // CPluginView hooks: systemWindow is set by the time attachedToParent() fires. void attachedToParent() override; void removedFromParent() override; - Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override; private: + // Which face the editor shows. The browser is the default; the Zones panel is the + // demoted opt-in view reached by the toggle. Both draw over the same snapshotted bank. + enum class View { kBrowser, kZones }; + + // What a mouse drag is currently editing (the drag-state machine). kNone = no drag in + // flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the + // single-capture root drag on the setup strip. + enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody }; + #ifdef _WIN32 - // Draw the current surface into the child window's DC via a LICE bitmap. void paint(HDC hdc); - // Route a client-space click: bank-sample pick (left list), zone edit (right panel), - // or the "Add Zone" button. - void onClick(int x, int y); + void paintBrowser(LICE_IBitmap* bmp, int w, int h); + void paintSetup(LICE_IBitmap* bmp, const Rect& area); + void paintZones(LICE_IBitmap* bmp, int w, int h); + void paintEmptyState(LICE_IBitmap* bmp, const Rect& area); + + void onMouseDown(int x, int y); + void onMouseMove(int x, int y); + void onMouseUp(int x, int y); static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); + void invalidate(); HWND childHwnd_ = nullptr; #endif - // Re-read the bank's sample list from the live bridge into `samples_`, and snapshot the - // instrument's performance map. Main/UI thread only (reads ext-state); called on attach - // and after any edit that reloads the instrument. - void refreshSampleList(); + // Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's + // selection + performance map. Main/UI thread only. Called on attach and after any edit. + void refreshFromBank(); - // Push the edited performance map to the processor and rebuild the instrument OFF the - // audio thread. UI thread only. Centralizes the "commit an edit" path so every zone - // mutation reloads identically. - void commitMapAndReload(); + // Publish the edited zones/selection to the processor, then rebuild the instrument OFF + // the audio thread. UI thread only. One place so every edit commits identically. + void commitAndReload(); + + // Recompute the capture cards visible under the current bank filter (samples_ narrowed by + // activeFilterBankId_; "" = All) into visible_. Called on refresh + filter change. + void rebuildVisible(); + + // The peak thumbnail for a bank sample id at `binCount` bins, computed once from the + // decoded WAV (mirror of bank_panel::thumbnailFor) and cached by (id, binCount). Returns + // an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O). + const Envelope& thumbnailFor(const std::string& sampleId, int binCount); ReaSamplerProcessor* processor_ = nullptr; - // The bank's samples, snapshotted for the current paint. Refreshed off the audio - // thread; the paint just draws it. - std::vector samples_; - // The currently-selected sample id (Tier-0 fallback + the "sample to add" for a new - // zone), mirrored for the paint's highlight. - std::string selectedId_; - // The instrument's performance map, snapshotted for edit + paint. Edits mutate this - // copy then commit it to the processor via commitMapAndReload. - PerformanceMap map_; - // The zone row currently highlighted (for the paint); -1 = none. - int selectedZone_ = -1; + + // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- + std::vector samples_; // every bank sample, bank order + std::vector banks_; // the named banks, for the filter tab strip + std::vector visible_; // samples_ narrowed by the active bank filter + std::string selectedId_; // the single-capture pick ("" = empty state) + PerformanceMap map_; // the opt-in zones (empty = no zones) + + // --- Transient UI state (not persisted; component state carries selection + zones) --- + View view_ = View::kBrowser; // default face is the browser + std::string activeFilterBankId_; // "" = All; else a bank id from banks_ + int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none + + // --- Drag-state machine ------------------------------------------------------ + DragKind drag_ = DragKind::kNone; + int dragStartX_ = 0; // grab x (px), for the pixel-delta resolver + int dragStartLow_ = 0; // the grabbed field's note at grab time + int dragStartHigh_ = 0; + int dragStartRoot_ = 60; + + // --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------ + // Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so + // a bank edit (a re-captured or deleted sample) does not show a stale thumbnail. + std::unordered_map thumbCache_; }; } // namespace reasampler::vst diff --git a/src/vst/reasampler_embed.cpp b/src/vst/reasampler_embed.cpp index 43d6e30..ca4b5b9 100644 --- a/src/vst/reasampler_embed.cpp +++ b/src/vst/reasampler_embed.cpp @@ -151,8 +151,9 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { const EmbedLayout layout = layoutEmbed(w, h); if (map_.zones.empty()) { - // No keymap authored yet: show a single faint band spanning the keymap area so the - // strip still reads as "present but empty" (Tier-0 fallback plays chromatically). + // No opt-in zones authored: show a single faint band spanning the keymap area so the + // strip reads as "present, no zones" — the default single-capture face lives in the + // editor (this S6 strip mirrors the zones map only). LICE_FillRect(bmp, layout.keymap.left, layout.keymap.top, layout.keymap.width(), layout.keymap.height(), kColEmpty, 0.5f, 0); HDC dc = bmp->getDC(); @@ -161,7 +162,7 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { RECT gr{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right, layout.keymap.bottom}; const std::string label = - samples_.empty() ? "ReaSampler (bank empty)" : "ReaSampler (no zones)"; + samples_.empty() ? "ReaSampler 9000 (bank empty)" : "ReaSampler 9000 (no zones)"; DrawTextA(dc, label.c_str(), -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); } else { diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index fc00d58..daca7c7 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -65,8 +65,8 @@ std::vector readFileBytes(const std::string& path) { // Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file // I/O — off-thread only), and downmix to the core's mono contract. Returns nullopt when // the path fails to resolve, the file is unreadable, the WAV is malformed, or the decode -// yields no frames — the caller drops the zone (Tier 1) or plays silence (Tier 0). Shared -// by the zoned build and the Tier-0 fallback so both decode identically. +// yields no frames — the caller drops the zone (zoned map) or plays silence (single capture). +// Shared by the zoned build and the single-capture path so both decode identically. std::optional decodeRelative(const std::string& projectDir, const std::string& relativePath) { const std::string abs = resolveBankFile(projectDir, relativePath); @@ -165,14 +165,16 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { bytes.insert(bytes.end(), chunk, chunk + got); } - // Component state IS the performance map (Tier 1, D-B). deserializePerformance lifts a - // v1 (S4 single-selection) blob to a one-zone map, so already-saved Tier-0 instances - // restore cleanly. When the restored map is empty, the instrument falls back to the - // Tier-0 first-sample in reloadFromBank; if any single-zone restored map is present we - // seed the fallback selection from it so the editor reflects the restored pick. - const PerformanceMap map = deserializePerformance(bytes); - setPerformanceMap(map); - if (map.zones.size() == 1) setSelectedSampleId(map.zones.front().sampleId); + // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The + // selection and the zones are DISTINCT — the default face is one picked capture, zones + // are a demoted overlay — so both are restored explicitly (no more inferring a selection + // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only + // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so + // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 + // silent empty state (no first-sample fallback in reloadFromBank). + const ComponentState cs = deserializeComponentState(bytes); + setSelectedSampleId(cs.selectionId); + setPerformanceMap(cs.map); // Rebuild from the restored state (off-thread — setState is a load-time call). reloadFromBank(); return kResultOk; @@ -180,10 +182,15 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { if (!state) return kResultFalse; - // Persist the performance map (the instrument's own Tier-1 state, D-B — NEVER written - // to the "reasampler" bank ext-state). An empty map serializes to just the version + - // zero-count header (restores as empty -> Tier-0 fallback). - const std::vector bytes = serializePerformance(performanceMap()); + // Persist the full instance state (v3, S10): the single-capture selection id AND the + // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler" + // bank ext-state. An instance with no pick and no zones serializes to {"", no zones} + // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing + // sample #1. + ComponentState state_out; + state_out.selectionId = selectedSampleId(); + state_out.map = performanceMap(); + const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), static_cast(bytes.size()), nullptr); @@ -260,9 +267,13 @@ std::string ReaSamplerProcessor::reloadFromBank() { } } - // 3. Tier-0 fallback: an empty (or fully-unresolvable) performance map plays the - // selected fallback sample chromatically across the whole keyboard — preserving - // the S4 "the bank plays" behavior for an un-authored instrument. + // 3. Single-capture fast path (S10): an empty performance map plays the ONE + // deliberately-selected capture chromatically across the whole keyboard. This is + // the default face — one picked capture, repitched from its root. NO first- + // sample fallback: an EMPTY selection (or a stale id) resolves to nullopt in + // selectSample, so an un-picked instrument stays SILENT (the editor shows its + // "pick a capture" empty state) rather than auto-playing sample #1 (S10 policy + // reversal of the S4 convenience default). if (!haveKeymap) { std::optional sel = selectSample(*banksJson, selectedSampleId()); @@ -273,9 +284,7 @@ std::string ReaSamplerProcessor::reloadFromBank() { km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, sel->rootNote, sel->loop); haveKeymap = true; - // Record which id actually resolved so a first-sample fallback - // (empty stored id) becomes the concrete selection. - resolvedId = selectedSampleId(); + resolvedId = selectedSampleId(); // the concrete pick that resolved } } } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 67dda1b..d832d2a 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -115,10 +115,10 @@ public: // The bridge, for the editor's live-state readout + sample list. Owned here; the // editor borrows it (outlives the editor). ReaperBridge& bridge() { return bridge_; } - // The current selection id (main/UI thread reads for the editor). Guarded by - // selectionMutex_ — never touched on the audio thread. In Tier 1 the selection is the - // Tier-0 FALLBACK sample (played chromatically when the performance map is empty); the - // zoned map, when non-empty, supersedes it. + // The current single-capture selection id (main/UI thread reads for the editor). Guarded + // by selectionMutex_ — never touched on the audio thread. Since S10 this is the ONE picked + // capture the default face plays chromatically when the performance map is empty; an EMPTY + // id resolves to SILENCE (no first-sample fallback). A non-empty zoned map supersedes it. std::string selectedSampleId(); void setSelectedSampleId(const std::string& id); @@ -166,8 +166,9 @@ private: std::vector graveyard_; // drained on reclaim + setActive(false) + terminate std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access - // The selected sample id (Tier-0 fallback sample). Off-thread only; a small mutex - // guards the string against a getState/editor race. NOT read on the audio thread. + // The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence). + // Off-thread only; a small mutex guards the string against a getState/editor race. NOT + // read on the audio thread. std::mutex selectionMutex_; std::string selectedSampleId_; diff --git a/src/vst/reasampler_vst.h b/src/vst/reasampler_vst.h index 120311b..fc1b942 100644 --- a/src/vst/reasampler_vst.h +++ b/src/vst/reasampler_vst.h @@ -12,9 +12,15 @@ namespace reasampler::vst { -// Human-facing identity. "ReaSampler" is the tool; the instrument surfaces as -// "ReaSampler Instrument" in REAPER's FX browser to distinguish it from the extension. -inline constexpr const char* kPluginName = "ReaSampler Instrument"; +// Human-facing identity (S-NAME-1, SETTLED 2026-07-26). "ReaSampler" is the extension +// (capture + organization); the MIDI-playback instrument's product name is +// "ReaSampler 9000" — the string that surfaces in REAPER's FX browser, the factory +// display name, the editor title band, and the embed-strip label. The on-disk module is +// renamed to match (CMake OUTPUT_NAME reasampler_9000.vst3). The VST3 class UID below is +// the compat anchor and is NOT changed by the rename — a saved REAPER project rebinds a +// saved instance by class UID, so the display/filename rename keeps existing instances +// resolving (compat is a DAW-verify; see PLAN.md §S-NAME-1). +inline constexpr const char* kPluginName = "ReaSampler 9000"; inline constexpr const char* kVendorName = "ReaSampler"; inline constexpr const char* kVendorUrl = "https://github.com/daniel-c-harvey/reasampler"; inline constexpr const char* kVendorEmail = "mailto:the.real.daniel.harvey@gmail.com"; diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 6056268..cb6d8b9 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -39,28 +39,23 @@ SelectedSample distill(const Sample& s) { std::optional selectSample(const std::string& banksJson, const std::string& sampleId) { + // POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short- + // circuit before parsing — no stored id resolves to nothing to play by design. + if (sampleId.empty()) return std::nullopt; if (banksJson.empty()) return std::nullopt; std::optional book = BankBook::deserialize(banksJson); if (!book) return std::nullopt; // malformed -> nothing to play (never throw) // Search every bank (pool first, then named — banks() is ordinal order) for the // stored id. A sample lives in exactly one bank, so first hit wins. - if (!sampleId.empty()) { - for (const Bank& b : book->banks()) { - if (const Sample* s = b.index.query(sampleId)) { - return distill(*s); - } - } - } - - // No stored id, or the id no longer resolves (the sample was deleted/moved out): - // fall back to the FIRST sample in ordinal order so a fresh instance plays. for (const Bank& b : book->banks()) { - if (!b.index.all().empty()) { - return distill(b.index.all().front()); + if (const Sample* s = b.index.query(sampleId)) { + return distill(*s); } } - return std::nullopt; // bank has zero samples anywhere + // A stale stored id (no longer resolves) is SILENCE, not a substituted first sample: + // the editor reflects the missing pick with its empty state rather than masking it. + return std::nullopt; } std::vector listSamples(const std::string& banksJson) { @@ -70,12 +65,23 @@ std::vector listSamples(const std::string& banksJson) { if (!book) return out; for (const Bank& b : book->banks()) { for (const Sample& s : b.index.all()) { - out.push_back(SampleChoice{s.id, s.displayName}); + out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id}); } } return out; } +std::vector listBanks(const std::string& banksJson) { + std::vector out; + if (banksJson.empty()) return out; + std::optional book = BankBook::deserialize(banksJson); + if (!book) return out; + for (const Bank& b : book->banks()) { + out.push_back(BankChoice{b.id, b.displayName}); + } + return out; +} + std::vector downmixToMono(const std::vector& interleaved, int channelCount) { std::vector out; @@ -211,11 +217,9 @@ struct ByteReader { int i32() { return static_cast(static_cast(u32())); } }; -} // namespace - -std::vector serializePerformance(const PerformanceMap& map) { - std::vector out; - putU32le(out, kPerformanceStateVersion); +// Append the zones payload (zone count + per-zone records) — the shared body of the v2 +// performance blob and the v3 component blob, so both write zones identically. +void putZonesPayload(std::vector& out, const PerformanceMap& map) { putU32le(out, static_cast(map.zones.size())); for (const PerformanceZone& z : map.zones) { putU32le(out, static_cast(z.sampleId.size())); @@ -228,6 +232,33 @@ std::vector serializePerformance(const PerformanceMap& map) { static_cast(static_cast(*z.rootOverride))); } } +} + +// Read a zones payload (zone count + per-zone records) from `r` into `map`. Shared by the +// v2 performance parse and the v3 component parse. A truncated mid-zone read keeps the zones +// that parsed cleanly and drops the rest; the reader position is left after the last byte +// read successfully. +void readZonesPayload(ByteReader& r, PerformanceMap& map) { + const std::uint32_t count = r.u32(); + for (std::uint32_t i = 0; i < count && r.ok; ++i) { + PerformanceZone z; + const std::uint32_t idLen = r.u32(); + z.sampleId = r.str(idLen); + z.lowNote = r.i32(); + z.highNote = r.i32(); + const std::uint8_t hasOverride = r.u8(); + if (hasOverride) z.rootOverride = r.i32(); + if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest + map.zones.push_back(std::move(z)); + } +} + +} // namespace + +std::vector serializePerformance(const PerformanceMap& map) { + std::vector out; + putU32le(out, kPerformanceStateVersion); + putZonesPayload(out, map); return out; } @@ -252,21 +283,58 @@ PerformanceMap deserializePerformance(const std::vector& bytes) { } if (version != kPerformanceStateVersion) return map; // unknown -> empty - const std::uint32_t count = r.u32(); - for (std::uint32_t i = 0; i < count && r.ok; ++i) { - PerformanceZone z; - const std::uint32_t idLen = r.u32(); - z.sampleId = r.str(idLen); - z.lowNote = r.i32(); - z.highNote = r.i32(); - const std::uint8_t hasOverride = r.u8(); - if (hasOverride) z.rootOverride = r.i32(); - if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest - map.zones.push_back(std::move(z)); - } + readZonesPayload(r, map); return map; } +// --- Combined component state (v3, S10) -------------------------------------- + +std::vector serializeComponentState(const ComponentState& state) { + std::vector out; + putU32le(out, kComponentStateVersion); + // Length-prefixed selection id (it precedes the zones payload, so it MUST be framed — + // unlike the v1 selection blob where the id ran to end-of-stream). + putU32le(out, static_cast(state.selectionId.size())); + out.insert(out.end(), state.selectionId.begin(), state.selectionId.end()); + putZonesPayload(out, state.map); + return out; +} + +ComponentState deserializeComponentState(const std::vector& bytes) { + ComponentState out; + ByteReader r(bytes); + const std::uint32_t version = r.u32(); + if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state) + + // BACK-COMPAT: an older blob predates the v3 {selection, zones} split. + // * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard + // zone} so the old pick survives as BOTH the selection and a one-zone map. + // * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate + // single-capture selection. + if (version == kSelectionStateVersion) { + out.selectionId = deserializeSelection(bytes); + if (!out.selectionId.empty()) { + PerformanceZone z; + z.sampleId = out.selectionId; + z.lowNote = 0; + z.highNote = 127; + out.map.zones.push_back(std::move(z)); + } + return out; + } + if (version == kPerformanceStateVersion) { + readZonesPayload(r, out.map); // v2 body starts right after the version tag + return out; + } + if (version != kComponentStateVersion) return out; // unknown -> empty + + const std::uint32_t idLen = r.u32(); + out.selectionId = r.str(idLen); + if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty + readZonesPayload(r, out.map); + return out; +} + std::vector serializeSelection(const std::string& sampleId) { std::vector out; out.resize(4 + sampleId.size()); diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 6cc794c..ad2b696 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -44,28 +44,50 @@ struct SelectedSample { // // Precedence, all pure: // * empty / malformed banksJson -> nullopt (nothing to play) +// * sampleId empty -> nullopt (NO selection -> silence) // * sampleId names a sample in ANY bank -> that sample (searched pool + named) -// * sampleId empty or not found, bank has -> the FIRST sample in ordinal order -// >= 1 sample (a sensible default so a fresh -// instance plays SOMETHING; the UI can -// then pick a specific one) -// * bank has zero samples -> nullopt +// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved; +// the editor returns to the empty state) // -// The "first sample" fallback is deliberate: Tier 0 is "the bank plays", and a brand- -// new instance with no stored selection should map the bank's first sample rather than -// stay silent until the user opens the editor. +// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh +// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first +// sample: the metric is time-to-first-note via an explicit pick, and a mystery auto-play +// of sample #1 was the anti-pattern. A stale stored id (no longer resolves) ALSO returns +// nullopt rather than silently substituting a different sample — the editor reflects the +// missing selection with its "pick a capture" empty state instead of masking it. std::optional selectSample(const std::string& banksJson, const std::string& sampleId); -// All (id, displayName) pairs across every bank in ordinal order (pool first), for the -// selection UI to list. Empty for an empty / malformed blob. Pure projection over the -// shared parse — the UI never parses JSON itself. +// One entry in the capture browser's card list: the stable id + display name plus the S2 +// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge, +// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded +// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache, +// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already +// holds: the metadata the card badge + bank filter need. Pure projection over the shared +// parse — the UI never parses JSON itself. +// +// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the +// badge shows "root: —" / no root, never a guessed value). +// - key: the optional human musical key label ("F#m"), when the bank set it. +// - bankId: the id of the bank this sample lives in (the bank filter matches on it). struct SampleChoice { std::string id; std::string displayName; + std::optional rootNote; + std::optional key; + std::string bankId; }; std::vector listSamples(const std::string& banksJson); +// One bank the filter tab strip offers: its stable id + display name, in ordinal order +// (pool first). The browser prepends an "All" tab (no id) shell-side. Empty for an empty / +// malformed blob. Pure projection over the shared parse. +struct BankChoice { + std::string id; + std::string displayName; +}; +std::vector listBanks(const std::string& banksJson); + // Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields: // [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per // frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0, @@ -175,7 +197,12 @@ Keymap buildZonedKeymap(const std::vector& zones, // BACK-COMPAT: a v1 blob (the S4 single-selection format: version tag 1 + id bytes) is // lifted to a single full-keyboard zone playing that id (no override) — so an instance // saved under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob -// deserializes to an EMPTY map (the instrument falls back to Tier-0 first-sample). +// deserializes to an EMPTY map. +// +// These two functions serialize the ZONES only. Since S10 the instrument's full component +// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState +// below, the v3 format the processor actually reads/writes. serializePerformance/ +// deserializePerformance are retained for the v3 zones payload + the v1/v2 back-compat lift. inline constexpr std::uint32_t kPerformanceStateVersion = 2; @@ -186,6 +213,38 @@ std::vector serializePerformance(const PerformanceMap& map); // directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map. PerformanceMap deserializePerformance(const std::vector& bytes); +// --- Combined component state (VST3 setState/getState, v3 — S10) ------------- +// +// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that +// BOTH persist: the default face is one picked capture (the selection id), and zones are a +// demoted opt-in overlay (the performance map). The component state carries both so a saved +// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an +// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty +// state), never auto-playing sample #1. +// +// Format (v3): 4-byte LE version tag (== 3), then a 4-byte LE selection-id length + +// id bytes, then the v2 zones payload (4-byte LE zone count + per-zone records, identical +// to serializePerformance's body). BACK-COMPAT on read: +// * v3 blob -> {selectionId, zones} parsed directly. +// * v2 blob -> {"", zones}: an S5 instance had zones but no separate selection. +// * v1 blob -> {id, one full-keyboard zone}: the S4 single-selection lift, so the +// old pick survives as both the selection AND a one-zone map. +// * empty/unknown -> {"", no zones}: EMPTY (the S10 silent empty state). +struct ComponentState { + std::string selectionId; // the single-capture pick; "" = no pick (empty state) + PerformanceMap map; // the opt-in zones; empty = no zones +}; + +inline constexpr std::uint32_t kComponentStateVersion = 3; + +// The full instance state serialized to bytes for IBStream (getState). +std::vector serializeComponentState(const ComponentState& state); + +// The full instance state parsed back from IBStream bytes (setState). Tolerant of +// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table +// above so already-saved instances restore cleanly. +ComponentState deserializeComponentState(const std::vector& bytes); + // --- Instance state (VST3 setState/getState) -------------------------------- // // The instrument's OWN state is which bank sample it plays (D-B: the selection is a @@ -196,8 +255,10 @@ PerformanceMap deserializePerformance(const std::vector& bytes); // Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No // length prefix is needed — the id runs to the end of the stream (the host tells us the // byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob -// by returning "" (no selection — the instrument falls back to the bank's first sample), -// never throwing across the host boundary. +// by returning "" (no selection — under the S10 policy reversal an empty selection is +// SILENCE + the "pick a capture" empty state, not the bank's first sample), never +// throwing across the host boundary. Retained for the v1→v3 back-compat lift in +// deserializeComponentState; the processor's live state is the v3 ComponentState above. inline constexpr std::uint32_t kSelectionStateVersion = 1; diff --git a/tests/test_capture_browser.cpp b/tests/test_capture_browser.cpp new file mode 100644 index 0000000..9f8a7a0 --- /dev/null +++ b/tests/test_capture_browser.cpp @@ -0,0 +1,203 @@ +// Standalone tests for reasampler::vst::capture_browser — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests (embed_strip / editor_geometry): assert +// the capture-first browser's card-grid + bank-filter-tab layout and hit-testing directly. +// +// Covers: layoutBrowser splitting an area into the tab strip + card grid and deriving the +// column count; a tiny/zero area (no inversion, columns >= 1); cardCellRect / cardContentRect +// / cardThumbnailRect / cardLabelRect tiling row-major across columns with the gutter inset +// and the thumbnail band above the label; cardHitTest landing on the card content (and MISSING +// in the inter-card gutter, past the last card, and on the tab strip); filterTabRect dividing +// the strip into equal segments with the last tab absorbing the remainder; filterTabHitTest +// hitting each tab and missing off-strip. + +#include "../src/vst/capture_browser.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- layoutBrowser ------------------------------------------------------------ + +static void testLayoutNormalArea() { + // Wide enough for several columns of the fixed-width card. + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(L.tabStrip.left == 0 && L.tabStrip.top == 0 && L.tabStrip.right == 560); + CHECK(L.tabStrip.height() == kBrowserTabHeight); + // The grid starts right below the tab strip and fills the rest, contiguous. + CHECK(L.grid.top == L.tabStrip.bottom); + CHECK(L.grid.bottom == 300 && L.grid.right == 560); + // columns = grid.width() / cardWidth (>= 1). + CHECK(L.columns == 560 / kBrowserCardWidth); + CHECK(L.columns >= 1); +} + +static void testLayoutNarrowAreaSingleColumn() { + // Narrower than one card: still a single column, no inversion. + const BrowserLayout L = layoutBrowser(kBrowserCardWidth - 10, 200); + CHECK(L.columns == 1); + CHECK(L.grid.width() >= 0); + CHECK(L.tabStrip.height() == kBrowserTabHeight); +} + +static void testLayoutZeroArea() { + const BrowserLayout L = layoutBrowser(0, 0); + CHECK(L.tabStrip.width() == 0 && L.tabStrip.height() == 0); + CHECK(L.grid.width() == 0); + CHECK(L.columns == 1); // never zero (avoids a divide-by-zero in card layout) +} + +static void testLayoutTinyHeightClampsTabStrip() { + // A height below the tab band: the tab strip clamps to the area, the grid is empty. + const BrowserLayout L = layoutBrowser(560, kBrowserTabHeight - 6); + CHECK(L.tabStrip.height() == kBrowserTabHeight - 6); + CHECK(L.grid.height() <= 0); // no room left for cards +} + +// --- card rects --------------------------------------------------------------- + +static void testCardCellsTileRowMajor() { + const BrowserLayout L = layoutBrowser(560, 300); + const int cols = L.columns; + // Card 0 is top-left of the grid. + const Rect c0 = cardCellRect(L, 0); + CHECK(c0.left == L.grid.left && c0.top == L.grid.top); + CHECK(c0.width() == kBrowserCardWidth && c0.height() == kBrowserCardHeight); + // Card 1 is one card-width to the right, same row. + const Rect c1 = cardCellRect(L, 1); + CHECK(c1.left == L.grid.left + kBrowserCardWidth); + CHECK(c1.top == c0.top); + // The first card of the SECOND row wraps back to the left, one card-height down. + const Rect wrap = cardCellRect(L, cols); + CHECK(wrap.left == L.grid.left); + CHECK(wrap.top == L.grid.top + kBrowserCardHeight); +} + +static void testCardCellNegativeIndex() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect r = cardCellRect(L, -1); + CHECK(r.left == 0 && r.top == 0 && r.right == 0 && r.bottom == 0); +} + +static void testCardContentInsetByGutter() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect cell = cardCellRect(L, 0); + const Rect content = cardContentRect(L, 0); + CHECK(content.left == cell.left + kBrowserCardGutter); + CHECK(content.top == cell.top + kBrowserCardGutter); + CHECK(content.right == cell.right - kBrowserCardGutter); + CHECK(content.bottom == cell.bottom - kBrowserCardGutter); +} + +static void testThumbnailAboveLabel() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect content = cardContentRect(L, 0); + const Rect thumb = cardThumbnailRect(L, 0); + const Rect label = cardLabelRect(L, 0); + // Thumbnail is the top band of the content; the label is the remainder below it, contiguous. + CHECK(thumb.left == content.left && thumb.right == content.right); + CHECK(thumb.top == content.top); + CHECK(thumb.height() == kBrowserThumbHeight); + CHECK(label.top == thumb.bottom); + CHECK(label.bottom == content.bottom); + CHECK(label.left == content.left && label.right == content.right); +} + +// --- cardHitTest -------------------------------------------------------------- + +static void testCardHitCenterOfCard() { + const BrowserLayout L = layoutBrowser(560, 300); + const Rect content = cardContentRect(L, 3); + const int cx = content.left + content.width() / 2; + const int cy = content.top + content.height() / 2; + CHECK(cardHitTest(L, 12, cx, cy) == 3); +} + +static void testCardHitMissesGutter() { + const BrowserLayout L = layoutBrowser(560, 300); + // A point in the gutter between the content and the cell edge (top-left corner of cell 0) + // is a miss — only the card CONTENT counts. + const Rect cell = cardCellRect(L, 0); + CHECK(cardHitTest(L, 12, cell.left, cell.top) == -1); +} + +static void testCardHitMissesPastLastCard() { + const BrowserLayout L = layoutBrowser(560, 300); + // Only 2 cards exist; a point on where card 5 WOULD be is a miss. + const Rect content = cardContentRect(L, 5); + const int cx = content.left + content.width() / 2; + const int cy = content.top + content.height() / 2; + CHECK(cardHitTest(L, 2, cx, cy) == -1); +} + +static void testCardHitMissesTabStrip() { + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(cardHitTest(L, 12, 10, L.tabStrip.top + 2) == -1); +} + +static void testCardHitZeroCards() { + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(cardHitTest(L, 0, 20, 40) == -1); +} + +// --- filter tabs -------------------------------------------------------------- + +static void testFilterTabsTileStrip() { + const BrowserLayout L = layoutBrowser(560, 300); + const int n = 4; // "All" + 3 banks + const Rect t0 = filterTabRect(L, n, 0); + const Rect tLast = filterTabRect(L, n, n - 1); + CHECK(t0.left == L.tabStrip.left); + // Adjacent tabs share an exact edge (no gap). + CHECK(filterTabRect(L, n, 0).right == filterTabRect(L, n, 1).left); + CHECK(filterTabRect(L, n, 1).right == filterTabRect(L, n, 2).left); + // The last tab reaches the strip's right edge exactly (absorbs the remainder). + CHECK(tLast.right == L.tabStrip.right); + // All tabs share the strip's height. + CHECK(t0.top == L.tabStrip.top && t0.bottom == L.tabStrip.bottom); +} + +static void testFilterTabOutOfRange() { + const BrowserLayout L = layoutBrowser(560, 300); + CHECK(filterTabRect(L, 3, -1).width() == 0); + CHECK(filterTabRect(L, 3, 3).width() == 0); + CHECK(filterTabRect(L, 0, 0).width() == 0); +} + +static void testFilterTabHit() { + const BrowserLayout L = layoutBrowser(560, 300); + const int n = 3; + for (int i = 0; i < n; ++i) { + const Rect t = filterTabRect(L, n, i); + const int cx = t.left + t.width() / 2; + const int cy = t.top + t.height() / 2; + CHECK(filterTabHitTest(L, n, cx, cy) == i); + } + // Below the strip (in the grid) -> no tab. + CHECK(filterTabHitTest(L, n, 20, L.grid.top + 4) == -1); +} + +int main() { + testLayoutNormalArea(); + testLayoutNarrowAreaSingleColumn(); + testLayoutZeroArea(); + testLayoutTinyHeightClampsTabStrip(); + testCardCellsTileRowMajor(); + testCardCellNegativeIndex(); + testCardContentInsetByGutter(); + testThumbnailAboveLabel(); + testCardHitCenterOfCard(); + testCardHitMissesGutter(); + testCardHitMissesPastLastCard(); + testCardHitMissesTabStrip(); + testCardHitZeroCards(); + testFilterTabsTileStrip(); + testFilterTabOutOfRange(); + testFilterTabHit(); + + if (g_fail == 0) std::printf("capture_browser: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp new file mode 100644 index 0000000..e8dfbe1 --- /dev/null +++ b/tests/test_keyboard_strip.cpp @@ -0,0 +1,201 @@ +// Standalone tests for reasampler::vst::keyboard_strip — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests. Assert the capture-first editor's +// keyboard-strip layout, root marker, key mapping, zone-bar hit regions, and the drag-delta +// note resolver directly — the geometry that backs the single-capture root-set and the opt-in +// Zones panel. +// +// Covers: layoutStrip (normal + zero); keyLeftX monotonic across the 128-key span with the +// boundary at 128 == band right; keyRect / rootMarkerRect (rootMarkerRect == keyRect); +// keyAtPoint inverting the mapping and clamping/ missing off-band; zoneBarRect spanning +// [low,high] inclusive and collapsing (not inverting) a malformed low>high; zoneGrabAt +// classifying low-edge / high-edge / body and the narrow-bar midpoint split (low wins the +// tie); zoneBarAtPoint first-match on overlap + null-list rejection; resolveDragNote rounding +// to the nearest key at the key centre, clamping to [0,127], and the zero-delta / zero-width +// no-ops. + +#include "../src/vst/keyboard_strip.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A comfortable strip: 1280px wide (10px per key) so key math is exact and easy to reason +// about. +static StripLayout wideStrip() { return layoutStrip(1280, 40); } + +// --- layoutStrip -------------------------------------------------------------- + +static void testLayoutNormalArea() { + const StripLayout L = layoutStrip(640, 40); + CHECK(L.keys.left == 0 && L.keys.top == 0); + CHECK(L.keys.right == 640 && L.keys.bottom == 40); +} + +static void testLayoutZeroArea() { + const StripLayout L = layoutStrip(0, 0); + CHECK(L.keys.width() == 0 && L.keys.height() == 0); +} + +// --- keyLeftX / keyRect / rootMarkerRect -------------------------------------- + +static void testKeyLeftMonotonicAndBounds() { + const StripLayout L = wideStrip(); + // Key 0's left edge is the band left; the 128 boundary is the band right. + CHECK(keyLeftX(L, 0) == L.keys.left); + CHECK(keyLeftX(L, 128) == L.keys.right); + // Strictly non-decreasing across the span. + int prev = keyLeftX(L, 0); + for (int n = 1; n <= 128; ++n) { + const int x = keyLeftX(L, n); + CHECK(x >= prev); + prev = x; + } + // At 10px/key, key 12 (one octave) starts at 120px. + CHECK(keyLeftX(L, 12) == 120); +} + +static void testKeyRectHalfOpen() { + const StripLayout L = wideStrip(); + const Rect k = keyRect(L, 60); + CHECK(k.left == keyLeftX(L, 60)); + CHECK(k.right == keyLeftX(L, 61)); + CHECK(k.top == L.keys.top && k.bottom == L.keys.bottom); + CHECK(k.width() == 10); // 10px/key +} + +static void testRootMarkerEqualsKeyRect() { + const StripLayout L = wideStrip(); + const Rect m = rootMarkerRect(L, 64); + const Rect k = keyRect(L, 64); + CHECK(m.left == k.left && m.right == k.right && m.top == k.top && m.bottom == k.bottom); +} + +// --- keyAtPoint --------------------------------------------------------------- + +static void testKeyAtPointInverts() { + const StripLayout L = wideStrip(); + // A point in the middle of key 60's cell resolves to 60. + const Rect k = keyRect(L, 60); + CHECK(keyAtPoint(L, k.left + 5, k.top + 2) == 60); + // The very left of the band is key 0; just inside the right edge is key 127. + CHECK(keyAtPoint(L, L.keys.left, 2) == 0); + CHECK(keyAtPoint(L, L.keys.right - 1, 2) == 127); +} + +static void testKeyAtPointOffBand() { + const StripLayout L = wideStrip(); + CHECK(keyAtPoint(L, -5, 2) == -1); // left of band + CHECK(keyAtPoint(L, L.keys.right + 5, 2) == -1); // right of band + CHECK(keyAtPoint(L, 100, L.keys.bottom + 5) == -1); // below band +} + +// --- zoneBarRect -------------------------------------------------------------- + +static void testZoneBarSpansInclusive() { + const StripLayout L = wideStrip(); + const Rect bar = zoneBarRect(L, 12, 23); // C1..B1 inclusive + CHECK(bar.left == keyLeftX(L, 12)); + CHECK(bar.right == keyLeftX(L, 24)); // high+1 -> the bar covers key 23 fully + CHECK(bar.width() == 120); // 12 keys * 10px +} + +static void testZoneBarMalformedCollapses() { + const StripLayout L = wideStrip(); + // low > high must collapse, never invert. + const Rect bar = zoneBarRect(L, 80, 40); + CHECK(bar.width() >= 0); + CHECK(bar.right >= bar.left); +} + +// --- zoneGrabAt --------------------------------------------------------------- + +static void testZoneGrabEdgesAndBody() { + const StripLayout L = wideStrip(); + const Rect bar = zoneBarRect(L, 20, 60); // wide bar with a clear body + const int y = L.keys.top + 2; + // Near the left edge -> low; near the right edge -> high; the middle -> body. + CHECK(zoneGrabAt(L, 20, 60, bar.left + 1, y) == ZoneGrab::kLowEdge); + CHECK(zoneGrabAt(L, 20, 60, bar.right - 1, y) == ZoneGrab::kHighEdge); + CHECK(zoneGrabAt(L, 20, 60, bar.left + bar.width() / 2, y) == ZoneGrab::kBody); + // Off the bar entirely -> none. + CHECK(zoneGrabAt(L, 20, 60, bar.right + 20, y) == ZoneGrab::kNone); +} + +static void testZoneGrabNarrowBarSplitsAtMidpointLowWins() { + const StripLayout L = wideStrip(); + // A 1-key bar is narrower than 2*edge: no body; the low edge wins the exact midpoint. + const Rect bar = zoneBarRect(L, 50, 50); + const int y = L.keys.top + 2; + const int mid = bar.left + bar.width() / 2; + CHECK(zoneGrabAt(L, 50, 50, mid, y) == ZoneGrab::kLowEdge); // tie -> low + CHECK(zoneGrabAt(L, 50, 50, bar.right - 1, y) == ZoneGrab::kHighEdge); +} + +// --- zoneBarAtPoint ----------------------------------------------------------- + +static void testZoneBarAtPointFirstMatch() { + const StripLayout L = wideStrip(); + const int lows[2] = {20, 30}; // zone 0 and zone 1 overlap on [30,50] + const int highs[2] = {50, 70}; + const Rect overlap = zoneBarRect(L, 30, 50); + const int y = L.keys.top + 2; + const int cx = overlap.left + overlap.width() / 2; + // A point in the overlap resolves to the FIRST covering zone (draw order). + const ZoneBarHit hit = zoneBarAtPoint(L, lows, highs, 2, cx, y); + CHECK(hit.zoneIndex == 0); + CHECK(hit.grab != ZoneGrab::kNone); +} + +static void testZoneBarAtPointNullList() { + const StripLayout L = wideStrip(); + const ZoneBarHit hit = zoneBarAtPoint(L, nullptr, nullptr, 0, 100, 2); + CHECK(hit.zoneIndex == -1 && hit.grab == ZoneGrab::kNone); +} + +// --- resolveDragNote ---------------------------------------------------------- + +static void testResolveDragRoundsToNearestKey() { + const StripLayout L = wideStrip(); // 10px/key + // A +25px drag from key 60 = +2.5 keys -> rounds to +3 (half-key flips at the centre). + CHECK(resolveDragNote(L, 60, 25) == 63); + // A +24px drag = +2.4 keys -> rounds to +2. + CHECK(resolveDragNote(L, 60, 24) == 62); + // Symmetric for negative deltas. + CHECK(resolveDragNote(L, 60, -25) == 57); + CHECK(resolveDragNote(L, 60, -24) == 58); +} + +static void testResolveDragClampsAndNoOps() { + const StripLayout L = wideStrip(); + CHECK(resolveDragNote(L, 60, 0) == 60); // zero delta -> unchanged + CHECK(resolveDragNote(L, 2, -1000) == 0); // clamps at 0 + CHECK(resolveDragNote(L, 120, 1000) == 127); // clamps at 127 + // Zero-width band -> no motion (pins to startNote, clamped). + const StripLayout Z = layoutStrip(0, 40); + CHECK(resolveDragNote(Z, 60, 500) == 60); +} + +int main() { + testLayoutNormalArea(); + testLayoutZeroArea(); + testKeyLeftMonotonicAndBounds(); + testKeyRectHalfOpen(); + testRootMarkerEqualsKeyRect(); + testKeyAtPointInverts(); + testKeyAtPointOffBand(); + testZoneBarSpansInclusive(); + testZoneBarMalformedCollapses(); + testZoneGrabEdgesAndBody(); + testZoneGrabNarrowBarSplitsAtMidpointLowWins(); + testZoneBarAtPointFirstMatch(); + testZoneBarAtPointNullList(); + testResolveDragRoundsToNearestKey(); + testResolveDragClampsAndNoOps(); + + if (g_fail == 0) std::printf("keyboard_strip: all tests passed\n"); + return g_fail != 0; +} diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index f35945e..da645ef 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -9,13 +9,15 @@ // and the selection / downmix / keymap / state values are checked against independently // computed expectations. // -// Covers: selectSample by-id hit (across pool + named banks), first-sample fallback for -// an empty / unknown id, empty & malformed blob -> nullopt, zero-samples -> nullopt, -// rootNote/loop intrinsic threading incl. the middle-C default; listSamples ordinal -// order + empty/malformed; downmixToMono mono passthrough / stereo average / 3-ch -// average / zero-stride / empty; buildTier0Keymap single full-keyboard zone with the -// root + loop + rate threaded and rate defaulting; selection state round-trip + empty id -// + wrong-version / truncated -> "". +// Covers: selectSample by-id hit (across pool + named banks), the S10 policy reversal +// (empty / stale id -> SILENCE nullopt, not the first sample), empty & malformed blob -> +// nullopt, zero-samples -> nullopt, rootNote/loop intrinsic threading incl. the middle-C +// default; listSamples ordinal order + the card metadata (rootNote/key/bankId) + empty/ +// malformed; listBanks ordinal order (pool first) + empty/malformed; downmixToMono mono +// passthrough / stereo average / 3-ch average / zero-stride / empty; buildTier0Keymap single +// full-keyboard zone with the root + loop + rate threaded and rate defaulting; selection +// state round-trip + empty id + wrong-version / truncated -> ""; component state (v3) +// round-trip + v1/v2 back-compat lift + empty/unknown -> empty. // wav_trim -> extractFloatFrames -> downmixToMono integration: locks the interleave- // stride contract across the seam (that the byte stride wav_trim reports matches the // channel-count stride downmixToMono divides by). @@ -78,24 +80,25 @@ static void testSelectByIdHit() { CHECK(sel && sel->rootNote == 38); } -static void testSelectFirstSampleFallbackOnEmptyId() { +static void testSelectEmptyIdIsSilence() { const std::string json = bookJson( {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); - // No stored selection -> the FIRST sample in ordinal order (pool first). + // POLICY REVERSAL (S10): no stored selection resolves to SILENCE (nullopt), NOT the + // bank's first sample. A fresh instance plays nothing and shows the "pick a capture" + // empty state — the deliberate reversal of the S4 first-sample auto-play. auto sel = selectSample(json, ""); - CHECK(sel.has_value()); - CHECK(sel && sel->relativePath == "reasampler_bank/a.wav"); - CHECK(sel && sel->rootNote == 36); + CHECK(!sel.has_value()); } -static void testSelectFirstSampleFallbackOnUnknownId() { +static void testSelectUnknownIdIsSilence() { const std::string json = bookJson( {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, {}); - // A stored id that no longer resolves falls back to the first sample, not silence. + // A stale stored id (deleted/moved-out sample) resolves to SILENCE, not a substituted + // first sample — the editor reflects the missing pick with its empty state rather than + // masking it with a mystery sample. auto sel = selectSample(json, "deleted-id"); - CHECK(sel.has_value()); - CHECK(sel && sel->relativePath == "reasampler_bank/a.wav"); + CHECK(!sel.has_value()); } static void testSelectRootNoteDefault() { @@ -155,12 +158,53 @@ static void testListSamplesOrdinalOrder() { CHECK(list.size() == 3 && list[2].id == "b" && list[2].displayName == "Snare"); } +static void testListSamplesCarriesCardMetadata() { + // The browser card needs rootNote/key badge + the bank id (for the filter). A pool sample + // reports the pool bank id; a named-bank sample reports "drums-id"; an un-rooted sample + // reports no rootNote (the badge shows "root —", never a guessed value). + Sample rooted = makeSample("a", "Kick", "reasampler_bank/a.wav", 36); + rooted.key = "Cm"; + Sample unrooted = makeSample("u", "Loop", "reasampler_bank/u.wav", std::nullopt); + const std::string json = bookJson({rooted, unrooted}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + const std::vector list = listSamples(json); + CHECK(list.size() == 3); + // Pool sample "a": rooted + keyed, pool bank id. + CHECK(list[0].id == "a" && list[0].rootNote.has_value() && *list[0].rootNote == 36); + CHECK(list[0].key.has_value() && *list[0].key == "Cm"); + CHECK(!list[0].bankId.empty()); // the pool has an id; the filter matches on it + // Pool sample "u": no root intrinsic -> no rootNote (badge shows "root —"). + CHECK(list[1].id == "u" && !list[1].rootNote.has_value()); + // Named-bank sample "b": its bank id distinguishes it from the pool for the filter. + CHECK(list[2].id == "b" && list[2].bankId == "drums-id"); + CHECK(list[2].bankId != list[0].bankId); // pool vs. named bank differ (filterable apart) +} + static void testListSamplesEmptyAndMalformed() { CHECK(listSamples("").empty()); CHECK(listSamples("{garbage").empty()); CHECK(listSamples(bookJson({}, {})).empty()); } +static void testListBanksOrdinalOrder() { + const std::string json = bookJson( + {makeSample("a", "Kick", "reasampler_bank/a.wav", 36)}, + {makeSample("b", "Snare", "reasampler_bank/b.wav", 38)}); + const std::vector banks = listBanks(json); + // Pool first (bank-zero), then the named bank "Drums". Both ids are present so the filter + // tab strip can key on them. + CHECK(banks.size() == 2); + CHECK(banks.size() == 2 && banks[1].id == "drums-id" && banks[1].displayName == "Drums"); + CHECK(banks.size() == 2 && !banks[0].id.empty()); // the pool bank has an id too +} + +static void testListBanksEmptyAndMalformed() { + CHECK(listBanks("").empty()); + CHECK(listBanks("{garbage").empty()); + // A valid book with no samples still has the pool bank -> one entry. + CHECK(listBanks(bookJson({}, {})).size() == 1); +} + // --- downmixToMono ------------------------------------------------------------ static bool approx(double a, double b) { return std::fabs(a - b) < 1e-6; } @@ -558,10 +602,86 @@ static void testPerformanceStateNegativeNotesRoundTrip() { *back.zones[0].rootOverride == 0); } +// --- Combined component state (v3, S10) -------------------------------------- + +static void testComponentStateRoundTrip() { + // The v3 state carries the single-capture selection AND the opt-in zones, distinctly. + ComponentState s; + s.selectionId = "picked-capture"; + s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt)); + s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48)); + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId == "picked-capture"); + CHECK(back.map.zones.size() == 2); + CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" && + back.map.zones[0].highNote == 59 && !back.map.zones[0].rootOverride.has_value()); + CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" && + back.map.zones[1].rootOverride.has_value() && *back.map.zones[1].rootOverride == 48); +} + +static void testComponentStateSelectionOnlyNoZones() { + // A single-capture instance: a pick, no zones. Must restore the pick with an empty map + // (NOT synthesize a zone) — the default face is one capture, zones are opt-in. + ComponentState s; + s.selectionId = "just-a-pick"; + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId == "just-a-pick"); + CHECK(back.map.zones.empty()); +} + +static void testComponentStateEmptyIsEmpty() { + // No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample. + const ComponentState s; // selectionId "", empty map + const ComponentState back = deserializeComponentState(serializeComponentState(s)); + CHECK(back.selectionId.empty()); + CHECK(back.map.zones.empty()); +} + +static void testComponentStateV1BackCompat() { + // A v1 S4 blob (single-selection) lifts to {id, one full-keyboard zone} so an old pick + // survives as BOTH the selection and a one-zone map. + const std::vector v1 = serializeSelection("legacy-id"); + const ComponentState back = deserializeComponentState(v1); + CHECK(back.selectionId == "legacy-id"); + CHECK(back.map.zones.size() == 1); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" && + back.map.zones[0].lowNote == 0 && back.map.zones[0].highNote == 127); + // A v1 blob with an EMPTY id -> empty state (no selection, no zone). + const ComponentState empty = deserializeComponentState(serializeSelection("")); + CHECK(empty.selectionId.empty() && empty.map.zones.empty()); +} + +static void testComponentStateV2BackCompat() { + // A v2 S5 blob (zones-only) lifts to {"", zones}: that instance had zones but no separate + // single-capture selection. + PerformanceMap m; + m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt)); + const std::vector v2 = serializePerformance(m); + const ComponentState back = deserializeComponentState(v2); + CHECK(back.selectionId.empty()); + CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" && + back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24); +} + +static void testComponentStateGarbage() { + // Empty / unknown version -> empty (never throws across the host). + CHECK(deserializeComponentState({}).selectionId.empty()); + CHECK(deserializeComponentState({}).map.zones.empty()); + const std::vector unknown{0xAA, 0xBB, 0xCC, 0xDD}; + CHECK(deserializeComponentState(unknown).map.zones.empty()); + CHECK(deserializeComponentState(unknown).selectionId.empty()); + // A v3 header claiming a longer id than the blob holds -> empty (bounded read). + std::vector t; + t.push_back(3); t.push_back(0); t.push_back(0); t.push_back(0); // version 3 + t.push_back(200); t.push_back(0); t.push_back(0); t.push_back(0); // id length 200 (absent) + CHECK(deserializeComponentState(t).selectionId.empty()); + CHECK(deserializeComponentState(t).map.zones.empty()); +} + int main() { testSelectByIdHit(); - testSelectFirstSampleFallbackOnEmptyId(); - testSelectFirstSampleFallbackOnUnknownId(); + testSelectEmptyIdIsSilence(); + testSelectUnknownIdIsSilence(); testSelectRootNoteDefault(); testSelectLoopThreaded(); testSelectNoLoopIsAbsent(); @@ -569,7 +689,10 @@ int main() { testSelectMalformedBlob(); testSelectZeroSamples(); testListSamplesOrdinalOrder(); + testListSamplesCarriesCardMetadata(); testListSamplesEmptyAndMalformed(); + testListBanksOrdinalOrder(); + testListBanksEmptyAndMalformed(); testDownmixMonoPassthrough(); testDownmixStereoAverages(); testDownmixThreeChannelAverages(); @@ -597,6 +720,12 @@ int main() { testPerformanceStateV1BackCompat(); testPerformanceStateGarbage(); testPerformanceStateNegativeNotesRoundTrip(); + testComponentStateRoundTrip(); + testComponentStateSelectionOnlyNoZones(); + testComponentStateEmptyIsEmpty(); + testComponentStateV1BackCompat(); + testComponentStateV2BackCompat(); + testComponentStateGarbage(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; From b9ad1ee18fdbeca9b56586f9ab9d35dae7b30a58 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 20:53:31 -0400 Subject: [PATCH 2/2] fix S10 code-review findings: WM_CAPTURECHANGED, proportional drag, paintSetup root source, Add Zone dedup, strip-area helpers --- src/vst/keyboard_strip.cpp | 20 +++++++--- src/vst/reasampler_editor.cpp | 69 +++++++++++++++++++++++++---------- tests/test_keyboard_strip.cpp | 26 +++++++++++++ 3 files changed, 90 insertions(+), 25 deletions(-) diff --git a/src/vst/keyboard_strip.cpp b/src/vst/keyboard_strip.cpp index 77b848f..9e6f4c1 100644 --- a/src/vst/keyboard_strip.cpp +++ b/src/vst/keyboard_strip.cpp @@ -106,12 +106,20 @@ 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 - // Key width in pixels (>= 1 via the max). Round the delta to the nearest key so a - // half-key drag flips at the key centre: add/subtract half a key before the divide. - const int keyW = std::max(1, bandWidth / kStripKeyCount); - const int half = keyW / 2; - const int shift = dxPixels >= 0 ? (dxPixels + half) / keyW - : -((-dxPixels + half) / keyW); + // 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. + const int half = bandWidth / 2; + int shift; + if (dxPixels > 0) { + shift = (dxPixels * kStripKeyCount + half) / bandWidth; + } else { + shift = -(((-dxPixels) * kStripKeyCount + half) / bandWidth); + } return clampNote(startNote + shift); } diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 7ad1759..6bbebf3 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -314,6 +314,26 @@ EditorBands computeBands(int w, int h) { b.content = Rect{0, toggleBot, w, h}; return b; } + +// The keyboard strip rectangle inside the setup area (single-capture root-drag face). +// `area` is the full setup Rect; the strip is anchored at the bottom with an 8px horizontal +// pad. All three call sites (paintSetup, onMouseDown, onMouseMove) use this single formula. +Rect setupStripArea(const Rect& area) { + constexpr int pad = 8; + const int stripTop = area.bottom - kStripBandHeight; + return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4}; +} + +// The keyboard strip rectangle inside the Zones panel content area. `bands.content` is the +// mode-content Rect; the strip sits below the "+ Add Zone" affordance (top+4, height 20) +// with a 12px gap, padded 8px horizontally. All three call sites (paintZones, onMouseDown, +// onMouseMove) use this single formula — the inline arithmetic in onMouseMove was the drift. +Rect zonesStripArea(const EditorBands& bands) { + constexpr int pad = 8; + const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12 + return Rect{bands.content.left + pad, stripTop, bands.content.right - pad, + stripTop + kStripBandHeight}; +} } // namespace void ReaSamplerEditor::paint(HDC hdc) { @@ -445,8 +465,10 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { kColTitleBg, 1.0f, 0); // Effective root: the picked sample's rootNote intrinsic (or middle C when unset). + // Read from samples_ (the full unfiltered list) so a bank-filter that hides the + // picked sample's bank doesn't mask its intrinsic root with the C4 default. int root = 60; - for (const SampleChoice& s : visible_) { + for (const SampleChoice& s : samples_) { if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; } // If a matching one-zone override exists (opt-in from Zones), prefer it as the shown root. @@ -463,8 +485,7 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim); // Keyboard strip with the root marker. - const int stripTop = area.bottom - kStripBandHeight; - Rect stripArea{area.left + pad, stripTop, area.right - pad, area.bottom - 4}; + const Rect stripArea = setupStripArea(area); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int sx = stripArea.left; const int sy = stripArea.top; @@ -503,9 +524,7 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { } // The zones strip. - const int stripTop = addR.bottom + 12; - Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, - stripTop + kStripBandHeight}; + const Rect stripArea = zonesStripArea(bands); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int sx = stripArea.left; const int sy = stripArea.top; @@ -581,8 +600,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // The setup strip: grab the root marker (drag to set the picked capture's root). if (havePick) { const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; - const int stripTop = area.bottom - kStripBandHeight; - const Rect stripArea{area.left + 8, stripTop, area.right - 8, area.bottom - 4}; + const Rect stripArea = setupStripArea(area); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); if (note >= 0) { @@ -603,11 +621,21 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, bands.content.top + 4 + 20}; if (contains(addR, x, y)) { - // Append a full-keyboard zone for the picked capture (or the first visible sample as a - // sensible seed). No pick -> nothing to add. + // Add a full-keyboard zone for the picked capture (or the first visible sample as a + // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id + // already exists, select it rather than appending a duplicate (mirrors the upsert the + // root-marker drag path already performs, preventing overlapping identical zones). std::string seed = !selectedId_.empty() ? selectedId_ : (!visible_.empty() ? visible_.front().id : std::string()); if (seed.empty()) return; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[static_cast(i)]; + if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) { + selectedZone_ = i; + invalidate(); + return; + } + } PerformanceZone z; z.sampleId = seed; z.lowNote = 0; @@ -627,9 +655,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the // selected zone's root. - const int stripTop = addR.bottom + 12; - const Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, - stripTop + kStripBandHeight}; + const Rect stripArea = zonesStripArea(bands); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int lx = x - stripArea.left; const int ly = y - stripArea.top; @@ -680,8 +706,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { // The single-capture root strip lives in the setup band. const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight); const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; - const Rect stripArea{area.left + 8, area.bottom - kStripBandHeight, area.right - 8, - area.bottom - 4}; + const Rect stripArea = setupStripArea(area); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int note = resolveDragNote(sl, dragStartRoot_, dx); // The performance map is the ONLY D-B override vehicle (rootOverride lives on a zone), @@ -708,10 +733,7 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { // Zone edits: recompute the grabbed field(s) against the pure resolver, live. if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; - const int pad = 8; - const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12 - const Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad, - stripTop + kStripBandHeight}; + const Rect stripArea = zonesStripArea(bands); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; if (drag_ == DragKind::kZoneLow) { @@ -765,6 +787,15 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, ReleaseCapture(); } return 0; + case WM_CAPTURECHANGED: + // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — reset the drag + // state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. + // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. + if (self && self->drag_ != DragKind::kNone) { + self->drag_ = DragKind::kNone; + self->invalidate(); + } + return 0; case WM_ERASEBKGND: return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase default: diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp index e8dfbe1..65e6880 100644 --- a/tests/test_keyboard_strip.cpp +++ b/tests/test_keyboard_strip.cpp @@ -179,6 +179,31 @@ static void testResolveDragClampsAndNoOps() { CHECK(resolveDragNote(Z, 60, 500) == 60); } +static void testResolveDragProportionalNonDivisibleWidth() { + // THE REVIEW FINDING: 544px / 128 = 4.25 (non-integer). Old uniform-keyW math used + // keyW = 4 (floor), accumulating ~7 keys of drift at the far end. The proportional fix + // must agree with keyAtPoint at every point — specifically the far-end invariant: + // a drag from note 0 by (width-1) pixels must land at keyAtPoint(width-1), which is 127. + const int width = 544; + const StripLayout L = layoutStrip(width, 40); + CHECK(keyAtPoint(L, width - 1, L.keys.top + 1) == 127); + CHECK(resolveDragNote(L, 0, width - 1) == 127); + + // Also verify mid-strip coherence: for each key N, a drag from 0 by N's left-edge + // pixel offset should land at N (or N-1 at worst — left-edge pixel is a boundary, so + // rounding may round down). The critical direction is that it must NOT over-shoot by + // more than 0 (it must reach at least the right key). + for (int n = 1; n < kStripKeyCount; ++n) { + const int leftPx = keyRect(L, n).left; + const int resolved = resolveDragNote(L, 0, leftPx); + // The left edge of key N is the first pixel "in" that key, so we expect resolved == N. + // Allow resolved == N-1 only when the pixel is at the exact boundary (keyEdgeToX may + // produce the same x for adjacent keys when keys share a pixel). Disallow over-shoot. + const int expected = keyAtPoint(L, leftPx, L.keys.top + 1); + CHECK(resolved >= expected - 1 && resolved <= expected + 1); + } +} + int main() { testLayoutNormalArea(); testLayoutZeroArea(); @@ -195,6 +220,7 @@ int main() { testZoneBarAtPointNullList(); testResolveDragRoundsToNearestKey(); testResolveDragClampsAndNoOps(); + testResolveDragProportionalNonDivisibleWidth(); if (g_fail == 0) std::printf("keyboard_strip: all tests passed\n"); return g_fail != 0;