From ae23ee08820b16ede7a40202a1577ac2e979fea4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 30 Jul 2026 09:14:07 -0400 Subject: [PATCH] Rebuild the chrome band: full-width piano strip with uniform key widths, note tooltips, one toolbar font --- CMakeLists.txt | 8 +- src/core/instrument/CLAUDE.md | 4 +- src/core/instrument/ui/keyboard_strip.cpp | 129 +++--- src/core/instrument/ui/keyboard_strip.h | 55 ++- src/core/instrument/ui/sample_chrome.cpp | 87 +++-- src/core/instrument/ui/sample_chrome.h | 21 +- src/shell/instrument/CLAUDE.md | 2 +- src/shell/instrument/editor_input_chrome.cpp | 32 +- src/shell/instrument/editor_internal.h | 64 +-- src/shell/instrument/editor_paint.cpp | 3 + src/shell/instrument/editor_paint_chrome.cpp | 143 ++++++- src/shell/instrument/reasampler_editor.h | 5 +- tests/test_keyboard_strip.cpp | 389 ++++++++++++------- tests/test_sample_chrome.cpp | 100 +++-- 14 files changed, 656 insertions(+), 386 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2f45f00..d3086b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -897,8 +897,8 @@ add_library(capture_browser STATIC src/core/instrument/ui/capture_browser.cpp) target_include_directories(capture_browser PUBLIC src) target_link_libraries(capture_browser PUBLIC editor_geometry) -# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, and the -# drag-delta note resolver for the editor's keyboard strip (root display + root-set). The +# keyboard_strip (Phase S10) — PURE piano-key geometry, hit-test, root marker, drag +# resolver, and note naming for the editor's keyboard strip (root display + root-set). The # mirror of embed_strip; links editor_geometry for the shared Rect. NEITHER SDK. add_library(keyboard_strip STATIC src/core/instrument/ui/keyboard_strip.cpp) target_include_directories(keyboard_strip PUBLIC src) @@ -1341,6 +1341,8 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # value<->pixel mapping the editor's parameter surface draws + routes against. # browser_scroll pulls capture_browser transitively; param_slider links editor_geometry / # the stdlib only. All engine-free, DAW-verified in the shell. + # tooltip: the pure placement math (anchor + text extent -> clamped box) the piano + # strip's note-name chip shares with the dock panel's hover tooltip. # theme + component_geometry + bank_grid: the Phase L (L1) draw-kit's PURE deps (L3). The # kit draws every editor/embed surface by palette ROLE via draw_kit.cpp (compiled into the # module above): theme supplies role->KitColor + spectralColor, component_geometry the @@ -1358,7 +1360,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal sampler_core sample_map component_state_io capture_paths embed_strip app_version capture_browser keyboard_strip sample_bands sample_chrome - waveform_view bank_sync browser_scroll param_slider + waveform_view bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit knob_deck curve_popup master_gain sample_usage file_bytes) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 766cb03..33f45de 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -218,8 +218,8 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma - `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own. - `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory: three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack. -- `sample_chrome` — the CHROME band's interior: the toolbar row (title + Browse) over the control row (root strip, preview, velocity knob cell, curve button, channel toggle). The fixed run is right-anchored; the root strip takes the remainder. -- `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, root-marker rect, and the drag-delta note resolver. +- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, curve button, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. +- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins. - `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap. - **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one. - `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing. diff --git a/src/core/instrument/ui/keyboard_strip.cpp b/src/core/instrument/ui/keyboard_strip.cpp index 96494b2..29aa23c 100644 --- a/src/core/instrument/ui/keyboard_strip.cpp +++ b/src/core/instrument/ui/keyboard_strip.cpp @@ -8,18 +8,29 @@ namespace reasampler::instrument::ui { namespace { +// Pitch class of each natural, and the count of naturals strictly below each pitch class. +constexpr int kNaturalPitchClass[7] = {0, 2, 4, 5, 7, 9, 11}; +constexpr int kNaturalsBelowPc[12] = {0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6}; + int clampNote(int n) { if (n < 0) return 0; if (n > kStripKeyCount - 1) return kStripKeyCount - 1; return n; } -// Maps a key boundary (0..128) to an x pixel. Key N's left is keyEdgeToX(N), right is -// keyEdgeToX(N+1) — tiles adjacent keys without a seam. Mirrors embed_strip::keyEdgeToX. -int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { - if (keyEdge <= 0) return bandLeft; - if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth; - return bandLeft + (keyEdge * bandWidth) / kStripKeyCount; +// The MIDI note of white key `index` (0..74). +int whiteNoteAt(int index) { + const int i = index < 0 ? 0 : (index > kStripWhiteKeyCount - 1 ? kStripWhiteKeyCount - 1 + : index); + return clampNote((i / 7) * 12 + kNaturalPitchClass[i % 7]); +} + +// The black key straddling white-key boundary `b`, or -1 where the scale has none (E-F and +// B-C are adjacent naturals). +int blackNoteAtBoundary(int b) { + if (b <= 0 || b >= kStripWhiteKeyCount) return -1; + const int below = whiteNoteAt(b) - 1; + return (below >= 0 && !isNaturalKey(below)) ? below : -1; } } // namespace @@ -28,42 +39,22 @@ 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::ltrb(0, 0, cw, ch); + out.band = Rect::ltrb(0, 0, cw, ch); + if (ch <= 0) return out; + + out.whiteWidth = cw / kStripWhiteKeyCount; + if (out.whiteWidth <= 0) return out; // narrower than one pixel per white key + + const int keysW = out.whiteWidth * kStripWhiteKeyCount; + const int left = (cw - keysW) / 2; // residue split evenly into the two end margins + out.keys = Rect::ltrb(left, 0, left + keysW, ch); + out.blackWidth = std::max(1, (out.whiteWidth * 3) / 5); + out.blackHeight = std::max(1, (ch * 3) / 5); 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 (0..127); callers pass note+1 to get its right edge, 128 -> band right. - const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note); - return keyEdgeToX(band.x, bandWidth, edge); -} - -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::ltrb(leftX, layout.keys.y, 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; - // Inverts keyEdgeToX: the key whose half-open [leftX, rightX) contains x. - const int offset = x - band.x; - int note = (offset * kStripKeyCount) / bandWidth; - return clampNote(note); -} - bool isNaturalKey(int note) { - const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note); + const int n = clampNote(note); static constexpr bool kNatural[12] = { true, // 0 C false, // 1 C# @@ -81,20 +72,56 @@ bool isNaturalKey(int note) { return kNatural[n % 12]; } -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 - // Same linear mapping as keyAtPoint/keyEdgeToX (exact rational), not a truncated-integer - // bandWidth/128 key width — that drifted at the far end of the strip. - const int half = bandWidth / 2; - int shift; - if (dxPixels > 0) { - shift = (dxPixels * kStripKeyCount + half) / bandWidth; - } else { - shift = -(((-dxPixels) * kStripKeyCount + half) / bandWidth); +int whiteIndexOf(int note) { + const int n = clampNote(note); + return (n / 12) * 7 + kNaturalsBelowPc[n % 12]; +} + +Rect keyRect(const StripLayout& layout, int note) { + if (layout.keys.empty()) return Rect{}; + const int n = clampNote(note); + const int wi = whiteIndexOf(n); + if (isNaturalKey(n)) { + const int left = layout.keys.x + wi * layout.whiteWidth; + return Rect::ltrb(left, layout.keys.y, left + layout.whiteWidth, layout.keys.bottom()); } - return clampNote(startNote + shift); + const int centre = layout.keys.x + wi * layout.whiteWidth; + const int left = centre - layout.blackWidth / 2; + return Rect::ltrb(left, layout.keys.y, left + layout.blackWidth, + layout.keys.y + layout.blackHeight); +} + +Rect rootMarkerRect(const StripLayout& layout, int rootNote) { + return keyRect(layout, rootNote); +} + +int keyAtPoint(const StripLayout& layout, int x, int y) { + if (!contains(layout.keys, x, y)) return -1; + const int offset = x - layout.keys.x; + const int wi = std::min(offset / layout.whiteWidth, kStripWhiteKeyCount - 1); + if (y < layout.keys.y + layout.blackHeight) { + // Only the two boundaries flanking this white key can carry an overlapping black. + const int candidates[2] = {wi, wi + 1}; + for (const int b : candidates) { + const int note = blackNoteAtBoundary(b); + if (note >= 0 && contains(keyRect(layout, note), x, y)) return note; + } + } + return whiteNoteAt(wi); +} + +int resolveDragNote(const StripLayout& layout, int x, int y) { + if (layout.keys.empty()) return -1; + const int cx = std::clamp(x, layout.keys.x, layout.keys.right() - 1); + const int cy = std::clamp(y, layout.keys.y, layout.keys.bottom() - 1); + return keyAtPoint(layout, cx, cy); +} + +std::string noteName(int note) { + static constexpr const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"}; + const int n = clampNote(note); + return std::string(kNames[n % 12]) + std::to_string(n / 12 - 1); } } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/keyboard_strip.h b/src/core/instrument/ui/keyboard_strip.h index f09d15e..2c9fd2b 100644 --- a/src/core/instrument/ui/keyboard_strip.h +++ b/src/core/instrument/ui/keyboard_strip.h @@ -1,13 +1,14 @@ -// keyboard_strip.h — layout + hit-test + drag math for the editor's keyboard strip. -// Mirror of embed_strip/mode_switch; the shell draws and marshals mouse events into these -// functions. +// keyboard_strip.h — piano-keyboard geometry for the editor's root strip: per-class key +// rects, hit-test, the root marker, and the note name a hovered key reports. // -// The strip maps the full 128-key MIDI span across a horizontal band (the same idiom -// embed_strip uses). The loaded capture responds across that whole span, so the strip's -// job is the root marker: click a key, or drag the marker, to set the root. +// Same-class keys are one integer width by construction. An arbitrary band width is not +// divisible by the 75 white keys, so the residue lands in symmetric end margins — uniform +// key widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins. #pragma once +#include + #include "core/instrument/ui/editor_geometry.h" // Rect, contains namespace reasampler::instrument::ui { @@ -16,35 +17,47 @@ namespace reasampler::instrument::ui { // stay independent. inline constexpr int kStripKeyCount = 128; -// The keys band takes the whole strip area today; clamped so a degenerate size never -// yields an inverted rect. +// Naturals in MIDI 0..127 (C-1 .. G9): ten full octaves of seven, plus C D E F G. +inline constexpr int kStripWhiteKeyCount = 75; + struct StripLayout { - Rect keys; + Rect band; // the surface handed in, edge to edge + Rect keys; // the tiled key area, kStripWhiteKeyCount * whiteWidth, centred in band + int whiteWidth = 0; + int blackWidth = 0; + int blackHeight = 0; // black keys are short; below them the white key answers }; -// Divide a (w x h) strip area into its regions. Pure. +// Divide a (w x h) strip area into its key geometry. Pure. A band too narrow for one pixel +// per white key yields empty `keys` — nothing draws and nothing hit-tests. StripLayout layoutStrip(int w, int h); -// x pixel of the LEFT edge of key `note` (0..127) under the linear 128-key map; key N -// occupies [keyLeftX(N), keyLeftX(N+1)). note==128 maps to the band's right edge. -int keyLeftX(const StripLayout& layout, int note); +// True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal +// temperament; false for an accidental (black) key. +bool isNaturalKey(int note); -// Half-open rect of a single key `note`, clamped to [0,127]. +// Naturals strictly below `note`. For a white key that is its own ordinal; for a black key +// it is the white-key boundary the accidental straddles. +int whiteIndexOf(int note); + +// Rect of a single key, clamped to [0,127]. Whites are full band height and whiteWidth +// wide; blacks are blackHeight tall and blackWidth wide, centred on their boundary. Rect keyRect(const StripLayout& layout, int note); // Root-marker rect; equivalent to keyRect(layout, rootNote) but named so the intent reads // at the call site. Rect rootMarkerRect(const StripLayout& layout, int rootNote); -// MIDI note a point (x, y) lands on, or -1 outside the keys band. +// MIDI note a point (x, y) lands on, or -1 outside the tiled keys. A black key wins inside +// its own short zone; anywhere else the white key beneath answers. int keyAtPoint(const StripLayout& layout, int x, int y); -// Resolves a drag to a new MIDI note: `startNote` shifted by round(dxPixels / keyWidth), -// clamped to [0,127]. The one arithmetic behind the root-marker drag. -int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); +// The note a live drag resolves to: keyAtPoint with the point clamped into the key area, so +// a drag that wanders off the strip keeps tracking rather than dropping the edit. -1 only +// when there is no key area at all. +int resolveDragNote(const StripLayout& layout, int x, int y); -// True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal -// temperament; false for an accidental (black) key. -bool isNaturalKey(int note); +// DAW convention (the one REAPER uses): MIDI 60 is C4, so MIDI 0 is C-1. +std::string noteName(int note); } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/sample_chrome.cpp b/src/core/instrument/ui/sample_chrome.cpp index aec545f..ca010a8 100644 --- a/src/core/instrument/ui/sample_chrome.cpp +++ b/src/core/instrument/ui/sample_chrome.cpp @@ -10,14 +10,20 @@ namespace reasampler::instrument::ui { namespace { -constexpr int kStripBandHeight = 40; // the root/piano strip's own height inside the row +// The toolbar row carries the whole control run, so it is taller than the Browse modal's +// plain kTitleHeight bar — the velocity knob cell (knob over label) sets the floor. Both +// rows still fit the band the allocator hands out (kTitleHeight + kChromeRowHeight). +constexpr int kToolbarHeight = 44; +constexpr int kStripBandHeight = 30; -// The control row's fixed right-anchored run, right to left. +constexpr int kRunGap = 6; // between adjacent items of the toolbar run constexpr int kChanSegW = 52; constexpr int kChanSegH = 18; -constexpr int kCurveBtnSize = 28; -constexpr int kVelCellW = 48; +constexpr int kCurveBtnSize = 24; +constexpr int kVelCellW = 44; +constexpr int kVelLabelH = 12; constexpr int kPreviewBtnW = 64; +constexpr int kRunButtonH = 24; // Browse and Preview } // namespace @@ -25,46 +31,61 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) { ChromeRects r; if (chrome.empty()) return r; - const int titleH = std::min(kTitleHeight, chrome.height); - r.toolbar = Rect::ltrb(chrome.x, chrome.y, chrome.right(), chrome.y + titleH); + const int toolbarH = std::min(kToolbarHeight, chrome.height); + r.toolbar = Rect::ltrb(chrome.x, chrome.y, chrome.right(), chrome.y + toolbarH); r.controls = Rect::ltrb(chrome.x, r.toolbar.bottom(), chrome.right(), chrome.bottom()); - const int navTop = r.toolbar.y + 2; - const int navBot = std::max(navTop, r.toolbar.bottom() - 2); - r.navBrowse = Rect::ltrb(std::max(chrome.x, chrome.right() - kPad - kNavButtonWidth), - navTop, std::max(chrome.x, chrome.right() - kPad), navBot); + const Rect& row = r.toolbar; + const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; }; + const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); }; - if (r.controls.empty()) return r; - const Rect& row = r.controls; + // The fixed run, right to left: Browse, Mono|Stereo, curve, velocity cell, preview. + const int navH = std::min(kRunButtonH, row.height); + const int navTop = topFor(navH); + const int navRight = std::max(row.x, row.right() - kPad); + r.navBrowse = Rect::ltrb(leftOf(navRight, kNavButtonWidth), navTop, navRight, + navTop + navH); - // Vertically centre the two heights the row uses: the tall strip band (which the preview - // button and velocity cell align to) and the smaller square/segment controls. - const int stripTop = row.y + (row.height - kStripBandHeight) / 2; - const int stripBot = stripTop + kStripBandHeight; - - const int chanTop = row.y + (row.height - kChanSegH) / 2; - const int chanRight = row.right() - kPad; - r.chanStereo = Rect::ltrb(chanRight - kChanSegW, chanTop, chanRight, chanTop + kChanSegH); - r.chanMono = Rect::ltrb(r.chanStereo.x - kChanSegW, chanTop, r.chanStereo.x, + const int chanTop = topFor(kChanSegH); + const int chanRight = leftOf(r.navBrowse.x, kRunGap); + r.chanStereo = Rect::ltrb(leftOf(chanRight, kChanSegW), chanTop, chanRight, + chanTop + kChanSegH); + r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x, chanTop + kChanSegH); - const int curveTop = row.y + (row.height - kCurveBtnSize) / 2; - r.curveBtn = Rect::ltrb(r.chanMono.x - kPad - kCurveBtnSize, curveTop, - r.chanMono.x - kPad, curveTop + kCurveBtnSize); + const int curveTop = topFor(kCurveBtnSize); + const int curveRight = leftOf(r.chanMono.x, kRunGap); + r.curveBtn = Rect::ltrb(leftOf(curveRight, kCurveBtnSize), curveTop, curveRight, + curveTop + kCurveBtnSize); - r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop, - r.curveBtn.x - kPad, stripBot); - const int knobLeft = r.velCell.x + (kVelCellW - knobSize) / 2; + const int cellH = std::min(row.height, knobSize + kVelLabelH); + const int cellTop = topFor(cellH); + const int cellRight = leftOf(r.curveBtn.x, kRunGap); + r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH); + const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2; r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize, - r.velCell.y + knobSize); + r.velCell.y + std::min(knobSize, cellH)); r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(), r.velCell.bottom()); - r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop, - r.velCell.x - kPad, stripBot); - // Remainder width; clamped so a narrow window collapses the strip rather than inverting it. - r.rootStrip = Rect::ltrb(row.x + kPad, stripTop, - std::max(row.x + kPad, r.preview.x - kPad), stripBot); + const int prevTop = topFor(std::min(kRunButtonH, row.height)); + const int prevRight = leftOf(r.velCell.x, kRunGap); + r.preview = Rect::ltrb(leftOf(prevRight, kPreviewBtnW), prevTop, prevRight, + prevTop + std::min(kRunButtonH, row.height)); + + // The title takes what the run leaves; clamped so a narrow window collapses it rather + // than inverting it. + r.title = Rect::ltrb(row.x + kPad, row.y, std::max(row.x + kPad, r.preview.x - kRunGap), + row.bottom()); + + if (r.controls.empty()) return r; + // The strip row belongs to the strip alone — inset only by the shared band pad, so it + // lines up with the waveform band directly beneath it. + const int stripH = std::min(kStripBandHeight, r.controls.height); + const int stripTop = r.controls.y + (r.controls.height - stripH) / 2; + r.rootStrip = Rect::ltrb(r.controls.x + kPad, stripTop, + std::max(r.controls.x + kPad, r.controls.right() - kPad), + stripTop + stripH); return r; } diff --git a/src/core/instrument/ui/sample_chrome.h b/src/core/instrument/ui/sample_chrome.h index 27dd977..f67f3aa 100644 --- a/src/core/instrument/ui/sample_chrome.h +++ b/src/core/instrument/ui/sample_chrome.h @@ -1,8 +1,8 @@ #pragma once // sample_chrome.h — interior geometry of the Sample face's CHROME band: the toolbar row -// (title + Browse) over the control row (root/piano strip, preview trigger, preview-velocity -// knob cell, curve-preview button, Mono|Stereo toggle). Reads the band rect the allocator -// hands it (sample_bands) and never allocates vertical space of its own. +// (title + the whole right-anchored control run + Browse) over the strip row, which the +// piano strip has to itself. Reads the band rect the allocator hands it (sample_bands) and +// never allocates vertical space of its own. #include "core/instrument/ui/editor_geometry.h" // Rect @@ -11,21 +11,22 @@ namespace reasampler::instrument::ui { inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button // Every interactive rect inside the chrome band, in one pass so draw and hit-test cannot -// derive them differently. The control row's right-anchored run is fixed-width (preview, -// velocity cell, curve button, channel toggle) and the root strip takes the remainder, so -// the strip grows with the window. +// derive them differently. The toolbar's fixed run is right-anchored and the title takes +// what is left of that row; the strip row carries nothing but the strip, so the strip grows +// with the window in both directions. struct ChromeRects { Rect toolbar; // full-width top row - Rect navBrowse; // right-anchored in the toolbar - Rect controls; // full-width second row - Rect rootStrip; // remainder-width, left - Rect preview; + Rect title; // the title text slot: the toolbar left of the control run + Rect preview; // ---- the right-anchored run, left to right ---- Rect velCell; // preview-velocity knob cell (knob + label band) Rect velKnob; Rect velLabel; Rect curveBtn; // opens the velocity-curve popup Rect chanMono; Rect chanStereo; + Rect navBrowse; + Rect controls; // full-width second row + Rect rootStrip; // the piano strip: the whole row, inset only by the shared band pad }; // `knobSize` is the deck knob square, passed in so this module does not depend on knob_deck. diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index d6fe147..2c6d196 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -90,7 +90,7 @@ scattered `#ifdef`s in the VST shell, except the one described below). - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. -- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / spectral strip / root marker / title band), label helpers, deck group ids, and the velocity-curve box derivation — the helpers more than one band TU needs. +- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, deck group ids, and the velocity-curve box derivation — the helpers more than one band TU needs. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. - `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)* ## Gotchas diff --git a/src/shell/instrument/editor_input_chrome.cpp b/src/shell/instrument/editor_input_chrome.cpp index 5a78b4a..5886f83 100644 --- a/src/shell/instrument/editor_input_chrome.cpp +++ b/src/shell/instrument/editor_input_chrome.cpp @@ -1,12 +1,12 @@ // editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger, // the preview-velocity knob grab, the curve-button summon, the channel toggle, and the -// root-marker grab plus its live drag. Windows-only. +// piano strip's root grab plus its live drag. Windows-only. #include "shell/instrument/reasampler_editor.h" #ifdef _WIN32 -#include "core/instrument/ui/keyboard_strip.h" // keyAtPoint / resolveDragNote (root marker) +#include "core/instrument/ui/keyboard_strip.h" // keyAtPoint / resolveDragNote (root key) #include "shell/instrument/editor_internal.h" #include "shell/instrument/reasampler_processor.h" @@ -69,31 +69,32 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) { return true; } - // The root strip: grab the root marker. A plain click sets the root to the clicked key - // (applied below as the first delta==0 move). - if (cr.rootStrip.width > 0) { + // The piano strip: clicking a key sets the root, and holding tracks the pointer. The + // click itself lands below as the first (unmoved) drag resolve. + if (!cr.rootStrip.empty()) { const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); - const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y); - if (note >= 0) { + if (keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y) >= 0) { drag_ = DragKind::kRootMarker; dragStartX_ = x; - dragStartRoot_ = note; dragStartParams_ = params_; - onMouseMove(x, y); // apply the click as the first delta==0 set + onMouseMove(x, y); return true; } } - // A click on the control row's background is consumed so it can't fall through to a + // A click on the strip row's background is consumed so it can't fall through to a // band the user cannot see under the chrome. return contains(cr.controls, x, y); } void ReaSamplerEditor::dragChrome(const FaceLayout& fl, int x, int y) { - (void)y; const Rect& stripArea = fl.chrome.rootStrip; - if (stripArea.width <= 0) return; + if (stripArea.empty()) return; + // Absolute tracking, not a pixel delta: with black keys overlaying whites there is no + // one pixels-per-semitone rate a delta could use. const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); - params_.rootOverride = resolveDragNote(sl, dragStartRoot_, x - dragStartX_); + const int note = resolveDragNote(sl, x - stripArea.x, y - stripArea.y); + if (note < 0) return; + params_.rootOverride = note; invalidate(); // live feedback; the commit lands on WM_LBUTTONUP } @@ -107,6 +108,11 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl if (contains(cr.curveBtn, x, y)) return {HoverKind::kCurveButton, -1}; if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1}; if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1}; + if (!cr.rootStrip.empty()) { + const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); + const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y); + if (note >= 0) return {HoverKind::kStripKey, note}; + } return {}; } diff --git a/src/shell/instrument/editor_internal.h b/src/shell/instrument/editor_internal.h index 94c1314..a726eee 100644 --- a/src/shell/instrument/editor_internal.h +++ b/src/shell/instrument/editor_internal.h @@ -1,8 +1,8 @@ // editor_internal.h — shared helpers for the ReaSamplerEditor TU family. Included ONLY by // the editor's own shell TUs (editor_session / editor_controls / editor_paint_* / // editor_input_* / editor_platform) — never a public seam. Holds the Rect<->kit adapters, -// small draw primitives (knob face / spectral strip / root marker / title band), label -// helpers, deck group ids, and the velocity-curve box derivation. All inline. +// small draw primitives (knob face / title band), label helpers, deck group ids, and the +// velocity-curve box derivation. All inline. #pragma once @@ -14,6 +14,7 @@ #include "core/instrument/engine/velocity_curve.h" // VelocityCurve::Box (curveBoxFromRect) #include "core/instrument/map/sample_map.h" // SampleChoice / SampleRefs (sampleLabel) #include "core/instrument/ui/editor_geometry.h" // Rect (the shared sub-rect type) +#include "core/instrument/ui/keyboard_strip.h" // noteName (the one note-naming source) #ifdef _WIN32 #include "wdltypes.h" @@ -22,7 +23,6 @@ #include "core/audio/peaks.h" // Envelope (drawEnvelope) #include "core/instrument/ui/capture_browser.h" // BrowserLayout / cardThumbnailRect (thumbBins) #include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace) -#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / isNaturalKey (spectral strip) #include "core/ui/component_geometry.h" // KitBox / waveformColumnCount #include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor #include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text/drawWaveform/toLice @@ -55,15 +55,10 @@ inline instrument::engine::VelocityCurve::Box curveBoxFromRect( (std::max)(0, r.height - 2 * kVelCurveInset)}; } -// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the -// common DAW convention REAPER uses). +// A short MIDI-note label ("C4", "F#3"). The naming itself is the pure strip module's, so +// a browser badge and a strip tooltip can never disagree about what a note is called. inline 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); + return instrument::ui::noteName(note); } // A display name for a bank sample id: the snapshotted bank list first, then the @@ -170,53 +165,6 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect toLice(ui::roleColor(needleRole)), 1.0f, 0, true); } -// Draws the pastel spectral keyboard-strip background: each MIDI key column filled with -// its spectral hue, accidentals darkened with an overlay wash so pitch position reads as -// a keyboard at a glance. -inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) { - using instrument::ui::StripLayout; - if (stripArea.width <= 0 || stripArea.height <= 0) return; - const StripLayout sl = instrument::ui::layoutStrip(stripArea.width, stripArea.height); - const int sx = stripArea.x; - const int sy = stripArea.y; - const int h = stripArea.height; - const LICE_pixel darkKey = toLice(ui::roleColor(ui::Role::BgBase)); - for (int n = 0; n <= 127; ++n) { - const instrument::ui::Rect k = instrument::ui::keyRect(sl, n); - const int x0 = k.x + sx; - const int x1 = - (n < 127) ? instrument::ui::keyRect(sl, n + 1).x + sx : stripArea.right(); - const int cw = (std::max)(1, x1 - x0); - const ui::KitColor hue = ui::spectralColor(static_cast(n) / 127.0); - LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0); - if (!instrument::ui::isNaturalKey(n)) { - LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0); - } - } - // Faint per-octave key ticks (hairline role) for orientation. - const LICE_pixel tick = toLice(ui::roleColor(ui::Role::LineHairline)); - for (int n = 0; n <= 127; n += 12) { - const instrument::ui::Rect k = instrument::ui::keyRect(sl, n); - LICE_Line(bmp, k.x + sx, sy, k.x + sx, sy + h, tick, 1.0f, 0, false); - } -} - -// Draws the single-capture root marker: an accent-primary bar with a soft static glow — -// the "this is live" mark. -inline void drawRootMarker(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea, - const instrument::ui::StripLayout& sl, int root) { - const int sx = stripArea.x; - const int sy = stripArea.y; - const int h = stripArea.height; - const instrument::ui::Rect marker = instrument::ui::rootMarkerRect(sl, root); - const int mw = (std::max)(2, marker.width); - const LICE_pixel accent = toLice(ui::roleColor(ui::Role::AccentPrimary)); - const LICE_pixel glow = toLice(ui::roleColor(ui::Role::AccentHot)); - // Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse). - LICE_FillRect(bmp, marker.x + sx - 3, sy, mw + 6, h, glow, 0.30f, 0); - LICE_FillRect(bmp, marker.x + sx, sy, mw, h, accent, 1.0f, 0); -} - #endif // _WIN32 } // namespace reasampler::vst diff --git a/src/shell/instrument/editor_paint.cpp b/src/shell/instrument/editor_paint.cpp index 08cce9a..9259507 100644 --- a/src/shell/instrument/editor_paint.cpp +++ b/src/shell/instrument/editor_paint.cpp @@ -71,6 +71,9 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { // The curve popup: a centered sheet over the whole face, drawn last. if (curvePopupOpen_) paintCurvePopup(bmp, w, h); + + // The piano strip's note-name chip overhangs its band, so it goes on top of everything. + paintChromeTooltip(bmp, fl, w, h); } void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { diff --git a/src/shell/instrument/editor_paint_chrome.cpp b/src/shell/instrument/editor_paint_chrome.cpp index 6be09c8..0603ae3 100644 --- a/src/shell/instrument/editor_paint_chrome.cpp +++ b/src/shell/instrument/editor_paint_chrome.cpp @@ -1,7 +1,7 @@ // editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title + -// live readout + Browse) and the control row (root/piano strip with its root marker, the -// preview trigger, the preview-velocity knob cell, the curve-preview button, and the -// Mono|Stereo toggle). Windows-only; all rects come from the pure sample_chrome interior. +// live readout, then the control run — preview, preview-velocity knob, curve button, +// Mono|Stereo, Browse) over the strip row, which the piano strip has to itself. Windows-only; +// all rects come from the pure sample_chrome interior and the pure keyboard_strip geometry. #include "shell/instrument/reasampler_editor.h" @@ -10,9 +10,11 @@ #include #include +#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / noteName #include "core/instrument/ui/knob_deck.h" // kDeckKnobSize (the shared knob square) +#include "core/ui/tooltip.h" // computeTooltip (shared placement math) #include "core/version/app_version.h" // vstPluginName (channel-derived title band) -#include "shell/instrument/editor_internal.h" // kit adapters + knob face / spectral strip / root marker +#include "shell/instrument/editor_internal.h" // kit adapters + knob face #include "shell/instrument/reasampler_processor.h" namespace reasampler::vst { @@ -21,9 +23,81 @@ using namespace reasampler::ui; // kit vocabulary using namespace reasampler::instrument::ui; // chrome geometry + keyboard strip using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback) +namespace { + +// Every text element on the toolbar row draws at this one size/weight — including the +// product title, which used to be the row's odd one out. +constexpr Font kToolbarFont = Font::Label; + +// Kit font is proportional, so the char width is a generous estimate (pads, never clips). +constexpr int kTooltipCharPx = 7; +constexpr int kTooltipTextH = 14; + +constexpr int kRootBadgeW = 38; +constexpr int kRootBadgeH = 13; + +// The piano strip: white keys tiled at one width, black keys overlaid at one width, each +// tinted with its spectral hue so pitch position reads at a glance. `hoverNote` is outlined +// (-1 for none). All rects are strip-local; `area` supplies the origin. +void drawKeyboard(LICE_IBitmap* bmp, const Rect& area, const StripLayout& sl, int hoverNote) { + fillSurface(bmp, toKitBox(area), Role::BgBase, InteractionState::Rest); + if (sl.keys.empty()) return; + + const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); + const LICE_pixel shadow = toLice(roleColor(Role::BgBase)); + const auto hueOf = [](int n) { + return toLice(spectralColor(static_cast(n) / (kStripKeyCount - 1))); + }; + + for (int n = 0; n < kStripKeyCount; ++n) { + if (!isNaturalKey(n)) continue; + const Rect k = keyRect(sl, n); + LICE_FillRect(bmp, area.x + k.x, area.y + k.y, k.width, k.height, hueOf(n), 0.55f, 0); + LICE_Line(bmp, area.x + k.right() - 1, area.y + k.y, area.x + k.right() - 1, + area.y + k.bottom() - 1, hairline, 0.6f, 0, false); + } + // Blacks last: they overlap the whites they straddle. + for (int n = 0; n < kStripKeyCount; ++n) { + if (isNaturalKey(n)) continue; + const Rect k = keyRect(sl, n); + LICE_FillRect(bmp, area.x + k.x, area.y + k.y, k.width, k.height, shadow, 1.0f, 0); + LICE_FillRect(bmp, area.x + k.x, area.y + k.y, k.width, k.height, hueOf(n), 0.35f, 0); + } + if (hoverNote >= 0) { + const Rect k = keyRect(sl, hoverNote); + LICE_DrawRect(bmp, area.x + k.x, area.y + k.y, k.width - 1, k.height - 1, + toLice(roleColor(Role::TextPrimary)), 0.8f, 0); + } +} + +// The root affordance: the root key lit accent-primary with a static glow, plus a name badge +// (a key is far too narrow to carry text itself). The badge is clamped inside the strip. +void drawRootKey(LICE_IBitmap* bmp, const Rect& area, const StripLayout& sl, int root) { + if (sl.keys.empty()) return; + const Rect k = rootMarkerRect(sl, root); + const int kx = area.x + k.x; + const LICE_pixel accent = toLice(roleColor(Role::AccentPrimary)); + const LICE_pixel glow = toLice(roleColor(Role::AccentHot)); + // Static glow: a wider low-alpha halo behind the lit key (a drawn state, not a pulse). + LICE_FillRect(bmp, kx - 3, area.y + k.y, k.width + 6, k.height, glow, 0.30f, 0); + LICE_FillRect(bmp, kx, area.y + k.y, k.width, k.height, accent, 1.0f, 0); + + const int badgeH = (std::min)(kRootBadgeH, area.height); + const int badgeW = (std::min)(kRootBadgeW, area.width); + int bx = kx + (k.width - badgeW) / 2; + bx = (std::max)(area.x, (std::min)(bx, area.right() - badgeW)); + const Rect badge = Rect::ltrb(bx, area.bottom() - badgeH, bx + badgeW, area.bottom()); + LICE_FillRect(bmp, badge.x, badge.y, badge.width, badge.height, accent, 0.92f, 0); + kitTextCentered(bmp, badge, noteName(root).c_str(), Font::Micro, Role::BgBase); +} + +} // namespace + void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty) { const ChromeRects& cr = fl.chrome; + fillSurface(bmp, toKitBox(cr.toolbar), Role::BgPanel, InteractionState::Rest); + // Toolbar: product name + live readout. The beta channel gets no distinct accent; the // channel-derived vstPluginName is the only beta-vs-stable signal. std::string title = version::vstPluginName(); @@ -38,7 +112,7 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool } else { title += " [host: no bridge]"; } - drawTitleBand(bmp, cr.toolbar, title); + kitText(bmp, cr.title, title.c_str(), kToolbarFont, Role::TextPrimary); // Browse: the picker. When nothing is loaded it is the empty state's dominant // call-to-action — draw it Active (accent-primary) so it reads as "start here". @@ -50,19 +124,9 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool drawButton(bmp, box, "Browse", st, /*warn=*/false); } - // The control row draws only once a capture is loaded — with nothing picked there is no - // root, no preview and no channel decision to make. - if (empty || cr.controls.empty()) return; - - fillSurface(bmp, toKitBox(cr.controls), Role::BgPanel, InteractionState::Rest); - - // Root strip: the full 128-key spectral band with the root marked. The loaded capture - // responds across the whole strip, repitched from that root. - if (cr.rootStrip.width > 0) { - drawSpectralStrip(bmp, cr.rootStrip); - const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); - drawRootMarker(bmp, cr.rootStrip, sl, effectiveRoot()); - } + // The rest of the run and the strip row draw only once a capture is loaded — with + // nothing picked there is no root, no preview and no channel decision to make. + if (empty) return; // Preview-trigger button (fires the loaded capture at root through the live voice engine). { @@ -106,11 +170,50 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool : InteractionState::Rest); fillSurface(bmp, toKitBox(cr.chanMono), Role::BgCell, monoState); fillSurface(bmp, toKitBox(cr.chanStereo), Role::BgCell, stereoState); - kitTextCentered(bmp, cr.chanMono, "Mono", Font::Label, + kitTextCentered(bmp, cr.chanMono, "Mono", kToolbarFont, !isStereo ? Role::BgBase : Role::TextPrimary); - kitTextCentered(bmp, cr.chanStereo, "Stereo", Font::Label, + kitTextCentered(bmp, cr.chanStereo, "Stereo", kToolbarFont, isStereo ? Role::BgBase : Role::TextPrimary); } + + // The strip row: the full 128-key piano with the root lit. The loaded capture responds + // across the whole strip, repitched from that root. + if (cr.controls.empty() || cr.rootStrip.empty()) return; + fillSurface(bmp, toKitBox(cr.controls), Role::BgPanel, InteractionState::Rest); + const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height); + // Same staleness guard as the tooltip: a latched hover note outlives a drag it started. + const int hoverNote = (drag_ == DragKind::kNone && hover_.kind == HoverKind::kStripKey) + ? hover_.index : -1; + drawKeyboard(bmp, cr.rootStrip, sl, hoverNote); + drawRootKey(bmp, cr.rootStrip, sl, effectiveRoot()); +} + +// Drawn after every band so the chip is never painted over. No hover delay: the strip is a +// continuous readout you sweep, and a delay there reads as a dead surface — unlike the bank +// panel's buttons, where the delay stops tooltips firing on every traverse. +void ReaSamplerEditor::paintChromeTooltip(LICE_IBitmap* bmp, const FaceLayout& fl, int w, + int h) { + if (hover_.kind != HoverKind::kStripKey || hover_.index < 0) return; + // Hover is deliberately not re-resolved mid-drag, so the latched note would go stale + // under a root drag — the root badge is the live readout there. + if (drag_ != DragKind::kNone) return; + const Rect& area = fl.chrome.rootStrip; + if (area.empty()) return; + const StripLayout sl = layoutStrip(area.width, area.height); + const Rect key = keyRect(sl, hover_.index); + if (key.empty()) return; + + const std::string label = noteName(hover_.index); + const int textW = static_cast(label.size()) * kTooltipCharPx; + const TooltipBox tb = computeTooltip(area.x + key.x, area.y + key.y, key.width, key.height, + textW, kTooltipTextH, w, h, TooltipSpec{}); + if (tb.empty()) return; + + const Rect box = Rect::ltrb(tb.x, tb.y, tb.x + tb.width, tb.y + tb.height); + fillSurface(bmp, toKitBox(box), Role::BgCell, InteractionState::Hover); + LICE_DrawRect(bmp, box.x, box.y, box.width, box.height, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + kitTextCentered(bmp, box, label.c_str(), Font::Label, Role::TextPrimary); } } // namespace reasampler::vst diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 9c09a32..dee3247 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -130,6 +130,7 @@ private: kControl, // a knob-deck element (index = control id) kCurveNode, // a velocity-curve control point (index = point index) kVelKnob, // the chrome preview-velocity radial knob + kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip kCurveButton, // the chrome mini curve-preview button (opens the popup) kPopupClose, // the curve popup's Close (x) button }; @@ -160,6 +161,9 @@ private: // Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob, // curve button, channel toggle). void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty); + // The hovered piano key's note-name chip. Drawn after every band — it overhangs the + // chrome into whatever is below it. + void paintChromeTooltip(LICE_IBitmap* bmp, const FaceLayout& fl, int w, int h); // Waveform: the channel lane(s), the loop/start markers, and the envelope overlay. void paintWaveform(LICE_IBitmap* bmp, const Rect& band); // Decks: the group fence + caption + compact caption toggles + radial knobs with @@ -417,7 +421,6 @@ private: int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag int dragCurX_ = 0; // live cursor x (px) during a drag — updated in onMouseMove int dragCurY_ = 0; // live cursor y (px) during a drag — updated in onMouseMove - int dragStartRoot_ = 60; // the root note at grab time InstrumentParams dragStartParams_; // params_ snapshotted at grab; restored on capture-loss // Waveform-marker drag: which marker + the marker set snapshotted at grab time, so the diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp index 69a4647..fabaed9 100644 --- a/tests/test_keyboard_strip.cpp +++ b/tests/test_keyboard_strip.cpp @@ -1,18 +1,18 @@ -// Standalone tests for reasampler::instrument::ui::keyboard_strip — no VST3, no REAPER, no framework. -// Same fast assert loop as the sibling pure tests. Assert the editor's keyboard-strip -// layout, root marker, key mapping, and drag-delta note resolver directly — the geometry -// that backs the root display and root-set. +// Standalone tests for reasampler::instrument::ui::keyboard_strip — no VST3, no REAPER, no +// framework. Same fast assert loop as the sibling pure tests. // -// 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; resolveDragNote rounding -// to the nearest key at the key centre, clamping to [0,127], and the zero-delta / zero-width -// no-ops; isNaturalKey across a full octave (C4..B4), at boundary notes 0 and 127, and with -// out-of-range inputs that clamp to [0,127]. +// Covers: layoutStrip (normal, degenerate, sub-key-width); same-class key-width uniformity +// swept across editor widths AND DPI scale factors; the tiled key area staying centred +// inside a band that spans the full width it was handed; whiteIndexOf / isNaturalKey across +// octave boundaries and the 0..127 extremes; keyRect tiling and black-over-white overlap; +// keyAtPoint resolving black-over-white by zone and missing off-band; the root affordance's +// hit-to-marker round trip; resolveDragNote clamping a wandering pointer; and noteName under +// the C4 (MIDI 60) DAW convention. #include "../src/core/instrument/ui/keyboard_strip.h" #include +#include using namespace reasampler; using namespace reasampler::instrument::ui; @@ -21,171 +21,274 @@ 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); } +// A comfortable strip: wide enough that every class is several pixels across. +static StripLayout wideStrip() { return layoutStrip(1280, 30); } + +// The widths a real editor hands the strip — the 560px minimum client up to a wide +// window — crossed with the DPI scale factors Windows actually reports. +static const int kBaseWidths[] = {544, 600, 640, 700, 749, 750, 751, 824, 900, 1000, + 1024, 1103, 1264, 1600, 1920, 2400}; +static const double kDpiScales[] = {1.0, 1.25, 1.5, 1.75, 2.0}; // --- layoutStrip -------------------------------------------------------------- -static void testLayoutNormalArea() { - const StripLayout L = layoutStrip(640, 40); - CHECK(L.keys.x == 0 && L.keys.y == 0); - CHECK(L.keys.right() == 640 && L.keys.bottom() == 40); +static void testLayoutFillsTheBandAndCentresTheKeys() { + const StripLayout L = layoutStrip(824, 30); + CHECK(L.band == Rect::ltrb(0, 0, 824, 30)); + CHECK(L.whiteWidth == 824 / kStripWhiteKeyCount); + CHECK(L.keys.width == L.whiteWidth * kStripWhiteKeyCount); + CHECK(L.keys.y == 0 && L.keys.height == 30); + // The residue an indivisible width leaves splits evenly between the two end margins. + const int leftMargin = L.keys.x - L.band.x; + const int rightMargin = L.band.right() - L.keys.right(); + CHECK(leftMargin >= 0 && rightMargin >= 0); + CHECK(rightMargin - leftMargin >= 0 && rightMargin - leftMargin <= 1); } -static void testLayoutZeroArea() { - const StripLayout L = layoutStrip(0, 0); - CHECK(L.keys.width == 0 && L.keys.height == 0); +static void testDegenerateSizesYieldNoKeys() { + const StripLayout zero = layoutStrip(0, 0); + CHECK(zero.band.empty()); + CHECK(zero.keys.empty()); + CHECK(keyAtPoint(zero, 0, 0) == -1); + CHECK(keyRect(zero, 60).empty()); + CHECK(resolveDragNote(zero, 5, 5) == -1); + + // Narrower than one pixel per white key: no keys at all, but the band still reports its + // size so the caller can draw the empty surface. + const StripLayout narrow = layoutStrip(kStripWhiteKeyCount - 1, 30); + CHECK(narrow.band.width == kStripWhiteKeyCount - 1); + CHECK(narrow.keys.empty()); + CHECK(keyAtPoint(narrow, 10, 10) == -1); } -// --- keyLeftX / keyRect / rootMarkerRect -------------------------------------- +// --- the sharp one: same-class widths are uniform at every width and DPI scale --- -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.x); - 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; +static void testSameClassKeysAreEqualWidthAcrossWidthsAndDpiScales() { + for (const int base : kBaseWidths) { + for (const double scale : kDpiScales) { + const int w = static_cast(base * scale); + const int h = static_cast(30 * scale); + const StripLayout L = layoutStrip(w, h); + if (L.keys.empty()) continue; // covered by the degenerate test + + int whiteW = -1; + int blackW = -1; + int blackH = -1; + for (int n = 0; n < kStripKeyCount; ++n) { + const Rect k = keyRect(L, n); + CHECK(!k.empty()); + if (isNaturalKey(n)) { + if (whiteW < 0) whiteW = k.width; + CHECK(k.width == whiteW); + CHECK(k.height == L.keys.height); // whites run the full band height + } else { + if (blackW < 0) { blackW = k.width; blackH = k.height; } + CHECK(k.width == blackW); + CHECK(k.height == blackH); + } + } + CHECK(whiteW == L.whiteWidth); + CHECK(blackW == L.blackWidth); + CHECK(blackH == L.blackHeight); + CHECK(blackW < whiteW); // the two classes stay visually distinct + CHECK(blackH < L.keys.height); + } } - // 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.x == keyLeftX(L, 60)); - CHECK(k.right() == keyLeftX(L, 61)); - CHECK(k.y == L.keys.y && k.bottom() == L.keys.bottom()); - CHECK(k.width == 10); // 10px/key +static void testKeyAreaSpansTheBandWithinOneKeyAtEveryTestedWidth() { + for (const int base : kBaseWidths) { + for (const double scale : kDpiScales) { + const int w = static_cast(base * scale); + const StripLayout L = layoutStrip(w, 30); + CHECK(L.band.width == w); // the strip always spans the width it was handed + if (L.keys.empty()) continue; + // The keys cover all but w % 75 — the price of uniform integer key widths, and + // the reason the residue is a margin rather than a per-key rounding wobble. + const int margins = L.band.width - L.keys.width; + CHECK(margins == w % kStripWhiteKeyCount); + CHECK(margins >= 0 && margins < kStripWhiteKeyCount); + const int leftMargin = L.keys.x - L.band.x; + CHECK(leftMargin == margins / 2); // split evenly, odd pixel to the right + CHECK(L.keys.x >= L.band.x && L.keys.right() <= L.band.right()); + } + } } -static void testRootMarkerEqualsKeyRect() { +// --- key classification + white ordinals -------------------------------------- + +static void testIsNaturalKeyAcrossAnOctaveAndTheExtremes() { + // C4..B4 (MIDI 60..71). + const bool expected[12] = {true, false, true, false, true, true, + false, true, false, true, false, true}; + for (int i = 0; i < 12; ++i) CHECK(isNaturalKey(60 + i) == expected[i]); + + CHECK(isNaturalKey(0) == true); // C-1 + CHECK(isNaturalKey(1) == false); // C#-1 + CHECK(isNaturalKey(127) == true); // G9 + CHECK(isNaturalKey(126) == false); // F#9 + // Out-of-range clamps rather than indexing off the table. + CHECK(isNaturalKey(-100) == true); + CHECK(isNaturalKey(200) == true); +} + +static void testWhiteIndexCountsNaturalsBelowTheNote() { + CHECK(whiteIndexOf(0) == 0); // C-1 is the first white key + CHECK(whiteIndexOf(1) == 1); // C#-1 straddles the C/D boundary + CHECK(whiteIndexOf(2) == 1); // D-1 is the second white key + CHECK(whiteIndexOf(4) == 2); // E-1 + CHECK(whiteIndexOf(5) == 3); // F-1 (no black between E and F) + CHECK(whiteIndexOf(11) == 6); // B-1 + CHECK(whiteIndexOf(12) == 7); // C0 opens the next octave + CHECK(whiteIndexOf(60) == 35); // C4 + CHECK(whiteIndexOf(127) == kStripWhiteKeyCount - 1); // G9 is the last white key +} + +// --- keyRect ------------------------------------------------------------------ + +static void testWhiteKeysTileTheKeyAreaGapFree() { const StripLayout L = wideStrip(); - const Rect m = rootMarkerRect(L, 64); - const Rect k = keyRect(L, 64); - CHECK(m.x == k.x && m.right() == k.right() && m.y == k.y && m.bottom() == k.bottom()); + int expectedLeft = L.keys.x; + for (int n = 0; n < kStripKeyCount; ++n) { + if (!isNaturalKey(n)) continue; + const Rect k = keyRect(L, n); + CHECK(k.x == expectedLeft); + expectedLeft = k.right(); + } + CHECK(expectedLeft == L.keys.right()); // the last white ends exactly on the key area +} + +static void testBlackKeysStraddleTheirWhiteBoundary() { + const StripLayout L = wideStrip(); + for (int n = 1; n < kStripKeyCount - 1; ++n) { + if (isNaturalKey(n)) continue; + const Rect black = keyRect(L, n); + const Rect below = keyRect(L, n - 1); // the natural under the accidental + const Rect above = keyRect(L, n + 1); + CHECK(black.x > below.x && black.right() < above.right()); + CHECK(black.x < below.right()); // overlaps the white on its left + CHECK(black.right() > above.x); // and the white on its right + } +} + +static void testRootMarkerIsTheRootKey() { + const StripLayout L = wideStrip(); + CHECK(rootMarkerRect(L, 64) == keyRect(L, 64)); + CHECK(rootMarkerRect(L, 61) == keyRect(L, 61)); + // Out-of-range roots clamp instead of producing a stray rect. + CHECK(rootMarkerRect(L, -5) == keyRect(L, 0)); + CHECK(rootMarkerRect(L, 999) == keyRect(L, 127)); } // --- keyAtPoint --------------------------------------------------------------- -static void testKeyAtPointInverts() { +static void testEveryKeyIsReachableAtItsOwnCentre() { 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.x + 5, k.y + 2) == 60); - // The very left of the band is key 0; just inside the right edge is key 127. - CHECK(keyAtPoint(L, L.keys.x, 2) == 0); - CHECK(keyAtPoint(L, L.keys.right() - 1, 2) == 127); + for (int n = 0; n < kStripKeyCount; ++n) { + const Rect k = keyRect(L, n); + const int cx = k.x + k.width / 2; + // A white key only answers below the black zone, where the accidentals end. + const int cy = isNaturalKey(n) ? L.keys.bottom() - 1 : k.y + k.height / 2; + CHECK(keyAtPoint(L, cx, cy) == n); + } } -static void testKeyAtPointOffBand() { +static void testBlackKeysWinInTheirZoneAndWhitesWinBelowIt() { 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 + const Rect cSharp = keyRect(L, 61); // C#4 + const int cx = cSharp.x + cSharp.width / 2; + CHECK(keyAtPoint(L, cx, cSharp.y) == 61); // in the black zone + const int below = keyAtPoint(L, cx, L.keys.bottom() - 1); + CHECK(below != 61); // below it, a white answers + CHECK(below == 60 || below == 62); // C4 or D4, whichever it overlaps + // E-F and B-C have no accidental between them: the top row there is still white. + const Rect e4 = keyRect(L, 64); + CHECK(keyAtPoint(L, e4.right() - 1, e4.y) == 64); +} + +static void testKeyAtPointMissesOffBandAndInTheEndMargins() { + const StripLayout L = layoutStrip(824, 30); + CHECK(keyAtPoint(L, -5, 5) == -1); + CHECK(keyAtPoint(L, L.band.right() + 5, 5) == -1); + CHECK(keyAtPoint(L, 100, L.band.bottom() + 5) == -1); + CHECK(keyAtPoint(L, 100, -1) == -1); + if (L.keys.x > L.band.x) CHECK(keyAtPoint(L, L.band.x, 5) == -1); // left cheek margin + if (L.keys.right() < L.band.right()) + CHECK(keyAtPoint(L, L.band.right() - 1, 5) == -1); // right cheek margin +} + +// --- the root affordance ------------------------------------------------------ + +static void testHitTestingAKeyMarksThatSameKey() { + // The pure half of "click a key, the displayed root moves there": whatever keyAtPoint + // resolves, the root marker lands exactly on that key — no off-by-one between the key + // the pointer hit and the key drawn lit. + const StripLayout L = wideStrip(); + for (int n = 0; n < kStripKeyCount; ++n) { + const Rect k = keyRect(L, n); + const int cx = k.x + k.width / 2; + const int cy = isNaturalKey(n) ? L.keys.bottom() - 1 : k.y + k.height / 2; + const int hit = keyAtPoint(L, cx, cy); + CHECK(hit == n); + CHECK(rootMarkerRect(L, hit) == k); + } } // --- 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() { +static void testDragTracksThePointerAndClampsWhenItWanders() { 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); + const Rect g4 = keyRect(L, 67); + const int cx = g4.x + g4.width / 2; + CHECK(resolveDragNote(L, cx, L.keys.bottom() - 1) == 67); + + // Wandering off the strip keeps tracking at the clamped edge rather than dropping out. + CHECK(resolveDragNote(L, -500, L.keys.bottom() - 1) == 0); + CHECK(resolveDragNote(L, L.band.right() + 500, L.keys.bottom() - 1) == 127); + // Above the strip clamps into the black zone; G4's centre is clear of both flanking + // accidentals, so it still answers G4 rather than F#4 or G#4. + CHECK(resolveDragNote(L, cx, -400) == 67); + CHECK(resolveDragNote(L, cx, 4000) == 67); } -// --- isNaturalKey ------------------------------------------------------------- +// --- noteName ----------------------------------------------------------------- -static void testIsNaturalKeyFullOctave() { - // Semitone positions 0..11 starting at C4 (MIDI 60): - // C=60(nat) C#=61(acc) D=62(nat) D#=63(acc) E=64(nat) F=65(nat) - // F#=66(acc) G=67(nat) G#=68(acc) A=69(nat) A#=70(acc) B=71(nat) - const bool expected[12] = { - true, false, true, false, true, true, - false, true, false, true, false, true, - }; - for (int i = 0; i < 12; ++i) { - CHECK(isNaturalKey(60 + i) == expected[i]); - } -} - -static void testIsNaturalKeyBoundaryNotes() { - // Note 0 is C (natural); note 127 is G (natural); note 1 is C# (accidental). - CHECK(isNaturalKey(0) == true); // C0 — natural - CHECK(isNaturalKey(1) == false); // C#0 — accidental - CHECK(isNaturalKey(127) == true); // G9 — natural (127 % 12 == 7) - CHECK(isNaturalKey(126) == false); // F#9 — accidental (126 % 12 == 6) -} - -static void testIsNaturalKeyOutOfRangeClamped() { - // Values outside [0,127] clamp to [0,127]; must not crash/UB. - // note -1 clamps to 0 (C, natural); note 128 clamps to 127 (G, natural). - CHECK(isNaturalKey(-1) == true); - CHECK(isNaturalKey(128) == true); - CHECK(isNaturalKey(-100) == true); - CHECK(isNaturalKey(200) == true); -} - -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.y + 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).x; - 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.y + 1); - CHECK(resolved >= expected - 1 && resolved <= expected + 1); - } +static void testNoteNamesFollowTheC4Convention() { + CHECK(noteName(60) == "C4"); // middle C, the convention REAPER uses + CHECK(noteName(61) == "C#4"); + CHECK(noteName(59) == "B3"); // the octave rolls at B->C, not at A->B + CHECK(noteName(72) == "C5"); + CHECK(noteName(0) == "C-1"); // the low extreme + CHECK(noteName(11) == "B-1"); + CHECK(noteName(12) == "C0"); + CHECK(noteName(127) == "G9"); // the high extreme + CHECK(noteName(126) == "F#9"); + // Out-of-range clamps to the extremes rather than naming an unplayable note. + CHECK(noteName(-1) == "C-1"); + CHECK(noteName(500) == "G9"); } int main() { - testLayoutNormalArea(); - testLayoutZeroArea(); - testKeyLeftMonotonicAndBounds(); - testKeyRectHalfOpen(); - testRootMarkerEqualsKeyRect(); - testKeyAtPointInverts(); - testKeyAtPointOffBand(); - testResolveDragRoundsToNearestKey(); - testResolveDragClampsAndNoOps(); - testResolveDragProportionalNonDivisibleWidth(); - testIsNaturalKeyFullOctave(); - testIsNaturalKeyBoundaryNotes(); - testIsNaturalKeyOutOfRangeClamped(); + testLayoutFillsTheBandAndCentresTheKeys(); + testDegenerateSizesYieldNoKeys(); + testSameClassKeysAreEqualWidthAcrossWidthsAndDpiScales(); + testKeyAreaSpansTheBandWithinOneKeyAtEveryTestedWidth(); + testIsNaturalKeyAcrossAnOctaveAndTheExtremes(); + testWhiteIndexCountsNaturalsBelowTheNote(); + testWhiteKeysTileTheKeyAreaGapFree(); + testBlackKeysStraddleTheirWhiteBoundary(); + testRootMarkerIsTheRootKey(); + testEveryKeyIsReachableAtItsOwnCentre(); + testBlackKeysWinInTheirZoneAndWhitesWinBelowIt(); + testKeyAtPointMissesOffBandAndInTheEndMargins(); + testHitTestingAKeyMarksThatSameKey(); + testDragTracksThePointerAndClampsWhenItWanders(); + testNoteNamesFollowTheC4Convention(); - if (g_fail == 0) std::printf("keyboard_strip: all tests passed\n"); - return g_fail != 0; + if (g_fail == 0) { + std::printf("keyboard_strip: all tests passed\n"); + return 0; + } + std::printf("keyboard_strip: %d failure(s)\n", g_fail); + return 1; } diff --git a/tests/test_sample_chrome.cpp b/tests/test_sample_chrome.cpp index 47f0bdf..5ad0eb8 100644 --- a/tests/test_sample_chrome.cpp +++ b/tests/test_sample_chrome.cpp @@ -1,16 +1,17 @@ // Standalone tests for reasampler::instrument::ui::sample_chrome — no VST3, no REAPER, no // test framework. // -// Covers: the chrome band's two rows (toolbar over control row, tiling the band exactly); -// the Browse button right-anchored inside the toolbar; the control row's fixed -// right-anchored run in order (preview, velocity cell, curve button, Mono|Stereo) with the -// root strip taking the remainder; the velocity knob centred in its cell above its label; -// and degenerate bands yielding no inverted rects. +// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the +// toolbar's fixed right-anchored run in order (preview, velocity cell, curve button, +// Mono|Stereo, Browse) with the title taking the remainder; the velocity knob centred in its +// cell above its label; the piano strip owning its whole row at every width; no rect on the +// toolbar overlapping any other; and degenerate bands yielding no inverted rects. #include "../src/core/instrument/ui/sample_bands.h" #include "../src/core/instrument/ui/sample_chrome.h" #include +#include using namespace reasampler; using namespace reasampler::instrument::ui; @@ -25,47 +26,83 @@ static Rect chromeBand(int w = 840, int h = 620) { return computeSampleBands(w, h, 120).chrome; } +// True when the two rects share at least one pixel. +static bool overlaps(const Rect& a, const Rect& b) { + if (a.empty() || b.empty()) return false; + return a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom(); +} + static void testRowsTileTheBandExactly() { const Rect band = chromeBand(); const ChromeRects r = chromeRects(band, kKnob); CHECK(r.toolbar.y == band.y); - CHECK(r.toolbar.height == kTitleHeight); CHECK(r.controls.y == r.toolbar.bottom()); CHECK(r.controls.bottom() == band.bottom()); CHECK(r.toolbar.x == band.x && r.toolbar.right() == band.right()); CHECK(r.controls.x == band.x && r.controls.right() == band.right()); + // The toolbar has to be tall enough for the velocity knob cell it now carries. + CHECK(r.toolbar.height >= kKnob); + CHECK(r.controls.height > 0); } -static void testBrowseIsRightAnchoredInsideTheToolbar() { +static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() { const Rect band = chromeBand(); const ChromeRects r = chromeRects(band, kKnob); + // Rightmost first: Browse, stereo, mono, curve button, velocity cell, preview, title. CHECK(r.navBrowse.right() == band.right() - kPad); CHECK(r.navBrowse.width == kNavButtonWidth); - CHECK(r.navBrowse.y >= r.toolbar.y); - CHECK(r.navBrowse.bottom() <= r.toolbar.bottom()); -} - -static void testControlRunIsOrderedRightToLeftWithoutOverlap() { - const Rect band = chromeBand(); - const ChromeRects r = chromeRects(band, kKnob); - // Rightmost first: stereo, mono, curve button, velocity cell, preview, then the strip. - CHECK(r.chanStereo.right() == band.right() - kPad); + CHECK(r.chanStereo.right() <= r.navBrowse.x); CHECK(r.chanMono.right() == r.chanStereo.x); CHECK(r.curveBtn.right() <= r.chanMono.x); CHECK(r.velCell.right() <= r.curveBtn.x); CHECK(r.preview.right() <= r.velCell.x); - CHECK(r.rootStrip.right() <= r.preview.x); - CHECK(r.rootStrip.x == band.x + kPad); - CHECK(r.rootStrip.width > 0); + CHECK(r.title.right() <= r.preview.x); + CHECK(r.title.x == band.x + kPad); + CHECK(r.title.width > 0); + + // Every toolbar rect sits inside the toolbar row. + const Rect items[] = {r.title, r.preview, r.velCell, r.curveBtn, r.chanMono, + r.chanStereo, r.navBrowse}; + for (const Rect& it : items) { + CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom()); + } } -static void testRootStripTakesTheRemainderWidth() { +static void testChromePartsNeverOverlapAtAnyWidth() { + for (int w = 560; w <= 2400; w += 37) { + const ChromeRects r = chromeRects(chromeBand(w, 620), kKnob); + // The strip row and the toolbar row are disjoint by construction; the strip must + // stay inside its own row, clear of every control. + CHECK(!overlaps(r.toolbar, r.rootStrip)); + CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom()); + const Rect items[] = {r.preview, r.velCell, r.curveBtn, r.chanMono, r.chanStereo, + r.navBrowse}; + for (const Rect& it : items) { + CHECK(!overlaps(it, r.rootStrip)); + CHECK(!overlaps(it, r.title)); + } + // The run's own members are pairwise disjoint (velKnob/velLabel are inside velCell, + // so they are checked against the cell's neighbours, not the cell). + for (int i = 0; i < 6; ++i) { + for (int j = i + 1; j < 6; ++j) CHECK(!overlaps(items[i], items[j])); + } + } +} + +static void testStripOwnsItsWholeRowAndGrowsWithTheWindow() { const ChromeRects narrow = chromeRects(chromeBand(600, 620), kKnob); const ChromeRects wide = chromeRects(chromeBand(1000, 620), kKnob); - // The fixed run keeps its size; every extra pixel goes to the strip. + for (const ChromeRects* r : {&narrow, &wide}) { + // Inset only by the shared band pad — the same inset the waveform band beneath uses, + // so the two line up. No control shortens it. + CHECK(r->rootStrip.x == r->controls.x + kPad); + CHECK(r->rootStrip.right() == r->controls.right() - kPad); + } + CHECK(wide.rootStrip.width == narrow.rootStrip.width + 400); + // The fixed run keeps its size; every extra pixel goes to the title, not the run. CHECK(wide.preview.width == narrow.preview.width); CHECK(wide.velCell.width == narrow.velCell.width); - CHECK(wide.rootStrip.width == narrow.rootStrip.width + 400); + CHECK(wide.title.width == narrow.title.width + 400); } static void testVelocityKnobIsCentredInItsCellAboveTheLabel() { @@ -77,6 +114,7 @@ static void testVelocityKnobIsCentredInItsCellAboveTheLabel() { CHECK(leftGap == rightGap); // horizontally centred in the cell CHECK(r.velLabel.y == r.velKnob.bottom()); CHECK(r.velLabel.bottom() == r.velCell.bottom()); + CHECK(r.velLabel.height > 0); CHECK(r.velLabel.x == r.velCell.x && r.velLabel.right() == r.velCell.right()); } @@ -85,28 +123,30 @@ static void testDegenerateBandYieldsNoInvertedRects() { CHECK(empty.toolbar.empty() && empty.controls.empty()); CHECK(empty.rootStrip.empty() && empty.preview.empty()); - // A band far too narrow for the fixed run: the strip collapses, nothing inverts. + // A band far too narrow for the fixed run: everything collapses left, nothing inverts. const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight), kKnob); - CHECK(tiny.rootStrip.right() >= tiny.rootStrip.x); - CHECK(tiny.navBrowse.right() >= tiny.navBrowse.x); - CHECK(tiny.preview.right() >= tiny.preview.x || tiny.preview.width < 0); + const Rect items[] = {tiny.title, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel, + tiny.curveBtn, tiny.chanMono, tiny.chanStereo, tiny.navBrowse, + tiny.rootStrip}; + for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y); } static void testToolbarOnlyBandStillPlacesTheNav() { - // A band clipped to just the toolbar row: the control row is empty but Browse still + // A band clipped to just the toolbar row: the strip row is empty but Browse still // resolves, so the empty state's call-to-action is never unreachable. const ChromeRects r = chromeRects(Rect::ltrb(0, 0, 400, kTitleHeight), kKnob); CHECK(r.toolbar.height == kTitleHeight); CHECK(r.controls.empty()); + CHECK(r.rootStrip.empty()); CHECK(r.navBrowse.width == kNavButtonWidth); } int main() { testRowsTileTheBandExactly(); - testBrowseIsRightAnchoredInsideTheToolbar(); - testControlRunIsOrderedRightToLeftWithoutOverlap(); - testRootStripTakesTheRemainderWidth(); + testToolbarRunIsOrderedRightToLeftWithoutOverlap(); + testChromePartsNeverOverlapAtAnyWidth(); + testStripOwnsItsWholeRowAndGrowsWithTheWindow(); testVelocityKnobIsCentredInItsCellAboveTheLabel(); testDegenerateBandYieldsNoInvertedRects(); testToolbarOnlyBandStillPlacesTheNav();