deck: filter mod moves to FILTER ENV, cell runs centre in their reserves, two-segment toggles become single buttons, deck focuses its overlay

This commit is contained in:
2026-08-03 13:12:28 -04:00
parent 0eb2c67875
commit 450559f155
22 changed files with 885 additions and 504 deletions
+174 -54
View File
@@ -82,10 +82,10 @@ static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() {
CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW);
}
// Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes:
// it was 1030, +42 from PITCH/RATE's third cell and 92 from FILTER's Band|Notch caption move
// take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold
// them at 312 in Trigger too — asserted here rather than assumed.
// Both rows fit their block, in BOTH play modes. The filter mod depth's move across the rows is
// what these two numbers now carry: SOUND loses one cell (980 -> 920) and CONTOUR gains one
// (876 -> 936). Row 2's 936 is mode-stable because FILTER ENV's and AMP's reserve slots hold
// them at 372/312 in Trigger too — asserted here rather than assumed.
static void testBothRowsAndTheSpanningDeckFitTheBudget() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
@@ -101,9 +101,9 @@ static void testBothRowsAndTheSpanningDeckFitTheBudget() {
const int spanning = static_cast<int>(DeckRow::Spanning);
CHECK(count[sound] == 4);
CHECK(width[sound] == 980); // 192 + 432 + 192 + 164
CHECK(width[sound] == 920); // 192 + 372 + 192 + 164
CHECK(count[contour] == 3);
CHECK(width[contour] == 876); // 252 + 312 + 312
CHECK(width[contour] == 936); // 252 + 372 + 312
CHECK(count[spanning] == 1);
CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent
@@ -115,38 +115,46 @@ static void testBothRowsAndTheSpanningDeckFitTheBudget() {
}
}
// The gutters the justification law produces at the floor, and the alignment they buy.
// At the 1028 block the justification law makes the tie-line exact by arithmetic rather than
// by a special rule: row 1's slack is 48 over three gutters (16 each, no residue) and row 2's
// is 152 over two (76 each), which lands both filter edges on 640. Only two of the three
// properties §1.3 once claimed can hold at once — a smallest gutter of exactly kDeckGroupGap
// needs a 1016 block — and 12 is a floor, not a target, so 16 satisfies the real rule.
static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
// The gutters the justification law produces at the floor and the alignment it no longer
// buys. THE FILTER TIE-LINE IS GONE, and it is recorded here as a LOSS rather than left to be
// rediscovered: moving the mod depth from FILTER to FILTER ENV made the two filter groups
// EQUAL in width (372 each), and under space-between two equal groups whose rows carry
// different preceding widths can only share a right edge at one block width — which the
// arithmetic below shows is far below the width either row needs. It is unreachable, not
// merely missed, so kDeckRowBlockW and the editor floor are deliberately NOT moved to chase it.
static void testGutterArithmeticAndTheLostFilterTieLineAtTheFloor() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const auto box = [&](int id) {
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box;
};
// Row 1: flush left, flush right on the block, and three EQUAL gutters — 48 divides by 3
// Row 1: flush left, flush right on the block, and three EQUAL gutters — 108 divides by 3
// with no residue, so no gutter carries a leftover pixel.
CHECK(box(kGroupPitch).x == kPad);
CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 16);
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 16);
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 16);
CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 36);
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 36);
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 36);
CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW);
// Row 2: flush left, flush right, two gutters exactly equal.
// Row 2: flush left, flush right, two gutters exactly equal — 92 over two.
CHECK(box(kGroupPitchEnv).x == kPad);
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 76);
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 76);
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 46);
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 46);
CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW);
// The tie-line, block-relative: both filter edges on ONE pixel, which is what the widen
// bought. Pinned as an identity too, so a group-width change cannot pass by moving both.
CHECK(box(kGroupFilterEnv).right() - kPad == 640);
CHECK(box(kGroupFilter).right() - kPad == 640);
CHECK(box(kGroupFilter).right() == box(kGroupFilterEnv).right());
// The loss, block-relative and exact: row 1's filter edge lands 70 px LEFT of row 2's.
CHECK(box(kGroupFilter).right() - kPad == 600);
CHECK(box(kGroupFilterEnv).right() - kPad == 670);
CHECK(box(kGroupFilter).right() != box(kGroupFilterEnv).right());
// And it is unreachable at any block width, which is the part that makes it a loss rather
// than a tuning problem. Solving 192 + (W-920)/3 == 252 + (W-936)/2 over the reals gives
// W = 608 — narrower than either row's own content (920 and 936), so no block that can
// hold the deck at all can also tie the two edges.
const double tieAt = 608.0;
for (int W : {920, 936, kDeckRowBlockW}) CHECK(static_cast<double>(W) > tieAt);
CHECK(192.0 + (tieAt - 920.0) / 3.0 == 252.0 + (tieAt - 936.0) / 2.0);
// MASTER is right-anchored outside the block, one kDeckGroupGap clear of it.
CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap);
@@ -155,9 +163,9 @@ static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
// No gutter is ever narrower than kDeckGroupGap at or above the floor, and both rows stay
// flush at every width — the property the exact-at-the-floor numbers above are one point of.
// Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides
// its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past
// row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure.
// The two filter edges SEPARATE monotonically with width, which is accepted and deliberate:
// row 1 divides its slack over three gutters and row 2 over two, so row 2's filter edge pulls
// right past row 1's and the gap only opens. Encoded as EXPECTED, not as a failure.
//
// Checked per ROW (tracking the last-seen box in each of the two categorical rows while
// walking dl.groups in deck order), not just deck-order neighbours: two same-row groups can
@@ -190,9 +198,8 @@ static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() {
CHECK(drift <= lastDrift);
lastDrift = drift;
}
// It really does open up: the tie-line is exact AT the floor and separates above it,
// which is the accepted outcome rather than a near-miss to be pinned back.
CHECK(lastDrift < -50);
// It really does open up, from the 70 the floor already carries.
CHECK(lastDrift < -70);
}
}
@@ -268,11 +275,74 @@ static void testTheMasterColumnDoesNotDivideItsRunVertically() {
CHECK(m2.column.box == m.column.box);
}
// MASTER's caption row and knob row measure exactly equal (130 == 130) today, so a column
// derived from either edge lands in the same place — that balance is what let a left-derived
// offset masquerade as right-anchored. Widen the caption reserve alone (as a wider caption or
// a limiter-toggle change would) and the column must still land flush against the group's own
// right padding, derived from innerRight rather than measured past the cell slots.
// MASTER is the group that BINDS the single-button enable width, and it has ZERO slack: its
// knob row measures kDeckSpanningW 2·pad, so the caption row (46 + gap + button + gap + the
// GR lamp) may reach exactly that and no more. Past 64 the caption row takes over, the spanning
// deck grows, and the growth comes straight out of the 82 px between the editor's floor and its
// ceiling. Pinned at the boundary in both directions rather than as an inequality.
static void testTheLimiterButtonIsAtMostSixtyFourPxBeforeMasterGrows() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckGroupDesc& m = g[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
CHECK(m.captionToggle.id == cell(DeckParam::kLimiterEnable));
CHECK(m.captionToggle.style == DeckToggleStyle::kEnable);
CHECK(deckGroupWidth(m) == kDeckSpanningW);
// The knob row IS the measurement, and it is exactly the group's inner width.
CHECK(kDeckCellW + kDeckColumnGap + kMeterColumnW == kDeckSpanningW - 2 * kDeckGroupPadX);
DeckGroupDesc probe = m;
probe.captionToggle.width = 64;
CHECK(deckGroupWidth(probe) == kDeckSpanningW); // at the ceiling, still knob-row-driven
probe.captionToggle.width = 65;
CHECK(deckGroupWidth(probe) > kDeckSpanningW); // one past it, the spanning deck grows
// And the shipped width is inside the ceiling, so the budget below stays unspent.
CHECK(m.captionToggle.width <= 64);
}
// hitTestKnobFace resolves against the drawn CIRCLES and runs no toggle-precedence pass, so it
// is only correct while no toggle rect reaches a dial. The single-button styles made every
// button on the deck wider, so the claim is re-checked here over the SHIPPED descriptors in
// both faces — test_knob_deck's peer proves the geometry over a synthetic group; this proves it
// for the buttons that actually ship. Rect disjointness rather than a pixel sweep: inKnobFace
// answers only inside the knob rect, so no overlapping pixel can exist without one.
static void testNoShippedToggleReachesADrawnKnobFace() {
const auto disjoint = [](const Rect& a, const Rect& b) {
return a.empty() || b.empty() || a.right() <= b.x || b.right() <= a.x ||
a.bottom() <= b.y || b.bottom() <= a.y;
};
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
int swept = 0;
for (const DeckGroupLayout& lay : dl.groups) {
for (const DeckToggleLayout* t : {&lay.captionToggle, &lay.captionToggle2,
&lay.rowToggle}) {
if (t->id < 0) continue;
++swept;
for (const Rect& seg : {t->seg0, t->seg1}) {
// Against every group's cells, not just this one's: the row toggle anchors
// past its own run and a neighbour is what it would reach first.
for (const DeckGroupLayout& other : dl.groups) {
for (const DeckCellLayout& c : other.cells) CHECK(disjoint(seg, c.knob));
}
}
}
}
CHECK(swept == 11); // every shipped toggle was actually reached by the sweep
}
}
// The 82 px between the floor and the ceiling is untouched by this whole reflow — the mod
// depth's move is a swap between the two rows, not a purchase.
static void testTheEditorWidthBudgetIsStillUnspent() {
CHECK(kEditorMinWidth == 1198);
CHECK(kEditorCeilingWidth - kEditorMinWidth == 82);
CHECK(kDeckRowBlockW == 1028);
CHECK(kDeckSpanningW == 142);
}
// Widen the caption reserve alone (as a wider caption or a limiter-toggle change would) and the
// column must still land flush against the group's own right padding, derived from innerRight
// rather than measured past the cell slots — the bug a balanced caption row once hid.
static void testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
DeckGroupDesc probe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
@@ -323,11 +393,11 @@ static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() {
CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over
}
// The kEnvModeSegW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 47,
// AMP at 55) pinned against the descriptors they derive from, the same way the Pitch/Rate
// caption ceiling above is: a change to either group's caption width or its enable toggle
// The kEnvModeW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 122,
// AMP at 126) pinned against the descriptors they derive from, the same way the Pitch/Rate
// caption ceiling above is: a change to either group's caption width or its enable button
// would otherwise invalidate the recorded numbers with nothing failing.
static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
static void testEnvModeCeilingsArePinnedForPitchEnvAndAmp() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckGroupDesc& penv = g[static_cast<std::size_t>(indexOfGroup(g, kGroupPitchEnv))];
const DeckGroupDesc& amp = g[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
@@ -335,16 +405,19 @@ static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
CHECK(deckGroupWidth(amp) == 312);
DeckGroupDesc penvProbe = penv;
penvProbe.captionToggle2.segWidth = 47;
penvProbe.captionToggle2.width = 122;
CHECK(deckGroupWidth(penvProbe) == 252); // at the ceiling, still knob-row-driven
penvProbe.captionToggle2.segWidth = 48;
penvProbe.captionToggle2.width = 123;
CHECK(deckGroupWidth(penvProbe) > 252); // one past it, the caption row takes over
DeckGroupDesc ampProbe = amp;
ampProbe.captionToggle2.segWidth = 55;
ampProbe.captionToggle2.width = 126;
CHECK(deckGroupWidth(ampProbe) == 312);
ampProbe.captionToggle2.segWidth = 56;
ampProbe.captionToggle2.width = 127;
CHECK(deckGroupWidth(ampProbe) > 312);
// The two ceilings above are what make PITCH ENV the binding group: 122 < 126, so the
// shipped width has to clear PITCH ENV's, and it does.
CHECK(penv.captionToggle2.width <= 122);
}
// Every group's width, in BOTH play modes, against the measured layout table
@@ -353,8 +426,8 @@ static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
// contour row's 876 a constant rather than a Gate-only fact.
static void testEveryGroupWidthMatchesTheMeasuredLayout() {
const struct { int id; int width; } want[] = {
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 432},
{kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 372},
{kGroupFilterEnv, 372}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
{kGroupVoice, 164}, {kGroupMaster, 142},
};
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
@@ -370,29 +443,76 @@ static void testEveryGroupWidthMatchesTheMeasuredLayout() {
dl.groups[static_cast<std::size_t>(indexOfGroup(g, w.id))];
CHECK(lay.box.width == w.width);
}
// Gate carries no reserves, so its cells are the deck's base size; Trigger's two
// reduced faces divide the same reserved run between fewer cells and get wider ones.
// EVERY cell is kDeckCellW in EITHER mode — the spacing law. Trigger's two reduced
// faces keep the same reserved run and spend it on end margins, not on wider knobs.
for (const DeckGroupLayout& lay : dl.groups) {
for (const DeckCellLayout& c : lay.cells) {
CHECK(c.cell.width >= kDeckCellW);
if (mode == PlayMode::Gate) CHECK(c.cell.width == kDeckCellW);
}
for (const DeckCellLayout& c : lay.cells) CHECK(c.cell.width == kDeckCellW);
}
}
}
// THE Gate-face regression pin. Every group box and every cell rect at the editor's floor,
// block-relative, against the pre-reflow measurements. Three of the eight are the whole point:
// FILTER 432 -> 372, one cell narrower — the mod depth left it.
// FILTER ENV 312 -> 372, one cell wider — the mod depth arrived.
// VELOCITY its box translates 20 px LEFT. Nothing about the group changed; row 1's freed
// 60 px is divided over three gutters by the space-between law, and every group
// between the narrowed one and the row's flush-right end shifts by the share it
// did not absorb. That translation is the law working, not a second edit.
// Everything else — PITCH/RATE, PITCH ENV, AMP ENV, VOICE, MASTER — is pinned UNCHANGED to the
// pixel, boxes and cells alike, which is the criterion this reflow is measured against.
static void testTheGateFaceIsPixelIdenticalApartFromTheTwoFilterGroups() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const auto lay = [&](int id) -> const DeckGroupLayout& {
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))];
};
// {group, block-relative box x, width, cell count} — the pre-reflow numbers for the five
// untouched groups, and the derived ones for the three the move implicates.
const struct { int id; int x; int w; std::size_t cells; } want[] = {
{kGroupPitch, 0, 192, 3}, // unchanged
{kGroupFilter, 228, 372, 6}, // was x=208 w=432 with 7 cells
{kGroupVelocity, 636, 192, 3}, // unchanged group, box translated from x=656
{kGroupVoice, 864, 164, 1}, // unchanged
{kGroupPitchEnv, 0, 252, 4}, // unchanged
{kGroupFilterEnv, 298, 372, 6}, // was x=328 w=312 with 5 cells
{kGroupAmpEnv, 716, 312, 5}, // unchanged
};
for (const auto& w : want) {
const DeckGroupLayout& l = lay(w.id);
CHECK(l.box.x - kPad == w.x);
CHECK(l.box.width == w.w);
CHECK(l.cells.size() == w.cells);
// Cells: natural pitch, abutting, starting flush at the group's inner left (no Gate
// group carries a reserve, so the centring offset is zero everywhere here).
CHECK(l.cells.front().cell.x == l.box.x + kDeckGroupPadX);
for (std::size_t k = 0; k < l.cells.size(); ++k) {
CHECK(l.cells[k].cell.width == kDeckCellW);
CHECK(l.cells[k].cell.x - l.box.x == kDeckGroupPadX +
static_cast<int>(k) * kDeckCellW);
}
}
// The two filter groups moved by EXACTLY one cell, in opposite directions.
CHECK(lay(kGroupFilter).box.width + kDeckCellW == 432);
CHECK(lay(kGroupFilterEnv).box.width - kDeckCellW == 312);
}
int main() {
testDeckFitsInsideTheEnforcedMinimumWindow();
testTheGateFaceIsPixelIdenticalApartFromTheTwoFilterGroups();
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
testBothRowsAndTheSpanningDeckFitTheBudget();
testGutterArithmeticAndTheFilterTieLineAtTheFloor();
testGutterArithmeticAndTheLostFilterTieLineAtTheFloor();
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
testTheMasterDeckInteriorLandsOnBothRowBaselines();
testTheMasterColumnDoesNotDivideItsRunVertically();
testTheLimiterButtonIsAtMostSixtyFourPxBeforeMasterGrows();
testNoShippedToggleReachesADrawnKnobFace();
testTheEditorWidthBudgetIsStillUnspent();
testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow();
testTheModeTogglesCostNoGroupWidth();
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp();
testEnvModeCeilingsArePinnedForPitchEnvAndAmp();
testEveryGroupWidthMatchesTheMeasuredLayout();
if (g_fail == 0) std::printf("deck_groups_measured: all tests passed\n");
return g_fail == 0 ? 0 : 1;