feat: legible ReaSampler 9000 editor — bigger knobs, ms time constants, per-ring double-click reset, and an antialiased draw pass

This commit is contained in:
2026-08-01 09:16:30 -04:00
parent 213ecfafe6
commit 7f74b11dce
27 changed files with 859 additions and 285 deletions
+63 -11
View File
@@ -9,6 +9,8 @@
// always places; deckHeight consistency with deckRowCount.
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding
// misses, outside-deck misses.
// * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring,
// both exclusive boundaries, and the points where it deliberately disagrees with the cell.
#include "../src/core/instrument/ui/knob_deck.h"
@@ -183,7 +185,7 @@ static void testHitTest() {
// not cover is smaller than one pixel per cell.
static void testReservedCellWidthGoesToTheCellsPresent() {
const DeckGroupDesc full{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24}, {}};
// Three, four, and a lone cell against the same five-slot reserve — 240/3, 240/4, 240/1.
// Three, four, and a lone cell against the same five-slot reserve.
const std::vector<std::vector<int>> faces = {
{20, 21, 22, -1, -1}, {20, 21, 22, 23, -1}, {20, -1, -1, -1, -1}};
for (const std::vector<int>& ids : faces) {
@@ -202,7 +204,13 @@ static void testReservedCellWidthGoesToTheCellsPresent() {
const DeckCellLayout& c = lay.cells[static_cast<std::size_t>(i)];
CHECK(c.cell.width == lay.cells[0].cell.width); // uniform
CHECK(c.knob.width == kDeckKnobSize); // the dial itself is fixed
CHECK(c.knob.x - c.cell.x == c.cell.right() - c.knob.right());
// Centred as exactly as integers allow: a cell whose spare width is odd cannot
// split it evenly, and the layout's integer division gives the odd pixel to the
// RIGHT margin. Pinned as a directional identity rather than a tolerance, so a
// future off-by-one on the other side would still fail here.
const int leftGap = c.knob.x - c.cell.x;
const int rightGap = c.cell.right() - c.knob.right();
CHECK(rightGap - leftGap == (c.cell.width - kDeckKnobSize) % 2);
if (i > 0) CHECK(c.cell.x == lay.cells[static_cast<std::size_t>(i - 1)].cell.right());
}
// Uncovered run is the indivisible residue only, split evenly at the two ends.
@@ -222,21 +230,21 @@ static void testReservedCellWidthGoesToTheCellsPresent() {
layoutDeck(b, 0, 0, 824).groups[0].rowToggle.seg0);
}
// The three faces above (240/3, 240/4, 240/1) all divide their run evenly, so none of them
// actually exercises "residue in symmetric end margins". An 8-slot reserve with 5 present
// (384/5 = 76 r4) does: residue 4 is the smallest case that can tell a symmetric split (2/2)
// apart from a trailing-only one (0/4) — a residue of 1 (0/1 vs 1/0... i.e. 0/1) can't, since
// leadPad = residue/2 rounds to 0 either way, which is exactly why this seam's earlier test
// passed without pinning the rule it was named for.
// The three faces above all divide their run evenly, so none of them actually exercises
// "residue in symmetric end margins". An 8-slot reserve with 7 present (480/7 = 68 r4) does:
// residue 4 is the smallest case that can tell a symmetric split (2/2) apart from a
// trailing-only one (0/4) — a residue of 1 can't, since leadPad = residue/2 rounds to 0 either
// way, which is exactly why this seam's earlier test passed without pinning the rule it was
// named for.
static void testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds() {
const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, -1, -1, -1}, {}};
const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, 25, 26, -1}, {}};
std::vector<DeckGroupDesc> gs{g};
const DeckLayout dl = layoutDeck(gs, 0, 0, 824);
const DeckGroupLayout& lay = dl.groups[0];
CHECK(lay.cells.size() == 5);
CHECK(lay.cells.size() == 7);
const int run = 8 * kDeckCellW;
const int present = 5;
const int present = 7;
const int cellW = run / present; // 76: the same integer division the layout uses
const int expectedResidue = run - cellW * present; // 4
CHECK(expectedResidue == 4);
@@ -331,6 +339,49 @@ static void testCaptionToggle2() {
CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 302 && h.segment == 1);
}
// The double-click RESET resolve. Unlike hitTestDeck's whole-cell grab, this one answers the
// drawn circles: inner disc -> inner target, outer ring -> outer target, anything off the dial
// (the label band, the cell margin, outside the deck) -> neither. Both boundaries are exclusive.
static void testKnobFaceResolvesInnerRingOuterRingAndMisses() {
const std::vector<DeckGroupDesc> g = shellLikeDeck();
const DeckLayout dl = layoutDeck(g, 0, 0, 900);
const DeckCellLayout& c = dl.groups[0].cells[0];
const int cx = c.knob.x + c.knob.width / 2;
const int cy = c.knob.y + c.knob.height / 2;
const int rOuter = c.knob.width / 2;
const int rInner = c.inner.width / 2;
CHECK(rInner > 0 && rInner < rOuter);
// Dead centre is the inner target; just inside the inner radius still is.
DeckFaceHit h = hitTestKnobFace(dl, cx, cy);
CHECK(h.id == c.id && h.inner);
h = hitTestKnobFace(dl, cx + rInner - 1, cy);
CHECK(h.id == c.id && h.inner);
// EXACTLY on the inner radius is the outer ring — the boundary belongs to neither disc.
h = hitTestKnobFace(dl, cx + rInner, cy);
CHECK(h.id == c.id && !h.inner);
// Just inside the rim is still the outer ring...
h = hitTestKnobFace(dl, cx + rOuter - 1, cy);
CHECK(h.id == c.id && !h.inner);
// ...and EXACTLY on the rim is a miss, by the same exclusive rule.
h = hitTestKnobFace(dl, cx + rOuter, cy);
CHECK(h.id == -1 && !h.inner);
// The cell corner is inside the CELL (hitTestDeck resolves it as a grab) but outside the
// circle — the two resolves deliberately disagree there.
CHECK(hitTestDeck(dl, c.cell.x + 1, c.cell.y + 1).kind == DeckHitKind::Knob);
CHECK(hitTestKnobFace(dl, c.cell.x + 1, c.cell.y + 1).id == -1);
// The label band under the knob: a grab anchor, never a reset target.
CHECK(hitTestDeck(dl, c.label.x + 2, c.label.y + 2).kind == DeckHitKind::Knob);
CHECK(hitTestKnobFace(dl, c.label.x + 2, c.label.y + 2).id == -1);
// Off the deck entirely.
CHECK(hitTestKnobFace(dl, -50, -50).id == -1);
// A diagonal at 45 degrees inside the rim: proves the resolve is radial, not the inscribed
// square a rect test would accept — this point is inside the knob RECT but outside the disc.
const int diag = static_cast<int>(rOuter * 0.75) + 1; // dist ~ 1.06 * rOuter
CHECK(hitTestKnobFace(dl, cx + diag, cy + diag).id == -1);
}
static void testEmptyDeck() {
const std::vector<DeckGroupDesc> none;
CHECK(deckRowCount(none, 800) == 0);
@@ -349,6 +400,7 @@ int main() {
testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds();
testCaptionRadioGeometryAndHit();
testInnerDialHit();
testKnobFaceResolvesInnerRingOuterRingAndMisses();
testCaptionToggle2();
testEmptyDeck();
if (g_fail) {