instrument: a deck group's reserved cell width goes to the cells present

A Trigger face dropping Sustain and Release now gets wider cells instead of
144 px of dead slots. Group widths, row packing and Gate are untouched.
This commit is contained in:
2026-07-31 22:43:13 -04:00
parent 1c774226d3
commit d8ffd860d1
8 changed files with 213 additions and 29 deletions
+1 -1
View File
@@ -307,7 +307,7 @@ anything for a trigger shape.
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search. - `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. - `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain. - `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, and the deck has six pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds. - `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, and the deck has six pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there. - `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. - `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis.
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types. - `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
+3 -2
View File
@@ -88,8 +88,9 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW}; amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW};
if (trigger) { if (trigger) {
// The play span first, then the AHD that shapes it, time-ordered left-to-right so // The play span first, then the AHD that shapes it, time-ordered left-to-right so
// the row reads like the drawn envelope. One blank keeps the group's width — and // the row reads like the drawn envelope. One reserve (-1) keeps the group's width —
// therefore its neighbours' placement — identical across a mode flip. // and therefore its neighbours' placement — identical across a mode flip; its
// pixels go to the four cells that remain (knob_deck.h).
amp.cellIds = {id(DeckParam::kTrigLength), id(DeckParam::kTrigAttack), amp.cellIds = {id(DeckParam::kTrigLength), id(DeckParam::kTrigAttack),
id(DeckParam::kTrigHold), id(DeckParam::kTrigDecay), -1}; id(DeckParam::kTrigHold), id(DeckParam::kTrigDecay), -1};
} else { } else {
+2 -2
View File
@@ -111,8 +111,8 @@ CurveTarget curveTargetFor(int controlId);
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the // The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the
// two instance-wide groups. `playMode` picks the AMP and FILTER ENV groups' faces — AHDSR in // two instance-wide groups. `playMode` picks the AMP and FILTER ENV groups' faces — AHDSR in
// Gate, AHD in Trigger — via knob_deck's blank-cell reservation (knob_deck.h) so a mode flip // Gate, AHD in Trigger — via knob_deck's cell-width reservation (knob_deck.h) so a mode flip
// never reflows the neighbouring groups. // never reflows the neighbouring groups and never leaves a hole in the narrower face.
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode); std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// The curve-exponent control a stage knob's INNER DIAL edits, or kCount when the knob shapes // The curve-exponent control a stage knob's INNER DIAL edits, or kCount when the knob shapes
+19 -7
View File
@@ -65,14 +65,23 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
placeToggle(g.captionToggle, out.captionToggle); placeToggle(g.captionToggle, out.captionToggle);
placeToggle(g.captionToggle2, out.captionToggle2); placeToggle(g.captionToggle2, out.captionToggle2);
// Knob row: fixed cells left-to-right, then the optional row toggle. // Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared
// id, reserves included). Integer division puts an indivisible residue in symmetric end
// margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule.
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap; const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
int x = innerLeft; const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
int presentCells = 0;
for (int id : g.cellIds) { for (int id : g.cellIds) {
if (id >= 0) ++presentCells;
}
const int cellW = presentCells > 0 ? runWidth / presentCells : 0;
int x = innerLeft + (runWidth - presentCells * cellW) / 2;
for (int id : g.cellIds) {
if (id < 0) continue;
DeckCellLayout c; DeckCellLayout c;
c.id = id; c.id = id;
c.cell = Rect::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH); c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH);
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2; const int knobLeft = x + (cellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4; const int knobTop = cellTop + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize); c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2; const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
@@ -82,13 +91,16 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int labelTop = knobTop + kDeckKnobSize + 4; const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH); c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
out.cells.push_back(c); out.cells.push_back(c);
x += kDeckCellW; x += cellW;
} }
if (g.rowToggle.id >= 0) { if (g.rowToggle.id >= 0) {
if (!g.cellIds.empty()) x += kDeckToggleGap; // Anchored past the whole reserved run, not past the last cell, so a residue margin
// cannot shift it.
int tx = innerLeft + runWidth;
if (!g.cellIds.empty()) tx += kDeckToggleGap;
const int segW = g.rowToggle.segWidth; const int segW = g.rowToggle.segWidth;
const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2; const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2;
const Rect seg0 = Rect::ltrb(x, togTop, x + segW, togTop + kDeckToggleH); const Rect seg0 = Rect::ltrb(tx, togTop, tx + segW, togTop + kDeckToggleH);
const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH); const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH);
out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1}; out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1};
} }
+6 -5
View File
@@ -5,9 +5,9 @@
// //
// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a // The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
// caption row (caption left, the group's compact mode toggle right-anchored) over a knob // caption row (caption left, the group's compact mode toggle right-anchored) over a knob
// row of fixed cells (knob centered, label band beneath). A group may also place one // row of equal-width cells (knob centered, label band beneath). A group may also place one
// two-segment toggle in the knob row after its cells. Groups that must keep stable // two-segment toggle in the knob row after its cells. Groups that must keep stable
// geometry across a mode flip reserve blank cells (id -1) so a mode flip never reflows // geometry across a mode flip reserve cell width (id -1) so a mode flip never reflows
// neighbouring groups. // neighbouring groups.
// //
// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group // Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
@@ -57,7 +57,8 @@ struct DeckRadioDesc {
}; };
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1 // One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell // reserves one cell's WIDTH without a cell, and the cells present divide the whole run —
// see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell
// reserves for the caption text (this module does not measure text). // reserves for the caption text (this module does not measure text).
struct DeckGroupDesc { struct DeckGroupDesc {
int id = 0; // shell group id (opaque here) int id = 0; // shell group id (opaque here)
@@ -87,7 +88,7 @@ struct DeckRadioLayout {
struct DeckCellLayout { struct DeckCellLayout {
int id = -1; int id = -1;
Rect cell; // the full 48x58 cell Rect cell; // the whole cell; width is the group's reserved run divided by its cell count
Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it) Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it)
Rect inner; // the concentric kDeckInnerDialSize square inside `knob` Rect inner; // the concentric kDeckInnerDialSize square inside `knob`
Rect label; // the 12px label band beneath the knob Rect label; // the 12px label band beneath the knob
@@ -141,7 +142,7 @@ struct DeckHit {
// The deck element a point lands on: a knob cell (the whole cell, not just the knob // The deck element a point lands on: a knob cell (the whole cell, not just the knob
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking // circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or // a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or
// the caption-row corner radio. Blank cells (id -1) and everything else miss. // the caption-row corner radio. Everything else — fence, padding, outside — misses.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
@@ -186,7 +186,6 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
// The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry. // The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry.
// The predicate is the input side's, so the drawn state and the inert grab agree. // The predicate is the input side's, so the drawn state and the inert grab agree.
for (const DeckCellLayout& c : g.cells) { for (const DeckCellLayout& c : g.cells) {
if (c.id < 0) continue; // reserved blank cell (the Trigger face's spare)
const bool disabled = deckKnobDisabled(c.id); const bool disabled = deckKnobDisabled(c.id);
// A VELOCITY cell is a popup opener, not a dial: it shows its curve in miniature // A VELOCITY cell is a popup opener, not a dial: it shows its curve in miniature
// where a knob face would be, and its whole cell is the click target. // where a knob face would be, and its whole cell is the click target.
+121 -3
View File
@@ -3,8 +3,9 @@
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp), // descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
// the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve // the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve
// cells and its placement immediately left of VOICE, the wrapped deck height at the editor's // cells and its placement immediately left of VOICE, the wrapped deck height at the editor's
// floor width and its fit // floor width and its fit inside the floor window, the pinned Gate widths and row assignment,
// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob // that no face leaves slack where its dropped controls were and that a Gate/Spline/Gate round
// trip restores the layout exactly, the hit-test reaching the new filter controls, the bipolar knob
// law's inverse pair, the commit-tier routing — which controls are live, and which drags take // law's inverse pair, the commit-tier routing — which controls are live, and which drags take
// the live tier — and the overlay-selection state machine (exclusivity, the none resting state, // the live tier — and the overlay-selection state machine (exclusivity, the none resting state,
// and which selections are inert). // and which selections are inert).
@@ -89,7 +90,7 @@ static void testCurveTargetNamesEachCellsOwnDestination() {
CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter); CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter);
CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone); CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone);
CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone); CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone);
CHECK(curveTargetFor(-1) == CurveTarget::kNone); // a blank reserved cell CHECK(curveTargetFor(-1) == CurveTarget::kNone); // a width reserve, not a control
CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space
} }
@@ -264,6 +265,7 @@ static void testDeckFitsInsideTheEnforcedMinimumWindow() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode); const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const int h = deckHeight(g, kAvailAtMinWidth); const int h = deckHeight(g, kAvailAtMinWidth);
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h); const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
CHECK(deckRowCount(g, kAvailAtMinWidth) == 3); // either face, three rows at the floor
CHECK(b.decks.height == h); CHECK(b.decks.height == h);
// Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the // Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the
// deck down until the waveform hits its floor, so any deck too tall to fit stops // deck down until the waveform hits its floor, so any deck too tall to fit stops
@@ -529,6 +531,119 @@ static void testTheModeTogglesCostNoGroupWidth() {
} }
} }
// A typical larger window, to check the same properties once the deck has re-wrapped.
static constexpr int kAvailAtLargerWidth = 1100 - 2 * kPad;
// 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.
static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
for (int avail : {kAvailAtMinWidth, kAvailAtLargerWidth}) {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
CHECK(dl.groups.size() == g.size());
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
const DeckGroupLayout& lay = dl.groups[i];
const int reserved = static_cast<int>(g[i].cellIds.size()) * kDeckCellW;
const std::size_t present = lay.cells.size();
CHECK(present > 0);
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);
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);
}
}
}
}
// Gate is the common face and it already packs correctly: pin its group widths and row
// assignment at the floor so a later edit anywhere in the deck cannot reflow it silently.
// (Measured from the shipped descriptors, not copied out of a failing run.)
static void testGateModeWidthsAndRowAssignmentAreUnchanged() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const struct { int id; int width; int row; } want[] = {
{kGroupPitch, 150, 0}, {kGroupPitchEnv, 204, 0}, {kGroupFilter, 440, 0},
{kGroupFilterEnv, 252, 1}, {kGroupAmpEnv, 252, 1}, {kGroupVelocity, 156, 1},
{kGroupVoice, 152, 2}, {kGroupMaster, 60, 2},
};
CHECK(g.size() == sizeof(want) / sizeof(want[0]));
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
CHECK(dl.groups[i].id == want[i].id);
CHECK(deckGroupWidth(g[i]) == want[i].width);
CHECK(dl.groups[i].box.width == want[i].width);
CHECK(dl.groups[i].box.y == want[i].row * (kDeckGroupH + kDeckRowGap));
// Gate carries no reserves, so its cells are the deck's base size.
for (const DeckCellLayout& c : dl.groups[i].cells) CHECK(c.cell.width == kDeckCellW);
}
}
static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) {
return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1;
}
static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
if (a.rowCount != b.rowCount || a.height != b.height ||
a.groups.size() != b.groups.size()) return false;
for (std::size_t i = 0; i < a.groups.size(); ++i) {
const DeckGroupLayout& x = a.groups[i];
const DeckGroupLayout& y = b.groups[i];
if (x.id != y.id || !(x.box == y.box) || !(x.caption == y.caption)) return false;
if (x.captionRadio.id != y.captionRadio.id || !(x.captionRadio.box == y.captionRadio.box))
return false;
if (!sameToggle(x.captionToggle, y.captionToggle) ||
!sameToggle(x.captionToggle2, y.captionToggle2) ||
!sameToggle(x.rowToggle, y.rowToggle)) return false;
if (x.cells.size() != y.cells.size()) return false;
for (std::size_t k = 0; k < x.cells.size(); ++k) {
const DeckCellLayout& c = x.cells[k];
const DeckCellLayout& d = y.cells[k];
if (c.id != d.id || !(c.cell == d.cell) || !(c.knob == d.knob) ||
!(c.inner == d.inner) || !(c.label == d.label)) return false;
}
}
return true;
}
// A Spline excursion is fully reversible at the layout level: the mode forcing swaps the amp
// and filter faces onto their wider cells and back, leaving no residue in the geometry. Driven
// through splineActive and the editor's own forcing rule, so the deck cannot agree with a
// forcing rule the shell does not use.
static void testGateSplineGateRoundTripsToTheSameLayout() {
PlayParams p; // Gate, all three envelopes staged
const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
p.ampSpline.mode = EnvMode::Spline;
if (splineActive(p)) p.playMode = PlayMode::Trigger; // editor_controls' forcing, verbatim
CHECK(p.playMode == PlayMode::Trigger);
const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
// The excursion is real: the amp face's cells are strictly wider than Gate's.
const DeckGroupLayout& gateAmp =
before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate),
kGroupAmpEnv))];
const DeckGroupLayout& trigAmp =
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(!sameLayout(before, drawn));
p.ampSpline.mode = EnvMode::Staged;
CHECK(!splineActive(p));
p.playMode = PlayMode::Gate; // Gate is selectable again once nothing is drawn
const DeckLayout after = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
CHECK(sameLayout(before, after));
}
int main() { int main() {
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks(); testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
testClickingTheActiveOverlayRadioClearsToNone(); testClickingTheActiveOverlayRadioClearsToNone();
@@ -550,6 +665,9 @@ int main() {
testAmpGroupWidthSurvivesAGateTriggerFlip(); testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtTheEditorFloorWidth(); testWrappedDeckHeightAtTheEditorFloorWidth();
testDeckFitsInsideTheEnforcedMinimumWindow(); testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testGateModeWidthsAndRowAssignmentAreUnchanged();
testGateSplineGateRoundTripsToTheSameLayout();
testHitTestResolvesTheNewFilterControls(); testHitTestResolvesTheNewFilterControls();
testBipolarKnobLawRoundTripsAndIsExactAtCentre(); testBipolarKnobLawRoundTripsAndIsExactAtCentre();
if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
+61 -8
View File
@@ -2,15 +2,17 @@
// assert loop as the sibling pure tests. Assert the r11 deck layout HARD: // assert loop as the sibling pure tests. Assert the r11 deck layout HARD:
// //
// * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths. // * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths.
// * layout — caption toggle right-anchored IN the caption row; cells fixed 48x58 left-to-right // * layout — caption toggle right-anchored IN the caption row; cells abutting left-to-right
// inside the box; knob square centered; label band beneath; row toggle after the cells. // inside the box; knob square centered; label band beneath; row toggle after the cells.
// * reserves — a -1 id holds the group's width and hands its pixels to the cells present.
// * wrap — deterministic whole-group wrap at a narrowing width; the first group of a row // * wrap — deterministic whole-group wrap at a narrowing width; the first group of a row
// always places; deckHeight consistency with deckRowCount. // always places; deckHeight consistency with deckRowCount.
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, blank (-1) cells // * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding
// and fence padding miss, outside-deck miss. // misses, outside-deck misses.
#include "../src/core/instrument/ui/knob_deck.h" #include "../src/core/instrument/ui/knob_deck.h"
#include <algorithm>
#include <cstdio> #include <cstdio>
#include <vector> #include <vector>
@@ -146,14 +148,19 @@ static void testHitTest() {
h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1); h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1);
CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1); CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1);
// A blank cell (id -1) misses even though its rect exists. // A reserve (id -1) yields no cell of its own, so every point of the knob row lands on a
// real control: no dead rect survives for a grab to fall into.
std::vector<DeckGroupDesc> trig; std::vector<DeckGroupDesc> trig;
trig.push_back({0, 78, {}, {100, 44}, {}, {20, 21, 22, -1, -1}, {}}); trig.push_back({0, 78, {}, {100, 44}, {}, {20, 21, 22, -1, -1}, {}});
const DeckLayout tl = layoutDeck(trig, 0, 0, 824); const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
const DeckCellLayout& blank = tl.groups[0].cells[4]; const DeckGroupLayout& tg = tl.groups[0];
CHECK(blank.id == -1); CHECK(tg.cells.size() == 3);
h = hitTestDeck(tl, blank.cell.x + 5, blank.cell.y + 5); for (const DeckCellLayout& c : tg.cells) CHECK(c.id >= 0);
CHECK(h.kind == DeckHitKind::None); const DeckCellLayout& last = tg.cells.back();
for (int px = tg.cells[0].cell.x; px < last.cell.right(); ++px) {
const DeckHit rowHit = hitTestDeck(tl, px, last.cell.y + 5);
CHECK(rowHit.kind == DeckHitKind::Knob && rowHit.id >= 0);
}
// The fence padding inside the box misses; outside the deck misses. // The fence padding inside the box misses; outside the deck misses.
h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1); h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1);
@@ -162,6 +169,51 @@ static void testHitTest() {
CHECK(h.kind == DeckHitKind::None); CHECK(h.kind == DeckHitKind::None);
} }
// A reserve holds the group's WIDTH and hands its pixels to the cells that are present. The
// three properties together are what stops a narrower face reading as a hole: the group is
// exactly as wide as the full-face one, the cells are uniform and abutting, and what they do
// 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.
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) {
DeckGroupDesc narrow = full;
narrow.cellIds = ids;
CHECK(deckGroupWidth(narrow) == deckGroupWidth(full));
std::vector<DeckGroupDesc> g{narrow};
const DeckLayout dl = layoutDeck(g, 0, 0, 824);
const DeckGroupLayout& lay = dl.groups[0];
const int present = static_cast<int>(lay.cells.size());
CHECK(present == 5 - static_cast<int>(std::count(ids.begin(), ids.end(), -1)));
const int run = 5 * kDeckCellW;
for (int i = 0; i < present; ++i) {
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());
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.
const int covered = lay.cells.back().cell.right() - lay.cells[0].cell.x;
CHECK(run - covered < present);
const int leadPad = lay.cells[0].cell.x - (lay.box.x + kDeckGroupPadX);
CHECK(leadPad == (run - covered) / 2);
}
// A reserve does not move the row toggle: it anchors past the whole run, so the FILTER
// group's law switch cannot drift when a neighbouring face changes shape.
DeckGroupDesc withToggle{1, 40, {}, {}, {}, {20, 21, 22, 23, 24}, {104, 44}};
std::vector<DeckGroupDesc> a{withToggle};
withToggle.cellIds = {20, 21, -1, -1, -1};
std::vector<DeckGroupDesc> b{withToggle};
CHECK(layoutDeck(a, 0, 0, 824).groups[0].rowToggle.seg0 ==
layoutDeck(b, 0, 0, 824).groups[0].rowToggle.seg0);
}
// The corner radio widens the caption row, takes the far corner, and pushes the caption // The corner radio widens the caption row, takes the far corner, and pushes the caption
// toggle left of itself — the three properties the overlay-select switch relies on. // toggle left of itself — the three properties the overlay-select switch relies on.
static void testCaptionRadioGeometryAndHit() { static void testCaptionRadioGeometryAndHit() {
@@ -254,6 +306,7 @@ int main() {
testFirstGroupAlwaysPlaces(); testFirstGroupAlwaysPlaces();
testGroupInnerGeometry(); testGroupInnerGeometry();
testHitTest(); testHitTest();
testReservedCellWidthGoesToTheCellsPresent();
testCaptionRadioGeometryAndHit(); testCaptionRadioGeometryAndHit();
testInnerDialHit(); testInnerDialHit();
testCaptionToggle2(); testCaptionToggle2();