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.
- `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.
- `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.
- `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.
+3 -2
View File
@@ -88,8 +88,9 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW};
if (trigger) {
// 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
// therefore its neighbours' placement — identical across a mode flip.
// the row reads like the drawn envelope. One reserve (-1) keeps the group's width —
// 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),
id(DeckParam::kTrigHold), id(DeckParam::kTrigDecay), -1};
} 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
// 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
// never reflows the neighbouring groups.
// Gate, AHD in Trigger — via knob_deck's cell-width reservation (knob_deck.h) so a mode flip
// never reflows the neighbouring groups and never leaves a hole in the narrower face.
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// 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.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;
int x = innerLeft;
const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
int presentCells = 0;
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;
c.id = id;
c.cell = Rect::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH);
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2;
c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH);
const int knobLeft = x + (cellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
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;
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
out.cells.push_back(c);
x += kDeckCellW;
x += cellW;
}
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 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);
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
// 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
// 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.
//
// 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
// 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).
struct DeckGroupDesc {
int id = 0; // shell group id (opaque here)
@@ -87,7 +88,7 @@ struct DeckRadioLayout {
struct DeckCellLayout {
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 inner; // the concentric kDeckInnerDialSize square inside `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
// 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
// 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);
} // namespace reasampler::instrument::ui