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
+155 -54
View File
@@ -125,11 +125,10 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
const std::vector<int> expected = {
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
cell(DeckParam::kFilterKeyTrack)};
cell(DeckParam::kFilterVel), cell(DeckParam::kFilterKeyTrack)};
CHECK(f.cellIds == expected);
// Off by default is a state question, but reachability is a layout one: BOTH toggles now
// ride the caption row, which is what takes the group from 524 to 432.
// ride the caption row, which is what takes the group from 524 to 372.
CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable));
CHECK(f.captionToggle2.id == cell(DeckParam::kFilterLaw));
CHECK(f.rowToggle.id == -1);
@@ -138,39 +137,111 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
const std::vector<int> env = {
cell(DeckParam::kFilterEnvAttack), cell(DeckParam::kFilterEnvHold),
cell(DeckParam::kFilterEnvDecay), cell(DeckParam::kFilterEnvSustain),
cell(DeckParam::kFilterEnvRelease)};
cell(DeckParam::kFilterEnvRelease), cell(DeckParam::kFilterModAmt)};
CHECK(fe.cellIds == env);
// The filter envelope has no enable of its own — the FILTER group's toggle governs both.
CHECK(fe.captionToggle.id == -1);
CHECK(fe.rowToggle.id == -1);
}
// Exactly the three envelope decks carry a SELECTABLE overlay radio, each its own, and no
// other group has one — the exclusivity the shell enforces is only meaningful if the id space
// is. MASTER occupies the same corner slot with a PASSIVE lamp, which is a different thing:
// it must never be counted as, or reachable as, a selector.
static void testOnlyTheThreeEnvelopeDecksCarryASelectableRadio() {
// The mod DEPTH sits with the envelope it scales, LAST in that group's run, in both faces —
// the kPitchEnvDepth shape. And it left FILTER: a control drawn in two groups would be two
// controls to the user even though it is one parameter.
static void testTheFilterModDepthLivesWithTheFilterEnvelopeInBothFaces() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
int radios = 0;
const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
CHECK(fe.cellIds.back() == cell(DeckParam::kFilterModAmt));
// Exactly once across the WHOLE deck, and not in FILTER.
int seen = 0;
for (const DeckGroupDesc& d : g) {
for (int c : d.cellIds) {
if (c != cell(DeckParam::kFilterModAmt)) continue;
++seen;
CHECK(d.id == kGroupFilterEnv);
}
}
CHECK(seen == 1);
// The depth knob mirrors kPitchEnvDepth: last in its envelope's run, and neither is a
// staged segment, so neither carries an inner curve dial.
const DeckGroupDesc& pe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupPitchEnv))];
CHECK(pe.cellIds.back() == cell(DeckParam::kPitchEnvDepth));
CHECK(curveParamFor(DeckParam::kFilterModAmt) == DeckParam::kCount);
}
}
// No group carries a selectable overlay radio any more — the deck itself is the target, and a
// radio beside it would be a second way to say the same thing. MASTER keeps the corner slot for
// its PASSIVE gain-reduction lamp, which is a readout and must never become a selector.
static void testNoGroupCarriesASelectableRadioAndMasterKeepsItsLamp() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
int lamps = 0;
for (const DeckGroupDesc& d : g) {
if (d.captionRadio.id < 0) continue;
if (d.captionRadio.passive) {
CHECK(d.id == kGroupMaster);
CHECK(d.captionRadio.id == cell(DeckParam::kMasterGr));
// A passive slot names no overlay, so no click on it could select one even if
// the hit-test ever handed it through.
CHECK(overlayEnvForRadio(d.captionRadio.id) == OverlayEnv::kNone);
continue;
}
++radios;
const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect)
: d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect)
: d.id == kGroupFilterEnv ? cell(DeckParam::kFilterEnvSelect)
: -1;
CHECK(d.captionRadio.id == want);
CHECK(d.captionRadio.passive);
CHECK(d.id == kGroupMaster);
CHECK(d.captionRadio.id == cell(DeckParam::kMasterGr));
++lamps;
}
CHECK(radios == 3);
CHECK(lamps == 1);
}
}
// The focus map: exactly the three envelope decks name an overlay, every other group and every
// off-deck point (-1) names kNone — which is how a click outside them CLEARS the focus. Setting
// is idempotent by construction: the map is a function of the group alone, so re-clicking a
// focused deck cannot toggle it off.
static void testTheOverlayFocusMapNamesTheThreeEnvelopeDecksAndNothingElse() {
CHECK(overlayEnvForGroup(kGroupAmpEnv) == OverlayEnv::kAmp);
CHECK(overlayEnvForGroup(kGroupPitchEnv) == OverlayEnv::kPitch);
CHECK(overlayEnvForGroup(kGroupFilterEnv) == OverlayEnv::kFilter);
for (int id : {kGroupPitch, kGroupFilter, kGroupVelocity, kGroupVoice, kGroupMaster}) {
CHECK(overlayEnvForGroup(id) == OverlayEnv::kNone);
}
CHECK(overlayEnvForGroup(-1) == OverlayEnv::kNone); // outside every deck
CHECK(overlayEnvForGroup(9999) == OverlayEnv::kNone); // not a group id at all
// The map is TOTAL over the shipped inventory: every group answers, and exactly three
// answer with an envelope, so a group added without a decision here shows up as a miscount.
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
int named = 0;
for (const DeckGroupDesc& d : sampleDeckGroups(mode)) {
if (overlayEnvForGroup(d.id) != OverlayEnv::kNone) ++named;
}
CHECK(named == 3);
}
}
// Every converted control is ONE button, and the two variants are told apart structurally
// rather than by what they are labelled: an enable has an off state, a mode selector's label
// IS the state. The five explicitly-not-converted controls keep their two segments — a named
// boundary, not an oversight. Eleven toggles ship; the count is asserted so a new one cannot
// arrive without a style decision here.
static void testTheConvertedTogglesAreSingleButtonsAndTheRestStaySegmented() {
const DeckParam enables[] = {DeckParam::kPitchEnvEnable, DeckParam::kFilterEnable,
DeckParam::kLimiterEnable};
const DeckParam modes[] = {DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode,
DeckParam::kFilterEnvMode};
const DeckParam segmented[] = {DeckParam::kPlayMode, DeckParam::kPitchEngine,
DeckParam::kVoiceMode, DeckParam::kFilterLaw,
DeckParam::kMonoTrigger};
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
int seen = 0;
for (const DeckGroupDesc& d : sampleDeckGroups(mode)) {
for (const DeckToggleDesc* t : {&d.captionToggle, &d.captionToggle2, &d.rowToggle}) {
if (t->id < 0) continue;
++seen;
DeckToggleStyle want = DeckToggleStyle::kSegmented;
for (DeckParam p : enables) if (t->id == cell(p)) want = DeckToggleStyle::kEnable;
for (DeckParam p : modes) if (t->id == cell(p)) want = DeckToggleStyle::kMode;
bool named = want != DeckToggleStyle::kSegmented;
for (DeckParam p : segmented) if (t->id == cell(p)) named = true;
CHECK(named); // every shipped toggle is one of the eight named above
CHECK(t->style == want);
}
}
CHECK(seen == 11);
}
}
@@ -195,7 +266,8 @@ static void testGateAndTriggerFacesCarryTheirOwnShapes() {
const DeckGroupDesc& tFe = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupFilterEnv))];
const std::vector<int> trigFe = {cell(DeckParam::kFilterTrigAttack),
cell(DeckParam::kFilterTrigHold),
cell(DeckParam::kFilterTrigDecay), -1, -1};
cell(DeckParam::kFilterTrigDecay), -1, -1,
cell(DeckParam::kFilterModAmt)};
CHECK(tFe.cellIds == trigFe);
CHECK(gFe.cellIds != tFe.cellIds);
// Same cell count either way, so the group's width — and its neighbours' placement —
@@ -306,19 +378,19 @@ static void testEveryDeckGroupBelongsToExactlyOneRow() {
}
}
// The gap fix as a property of the shipped descriptors, not a picture: whichever face a
// mode-dependent group shows, its knob row still spans the group's whole reserved run. The
// Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the
// dropped control was. What the run does not cover is the indivisible residue alone, strictly
// under one pixel per cell. Checked at both a tight and a genuinely wider width.
static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
// Every knob on the deck sits at its natural pitch in BOTH faces, and a reduced face pays for
// its dropped controls in symmetric end margins rather than in wider cells — the defect this
// track closes was Trigger's FILTER ENV at ~100px cells and its AMP at ~75 against the standard
// 60. Checked at both a tight and a genuinely wider width, since the group box moves with the
// justification but the run inside it must not change shape.
static void testEveryCellKeepsItsNaturalPitchInBothFaces() {
for (int avail : {kSampleAvail, kSampleAvailWide}) {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail);
CHECK(dl.groups.size() == g.size());
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
// The spanning deck's slots STACK — the run-division law this pins is the
// The spanning deck's slots STACK — the centring law this pins is the
// horizontal one, and its vertical guard is its own test.
if (g[i].row == DeckRow::Spanning) continue;
const DeckGroupLayout& lay = dl.groups[i];
@@ -328,20 +400,43 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
for (std::size_t k = 0; k < present; ++k) {
const DeckCellLayout& c = lay.cells[k];
CHECK(c.id >= 0); // a reserve yields width, never a dead rect
CHECK(c.cell.width == lay.cells[0].cell.width);
CHECK(c.cell.width == kDeckCellW);
if (k > 0) CHECK(c.cell.x == lay.cells[k - 1].cell.right());
}
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
CHECK(reserved - covered < static_cast<int>(present));
CHECK(lay.cells.front().cell.x >= lay.box.x + kDeckGroupPadX);
CHECK(lay.cells.back().cell.right() <= lay.box.right() - kDeckGroupPadX);
const int lead = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
const int trail =
(lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
CHECK(lead >= 0 && trail >= 0);
// A group whose knob row is not what it measures from (VOICE's row toggle, or
// a caption-bound group) has trailing box width beyond the run; the LEAD margin
// is the reserve's own half either way.
CHECK(lead == (reserved - static_cast<int>(present) * kDeckCellW) / 2);
}
}
}
}
// The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned
// once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here.
// The two mode-dependent groups are where the defect lived: their reserves buy a stable box
// width, and after the reflow they buy it without stretching a single knob.
static void testTheReducedTriggerFacesAreTheSameKnobsAsGateJustCentred() {
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
const DeckLayout gl = layoutDeck(gate, kSamplePad, 0, kSampleAvail);
const DeckLayout tl = layoutDeck(trig, kSamplePad, 0, kSampleAvail);
for (int id : {kGroupFilterEnv, kGroupAmpEnv}) {
const DeckGroupLayout& a = gl.groups[static_cast<std::size_t>(indexOfGroup(gate, id))];
const DeckGroupLayout& b = tl.groups[static_cast<std::size_t>(indexOfGroup(trig, id))];
CHECK(a.box == b.box); // the box does not move — what the reserves are for
CHECK(b.cells.size() < a.cells.size());
for (const DeckCellLayout& c : b.cells) CHECK(c.cell.width == kDeckCellW);
// Centred: the two margins match, and together they are the dropped cells' width.
const int lead = b.cells.front().cell.x - (b.box.x + kDeckGroupPadX);
const int trail = (b.box.right() - kDeckGroupPadX) - b.cells.back().cell.right();
CHECK(lead == trail);
CHECK(lead + trail ==
static_cast<int>(a.cells.size() - b.cells.size()) * kDeckCellW);
}
}
static void testHitTestResolvesTheNewFilterControls() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
@@ -356,17 +451,17 @@ static void testHitTestResolvesTheNewFilterControls() {
CHECK(hit.kind == DeckHitKind::Knob);
CHECK(hit.id == c.id);
}
CHECK(f.cells.size() == 7);
CHECK(f.cells.size() == 6);
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
// The enable toggle's two segments and the morph-law row toggle's two.
const DeckHit off = hitTestDeck(dl, f.captionToggle.seg0.x + 2,
f.captionToggle.seg0.y + 2);
CHECK(off.kind == DeckHitKind::CaptionToggle);
CHECK(off.id == cell(DeckParam::kFilterEnable) && off.segment == 0);
const DeckHit on = hitTestDeck(dl, f.captionToggle.seg1.x + 2,
f.captionToggle.seg1.y + 2);
CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1);
// The enable is ONE button now: both ends of it answer the same hit with no segment, so
// the commit has to derive the next state rather than read one off the click.
for (int px : {f.captionToggle.seg0.x + 2, f.captionToggle.seg0.right() - 2}) {
const DeckHit en = hitTestDeck(dl, px, f.captionToggle.seg0.y + 2);
CHECK(en.kind == DeckHitKind::CaptionToggle);
CHECK(en.id == cell(DeckParam::kFilterEnable) && en.segment == -1);
CHECK(en.group == kGroupFilter);
}
// The morph law answers from its NEW home in the caption row, and as a CaptionToggle —
// the shell's toggle branch handles both kinds, so the move must not change the id or the
@@ -409,7 +504,7 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
}
static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) {
return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1;
return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1 && a.style == b.style;
}
static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
@@ -449,7 +544,8 @@ static void testGateSplineGateRoundTripsToTheSameLayout() {
enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through
CHECK(p.playMode == PlayMode::Trigger);
const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail);
// The excursion is real: the amp face's cells are strictly wider than Gate's.
// The excursion is real: the amp face drops a cell and the shorter run re-centres, so its
// first knob starts further in than Gate's. (It is not WIDER — the cells hold their pitch.)
const DeckGroupLayout& gateAmp =
before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate),
kGroupAmpEnv))];
@@ -457,7 +553,8 @@ static void testGateSplineGateRoundTripsToTheSameLayout() {
drawn.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Trigger),
kGroupAmpEnv))];
CHECK(trigAmp.cells.size() < gateAmp.cells.size());
CHECK(trigAmp.cells[0].cell.width > gateAmp.cells[0].cell.width);
CHECK(trigAmp.cells[0].cell.width == gateAmp.cells[0].cell.width);
CHECK(trigAmp.cells[0].cell.x > gateAmp.cells[0].cell.x);
CHECK(!sameLayout(before, drawn));
p.ampSpline.mode = EnvMode::Staged;
@@ -473,13 +570,17 @@ int main() {
testCurveTargetNamesEachCellsOwnDestination();
testVelocityCellsHitTestWithinTheirGroup();
testFilterGroupCarriesItsToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryASelectableRadio();
testTheFilterModDepthLivesWithTheFilterEnvelopeInBothFaces();
testNoGroupCarriesASelectableRadioAndMasterKeepsItsLamp();
testTheOverlayFocusMapNamesTheThreeEnvelopeDecksAndNothingElse();
testTheConvertedTogglesAreSingleButtonsAndTheRestStaySegmented();
testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
testAmpGroupWidthSurvivesAGateTriggerFlip();
testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
testEveryDeckGroupBelongsToExactlyOneRow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testEveryCellKeepsItsNaturalPitchInBothFaces();
testTheReducedTriggerFacesAreTheSameKnobsAsGateJustCentred();
testHitTestResolvesTheNewFilterControls();
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
testGateSplineGateRoundTripsToTheSameLayout();